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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 17:44:38 +02:00
parent 487c5f5f38
commit 08ee1f20f5
6 changed files with 91 additions and 35 deletions

View File

@@ -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) => <span className="muted">{it.product_brand || ""}</span> },
{ key: "category", header: "Kategorie", width: 200,
filterText: (it) => catPfad.get(it.category_id) || it.category_name || "",
render: (it) => <span className="muted">{catPfad.get(it.category_id) || it.category_name || ""}</span> },
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)" : <CategoryPathLabel parts={pathParts(v)} />),
sortValue: (it) => catInfo.get(it.category_id)?.path || it.category_name || "",
render: (it) => <CategoryPathLabel parts={catInfo.get(it.category_id)?.parts} fallback={it.category_name || ""} /> },
{ key: "location", header: "Lagerort", width: 220,
filterText: (it) => locationPathById(it.location_id, locations),
render: (it) => (isAdmin ? (

View File

@@ -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) => <span className="muted">{p.brand || ""}</span> },
{ key: "category", header: "Kategorie", width: 210,
filterText: (p) => catPfad.get(p.category_id) || p.category_name || "",
render: (p) => <span className="muted">{catPfad.get(p.category_id) || p.category_name || ""}</span> },
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)" : <CategoryPathLabel parts={pathParts(v)} />),
sortValue: (p) => catInfo.get(p.category_id)?.path || p.category_name || "",
render: (p) => <CategoryPathLabel parts={catInfo.get(p.category_id)?.parts} fallback={p.category_name || ""} /> },
{ key: "unit", header: "Einheit", width: 200, filterText: einheitText,
render: einheitText },
{ key: "stock", header: "Bestand", width: 130, align: "num",