"NaN Glaeser" auf der Karte "Artikel im Blick". Die Karte reichte einen
ARTIKEL an articlePrimary() weiter, das fuer CHARGEN geschrieben ist und
die Menge unter dem Namen `quantity` sucht. Ein Artikel nennt sie
`stock` - also undefined / 190 = NaN. Neu bestandPrimary() und
bestandSecondary() in units.js als die eine Stelle, an der zwischen den
beiden Namen uebersetzt wird; sie delegieren an die vorhandenen
Formatierer, damit es nicht zwei Fassungen gibt. Steckte seit 8959814
drin.
Dazu gibt fmt() fuer nicht-endliche Zahlen jetzt einen Strich aus statt
"NaN". Dem Benutzer "NaN" hinzuschreiben ist nie richtig - der eigentliche
Fehler wird trotzdem an der Quelle behoben, nicht hier.
Die Spalte "Verbrauch" schnitt ab. Zwei beschriftete Knoepfe brauchen
rund 230 px, die Spalte hatte 190, und .datatable ist table-layout:fixed
mit overflow:hidden. Weil die Zelle rechtsbuendig ist, fiel der ANFANG
weg - sichtbar stand dort "rbrauchen". Jetzt 240 px, dazu flex-wrap in
der Zelle, damit die Knoepfe umbrechen statt abzuschneiden, wenn jemand
die Spalte von Hand schmaler zieht.
Die LETZTE Spalte liess sich nicht verbreitern. Ihr Anfasser sitzt exakt
am rechten Rand des Rollbereichs (gemessen: 0 px Luft), und nach rechts
ist dort Schluss - der Zeiger kann nicht weiter, also kam nie ein
positiver Abstand zum Startpunkt zustande. Der Resize rechnet jetzt mit
der BEWEGUNG seit dem letzten Schritt statt mit dem Abstand zum Start,
und in der Randzone waechst die Spalte von selbst weiter, solange die
Taste haelt; ein Taktgeber treibt das an, denn ein stehender Zeiger
meldet keine Bewegung. Die Tabelle rollt dabei mit, damit der Anfasser
unter dem Zeiger bleibt.
"3 Glas" statt "3 Glaeser" in der Chargenliste, waehrend die Uebersicht
"3 Glaeser" schrieb. Die Mehrzahlformen liegen in einer Modulvariablen,
weil auch Nicht-Komponenten sie brauchen - eine Aenderung daran loest
aber kein Neuzeichnen aus. Wer seine Liste zeichnete, bevor
/package-types zurueck war, blieb bei der Einzahl. Ein Zaehler im
Einstellungs-Kontext zieht die Formatierer jetzt nach.
Von Hand geprueft: Karte zeigt "3 Glaeser / 570 g"; beide Knoepfe stehen
vollstaendig in der Spalte; die letzte Spalte waechst beim Halten am
Rand von 240 auf ueber 470 und bleibt beim Loslassen stehen, waehrend
eine mittlere Spalte weiterhin exakt der Ziehstrecke folgt (170 -> 230);
Chargenliste schreibt "3 Glaeser".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
564 lines
24 KiB
React
564 lines
24 KiB
React
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 (
|
||
<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, depth, hasChildren }) => (
|
||
<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" : ""}>
|
||
{tree && treeActive && c.key === tree.column ? (
|
||
<span className="dt-tree-cell" style={{ paddingLeft: depth * 18 }}>
|
||
{hasChildren ? (
|
||
<button type="button" className="btn-icon tree-toggle"
|
||
onClick={() => toggleCollapse(tree.idOf(row))}
|
||
title={collapsed.has(tree.idOf(row)) ? "Ausklappen" : "Einklappen"}>
|
||
<Icon name="chevronRight" size={12}
|
||
className={collapsed.has(tree.idOf(row)) ? "" : "rotated"} />
|
||
</button>
|
||
) : <span className="tree-spacer-sm" />}
|
||
{c.render(row)}
|
||
</span>
|
||
) : c.render(row)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
{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) => (
|
||
<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>
|
||
))
|
||
) : (
|
||
// Während des Ladens NICHT „nichts gefunden" behaupten – die Leer-
|
||
// Meldung kommt erst, wenn die Daten wirklich da (und leer) sind.
|
||
<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>
|
||
);
|
||
}
|