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

@@ -4,21 +4,25 @@ import { useConfirm } from "../confirm";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { asTree } from "../categoryTree";
import { FIELD_TYPES, fieldTypeLabel } from "../fields";
const MODUS = { food: "Lebensmittel", object: "Gegenstand" };
/**
* Kategorien ordnen die Artikelliste mehr nicht.
*
* Bewusst ohne Bestand, Mindestbestand und EAN-Codes: Das ist Sache der
* Gruppen. Genau dieser Unterschied soll auf den ersten Blick sichtbar sein.
* Kategorien ordnen die Artikelliste und bestimmen die Verwaltungsart:
* „Lebensmittel“ (Chargen + MHD) oder „Gegenstand“ (Menge je Lagerort). Zusätzlich
* lassen sich je Kategorie beliebig viele eigene Felder festlegen, die an ihre
* Unterkategorien vererbt werden.
*/
export default function Categories() {
const confirm = useConfirm();
const { isAdmin } = useAuth();
const [categories, setCategories] = useState([]);
const [form, setForm] = useState({ name: "", parent_id: "" });
const [form, setForm] = useState({ name: "", parent_id: "", tracking: "" });
const [error, setError] = useState(null);
// Eingeklappte Oberkategorien; ihre Unterkategorien werden ausgeblendet.
const [collapsed, setCollapsed] = useState(() => new Set());
// Kategorie, deren Felder gerade verwaltet werden.
const [feldKat, setFeldKat] = useState(null);
function toggle(id) {
setCollapsed((alt) => {
@@ -46,8 +50,9 @@ export default function Categories() {
await api.createCategory({
name: form.name,
parent_id: form.parent_id === "" ? null : Number(form.parent_id),
tracking: form.tracking === "" ? null : form.tracking,
});
setForm({ name: "", parent_id: form.parent_id });
setForm({ name: "", parent_id: form.parent_id, tracking: form.tracking });
load();
} catch (err) {
setError(err.message);
@@ -70,13 +75,14 @@ export default function Categories() {
: "";
const ok = await confirm({
title: `Kategorie „${category.name}“ löschen?`,
message: `Unterkategorien rücken eine Ebene nach oben.${hinweis}`,
message: `Unterkategorien rücken eine Ebene nach oben. Eigene Felder dieser Kategorie werden entfernt.${hinweis}`,
confirmLabel: "Löschen",
danger: true,
});
if (!ok) return;
try {
await api.deleteCategory(category.id);
if (feldKat && feldKat.id === category.id) setFeldKat(null);
load();
} catch (err) {
setError(err.message);
@@ -85,9 +91,6 @@ export default function Categories() {
const tree = asTree(categories);
const nameById = Object.fromEntries(categories.map((c) => [c.id, c.name]));
// Verschachtelung kann mehrstufig sein: eine Zeile ist versteckt, sobald
// irgendein Vorfahre eingeklappt ist nicht nur der direkte Elternteil.
const parentOf = Object.fromEntries(categories.map((c) => [c.id, c.parent_id]));
const childCount = (id) => categories.filter((c) => c.parent_id === id).length;
function versteckt(id) {
@@ -106,9 +109,9 @@ export default function Categories() {
<div>
<h1>Kategorien</h1>
<div className="sub">
Ordnen die Artikelliste für den Überblick zeig mir alle Süßwaren.
Kategorien haben bewusst keinen Bestand und keine EAN-Codes; das
übernehmen die Gruppen.
Ordnen die Artikelliste und legen die Verwaltungsart fest:
Lebensmittel (Chargen + MHD) oder Gegenstand (Menge je Lagerort).
Je Kategorie lassen sich eigene Felder festlegen, die Unterkategorien erben.
</div>
</div>
</div>
@@ -121,6 +124,7 @@ export default function Categories() {
<thead>
<tr>
<th>Kategorie</th>
<th>Art</th>
<th className="num">Artikel</th>
<th></th>
</tr>
@@ -130,7 +134,7 @@ export default function Categories() {
const kinder = childCount(c.id);
const zu = collapsed.has(c.id);
return (
<tr key={c.id}>
<tr key={c.id} className={feldKat && feldKat.id === c.id ? "row-active" : ""}>
<td data-label="Kategorie">
<span className="cell-row" style={{ paddingLeft: c.depth * 22 }}>
{kinder > 0 ? (
@@ -155,8 +159,23 @@ export default function Categories() {
)}
</span>
</td>
<td data-label="Art">
{isAdmin ? (
<select value={c.tracking} style={{ marginTop: 0, minWidth: 130 }}
onChange={(e) => patch(c, { tracking: e.target.value })}>
<option value="food">Lebensmittel</option>
<option value="object">Gegenstand</option>
</select>
) : (
<span className="badge">{MODUS[c.tracking] || c.tracking}</span>
)}
</td>
<td data-label="Artikel" className="num muted">{c.product_count}</td>
<td className="num">
<button className="btn-icon" title="Felder verwalten"
onClick={() => setFeldKat(feldKat && feldKat.id === c.id ? null : c)}>
<Icon name="tag" size={16} />
</button>
{isAdmin && (
<button className="btn-icon danger" onClick={() => remove(c)} title="Löschen">
<Icon name="trash" size={16} />
@@ -167,20 +186,20 @@ export default function Categories() {
);
})}
{categories.length === 0 && (
<tr><td colSpan={3} className="empty">Noch keine Kategorien.</td></tr>
<tr><td colSpan={4} className="empty">Noch keine Kategorien.</td></tr>
)}
</tbody>
</table>
</div>
</div>
<div>
<div className="stack">
{isAdmin && (
<form className="card" onSubmit={add}>
<div className="card-head"><Icon name="tag" /><h2>Neue Kategorie</h2></div>
<label>
Name
<input value={form.name} placeholder="z.B. Süßwaren & Snacks"
<input value={form.name} placeholder="z.B. Elektronik"
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label>
<label>
@@ -195,16 +214,166 @@ export default function Categories() {
))}
</select>
</label>
<label>
Verwaltungsart
<select value={form.tracking}
onChange={(e) => setForm({ ...form, tracking: e.target.value })}>
<option value="">automatisch (erbt bzw. Gegenstand)</option>
<option value="food">Lebensmittel (Chargen + MHD)</option>
<option value="object">Gegenstand (Menge je Lagerort)</option>
</select>
</label>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
<p className="muted small">
Unterkategorien sind beliebig tief möglich. Filterst du später auf eine
Oberkategorie, erscheinen die Artikel ihrer Unterkategorien mit.
Namen lassen sich in der Tabelle direkt ändern.
Unterkategorien erben Art und Felder der Oberkategorie. Auf eine
Oberkategorie gefiltert erscheinen auch die Artikel ihrer Unterkategorien.
</p>
</form>
)}
{feldKat && (
<CategoryFields
key={feldKat.id}
category={feldKat}
isAdmin={isAdmin}
onClose={() => setFeldKat(null)}
/>
)}
</div>
</div>
</div>
);
}
const EMPTY_FIELD = { label: "", field_type: "text", unit: "", options: "", required: false };
/** Verwaltung der eigenen Felder einer Kategorie (inkl. der geerbten zur Info). */
function CategoryFields({ category, isAdmin, onClose }) {
const confirm = useConfirm();
const [fields, setFields] = useState([]);
const [form, setForm] = useState(EMPTY_FIELD);
const [error, setError] = useState(null);
async function load() {
try {
setFields(await api.categoryFields(category.id));
} catch (err) {
setError(err.message);
}
}
useEffect(() => { load(); }, [category.id]);
async function add(e) {
e.preventDefault();
setError(null);
try {
await api.createFieldDefinition({
category_id: category.id,
label: form.label,
field_type: form.field_type,
unit: form.field_type === "number" ? (form.unit || null) : null,
options: form.field_type === "select"
? form.options.split(",").map((o) => o.trim()).filter(Boolean)
: null,
required: form.required,
});
setForm(EMPTY_FIELD);
load();
} catch (err) {
setError(err.message);
}
}
async function remove(field) {
const ok = await confirm({
title: `Feld „${field.label}“ löschen?`,
message: "Die zu diesem Feld erfassten Werte gehen an allen Artikeln verloren.",
confirmLabel: "Löschen",
danger: true,
});
if (!ok) return;
try {
await api.deleteFieldDefinition(field.id);
load();
} catch (err) {
setError(err.message);
}
}
return (
<div className="card">
<div className="card-head">
<Icon name="tag" />
<h2>Felder: {category.name}</h2>
<button className="btn-icon" style={{ marginLeft: "auto" }} title="Schließen" onClick={onClose}>
<Icon name="close" size={16} />
</button>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{fields.length === 0 ? (
<p className="muted small">Noch keine Felder für diese Kategorie.</p>
) : (
<ul className="clean-list">
{fields.map((f) => (
<li key={f.id} className="cell-row">
<span className="strong">{f.label}</span>
<span className="badge nowrap">{fieldTypeLabel(f.field_type)}</span>
{f.field_type === "number" && f.unit && <span className="muted small">{f.unit}</span>}
{f.required && <span className="badge warn nowrap">Pflicht</span>}
{f.inherited ? (
<span className="badge nowrap" title="stammt aus einer Oberkategorie">geerbt</span>
) : (
isAdmin && (
<button className="btn-icon danger" style={{ marginLeft: "auto" }}
title="Feld löschen" onClick={() => remove(f)}>
<Icon name="trash" size={15} />
</button>
)
)}
</li>
))}
</ul>
)}
{isAdmin && (
<form onSubmit={add} className="stack" style={{ marginTop: "var(--sp-3)" }}>
<label>
Feldname
<input value={form.label} placeholder="z.B. Kapazität"
onChange={(e) => setForm({ ...form, label: e.target.value })} required />
</label>
<label>
Typ
<select value={form.field_type}
onChange={(e) => setForm({ ...form, field_type: e.target.value })}>
{FIELD_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</label>
{form.field_type === "number" && (
<label>
Einheit (optional)
<input value={form.unit} placeholder="z.B. mAh"
onChange={(e) => setForm({ ...form, unit: e.target.value })} />
</label>
)}
{form.field_type === "select" && (
<label>
Auswahlmöglichkeiten (mit Komma trennen)
<input value={form.options} placeholder="z.B. S, M, L, XL"
onChange={(e) => setForm({ ...form, options: e.target.value })} />
</label>
)}
<label className="check-inline">
<input type="checkbox" checked={form.required}
onChange={(e) => setForm({ ...form, required: e.target.checked })} />
<span>Pflichtfeld</span>
</label>
<button className="btn primary"><Icon name="plus" size={16} />Feld hinzufügen</button>
</form>
)}
</div>
);
}

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

131
web/src/pages/Shops.jsx Normal file
View File

@@ -0,0 +1,131 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import { useConfirm } from "../confirm";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
/**
* Shops / Bezugsquellen: verwaltbare Liste für „gekauft bei“ an Gegenständen.
* Am Artikel ist die Auswahl optional (unbekannt / mehrere Herkünfte = leer).
*/
export default function Shops() {
const confirm = useConfirm();
const { isAdmin } = useAuth();
const [shops, setShops] = useState([]);
const [form, setForm] = useState({ name: "", website: "" });
const [error, setError] = useState(null);
async function load() {
try {
setShops(await api.listShops());
} catch (err) {
setError(err.message);
}
}
useEffect(() => { load(); }, []);
async function add(e) {
e.preventDefault();
setError(null);
try {
await api.createShop({ name: form.name, website: form.website || null });
setForm({ name: "", website: "" });
load();
} catch (err) {
setError(err.message);
}
}
async function patch(shop, body) {
setError(null);
try {
await api.updateShop(shop.id, body);
load();
} catch (err) {
setError(err.message);
}
}
async function remove(shop) {
const hinweis = shop.product_count
? ` ${shop.product_count} Artikel verlieren die Bezugsquelle, bleiben aber erhalten.`
: "";
const ok = await confirm({
title: `Shop „${shop.name}“ löschen?`,
message: `Die Liste ist nur eine Auswahlhilfe.${hinweis}`,
confirmLabel: "Löschen",
danger: true,
});
if (!ok) return;
try {
await api.deleteShop(shop.id);
load();
} catch (err) {
setError(err.message);
}
}
return (
<div>
<div className="page-head">
<div>
<h1>Shops</h1>
<div className="sub">
Bezugsquellen für gekauft bei an Gegenständen einmal anlegen, überall auswählbar.
</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="card form-narrow">
{isAdmin && (
<form onSubmit={add}>
<div className="row">
<label className="grow">
Name
<input placeholder="z.B. MediaMarkt" value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label>
<label className="grow">
Website (optional)
<input placeholder="https://…" value={form.website}
onChange={(e) => setForm({ ...form, website: e.target.value })} />
</label>
</div>
<button className="btn primary"><Icon name="plus" size={16} />Hinzufügen</button>
</form>
)}
<ul className="simple-list">
{shops.map((s) => (
<li key={s.id}>
<span style={{ display: "flex", alignItems: "center", gap: 8, flex: 1 }}>
<Icon name="cart" size={15} className="muted" />
{isAdmin ? (
<input defaultValue={s.name} style={{ marginTop: 0, minWidth: 140 }}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== s.name) patch(s, { name: v });
}} />
) : <span className="strong">{s.name}</span>}
{s.website && (
<a href={s.website} target="_blank" rel="noreferrer" className="muted small">
{s.website.replace(/^https?:\/\//, "")}
</a>
)}
{s.product_count > 0 && <span className="badge nowrap">{s.product_count} Artikel</span>}
</span>
{isAdmin && (
<button className="btn-icon danger" onClick={() => remove(s)} title="Löschen">
<Icon name="trash" size={16} />
</button>
)}
</li>
))}
{shops.length === 0 && <li className="empty">Noch keine Shops.</li>}
</ul>
</div>
</div>
);
}