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:
Scarriffle
2026-07-22 10:18:30 +02:00
parent 9d4fd030d8
commit b683f2d93b
19 changed files with 1304 additions and 421 deletions

View File

@@ -2,19 +2,28 @@ import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { BASE_UNITS, fmt, unitShort } from "../units";
const EMPTY = {
barcode: "",
name: "",
brand: "",
image_url: "",
base_unit: "piece",
package_size: "",
min_stock: "",
group_id: "",
barcode: "", name: "", brand: "", image_url: "",
base_unit: "piece", package_size: "", min_stock: "", group_id: "",
};
// Versucht, aus einem OFF-Kategorietext eine vorhandene Gruppe zu erraten.
function guessGroup(groups, suggestion) {
if (!suggestion) return "";
const haystack = [
suggestion.category_suggestion || "",
...(suggestion.category_tags || []),
suggestion.name || "",
]
.join(" ")
.toLowerCase();
const hit = groups.find((g) => haystack.includes(g.name.toLowerCase()));
return hit ? String(hit.id) : "";
}
export default function ProductForm() {
const { id } = useParams();
const isNew = !id;
@@ -37,13 +46,9 @@ export default function ProductForm() {
const p = await api.getProduct(id);
setProduct(p);
setForm({
barcode: p.barcode || "",
name: p.name,
brand: p.brand || "",
image_url: p.image_url || "",
base_unit: p.base_unit,
package_size: p.package_size ?? "",
min_stock: p.min_stock ?? "",
barcode: p.barcode || "", name: p.name, brand: p.brand || "",
image_url: p.image_url || "", base_unit: p.base_unit,
package_size: p.package_size ?? "", min_stock: p.min_stock ?? "",
group_id: p.group_id ?? "",
});
setLots(await api.listLots(id));
@@ -71,14 +76,19 @@ export default function ProductForm() {
navigate(`/products/${res.existing_product.id}`);
} else if (res.found && res.suggestion) {
const s = res.suggestion;
const groupGuess = guessGroup(groups, s);
setForm((f) => ({
...f,
name: s.name || f.name,
brand: s.brand || f.brand,
image_url: s.image_url || f.image_url,
base_unit: s.base_unit || f.base_unit,
group_id: f.group_id || groupGuess,
}));
setInfo("Vorschlag von Open Food Facts übernommen.");
setInfo(
"Daten von Open Food Facts übernommen." +
(groupGuess ? " Passende Gruppe vorgeschlagen." : "")
);
} else {
setInfo("Barcode unbekannt bitte Daten selbst eingeben.");
}
@@ -135,21 +145,26 @@ export default function ProductForm() {
return (
<div>
<div className="page-head">
<h1>{isNew ? "Neues Produkt" : form.name || "Produkt"}</h1>
<div>
<h1>{isNew ? "Neues Produkt" : form.name || "Produkt"}</h1>
{!isNew && <div className="sub">Bestand: {fmt(product?.stock)} {unitShort(form.base_unit)}</div>}
</div>
<button className="btn ghost" onClick={() => navigate("/products")}>Zurück</button>
</div>
{error && <div className="alert error">{error}</div>}
{info && <div className="alert info">{info}</div>}
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{info && <div className="alert info"><Icon name="check" size={16} />{info}</div>}
<div className="grid-2">
<form className="card" onSubmit={save}>
<div className="row">
<div className="field-inline">
<label className="grow">
Barcode
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
</label>
{isAdmin && (
<button type="button" className="btn" onClick={lookup} style={{ alignSelf: "flex-end" }}>
Nachschlagen
<button type="button" className="btn" onClick={lookup}>
<Icon name="search" size={16} />Nachschlagen
</button>
)}
</div>
@@ -165,21 +180,18 @@ export default function ProductForm() {
<label className="grow">
Basiseinheit
<select value={form.base_unit} onChange={(e) => set("base_unit", e.target.value)} disabled={readOnly}>
{BASE_UNITS.map((u) => (
<option key={u.value} value={u.value}>{u.label}</option>
))}
{BASE_UNITS.map((u) => <option key={u.value} value={u.value}>{u.label}</option>)}
</select>
</label>
<label className="grow">
Packungsgröße (in Basiseinheit)
Packungsgröße
<input type="number" step="any" value={form.package_size}
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly}
placeholder="z.B. 500" />
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly} placeholder="z.B. 500" />
</label>
</div>
<div className="row">
<label className="grow">
Mindestbestand (in Basiseinheit)
Mindestbestand
<input type="number" step="any" value={form.min_stock}
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} />
</label>
@@ -187,39 +199,43 @@ export default function ProductForm() {
Gruppe
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)} disabled={readOnly}>
<option value=""> keine </option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</label>
</div>
{isAdmin && (
<div className="row">
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
{!isNew && <button type="button" className="btn danger" onClick={remove}>Löschen</button>}
{!isNew && (
<button type="button" className="btn danger" onClick={remove}>
<Icon name="trash" size={16} />Löschen
</button>
)}
</div>
)}
{readOnly && <p className="muted">Nur Administratoren können Produkte bearbeiten.</p>}
{readOnly && <p className="muted small mt-0">Nur Administratoren können Produkte bearbeiten.</p>}
</form>
{!isNew && (
<section className="card">
<h2>Chargen (Bestand: {fmt(product?.stock)} {unitShort(form.base_unit)})</h2>
<div className="card-head"><Icon name="box" /><h2>Chargen im Bestand</h2></div>
{form.image_url && <img className="product-img" src={form.image_url} alt="" />}
<table className="table">
<thead><tr><th>Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead>
<tbody>
{lots.map((l) => (
<tr key={l.id}>
<td>{fmt(l.quantity)} {unitShort(form.base_unit)}</td>
<td>{l.best_before || ""}</td>
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
</tr>
))}
{lots.length === 0 && <tr><td colSpan={3} className="muted">Keine Chargen im Bestand.</td></tr>}
</tbody>
</table>
<div className="table-wrap">
<table className="table">
<thead><tr><th className="num">Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead>
<tbody>
{lots.map((l) => (
<tr key={l.id}>
<td className="num">{fmt(l.quantity)} {unitShort(form.base_unit)}</td>
<td>{l.best_before || ""}</td>
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
</tr>
))}
{lots.length === 0 && <tr><td colSpan={3} className="empty">Keine Chargen im Bestand.</td></tr>}
</tbody>
</table>
</div>
</section>
)}
</div>