Files
Vorrania/web/src/pages/Groups.jsx
Scarriffle 01a3528227 Web: Produkte in Lebensmittel/Gegenstaende getrennt; Spalten-Labels; Produktformular aufgeraeumt
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>
2026-07-27 18:26:41 +02:00

244 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}