diff --git a/web/src/categoryPath.js b/web/src/categoryPath.js index f6a1f39..951f84e 100644 --- a/web/src/categoryPath.js +++ b/web/src/categoryPath.js @@ -1,23 +1,33 @@ // Kategorien sind verschachtelbar (Klamotten → kurze Hosen). Produkte hängen an -// der Blatt-Kategorie; um „alle Klamotten" filtern zu können, arbeiten die -// Listen mit dem vollen Pfad statt nur dem Blattnamen. +// der Blatt-Kategorie; um „alle Klamotten" filtern zu können, arbeiten die Listen +// mit dem Pfad. `tokens` enthält jede Ebene als eigenen Wert (Ober- und eigene +// Kategorie), damit im Filter-Dropdown auch die Oberkategorie wählbar ist. -/** Map Kategorie-ID → voller Pfad "Klamotten → kurze Hosen". */ -export function categoryPathMap(categories) { +/** + * Map Kategorie-ID → { path, parts, tokens }. + * path = "Klamotten → kurze Hosen" + * parts = ["Klamotten", "kurze Hosen"] + * tokens = ["Klamotten", "Klamotten → kurze Hosen"] (jede Ebene kumuliert) + */ +export function categoryInfoMap(categories) { const byId = new Map((categories || []).map((c) => [c.id, c])); - const cache = new Map(); - const pfad = (id, seen) => { - if (cache.has(id)) return cache.get(id); + const partsCache = new Map(); + const parts = (id, seen) => { + if (partsCache.has(id)) return partsCache.get(id); const c = byId.get(id); - if (!c) return ""; - if (seen.has(id)) return c.name; // Ringschutz + if (!c) return []; + if (seen.has(id)) return [c.name]; // Ringschutz seen.add(id); - const oben = c.parent_id != null ? pfad(c.parent_id, seen) : ""; - const p = oben ? `${oben} → ${c.name}` : c.name; - cache.set(id, p); - return p; + const oben = c.parent_id != null ? parts(c.parent_id, seen) : []; + const arr = [...oben, c.name]; + partsCache.set(id, arr); + return arr; }; const out = new Map(); - for (const c of categories || []) out.set(c.id, pfad(c.id, new Set())); + for (const c of categories || []) { + const arr = parts(c.id, new Set()); + const tokens = arr.map((_, i) => arr.slice(0, i + 1).join(" → ")); + out.set(c.id, { path: arr.join(" → "), parts: arr, tokens }); + } return out; } diff --git a/web/src/components/CategoryPathLabel.jsx b/web/src/components/CategoryPathLabel.jsx new file mode 100644 index 0000000..9a1a6a8 --- /dev/null +++ b/web/src/components/CategoryPathLabel.jsx @@ -0,0 +1,23 @@ +/** + * Kategorie-Pfad mit subtil dargestellten Oberkategorien und normalem Blatt, + * z.B. „Klamotten →" klein/gedämpft und „kurze Hosen" normal. Wird in der + * Tabellenzelle und im Filter-Dropdown verwendet. + */ +export default function CategoryPathLabel({ parts, fallback = "–" }) { + if (!parts || parts.length === 0) return {fallback}; + const ancestors = parts.slice(0, -1); + const leaf = parts[parts.length - 1]; + return ( + + {ancestors.length > 0 && ( + {ancestors.join(" → ")} → + )} + {leaf} + + ); +} + +/** Aus einem Token-/Pfad-String die Teile gewinnen (Trenner " → "). */ +export function pathParts(value) { + return String(value ?? "").split(" → "); +} diff --git a/web/src/components/DataTable.jsx b/web/src/components/DataTable.jsx index a7684bf..cb61d9e 100644 --- a/web/src/components/DataTable.jsx +++ b/web/src/components/DataTable.jsx @@ -71,15 +71,20 @@ export default function DataTable({ : fixedWidth(c)); const totalWidth = Math.round(orderedCols.reduce((s, c) => s + widthOf(c), 0)); + // Mögliche Filterwerte je Zeile: `filterValues` (mehrere Tokens, z.B. jede + // Kategorie-Ebene) hat Vorrang, sonst der einzelne `filterText`. + const rowValues = (c, r) => (c.filterValues ? c.filterValues(r) : [String(c.filterText(r) ?? "")]); + const distinctByKey = useMemo(() => { const m = {}; for (const c of columns) { - if (!c.filterText) continue; + if (!c.filterText && !c.filterValues) continue; const set = new Set(); - for (const r of rows) set.add(String(c.filterText(r) ?? "")); + for (const r of rows) for (const v of rowValues(c, r)) set.add(String(v)); m[c.key] = [...set].sort((a, b) => a.localeCompare(b, "de")); } return m; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [columns, rows]); const anyFilter = orderedCols.some((c) => { @@ -91,10 +96,10 @@ export default function DataTable({ const flat = useMemo(() => { let out = rows.filter((r) => orderedCols.every((c) => { const f = filters[c.key]; - if (!f || !c.filterText) return true; - const val = String(c.filterText(r) ?? ""); - if (f.values && !f.values.has(val)) return false; - if (f.text && !val.toLowerCase().includes(f.text.toLowerCase())) return false; + if (!f || (!c.filterText && !c.filterValues)) return true; + if (f.values && !rowValues(c, r).some((v) => f.values.has(String(v)))) return false; + if (f.text && c.filterText + && !String(c.filterText(r) ?? "").toLowerCase().includes(f.text.toLowerCase())) return false; return true; })); if (sort) { @@ -259,11 +264,13 @@ export default function DataTable({