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:
@@ -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>} />
|
||||
|
||||
@@ -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"),
|
||||
|
||||
295
web/src/components/ObjektBestand.jsx
Normal file
295
web/src/components/ObjektBestand.jsx
Normal 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
99
web/src/fields.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
131
web/src/pages/Shops.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user