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>
1078 lines
44 KiB
JavaScript
1078 lines
44 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 { 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 & 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>
|
||
);
|
||
}
|