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"; // Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm, // Milliliter, Stück) – NICHT die Packung. „1 Packung" ist nichtssagend, weil eine // Packung 500 g, 1 kg oder 25 ml sein kann; deshalb rechnen wir alles in die // echte Einheit um. dispFactor = Basiseinheiten je Anzeigeeinheit. const dispFactor = (p) => (p.unit_factor && p.unit_factor > 0 ? p.unit_factor : 1); const dispLabel = (p) => (p.unit_name || "Stück"); // Mindestbestand gibt es nur bei Charge-Artikeln: Lebensmittel und // Verbrauchsgegenstände (bulk). „Menge je Lagerort" und Einzelstücke nicht. const canHaveMin = (p) => p.tracking !== "object" || p.bulk === true; // Artikeleinheit (Packung, sonst Anzeigeeinheit): nur noch nötig, um die je-Lagerort- // Mindestbestände umzurechnen, die in Artikeleinheiten gespeichert sind. const articleUnit = (p) => (p.package_size && p.package_size > 0 ? p.package_size : (p.unit_factor || 1)); /** * 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. */ const EMPTY_DRAFT = { kind: "product", targetId: "", scope: "global", locId: "", menge: "" }; 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); const [loading, setLoading] = useState(true); // „+ hinzufügen"-Dialog: Ziel (Produkt/Gruppe) → Geltung (Gesamt/Lagerort) → Menge. const [showAdd, setShowAdd] = useState(false); const [draft, setDraft] = useState(EMPTY_DRAFT); async function load() { setLoading(true); 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); } finally { setLoading(false); } } useEffect(() => { load(); }, []); const catInfo = useMemo(() => categoryInfoMap(categories), [categories]); const rows = useMemo(() => { const out = []; for (const p of products.filter(canHaveMin)) { out.push({ key: `p:${p.id}:global`, kind: "product", scope: "global", entity: p, name: p.name, catId: p.category_id, ort: "(Gesamt)", // Alles in der Anzeigeeinheit (Gramm/Milliliter/Stück): min_stock ist in // Basiseinheiten gespeichert. soll: p.min_stock != null ? p.min_stock / dispFactor(p) : null, unit: dispLabel(p), bestand: p.stock / dispFactor(p), bestandUnit: dispLabel(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) || "?", // je-Lagerort ist in Artikeleinheiten gespeichert → in Anzeigeeinheit umrechnen. soll: (l.min_stock * articleUnit(p)) / dispFactor(p), unit: dispLabel(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/neuen/entfernten Eintrag neu // bauen. Der Endpunkt ersetzt die komplette Liste – neue Lagerorte müssen also // ergänzt (nicht nur bestehende geändert) werden, sonst wird nie einer angelegt. const mergeLoc = (list, locId, num) => { const out = (list || []).map((e) => ({ location_id: e.location_id, min_stock: e.min_stock })); const hit = out.find((e) => String(e.location_id) === String(locId)); if (hit) hit.min_stock = num; else out.push({ location_id: locId, min_stock: num }); return out.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; // Eingabe in der Anzeigeeinheit → Basiseinheiten speichern. await api.updateProduct(p.id, { min_stock: num == null ? null : Math.round(num * dispFactor(p)), min_stock_in_packages: false, min_stock_unit_id: p.display_unit_id ?? null, }); } else if (row.kind === "product" && row.scope === "loc") { const p = row.entity; // Eingabe in der Anzeigeeinheit → Artikeleinheiten (so gespeichert). const artikel = num == null ? null : (num * dispFactor(p)) / articleUnit(p); await api.setProductLocationMinStock(p.id, mergeLoc(p.location_min_stocks, row.locId, artikel)); } 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); } } // Ausgewähltes Ziel des Dialogs + dessen Einheit (Menge wird darin erfasst). const draftTarget = draft.kind === "product" ? products.find((p) => String(p.id) === String(draft.targetId)) : groups.find((g) => String(g.id) === String(draft.targetId)); const draftUnit = draft.kind === "product" ? (draftTarget ? dispLabel(draftTarget) : "") : (draftTarget ? (draftTarget.min_stock_unit_name || "Stück") : ""); function resetAdd() { setShowAdd(false); setDraft(EMPTY_DRAFT); } async function saveNew() { setError(null); const roh = String(draft.menge).trim().replace(",", "."); const num = roh === "" ? null : Number(roh); if (!draft.targetId) { setError("Bitte ein Ziel wählen."); return; } if (num == null || Number.isNaN(num) || num <= 0) { setError("Bitte eine Menge größer 0 angeben."); return; } if (draft.scope === "loc" && !draft.locId) { setError("Bitte einen Lagerort wählen."); return; } try { if (draft.kind === "product") { const p = draftTarget; if (draft.scope === "global") { // Menge in der Anzeigeeinheit erfasst → in Basiseinheiten speichern. await api.updateProduct(p.id, { min_stock: Math.round(num * dispFactor(p)), min_stock_in_packages: false, min_stock_unit_id: p.display_unit_id ?? null, }); } else { // Anzeigeeinheit → Artikeleinheiten (so gespeichert). const artikel = (num * dispFactor(p)) / articleUnit(p); await api.setProductLocationMinStock(p.id, mergeLoc(p.location_min_stocks, draft.locId, artikel)); } } else { const g = draftTarget; if (draft.scope === "global") { await api.updateGroup(g.id, { min_stock: num }); } else { await api.setGroupLocationMinStock(g.id, mergeLoc(g.location_min_stocks, draft.locId, num)); } } resetAdd(); 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" : ""}.
{isAdmin && ( )}
{error &&
{error}
} {isAdmin && showAdd && (

Mindestbestand hinzufügen

{draft.scope === "loc" && ( )}
)}
r.key} empty="Noch keine Mindestbestände." />
); }