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:
146
web/src/components/GroupParentSelect.jsx
Normal file
146
web/src/components/GroupParentSelect.jsx
Normal 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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,36 @@ import { useState } from "react";
|
||||
import Icon from "./Icon";
|
||||
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 =
|
||||
* 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 }) {
|
||||
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 [ok, setOk] = useState(false);
|
||||
|
||||
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() {
|
||||
if (!frei.length) return;
|
||||
@@ -36,7 +52,7 @@ export default function LocationMinStock({ locations, initial = [], unitLabel =
|
||||
try {
|
||||
const list = rows
|
||||
.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);
|
||||
setOk(true);
|
||||
} 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 (
|
||||
<div className="stack">
|
||||
{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) => (
|
||||
<div className="field-inline" key={i} style={{ marginBottom: 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 })}>
|
||||
{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>
|
||||
</label>
|
||||
<label style={{ margin: 0, width: 130 }}>
|
||||
@@ -74,7 +86,7 @@ export default function LocationMinStock({ locations, initial = [], unitLabel =
|
||||
))}
|
||||
<div className="field-inline" style={{ marginBottom: 0 }}>
|
||||
<button type="button" className="btn" onClick={addRow} disabled={!frei.length}>
|
||||
<Icon name="plus" size={16} />Lagerort
|
||||
<Icon name="plus" size={16} />Mindestbestand
|
||||
</button>
|
||||
<button type="button" className="btn primary" onClick={save} disabled={busy}>
|
||||
<Icon name="check" size={16} />{busy ? "Speichern…" : "Bedarfe speichern"}
|
||||
|
||||
@@ -345,6 +345,9 @@ function KarteEinkaufsliste({ props: karteProps }) {
|
||||
<label>
|
||||
<input type="checkbox" checked={!!erledigt[key]} onChange={(e) => abhaken(key, e.target.checked)} />
|
||||
<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>
|
||||
</label>
|
||||
<span className="muted small">
|
||||
@@ -374,7 +377,7 @@ function KarteEinkaufsliste({ props: karteProps }) {
|
||||
<div className="card-stack">
|
||||
{!gesamtLeer && (
|
||||
<div>
|
||||
<div className="muted small" style={{ fontWeight: 600 }}>Gesamt</div>
|
||||
<div className="muted small" style={{ fontWeight: 600 }}>Überall</div>
|
||||
{abschnitt(daten.gruppen, daten.produkte, "")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
77
web/src/groupGraph.js
Normal file
77
web/src/groupGraph.js
Normal 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);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import BarcodeList from "../components/BarcodeList";
|
||||
import Icon from "../components/Icon";
|
||||
import DataTable from "../components/DataTable";
|
||||
import LocationMinStock from "../components/LocationMinStock";
|
||||
import { fmt } from "../units";
|
||||
import GroupParentSelect from "../components/GroupParentSelect";
|
||||
import { fmt, kindShort } from "../units";
|
||||
|
||||
export default function Groups() {
|
||||
const confirm = useConfirm();
|
||||
@@ -14,7 +15,9 @@ export default function Groups() {
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [units, setUnits] = 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 [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -45,8 +48,11 @@ export default function Groups() {
|
||||
name: form.name,
|
||||
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),
|
||||
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();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -66,7 +72,9 @@ export default function Groups() {
|
||||
async function remove(group) {
|
||||
const ok = await confirm({
|
||||
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",
|
||||
danger: true,
|
||||
});
|
||||
@@ -81,6 +89,7 @@ export default function Groups() {
|
||||
}
|
||||
|
||||
const selected = groups.find((g) => g.id === selectedId) || null;
|
||||
const nameById = Object.fromEntries(groups.map((g) => [g.id, g.name]));
|
||||
|
||||
const columns = [
|
||||
{ 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 }); }} />
|
||||
) : <span className="strong">{g.name}</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>
|
||||
);
|
||||
} },
|
||||
{ key: "products", header: "Produkte", width: 100, align: "num",
|
||||
sortValue: (g) => g.product_count, render: (g) => <span className="muted">{g.product_count}</span> },
|
||||
{ key: "parents", header: "Obergruppen", width: 220,
|
||||
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",
|
||||
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,
|
||||
@@ -190,17 +234,18 @@ export default function Groups() {
|
||||
<section className="card">
|
||||
<div className="card-head">
|
||||
<Icon name="location" />
|
||||
<h2>Mindestbestand je Lagerort</h2>
|
||||
<h2>Mindestbestand</h2>
|
||||
</div>
|
||||
<p className="muted small mt-0">
|
||||
Bedarf dieser Gruppe je Lagerort (z.B. Ferienhaus, Zuhause), zusätzlich zum
|
||||
Gesamt-Mindestbestand{selected.min_stock_unit_name ? ` (in ${selected.min_stock_unit_name})` : ""}.
|
||||
Bedarf dieser Gruppe je Lagerort — „Überall“ heißt: egal wo, Hauptsache
|
||||
die Menge ist im Haus. Käufe für einen Lagerort decken „Überall“ mit ab.
|
||||
Mengen in {kindShort(selected.kind) || "Basiseinheiten"}.
|
||||
</p>
|
||||
<LocationMinStock
|
||||
key={selected.id}
|
||||
locations={locations}
|
||||
initial={selected.location_min_stocks || []}
|
||||
unitLabel={selected.min_stock_unit_name || ""}
|
||||
unitLabel={kindShort(selected.kind)}
|
||||
onError={setError}
|
||||
onSave={async (list) => {
|
||||
await api.setGroupLocationMinStock(selected.id, list);
|
||||
@@ -233,10 +278,22 @@ export default function Groups() {
|
||||
</select>
|
||||
</label>
|
||||
</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>
|
||||
<p className="muted small">
|
||||
Der Bestand der Gruppe summiert nur Produkte, die zur gewählten Einheit passen.
|
||||
Namen lassen sich in der Tabelle direkt ändern.
|
||||
Bestand und Mindestbestand einer Gruppe zählen auch die Artikel ihrer
|
||||
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>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -3,11 +3,8 @@ import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import { useConfirm } from "../confirm";
|
||||
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 { fmt, gebinde } from "../units";
|
||||
import { fmt, gebinde, 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
|
||||
@@ -18,9 +15,6 @@ const dispLabel = (p) => (p.unit_name || "Stück");
|
||||
// Mindestbestand gibt es nur bei Charge-Artikeln: Lebensmittel und
|
||||
// Verbrauchsgegenstände (bulk). „Menge je Lagerort" und Einzelstücke nicht.
|
||||
const canHaveMin = (p) => p.tracking !== "object" || p.bulk === true;
|
||||
// Artikeleinheit (Packung, sonst Anzeigeeinheit): nur noch nötig, um die je-Lagerort-
|
||||
// Mindestbestände umzurechnen, die in Artikeleinheiten gespeichert sind.
|
||||
const articleUnit = (p) => (p.package_size && p.package_size > 0 ? p.package_size : (p.unit_factor || 1));
|
||||
// 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);
|
||||
@@ -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));
|
||||
|
||||
// Gruppen: analog, mit dem GRUPPEN-Gebinde (Richtwert der Gruppe, nicht der
|
||||
// einzelnen Produkte). Packungen nur möglich, wenn die Gruppen-Einheit (Art)
|
||||
// bekannt ist.
|
||||
// einzelnen Produkte).
|
||||
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)
|
||||
? gebinde(menge, g.package_label || "Packung")
|
||||
: (g.min_stock_unit_name || ""));
|
||||
const kindShort = (kind) => ({ weight: "g", volume: "ml", count: "Stück" }[kind] || "");
|
||||
: (g.min_stock_unit_name || kindShort(g.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
|
||||
* Bedarfe je Lagerort, inline editierbar. Ergänzt die einzelnen Produkt-/Gruppen-
|
||||
* Formulare um einen Überblick an einem Ort.
|
||||
* 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 = { kind: "product", targetId: "", scope: "global", locId: "", menge: "", pkg: false };
|
||||
const EMPTY_DRAFT = { locId: UEBERALL, kind: "product", targetId: "", menge: "", pkg: false };
|
||||
|
||||
export default function MinStock() {
|
||||
const { isAdmin } = useAuth();
|
||||
@@ -52,119 +59,96 @@ export default function MinStock() {
|
||||
const [products, setProducts] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [locations, setLocations] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// „+ hinzufügen"-Dialog: Ziel (Produkt/Gruppe) → Geltung (Gesamt/Lagerort) → Menge.
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [draft, setDraft] = useState(EMPTY_DRAFT);
|
||||
// „+ 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, cs] = await Promise.all([
|
||||
api.listProducts("", ""), api.listGroups(), api.listLocations(), api.listCategories(),
|
||||
const [ps, gs, ls] = await Promise.all([
|
||||
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); }
|
||||
}
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const catInfo = useMemo(() => categoryInfoMap(categories), [categories]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
// Eine Zeile je vorhandenem Eintrag – keine Leerzeilen mehr für jeden Artikel.
|
||||
const zeilen = useMemo(() => {
|
||||
const out = [];
|
||||
for (const p of products.filter(canHaveMin)) {
|
||||
const gSoll = p.min_stock != null ? p.min_stock / effFactor(p) : null;
|
||||
const bauen = (kind, entity, name, faktor, einheit) => {
|
||||
for (const e of entity.location_min_stocks || []) {
|
||||
const soll = e.min_stock / faktor;
|
||||
out.push({
|
||||
key: `p:${p.id}:global`, kind: "product", scope: "global", entity: p,
|
||||
name: p.name, catId: p.category_id, ort: "(Gesamt)",
|
||||
// In der gewählten Einheit: Anzeigeeinheit (g/ml) ODER Packung. min_stock
|
||||
// ist in Basiseinheiten gespeichert.
|
||||
soll: gSoll,
|
||||
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),
|
||||
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) {
|
||||
out.push({
|
||||
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),
|
||||
});
|
||||
}
|
||||
bauen("group", g, g.name, grpFactor(g), (m) => grpUnit(g, m));
|
||||
}
|
||||
return out;
|
||||
}, [products, groups, locations]);
|
||||
}, [products, groups]);
|
||||
|
||||
// Eine Lagerort-Bedarfsliste mit einem geänderten/neuen/entfernten Eintrag neu
|
||||
// bauen. Der Endpunkt ersetzt die komplette Liste – neue Lagerorte müssen also
|
||||
// ergänzt (nicht nur bestehende geändert) werden, sonst wird nie einer angelegt.
|
||||
const mergeLoc = (list, locId, num) => {
|
||||
// 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 hit = out.find((e) => String(e.location_id) === String(locId));
|
||||
if (hit) hit.min_stock = num;
|
||||
else out.push({ location_id: locId, min_stock: num });
|
||||
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 {
|
||||
if (row.kind === "product" && row.scope === "global") {
|
||||
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();
|
||||
await speichern(row.kind, row.entity, row.locId, num == null ? null : num * faktorVon(row));
|
||||
} catch (err) { setError(err.message); }
|
||||
}
|
||||
|
||||
// Einheit des Mindestbestands umschalten (Anzeigeeinheit ↔ Packung). Der
|
||||
// gespeicherte Wert bleibt physisch gleich (global in Basiseinheiten, je Ort in
|
||||
// Artikeleinheiten) – es wechselt nur, in welcher Einheit angezeigt/erfasst wird.
|
||||
// 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);
|
||||
@@ -174,9 +158,6 @@ export default function MinStock() {
|
||||
} 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; }
|
||||
@@ -210,97 +191,54 @@ export default function MinStock() {
|
||||
} 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) {
|
||||
const ziel = row.kind === "group" ? `Gruppe „${row.name}"` : `„${row.name}"`;
|
||||
const wo = row.scope === "loc" ? ` am Lagerort „${row.ort}"` : "";
|
||||
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}${wo} entfernen?`,
|
||||
message: `Mindestbestand von ${ziel} ${ort} entfernen?`,
|
||||
confirmLabel: "Löschen", danger: true,
|
||||
}))) return;
|
||||
setError(null);
|
||||
try {
|
||||
if (row.kind === "product" && row.scope === "global") {
|
||||
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();
|
||||
await speichern(row.kind, row.entity, row.locId, null);
|
||||
} catch (err) { setError(err.message); }
|
||||
}
|
||||
|
||||
// Einen bestehenden Mindestbestand auf einen anderen Ort verschieben: „(Gesamt)"
|
||||
// (ziel = "") oder ein Lagerort. Der Wert bleibt, der bisherige Geltungsbereich
|
||||
// wird geleert – so entsteht keine Dublette. Produkt-Werte werden zwischen
|
||||
// Anzeige-/Artikel-/Basiseinheit umgerechnet (wie in saveSoll), Gruppen nicht.
|
||||
async function moveRow(row, ziel) {
|
||||
const aktuell = row.scope === "loc" ? row.locId : "";
|
||||
if (String(ziel) === String(aktuell)) return;
|
||||
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))) {
|
||||
// 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: "Lagerort schon belegt",
|
||||
message: `Für „${row.name}" gibt es an diesem Lagerort bereits einen Mindestbestand. Mit dem verschobenen Wert überschreiben?`,
|
||||
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 {
|
||||
if (row.kind === "product") {
|
||||
const p = ent;
|
||||
const artikel = (row.soll * effFactor(p)) / articleUnit(p);
|
||||
let liste = p.location_min_stocks || [];
|
||||
if (row.scope === "loc") liste = mergeLoc(liste, row.locId, null); // alten Ort raus
|
||||
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);
|
||||
}
|
||||
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); }
|
||||
}
|
||||
|
||||
// Ausgewähltes Ziel des Dialogs + dessen Einheit (Menge wird darin erfasst).
|
||||
const draftTarget = draft.kind === "product"
|
||||
? products.find((p) => String(p.id) === String(draft.targetId))
|
||||
: groups.find((g) => String(g.id) === String(draft.targetId));
|
||||
const 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") : "");
|
||||
// ---- Hinzufügen ----
|
||||
|
||||
function resetAdd() {
|
||||
setShowAdd(false);
|
||||
setDraft(EMPTY_DRAFT);
|
||||
}
|
||||
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);
|
||||
@@ -308,95 +246,50 @@ export default function MinStock() {
|
||||
const num = roh === "" ? null : Number(roh);
|
||||
if (!draft.targetId) { setError("Bitte ein Ziel wählen."); return; }
|
||||
if (num == null || Number.isNaN(num) || num <= 0) { setError("Bitte eine Menge größer 0 angeben."); return; }
|
||||
if (draft.scope === "loc" && !draft.locId) { setError("Bitte einen Lagerort wählen."); return; }
|
||||
try {
|
||||
if (draft.kind === "product") {
|
||||
const p = draftTarget;
|
||||
// In der gewählten Einheit erfasst (Anzeigeeinheit ODER Packung).
|
||||
const f = draftUsePkg ? p.package_size : dispFactor(p);
|
||||
if (draft.scope === "global") {
|
||||
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();
|
||||
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 columns = [
|
||||
{ key: "art", header: "Art", width: 96, filterText: (r) => (r.kind === "group" ? "Gruppe" : "Produkt"),
|
||||
render: (r) => <span className="badge nowrap">{r.kind === "group" ? "Gruppe" : "Produkt"}</span> },
|
||||
{ key: "name", header: "Name", grow: true, min: 180,
|
||||
filterText: (r) => r.name, sortValue: (r) => r.name,
|
||||
render: (r) => <span className="strong">{r.name}</span> },
|
||||
{ key: "category", header: "Kategorie", width: 200,
|
||||
filterText: (r) => (r.catId != null ? (catInfo.get(r.catId)?.path || "") : ""),
|
||||
filterValues: (r) => (r.catId != null ? (catInfo.get(r.catId)?.tokens || [""]) : [""]),
|
||||
filterOptionLabel: (v) => (v === "" ? "(Leere)" : <CategoryPathLabel parts={pathParts(v)} />),
|
||||
render: (r) => (r.catId != null
|
||||
? <CategoryPathLabel parts={catInfo.get(r.catId)?.parts} fallback="–" />
|
||||
: <span className="muted">–</span>) },
|
||||
{ key: "ort", header: "Ort", width: 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;
|
||||
}
|
||||
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 (
|
||||
<select value={r.scope === "loc" ? r.locId : ""} style={{ marginTop: 0, minWidth: 150 }}
|
||||
title="Lagerort ändern – der Mindestbestand wird verschoben"
|
||||
onChange={(e) => moveRow(r, e.target.value)}>
|
||||
<option value="">(Gesamt)</option>
|
||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
);
|
||||
} },
|
||||
{ key: "soll", header: "Mindestbestand", width: 190,
|
||||
sortValue: (r) => r.soll ?? -1,
|
||||
render: (r) => (isAdmin ? (
|
||||
<div className="field-inline" style={{ gap: "var(--sp-1)", flexWrap: "nowrap" }}>
|
||||
<input type="number" step="any" min="0" style={{ marginTop: 0, minWidth: 80 }}
|
||||
<div className="field-inline" key={r.key} style={{ marginBottom: 0, alignItems: "center" }}>
|
||||
<span className="grow" style={{ flex: "1 1 auto", minWidth: 0 }}>
|
||||
<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. {g.child_ids.length} Untergruppen
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{isAdmin ? (
|
||||
<input type="number" step="any" min="0" style={{ marginTop: 0, width: 100 }}
|
||||
key={r.soll ?? "none"} defaultValue={r.soll ?? ""}
|
||||
onBlur={(e) => saveSoll(r, e.target.value)} />
|
||||
{r.soll != null && (
|
||||
<button type="button" className="btn-icon danger" title="Mindestbestand löschen"
|
||||
onClick={() => deleteRow(r)}><Icon name="trash" size={15} /></button>
|
||||
)}
|
||||
</div>
|
||||
) : (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 }}
|
||||
) : <span style={{ width: 100 }}>{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"} style={{ marginTop: 0, width: 130 }}
|
||||
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>
|
||||
);
|
||||
}
|
||||
// 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 }}
|
||||
) : isAdmin && g && g.min_stock_unit_id ? (
|
||||
<select value={grpPkgMode(g) ? "pkg" : "unit"} style={{ marginTop: 0, width: 130 }}
|
||||
title="Einheit für den Mindestbestand umschalten"
|
||||
onChange={(e) => {
|
||||
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>}
|
||||
<option value="edit">{g.package_size > 0 ? "Gebinde ändern…" : "Gebinde festlegen…"}</option>
|
||||
</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 (
|
||||
<div>
|
||||
@@ -422,27 +331,35 @@ export default function MinStock() {
|
||||
<div>
|
||||
<h1>Mindestbestände</h1>
|
||||
<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>
|
||||
{isAdmin && (
|
||||
<button className="btn primary" onClick={() => (showAdd ? resetAdd() : setShowAdd(true))}>
|
||||
<Icon name={showAdd ? "close" : "plus"} size={16} />
|
||||
{showAdd ? "Abbrechen" : "Mindestbestand"}
|
||||
<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 && showAdd && (
|
||||
{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
|
||||
<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">
|
||||
Ziel
|
||||
Was
|
||||
<div className="segmented">
|
||||
<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" : ""}
|
||||
onClick={() => setDraft({ ...draft, kind: "group", targetId: "" })}>Gruppe</button>
|
||||
</div>
|
||||
@@ -450,15 +367,15 @@ export default function MinStock() {
|
||||
</div>
|
||||
<div className="row">
|
||||
<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 })}>
|
||||
<option value="">– wählen –</option>
|
||||
{(draft.kind === "product"
|
||||
? products.filter(canHaveMin).sort((a, b) => a.name.localeCompare(b.name, "de"))
|
||||
: [...groups].sort((a, b) => a.name.localeCompare(b.name, "de"))
|
||||
).map((t) => (
|
||||
{zielListe.map((t) => (
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
@@ -477,28 +394,6 @@ export default function MinStock() {
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="row">
|
||||
<label className="seg-label">
|
||||
Geltung
|
||||
<div className="segmented">
|
||||
<button type="button" className={draft.scope === "global" ? "active" : ""}
|
||||
onClick={() => setDraft({ ...draft, scope: "global", locId: "" })}>Gesamt</button>
|
||||
<button type="button" className={draft.scope === "loc" ? "active" : ""}
|
||||
onClick={() => setDraft({ ...draft, scope: "loc" })}>Lagerort</button>
|
||||
</div>
|
||||
</label>
|
||||
{draft.scope === "loc" && (
|
||||
<label className="grow" style={{ maxWidth: 360 }}>
|
||||
Lagerort
|
||||
<select value={draft.locId} onChange={(e) => setDraft({ ...draft, locId: e.target.value })}>
|
||||
<option value="">– wählen –</option>
|
||||
{locations.map((l) => (
|
||||
<option key={l.id} value={l.id}>{locationPathById(l.id, locations)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="grow" style={{ maxWidth: 300 }}>
|
||||
Menge{draftUnit ? ` (in ${draftUnit})` : ""}
|
||||
@@ -508,7 +403,7 @@ export default function MinStock() {
|
||||
</div>
|
||||
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
|
||||
<button className="btn primary" onClick={saveNew}>Speichern</button>
|
||||
<button className="btn" onClick={resetAdd}>Abbrechen</button>
|
||||
<button className="btn" onClick={() => setDraft(null)}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -517,7 +412,7 @@ export default function MinStock() {
|
||||
<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
|
||||
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">
|
||||
@@ -539,10 +434,31 @@ export default function MinStock() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<DataTable id="min-stock" columns={columns} rows={rows} loading={loading}
|
||||
getRowKey={(r) => r.key} empty="Noch keine Mindestbestände." />
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -820,8 +820,14 @@ export default function ProductForm() {
|
||||
</label>
|
||||
</div>
|
||||
<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">
|
||||
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">
|
||||
<input type="number" step="any" value={form.min_stock}
|
||||
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" />
|
||||
@@ -838,6 +844,7 @@ export default function ProductForm() {
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
<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.">
|
||||
Gruppe
|
||||
@@ -853,25 +860,27 @@ export default function ProductForm() {
|
||||
<div style={{ marginTop: "var(--sp-2)", marginBottom: "var(--sp-5)" }}>
|
||||
<div className="card-head" style={{ marginBottom: "var(--sp-1)" }}>
|
||||
<Icon name="location" size={16} />
|
||||
<h3 style={{ margin: 0 }}>Mindestbestand je Lagerort</h3>
|
||||
<h3 style={{ margin: 0 }}>Mindestbestand</h3>
|
||||
</div>
|
||||
<p className="muted small mt-0">
|
||||
Zusätzlich zum Gesamt-Mindestbestand: eigener Bedarf je Lagerort (z.B.
|
||||
Ferienhaus, Zuhause). Menge in {product.unit_name || "Stück"}.
|
||||
Wird separat gespeichert.
|
||||
Was soll wo immer da sein? „Überall“ heißt: egal wo, Hauptsache im
|
||||
Haus — Käufe für einen Lagerort decken ihn mit ab. Menge in{" "}
|
||||
{product.unit_name || "Stück"}. Wird sofort gespeichert.
|
||||
</p>
|
||||
<LocationMinStock
|
||||
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(
|
||||
(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"}
|
||||
onError={(m) => toast(m, "warn")}
|
||||
onSave={async (list) => {
|
||||
const f = artToDisp(product);
|
||||
const artList = list.map((e) => ({ ...e, min_stock: e.min_stock / f }));
|
||||
const updated = await api.setProductLocationMinStock(product.id, artList);
|
||||
const f = product.unit_factor || 1;
|
||||
const basisListe = list.map((e) => ({ ...e, min_stock: e.min_stock * f }));
|
||||
const updated = await api.setProductLocationMinStock(product.id, basisListe);
|
||||
setProduct(updated);
|
||||
toast("Bedarfe je Lagerort gespeichert.");
|
||||
toast("Mindestbestände gespeichert.");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function ShoppingList() {
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
{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">
|
||||
@@ -48,6 +48,11 @@ export default function ShoppingList() {
|
||||
<label>
|
||||
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
|
||||
<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>
|
||||
</label>
|
||||
<span className="muted small">
|
||||
@@ -88,6 +93,11 @@ export default function ShoppingList() {
|
||||
<label>
|
||||
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
|
||||
<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>
|
||||
</label>
|
||||
<span className="muted small">
|
||||
@@ -118,6 +128,14 @@ export default function ShoppingList() {
|
||||
<p className="muted small">
|
||||
Das Abhaken hilft beim Einkaufen und wird (noch) nicht dauerhaft gespeichert.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,14 @@ export function unitShort(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.
|
||||
export function unitOptions(product) {
|
||||
const opts = [{ value: baseUnitToInput(product.base_unit), label: unitShort(product.base_unit) }];
|
||||
|
||||
Reference in New Issue
Block a user