import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import Icon from "./Icon"; /** * 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.", toolbarExtra = 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 [widths, setWidths] = useState(() => loadPersisted(id).widths || {}); const [filters, setFilters] = useState({}); // { key: { text, values:Set|null } } const [sort, setSort] = useState(null); // { key, dir } const [filterShown, setFilterShown] = useState(true); const [openFilter, setOpenFilter] = useState(null); useEffect(() => { savePersisted(id, { order, widths }); }, [id, order, widths]); // Containerbreite messen, damit die grow-Spalte den Rest füllt. 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 orderedCols = order.map((k) => colByKey[k]).filter(Boolean); 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 distinctByKey = useMemo(() => { const m = {}; for (const c of columns) { if (!c.filterText) continue; const set = new Set(); for (const r of rows) set.add(String(c.filterText(r) ?? "")); m[c.key] = [...set].sort((a, b) => a.localeCompare(b, "de")); } return m; }, [columns, rows]); const visible = 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; 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]); 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(); resizing.current = { key: c.key, startX: e.clientX, startW: widthOf(c) }; 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({}); savePersisted(id, {}); } return (
{toolbarExtra}
{orderedCols.map((c) => )} {orderedCols.map((c) => ( ))} {filterShown && ( {orderedCols.map((c) => ( ))} )} {visible.map((r) => ( {orderedCols.map((c) => ( ))} ))} {visible.length === 0 && ( )}
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 ? ( setFilters((old) => ({ ...old, [c.key]: f }))} /> ) : null}
{c.render(r)}
{empty}
); } /** Freitext-Feld + Dropdown der vorhandenen Werte (eine Spalte). */ function FilterCell({ col, filter, distinct, onChange, open, onToggleOpen }) { 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 (
onChange({ text: e.target.value, values })} /> {open && pos && createPortal(
setSearch(e.target.value)} autoFocus />
{shown.map((v) => ( ))} {shown.length === 0 &&
Keine Werte.
}
, document.body, )}
); }