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:
23
web/src/components/CategoryPathLabel.jsx
Normal file
23
web/src/components/CategoryPathLabel.jsx
Normal 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(" → ");
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user