feat(ui): UI-003 zentrale Statuskomponenten + SyncStatus-Lücke geschlossen
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:
@@ -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...">`
|
- **Akzeptanzkriterien:** kein Status mehr als Inline-`<span className="badge...">`
|
||||||
dupliziert.
|
dupliziert.
|
||||||
- **Tests:** ggf. kleiner Komponententest je Status-Mapping.
|
- **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
|
## UI-004 — Dashboard neu
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 { SyncStatus } from "./status/SyncStatus";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
|
||||||
function paletteOeffnen() {
|
function paletteOeffnen() {
|
||||||
@@ -47,6 +48,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
{istAdmin ? "Administration" : "Verwaltung"}
|
{istAdmin ? "Administration" : "Verwaltung"}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
|
<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
|
||||||
</button>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@ import { Link } from "react-router-dom";
|
|||||||
|
|
||||||
import { apiRequest } from "../api/client";
|
import { apiRequest } from "../api/client";
|
||||||
import type { Mangel } from "../api/types";
|
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 {
|
interface Kennzahlen {
|
||||||
anzahl_offener_fehlbestaende: number;
|
anzahl_offener_fehlbestaende: number;
|
||||||
@@ -57,6 +61,14 @@ interface Ablaufdatumeintrag {
|
|||||||
status: string;
|
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> = {
|
const GRUND_TEXT: Record<string, string> = {
|
||||||
fehlbestand_offen: "Offener Fehlbestand",
|
fehlbestand_offen: "Offener Fehlbestand",
|
||||||
ablaufdatum_abgelaufen: "Ablaufdatum überschritten",
|
ablaufdatum_abgelaufen: "Ablaufdatum überschritten",
|
||||||
@@ -118,26 +130,22 @@ export function DashboardPage() {
|
|||||||
{!laedt && bereitschaft && (
|
{!laedt && bereitschaft && (
|
||||||
<div className="card-grid">
|
<div className="card-grid">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 style={{ marginTop: 0 }}>Einsatzbereites Material</h3>
|
<h3 style={{ marginTop: 0 }}>Einsatzbereitschaft</h3>
|
||||||
<p className="stat-number">
|
<p className="stat-number">
|
||||||
{bereitschaft.einsatzbereit} <span style={{ fontSize: "1rem", fontWeight: 600, color: "var(--color-text-muted)" }}>von {bereitschaft.gesamt} einsatzbereit</span>
|
{bereitschaft.einsatzbereit} <span style={{ fontSize: "1rem", fontWeight: 600, color: "var(--color-text-muted)" }}>von {bereitschaft.gesamt} einsatzbereit</span>
|
||||||
</p>
|
</p>
|
||||||
{bereitschaft.eingeschraenkt_einsatzbereit > 0 && (
|
<div className="row" style={{ flexWrap: "wrap", marginBottom: "0.5rem" }}>
|
||||||
<p className="text-muted" style={{ color: "var(--color-warning)" }}>
|
<ReadinessBadge status="ready" label={`${bereitschaft.einsatzbereit} einsatzbereit`} />
|
||||||
{bereitschaft.eingeschraenkt_einsatzbereit} eingeschränkt einsatzbereit (aktive
|
{bereitschaft.eingeschraenkt_einsatzbereit > 0 && (
|
||||||
Mindermengen-Genehmigung)
|
<ReadinessBadge status="limited" label={`${bereitschaft.eingeschraenkt_einsatzbereit} eingeschränkt`} />
|
||||||
</p>
|
)}
|
||||||
)}
|
{bereitschaft.unbekannt > 0 && (
|
||||||
{bereitschaft.unbekannt > 0 && (
|
<ReadinessBadge status="unknown" label={`${bereitschaft.unbekannt} unbekannt`} />
|
||||||
<p className="text-muted">
|
)}
|
||||||
{bereitschaft.unbekannt} noch nie kontrolliert (Status unbekannt)
|
{bereitschaft.nicht_einsatzbereit > 0 && (
|
||||||
</p>
|
<ReadinessBadge status="not-ready" label={`${bereitschaft.nicht_einsatzbereit} nicht bereit`} />
|
||||||
)}
|
)}
|
||||||
{bereitschaft.nicht_einsatzbereit > 0 && (
|
</div>
|
||||||
<p className="text-muted" style={{ color: "var(--color-danger)" }}>
|
|
||||||
{bereitschaft.nicht_einsatzbereit} nicht einsatzbereit
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{bereitschaft.details.length > 0 && (
|
{bereitschaft.details.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<button className="btn btn-secondary" onClick={() => setDetailsOffen(!detailsOffen)}>
|
<button className="btn btn-secondary" onClick={() => setDetailsOffen(!detailsOffen)}>
|
||||||
@@ -150,8 +158,11 @@ export function DashboardPage() {
|
|||||||
<Link to={`/objekte/${d.objekt_id}/kontrolle`}>
|
<Link to={`/objekte/${d.objekt_id}/kontrolle`}>
|
||||||
{d.name} ({d.code})
|
{d.name} ({d.code})
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-muted">
|
<span className="row" style={{ gap: "0.4rem" }}>
|
||||||
{d.gruende.map((g) => GRUND_TEXT[g] ?? g).join(", ")}
|
<ReadinessBadge status={readinessVonGruenden(d.gruende)} />
|
||||||
|
<span className="text-muted" style={{ fontSize: "0.85rem" }}>
|
||||||
|
{d.gruende.map((g) => GRUND_TEXT[g] ?? g).join(", ")}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -169,10 +180,10 @@ export function DashboardPage() {
|
|||||||
<ul className="card-list">
|
<ul className="card-list">
|
||||||
{prueftermine.map((p, i) => (
|
{prueftermine.map((p, i) => (
|
||||||
<li key={i} className="row-between" style={{ padding: "0.3rem 0" }}>
|
<li key={i} className="row-between" style={{ padding: "0.3rem 0" }}>
|
||||||
<span>{p.bezeichnung}</span>
|
<span>
|
||||||
<span style={{ color: p.status === "abgelaufen" ? "var(--color-danger)" : undefined }}>
|
{p.bezeichnung} <span className="text-muted">({p.faelligkeitsdatum})</span>
|
||||||
{p.faelligkeitsdatum} ({p.verbleibende_tage} Tage)
|
|
||||||
</span>
|
</span>
|
||||||
|
<InspectionStatus status={p.status} tage={p.verbleibende_tage} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -187,10 +198,10 @@ export function DashboardPage() {
|
|||||||
<ul className="card-list">
|
<ul className="card-list">
|
||||||
{ablaufdaten.map((a) => (
|
{ablaufdaten.map((a) => (
|
||||||
<li key={`${a.objekt_id}-${a.material_id}`} className="row-between" style={{ padding: "0.3rem 0" }}>
|
<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>
|
||||||
<span className="text-muted" style={{ color: a.status === "abgelaufen" ? "var(--color-danger)" : undefined }}>
|
Objekt #{a.objekt_id} – Material #{a.material_id} <span className="text-muted">({a.ablaufdatum})</span>
|
||||||
{a.ablaufdatum} ({a.verbleibende_tage} Tage)
|
|
||||||
</span>
|
</span>
|
||||||
|
<InventoryStatus status={a.status} tage={a.verbleibende_tage} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
Reference in New Issue
Block a user