Web: zentrale Mindestbestand-Liste (Produkte + Gruppen, Gesamt + je Lagerort)

Neue Seite 'Mindestbestaende' (Sidebar): filterbare Tabelle mit je einer Zeile pro Mindestbestand - Gesamt je Produkt/Gruppe und je Lagerort - inline editierbar. Produkt-Gesamtwert wird nicht-destruktiv umgerechnet (Faktor aus dem bestehenden Wert abgeleitet, sonst Artikeleinheit); Lagerort-Bedarfe und Gruppenwerte ueber die vorhandenen Endpunkte. Nur-Lesen fuer Nicht-Admins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 19:00:33 +02:00
parent 912f3dac6f
commit 189b1f8e6b
2 changed files with 160 additions and 0 deletions

View File

@@ -8,6 +8,7 @@ import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
import Products from "./pages/Products"; import Products from "./pages/Products";
import ItemList from "./pages/ItemList"; import ItemList from "./pages/ItemList";
import MinStock from "./pages/MinStock";
import ProductForm from "./pages/ProductForm"; import ProductForm from "./pages/ProductForm";
import CheckIn from "./pages/CheckIn"; import CheckIn from "./pages/CheckIn";
import CheckOut from "./pages/CheckOut"; import CheckOut from "./pages/CheckOut";
@@ -79,6 +80,7 @@ function Sidebar() {
<NavItem to="/gegenstaende" icon="package" label="Gegenstände" /> <NavItem to="/gegenstaende" icon="package" label="Gegenstände" />
<NavItem to="/items" icon="tag" label="Einzelstücke" /> <NavItem to="/items" icon="tag" label="Einzelstücke" />
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" /> <NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
<NavItem to="/mindestbestaende" icon="check" label="Mindestbestände" />
<NavItem to="/history" icon="history" label="Verlauf" /> <NavItem to="/history" icon="history" label="Verlauf" />
{isAdmin && ( {isAdmin && (
<> <>
@@ -151,6 +153,7 @@ export default function App() {
<Route path="/groups" element={<Protected adminOnly wide><Groups /></Protected>} /> <Route path="/groups" element={<Protected adminOnly wide><Groups /></Protected>} />
<Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} /> <Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} />
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} /> <Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
<Route path="/mindestbestaende" element={<Protected wide><MinStock /></Protected>} />
<Route path="/history" element={<Protected><History /></Protected>} /> <Route path="/history" element={<Protected><History /></Protected>} />
<Route path="/shops" element={<Protected adminOnly><Shops /></Protected>} /> <Route path="/shops" element={<Protected adminOnly><Shops /></Protected>} />
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} /> <Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />

157
web/src/pages/MinStock.jsx Normal file
View File

@@ -0,0 +1,157 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
import { categoryInfoMap } from "../categoryPath";
import { locationPathById } from "../locationPath";
import { fmt } from "../units";
// Artikeleinheit eines Produkts (Gebinde oder Produkteinheit) wie in der Produktliste.
const articleUnit = (p) => (p.package_size && p.package_size > 0 ? p.package_size : (p.unit_factor || 1));
const articleLabel = (p) => p.package_label || p.unit_name || "Stück";
/**
* Zentrale Liste aller Mindestbestände: je Produkt/Gruppe der Gesamt-Wert und die
* Bedarfe je Lagerort, inline editierbar. Ergänzt die einzelnen Produkt-/Gruppen-
* Formulare um einen Überblick an einem Ort.
*/
export default function MinStock() {
const { isAdmin } = useAuth();
const [products, setProducts] = useState([]);
const [groups, setGroups] = useState([]);
const [locations, setLocations] = useState([]);
const [categories, setCategories] = useState([]);
const [error, setError] = useState(null);
async function load() {
try {
const [ps, gs, ls, cs] = await Promise.all([
api.listProducts("", ""), api.listGroups(), api.listLocations(), api.listCategories(),
]);
setProducts(ps); setGroups(gs); setLocations(ls); setCategories(cs);
} catch (err) { setError(err.message); }
}
useEffect(() => { load(); }, []);
const catInfo = useMemo(() => categoryInfoMap(categories), [categories]);
const rows = useMemo(() => {
const out = [];
for (const p of products) {
out.push({
key: `p:${p.id}:global`, kind: "product", scope: "global", entity: p,
name: p.name, catId: p.category_id, ort: "(Gesamt)",
soll: p.min_stock_display, unit: p.min_stock_unit_label || articleLabel(p),
bestand: p.stock / articleUnit(p), bestandUnit: articleLabel(p),
});
for (const l of (p.location_min_stocks || [])) {
out.push({
key: `p:${p.id}:${l.location_id}`, kind: "product", scope: "loc", entity: p,
locId: l.location_id, name: p.name, catId: p.category_id,
ort: locationPathById(l.location_id, locations) || "?",
soll: l.min_stock, unit: articleLabel(p), bestand: null,
});
}
}
for (const g of groups) {
out.push({
key: `g:${g.id}:global`, kind: "group", scope: "global", entity: g,
name: g.name, catId: null, ort: "(Gesamt)",
soll: g.min_stock, unit: g.min_stock_unit_name || "", bestand: g.stock, bestandUnit: g.min_stock_unit_name || "",
});
for (const l of (g.location_min_stocks || [])) {
out.push({
key: `g:${g.id}:${l.location_id}`, kind: "group", scope: "loc", entity: g,
locId: l.location_id, name: g.name, catId: null,
ort: locationPathById(l.location_id, locations) || "?",
soll: l.min_stock, unit: g.min_stock_unit_name || "", bestand: null,
});
}
}
return out;
}, [products, groups, locations]);
// Eine Lagerort-Bedarfsliste mit einem geänderten/entfernten Eintrag neu bauen.
const mergeLoc = (list, locId, num) => (list || [])
.map((e) => ({ location_id: e.location_id, min_stock: e.location_id === locId ? num : e.min_stock }))
.filter((e) => e.min_stock != null && e.min_stock > 0);
async function saveSoll(row, value) {
const roh = String(value).trim().replace(",", ".");
const num = roh === "" ? null : Number(roh);
if (num !== null && (Number.isNaN(num) || num < 0)) return;
if (num === (row.soll ?? null)) return;
try {
if (row.kind === "product" && row.scope === "global") {
const p = row.entity;
// Faktor aus dem bestehenden Wert ableiten (erhält die erfasste Einheit);
// ohne bestehenden Wert in Artikeleinheiten setzen.
if (p.min_stock != null && p.min_stock_display) {
const faktor = p.min_stock / p.min_stock_display;
await api.updateProduct(p.id, { min_stock: num == null ? null : Math.round(num * faktor) });
} else {
await api.updateProduct(p.id, {
min_stock: num == null ? null : Math.round(num * articleUnit(p)),
min_stock_in_packages: (p.package_size || 0) > 0,
min_stock_unit_id: null,
});
}
} else if (row.kind === "product" && row.scope === "loc") {
await api.setProductLocationMinStock(row.entity.id, mergeLoc(row.entity.location_min_stocks, row.locId, num));
} else if (row.kind === "group" && row.scope === "global") {
await api.updateGroup(row.entity.id, { min_stock: num });
} else if (row.kind === "group" && row.scope === "loc") {
await api.setGroupLocationMinStock(row.entity.id, mergeLoc(row.entity.location_min_stocks, row.locId, num));
}
await load();
} catch (err) { setError(err.message); }
}
const columns = [
{ key: "art", header: "Art", width: 96, filterText: (r) => (r.kind === "group" ? "Gruppe" : "Produkt"),
render: (r) => <span className="badge nowrap">{r.kind === "group" ? "Gruppe" : "Produkt"}</span> },
{ key: "name", header: "Name", grow: true, min: 180,
filterText: (r) => r.name, sortValue: (r) => r.name,
render: (r) => <span className="strong">{r.name}</span> },
{ key: "category", header: "Kategorie", width: 200,
filterText: (r) => (r.catId != null ? (catInfo.get(r.catId)?.path || "") : ""),
filterValues: (r) => (r.catId != null ? (catInfo.get(r.catId)?.tokens || [""]) : [""]),
filterOptionLabel: (v) => (v === "" ? "(Leere)" : <CategoryPathLabel parts={pathParts(v)} />),
render: (r) => (r.catId != null
? <CategoryPathLabel parts={catInfo.get(r.catId)?.parts} fallback="" />
: <span className="muted"></span>) },
{ key: "ort", header: "Ort", width: 220, filterText: (r) => r.ort, sortValue: (r) => r.ort,
render: (r) => (r.scope === "global" ? <span className="muted">{r.ort}</span> : r.ort) },
{ key: "soll", header: "Mindestbestand", width: 150,
sortValue: (r) => r.soll ?? -1,
render: (r) => (isAdmin ? (
<input type="number" step="any" min="0" style={{ marginTop: 0, minWidth: 80 }}
key={r.soll ?? "none"} defaultValue={r.soll ?? ""}
onBlur={(e) => saveSoll(r, e.target.value)} />
) : (r.soll != null ? fmt(r.soll) : "")) },
{ key: "unit", header: "Einheit", width: 120, filterText: (r) => r.unit || "",
render: (r) => <span className="muted">{r.unit || ""}</span> },
{ key: "bestand", header: "Bestand", width: 120, align: "num",
render: (r) => (r.bestand != null ? `${fmt(r.bestand)} ${r.bestandUnit || ""}` : <span className="muted"></span>) },
];
return (
<div>
<div className="page-head">
<div>
<h1>Mindestbestände</h1>
<div className="sub">
Gesamt- und Lagerort-Bedarfe von Produkten und Gruppen an einem Ort{isAdmin ? " Werte direkt editierbar" : ""}.
</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="card">
<DataTable id="min-stock" columns={columns} rows={rows}
getRowKey={(r) => r.key} empty="Noch keine Mindestbestände." />
</div>
</div>
);
}