diff --git a/ios/Sources/GroupViews.swift b/ios/Sources/GroupViews.swift index 23e81cf..fda3674 100644 --- a/ios/Sources/GroupViews.swift +++ b/ios/Sources/GroupViews.swift @@ -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) { + 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 +} diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift index 7d887c5..616269b 100644 --- a/ios/Sources/ProductDetailView.swift +++ b/ios/Sources/ProductDetailView.swift @@ -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: { diff --git a/ios/Sources/ProductViews.swift b/ios/Sources/ProductViews.swift index 552d2c6..72d3657 100644 --- a/ios/Sources/ProductViews.swift +++ b/ios/Sources/ProductViews.swift @@ -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 { diff --git a/web/src/components/GroupParentSelect.jsx b/web/src/components/GroupParentSelect.jsx index 9b79900..9ebcab1 100644 --- a/web/src/components/GroupParentSelect.jsx +++ b/web/src/components/GroupParentSelect.jsx @@ -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())) - .slice() - .sort((a, b) => a.name.localeCompare(b.name, "de")); + // 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")) + .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({ /> )} - {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 ( -
+
); diff --git a/web/src/groupGraph.js b/web/src/groupGraph.js index 280fcaa..3b6fc00 100644 --- a/web/src/groupGraph.js +++ b/web/src/groupGraph.js @@ -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
diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index 8c54614..a1ea785 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -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() { @@ -1043,7 +1046,9 @@ export default function ProductForm() { diff --git a/web/src/pages/Products.jsx b/web/src/pages/Products.jsx index 1c329b0..b99758a 100644 --- a/web/src/pages/Products.jsx +++ b/web/src/pages/Products.jsx @@ -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)" : ), sortValue: (p) => catInfo.get(p.category_id)?.path || p.category_name || "", render: (p) => }, - // 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 - ? {p.group_name} + // 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)" : ), + sortValue: (p) => grpInfo.get(p.group_id)?.path || p.group_name || "", + render: (p) => (p.group_id != null + ? + + : ) }, // Nur in der Gegenstände-Liste: Menge je Lagerort / Einzelstücke / Verbrauchsgegenstand. activeTyp === "object" && { key: "verwaltung", header: "Verwaltung", label: "Verwaltung", width: 190,