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