diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index 61f34b7..74a8409 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -23,7 +23,7 @@ import { const EMPTY = { barcode: "", name: "", brand: "", image_url: "", unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "", - group_id: "", category_id: "", shop_id: "", product_url: "", individual: false, + group_id: "", category_id: "", shop_id: "", product_url: "", individual: false, bulk: false, }; // (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes) @@ -108,6 +108,12 @@ export default function ProductForm() { // Der Umschalter bestimmt die Verwaltungsart; die Kategorieliste ist darauf // gefiltert, also passt die gewählte Kategorie immer zum Modus. const isObject = modus === "object"; + // Verwaltungsart eines Gegenstands: Menge je Lagerort (count), Einzelstücke + // (individual) oder Verbrauchsgegenstand (bulk – wie ein Lebensmittel als Charge). + const objMode = form.individual ? "individual" : form.bulk ? "bulk" : "count"; + // foodLike nutzt die Lebensmittel-Pfade (Chargen, Einheit, MHD, Mindestbestand): + // gilt für echte Lebensmittel UND Verbrauchsgegenstände. + const foodLike = !isObject || form.bulk; // Gegenstände gleicht das Backend gegen Open Products Facts ab, Lebensmittel // gegen Open Food Facts – die Beschriftung folgt dem Modus. const datenquelle = isObject ? "Open Products Facts" : "Open Food Facts"; @@ -126,6 +132,11 @@ export default function ProductForm() { } } + // Verwaltungsart eines Gegenstands setzen (individual/bulk schließen sich aus). + function setObjMode(mode) { + setForm((f) => ({ ...f, individual: mode === "individual", bulk: mode === "bulk" })); + } + function setField(fieldId, value) { setFieldValues((v) => ({ ...v, [fieldId]: value })); } @@ -390,6 +401,7 @@ export default function ProductForm() { shop_id: p.shop_id ?? "", product_url: p.product_url || "", individual: Boolean(p.individual), + bulk: Boolean(p.bulk), }); setFieldValues(p.field_values || {}); setModus(p.tracking === "object" ? "object" : "food"); @@ -437,35 +449,44 @@ export default function ProductForm() { shop_id: form.shop_id === "" ? null : Number(form.shop_id), product_url: form.product_url.trim() || null, }; - if (isObject) { - // Gegenstände: keine Lebensmittel-Felder (Einheit/Packung/MHD/Gruppe). - // Nur die geltenden eigenen Felder mitschicken – Werte fremder Kategorien - // (nach einem Kategoriewechsel) fallen dabei weg. - const erlaubt = new Set(effFields.map((f) => String(f.id))); - const fv = {}; - Object.entries(fieldValues).forEach(([k, v]) => { - if (erlaubt.has(String(k))) fv[k] = v; - }); - // Auch Gegenstände dürfen einen Mindestbestand haben (Verbrauchsmaterial): - // gleiche Umrechnung wie bei Lebensmitteln, für Stück-Artikel = Stückzahl. - return { - ...base, individual: form.individual, field_values: fv, - min_stock: minBase, - min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel), - min_stock_in_packages: sel === "package", - }; - } - return { - ...base, - individual: false, - 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", + const minStock = { min_stock: minBase, min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel), min_stock_in_packages: sel === "package", + }; + // Gegenstände: nur die geltenden eigenen Felder mitschicken – Werte fremder + // Kategorien (nach einem Kategoriewechsel) fallen dabei weg. + const erlaubt = new Set(effFields.map((f) => String(f.id))); + const fieldPayload = {}; + Object.entries(fieldValues).forEach(([k, v]) => { + if (erlaubt.has(String(k))) fieldPayload[k] = v; + }); + if (foodLike) { + // Lebensmittel ODER Verbrauchsgegenstand: als Charge mit Einheit/Packung/ + // MHD/Mindestbestand/Gruppe geführt. bulk nur bei Gegenständen. + return { + ...base, + individual: false, + bulk: isObject, + 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", + group_id: form.group_id === "" ? null : Number(form.group_id), + ...minStock, + ...(isObject ? { field_values: fieldPayload } : {}), + }; + } + // Gegenstand ohne Charge: Menge je Lagerort oder Einzelstücke. Mindestbestand + // in Stück (kein Einheit-Dropdown), gleiche Umrechnung liefert die Stückzahl. + // Menge-Gegenstände dürfen (wie Verbrauchsgegenstände) in eine Gruppe. + return { + ...base, + individual: form.individual, + bulk: false, group_id: form.group_id === "" ? null : Number(form.group_id), + field_values: fieldPayload, + ...minStock, }; } @@ -604,7 +625,100 @@ export default function ProductForm() { onSchliessen={() => setOffDaten(null)} /> )} - {!isObject && (<> + {/* Typ und Kategorie an fester Position – VOR den typ-spezifischen + Feldern, damit der Umschalter nicht je nach Auswahl springt. */} +
+ +
+
+ + {isAdmin && ( +
+ +
+ )} +
+ + {isAdmin && newCat && ( +
+
+

Neue {modus === "object" ? "Gegenstands" : "Lebensmittel"}-Kategorie

+ +
+
+ + +
+ +
+ )} + + {/* Verwaltungsart nur für Gegenstände: Menge je Lagerort / Einzelstücke / + Verbrauchsgegenstand (= wie ein Lebensmittel als Charge geführt). */} + {isObject && ( +
+ +
+ )} + {isObject && objMode === "bulk" && ( +

+ Wird wie ein Lebensmittel als Charge mit Menge und Einheit geführt + (z.B. Sonnencreme in ml) – nur ohne eindeutigen Code. +

+ )} + + {/* Lebensmittel ODER Verbrauchsgegenstand: Einheit, Packung, MHD, + Mindestbestand (mit Einheit), Gruppe, Mindestbestand je Lagerort. */} + {foodLike && (<>
)} )} -
- -
-
- - {isAdmin && ( -
- + + {/* Gegenstand-spezifische Zusätze: Bezugsquelle/Link und die + Stück-Mindestbestände (nur Menge je Lagerort / Einzelstücke). */} + {isObject && (<> + {objMode !== "bulk" && ( +
+ {objMode === "count" && ( + + )} +
)} -
- - {isAdmin && newCat && ( -
-
-

Neue {modus === "object" ? "Gegenstands" : "Lebensmittel"}-Kategorie

- -
+ {objMode !== "bulk" && (
- -
- -
- )} - - {isObject && (<> -
- -
-
-
- {!form.individual && ( - - )} - -
-
- -
- {!isNew && product && isAdmin && ( + )} + {objMode !== "bulk" && !isNew && product && isAdmin && (
@@ -911,7 +967,22 @@ export default function ProductForm() { {readOnly &&

Nur Administratoren können Produkte bearbeiten.

} - {!isNew && (isObject ? ( + {!isNew && (foodLike ? ( + // Lebensmittel und Verbrauchsgegenstände: Chargen (Menge, MHD, FEFO). + { + setLots(await api.listLots(id)); + setProduct(await api.getProduct(id)); + }} + onError={setError} + /> + ) : ( + // Gegenstand als Menge je Lagerort (Einzelstücke stehen unten). product && !form.individual && ( ) - ) : ( - { - setLots(await api.listLots(id)); - setProduct(await api.getProduct(id)); - }} - onError={setError} - /> ))}
diff --git a/web/src/styles.css b/web/src/styles.css index 6a2d35b..56d5767 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -397,6 +397,7 @@ td select { width: auto; min-width: 0; max-width: 100%; } .strong { font-weight: 640; } .thumb-cell { width: 44px; } .thumb { + display: block; /* sonst hebt die Inline-Grundlinie die Zeile beim Nachladen minimal */ width: 34px; height: 34px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface-2); } @@ -1007,6 +1008,8 @@ tr.row-active { background: var(--surface-2); } direkt daneben geklebt. */ .seg-label { display: flex; align-items: center; gap: var(--sp-3); } .seg-label .segmented { margin-top: 0; } +/* Dreifach-Umschalter (Verwaltung) darf bei Platznot umbrechen. */ +.segmented.seg-wrap { flex-wrap: wrap; } /* Artikelfoto mit Aufnahme-/Galerie-Knöpfen */ .produkt-foto { display: flex; flex-direction: column; gap: var(--sp-2); align-items: center; }