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:
@@ -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
157
web/src/pages/MinStock.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user