feat(dashboard): Dashboard-Start für Verantwortliche, Kachel-Anpassung, Status-Monitor
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:
@@ -5,13 +5,12 @@ import { AuthProvider, useAuth } from "./auth/AuthContext";
|
||||
import { AppShell } from "./components/AppShell";
|
||||
import { AdminPage } from "./pages/AdminPage";
|
||||
import { AktePage } from "./pages/AktePage";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { DashboardPage } from "./pages/dashboard/DashboardPage";
|
||||
import { FehlbestandListePage } from "./pages/FehlbestandListePage";
|
||||
import { KontrollPage } from "./pages/KontrollPage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { MangelListePage } from "./pages/MangelListePage";
|
||||
import { ObjektListPage } from "./pages/ObjektListPage";
|
||||
import { startseitenPfad } from "./settings/startseite";
|
||||
|
||||
function GeschuetzteRoute({ children }: { children: ReactNode }) {
|
||||
const { eingeloggt } = useAuth();
|
||||
@@ -24,7 +23,7 @@ function GeschuetzteRoute({ children }: { children: ReactNode }) {
|
||||
function Startseite() {
|
||||
const { eingeloggt, istVerantwortlich } = useAuth();
|
||||
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 }) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { NavLink, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { StartseiteAuswahl } from "./StartseiteAuswahl";
|
||||
import { SyncStatus } from "./status/SyncStatus";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
@@ -49,7 +48,6 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
{istAdmin ? "Administration" : "Verwaltung"}
|
||||
</NavLink>
|
||||
)}
|
||||
{istVerantwortlich && <StartseiteAuswahl />}
|
||||
<SyncStatus />
|
||||
<button className="btn btn-secondary" onClick={paletteOeffnen} title="Globale Suche (Strg/⌘+K)">
|
||||
🔍 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useNavigate } from "react-router-dom";
|
||||
import { apiRequest } from "../api/client";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import type { Me } from "../api/types";
|
||||
import { startseitenPfad } from "../settings/startseite";
|
||||
|
||||
const VERANTWORTLICH_ROLLEN = ["administration", "materialverantwortlicher", "leitungsverantwortlicher"];
|
||||
|
||||
@@ -27,7 +26,7 @@ export function LoginPage() {
|
||||
// selbst /auth/me abfragen statt auf den Context zu warten.
|
||||
const me = await apiRequest<Me>("/auth/me");
|
||||
const istVerantwortlich = me.rollen.some((r) => VERANTWORTLICH_ROLLEN.includes(r));
|
||||
navigate(startseitenPfad(istVerantwortlich));
|
||||
navigate(istVerantwortlich ? "/dashboard" : "/objekte");
|
||||
} catch {
|
||||
setFehler("Login oder Passwort falsch");
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user