diff --git a/web/src/components/DataTable.jsx b/web/src/components/DataTable.jsx new file mode 100644 index 0000000..d14beeb --- /dev/null +++ b/web/src/components/DataTable.jsx @@ -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 ( +
+
+ + + + + {toolbarExtra} +
+ {chooserOpen && chooserPos && createPortal( +
+
+ {allOrdered.map((c) => ( + + ))} +
+
, + document.body, + )} +
+ + + {orderedCols.map((c) => )} + + + + {orderedCols.map((c) => ( + + ))} + + {filterShown && ( + + {orderedCols.map((c) => ( + + ))} + + )} + + + {displayRows.map(({ row }) => ( + onRowClick(row, e) : undefined} + style={onRowClick ? { cursor: "pointer" } : undefined}> + {orderedCols.map((c) => ( + + ))} + + ))} + {displayRows.length === 0 && ( + loading && rows.length === 0 ? ( + Array.from({ length: 8 }, (_, r) => ( + + {orderedCols.map((c, ci) => ( + + ))} + + )) + ) : ( + + ) + )} + +
onDragStart(e, c)} + onDragOver={(e) => onDragOver(e, c)} + onDrop={(e) => onDrop(e, c)}> + toggleSort(c)}> + {c.header} + {sort?.key === c.key && {sort.dir === "asc" ? "▲" : "▼"}} + + {!c.fixed && startResize(e, c)} />} +
+ {(c.filterText || c.filterValues) ? ( + setFilters((old) => ({ ...old, [c.key]: f }))} + /> + ) : null} +
{c.render(row)}
+ {loading ? "Wird geladen…" : empty} +
+
+
+ ); +} + +/** 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 ( +
+ {hasText && ( + onChange({ text: e.target.value, values })} /> + )} + + {open && pos && createPortal( +
+ setSearch(e.target.value)} autoFocus /> +
+ + +
+
+ {shown.map((v) => ( + + ))} + {shown.length === 0 &&
Keine Werte.
} +
+
, + document.body, + )} +
+ ); +} diff --git a/web/src/components/Skeleton.jsx b/web/src/components/Skeleton.jsx new file mode 100644 index 0000000..af4cb33 --- /dev/null +++ b/web/src/components/Skeleton.jsx @@ -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 ( +