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

@@ -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 ? (
<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>) },
{ key: "art", header: "Art", width: 150, filterText: (c) => MODUS[c.tracking] || c.tracking,
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 (
<div>
@@ -142,94 +164,13 @@ export default function Categories() {
</div>
<div className="grid-2 wide-aside">
<div className="card" style={{ padding: 0 }}>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Kategorie</th>
<th>Art</th>
<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 className="card">
<DataTable id="categories" columns={columns}
rows={categories.filter((c) => !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." />
</div>
<div className="stack">

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>

View File

@@ -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 ? (
<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 (
<div>
<div className="page-head">
@@ -91,65 +134,10 @@ export default function PackageTypes() {
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="grid-2">
<div className="card" style={{ padding: 0 }}>
<div className="table-wrap">
<table className="table">
<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 className="grid-2 wide-aside">
<div className="card">
<DataTable id="package-types" columns={columns} rows={arten}
getRowKey={(a) => a.id} empty="Noch keine Gebinde." />
</div>
<form className="card" onSubmit={anlegen}>

View File

@@ -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) => (
<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 (
<div>
<div className="page-head">
@@ -62,34 +85,10 @@ export default function Units() {
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="grid-2">
<div className="card" style={{ padding: 0 }}>
<div className="table-wrap">
<table className="table">
<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 className="grid-2 wide-aside">
<div className="card">
<DataTable id="units" columns={columns} rows={units}
getRowKey={(u) => u.id} empty="Noch keine Einheiten." />
</div>
<form className="card" onSubmit={add}>