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