Mindestbestände: Gruppen in Packungen erfassen (Gruppen-Gebinde)

Gruppen mit verwalteter Einheit lassen sich in der Spalte 'Einheit' auf ein
Gruppen-Gebinde umschalten. Über 'Gebinde festlegen…' definiert man einmalig
'1 Packung ≈ X g/ml' (Richtwert, da Produkte unterschiedlich große Packungen
haben können). Danach werden Mindestbestand UND Bestand der Gruppe in
Packungen angezeigt/erfasst; das Backend rechnet vorhandene Werte beim
Umschalten um.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-07 18:42:15 +02:00
parent ecc1570100
commit 86e0edace8

View File

@@ -30,6 +30,15 @@ const effFactor = (p) => (pkgMode(p) ? p.package_size : dispFactor(p));
// Einheitslabel der gewählten Erfassungseinheit, passend zur Menge (1 Glas / 3 Gläser). // Einheitslabel der gewählten Erfassungseinheit, passend zur Menge (1 Glas / 3 Gläser).
const effUnit = (p, menge = 1) => (pkgMode(p) ? gebinde(menge, p.package_label || "Packung") : dispLabel(p)); const effUnit = (p, menge = 1) => (pkgMode(p) ? gebinde(menge, p.package_label || "Packung") : dispLabel(p));
// Gruppen: analog, mit dem GRUPPEN-Gebinde (Richtwert der Gruppe, nicht der
// einzelnen Produkte). Packungen nur möglich, wenn die Gruppen-Einheit (Art)
// bekannt ist.
const grpPkgMode = (g) => !!g.min_stock_in_packages && !!(g.package_size && g.package_size > 0);
const grpUnit = (g, menge = 1) => (grpPkgMode(g)
? gebinde(menge, g.package_label || "Packung")
: (g.min_stock_unit_name || ""));
const kindShort = (kind) => ({ weight: "g", volume: "ml", count: "Stück" }[kind] || "");
/** /**
* Zentrale Liste aller Mindestbestände: je Produkt/Gruppe der Gesamt-Wert und die * Zentrale Liste aller Mindestbestände: je Produkt/Gruppe der Gesamt-Wert und die
* Bedarfe je Lagerort, inline editierbar. Ergänzt die einzelnen Produkt-/Gruppen- * Bedarfe je Lagerort, inline editierbar. Ergänzt die einzelnen Produkt-/Gruppen-
@@ -49,6 +58,8 @@ export default function MinStock() {
// „+ hinzufügen"-Dialog: Ziel (Produkt/Gruppe) → Geltung (Gesamt/Lagerort) → Menge. // „+ hinzufügen"-Dialog: Ziel (Produkt/Gruppe) → Geltung (Gesamt/Lagerort) → Menge.
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false);
const [draft, setDraft] = useState(EMPTY_DRAFT); const [draft, setDraft] = useState(EMPTY_DRAFT);
// Gruppen-Gebinde festlegen/ändern: { id, size, label, baseShort }.
const [gebindeDlg, setGebindeDlg] = useState(null);
async function load() { async function load() {
setLoading(true); setLoading(true);
@@ -94,16 +105,18 @@ export default function MinStock() {
out.push({ out.push({
key: `g:${g.id}:global`, kind: "group", scope: "global", entity: g, key: `g:${g.id}:global`, kind: "group", scope: "global", entity: g,
name: g.name, catId: null, ort: "(Gesamt)", 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 || "", // soll/bestand kommen vom Server schon in der aktuellen Einheit (Gebinde
// ODER verwaltete Einheit) hier nur das passende Label.
soll: g.min_stock, unit: grpUnit(g, g.min_stock ?? 1),
bestand: g.stock, bestandUnit: grpUnit(g, g.stock ?? 1),
}); });
for (const l of (g.location_min_stocks || [])) { for (const l of (g.location_min_stocks || [])) {
out.push({ out.push({
key: `g:${g.id}:${l.location_id}`, kind: "group", scope: "loc", entity: g, key: `g:${g.id}:${l.location_id}`, kind: "group", scope: "loc", entity: g,
locId: l.location_id, name: g.name, catId: null, locId: l.location_id, name: g.name, catId: null,
ort: locationPathById(l.location_id, locations) || "?", ort: locationPathById(l.location_id, locations) || "?",
// Gruppen-Bestand je Ort kommt schon in der Gruppen-Einheit (wie Gesamt). soll: l.min_stock, unit: grpUnit(g, l.min_stock ?? 1),
soll: l.min_stock, unit: g.min_stock_unit_name || "", bestand: l.stock ?? null, bestandUnit: grpUnit(g, l.stock ?? 1),
bestand: l.stock ?? null, bestandUnit: g.min_stock_unit_name || "",
}); });
} }
} }
@@ -161,6 +174,42 @@ export default function MinStock() {
} catch (err) { setError(err.message); } } catch (err) { setError(err.message); }
} }
// Gruppen-Einheit umschalten: verwaltete Einheit ↔ Gruppen-Gebinde. Die Werte
// rechnet das Backend um (physischer Bedarf bleibt gleich). Ohne definiertes
// Gebinde erst den Festlegen-Dialog öffnen.
async function toggleGroupPkg(g, wantPkg) {
if (wantPkg === grpPkgMode(g)) return;
if (wantPkg && !(g.package_size > 0)) { openGebinde(g); return; }
setError(null);
try {
await api.updateGroup(g.id, { min_stock_in_packages: wantPkg });
await load();
} catch (err) { setError(err.message); }
}
function openGebinde(g) {
setGebindeDlg({
id: g.id, size: g.package_size ?? "", label: g.package_label ?? "",
baseShort: kindShort(g.kind),
});
}
async function saveGebinde() {
if (!gebindeDlg) return;
const size = Number(String(gebindeDlg.size).trim().replace(",", "."));
if (!size || size <= 0) { setError("Bitte eine Gebinde-Größe größer 0 angeben."); return; }
setError(null);
try {
await api.updateGroup(gebindeDlg.id, {
package_size: size,
package_label: (gebindeDlg.label || "Packung").trim(),
min_stock_in_packages: true,
});
setGebindeDlg(null);
await load();
} catch (err) { setError(err.message); }
}
// Mindestbestand ganz entfernen: Gesamt-Wert auf NULL, je-Lagerort-Eintrag raus. // Mindestbestand ganz entfernen: Gesamt-Wert auf NULL, je-Lagerort-Eintrag raus.
// Das Feld leeren tut dasselbe aber ein eigener Knopf macht das Löschen // Das Feld leeren tut dasselbe aber ein eigener Knopf macht das Löschen
// eindeutig (und bei Gesamt-Werten sichtbar, da die Zeile sonst bestehen bleibt). // eindeutig (und bei Gesamt-Werten sichtbar, da die Zeile sonst bestehen bleibt).
@@ -246,7 +295,7 @@ export default function MinStock() {
const draftUsePkg = draft.kind === "product" && !!draftTarget && hasPkg(draftTarget) && draft.pkg; const draftUsePkg = draft.kind === "product" && !!draftTarget && hasPkg(draftTarget) && draft.pkg;
const draftUnit = draft.kind === "product" const draftUnit = draft.kind === "product"
? (draftTarget ? (draftUsePkg ? (draftTarget.package_label || "Packung") : dispLabel(draftTarget)) : "") ? (draftTarget ? (draftUsePkg ? (draftTarget.package_label || "Packung") : dispLabel(draftTarget)) : "")
: (draftTarget ? (draftTarget.min_stock_unit_name || "Stück") : ""); : (draftTarget ? (grpUnit(draftTarget) || "Stück") : "");
function resetAdd() { function resetAdd() {
setShowAdd(false); setShowAdd(false);
@@ -343,6 +392,24 @@ export default function MinStock() {
</select> </select>
); );
} }
// Gruppen mit verwalteter Einheit: umschaltbar auf ein Gruppen-Gebinde.
if (isAdmin && r.kind === "group" && r.entity.min_stock_unit_id) {
const g = r.entity;
return (
<select value={grpPkgMode(g) ? "pkg" : "unit"} style={{ marginTop: 0, minWidth: 130 }}
title="Einheit für den Mindestbestand umschalten"
onChange={(e) => {
const v = e.target.value;
if (v === "unit") toggleGroupPkg(g, false);
else if (v === "pkg") toggleGroupPkg(g, true);
else openGebinde(g);
}}>
<option value="unit">{g.min_stock_unit_name || "Einheit"}</option>
{g.package_size > 0 && <option value="pkg">{g.package_label || "Packung"}</option>}
<option value="edit">{g.package_size > 0 ? "Gebinde ändern…" : "Gebinde festlegen…"}</option>
</select>
);
}
return <span className="muted">{r.unit || ""}</span>; return <span className="muted">{r.unit || ""}</span>;
} }, } },
{ key: "bestand", header: "Bestand", width: 120, align: "num", { key: "bestand", header: "Bestand", width: 120, align: "num",
@@ -446,6 +513,32 @@ export default function MinStock() {
</div> </div>
)} )}
{isAdmin && gebindeDlg && (
<div className="card" style={{ marginBottom: "var(--sp-3)" }}>
<div className="card-head"><Icon name="package" /><h2>Gruppen-Gebinde festlegen</h2></div>
<p className="muted small" style={{ marginTop: 0 }}>
Wie viel zählt 1 Packung" dieser Gruppe? Ein Richtwert die Produkte
der Gruppe dürfen unterschiedlich große Packungen haben.
</p>
<div className="row">
<label style={{ maxWidth: 200 }}>
Bezeichnung
<input value={gebindeDlg.label} placeholder="z.B. Glas"
onChange={(e) => setGebindeDlg({ ...gebindeDlg, label: e.target.value })} />
</label>
<label style={{ maxWidth: 220 }}>
Größe{gebindeDlg.baseShort ? ` (in ${gebindeDlg.baseShort})` : ""}
<input type="number" step="any" min="0" value={gebindeDlg.size} placeholder="z.B. 150"
onChange={(e) => setGebindeDlg({ ...gebindeDlg, size: e.target.value })} />
</label>
</div>
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
<button className="btn primary" onClick={saveGebinde}>Speichern</button>
<button className="btn" onClick={() => setGebindeDlg(null)}>Abbrechen</button>
</div>
</div>
)}
<div className="card"> <div className="card">
<DataTable id="min-stock" columns={columns} rows={rows} loading={loading} <DataTable id="min-stock" columns={columns} rows={rows} loading={loading}
getRowKey={(r) => r.key} empty="Noch keine Mindestbestände." /> getRowKey={(r) => r.key} empty="Noch keine Mindestbestände." />