MHD nur mit Monat und Jahr in Web-UI und iOS-App
Setzt den Backend-Umbau in beiden Oberflaechen um. Die Genauigkeit gilt jeweils fuer den ganzen Einlager-Vorgang und nicht je Charge: Auf einer Packung steht entweder ein Tagesdatum oder nur Monat/Jahr, gemischt kommt das nicht vor. Vorbelegt wird sie aus dem Produkt, laesst sich aber im Vorgang umstellen. Web-UI: Umschalter im Kopf der Chargenliste; das Eingabefeld wechselt zwischen type="date" und type="month". Im Produktformular gibt es das neue Feld "MHD-Angabe" neben der Gebinde-Bezeichnung. Anzeige laeuft ueber den Settings-Context, damit das eingestellte Datumsformat erhalten bleibt und Monatsangaben ueberall als "09/2026" erscheinen (Auslagern, Uebersicht, Chargentabelle). Die Abgelaufen-Warnung prueft bei Monatsangaben gegen den Monatsletzten - sonst haette eine Packung schon am Monatsersten als abgelaufen gegolten. iOS: Auswahl "Tagesdatum / nur Monat/Jahr" im Einlagern-Formular und beim Anlegen eines Artikels. SwiftUI hat keinen DatePicker ohne Tag, deshalb ein eigener MonthYearPicker aus zwei Auswahlfeldern; die Jahresliste reicht zwei Jahre zurueck (bereits abgelaufene Ware) und fuenfzehn nach vorn (Konserven). Die Chargenauswahl beim Auslagern zeigt Monatsangaben ebenfalls ohne Tag. Getestet: Web-Build (vite) und iOS-Geraetebuild laufen fehlerfrei durch, die App ist auf dem iPhone installiert. Das Verhalten in der Oberflaeche ist noch nicht von Hand durchgeklickt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import { guessGroup, suggestionToProduct } from "../offUtils";
|
||||
import { buildUnitOptions, fmt, isExpired } from "../units";
|
||||
import { buildUnitOptions, fmt, fromMonthInput, isExpired, monthInputToLastDay } from "../units";
|
||||
|
||||
const emptyLine = () => ({ quantity: "", best_before: "" });
|
||||
|
||||
@@ -25,6 +25,9 @@ export default function CheckIn() {
|
||||
const [unit, setUnit] = useState("");
|
||||
const [locationId, setLocationId] = useState("");
|
||||
const [lines, setLines] = useState([emptyLine()]);
|
||||
// Genauigkeit gilt fuer den ganzen Vorgang: Auf einer Packung steht entweder
|
||||
// ein Tagesdatum oder nur Monat/Jahr - nicht beides gemischt.
|
||||
const [precision, setPrecision] = useState("day");
|
||||
|
||||
const [error, setError] = useState(null);
|
||||
const [info, setInfo] = useState(null);
|
||||
@@ -49,6 +52,8 @@ export default function CheckIn() {
|
||||
// Gibt es ein Gebinde (Glas, Packung, …), ist das die naheliegende Eingabe.
|
||||
setUnit(p.package_size ? "package" : (p.unit_name || (opts[0] && opts[0].value) || ""));
|
||||
setLines([emptyLine()]);
|
||||
// Am Produkt hinterlegt, ob dort ueblicherweise nur Monat/Jahr aufgedruckt ist.
|
||||
setPrecision(p.date_precision === "month" ? "month" : "day");
|
||||
setLocationId("");
|
||||
}
|
||||
|
||||
@@ -109,6 +114,11 @@ export default function CheckIn() {
|
||||
|
||||
const totalQty = lines.reduce((s, l) => s + (Number(l.quantity) || 0), 0);
|
||||
|
||||
// Ein Monats-MHD laeuft erst am Monatsende ab - danach richtet sich die Warnung.
|
||||
function expiryCheckValue(value) {
|
||||
return precision === "month" ? monthInputToLastDay(value) : value;
|
||||
}
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setError(null); setInfo(null);
|
||||
@@ -116,7 +126,11 @@ export default function CheckIn() {
|
||||
.filter((l) => Number(l.quantity) > 0)
|
||||
.map((l) => ({
|
||||
quantity: Number(l.quantity),
|
||||
best_before: l.best_before || null,
|
||||
// Bei Monatsangabe reicht der Monatserste - das Backend legt daraus
|
||||
// den Monatsletzten und merkt sich die Genauigkeit.
|
||||
best_before:
|
||||
(precision === "month" ? fromMonthInput(l.best_before) : l.best_before) || null,
|
||||
best_before_precision: precision,
|
||||
location_id: locationId === "" ? null : Number(locationId),
|
||||
}));
|
||||
if (payloadLines.length === 0) {
|
||||
@@ -251,7 +265,13 @@ export default function CheckIn() {
|
||||
<div className="lines">
|
||||
<div className="lines-head">
|
||||
<span>Chargen</span>
|
||||
<span className="muted small">je Charge ein eigenes MHD</span>
|
||||
<label className="precision-pick">
|
||||
<span className="muted small">MHD-Angabe</span>
|
||||
<select value={precision} onChange={(e) => setPrecision(e.target.value)}>
|
||||
<option value="day">Tagesdatum</option>
|
||||
<option value="month">nur Monat/Jahr</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{lines.map((line, i) => (
|
||||
<div className="line" key={i}>
|
||||
@@ -263,10 +283,10 @@ export default function CheckIn() {
|
||||
</label>
|
||||
<label className="grow" style={{ margin: 0 }}>
|
||||
{i === 0 && <span className="line-label">MHD (optional)</span>}
|
||||
<input type="date" value={line.best_before}
|
||||
<input type={precision === "month" ? "month" : "date"} value={line.best_before}
|
||||
onChange={(e) => setLine(i, "best_before", e.target.value)}
|
||||
className={isExpired(line.best_before) ? "input-danger" : ""} />
|
||||
{isExpired(line.best_before) && (
|
||||
className={isExpired(expiryCheckValue(line.best_before)) ? "input-danger" : ""} />
|
||||
{isExpired(expiryCheckValue(line.best_before)) && (
|
||||
<span className="hint-danger"><Icon name="alert" size={12} />bereits abgelaufen</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSettings } from "../settings";
|
||||
import { buildUnitOptions, fmt, isExpired, unitShort } from "../units";
|
||||
|
||||
export default function CheckOut() {
|
||||
const { formatDate } = useSettings();
|
||||
const { formatBestBefore } = useSettings();
|
||||
const [barcode, setBarcode] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [results, setResults] = useState([]);
|
||||
@@ -161,7 +161,7 @@ export default function CheckOut() {
|
||||
<option value="">Automatisch – zuerst ablaufende zuerst (FEFO)</option>
|
||||
{lots.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.best_before ? `MHD ${formatDate(l.best_before)}` : "ohne MHD"} · {lotAmount(l.quantity)}
|
||||
{l.best_before ? `MHD ${formatBestBefore(l.best_before, l.best_before_precision)}` : "ohne MHD"} · {lotAmount(l.quantity)}
|
||||
{isExpired(l.best_before) ? " · abgelaufen" : ""}
|
||||
</option>
|
||||
))}
|
||||
@@ -172,7 +172,7 @@ export default function CheckOut() {
|
||||
<div className={`alert ${isExpired(selectedLot.best_before) ? "error" : "info"}`}>
|
||||
<Icon name="alert" size={16} />
|
||||
Gewählte Charge: {lotAmount(selectedLot.quantity)}
|
||||
{selectedLot.best_before ? ` · MHD ${formatDate(selectedLot.best_before)}` : " · ohne MHD"}
|
||||
{selectedLot.best_before ? ` · MHD ${formatBestBefore(selectedLot.best_before, selectedLot.best_before_precision)}` : " · ohne MHD"}
|
||||
{isExpired(selectedLot.best_before) ? " (abgelaufen)" : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSettings } from "../settings";
|
||||
import { amountText, articlePrimary, articleSecondary, fmt, relativeExpiry } from "../units";
|
||||
|
||||
export default function Dashboard() {
|
||||
const { formatDate } = useSettings();
|
||||
const { formatBestBefore } = useSettings();
|
||||
const [expiring, setExpiring] = useState([]);
|
||||
const [shopping, setShopping] = useState([]);
|
||||
const [groupShopping, setGroupShopping] = useState([]);
|
||||
@@ -91,7 +91,7 @@ export default function Dashboard() {
|
||||
<div className="muted small">{articleSecondary(it)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{formatDate(it.best_before)}</td>
|
||||
<td>{formatBestBefore(it.best_before, it.best_before_precision)}</td>
|
||||
<td>
|
||||
<span className={`badge ${it.days_left < 0 ? "danger" : "warn"}`}>
|
||||
{relativeExpiry(it.days_left)}
|
||||
|
||||
@@ -6,11 +6,13 @@ import BarcodeList from "../components/BarcodeList";
|
||||
import Icon from "../components/Icon";
|
||||
import { guessGroup } from "../offUtils";
|
||||
import { useSettings } from "../settings";
|
||||
import { daysUntil, expiryRowClass, fmt, isExpired, relativeExpiry, unitShort } from "../units";
|
||||
import {
|
||||
daysUntil, expiryRowClass, fmt, fromMonthInput, isExpired, relativeExpiry, toMonthInput, unitShort,
|
||||
} from "../units";
|
||||
|
||||
const EMPTY = {
|
||||
barcode: "", name: "", brand: "", image_url: "",
|
||||
unit_id: "", package_size: "", package_label: "", min_stock: "", group_id: "",
|
||||
unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "", group_id: "",
|
||||
};
|
||||
|
||||
// Auswahl für die Gebinde-Bezeichnung ("Packung" ist der leere Standardwert).
|
||||
@@ -143,6 +145,7 @@ export default function ProductForm() {
|
||||
barcode: p.barcode || "", name: p.name, brand: p.brand || "",
|
||||
image_url: p.image_url || "", unit_id: uid,
|
||||
package_size: p.package_size ?? "", package_label: p.package_label || "",
|
||||
date_precision: p.date_precision === "month" ? "month" : "day",
|
||||
min_stock: p.min_stock != null ? p.min_stock / f : "",
|
||||
group_id: p.group_id ?? "",
|
||||
});
|
||||
@@ -176,6 +179,7 @@ export default function ProductForm() {
|
||||
unit_id: form.unit_id === "" ? null : Number(form.unit_id),
|
||||
package_size: form.package_size === "" ? null : Number(form.package_size),
|
||||
package_label: form.package_label.trim() || null,
|
||||
date_precision: form.date_precision || "day",
|
||||
min_stock: minBase,
|
||||
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
|
||||
min_stock_in_packages: sel === "package",
|
||||
@@ -291,7 +295,17 @@ export default function ProductForm() {
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="grow" />
|
||||
<label className="grow">
|
||||
MHD-Angabe
|
||||
<select value={form.date_precision} onChange={(e) => set("date_precision", e.target.value)}
|
||||
disabled={readOnly}>
|
||||
<option value="day">Tagesdatum</option>
|
||||
<option value="month">nur Monat/Jahr</option>
|
||||
</select>
|
||||
<span className="muted small">
|
||||
Voreinstellung beim Einlagern – z.B. Konserven tragen oft nur „09/2026“.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
@@ -378,9 +392,9 @@ export default function ProductForm() {
|
||||
|
||||
/** Chargen-Karte mit Bearbeiten/Löschen. Bearbeitet wird in der Produkteinheit. */
|
||||
function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onChanged, onError }) {
|
||||
const { formatDate } = useSettings();
|
||||
const { formatBestBefore } = useSettings();
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [draft, setDraft] = useState({ quantity: "", best_before: "" });
|
||||
const [draft, setDraft] = useState({ quantity: "", best_before: "", precision: "day" });
|
||||
// In welcher Einheit die Menge bearbeitet wird: "unit" oder "package".
|
||||
const [editUnit, setEditUnit] = useState("unit");
|
||||
|
||||
@@ -410,7 +424,14 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
|
||||
const mode = pkgSize > 0 ? "package" : "unit";
|
||||
setEditId(l.id);
|
||||
setEditUnit(mode);
|
||||
setDraft({ quantity: l.quantity / editFactorOf(mode), best_before: l.best_before || "" });
|
||||
setDraft({
|
||||
quantity: l.quantity / editFactorOf(mode),
|
||||
best_before:
|
||||
l.best_before_precision === "month"
|
||||
? toMonthInput(l.best_before)
|
||||
: l.best_before || "",
|
||||
precision: l.best_before_precision === "month" ? "month" : "day",
|
||||
});
|
||||
}
|
||||
|
||||
function changeEditUnit(newMode) {
|
||||
@@ -426,7 +447,10 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
|
||||
try {
|
||||
await api.updateLot(l.id, {
|
||||
quantity: Number(draft.quantity) * editFactor,
|
||||
best_before: draft.best_before || null,
|
||||
best_before:
|
||||
(draft.precision === "month" ? fromMonthInput(draft.best_before) : draft.best_before) ||
|
||||
null,
|
||||
best_before_precision: draft.precision || "day",
|
||||
});
|
||||
setEditId(null);
|
||||
await onChanged();
|
||||
@@ -498,12 +522,13 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
|
||||
</td>
|
||||
<td>
|
||||
{editing ? (
|
||||
<input type="date" value={draft.best_before}
|
||||
<input type={draft.precision === "month" ? "month" : "date"}
|
||||
value={draft.best_before}
|
||||
onChange={(e) => setDraft({ ...draft, best_before: e.target.value })}
|
||||
style={{ marginTop: 0, minWidth: 150 }} />
|
||||
) : (
|
||||
<>
|
||||
{l.best_before ? formatDate(l.best_before) : "–"}
|
||||
{l.best_before ? formatBestBefore(l.best_before, l.best_before_precision) : "–"}
|
||||
{cls && (
|
||||
<span className={`badge ${cls === "row-danger" ? "danger" : "warn"}`}
|
||||
style={{ marginLeft: 6 }}>
|
||||
|
||||
Reference in New Issue
Block a user