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)" :