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

@@ -12,6 +12,7 @@ import CheckIn from "./pages/CheckIn";
import CheckOut from "./pages/CheckOut";
import Groups from "./pages/Groups";
import Categories from "./pages/Categories";
import Shops from "./pages/Shops";
import Locations from "./pages/Locations";
import PackageTypes from "./pages/PackageTypes";
import Units from "./pages/Units";
@@ -80,6 +81,7 @@ function Sidebar() {
<>
<div className="nav-section">Verwaltung</div>
<NavItem to="/locations" icon="location" label="Lagerorte" />
<NavItem to="/shops" icon="cart" label="Shops" />
<NavItem to="/units" icon="box" label="Einheiten" />
<NavItem to="/package-types" icon="package" label="Gebinde" />
<NavItem to="/users" icon="users" label="Benutzer" />
@@ -136,6 +138,7 @@ export default function App() {
<Route path="/categories" element={<Protected><Categories /></Protected>} />
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
<Route path="/history" element={<Protected><History /></Protected>} />
<Route path="/shops" element={<Protected adminOnly><Shops /></Protected>} />
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
<Route path="/units" element={<Protected adminOnly><Units /></Protected>} />
<Route path="/package-types" element={<Protected adminOnly><PackageTypes /></Protected>} />

View File

@@ -152,6 +152,10 @@ export const api = {
checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
checkInBatch: (body) => request("/stock/checkin/batch", { method: "POST", body }),
checkOut: (body) => request("/stock/checkout", { method: "POST", body }),
// Gegenstände: umlagern (ohne Grund) und entfernen (Grund Pflicht)
relocateStock: (body) => request("/stock/relocate", { method: "POST", body }),
removeStock: (body) => request("/stock/remove", { method: "POST", body }),
productRemovals: (id) => request(`/products/${id}/removals`),
listLots: (productId) =>
request(`/lots${productId ? `?product_id=${productId}` : ""}`),
updateLot: (id, body) => request(`/lots/${id}`, { method: "PATCH", body }),
@@ -187,11 +191,27 @@ export const api = {
createLocation: (body) => request("/locations", { method: "POST", body }),
deleteLocation: (id) => request(`/locations/${id}`, { method: "DELETE" }),
// Kategorien: reine Ordnungshilfe, verschachtelbar (siehe Gruppen fuer Bestaende)
// Kategorien: Ordnungshilfe + Verwaltungsart (food/object), verschachtelbar
listCategories: () => request("/categories"),
createCategory: (body) => request("/categories", { method: "POST", body }),
updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }),
deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }),
// Effektive (vererbte) Felder einer Kategorie für das Artikelformular.
categoryFields: (id) => request(`/categories/${id}/fields`),
// Selbst definierte Felder je Kategorie
listFieldDefinitions: (categoryId) =>
request(`/field-definitions${categoryId != null ? `?category_id=${categoryId}` : ""}`),
createFieldDefinition: (body) => request("/field-definitions", { method: "POST", body }),
updateFieldDefinition: (id, body) =>
request(`/field-definitions/${id}`, { method: "PATCH", body }),
deleteFieldDefinition: (id) => request(`/field-definitions/${id}`, { method: "DELETE" }),
// Shops / Bezugsquellen (nur für Gegenstände)
listShops: () => request("/shops"),
createShop: (body) => request("/shops", { method: "POST", body }),
updateShop: (id, body) => request(`/shops/${id}`, { method: "PATCH", body }),
deleteShop: (id) => request(`/shops/${id}`, { method: "DELETE" }),
// Gebinde (Einzahl/Mehrzahl): "Glas" -> "Gläser"
listPackageTypes: () => request("/package-types"),

View File

@@ -0,0 +1,295 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "./Icon";
import { fmt } from "../units";
// Gründe zum Entfernen (Wert = Backend-Enum, Label = Anzeige).
export const REASONS = [
{ value: "broken", label: "kaputt" },
{ value: "lost", label: "verloren" },
{ value: "given_away", label: "verschenkt" },
{ value: "sold", label: "verkauft" },
{ value: "used_up", label: "aufgebraucht" },
{ value: "other", label: "sonstiges" },
];
const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v;
/**
* Bestand eines Gegenstands je Lagerort das Gegenstück zur Chargen-Karte der
* Lebensmittel. Erlaubt Hinzufügen, Umlagern (ohne Grund) und Entfernen (mit
* Pflicht-Grund) und zeigt eine kleine Entnahme-Statistik.
*/
export default function ObjektBestand({ product, lots, locations, isAdmin, onChanged, onError }) {
const confirm = useConfirm();
const toast = useToast();
const einheit = product?.unit_name || "Stück";
const nameById = Object.fromEntries((locations || []).map((l) => [l.id, l.name]));
const ortName = (id) => (id == null ? "Ohne Lagerort" : nameById[id] || `Ort ${id}`);
const [addForm, setAddForm] = useState({ location_id: "", quantity: "" });
const [move, setMove] = useState(null); // { from, to, quantity } | null
const [remove, setRemove] = useState(null); // { location_id, quantity, reason, note } | null
const [editId, setEditId] = useState(null);
const [editQty, setEditQty] = useState("");
const [removals, setRemovals] = useState({ stats: [], history: [] });
async function ladeRemovals() {
try {
setRemovals(await api.productRemovals(product.id));
} catch { /* Statistik ist optional */ }
}
useEffect(() => { ladeRemovals(); /* eslint-disable-next-line */ }, [product.id, lots]);
async function nachAktion() {
await onChanged();
await ladeRemovals();
}
async function hinzufuegen(e) {
e.preventDefault();
const menge = Number(addForm.quantity);
if (!menge || menge <= 0) return;
try {
await api.checkIn({
product_id: product.id,
quantity: menge,
unit: einheit,
location_id: addForm.location_id === "" ? null : Number(addForm.location_id),
});
setAddForm({ location_id: addForm.location_id, quantity: "" });
await nachAktion();
} catch (err) { onError(err.message); }
}
async function speichereMenge(lot) {
const menge = Number(editQty);
try {
await api.updateLot(lot.id, { quantity: menge });
setEditId(null);
await nachAktion();
} catch (err) { onError(err.message); }
}
async function loescheZeile(lot) {
const ok = await confirm({
title: `Bestand in „${ortName(lot.location_id)}“ entfernen?`,
message: "Die Menge wird als Korrektur im Verlauf protokolliert.",
confirmLabel: "Entfernen", danger: true,
});
if (!ok) return;
try {
await api.deleteLot(lot.id);
await nachAktion();
} catch (err) { onError(err.message); }
}
async function umlagern(e) {
e.preventDefault();
const menge = Number(move.quantity);
if (!menge || menge <= 0) return;
try {
await api.relocateStock({
product_id: product.id,
quantity: menge,
from_location_id: move.from === "" ? null : Number(move.from),
to_location_id: move.to === "" ? null : Number(move.to),
});
setMove(null);
toast("Umgelagert.");
await nachAktion();
} catch (err) { onError(err.message); }
}
async function entfernen(e) {
e.preventDefault();
const menge = Number(remove.quantity);
if (!menge || menge <= 0) return;
try {
await api.removeStock({
product_id: product.id,
quantity: menge,
location_id: remove.location_id === "" ? null : Number(remove.location_id),
reason: remove.reason,
note: remove.note || null,
});
setRemove(null);
toast("Aus dem Bestand entfernt.");
await nachAktion();
} catch (err) { onError(err.message); }
}
const gesamt = lots.reduce((s, l) => s + l.quantity, 0);
return (
<section className="card">
<div className="card-head">
<Icon name="location" /><h2>Bestand je Lagerort</h2>
<span className="muted small" style={{ marginLeft: "auto" }}>
gesamt {fmt(gesamt)} {einheit}
</span>
</div>
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Lagerort</th><th className="num">Menge</th><th></th></tr>
</thead>
<tbody>
{lots.map((l) => (
<tr key={l.id}>
<td data-label="Lagerort">{ortName(l.location_id)}</td>
<td data-label="Menge" className="num">
{editId === l.id ? (
<input type="number" step="any" min="0" value={editQty} style={{ marginTop: 0, width: 90 }}
onChange={(e) => setEditQty(e.target.value)} />
) : (
<>{fmt(l.quantity)} <span className="muted small">{einheit}</span></>
)}
</td>
<td className="num">
{isAdmin && (editId === l.id ? (
<div className="btn-pair">
<button className="btn sm primary" onClick={() => speichereMenge(l)}>OK</button>
<button className="btn sm" onClick={() => setEditId(null)}>Abbrechen</button>
</div>
) : (
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
<button className="btn-icon" title="Menge korrigieren"
onClick={() => { setEditId(l.id); setEditQty(String(l.quantity)); }}>
<Icon name="edit" size={16} />
</button>
<button className="btn-icon" title="Von hier umlagern"
onClick={() => setMove({ from: l.location_id ?? "", to: "", quantity: "" })}>
<Icon name="checkout" size={16} />
</button>
<button className="btn-icon" title="Mit Grund entfernen"
onClick={() => setRemove({ location_id: l.location_id ?? "", quantity: "", reason: "broken", note: "" })}>
<Icon name="trash" size={16} />
</button>
</div>
))}
</td>
</tr>
))}
{lots.length === 0 && <tr><td colSpan={3} className="empty">Noch kein Bestand.</td></tr>}
</tbody>
</table>
</div>
{isAdmin && (
<form onSubmit={hinzufuegen} className="row" style={{ marginTop: "var(--sp-3)" }}>
<label className="grow">
Lagerort
<select value={addForm.location_id}
onChange={(e) => setAddForm({ ...addForm, location_id: e.target.value })}>
<option value=""> ohne Lagerort </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label style={{ width: 120 }}>
Menge
<input type="number" step="any" min="0" value={addForm.quantity}
onChange={(e) => setAddForm({ ...addForm, quantity: e.target.value })} placeholder="z.B. 3" />
</label>
<button className="btn primary" style={{ alignSelf: "end" }}>
<Icon name="plus" size={16} />Hinzufügen
</button>
</form>
)}
{/* Umlagern-Dialog */}
{isAdmin && move && (
<form onSubmit={umlagern} className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
<div className="card-head"><Icon name="checkout" /><h3>Umlagern</h3>
<button type="button" className="btn-icon" style={{ marginLeft: "auto" }}
onClick={() => setMove(null)}><Icon name="close" size={16} /></button>
</div>
<div className="row">
<label className="grow">Von
<select value={move.from} onChange={(e) => setMove({ ...move, from: e.target.value })}>
<option value=""> ohne Lagerort </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label className="grow">Nach
<select value={move.to} onChange={(e) => setMove({ ...move, to: e.target.value })}>
<option value=""> ohne Lagerort </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label style={{ width: 110 }}>Menge
<input type="number" step="any" min="0" value={move.quantity}
onChange={(e) => setMove({ ...move, quantity: e.target.value })} />
</label>
</div>
<button className="btn primary">Umlagern</button>
</form>
)}
{/* Entfernen-Dialog (Grund Pflicht) */}
{isAdmin && remove && (
<form onSubmit={entfernen} className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
<div className="card-head"><Icon name="trash" /><h3>Aus Bestand entfernen</h3>
<button type="button" className="btn-icon" style={{ marginLeft: "auto" }}
onClick={() => setRemove(null)}><Icon name="close" size={16} /></button>
</div>
<div className="row">
<label className="grow">Lagerort
<select value={remove.location_id}
onChange={(e) => setRemove({ ...remove, location_id: e.target.value })}>
<option value=""> ohne Lagerort </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label style={{ width: 110 }}>Menge
<input type="number" step="any" min="0" value={remove.quantity}
onChange={(e) => setRemove({ ...remove, quantity: e.target.value })} />
</label>
<label className="grow">Grund
<select value={remove.reason}
onChange={(e) => setRemove({ ...remove, reason: e.target.value })}>
{REASONS.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</label>
</div>
<label>Notiz (optional)
<input value={remove.note} placeholder="z.B. runtergefallen"
onChange={(e) => setRemove({ ...remove, note: e.target.value })} />
</label>
<button className="btn danger">Entfernen</button>
</form>
)}
{/* Entnahme-Statistik */}
{removals.stats.length > 0 && (
<div style={{ marginTop: "var(--sp-3)" }}>
<div className="feldkopf" style={{ marginBottom: "var(--sp-2)" }}>
<span className="muted small">Bereits entnommen:</span>
{removals.stats.map((s) => (
<span key={s.reason} className="badge nowrap" title={`${s.count}×`}>
{reasonLabel(s.reason)}: {fmt(s.quantity)}
</span>
))}
</div>
{removals.history.length > 0 && (
<ul className="clean-list">
{removals.history.slice(0, 5).map((h, i) => (
<li key={i}>
<span className="badge nowrap">{reasonLabel(h.reason)}</span>
<span>{fmt(h.quantity)} {einheit}</span>
{h.location_name && <span className="muted small">aus {h.location_name}</span>}
{h.note && <span className="muted small"> {h.note}</span>}
<span className="muted small" style={{ marginLeft: "auto" }}>
{new Date(h.created_at).toLocaleDateString("de-DE")}
</span>
</li>
))}
</ul>
)}
</div>
)}
</section>
);
}

99
web/src/fields.jsx Normal file
View File

@@ -0,0 +1,99 @@
// Selbst definierte Felder: Typen-Registry und die passenden Eingabe-Elemente.
// Bewusst schlank gehalten und aus einer Konfiguration heraus gerendert analog
// zur Karten-Registry des Dashboards (dashboard/cards.jsx).
// Auswahl im Feld-Verwaltungs-Dialog (Reihenfolge = Anzeige).
export const FIELD_TYPES = [
{ value: "text", label: "Text (einzeilig)" },
{ value: "textarea", label: "Text (mehrzeilig)" },
{ value: "number", label: "Zahl (mit Einheit)" },
{ value: "date", label: "Datum" },
{ value: "select", label: "Auswahlliste" },
{ value: "boolean", label: "Ja/Nein" },
];
export function fieldTypeLabel(value) {
return FIELD_TYPES.find((t) => t.value === value)?.label || value;
}
/** Eine einzelne Eingabe passend zum Feldtyp. */
export function FieldInput({ field, value, onChange, disabled }) {
const v = value ?? "";
switch (field.field_type) {
case "textarea":
return (
<textarea rows={3} value={v} disabled={disabled}
onChange={(e) => onChange(e.target.value)} />
);
case "number":
return (
<span className="field-inline">
<input type="number" step="any" value={v} disabled={disabled}
onChange={(e) => onChange(e.target.value)} />
{field.unit && <span className="muted">{field.unit}</span>}
</span>
);
case "date":
return (
<input type="date" value={v} disabled={disabled}
onChange={(e) => onChange(e.target.value)} />
);
case "select":
return (
<select value={v} disabled={disabled}
onChange={(e) => onChange(e.target.value)}>
<option value=""> keine </option>
{(field.options || []).map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
);
case "boolean":
return (
<label className="check-inline">
<input type="checkbox" checked={v === "true"} disabled={disabled}
onChange={(e) => onChange(e.target.checked ? "true" : "false")} />
<span className="muted">Ja</span>
</label>
);
default:
return (
<input value={v} disabled={disabled}
onChange={(e) => onChange(e.target.value)} />
);
}
}
/**
* Rendert die effektiven (vererbten) Felder einer Kategorie als Formularblock.
*
* ``values`` ist eine Map { String(feld_id): Wert }, wie sie das Backend liefert
* und erwartet. ``onChange(feldId, wert)`` meldet Änderungen zurück.
*/
export function DynamicFields({ fields, values, onChange, disabled }) {
if (!fields || fields.length === 0) return null;
return (
<div className="stack">
{fields.map((f) => (
<label key={f.id}>
<span className="feldkopf">
{f.label}
{f.field_type === "number" && f.unit ? ` (${f.unit})` : ""}
{f.required ? " *" : ""}
{f.inherited && (
<span className="badge nowrap" title={"geerbt von einer Oberkategorie"}>
geerbt
</span>
)}
</span>
<FieldInput
field={f}
value={values[String(f.id)]}
onChange={(val) => onChange(String(f.id), val)}
disabled={disabled}
/>
</label>
))}
</div>
);
}

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>
);
}

View File

@@ -839,3 +839,22 @@ select.zeitraum:hover { color: var(--text); }
.aktion-zeile { display: flex; gap: 8px; align-items: center; }
.aktion-zeile .btn { flex: 1; justify-content: center; }
.aktion-menge { width: 72px; text-align: right; }
/* ---- Gegenstände (Non-Food): eigene Felder, Bestand je Ort, Umlagern ---- */
.stack { display: flex; flex-direction: column; gap: var(--sp-3); }
.clean-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
.clean-list li { display: flex; align-items: center; gap: var(--sp-2); padding: 6px 8px; background: var(--surface-2); border-radius: 8px; }
.check-inline { display: flex; align-items: center; gap: var(--sp-2); }
.check-inline input { width: auto; margin: 0; }
.feldkopf { display: inline-flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
tr.row-active { background: var(--surface-2); }
/* Bestand je Lagerort */
.ort-zeile { display: flex; align-items: center; gap: var(--sp-2); padding: 6px 0; }
.ort-zeile .name { flex: 1; }
.ort-menge { width: 90px; text-align: right; }
/* Eingebettetes Unter-Panel (Umlagern-/Entfernen-Dialog) */
.card-sub { border: 1px solid var(--border); border-radius: var(--radius); padding: var(--sp-3); background: var(--surface-2); }
.card-sub .card-head { margin-top: 0; }
.card-sub h3 { margin: 0; font-size: 15px; }