Gruppen: Bestand in der richtigen Einheit, Baumansicht, Gruppen-Spalte
Anzeigefehler: die Gruppenliste klebte immer min_stock_unit_name hinter den
Bestand - auch wenn die Gruppe laengst im Gruppen-Gebinde zaehlt. Aus 790 g bei
einem 190-g-Glas wurde so "4,16 Gramm" statt "4,16 Glaeser". Gerechnet war
richtig, beschriftet falsch.
Ursache war doppelte Logik: MinStock.jsx hatte grpPkgMode/grpFactor/grpUnit
lokal, Groups.jsx gar nicht. Die drei stehen jetzt in units.js und werden von
beiden benutzt. Die Einheiten-Spalte sagt ausserdem, wenn eine Gruppe im
Gebinde zaehlt ("zaehlt in Glaeser a 190 g") und bietet den Weg zurueck an.
Gruppenliste als Baum: eine Gruppe mit zwei Obergruppen erscheint unter beiden -
das ist die ehrliche Darstellung eines Graphen. Jede Zeile traegt ihren PFAD als
Schluessel, damit die vorhandene Baumlogik der DataTable unveraendert zurecht
kommt; die kennt nur einen Elternteil je Zeile, und ein Pfad hat genau einen.
Beim Sortieren/Filtern flacht sie wie gewohnt ab.
"1 Untergruppen" heisst jetzt "1 Untergruppe" - an allen vier Stellen ueber
einen gemeinsamen Helfer (anzahlWort), Web und App.
Lebensmittelliste bekommt eine Spalte "Gruppe" mit Filter. Dafuer liefert
ProductOut jetzt group_name, symmetrisch zu category_name, mit joinedload gegen
N+1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useAuth } from "../auth";
|
||||
@@ -7,7 +7,7 @@ import Icon from "../components/Icon";
|
||||
import DataTable from "../components/DataTable";
|
||||
import LocationMinStock from "../components/LocationMinStock";
|
||||
import GroupParentSelect from "../components/GroupParentSelect";
|
||||
import { fmt, kindShort } from "../units";
|
||||
import { anzahlWort, fmt, grpPkgMode, grpUnit, kindShort } from "../units";
|
||||
|
||||
export default function Groups() {
|
||||
const confirm = useConfirm();
|
||||
@@ -91,6 +91,40 @@ export default function Groups() {
|
||||
const selected = groups.find((g) => g.id === selectedId) || null;
|
||||
const nameById = Object.fromEntries(groups.map((g) => [g.id, g.name]));
|
||||
|
||||
// Gruppen als Baum ausrollen. Eine Gruppe mit ZWEI Obergruppen erscheint
|
||||
// unter beiden – das ist die ehrliche Darstellung eines Graphen; sie zu
|
||||
// verstecken hiesse, den Sinn mehrerer Obergruppen zu verbergen.
|
||||
//
|
||||
// Jede Zeile traegt ihren PFAD als Schluessel („/3/7"). Damit kommt die
|
||||
// vorhandene Baumlogik der DataTable unveraendert zurecht: sie kennt nur
|
||||
// einen Elternteil je Zeile, und ein Pfad hat genau einen.
|
||||
const baumZeilen = useMemo(() => {
|
||||
const vorhanden = new Set(groups.map((g) => g.id));
|
||||
const nachName = (a, b) => a.name.localeCompare(b.name, "de");
|
||||
const kinderVon = new Map();
|
||||
const wurzeln = [];
|
||||
for (const g of groups) {
|
||||
const eltern = (g.parent_ids || []).filter((id) => vorhanden.has(id));
|
||||
if (eltern.length === 0) wurzeln.push(g);
|
||||
for (const e of eltern) {
|
||||
if (!kinderVon.has(e)) kinderVon.set(e, []);
|
||||
kinderVon.get(e).push(g);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
const walk = (g, elternPfad, gesehen) => {
|
||||
const pfad = `${elternPfad ?? ""}/${g.id}`;
|
||||
out.push({ ...g, _pfad: pfad, _elternPfad: elternPfad });
|
||||
if (gesehen.has(g.id)) return; // Ringschutz, falls doch einer da ist
|
||||
const weiter = new Set(gesehen).add(g.id);
|
||||
for (const k of (kinderVon.get(g.id) || []).slice().sort(nachName)) {
|
||||
walk(k, pfad, weiter);
|
||||
}
|
||||
};
|
||||
for (const w of wurzeln.slice().sort(nachName)) walk(w, null, new Set());
|
||||
return out;
|
||||
}, [groups]);
|
||||
|
||||
const columns = [
|
||||
{ key: "name", header: "Gruppe", grow: true, min: 160,
|
||||
filterText: (g) => g.name, sortValue: (g) => g.name,
|
||||
@@ -105,7 +139,7 @@ export default function Groups() {
|
||||
{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
|
||||
{anzahlWort(g.child_ids.length, "Untergruppe", "Untergruppen")}
|
||||
</span>
|
||||
)}
|
||||
{/* Ohne Einheit summiert eine Gruppe Gramm und Stück zu einer
|
||||
@@ -143,21 +177,49 @@ export default function Groups() {
|
||||
)}
|
||||
</span>
|
||||
) },
|
||||
{ key: "stock", header: "Bestand", width: 130, align: "num",
|
||||
sortValue: (g) => g.stock, render: (g) => `${fmt(g.stock)} ${g.min_stock_unit_name || ""}` },
|
||||
// Bestand steht in der Einheit, in der die Gruppe ZÄHLT – bei aktivem
|
||||
// Gruppen-Gebinde also in Gläsern, nicht in Gramm. Vorher klebte hier
|
||||
// immer min_stock_unit_name dahinter, was aus 790 g „4,16 Gramm" machte.
|
||||
{ key: "stock", header: "Bestand", width: 140, align: "num",
|
||||
sortValue: (g) => g.stock,
|
||||
render: (g) => `${fmt(g.stock)} ${grpUnit(g, g.stock)}` },
|
||||
{ key: "min", header: "Mindestbestand", width: 150, sortValue: (g) => g.min_stock ?? -1,
|
||||
render: (g) => (isAdmin ? (
|
||||
<input type="number" step="any" defaultValue={g.min_stock ?? ""} style={{ marginTop: 0, minWidth: 90 }}
|
||||
onBlur={(e) => { const v = e.target.value; if (v !== String(g.min_stock ?? "")) patch(g, { min_stock: v === "" ? null : Number(v) }); }} />
|
||||
) : (g.min_stock != null ? fmt(g.min_stock) : "–")) },
|
||||
<span className="field-inline" style={{ gap: "var(--sp-1)", flexWrap: "nowrap" }}>
|
||||
<input type="number" step="any" defaultValue={g.min_stock ?? ""} style={{ marginTop: 0, minWidth: 70 }}
|
||||
onBlur={(e) => { const v = e.target.value; if (v !== String(g.min_stock ?? "")) patch(g, { min_stock: v === "" ? null : Number(v) }); }} />
|
||||
<span className="muted small" style={{ alignSelf: "center" }}>{grpUnit(g, g.min_stock ?? 1)}</span>
|
||||
</span>
|
||||
) : (g.min_stock != null ? `${fmt(g.min_stock)} ${grpUnit(g, g.min_stock)}` : "–")) },
|
||||
{ key: "unit", header: "Einheit", width: 150, filterText: (g) => g.min_stock_unit_name || "",
|
||||
render: (g) => (isAdmin ? (
|
||||
<select value={g.min_stock_unit_id ?? ""} style={{ marginTop: 0 }}
|
||||
onChange={(e) => patch(g, { min_stock_unit_id: e.target.value === "" ? null : Number(e.target.value) })}>
|
||||
<option value="">– Basiseinheit –</option>
|
||||
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
) : (g.min_stock_unit_name || "–")) },
|
||||
render: (g) => (
|
||||
<span className="cell-col">
|
||||
{isAdmin ? (
|
||||
<select value={g.min_stock_unit_id ?? ""} style={{ marginTop: 0 }}
|
||||
onChange={(e) => patch(g, { min_stock_unit_id: e.target.value === "" ? null : Number(e.target.value) })}>
|
||||
<option value="">– Basiseinheit –</option>
|
||||
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
) : (g.min_stock_unit_name || "–")}
|
||||
{/* Zählt die Gruppe im Gebinde, sagt die Einheit darüber nur die
|
||||
halbe Wahrheit – der Zusatz nennt den Umrechnungswert. */}
|
||||
{grpPkgMode(g) && (
|
||||
<span className="muted small">
|
||||
zählt in {grpUnit(g, 2)} à {fmt(g.package_size)} {kindShort(g.kind)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
{" · "}
|
||||
<button type="button" className="link-btn"
|
||||
title="Bestand und Mindestbestand wieder in der Einheit oben lesen"
|
||||
onClick={() => patch(g, { min_stock_in_packages: false })}>
|
||||
in {g.min_stock_unit_name || kindShort(g.kind)} rechnen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) },
|
||||
{ key: "codes", header: "EAN-Codes", width: 130, align: "num",
|
||||
render: (g) => (
|
||||
<button className="link-btn" onClick={() => setSelectedId(g.id)}>
|
||||
@@ -186,8 +248,10 @@ export default function Groups() {
|
||||
|
||||
<div className="grid-2 wide-aside">
|
||||
<div className="card">
|
||||
<DataTable id="groups" columns={columns} rows={groups} loading={loading}
|
||||
getRowKey={(g) => g.id} empty="Noch keine Gruppen." />
|
||||
<DataTable id="groups" columns={columns} rows={baumZeilen} loading={loading}
|
||||
getRowKey={(g) => g._pfad}
|
||||
tree={{ column: "name", idOf: (g) => g._pfad, parentIdOf: (g) => g._elternPfad }}
|
||||
empty="Noch keine Gruppen." />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useAuth } from "../auth";
|
||||
import { useConfirm } from "../confirm";
|
||||
import Icon from "../components/Icon";
|
||||
import { locationOptions, locationPathById } from "../locationPath";
|
||||
import { fmt, gebinde, kindShort } from "../units";
|
||||
import { anzahlWort, fmt, gebinde, grpFactor, grpPkgMode, grpUnit, kindShort } from "../units";
|
||||
|
||||
// Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm,
|
||||
// Milliliter, Stück) – NICHT die Packung. „1 Packung" ist nichtssagend, weil eine
|
||||
@@ -24,13 +24,9 @@ const effFactor = (p) => (pkgMode(p) ? p.package_size : dispFactor(p));
|
||||
// Einheitslabel der gewählten Erfassungseinheit, passend zur Menge (1 Glas / 3 Gläser).
|
||||
const effUnit = (p, menge = 1) => (pkgMode(p) ? gebinde(menge, p.package_label || "Packung") : dispLabel(p));
|
||||
|
||||
// Gruppen: analog, mit dem GRUPPEN-Gebinde (Richtwert der Gruppe, nicht der
|
||||
// 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 || kindShort(g.kind) || ""));
|
||||
// Gruppen rechnen in ihrer verwalteten Einheit ODER im Gruppen-Gebinde –
|
||||
// grpPkgMode/grpFactor/grpUnit stehen in units.js, weil Groups.jsx sie genauso
|
||||
// braucht (und ohne sie den Bestand falsch beschriftet hat).
|
||||
|
||||
// „Überall" ist der Ort NULL. Im <select> braucht es einen Wert, deshalb ein
|
||||
// Platzhalter, der beim Speichern wieder zu null wird.
|
||||
@@ -269,7 +265,7 @@ export default function MinStock() {
|
||||
{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
|
||||
inkl. {anzahlWort(g.child_ids.length, "Untergruppe", "Untergruppen")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -375,7 +371,7 @@ export default function MinStock() {
|
||||
{t.name}
|
||||
{draft.kind === "product" && t.brand ? ` · ${t.brand}` : ""}
|
||||
{draft.kind === "group" && t.child_ids?.length
|
||||
? ` (inkl. ${t.child_ids.length} Untergruppen)` : ""}
|
||||
? ` (inkl. ${anzahlWort(t.child_ids.length, "Untergruppe", "Untergruppen")})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -91,6 +91,16 @@ export default function Products({ fixedType = null }) {
|
||||
filterOptionLabel: (v) => (v === "" ? "(Leere)" : <CategoryPathLabel parts={pathParts(v)} />),
|
||||
sortValue: (p) => catInfo.get(p.category_id)?.path || p.category_name || "",
|
||||
render: (p) => <CategoryPathLabel parts={catInfo.get(p.category_id)?.parts} fallback={p.category_name || "–"} /> },
|
||||
// Gruppe: zählt Bestände mehrerer Marken zusammen. Als eigene Spalte, damit
|
||||
// sich die Liste danach filtern lässt („zeig mir alles aus Pesto Rosso").
|
||||
{ key: "gruppe", header: "Gruppe", width: 170,
|
||||
filterText: (p) => p.group_name || "",
|
||||
filterValues: (p) => [p.group_name || ""],
|
||||
filterOptionLabel: (v) => (v === "" ? "(Ohne Gruppe)" : v),
|
||||
sortValue: (p) => p.group_name || "",
|
||||
render: (p) => (p.group_name
|
||||
? <span className="badge accent">{p.group_name}</span>
|
||||
: <span className="muted">–</span>) },
|
||||
// Nur in der Gegenstände-Liste: Menge je Lagerort / Einzelstücke / Verbrauchsgegenstand.
|
||||
activeTyp === "object" && { key: "verwaltung", header: "Verwaltung", label: "Verwaltung", width: 190,
|
||||
filterText: (p) => verwaltungLabel(p),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import ShoppingNeedText from "../components/ShoppingNeedText";
|
||||
import { anzahlWort } from "../units";
|
||||
|
||||
export default function ShoppingList() {
|
||||
const [items, setItems] = useState([]);
|
||||
@@ -50,7 +51,7 @@ export default function ShoppingList() {
|
||||
<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
|
||||
inkl. {anzahlWort(it.subgroup_count, "Untergruppe", "Untergruppen")}
|
||||
</span>
|
||||
)}
|
||||
<span className="item-name">{it.name}</span>
|
||||
@@ -95,7 +96,7 @@ export default function ShoppingList() {
|
||||
<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
|
||||
inkl. {anzahlWort(it.subgroup_count, "Untergruppe", "Untergruppen")}
|
||||
</span>
|
||||
)}
|
||||
<span className="item-name">{it.name}</span>
|
||||
|
||||
@@ -387,6 +387,8 @@ td select { width: auto; min-width: 0; max-width: 100%; }
|
||||
/* Zelle mit Eingabe plus Kennzeichen (z.B. "niedrig"): eine Zeile, damit das
|
||||
Kennzeichen immer an derselben Stelle sitzt statt mal darunter zu rutschen. */
|
||||
.cell-row { display: flex; align-items: center; gap: 6px; }
|
||||
/* Zwei Zeilen in einer Zelle: Steuerelement oben, Erläuterung darunter. */
|
||||
.cell-col { display: flex; flex-direction: column; gap: 2px; align-items: stretch; }
|
||||
/* Beschriftung mit Erklaerung im Tooltip: die gepunktete Linie zeigt an, dass
|
||||
dort etwas steht - so bleibt das Formular kurz, ohne Wissen zu verstecken. */
|
||||
.tip { border-bottom: 1px dotted var(--border-strong); }
|
||||
|
||||
@@ -54,6 +54,35 @@ export function kindShort(kind) {
|
||||
return KIND_SHORT[kind] || "";
|
||||
}
|
||||
|
||||
/** „1 Untergruppe" / „2 Untergruppen" – Ein-/Mehrzahl an einer Stelle. */
|
||||
export function anzahlWort(n, einzahl, mehrzahl) {
|
||||
return `${n} ${Number(n) === 1 ? einzahl : mehrzahl}`;
|
||||
}
|
||||
|
||||
// ---- Gruppen: in welcher Einheit sie zählen ----
|
||||
// Eine Gruppe rechnet entweder in ihrer verwalteten Einheit (Gramm, Kilogramm)
|
||||
// ODER – wenn ein Gruppen-Gebinde definiert und aktiv ist – in ganzen Packungen.
|
||||
// Beides zusammen an EINER Stelle, weil genau diese Unterscheidung vorher an
|
||||
// zwei Orten stand und in Groups.jsx fehlte: dort klebte immer „Gramm" hinter
|
||||
// einem Bestand, der längst in Gläsern gerechnet war.
|
||||
|
||||
/** Zählt die Gruppe in ihrem Gebinde (statt in der verwalteten Einheit)? */
|
||||
export function grpPkgMode(g) {
|
||||
return !!g?.min_stock_in_packages && !!(g.package_size && g.package_size > 0);
|
||||
}
|
||||
|
||||
/** Basiseinheiten je Erfassungseinheit der Gruppe. */
|
||||
export function grpFactor(g) {
|
||||
return grpPkgMode(g) ? g.package_size : (g?.min_stock_unit_factor || 1);
|
||||
}
|
||||
|
||||
/** Beschriftung der Erfassungseinheit, passend zur Menge (1 Glas / 3 Gläser). */
|
||||
export function grpUnit(g, menge = 1) {
|
||||
return grpPkgMode(g)
|
||||
? gebinde(menge, g.package_label || "Packung")
|
||||
: (g?.min_stock_unit_name || kindShort(g?.kind) || "");
|
||||
}
|
||||
|
||||
// Einheiten-ART je Basiseinheit – Spiegelbild von conversion.py::KIND_OF_BASE.
|
||||
export const KIND_OF_BASE = { piece: "count", gram: "weight", milliliter: "volume" };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user