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:
410
web/src/dashboard/cards.jsx
Normal file
410
web/src/dashboard/cards.jsx
Normal file
@@ -0,0 +1,410 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import { useSettings } from "../settings";
|
||||
import {
|
||||
amountText, articlePrimary, articleSecondary, fmt, relativeExpiry,
|
||||
} from "../units";
|
||||
import Bars from "./charts/Bars";
|
||||
import Donut from "./charts/Donut";
|
||||
import Line from "./charts/Line";
|
||||
import { istDunkel, mitFarben, tokenFarbe } from "./palette";
|
||||
|
||||
/**
|
||||
* Verzeichnis aller Karten der Startseite.
|
||||
*
|
||||
* Eine Karte ist ein Eintrag hier – mehr braucht es nicht, um sie im
|
||||
* Hinzufügen-Menü und im Raster verfügbar zu machen. Jede Karte lädt ihre
|
||||
* eigenen Daten; so kostet eine nicht genutzte Karte auch keine Abfrage.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------- Helfer
|
||||
function useDaten(laden, abhaengig = []) {
|
||||
const [daten, setDaten] = useState(null);
|
||||
const [fehler, setFehler] = useState(null);
|
||||
useEffect(() => {
|
||||
let aktiv = true;
|
||||
laden()
|
||||
.then((d) => aktiv && setDaten(d))
|
||||
.catch((e) => aktiv && setFehler(e.message));
|
||||
return () => { aktiv = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, abhaengig);
|
||||
return { daten, fehler };
|
||||
}
|
||||
|
||||
function Zustand({ fehler, daten, children, leer = "Keine Daten." }) {
|
||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||
if (daten == null) return <div className="empty">Lädt…</div>;
|
||||
if (Array.isArray(daten) && daten.length === 0) return <div className="empty">{leer}</div>;
|
||||
return children;
|
||||
}
|
||||
|
||||
function kurzesDatum(iso) {
|
||||
const d = new Date(`${iso}T00:00:00`);
|
||||
return Number.isNaN(d.getTime())
|
||||
? iso
|
||||
: d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Aktionen
|
||||
function KarteAktionen() {
|
||||
return (
|
||||
<div className="card-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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Kennzahlen
|
||||
function Kennzahl({ wert, label, icon, ton }) {
|
||||
return (
|
||||
<div className={`kpi ${ton || ""}`}>
|
||||
<div className="kpi-label"><Icon name={icon} size={14} />{label}</div>
|
||||
<div className="kpi-value">{wert}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KarteStatus() {
|
||||
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||||
return (
|
||||
<Zustand fehler={fehler} daten={daten}>
|
||||
<div className="kpi-row">
|
||||
<Kennzahl icon="clock" label="Bald ablaufend" wert={daten?.expiring_soon ?? 0}
|
||||
ton={daten?.expiring_soon ? "warn" : ""} />
|
||||
<Kennzahl icon="alert" label="Abgelaufen" wert={daten?.expired ?? 0}
|
||||
ton={daten?.expired ? "danger" : ""} />
|
||||
<Kennzahl icon="cart" label="Nachzukaufen" wert={daten?.shopping_items ?? 0} />
|
||||
</div>
|
||||
</Zustand>
|
||||
);
|
||||
}
|
||||
|
||||
function KarteArtikelzahl() {
|
||||
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||||
return (
|
||||
<Zustand fehler={fehler} daten={daten}>
|
||||
<Kennzahl icon="package" label="Artikel mit Bestand" wert={daten?.products_in_stock ?? 0} />
|
||||
</Zustand>
|
||||
);
|
||||
}
|
||||
|
||||
function KarteEinheiten() {
|
||||
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||||
return (
|
||||
<Zustand fehler={fehler} daten={daten}>
|
||||
<Kennzahl icon="box" label="Artikeleinheiten im Lager" wert={fmt(daten?.article_units ?? 0)} />
|
||||
</Zustand>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Listen
|
||||
function KarteAblauf({ nurAbgelaufen = false }) {
|
||||
const { formatBestBefore } = useSettings();
|
||||
const { daten, fehler } = useDaten(() => api.expiring());
|
||||
const zeilen = (daten || []).filter((e) => (nurAbgelaufen ? e.days_left < 0 : true));
|
||||
|
||||
return (
|
||||
<Zustand fehler={fehler} daten={daten}
|
||||
leer={nurAbgelaufen ? "Nichts ist abgelaufen." : "Nichts läuft demnächst ab."}>
|
||||
{zeilen.length === 0 ? (
|
||||
<div className="empty">{nurAbgelaufen ? "Nichts ist abgelaufen." : "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>
|
||||
{zeilen.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>
|
||||
)}
|
||||
</Zustand>
|
||||
);
|
||||
}
|
||||
|
||||
function KarteEinkaufsliste() {
|
||||
const { daten, fehler } = useDaten(async () => {
|
||||
const [produkte, gruppen] = await Promise.all([api.shoppingList(), api.groupShoppingList()]);
|
||||
return { produkte, gruppen };
|
||||
});
|
||||
|
||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||
if (!daten) return <div className="empty">Lädt…</div>;
|
||||
if (!daten.produkte.length && !daten.gruppen.length) {
|
||||
return <div className="empty">Alle Mindestbestände erreicht.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
{daten.gruppen.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>
|
||||
))}
|
||||
{daten.produkte.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>
|
||||
);
|
||||
}
|
||||
|
||||
const BEWEGUNG_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
|
||||
|
||||
function KarteBewegungen() {
|
||||
const { daten, fehler } = useDaten(() => api.listMovements(20));
|
||||
return (
|
||||
<Zustand fehler={fehler} daten={daten} leer="Noch keine Bewegungen.">
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>Zeitpunkt</th><th>Aktion</th><th>Produkt</th></tr></thead>
|
||||
<tbody>
|
||||
{(daten || []).map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td data-label="Zeitpunkt" className="muted">
|
||||
{new Date(m.created_at).toLocaleString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
|
||||
})}
|
||||
</td>
|
||||
<td data-label="Aktion">
|
||||
<span className={`badge ${m.type === "in" ? "ok" : m.type === "out" ? "warn" : ""}`}>
|
||||
{BEWEGUNG_LABEL[m.type] || m.type}
|
||||
</span>
|
||||
</td>
|
||||
<td data-label="Produkt">{m.product_name}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Zustand>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Diagramme
|
||||
function KarteAblaufRing() {
|
||||
const { daten, fehler } = useDaten(() => api.dashboardExpirySplit());
|
||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||
if (!daten) return <div className="empty">Lädt…</div>;
|
||||
|
||||
// Zustandsfarben, nie Serienfarben – und immer mit Beschriftung.
|
||||
const segmente = [
|
||||
{ name: "In Ordnung", value: daten.ok, color: tokenFarbe("--ok", "#2e7d55") },
|
||||
{ name: "Bald ablaufend", value: daten.soon, color: tokenFarbe("--warn", "#b06f14") },
|
||||
{ name: "Abgelaufen", value: daten.expired, color: tokenFarbe("--danger", "#c0392b") },
|
||||
{ name: "Ohne MHD", value: daten.no_date, color: tokenFarbe("--muted", "#6b7480") },
|
||||
];
|
||||
return <Donut segmente={segmente} gesamtLabel="Einheiten" />;
|
||||
}
|
||||
|
||||
function KarteKategorienRing() {
|
||||
const { daten, fehler } = useDaten(() => api.dashboardByCategory());
|
||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||
if (!daten) return <div className="empty">Lädt…</div>;
|
||||
|
||||
const segmente = mitFarben(
|
||||
daten.map((k) => ({ name: k.name, value: k.article_units })),
|
||||
istDunkel(),
|
||||
);
|
||||
return <Donut segmente={segmente} gesamtLabel="Einheiten" />;
|
||||
}
|
||||
|
||||
function KarteKategorienSaeulen() {
|
||||
const { daten, fehler } = useDaten(() => api.dashboardByCategory());
|
||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||
if (!daten) return <div className="empty">Lädt…</div>;
|
||||
|
||||
const zeilen = daten.slice(0, 8).map((k) => ({
|
||||
name: k.name,
|
||||
teile: [
|
||||
{ key: "ok", value: k.ok, label: "In Ordnung", color: tokenFarbe("--ok", "#2e7d55") },
|
||||
{ key: "soon", value: k.soon, label: "Bald ablaufend", color: tokenFarbe("--warn", "#b06f14") },
|
||||
{ key: "expired", value: k.expired, label: "Abgelaufen", color: tokenFarbe("--danger", "#c0392b") },
|
||||
{ key: "no_date", value: k.no_date, label: "Ohne MHD", color: tokenFarbe("--muted", "#6b7480") },
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="chart-legend inline">
|
||||
{[["In Ordnung", "--ok"], ["Bald ablaufend", "--warn"], ["Abgelaufen", "--danger"], ["Ohne MHD", "--muted"]]
|
||||
.map(([name, token]) => (
|
||||
<li key={name}>
|
||||
<span className="swatch" style={{ background: tokenFarbe(token) }} />
|
||||
<span className="legend-name">{name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Bars zeilen={zeilen} einheit="Einheiten" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function KarteBestandsverlauf({ tage = 90 }) {
|
||||
const { daten, fehler } = useDaten(() => api.dashboardTimeline(tage), [tage]);
|
||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||
if (!daten) return <div className="empty">Lädt…</div>;
|
||||
|
||||
return (
|
||||
<Line
|
||||
labels={daten.map((p) => kurzesDatum(p.date))}
|
||||
serien={[{
|
||||
name: "Artikeleinheiten",
|
||||
color: tokenFarbe("--accent", "#3f51b5"),
|
||||
werte: daten.map((p) => p.article_units),
|
||||
}]}
|
||||
einheit="Einheiten"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function KarteArtikelverlauf() {
|
||||
const [produkte, setProdukte] = useState([]);
|
||||
const [gewaehlt, setGewaehlt] = useState("");
|
||||
const { daten, fehler } = useDaten(
|
||||
() => (gewaehlt ? api.dashboardTimeline(90, gewaehlt) : Promise.resolve([])),
|
||||
[gewaehlt],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
api.listProducts().then((liste) => {
|
||||
setProdukte(liste);
|
||||
if (liste.length && !gewaehlt) setGewaehlt(String(liste[0].id));
|
||||
}).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="card-stack">
|
||||
<select value={gewaehlt} onChange={(e) => setGewaehlt(e.target.value)}
|
||||
style={{ marginTop: 0, maxWidth: 280 }}>
|
||||
<option value="">– Artikel wählen –</option>
|
||||
{produkte.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
{fehler ? (
|
||||
<div className="alert error"><Icon name="alert" size={16} />{fehler}</div>
|
||||
) : (
|
||||
<Line
|
||||
labels={(daten || []).map((p) => kurzesDatum(p.date))}
|
||||
serien={[{
|
||||
name: "Bestand",
|
||||
color: tokenFarbe("--accent", "#3f51b5"),
|
||||
werte: (daten || []).map((p) => p.article_units),
|
||||
}]}
|
||||
einheit="Einheiten"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KarteBewegungsverlauf({ tage = 30 }) {
|
||||
const { daten, fehler } = useDaten(() => api.dashboardActivity(tage), [tage]);
|
||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||
if (!daten) return <div className="empty">Lädt…</div>;
|
||||
|
||||
return (
|
||||
<Line
|
||||
labels={daten.map((p) => kurzesDatum(p.date))}
|
||||
serien={[
|
||||
{ name: "Eingelagert", color: tokenFarbe("--ok", "#2e7d55"), werte: daten.map((p) => p.checked_in) },
|
||||
{ name: "Ausgelagert", color: tokenFarbe("--warn", "#b06f14"), werte: daten.map((p) => p.checked_out) },
|
||||
]}
|
||||
einheit="Vorgänge"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Verzeichnis
|
||||
export const KARTEN = {
|
||||
actions: {
|
||||
titel: "Schnellzugriff", icon: "checkin", standard: { w: 4, h: 1 }, min: { w: 3, h: 1 },
|
||||
komponente: KarteAktionen,
|
||||
},
|
||||
status: {
|
||||
titel: "Status", icon: "dashboard", standard: { w: 8, h: 1 }, min: { w: 4, h: 1 },
|
||||
komponente: KarteStatus,
|
||||
},
|
||||
"kpi-products": {
|
||||
titel: "Artikel mit Bestand", icon: "package", standard: { w: 3, h: 1 }, min: { w: 2, h: 1 },
|
||||
komponente: KarteArtikelzahl,
|
||||
},
|
||||
"kpi-units": {
|
||||
titel: "Artikeleinheiten", icon: "box", standard: { w: 3, h: 1 }, min: { w: 2, h: 1 },
|
||||
komponente: KarteEinheiten,
|
||||
},
|
||||
expiring: {
|
||||
titel: "Bald ablaufend", icon: "clock", standard: { w: 6, h: 3 }, min: { w: 4, h: 2 },
|
||||
komponente: () => <KarteAblauf />,
|
||||
},
|
||||
expired: {
|
||||
titel: "Abgelaufen", icon: "alert", standard: { w: 6, h: 2 }, min: { w: 4, h: 2 },
|
||||
komponente: () => <KarteAblauf nurAbgelaufen />,
|
||||
},
|
||||
shopping: {
|
||||
titel: "Einkaufsliste", icon: "cart", standard: { w: 6, h: 3 }, min: { w: 4, h: 2 },
|
||||
komponente: KarteEinkaufsliste,
|
||||
},
|
||||
movements: {
|
||||
titel: "Letzte Bewegungen", icon: "history", standard: { w: 6, h: 3 }, min: { w: 4, h: 2 },
|
||||
komponente: KarteBewegungen,
|
||||
},
|
||||
"expiry-donut": {
|
||||
titel: "Ablauf-Anteile", icon: "clock", standard: { w: 4, h: 3 }, min: { w: 3, h: 3 },
|
||||
komponente: KarteAblaufRing,
|
||||
},
|
||||
"category-donut": {
|
||||
titel: "Kategorien-Anteile", icon: "tag", standard: { w: 4, h: 3 }, min: { w: 3, h: 3 },
|
||||
komponente: KarteKategorienRing,
|
||||
},
|
||||
"category-bars": {
|
||||
titel: "Kategorien nach Zustand", icon: "tag", standard: { w: 8, h: 3 }, min: { w: 4, h: 2 },
|
||||
komponente: KarteKategorienSaeulen,
|
||||
},
|
||||
"stock-timeline": {
|
||||
titel: "Bestandsverlauf", icon: "history", standard: { w: 8, h: 3 }, min: { w: 4, h: 2 },
|
||||
komponente: KarteBestandsverlauf,
|
||||
},
|
||||
"product-timeline": {
|
||||
titel: "Verlauf eines Artikels", icon: "package", standard: { w: 8, h: 3 }, min: { w: 4, h: 3 },
|
||||
komponente: KarteArtikelverlauf,
|
||||
},
|
||||
"activity-timeline": {
|
||||
titel: "Ein- und Auslagerungen", icon: "history", standard: { w: 8, h: 3 }, min: { w: 4, h: 2 },
|
||||
komponente: KarteBewegungsverlauf,
|
||||
},
|
||||
};
|
||||
|
||||
export const KARTEN_IDS = Object.keys(KARTEN);
|
||||
Reference in New Issue
Block a user