feat: Excel-artige DataTable (Spaltenbreite/Sortieren/Filtern) für alle Tabellen

- Portierte DataTable-Komponente (aus Vorrania): Spalten per Drag breiter ziehen
  und verschieben, Klick-Sortierung, Filterzeile je Spalte, Spalten ein-/ausblenden;
  Breiten/Reihenfolge/Filter pro Tabelle im Browser gemerkt (localStorage)
- Zu langer Zellinhalt wird mit „…" abgeschnitten (Titel bleibt sichtbar, Excel-artig)
- Umgestellt: Buchungen, Import-Prüfansicht (inkl. interaktiver Zellen), Import-
  Verlauf, Benutzer
- Neue Skeleton-Komponente für den Ladezustand

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 22:58:01 +02:00
co-authored by Claude Opus 4.8
parent e02ef7eb8f
commit 1d5b23c127
6 changed files with 685 additions and 173 deletions
+416
View File
@@ -0,0 +1,416 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Icon from "./Icon";
import { Skeleton } from "./Skeleton";
/**
* Excel-artige Tabelle in der bestehenden Designsprache: feste Filterzeile je
* Spalte (Freitext + Dropdown der vorhandenen Werte, über Spalten kombinierbar),
* Spalten per Drag verschieben und in der Breite ziehen, Klick-Sortierung.
* Reihenfolge + Breiten merkt sich der Browser (localStorage, je `id`).
*
* Spalten-Def: { key, header, render(row)→JSX, filterText(row)? (aktiviert Filter),
* sortValue(row)? (Sortierung), align:"num", grow:true (füllt Restbreite),
* fixed:true (kein Reorder/Resize), width, min }.
*/
const DEFAULT_WIDTH = 150;
function loadPersisted(id) {
try { return JSON.parse(localStorage.getItem(`datatable:${id}`)) || {}; }
catch { return {}; }
}
function savePersisted(id, data) {
try { localStorage.setItem(`datatable:${id}`, JSON.stringify(data)); } catch { /* ignore */ }
}
export default function DataTable({
id, columns, rows, getRowKey, rowClassName,
empty = "Nichts gefunden.", loading = false, toolbarExtra = null,
onVisibleRows = null, onRowClick = null,
}) {
const colByKey = useMemo(() => Object.fromEntries(columns.map((c) => [c.key, c])), [columns]);
const [order, setOrder] = useState(() => {
const p = loadPersisted(id);
const saved = Array.isArray(p.order) ? p.order.filter((k) => colByKey[k]) : [];
const rest = columns.map((c) => c.key).filter((k) => !saved.includes(k));
return [...saved, ...rest];
});
const schluessel = columns.map((c) => c.key).join("|");
useEffect(() => {
setOrder((alt) => {
const fehlend = columns.map((c) => c.key).filter((k) => !alt.includes(k));
return fehlend.length ? [...alt, ...fehlend] : alt;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schluessel]);
const [widths, setWidths] = useState(() => loadPersisted(id).widths || {});
const [filters, setFilters] = useState(() => {
const saved = loadPersisted(id).filters || {};
const out = {};
for (const [k, v] of Object.entries(saved)) {
if (!colByKey[k] || !v) continue;
out[k] = { text: v.text || "", values: Array.isArray(v.values) ? new Set(v.values) : null };
}
return out;
}); // { key: { text, values:Set|null } }
const [sort, setSort] = useState(() => {
const s = loadPersisted(id).sort;
return s && s.key && colByKey[s.key] ? s : null;
}); // { key, dir }
const [filterShown, setFilterShown] = useState(true);
const [openFilter, setOpenFilter] = useState(null);
const [hidden, setHidden] = useState(
() => new Set((loadPersisted(id).hidden || []).filter((k) => colByKey[k])),
);
const [chooserOpen, setChooserOpen] = useState(false);
useEffect(() => {
const filtersSer = {};
for (const [k, v] of Object.entries(filters)) {
if (!v || (!v.text && v.values == null)) continue;
filtersSer[k] = { text: v.text || "", values: v.values ? [...v.values] : null };
}
savePersisted(id, { order, widths, hidden: [...hidden], filters: filtersSer, sort });
}, [id, order, widths, hidden, filters, sort]);
const wrapRef = useRef(null);
const [containerWidth, setContainerWidth] = useState(0);
useLayoutEffect(() => {
const el = wrapRef.current;
if (!el) return undefined;
const ro = new ResizeObserver(([e]) => setContainerWidth(e.contentRect.width));
ro.observe(el);
setContainerWidth(el.clientWidth);
return () => ro.disconnect();
}, []);
const allOrdered = order.map((k) => colByKey[k]).filter(Boolean);
const orderedCols = allOrdered.filter((c) => !hidden.has(c.key));
const chooserBtn = useRef(null);
const chooserPop = useRef(null);
const [chooserPos, setChooserPos] = useState(null);
useLayoutEffect(() => {
if (chooserOpen && chooserBtn.current) {
const r = chooserBtn.current.getBoundingClientRect();
setChooserPos({
left: Math.min(r.left, window.innerWidth - 240),
top: r.bottom + 4,
maxHeight: Math.max(180, window.innerHeight - r.bottom - 24),
});
}
}, [chooserOpen]);
useEffect(() => {
if (!chooserOpen) return undefined;
function onDoc(e) {
if (chooserBtn.current?.contains(e.target) || chooserPop.current?.contains(e.target)) return;
setChooserOpen(false);
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [chooserOpen]);
function toggleHidden(key) {
setHidden((s) => {
const n = new Set(s);
if (n.has(key)) { n.delete(key); return n; }
if (allOrdered.filter((c) => !n.has(c.key) && c.key !== key).length === 0) return s;
n.add(key);
return n;
});
}
const isGrow = (c) => c.grow && widths[c.key] == null;
const fixedWidth = (c) => widths[c.key] ?? c.width ?? DEFAULT_WIDTH;
const growCols = orderedCols.filter(isGrow);
const sumFixed = orderedCols.filter((c) => !isGrow(c)).reduce((s, c) => s + fixedWidth(c), 0);
const remaining = containerWidth - sumFixed - 2;
const widthOf = (c) => (isGrow(c)
? Math.max(c.min ?? 160, growCols.length ? remaining / growCols.length : 160)
: fixedWidth(c));
const totalWidth = Math.round(orderedCols.reduce((s, c) => s + widthOf(c), 0));
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 && !c.filterValues) continue;
const set = new Set();
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 flat = useMemo(() => {
let out = rows.filter((r) => orderedCols.every((c) => {
const f = filters[c.key];
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) {
const c = colByKey[sort.key];
const val = (r) => (c?.sortValue ? c.sortValue(r) : (c?.filterText?.(r) ?? ""));
out = [...out].sort((a, b) => {
const va = val(a); const vb = val(b);
const cmp = (typeof va === "number" && typeof vb === "number")
? va - vb : String(va).localeCompare(String(vb), "de");
return sort.dir === "desc" ? -cmp : cmp;
});
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, order, filters, sort]);
const displayRows = useMemo(() => flat.map((row) => ({ row })), [flat]);
const onVisibleRef = useRef(onVisibleRows);
onVisibleRef.current = onVisibleRows;
useEffect(() => {
onVisibleRef.current?.(displayRows.map((d) => d.row));
}, [displayRows]);
function toggleSort(c) {
if (!c.sortValue && !c.filterText) return;
setSort((s) => {
if (!s || s.key !== c.key) return { key: c.key, dir: "asc" };
if (s.dir === "asc") return { key: c.key, dir: "desc" };
return null;
});
}
// --- Resize (Pointer) ---
const resizing = useRef(null);
function startResize(e, c) {
e.preventDefault(); e.stopPropagation();
const startW = widthOf(c);
setWidths((old) => {
const next = { ...old };
for (const gc of growCols) if (next[gc.key] == null) next[gc.key] = Math.round(widthOf(gc));
return next;
});
resizing.current = { key: c.key, startX: e.clientX, startW };
window.addEventListener("pointermove", onResizeMove);
window.addEventListener("pointerup", endResize);
}
function onResizeMove(e) {
const r = resizing.current; if (!r) return;
const w = Math.max(60, Math.round(r.startW + (e.clientX - r.startX)));
setWidths((old) => ({ ...old, [r.key]: w }));
}
function endResize() {
resizing.current = null;
window.removeEventListener("pointermove", onResizeMove);
window.removeEventListener("pointerup", endResize);
}
// --- Reorder (HTML5 Drag) ---
const dragKey = useRef(null);
const [dropKey, setDropKey] = useState(null);
function onDragStart(e, c) {
if (resizing.current) { e.preventDefault(); return; }
dragKey.current = c.key;
e.dataTransfer.effectAllowed = "move";
}
function onDragOver(e, c) { e.preventDefault(); if (c.key !== dropKey) setDropKey(c.key); }
function onDrop(e, c) {
e.preventDefault();
const from = dragKey.current; const to = c.key;
dragKey.current = null; setDropKey(null);
if (!from || from === to) return;
setOrder((ord) => {
const next = ord.filter((k) => k !== from);
next.splice(next.indexOf(to), 0, from);
return next;
});
}
function resetColumns() {
setOrder(columns.map((c) => c.key));
setWidths({});
setHidden(new Set());
}
return (
<div className="dt-root">
<div className="dt-toolbar">
<button type="button" className={`btn sm ${filterShown ? "" : "ghost"}`}
onClick={() => setFilterShown((v) => !v)}>
<Icon name="search" size={14} />Filter
</button>
<button type="button" className="btn sm ghost" onClick={() => setFilters({})}>Filter löschen</button>
<button type="button" ref={chooserBtn} className={`btn sm ${chooserOpen ? "" : "ghost"}`}
onClick={() => setChooserOpen((o) => !o)}>
<Icon name="settings" size={14} />Spalten
</button>
<button type="button" className="btn sm ghost" onClick={resetColumns}>Spalten zurücksetzen</button>
{toolbarExtra}
</div>
{chooserOpen && chooserPos && createPortal(
<div ref={chooserPop} className="dt-pop" style={{ left: chooserPos.left, top: chooserPos.top }}>
<div className="dt-pop-list" style={{ maxHeight: chooserPos.maxHeight }}>
{allOrdered.map((c) => (
<label key={c.key} className="dt-pop-opt">
<input type="checkbox" checked={!hidden.has(c.key)} onChange={() => toggleHidden(c.key)} />
<span>{c.label || c.header || c.key}</span>
</label>
))}
</div>
</div>,
document.body,
)}
<div className="table-wrap" ref={wrapRef}>
<table className="table datatable" style={{ width: totalWidth ? `${totalWidth}px` : "100%" }}>
<colgroup>
{orderedCols.map((c) => <col key={c.key} style={{ width: `${Math.round(widthOf(c))}px` }} />)}
</colgroup>
<thead>
<tr>
{orderedCols.map((c) => (
<th key={c.key}
className={`dt-th ${c.align === "num" ? "num" : ""} ${dropKey === c.key ? "drop" : ""} ${sort?.key === c.key ? "sorted" : ""}`}
draggable={!c.fixed}
onDragStart={(e) => onDragStart(e, c)}
onDragOver={(e) => onDragOver(e, c)}
onDrop={(e) => onDrop(e, c)}>
<span className="dt-th-label" onClick={() => toggleSort(c)}>
<span className="dt-th-text">{c.header}</span>
{sort?.key === c.key && <span className="dt-sort">{sort.dir === "asc" ? "▲" : "▼"}</span>}
</span>
{!c.fixed && <span className="dt-resize" onPointerDown={(e) => startResize(e, c)} />}
</th>
))}
</tr>
{filterShown && (
<tr className="dt-filter">
{orderedCols.map((c) => (
<td key={c.key}>
{(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 }))}
/>
) : null}
</td>
))}
</tr>
)}
</thead>
<tbody>
{displayRows.map(({ row }) => (
<tr key={getRowKey(row)} className={rowClassName?.(row) || ""}
onClick={onRowClick ? (e) => onRowClick(row, e) : undefined}
style={onRowClick ? { cursor: "pointer" } : undefined}>
{orderedCols.map((c) => (
<td key={c.key} className={c.align === "num" ? "num" : ""}>{c.render(row)}</td>
))}
</tr>
))}
{displayRows.length === 0 && (
loading && rows.length === 0 ? (
Array.from({ length: 8 }, (_, r) => (
<tr key={`skel-${r}`} className="skel-tr" aria-hidden="true">
{orderedCols.map((c, ci) => (
<td key={c.key} className={c.align === "num" ? "num" : ""}>
<Skeleton
w={c.align === "num" ? "40%" : `${[80, 64, 72, 58, 86][(r + ci) % 5]}%`}
style={c.align === "num" ? { marginLeft: "auto" } : undefined}
/>
</td>
))}
</tr>
))
) : (
<tr><td className="empty" colSpan={orderedCols.length}>
{loading ? "Wird geladen…" : empty}
</td></tr>
)
)}
</tbody>
</table>
</div>
</div>
);
}
/** Freitext-Feld + Dropdown der vorhandenen Werte (eine Spalte). */
function FilterCell({ col, filter, distinct, onChange, open, onToggleOpen, optionLabel, hasText = true }) {
const btnRef = useRef(null);
const popRef = useRef(null);
const [pos, setPos] = useState(null);
const [search, setSearch] = useState("");
const text = filter?.text || "";
const values = filter?.values || null; // null = alle erlaubt
useLayoutEffect(() => {
if (open && btnRef.current) {
const r = btnRef.current.getBoundingClientRect();
setPos({ left: Math.min(r.left, window.innerWidth - 260), top: r.bottom + 4 });
}
}, [open]);
useEffect(() => {
if (!open) return undefined;
function onDoc(e) {
if (btnRef.current?.contains(e.target) || popRef.current?.contains(e.target)) return;
onToggleOpen(null);
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open, onToggleOpen]);
const shown = distinct.filter((v) => v.toLowerCase().includes(search.toLowerCase()));
const isChecked = (v) => values == null || values.has(v);
const active = Boolean(text) || (values != null && values.size !== distinct.length);
function toggleValue(v) {
const cur = values == null ? new Set(distinct) : new Set(values);
if (cur.has(v)) cur.delete(v); else cur.add(v);
onChange({ text, values: cur.size === distinct.length ? null : cur });
}
function setAll(on) { onChange({ text, values: on ? null : new Set() }); }
return (
<div className="dt-filter-cell">
{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(
<div ref={popRef} className="dt-pop" style={{ left: pos.left, top: pos.top }}>
<input className="dt-pop-search" placeholder="Suchen…" value={search}
onChange={(e) => setSearch(e.target.value)} autoFocus />
<div className="dt-pop-actions">
<button type="button" className="link-btn" onClick={() => setAll(true)}>Alle</button>
<button type="button" className="link-btn" onClick={() => setAll(false)}>Keine</button>
</div>
<div className="dt-pop-list">
{shown.map((v) => (
<label key={v} className="dt-pop-opt">
<input type="checkbox" checked={isChecked(v)} onChange={() => toggleValue(v)} />
<span>{optionLabel ? optionLabel(v) : (v === "" ? "(Leere)" : v)}</span>
</label>
))}
{shown.length === 0 && <div className="muted small">Keine Werte.</div>}
</div>
</div>,
document.body,
)}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Schimmernde Ladeplatzhalter ("Skeleton"). Nur CSS-Tokens (--surface-2 /
* --border), passt sich daher automatisch an Hell/Dunkel an. Reine Deko, deshalb
* durchgehend aria-hidden – Screenreader sollen keine leeren Balken vorlesen.
*/
/** Eine einzelne schimmernde Leiste. Breite/Höhe als px oder CSS-Wert ("60%"). */
export function Skeleton({ w, h = 14, radius, className = "", style }) {
return (
<span
className={`skeleton ${className}`.trim()}
aria-hidden="true"
style={{ width: w, height: h, borderRadius: radius, ...style }}
/>
);
}