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

94
web/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-grid-layout": "^1.5.3",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
@@ -1337,6 +1338,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1421,6 +1431,12 @@
"node": ">=6"
}
},
"node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1554,6 +1570,15 @@
"node": ">=18"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1603,6 +1628,17 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1628,6 +1664,44 @@
"react": "^18.3.1"
}
},
"node_modules/react-draggable": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.0.tgz",
"integrity": "sha512-kTpANmKWVnFXiZ76Ag2ZowiFStuBYnJ606PI1TbUsOg29/400/JNIxI9+CuenhiAqFuXWJffz6F4UI3R51kUug==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-grid-layout": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.3.tgz",
"integrity": "sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"fast-equals": "^4.0.3",
"prop-types": "^15.8.1",
"react-draggable": "^4.4.6",
"react-resizable": "^3.0.5",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -1638,6 +1712,20 @@
"node": ">=0.10.0"
}
},
"node_modules/react-resizable": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz",
"integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==",
"license": "MIT",
"dependencies": {
"prop-types": "15.x",
"react-draggable": "^4.5.0"
},
"peerDependencies": {
"react": ">= 16.3",
"react-dom": ">= 16.3"
}
},
"node_modules/react-router": {
"version": "6.30.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
@@ -1670,6 +1758,12 @@
"react-dom": ">=16.8"
}
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",

View File

@@ -11,6 +11,7 @@
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-grid-layout": "^1.5.3",
"react-router-dom": "^6.28.0"
},
"devDependencies": {

View File

@@ -140,6 +140,21 @@ export const api = {
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
listMovements: (limit) => request(`/movements${limit ? `?limit=${limit}` : ""}`),
// Startseite: Anordnung und Auswertungen
dashboardLayout: () => request("/dashboard/layout"),
saveDashboardLayout: (layout) => request("/dashboard/layout", { method: "PUT", body: { layout } }),
resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }),
saveDashboardDefault: (layout) =>
request("/dashboard/layout/default", { method: "PUT", body: { layout } }),
setDashboardEnforced: (value) =>
request(`/dashboard/layout/enforced?value=${value ? "true" : "false"}`, { method: "PUT" }),
dashboardStats: () => request("/dashboard/stats"),
dashboardExpirySplit: () => request("/dashboard/expiry-split"),
dashboardByCategory: () => request("/dashboard/by-category"),
dashboardTimeline: (days, productId) =>
request(`/dashboard/timeline?days=${days}${productId ? `&product_id=${productId}` : ""}`),
dashboardActivity: (days) => request(`/dashboard/activity?days=${days}`),
// Stammdaten
listLocations: () => request("/locations"),
createLocation: (body) => request("/locations", { method: "POST", body }),

410
web/src/dashboard/cards.jsx Normal file
View 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);

View File

@@ -0,0 +1,56 @@
import { useState } from "react";
import { fmt } from "../../units";
/**
* Gestapelte waagerechte Säulen (Balken).
*
* Waagerecht, weil die Beschriftungen Kategorienamen sind die brauchen Platz
* und wären senkrecht gedreht kaum lesbar. Zwischen gestapelten Abschnitten
* bleibt eine Lücke in Hintergrundfarbe, damit angrenzende Flächen sich trennen.
*
* zeilen: [{ name, teile: [{ key, value, color, label }] }]
*/
export default function Bars({ zeilen = [], einheit = "" }) {
const [aktiv, setAktiv] = useState(null);
const summen = zeilen.map((z) => z.teile.reduce((s, t) => s + t.value, 0));
const groesste = Math.max(...summen, 0);
if (!zeilen.length || groesste <= 0) {
return <div className="empty">Keine Daten vorhanden.</div>;
}
return (
<div className="chart-bars">
{zeilen.map((zeile, i) => {
const summe = summen[i];
return (
<div className="bar-row" key={zeile.name}>
<div className="bar-name" title={zeile.name}>{zeile.name}</div>
<div className="bar-track">
{zeile.teile.filter((t) => t.value > 0).map((teil) => (
<div
key={teil.key}
className="bar-part"
style={{
width: `${(teil.value / groesste) * 100}%`,
background: teil.color,
}}
onMouseEnter={() => setAktiv({ zeile: zeile.name, ...teil })}
onMouseLeave={() => setAktiv(null)}
/>
))}
</div>
<div className="bar-total">{fmt(summe)}</div>
</div>
);
})}
{aktiv && (
<div className="chart-hint">
<strong>{aktiv.zeile}</strong> · {aktiv.label}: {fmt(aktiv.value)} {einheit}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useState } from "react";
import { fmt } from "../../units";
/**
* Ringdiagramm für Anteile.
*
* Ring statt Vollkreis: In der Mitte steht die Gesamtsumme, die man sonst
* nirgends ablesen könnte. Zwischen den Segmenten liegt eine schmale Lücke in
* Hintergrundfarbe so bleiben benachbarte Flächen auch dann unterscheidbar,
* wenn ihre Farben ähnlich wirken.
*
* segmente: [{ name, value, color }]
*/
export default function Donut({ segmente = [], gesamtLabel = "gesamt", einheit = "" }) {
const [aktiv, setAktiv] = useState(null);
const werte = segmente.filter((s) => s.value > 0);
const summe = werte.reduce((s, e) => s + e.value, 0);
if (summe <= 0) {
return <div className="empty">Keine Daten vorhanden.</div>;
}
const groesse = 180;
const mitte = groesse / 2;
const radius = 74;
const dicke = 26;
const umfang = 2 * Math.PI * radius;
const luecke = 2; // Pixel Abstand zwischen den Segmenten
let versatz = 0;
const boegen = werte.map((s) => {
const anteil = s.value / summe;
const laenge = Math.max(anteil * umfang - luecke, 0.5);
const bogen = { ...s, laenge, versatz, anteil };
versatz += anteil * umfang;
return bogen;
});
const gezeigt = aktiv ?? null;
return (
<div className="chart-donut">
<svg viewBox={`0 0 ${groesse} ${groesse}`} className="donut-svg" role="img"
aria-label={`Ringdiagramm, ${werte.length} Anteile`}>
<g transform={`rotate(-90 ${mitte} ${mitte})`}>
{boegen.map((b) => (
<circle key={b.name}
cx={mitte} cy={mitte} r={radius}
fill="none"
stroke={b.color}
strokeWidth={gezeigt?.name === b.name ? dicke + 4 : dicke}
strokeDasharray={`${b.laenge} ${umfang - b.laenge}`}
strokeDashoffset={-b.versatz}
onMouseEnter={() => setAktiv(b)}
onMouseLeave={() => setAktiv(null)}
/>
))}
</g>
<text x={mitte} y={mitte - 4} textAnchor="middle" className="donut-value">
{fmt(gezeigt ? gezeigt.value : summe)}
</text>
<text x={mitte} y={mitte + 14} textAnchor="middle" className="donut-label">
{gezeigt ? gezeigt.name : `${gesamtLabel}${einheit ? ` (${einheit})` : ""}`}
</text>
</svg>
<ul className="chart-legend">
{boegen.map((b) => (
<li key={b.name}
onMouseEnter={() => setAktiv(b)}
onMouseLeave={() => setAktiv(null)}>
<span className="swatch" style={{ background: b.color }} />
<span className="legend-name">{b.name}</span>
<span className="legend-value">
{fmt(b.value)} · {Math.round(b.anteil * 100)} %
</span>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { useEffect, useRef, useState } from "react";
import { fmt } from "../../units";
import { tokenFarbe } from "../palette";
/** Misst die tatsächliche Breite, damit das SVG in echten Pixeln zeichnet.
* Ein festes viewBox mit Skalierung würde Linienstärken verzerren. */
function useBreite(ref) {
const [breite, setBreite] = useState(0);
useEffect(() => {
if (!ref.current) return undefined;
const beobachter = new ResizeObserver(([eintrag]) => {
setBreite(eintrag.contentRect.width);
});
beobachter.observe(ref.current);
return () => beobachter.disconnect();
}, [ref]);
return breite;
}
/**
* Liniendiagramm über die Zeit.
*
* Bewusst nur EINE Werteachse: Zwei Skalen in einem Bild lassen sich beliebig
* gegeneinander verschieben und suggerieren Zusammenhänge, die es nicht gibt.
* Serien mit unterschiedlichen Größenordnungen gehören in getrennte Karten.
*
* labels: ["01.07.", …] gemeinsame X-Achse
* serien: [{ name, color, werte: number[] }]
*/
export default function Line({ labels = [], serien = [], einheit = "", hoehe = 200 }) {
const wirt = useRef(null);
const breite = useBreite(wirt);
const [index, setIndex] = useState(null);
const hatDaten = labels.length > 1 && serien.some((s) => s.werte?.length);
if (!hatDaten) {
return <div className="chart-line" ref={wirt}><div className="empty">Keine Daten vorhanden.</div></div>;
}
const rand = { oben: 12, rechts: 12, unten: 24, links: 44 };
const w = Math.max(breite, 240);
const h = hoehe;
const flaecheB = Math.max(w - rand.links - rand.rechts, 10);
const flaecheH = Math.max(h - rand.oben - rand.unten, 10);
const alleWerte = serien.flatMap((s) => s.werte);
const maxWert = Math.max(...alleWerte, 1);
// Von null aus: Bei Mengen ist eine abgeschnittene Achse irreführend.
const skalaMax = maxWert * 1.1;
const x = (i) => rand.links + (labels.length === 1 ? 0 : (i / (labels.length - 1)) * flaecheB);
const y = (wert) => rand.oben + flaecheH - (wert / skalaMax) * flaecheH;
const gitter = [0, 0.5, 1].map((teil) => ({
wert: skalaMax * teil,
y: y(skalaMax * teil),
}));
// Nur wenige Datumsbeschriftungen, sonst überlappen sie.
const schritt = Math.max(1, Math.round(labels.length / 5));
const achse = labels.map((l, i) => ({ l, i })).filter(({ i }) => i % schritt === 0);
const gitterFarbe = tokenFarbe("--border", "#ddd");
const textFarbe = tokenFarbe("--muted", "#888");
function bewegen(event) {
const box = event.currentTarget.getBoundingClientRect();
const rel = event.clientX - box.left - rand.links;
const i = Math.round((rel / flaecheB) * (labels.length - 1));
setIndex(Math.min(Math.max(i, 0), labels.length - 1));
}
return (
<div className="chart-line" ref={wirt}>
{serien.length > 1 && (
<ul className="chart-legend inline">
{serien.map((s) => (
<li key={s.name}>
<span className="swatch" style={{ background: s.color }} />
<span className="legend-name">{s.name}</span>
</li>
))}
</ul>
)}
<div className="line-plot">
<svg width={w} height={h} role="img"
aria-label={`Liniendiagramm über ${labels.length} Tage`}
onMouseMove={bewegen} onMouseLeave={() => setIndex(null)}>
{gitter.map((g) => (
<g key={g.wert}>
<line x1={rand.links} x2={w - rand.rechts} y1={g.y} y2={g.y}
stroke={gitterFarbe} strokeWidth="1" />
<text x={rand.links - 8} y={g.y + 4} textAnchor="end"
fontSize="11" fill={textFarbe}>{fmt(g.wert)}</text>
</g>
))}
{achse.map(({ l, i }) => (
<text key={l + i} x={x(i)} y={h - 6} textAnchor="middle"
fontSize="11" fill={textFarbe}>{l}</text>
))}
{serien.map((s) => (
<path key={s.name} fill="none" stroke={s.color} strokeWidth="2"
strokeLinejoin="round" strokeLinecap="round"
d={s.werte.map((wert, i) => `${i === 0 ? "M" : "L"} ${x(i)} ${y(wert)}`).join(" ")} />
))}
{index != null && (
<>
<line x1={x(index)} x2={x(index)} y1={rand.oben} y2={rand.oben + flaecheH}
stroke={gitterFarbe} strokeWidth="1" />
{serien.map((s) => (
<circle key={s.name} cx={x(index)} cy={y(s.werte[index] ?? 0)} r="4.5"
fill={s.color} stroke={tokenFarbe("--surface", "#fff")} strokeWidth="2" />
))}
</>
)}
</svg>
{index != null && (
<div className="line-tooltip"
style={{ left: Math.min(Math.max(x(index), 60), w - 60) }}>
<div className="tip-date">{labels[index]}</div>
{serien.map((s) => (
<div key={s.name} className="tip-row">
<span className="swatch" style={{ background: s.color }} />
{serien.length > 1 && <span className="tip-name">{s.name}</span>}
<strong>{fmt(s.werte[index] ?? 0)}</strong>
{einheit && <span className="muted small"> {einheit}</span>}
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
/**
* Farben für die Diagramme.
*
* Zwei getrennte Aufgaben, zwei getrennte Paletten das ist keine Kosmetik:
*
* 1. ZUSTAND (ok / bald ablaufend / abgelaufen) ist eine *Statusangabe*. Dafür
* gelten die Tokens der Anwendung (--ok, --warn, --danger). Statusfarben
* werden nie als Serienfarben zweckentfremdet und stehen nie allein: In der
* Legende steht immer auch der Text.
*
* 2. KATEGORIEN sind *Identität*. Dafür eine feste Reihenfolge geprüfter Farben.
* Die Reihenfolge ist Teil der Sicherheit gegen Farbfehlsichtigkeit sie
* wird nicht umsortiert und nicht im Kreis wiederholt. Über sechs Kategorien
* hinaus wird zu „Andere“ gebündelt, statt weitere Farben zu erfinden.
*
* Dunkel ist kein Umdrehen der Hellwerte, sondern eigene Stufen für den dunklen
* Untergrund.
*/
// Feste Reihenfolge. Höchstens SERIES_MAX Einträge werden eingefärbt.
export const CATEGORY_COLORS_LIGHT = [
"#2a78d6", // blau
"#eb6834", // orange
"#1baf7a", // aqua
"#eda100", // gelb
"#e87ba4", // magenta
"#008300", // grün
];
export const CATEGORY_COLORS_DARK = [
"#3987e5",
"#d95926",
"#199e70",
"#c98500",
"#d55181",
"#008300",
];
/** Ab hier wird gebündelt „Andere“ bekommt einen neutralen Grauton. */
export const SERIES_MAX = 6;
export const OTHER_COLOR_LIGHT = "#8a8f98";
export const OTHER_COLOR_DARK = "#6f7783";
/** Erkennt das aktive Farbschema (Systemeinstellung + eigener Umschalter). */
export function istDunkel() {
if (typeof document === "undefined") return false;
const gesetzt = document.documentElement.getAttribute("data-theme");
if (gesetzt === "dark") return true;
if (gesetzt === "light") return false;
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
}
export function kategorieFarben(dunkel) {
return dunkel ? CATEGORY_COLORS_DARK : CATEGORY_COLORS_LIGHT;
}
export function andereFarbe(dunkel) {
return dunkel ? OTHER_COLOR_DARK : OTHER_COLOR_LIGHT;
}
/**
* Bündelt eine sortierte Liste auf SERIES_MAX Einträge plus „Andere“ und
* vergibt die Farben in fester Reihenfolge.
* Erwartet Objekte mit { name, value }.
*/
export function mitFarben(eintraege, dunkel) {
const farben = kategorieFarben(dunkel);
const sortiert = [...eintraege].sort((a, b) => b.value - a.value);
const vorne = sortiert.slice(0, SERIES_MAX).map((e, i) => ({ ...e, color: farben[i] }));
const rest = sortiert.slice(SERIES_MAX);
if (rest.length === 0) return vorne;
return [
...vorne,
{
name: `Andere (${rest.length})`,
value: rest.reduce((s, e) => s + e.value, 0),
color: andereFarbe(dunkel),
},
];
}
/** Zustandsfarben kommen aus den CSS-Tokens, damit Hell/Dunkel automatisch passt. */
export const STATUS_SERIES = [
{ key: "ok", name: "In Ordnung", cssVar: "--ok" },
{ key: "soon", name: "Bald ablaufend", cssVar: "--warn" },
{ key: "expired", name: "Abgelaufen", cssVar: "--danger" },
{ key: "no_date", name: "Ohne MHD", cssVar: "--muted" },
];
/** Liest einen CSS-Tokenwert aus die Diagramme zeichnen in echtem SVG. */
export function tokenFarbe(name, fallback = "#888") {
if (typeof document === "undefined") return fallback;
const wert = getComputedStyle(document.documentElement).getPropertyValue(name);
return wert?.trim() || fallback;
}

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>
);

View File

@@ -466,6 +466,130 @@ td select { width: auto; min-width: 0; max-width: 100%; }
.stat .stat-label { font-size: 0.75rem; color: var(--muted); font-weight: 560; display: flex; align-items: center; gap: 6px; }
.stat .stat-value { font-size: 1.6rem; font-weight: 680; letter-spacing: -0.02em; margin-top: 6px; }
/* ---------- Startseite: Raster ----------
react-grid-layout bringt eigenes CSS mit. Statt es einzubinden, setzen wir
die wenigen noetigen Regeln selbst - so sehen die Karten aus wie alle
anderen und die Anfasser folgen unseren Tokens. */
.dash-grid { position: relative; }
.react-grid-item { transition: transform 160ms ease, width 160ms ease, height 160ms ease; }
.react-grid-item.react-draggable-dragging,
.react-grid-item.resizing { transition: none; z-index: 5; }
/* Platzhalter, der beim Ziehen anzeigt, wo die Karte landet. */
.react-grid-placeholder {
background: var(--accent-soft);
border: 1px dashed var(--accent);
border-radius: var(--radius);
opacity: 0.8;
}
.dash-card {
margin-bottom: 0; /* Abstand macht das Raster */
display: flex; flex-direction: column;
overflow: hidden; padding: 0;
}
.dash-card-head {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.dash-card-head h2 { margin: 0; flex: 1; font-size: 0.9rem; }
.dash-card-head .icon { color: var(--muted); }
.dash-card-body { flex: 1; min-height: 0; overflow: auto; padding: var(--sp-4); }
/* Anfasser nur im Bearbeitungsmodus - sonst stoert er beim Lesen. */
.dash-grid .react-resizable-handle { display: none; }
.dash-grid.editing .dash-card-head { cursor: grab; }
.dash-grid.editing .dash-card { outline: 1px dashed var(--border-strong); }
.dash-grid.editing .react-resizable-handle {
display: block; position: absolute; right: 3px; bottom: 3px;
width: 14px; height: 14px; cursor: se-resize;
border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted);
border-bottom-right-radius: 3px; opacity: 0.7;
}
.dash-foot {
display: flex; align-items: center; gap: var(--sp-3);
flex-wrap: wrap; margin-top: var(--sp-5);
padding-top: var(--sp-4); border-top: 1px solid var(--border);
}
.enforce-toggle {
display: flex; align-items: center; gap: var(--sp-2);
margin: 0; font-size: 0.85rem; color: var(--muted); font-weight: 500;
}
.enforce-toggle input { width: auto; margin: 0; accent-color: var(--accent); }
/* Menue "Karte hinzufuegen" */
.menu-wrap { position: relative; }
.menu {
position: absolute; right: 0; top: calc(100% + 6px); z-index: 30;
list-style: none; margin: 0; padding: var(--sp-2);
min-width: 240px; max-height: 320px; overflow: auto;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: 0 6px 20px rgba(16, 24, 40, 0.14);
}
.menu li { padding: 0; }
.menu .link-btn {
display: flex; align-items: center; gap: var(--sp-2);
width: 100%; padding: 7px var(--sp-2); border-radius: var(--radius-sm);
color: var(--text);
}
.menu .link-btn:hover { background: var(--surface-2); text-decoration: none; }
/* ---------- Startseite: Kartentypen ---------- */
.card-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
.card-stack { display: flex; flex-direction: column; gap: var(--sp-3); height: 100%; }
.kpi-row { display: flex; gap: var(--sp-4); flex-wrap: wrap; }
.kpi { flex: 1 1 120px; min-width: 0; }
.kpi-label {
display: flex; align-items: center; gap: 6px;
font-size: 0.75rem; color: var(--muted); font-weight: 560;
}
.kpi-value { font-size: 1.7rem; font-weight: 680; letter-spacing: -0.02em; margin-top: 2px; }
.kpi.warn .kpi-value { color: var(--warn); }
.kpi.danger .kpi-value { color: var(--danger); }
/* ---------- Diagramme ---------- */
.chart-donut { display: flex; align-items: center; gap: var(--sp-4); flex-wrap: wrap; }
.donut-svg { width: 160px; height: 160px; flex: 0 0 auto; }
.donut-svg circle { transition: stroke-width 120ms ease; cursor: default; }
.donut-value { font-size: 20px; font-weight: 680; fill: var(--text); }
.donut-label { font-size: 10px; fill: var(--muted); }
.chart-legend { list-style: none; margin: 0; padding: 0; flex: 1; min-width: 140px; }
.chart-legend li {
display: flex; align-items: center; gap: var(--sp-2);
padding: 3px 0; font-size: 0.82rem;
}
.chart-legend.inline { display: flex; flex-wrap: wrap; gap: var(--sp-3); margin-bottom: var(--sp-3); }
.chart-legend.inline li { padding: 0; }
.chart-legend .legend-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chart-legend .legend-value { color: var(--muted); font-size: 0.78rem; white-space: nowrap; }
.swatch { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
.chart-bars { display: flex; flex-direction: column; gap: 7px; }
.bar-row { display: grid; grid-template-columns: minmax(80px, 26%) 1fr auto; align-items: center; gap: var(--sp-3); }
.bar-name { font-size: 0.82rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-track { display: flex; height: 16px; border-radius: 4px; overflow: hidden; background: var(--surface-2); }
/* 2px Luecke in Flaechenfarbe: trennt angrenzende Abschnitte zuverlaessig. */
.bar-part + .bar-part { border-left: 2px solid var(--surface); }
.bar-total { font-size: 0.8rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.chart-hint { font-size: 0.8rem; color: var(--muted); margin-top: var(--sp-2); }
.chart-line { width: 100%; }
.line-plot { position: relative; }
.line-plot svg { display: block; }
.line-tooltip {
position: absolute; top: 4px; transform: translateX(-50%);
background: var(--surface); border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); padding: 6px 9px; pointer-events: none;
box-shadow: 0 4px 12px rgba(16, 24, 40, 0.12); font-size: 0.8rem; white-space: nowrap;
}
.line-tooltip .tip-date { color: var(--muted); font-size: 0.72rem; margin-bottom: 3px; }
.line-tooltip .tip-row { display: flex; align-items: center; gap: 6px; }
.line-tooltip .tip-name { color: var(--muted); }
/* ---------- Login ---------- */
.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4); }
.login-card { width: 100%; max-width: 360px; }