Zwei Ursachen dafuer, dass der Knopf folgenlos wirkte. Sichtbar: Uebernehmen schreibt nur ins Formular, das Artikelbild wechselt erst beim Speichern - es gab aber nichts, woran man das erkannt haette. Die Spalte "Bisher" zeigt jetzt sofort das uebernommene Bild mit Kennzeichnung, der Knopf heisst danach "Uebernommen", und eine Meldung erklaert den naechsten Schritt. Tatsaechlich folgenlos war der haeufigste Fall: Stimmte die Bildadresse laengst und nur der Abruf war damals fehlgeschlagen, aenderte sich beim Speichern nichts - und die Bedingung fragte genau nach einer Aenderung. Sie richtet sich jetzt danach, woher die vorhandene Kopie stammt. Fehlt sie, wird geholt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
726 lines
28 KiB
JavaScript
726 lines
28 KiB
JavaScript
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 (
|
||
<div>
|
||
<div className="page-head">
|
||
<div>
|
||
<h1>{isNew ? "Neues Produkt" : form.name || "Produkt"}</h1>
|
||
{!isNew && product && (
|
||
<div className="sub">
|
||
Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}
|
||
{product.package_size ? ` · ${fmt(product.stock / product.package_size)} Pkg` : ""}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button className="btn ghost" onClick={() => navigate(-1)}>Zurück</button>
|
||
</div>
|
||
|
||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||
|
||
<div className="grid-2">
|
||
<form className="card" onSubmit={save}>
|
||
{/* 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. */}
|
||
<div className="produkt-kopf">
|
||
<div className="produkt-kopf-felder">
|
||
<div className="field-inline">
|
||
<label className="grow">
|
||
Barcode
|
||
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
|
||
</label>
|
||
{isAdmin && (
|
||
<div className="knopf-spalte">
|
||
<button type="button" className="btn" onClick={() => runLookup(form.barcode, units)}>
|
||
<Icon name="search" size={16} />Nachschlagen
|
||
</button>
|
||
{!isNew && (
|
||
<button type="button" className="btn" disabled={offBusy}
|
||
onClick={holeOffDaten}>
|
||
<Icon name="download" size={16} />
|
||
{offBusy ? "Frage an…" : "Mit Open Food Facts vergleichen"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<label>
|
||
Name
|
||
<input value={form.name} onChange={(e) => set("name", e.target.value)} required disabled={readOnly} />
|
||
</label>
|
||
<label>
|
||
Marke
|
||
<input value={form.brand} onChange={(e) => set("brand", e.target.value)} disabled={readOnly} />
|
||
</label>
|
||
</div>
|
||
{!isNew && <ProduktBild productId={id} alt={form.name} version={bildVersion} />}
|
||
</div>
|
||
|
||
{offDaten && (
|
||
<OffVergleich
|
||
felder={offFelder()}
|
||
bildEigenId={id}
|
||
bildFremdUrl={offDaten.image_url || ""}
|
||
bildUebernommen={Boolean(offDaten.image_url) && form.image_url === offDaten.image_url}
|
||
onUebernehmen={uebernehmen}
|
||
onAlle={alleUebernehmen}
|
||
onSchliessen={() => setOffDaten(null)}
|
||
/>
|
||
)}
|
||
<div className="row">
|
||
<label className="grow">
|
||
Einheit
|
||
<select value={form.unit_id} onChange={(e) => set("unit_id", e.target.value)} disabled={readOnly} required>
|
||
<option value="">– wählen –</option>
|
||
{KIND_ORDER.filter((k) => units.some((u) => u.kind === k)).map((k) => (
|
||
<optgroup key={k} label={KIND_LABEL[k]}>
|
||
{units.filter((u) => u.kind === k).map((u) => (
|
||
<option key={u.id} value={u.id}>{u.name}</option>
|
||
))}
|
||
</optgroup>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="grow">
|
||
Packungsgröße{baseShort ? ` (in ${baseShort})` : ""}
|
||
<input type="number" step="any" value={form.package_size}
|
||
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly} placeholder="z.B. 500" />
|
||
</label>
|
||
</div>
|
||
<div className="row">
|
||
<label className="grow">
|
||
Bezeichnung der Einheit
|
||
<select value={form.package_label} onChange={(e) => set("package_label", e.target.value)}
|
||
disabled={readOnly}>
|
||
<option value="">Packung (Standard)</option>
|
||
{PACKAGE_LABELS.map((l) => <option key={l} value={l}>{l}</option>)}
|
||
{form.package_label && !PACKAGE_LABELS.includes(form.package_label) && (
|
||
<option value={form.package_label}>{form.package_label}</option>
|
||
)}
|
||
</select>
|
||
</label>
|
||
<label className="grow">
|
||
<span className="tip" title="Voreinstellung beim Einlagern – z.B. Konserven tragen oft nur „09/2026“.">
|
||
MHD-Angabe
|
||
</span>
|
||
<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>
|
||
</label>
|
||
</div>
|
||
<div className="row">
|
||
<label className="grow">
|
||
Mindestbestand
|
||
<div className="field-inline">
|
||
<input type="number" step="any" value={form.min_stock}
|
||
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" />
|
||
<select value={minUnit || form.unit_id} onChange={(e) => changeMinUnit(e.target.value)}
|
||
disabled={readOnly} style={{ marginTop: 0 }}>
|
||
{units
|
||
.filter((u) => !selectedUnit || u.kind === selectedUnit.kind)
|
||
.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||
{form.package_size && (
|
||
<option value="package">
|
||
{form.package_label.trim() || "Packung"} à {fmt(form.package_size)} {baseShort}
|
||
</option>
|
||
)}
|
||
</select>
|
||
</div>
|
||
</label>
|
||
<label className="grow">
|
||
<span className="tip" title="Zählt Bestände mehrerer Marken zusammen. Der EAN-Code dieses Artikels erscheint danach automatisch bei der Gruppe.">
|
||
Gruppe
|
||
</span>
|
||
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)} disabled={readOnly}>
|
||
<option value="">– keine –</option>
|
||
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div className="row">
|
||
<label className="grow">
|
||
<span className="tip" title="Nur für den Überblick in der Artikelliste – ohne Einfluss auf Bestände.">
|
||
Kategorie
|
||
</span>
|
||
<select value={form.category_id} onChange={(e) => set("category_id", e.target.value)}
|
||
disabled={readOnly}>
|
||
<option value="">– keine –</option>
|
||
{asTree(categories).map((c) => (
|
||
<option key={c.id} value={c.id}>{"— ".repeat(c.depth)}{c.name}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<div className="grow" />
|
||
</div>
|
||
|
||
{isAdmin && (
|
||
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
|
||
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
|
||
{!isNew && (
|
||
<button type="button" className="btn danger" onClick={remove}>
|
||
<Icon name="trash" size={16} />Löschen
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
{readOnly && <p className="muted small mt-0">Nur Administratoren können Produkte bearbeiten.</p>}
|
||
</form>
|
||
|
||
{!isNew && (
|
||
<LotsCard
|
||
product={product}
|
||
lots={lots}
|
||
baseShort={baseShort}
|
||
warnDays={warnDays}
|
||
isAdmin={isAdmin}
|
||
onChanged={async () => {
|
||
setLots(await api.listLots(id));
|
||
setProduct(await api.getProduct(id));
|
||
}}
|
||
onError={setError}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Unterhalb der beiden Spalten: die Codes braucht man selten, sie sollen
|
||
Formular und Chargen nicht auseinanderschieben. */}
|
||
{!isNew && product && (
|
||
<section className="card">
|
||
<div className="card-head"><Icon name="search" /><h2>Weitere EAN-Codes</h2></div>
|
||
<BarcodeList
|
||
barcodes={product.barcodes || []}
|
||
disabled={readOnly}
|
||
hint="Nützlich, wenn dasselbe Produkt mehrere Codes hat (z.B. Aktionspackung)."
|
||
onAdd={async (body) => {
|
||
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); }
|
||
}}
|
||
/>
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<section className="card">
|
||
<div className="card-head"><Icon name="box" /><h2>Chargen im Bestand</h2></div>
|
||
{lots.some((l) => isExpired(l.best_before)) && (
|
||
<div className="alert error"><Icon name="alert" size={16} />
|
||
Dieses Produkt hat abgelaufene Chargen im Bestand.
|
||
</div>
|
||
)}
|
||
<div className="table-wrap">
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th className="num" style={{ minWidth: 150 }}>Menge</th>
|
||
<th style={{ minWidth: 160 }}>MHD</th>
|
||
<th>Eingelagert</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{lots.map((l) => {
|
||
const cls = expiryRowClass(l.best_before, warnDays);
|
||
const dLeft = daysUntil(l.best_before);
|
||
const editing = editId === l.id;
|
||
return (
|
||
<tr key={l.id} className={cls}>
|
||
<td data-label="Menge" className="num">
|
||
{editing ? (
|
||
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
|
||
<input type="number" step="any" min="0" value={draft.quantity}
|
||
onChange={(e) => setDraft({ ...draft, quantity: e.target.value })}
|
||
style={{ marginTop: 0 }} />
|
||
{pkgSize > 0 ? (
|
||
<select value={editUnit} onChange={(e) => changeEditUnit(e.target.value)}
|
||
style={{ marginTop: 0 }}>
|
||
<option value="package">{pkgLabel}</option>
|
||
<option value="unit">{unitName}</option>
|
||
</select>
|
||
) : (
|
||
<span className="muted small">{unitName}</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<>
|
||
{fmt(l.quantity / primaryFactor)} {primaryLabel}
|
||
{secondary(l.quantity) && (
|
||
<div className="muted small">{secondary(l.quantity)}</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</td>
|
||
<td data-label="MHD">
|
||
{editing ? (
|
||
<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 ? formatBestBefore(l.best_before, l.best_before_precision) : "–"}
|
||
{cls && (
|
||
<span className={`badge ${cls === "row-danger" ? "danger" : "warn"}`}
|
||
style={{ marginLeft: 6 }}>
|
||
{relativeExpiry(dLeft)}
|
||
</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</td>
|
||
<td data-label="Eingelagert" className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
|
||
<td className="num">
|
||
{isAdmin && (editing ? (
|
||
<div className="btn-pair">
|
||
<button className="btn sm primary" onClick={() => saveEdit(l)}>Speichern</button>
|
||
<button className="btn sm" onClick={() => setEditId(null)}>Abbrechen</button>
|
||
</div>
|
||
) : (
|
||
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
|
||
<button className="btn-icon" onClick={() => startEdit(l)} title="Charge bearbeiten">
|
||
<Icon name="edit" size={16} />
|
||
</button>
|
||
<button className="btn-icon danger" onClick={() => removeLot(l)} title="Charge löschen">
|
||
<Icon name="trash" size={16} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{lots.length === 0 && <tr><td colSpan={4} className="empty">Keine Chargen im Bestand.</td></tr>}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{pkgSize > 0 && (
|
||
<p className="muted small">1 {pkgLabel} = {fmt(pkgSize)} {baseShort}</p>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|