Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln

Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende:
Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen
mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder
je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine
verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich
ueber Open Products Facts.

Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in
Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-25 15:33:56 +02:00
parent 580afcc133
commit 5b952524d7
29 changed files with 3116 additions and 115 deletions

View File

@@ -10,6 +10,8 @@ import ProduktBild from "../components/ProduktBild";
import { useToast } from "../toast";
import { useSettings } from "../settings";
import { asTree } from "../categoryTree";
import { DynamicFields } from "../fields";
import ObjektBestand from "../components/ObjektBestand";
import {
daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry,
toMonthInput, unitShort,
@@ -18,7 +20,7 @@ import {
const EMPTY = {
barcode: "", name: "", brand: "", image_url: "",
unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "",
group_id: "", category_id: "",
group_id: "", category_id: "", shop_id: "", product_url: "",
};
// (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes)
@@ -45,6 +47,11 @@ export default function ProductForm() {
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({});
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
// Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit.
@@ -59,10 +66,20 @@ export default function ProductForm() {
const unitFactor = selectedUnit ? selectedUnit.factor : 1;
const unitName = selectedUnit ? selectedUnit.name : "";
// Die gewählte Kategorie bestimmt die Verwaltungsart. Ohne Kategorie: Lebensmittel.
const currentCategory =
categories.find((c) => String(c.id) === String(form.category_id)) || null;
const mode = currentCategory ? currentCategory.tracking : "food";
const isObject = mode === "object";
function set(k, v) {
setForm((f) => ({ ...f, [k]: v }));
}
function setField(fieldId, value) {
setFieldValues((v) => ({ ...v, [fieldId]: value }));
}
function canonicalUnitId(unitList, baseUnit) {
const name = CANONICAL_NAME[baseUnit];
const hit = unitList.find((u) => u.name === name);
@@ -203,13 +220,16 @@ export default function ProductForm() {
useEffect(() => {
async function load() {
try {
const [gs, us, cs, pts] = await Promise.all([
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");
@@ -238,7 +258,10 @@ export default function ProductForm() {
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 || {});
setLots(await api.listLots(id));
} else {
const bc = searchParams.get("barcode");
@@ -255,17 +278,47 @@ export default function ProductForm() {
// 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);
}
return {
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,
@@ -274,7 +327,6 @@ export default function ProductForm() {
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
min_stock_in_packages: sel === "package",
group_id: form.group_id === "" ? null : Number(form.group_id),
category_id: form.category_id === "" ? null : Number(form.category_id),
};
}
@@ -306,7 +358,7 @@ export default function ProductForm() {
async function remove() {
const ok = await confirm({
title: "Produkt löschen?",
message: "Alle Chargen dieses Produkts gehen dabei verloren.",
message: "Der gesamte Bestand dieses Produkts geht dabei verloren.",
confirmLabel: "Löschen",
danger: true,
});
@@ -393,6 +445,7 @@ export default function ProductForm() {
onSchliessen={() => setOffDaten(null)}
/>
)}
{!isObject && (<>
<div className="row">
<label className="grow">
Einheit
@@ -471,22 +524,58 @@ export default function ProductForm() {
</select>
</label>
</div>
</>)}
<div className="row">
<label className="grow">
<span className="tip" title="Nur für den Überblick in der Artikelliste ohne Einfluss auf Bestände.">
<span className="tip" title="Bestimmt zugleich die Verwaltungsart: Gegenstands-Kategorien blenden MHD/Chargen aus und zeigen Menge je Lagerort.">
Kategorie
</span>
<select value={form.category_id} onChange={(e) => set("category_id", e.target.value)}
disabled={readOnly}>
<option value=""> keine </option>
{asTree(categories).map((c) => (
<option key={c.id} value={c.id}>{"— ".repeat(c.depth)}{c.name}</option>
<option key={c.id} value={c.id}>
{"— ".repeat(c.depth)}{c.name}{c.tracking === "object" ? " · Gegenstand" : ""}
</option>
))}
</select>
</label>
<div className="grow" />
</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>
{effFields.length > 0 && (
<div style={{ marginTop: "var(--sp-2)" }}>
<div className="card-head" style={{ marginBottom: "var(--sp-2)" }}>
<Icon name="tag" /><h2>Eigene Felder</h2>
</div>
<DynamicFields fields={effFields} values={fieldValues}
onChange={setField} disabled={readOnly} />
</div>
)}
</>)}
{isAdmin && (
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
@@ -500,7 +589,21 @@ export default function ProductForm() {
{readOnly && <p className="muted small mt-0">Nur Administratoren können Produkte bearbeiten.</p>}
</form>
{!isNew && (
{!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}
@@ -513,7 +616,7 @@ export default function ProductForm() {
}}
onError={setError}
/>
)}
))}
</div>
{/* Unterhalb der beiden Spalten: die Codes braucht man selten, sie sollen