From 050496a82d0a220ada628dbeec43a903ac9779d3 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 27 Jul 2026 15:10:00 +0200 Subject: [PATCH] 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 --- web/src/components/DataTable.jsx | 83 ++++++++++++++-- web/src/pages/Categories.jsx | 163 ++++++++++--------------------- web/src/pages/Groups.jsx | 127 ++++++++++-------------- web/src/pages/PackageTypes.jsx | 106 +++++++++----------- web/src/pages/Units.jsx | 55 +++++------ web/src/styles.css | 1 + 6 files changed, 254 insertions(+), 281 deletions(-) diff --git a/web/src/components/DataTable.jsx b/web/src/components/DataTable.jsx index 071d8f1..85296f9 100644 --- a/web/src/components/DataTable.jsx +++ b/web/src/components/DataTable.jsx @@ -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({ )} - {visible.map((r) => ( - + {displayRows.map(({ row, depth, hasChildren }) => ( + {orderedCols.map((c) => ( - {c.render(r)} + + {tree && treeActive && c.key === tree.column ? ( + + {hasChildren ? ( + + ) : } + {c.render(row)} + + ) : c.render(row)} + ))} ))} - {visible.length === 0 && ( + {displayRows.length === 0 && ( {empty} )} diff --git a/web/src/pages/Categories.jsx b/web/src/pages/Categories.jsx index 0e820c6..3962eb5 100644 --- a/web/src/pages/Categories.jsx +++ b/web/src/pages/Categories.jsx @@ -6,6 +6,7 @@ import Icon from "../components/Icon"; import { asTree } from "../categoryTree"; import { FIELD_TYPES, fieldTypeLabel } from "../fields"; import CategorySelect from "../components/CategorySelect"; +import DataTable from "../components/DataTable"; const MODUS = { food: "Lebensmittel", object: "Gegenstand" }; @@ -21,21 +22,11 @@ export default function Categories() { const [categories, setCategories] = useState([]); const [form, setForm] = useState({ name: "", parent_id: "", tracking: "" }); const [error, setError] = useState(null); - const [collapsed, setCollapsed] = useState(() => new Set()); // Kategorie, deren Felder gerade verwaltet werden. const [feldKat, setFeldKat] = useState(null); // "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände. const [typFilter, setTypFilter] = useState(""); - function toggle(id) { - setCollapsed((alt) => { - const neu = new Set(alt); - if (neu.has(id)) neu.delete(id); - else neu.add(id); - return neu; - }); - } - async function load() { try { setCategories(await api.listCategories()); @@ -94,8 +85,6 @@ export default function Categories() { const tree = asTree(categories); const nameById = Object.fromEntries(categories.map((c) => [c.id, c.name])); - const parentOf = Object.fromEntries(categories.map((c) => [c.id, c.parent_id])); - const childCount = (id) => categories.filter((c) => c.parent_id === id).length; // Eigene Unterkategorien (rekursiv) – als Ziel beim Verschieben ausgeschlossen, // sonst entstünde ein Ring. function descendantIds(id) { @@ -109,17 +98,50 @@ export default function Categories() { } return result; } - function versteckt(id) { - let p = parentOf[id]; - while (p != null) { - if (collapsed.has(p)) return true; - p = parentOf[p]; - } - return false; - } - const sichtbar = tree.filter( - (c) => !versteckt(c.id) && (!typFilter || c.tracking === typFilter), - ); + + const columns = [ + { key: "name", header: "Kategorie", grow: true, min: 220, + filterText: (c) => c.name, sortValue: (c) => c.name, + render: (c) => (isAdmin ? ( + { const v = e.target.value.trim(); if (v && v !== c.name) patch(c, { name: v }); }} /> + ) : {c.name}) }, + { key: "art", header: "Art", width: 150, filterText: (c) => MODUS[c.tracking] || c.tracking, + render: (c) => (isAdmin ? ( + + ) : {MODUS[c.tracking] || c.tracking}) }, + { key: "parent", header: "Übergeordnet", width: 220, + filterText: (c) => (c.parent_id ? nameById[c.parent_id] || "" : ""), + render: (c) => { + if (!isAdmin) return {c.parent_id ? nameById[c.parent_id] : "–"}; + const desc = descendantIds(c.id); + const parentOptions = tree.filter((o) => o.tracking === c.tracking && o.id !== c.id && !desc.has(o.id)); + return ( + patch(c, { parent_id: pid })} /> + ); + } }, + { key: "count", header: "Artikel", width: 90, align: "num", sortValue: (c) => c.product_count, + render: (c) => {c.product_count} }, + { key: "actions", header: "", fixed: true, width: 96, align: "num", + render: (c) => ( + + + {isAdmin && ( + + )} + + ) }, + ]; return (
@@ -142,94 +164,13 @@ export default function Categories() {
-
-
- - - - - - - - - - - - {sichtbar.map((c) => { - const kinder = childCount(c.id); - const zu = collapsed.has(c.id); - // Mögliche neue Elternkategorien: gleicher Typ, nicht sie selbst - // und keine ihrer Unterkategorien. - const desc = descendantIds(c.id); - const parentOptions = tree.filter( - (o) => o.tracking === c.tracking && o.id !== c.id && !desc.has(o.id), - ); - return ( - - - - - - - - ); - })} - {categories.length === 0 && ( - - )} - -
KategorieArtÜbergeordnetArtikel
- - {kinder > 0 ? ( - - ) : } - {isAdmin ? ( - { - const v = e.target.value.trim(); - if (v && v !== c.name) patch(c, { name: v }); - }} /> - ) : {c.name}} - {zu && kinder > 0 && ( - {kinder} ausgeblendet - )} - - - {isAdmin ? ( - - ) : ( - {MODUS[c.tracking] || c.tracking} - )} - - {isAdmin ? ( - patch(c, { parent_id: pid })} - /> - ) : ( - {c.parent_id ? nameById[c.parent_id] : "–"} - )} - {c.product_count} - - {isAdmin && ( - - )} -
Noch keine Kategorien.
-
+
+ !typFilter || c.tracking === typFilter)} + getRowKey={(c) => c.id} + rowClassName={(c) => (feldKat && feldKat.id === c.id ? "row-active" : "")} + tree={{ column: "name", idOf: (c) => c.id, parentIdOf: (c) => c.parent_id }} + empty="Noch keine Kategorien." />
diff --git a/web/src/pages/Groups.jsx b/web/src/pages/Groups.jsx index 3b7b8d8..1a377dd 100644 --- a/web/src/pages/Groups.jsx +++ b/web/src/pages/Groups.jsx @@ -4,6 +4,7 @@ import { useConfirm } from "../confirm"; import { useAuth } from "../auth"; import BarcodeList from "../components/BarcodeList"; import Icon from "../components/Icon"; +import DataTable from "../components/DataTable"; import LocationMinStock from "../components/LocationMinStock"; import { fmt } from "../units"; @@ -77,6 +78,52 @@ export default function Groups() { const selected = groups.find((g) => g.id === selectedId) || null; + const columns = [ + { key: "name", header: "Gruppe", grow: true, min: 160, + filterText: (g) => g.name, sortValue: (g) => g.name, + render: (g) => { + const low = g.min_stock != null && g.stock < g.min_stock; + return ( + + {isAdmin ? ( + { const v = e.target.value.trim(); if (v && v !== g.name) patch(g, { name: v }); }} /> + ) : {g.name}} + {low && niedrig} + + ); + } }, + { key: "products", header: "Produkte", width: 100, align: "num", + sortValue: (g) => g.product_count, render: (g) => {g.product_count} }, + { key: "stock", header: "Bestand", width: 130, align: "num", + sortValue: (g) => g.stock, render: (g) => `${fmt(g.stock)} ${g.min_stock_unit_name || ""}` }, + { key: "min", header: "Mindestbestand", width: 150, sortValue: (g) => g.min_stock ?? -1, + render: (g) => (isAdmin ? ( + { const v = e.target.value; if (v !== String(g.min_stock ?? "")) patch(g, { min_stock: v === "" ? null : Number(v) }); }} /> + ) : (g.min_stock != null ? fmt(g.min_stock) : "–")) }, + { key: "unit", header: "Einheit", width: 150, filterText: (g) => g.min_stock_unit_name || "", + render: (g) => (isAdmin ? ( + + ) : (g.min_stock_unit_name || "–")) }, + { key: "codes", header: "EAN-Codes", width: 130, align: "num", + render: (g) => ( + + ) }, + { key: "actions", header: "", fixed: true, width: 56, align: "num", + render: (g) => isAdmin && ( + + ) }, + ]; + return (
@@ -90,83 +137,9 @@ export default function Groups() { {error &&
{error}
}
-
-
- - - - - - - - - - - - - - {groups.map((g) => { - const low = g.min_stock != null && g.stock < g.min_stock; - return ( - - - - - - - - - - ); - })} - {groups.length === 0 && } - -
GruppeProdukteBestandMindestbestandEinheitEAN-Codes
- - {isAdmin ? ( - { - const v = e.target.value.trim(); - if (v && v !== g.name) patch(g, { name: v }); - }} /> - ) : {g.name}} - {low && niedrig} - - {g.product_count}{fmt(g.stock)} {g.min_stock_unit_name || ""} - {isAdmin ? ( - { - const v = e.target.value; - if (v !== String(g.min_stock ?? "")) { - patch(g, { min_stock: v === "" ? null : Number(v) }); - } - }} /> - ) : (g.min_stock != null ? fmt(g.min_stock) : "–")} - - {isAdmin ? ( - - ) : (g.min_stock_unit_name || "–")} - - - - {isAdmin && ( - - )} -
Noch keine Gruppen.
-
+
+ g.id} empty="Noch keine Gruppen." />
diff --git a/web/src/pages/PackageTypes.jsx b/web/src/pages/PackageTypes.jsx index 0224678..037946f 100644 --- a/web/src/pages/PackageTypes.jsx +++ b/web/src/pages/PackageTypes.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { api } from "../api"; import { useConfirm } from "../confirm"; import Icon from "../components/Icon"; +import DataTable from "../components/DataTable"; import { useSettings } from "../settings"; /** @@ -78,6 +79,48 @@ export default function PackageTypes() { } } + const columns = [ + { key: "singular", header: "Einzahl", grow: true, min: 140, + filterText: (a) => a.singular, sortValue: (a) => a.singular, + render: (a) => (bearbeitung?.id === a.id ? ( + setBearbeitung({ ...bearbeitung, singular: e.target.value })} /> + ) : ( + + {a.singular} + {a.is_builtin && eingebaut} + + )) }, + { key: "plural", header: "Mehrzahl", width: 180, filterText: (a) => a.plural, + render: (a) => (bearbeitung?.id === a.id ? ( + setBearbeitung({ ...bearbeitung, plural: e.target.value })} /> + ) : a.plural) }, + { key: "beispiel", header: "Beispiel", width: 200, + filterText: (a) => `1 ${a.singular} · 3 ${a.plural}`, + render: (a) => 1 {a.singular} · 3 {a.plural} }, + { key: "actions", header: "", fixed: true, width: 160, align: "num", + render: (a) => (bearbeitung?.id === a.id ? ( + + + + + ) : ( + + + {!a.is_builtin && ( + + )} + + )) }, + ]; + return (
@@ -91,65 +134,10 @@ export default function PackageTypes() {
{error &&
{error}
} -
-
-
- - - - - - {arten.map((art) => { - const offen = bearbeitung?.id === art.id; - return ( - - - - - - - ); - })} - -
EinzahlMehrzahlBeispiel
- {offen ? ( - setBearbeitung({ ...bearbeitung, singular: e.target.value })} /> - ) : ( - - {art.singular} - {art.is_builtin && eingebaut} - - )} - - {offen ? ( - setBearbeitung({ ...bearbeitung, plural: e.target.value })} /> - ) : art.plural} - - 1 {art.singular} · 3 {art.plural} - - {offen ? ( - - - - - ) : ( - - - {!art.is_builtin && ( - - )} - - )} -
-
+
+
+ a.id} empty="Noch keine Gebinde." />
diff --git a/web/src/pages/Units.jsx b/web/src/pages/Units.jsx index 60e1e18..cecce7f 100644 --- a/web/src/pages/Units.jsx +++ b/web/src/pages/Units.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { api } from "../api"; import { useConfirm } from "../confirm"; import Icon from "../components/Icon"; +import DataTable from "../components/DataTable"; import { fmt } from "../units"; const KIND_LABEL = { count: "Anzahl (Stück)", weight: "Gewicht (Basis: Gramm)", volume: "Volumen (Basis: Milliliter)" }; @@ -52,6 +53,28 @@ export default function Units() { const baseUnitOfKind = { count: "Stück", weight: "Gramm", volume: "Milliliter" }; + const columns = [ + { key: "name", header: "Name", grow: true, min: 160, + filterText: (u) => u.name, sortValue: (u) => u.name, + render: (u) => ( + + {u.name} + {u.is_builtin && eingebaut} + + ) }, + { key: "kind", header: "Art", width: 230, filterText: (u) => KIND_LABEL[u.kind] || u.kind, + render: (u) => {KIND_LABEL[u.kind] || u.kind} }, + { key: "factor", header: "Faktor", width: 150, align: "num", sortValue: (u) => u.factor, + filterText: (u) => `${fmt(u.factor)} ${baseUnitOfKind[u.kind]}`, + render: (u) => `${fmt(u.factor)} ${baseUnitOfKind[u.kind]}` }, + { key: "actions", header: "", fixed: true, width: 56, align: "num", + render: (u) => (!u.is_builtin && ( + + )) }, + ]; + return (
@@ -62,34 +85,10 @@ export default function Units() {
{error &&
{error}
} -
-
-
- - - - {units.map((u) => ( - - - - - - - ))} - -
NameArtFaktor
- - {u.name} - {u.is_builtin && eingebaut} - - {KIND_LABEL[u.kind] || u.kind}{fmt(u.factor)} {baseUnitOfKind[u.kind]} - {!u.is_builtin && ( - - )} -
-
+
+
+ u.id} empty="Noch keine Einheiten." />
diff --git a/web/src/styles.css b/web/src/styles.css index 4db91e2..ca21e2f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -551,6 +551,7 @@ td select { width: auto; min-width: 0; max-width: 100%; } .dt-pop-opt { display: flex; align-items: center; gap: 6px; padding: 3px 4px; font-size: 0.85rem; cursor: pointer; margin: 0; } .dt-pop-opt:hover { background: var(--surface-2); } .dt-pop-opt span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dt-tree-cell { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; } /* ---------- EAN-Code-Zeile ---------- */ .code-row { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; flex-wrap: wrap; }