Files
Vorrania/web/src/pages/ProductForm.jsx
Scarriffle e67bdb32c4 Gebinde-Bezeichnung als normales Dropdown (statt Textfeld mit datalist)
Das datalist-Eingabefeld sah anders aus als die uebrigen Auswahlfelder im
Produktformular. Jetzt ein einheitliches <select>: leerer Standardwert
"Packung (Standard)" plus gaengige Bezeichnungen (Glas, Tuete, Flasche, Dose,
Tube, Becher, Beutel, Karton, Riegel, Rolle). Ein bereits gespeicherter
abweichender Wert bleibt als Option erhalten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 13:43:00 +02:00

518 lines
20 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 { useAuth } from "../auth";
import Icon from "../components/Icon";
import { guessGroup } from "../offUtils";
import { daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units";
const EMPTY = {
barcode: "", name: "", brand: "", image_url: "",
unit_id: "", package_size: "", package_label: "", min_stock: "", group_id: "",
};
// Auswahl für die Gebinde-Bezeichnung ("Packung" ist der leere Standardwert).
const PACKAGE_LABELS = [
"Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton", "Riegel", "Rolle",
];
// Kanonische Einheit je Basiseinheit (für OFF-Vorschläge und Altbestand).
const CANONICAL_NAME = { piece: "Stück", gram: "Gramm", milliliter: "Milliliter" };
const KIND_LABEL = { count: "Anzahl", weight: "Gewicht", volume: "Volumen" };
const KIND_ORDER = ["count", "weight", "volume"];
export default function ProductForm() {
const { id } = useParams();
const isNew = !id;
const { isAdmin } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [form, setForm] = useState(EMPTY);
const [product, setProduct] = useState(null);
const [lots, setLots] = useState([]);
const [groups, setGroups] = useState([]);
const [units, setUnits] = useState([]);
const [error, setError] = useState(null);
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState(false);
// Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit.
const [minUnit, setMinUnit] = useState("");
const [warnDays, setWarnDays] = useState(7);
const selectedUnit = units.find((u) => String(u.id) === String(form.unit_id)) || null;
const unitFactor = selectedUnit ? selectedUnit.factor : 1;
const unitName = selectedUnit ? selectedUnit.name : "";
function set(k, v) {
setForm((f) => ({ ...f, [k]: v }));
}
function canonicalUnitId(unitList, baseUnit) {
const name = CANONICAL_NAME[baseUnit];
const hit = unitList.find((u) => u.name === name);
return hit ? String(hit.id) : "";
}
// Faktor, mit dem der angezeigte Mindestbestand in Basiseinheiten umgerechnet wird.
// sel ist "package" oder die ID einer verwalteten Einheit.
function minFactor(sel, pkgSize, unitList) {
if (sel === "package") return Number(pkgSize) > 0 ? Number(pkgSize) : 1;
const u = unitList.find((x) => String(x.id) === String(sel));
return u ? u.factor : 1;
}
function changeMinUnit(newSel) {
if (form.min_stock !== "" && newSel !== minUnit) {
const oldF = minFactor(minUnit, form.package_size, units);
const newF = minFactor(newSel, form.package_size, units);
set("min_stock", String((Number(form.min_stock) * oldF) / newF));
}
setMinUnit(newSel);
}
function applySuggestion(s, groupsList, unitList) {
const groupGuess = guessGroup(groupsList, s);
setForm((f) => ({
...f,
barcode: s.barcode || f.barcode,
name: s.name || f.name,
brand: s.brand || f.brand,
image_url: s.image_url || f.image_url,
unit_id: f.unit_id || canonicalUnitId(unitList, s.base_unit || "piece"),
package_size: s.package_size != null ? String(s.package_size) : f.package_size,
group_id: f.group_id || groupGuess,
}));
if (s.package_size != null) setMinUnit("package");
setInfo(
"Daten von Open Food Facts übernommen." +
(s.package_size != null ? "" : " (Füllmenge nicht hinterlegt bitte Packungsgröße prüfen.)") +
(groupGuess ? " Passende Gruppe vorgeschlagen." : "")
);
}
async function runLookup(code, groupsList, unitList) {
if (!code) return;
setError(null);
setInfo(null);
try {
const res = await api.lookup(code.trim());
if (res.found && res.existing_product) {
setInfo("Dieses Produkt existiert bereits.");
navigate(`/products/${res.existing_product.id}`);
} else if (res.found && res.suggestion) {
applySuggestion(res.suggestion, groupsList, unitList);
} else {
setInfo("Barcode unbekannt bitte Daten selbst eingeben.");
}
} catch (err) {
setError(err.message);
}
}
useEffect(() => {
async function load() {
try {
const [gs, us] = await Promise.all([api.listGroups(), api.listUnits()]);
setGroups(gs);
setUnits(us);
try {
const s = await api.listSettings();
const row = s.find((x) => x.key === "expiry_warning_days");
if (row) setWarnDays(parseInt(row.value, 10) || 7);
} catch { /* optional */ }
if (!isNew) {
const p = await api.getProduct(id);
setProduct(p);
const uid = p.display_unit_id
? String(p.display_unit_id)
: canonicalUnitId(us, p.base_unit);
// Erfassungseinheit des Mindestbestands wiederherstellen.
const mode = p.min_stock_in_packages
? "package"
: p.min_stock_unit_id
? String(p.min_stock_unit_id)
: uid;
const f = minFactor(mode, p.package_size, us);
setMinUnit(mode);
setForm({
barcode: p.barcode || "", name: p.name, brand: p.brand || "",
image_url: p.image_url || "", unit_id: uid,
package_size: p.package_size ?? "", package_label: p.package_label || "",
min_stock: p.min_stock != null ? p.min_stock / f : "",
group_id: p.group_id ?? "",
});
setLots(await api.listLots(id));
} else {
const bc = searchParams.get("barcode");
if (bc) {
set("barcode", bc);
await runLookup(bc, gs, us);
}
}
} catch (err) {
setError(err.message);
}
}
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
function buildPayload() {
const sel = minUnit || form.unit_id;
let minBase = null;
if (form.min_stock !== "") {
minBase = Number(form.min_stock) * minFactor(sel, form.package_size, units);
}
return {
barcode: form.barcode || null,
name: form.name,
brand: form.brand || null,
image_url: form.image_url || null,
unit_id: form.unit_id === "" ? null : Number(form.unit_id),
package_size: form.package_size === "" ? null : Number(form.package_size),
package_label: form.package_label.trim() || null,
min_stock: minBase,
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
min_stock_in_packages: sel === "package",
group_id: form.group_id === "" ? null : Number(form.group_id),
};
}
async function save(e) {
e.preventDefault();
setError(null);
setBusy(true);
try {
if (isNew) {
const created = await api.createProduct(buildPayload());
navigate(`/products/${created.id}`);
} else {
await api.updateProduct(id, buildPayload());
setInfo("Gespeichert.");
setProduct(await api.getProduct(id));
setLots(await api.listLots(id));
}
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
async function remove() {
if (!confirm("Produkt wirklich löschen? Alle Chargen gehen verloren.")) return;
try {
await api.deleteProduct(id);
navigate("/products");
} catch (err) {
setError(err.message);
}
}
const readOnly = !isAdmin;
const BASE_SHORT_OF_KIND = { count: "Stk", weight: "g", volume: "ml" };
const baseShort = selectedUnit
? BASE_SHORT_OF_KIND[selectedUnit.kind]
: product ? unitShort(product.base_unit) : "";
return (
<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>}
{info && <div className="alert info"><Icon name="check" size={16} />{info}</div>}
<div className="grid-2">
<form className="card" onSubmit={save}>
<div className="field-inline">
<label className="grow">
Barcode
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
</label>
{isAdmin && (
<button type="button" className="btn" onClick={() => runLookup(form.barcode, groups, units)}>
<Icon name="search" size={16} />Nachschlagen
</button>
)}
</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 className="row">
<label className="grow">
Einheit
<select value={form.unit_id} onChange={(e) => set("unit_id", e.target.value)} disabled={readOnly} required>
<option value=""> wählen </option>
{KIND_ORDER.filter((k) => units.some((u) => u.kind === k)).map((k) => (
<optgroup key={k} label={KIND_LABEL[k]}>
{units.filter((u) => u.kind === k).map((u) => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</optgroup>
))}
</select>
</label>
<label className="grow">
Packungsgröße{baseShort ? ` (in ${baseShort})` : ""}
<input type="number" step="any" value={form.package_size}
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly} placeholder="z.B. 500" />
</label>
</div>
<div className="row">
<label className="grow">
Bezeichnung der Einheit
<select value={form.package_label} onChange={(e) => set("package_label", e.target.value)}
disabled={readOnly}>
<option value="">Packung (Standard)</option>
{PACKAGE_LABELS.map((l) => <option key={l} value={l}>{l}</option>)}
{form.package_label && !PACKAGE_LABELS.includes(form.package_label) && (
<option value={form.package_label}>{form.package_label}</option>
)}
</select>
</label>
<div className="grow" />
</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">
Gruppe
<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>
{isAdmin && (
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
{!isNew && (
<button type="button" className="btn danger" onClick={remove}>
<Icon name="trash" size={16} />Löschen
</button>
)}
</div>
)}
{readOnly && <p className="muted small mt-0">Nur Administratoren können Produkte bearbeiten.</p>}
</form>
{!isNew && (
<LotsCard
product={product}
lots={lots}
baseShort={baseShort}
warnDays={warnDays}
isAdmin={isAdmin}
imageUrl={form.image_url}
onChanged={async () => {
setLots(await api.listLots(id));
setProduct(await api.getProduct(id));
}}
onError={setError}
/>
)}
</div>
</div>
);
}
/** Chargen-Karte mit Bearbeiten/Löschen. Bearbeitet wird in der Produkteinheit. */
function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onChanged, onError }) {
const [editId, setEditId] = useState(null);
const [draft, setDraft] = useState({ quantity: "", best_before: "" });
// In welcher Einheit die Menge bearbeitet wird: "unit" oder "package".
const [editUnit, setEditUnit] = useState("unit");
const unitFactor = product?.unit_factor || 1;
const unitName = product?.unit_name || baseShort;
const pkgSize = product?.package_size || 0;
const pkgLabel = product?.package_label || "Packung";
// Leitangabe: Packungen, sobald eine Packungsgröße hinterlegt ist
// sonst die Produkteinheit. Darunter steht die jeweils andere Angabe klein.
const primaryFactor = pkgSize > 0 ? pkgSize : unitFactor;
const primaryLabel = pkgSize > 0 ? pkgLabel : unitName;
function secondary(qty) {
if (pkgSize > 0) return `${fmt(qty / unitFactor)} ${unitName}`;
return unitFactor !== 1 ? `${fmt(qty)} ${baseShort}` : "";
}
// Faktor der aktuell im Editor gewählten Eingabeeinheit.
function editFactorOf(mode) {
return mode === "package" && pkgSize > 0 ? pkgSize : unitFactor;
}
const editFactor = editFactorOf(editUnit);
function startEdit(l) {
// Bearbeiten startet in der Leitangabe (Packungen, falls vorhanden).
const mode = pkgSize > 0 ? "package" : "unit";
setEditId(l.id);
setEditUnit(mode);
setDraft({ quantity: l.quantity / editFactorOf(mode), best_before: l.best_before || "" });
}
function changeEditUnit(newMode) {
if (draft.quantity !== "" && newMode !== editUnit) {
const oldF = editFactorOf(editUnit);
const newF = editFactorOf(newMode);
setDraft((d) => ({ ...d, quantity: String((Number(d.quantity) * oldF) / newF) }));
}
setEditUnit(newMode);
}
async function saveEdit(l) {
try {
await api.updateLot(l.id, {
quantity: Number(draft.quantity) * editFactor,
best_before: draft.best_before || null,
});
setEditId(null);
await onChanged();
} catch (err) {
onError(err.message);
}
}
async function removeLot(l) {
if (!confirm("Charge wirklich löschen?")) return;
try {
await api.deleteLot(l.id);
await onChanged();
} catch (err) {
onError(err.message);
}
}
return (
<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>
)}
{imageUrl && <img className="product-img" src={imageUrl} alt="" />}
<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 className="num">
{editing ? (
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
<input type="number" step="any" min="0" value={draft.quantity}
onChange={(e) => setDraft({ ...draft, quantity: e.target.value })}
style={{ marginTop: 0 }} />
{pkgSize > 0 ? (
<select value={editUnit} onChange={(e) => changeEditUnit(e.target.value)}
style={{ marginTop: 0 }}>
<option value="package">{pkgLabel}</option>
<option value="unit">{unitName}</option>
</select>
) : (
<span className="muted small">{unitName}</span>
)}
</div>
) : (
<>
{fmt(l.quantity / primaryFactor)} {primaryLabel}
{secondary(l.quantity) && (
<div className="muted small">{secondary(l.quantity)}</div>
)}
</>
)}
</td>
<td>
{editing ? (
<input type="date" value={draft.best_before}
onChange={(e) => setDraft({ ...draft, best_before: e.target.value })}
style={{ marginTop: 0, minWidth: 150 }} />
) : (
<>
{l.best_before || ""}
{cls === "row-danger" && <span className="badge danger" style={{ marginLeft: 6 }}>abgelaufen</span>}
{cls === "row-warn" && <span className="badge warn" style={{ marginLeft: 6 }}>{dLeft}d</span>}
</>
)}
</td>
<td 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>
);
}