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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user