feat(ui): UI-003 zentrale Statuskomponenten + SyncStatus-Lücke geschlossen
CI / backend-tests (push) Successful in 2m3s
CI / frontend-build (push) Successful in 19s

ReadinessBadge (ready/limited/not-ready/unknown, "unbekannt" nie wie
"einsatzbereit"), FristBadge als Basis für InspectionStatus/
InventoryStatus, SyncStatus neu in AppShell verdrahtet - Online/
Offline/Sync-Queue-Status war zuvor nirgends sichtbar, obwohl die
Offline-Infrastruktur seit Prompt 17 existiert. Dashboard nutzt die
neuen Komponenten statt Ad-hoc-Inline-Farben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-06 13:30:45 +02:00
co-authored by Claude Sonnet 5
parent b6ada2b5a1
commit 79ddb37a36
8 changed files with 177 additions and 26 deletions
+12 -1
View File
@@ -75,7 +75,18 @@ Aktueller Stand vor Redesign: 39 Seiten-/Komponenten-Dateien, ~7.000 Zeilen, ein
- **Akzeptanzkriterien:** kein Status mehr als Inline-`<span className="badge...">`
dupliziert.
- **Tests:** ggf. kleiner Komponententest je Status-Mapping.
- **DoD:** offen.
- **DoD:** umgesetzt (2026-09-06). `components/status/ReadinessBadge.tsx`
(ready/limited/not-ready/unknown, eigene Farben/Icons, "unbekannt" sieht nie
wie "einsatzbereit" aus), `FristBadge.tsx` als gemeinsame Basis für
`InspectionStatus.tsx`/`InventoryStatus.tsx` (beide Backend-Endpunkte liefern
dieselben zwei Fristzustände), `SyncStatus.tsx` neu in `AppShell` verdrahtet
- Online/Offline/Queue-Länge war zuvor NIRGENDS sichtbar, obwohl
`useOnlineStatus`/Offline-Queue seit Prompt 17 existieren. In `DashboardPage`
eingesetzt (Einsatzbereitschaft-Chips, Prüftermine, Ablaufdaten).
`objekt.status` (aktiv/ausser_dienst/in_wartung, ObjektListPage/AktePage)
bewusst NICHT auf ReadinessBadge umgestellt - anderes Konzept
(Objekt-Lebenszyklus statt Einsatzbereitschaft), Vermischung wäre fachlich
falsch. Echte Einsatzbereitschaft pro Objekt (mit Begründung) kommt in UI-005.
## UI-004 — Dashboard neu
+2
View File
@@ -3,6 +3,7 @@ import { NavLink, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { CommandPalette } from "./CommandPalette";
import { SyncStatus } from "./status/SyncStatus";
import { ThemeToggle } from "./ThemeToggle";
function paletteOeffnen() {
@@ -47,6 +48,7 @@ export function AppShell({ children }: { children: ReactNode }) {
{istAdmin ? "Administration" : "Verwaltung"}
</NavLink>
)}
<SyncStatus />
<button className="btn btn-secondary" onClick={paletteOeffnen} title="Globale Suche (Strg/⌘+K)">
🔍 Suche
</button>
@@ -0,0 +1,31 @@
export type FristStatus = "abgelaufen" | "bald_ablaufend" | "ok";
const LABEL: Record<FristStatus, string> = {
abgelaufen: "Überfällig",
bald_ablaufend: "Bald fällig",
ok: "In Ordnung",
};
const KLASSE: Record<FristStatus, string> = {
abgelaufen: "badge-danger",
bald_ablaufend: "badge-warning",
ok: "badge-success",
};
interface Props {
status: string;
tage?: number;
}
/** UI-003 (Epic 20): gemeinsame Basis für InspectionStatus/InventoryStatus -
* beide Backend-Endpunkte (Prüftermine/Ablaufdaten) liefern dieselben zwei
* Fristzustände (siehe app/services/dashboard.py `_ablauf_status`). */
export function FristBadge({ status, tage }: Props) {
const bekannt = status === "abgelaufen" || status === "bald_ablaufend" ? status : "ok";
return (
<span className={`badge ${KLASSE[bekannt]}`}>
{LABEL[bekannt]}
{tage !== undefined && ` (${tage} Tage)`}
</span>
);
}
@@ -0,0 +1,6 @@
import { FristBadge } from "./FristBadge";
/** UI-003 (Epic 20): Geräteprüfung-Fristanzeige (Dashboard "Nächste Prüftermine"). */
export function InspectionStatus({ status, tage }: { status: string; tage?: number }) {
return <FristBadge status={status} tage={tage} />;
}
@@ -0,0 +1,6 @@
import { FristBadge } from "./FristBadge";
/** UI-003 (Epic 20): Chargen-/Ablaufdatum-Fristanzeige (Dashboard "Bevorstehende Ablaufdaten"). */
export function InventoryStatus({ status, tage }: { status: string; tage?: number }) {
return <FristBadge status={status} tage={tage} />;
}
@@ -0,0 +1,40 @@
export type Readiness = "ready" | "limited" | "not-ready" | "unknown";
const LABEL: Record<Readiness, string> = {
ready: "Einsatzbereit",
limited: "Eingeschränkt einsatzbereit",
"not-ready": "Nicht einsatzbereit",
unknown: "Unbekannt",
};
const ICON: Record<Readiness, string> = {
ready: "🟢",
limited: "🟡",
"not-ready": "🔴",
unknown: "⚪",
};
const KLASSE: Record<Readiness, string> = {
ready: "badge-ready",
limited: "badge-limited",
"not-ready": "badge-not-ready",
unknown: "badge-unknown",
};
interface Props {
status: Readiness;
label?: string;
}
/**
* UI-003 (Epic 20): einzige Stelle, die Einsatzbereitschaft farblich/textlich
* darstellt - "unbekannt" (noch nie kontrolliert) sieht bewusst NIE wie
* "einsatzbereit" aus (eigene Farbe/Icon, siehe global.css badge-unknown).
*/
export function ReadinessBadge({ status, label }: Props) {
return (
<span className={`badge ${KLASSE[status]}`}>
{ICON[status]} {label ?? LABEL[status]}
</span>
);
}
@@ -0,0 +1,44 @@
import { useEffect, useState } from "react";
import { alleEintraege } from "../../offline/queue";
import { useOnlineStatus } from "../../offline/useOnlineStatus";
/**
* UI-003 (Epic 20): Online-/Offline-/Sync-Status war bisher nirgends sichtbar
* (useOnlineStatus/Queue existierten nur intern für den Kontroll-Workflow) -
* echte Lücke laut Redesign-Vorgabe, kein reiner Umbau.
*/
export function SyncStatus() {
const online = useOnlineStatus();
const [wartend, setWartend] = useState(0);
useEffect(() => {
let aktiv = true;
async function pruefen() {
const eintraege = await alleEintraege();
if (aktiv) setWartend(eintraege.filter((e) => e.status !== "fehler").length);
}
void pruefen();
const interval = window.setInterval(pruefen, 5000);
return () => {
aktiv = false;
window.clearInterval(interval);
};
}, []);
if (online && wartend === 0) {
return <span className="badge badge-success" title="Online, alles synchronisiert">🟢 Online</span>;
}
if (online && wartend > 0) {
return (
<span className="badge badge-warning" title={`${wartend} Eintrag/Einträge werden übertragen`}>
🟡 Synchronisiere ({wartend})
</span>
);
}
return (
<span className="badge badge-danger" title={wartend > 0 ? `${wartend} Eintrag/Einträge warten auf Verbindung` : "Keine Verbindung"}>
🔴 Offline{wartend > 0 ? ` (${wartend} wartend)` : ""}
</span>
);
}
+29 -18
View File
@@ -3,6 +3,10 @@ import { Link } from "react-router-dom";
import { apiRequest } from "../api/client";
import type { 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";
interface Kennzahlen {
anzahl_offener_fehlbestaende: number;
@@ -57,6 +61,14 @@ interface Ablaufdatumeintrag {
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";
}
const GRUND_TEXT: Record<string, string> = {
fehlbestand_offen: "Offener Fehlbestand",
ablaufdatum_abgelaufen: "Ablaufdatum überschritten",
@@ -118,26 +130,22 @@ export function DashboardPage() {
{!laedt && bereitschaft && (
<div className="card-grid">
<div className="card">
<h3 style={{ marginTop: 0 }}>Einsatzbereites Material</h3>
<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 && (
<p className="text-muted" style={{ color: "var(--color-warning)" }}>
{bereitschaft.eingeschraenkt_einsatzbereit} eingeschränkt einsatzbereit (aktive
Mindermengen-Genehmigung)
</p>
<ReadinessBadge status="limited" label={`${bereitschaft.eingeschraenkt_einsatzbereit} eingeschränkt`} />
)}
{bereitschaft.unbekannt > 0 && (
<p className="text-muted">
{bereitschaft.unbekannt} noch nie kontrolliert (Status unbekannt)
</p>
<ReadinessBadge status="unknown" label={`${bereitschaft.unbekannt} unbekannt`} />
)}
{bereitschaft.nicht_einsatzbereit > 0 && (
<p className="text-muted" style={{ color: "var(--color-danger)" }}>
{bereitschaft.nicht_einsatzbereit} nicht einsatzbereit
</p>
<ReadinessBadge status="not-ready" label={`${bereitschaft.nicht_einsatzbereit} nicht bereit`} />
)}
</div>
{bereitschaft.details.length > 0 && (
<>
<button className="btn btn-secondary" onClick={() => setDetailsOffen(!detailsOffen)}>
@@ -150,9 +158,12 @@ export function DashboardPage() {
<Link to={`/objekte/${d.objekt_id}/kontrolle`}>
{d.name} ({d.code})
</Link>
<span className="text-muted">
<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>
@@ -169,10 +180,10 @@ export function DashboardPage() {
<ul className="card-list">
{prueftermine.map((p, i) => (
<li key={i} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>{p.bezeichnung}</span>
<span style={{ color: p.status === "abgelaufen" ? "var(--color-danger)" : undefined }}>
{p.faelligkeitsdatum} ({p.verbleibende_tage} Tage)
<span>
{p.bezeichnung} <span className="text-muted">({p.faelligkeitsdatum})</span>
</span>
<InspectionStatus status={p.status} tage={p.verbleibende_tage} />
</li>
))}
</ul>
@@ -187,10 +198,10 @@ export function DashboardPage() {
<ul className="card-list">
{ablaufdaten.map((a) => (
<li key={`${a.objekt_id}-${a.material_id}`} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>Objekt #{a.objekt_id} Material #{a.material_id}</span>
<span className="text-muted" style={{ color: a.status === "abgelaufen" ? "var(--color-danger)" : undefined }}>
{a.ablaufdatum} ({a.verbleibende_tage} Tage)
<span>
Objekt #{a.objekt_id} Material #{a.material_id} <span className="text-muted">({a.ablaufdatum})</span>
</span>
<InventoryStatus status={a.status} tage={a.verbleibende_tage} />
</li>
))}
</ul>