EAN-Codes:
- Das Kennzeichen "Artikel" ist weg; stattdessen stehen Marke und Artikelname
zusammen ("Zweifel - Moutarde Original Chips"). Die Marke allein reichte nicht,
um einen Code wiederzuerkennen. Hinweistext entsprechend angepasst.
Produktseite:
- "Weitere EAN-Codes" steht jetzt unterhalb von Formular und Chargen statt als
dritte Spalte dazwischen. Die Codes braucht man selten und sie schoben die
beiden wichtigen Bloecke auseinander.
Tabellen:
- "overflow-wrap: anywhere" galt fuer ALLE Zellen und zerriss Woerter mitten
drin ("PROD UKTE", "555 Gram m"). Jetzt nur noch gezielt per Klasse.
- Kopfzeilen und Zahl-plus-Einheit brechen nicht mehr um.
- Eingaben in Tabellen sind kompakter (kleinere Polsterung, Zahlenfelder 8ch),
Auswahlfelder richten sich nach ihrem Text statt 132px Mindestbreite -
dadurch sind die Zeilen deutlich schlanker.
- Kennzeichen wie "niedrig" oder "abgelaufen" sitzen ueber .cell-row in einer
Zeile mit dem Wert und rutschen nicht mehr je nach Zeilenhoehe herum.
Geprueft: "npm run build" laeuft durch. Backend unveraendert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
242 lines
9.8 KiB
JavaScript
242 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 { 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 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>
|
||
|
||
<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>
|
||
)}
|
||
|
||
{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>
|
||
);
|
||
}
|