Web: Excel-artige DataTable + volle Breite; Produkte migriert
Neue wiederverwendbare DataTable: feste Filterzeile je Spalte (Freitext + Dropdown der vorhandenen Werte, ueber Spalten kombinierbar), Spalten per Drag verschieben und in der Breite ziehen, Klick-Sortierung; Reihenfolge/Breiten merkt der Browser (localStorage). Produkte-Seite darauf umgestellt. Tabellenseiten (Produkte/Kategorien/Gruppen/Einheiten/Gebinde) nutzen jetzt die volle Fensterbreite (content--wide); Kategorien-Seitenspalte etwas breiter und die Feldliste bricht um, statt ueber den Container zu laufen. Farbschema unveraendert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
293
web/src/components/DataTable.jsx
Normal file
293
web/src/components/DataTable.jsx
Normal file
@@ -0,0 +1,293 @@
|
||||
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 (
|
||||
<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" className="btn sm ghost" onClick={resetColumns}>Spalten zurücksetzen</button>
|
||||
{toolbarExtra}
|
||||
</div>
|
||||
<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 ? (
|
||||
<FilterCell
|
||||
col={c}
|
||||
filter={filters[c.key]}
|
||||
distinct={distinctByKey[c.key] || []}
|
||||
open={openFilter === c.key}
|
||||
onToggleOpen={setOpenFilter}
|
||||
onChange={(f) => setFilters((old) => ({ ...old, [c.key]: f }))}
|
||||
/>
|
||||
) : null}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.map((r) => (
|
||||
<tr key={getRowKey(r)} className={rowClassName?.(r) || ""}>
|
||||
{orderedCols.map((c) => (
|
||||
<td key={c.key} className={c.align === "num" ? "num" : ""}>{c.render(r)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{visible.length === 0 && (
|
||||
<tr><td className="empty" colSpan={orderedCols.length}>{empty}</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="dt-filter-cell">
|
||||
<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>{v === "" ? "(Leere)" : v}</span>
|
||||
</label>
|
||||
))}
|
||||
{shown.length === 0 && <div className="muted small">Keine Werte.</div>}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user