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

@@ -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));
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({
<tr className="dt-filter">
{orderedCols.map((c) => (
<td key={c.key}>
{c.filterText ? (
{(c.filterText || c.filterValues) ? (
<FilterCell
col={c}
filter={filters[c.key]}
distinct={distinctByKey[c.key] || []}
optionLabel={c.filterOptionLabel}
hasText={Boolean(c.filterText)}
open={openFilter === c.key}
onToggleOpen={setOpenFilter}
onChange={(f) => 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 (
<div className="dt-filter-cell">
<input className="dt-filter-input" placeholder="Filter…" value={text}
onChange={(e) => onChange({ text: e.target.value, values })} />
{hasText && (
<input className="dt-filter-input" placeholder="Filter…" value={text}
onChange={(e) => onChange({ text: e.target.value, values })} />
)}
<button type="button" ref={btnRef} className={`dt-filter-btn ${active ? "active" : ""}`}
title="Werte auswählen" onClick={() => onToggleOpen(open ? null : col.key)}></button>
{open && pos && createPortal(
@@ -361,7 +370,7 @@ function FilterCell({ col, filter, distinct, onChange, open, onToggleOpen }) {
{shown.map((v) => (
<label key={v} className="dt-pop-opt">
<input type="checkbox" checked={isChecked(v)} onChange={() => toggleValue(v)} />
<span>{v === "" ? "(Leere)" : v}</span>
<span>{optionLabel ? optionLabel(v) : (v === "" ? "(Leere)" : v)}</span>
</label>
))}
{shown.length === 0 && <div className="muted small">Keine Werte.</div>}