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:
@@ -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;
|
||||
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);
|
||||
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}:${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,113 +246,84 @@ 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;
|
||||
}
|
||||
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 }}
|
||||
const zielListe = !draft ? [] : (draft.kind === "product"
|
||||
? products.filter(canHaveMin).slice().sort((a, b) => a.name.localeCompare(b.name, "de"))
|
||||
: groups.slice().sort((a, b) => a.name.localeCompare(b.name, "de")));
|
||||
|
||||
function zeile(r) {
|
||||
const g = r.kind === "group" ? r.entity : null;
|
||||
return (
|
||||
<div className="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 && (
|
||||
) : <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"
|
||||
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 }}
|
||||
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>) },
|
||||
];
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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." />
|
||||
</div>
|
||||
{loading && <div className="card"><p className="muted">Wird geladen…</p></div>}
|
||||
|
||||
{!loading && abschnitte.map((a) => (
|
||||
<section className="card" key={a.id} style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<div className="card-head">
|
||||
<Icon name="location" />
|
||||
<h2>{a.label}</h2>
|
||||
<span className="muted small">{a.zeilen.length} {a.zeilen.length === 1 ? "Eintrag" : "Einträge"}</span>
|
||||
</div>
|
||||
<div className="stack">
|
||||
{a.zeilen.length === 0 && (
|
||||
<p className="muted small mt-0">Hier ist noch nichts hinterlegt.</p>
|
||||
)}
|
||||
{a.zeilen.map(zeile)}
|
||||
{isAdmin && (
|
||||
<div className="field-inline" style={{ marginBottom: 0 }}>
|
||||
<button type="button" className="btn"
|
||||
onClick={() => setDraft({ ...EMPTY_DRAFT, locId: String(a.id) })}>
|
||||
<Icon name="plus" size={16} />hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -820,24 +820,31 @@ export default function ProductForm() {
|
||||
</label>
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Mindestbestand
|
||||
<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" />
|
||||
<select value={minUnit || form.unit_id} onChange={(e) => changeMinUnit(e.target.value)}
|
||||
disabled={readOnly} style={{ marginTop: 0 }}>
|
||||
{units
|
||||
.filter((u) => !selectedUnit || u.kind === selectedUnit.kind)
|
||||
.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
{form.package_size && (
|
||||
<option value="package">
|
||||
{form.package_label.trim() || "Packung"} à {fmt(form.package_size)} {baseShort}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
{/* 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">
|
||||
<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" />
|
||||
<select value={minUnit || form.unit_id} onChange={(e) => changeMinUnit(e.target.value)}
|
||||
disabled={readOnly} style={{ marginTop: 0 }}>
|
||||
{units
|
||||
.filter((u) => !selectedUnit || u.kind === selectedUnit.kind)
|
||||
.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
{form.package_size && (
|
||||
<option value="package">
|
||||
{form.package_label.trim() || "Packung"} à {fmt(form.package_size)} {baseShort}
|
||||
</option>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user