Files
Vorrania/web/src/pages/MinStock.jsx
T
ScarriffleandClaude Opus 5 7cb61d226f Lagerorte: ueberall ein aufklappbarer Baum statt flacher Pfadliste
Die Lagerort-Auswahl schrieb in jeder Zeile den ganzen Pfad aus
("Hedingen → Guido Zimmer → Kallax → Fach Oben Links → Oben"). Bei einem
tiefen Baum ist das eine Wand aus Wiederholungen, und Geschwister stehen
nur zufaellig beieinander.

Jetzt dasselbe Auswahlfeld wie bei den Kategorien: eingerueckter Baum,
Aeste ein- und ausklappbar. Geschlossen steht weiter der volle Pfad da -
"Oben" allein waere nicht eindeutig, genau dafuer war der Pfad gedacht.

CategorySelect konnte das schon und war auf Lagerort-Codes vorbereitet
(seine IDs duerfen Zeichenketten sein). Dazu kam nur LocationSelect als
duenne Huelle: Baumaufbau und die Umrechnung "" <-> null, damit die 16
Aufrufstellen weiter mit dem leeren String fuer "kein Ort" arbeiten.

Zwei Kleinigkeiten dabei: CategorySelect bekommt showRoot, weil bei den
Mindestbestaenden "nichts gewaehlt" kein gueltiger Wert ist - dort heisst
die oberste Zeile "Ueberall". Und LocationSelect sortiert Geschwister
selbst nach Namen; der Server sortiert zeichenweise, dort stuende "Fach
oben Rechts" hinter "Fach Unten Rechts".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:37:00 +02:00

469 lines
21 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { api } from "../api";
import { useAuth } from "../auth";
import { useConfirm } from "../confirm";
import Icon from "../components/Icon";
import { locationOptions, locationPathById } from "../locationPath";
import LocationSelect from "../components/LocationSelect";
import { gruppenOptionen } from "../groupGraph";
import { anzahlWort, fmt, gebinde, grpFactor, grpPkgMode, grpUnit, kindShort } 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;
// Gebinde-Umschaltung: hat das Produkt eine Packungsgröße, lässt sich der
// Mindestbestand wahlweise in Packungen (Glas/Dose) statt in g/ml erfassen.
const hasPkg = (p) => !!(p.package_size && p.package_size > 0);
const pkgMode = (p) => hasPkg(p) && !!p.min_stock_in_packages;
// Basiseinheiten je gewählter Erfassungseinheit (Packung ODER Anzeigeeinheit).
const effFactor = (p) => (pkgMode(p) ? p.package_size : dispFactor(p));
// 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));
// Gruppen rechnen in ihrer verwalteten Einheit ODER im Gruppen-Gebinde –
// grpPkgMode/grpFactor/grpUnit stehen in units.js, weil Groups.jsx sie genauso
// braucht (und ohne sie den Bestand falsch beschriftet hat).
// „Ü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);
/**
* Mindestbestände – nach LAGERORT gruppiert.
*
* 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 = { locId: UEBERALL, kind: "product", targetId: "", menge: "", pkg: false };
export default function MinStock() {
const { isAdmin } = useAuth();
const confirm = useConfirm();
const [products, setProducts] = useState([]);
const [groups, setGroups] = useState([]);
const [locations, setLocations] = useState([]);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
// „+ Mindestbestand": Ort steht meist schon fest (Abschnitt), dann fehlen nur
// noch Ziel und Menge.
const [draft, setDraft] = useState(null);
// Gruppen-Gebinde festlegen/ändern: { id, size, label, baseShort }.
const [gebindeDlg, setGebindeDlg] = useState(null);
async function load() {
setLoading(true);
try {
const [ps, gs, ls] = await Promise.all([
api.listProducts("", ""), api.listGroups(), api.listLocations(),
]);
setProducts(ps); setGroups(gs); setLocations(ls);
} catch (err) { setError(err.message); } finally { setLoading(false); }
}
useEffect(() => { load(); }, []);
// Eine Zeile je vorhandenem Eintrag – keine Leerzeilen mehr für jeden Artikel.
const zeilen = useMemo(() => {
const out = [];
const bauen = (kind, entity, name, faktor, einheit) => {
for (const e of entity.location_min_stocks || []) {
const soll = e.min_stock / faktor;
out.push({
key: `${kind}:${entity.id}:${vonId(e.location_id)}`,
kind, entity, name, locId: e.location_id,
soll, unit: einheit(soll),
bestand: e.stock != null ? e.stock / faktor : null,
bestandUnit: einheit(e.stock != null ? e.stock / faktor : 1),
});
}
};
for (const p of products.filter(canHaveMin)) {
bauen("product", p, p.name, effFactor(p), (m) => effUnit(p, m));
}
for (const g of groups) {
bauen("group", g, g.name, grpFactor(g), (m) => grpUnit(g, m));
}
return out;
}, [products, groups]);
// Abschnitte: „Überall" zuerst, danach die Lagerorte alphabetisch nach Pfad.
const abschnitte = useMemo(() => {
const orte = [{ id: UEBERALL, label: "Überall (egal wo)" }, ...locationOptions(locations)];
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 treffer = out.find((e) => gleicherOrt(e.location_id, locId));
if (treffer) treffer.min_stock = menge;
else out.push({ location_id: zuId(vonId(locId)), min_stock: menge });
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) {
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;
setError(null);
try {
await speichern(row.kind, row.entity, row.locId, num == null ? null : num * faktorVon(row));
} catch (err) { setError(err.message); }
}
// Einheit umschalten (Anzeigeeinheit ↔ Packung). Der gespeicherte Wert bleibt
// physisch gleich – er steht in Basiseinheiten, es wechselt nur die Anzeige.
async function toggleProductUnit(p, wantPkg) {
if (wantPkg === pkgMode(p)) return;
setError(null);
try {
await api.updateProduct(p.id, { min_stock_in_packages: wantPkg });
await load();
} catch (err) { setError(err.message); }
}
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); }
}
async function deleteRow(row) {
const ziel = row.kind === "group" ? `Gruppe „${row.name}“` : `„${row.name}“`;
const ort = row.locId == null ? "„Überall“" : `am Lagerort „${locationPathById(row.locId, locations)}“`;
if (!(await confirm({
title: "Mindestbestand löschen?",
message: `Mindestbestand von ${ziel} ${ort} entfernen?`,
confirmLabel: "Löschen", danger: true,
}))) return;
setError(null);
try {
await speichern(row.kind, row.entity, row.locId, null);
} catch (err) { setError(err.message); }
}
// Einen Eintrag an einen anderen Ort verschieben. Der Wert bleibt (beides in
// Basiseinheiten), der alte Ort fällt weg – so entsteht keine Dublette.
async function moveRow(row, zielOrt) {
if (gleicherOrt(row.locId, zielOrt)) return;
const belegt = (row.entity.location_min_stocks || [])
.some((e) => gleicherOrt(e.location_id, zielOrt));
if (belegt) {
const ok = await confirm({
title: "Ort schon belegt",
message: `Für „${row.name}“ gibt es dort bereits einen Mindestbestand. Mit dem verschobenen Wert überschreiben?`,
confirmLabel: "Überschreiben", danger: true,
});
if (!ok) return;
}
setError(null);
try {
const base = row.soll * faktorVon(row);
let liste = mergeLoc(row.entity.location_min_stocks, row.locId, null);
liste = mergeLoc(liste, zielOrt, base);
if (row.kind === "product") await api.setProductLocationMinStock(row.entity.id, liste);
else await api.setGroupLocationMinStock(row.entity.id, liste);
await load();
} catch (err) { setError(err.message); }
}
// ---- Hinzufügen ----
const draftTarget = !draft ? null : (draft.kind === "product"
? products.find((p) => String(p.id) === String(draft.targetId))
: 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() {
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; }
try {
const faktor = draft.kind === "product"
? (draftUsePkg ? draftTarget.package_size : dispFactor(draftTarget))
: grpFactor(draftTarget);
await speichern(draft.kind, draftTarget, zuId(draft.locId), num * faktor);
setDraft(null);
} catch (err) { setError(err.message); }
}
const zielListe = !draft ? [] : (draft.kind === "product"
? products.filter(canHaveMin).slice().sort((a, b) => a.name.localeCompare(b.name, "de"))
: groups.slice().sort((a, b) => a.name.localeCompare(b.name, "de")));
function zeile(r) {
const g = r.kind === "group" ? r.entity : null;
return (
<div className="min-zeile" key={r.key}>
<span className="name">
<span className="strong">{r.name}</span>
{g && <span className="badge accent" style={{ marginLeft: 6 }}>Gruppe</span>}
{g?.child_ids?.length > 0 && (
<span className="badge" style={{ marginLeft: 6 }}
title="Bestand und Mindestbestand zählen die Untergruppen mit.">
inkl. {anzahlWort(g.child_ids.length, "Untergruppe", "Untergruppen")}
</span>
)}
</span>
{isAdmin ? (
<input type="number" step="any" min="0"
key={r.soll ?? "none"} defaultValue={r.soll ?? ""}
onBlur={(e) => saveSoll(r, e.target.value)} />
) : <span>{fmt(r.soll)}</span>}
{/* Einheit: bei Packungsartikeln und Gruppen mit Einheit umschaltbar. */}
{isAdmin && r.kind === "product" && hasPkg(r.entity) ? (
<select value={pkgMode(r.entity) ? "pkg" : "disp"}
title="Einheit für den Mindestbestand umschalten"
onChange={(e) => toggleProductUnit(r.entity, e.target.value === "pkg")}>
<option value="disp">{dispLabel(r.entity)}</option>
<option value="pkg">{r.entity.package_label || "Packung"}</option>
</select>
) : isAdmin && g && g.min_stock_unit_id ? (
<select value={grpPkgMode(g) ? "pkg" : "unit"}
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>
) : (
<span className="muted small">{r.unit || "–"}</span>
)}
<span className="muted small bestand">
{r.bestand != null ? `Bestand ${fmt(r.bestand)} ${r.bestandUnit || ""}` : ""}
</span>
{isAdmin && (
<>
<LocationSelect value={vonId(r.locId)} locations={locations}
title="Ort ändern – der Mindestbestand wird verschoben"
showRoot={false} extraOptions={[{ value: UEBERALL, label: "Überall" }]}
onChange={(id) => moveRow(r, id)} />
<button type="button" className="btn-icon danger" title="Mindestbestand löschen"
onClick={() => deleteRow(r)}><Icon name="trash" size={15} /></button>
</>
)}
{/* Ohne Adminrechte bleiben die letzten beiden Spalten leer, damit die
Zeilen trotzdem fluchten. */}
{!isAdmin && (<><span /><span /></>)}
</div>
);
}
return (
<div>
<div className="page-head">
<div>
<h1>Mindestbestände</h1>
<div className="sub">
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>
{isAdmin && (
<button className="btn primary" onClick={() => setDraft(draft ? null : { ...EMPTY_DRAFT })}>
<Icon name={draft ? "close" : "plus"} size={16} />
{draft ? "Abbrechen" : "Mindestbestand"}
</button>
)}
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{isAdmin && draft && (
<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="grow" style={{ maxWidth: 360 }}>
Wo
<LocationSelect value={draft.locId} locations={locations}
showRoot={false} extraOptions={[{ value: UEBERALL, label: "Überall (egal wo)" }]}
onChange={(id) => setDraft({ ...draft, locId: id })} />
</label>
<label className="seg-label">
Was
<div className="segmented">
<button type="button" className={draft.kind === "product" ? "active" : ""}
onClick={() => setDraft({ ...draft, kind: "product", targetId: "" })}>Artikel</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" ? "Lebensmittel / Verbrauchsgegenstand" : "Gruppe"}
<select value={draft.targetId} onChange={(e) => setDraft({ ...draft, targetId: e.target.value })}>
<option value="">– wählen –</option>
{/* Gruppen als Baum, damit sichtbar ist, was unter was hängt. */}
{draft.kind === "group"
? gruppenOptionen(groups).map((o) => (
<option key={o.key} value={o.id}>
{o.label}
{o.gruppe.child_ids?.length
? ` (inkl. ${anzahlWort(o.gruppe.child_ids.length, "Untergruppe", "Untergruppen")})` : ""}
</option>
))
: zielListe.map((t) => (
<option key={t.id} value={t.id}>
{t.name}{t.brand ? ` · ${t.brand}` : ""}
</option>
))}
</select>
</label>
</div>
{draft.kind === "product" && draftTarget && hasPkg(draftTarget) && (
<div className="row">
<label className="seg-label">
Einheit
<div className="segmented">
<button type="button" className={!draft.pkg ? "active" : ""}
onClick={() => setDraft({ ...draft, pkg: false })}>{dispLabel(draftTarget)}</button>
<button type="button" className={draft.pkg ? "active" : ""}
onClick={() => setDraft({ ...draft, pkg: true })}>{draftTarget.package_label || "Packung"}</button>
</div>
</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={() => setDraft(null)}>Abbrechen</button>
</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>
)}
{loading && <div className="card"><p className="muted">Wird geladen…</p></div>}
{!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 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>
);
}