Gruppen hierarchisch: in allen Auswahlfeldern und in der Artikelliste

Die Gruppen-Dropdowns waren flache, alphabetische Listen - man sah nicht, dass
"Grillwurst" unter "Wurst" haengt. Ein gemeinsamer Helfer (gruppenOptionen)
rollt den Graphen zum Baum aus und rueckt ein; eine Gruppe mit mehreren
Obergruppen erscheint unter jeder, denn genau das ist ihr Sinn, und ausgewaehlt
wird ohnehin dieselbe ID.

Eingerueckt wird mit GESCHUETZTEN Leerzeichen - normale fasst der Browser in
einem <option> zusammen, die Einrueckung waere sonst wirkungslos.

Betroffen: Gruppen-Auswahl im Artikelformular (beide Zweige), beim Einlagern,
die Ziel-Auswahl der Mindestbestaende und die Obergruppen-Mehrfachauswahl (die
beim Suchen bewusst abflacht, weil eine Einrueckung ohne die Eltern in die Irre
fuehrt). In der App dasselbe in beiden Gruppen-Pickern.

Die Gruppen-Spalte der Artikelliste zeigt jetzt den Weg von oben statt eines
Etiketts - "Wurst → Grillwurst", mit gedaempften Vorfahren wie bei der
Kategorie. Gefiltert wird ueber ALLE Wege: wer auf "Wurst" filtert, sieht auch
die Artikel aus den Untergruppen. Dafuer gibt es gruppenInfoMap als Gegenstueck
zu categoryInfoMap, das mit mehreren Wegen je Gruppe umgehen kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-16 09:59:32 +02:00
co-authored by Claude Opus 5
parent 302e7625e5
commit e00eef275c
9 changed files with 184 additions and 36 deletions
+4 -1
View File
@@ -10,6 +10,7 @@ import { asTree } from "../categoryTree";
import CategorySelect from "../components/CategorySelect";
import { locationOptions } from "../locationPath";
import { buildUnitOptions, fmt, fromMonthInput, isExpired, monthInputToLastDay } from "../units";
import { gruppenOptionen } from "../groupGraph";
const emptyLine = () => ({ quantity: "", best_before: "" });
@@ -159,7 +160,9 @@ export default function CheckIn() {
Gruppe
<select value={newGroupId} onChange={(e) => setNewGroupId(e.target.value)}>
<option value="">– keine –</option>
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
{gruppenOptionen(groups).map((o) => (
<option key={o.key} value={o.id}>{o.label}</option>
))}
</select>
<span className="muted small">
Zählt Bestände mehrerer Marken zusammen. Der EAN-Code landet dann auch dort.
+15 -8
View File
@@ -4,6 +4,7 @@ import { useAuth } from "../auth";
import { useConfirm } from "../confirm";
import Icon from "../components/Icon";
import { locationOptions, locationPathById } from "../locationPath";
import { gruppenOptionen } from "../groupGraph";
import { anzahlWort, fmt, gebinde, grpFactor, grpPkgMode, grpUnit, kindShort } from "../units";
// Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm,
@@ -366,14 +367,20 @@ export default function MinStock() {
{draft.kind === "product" ? "Lebensmittel / Verbrauchsgegenstand" : "Gruppe"}
<select value={draft.targetId} onChange={(e) => setDraft({ ...draft, targetId: e.target.value })}>
<option value="">– wählen –</option>
{zielListe.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
{draft.kind === "product" && t.brand ? ` · ${t.brand}` : ""}
{draft.kind === "group" && t.child_ids?.length
? ` (inkl. ${anzahlWort(t.child_ids.length, "Untergruppe", "Untergruppen")})` : ""}
</option>
))}
{/* Gruppen als Baum, damit sichtbar ist, was unter was hängt. */}
{draft.kind === "group"
? gruppenOptionen(groups).map((o) => (
<option key={o.key} value={o.id}>
{o.label}
{o.gruppe.child_ids?.length
? ` (inkl. ${anzahlWort(o.gruppe.child_ids.length, "Untergruppe", "Untergruppen")})` : ""}
</option>
))
: zielListe.map((t) => (
<option key={t.id} value={t.id}>
{t.name}{t.brand ? ` · ${t.brand}` : ""}
</option>
))}
</select>
</label>
</div>
+7 -2
View File
@@ -17,6 +17,7 @@ import ObjektBestand from "../components/ObjektBestand";
import Einzelstuecke from "../components/Einzelstuecke";
import SplitLotDialog from "../components/SplitLotDialog";
import { locationOptions, locationPathById } from "../locationPath";
import { gruppenOptionen } from "../groupGraph";
import {
BASE_UNITS, daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired,
relativeExpiry, toMonthInput, unitShort, zweitFaktor,
@@ -968,7 +969,9 @@ export default function ProductForm() {
</span>
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)} disabled={readOnly}>
<option value="">– keine –</option>
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
{gruppenOptionen(groups).map((o) => (
<option key={o.key} value={o.id}>{o.label}</option>
))}
</select>
</label>
</div>
@@ -1043,7 +1046,9 @@ export default function ProductForm() {
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)}
disabled={readOnly}>
<option value="">– keine –</option>
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
{gruppenOptionen(groups).map((o) => (
<option key={o.key} value={o.id}>{o.label}</option>
))}
</select>
</label>
</div>
+19 -9
View File
@@ -7,6 +7,7 @@ import DataTable from "../components/DataTable";
import { ProduktThumb } from "../components/ProduktBild";
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
import { categoryInfoMap } from "../categoryPath";
import { gruppenInfoMap } from "../groupGraph";
import { fmt, gebinde, unitShort } from "../units";
// Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit).
@@ -43,6 +44,8 @@ export default function Products({ fixedType = null }) {
const { isAdmin } = useAuth();
const [products, setProducts] = useState([]);
const [categories, setCategories] = useState([]);
// Gruppen nur fuer die Pfad-Anzeige der Gruppen-Spalte.
const [groups, setGroups] = useState([]);
// "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände.
const [typ, setTyp] = useState("");
const [error, setError] = useState(null);
@@ -56,11 +59,13 @@ export default function Products({ fixedType = null }) {
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
api.listCategories().then(setCategories).catch(() => {});
api.listGroups().then(setGroups).catch(() => {});
}, []);
// Kategorie als Pfad + Tokens (jede Ebene), damit "Klamotten" auch die
// Unterkategorien findet und die Oberkategorie im Filter wählbar ist.
const catInfo = useMemo(() => categoryInfoMap(categories), [categories]);
const grpInfo = useMemo(() => gruppenInfoMap(groups), [groups]);
// Zwei getrennte Seiten (Lebensmittel/Gegenstände) fixieren den Typ; sonst der
// Umschalter oben.
const activeTyp = fixedType || typ;
@@ -91,15 +96,20 @@ 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>
// Gruppe: zählt Bestände mehrerer Marken zusammen. Mit ihrem Weg von oben
// dargestellt, wie die Kategorie – ein Etikett verschwiege, dass „Grillwurst"
// unter „Wurst" hängt. Gefiltert wird über ALLE Wege, damit „Wurst" auch die
// Artikel aus den Untergruppen zeigt.
{ key: "gruppe", header: "Gruppe", width: 200,
filterText: (p) => grpInfo.get(p.group_id)?.path || p.group_name || "",
filterValues: (p) => grpInfo.get(p.group_id)?.tokens
|| (p.group_name ? [p.group_name] : [""]),
filterOptionLabel: (v) => (v === "" ? "(Ohne Gruppe)" : <CategoryPathLabel parts={pathParts(v)} />),
sortValue: (p) => grpInfo.get(p.group_id)?.path || p.group_name || "",
render: (p) => (p.group_id != null
? <span title={(grpInfo.get(p.group_id)?.alle || []).join(" · ")}>
<CategoryPathLabel parts={grpInfo.get(p.group_id)?.parts} fallback={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,