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
parent 302e7625e5
commit e00eef275c
9 changed files with 184 additions and 36 deletions

View File

@@ -327,3 +327,47 @@ struct GroupParentPicker: View {
.navigationBarTitleDisplayMode(.inline)
}
}
/// Eine Zeile fuer ein Gruppen-Auswahlfeld: Gruppe plus ihre Tiefe im Baum.
struct GruppenOption: Identifiable {
let id: String // Pfad eine Gruppe kann unter mehreren haengen
let gruppe: GroupItem
let tiefe: Int
/// Eingerueckte Beschriftung fuer den Picker.
var label: String {
String(repeating: " ", count: tiefe) + (tiefe > 0 ? "" : "") + gruppe.name
}
}
/// Gruppen als Baum ausrollen fuer Auswahlfelder.
///
/// Eine Gruppe mit mehreren Obergruppen erscheint unter JEDER; das ist der Sinn
/// mehrerer Obergruppen, und ausgewaehlt wird ohnehin dieselbe ID.
func gruppenOptionen(_ groups: [GroupItem]) -> [GruppenOption] {
let vorhanden = Set(groups.map(\.id))
var kinderVon: [Int: [GroupItem]] = [:]
var wurzeln: [GroupItem] = []
for g in groups {
let eltern = (g.parentIds ?? []).filter { vorhanden.contains($0) }
if eltern.isEmpty { wurzeln.append(g) }
for e in eltern { kinderVon[e, default: []].append(g) }
}
let nachName: (GroupItem, GroupItem) -> Bool = {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
var out: [GruppenOption] = []
func walk(_ g: GroupItem, _ tiefe: Int, _ pfad: String, _ gesehen: Set<Int>) {
let eigen = "\(pfad)/\(g.id)"
out.append(GruppenOption(id: eigen, gruppe: g, tiefe: tiefe))
if gesehen.contains(g.id) { return } // Ringschutz
var weiter = gesehen
weiter.insert(g.id)
for k in (kinderVon[g.id] ?? []).sorted(by: nachName) {
walk(k, tiefe + 1, eigen, weiter)
}
}
for w in wurzeln.sorted(by: nachName) { walk(w, 0, "", []) }
return out
}

View File

@@ -217,8 +217,9 @@ struct ProductDetailView: View {
Section {
Picker("Gruppe", selection: $groupId) {
Text(" keine ").tag(Int?.none)
ForEach(groups) { gruppe in
Text(gruppe.name).tag(Int?.some(gruppe.id))
// Als Baum, damit sichtbar ist, was unter was haengt.
ForEach(gruppenOptionen(groups)) { eintrag in
Text(eintrag.label).tag(Int?.some(eintrag.gruppe.id))
}
}
} footer: {

View File

@@ -166,8 +166,9 @@ struct ProductFormView: View {
Section {
Picker("Gruppe", selection: $selectedGroupId) {
Text(" keine ").tag(Int?.none)
ForEach(groups) { gruppe in
Text(gruppe.name).tag(Int?.some(gruppe.id))
// Als Baum, damit sichtbar ist, was unter was haengt.
ForEach(gruppenOptionen(groups)) { eintrag in
Text(eintrag.label).tag(Int?.some(eintrag.gruppe.id))
}
}
if session.isAdmin {

View File

@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Icon from "./Icon";
import { nachfahrenIds, pfadText } from "../groupGraph";
import { gruppenOptionen, nachfahrenIds, pfadText } from "../groupGraph";
/**
* Mehrfachauswahl der Obergruppen einer Gruppe.
@@ -35,10 +35,15 @@ export default function GroupParentSelect({
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()))
// Als Baum, damit die Struktur beim Zuordnen sichtbar ist. Beim Suchen
// flacht die Liste ab eine Einrückung ohne ihre Eltern wäre irreführend.
const sichtbar = filter
? groups
.filter((g) => g.name.toLowerCase().includes(filter.toLowerCase()))
.slice()
.sort((a, b) => a.name.localeCompare(b.name, "de"));
.sort((a, b) => a.name.localeCompare(b.name, "de"))
.map((g) => ({ id: g.id, key: `f${g.id}`, gruppe: g, tiefe: 0, label: g.name }))
: gruppenOptionen(groups);
const label = value?.length
? value.map((id) => nameById[id]).filter(Boolean).join(", ")
@@ -115,22 +120,23 @@ export default function GroupParentSelect({
/>
</div>
)}
{sichtbar.map((g) => {
const aus = gesperrt.has(g.id);
const an = gewaehlt.has(g.id);
{sichtbar.map((o) => {
const aus = gesperrt.has(o.id);
const an = gewaehlt.has(o.id);
return (
<div key={g.id} className="tree-pop-line">
<div key={o.key} className="tree-pop-line" style={{ paddingLeft: o.tiefe * 14 }}>
<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)}
: pfadText(groups, o.id)}
onClick={() => umschalten(o.id)}
>
<span style={{ display: "inline-block", width: 18 }}>{an ? "✓" : ""}</span>
{g.name}
{o.tiefe > 0 && <span className="muted"> </span>}
{o.gruppe.name}
</button>
</div>
);

View File

@@ -75,3 +75,74 @@ export function elternNamen(groups, group) {
const map = byId(groups);
return (group?.parent_ids || []).map((id) => map.get(id)?.name).filter(Boolean);
}
/**
* Gruppen für ein Auswahlfeld: als Baum ausgerollt und eingerückt.
*
* Eine Gruppe mit mehreren Obergruppen erscheint unter JEDER genau das ist
* der Sinn mehrerer Obergruppen. Der Wert ist ohnehin dieselbe ID, egal welche
* Zeile man trifft.
*
* Eingerückt wird mit GESCHÜTZTEN Leerzeichen: normale fasst der Browser in
* einem <option> zusammen, die Einrückung wäre dann wirkungslos.
*/
export function gruppenOptionen(groups) {
const liste = groups || [];
const vorhanden = new Set(liste.map((g) => g.id));
const nachName = (a, b) => a.name.localeCompare(b.name, "de");
const kinderVon = new Map();
const wurzeln = [];
for (const g of liste) {
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, tiefe, pfad, gesehen) => {
const eigen = `${pfad}/${g.id}`;
out.push({
id: g.id,
key: eigen,
gruppe: g,
tiefe,
label: `${"\u00A0\u00A0\u00A0".repeat(tiefe)}${tiefe > 0 ? "\u21B3 " : ""}${g.name}`,
});
if (gesehen.has(g.id)) return; // Ringschutz
const weiter = new Set(gesehen).add(g.id);
for (const k of (kinderVon.get(g.id) || []).slice().sort(nachName)) {
walk(k, tiefe + 1, eigen, weiter);
}
};
for (const w of wurzeln.slice().sort(nachName)) walk(w, 0, "", new Set());
return out;
}
/**
* Gegenstück zu categoryInfoMap für Gruppen: je Gruppe der Weg von oben.
*
* Anders als bei Kategorien kann es MEHRERE Wege geben („Grillwurst" unter
* „Wurst" und unter „Grillgut"). Angezeigt wird der erste, gefiltert wird über
* alle wer auf „Wurst" filtert, will „Grillwurst" mitsehen.
*/
export function gruppenInfoMap(groups) {
const out = new Map();
for (const g of groups || []) {
const wege = pfade(groups, g.id);
const erster = wege[0]?.length ? wege[0] : [g.name];
const tokens = new Set();
for (const weg of wege) {
weg.forEach((_, i) => tokens.add(weg.slice(0, i + 1).join(" → ")));
}
out.set(g.id, {
parts: erster,
path: erster.join(" → "),
alle: wege.map((w) => w.join(" → ")),
tokens: [...tokens],
});
}
return out;
}

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.

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,12 +367,18 @@ 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) => (
{/* 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}
{draft.kind === "product" && t.brand ? ` · ${t.brand}` : ""}
{draft.kind === "group" && t.child_ids?.length
? ` (inkl. ${anzahlWort(t.child_ids.length, "Untergruppe", "Untergruppen")})` : ""}
{t.name}{t.brand ? ` · ${t.brand}` : ""}
</option>
))}
</select>

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>

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,