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:
Scarriffle
2026-08-16 09:40:09 +02:00
parent 158175f693
commit 3de168b598
12 changed files with 148 additions and 31 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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),

View File

@@ -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>

View File

@@ -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); }

View File

@@ -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" };