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:
56
web/src/dashboard/charts/Bars.jsx
Normal file
56
web/src/dashboard/charts/Bars.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
web/src/dashboard/charts/Donut.jsx
Normal file
83
web/src/dashboard/charts/Donut.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
web/src/dashboard/charts/Line.jsx
Normal file
139
web/src/dashboard/charts/Line.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user