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 }. * * `tree` (optional): { column, idOf(row), parentIdOf(row) } – zeigt die Zeilen als * ein-/ausklappbaren Baum in der Spalte `column`. Sobald gefiltert oder sortiert * wird, wechselt die Ansicht auf eine flache, gefilterte Liste. */ 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, tree = 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]; }); // `order` ist eine Momentaufnahme vom Mounten. Seiten duerfen ihre Spalten // aber danach aendern (die Artikelliste zeigt „Verwaltung" nur bei // Gegenstaenden), und eine neu hinzugefuegte Spalte steht ohnehin nie in der // gespeicherten Reihenfolge. Fehlt sie dort, faellt sie aus allOrdered heraus // und ist WEG – nicht nur aus dem Spalten-Menue, sondern aus der Tabelle. // Deshalb hier nachziehen. Angehaengt, nicht einsortiert: eine selbst // gewaehlte Reihenfolge soll nicht umspringen. 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 || {}); // Filter + Sortierung bleiben erhalten (localStorage je Tabelle), bis man // „Filter löschen" drückt oder anders sortiert – auch nach Navigieren/Neuladen. // Beim Speichern werden Sets zu Arrays; beim Laden zurück zu Sets. 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 [collapsed, setCollapsed] = useState(() => new Set()); const [hidden, setHidden] = useState( () => new Set((loadPersisted(id).hidden || []).filter((k) => colByKey[k])), ); const [chooserOpen, setChooserOpen] = useState(false); useEffect(() => { // Nur echte Einschränkungen sichern (leerer Text + values=null = kein Filter); // Sets werden zu Arrays serialisiert. 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]); // 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 allOrdered = order.map((k) => colByKey[k]).filter(Boolean); const orderedCols = allOrdered.filter((c) => !hidden.has(c.key)); // Spalten-Wähler-Popover. const chooserBtn = useRef(null); const chooserPop = useRef(null); const [chooserPos, setChooserPos] = useState(null); useLayoutEffect(() => { if (chooserOpen && chooserBtn.current) { const r = chooserBtn.current.getBoundingClientRect(); // Hoehe an den freien Platz koppeln. Eine feste Obergrenze schnitt die // letzten Eintraege ab, ohne dass man es sah – auf macOS blendet das // System die Bildlaufleiste aus, und dann fehlt die Spalte scheinbar. 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; } // Mindestens eine Spalte sichtbar lassen. 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)); // 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 && !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 anyFilter = orderedCols.some((c) => { const f = filters[c.key]; return f && (f.text || (f.values && f.values.size !== (distinctByKey[c.key]?.length ?? 0))); }); // Flach gefiltert + sortiert. 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]); // Baum-Reihenfolge (nur wenn tree und weder gefiltert noch sortiert). const treeActive = Boolean(tree) && !anyFilter && !sort; const treeData = useMemo(() => { if (!tree) return null; const byId = new Map(rows.map((r) => [tree.idOf(r), r])); const byParent = new Map(); for (const r of rows) { const p = tree.parentIdOf(r); if (!byParent.has(p)) byParent.set(p, []); byParent.get(p).push(r); } const ids = new Set(rows.map(tree.idOf)); const order2 = []; const walk = (r, depth) => { const kids = byParent.get(tree.idOf(r)) || []; order2.push({ row: r, depth, hasChildren: kids.length > 0 }); for (const k of kids) walk(k, depth + 1); }; for (const r of rows) { const p = tree.parentIdOf(r); if (p == null || !ids.has(p)) walk(r, 0); } return { order: order2, byId }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [rows, tree]); const displayRows = useMemo(() => { if (treeActive && treeData) { const hidden = (row) => { let p = tree.parentIdOf(row); while (p != null) { if (collapsed.has(p)) return true; const pr = treeData.byId.get(p); p = pr ? tree.parentIdOf(pr) : null; } return false; }; return treeData.order.filter((n) => !hidden(n.row)); } return flat.map((row) => ({ row, depth: 0, hasChildren: false })); // eslint-disable-next-line react-hooks/exhaustive-deps }, [treeActive, treeData, flat, collapsed]); // Sichtbare (gefilterte/sortierte) Zeilen nach außen melden – z.B. damit eine // Seite „alle sichtbaren auswählen" anbieten kann. Ref, damit eine inline // übergebene Funktion keine Endlosschleife auslöst. 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; }); } function toggleCollapse(cid) { setCollapsed((s) => { const n = new Set(s); if (n.has(cid)) n.delete(cid); else n.add(cid); return n; }); } // --- Resize (Pointer) --- // // Gerechnet wird mit der BEWEGUNG seit dem letzten Schritt, nicht mit dem // Abstand zum Startpunkt. Nur so laesst sich die letzte Spalte verbreitern: // Ihr Anfasser sitzt am rechten Rand, und dort ist fuer den Zeiger Schluss - // nach rechts gibt es kein Fenster mehr. Steht er in der Randzone, waechst die // Spalte deshalb von selbst weiter, Schritt fuer Schritt, solange die Taste // haelt. Ein Taktgeber treibt das an, denn ein stehender Zeiger meldet keine // Bewegung mehr. const resizing = useRef(null); const rollTimer = useRef(null); const RAND = 36; // Breite der Randzone const SCHRITT = 14; // Pixel je Takt in der Randzone function startResize(e, c) { e.preventDefault(); e.stopPropagation(); // Die flexible (grow-)Spalte auf ihre aktuelle Pixelbreite einfrieren. Sonst // "klaut" das Breiterziehen einer Spalte der grow-Spalte den Platz - dann // schrumpft die Nachbarspalte und alles wandert nach links. Danach verhalten // sich die Spalten wie in Excel (unabhaengig, notfalls scrollt die Tabelle). 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, breite: widthOf(c), zeigerX: e.clientX, letzterX: e.clientX }; rollTimer.current = window.setInterval(anwenden, 30); window.addEventListener("pointermove", onResizeMove); window.addEventListener("pointerup", endResize); } function anwenden() { const r = resizing.current; if (!r) return; let delta = r.zeigerX - r.letzterX; r.letzterX = r.zeigerX; const el = wrapRef.current; let amRand = false; if (el) { const kasten = el.getBoundingClientRect(); if (r.zeigerX > kasten.right - RAND) { delta += SCHRITT; amRand = true; } else if (r.zeigerX < kasten.left + RAND) delta -= SCHRITT; } if (!delta) return; const neu = Math.max(60, r.breite + delta); if (neu === r.breite) return; r.breite = neu; setWidths((old) => ({ ...old, [r.key]: Math.round(neu) })); // Mitlaufen, damit der Anfasser unter dem Zeiger bleibt, waehrend die // Tabelle nach rechts waechst. if (amRand && el) requestAnimationFrame(() => { el.scrollLeft = el.scrollWidth; }); } function onResizeMove(e) { const r = resizing.current; if (!r) return; r.zeigerX = e.clientX; anwenden(); } function endResize() { resizing.current = null; if (rollTimer.current) { window.clearInterval(rollTimer.current); rollTimer.current = null; } window.removeEventListener("pointermove", onResizeMove); window.removeEventListener("pointerup", endResize); } // Beim Verlassen der Seite mitten im Ziehen bliebe sonst ein Taktgeber laufen. useEffect(() => () => { if (rollTimer.current) window.clearInterval(rollTimer.current); }, []); // --- 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() { // Nur Spalten (Reihenfolge/Breiten/Sichtbarkeit) zurücksetzen – Filter und // Sortierung bleiben; der Speicher-Effekt schreibt den neuen Stand. 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, depth, hasChildren }) => ( onRowClick(row, e) : undefined} style={onRowClick ? { cursor: "pointer" } : undefined}> {orderedCols.map((c) => ( ))} ))} {displayRows.length === 0 && ( loading && rows.length === 0 ? ( // Erstladung: schimmernde Platzhalterzeilen in Spaltenform statt // eines nackten „Wird geladen…". (Beim Nachladen mit bereits // vorhandenen Zeilen greift dieser Zweig nicht - die Daten // bleiben stehen.) Array.from({ length: 8 }, (_, r) => ( {orderedCols.map((c, ci) => ( ))} )) ) : ( // Während des Ladens NICHT „nichts gefunden" behaupten – die Leer- // Meldung kommt erst, wenn die Daten wirklich da (und leer) sind. ) )}
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}
{tree && treeActive && c.key === tree.column ? ( {hasChildren ? ( ) : } {c.render(row)} ) : 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, )}
); }