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",