Web: Kategorien (Baum), Gruppen, Einheiten, Gebinde auf DataTable

Restliche Tabellenseiten auf die filterbare DataTable umgestellt. Kategorien nutzen den neuen Tree-Modus (ein-/ausklappbar wie bisher; sobald gefiltert/sortiert wird, flache Liste). Inline-Bearbeitung (Namen, Art, uebergeordnet, Mindestbestand, Gebinde-Formen) bleibt in den Zell-Renderern erhalten; Seiten-Formulare/Panels unveraendert. Damit haben Produkte, Einzelstuecke, Kategorien, Gruppen, Einheiten und Gebinde die gleiche Filter-/Spalten-Bedienung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 15:10:00 +02:00
parent ffbe33bec4
commit 050496a82d
6 changed files with 254 additions and 281 deletions

View File

@@ -11,6 +11,10 @@ import Icon from "./Icon";
* 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;
@@ -24,7 +28,7 @@ function savePersisted(id, data) {
export default function DataTable({
id, columns, rows, getRowKey, rowClassName,
empty = "Nichts gefunden.", toolbarExtra = null,
empty = "Nichts gefunden.", toolbarExtra = null, tree = null,
}) {
const colByKey = useMemo(() => Object.fromEntries(columns.map((c) => [c.key, c])), [columns]);
@@ -39,6 +43,7 @@ export default function DataTable({
const [sort, setSort] = useState(null); // { key, dir }
const [filterShown, setFilterShown] = useState(true);
const [openFilter, setOpenFilter] = useState(null);
const [collapsed, setCollapsed] = useState(() => new Set());
useEffect(() => { savePersisted(id, { order, widths }); }, [id, order, widths]);
@@ -77,7 +82,13 @@ export default function DataTable({
return m;
}, [columns, rows]);
const visible = useMemo(() => {
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) return true;
@@ -100,6 +111,49 @@ export default function DataTable({
// 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]);
function toggleSort(c) {
if (!c.sortValue && !c.filterText) return;
setSort((s) => {
@@ -108,6 +162,9 @@ export default function DataTable({
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) ---
const resizing = useRef(null);
@@ -208,14 +265,28 @@ export default function DataTable({
)}
</thead>
<tbody>
{visible.map((r) => (
<tr key={getRowKey(r)} className={rowClassName?.(r) || ""}>
{displayRows.map(({ row, depth, hasChildren }) => (
<tr key={getRowKey(row)} className={rowClassName?.(row) || ""}>
{orderedCols.map((c) => (
<td key={c.key} className={c.align === "num" ? "num" : ""}>{c.render(r)}</td>
<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>
))}
{visible.length === 0 && (
{displayRows.length === 0 && (
<tr><td className="empty" colSpan={orderedCols.length}>{empty}</td></tr>
)}
</tbody>