("/historie?limit=15"),
+ ])
+ .then(([b, p, a, k, m, q, h]) => {
+ setBereitschaft(b);
+ setPrueftermine(p);
+ setAblaufdaten(a);
+ setKennzahlen(k);
+ setMaengel(m);
+ setQualifikationsablaeufe(q);
+ setAktivitaet(h);
+ setFehler(null);
+ })
+ .catch(() => setFehler("Dashboard-Daten konnten nicht geladen werden."));
+ }, []);
+
+ // Status-Monitor-Anwendungsfall (Dashboard läuft dauerhaft auf einem Bildschirm) -
+ // periodisch neu laden statt nur einmalig beim Öffnen.
+ useEffect(() => {
+ ladeDaten().finally(() => setLaedt(false));
+ const intervall = setInterval(ladeDaten, AKTUALISIERUNGS_INTERVALL_MS);
+ return () => clearInterval(intervall);
+ }, [ladeDaten]);
+
+ useEffect(() => {
+ function fullscreenGeaendert() {
+ if (!document.fullscreenElement) setMonitorModus(false);
+ }
+ document.addEventListener("fullscreenchange", fullscreenGeaendert);
+ return () => document.removeEventListener("fullscreenchange", fullscreenGeaendert);
+ }, []);
+
+ function tileVerschieben(id: DashboardTileId, richtung: -1 | 1) {
+ const index = layout.reihenfolge.indexOf(id);
+ const zielIndex = index + richtung;
+ if (zielIndex < 0 || zielIndex >= layout.reihenfolge.length) return;
+ const neueReihenfolge = [...layout.reihenfolge];
+ [neueReihenfolge[index], neueReihenfolge[zielIndex]] = [neueReihenfolge[zielIndex], neueReihenfolge[index]];
+ const neuesLayout = { ...layout, reihenfolge: neueReihenfolge };
+ setLayout(neuesLayout);
+ layoutSpeichern(neuesLayout);
+ }
+
+ function ausblendenUmschalten(id: DashboardTileId) {
+ const istAusgeblendet = layout.ausgeblendet.includes(id);
+ const neuesLayout = {
+ ...layout,
+ ausgeblendet: istAusgeblendet ? layout.ausgeblendet.filter((x) => x !== id) : [...layout.ausgeblendet, id],
+ };
+ setLayout(neuesLayout);
+ layoutSpeichern(neuesLayout);
+ }
+
+ function layoutZuruecksetzen() {
+ const neuesLayout = { reihenfolge: STANDARD_REIHENFOLGE, ausgeblendet: [] };
+ setLayout(neuesLayout);
+ layoutSpeichern(neuesLayout);
+ }
+
+ async function monitorUmschalten() {
+ if (monitorModus) {
+ if (document.fullscreenElement) await document.exitFullscreen();
+ setMonitorModus(false);
+ return;
+ }
+ setBearbeitenModus(false);
+ try {
+ await document.documentElement.requestFullscreen();
+ } catch {
+ // Fullscreen optional - Monitor-Modus funktioniert auch im Fenster
+ }
+ setMonitorModus(true);
+ }
+
+ const maengelKritischAnzahl = maengel.filter((m) => m.status !== "erledigt" && m.prioritaet === "hoch").length;
+ const maengelNeuAnzahl = maengel.filter((m) => m.status === "neu").length;
+ const pruefungenUeberfaellig = prueftermine.filter((p) => p.status === "abgelaufen").length;
+ const ablaufdatenAbgelaufen = ablaufdaten.filter((a) => a.status === "abgelaufen").length;
+
+ function tileInhalt(id: DashboardTileId) {
+ switch (id) {
+ case "aufgaben":
+ return bereitschaft ? (
+
+ ) : null;
+ case "einsatzbereitschaft":
+ return bereitschaft ? : null;
+ case "prueftermine":
+ return ;
+ case "ablaufdaten":
+ return ;
+ case "kennzahlen":
+ return kennzahlen ? : null;
+ case "maengel":
+ return (
+
+ );
+ case "qualifikation":
+ return ;
+ case "aktivitaet":
+ return ;
+ default:
+ return null;
+ }
+ }
+
+ return (
+
+
+
Dashboard
+
+ {!monitorModus && (
+
+ )}
+ {bearbeitenModus && (
+
+ )}
+
+
+
+ {fehler && {fehler}
}
+ {laedt && Lade…
}
+ {!laedt && bereitschaft && (
+
+ {layout.reihenfolge.map((id, index) => {
+ const ausgeblendet = layout.ausgeblendet.includes(id);
+ if (ausgeblendet && !bearbeitenModus) return null;
+ const inhalt = tileInhalt(id);
+ if (!inhalt) return null;
+ return (
+ 0}
+ kannRunterVerschieben={index < layout.reihenfolge.length - 1}
+ onAusblendenUmschalten={() => ausblendenUmschalten(id)}
+ onHoch={() => tileVerschieben(id, -1)}
+ onRunter={() => tileVerschieben(id, 1)}
+ >
+ {inhalt}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/dashboard/TileFrame.tsx b/frontend/src/pages/dashboard/TileFrame.tsx
new file mode 100644
index 0000000..040a885
--- /dev/null
+++ b/frontend/src/pages/dashboard/TileFrame.tsx
@@ -0,0 +1,49 @@
+import type { ReactNode } from "react";
+
+interface Props {
+ title: string;
+ vollbreite?: boolean;
+ bearbeitenAktiv: boolean;
+ ausgeblendet: boolean;
+ kannHochVerschieben: boolean;
+ kannRunterVerschieben: boolean;
+ onAusblendenUmschalten: () => void;
+ onHoch: () => void;
+ onRunter: () => void;
+ children: ReactNode;
+}
+
+export function TileFrame({
+ title,
+ vollbreite,
+ bearbeitenAktiv,
+ ausgeblendet,
+ kannHochVerschieben,
+ kannRunterVerschieben,
+ onAusblendenUmschalten,
+ onHoch,
+ onRunter,
+ children,
+}: Props) {
+ return (
+
+
+
{title}
+ {bearbeitenAktiv && (
+
+
+
+
+
+ )}
+
+ {!ausgeblendet && children}
+
+ );
+}
diff --git a/frontend/src/pages/dashboard/layout.ts b/frontend/src/pages/dashboard/layout.ts
new file mode 100644
index 0000000..7c5f080
--- /dev/null
+++ b/frontend/src/pages/dashboard/layout.ts
@@ -0,0 +1,55 @@
+export type DashboardTileId =
+ | "aufgaben"
+ | "einsatzbereitschaft"
+ | "prueftermine"
+ | "ablaufdaten"
+ | "kennzahlen"
+ | "maengel"
+ | "qualifikation"
+ | "aktivitaet";
+
+export const STANDARD_REIHENFOLGE: DashboardTileId[] = [
+ "aufgaben",
+ "einsatzbereitschaft",
+ "prueftermine",
+ "ablaufdaten",
+ "kennzahlen",
+ "maengel",
+ "qualifikation",
+ "aktivitaet",
+];
+
+export interface DashboardLayout {
+ reihenfolge: DashboardTileId[];
+ ausgeblendet: DashboardTileId[];
+}
+
+const SPEICHER_KEY = "mabea-dashboard-layout";
+
+function istGueltigeId(wert: unknown): wert is DashboardTileId {
+ return typeof wert === "string" && (STANDARD_REIHENFOLGE as string[]).includes(wert);
+}
+
+export function layoutLaden(): DashboardLayout {
+ try {
+ const roh = localStorage.getItem(SPEICHER_KEY);
+ if (!roh) return { reihenfolge: STANDARD_REIHENFOLGE, ausgeblendet: [] };
+ const geparst = JSON.parse(roh);
+ const gespeicherteReihenfolge = Array.isArray(geparst.reihenfolge)
+ ? geparst.reihenfolge.filter(istGueltigeId)
+ : [];
+ const fehlende = STANDARD_REIHENFOLGE.filter((id) => !gespeicherteReihenfolge.includes(id));
+ const ausgeblendet = Array.isArray(geparst.ausgeblendet) ? geparst.ausgeblendet.filter(istGueltigeId) : [];
+ return { reihenfolge: [...gespeicherteReihenfolge, ...fehlende], ausgeblendet };
+ } catch {
+ return { reihenfolge: STANDARD_REIHENFOLGE, ausgeblendet: [] };
+ }
+}
+
+export function layoutSpeichern(layout: DashboardLayout): void {
+ try {
+ localStorage.setItem(SPEICHER_KEY, JSON.stringify(layout));
+ } catch {
+ // Speichern optional - Layout fällt auf Standard zurück
+ }
+}
diff --git a/frontend/src/pages/dashboard/tiles/AblaufdatenTile.tsx b/frontend/src/pages/dashboard/tiles/AblaufdatenTile.tsx
new file mode 100644
index 0000000..a4048d2
--- /dev/null
+++ b/frontend/src/pages/dashboard/tiles/AblaufdatenTile.tsx
@@ -0,0 +1,28 @@
+import { Link } from "react-router-dom";
+
+import { InventoryStatus } from "../../../components/status/InventoryStatus";
+import type { Ablaufdatumeintrag } from "../types";
+
+interface Props {
+ ablaufdaten: Ablaufdatumeintrag[];
+}
+
+export function AblaufdatenTile({ ablaufdaten }: Props) {
+ if (ablaufdaten.length === 0) {
+ return Keine bald ablaufenden Chargen.
;
+ }
+
+ return (
+
+ {ablaufdaten.map((a) => (
+ -
+
+ {a.objekt_name} – {a.material_name}{" "}
+ ({a.ablaufdatum})
+
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/pages/dashboard/tiles/AktivitaetTile.tsx b/frontend/src/pages/dashboard/tiles/AktivitaetTile.tsx
new file mode 100644
index 0000000..5d6f9c6
--- /dev/null
+++ b/frontend/src/pages/dashboard/tiles/AktivitaetTile.tsx
@@ -0,0 +1,34 @@
+import { Link } from "react-router-dom";
+
+import type { HistorieEintrag } from "../../../api/types";
+import { EREIGNIS_LABEL } from "../../../components/status/ereignisText";
+
+interface Props {
+ aktivitaet: HistorieEintrag[];
+}
+
+export function AktivitaetTile({ aktivitaet }: Props) {
+ if (aktivitaet.length === 0) {
+ return Noch keine Ereignisse.
;
+ }
+
+ return (
+
+ {aktivitaet.map((h) => (
+ -
+
+ {EREIGNIS_LABEL[h.ereignistyp] ?? h.ereignistyp}
+ {h.entitaet_typ === "objekt" && (
+ <>
+ {" "}
+ #{h.entitaet_id}
+ >
+ )}
+ {h.benutzer_name && · {h.benutzer_name}}
+
+ {new Date(h.zeitpunkt).toLocaleString("de-DE")}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/pages/dashboard/tiles/AufgabenTile.tsx b/frontend/src/pages/dashboard/tiles/AufgabenTile.tsx
new file mode 100644
index 0000000..c495abd
--- /dev/null
+++ b/frontend/src/pages/dashboard/tiles/AufgabenTile.tsx
@@ -0,0 +1,58 @@
+import { Link } from "react-router-dom";
+
+import type { Einsatzbereitschaft, Kennzahlen } from "../types";
+
+interface Props {
+ kennzahlen: Kennzahlen | null;
+ maengelNeuAnzahl: number;
+ maengelKritischAnzahl: number;
+ pruefungenUeberfaellig: number;
+ ablaufdatenAbgelaufen: number;
+ bereitschaft: Einsatzbereitschaft;
+}
+
+export function AufgabenTile({
+ kennzahlen,
+ maengelNeuAnzahl,
+ maengelKritischAnzahl,
+ pruefungenUeberfaellig,
+ ablaufdatenAbgelaufen,
+ bereitschaft,
+}: Props) {
+ const aufgaben = [
+ kennzahlen && kennzahlen.anzahl_offener_fehlbestaende > 0
+ ? { text: `${kennzahlen.anzahl_offener_fehlbestaende} offene Fehlbestände`, ziel: "/fehlbestaende" }
+ : null,
+ maengelNeuAnzahl > 0
+ ? { text: `${maengelNeuAnzahl} neue Mängelmeldung${maengelNeuAnzahl === 1 ? "" : "en"}`, ziel: "/maengel" }
+ : null,
+ maengelKritischAnzahl > 0
+ ? { text: `${maengelKritischAnzahl} kritische Mängel offen`, ziel: "/maengel" }
+ : null,
+ pruefungenUeberfaellig > 0
+ ? { text: `${pruefungenUeberfaellig} Geräteprüfungen überfällig`, ziel: null }
+ : null,
+ ablaufdatenAbgelaufen > 0
+ ? { text: `${ablaufdatenAbgelaufen} Chargen abgelaufen`, ziel: null }
+ : null,
+ bereitschaft.nie_kontrolliert_gesamt > 0
+ ? { text: `${bereitschaft.nie_kontrolliert_gesamt} Objekte noch nie kontrolliert`, ziel: "/objekte" }
+ : null,
+ ].filter((a): a is { text: string; ziel: string | null } => a !== null);
+
+ if (aufgaben.length === 0) {
+ return (
+
+ ✅ Nichts Dringendes offen.
+
+ );
+ }
+
+ return (
+
+ {aufgaben.map((a, i) => (
+ - {a.ziel ? {a.text} : {a.text}}
+ ))}
+
+ );
+}
diff --git a/frontend/src/pages/dashboard/tiles/EinsatzbereitschaftTile.tsx b/frontend/src/pages/dashboard/tiles/EinsatzbereitschaftTile.tsx
new file mode 100644
index 0000000..2935cb6
--- /dev/null
+++ b/frontend/src/pages/dashboard/tiles/EinsatzbereitschaftTile.tsx
@@ -0,0 +1,74 @@
+import { useState } from "react";
+import { Link } from "react-router-dom";
+
+import { GRUND_TEXT } from "../../../components/status/gruendeText";
+import type { Readiness } from "../../../components/status/ReadinessBadge";
+import { ReadinessBadge } from "../../../components/status/ReadinessBadge";
+import type { Einsatzbereitschaft } from "../types";
+
+// Spiegelt die Fallunterscheidung in app/services/dashboard.py::einsatzbereitschaft -
+// welche Kombination von Gründen zu welcher Kategorie führt.
+function readinessVonGruenden(gruende: string[]): Readiness {
+ if (gruende.length === 1 && gruende[0] === "mindermenge_genehmigt") return "limited";
+ if (gruende.length === 1 && gruende[0] === "noch_nie_kontrolliert") return "unknown";
+ return "not-ready";
+}
+
+interface Props {
+ bereitschaft: Einsatzbereitschaft;
+}
+
+export function EinsatzbereitschaftTile({ bereitschaft }: Props) {
+ const [detailsOffen, setDetailsOffen] = useState(false);
+
+ return (
+ <>
+
+ {bereitschaft.einsatzbereit}{" "}
+
+ von {bereitschaft.gesamt} einsatzbereit
+
+
+
+
+ {bereitschaft.eingeschraenkt_einsatzbereit > 0 && (
+
+ )}
+ {bereitschaft.unbekannt > 0 && }
+ {bereitschaft.nicht_einsatzbereit > 0 && (
+
+ )}
+
+ {bereitschaft.nie_kontrolliert_gesamt > bereitschaft.unbekannt && (
+
+ Davon {bereitschaft.nie_kontrolliert_gesamt} insgesamt noch nie kontrolliert (auch bei Objekten mit weiteren
+ Problemen).
+
+ )}
+ {bereitschaft.details.length > 0 && (
+ <>
+
+ {detailsOffen && (
+
+ {bereitschaft.details.map((d) => (
+ -
+
+ {d.name} ({d.code})
+
+
+
+
+ {d.gruende.map((g) => GRUND_TEXT[g] ?? g).join(", ")}
+
+
+
+ ))}
+
+ )}
+ >
+ )}
+ >
+ );
+}
diff --git a/frontend/src/pages/dashboard/tiles/KennzahlenTile.tsx b/frontend/src/pages/dashboard/tiles/KennzahlenTile.tsx
new file mode 100644
index 0000000..a0f3744
--- /dev/null
+++ b/frontend/src/pages/dashboard/tiles/KennzahlenTile.tsx
@@ -0,0 +1,19 @@
+import type { Kennzahlen } from "../types";
+
+interface Props {
+ kennzahlen: Kennzahlen;
+}
+
+export function KennzahlenTile({ kennzahlen }: Props) {
+ return (
+
+ - Offene Fehlbestände: {kennzahlen.anzahl_offener_fehlbestaende}
+ - Genehmigte Mindermengen: {kennzahlen.anzahl_genehmigter_mindermengen}
+ -
+ Kürzlich erledigt (7 Tage): {kennzahlen.anzahl_kuerzlich_erledigter_fehlbestaende}
+
+ - Problematische Objekte: {kennzahlen.anzahl_problematischer_objekte}
+ - Objekte gesamt: {kennzahlen.anzahl_objekte_gesamt}
+
+ );
+}
diff --git a/frontend/src/pages/dashboard/tiles/MaengelTile.tsx b/frontend/src/pages/dashboard/tiles/MaengelTile.tsx
new file mode 100644
index 0000000..6eb358c
--- /dev/null
+++ b/frontend/src/pages/dashboard/tiles/MaengelTile.tsx
@@ -0,0 +1,58 @@
+import { useState } from "react";
+import { Link } from "react-router-dom";
+
+import type { Mangel } from "../../../api/types";
+
+interface Props {
+ maengel: Mangel[];
+ maengelNeuAnzahl: number;
+ maengelKritischAnzahl: number;
+}
+
+export function MaengelTile({ maengel, maengelNeuAnzahl, maengelKritischAnzahl }: Props) {
+ const [maengelOffen, setMaengelOffen] = useState(false);
+ const maengelOffenListe = maengel.filter((m) => m.status !== "erledigt");
+
+ return (
+ <>
+ {maengelOffenListe.length} offen
+ {maengelNeuAnzahl > 0 && (
+
+ {maengelNeuAnzahl} neu gemeldet
+
+ )}
+ {maengelKritischAnzahl > 0 && (
+
+ {maengelKritischAnzahl} kritisch (blockiert Einsatzbereitschaft)
+
+ )}
+ {maengelOffenListe.length > 0 && (
+ <>
+
+ {maengelOffen && (
+
+ {maengelOffenListe.map((m) => (
+ -
+
+ {m.beschreibung}
+ {m.status === "neu" && (
+
+ neu
+
+ )}
+
+
+ {m.prioritaet}
+
+
+ ))}
+
+ )}
+ >
+ )}
+ {maengelOffenListe.length === 0 && Keine offenen Mängel.
}
+ >
+ );
+}
diff --git a/frontend/src/pages/dashboard/tiles/PrueftermineTile.tsx b/frontend/src/pages/dashboard/tiles/PrueftermineTile.tsx
new file mode 100644
index 0000000..5cdcb3c
--- /dev/null
+++ b/frontend/src/pages/dashboard/tiles/PrueftermineTile.tsx
@@ -0,0 +1,28 @@
+import { Link } from "react-router-dom";
+
+import { InspectionStatus } from "../../../components/status/InspectionStatus";
+import type { Prueftermineintrag } from "../types";
+
+interface Props {
+ prueftermine: Prueftermineintrag[];
+}
+
+export function PrueftermineTile({ prueftermine }: Props) {
+ if (prueftermine.length === 0) {
+ return Keine anstehenden oder überfälligen Geräteprüfungen.
;
+ }
+
+ return (
+
+ {prueftermine.map((p, i) => (
+ -
+
+ {p.bezeichnung}{" "}
+ ({p.faelligkeitsdatum})
+
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/pages/dashboard/tiles/QualifikationTile.tsx b/frontend/src/pages/dashboard/tiles/QualifikationTile.tsx
new file mode 100644
index 0000000..4a31bfb
--- /dev/null
+++ b/frontend/src/pages/dashboard/tiles/QualifikationTile.tsx
@@ -0,0 +1,26 @@
+import type { Qualifikationsablauf } from "../types";
+
+interface Props {
+ qualifikationsablaeufe: Qualifikationsablauf[];
+}
+
+export function QualifikationTile({ qualifikationsablaeufe }: Props) {
+ if (qualifikationsablaeufe.length === 0) {
+ return Keine bald ablaufenden Qualifikationen.
;
+ }
+
+ return (
+
+ {qualifikationsablaeufe.map((q) => (
+ -
+
+ {q.benutzer_name} ({q.qualifikationstyp_name})
+
+
+ {q.gueltig_bis} ({q.verbleibende_tage} Tage)
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/pages/dashboard/types.ts b/frontend/src/pages/dashboard/types.ts
new file mode 100644
index 0000000..ef31a4a
--- /dev/null
+++ b/frontend/src/pages/dashboard/types.ts
@@ -0,0 +1,55 @@
+export interface Kennzahlen {
+ anzahl_offener_fehlbestaende: number;
+ anzahl_genehmigter_mindermengen: number;
+ anzahl_kuerzlich_erledigter_fehlbestaende: number;
+ anzahl_bevorstehender_ablaufdaten: number;
+ anzahl_problematischer_objekte: number;
+ anzahl_objekte_gesamt: number;
+}
+
+export interface Qualifikationsablauf {
+ benutzer_qualifikation_id: string;
+ benutzer_id: number;
+ benutzer_name: string;
+ qualifikationstyp_id: number;
+ qualifikationstyp_name: string;
+ gueltig_bis: string;
+ verbleibende_tage: number;
+ status: string;
+}
+
+export interface ObjektMangel {
+ objekt_id: number;
+ code: string;
+ name: string;
+ gruende: string[];
+}
+
+export interface Einsatzbereitschaft {
+ einsatzbereit: number;
+ eingeschraenkt_einsatzbereit: number;
+ unbekannt: number;
+ nicht_einsatzbereit: number;
+ gesamt: number;
+ nie_kontrolliert_gesamt: number;
+ details: ObjektMangel[];
+}
+
+export interface Prueftermineintrag {
+ typ: string;
+ objekt_id: number;
+ bezeichnung: string;
+ faelligkeitsdatum: string;
+ verbleibende_tage: number;
+ status: string;
+}
+
+export interface Ablaufdatumeintrag {
+ objekt_id: number;
+ objekt_name: string;
+ material_id: number;
+ material_name: string;
+ ablaufdatum: string;
+ verbleibende_tage: number;
+ status: string;
+}
diff --git a/frontend/src/settings/startseite.ts b/frontend/src/settings/startseite.ts
deleted file mode 100644
index 1512c2b..0000000
--- a/frontend/src/settings/startseite.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-export type StartseitePraeferenz = "auto" | "dashboard" | "objekte";
-
-const SPEICHER_KEY = "mabea-startseite";
-
-export function gespeichertePraeferenz(): StartseitePraeferenz {
- try {
- const wert = localStorage.getItem(SPEICHER_KEY);
- if (wert === "dashboard" || wert === "objekte") return wert;
- } catch {
- // localStorage evtl. blockiert - "auto" als Fallback
- }
- return "auto";
-}
-
-export function praeferenzSpeichern(praeferenz: StartseitePraeferenz): void {
- try {
- if (praeferenz === "auto") localStorage.removeItem(SPEICHER_KEY);
- else localStorage.setItem(SPEICHER_KEY, praeferenz);
- } catch {
- // Speichern optional
- }
-}
-
-/** "auto" = bisheriges Verhalten (Verantwortliche -> Dashboard, sonst Objekte). */
-export function startseitenPfad(istVerantwortlich: boolean): string {
- const praeferenz = gespeichertePraeferenz();
- if (praeferenz === "dashboard" && istVerantwortlich) return "/dashboard";
- if (praeferenz === "objekte") return "/objekte";
- return istVerantwortlich ? "/dashboard" : "/objekte";
-}