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

@@ -1,23 +1,33 @@
// Kategorien sind verschachtelbar (Klamotten → kurze Hosen). Produkte hängen an // Kategorien sind verschachtelbar (Klamotten → kurze Hosen). Produkte hängen an
// der Blatt-Kategorie; um „alle Klamotten" filtern zu können, arbeiten die // der Blatt-Kategorie; um „alle Klamotten" filtern zu können, arbeiten die Listen
// Listen mit dem vollen Pfad statt nur dem Blattnamen. // 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 byId = new Map((categories || []).map((c) => [c.id, c]));
const cache = new Map(); const partsCache = new Map();
const pfad = (id, seen) => { const parts = (id, seen) => {
if (cache.has(id)) return cache.get(id); if (partsCache.has(id)) return partsCache.get(id);
const c = byId.get(id); const c = byId.get(id);
if (!c) return ""; if (!c) return [];
if (seen.has(id)) return c.name; // Ringschutz if (seen.has(id)) return [c.name]; // Ringschutz
seen.add(id); seen.add(id);
const oben = c.parent_id != null ? pfad(c.parent_id, seen) : ""; const oben = c.parent_id != null ? parts(c.parent_id, seen) : [];
const p = oben ? `${oben}${c.name}` : c.name; const arr = [...oben, c.name];
cache.set(id, p); partsCache.set(id, arr);
return p; return arr;
}; };
const out = new Map(); 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; return out;
} }

View File

@@ -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 <span className="muted">{fallback}</span>;
const ancestors = parts.slice(0, -1);
const leaf = parts[parts.length - 1];
return (
<span className="cat-path">
{ancestors.length > 0 && (
<span className="cat-ancestors">{ancestors.join(" → ")} </span>
)}
<span className="cat-leaf">{leaf}</span>
</span>
);
}
/** Aus einem Token-/Pfad-String die Teile gewinnen (Trenner " → "). */
export function pathParts(value) {
return String(value ?? "").split(" → ");
}

View File

@@ -71,15 +71,20 @@ export default function DataTable({
: fixedWidth(c)); : fixedWidth(c));
const totalWidth = Math.round(orderedCols.reduce((s, c) => s + widthOf(c), 0)); 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 distinctByKey = useMemo(() => {
const m = {}; const m = {};
for (const c of columns) { for (const c of columns) {
if (!c.filterText) continue; if (!c.filterText && !c.filterValues) continue;
const set = new Set(); 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")); m[c.key] = [...set].sort((a, b) => a.localeCompare(b, "de"));
} }
return m; return m;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columns, rows]); }, [columns, rows]);
const anyFilter = orderedCols.some((c) => { const anyFilter = orderedCols.some((c) => {
@@ -91,10 +96,10 @@ export default function DataTable({
const flat = useMemo(() => { const flat = useMemo(() => {
let out = rows.filter((r) => orderedCols.every((c) => { let out = rows.filter((r) => orderedCols.every((c) => {
const f = filters[c.key]; const f = filters[c.key];
if (!f || !c.filterText) return true; if (!f || (!c.filterText && !c.filterValues)) return true;
const val = String(c.filterText(r) ?? ""); if (f.values && !rowValues(c, r).some((v) => f.values.has(String(v)))) return false;
if (f.values && !f.values.has(val)) return false; if (f.text && c.filterText
if (f.text && !val.toLowerCase().includes(f.text.toLowerCase())) return false; && !String(c.filterText(r) ?? "").toLowerCase().includes(f.text.toLowerCase())) return false;
return true; return true;
})); }));
if (sort) { if (sort) {
@@ -259,11 +264,13 @@ export default function DataTable({
<tr className="dt-filter"> <tr className="dt-filter">
{orderedCols.map((c) => ( {orderedCols.map((c) => (
<td key={c.key}> <td key={c.key}>
{c.filterText ? ( {(c.filterText || c.filterValues) ? (
<FilterCell <FilterCell
col={c} col={c}
filter={filters[c.key]} filter={filters[c.key]}
distinct={distinctByKey[c.key] || []} distinct={distinctByKey[c.key] || []}
optionLabel={c.filterOptionLabel}
hasText={Boolean(c.filterText)}
open={openFilter === c.key} open={openFilter === c.key}
onToggleOpen={setOpenFilter} onToggleOpen={setOpenFilter}
onChange={(f) => setFilters((old) => ({ ...old, [c.key]: f }))} onChange={(f) => setFilters((old) => ({ ...old, [c.key]: f }))}
@@ -307,7 +314,7 @@ export default function DataTable({
} }
/** Freitext-Feld + Dropdown der vorhandenen Werte (eine Spalte). */ /** 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 btnRef = useRef(null);
const popRef = useRef(null); const popRef = useRef(null);
const [pos, setPos] = useState(null); const [pos, setPos] = useState(null);
@@ -345,8 +352,10 @@ function FilterCell({ col, filter, distinct, onChange, open, onToggleOpen }) {
return ( return (
<div className="dt-filter-cell"> <div className="dt-filter-cell">
{hasText && (
<input className="dt-filter-input" placeholder="Filter…" value={text} <input className="dt-filter-input" placeholder="Filter…" value={text}
onChange={(e) => onChange({ text: e.target.value, values })} /> onChange={(e) => onChange({ text: e.target.value, values })} />
)}
<button type="button" ref={btnRef} className={`dt-filter-btn ${active ? "active" : ""}`} <button type="button" ref={btnRef} className={`dt-filter-btn ${active ? "active" : ""}`}
title="Werte auswählen" onClick={() => onToggleOpen(open ? null : col.key)}></button> title="Werte auswählen" onClick={() => onToggleOpen(open ? null : col.key)}></button>
{open && pos && createPortal( {open && pos && createPortal(
@@ -361,7 +370,7 @@ function FilterCell({ col, filter, distinct, onChange, open, onToggleOpen }) {
{shown.map((v) => ( {shown.map((v) => (
<label key={v} className="dt-pop-opt"> <label key={v} className="dt-pop-opt">
<input type="checkbox" checked={isChecked(v)} onChange={() => toggleValue(v)} /> <input type="checkbox" checked={isChecked(v)} onChange={() => toggleValue(v)} />
<span>{v === "" ? "(Leere)" : v}</span> <span>{optionLabel ? optionLabel(v) : (v === "" ? "(Leere)" : v)}</span>
</label> </label>
))} ))}
{shown.length === 0 && <div className="muted small">Keine Werte.</div>} {shown.length === 0 && <div className="muted small">Keine Werte.</div>}

View File

@@ -6,7 +6,8 @@ import Icon from "../components/Icon";
import DataTable from "../components/DataTable"; import DataTable from "../components/DataTable";
import { ProduktThumb } from "../components/ProduktBild"; import { ProduktThumb } from "../components/ProduktBild";
import { locationOptions, locationPathById } from "../locationPath"; 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) * Liste aller Einzelstücke (physische Exemplare, jedes mit eigener UID/Lagerort)
@@ -31,8 +32,9 @@ export default function ItemList() {
} }
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
// Kategorie als voller Pfad, damit "Klamotten" auch die Unterkategorien findet. // Kategorie als Pfad + Tokens (jede Ebene), damit "Klamotten" auch die
const catPfad = useMemo(() => categoryPathMap(categories), [categories]); // Unterkategorien findet und die Oberkategorie im Filter wählbar ist.
const catInfo = useMemo(() => categoryInfoMap(categories), [categories]);
async function patch(it, body) { async function patch(it, body) {
try { await api.updateItem(it.id, body); await load(); } 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 || "", { key: "brand", header: "Marke", width: 140, filterText: (it) => it.product_brand || "",
render: (it) => <span className="muted">{it.product_brand || ""}</span> }, render: (it) => <span className="muted">{it.product_brand || ""}</span> },
{ key: "category", header: "Kategorie", width: 200, { key: "category", header: "Kategorie", width: 200,
filterText: (it) => catPfad.get(it.category_id) || it.category_name || "", filterText: (it) => catInfo.get(it.category_id)?.path || it.category_name || "",
render: (it) => <span className="muted">{catPfad.get(it.category_id) || it.category_name || ""}</span> }, 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, { key: "location", header: "Lagerort", width: 220,
filterText: (it) => locationPathById(it.location_id, locations), filterText: (it) => locationPathById(it.location_id, locations),
render: (it) => (isAdmin ? ( render: (it) => (isAdmin ? (

View File

@@ -5,7 +5,8 @@ import { useAuth } from "../auth";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import DataTable from "../components/DataTable"; import DataTable from "../components/DataTable";
import { ProduktThumb } from "../components/ProduktBild"; 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"; import { fmt, gebinde, unitShort } from "../units";
// Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit). // Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit).
@@ -39,8 +40,9 @@ export default function Products() {
api.listCategories().then(setCategories).catch(() => {}); api.listCategories().then(setCategories).catch(() => {});
}, []); }, []);
// Kategorie als voller Pfad, damit "Klamotten" auch die Unterkategorien findet. // Kategorie als Pfad + Tokens (jede Ebene), damit "Klamotten" auch die
const catPfad = useMemo(() => categoryPathMap(categories), [categories]); // 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 sichtbar = products.filter((p) => !typ || p.tracking === typ);
const columns = [ const columns = [
@@ -61,8 +63,11 @@ export default function Products() {
{ key: "brand", header: "Marke", width: 150, filterText: (p) => p.brand || "", { key: "brand", header: "Marke", width: 150, filterText: (p) => p.brand || "",
render: (p) => <span className="muted">{p.brand || ""}</span> }, render: (p) => <span className="muted">{p.brand || ""}</span> },
{ key: "category", header: "Kategorie", width: 210, { key: "category", header: "Kategorie", width: 210,
filterText: (p) => catPfad.get(p.category_id) || p.category_name || "", filterText: (p) => catInfo.get(p.category_id)?.path || p.category_name || "",
render: (p) => <span className="muted">{catPfad.get(p.category_id) || p.category_name || ""}</span> }, 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, { key: "unit", header: "Einheit", width: 200, filterText: einheitText,
render: einheitText }, render: einheitText },
{ key: "stock", header: "Bestand", width: 130, align: "num", { key: "stock", header: "Bestand", width: 130, align: "num",

View File

@@ -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 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-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%; } .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 ---------- */ /* ---------- EAN-Code-Zeile ---------- */
.code-row { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; flex-wrap: wrap; } .code-row { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; flex-wrap: wrap; }