feat(dashboard): Dashboard-Start für Verantwortliche, Kachel-Anpassung, Status-Monitor
CI / backend-tests (push) Failing after 2m5s
CI / frontend-build (push) Successful in 19s

Dashboard immer Startseite für Verantwortliche (Mitarbeiter -> Objekte), kaputte Startseiten-Auswahl entfernt. Neu gemeldete Mängel als eigener Hinweis. Kacheln per Anpassen-Modus verschieb-/ausblendbar (localStorage), Status-Monitor-Modus mit Vollbild und Auto-Refresh (60s). DashboardPage dabei in kleine Dateien pro Kachel gesplittet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-07 12:02:47 +02:00
co-authored by Claude Sonnet 5
parent 9b2701e220
commit 5fc8079df6
21 changed files with 3020 additions and 415 deletions
+1923
View File
File diff suppressed because it is too large Load Diff
+128
View File
@@ -260,3 +260,131 @@ Keine Commits in dieser Session.
- frontend/src/pages/admin/ObjektSection.tsx | 94 ++++++++++++++++++++------------------ - frontend/src/pages/admin/ObjektSection.tsx | 94 ++++++++++++++++++++------------------
--- ---
## 2026-09-06 21:46 21:49 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- arbeitskacheln/00_index.md | 2 +-
- arbeitskacheln/20_ui_redesign.md | 9 ++++++++-
- frontend/src/pages/FehlbestandListePage.tsx | 12 +++++++++++-
- frontend/src/pages/MangelListePage.tsx | 13 +++++++++++--
- frontend/src/pages/admin/BeladungsplanerBoard.tsx | 4 ++--
- frontend/src/pages/admin/GeraeteInstanzenListe.tsx | 9 ++++++++-
- frontend/src/pages/kontrolle/NachfuellDialog.tsx | 2 +-
---
## 2026-09-06 21:58 21:59 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- arbeitskacheln/00_index.md | 2 +-
- arbeitskacheln/20_ui_redesign.md | 9 ++++++++-
- frontend/src/pages/FehlbestandListePage.tsx | 12 +++++++++++-
- frontend/src/pages/MangelListePage.tsx | 13 +++++++++++--
- frontend/src/pages/admin/BeladungsplanerBoard.tsx | 4 ++--
- frontend/src/pages/admin/GeraeteInstanzenListe.tsx | 9 ++++++++-
- frontend/src/pages/kontrolle/NachfuellDialog.tsx | 2 +-
---
## 2026-09-06 22:23 22:26 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- arbeitskacheln/20_ui_redesign.md | 4 ++++
- frontend/src/components/status/ereignisText.ts | 21 +++++++++++++++++++++
- frontend/src/pages/AktePage.tsx | 3 ++-
- frontend/src/pages/DashboardPage.tsx | 33 +++++++++++++++++++++++++++++++--
---
## 2026-09-06 22:41 22:43 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/services/dashboard.py | 17 ++++++-----------
- backend/app/services/kontrolle/erfassung.py | 50 ++++++++++++++++++++++++++++++++++++++++++--------
- backend/tests/test_dashboard.py | 3 ---
- backend/tests/test_kontrolle.py | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/api/client.ts | 2 +-
- frontend/src/components/CommandPalette.tsx | 2 +-
- frontend/src/components/ThemeToggle.tsx | 2 +-
- frontend/src/components/status/FristBadge.tsx | 2 +-
- frontend/src/components/status/InspectionStatus.tsx | 2 +-
- frontend/src/components/status/InventoryStatus.tsx | 2 +-
- frontend/src/components/status/ReadinessBadge.tsx | 2 +-
- frontend/src/components/status/SyncStatus.tsx | 2 +-
- frontend/src/pages/AdminPage.tsx | 2 +-
- frontend/src/pages/AktePage.tsx | 2 +-
- frontend/src/pages/DashboardPage.tsx | 4 ----
- frontend/src/pages/FehlbestandListePage.tsx | 2 +-
- frontend/src/pages/MangelListePage.tsx | 4 ++--
- frontend/src/pages/ObjektListPage.tsx | 8 +++-----
- frontend/src/pages/admin/GeraeteInstanzenListe.tsx | 2 +-
- frontend/src/pages/kontrolle/PositionCard.tsx | 2 +-
---
## 2026-09-06 22:55 23:06 (11m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/pages/AktePage.tsx | 37 ++++++++++++++++++++++++++++++-------
- frontend/src/pages/DashboardPage.tsx | 6 ++++--
---
## 2026-09-06 23:28 23:30 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/pages/AktePage.tsx | 32 +++++++++++++++++---------------
---
## 2026-09-06 23:34 23:37 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/services/akte.py | 17 ++++++++++++++---
- backend/tests/test_akte.py | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-07 00:04 00:06 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/schemas/akte.py | 3 ++-
- backend/app/services/akte.py | 1 +
- backend/tests/test_akte.py | 1 +
- frontend/src/api/types.ts | 1 +
- frontend/src/pages/AktePage.tsx | 11 +++++++++++
---
+267
View File
@@ -284,3 +284,270 @@ Keine Commits in dieser Session.
- frontend/src/pages/admin/ObjektSection.tsx | 7 + - frontend/src/pages/admin/ObjektSection.tsx | 7 +
--- ---
## 2026-09-06 09:54 09:55 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- arbeitskacheln/15_mobile.md | 6 ++----
- frontend/src/pages/ObjektListPage.tsx | 9 +++++----
- frontend/src/pages/admin/LagerSection.tsx | 47 ++++++++++++++++++++++++++++++++++++++++++++---
---
## 2026-09-06 13:30 13:32 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- 79ddb37 feat(ui): UI-003 zentrale Statuskomponenten + SyncStatus-Lücke geschlossen
### Geänderte Dateien
- arbeitskacheln/20_ui_redesign.md | 13 ++++++++++++-
- frontend/src/components/AppShell.tsx | 2 ++
- frontend/src/components/status/FristBadge.tsx | 31 +++++++++++++++++++++++++++++++
- frontend/src/components/status/InspectionStatus.tsx | 6 ++++++
- frontend/src/components/status/InventoryStatus.tsx | 6 ++++++
- frontend/src/components/status/ReadinessBadge.tsx | 40 ++++++++++++++++++++++++++++++++++++++++
- frontend/src/components/status/SyncStatus.tsx | 44 ++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/DashboardPage.tsx | 61 ++++++++++++++++++++++++++++++++++++-------------------------
---
## 2026-09-06 13:41 13:46 (4m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- docs/BENUTZERHANDBUCH.md | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-06 21:37 21:38 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- arbeitskacheln/20_ui_redesign.md | 15 ++++++-
- backend/app/schemas/akte.py | 2 +
- backend/app/services/akte.py | 5 +++
- backend/app/services/dashboard.py | 175 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
- backend/tests/test_akte.py | 15 +++++++
- frontend/src/api/client.ts | 18 ++++++++
- frontend/src/api/types.ts | 2 +
- frontend/src/components/DokumentePanel.tsx | 26 ++++++++---
- frontend/src/components/status/gruendeText.ts | 13 ++++++
- frontend/src/pages/AktePage.tsx | 27 ++++++-----
- frontend/src/pages/DashboardPage.tsx | 13 +-----
---
## 2026-09-06 21:39 21:41 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- arbeitskacheln/20_ui_redesign.md | 9 ++++++++-
- frontend/src/pages/KontrollPage.tsx | 4 ++--
- frontend/src/pages/kontrolle/PositionCard.tsx | 16 +++++++++++++---
---
## 2026-09-06 21:42 21:44 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- arbeitskacheln/20_ui_redesign.md | 8 +++++++-
- frontend/src/pages/AktePage.tsx | 17 +++++++++++------
- frontend/src/pages/MangelListePage.tsx | 9 ++++++++-
---
## 2026-09-06 21:59 22:03 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
- 65203a0 fix(dashboard): "nie kontrolliert" ging bei zusätzlichen Gründen verloren
### Geänderte Dateien
- backend/app/schemas/dashboard.py | 1 +
- backend/app/services/dashboard.py | 22 ++++++++++++++++++++--
- backend/tests/test_dashboard.py | 13 ++++++++++---
- frontend/src/pages/DashboardPage.tsx | 11 +++++++++--
---
## 2026-09-06 22:48 22:50 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/services/dashboard.py | 17 ++++++-----------
- backend/app/services/kontrolle/erfassung.py | 50 ++++++++++++++++++++++++++++++++++++++++++--------
- backend/tests/test_dashboard.py | 3 ---
- backend/tests/test_kontrolle.py | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/api/client.ts | 2 +-
- frontend/src/components/CommandPalette.tsx | 2 +-
- frontend/src/components/ThemeToggle.tsx | 2 +-
- frontend/src/components/status/FristBadge.tsx | 2 +-
- frontend/src/components/status/InspectionStatus.tsx | 2 +-
- frontend/src/components/status/InventoryStatus.tsx | 2 +-
- frontend/src/components/status/ReadinessBadge.tsx | 2 +-
- frontend/src/components/status/SyncStatus.tsx | 2 +-
- frontend/src/pages/AdminPage.tsx | 2 +-
- frontend/src/pages/AktePage.tsx | 2 +-
- frontend/src/pages/DashboardPage.tsx | 4 ----
- frontend/src/pages/FehlbestandListePage.tsx | 2 +-
- frontend/src/pages/MangelListePage.tsx | 4 ++--
- frontend/src/pages/ObjektListPage.tsx | 8 +++-----
- frontend/src/pages/admin/GeraeteInstanzenListe.tsx | 2 +-
- frontend/src/pages/kontrolle/PositionCard.tsx | 2 +-
---
## 2026-09-06 23:20 23:24 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- arbeitskacheln/10_maintenance.md | 40 ++++++++-----
- arbeitskacheln/21_akte_redesign.md | 39 +++++++++++++
- backend/alembic/versions/0022_wartung.py | 82 ++++++++++++++++++++++++++
- backend/app/api/v1/api.py | 2 +
- backend/app/api/v1/endpoints/wartung.py | 153 +++++++++++++++++++++++++++++++++++++++++++++++++
- backend/app/models/__init__.py | 14 +++++
- backend/app/models/wartung.py | 80 ++++++++++++++++++++++++++
- backend/app/schemas/akte.py | 13 +++++
- backend/app/schemas/dokument.py | 2 +-
- backend/app/schemas/wartung.py | 101 ++++++++++++++++++++++++++++++++
- backend/app/services/akte.py | 33 ++++++++++-
- backend/app/services/datumshilfen.py | 10 ++++
- backend/app/services/geraet_instanz.py | 13 +----
- backend/app/services/wartung.py | 216 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- backend/tests/test_akte.py | 4 ++
- backend/tests/test_wartung.py | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/api/types.ts | 62 ++++++++++++++++++++
- frontend/src/components/CommandPalette.tsx | 1 +
- frontend/src/components/WartungSection.tsx | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/AdminPage.tsx | 10 ++++
- frontend/src/pages/AktePage.tsx | 63 +++++++++++++++-----
- frontend/src/pages/admin/WartungsplanSection.tsx | 153 +++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/styles/global.css | 53 +++++++++++++++++
---
## 2026-09-06 23:46 23:47 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/services/kontrolle/erfassung.py | 14 +++++++++-----
- backend/tests/test_kontrolle.py | 4 +---
- backend/tests/test_mindermenge.py | 3 ++-
---
## 2026-09-07 00:08 00:09 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/services/vorlagen.py | 21 +++++++++++++++++++++
- backend/tests/test_vorlagen.py | 22 ++++++++++++++++++++++
---
## 2026-09-07 00:13 00:17 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/pages/admin/FachSection.tsx | 12 +++++++++---
---
## 2026-09-07 00:19 00:22 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/api/v1/endpoints/stammdaten.py | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
- backend/app/schemas/stammdaten.py | 10 ++++++++
- backend/app/services/vorlagen.py | 7 +++---
- backend/tests/test_stammdaten.py | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/admin/FachSection.tsx | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
---
## 2026-09-07 00:25 00:26 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/api/v1/endpoints/stammdaten.py | 44 +++++++++++++++++++++++++++
- backend/app/schemas/stammdaten.py | 9 ++++++
- backend/app/services/material_merge.py | 63 +++++++++++++++++++++++++++++++++++++++
- backend/tests/test_stammdaten.py | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/admin/MaterialSection.tsx | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
---
## 2026-09-07 00:32 00:35 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/schemas/dashboard.py | 2 ++
- backend/app/services/dashboard.py | 7 +++++--
- frontend/src/pages/DashboardPage.tsx | 4 +++-
---
## 2026-09-07 11:53 11:56 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/pages/kontrolle/PositionCard.tsx | 48 ++++++++++++++++++++++++++++++++++--------------
---
## 2026-09-07 11:58 12:02 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- frontend/src/pages/kontrolle/PositionCard.tsx | 48 ++++++++++++++++++++++++++++++++++--------------
---
+2 -3
View File
@@ -5,13 +5,12 @@ import { AuthProvider, useAuth } from "./auth/AuthContext";
import { AppShell } from "./components/AppShell"; import { AppShell } from "./components/AppShell";
import { AdminPage } from "./pages/AdminPage"; import { AdminPage } from "./pages/AdminPage";
import { AktePage } from "./pages/AktePage"; import { AktePage } from "./pages/AktePage";
import { DashboardPage } from "./pages/DashboardPage"; import { DashboardPage } from "./pages/dashboard/DashboardPage";
import { FehlbestandListePage } from "./pages/FehlbestandListePage"; import { FehlbestandListePage } from "./pages/FehlbestandListePage";
import { KontrollPage } from "./pages/KontrollPage"; import { KontrollPage } from "./pages/KontrollPage";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { MangelListePage } from "./pages/MangelListePage"; import { MangelListePage } from "./pages/MangelListePage";
import { ObjektListPage } from "./pages/ObjektListPage"; import { ObjektListPage } from "./pages/ObjektListPage";
import { startseitenPfad } from "./settings/startseite";
function GeschuetzteRoute({ children }: { children: ReactNode }) { function GeschuetzteRoute({ children }: { children: ReactNode }) {
const { eingeloggt } = useAuth(); const { eingeloggt } = useAuth();
@@ -24,7 +23,7 @@ function GeschuetzteRoute({ children }: { children: ReactNode }) {
function Startseite() { function Startseite() {
const { eingeloggt, istVerantwortlich } = useAuth(); const { eingeloggt, istVerantwortlich } = useAuth();
if (!eingeloggt) return <Navigate to="/login" replace />; if (!eingeloggt) return <Navigate to="/login" replace />;
return <Navigate to={startseitenPfad(istVerantwortlich)} replace />; return <Navigate to={istVerantwortlich ? "/dashboard" : "/objekte"} replace />;
} }
function AdminRoute({ children }: { children: ReactNode }) { function AdminRoute({ children }: { children: ReactNode }) {
-2
View File
@@ -3,7 +3,6 @@ import { NavLink, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { CommandPalette } from "./CommandPalette"; import { CommandPalette } from "./CommandPalette";
import { StartseiteAuswahl } from "./StartseiteAuswahl";
import { SyncStatus } from "./status/SyncStatus"; import { SyncStatus } from "./status/SyncStatus";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
@@ -49,7 +48,6 @@ export function AppShell({ children }: { children: ReactNode }) {
{istAdmin ? "Administration" : "Verwaltung"} {istAdmin ? "Administration" : "Verwaltung"}
</NavLink> </NavLink>
)} )}
{istVerantwortlich && <StartseiteAuswahl />}
<SyncStatus /> <SyncStatus />
<button className="btn btn-secondary" onClick={paletteOeffnen} title="Globale Suche (Strg/⌘+K)"> <button className="btn btn-secondary" onClick={paletteOeffnen} title="Globale Suche (Strg/⌘+K)">
🔍 Suche 🔍 Suche
@@ -1,29 +0,0 @@
import { useState } from "react";
import type { StartseitePraeferenz } from "../settings/startseite";
import { gespeichertePraeferenz, praeferenzSpeichern } from "../settings/startseite";
/** Nur relevant für Verantwortliche - Mitarbeiter haben ohnehin nur "Objekte". */
export function StartseiteAuswahl() {
const [praeferenz, setPraeferenz] = useState<StartseitePraeferenz>(() => gespeichertePraeferenz());
function aendern(neu: StartseitePraeferenz) {
setPraeferenz(neu);
praeferenzSpeichern(neu);
}
return (
<select
className="input"
style={{ width: "auto" }}
value={praeferenz}
onChange={(e) => aendern(e.target.value as StartseitePraeferenz)}
title="Startseite nach Login"
aria-label="Startseite nach Login"
>
<option value="auto">Startseite: automatisch</option>
<option value="dashboard">Startseite: Dashboard</option>
<option value="objekte">Startseite: Objekte</option>
</select>
);
}
-349
View File
@@ -1,349 +0,0 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { apiRequest } from "../api/client";
import type { HistorieEintrag, Mangel } from "../api/types";
import { InspectionStatus } from "../components/status/InspectionStatus";
import { InventoryStatus } from "../components/status/InventoryStatus";
import type { Readiness } from "../components/status/ReadinessBadge";
import { ReadinessBadge } from "../components/status/ReadinessBadge";
import { GRUND_TEXT } from "../components/status/gruendeText";
import { EREIGNIS_LABEL } from "../components/status/ereignisText";
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;
}
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;
}
interface ObjektMangel {
objekt_id: number;
code: string;
name: string;
gruende: string[];
}
interface Einsatzbereitschaft {
einsatzbereit: number;
eingeschraenkt_einsatzbereit: number;
unbekannt: number;
nicht_einsatzbereit: number;
gesamt: number;
nie_kontrolliert_gesamt: number;
details: ObjektMangel[];
}
interface Prueftermineintrag {
typ: string;
objekt_id: number;
bezeichnung: string;
faelligkeitsdatum: string;
verbleibende_tage: number;
status: string;
}
interface Ablaufdatumeintrag {
objekt_id: number;
objekt_name: string;
material_id: number;
material_name: string;
ablaufdatum: string;
verbleibende_tage: number;
status: string;
}
// 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";
}
// Leitungs-Dashboard (Karte-Nachtrag): zwei neue Kacheln (Einsatzbereitschaft,
// Prüftermine) plus die bestehende Ablaufdaten-Kachel, alle drei eigenständig -
// widersprechen sich nicht, da überfällige Prüfung/Ablauf beide zusätzlich als
// Grund in die Einsatzbereitschaft einfließen.
export function DashboardPage() {
const [bereitschaft, setBereitschaft] = useState<Einsatzbereitschaft | null>(null);
const [prueftermine, setPrueftermine] = useState<Prueftermineintrag[]>([]);
const [ablaufdaten, setAblaufdaten] = useState<Ablaufdatumeintrag[]>([]);
const [kennzahlen, setKennzahlen] = useState<Kennzahlen | null>(null);
const [maengel, setMaengel] = useState<Mangel[]>([]);
const [qualifikationsablaeufe, setQualifikationsablaeufe] = useState<Qualifikationsablauf[]>([]);
const [aktivitaet, setAktivitaet] = useState<HistorieEintrag[]>([]);
const [laedt, setLaedt] = useState(true);
const [fehler, setFehler] = useState<string | null>(null);
const [detailsOffen, setDetailsOffen] = useState(false);
const [maengelOffen, setMaengelOffen] = useState(false);
useEffect(() => {
Promise.all([
apiRequest<Einsatzbereitschaft>("/dashboard/einsatzbereitschaft"),
apiRequest<Prueftermineintrag[]>("/dashboard/prueftermine"),
apiRequest<Ablaufdatumeintrag[]>("/dashboard/ablaufdaten"),
apiRequest<Kennzahlen>("/dashboard/kennzahlen"),
apiRequest<Mangel[]>("/maengel"),
apiRequest<Qualifikationsablauf[]>("/dashboard/qualifikationsablaeufe"),
apiRequest<HistorieEintrag[]>("/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);
})
.catch(() => setFehler("Dashboard-Daten konnten nicht geladen werden."))
.finally(() => setLaedt(false));
}, []);
const maengelOffenAnzahl = maengel.filter((m) => m.status !== "erledigt").length;
const maengelKritischAnzahl = maengel.filter((m) => m.status !== "erledigt" && m.prioritaet === "hoch").length;
const maengelOffenListe = maengel.filter((m) => m.status !== "erledigt");
const pruefungenUeberfaellig = prueftermine.filter((p) => p.status === "abgelaufen").length;
const ablaufdatenAbgelaufen = ablaufdaten.filter((a) => a.status === "abgelaufen").length;
const aufgaben = [
kennzahlen && kennzahlen.anzahl_offener_fehlbestaende > 0
? { text: `${kennzahlen.anzahl_offener_fehlbestaende} offene Fehlbestände`, ziel: "/fehlbestaende" }
: 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 && 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);
return (
<main className="page-wide">
<h1>Dashboard</h1>
{fehler && <div className="alert alert-danger">{fehler}</div>}
{laedt && <p className="text-muted">Lade</p>}
{!laedt && bereitschaft && (
<div className="card-grid">
<div className="card" style={{ flexBasis: "100%" }}>
<h3 style={{ marginTop: 0 }}>Was ist jetzt zu tun?</h3>
{aufgaben.length === 0 ? (
<p className="text-success" style={{ fontWeight: 600 }}>
Nichts Dringendes offen.
</p>
) : (
<ul className="card-list">
{aufgaben.map((a, i) => (
<li key={i}>
{a.ziel ? <Link to={a.ziel}>{a.text}</Link> : <span>{a.text}</span>}
</li>
))}
</ul>
)}
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Einsatzbereitschaft</h3>
<p className="stat-number">
{bereitschaft.einsatzbereit} <span style={{ fontSize: "1rem", fontWeight: 600, color: "var(--color-text-muted)" }}>von {bereitschaft.gesamt} einsatzbereit</span>
</p>
<div className="row" style={{ flexWrap: "wrap", marginBottom: "0.5rem" }}>
<ReadinessBadge status="ready" label={`${bereitschaft.einsatzbereit} einsatzbereit`} />
{bereitschaft.eingeschraenkt_einsatzbereit > 0 && (
<ReadinessBadge status="limited" label={`${bereitschaft.eingeschraenkt_einsatzbereit} eingeschränkt`} />
)}
{bereitschaft.unbekannt > 0 && (
<ReadinessBadge status="unknown" label={`${bereitschaft.unbekannt} unbekannt`} />
)}
{bereitschaft.nicht_einsatzbereit > 0 && (
<ReadinessBadge status="not-ready" label={`${bereitschaft.nicht_einsatzbereit} nicht bereit`} />
)}
</div>
{bereitschaft.nie_kontrolliert_gesamt > bereitschaft.unbekannt && (
<p className="text-muted" style={{ fontSize: "0.85rem" }}>
Davon {bereitschaft.nie_kontrolliert_gesamt} insgesamt noch nie kontrolliert (auch bei Objekten mit
weiteren Problemen).
</p>
)}
{bereitschaft.details.length > 0 && (
<>
<button className="btn btn-secondary" onClick={() => setDetailsOffen(!detailsOffen)}>
{detailsOffen ? "Details ausblenden" : "Details anzeigen"}
</button>
{detailsOffen && (
<ul className="card-list" style={{ marginTop: "0.5rem" }}>
{bereitschaft.details.map((d) => (
<li key={d.objekt_id} className="row-between" style={{ padding: "0.3rem 0" }}>
<Link to={`/objekte/${d.objekt_id}/kontrolle`}>
{d.name} ({d.code})
</Link>
<span className="row" style={{ gap: "0.4rem" }}>
<ReadinessBadge status={readinessVonGruenden(d.gruende)} />
<span className="text-muted" style={{ fontSize: "0.85rem" }}>
{d.gruende.map((g) => GRUND_TEXT[g] ?? g).join(", ")}
</span>
</span>
</li>
))}
</ul>
)}
</>
)}
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Nächste Prüftermine</h3>
{prueftermine.length === 0 ? (
<p className="text-muted">Keine anstehenden oder überfälligen Geräteprüfungen.</p>
) : (
<ul className="card-list">
{prueftermine.map((p, i) => (
<li key={i} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
<Link to={`/akte/objekt/${p.objekt_id}`}>{p.bezeichnung}</Link>{" "}
<span className="text-muted">({p.faelligkeitsdatum})</span>
</span>
<InspectionStatus status={p.status} tage={p.verbleibende_tage} />
</li>
))}
</ul>
)}
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Bevorstehende Ablaufdaten</h3>
{ablaufdaten.length === 0 ? (
<p className="text-muted">Keine bald ablaufenden Chargen.</p>
) : (
<ul className="card-list">
{ablaufdaten.map((a) => (
<li key={`${a.objekt_id}-${a.material_id}`} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
<Link to={`/akte/objekt/${a.objekt_id}`}>{a.objekt_name}</Link> {a.material_name}{" "}
<span className="text-muted">({a.ablaufdatum})</span>
</span>
<InventoryStatus status={a.status} tage={a.verbleibende_tage} />
</li>
))}
</ul>
)}
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Kennzahlen</h3>
{kennzahlen && (
<ul className="card-list">
<li style={{ padding: "0.3rem 0" }}>Offene Fehlbestände: {kennzahlen.anzahl_offener_fehlbestaende}</li>
<li style={{ padding: "0.3rem 0" }}>
Genehmigte Mindermengen: {kennzahlen.anzahl_genehmigter_mindermengen}
</li>
<li style={{ padding: "0.3rem 0" }}>
Kürzlich erledigt (7 Tage): {kennzahlen.anzahl_kuerzlich_erledigter_fehlbestaende}
</li>
<li style={{ padding: "0.3rem 0" }}>
Problematische Objekte: {kennzahlen.anzahl_problematischer_objekte}
</li>
<li style={{ padding: "0.3rem 0" }}>Objekte gesamt: {kennzahlen.anzahl_objekte_gesamt}</li>
</ul>
)}
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Mängel</h3>
<p className="stat-number">{maengelOffenAnzahl} offen</p>
{maengelKritischAnzahl > 0 && (
<p className="text-muted" style={{ color: "var(--color-danger)" }}>
{maengelKritischAnzahl} kritisch (blockiert Einsatzbereitschaft)
</p>
)}
{maengelOffenListe.length > 0 && (
<>
<button className="btn btn-secondary" onClick={() => setMaengelOffen(!maengelOffen)}>
{maengelOffen ? "Details ausblenden" : "Details anzeigen"}
</button>
{maengelOffen && (
<ul className="card-list" style={{ marginTop: "0.5rem" }}>
{maengelOffenListe.map((m) => (
<li key={m.id} className="row-between" style={{ padding: "0.3rem 0" }}>
<Link to="/maengel">{m.beschreibung}</Link>
<span className="text-muted" style={{ color: m.prioritaet === "hoch" ? "var(--color-danger)" : undefined }}>
{m.prioritaet}
</span>
</li>
))}
</ul>
)}
</>
)}
{maengelOffenListe.length === 0 && <p className="text-muted">Keine offenen Mängel.</p>}
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Qualifikationsablauf</h3>
{qualifikationsablaeufe.length === 0 ? (
<p className="text-muted">Keine bald ablaufenden Qualifikationen.</p>
) : (
<ul className="card-list">
{qualifikationsablaeufe.map((q) => (
<li key={q.benutzer_qualifikation_id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
{q.benutzer_name} <span className="text-muted">({q.qualifikationstyp_name})</span>
</span>
<span style={{ color: q.status === "abgelaufen" ? "var(--color-danger)" : undefined }}>
{q.gueltig_bis} ({q.verbleibende_tage} Tage)
</span>
</li>
))}
</ul>
)}
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Letzte Aktivität</h3>
{aktivitaet.length === 0 ? (
<p className="text-muted">Noch keine Ereignisse.</p>
) : (
<ul className="card-list">
{aktivitaet.map((h) => (
<li key={h.id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
{EREIGNIS_LABEL[h.ereignistyp] ?? h.ereignistyp}
{h.entitaet_typ === "objekt" && (
<>
{" "}
<Link to={`/akte/objekt/${h.entitaet_id}`}>#{h.entitaet_id}</Link>
</>
)}
{h.benutzer_name && <span className="text-muted"> · {h.benutzer_name}</span>}
</span>
<span className="text-muted">{new Date(h.zeitpunkt).toLocaleString("de-DE")}</span>
</li>
))}
</ul>
)}
</div>
</div>
)}
</main>
);
}
+1 -2
View File
@@ -4,7 +4,6 @@ import { useNavigate } from "react-router-dom";
import { apiRequest } from "../api/client"; import { apiRequest } from "../api/client";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import type { Me } from "../api/types"; import type { Me } from "../api/types";
import { startseitenPfad } from "../settings/startseite";
const VERANTWORTLICH_ROLLEN = ["administration", "materialverantwortlicher", "leitungsverantwortlicher"]; const VERANTWORTLICH_ROLLEN = ["administration", "materialverantwortlicher", "leitungsverantwortlicher"];
@@ -27,7 +26,7 @@ export function LoginPage() {
// selbst /auth/me abfragen statt auf den Context zu warten. // selbst /auth/me abfragen statt auf den Context zu warten.
const me = await apiRequest<Me>("/auth/me"); const me = await apiRequest<Me>("/auth/me");
const istVerantwortlich = me.rollen.some((r) => VERANTWORTLICH_ROLLEN.includes(r)); const istVerantwortlich = me.rollen.some((r) => VERANTWORTLICH_ROLLEN.includes(r));
navigate(startseitenPfad(istVerantwortlich)); navigate(istVerantwortlich ? "/dashboard" : "/objekte");
} catch { } catch {
setFehler("Login oder Passwort falsch"); setFehler("Login oder Passwort falsch");
} finally { } finally {
@@ -0,0 +1,215 @@
import { useCallback, useEffect, useState } from "react";
import { apiRequest } from "../../api/client";
import type { HistorieEintrag, Mangel } from "../../api/types";
import type { DashboardTileId } from "./layout";
import { layoutLaden, layoutSpeichern, STANDARD_REIHENFOLGE } from "./layout";
import { TileFrame } from "./TileFrame";
import { AblaufdatenTile } from "./tiles/AblaufdatenTile";
import { AktivitaetTile } from "./tiles/AktivitaetTile";
import { AufgabenTile } from "./tiles/AufgabenTile";
import { EinsatzbereitschaftTile } from "./tiles/EinsatzbereitschaftTile";
import { KennzahlenTile } from "./tiles/KennzahlenTile";
import { MaengelTile } from "./tiles/MaengelTile";
import { PrueftermineTile } from "./tiles/PrueftermineTile";
import { QualifikationTile } from "./tiles/QualifikationTile";
import type { Ablaufdatumeintrag, Einsatzbereitschaft, Kennzahlen, Prueftermineintrag, Qualifikationsablauf } from "./types";
const AKTUALISIERUNGS_INTERVALL_MS = 60_000;
const TITEL: Record<DashboardTileId, string> = {
aufgaben: "Was ist jetzt zu tun?",
einsatzbereitschaft: "Einsatzbereitschaft",
prueftermine: "Nächste Prüftermine",
ablaufdaten: "Bevorstehende Ablaufdaten",
kennzahlen: "Kennzahlen",
maengel: "Mängel",
qualifikation: "Qualifikationsablauf",
aktivitaet: "Letzte Aktivität",
};
export function DashboardPage() {
const [bereitschaft, setBereitschaft] = useState<Einsatzbereitschaft | null>(null);
const [prueftermine, setPrueftermine] = useState<Prueftermineintrag[]>([]);
const [ablaufdaten, setAblaufdaten] = useState<Ablaufdatumeintrag[]>([]);
const [kennzahlen, setKennzahlen] = useState<Kennzahlen | null>(null);
const [maengel, setMaengel] = useState<Mangel[]>([]);
const [qualifikationsablaeufe, setQualifikationsablaeufe] = useState<Qualifikationsablauf[]>([]);
const [aktivitaet, setAktivitaet] = useState<HistorieEintrag[]>([]);
const [laedt, setLaedt] = useState(true);
const [fehler, setFehler] = useState<string | null>(null);
const [layout, setLayout] = useState(() => layoutLaden());
const [bearbeitenModus, setBearbeitenModus] = useState(false);
const [monitorModus, setMonitorModus] = useState(false);
const ladeDaten = useCallback(() => {
return Promise.all([
apiRequest<Einsatzbereitschaft>("/dashboard/einsatzbereitschaft"),
apiRequest<Prueftermineintrag[]>("/dashboard/prueftermine"),
apiRequest<Ablaufdatumeintrag[]>("/dashboard/ablaufdaten"),
apiRequest<Kennzahlen>("/dashboard/kennzahlen"),
apiRequest<Mangel[]>("/maengel"),
apiRequest<Qualifikationsablauf[]>("/dashboard/qualifikationsablaeufe"),
apiRequest<HistorieEintrag[]>("/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 ? (
<AufgabenTile
kennzahlen={kennzahlen}
maengelNeuAnzahl={maengelNeuAnzahl}
maengelKritischAnzahl={maengelKritischAnzahl}
pruefungenUeberfaellig={pruefungenUeberfaellig}
ablaufdatenAbgelaufen={ablaufdatenAbgelaufen}
bereitschaft={bereitschaft}
/>
) : null;
case "einsatzbereitschaft":
return bereitschaft ? <EinsatzbereitschaftTile bereitschaft={bereitschaft} /> : null;
case "prueftermine":
return <PrueftermineTile prueftermine={prueftermine} />;
case "ablaufdaten":
return <AblaufdatenTile ablaufdaten={ablaufdaten} />;
case "kennzahlen":
return kennzahlen ? <KennzahlenTile kennzahlen={kennzahlen} /> : null;
case "maengel":
return (
<MaengelTile maengel={maengel} maengelNeuAnzahl={maengelNeuAnzahl} maengelKritischAnzahl={maengelKritischAnzahl} />
);
case "qualifikation":
return <QualifikationTile qualifikationsablaeufe={qualifikationsablaeufe} />;
case "aktivitaet":
return <AktivitaetTile aktivitaet={aktivitaet} />;
default:
return null;
}
}
return (
<main className="page-wide">
<div className="row-between">
<h1>Dashboard</h1>
<div className="row" style={{ gap: "0.4rem" }}>
{!monitorModus && (
<button className="btn btn-secondary" onClick={() => setBearbeitenModus(!bearbeitenModus)}>
{bearbeitenModus ? "Fertig" : "Anpassen"}
</button>
)}
{bearbeitenModus && (
<button className="btn btn-secondary" onClick={layoutZuruecksetzen}>
Zurücksetzen
</button>
)}
<button className="btn btn-secondary" onClick={monitorUmschalten}>
{monitorModus ? "Monitor beenden" : "Status-Monitor"}
</button>
</div>
</div>
{fehler && <div className="alert alert-danger">{fehler}</div>}
{laedt && <p className="text-muted">Lade</p>}
{!laedt && bereitschaft && (
<div className="card-grid" style={monitorModus ? { fontSize: "1.15rem" } : undefined}>
{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 (
<TileFrame
key={id}
title={TITEL[id]}
vollbreite={id === "aufgaben"}
bearbeitenAktiv={bearbeitenModus}
ausgeblendet={ausgeblendet}
kannHochVerschieben={index > 0}
kannRunterVerschieben={index < layout.reihenfolge.length - 1}
onAusblendenUmschalten={() => ausblendenUmschalten(id)}
onHoch={() => tileVerschieben(id, -1)}
onRunter={() => tileVerschieben(id, 1)}
>
{inhalt}
</TileFrame>
);
})}
</div>
)}
</main>
);
}
@@ -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 (
<div className="card" style={{ flexBasis: vollbreite ? "100%" : undefined, opacity: ausgeblendet ? 0.5 : 1 }}>
<div className="row-between" style={{ marginBottom: "0.4rem" }}>
<h3 style={{ margin: 0 }}>{title}</h3>
{bearbeitenAktiv && (
<div className="row" style={{ gap: "0.3rem" }}>
<button className="btn btn-secondary" onClick={onHoch} disabled={!kannHochVerschieben} title="Nach oben">
</button>
<button className="btn btn-secondary" onClick={onRunter} disabled={!kannRunterVerschieben} title="Nach unten">
</button>
<button className="btn btn-secondary" onClick={onAusblendenUmschalten}>
{ausgeblendet ? "Einblenden" : "Ausblenden"}
</button>
</div>
)}
</div>
{!ausgeblendet && children}
</div>
);
}
+55
View File
@@ -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
}
}
@@ -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 <p className="text-muted">Keine bald ablaufenden Chargen.</p>;
}
return (
<ul className="card-list">
{ablaufdaten.map((a) => (
<li key={`${a.objekt_id}-${a.material_id}`} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
<Link to={`/akte/objekt/${a.objekt_id}`}>{a.objekt_name}</Link> {a.material_name}{" "}
<span className="text-muted">({a.ablaufdatum})</span>
</span>
<InventoryStatus status={a.status} tage={a.verbleibende_tage} />
</li>
))}
</ul>
);
}
@@ -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 <p className="text-muted">Noch keine Ereignisse.</p>;
}
return (
<ul className="card-list">
{aktivitaet.map((h) => (
<li key={h.id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
{EREIGNIS_LABEL[h.ereignistyp] ?? h.ereignistyp}
{h.entitaet_typ === "objekt" && (
<>
{" "}
<Link to={`/akte/objekt/${h.entitaet_id}`}>#{h.entitaet_id}</Link>
</>
)}
{h.benutzer_name && <span className="text-muted"> · {h.benutzer_name}</span>}
</span>
<span className="text-muted">{new Date(h.zeitpunkt).toLocaleString("de-DE")}</span>
</li>
))}
</ul>
);
}
@@ -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 (
<p className="text-success" style={{ fontWeight: 600 }}>
Nichts Dringendes offen.
</p>
);
}
return (
<ul className="card-list">
{aufgaben.map((a, i) => (
<li key={i}>{a.ziel ? <Link to={a.ziel}>{a.text}</Link> : <span>{a.text}</span>}</li>
))}
</ul>
);
}
@@ -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 (
<>
<p className="stat-number">
{bereitschaft.einsatzbereit}{" "}
<span style={{ fontSize: "1rem", fontWeight: 600, color: "var(--color-text-muted)" }}>
von {bereitschaft.gesamt} einsatzbereit
</span>
</p>
<div className="row" style={{ flexWrap: "wrap", marginBottom: "0.5rem" }}>
<ReadinessBadge status="ready" label={`${bereitschaft.einsatzbereit} einsatzbereit`} />
{bereitschaft.eingeschraenkt_einsatzbereit > 0 && (
<ReadinessBadge status="limited" label={`${bereitschaft.eingeschraenkt_einsatzbereit} eingeschränkt`} />
)}
{bereitschaft.unbekannt > 0 && <ReadinessBadge status="unknown" label={`${bereitschaft.unbekannt} unbekannt`} />}
{bereitschaft.nicht_einsatzbereit > 0 && (
<ReadinessBadge status="not-ready" label={`${bereitschaft.nicht_einsatzbereit} nicht bereit`} />
)}
</div>
{bereitschaft.nie_kontrolliert_gesamt > bereitschaft.unbekannt && (
<p className="text-muted" style={{ fontSize: "0.85rem" }}>
Davon {bereitschaft.nie_kontrolliert_gesamt} insgesamt noch nie kontrolliert (auch bei Objekten mit weiteren
Problemen).
</p>
)}
{bereitschaft.details.length > 0 && (
<>
<button className="btn btn-secondary" onClick={() => setDetailsOffen(!detailsOffen)}>
{detailsOffen ? "Details ausblenden" : "Details anzeigen"}
</button>
{detailsOffen && (
<ul className="card-list" style={{ marginTop: "0.5rem" }}>
{bereitschaft.details.map((d) => (
<li key={d.objekt_id} className="row-between" style={{ padding: "0.3rem 0" }}>
<Link to={`/objekte/${d.objekt_id}/kontrolle`}>
{d.name} ({d.code})
</Link>
<span className="row" style={{ gap: "0.4rem" }}>
<ReadinessBadge status={readinessVonGruenden(d.gruende)} />
<span className="text-muted" style={{ fontSize: "0.85rem" }}>
{d.gruende.map((g) => GRUND_TEXT[g] ?? g).join(", ")}
</span>
</span>
</li>
))}
</ul>
)}
</>
)}
</>
);
}
@@ -0,0 +1,19 @@
import type { Kennzahlen } from "../types";
interface Props {
kennzahlen: Kennzahlen;
}
export function KennzahlenTile({ kennzahlen }: Props) {
return (
<ul className="card-list">
<li style={{ padding: "0.3rem 0" }}>Offene Fehlbestände: {kennzahlen.anzahl_offener_fehlbestaende}</li>
<li style={{ padding: "0.3rem 0" }}>Genehmigte Mindermengen: {kennzahlen.anzahl_genehmigter_mindermengen}</li>
<li style={{ padding: "0.3rem 0" }}>
Kürzlich erledigt (7 Tage): {kennzahlen.anzahl_kuerzlich_erledigter_fehlbestaende}
</li>
<li style={{ padding: "0.3rem 0" }}>Problematische Objekte: {kennzahlen.anzahl_problematischer_objekte}</li>
<li style={{ padding: "0.3rem 0" }}>Objekte gesamt: {kennzahlen.anzahl_objekte_gesamt}</li>
</ul>
);
}
@@ -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 (
<>
<p className="stat-number">{maengelOffenListe.length} offen</p>
{maengelNeuAnzahl > 0 && (
<p className="text-muted" style={{ color: "var(--color-warning)", fontWeight: 600 }}>
{maengelNeuAnzahl} neu gemeldet
</p>
)}
{maengelKritischAnzahl > 0 && (
<p className="text-muted" style={{ color: "var(--color-danger)" }}>
{maengelKritischAnzahl} kritisch (blockiert Einsatzbereitschaft)
</p>
)}
{maengelOffenListe.length > 0 && (
<>
<button className="btn btn-secondary" onClick={() => setMaengelOffen(!maengelOffen)}>
{maengelOffen ? "Details ausblenden" : "Details anzeigen"}
</button>
{maengelOffen && (
<ul className="card-list" style={{ marginTop: "0.5rem" }}>
{maengelOffenListe.map((m) => (
<li key={m.id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
<Link to="/maengel">{m.beschreibung}</Link>
{m.status === "neu" && (
<span className="text-muted" style={{ color: "var(--color-warning)", marginLeft: "0.4rem" }}>
neu
</span>
)}
</span>
<span className="text-muted" style={{ color: m.prioritaet === "hoch" ? "var(--color-danger)" : undefined }}>
{m.prioritaet}
</span>
</li>
))}
</ul>
)}
</>
)}
{maengelOffenListe.length === 0 && <p className="text-muted">Keine offenen Mängel.</p>}
</>
);
}
@@ -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 <p className="text-muted">Keine anstehenden oder überfälligen Geräteprüfungen.</p>;
}
return (
<ul className="card-list">
{prueftermine.map((p, i) => (
<li key={i} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
<Link to={`/akte/objekt/${p.objekt_id}`}>{p.bezeichnung}</Link>{" "}
<span className="text-muted">({p.faelligkeitsdatum})</span>
</span>
<InspectionStatus status={p.status} tage={p.verbleibende_tage} />
</li>
))}
</ul>
);
}
@@ -0,0 +1,26 @@
import type { Qualifikationsablauf } from "../types";
interface Props {
qualifikationsablaeufe: Qualifikationsablauf[];
}
export function QualifikationTile({ qualifikationsablaeufe }: Props) {
if (qualifikationsablaeufe.length === 0) {
return <p className="text-muted">Keine bald ablaufenden Qualifikationen.</p>;
}
return (
<ul className="card-list">
{qualifikationsablaeufe.map((q) => (
<li key={q.benutzer_qualifikation_id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
{q.benutzer_name} <span className="text-muted">({q.qualifikationstyp_name})</span>
</span>
<span style={{ color: q.status === "abgelaufen" ? "var(--color-danger)" : undefined }}>
{q.gueltig_bis} ({q.verbleibende_tage} Tage)
</span>
</li>
))}
</ul>
);
}
+55
View File
@@ -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;
}
-30
View File
@@ -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";
}