import { useEffect, useState } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { api } from "../api"; import { useConfirm } from "../confirm"; import { useAuth } from "../auth"; import BarcodeList from "../components/BarcodeList"; import Icon from "../components/Icon"; import OffVergleich from "../components/OffVergleich"; import ProduktBild from "../components/ProduktBild"; import { useToast } from "../toast"; import { useSettings } from "../settings"; import { asTree } from "../categoryTree"; import { DynamicFields, FIELD_TYPES } from "../fields"; import ObjektBestand from "../components/ObjektBestand"; import { daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry, toMonthInput, unitShort, } from "../units"; 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: "", }; // (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes) // Kanonische Einheit je Basiseinheit (für OFF-Vorschläge und Altbestand). const CANONICAL_NAME = { piece: "Stück", gram: "Gramm", milliliter: "Milliliter" }; const KIND_LABEL = { count: "Anzahl", weight: "Gewicht", volume: "Volumen" }; const KIND_ORDER = ["count", "weight", "volume"]; // Foto vor dem Hochladen verkleinern: kleiner in der DB, schneller im Upload und // zuverlässig unter dem Server-Limit. Bei Problemen fällt es aufs Original zurück. function verkleinereBild(datei, maxDim = 1600, quality = 0.85) { return new Promise((resolve) => { if (!datei || !datei.type?.startsWith("image/")) return resolve(datei); const url = URL.createObjectURL(datei); const img = new Image(); img.onload = () => { URL.revokeObjectURL(url); const faktor = Math.min(1, maxDim / Math.max(img.width, img.height)); if (faktor >= 1 && datei.size <= 1_500_000) return resolve(datei); const w = Math.round(img.width * faktor); const h = Math.round(img.height * faktor); const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; canvas.getContext("2d").drawImage(img, 0, 0, w, h); canvas.toBlob( (blob) => resolve(blob ? new File([blob], "foto.jpg", { type: "image/jpeg" }) : datei), "image/jpeg", quality, ); }; img.onerror = () => { URL.revokeObjectURL(url); resolve(datei); }; img.src = url; }); } export default function ProductForm() { const confirm = useConfirm(); const toast = useToast(); const { id } = useParams(); const isNew = !id; const { isAdmin } = useAuth(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [form, setForm] = useState(EMPTY); const [product, setProduct] = useState(null); const [lots, setLots] = useState([]); const [groups, setGroups] = useState([]); const [categories, setCategories] = useState([]); const [units, setUnits] = useState([]); const [gebindearten, setGebindearten] = useState([]); const [locations, setLocations] = useState([]); const [shops, setShops] = useState([]); // Gegenstände: effektive (vererbte) Felder der Kategorie + deren Werte am Artikel. const [effFields, setEffFields] = useState([]); const [fieldValues, setFieldValues] = useState({}); // Umschalter Lebensmittel/Gegenstand: filtert die Kategorieauswahl auf den Typ. // Neuanlage startet als Gegenstand (der häufigere Non-Food-Fall); beim Bearbeiten // wird er aus dem Artikel gesetzt. const [modus, setModus] = useState("object"); // Direkt im Formular neue Kategorien/Felder anlegen. const [newCat, setNewCat] = useState(null); // { name, parent_id } | null const [newField, setNewField] = useState(null); // { label, field_type, unit, options, required } | null const [error, setError] = useState(null); const [busy, setBusy] = useState(false); // Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit. const [minUnit, setMinUnit] = useState(""); const [warnDays, setWarnDays] = useState(7); // Offene Gegenüberstellung mit Open Food Facts (null = zu). const [offDaten, setOffDaten] = useState(null); const [offBusy, setOffBusy] = useState(false); const [bildVersion, setBildVersion] = useState(0); const selectedUnit = units.find((u) => String(u.id) === String(form.unit_id)) || null; const unitFactor = selectedUnit ? selectedUnit.factor : 1; const unitName = selectedUnit ? selectedUnit.name : ""; // Der Umschalter bestimmt die Verwaltungsart; die Kategorieliste ist darauf // gefiltert, also passt die gewählte Kategorie immer zum Modus. const isObject = modus === "object"; // 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"; function set(k, v) { setForm((f) => ({ ...f, [k]: v })); } // Typ umschalten: passt die gewählte Kategorie nicht mehr, wird sie geleert. function changeModus(neu) { setModus(neu); const cat = categories.find((c) => String(c.id) === String(form.category_id)); if (cat && cat.tracking !== neu) { set("category_id", ""); setEffFields([]); } } function setField(fieldId, value) { setFieldValues((v) => ({ ...v, [fieldId]: value })); } // Öffnet den Inline-Dialog „Neue Kategorie" – als Unterkategorie der gerade // gewählten (falls eine gewählt ist), im selben Modus. function openNewCat() { // Neue Kategorie erbt den aktuell gewählten Typ (Umschalter). setNewCat({ name: "", parent_id: form.category_id || "", tracking: modus }); } async function createCategoryInline() { if (!newCat?.name.trim()) return; setError(null); try { const created = await api.createCategory({ name: newCat.name.trim(), parent_id: newCat.parent_id === "" ? null : Number(newCat.parent_id), tracking: newCat.tracking, }); setCategories(await api.listCategories()); set("category_id", String(created.id)); setNewCat(null); toast(`Kategorie „${created.name}“ angelegt.`); } catch (err) { setError(err.message); } } async function createFieldInline() { if (!newField?.label.trim() || form.category_id === "") return; setError(null); try { await api.createFieldDefinition({ category_id: Number(form.category_id), label: newField.label.trim(), field_type: newField.field_type, unit: newField.field_type === "number" ? (newField.unit || null) : null, options: newField.field_type === "select" ? newField.options.split(",").map((o) => o.trim()).filter(Boolean) : null, required: newField.required, }); setEffFields(await api.categoryFields(Number(form.category_id))); setNewField(null); toast("Feld hinzugefügt."); } catch (err) { setError(err.message); } } async function onPickPhoto(e) { const file = e.target.files?.[0]; e.target.value = ""; // erlaubt, dieselbe Datei erneut zu wählen if (!file || isNew) return; setError(null); try { const foto = await verkleinereBild(file); await api.uploadProductImage(id, foto); setBildVersion((v) => v + 1); toast("Foto gespeichert."); } catch (err) { setError(err.message); } } async function removePhoto() { const ok = await confirm({ title: "Foto entfernen?", confirmLabel: "Entfernen", danger: true, }); if (!ok) return; try { await api.deleteProductImage(id); setBildVersion((v) => v + 1); } catch (err) { setError(err.message); } } function canonicalUnitId(unitList, baseUnit) { const name = CANONICAL_NAME[baseUnit]; const hit = unitList.find((u) => u.name === name); return hit ? String(hit.id) : ""; } // Faktor, mit dem der angezeigte Mindestbestand in Basiseinheiten umgerechnet wird. // sel ist "package" oder die ID einer verwalteten Einheit. function minFactor(sel, pkgSize, unitList) { if (sel === "package") return Number(pkgSize) > 0 ? Number(pkgSize) : 1; const u = unitList.find((x) => String(x.id) === String(sel)); return u ? u.factor : 1; } function changeMinUnit(newSel) { if (form.min_stock !== "" && newSel !== minUnit) { const oldF = minFactor(minUnit, form.package_size, units); const newF = minFactor(newSel, form.package_size, units); set("min_stock", String((Number(form.min_stock) * oldF) / newF)); } setMinUnit(newSel); } function applySuggestion(res, unitList) { const s = res.suggestion; setForm((f) => ({ ...f, barcode: s.barcode || f.barcode, name: s.name || f.name, brand: s.brand || f.brand, image_url: s.image_url || f.image_url, unit_id: f.unit_id || canonicalUnitId(unitList, s.base_unit || "piece"), package_size: s.package_size != null ? String(s.package_size) : f.package_size, // Kategorie schlaegt das Backend aus der OFF-Einordnung vor; die Gruppe // nur, wenn der Code dort ausdruecklich hinterlegt ist. category_id: f.category_id || (res.category_id ? String(res.category_id) : ""), group_id: f.group_id || (res.group_id ? String(res.group_id) : ""), })); if (s.package_size != null) setMinUnit("package"); if (res.category_id) { const cat = categories.find((c) => String(c.id) === String(res.category_id)); if (cat) setModus(cat.tracking); } toast( `Daten von ${datenquelle} übernommen.` + (s.package_size != null ? "" : " (Füllmenge nicht hinterlegt – bitte Packungsgröße prüfen.)") + (res.category_name ? ` Kategorie „${res.category_name}“ vorgeschlagen.` : "") + (res.group_name ? ` Gruppe „${res.group_name}“ über den EAN-Code erkannt.` : "") ); } async function runLookup(code, unitList) { if (!code) return; setError(null); try { const res = await api.lookup(code.trim()); if (res.found && res.existing_product) { toast("Dieses Produkt existiert bereits."); navigate(`/products/${res.existing_product.id}`); } else if (res.found && res.suggestion) { applySuggestion(res, unitList); } else { toast("Barcode unbekannt – bitte Daten selbst eingeben.", "warn"); } } catch (err) { setError(err.message); } } /** * Open Food Facts erneut befragen, obwohl der Artikel schon existiert. * Übernommen wird nichts von allein – das entscheidet die Gegenüberstellung. */ async function holeOffDaten() { setError(null); setOffBusy(true); try { const res = await api.offCompare(id); if (res.found && res.suggestion) { setOffDaten({ ...res.suggestion, category_id: res.category_id, category_name: res.category_name }); } else { setOffDaten(null); toast(`${datenquelle} kennt keinen der hinterlegten EAN-Codes.`, "warn"); } } catch (err) { setError(err.message); } finally { setOffBusy(false); } } /** Welche Felder die Gegenüberstellung zeigt – und woher ihr Wert kommt. */ function offFelder() { if (!offDaten) return []; const kategorie = categories.find((c) => String(c.id) === String(form.category_id)); return [ { schluessel: "name", titel: "Name", eigen: form.name, fremd: offDaten.name || "" }, { schluessel: "brand", titel: "Marke", eigen: form.brand, fremd: offDaten.brand || "" }, { schluessel: "package_size", titel: "Packungsgröße", eigen: form.package_size, fremd: offDaten.package_size ?? "", }, { schluessel: "category_id", titel: "Kategorie", eigen: form.category_id, fremd: offDaten.category_id ?? "", anzeigeEigen: kategorie ? kategorie.name : "–", anzeigeFremd: offDaten.category_name || "–", }, ]; } function uebernehmen(schluessel) { if (!offDaten) return; if (schluessel === "image_url") { set("image_url", offDaten.image_url || ""); // Das Bild selbst wechselt erst beim Speichern - ohne Hinweis wirkt der // Knopf folgenlos. toast("Bild übernommen. Mit „Speichern“ wird es geholt."); return; } if (schluessel === "package_size") { set("package_size", offDaten.package_size != null ? String(offDaten.package_size) : ""); return; } if (schluessel === "category_id") { set("category_id", offDaten.category_id ? String(offDaten.category_id) : ""); return; } set(schluessel, offDaten[schluessel] || ""); } function alleUebernehmen() { offFelder().forEach((f) => { if (f.fremd !== "" && f.fremd !== null && String(f.fremd) !== String(f.eigen)) { uebernehmen(f.schluessel); } }); if (offDaten?.image_url) uebernehmen("image_url"); } useEffect(() => { async function load() { try { const [gs, us, cs, pts, locs, shs] = await Promise.all([ api.listGroups(), api.listUnits(), api.listCategories(), api.listPackageTypes(), api.listLocations(), api.listShops(), ]); setGroups(gs); setUnits(us); setCategories(cs); setGebindearten(pts); setLocations(locs); setShops(shs); try { const s = await api.listSettings(); const row = s.find((x) => x.key === "expiry_warning_days"); if (row) setWarnDays(parseInt(row.value, 10) || 7); } catch { /* optional */ } if (!isNew) { const p = await api.getProduct(id); setProduct(p); const uid = p.display_unit_id ? String(p.display_unit_id) : canonicalUnitId(us, p.base_unit); // Erfassungseinheit des Mindestbestands wiederherstellen. const mode = p.min_stock_in_packages ? "package" : p.min_stock_unit_id ? String(p.min_stock_unit_id) : uid; const f = minFactor(mode, p.package_size, us); setMinUnit(mode); setForm({ 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 ?? "", category_id: p.category_id ?? "", shop_id: p.shop_id ?? "", product_url: p.product_url || "", }); setFieldValues(p.field_values || {}); setModus(p.tracking === "object" ? "object" : "food"); setLots(await api.listLots(id)); } else { const bc = searchParams.get("barcode"); if (bc) { set("barcode", bc); await runLookup(bc, us); } } } catch (err) { setError(err.message); } } load(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); // Wechselt die Kategorie, die geltenden (vererbten) Felder nachladen – // aber nur für Gegenstände. Lebensmittel haben keine eigenen Felder. useEffect(() => { const cat = categories.find((c) => String(c.id) === String(form.category_id)) || null; if (!cat || cat.tracking !== "object") { setEffFields([]); return; } let aktiv = true; api.categoryFields(cat.id).then((fs) => { if (aktiv) setEffFields(fs); }).catch(() => {}); return () => { aktiv = false; }; }, [form.category_id, categories]); function buildPayload() { const sel = minUnit || form.unit_id; let minBase = null; if (form.min_stock !== "") { minBase = Number(form.min_stock) * minFactor(sel, form.package_size, units); } const base = { barcode: form.barcode || null, name: form.name, brand: form.brand || null, image_url: form.image_url || null, category_id: form.category_id === "" ? null : Number(form.category_id), 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; }); return { ...base, field_values: fv }; } return { ...base, 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", group_id: form.group_id === "" ? null : Number(form.group_id), }; } async function save(e) { e.preventDefault(); setError(null); setBusy(true); try { if (isNew) { const created = await api.createProduct(buildPayload()); navigate(`/products/${created.id}`); } else { await api.updateProduct(id, buildPayload()); toast("Gespeichert."); setProduct(await api.getProduct(id)); setLots(await api.listLots(id)); // Der Server holt bei geaenderter Bildadresse ein neues Bild. Ohne // diesen Zaehler zeigte die Seite weiter das alte aus dem Zwischenspeicher. setBildVersion((v) => v + 1); setOffDaten(null); } } catch (err) { setError(err.message); } finally { setBusy(false); } } async function remove() { const ok = await confirm({ title: "Produkt löschen?", message: "Der gesamte Bestand dieses Produkts geht dabei verloren.", confirmLabel: "Löschen", danger: true, }); if (!ok) return; try { await api.deleteProduct(id); navigate("/products"); } catch (err) { setError(err.message); } } const readOnly = !isAdmin; const BASE_SHORT_OF_KIND = { count: "Stk", weight: "g", volume: "ml" }; const baseShort = selectedUnit ? BASE_SHORT_OF_KIND[selectedUnit.kind] : product ? unitShort(product.base_unit) : ""; return (

{isNew ? "Neues Produkt" : form.name || "Produkt"}

{!isNew && product && (
Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name} {product.package_size ? ` · ${fmt(product.stock / product.package_size)} Pkg` : ""}
)}
{error &&
{error}
}
{/* Bild steht neben den Kennfeldern, nicht darueber: Der Platz rechts von Barcode, Name und Marke ist ohnehin selten ausgenutzt. Fehlt ein Bild, rendert die Komponente nichts und die Felder nehmen die volle Breite ein. */}
{isAdmin && (
{!isNew && ( )}
)}
{!isNew && (
{isAdmin && (
)}
)}
{offDaten && ( setOffDaten(null)} /> )} {!isObject && (<>
)}
{isAdmin && (
)}
{isAdmin && newCat && (

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

)} {isObject && (<>
{form.category_id !== "" && (

Eigene Felder

{isAdmin && ( )}
{effFields.length > 0 ? ( ) : (

Für diese Kategorie sind noch keine Felder festgelegt.

)} {isAdmin && newField && (
{newField.field_type === "number" && ( )} {newField.field_type === "select" && ( )}
)}
)} )} {isAdmin && (
{!isNew && ( )}
)} {readOnly &&

Nur Administratoren können Produkte bearbeiten.

} {!isNew && (isObject ? ( product && ( { setLots(await api.listLots(id)); setProduct(await api.getProduct(id)); }} onError={setError} /> ) ) : ( { setLots(await api.listLots(id)); setProduct(await api.getProduct(id)); }} onError={setError} /> ))}
{/* Unterhalb der beiden Spalten: die Codes braucht man selten, sie sollen Formular und Chargen nicht auseinanderschieben. */} {!isNew && product && (

Weitere EAN-Codes

{ try { setProduct(await api.addProductBarcode(id, body)); } catch (err) { setError(err.message); } }} onDelete={async (code) => { try { await api.deleteProductBarcode(id, code); setProduct(await api.getProduct(id)); } catch (err) { setError(err.message); } }} />
)}
); } /** Chargen-Karte mit Bearbeiten/Löschen. Bearbeitet wird in der Produkteinheit. */ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, onChanged, onError }) { const confirm = useConfirm(); const toast = useToast(); const { formatBestBefore } = useSettings(); const [editId, setEditId] = useState(null); const [draft, setDraft] = useState({ quantity: "", best_before: "", precision: "day" }); // In welcher Einheit die Menge bearbeitet wird: "unit" oder "package". const [editUnit, setEditUnit] = useState("unit"); const unitFactor = product?.unit_factor || 1; const unitName = product?.unit_name || baseShort; const pkgSize = product?.package_size || 0; const pkgLabel = product?.package_label || "Packung"; // Leitangabe: Packungen, sobald eine Packungsgröße hinterlegt ist – // sonst die Produkteinheit. Darunter steht die jeweils andere Angabe klein. const primaryFactor = pkgSize > 0 ? pkgSize : unitFactor; // Mengenabhaengig: "1 Glas", aber "3 Gläser". Einheiten (Gramm, Liter) // bleiben unveraendert - die haben im Deutschen keine Mehrzahl. const primaryLabel = (menge) => (pkgSize > 0 ? gebinde(menge, pkgLabel) : unitName); function secondary(qty) { if (pkgSize > 0) return `${fmt(qty / unitFactor)} ${unitName}`; return unitFactor !== 1 ? `${fmt(qty)} ${baseShort}` : ""; } // Faktor der aktuell im Editor gewählten Eingabeeinheit. function editFactorOf(mode) { return mode === "package" && pkgSize > 0 ? pkgSize : unitFactor; } const editFactor = editFactorOf(editUnit); function startEdit(l) { // Bearbeiten startet in der Leitangabe (Packungen, falls vorhanden). const mode = pkgSize > 0 ? "package" : "unit"; setEditId(l.id); setEditUnit(mode); 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) { if (draft.quantity !== "" && newMode !== editUnit) { const oldF = editFactorOf(editUnit); const newF = editFactorOf(newMode); setDraft((d) => ({ ...d, quantity: String((Number(d.quantity) * oldF) / newF) })); } setEditUnit(newMode); } async function saveEdit(l) { try { await api.updateLot(l.id, { quantity: Number(draft.quantity) * editFactor, best_before: (draft.precision === "month" ? fromMonthInput(draft.best_before) : draft.best_before) || null, best_before_precision: draft.precision || "day", }); setEditId(null); await onChanged(); } catch (err) { onError(err.message); } } async function removeLot(l) { const ok = await confirm({ title: "Charge löschen?", message: "Die Menge wird als Korrektur im Verlauf protokolliert.", confirmLabel: "Löschen", danger: true, }); if (!ok) return; try { await api.deleteLot(l.id); await onChanged(); } catch (err) { onError(err.message); } } return (

Chargen im Bestand

{lots.some((l) => isExpired(l.best_before)) && (
Dieses Produkt hat abgelaufene Chargen im Bestand.
)}
{lots.map((l) => { const cls = expiryRowClass(l.best_before, warnDays); const dLeft = daysUntil(l.best_before); const editing = editId === l.id; return ( ); })} {lots.length === 0 && }
Menge MHD Eingelagert
{editing ? (
setDraft({ ...draft, quantity: e.target.value })} style={{ marginTop: 0 }} /> {pkgSize > 0 ? ( ) : ( {unitName} )}
) : ( <> {fmt(l.quantity / primaryFactor)} {primaryLabel(l.quantity / primaryFactor)} {secondary(l.quantity) && (
{secondary(l.quantity)}
)} )}
{editing ? ( setDraft({ ...draft, best_before: e.target.value })} style={{ marginTop: 0, minWidth: 150 }} /> ) : ( <> {l.best_before ? formatBestBefore(l.best_before, l.best_before_precision) : "–"} {cls && ( {relativeExpiry(dLeft)} )} )} {new Date(l.created_at).toLocaleDateString("de-DE")} {isAdmin && (editing ? (
) : (
))}
Keine Chargen im Bestand.
{pkgSize > 0 && (

1 {pkgLabel} = {fmt(pkgSize)} {baseShort}

)}
); }