Modulare Startseite: Kartenraster, eigene Anordnung je Benutzer, Auswertungen

Die Uebersicht war fest verdrahtet und fuer alle gleich. Jetzt besteht sie aus
Karten in einem 12-Spalten-Raster, die sich ziehen, in der Groesse aendern und
einrasten lassen (react-grid-layout). "Anordnen" schaltet das frei, gespeichert
wird ausdruecklich - Probieren bleibt folgenlos.

Anordnung:
- Neue Tabelle dashboard_layouts; user_id = NULL ist die Admin-Vorgabe.
- Jeder Benutzer hat seine eigene Anordnung. Admins speichern eine Vorgabe fuer
  alle und koennen sie ueber die Einstellung dashboard_enforced verbindlich
  machen; dann lehnt das Backend das Speichern eigener Anordnungen ab.
- "Auf Vorgabe zuruecksetzen" verwirft die persoenliche Anordnung.

15 Karten (Verzeichnis web/src/dashboard/cards.jsx, neue Karte = ein Eintrag):
Schnellzugriff, Status, Artikelzahl, Artikeleinheiten, Bald ablaufend,
Abgelaufen, Einkaufsliste, letzte Bewegungen sowie sechs Auswertungen
(Ablauf-Ring, Kategorien-Ring, Kategorien nach Zustand, Bestandsverlauf,
Verlauf eines Artikels, Ein-/Auslagerungen).

Diagramme als eigene SVG-Komponenten statt Diagramm-Paket:
- Zustandsfarben (ok/bald/abgelaufen) sind Statusangaben aus den Tokens und
  stehen nie ohne Beschriftung; Kategorien nutzen eine gepruefte Farbreihe in
  fester Ordnung, ab sieben Kategorien wird zu "Andere" gebuendelt.
- Eine Werteachse, duenne Marken, zurueckhaltendes Gitter, Fadenkreuz mit
  Kurzinfo. Hell und Dunkel haben eigene Farbstufen.

Mengen durchgehend in Artikeleinheiten (Glaeser, Packungen, Stueck): Gramm und
Stueck lassen sich nicht addieren, Gebinde schon. Der bisher dreifach
vorhandene Helfer liegt jetzt einmal in services/conversion.py::article_unit.

Der Verlauf wird rueckwaerts vom heutigen Bestand aus den Bewegungen
rekonstruiert. Bekannte Ungenauigkeit, im Code und in der Roadmap vermerkt:
Die Umrechnung nutzt die heutige Packungsgroesse.

Geprueft: "npm run build" laeuft durch. Die neuen pytest-Tests
(backend/tests/test_dashboard.py) konnten hier nicht laufen - auf diesem
Rechner ist kein Python installiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-23 09:48:16 +02:00
parent 4448718d8d
commit 84ce596446
17 changed files with 1940 additions and 115 deletions

View File

@@ -1,142 +1,271 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useEffect, useMemo, useState } from "react";
import GridLayout, { WidthProvider } from "react-grid-layout";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { useSettings } from "../settings";
import { amountText, articlePrimary, articleSecondary, fmt, relativeExpiry } from "../units";
import { useConfirm } from "../confirm";
import { KARTEN, KARTEN_IDS } from "../dashboard/cards";
const Raster = WidthProvider(GridLayout);
const SPALTEN = 12;
const ZEILENHOEHE = 96;
/**
* Startseite als frei zusammenstellbares Raster.
*
* Ansehen ist der Normalzustand. Erst „Anordnen“ schaltet Ziehen, Größe ändern
* und Entfernen frei so verschiebt man nichts versehentlich beim Lesen.
* Gespeichert wird ausdrücklich, damit ein Probieren folgenlos bleibt.
*/
export default function Dashboard() {
const { formatBestBefore } = useSettings();
const [expiring, setExpiring] = useState([]);
const [shopping, setShopping] = useState([]);
const [groupShopping, setGroupShopping] = useState([]);
const [error, setError] = useState(null);
const { isAdmin } = useAuth();
const confirm = useConfirm();
useEffect(() => {
async function load() {
try {
const [e, s, g] = await Promise.all([
api.expiring(),
api.shoppingList(),
api.groupShoppingList(),
]);
setExpiring(e);
setShopping(s);
setGroupShopping(g);
} catch (err) {
setError(err.message);
}
const [layout, setLayout] = useState([]);
const [entwurf, setEntwurf] = useState(null); // nur im Bearbeitungsmodus gesetzt
const [herkunft, setHerkunft] = useState("builtin");
const [erzwungen, setErzwungen] = useState(false);
const [hatVorgabe, setHatVorgabe] = useState(false);
const [fehler, setFehler] = useState(null);
const [hinweis, setHinweis] = useState(null);
const [menuOffen, setMenuOffen] = useState(false);
const bearbeiten = entwurf !== null;
const aktuell = entwurf ?? layout;
async function laden() {
try {
const antwort = await api.dashboardLayout();
setLayout(bereinige(antwort.layout));
setHerkunft(antwort.source);
setErzwungen(antwort.enforced);
setHatVorgabe(antwort.has_default);
} catch (err) {
setFehler(err.message);
}
load();
}, []);
}
const overdue = expiring.filter((e) => e.days_left < 0).length;
useEffect(() => { laden(); }, []);
/** Unbekannte Karten aussortieren z.B. nach einem Update, das eine entfernt. */
function bereinige(liste) {
return (liste || []).filter((k) => KARTEN[k.i]);
}
const verfuegbar = useMemo(
() => KARTEN_IDS.filter((id) => !aktuell.some((k) => k.i === id)),
[aktuell],
);
/** Mindestgrößen kommen aus dem Verzeichnis und gehören in die Rasterangabe
* nicht zusätzlich als data-grid, sonst gibt es zwei Quellen der Wahrheit. */
const rasterLayout = useMemo(
() => aktuell.map((k) => ({
...k,
minW: KARTEN[k.i]?.min.w ?? 2,
minH: KARTEN[k.i]?.min.h ?? 1,
})),
[aktuell],
);
function starteBearbeitung() {
setEntwurf(aktuell.map((k) => ({ ...k })));
setHinweis(null);
}
function hinzufuegen(id) {
const karte = KARTEN[id];
const untenY = aktuell.reduce((max, k) => Math.max(max, k.y + k.h), 0);
setEntwurf([...(entwurf ?? []), { i: id, x: 0, y: untenY, ...karte.standard }]);
setMenuOffen(false);
}
function entfernen(id) {
setEntwurf((alt) => (alt ?? []).filter((k) => k.i !== id));
}
async function speichern() {
setFehler(null);
try {
const antwort = await api.saveDashboardLayout(schlank(entwurf));
setLayout(bereinige(antwort.layout));
setHerkunft(antwort.source);
setEntwurf(null);
setHinweis("Anordnung gespeichert.");
} catch (err) {
setFehler(err.message);
}
}
async function alsVorgabe() {
setFehler(null);
try {
await api.saveDashboardDefault(schlank(entwurf ?? layout));
setHatVorgabe(true);
setHinweis("Als Vorgabe für alle Benutzer gespeichert.");
} catch (err) {
setFehler(err.message);
}
}
async function erzwingen(wert) {
setFehler(null);
try {
const antwort = await api.setDashboardEnforced(wert);
setErzwungen(antwort.enforced);
setLayout(bereinige(antwort.layout));
setHerkunft(antwort.source);
setEntwurf(null);
setHinweis(wert
? "Die Startseite ist jetzt für alle verbindlich."
: "Benutzer dürfen ihre Startseite wieder selbst anordnen.");
} catch (err) {
setFehler(err.message);
}
}
async function zuruecksetzen() {
const ok = await confirm({
title: "Auf Vorgabe zurücksetzen?",
message: "Deine persönliche Anordnung wird verworfen.",
confirmLabel: "Zurücksetzen",
danger: true,
});
if (!ok) return;
try {
await api.resetDashboardLayout();
setEntwurf(null);
await laden();
setHinweis("Auf die Vorgabe zurückgesetzt.");
} catch (err) {
setFehler(err.message);
}
}
/** Nur die Rasterfelder speichern react-grid-layout hängt viel Beiwerk an. */
function schlank(liste) {
return (liste || []).map(({ i, x, y, w, h }) => ({ i, x, y, w, h }));
}
return (
<div>
<div className="page-head">
<div>
<h1>Übersicht</h1>
<div className="sub">Bestand, Ablauf und Bedarf auf einen Blick</div>
<div className="sub">
{erzwungen
? "Die Anordnung ist vom Administrator vorgegeben."
: "Karten lassen sich frei anordnen und in der Größe ändern."}
</div>
</div>
<div className="actions">
<Link className="btn primary" to="/checkin"><Icon name="checkin" size={16} />Einlagern</Link>
<Link className="btn" to="/checkout"><Icon name="checkout" size={16} />Auslagern</Link>
{!bearbeiten && !erzwungen && (
<button className="btn" onClick={starteBearbeitung}>
<Icon name="edit" size={16} />Anordnen
</button>
)}
{bearbeiten && (
<>
<div className="menu-wrap">
<button className="btn" onClick={() => setMenuOffen((o) => !o)}
disabled={verfuegbar.length === 0}>
<Icon name="plus" size={16} />Karte hinzufügen
</button>
{menuOffen && (
<ul className="menu">
{verfuegbar.map((id) => (
<li key={id}>
<button className="link-btn" onClick={() => hinzufuegen(id)}>
<Icon name={KARTEN[id].icon} size={15} />{KARTEN[id].titel}
</button>
</li>
))}
</ul>
)}
</div>
<button className="btn primary" onClick={speichern}>Speichern</button>
<button className="btn ghost" onClick={() => { setEntwurf(null); setMenuOffen(false); }}>
Abbrechen
</button>
</>
)}
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
{hinweis && <div className="alert ok"><Icon name="check" size={16} />{hinweis}</div>}
{overdue > 0 && (
<div className="alert error"><Icon name="alert" size={16} />
{overdue} Charge(n) im Lager sind bereits abgelaufen bitte aussortieren.
{bearbeiten && (
<div className="alert info">
<Icon name="edit" size={16} />
<span>
Karten ziehen zum Verschieben, an der rechten unteren Ecke größer ziehen.
Änderungen gelten erst nach Speichern.
</span>
</div>
)}
<div className="stat-row">
<div className="stat">
<div className="stat-label"><Icon name="clock" size={14} />Bald ablaufend</div>
<div className="stat-value">{expiring.length}</div>
</div>
<div className="stat">
<div className="stat-label"><Icon name="alert" size={14} />Überfällig</div>
<div className="stat-value">{overdue}</div>
</div>
<div className="stat">
<div className="stat-label"><Icon name="cart" size={14} />Nachzukaufen</div>
<div className="stat-value">{shopping.length + groupShopping.length}</div>
</div>
</div>
<Raster
className={`dash-grid ${bearbeiten ? "editing" : ""}`}
layout={rasterLayout}
cols={SPALTEN}
rowHeight={ZEILENHOEHE}
margin={[16, 16]}
containerPadding={[0, 0]}
isDraggable={bearbeiten}
isResizable={bearbeiten}
draggableHandle=".dash-card-head"
onLayoutChange={(neu) => { if (bearbeiten) setEntwurf(schlank(neu)); }}
compactType="vertical"
>
{aktuell.map((eintrag) => {
const karte = KARTEN[eintrag.i];
if (!karte) return null;
const Inhalt = karte.komponente;
return (
<section key={eintrag.i} className="card dash-card">
<div className="dash-card-head">
<Icon name={karte.icon} size={16} />
<h2>{karte.titel}</h2>
{bearbeiten && (
<button className="btn-icon danger" title="Karte entfernen"
onMouseDown={(e) => e.stopPropagation()}
onClick={() => entfernen(eintrag.i)}>
<Icon name="trash" size={15} />
</button>
)}
</div>
<div className="dash-card-body"><Inhalt /></div>
</section>
);
})}
</Raster>
<div className="grid-2">
<section className="card">
<div className="card-head"><Icon name="clock" />
<h2>Bald ablaufend</h2>
</div>
{expiring.length === 0 ? (
<div className="empty">Nichts läuft demnächst ab.</div>
) : (
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Produkt</th><th className="num">Menge</th><th>MHD</th><th>Frist</th></tr>
</thead>
<tbody>
{expiring.map((it) => (
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : "row-warn"}>
<td data-label="Produkt">{it.product_name}</td>
<td data-label="Menge" className="num">
{articlePrimary(it)}
{articleSecondary(it) && (
<div className="muted small">{articleSecondary(it)}</div>
)}
</td>
<td data-label="MHD">{formatBestBefore(it.best_before, it.best_before_precision)}</td>
<td data-label="Frist">
<span className={`badge ${it.days_left < 0 ? "danger" : "warn"}`}>
{relativeExpiry(it.days_left)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{aktuell.length === 0 && (
<div className="card"><div className="empty">
Keine Karten ausgewählt. Über Anordnen lassen sich welche hinzufügen.
</div></div>
)}
<section className="card">
<div className="card-head"><Icon name="cart" />
<h2>Einkaufsliste</h2>
</div>
{shopping.length === 0 && groupShopping.length === 0 ? (
<div className="empty">Alle Mindestbestände erreicht.</div>
) : (
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Bedarf</th><th className="num">Bestand</th><th className="num">Fehlt</th></tr>
</thead>
<tbody>
{groupShopping.map((it) => (
<tr key={`g${it.group_id}`}>
<td data-label="Bedarf"><span className="badge accent">Gruppe</span> {it.name}</td>
<td data-label="Bestand" className="num">{fmt(it.stock)} {it.unit_name}</td>
<td data-label="Fehlt" className="num strong">{fmt(it.deficit)} {it.unit_name}</td>
</tr>
))}
{shopping.map((it) => (
<tr key={`p${it.product_id}`}>
<td data-label="Bedarf">{it.name}</td>
<td data-label="Bestand" className="num">{amountText(it.stock, it.package_size, it.base_unit)}</td>
<td data-label="Fehlt" className="num strong">{amountText(it.deficit, it.package_size, it.base_unit)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<div className="dash-foot">
{!erzwungen && herkunft === "user" && hatVorgabe && (
<button className="btn ghost" onClick={zuruecksetzen}>
<Icon name="history" size={15} />Auf Vorgabe zurücksetzen
</button>
)}
{isAdmin && (
<>
<button className="btn ghost" onClick={alsVorgabe}>
<Icon name="users" size={15} />Als Vorgabe für alle speichern
</button>
<label className="enforce-toggle">
<input type="checkbox" checked={erzwungen}
onChange={(e) => erzwingen(e.target.checked)} />
Vorgabe verbindlich (Benutzer können nicht umbauen)
</label>
</>
)}
</div>
</div>
);