Rueckfragen liefen bisher ueber window.confirm. Das reisst aus der Oberflaeche,
zeigt den Hostnamen, laesst sich nicht gestalten und die Knoepfe heissen immer
"OK/Abbrechen". Neu ist ein eigener Dialog (src/confirm.jsx) als Provider mit
dem Hook useConfirm. Der Aufruf bleibt so einfach wie vorher, weil er eine
Zusage zurueckgibt:
if (!(await confirm({ title: "…?", message: "…" }))) return;
Damit sind alle neun Stellen umgestellt: Gruppen, Kategorien, Lagerorte,
Einheiten, Benutzer, API-Tokens, Produkt und Charge loeschen sowie das Ersetzen
beim Import. Jede Rueckfrage hat jetzt einen sprechenden Titel, einen Satz zu
den Folgen und einen benannten Knopf ("Loeschen", "Widerrufen", "Ersetzen")
statt eines nichtssagenden OK. Unwiderrufliche Schritte sind rot.
Kuenftig gilt: keine Browserdialoge mehr.
Export: Dateinamen tragen jetzt Datum und Uhrzeit
(bestand_2026-07-22_2130.csv, project-good-backup_2026-07-22_2130.json). Ohne
Zeitstempel hiessen mehrere Ausleitungen alle gleich und der Browser haengte
(1), (2) an - dann war nicht mehr erkennbar, welche die aktuelle ist. Der
Zeitstempel entsteht an beiden Enden gleich: im Content-Disposition-Kopf des
Backends und im Dateinamen, den der Browser setzt. Im JSON steht zusaetzlich
die Ortszeit neben dem bereits vorhandenen UTC-Zeitpunkt. Die CSV bleibt
inhaltlich unveraendert - eine zusaetzliche Spalte oder Kopfzeile wuerde die
Datei in Excel nur stoeren.
Notizen an Gruppen-Codes: Codes, die beim Zuordnen eines Artikels automatisch
entstehen, hatten bisher keine Moeglichkeit, eine Notiz zu bekommen - die liess
sich nur beim Anlegen von Hand mitgeben. Neu PATCH /groups/{id}/barcodes/{code}
und ein Notizfeld in der Liste, das beim Verlassen speichert. Leeren entfernt
die Notiz.
Getestet: 57 pytest-Tests unveraendert gruen, Web-Build laeuft durch. Gegen die
laufende API geprueft: Zeitstempel in beiden Content-Disposition-Koepfen und im
JSON-Inhalt; Notiz an einem automatisch angelegten Code setzen, aendern und
leeren, unbekannter Code antwortet mit 404. Die Dialoge selbst habe ich nicht
im Browser angeklickt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
237 lines
9.4 KiB
JavaScript
237 lines
9.4 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 { fmt } from "../units";
|
||
|
||
export default function Groups() {
|
||
const confirm = useConfirm();
|
||
const { isAdmin } = useAuth();
|
||
const [groups, setGroups] = useState([]);
|
||
const [units, setUnits] = 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] = await Promise.all([api.listGroups(), api.listUnits()]);
|
||
setGroups(gs);
|
||
setUnits(us);
|
||
} 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;
|
||
|
||
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">
|
||
<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>
|
||
{isAdmin ? (
|
||
<input defaultValue={g.name} style={{ marginTop: 0, minWidth: 130 }}
|
||
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" style={{ marginLeft: 6 }}>niedrig</span>}
|
||
</td>
|
||
<td className="num muted">{g.product_count}</td>
|
||
<td className="num">{fmt(g.stock)} {g.min_stock_unit_name || ""}</td>
|
||
<td>
|
||
{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>
|
||
{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 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>
|
||
|
||
<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. Zusätzlich kannst du Codes " +
|
||
"hinterlegen, die noch zu keinem Artikel gehören: Wird so ein Code beim " +
|
||
"Einlagern gescannt, landet der neu angelegte Artikel automatisch in dieser Gruppe."}
|
||
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>
|
||
)}
|
||
|
||
{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>
|
||
);
|
||
}
|