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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user