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

@@ -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 (
<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 (
<div>
<div className="page-head">
@@ -90,83 +137,9 @@ export default function Groups() {
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="grid-2 wide-aside">
<div className="card" style={{ padding: 0 }}>
<div className="table-wrap">
<table className="table">
<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 className="card">
<DataTable id="groups" columns={columns} rows={groups}
getRowKey={(g) => g.id} empty="Noch keine Gruppen." />
</div>
<div>