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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user