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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
{bereitschaft.eingeschraenkt_einsatzbereit > 0 && (
|
||||
<p className="text-muted" style={{ color: "var(--color-warning)" }}>
|
||||
{bereitschaft.eingeschraenkt_einsatzbereit} eingeschränkt einsatzbereit (aktive
|
||||
Mindermengen-Genehmigung)
|
||||
</p>
|
||||
)}
|
||||
{bereitschaft.unbekannt > 0 && (
|
||||
<p className="text-muted">
|
||||
{bereitschaft.unbekannt} noch nie kontrolliert (Status unbekannt)
|
||||
</p>
|
||||
)}
|
||||
{bereitschaft.nicht_einsatzbereit > 0 && (
|
||||
<p className="text-muted" style={{ color: "var(--color-danger)" }}>
|
||||
{bereitschaft.nicht_einsatzbereit} nicht einsatzbereit
|
||||
</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.details.length > 0 && (
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={() => setDetailsOffen(!detailsOffen)}>
|
||||
@@ -150,8 +158,11 @@ export function DashboardPage() {
|
||||
<Link to={`/objekte/${d.objekt_id}/kontrolle`}>
|
||||
{d.name} ({d.code})
|
||||
</Link>
|
||||
<span className="text-muted">
|
||||
{d.gruende.map((g) => GRUND_TEXT[g] ?? g).join(", ")}
|
||||
<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>
|
||||
))}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user