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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user