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), * Spalten-Def: { key, header, render(row)→JSX, filterText(row)? (aktiviert Filter),
* sortValue(row)? (Sortierung), align:"num", grow:true (füllt Restbreite), * sortValue(row)? (Sortierung), align:"num", grow:true (füllt Restbreite),
* fixed:true (kein Reorder/Resize), width, min }. * 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; const DEFAULT_WIDTH = 150;
@@ -24,7 +28,7 @@ function savePersisted(id, data) {
export default function DataTable({ export default function DataTable({
id, columns, rows, getRowKey, rowClassName, 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]); 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 [sort, setSort] = useState(null); // { key, dir }
const [filterShown, setFilterShown] = useState(true); const [filterShown, setFilterShown] = useState(true);
const [openFilter, setOpenFilter] = useState(null); const [openFilter, setOpenFilter] = useState(null);
const [collapsed, setCollapsed] = useState(() => new Set());
useEffect(() => { savePersisted(id, { order, widths }); }, [id, order, widths]); useEffect(() => { savePersisted(id, { order, widths }); }, [id, order, widths]);
@@ -77,7 +82,13 @@ export default function DataTable({
return m; return m;
}, [columns, rows]); }, [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) => { let out = rows.filter((r) => orderedCols.every((c) => {
const f = filters[c.key]; const f = filters[c.key];
if (!f || !c.filterText) return true; if (!f || !c.filterText) return true;
@@ -100,6 +111,49 @@ export default function DataTable({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, order, filters, sort]); }, [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) { function toggleSort(c) {
if (!c.sortValue && !c.filterText) return; if (!c.sortValue && !c.filterText) return;
setSort((s) => { setSort((s) => {
@@ -108,6 +162,9 @@ export default function DataTable({
return null; 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) --- // --- Resize (Pointer) ---
const resizing = useRef(null); const resizing = useRef(null);
@@ -208,14 +265,28 @@ export default function DataTable({
)} )}
</thead> </thead>
<tbody> <tbody>
{visible.map((r) => ( {displayRows.map(({ row, depth, hasChildren }) => (
<tr key={getRowKey(r)} className={rowClassName?.(r) || ""}> <tr key={getRowKey(row)} className={rowClassName?.(row) || ""}>
{orderedCols.map((c) => ( {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> </tr>
))} ))}
{visible.length === 0 && ( {displayRows.length === 0 && (
<tr><td className="empty" colSpan={orderedCols.length}>{empty}</td></tr> <tr><td className="empty" colSpan={orderedCols.length}>{empty}</td></tr>
)} )}
</tbody> </tbody>

View File

@@ -6,6 +6,7 @@ import Icon from "../components/Icon";
import { asTree } from "../categoryTree"; import { asTree } from "../categoryTree";
import { FIELD_TYPES, fieldTypeLabel } from "../fields"; import { FIELD_TYPES, fieldTypeLabel } from "../fields";
import CategorySelect from "../components/CategorySelect"; import CategorySelect from "../components/CategorySelect";
import DataTable from "../components/DataTable";
const MODUS = { food: "Lebensmittel", object: "Gegenstand" }; const MODUS = { food: "Lebensmittel", object: "Gegenstand" };
@@ -21,21 +22,11 @@ export default function Categories() {
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [form, setForm] = useState({ name: "", parent_id: "", tracking: "" }); const [form, setForm] = useState({ name: "", parent_id: "", tracking: "" });
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [collapsed, setCollapsed] = useState(() => new Set());
// Kategorie, deren Felder gerade verwaltet werden. // Kategorie, deren Felder gerade verwaltet werden.
const [feldKat, setFeldKat] = useState(null); const [feldKat, setFeldKat] = useState(null);
// "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände. // "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände.
const [typFilter, setTypFilter] = useState(""); 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() { async function load() {
try { try {
setCategories(await api.listCategories()); setCategories(await api.listCategories());
@@ -94,8 +85,6 @@ export default function Categories() {
const tree = asTree(categories); const tree = asTree(categories);
const nameById = Object.fromEntries(categories.map((c) => [c.id, c.name])); 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, // Eigene Unterkategorien (rekursiv) als Ziel beim Verschieben ausgeschlossen,
// sonst entstünde ein Ring. // sonst entstünde ein Ring.
function descendantIds(id) { function descendantIds(id) {
@@ -109,17 +98,50 @@ export default function Categories() {
} }
return result; return result;
} }
function versteckt(id) {
let p = parentOf[id]; const columns = [
while (p != null) { { key: "name", header: "Kategorie", grow: true, min: 220,
if (collapsed.has(p)) return true; filterText: (c) => c.name, sortValue: (c) => c.name,
p = parentOf[p]; render: (c) => (isAdmin ? (
} <input defaultValue={c.name} style={{ marginTop: 0, minWidth: 150 }}
return false; onBlur={(e) => { const v = e.target.value.trim(); if (v && v !== c.name) patch(c, { name: v }); }} />
} ) : <span className="strong">{c.name}</span>) },
const sichtbar = tree.filter( { key: "art", header: "Art", width: 150, filterText: (c) => MODUS[c.tracking] || c.tracking,
(c) => !versteckt(c.id) && (!typFilter || c.tracking === typFilter), render: (c) => (isAdmin ? (
<select value={c.tracking} style={{ marginTop: 0 }}
onChange={(e) => patch(c, { tracking: e.target.value })}>
<option value="food">Lebensmittel</option>
<option value="object">Gegenstand</option>
</select>
) : <span className="badge">{MODUS[c.tracking] || c.tracking}</span>) },
{ key: "parent", header: "Übergeordnet", width: 220,
filterText: (c) => (c.parent_id ? nameById[c.parent_id] || "" : ""),
render: (c) => {
if (!isAdmin) return <span className="muted">{c.parent_id ? nameById[c.parent_id] : ""}</span>;
const desc = descendantIds(c.id);
const parentOptions = tree.filter((o) => o.tracking === c.tracking && o.id !== c.id && !desc.has(o.id));
return (
<CategorySelect value={c.parent_id ?? null} nodes={parentOptions}
onChange={(pid) => patch(c, { parent_id: pid })} />
); );
} },
{ key: "count", header: "Artikel", width: 90, align: "num", sortValue: (c) => c.product_count,
render: (c) => <span className="muted">{c.product_count}</span> },
{ key: "actions", header: "", fixed: true, width: 96, align: "num",
render: (c) => (
<span className="cell-row" style={{ justifyContent: "flex-end" }}>
<button className="btn-icon" title="Felder verwalten"
onClick={() => setFeldKat(feldKat && feldKat.id === c.id ? null : c)}>
<Icon name="tag" size={16} />
</button>
{isAdmin && (
<button className="btn-icon danger" onClick={() => remove(c)} title="Löschen">
<Icon name="trash" size={16} />
</button>
)}
</span>
) },
];
return ( return (
<div> <div>
@@ -142,94 +164,13 @@ export default function Categories() {
</div> </div>
<div className="grid-2 wide-aside"> <div className="grid-2 wide-aside">
<div className="card" style={{ padding: 0 }}> <div className="card">
<div className="table-wrap"> <DataTable id="categories" columns={columns}
<table className="table"> rows={categories.filter((c) => !typFilter || c.tracking === typFilter)}
<thead> getRowKey={(c) => c.id}
<tr> rowClassName={(c) => (feldKat && feldKat.id === c.id ? "row-active" : "")}
<th>Kategorie</th> tree={{ column: "name", idOf: (c) => c.id, parentIdOf: (c) => c.parent_id }}
<th>Art</th> empty="Noch keine Kategorien." />
<th>Übergeordnet</th>
<th className="num">Artikel</th>
<th></th>
</tr>
</thead>
<tbody>
{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 (
<tr key={c.id} className={feldKat && feldKat.id === c.id ? "row-active" : ""}>
<td data-label="Kategorie">
<span className="cell-row" style={{ paddingLeft: c.depth * 22 }}>
{kinder > 0 ? (
<button type="button" className="btn-icon tree-toggle"
onClick={() => toggle(c.id)} aria-expanded={!zu}
title={zu ? "Unterkategorien einblenden" : "Unterkategorien ausblenden"}>
<Icon name="chevronRight" size={14} className={zu ? "" : "rotated"} />
</button>
) : <span className="tree-spacer" />}
{isAdmin ? (
<input defaultValue={c.name} style={{ marginTop: 0, minWidth: 150 }}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== c.name) patch(c, { name: v });
}} />
) : <span className="strong">{c.name}</span>}
{zu && kinder > 0 && (
<span className="badge nowrap">{kinder} ausgeblendet</span>
)}
</span>
</td>
<td data-label="Art">
{isAdmin ? (
<select value={c.tracking} style={{ marginTop: 0, minWidth: 130 }}
onChange={(e) => patch(c, { tracking: e.target.value })}>
<option value="food">Lebensmittel</option>
<option value="object">Gegenstand</option>
</select>
) : (
<span className="badge">{MODUS[c.tracking] || c.tracking}</span>
)}
</td>
<td data-label="Übergeordnet">
{isAdmin ? (
<CategorySelect
value={c.parent_id ?? null}
nodes={parentOptions}
onChange={(pid) => patch(c, { parent_id: pid })}
/>
) : (
<span className="muted">{c.parent_id ? nameById[c.parent_id] : ""}</span>
)}
</td>
<td data-label="Artikel" className="num muted">{c.product_count}</td>
<td className="num">
<button className="btn-icon" title="Felder verwalten"
onClick={() => setFeldKat(feldKat && feldKat.id === c.id ? null : c)}>
<Icon name="tag" size={16} />
</button>
{isAdmin && (
<button className="btn-icon danger" onClick={() => remove(c)} title="Löschen">
<Icon name="trash" size={16} />
</button>
)}
</td>
</tr>
);
})}
{categories.length === 0 && (
<tr><td colSpan={5} className="empty">Noch keine Kategorien.</td></tr>
)}
</tbody>
</table>
</div>
</div> </div>
<div className="stack"> <div className="stack">

View File

@@ -4,6 +4,7 @@ import { useConfirm } from "../confirm";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import BarcodeList from "../components/BarcodeList"; import BarcodeList from "../components/BarcodeList";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
import LocationMinStock from "../components/LocationMinStock"; import LocationMinStock from "../components/LocationMinStock";
import { fmt } from "../units"; import { fmt } from "../units";
@@ -77,6 +78,52 @@ export default function Groups() {
const selected = groups.find((g) => g.id === selectedId) || null; 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 (
<span className="cell-row">
{isAdmin ? (
<input defaultValue={g.name} style={{ marginTop: 0, minWidth: 120 }}
onBlur={(e) => { const v = e.target.value.trim(); if (v && v !== g.name) patch(g, { name: v }); }} />
) : <span className="strong">{g.name}</span>}
{low && <span className="badge warn">niedrig</span>}
</span>
);
} },
{ key: "products", header: "Produkte", width: 100, align: "num",
sortValue: (g) => g.product_count, render: (g) => <span className="muted">{g.product_count}</span> },
{ 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 ? (
<input type="number" step="any" defaultValue={g.min_stock ?? ""} style={{ marginTop: 0, minWidth: 90 }}
onBlur={(e) => { 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 ? (
<select value={g.min_stock_unit_id ?? ""} style={{ marginTop: 0 }}
onChange={(e) => patch(g, { min_stock_unit_id: e.target.value === "" ? null : Number(e.target.value) })}>
<option value=""> Basiseinheit </option>
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
) : (g.min_stock_unit_name || "")) },
{ key: "codes", header: "EAN-Codes", width: 130, align: "num",
render: (g) => (
<button className="link-btn" onClick={() => setSelectedId(g.id)}>
{(g.product_barcodes?.length || 0) + (g.barcodes?.length || 0)} verwalten
</button>
) },
{ key: "actions", header: "", fixed: true, width: 56, align: "num",
render: (g) => isAdmin && (
<button className="btn-icon danger" onClick={() => remove(g)} title="Löschen">
<Icon name="trash" size={16} />
</button>
) },
];
return ( return (
<div> <div>
<div className="page-head"> <div className="page-head">
@@ -90,83 +137,9 @@ export default function Groups() {
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>} {error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="grid-2 wide-aside"> <div className="grid-2 wide-aside">
<div className="card" style={{ padding: 0 }}> <div className="card">
<div className="table-wrap"> <DataTable id="groups" columns={columns} rows={groups}
<table className="table"> getRowKey={(g) => g.id} empty="Noch keine Gruppen." />
<thead>
<tr>
<th>Gruppe</th>
<th className="num">Produkte</th>
<th className="num">Bestand</th>
<th>Mindestbestand</th>
<th>Einheit</th>
<th className="num">EAN-Codes</th>
<th></th>
</tr>
</thead>
<tbody>
{groups.map((g) => {
const low = g.min_stock != null && g.stock < g.min_stock;
return (
<tr key={g.id}>
<td data-label="Gruppe">
<span className="cell-row">
{isAdmin ? (
<input defaultValue={g.name} style={{ marginTop: 0, minWidth: 120 }}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== g.name) patch(g, { name: v });
}} />
) : <span className="strong">{g.name}</span>}
{low && <span className="badge warn">niedrig</span>}
</span>
</td>
<td data-label="Produkte" className="num muted">{g.product_count}</td>
<td data-label="Bestand" className="num">{fmt(g.stock)} {g.min_stock_unit_name || ""}</td>
<td data-label="Mindestbestand">
{isAdmin ? (
<input type="number" step="any" defaultValue={g.min_stock ?? ""}
style={{ marginTop: 0, minWidth: 90 }}
onBlur={(e) => {
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) : "")}
</td>
<td data-label="Einheit">
{isAdmin ? (
<select value={g.min_stock_unit_id ?? ""} style={{ marginTop: 0 }}
onChange={(e) => patch(g, {
min_stock_unit_id: e.target.value === "" ? null : Number(e.target.value),
})}>
<option value=""> Basiseinheit </option>
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
) : (g.min_stock_unit_name || "")}
</td>
<td data-label="EAN-Codes" className="num">
<button className="link-btn" onClick={() => setSelectedId(g.id)}>
{/* Beide Herkünfte zählen, sonst steht bei einer Gruppe
mit Artikeln irreführend eine 0. */}
{(g.product_barcodes?.length || 0) + (g.barcodes?.length || 0)} verwalten
</button>
</td>
<td className="num">
{isAdmin && (
<button className="btn-icon danger" onClick={() => remove(g)} title="Löschen">
<Icon name="trash" size={16} />
</button>
)}
</td>
</tr>
);
})}
{groups.length === 0 && <tr><td colSpan={7} className="empty">Noch keine Gruppen.</td></tr>}
</tbody>
</table>
</div>
</div> </div>
<div> <div>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { useConfirm } from "../confirm"; import { useConfirm } from "../confirm";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
import { useSettings } from "../settings"; 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 ? (
<input value={bearbeitung.singular} style={{ marginTop: 0 }}
onChange={(e) => setBearbeitung({ ...bearbeitung, singular: e.target.value })} />
) : (
<span className="cell-row">
<span className="strong">{a.singular}</span>
{a.is_builtin && <span className="badge nowrap">eingebaut</span>}
</span>
)) },
{ key: "plural", header: "Mehrzahl", width: 180, filterText: (a) => a.plural,
render: (a) => (bearbeitung?.id === a.id ? (
<input value={bearbeitung.plural} style={{ marginTop: 0 }}
onChange={(e) => setBearbeitung({ ...bearbeitung, plural: e.target.value })} />
) : a.plural) },
{ key: "beispiel", header: "Beispiel", width: 200,
filterText: (a) => `1 ${a.singular} · 3 ${a.plural}`,
render: (a) => <span className="muted">1 {a.singular} · 3 {a.plural}</span> },
{ key: "actions", header: "", fixed: true, width: 160, align: "num",
render: (a) => (bearbeitung?.id === a.id ? (
<span className="cell-row">
<button type="button" className="btn sm primary" onClick={speichern}>Speichern</button>
<button type="button" className="btn sm ghost" onClick={() => setBearbeitung(null)}>Abbrechen</button>
</span>
) : (
<span className="cell-row">
<button type="button" className="btn-icon" title="Bearbeiten"
onClick={() => setBearbeitung({ ...a })}>
<Icon name="edit" size={16} />
</button>
{!a.is_builtin && (
<button type="button" className="btn-icon danger" title="Löschen"
onClick={() => loeschen(a)}>
<Icon name="trash" size={16} />
</button>
)}
</span>
)) },
];
return ( return (
<div> <div>
<div className="page-head"> <div className="page-head">
@@ -91,65 +134,10 @@ export default function PackageTypes() {
</div> </div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>} {error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="grid-2"> <div className="grid-2 wide-aside">
<div className="card" style={{ padding: 0 }}> <div className="card">
<div className="table-wrap"> <DataTable id="package-types" columns={columns} rows={arten}
<table className="table"> getRowKey={(a) => a.id} empty="Noch keine Gebinde." />
<thead>
<tr><th>Einzahl</th><th>Mehrzahl</th><th>Beispiel</th><th></th></tr>
</thead>
<tbody>
{arten.map((art) => {
const offen = bearbeitung?.id === art.id;
return (
<tr key={art.id}>
<td data-label="Einzahl" className="strong">
{offen ? (
<input value={bearbeitung.singular}
onChange={(e) => setBearbeitung({ ...bearbeitung, singular: e.target.value })} />
) : (
<span className="cell-row">
<span>{art.singular}</span>
{art.is_builtin && <span className="badge nowrap">eingebaut</span>}
</span>
)}
</td>
<td data-label="Mehrzahl">
{offen ? (
<input value={bearbeitung.plural}
onChange={(e) => setBearbeitung({ ...bearbeitung, plural: e.target.value })} />
) : art.plural}
</td>
<td data-label="Beispiel" className="muted">
1 {art.singular} · 3 {art.plural}
</td>
<td className="num">
{offen ? (
<span className="cell-row">
<button type="button" className="btn sm primary" onClick={speichern}>Speichern</button>
<button type="button" className="btn sm ghost" onClick={() => setBearbeitung(null)}>Abbrechen</button>
</span>
) : (
<span className="cell-row">
<button type="button" className="btn-icon" title="Bearbeiten"
onClick={() => setBearbeitung({ ...art })}>
<Icon name="edit" size={16} />
</button>
{!art.is_builtin && (
<button type="button" className="btn-icon danger" title="Löschen"
onClick={() => loeschen(art)}>
<Icon name="trash" size={16} />
</button>
)}
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div> </div>
<form className="card" onSubmit={anlegen}> <form className="card" onSubmit={anlegen}>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { useConfirm } from "../confirm"; import { useConfirm } from "../confirm";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
import { fmt } from "../units"; import { fmt } from "../units";
const KIND_LABEL = { count: "Anzahl (Stück)", weight: "Gewicht (Basis: Gramm)", volume: "Volumen (Basis: Milliliter)" }; 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 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) => (
<span className="cell-row">
<span className="strong">{u.name}</span>
{u.is_builtin && <span className="badge nowrap">eingebaut</span>}
</span>
) },
{ key: "kind", header: "Art", width: 230, filterText: (u) => KIND_LABEL[u.kind] || u.kind,
render: (u) => <span className="muted">{KIND_LABEL[u.kind] || u.kind}</span> },
{ 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 && (
<button className="btn-icon danger" onClick={() => remove(u)} title="Löschen">
<Icon name="trash" size={16} />
</button>
)) },
];
return ( return (
<div> <div>
<div className="page-head"> <div className="page-head">
@@ -62,34 +85,10 @@ export default function Units() {
</div> </div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>} {error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="grid-2"> <div className="grid-2 wide-aside">
<div className="card" style={{ padding: 0 }}> <div className="card">
<div className="table-wrap"> <DataTable id="units" columns={columns} rows={units}
<table className="table"> getRowKey={(u) => u.id} empty="Noch keine Einheiten." />
<thead><tr><th>Name</th><th>Art</th><th className="num">Faktor</th><th></th></tr></thead>
<tbody>
{units.map((u) => (
<tr key={u.id}>
<td data-label="Name" className="strong">
<span className="cell-row">
<span>{u.name}</span>
{u.is_builtin && <span className="badge nowrap">eingebaut</span>}
</span>
</td>
<td data-label="Art" className="muted">{KIND_LABEL[u.kind] || u.kind}</td>
<td data-label="Faktor" className="num">{fmt(u.factor)} {baseUnitOfKind[u.kind]}</td>
<td className="num">
{!u.is_builtin && (
<button className="btn-icon danger" onClick={() => remove(u)} title="Löschen">
<Icon name="trash" size={16} />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
<form className="card" onSubmit={add}> <form className="card" onSubmit={add}>

View File

@@ -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 { 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:hover { background: var(--surface-2); }
.dt-pop-opt span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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 ---------- */ /* ---------- EAN-Code-Zeile ---------- */
.code-row { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; flex-wrap: wrap; } .code-row { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; flex-wrap: wrap; }