Web-UI: professionelles Redesign (Icons statt Emojis) + neue Features
Design: - Neues Icon-Set (Inline-SVG, Feather-Stil), keine Emojis mehr - Kohaerentes Design-System (Sidebar-Layout, Palette, Spacing, Light/Dark) - Alle Seiten ueberarbeitet (Login, Dashboard, Produkte, Formular, Ein-/Auslagern, Lagerorte, Benutzer, Einkaufsliste) Neue Features: - Gruppen-Verwaltung (anlegen/bearbeiten/loeschen, Produktzahl + Bestand, Gruppen-Mindestbestand) inkl. Einkaufsliste - Einfache Gruppen-Auto-Zuordnung aus OFF-Kategorie im Produktformular - Verlauf-Seite (Bewegungen: wer/was/wann) via neuem /movements-Endpoint - Einstellungen-Seite (Ablauf-Warnfrist) - Lagerorte mit optional uebergeordnetem Ort (verschachtelbar) Backend: - groups: PATCH + Anreicherung (product_count, stock) - views: /shopping-list/groups, /movements - schemas: GroupUpdate, GroupShoppingItem, MovementOut Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
133
web/src/pages/Groups.jsx
Normal file
133
web/src/pages/Groups.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import { fmt } from "../units";
|
||||
|
||||
export default function Groups() {
|
||||
const { isAdmin } = useAuth();
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [name, setName] = useState("");
|
||||
const [minStock, setMinStock] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setGroups(await api.listGroups());
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function add(e) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await api.createGroup({ name, min_stock: minStock === "" ? null : Number(minStock) });
|
||||
setName("");
|
||||
setMinStock("");
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMin(group, value) {
|
||||
try {
|
||||
await api.updateGroup(group.id, { min_stock: value === "" ? null : Number(value) });
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(group) {
|
||||
if (!confirm(`Gruppe "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Gruppenzuordnung.`)) return;
|
||||
try {
|
||||
await api.deleteGroup(group.id);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Gruppen</h1>
|
||||
<div className="sub">Fasse Produkte zusammen (z.B. Nudeln, Mehl) und setze einen Gruppen-Mindestbestand</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></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map((g) => {
|
||||
const low = g.min_stock != null && g.stock < g.min_stock;
|
||||
return (
|
||||
<tr key={g.id}>
|
||||
<td className="strong">
|
||||
{g.name}
|
||||
{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)}</td>
|
||||
<td>
|
||||
{isAdmin ? (
|
||||
<input type="number" step="any" defaultValue={g.min_stock ?? ""}
|
||||
style={{ maxWidth: 110, marginTop: 0 }}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v !== String(g.min_stock ?? "")) saveMin(g, v);
|
||||
}} />
|
||||
) : (g.min_stock != null ? fmt(g.min_stock) : "–")}
|
||||
</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={5} className="empty">Noch keine Gruppen.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<form className="card" onSubmit={add}>
|
||||
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
|
||||
<label>
|
||||
Name
|
||||
<input placeholder="z.B. Nudeln" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
Mindestbestand (optional)
|
||||
<input type="number" step="any" value={minStock} onChange={(e) => setMinStock(e.target.value)}
|
||||
placeholder="Gesamtmenge über alle Produkte der Gruppe" />
|
||||
</label>
|
||||
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
||||
<p className="muted small">
|
||||
Produkte ordnest du einer Gruppe im jeweiligen Produkt-Formular zu.
|
||||
Der Gruppen-Bestand ist die Summe der Produktbestände – sinnvoll, wenn
|
||||
die Produkte dieselbe Basiseinheit haben.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user