1) Zwei getrennte Sidebar-Eintraege 'Lebensmittel' und 'Gegenstaende' (Products mit fixedType) statt einem 'Produkte' mit Umschalter - so vermischt sich nichts. 2) Spalten-Waehler zeigt sprechende Labels ('Bild' statt 'thumb', 'Aktionen', ...). 3) Produktformular: leerer Grow-Platzhalter unter 'Typ' entfernt und 'Neue Kategorie' direkt neben das Kategorie-Dropdown gruppiert; Formular wieder auf Lesebreite (kein content--wide), da das Layout-Problem intern war.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
244 lines
9.8 KiB
JavaScript
244 lines
9.8 KiB
JavaScript
import { useEffect, useState } from "react";
|
||
import { api } from "../api";
|
||
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";
|
||
|
||
export default function Groups() {
|
||
const confirm = useConfirm();
|
||
const { isAdmin } = useAuth();
|
||
const [groups, setGroups] = useState([]);
|
||
const [units, setUnits] = useState([]);
|
||
const [locations, setLocations] = useState([]);
|
||
const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" });
|
||
const [selectedId, setSelectedId] = useState(null);
|
||
const [error, setError] = useState(null);
|
||
|
||
async function load() {
|
||
try {
|
||
const [gs, us, ls] = await Promise.all([
|
||
api.listGroups(), api.listUnits(), api.listLocations(),
|
||
]);
|
||
setGroups(gs);
|
||
setUnits(us);
|
||
setLocations(ls);
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
useEffect(() => { load(); }, []);
|
||
|
||
async function add(e) {
|
||
e.preventDefault();
|
||
setError(null);
|
||
try {
|
||
await api.createGroup({
|
||
name: form.name,
|
||
min_stock: form.min_stock === "" ? null : Number(form.min_stock),
|
||
min_stock_unit_id: form.min_stock_unit_id === "" ? null : Number(form.min_stock_unit_id),
|
||
});
|
||
setForm({ name: "", min_stock: "", min_stock_unit_id: form.min_stock_unit_id });
|
||
load();
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
async function patch(group, body) {
|
||
setError(null);
|
||
try {
|
||
await api.updateGroup(group.id, body);
|
||
load();
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
async function remove(group) {
|
||
const ok = await confirm({
|
||
title: `Gruppe „${group.name}“ löschen?`,
|
||
message: "Die Produkte bleiben erhalten, verlieren aber ihre Zuordnung zu dieser Gruppe.",
|
||
confirmLabel: "Löschen",
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api.deleteGroup(group.id);
|
||
if (selectedId === group.id) setSelectedId(null);
|
||
load();
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
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: "", label: "Aktionen", 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">
|
||
<div>
|
||
<h1>Gruppen</h1>
|
||
<div className="sub">
|
||
Bestände mehrerer Marken zusammenzählen – z.B. Mehl von Rewe, Aldi und Migros als „5 kg Mehl“. Mit gemeinsamem Mindestbestand; die EAN-Codes der zugeordneten Artikel erscheinen automatisch hier.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||
|
||
<div className="grid-2 wide-aside">
|
||
<div className="card">
|
||
<DataTable id="groups" columns={columns} rows={groups}
|
||
getRowKey={(g) => g.id} empty="Noch keine Gruppen." />
|
||
</div>
|
||
|
||
<div>
|
||
{selected && (
|
||
<section className="card">
|
||
<div className="card-head">
|
||
<Icon name="search" />
|
||
<h2>EAN-Codes der Gruppe: {selected.name}</h2>
|
||
</div>
|
||
<BarcodeList
|
||
barcodes={selected.barcodes || []}
|
||
productBarcodes={selected.product_barcodes || []}
|
||
disabled={!isAdmin}
|
||
hint={"Codes der zugeordneten Artikel stehen automatisch hier, mit Marke und " +
|
||
"Artikelnamen; entfernen lassen sie sich nur, indem der Artikel die Gruppe " +
|
||
"wechselt. Zusätzlich sind Codes möglich, die noch zu keinem Artikel " +
|
||
"gehören – für die gibt es eine Notiz, weil ihnen der Artikelname fehlt. " +
|
||
"Wird so ein Code beim Einlagern gescannt, landet der neu angelegte Artikel " +
|
||
"automatisch hier."}
|
||
onAdd={async (body) => {
|
||
try {
|
||
await api.addGroupBarcode(selected.id, body);
|
||
await load();
|
||
} catch (err) { setError(err.message); }
|
||
}}
|
||
onEditNote={async (code, note) => {
|
||
try {
|
||
await api.updateGroupBarcodeNote(selected.id, code, { note });
|
||
await load();
|
||
} catch (err) { setError(err.message); }
|
||
}}
|
||
onDelete={async (code) => {
|
||
try {
|
||
await api.deleteGroupBarcode(selected.id, code);
|
||
await load();
|
||
} catch (err) { setError(err.message); }
|
||
}}
|
||
/>
|
||
<button className="btn ghost" onClick={() => setSelectedId(null)}>Schließen</button>
|
||
</section>
|
||
)}
|
||
|
||
{selected && isAdmin && (
|
||
<section className="card">
|
||
<div className="card-head">
|
||
<Icon name="location" />
|
||
<h2>Mindestbestand je Lagerort</h2>
|
||
</div>
|
||
<p className="muted small mt-0">
|
||
Bedarf dieser Gruppe je Lagerort (z.B. Ferienhaus, Zuhause), zusätzlich zum
|
||
Gesamt-Mindestbestand{selected.min_stock_unit_name ? ` (in ${selected.min_stock_unit_name})` : ""}.
|
||
</p>
|
||
<LocationMinStock
|
||
key={selected.id}
|
||
locations={locations}
|
||
initial={selected.location_min_stocks || []}
|
||
unitLabel={selected.min_stock_unit_name || ""}
|
||
onError={setError}
|
||
onSave={async (list) => {
|
||
await api.setGroupLocationMinStock(selected.id, list);
|
||
await load();
|
||
}}
|
||
/>
|
||
</section>
|
||
)}
|
||
|
||
{isAdmin && (
|
||
<form className="card" onSubmit={add}>
|
||
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
|
||
<label>
|
||
Name
|
||
<input placeholder="z.B. Mehl" value={form.name}
|
||
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||
</label>
|
||
<div className="row">
|
||
<label className="grow">
|
||
Mindestbestand (optional)
|
||
<input type="number" step="any" value={form.min_stock}
|
||
onChange={(e) => setForm({ ...form, min_stock: e.target.value })} placeholder="z.B. 2" />
|
||
</label>
|
||
<label className="grow">
|
||
Einheit
|
||
<select value={form.min_stock_unit_id}
|
||
onChange={(e) => setForm({ ...form, min_stock_unit_id: e.target.value })}>
|
||
<option value="">– Basiseinheit –</option>
|
||
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
||
<p className="muted small">
|
||
Der Bestand der Gruppe summiert nur Produkte, die zur gewählten Einheit passen.
|
||
Namen lassen sich in der Tabelle direkt ändern.
|
||
</p>
|
||
</form>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|