From 08ee1f20f53fc1b3b4c79f93ffc8f2e050084e7e Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 27 Jul 2026 17:44:38 +0200 Subject: [PATCH] Web: Kategorie-Filter - Oberkategorie subtil + als eigener Eintrag waehlbar Die DataTable kann jetzt je Spalte mehrere Filter-Werte pro Zeile (filterValues/Tokens) und eine eigene Option-Darstellung (filterOptionLabel). Die Kategorie-Spalte liefert als Tokens jede Ebene kumuliert (Klamotten, Klamotten -> kurze Hosen); so erscheint die Oberkategorie als eigener, anklickbarer Eintrag und selektiert alle Unterkategorien. Ober-Ebenen werden in Zelle und Dropdown kleiner/gedaempft dargestellt (CategoryPathLabel), das Blatt normal. Co-Authored-By: Claude Opus 4.8 --- web/src/categoryPath.js | 38 +++++++++++++++--------- web/src/components/CategoryPathLabel.jsx | 23 ++++++++++++++ web/src/components/DataTable.jsx | 31 ++++++++++++------- web/src/pages/ItemList.jsx | 15 ++++++---- web/src/pages/Products.jsx | 15 ++++++---- web/src/styles.css | 4 +++ 6 files changed, 91 insertions(+), 35 deletions(-) create mode 100644 web/src/components/CategoryPathLabel.jsx 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({ {orderedCols.map((c) => ( - {c.filterText ? ( + {(c.filterText || c.filterValues) ? ( setFilters((old) => ({ ...old, [c.key]: f }))} @@ -307,7 +314,7 @@ export default function DataTable({ } /** Freitext-Feld + Dropdown der vorhandenen Werte (eine Spalte). */ -function FilterCell({ col, filter, distinct, onChange, open, onToggleOpen }) { +function FilterCell({ col, filter, distinct, onChange, open, onToggleOpen, optionLabel, hasText = true }) { const btnRef = useRef(null); const popRef = useRef(null); const [pos, setPos] = useState(null); @@ -345,8 +352,10 @@ function FilterCell({ col, filter, distinct, onChange, open, onToggleOpen }) { return (
- onChange({ text: e.target.value, values })} /> + {hasText && ( + onChange({ text: e.target.value, values })} /> + )} {open && pos && createPortal( @@ -361,7 +370,7 @@ function FilterCell({ col, filter, distinct, onChange, open, onToggleOpen }) { {shown.map((v) => ( ))} {shown.length === 0 &&
Keine Werte.
} diff --git a/web/src/pages/ItemList.jsx b/web/src/pages/ItemList.jsx index 01d2a3b..1d16642 100644 --- a/web/src/pages/ItemList.jsx +++ b/web/src/pages/ItemList.jsx @@ -6,7 +6,8 @@ import Icon from "../components/Icon"; import DataTable from "../components/DataTable"; import { ProduktThumb } from "../components/ProduktBild"; import { locationOptions, locationPathById } from "../locationPath"; -import { categoryPathMap } from "../categoryPath"; +import { categoryInfoMap } from "../categoryPath"; +import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel"; /** * Liste aller Einzelstücke (physische Exemplare, jedes mit eigener UID/Lagerort) – @@ -31,8 +32,9 @@ export default function ItemList() { } useEffect(() => { load(); }, []); - // Kategorie als voller Pfad, damit "Klamotten" auch die Unterkategorien findet. - const catPfad = useMemo(() => categoryPathMap(categories), [categories]); + // 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]); async function patch(it, body) { try { await api.updateItem(it.id, body); await load(); } @@ -53,8 +55,11 @@ export default function ItemList() { { key: "brand", header: "Marke", width: 140, filterText: (it) => it.product_brand || "", render: (it) => {it.product_brand || "–"} }, { key: "category", header: "Kategorie", width: 200, - filterText: (it) => catPfad.get(it.category_id) || it.category_name || "", - render: (it) => {catPfad.get(it.category_id) || it.category_name || "–"} }, + filterText: (it) => catInfo.get(it.category_id)?.path || it.category_name || "", + filterValues: (it) => catInfo.get(it.category_id)?.tokens || (it.category_name ? [it.category_name] : [""]), + filterOptionLabel: (v) => (v === "" ? "(Leere)" : ), + sortValue: (it) => catInfo.get(it.category_id)?.path || it.category_name || "", + render: (it) => }, { key: "location", header: "Lagerort", width: 220, filterText: (it) => locationPathById(it.location_id, locations), render: (it) => (isAdmin ? ( diff --git a/web/src/pages/Products.jsx b/web/src/pages/Products.jsx index 0affe5f..dca393a 100644 --- a/web/src/pages/Products.jsx +++ b/web/src/pages/Products.jsx @@ -5,7 +5,8 @@ import { useAuth } from "../auth"; import Icon from "../components/Icon"; import DataTable from "../components/DataTable"; import { ProduktThumb } from "../components/ProduktBild"; -import { categoryPathMap } from "../categoryPath"; +import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel"; +import { categoryInfoMap } from "../categoryPath"; import { fmt, gebinde, unitShort } from "../units"; // Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit). @@ -39,8 +40,9 @@ export default function Products() { api.listCategories().then(setCategories).catch(() => {}); }, []); - // Kategorie als voller Pfad, damit "Klamotten" auch die Unterkategorien findet. - const catPfad = useMemo(() => categoryPathMap(categories), [categories]); + // 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 sichtbar = products.filter((p) => !typ || p.tracking === typ); const columns = [ @@ -61,8 +63,11 @@ export default function Products() { { key: "brand", header: "Marke", width: 150, filterText: (p) => p.brand || "", render: (p) => {p.brand || "–"} }, { key: "category", header: "Kategorie", width: 210, - filterText: (p) => catPfad.get(p.category_id) || p.category_name || "", - render: (p) => {catPfad.get(p.category_id) || p.category_name || "–"} }, + filterText: (p) => catInfo.get(p.category_id)?.path || p.category_name || "", + filterValues: (p) => catInfo.get(p.category_id)?.tokens || (p.category_name ? [p.category_name] : [""]), + filterOptionLabel: (v) => (v === "" ? "(Leere)" : ), + sortValue: (p) => catInfo.get(p.category_id)?.path || p.category_name || "", + render: (p) => }, { key: "unit", header: "Einheit", width: 200, filterText: einheitText, render: einheitText }, { key: "stock", header: "Bestand", width: 130, align: "num", diff --git a/web/src/styles.css b/web/src/styles.css index 261f093..0208f82 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -555,6 +555,10 @@ td select { width: auto; min-width: 0; max-width: 100%; } .dt-pop-opt input[type="checkbox"] { flex: 0 0 auto; width: auto; margin: 0; accent-color: var(--accent); } .dt-pop-opt span { flex: 1 1 auto; min-width: 0; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dt-tree-cell { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; } +/* Kategorie-Pfad: Oberkategorien klein und gedämpft, Blatt normal. */ +.cat-ancestors { font-size: 0.82em; color: var(--muted); opacity: 0.7; } +.cat-leaf { color: var(--text); } +.table td.num .cat-path { justify-content: flex-end; } /* ---------- EAN-Code-Zeile ---------- */ .code-row { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; flex-wrap: wrap; }