Files
Vorrania/web/src/pages/ProductForm.jsx
Scarriffle 4141781ffb Web: Foto schon beim Anlegen eines Produkts hinterlegen
Bisher ging ein Foto erst nach dem Speichern (Produkt-ID noetig). Jetzt kann man
beim Neuanlegen direkt "Foto machen"/"Galerie" nutzen - das Bild wird vorgemerkt,
als Vorschau gezeigt und nach dem Anlegen hochgeladen. Nuetzlich, wenn Open Food/
Products Facts kein Bild liefert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 10:28:56 +02:00

1253 lines
52 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 CategorySelect from "../components/CategorySelect";
import LocationMinStock from "../components/LocationMinStock";
import { DynamicFields, FIELD_TYPES } from "../fields";
import ObjektBestand from "../components/ObjektBestand";
import Einzelstuecke from "../components/Einzelstuecke";
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: "", individual: false, bulk: false,
};
// (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"];
// Faktor Artikeleinheit → Anzeigeeinheit: die je-Lagerort-Mindestbestände sind in
// Artikeleinheiten (Packungen) gespeichert, angezeigt wird aber die echte Einheit
// (Gramm/Milliliter/Stück), damit „Packung" nicht nichtssagend dasteht.
const artToDisp = (p) => (
(p.package_size > 0 ? p.package_size : (p.unit_factor || 1)) / (p.unit_factor || 1)
);
// 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);
// Ob gerade ein Bild vorliegt steuert, ob „Entfernen" angeboten wird.
const [hatBild, setHatBild] = useState(false);
// Beim Neuanlegen gibt es noch keine Produkt-ID zum Hochladen: Foto zwischen-
// speichern und nach dem Anlegen hochladen. { file, url } | null.
const [pendingPhoto, setPendingPhoto] = useState(null);
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";
// Verwaltungsart eines Gegenstands: Menge je Lagerort (count), Einzelstücke
// (individual) oder Verbrauchsgegenstand (bulk wie ein Lebensmittel als Charge).
const objMode = form.individual ? "individual" : form.bulk ? "bulk" : "count";
// foodLike nutzt die Lebensmittel-Pfade (Chargen, Einheit, MHD, Mindestbestand):
// gilt für echte Lebensmittel UND Verbrauchsgegenstände.
const foodLike = !isObject || form.bulk;
// 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([]);
}
}
// Verwaltungsart eines Gegenstands setzen (individual/bulk schließen sich aus).
function setObjMode(mode) {
setForm((f) => ({ ...f, individual: mode === "individual", bulk: mode === "bulk" }));
}
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) return;
setError(null);
try {
const foto = await verkleinereBild(file);
if (isNew) {
// Noch keine ID Foto vormerken und erst nach dem Anlegen hochladen.
setPendingPhoto((alt) => {
if (alt?.url) URL.revokeObjectURL(alt.url);
return { file: foto, url: URL.createObjectURL(foto) };
});
return;
}
await api.uploadProductImage(id, foto);
setBildVersion((v) => v + 1);
toast("Foto gespeichert.");
} catch (err) {
setError(err.message);
}
}
function removePendingPhoto() {
setPendingPhoto((alt) => {
if (alt?.url) URL.revokeObjectURL(alt.url);
return null;
});
}
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 || "",
individual: Boolean(p.individual),
bulk: Boolean(p.bulk),
});
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,
};
const minStock = {
min_stock: minBase,
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
min_stock_in_packages: sel === "package",
};
// Gegenstände: 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 fieldPayload = {};
Object.entries(fieldValues).forEach(([k, v]) => {
if (erlaubt.has(String(k))) fieldPayload[k] = v;
});
if (foodLike) {
// Lebensmittel ODER Verbrauchsgegenstand: als Charge mit Einheit/Packung/
// MHD/Mindestbestand/Gruppe geführt. bulk nur bei Gegenständen.
return {
...base,
individual: false,
bulk: isObject,
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",
group_id: form.group_id === "" ? null : Number(form.group_id),
...minStock,
...(isObject ? { field_values: fieldPayload } : {}),
};
}
// Gegenstand ohne Charge: Menge je Lagerort oder Einzelstücke. Diese haben
// KEINEN Mindestbestand (den gibt es nur bei Lebensmitteln/Verbrauchs-
// gegenständen) etwaige Alt-Werte werden beim Speichern geleert. Gruppen
// sind weiter erlaubt (Menge je Lagerort).
return {
...base,
individual: form.individual,
bulk: false,
group_id: form.group_id === "" ? null : Number(form.group_id),
field_values: fieldPayload,
min_stock: null,
min_stock_unit_id: null,
min_stock_in_packages: false,
};
}
async function save(e) {
e.preventDefault();
setError(null);
setBusy(true);
try {
if (isNew) {
const created = await api.createProduct(buildPayload());
// Vorgemerktes Foto jetzt hochladen ein Fehler dabei darf das Anlegen
// nicht zunichte machen, der Artikel existiert bereits.
if (pendingPhoto) {
try {
await api.uploadProductImage(created.id, pendingPhoto.file);
} catch (err) {
toast(`Artikel angelegt, aber Foto fehlgeschlagen: ${err.message}`, "warn");
}
removePendingPhoto();
}
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" style={{ flexWrap: "wrap" }}>
<label className="grow" style={{ flex: "1 1 200px", minWidth: 160 }}>
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 || isAdmin) && (
<div className="produkt-foto">
{isNew
? pendingPhoto && (
<img className="produkt-bild" src={pendingPhoto.url} alt={form.name || ""} />
)
: <ProduktBild productId={id} alt={form.name} version={bildVersion} onLoaded={setHatBild} />}
{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>
{isNew
? pendingPhoto && (
<button type="button" className="btn sm" onClick={removePendingPhoto}>Entfernen</button>
)
: hatBild && (
<button type="button" className="btn sm" 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)}
/>
)}
{/* Typ und Kategorie an fester Position VOR den typ-spezifischen
Feldern, damit der Umschalter nicht je nach Auswahl springt. */}
<div className="row">
<label className="seg-label">
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>
<div className="row">
<label className="grow" style={{ maxWidth: 460 }}>
<span className="tip" title="Nur Kategorien des gewählten Typs. Gegenstands-Kategorien zeigen Menge je Lagerort, Lebensmittel MHD/Chargen.">
Kategorie
</span>
<CategorySelect
value={form.category_id === "" ? null : Number(form.category_id)}
nodes={asTree(categories).filter((c) => c.tracking === modus)}
rootLabel=" keine "
disabled={readOnly}
onChange={(id) => set("category_id", id == null ? "" : String(id))}
/>
</label>
{isAdmin && (
// Eigene, unsichtbare Beschriftungszeile, damit der Knopf auf
// gleicher Höhe wie das Auswahlfeld beginnt (nicht am Zeilenrand).
<div style={{ marginBottom: "var(--sp-4)" }}>
<div aria-hidden="true" style={{ visibility: "hidden", fontSize: "0.8rem", fontWeight: 580 }}>&nbsp;</div>
<button type="button" className="btn" style={{ width: "auto", marginTop: 5 }}
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)
<CategorySelect
value={newCat.parent_id === "" ? null : Number(newCat.parent_id)}
nodes={asTree(categories).filter((c) => c.tracking === modus)}
rootLabel=" oberste Ebene "
onChange={(id) => setNewCat({ ...newCat, parent_id: id == null ? "" : String(id) })}
/>
</label>
</div>
<button type="button" className="btn primary" onClick={createCategoryInline}>
Anlegen &amp; auswählen
</button>
</div>
)}
{/* Verwaltungsart nur für Gegenstände: Menge je Lagerort / Einzelstücke /
Verbrauchsgegenstand (= wie ein Lebensmittel als Charge geführt). */}
{isObject && (
<div className="row">
<label className="grow seg-label">
Verwaltung
<div className="segmented seg-wrap">
<button type="button" className={objMode === "count" ? "active" : ""}
onClick={() => setObjMode("count")} disabled={readOnly}>Menge je Lagerort</button>
<button type="button" className={objMode === "individual" ? "active" : ""}
onClick={() => setObjMode("individual")} disabled={readOnly}>Einzelstücke</button>
<button type="button" className={objMode === "bulk" ? "active" : ""}
onClick={() => setObjMode("bulk")} disabled={readOnly}>Verbrauchsgegenstand</button>
</div>
</label>
</div>
)}
{isObject && objMode === "bulk" && (
<p className="muted small mt-0" style={{ marginBottom: "var(--sp-2)" }}>
Wird wie ein Lebensmittel als Charge mit Menge und Einheit geführt
(z.B. Sonnencreme in ml) nur ohne eindeutigen Code.
</p>
)}
{/* Lebensmittel ODER Verbrauchsgegenstand: Einheit, Packung, MHD,
Mindestbestand (mit Einheit), Gruppe, Mindestbestand je Lagerort. */}
{foodLike && (<>
<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 Artikel 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>
{!isNew && product && isAdmin && (
<div style={{ marginTop: "var(--sp-2)", marginBottom: "var(--sp-5)" }}>
<div className="card-head" style={{ marginBottom: "var(--sp-1)" }}>
<Icon name="location" size={16} />
<h3 style={{ margin: 0 }}>Mindestbestand je Lagerort</h3>
</div>
<p className="muted small mt-0">
Zusätzlich zum Gesamt-Mindestbestand: eigener Bedarf je Lagerort (z.B.
Ferienhaus, Zuhause). Menge in {product.unit_name || "Stück"}.
Wird separat gespeichert.
</p>
<LocationMinStock
locations={locations}
initial={(product.location_min_stocks || []).map(
(e) => ({ ...e, min_stock: e.min_stock * artToDisp(product) }))}
unitLabel={product.unit_name || "Stück"}
onError={(m) => toast(m, "warn")}
onSave={async (list) => {
const f = artToDisp(product);
const artList = list.map((e) => ({ ...e, min_stock: e.min_stock / f }));
const updated = await api.setProductLocationMinStock(product.id, artList);
setProduct(updated);
toast("Bedarfe je Lagerort gespeichert.");
}}
/>
</div>
)}
</>)}
{/* Gegenstand-spezifische Zusätze: Bezugsquelle/Link und die
Stück-Mindestbestände (nur Menge je Lagerort / Einzelstücke). */}
{isObject && (<>
{objMode !== "bulk" && (
<div className="row">
{objMode === "count" && (
<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>
)}
{/* Menge je Lagerort darf gruppiert werden aber ohne Mindestbestand
(den gibt es nur bei Lebensmitteln/Verbrauchsgegenständen). */}
{objMode === "count" && (
<div className="row">
<label className="grow" style={{ maxWidth: 300 }}>
<span className="tip" title="Zählt Bestände mehrerer Gegenstände zusammen (in Stück).">
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>
)}
{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 && (foodLike ? (
// Lebensmittel und Verbrauchsgegenstände: Chargen (Menge, MHD, FEFO).
<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}
/>
) : (
// Gegenstand als Menge je Lagerort (Einzelstücke stehen unten).
product && !form.individual && (
<ObjektBestand
product={product}
lots={lots}
locations={locations}
isAdmin={isAdmin}
onChanged={async () => {
setLots(await api.listLots(id));
setProduct(await api.getProduct(id));
}}
onError={setError}
/>
)
))}
</div>
{/* Einzelstücke über die volle Breite die Tabelle hat viele Spalten. */}
{!isNew && product && isObject && form.individual && (
<Einzelstuecke
product={product}
locations={locations}
shops={shops}
isAdmin={isAdmin}
onChanged={async () => { 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 && (
<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>
);
}