Der Endpunkt ersetzt die komplette Lagerort-Liste; mergeLoc iterierte aber nur ueber bestehende Eintraege und liess neue Orte weg. Dadurch entstand nie ein neuer je-Lagerort-Mindestbestand, wenn nur ein Gesamt-Wert (leere Ortsliste) oder ein Eintrag an einem anderen Ort existierte. Jetzt wird ein fehlender Ort ergaenzt und der Abgleich per String() unabhaengig vom id-Typ gemacht. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
302 lines
14 KiB
JavaScript
302 lines
14 KiB
JavaScript
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) => <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>
|
||
{isAdmin && (
|
||
<button className="btn primary" onClick={() => (showAdd ? resetAdd() : setShowAdd(true))}>
|
||
<Icon name={showAdd ? "close" : "plus"} size={16} />
|
||
{showAdd ? "Abbrechen" : "Mindestbestand"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||
|
||
{isAdmin && showAdd && (
|
||
<div className="card" style={{ marginBottom: "var(--sp-3)" }}>
|
||
<div className="card-head"><Icon name="plus" /><h2>Mindestbestand hinzufügen</h2></div>
|
||
<div className="row">
|
||
<label className="seg-label">
|
||
Ziel
|
||
<div className="segmented">
|
||
<button type="button" className={draft.kind === "product" ? "active" : ""}
|
||
onClick={() => setDraft({ ...draft, kind: "product", targetId: "" })}>Gegenstand / Lebensmittel</button>
|
||
<button type="button" className={draft.kind === "group" ? "active" : ""}
|
||
onClick={() => setDraft({ ...draft, kind: "group", targetId: "" })}>Gruppe</button>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
<div className="row">
|
||
<label className="grow">
|
||
{draft.kind === "product" ? "Produkt / Gegenstand" : "Gruppe"}
|
||
<select value={draft.targetId} onChange={(e) => setDraft({ ...draft, targetId: e.target.value })}>
|
||
<option value="">– wählen –</option>
|
||
{(draft.kind === "product"
|
||
? products.filter(canHaveMin).sort((a, b) => a.name.localeCompare(b.name, "de"))
|
||
: [...groups].sort((a, b) => a.name.localeCompare(b.name, "de"))
|
||
).map((t) => (
|
||
<option key={t.id} value={t.id}>
|
||
{t.name}{draft.kind === "product" && t.brand ? ` · ${t.brand}` : ""}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div className="row">
|
||
<label className="seg-label">
|
||
Geltung
|
||
<div className="segmented">
|
||
<button type="button" className={draft.scope === "global" ? "active" : ""}
|
||
onClick={() => setDraft({ ...draft, scope: "global", locId: "" })}>Gesamt</button>
|
||
<button type="button" className={draft.scope === "loc" ? "active" : ""}
|
||
onClick={() => setDraft({ ...draft, scope: "loc" })}>Lagerort</button>
|
||
</div>
|
||
</label>
|
||
{draft.scope === "loc" && (
|
||
<label className="grow" style={{ maxWidth: 360 }}>
|
||
Lagerort
|
||
<select value={draft.locId} onChange={(e) => setDraft({ ...draft, locId: e.target.value })}>
|
||
<option value="">– wählen –</option>
|
||
{locations.map((l) => (
|
||
<option key={l.id} value={l.id}>{locationPathById(l.id, locations)}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
)}
|
||
</div>
|
||
<div className="row">
|
||
<label className="grow" style={{ maxWidth: 300 }}>
|
||
Menge{draftUnit ? ` (in ${draftUnit})` : ""}
|
||
<input type="number" step="any" min="0" value={draft.menge}
|
||
onChange={(e) => setDraft({ ...draft, menge: e.target.value })} placeholder="z.B. 2" />
|
||
</label>
|
||
</div>
|
||
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
|
||
<button className="btn primary" onClick={saveNew}>Speichern</button>
|
||
<button className="btn" onClick={resetAdd}>Abbrechen</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="card">
|
||
<DataTable id="min-stock" columns={columns} rows={rows} loading={loading}
|
||
getRowKey={(r) => r.key} empty="Noch keine Mindestbestände." />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|