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 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"}
|
||||||
|
|||||||
@@ -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
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 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 || []) {
|
||||||
out.push({
|
const soll = e.min_stock / faktor;
|
||||||
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({
|
out.push({
|
||||||
key: `p:${p.id}:${l.location_id}`, kind: "product", scope: "loc", entity: p,
|
key: `${kind}:${entity.id}:${vonId(e.location_id)}`,
|
||||||
locId: l.location_id, name: p.name, catId: p.category_id,
|
kind, entity, name, locId: e.location_id,
|
||||||
ort: locationPathById(l.location_id, locations) || "?",
|
soll, unit: einheit(soll),
|
||||||
soll: lSoll, unit: effUnit(p, lSoll),
|
bestand: e.stock != null ? e.stock / faktor : null,
|
||||||
// Bestand kommt in Basiseinheiten (wie der Gesamt-Bestand) → in die Einheit.
|
bestandUnit: einheit(e.stock != null ? e.stock / faktor : 1),
|
||||||
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,113 +246,84 @@ 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,
|
return (
|
||||||
filterText: (r) => (r.catId != null ? (catInfo.get(r.catId)?.path || "") : ""),
|
<div className="field-inline" key={r.key} style={{ marginBottom: 0, alignItems: "center" }}>
|
||||||
filterValues: (r) => (r.catId != null ? (catInfo.get(r.catId)?.tokens || [""]) : [""]),
|
<span className="grow" style={{ flex: "1 1 auto", minWidth: 0 }}>
|
||||||
filterOptionLabel: (v) => (v === "" ? "(Leere)" : <CategoryPathLabel parts={pathParts(v)} />),
|
<span className="strong">{r.name}</span>
|
||||||
render: (r) => (r.catId != null
|
{g && <span className="badge accent" style={{ marginLeft: 6 }}>Gruppe</span>}
|
||||||
? <CategoryPathLabel parts={catInfo.get(r.catId)?.parts} fallback="–" />
|
{g?.child_ids?.length > 0 && (
|
||||||
: <span className="muted">–</span>) },
|
<span className="badge" style={{ marginLeft: 6 }}
|
||||||
{ key: "ort", header: "Ort", width: 240, filterText: (r) => r.ort, sortValue: (r) => r.ort,
|
title="Bestand und Mindestbestand zählen die Untergruppen mit.">
|
||||||
render: (r) => {
|
inkl. {g.child_ids.length} Untergruppen
|
||||||
// Editierbar, wenn es einen Wert zu verschieben gibt (leere Produktzeilen
|
</span>
|
||||||
// ohne Bedarf bleiben Text – dort legt man über „+ Mindestbestand" an).
|
)}
|
||||||
if (!isAdmin || r.soll == null) {
|
</span>
|
||||||
return r.scope === "global" ? <span className="muted">{r.ort}</span> : r.ort;
|
|
||||||
}
|
{isAdmin ? (
|
||||||
return (
|
<input type="number" step="any" min="0" style={{ marginTop: 0, width: 100 }}
|
||||||
<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 }}
|
|
||||||
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>}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
) : 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;
|
||||||
|
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" 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"
|
<button type="button" className="btn-icon danger" title="Mindestbestand löschen"
|
||||||
onClick={() => deleteRow(r)}><Icon name="trash" size={15} /></button>
|
onClick={() => deleteRow(r)}><Icon name="trash" size={15} /></button>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
) : (r.soll != null ? fmt(r.soll) : "–")) },
|
</div>
|
||||||
{ 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"
|
|
||||||
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 }}
|
|
||||||
title="Einheit für den Mindestbestand umschalten"
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
if (v === "unit") toggleGroupPkg(g, false);
|
|
||||||
else if (v === "pkg") toggleGroupPkg(g, true);
|
|
||||||
else openGebinde(g);
|
|
||||||
}}>
|
|
||||||
<option value="unit">{g.min_stock_unit_name || "Einheit"}</option>
|
|
||||||
{g.package_size > 0 && <option value="pkg">{g.package_label || "Packung"}</option>}
|
|
||||||
<option value="edit">{g.package_size > 0 ? "Gebinde ändern…" : "Gebinde festlegen…"}</option>
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <span className="muted">{r.unit || "–"}</span>;
|
|
||||||
} },
|
|
||||||
{ 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) => (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -820,24 +820,31 @@ export default function ProductForm() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="grow">
|
{/* Beim Anlegen gibt es den Artikel noch nicht, also auch keine
|
||||||
Mindestbestand
|
Ort-Liste – hier bleibt das eine Feld, das als „Überall“ landet.
|
||||||
<div className="field-inline">
|
Beim Bearbeiten steht darunter die vollständige Ort-Liste. */}
|
||||||
<input type="number" step="any" value={form.min_stock}
|
{isNew && (
|
||||||
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" />
|
<label className="grow">
|
||||||
<select value={minUnit || form.unit_id} onChange={(e) => changeMinUnit(e.target.value)}
|
<span className="tip" title="Gilt „überall“ – egal wo im Haus. Weitere Orte lassen sich nach dem Anlegen ergänzen.">
|
||||||
disabled={readOnly} style={{ marginTop: 0 }}>
|
Mindestbestand (überall)
|
||||||
{units
|
</span>
|
||||||
.filter((u) => !selectedUnit || u.kind === selectedUnit.kind)
|
<div className="field-inline">
|
||||||
.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
<input type="number" step="any" value={form.min_stock}
|
||||||
{form.package_size && (
|
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" />
|
||||||
<option value="package">
|
<select value={minUnit || form.unit_id} onChange={(e) => changeMinUnit(e.target.value)}
|
||||||
{form.package_label.trim() || "Packung"} à {fmt(form.package_size)} {baseShort}
|
disabled={readOnly} style={{ marginTop: 0 }}>
|
||||||
</option>
|
{units
|
||||||
)}
|
.filter((u) => !selectedUnit || u.kind === selectedUnit.kind)
|
||||||
</select>
|
.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
</div>
|
{form.package_size && (
|
||||||
</label>
|
<option value="package">
|
||||||
|
{form.package_label.trim() || "Packung"} à {fmt(form.package_size)} {baseShort}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }];
|
||||||
|
|||||||
Reference in New Issue
Block a user