Web: Obergruppen zuordnen, Mindestbestaende nach Lagerort

Mindestbestaende-Seite ist jetzt nach Lagerort gegliedert statt nach Artikel -
"Ueberall (egal wo)" zuerst, danach die Lagerorte. Es gibt keine automatische
Leerzeile je Artikel mehr; angelegt wird ueber "+ hinzufuegen" im jeweiligen
Abschnitt, dann steht der Ort schon fest und es fehlen nur Ziel und Menge.
Das Zeilenmodell faellt damit von vier Faellen auf zwei, und weil der Server
einheitlich Basiseinheiten liefert, bleibt genau eine Umrechnung uebrig statt
der drei gegenlaeufigen von vorher.

Gruppen: neue Spalte "Obergruppen" mit Mehrfachauswahl (GroupParentSelect).
Bewusst kein Baum - CategorySelect und die tree-Prop der DataTable koennen nur
einen Elternteil und wuerden eine Gruppe mit zwei Obergruppen doppelt anzeigen.
Die eigene Gruppe und ihre Untergruppen sind in der Auswahl gesperrt, damit die
Ringregel sichtbar wird, bevor der Server sie ablehnt. Produktzahl zeigt
transitiv und direkt ("12 (3 direkt)"), dazu Badges fuer Untergruppen und eine
Warnung, wenn eine Gruppe mit Untergruppen keine Einheit hat - sonst summiert
sie Gramm und Stueck zu einer sinnlosen Zahl.

Einkaufsliste: "Gesamt" heisst "Ueberall", Gruppen zeigen ihre mitgezaehlten
Untergruppen, und eine Fussnote benennt die eine Stelle, die sich nicht
verrechnen laesst (zwei Obergruppen mit gemeinsamer Untergruppe).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-15 23:56:18 +02:00
parent 54261b98c0
commit 0537a2b53f
9 changed files with 603 additions and 357 deletions

View File

@@ -0,0 +1,146 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Icon from "./Icon";
import { nachfahrenIds, pfadText } from "../groupGraph";
/**
* Mehrfachauswahl der Obergruppen einer Gruppe.
*
* Bewusst KEIN Baum wie CategorySelect: eine Gruppe darf unter mehreren
* Obergruppen hängen („Grillwurst" unter „Wurst" UND unter „Grillgut"). Ein
* Baum müsste sie mehrfach anzeigen; eine flache, alphabetische Liste mit
* Häkchen ist ehrlicher und deutlich weniger Code. Der volle Weg steht als
* Titel an jeder Zeile, damit gleichnamige Äste unterscheidbar bleiben.
*
* Die eigene Gruppe und ihre Untergruppen sind gesperrt sie würden einen Ring
* erzeugen. Der Server lehnt das ohnehin ab; hier wird es vorher sichtbar.
*
* Das Menü hängt per Portal am <body>, damit es der horizontal scrollende
* Tabellenrahmen nicht abschneidet (wie bei CategorySelect).
*/
export default function GroupParentSelect({
value = [],
onChange,
groups = [],
selfId = null,
disabled = false,
}) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState(null);
const [filter, setFilter] = useState("");
const btnRef = useRef(null);
const popRef = useRef(null);
const gewaehlt = new Set(value || []);
const gesperrt = selfId == null ? new Set() : new Set([selfId, ...nachfahrenIds(groups, selfId)]);
const nameById = Object.fromEntries(groups.map((g) => [g.id, g.name]));
const sichtbar = groups
.filter((g) => !filter || g.name.toLowerCase().includes(filter.toLowerCase()))
.slice()
.sort((a, b) => a.name.localeCompare(b.name, "de"));
const label = value?.length
? value.map((id) => nameById[id]).filter(Boolean).join(", ")
: " keine ";
function place() {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ left: r.left, top: r.bottom + 4, width: r.width });
}
useLayoutEffect(() => { if (open) place(); }, [open]);
useEffect(() => {
if (!open) return;
function onDoc(e) {
if (btnRef.current?.contains(e.target)) return;
if (popRef.current?.contains(e.target)) return;
setOpen(false);
}
function onScroll(e) {
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
setOpen(false);
}
function onResize() { setOpen(false); }
function onKey(e) { if (e.key === "Escape") setOpen(false); }
document.addEventListener("mousedown", onDoc);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
document.removeEventListener("keydown", onKey);
};
}, [open]);
// Bei Mehrfachauswahl bleibt das Menü offen sonst müsste man es für jede
// weitere Obergruppe neu aufklappen.
function umschalten(id) {
const neu = new Set(gewaehlt);
if (neu.has(id)) neu.delete(id);
else neu.add(id);
onChange([...neu]);
}
return (
<>
<button
type="button"
ref={btnRef}
className="tree-select-btn"
disabled={disabled}
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<span className={value?.length ? "" : "muted"}>{label}</span>
<Icon name="chevronRight" size={13} className={`caret ${open ? "open" : ""}`} />
</button>
{open && pos && createPortal(
<div
ref={popRef}
className="tree-pop"
style={{ left: pos.left, top: pos.top, minWidth: Math.max(pos.width, 240) }}
>
{groups.length > 8 && (
<div className="tree-pop-line">
<input
autoFocus
placeholder="Suchen…"
value={filter}
style={{ marginTop: 0, width: "100%" }}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
)}
{sichtbar.map((g) => {
const aus = gesperrt.has(g.id);
const an = gewaehlt.has(g.id);
return (
<div key={g.id} className="tree-pop-line">
<button
type="button"
className={`tree-pop-opt ${an ? "sel" : ""}`}
disabled={aus}
title={aus
? "Würde einen Ring erzeugen (die Gruppe selbst oder eine ihrer Untergruppen)"
: pfadText(groups, g.id)}
onClick={() => umschalten(g.id)}
>
<span style={{ display: "inline-block", width: 18 }}>{an ? "✓" : ""}</span>
{g.name}
</button>
</div>
);
})}
{sichtbar.length === 0 && (
<div className="tree-pop-line muted" style={{ padding: 6 }}>Keine Gruppe gefunden.</div>
)}
</div>,
document.body,
)}
</>
);
}

View File

@@ -2,20 +2,36 @@ import { useState } from "react";
import Icon from "./Icon"; import Icon from "./Icon";
import { locationOptions } from "../locationPath"; import { locationOptions } from "../locationPath";
// „Überall" ist ein Ort wie jeder andere nur eben der oberste. Im Datenmodell
// ist er location_id = null; im <select> braucht es einen Wert, deshalb hier ein
// Platzhalter, der beim Speichern wieder zu null wird.
const UEBERALL = "__ueberall__";
const zuId = (wert) => (wert === UEBERALL ? null : wert);
const vonId = (id) => (id == null ? UEBERALL : String(id));
/** /**
* Editor für Mindestbestände je Lagerort (Bedarfe). Eine Zeile je Ort mit Menge; * Editor für Mindestbestände je Lagerort. Eine Zeile je Ort mit Menge;
* „Bedarfe speichern" ersetzt über onSave die komplette Liste. Menge 0/leer = * „Bedarfe speichern" ersetzt über onSave die komplette Liste. Menge 0/leer =
* Ort fällt weg. * Ort fällt weg.
*
* Den früheren separaten Gesamt-Mindestbestand gibt es nicht mehr er ist die
* Zeile „Überall (egal wo)" und wird hier genauso gepflegt wie jeder Lagerort.
* Mengen in Basiseinheiten (g/ml/Stück).
*/ */
export default function LocationMinStock({ locations, initial = [], unitLabel = "", onSave, onError }) { export default function LocationMinStock({ locations, initial = [], unitLabel = "", onSave, onError }) {
const [rows, setRows] = useState(() => const [rows, setRows] = useState(() =>
initial.map((e) => ({ location_id: String(e.location_id), min_stock: String(e.min_stock) })), initial.map((e) => ({ location_id: vonId(e.location_id), min_stock: String(e.min_stock) })),
); );
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [ok, setOk] = useState(false); const [ok, setOk] = useState(false);
const used = new Set(rows.map((r) => r.location_id)); const used = new Set(rows.map((r) => r.location_id));
const frei = locations.filter((l) => !used.has(String(l.id))); // „Überall" steht ganz oben und ist wie ein Ort nur einmal vergebbar.
const alleOrte = [
{ id: UEBERALL, label: "Überall (egal wo)" },
...locationOptions(locations),
];
const frei = alleOrte.filter((o) => !used.has(String(o.id)));
function addRow() { function addRow() {
if (!frei.length) return; if (!frei.length) return;
@@ -36,7 +52,7 @@ export default function LocationMinStock({ locations, initial = [], unitLabel =
try { try {
const list = rows const list = rows
.filter((r) => r.location_id !== "" && Number(r.min_stock) > 0) .filter((r) => r.location_id !== "" && Number(r.min_stock) > 0)
.map((r) => ({ location_id: r.location_id, min_stock: Number(r.min_stock) })); .map((r) => ({ location_id: zuId(r.location_id), min_stock: Number(r.min_stock) }));
await onSave(list); await onSave(list);
setOk(true); setOk(true);
} catch (err) { } catch (err) {
@@ -46,20 +62,16 @@ export default function LocationMinStock({ locations, initial = [], unitLabel =
} }
} }
if (!locations.length) {
return <p className="muted small mt-0">Erst Lagerorte anlegen, dann Bedarfe je Ort möglich.</p>;
}
return ( return (
<div className="stack"> <div className="stack">
{rows.length === 0 && ( {rows.length === 0 && (
<p className="muted small mt-0">Kein Bedarf je Lagerort festgelegt.</p> <p className="muted small mt-0">Kein Mindestbestand festgelegt.</p>
)} )}
{rows.map((r, i) => ( {rows.map((r, i) => (
<div className="field-inline" key={i} style={{ marginBottom: 0 }}> <div className="field-inline" key={i} style={{ marginBottom: 0 }}>
<label className="grow" style={{ flex: "1 1 auto", minWidth: 0, margin: 0 }}> <label className="grow" style={{ flex: "1 1 auto", minWidth: 0, margin: 0 }}>
<select value={r.location_id} onChange={(e) => setRow(i, { location_id: e.target.value })}> <select value={r.location_id} onChange={(e) => setRow(i, { location_id: e.target.value })}>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)} {alleOrte.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select> </select>
</label> </label>
<label style={{ margin: 0, width: 130 }}> <label style={{ margin: 0, width: 130 }}>
@@ -74,7 +86,7 @@ export default function LocationMinStock({ locations, initial = [], unitLabel =
))} ))}
<div className="field-inline" style={{ marginBottom: 0 }}> <div className="field-inline" style={{ marginBottom: 0 }}>
<button type="button" className="btn" onClick={addRow} disabled={!frei.length}> <button type="button" className="btn" onClick={addRow} disabled={!frei.length}>
<Icon name="plus" size={16} />Lagerort <Icon name="plus" size={16} />Mindestbestand
</button> </button>
<button type="button" className="btn primary" onClick={save} disabled={busy}> <button type="button" className="btn primary" onClick={save} disabled={busy}>
<Icon name="check" size={16} />{busy ? "Speichern…" : "Bedarfe speichern"} <Icon name="check" size={16} />{busy ? "Speichern…" : "Bedarfe speichern"}

View File

@@ -345,6 +345,9 @@ function KarteEinkaufsliste({ props: karteProps }) {
<label> <label>
<input type="checkbox" checked={!!erledigt[key]} onChange={(e) => abhaken(key, e.target.checked)} /> <input type="checkbox" checked={!!erledigt[key]} onChange={(e) => abhaken(key, e.target.checked)} />
<span className="badge accent">Gruppe</span> <span className="badge accent">Gruppe</span>
{it.subgroup_count > 0 && (
<span className="badge" title="Untergruppen sind mitgezählt.">+{it.subgroup_count}</span>
)}
<span className="item-name">{it.name}</span> <span className="item-name">{it.name}</span>
</label> </label>
<span className="muted small"> <span className="muted small">
@@ -374,7 +377,7 @@ function KarteEinkaufsliste({ props: karteProps }) {
<div className="card-stack"> <div className="card-stack">
{!gesamtLeer && ( {!gesamtLeer && (
<div> <div>
<div className="muted small" style={{ fontWeight: 600 }}>Gesamt</div> <div className="muted small" style={{ fontWeight: 600 }}>Überall</div>
{abschnitt(daten.gruppen, daten.produkte, "")} {abschnitt(daten.gruppen, daten.produkte, "")}
</div> </div>
)} )}

77
web/src/groupGraph.js Normal file
View File

@@ -0,0 +1,77 @@
// Gruppen bilden einen gerichteten azyklischen Graphen, keinen Baum:
// „Grillwurst" hängt unter „Wurst" UND unter „Grillgut". Deshalb hier
// Mengen-Helfer statt der Baum-Helfer aus categoryTree.js ein Baum-Walk
// würde eine über zwei Wege erreichbare Gruppe doppelt liefern.
//
// Alle Funktionen schützen sich mit einem gesehen-Set gegen Ringe. Die API
// lässt keinen zu, eine eingespielte Sicherung könnte aber einen mitbringen.
const byId = (groups) => new Map((groups || []).map((g) => [g.id, g]));
/** IDs aller Untergruppen (transitiv, ohne die Gruppe selbst). */
export function nachfahrenIds(groups, id) {
const map = byId(groups);
const gesehen = new Set();
const offen = [id];
while (offen.length) {
const cur = offen.pop();
for (const kind of map.get(cur)?.child_ids || []) {
if (!gesehen.has(kind)) {
gesehen.add(kind);
offen.push(kind);
}
}
}
gesehen.delete(id);
return gesehen;
}
/** IDs aller Obergruppen (transitiv, ohne die Gruppe selbst). */
export function vorfahrenIds(groups, id) {
const map = byId(groups);
const gesehen = new Set();
const offen = [...(map.get(id)?.parent_ids || [])];
while (offen.length) {
const cur = offen.pop();
if (gesehen.has(cur)) continue;
gesehen.add(cur);
offen.push(...(map.get(cur)?.parent_ids || []));
}
gesehen.delete(id);
return gesehen;
}
/**
* Alle Wege zu einer Gruppe, z.B. [["Wurst","Grillwurst"], ["Grillgut","Grillwurst"]].
* Anders als bei Kategorien gibt es nicht DEN einen Pfad deshalb eine Liste.
*/
export function pfade(groups, id) {
const map = byId(groups);
const bauen = (cur, gesehen) => {
const g = map.get(cur);
if (!g) return [[]];
const eltern = (g.parent_ids || []).filter((p) => !gesehen.has(p));
if (eltern.length === 0) return [[g.name]];
const raus = [];
for (const p of eltern) {
for (const oben of bauen(p, new Set([...gesehen, cur]))) {
raus.push([...oben, g.name]);
}
}
return raus;
};
return bauen(id, new Set());
}
/** Die Wege als ein Text: „Wurst → Grillwurst · Grillgut → Grillwurst". */
export function pfadText(groups, id) {
return pfade(groups, id)
.map((teile) => teile.join(" → "))
.join(" · ");
}
/** Namen der Obergruppen, für Chips und Spaltentexte. */
export function elternNamen(groups, group) {
const map = byId(groups);
return (group?.parent_ids || []).map((id) => map.get(id)?.name).filter(Boolean);
}

View File

@@ -6,7 +6,8 @@ import BarcodeList from "../components/BarcodeList";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import DataTable from "../components/DataTable"; import DataTable from "../components/DataTable";
import LocationMinStock from "../components/LocationMinStock"; import LocationMinStock from "../components/LocationMinStock";
import { fmt } from "../units"; import GroupParentSelect from "../components/GroupParentSelect";
import { fmt, kindShort } from "../units";
export default function Groups() { export default function Groups() {
const confirm = useConfirm(); const confirm = useConfirm();
@@ -14,7 +15,9 @@ export default function Groups() {
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [units, setUnits] = useState([]); const [units, setUnits] = useState([]);
const [locations, setLocations] = useState([]); const [locations, setLocations] = useState([]);
const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" }); const [form, setForm] = useState({
name: "", min_stock: "", min_stock_unit_id: "", parent_ids: [],
});
const [selectedId, setSelectedId] = useState(null); const [selectedId, setSelectedId] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -45,8 +48,11 @@ export default function Groups() {
name: form.name, name: form.name,
min_stock: form.min_stock === "" ? null : Number(form.min_stock), min_stock: form.min_stock === "" ? null : Number(form.min_stock),
min_stock_unit_id: form.min_stock_unit_id === "" ? null : Number(form.min_stock_unit_id), min_stock_unit_id: form.min_stock_unit_id === "" ? null : Number(form.min_stock_unit_id),
parent_ids: form.parent_ids,
});
setForm({
name: "", min_stock: "", min_stock_unit_id: form.min_stock_unit_id, parent_ids: [],
}); });
setForm({ name: "", min_stock: "", min_stock_unit_id: form.min_stock_unit_id });
load(); load();
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -66,7 +72,9 @@ export default function Groups() {
async function remove(group) { async function remove(group) {
const ok = await confirm({ const ok = await confirm({
title: `Gruppe „${group.name}“ löschen?`, title: `Gruppe „${group.name}“ löschen?`,
message: "Die Produkte bleiben erhalten, verlieren aber ihre Zuordnung zu dieser Gruppe.", message: "Die Produkte bleiben erhalten, verlieren aber ihre Zuordnung zu dieser Gruppe. "
+ "Untergruppen bleiben ebenfalls bestehen und verlieren nur die Verbindung — "
+ "sie rücken nicht automatisch eine Ebene hoch.",
confirmLabel: "Löschen", confirmLabel: "Löschen",
danger: true, danger: true,
}); });
@@ -81,6 +89,7 @@ export default function Groups() {
} }
const selected = groups.find((g) => g.id === selectedId) || null; const selected = groups.find((g) => g.id === selectedId) || null;
const nameById = Object.fromEntries(groups.map((g) => [g.id, g.name]));
const columns = [ const columns = [
{ key: "name", header: "Gruppe", grow: true, min: 160, { key: "name", header: "Gruppe", grow: true, min: 160,
@@ -94,11 +103,46 @@ export default function Groups() {
onBlur={(e) => { const v = e.target.value.trim(); if (v && v !== g.name) patch(g, { name: v }); }} /> onBlur={(e) => { const v = e.target.value.trim(); if (v && v !== g.name) patch(g, { name: v }); }} />
) : <span className="strong">{g.name}</span>} ) : <span className="strong">{g.name}</span>}
{low && <span className="badge warn">niedrig</span>} {low && <span className="badge warn">niedrig</span>}
{g.child_ids?.length > 0 && (
<span className="badge" title="Bestand und Mindestbestand zählen diese Untergruppen mit.">
{g.child_ids.length} Untergruppen
</span>
)}
{/* Ohne Einheit summiert eine Gruppe Gramm und Stück zu einer
sinnlosen Zahl. Bei einem grossen Untergraphen faellt das
besonders ins Gewicht. */}
{g.child_ids?.length > 0 && !g.min_stock_unit_id && (
<span className="badge warn" title="Ohne Einheit werden Artikel verschiedener Basiseinheiten (g, ml, Stück) zusammengezählt.">
Einheit fehlt
</span>
)}
</span> </span>
); );
} }, } },
{ key: "products", header: "Produkte", width: 100, align: "num", { key: "parents", header: "Obergruppen", width: 220,
sortValue: (g) => g.product_count, render: (g) => <span className="muted">{g.product_count}</span> }, filterText: (g) => (g.parent_ids || []).map((id) => nameById[id]).join(" "),
render: (g) => (isAdmin ? (
<GroupParentSelect
groups={groups}
selfId={g.id}
value={g.parent_ids || []}
onChange={(ids) => patch(g, { parent_ids: ids })}
/>
) : (
<span className="muted">
{(g.parent_ids || []).map((id) => nameById[id]).filter(Boolean).join(", ") || ""}
</span>
)) },
{ key: "products", header: "Produkte", width: 120, align: "num",
sortValue: (g) => g.product_count,
render: (g) => (
<span className="muted">
{g.product_count}
{g.direct_product_count !== g.product_count && (
<span className="small"> ({g.direct_product_count} direkt)</span>
)}
</span>
) },
{ key: "stock", header: "Bestand", width: 130, align: "num", { key: "stock", header: "Bestand", width: 130, align: "num",
sortValue: (g) => g.stock, render: (g) => `${fmt(g.stock)} ${g.min_stock_unit_name || ""}` }, sortValue: (g) => g.stock, render: (g) => `${fmt(g.stock)} ${g.min_stock_unit_name || ""}` },
{ key: "min", header: "Mindestbestand", width: 150, sortValue: (g) => g.min_stock ?? -1, { key: "min", header: "Mindestbestand", width: 150, sortValue: (g) => g.min_stock ?? -1,
@@ -190,17 +234,18 @@ export default function Groups() {
<section className="card"> <section className="card">
<div className="card-head"> <div className="card-head">
<Icon name="location" /> <Icon name="location" />
<h2>Mindestbestand je Lagerort</h2> <h2>Mindestbestand</h2>
</div> </div>
<p className="muted small mt-0"> <p className="muted small mt-0">
Bedarf dieser Gruppe je Lagerort (z.B. Ferienhaus, Zuhause), zusätzlich zum Bedarf dieser Gruppe je Lagerort Überall heißt: egal wo, Hauptsache
Gesamt-Mindestbestand{selected.min_stock_unit_name ? ` (in ${selected.min_stock_unit_name})` : ""}. die Menge ist im Haus. Käufe für einen Lagerort decken Überall mit ab.
Mengen in {kindShort(selected.kind) || "Basiseinheiten"}.
</p> </p>
<LocationMinStock <LocationMinStock
key={selected.id} key={selected.id}
locations={locations} locations={locations}
initial={selected.location_min_stocks || []} initial={selected.location_min_stocks || []}
unitLabel={selected.min_stock_unit_name || ""} unitLabel={kindShort(selected.kind)}
onError={setError} onError={setError}
onSave={async (list) => { onSave={async (list) => {
await api.setGroupLocationMinStock(selected.id, list); await api.setGroupLocationMinStock(selected.id, list);
@@ -233,10 +278,22 @@ export default function Groups() {
</select> </select>
</label> </label>
</div> </div>
<label>
Obergruppen (optional)
<GroupParentSelect
groups={groups}
selfId={null}
value={form.parent_ids}
onChange={(ids) => setForm({ ...form, parent_ids: ids })}
/>
</label>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button> <button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
<p className="muted small"> <p className="muted small">
Der Bestand der Gruppe summiert nur Produkte, die zur gewählten Einheit passen. Bestand und Mindestbestand einer Gruppe zählen auch die Artikel ihrer
Namen lassen sich in der Tabelle direkt ändern. Untergruppen. Eine Gruppe darf unter mehreren Obergruppen hängen
Grillwurst etwa unter Wurst <em>und</em> unter Grillgut.
Gezählt werden nur Artikel, die zur gewählten Einheit passen, auch in
den Untergruppen. Namen lassen sich in der Tabelle direkt ändern.
</p> </p>
</form> </form>
)} )}

View File

@@ -3,11 +3,8 @@ import { api } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import { useConfirm } from "../confirm"; import { useConfirm } from "../confirm";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
import { categoryInfoMap } from "../categoryPath";
import { locationOptions, locationPathById } from "../locationPath"; import { locationOptions, locationPathById } from "../locationPath";
import { fmt, gebinde } from "../units"; import { fmt, gebinde, kindShort } from "../units";
// Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm, // Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm,
// Milliliter, Stück) NICHT die Packung. „1 Packung" ist nichtssagend, weil eine // Milliliter, Stück) NICHT die Packung. „1 Packung" ist nichtssagend, weil eine
@@ -18,9 +15,6 @@ const dispLabel = (p) => (p.unit_name || "Stück");
// Mindestbestand gibt es nur bei Charge-Artikeln: Lebensmittel und // Mindestbestand gibt es nur bei Charge-Artikeln: Lebensmittel und
// Verbrauchsgegenstände (bulk). „Menge je Lagerort" und Einzelstücke nicht. // Verbrauchsgegenstände (bulk). „Menge je Lagerort" und Einzelstücke nicht.
const canHaveMin = (p) => p.tracking !== "object" || p.bulk === true; 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));
// Gebinde-Umschaltung: hat das Produkt eine Packungsgröße, lässt sich der // Gebinde-Umschaltung: hat das Produkt eine Packungsgröße, lässt sich der
// Mindestbestand wahlweise in Packungen (Glas/Dose) statt in g/ml erfassen. // Mindestbestand wahlweise in Packungen (Glas/Dose) statt in g/ml erfassen.
const hasPkg = (p) => !!(p.package_size && p.package_size > 0); const hasPkg = (p) => !!(p.package_size && p.package_size > 0);
@@ -31,20 +25,33 @@ const effFactor = (p) => (pkgMode(p) ? p.package_size : dispFactor(p));
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 // Gruppen: analog, mit dem GRUPPEN-Gebinde (Richtwert der Gruppe, nicht der
// einzelnen Produkte). Packungen nur möglich, wenn die Gruppen-Einheit (Art) // einzelnen Produkte).
// bekannt ist.
const grpPkgMode = (g) => !!g.min_stock_in_packages && !!(g.package_size && g.package_size > 0); const grpPkgMode = (g) => !!g.min_stock_in_packages && !!(g.package_size && g.package_size > 0);
const grpFactor = (g) => (grpPkgMode(g) ? g.package_size : (g.min_stock_unit_factor || 1));
const grpUnit = (g, menge = 1) => (grpPkgMode(g) const grpUnit = (g, menge = 1) => (grpPkgMode(g)
? gebinde(menge, g.package_label || "Packung") ? gebinde(menge, g.package_label || "Packung")
: (g.min_stock_unit_name || "")); : (g.min_stock_unit_name || kindShort(g.kind) || ""));
const kindShort = (kind) => ({ weight: "g", volume: "ml", count: "Stück" }[kind] || "");
// „Überall" ist der Ort NULL. Im <select> braucht es einen Wert, deshalb ein
// Platzhalter, der beim Speichern wieder zu null wird.
const UEBERALL = "__ueberall__";
const zuId = (wert) => (wert === UEBERALL || wert === "" ? null : wert);
const vonId = (id) => (id == null ? UEBERALL : String(id));
const gleicherOrt = (a, b) => vonId(a) === vonId(b);
/** /**
* Zentrale Liste aller Mindestbestände: je Produkt/Gruppe der Gesamt-Wert und die * Mindestbestände nach LAGERORT gruppiert.
* Bedarfe je Lagerort, inline editierbar. Ergänzt die einzelnen Produkt-/Gruppen- *
* Formulare um einen Überblick an einem Ort. * Ein Mindestbestand ist immer ein Bedarf an einem Ort. „Überall" (egal wo,
* Hauptsache im Haus) ist dabei ein Ort wie jeder andere, nämlich der oberste:
* Käufe für einen einzelnen Lagerort decken ihn mit ab. Den früheren separaten
* „Gesamt"-Wert gibt es deshalb nicht mehr.
*
* Alle Mengen kommen vom Server in Basiseinheiten und werden hier in die
* Erfassungseinheit des Ziels umgerechnet eine einzige Umrechnung statt der
* drei verschiedenen, die es vorher gab.
*/ */
const EMPTY_DRAFT = { kind: "product", targetId: "", scope: "global", locId: "", menge: "", pkg: false }; const EMPTY_DRAFT = { locId: UEBERALL, kind: "product", targetId: "", menge: "", pkg: false };
export default function MinStock() { export default function MinStock() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
@@ -52,119 +59,96 @@ export default function MinStock() {
const [products, setProducts] = useState([]); const [products, setProducts] = useState([]);
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [locations, setLocations] = useState([]); const [locations, setLocations] = useState([]);
const [categories, setCategories] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// „+ hinzufügen"-Dialog: Ziel (Produkt/Gruppe) → Geltung (Gesamt/Lagerort) → Menge. // „+ Mindestbestand": Ort steht meist schon fest (Abschnitt), dann fehlen nur
const [showAdd, setShowAdd] = useState(false); // noch Ziel und Menge.
const [draft, setDraft] = useState(EMPTY_DRAFT); const [draft, setDraft] = useState(null);
// Gruppen-Gebinde festlegen/ändern: { id, size, label, baseShort }. // Gruppen-Gebinde festlegen/ändern: { id, size, label, baseShort }.
const [gebindeDlg, setGebindeDlg] = useState(null); const [gebindeDlg, setGebindeDlg] = useState(null);
async function load() { async function load() {
setLoading(true); setLoading(true);
try { try {
const [ps, gs, ls, cs] = await Promise.all([ const [ps, gs, ls] = await Promise.all([
api.listProducts("", ""), api.listGroups(), api.listLocations(), api.listCategories(), api.listProducts("", ""), api.listGroups(), api.listLocations(),
]); ]);
setProducts(ps); setGroups(gs); setLocations(ls); setCategories(cs); setProducts(ps); setGroups(gs); setLocations(ls);
} catch (err) { setError(err.message); } finally { setLoading(false); } } catch (err) { setError(err.message); } finally { setLoading(false); }
} }
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
const catInfo = useMemo(() => categoryInfoMap(categories), [categories]); // Eine Zeile je vorhandenem Eintrag keine Leerzeilen mehr für jeden Artikel.
const zeilen = useMemo(() => {
const rows = useMemo(() => {
const out = []; const out = [];
for (const p of products.filter(canHaveMin)) { const bauen = (kind, entity, name, faktor, einheit) => {
const gSoll = p.min_stock != null ? p.min_stock / effFactor(p) : null; for (const e of entity.location_min_stocks || []) {
const soll = e.min_stock / faktor;
out.push({ out.push({
key: `p:${p.id}:global`, kind: "product", scope: "global", entity: p, key: `${kind}:${entity.id}:${vonId(e.location_id)}`,
name: p.name, catId: p.category_id, ort: "(Gesamt)", kind, entity, name, locId: e.location_id,
// In der gewählten Einheit: Anzeigeeinheit (g/ml) ODER Packung. min_stock soll, unit: einheit(soll),
// ist in Basiseinheiten gespeichert. bestand: e.stock != null ? e.stock / faktor : null,
soll: gSoll, bestandUnit: einheit(e.stock != null ? e.stock / faktor : 1),
unit: effUnit(p, gSoll ?? 1),
bestand: p.stock / effFactor(p), bestandUnit: effUnit(p, p.stock / effFactor(p)),
});
for (const l of (p.location_min_stocks || [])) {
// je-Lagerort ist in Artikeleinheiten gespeichert → in die gewählte Einheit.
const lSoll = (l.min_stock * articleUnit(p)) / effFactor(p);
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: lSoll, unit: effUnit(p, lSoll),
// Bestand kommt in Basiseinheiten (wie der Gesamt-Bestand) → in die Einheit.
bestand: l.stock != null ? l.stock / effFactor(p) : null,
bestandUnit: effUnit(p, l.stock != null ? l.stock / effFactor(p) : 1),
}); });
} }
};
for (const p of products.filter(canHaveMin)) {
bauen("product", p, p.name, effFactor(p), (m) => effUnit(p, m));
} }
for (const g of groups) { for (const g of groups) {
out.push({ bauen("group", g, g.name, grpFactor(g), (m) => grpUnit(g, m));
key: `g:${g.id}:global`, kind: "group", scope: "global", entity: g,
name: g.name, catId: null, ort: "(Gesamt)",
// 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 || [])) {
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: grpUnit(g, l.min_stock ?? 1),
bestand: l.stock ?? null, bestandUnit: grpUnit(g, l.stock ?? 1),
});
}
} }
return out; return out;
}, [products, groups, locations]); }, [products, groups]);
// Eine Lagerort-Bedarfsliste mit einem geänderten/neuen/entfernten Eintrag neu // Abschnitte: „Überall" zuerst, danach die Lagerorte alphabetisch nach Pfad.
// bauen. Der Endpunkt ersetzt die komplette Liste neue Lagerorte müssen also const abschnitte = useMemo(() => {
// ergänzt (nicht nur bestehende geändert) werden, sonst wird nie einer angelegt. const orte = [{ id: UEBERALL, label: "Überall (egal wo)" }, ...locationOptions(locations)];
const mergeLoc = (list, locId, num) => { const belegt = new Set(zeilen.map((r) => vonId(r.locId)));
return orte
.filter((o) => belegt.has(String(o.id)) || o.id === UEBERALL)
.map((o) => ({
...o,
zeilen: zeilen
.filter((r) => vonId(r.locId) === String(o.id))
.sort((a, b) => a.name.localeCompare(b.name, "de")),
}));
}, [zeilen, locations]);
// Eine Bedarfsliste mit einem geänderten/neuen/entfernten Eintrag neu bauen.
// Der Endpunkt ersetzt die komplette Liste neue Orte müssen also ergänzt
// (nicht nur bestehende geändert) werden, sonst wird nie einer angelegt.
const mergeLoc = (list, locId, menge) => {
const out = (list || []).map((e) => ({ location_id: e.location_id, min_stock: e.min_stock })); 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)); const treffer = out.find((e) => gleicherOrt(e.location_id, locId));
if (hit) hit.min_stock = num; if (treffer) treffer.min_stock = menge;
else out.push({ location_id: locId, min_stock: num }); else out.push({ location_id: zuId(vonId(locId)), min_stock: menge });
return out.filter((e) => e.min_stock != null && e.min_stock > 0); return out.filter((e) => e.min_stock != null && e.min_stock > 0);
}; };
const faktorVon = (row) => (row.kind === "product" ? effFactor(row.entity) : grpFactor(row.entity));
async function speichern(kind, entity, locId, mengeBase) {
const liste = mergeLoc(entity.location_min_stocks, locId, mengeBase);
if (kind === "product") await api.setProductLocationMinStock(entity.id, liste);
else await api.setGroupLocationMinStock(entity.id, liste);
await load();
}
async function saveSoll(row, value) { async function saveSoll(row, value) {
const roh = String(value).trim().replace(",", "."); const roh = String(value).trim().replace(",", ".");
const num = roh === "" ? null : Number(roh); const num = roh === "" ? null : Number(roh);
if (num !== null && (Number.isNaN(num) || num < 0)) return; if (num !== null && (Number.isNaN(num) || num < 0)) return;
if (num === (row.soll ?? null)) return; if (num === (row.soll ?? null)) return;
setError(null);
try { try {
if (row.kind === "product" && row.scope === "global") { await speichern(row.kind, row.entity, row.locId, num == null ? null : num * faktorVon(row));
const p = row.entity;
// Eingabe in der gewählten Einheit (Anzeigeeinheit ODER Packung) → Basiseinheiten.
await api.updateProduct(p.id, {
min_stock: num == null ? null : Math.round(num * effFactor(p)),
min_stock_in_packages: pkgMode(p),
min_stock_unit_id: pkgMode(p) ? null : (p.display_unit_id ?? null),
});
} else if (row.kind === "product" && row.scope === "loc") {
const p = row.entity;
// Eingabe in der gewählten Einheit → Artikeleinheiten (so gespeichert).
const artikel = num == null ? null : (num * effFactor(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); } } catch (err) { setError(err.message); }
} }
// Einheit des Mindestbestands umschalten (Anzeigeeinheit ↔ Packung). Der // Einheit umschalten (Anzeigeeinheit ↔ Packung). Der gespeicherte Wert bleibt
// gespeicherte Wert bleibt physisch gleich (global in Basiseinheiten, je Ort in // physisch gleich er steht in Basiseinheiten, es wechselt nur die Anzeige.
// Artikeleinheiten) es wechselt nur, in welcher Einheit angezeigt/erfasst wird.
async function toggleProductUnit(p, wantPkg) { async function toggleProductUnit(p, wantPkg) {
if (wantPkg === pkgMode(p)) return; if (wantPkg === pkgMode(p)) return;
setError(null); setError(null);
@@ -174,9 +158,6 @@ 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) { async function toggleGroupPkg(g, wantPkg) {
if (wantPkg === grpPkgMode(g)) return; if (wantPkg === grpPkgMode(g)) return;
if (wantPkg && !(g.package_size > 0)) { openGebinde(g); return; } if (wantPkg && !(g.package_size > 0)) { openGebinde(g); return; }
@@ -210,97 +191,54 @@ export default function MinStock() {
} catch (err) { setError(err.message); } } catch (err) { setError(err.message); }
} }
// Mindestbestand ganz entfernen: Gesamt-Wert auf NULL, je-Lagerort-Eintrag raus.
// 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).
async function deleteRow(row) { async function deleteRow(row) {
const ziel = row.kind === "group" ? `Gruppe „${row.name}"` : `${row.name}"`; const ziel = row.kind === "group" ? `Gruppe „${row.name}` : `${row.name}`;
const wo = row.scope === "loc" ? ` am Lagerort „${row.ort}"` : ""; const ort = row.locId == null ? "„Überall“" : `am Lagerort „${locationPathById(row.locId, locations)}`;
if (!(await confirm({ if (!(await confirm({
title: "Mindestbestand löschen?", title: "Mindestbestand löschen?",
message: `Mindestbestand von ${ziel}${wo} entfernen?`, message: `Mindestbestand von ${ziel} ${ort} entfernen?`,
confirmLabel: "Löschen", danger: true, confirmLabel: "Löschen", danger: true,
}))) return; }))) return;
setError(null); setError(null);
try { try {
if (row.kind === "product" && row.scope === "global") { await speichern(row.kind, row.entity, row.locId, null);
await api.updateProduct(row.entity.id, {
min_stock: null, min_stock_in_packages: pkgMode(row.entity),
min_stock_unit_id: pkgMode(row.entity) ? null : (row.entity.display_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, null));
} else if (row.kind === "group" && row.scope === "global") {
await api.updateGroup(row.entity.id, { min_stock: null });
} else if (row.kind === "group" && row.scope === "loc") {
await api.setGroupLocationMinStock(row.entity.id, mergeLoc(row.entity.location_min_stocks, row.locId, null));
}
await load();
} catch (err) { setError(err.message); } } catch (err) { setError(err.message); }
} }
// Einen bestehenden Mindestbestand auf einen anderen Ort verschieben: „(Gesamt)" // Einen Eintrag an einen anderen Ort verschieben. Der Wert bleibt (beides in
// (ziel = "") oder ein Lagerort. Der Wert bleibt, der bisherige Geltungsbereich // Basiseinheiten), der alte Ort fällt weg so entsteht keine Dublette.
// wird geleert so entsteht keine Dublette. Produkt-Werte werden zwischen async function moveRow(row, zielOrt) {
// Anzeige-/Artikel-/Basiseinheit umgerechnet (wie in saveSoll), Gruppen nicht. if (gleicherOrt(row.locId, zielOrt)) return;
async function moveRow(row, ziel) { const belegt = (row.entity.location_min_stocks || [])
const aktuell = row.scope === "loc" ? row.locId : ""; .some((e) => gleicherOrt(e.location_id, zielOrt));
if (String(ziel) === String(aktuell)) return; if (belegt) {
if (row.soll == null) return; // nichts zu verschieben
const ent = row.entity;
// Zielort schon mit einem Bedarf belegt? Dann würde er überschrieben.
if (ziel && (ent.location_min_stocks || []).some((e) => String(e.location_id) === String(ziel))) {
const ok = await confirm({ const ok = await confirm({
title: "Lagerort schon belegt", title: "Ort schon belegt",
message: `Für „${row.name}" gibt es an diesem Lagerort bereits einen Mindestbestand. Mit dem verschobenen Wert überschreiben?`, message: `Für „${row.name} gibt es dort bereits einen Mindestbestand. Mit dem verschobenen Wert überschreiben?`,
confirmLabel: "Überschreiben", danger: true, confirmLabel: "Überschreiben", danger: true,
}); });
if (!ok) return; if (!ok) return;
} }
setError(null); setError(null);
try { try {
if (row.kind === "product") { const base = row.soll * faktorVon(row);
const p = ent; let liste = mergeLoc(row.entity.location_min_stocks, row.locId, null);
const artikel = (row.soll * effFactor(p)) / articleUnit(p); liste = mergeLoc(liste, zielOrt, base);
let liste = p.location_min_stocks || []; if (row.kind === "product") await api.setProductLocationMinStock(row.entity.id, liste);
if (row.scope === "loc") liste = mergeLoc(liste, row.locId, null); // alten Ort raus else await api.setGroupLocationMinStock(row.entity.id, liste);
if (ziel) liste = mergeLoc(liste, ziel, artikel); // Zielort rein
// Gesamt-Wert setzen (Umzug nach Gesamt) oder leeren (Umzug weg von Gesamt).
if (row.scope === "global" || !ziel) {
await api.updateProduct(p.id, {
min_stock: ziel ? null : Math.round(row.soll * effFactor(p)),
min_stock_in_packages: pkgMode(p),
min_stock_unit_id: pkgMode(p) ? null : (p.display_unit_id ?? null),
});
}
if (row.scope === "loc" || ziel) await api.setProductLocationMinStock(p.id, liste);
} else {
const g = ent; // Gruppe: Gesamt und Ort teilen dieselbe Einheit keine Umrechnung
let liste = g.location_min_stocks || [];
if (row.scope === "loc") liste = mergeLoc(liste, row.locId, null);
if (ziel) liste = mergeLoc(liste, ziel, row.soll);
if (row.scope === "global" || !ziel) {
await api.updateGroup(g.id, { min_stock: ziel ? null : row.soll });
}
if (row.scope === "loc" || ziel) await api.setGroupLocationMinStock(g.id, liste);
}
await load(); await load();
} catch (err) { setError(err.message); } } catch (err) { setError(err.message); }
} }
// Ausgewähltes Ziel des Dialogs + dessen Einheit (Menge wird darin erfasst). // ---- Hinzufügen ----
const draftTarget = draft.kind === "product"
? products.find((p) => String(p.id) === String(draft.targetId))
: groups.find((g) => String(g.id) === String(draft.targetId));
const draftUsePkg = draft.kind === "product" && !!draftTarget && hasPkg(draftTarget) && draft.pkg;
const draftUnit = draft.kind === "product"
? (draftTarget ? (draftUsePkg ? (draftTarget.package_label || "Packung") : dispLabel(draftTarget)) : "")
: (draftTarget ? (grpUnit(draftTarget) || "Stück") : "");
function resetAdd() { const draftTarget = !draft ? null : (draft.kind === "product"
setShowAdd(false); ? products.find((p) => String(p.id) === String(draft.targetId))
setDraft(EMPTY_DRAFT); : groups.find((g) => String(g.id) === String(draft.targetId)));
} const draftUsePkg = !!draft && draft.kind === "product" && !!draftTarget && hasPkg(draftTarget) && draft.pkg;
const draftUnit = !draftTarget ? "" : (draft.kind === "product"
? (draftUsePkg ? (draftTarget.package_label || "Packung") : dispLabel(draftTarget))
: (grpUnit(draftTarget) || "Stück"));
async function saveNew() { async function saveNew() {
setError(null); setError(null);
@@ -308,95 +246,50 @@ export default function MinStock() {
const num = roh === "" ? null : Number(roh); const num = roh === "" ? null : Number(roh);
if (!draft.targetId) { setError("Bitte ein Ziel wählen."); return; } 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 (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 { try {
if (draft.kind === "product") { const faktor = draft.kind === "product"
const p = draftTarget; ? (draftUsePkg ? draftTarget.package_size : dispFactor(draftTarget))
// In der gewählten Einheit erfasst (Anzeigeeinheit ODER Packung). : grpFactor(draftTarget);
const f = draftUsePkg ? p.package_size : dispFactor(p); await speichern(draft.kind, draftTarget, zuId(draft.locId), num * faktor);
if (draft.scope === "global") { setDraft(null);
await api.updateProduct(p.id, {
min_stock: Math.round(num * f),
min_stock_in_packages: draftUsePkg,
min_stock_unit_id: draftUsePkg ? null : (p.display_unit_id ?? null),
});
} else {
const artikel = (num * f) / 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); } } catch (err) { setError(err.message); }
} }
const columns = [ const zielListe = !draft ? [] : (draft.kind === "product"
{ key: "art", header: "Art", width: 96, filterText: (r) => (r.kind === "group" ? "Gruppe" : "Produkt"), ? products.filter(canHaveMin).slice().sort((a, b) => a.name.localeCompare(b.name, "de"))
render: (r) => <span className="badge nowrap">{r.kind === "group" ? "Gruppe" : "Produkt"}</span> }, : groups.slice().sort((a, b) => a.name.localeCompare(b.name, "de")));
{ key: "name", header: "Name", grow: true, min: 180,
filterText: (r) => r.name, sortValue: (r) => r.name, function zeile(r) {
render: (r) => <span className="strong">{r.name}</span> }, const g = r.kind === "group" ? r.entity : null;
{ 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: 240, filterText: (r) => r.ort, sortValue: (r) => r.ort,
render: (r) => {
// Editierbar, wenn es einen Wert zu verschieben gibt (leere Produktzeilen
// ohne Bedarf bleiben Text dort legt man über „+ Mindestbestand" an).
if (!isAdmin || r.soll == null) {
return r.scope === "global" ? <span className="muted">{r.ort}</span> : r.ort;
}
return ( return (
<select value={r.scope === "loc" ? r.locId : ""} style={{ marginTop: 0, minWidth: 150 }} <div className="field-inline" key={r.key} style={{ marginBottom: 0, alignItems: "center" }}>
title="Lagerort ändern der Mindestbestand wird verschoben" <span className="grow" style={{ flex: "1 1 auto", minWidth: 0 }}>
onChange={(e) => moveRow(r, e.target.value)}> <span className="strong">{r.name}</span>
<option value="">(Gesamt)</option> {g && <span className="badge accent" style={{ marginLeft: 6 }}>Gruppe</span>}
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)} {g?.child_ids?.length > 0 && (
</select> <span className="badge" style={{ marginLeft: 6 }}
); title="Bestand und Mindestbestand zählen die Untergruppen mit.">
} }, inkl. {g.child_ids.length} Untergruppen
{ key: "soll", header: "Mindestbestand", width: 190, </span>
sortValue: (r) => r.soll ?? -1, )}
render: (r) => (isAdmin ? ( </span>
<div className="field-inline" style={{ gap: "var(--sp-1)", flexWrap: "nowrap" }}>
<input type="number" step="any" min="0" style={{ marginTop: 0, minWidth: 80 }} {isAdmin ? (
<input type="number" step="any" min="0" style={{ marginTop: 0, width: 100 }}
key={r.soll ?? "none"} defaultValue={r.soll ?? ""} key={r.soll ?? "none"} defaultValue={r.soll ?? ""}
onBlur={(e) => saveSoll(r, e.target.value)} /> onBlur={(e) => saveSoll(r, e.target.value)} />
{r.soll != null && ( ) : <span style={{ width: 100 }}>{fmt(r.soll)}</span>}
<button type="button" className="btn-icon danger" title="Mindestbestand löschen"
onClick={() => deleteRow(r)}><Icon name="trash" size={15} /></button> {/* Einheit: bei Packungsartikeln und Gruppen mit Einheit umschaltbar. */}
)} {isAdmin && r.kind === "product" && hasPkg(r.entity) ? (
</div> <select value={pkgMode(r.entity) ? "pkg" : "disp"} style={{ marginTop: 0, width: 130 }}
) : (r.soll != null ? fmt(r.soll) : "")) },
{ key: "unit", header: "Einheit", width: 140, filterText: (r) => r.unit || "",
render: (r) => {
// Produkte mit Packung: Einheit umschaltbar (g/ml ↔ Glas/Dose).
if (isAdmin && r.kind === "product" && hasPkg(r.entity)) {
return (
<select value={pkgMode(r.entity) ? "pkg" : "disp"} style={{ marginTop: 0, minWidth: 110 }}
title="Einheit für den Mindestbestand umschalten" title="Einheit für den Mindestbestand umschalten"
onChange={(e) => toggleProductUnit(r.entity, e.target.value === "pkg")}> onChange={(e) => toggleProductUnit(r.entity, e.target.value === "pkg")}>
<option value="disp">{dispLabel(r.entity)}</option> <option value="disp">{dispLabel(r.entity)}</option>
<option value="pkg">{r.entity.package_label || "Packung"}</option> <option value="pkg">{r.entity.package_label || "Packung"}</option>
</select> </select>
); ) : isAdmin && g && g.min_stock_unit_id ? (
} <select value={grpPkgMode(g) ? "pkg" : "unit"} style={{ marginTop: 0, width: 130 }}
// 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" title="Einheit für den Mindestbestand umschalten"
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v = e.target.value;
@@ -408,13 +301,29 @@ export default function MinStock() {
{g.package_size > 0 && <option value="pkg">{g.package_label || "Packung"}</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> <option value="edit">{g.package_size > 0 ? "Gebinde ändern…" : "Gebinde festlegen…"}</option>
</select> </select>
) : (
<span className="muted small" style={{ width: 130 }}>{r.unit || ""}</span>
)}
<span className="muted small" style={{ width: 130, textAlign: "right" }}>
{r.bestand != null ? `Bestand ${fmt(r.bestand)} ${r.bestandUnit || ""}` : ""}
</span>
{isAdmin && (
<>
<select value={vonId(r.locId)} style={{ marginTop: 0, width: 150 }}
title="Ort ändern der Mindestbestand wird verschoben"
onChange={(e) => moveRow(r, e.target.value)}>
<option value={UEBERALL}>Überall</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<button type="button" className="btn-icon danger" title="Mindestbestand löschen"
onClick={() => deleteRow(r)}><Icon name="trash" size={15} /></button>
</>
)}
</div>
); );
} }
return <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 ( return (
<div> <div>
@@ -422,27 +331,35 @@ export default function MinStock() {
<div> <div>
<h1>Mindestbestände</h1> <h1>Mindestbestände</h1>
<div className="sub"> <div className="sub">
Gesamt- und Lagerort-Bedarfe von Produkten und Gruppen an einem Ort{isAdmin ? " Werte direkt editierbar" : ""}. Was soll wo immer da sein? Überall heißt: egal wo, Hauptsache im Haus
Käufe für einen Lagerort decken ihn mit ab.
</div> </div>
</div> </div>
{isAdmin && ( {isAdmin && (
<button className="btn primary" onClick={() => (showAdd ? resetAdd() : setShowAdd(true))}> <button className="btn primary" onClick={() => setDraft(draft ? null : { ...EMPTY_DRAFT })}>
<Icon name={showAdd ? "close" : "plus"} size={16} /> <Icon name={draft ? "close" : "plus"} size={16} />
{showAdd ? "Abbrechen" : "Mindestbestand"} {draft ? "Abbrechen" : "Mindestbestand"}
</button> </button>
)} )}
</div> </div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>} {error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{isAdmin && showAdd && ( {isAdmin && draft && (
<div className="card" style={{ marginBottom: "var(--sp-3)" }}> <div className="card" style={{ marginBottom: "var(--sp-3)" }}>
<div className="card-head"><Icon name="plus" /><h2>Mindestbestand hinzufügen</h2></div> <div className="card-head"><Icon name="plus" /><h2>Mindestbestand hinzufügen</h2></div>
<div className="row"> <div className="row">
<label className="grow" style={{ maxWidth: 360 }}>
Wo
<select value={draft.locId} onChange={(e) => setDraft({ ...draft, locId: e.target.value })}>
<option value={UEBERALL}>Überall (egal wo)</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</label>
<label className="seg-label"> <label className="seg-label">
Ziel Was
<div className="segmented"> <div className="segmented">
<button type="button" className={draft.kind === "product" ? "active" : ""} <button type="button" className={draft.kind === "product" ? "active" : ""}
onClick={() => setDraft({ ...draft, kind: "product", targetId: "" })}>Gegenstand / Lebensmittel</button> onClick={() => setDraft({ ...draft, kind: "product", targetId: "" })}>Artikel</button>
<button type="button" className={draft.kind === "group" ? "active" : ""} <button type="button" className={draft.kind === "group" ? "active" : ""}
onClick={() => setDraft({ ...draft, kind: "group", targetId: "" })}>Gruppe</button> onClick={() => setDraft({ ...draft, kind: "group", targetId: "" })}>Gruppe</button>
</div> </div>
@@ -450,15 +367,15 @@ export default function MinStock() {
</div> </div>
<div className="row"> <div className="row">
<label className="grow"> <label className="grow">
{draft.kind === "product" ? "Produkt / Gegenstand" : "Gruppe"} {draft.kind === "product" ? "Lebensmittel / Verbrauchsgegenstand" : "Gruppe"}
<select value={draft.targetId} onChange={(e) => setDraft({ ...draft, targetId: e.target.value })}> <select value={draft.targetId} onChange={(e) => setDraft({ ...draft, targetId: e.target.value })}>
<option value=""> wählen </option> <option value=""> wählen </option>
{(draft.kind === "product" {zielListe.map((t) => (
? 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}> <option key={t.id} value={t.id}>
{t.name}{draft.kind === "product" && t.brand ? ` · ${t.brand}` : ""} {t.name}
{draft.kind === "product" && t.brand ? ` · ${t.brand}` : ""}
{draft.kind === "group" && t.child_ids?.length
? ` (inkl. ${t.child_ids.length} Untergruppen)` : ""}
</option> </option>
))} ))}
</select> </select>
@@ -477,28 +394,6 @@ export default function MinStock() {
</label> </label>
</div> </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"> <div className="row">
<label className="grow" style={{ maxWidth: 300 }}> <label className="grow" style={{ maxWidth: 300 }}>
Menge{draftUnit ? ` (in ${draftUnit})` : ""} Menge{draftUnit ? ` (in ${draftUnit})` : ""}
@@ -508,7 +403,7 @@ export default function MinStock() {
</div> </div>
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}> <div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
<button className="btn primary" onClick={saveNew}>Speichern</button> <button className="btn primary" onClick={saveNew}>Speichern</button>
<button className="btn" onClick={resetAdd}>Abbrechen</button> <button className="btn" onClick={() => setDraft(null)}>Abbrechen</button>
</div> </div>
</div> </div>
)} )}
@@ -517,7 +412,7 @@ export default function MinStock() {
<div className="card" style={{ marginBottom: "var(--sp-3)" }}> <div className="card" style={{ marginBottom: "var(--sp-3)" }}>
<div className="card-head"><Icon name="package" /><h2>Gruppen-Gebinde festlegen</h2></div> <div className="card-head"><Icon name="package" /><h2>Gruppen-Gebinde festlegen</h2></div>
<p className="muted small" style={{ marginTop: 0 }}> <p className="muted small" style={{ marginTop: 0 }}>
Wie viel zählt 1 Packung" dieser Gruppe? Ein Richtwert die Produkte Wie viel zählt 1 Packung dieser Gruppe? Ein Richtwert die Produkte
der Gruppe dürfen unterschiedlich große Packungen haben. der Gruppe dürfen unterschiedlich große Packungen haben.
</p> </p>
<div className="row"> <div className="row">
@@ -539,10 +434,31 @@ export default function MinStock() {
</div> </div>
)} )}
<div className="card"> {loading && <div className="card"><p className="muted">Wird geladen</p></div>}
<DataTable id="min-stock" columns={columns} rows={rows} loading={loading}
getRowKey={(r) => r.key} empty="Noch keine Mindestbestände." /> {!loading && abschnitte.map((a) => (
<section className="card" key={a.id} style={{ marginBottom: "var(--sp-3)" }}>
<div className="card-head">
<Icon name="location" />
<h2>{a.label}</h2>
<span className="muted small">{a.zeilen.length} {a.zeilen.length === 1 ? "Eintrag" : "Einträge"}</span>
</div> </div>
<div className="stack">
{a.zeilen.length === 0 && (
<p className="muted small mt-0">Hier ist noch nichts hinterlegt.</p>
)}
{a.zeilen.map(zeile)}
{isAdmin && (
<div className="field-inline" style={{ marginBottom: 0 }}>
<button type="button" className="btn"
onClick={() => setDraft({ ...EMPTY_DRAFT, locId: String(a.id) })}>
<Icon name="plus" size={16} />hinzufügen
</button>
</div>
)}
</div>
</section>
))}
</div> </div>
); );
} }

View File

@@ -820,8 +820,14 @@ export default function ProductForm() {
</label> </label>
</div> </div>
<div className="row"> <div className="row">
{/* Beim Anlegen gibt es den Artikel noch nicht, also auch keine
Ort-Liste hier bleibt das eine Feld, das als „Überall“ landet.
Beim Bearbeiten steht darunter die vollständige Ort-Liste. */}
{isNew && (
<label className="grow"> <label className="grow">
Mindestbestand <span className="tip" title="Gilt „überall“ egal wo im Haus. Weitere Orte lassen sich nach dem Anlegen ergänzen.">
Mindestbestand (überall)
</span>
<div className="field-inline"> <div className="field-inline">
<input type="number" step="any" value={form.min_stock} <input type="number" step="any" value={form.min_stock}
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" /> onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" />
@@ -838,6 +844,7 @@ export default function ProductForm() {
</select> </select>
</div> </div>
</label> </label>
)}
<label className="grow"> <label className="grow">
<span className="tip" title="Zählt Bestände mehrerer Artikel zusammen. Der EAN-Code dieses Artikels erscheint danach automatisch bei der Gruppe."> <span className="tip" title="Zählt Bestände mehrerer Artikel zusammen. Der EAN-Code dieses Artikels erscheint danach automatisch bei der Gruppe.">
Gruppe Gruppe
@@ -853,25 +860,27 @@ export default function ProductForm() {
<div style={{ marginTop: "var(--sp-2)", marginBottom: "var(--sp-5)" }}> <div style={{ marginTop: "var(--sp-2)", marginBottom: "var(--sp-5)" }}>
<div className="card-head" style={{ marginBottom: "var(--sp-1)" }}> <div className="card-head" style={{ marginBottom: "var(--sp-1)" }}>
<Icon name="location" size={16} /> <Icon name="location" size={16} />
<h3 style={{ margin: 0 }}>Mindestbestand je Lagerort</h3> <h3 style={{ margin: 0 }}>Mindestbestand</h3>
</div> </div>
<p className="muted small mt-0"> <p className="muted small mt-0">
Zusätzlich zum Gesamt-Mindestbestand: eigener Bedarf je Lagerort (z.B. Was soll wo immer da sein? Überall heißt: egal wo, Hauptsache im
Ferienhaus, Zuhause). Menge in {product.unit_name || "Stück"}. Haus Käufe für einen Lagerort decken ihn mit ab. Menge in{" "}
Wird separat gespeichert. {product.unit_name || "Stück"}. Wird sofort gespeichert.
</p> </p>
<LocationMinStock <LocationMinStock
locations={locations} locations={locations}
// Der Server liefert Basiseinheiten; hier in der Anzeigeeinheit
// des Artikels erfassen (g/ml/Stück), wie die Beschriftung sagt.
initial={(product.location_min_stocks || []).map( initial={(product.location_min_stocks || []).map(
(e) => ({ ...e, min_stock: e.min_stock * artToDisp(product) }))} (e) => ({ ...e, min_stock: e.min_stock / (product.unit_factor || 1) }))}
unitLabel={product.unit_name || "Stück"} unitLabel={product.unit_name || "Stück"}
onError={(m) => toast(m, "warn")} onError={(m) => toast(m, "warn")}
onSave={async (list) => { onSave={async (list) => {
const f = artToDisp(product); const f = product.unit_factor || 1;
const artList = list.map((e) => ({ ...e, min_stock: e.min_stock / f })); const basisListe = list.map((e) => ({ ...e, min_stock: e.min_stock * f }));
const updated = await api.setProductLocationMinStock(product.id, artList); const updated = await api.setProductLocationMinStock(product.id, basisListe);
setProduct(updated); setProduct(updated);
toast("Bedarfe je Lagerort gespeichert."); toast("Mindestbestände gespeichert.");
}} }}
/> />
</div> </div>

View File

@@ -33,7 +33,7 @@ export default function ShoppingList() {
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>} {error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{byLocation.length > 0 && ( {byLocation.length > 0 && (
<div className="sub" style={{ marginBottom: "var(--sp-2)" }}>Gesamt</div> <div className="sub" style={{ marginBottom: "var(--sp-2)" }}>Überall (egal wo)</div>
)} )}
<div className="card"> <div className="card">
@@ -48,6 +48,11 @@ export default function ShoppingList() {
<label> <label>
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} /> <input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
<span className="badge accent">Gruppe</span> <span className="badge accent">Gruppe</span>
{it.subgroup_count > 0 && (
<span className="badge" title="Bestand und Fehlmenge zählen die Untergruppen mit; Käufe für Untergruppen sind bereits abgezogen.">
inkl. {it.subgroup_count} Untergruppen
</span>
)}
<span className="item-name">{it.name}</span> <span className="item-name">{it.name}</span>
</label> </label>
<span className="muted small"> <span className="muted small">
@@ -88,6 +93,11 @@ export default function ShoppingList() {
<label> <label>
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} /> <input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
<span className="badge accent">Gruppe</span> <span className="badge accent">Gruppe</span>
{it.subgroup_count > 0 && (
<span className="badge" title="Bestand und Fehlmenge zählen die Untergruppen mit; Käufe für Untergruppen sind bereits abgezogen.">
inkl. {it.subgroup_count} Untergruppen
</span>
)}
<span className="item-name">{it.name}</span> <span className="item-name">{it.name}</span>
</label> </label>
<span className="muted small"> <span className="muted small">
@@ -118,6 +128,14 @@ export default function ShoppingList() {
<p className="muted small"> <p className="muted small">
Das Abhaken hilft beim Einkaufen und wird (noch) nicht dauerhaft gespeichert. Das Abhaken hilft beim Einkaufen und wird (noch) nicht dauerhaft gespeichert.
</p> </p>
<p className="muted small">
Bedarfe sind gegeneinander verrechnet: Was du für einen Lagerort oder eine
Untergruppe kaufst, deckt Überall bzw. die Obergruppe mit ab. Hängt
dieselbe Untergruppe unter zwei Obergruppen, die einander <em>nicht</em>
enthalten (Grillwurst unter Wurst und unter Grillgut), stehen beide Bedarfe
getrennt da ein Kauf kann dann beide decken. Ein eigener Mindestbestand auf
der gemeinsamen Untergruppe verrechnet auch das.
</p>
</div> </div>
); );
} }

View File

@@ -46,6 +46,14 @@ export function unitShort(baseUnit) {
return BASE_UNIT_SHORT[baseUnit] || baseUnit; return BASE_UNIT_SHORT[baseUnit] || baseUnit;
} }
// Basiseinheit-Kürzel zur Einheiten-ART (weight/volume/count), wie sie Gruppen
// über `kind` melden. Mindestbestände stehen immer in dieser Einheit.
export const KIND_SHORT = { weight: "g", volume: "ml", count: "Stück" };
export function kindShort(kind) {
return KIND_SHORT[kind] || "";
}
// Auswahlmöglichkeiten für Menge beim Ein-/Auslagern eines Produkts. // Auswahlmöglichkeiten für Menge beim Ein-/Auslagern eines Produkts.
export function unitOptions(product) { export function unitOptions(product) {
const opts = [{ value: baseUnitToInput(product.base_unit), label: unitShort(product.base_unit) }]; const opts = [{ value: baseUnitToInput(product.base_unit), label: unitShort(product.base_unit) }];