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 { daysUntil, expiryRowClass, fmt, fromMonthInput, 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: "", }; // Auswahl für die Gebinde-Bezeichnung ("Packung" ist der leere Standardwert). const PACKAGE_LABELS = [ "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton", "Riegel", "Rolle", ]; // 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"]; 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 [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 : ""; function set(k, v) { setForm((f) => ({ ...f, [k]: v })); } 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"); toast( "Daten von Open Food Facts ü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("Open Food Facts 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] = await Promise.all([ api.listGroups(), api.listUnits(), api.listCategories(), ]); setGroups(gs); setUnits(us); setCategories(cs); 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 ?? "", }); 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]); 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); } return { barcode: form.barcode || null, name: form.name, brand: form.brand || null, image_url: form.image_url || null, 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), category_id: form.category_id === "" ? null : Number(form.category_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: "Alle Chargen dieses Produkts gehen 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 && }
{offDaten && ( setOffDaten(null)} /> )}
{isAdmin && (
{!isNew && ( )}
)} {readOnly &&

Nur Administratoren können Produkte bearbeiten.

} {!isNew && ( { 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; const primaryLabel = pkgSize > 0 ? 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} {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}

)}
); }