import { useEffect, useState } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { api } from "../api"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; import { guessGroup } from "../offUtils"; import { daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units"; const EMPTY = { barcode: "", name: "", brand: "", image_url: "", unit_id: "", package_size: "", package_label: "", min_stock: "", group_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 { 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 [units, setUnits] = useState([]); const [error, setError] = useState(null); const [info, setInfo] = 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); 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(s, groupsList, unitList) { const groupGuess = guessGroup(groupsList, s); 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, group_id: f.group_id || groupGuess, })); if (s.package_size != null) setMinUnit("package"); setInfo( "Daten von Open Food Facts übernommen." + (s.package_size != null ? "" : " (Füllmenge nicht hinterlegt – bitte Packungsgröße prüfen.)") + (groupGuess ? " Passende Gruppe vorgeschlagen." : "") ); } async function runLookup(code, groupsList, unitList) { if (!code) return; setError(null); setInfo(null); try { const res = await api.lookup(code.trim()); if (res.found && res.existing_product) { setInfo("Dieses Produkt existiert bereits."); navigate(`/products/${res.existing_product.id}`); } else if (res.found && res.suggestion) { applySuggestion(res.suggestion, groupsList, unitList); } else { setInfo("Barcode unbekannt – bitte Daten selbst eingeben."); } } catch (err) { setError(err.message); } } useEffect(() => { async function load() { try { const [gs, us] = await Promise.all([api.listGroups(), api.listUnits()]); setGroups(gs); setUnits(us); 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 || "", min_stock: p.min_stock != null ? p.min_stock / f : "", group_id: p.group_id ?? "", }); setLots(await api.listLots(id)); } else { const bc = searchParams.get("barcode"); if (bc) { set("barcode", bc); await runLookup(bc, gs, 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, 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()); setInfo("Gespeichert."); setProduct(await api.getProduct(id)); setLots(await api.listLots(id)); } } catch (err) { setError(err.message); } finally { setBusy(false); } } async function remove() { if (!confirm("Produkt wirklich löschen? Alle Chargen gehen verloren.")) 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}
} {info &&
{info}
}
{isAdmin && ( )}
{isAdmin && (
{!isNew && ( )}
)} {readOnly &&

Nur Administratoren können Produkte bearbeiten.

} {!isNew && ( { setLots(await api.listLots(id)); setProduct(await api.getProduct(id)); }} onError={setError} /> )}
); } /** Chargen-Karte mit Bearbeiten/Löschen. Bearbeitet wird in der Produkteinheit. */ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onChanged, onError }) { const [editId, setEditId] = useState(null); const [draft, setDraft] = useState({ quantity: "", best_before: "" }); // 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 || "" }); } 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.best_before || null, }); setEditId(null); await onChanged(); } catch (err) { onError(err.message); } } async function removeLot(l) { if (!confirm("Charge wirklich löschen?")) 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.
)} {imageUrl && }
{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 || "–"} {cls === "row-danger" && abgelaufen} {cls === "row-warn" && {dLeft}d} )} {new Date(l.created_at).toLocaleDateString("de-DE")} {isAdmin && (editing ? (
) : (
))}
Keine Chargen im Bestand.
{pkgSize > 0 && (

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

)}
); }