Files
Vorrania/web/src/pages/ProductForm.jsx
Scarriffle 8d75ad63ce Web-Artikelformular: Umschalter Lebensmittel/Gegenstand
Statt die Verwaltungsart nur implizit ueber die Kategorie zu waehlen (und dabei
Werkzeug & Co. zwischen 50 Lebensmittel-Kategorien zu suchen), gibt es jetzt oben
einen klaren Umschalter. Die Kategorieauswahl zeigt nur Kategorien des gewaehlten
Typs; neu angelegte Kategorien erben den Typ automatisch. Neuanlage startet als
Gegenstand, beim Bearbeiten kommt der Modus aus dem Artikel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:37:16 +02:00

1078 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<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 ${datenquelle} 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 && (
<div className="produkt-foto">
<ProduktBild productId={id} alt={form.name} version={bildVersion} />
{isAdmin && (
<div className="foto-knoepfe">
<label className="btn sm" title="Mit der Kamera aufnehmen">
Foto machen
<input hidden type="file" accept="image/*" capture="environment" onChange={onPickPhoto} />
</label>
<label className="btn sm" title="Aus der Galerie wählen">
Galerie
<input hidden type="file" accept="image/*" onChange={onPickPhoto} />
</label>
<button type="button" className="btn sm ghost" onClick={removePhoto}>Entfernen</button>
</div>
)}
</div>
)}
</div>
{offDaten && (
<OffVergleich
quelle={datenquelle}
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)}
/>
)}
{!isObject && (<>
<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">
<span className="tip" title="Verwaltet unter Verwaltung → Gebinde, dort auch mit Mehrzahl.">
Gebinde
</span>
<select value={form.package_label} onChange={(e) => set("package_label", e.target.value)}
disabled={readOnly}>
<option value="">Packung (Standard)</option>
{gebindearten.map((g) => (
<option key={g.id} value={g.singular}>{g.singular} / {g.plural}</option>
))}
{/* Ein Artikel kann eine Bezeichnung tragen, die es in der
Verwaltung (noch) nicht gibt etwa aus einem Import. */}
{form.package_label && !gebindearten.some((g) => g.singular === 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">
Typ
<div className="segmented">
<button type="button" className={modus === "food" ? "active" : ""}
onClick={() => changeModus("food")} disabled={readOnly}>
Lebensmittel
</button>
<button type="button" className={modus === "object" ? "active" : ""}
onClick={() => changeModus("object")} disabled={readOnly}>
Gegenstand
</button>
</div>
</label>
<div className="grow" />
</div>
<div className="row">
<label className="grow">
<span className="tip" title="Nur Kategorien des gewählten Typs. Gegenstands-Kategorien zeigen Menge je Lagerort, Lebensmittel MHD/Chargen.">
Kategorie
</span>
<select value={form.category_id} onChange={(e) => set("category_id", e.target.value)}
disabled={readOnly}>
<option value=""> keine </option>
{asTree(categories).filter((c) => c.tracking === modus).map((c) => (
<option key={c.id} value={c.id}>
{"— ".repeat(c.depth)}{c.name}
</option>
))}
</select>
</label>
{isAdmin && (
<div className="grow" style={{ display: "flex", alignItems: "flex-end" }}>
<button type="button" className="btn" style={{ marginBottom: 2 }}
onClick={() => (newCat ? setNewCat(null) : openNewCat())}>
<Icon name="plus" size={16} />Neue Kategorie
</button>
</div>
)}
</div>
{isAdmin && newCat && (
<div className="card-sub" style={{ marginBottom: "var(--sp-3)" }}>
<div className="card-head"><Icon name="tag" />
<h3>Neue {modus === "object" ? "Gegenstands" : "Lebensmittel"}-Kategorie</h3>
<button type="button" className="btn-icon" style={{ marginLeft: "auto" }}
onClick={() => setNewCat(null)}><Icon name="close" size={16} /></button>
</div>
<div className="row">
<label className="grow">Name
<input value={newCat.name} placeholder="z.B. Powerbank"
onChange={(e) => setNewCat({ ...newCat, name: e.target.value })} />
</label>
<label className="grow">Untergeordnet (optional)
<select value={newCat.parent_id}
onChange={(e) => setNewCat({ ...newCat, parent_id: e.target.value })}>
<option value=""> oberste Ebene </option>
{asTree(categories).filter((c) => c.tracking === modus).map((c) => (
<option key={c.id} value={c.id}>{"— ".repeat(c.depth)}{c.name}</option>
))}
</select>
</label>
</div>
<button type="button" className="btn primary" onClick={createCategoryInline}>
Anlegen &amp; auswählen
</button>
</div>
)}
{isObject && (<>
<div className="row">
<label className="grow">
Gekauft bei
<select value={form.shop_id} onChange={(e) => set("shop_id", e.target.value)}
disabled={readOnly}>
<option value=""> unbekannt / mehrere </option>
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
<label className="grow">
Produktlink
<div className="field-inline">
<input type="url" value={form.product_url} placeholder="https://…"
onChange={(e) => set("product_url", e.target.value)} disabled={readOnly} />
{form.product_url && (
<a className="btn" href={form.product_url} target="_blank" rel="noreferrer"
title="Im Onlineshop öffnen">Öffnen</a>
)}
</div>
</label>
</div>
{form.category_id !== "" && (
<div style={{ marginTop: "var(--sp-2)" }}>
<div className="card-head" style={{ marginBottom: "var(--sp-2)" }}>
<Icon name="tag" /><h2>Eigene Felder</h2>
{isAdmin && (
<button type="button" className="btn ghost" style={{ marginLeft: "auto" }}
onClick={() => (newField ? setNewField(null)
: setNewField({ label: "", field_type: "text", unit: "", options: "", required: false }))}>
<Icon name="plus" size={16} />Feld
</button>
)}
</div>
{effFields.length > 0 ? (
<DynamicFields fields={effFields} values={fieldValues}
onChange={setField} disabled={readOnly} />
) : (
<p className="muted small">Für diese Kategorie sind noch keine Felder festgelegt.</p>
)}
{isAdmin && newField && (
<div className="card-sub" style={{ marginTop: "var(--sp-2)" }}>
<div className="row">
<label className="grow">Feldname
<input value={newField.label} placeholder="z.B. Kapazität"
onChange={(e) => setNewField({ ...newField, label: e.target.value })} />
</label>
<label style={{ width: 190 }}>Typ
<select value={newField.field_type}
onChange={(e) => setNewField({ ...newField, field_type: e.target.value })}>
{FIELD_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</label>
</div>
{newField.field_type === "number" && (
<label>Einheit (optional)
<input value={newField.unit} placeholder="z.B. mAh"
onChange={(e) => setNewField({ ...newField, unit: e.target.value })} />
</label>
)}
{newField.field_type === "select" && (
<label>Auswahlmöglichkeiten (mit Komma trennen)
<input value={newField.options} placeholder="z.B. S, M, L, XL"
onChange={(e) => setNewField({ ...newField, options: e.target.value })} />
</label>
)}
<label className="check-inline">
<input type="checkbox" checked={newField.required}
onChange={(e) => setNewField({ ...newField, required: e.target.checked })} />
<span>Pflichtfeld</span>
</label>
<button type="button" className="btn primary" onClick={createFieldInline}>
Feld hinzufügen
</button>
</div>
)}
</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 && (isObject ? (
product && (
<ObjektBestand
product={product}
lots={lots}
locations={locations}
isAdmin={isAdmin}
onChanged={async () => {
setLots(await api.listLots(id));
setProduct(await api.getProduct(id));
}}
onError={setError}
/>
)
) : (
<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;
// 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 (
<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(l.quantity / primaryFactor)}
{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>
);
}