diff --git a/web/src/App.jsx b/web/src/App.jsx index c499374..9186b27 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -8,6 +8,7 @@ import Login from "./pages/Login"; import Dashboard from "./pages/Dashboard"; import Products from "./pages/Products"; import ItemList from "./pages/ItemList"; +import MinStock from "./pages/MinStock"; import ProductForm from "./pages/ProductForm"; import CheckIn from "./pages/CheckIn"; import CheckOut from "./pages/CheckOut"; @@ -79,6 +80,7 @@ function Sidebar() { + {isAdmin && ( <> @@ -151,6 +153,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/pages/MinStock.jsx b/web/src/pages/MinStock.jsx new file mode 100644 index 0000000..f337ffa --- /dev/null +++ b/web/src/pages/MinStock.jsx @@ -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) => {r.kind === "group" ? "Gruppe" : "Produkt"} }, + { key: "name", header: "Name", grow: true, min: 180, + filterText: (r) => r.name, sortValue: (r) => r.name, + render: (r) => {r.name} }, + { 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)" : ), + render: (r) => (r.catId != null + ? + : ) }, + { key: "ort", header: "Ort", width: 220, filterText: (r) => r.ort, sortValue: (r) => r.ort, + render: (r) => (r.scope === "global" ? {r.ort} : r.ort) }, + { key: "soll", header: "Mindestbestand", width: 150, + sortValue: (r) => r.soll ?? -1, + render: (r) => (isAdmin ? ( + saveSoll(r, e.target.value)} /> + ) : (r.soll != null ? fmt(r.soll) : "–")) }, + { key: "unit", header: "Einheit", width: 120, filterText: (r) => r.unit || "", + render: (r) => {r.unit || "–"} }, + { key: "bestand", header: "Bestand", width: 120, align: "num", + render: (r) => (r.bestand != null ? `${fmt(r.bestand)} ${r.bestandUnit || ""}` : ) }, + ]; + + return ( +
+
+
+

Mindestbestände

+
+ Gesamt- und Lagerort-Bedarfe von Produkten und Gruppen an einem Ort{isAdmin ? " – Werte direkt editierbar" : ""}. +
+
+
+ {error &&
{error}
} +
+ r.key} empty="Noch keine Mindestbestände." /> +
+
+ ); +}