From 487c5f5f381c05e0c727abbdbe8a7df630d26796 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 27 Jul 2026 17:13:11 +0200 Subject: [PATCH] Web: Kategorie-Filter mit vollem Pfad (Oberkategorie findet Unterkategorien) Der Kategorie-Spaltenfilter zeigte nur die Blatt-Kategorien, die an Produkten haengen - die Oberkategorie (z.B. 'Klamotten') fehlte, obwohl darunter (kurze Hosen, T-Shirts) etwas eingelagert ist. Kategorie wird jetzt als voller Pfad 'Klamotten -> kurze Hosen' gefuehrt; Freitext 'Klamotten' findet damit alles darunter. Betrifft Produkt- und Einzelstueckliste. (Lagerorte nutzen den Pfad bereits.) Co-Authored-By: Claude Opus 4.8 --- web/src/categoryPath.js | 23 +++++++++++++++++++++++ web/src/pages/ItemList.jsx | 18 ++++++++++++------ web/src/pages/Products.jsx | 12 +++++++++--- 3 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 web/src/categoryPath.js diff --git a/web/src/categoryPath.js b/web/src/categoryPath.js new file mode 100644 index 0000000..f6a1f39 --- /dev/null +++ b/web/src/categoryPath.js @@ -0,0 +1,23 @@ +// 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. + +/** Map Kategorie-ID → voller Pfad "Klamotten → kurze Hosen". */ +export function categoryPathMap(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 c = byId.get(id); + 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 out = new Map(); + for (const c of categories || []) out.set(c.id, pfad(c.id, new Set())); + return out; +} diff --git a/web/src/pages/ItemList.jsx b/web/src/pages/ItemList.jsx index 0faa672..01d2a3b 100644 --- a/web/src/pages/ItemList.jsx +++ b/web/src/pages/ItemList.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { api } from "../api"; import { useAuth } from "../auth"; @@ -6,6 +6,7 @@ import Icon from "../components/Icon"; import DataTable from "../components/DataTable"; import { ProduktThumb } from "../components/ProduktBild"; import { locationOptions, locationPathById } from "../locationPath"; +import { categoryPathMap } from "../categoryPath"; /** * Liste aller Einzelstücke (physische Exemplare, jedes mit eigener UID/Lagerort) – @@ -17,18 +18,22 @@ export default function ItemList() { const [items, setItems] = useState([]); const [locations, setLocations] = useState([]); const [shops, setShops] = useState([]); + const [categories, setCategories] = useState([]); const [error, setError] = useState(null); async function load() { try { - const [is, ls, ss] = await Promise.all([ - api.listAllItems(), api.listLocations(), api.listShops(), + const [is, ls, ss, cs] = await Promise.all([ + api.listAllItems(), api.listLocations(), api.listShops(), api.listCategories(), ]); - setItems(is); setLocations(ls); setShops(ss); + setItems(is); setLocations(ls); setShops(ss); setCategories(cs); } catch (err) { setError(err.message); } } useEffect(() => { load(); }, []); + // Kategorie als voller Pfad, damit "Klamotten" auch die Unterkategorien findet. + const catPfad = useMemo(() => categoryPathMap(categories), [categories]); + async function patch(it, body) { try { await api.updateItem(it.id, body); await load(); } catch (err) { setError(err.message); } @@ -47,8 +52,9 @@ export default function ItemList() { render: (it) => it.product_name || "–" }, { key: "brand", header: "Marke", width: 140, filterText: (it) => it.product_brand || "", render: (it) => {it.product_brand || "–"} }, - { key: "category", header: "Kategorie", width: 160, filterText: (it) => it.category_name || "", - render: (it) => {it.category_name || "–"} }, + { 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 || "–"} }, { 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 dd9fb9c..0affe5f 100644 --- a/web/src/pages/Products.jsx +++ b/web/src/pages/Products.jsx @@ -1,10 +1,11 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { api } from "../api"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; import DataTable from "../components/DataTable"; import { ProduktThumb } from "../components/ProduktBild"; +import { categoryPathMap } from "../categoryPath"; import { fmt, gebinde, unitShort } from "../units"; // Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit). @@ -27,6 +28,7 @@ function einheitText(p) { export default function Products() { const { isAdmin } = useAuth(); const [products, setProducts] = useState([]); + const [categories, setCategories] = useState([]); // "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände. const [typ, setTyp] = useState(""); const [error, setError] = useState(null); @@ -34,8 +36,11 @@ export default function Products() { useEffect(() => { // Alle Produkte einmal laden; das Filtern erledigt die Tabelle clientseitig. api.listProducts("", "").then(setProducts).catch((err) => setError(err.message)); + api.listCategories().then(setCategories).catch(() => {}); }, []); + // Kategorie als voller Pfad, damit "Klamotten" auch die Unterkategorien findet. + const catPfad = useMemo(() => categoryPathMap(categories), [categories]); const sichtbar = products.filter((p) => !typ || p.tracking === typ); const columns = [ @@ -55,8 +60,9 @@ export default function Products() { ) }, { key: "brand", header: "Marke", width: 150, filterText: (p) => p.brand || "", render: (p) => {p.brand || "–"} }, - { key: "category", header: "Kategorie", width: 180, filterText: (p) => p.category_name || "", - render: (p) => {p.category_name || "–"} }, + { 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 || "–"} }, { key: "unit", header: "Einheit", width: 200, filterText: einheitText, render: einheitText }, { key: "stock", header: "Bestand", width: 130, align: "num",