Files
Vorrania/web/src/pages/Groups.jsx
Scarriffle e20a61bf3b Notizfeld bei EAN-Codes bekommt Platz, Seite nutzt mehr Breite
Bei einem Code mit langem Artikelnamen war das Notizfeld auf wenige Pixel
zusammengequetscht ("Zucker - Feinkristallzucker" brach auf zwei Zeilen um und
nahm dem Feld den Raum), waehrend es beim Nachbarcode normal breit war.

Drei Ursachen, alle behoben:

Der Inhalt lag als Inline-Stil in einer Flex-Zeile ohne Regeln fuer den
Umgang mit Platzmangel. Jetzt eigene Klassen: Der Artikelname kuerzt mit
Auslassungspunkten statt umzubrechen, das Notizfeld hat eine Mindestbreite von
150px und waechst mit, die Zeile darf bei echtem Platzmangel umbrechen. Auch die
Zeile zum Hinzufuegen bricht jetzt um, statt "EAN scannen oder eingeben" auf
"EAN scani" zu stutzen.

Die Seite war auf 1080px begrenzt. Auf breiten Bildschirmen blieb dadurch viel
Flaeche leer, obwohl Tabelle und Seitenblock sie gut gebrauchen koennen; jetzt
1400px.

Der Seitenblock war exakt halb so breit wie die Tabelle. Auf der Gruppenseite
bekommt er nun eine Mindestbreite von 360px, damit Code, Kennzeichnung,
Artikelname und Notizfeld nebeneinander Platz haben. Unterhalb von 820px stapelt
sich weiterhin alles untereinander.

Getestet: Web-Build laeuft durch. Die Darstellung im Browser habe ich nicht
selbst angesehen - die Breiten sind rechnerisch gesetzt, nicht ausprobiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:44:50 +02:00

239 lines
9.6 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 { 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>
{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 und sind mit „Artikel“ " +
"gekennzeichnet; entfernen lassen sie sich nur, indem der Artikel die Gruppe " +
"wechselt. Eine Notiz kannst du bei jedem Code hinterlegen. Zusätzlich sind " +
"Codes möglich, die noch zu keinem Artikel gehören: Wird so einer 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>
);
}