Modulare Startseite: Kartenraster, eigene Anordnung je Benutzer, Auswertungen

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

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

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

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

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

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

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

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

View File

@@ -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;
}