Files
MobilityOps/frontend/src/pages/Dashboard.tsx
T
NuklearRabbit 5fa4fe0811 feat(demo): plain-language integration status, richer audit, reset integrity
Integration status badges across Dashboard/Automation now show honest
plain-language labels instead of raw backend state strings (and fix a
few states that had no matching CSS colour class at all). Audit trail
gets a "view related events" action reusing the existing correlation_id
filter. About page gains scope/architecture/security/testing sections
and a guided-demo entry point. POST /api/v1/demo/reset now runs and
records a server-side scenario-integrity check. Also fixes a second real
race condition (caught by the return-review e2e test): the odometer
scenario pre-fill now resolves before ReturnForm mounts instead of
patching its value in after the fact.
2026-08-03 15:00:11 +02:00

231 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { api } from "../api/client";
import type { Dashboard as DashboardData, IntegrationStatus, KnowledgeHealth } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { Icon } from "../components/Icons";
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels";
const FLEET_METRICS: Array<{ key: keyof DashboardData["metrics"]; label: string; tone: string }> = [
{ key: "available", label: "Available", tone: "ready" },
{ key: "rented", label: "Rented", tone: "neutral" },
{ key: "cleaning", label: "Cleaning", tone: "neutral" },
{ key: "maintenance", label: "Maintenance", tone: "warning" },
{ key: "blocked", label: "Blocked", tone: "critical" },
];
function localTime(value: string, withDate = false) {
return new Date(value).toLocaleString("en-GB", {
...(withDate ? { day: "2-digit", month: "short" } : {}),
hour: "2-digit",
minute: "2-digit",
timeZone: "Europe/Brussels",
});
}
export function Dashboard() {
const { user } = useAuth();
const { manifest } = useDemoManifest();
const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide();
const [searchParams, setSearchParams] = useSearchParams();
const canSeeQuality = user?.role === "operations_manager";
const canSeeAutomation = user?.role === "operations_manager";
useEffect(() => {
if (searchParams.get("guide") === "start") {
restart();
openGuide();
setSearchParams({}, { replace: true });
}
// Only ever react to the initial `?guide=start` marker set by the login screen's
// "Start begeleide demo" CTA, so this intentionally runs once on mount.
}, []);
const [data, setData] = useState<DashboardData | null>(null);
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [severity, setSeverity] = useState("all");
const [query, setQuery] = useState("");
useEffect(() => {
api.get<DashboardData>("/api/v1/dashboard").then(setData).catch(() => setError("Dashboard data is unavailable right now."));
api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null));
}, []);
useEffect(() => {
if (!canSeeAutomation) return;
api
.get<IntegrationStatus>("/api/v1/integrations/status")
.then(setIntegrationStatus)
.catch(() => setIntegrationStatus(null));
}, [canSeeAutomation]);
const attention = useMemo(() => data?.attention_items.filter((item) => {
const matchesSeverity = severity === "all" || item.severity === severity;
const haystack = `${item.title} ${item.detail} ${item.link_ref}`.toLowerCase();
return matchesSeverity && haystack.includes(query.toLowerCase());
}) ?? [], [data, query, severity]);
if (error) return <ErrorState message={error} />;
if (!data) return <LoadingState label="Loading operations overview…" />;
const latestRun = data.recent_automation[0];
const n8nState = !latestRun ? "No delivery yet" : latestRun.status === "failed" ? "Needs attention" : latestRun.status;
return (
<div className="page dashboard-page">
<PageHeader eyebrow="Operations / Live overview" title="Good morning. Heres the fleet." description="Readiness, exceptions and hand-offs across todays operation." actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> View fleet</Link>} />
<section className="demo-start-panel" aria-label="Demo starten">
<div>
<Icon name="spark" />
<div>
<strong>Probeer een demonstratiescenario</strong>
<span>
{manifest ? `${manifest.scenarios.filter((s) => s.ready).length} van ${manifest.scenarios.length} scenario's klaar voor demo.` : "Vijf afgebakende scenario's."}
</span>
</div>
</div>
<div className="demo-start-actions">
{user?.role === "operations_manager" && (
<button type="button" className="button button-secondary" onClick={openGuide}>
<Icon name="spark" /> {completed.size > 0 ? `Verder met demo-gids (${currentIndex + 1}/${totalSteps})` : "Start demo-gids"}
</button>
)}
<Link className="button button-primary" to="/scenarios">Bekijk scenario's <Icon name="chevron" /></Link>
</div>
</section>
<section className="readiness-band" aria-labelledby="readiness-heading">
<div className="readiness-label">
<span className="live-indicator" />
<div><h2 id="readiness-heading">Fleet readiness</h2><p>Live from persisted vehicle state</p></div>
</div>
<dl className="readiness-metrics">
{FLEET_METRICS.map((metric) => (
<div key={metric.key} className={`metric-cell metric-${metric.tone}`}>
<dt>{metric.label}</dt><dd>{data.metrics[metric.key]}</dd>
</div>
))}
</dl>
<Link className="inline-action" to="/vehicles">Open fleet <Icon name="chevron" /></Link>
</section>
<div className="operations-grid">
<section className="work-panel attention-panel" aria-labelledby="attention-heading">
<SectionHeading title="Attention queue" description={`${data.metrics.open_quality_issues} open quality issues · ${data.metrics.pending_or_failed_workflows} workflow exceptions`} action={canSeeQuality ? <Link to="/data-quality">Review queue <Icon name="chevron" /></Link> : undefined} />
<div className="queue-controls">
<label className="compact-search"><Icon name="search" /><span className="visually-hidden">Search attention queue</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Filter issues…" /></label>
<label><span className="visually-hidden">Severity</span><select value={severity} onChange={(event) => setSeverity(event.target.value)}><option value="all">All severity</option><option value="high">Critical</option><option value="medium">Warning</option><option value="low">Info</option></select></label>
</div>
{attention.length === 0 ? <p className="quiet-empty"><Icon name="check" /> No issues match this filter.</p> : (
<ul className="attention-list">
{attention.slice(0, 6).map((item, index) => (
<li key={`${item.link_ref}-${index}`}>
<SeverityBadge severity={item.severity} />
<div className="queue-copy">
<p className="attention-title">
{item.issue_ref && canSeeQuality ? (
<Link to={`/data-quality/${item.issue_ref}`}>{item.title}</Link>
) : item.link_type === "vehicle" ? (
<Link to={`/vehicles/${item.link_ref}`}>{item.title}</Link>
) : (
item.title
)}
</p>
<p className="attention-detail">{item.detail}</p>
</div>
<span className="queue-ref">{item.link_ref}</span>
<Icon name="chevron" className="row-chevron" />
</li>
))}
</ul>
)}
</section>
<section className="work-panel timeline-panel" aria-labelledby="today-heading">
<SectionHeading title="Todays movements" description="Departures and returns in Europe/Brussels" action={<Link to="/bookings">All bookings <Icon name="chevron" /></Link>} />
{data.today.length === 0 ? <p className="quiet-empty"><Icon name="clock" /> No movements scheduled today.</p> : (
<ol className="movement-timeline">
{data.today.slice(0, 6).map((item) => (
<li key={`${item.kind}-${item.booking_ref}`}>
<time dateTime={item.scheduled_at}>{localTime(item.scheduled_at)}</time>
<span className={`timeline-node timeline-${item.kind}`}><Icon name={item.kind === "return" ? "arrow-left" : "chevron"} /></span>
<div><span className="movement-kind">{item.kind}</span><Link to={`/bookings/${item.booking_ref}`}>{item.booking_ref}</Link><small>{item.vehicle_ref}</small></div>
</li>
))}
</ol>
)}
</section>
</div>
<div className="secondary-grid">
<section className="work-panel integration-panel" aria-labelledby="integration-heading">
<SectionHeading title="Integration pulse" description="Current evidence from connected services" action={canSeeAutomation ? <Link to="/automation">System detail <Icon name="chevron" /></Link> : undefined} />
<ul className="integration-list">
<li>
<IntegrationMark kind="n8n" />
<div>
<strong>n8n delivery</strong>
<span>
{integrationStatus
? `${integrationStatus.n8n.succeeded} succeeded · ${integrationStatus.n8n.failed} failed`
: latestRun
? `Latest event ${latestRun.aggregate_ref}`
: "No workflow evidence recorded"}
</span>
</div>
{(() => {
const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null;
return (
<StatusBadge
status={meta?.statusClass ?? n8nState.toLowerCase().replace(/ /g, "_")}
label={meta?.label}
/>
);
})()}
</li>
<li>
<IntegrationMark kind="rag" />
<div>
<strong>Knowledge assistant</strong>
<span>{knowledge ? `${knowledge.provider === "ragcore" ? "RAGcore" : "Demo knowledge base"} · ${knowledge.document_count} procedures indexed` : "Health check unavailable"}</span>
</div>
<StatusBadge
status={knowledge?.available ? "available" : "unavailable"}
label={knowledge?.available ? (knowledge.provider === "ragcore" ? "Operational" : "Demo mode") : "Unavailable"}
/>
</li>
<li>
<IntegrationMark kind="mcp" />
<div>
<strong>MCP Hub</strong>
<span>{integrationStatus?.mcp_hub.registration_enabled ? "Registration enabled" : "Not yet connected"}</span>
</div>
{(() => {
const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null;
return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta?.label} />;
})()}
</li>
</ul>
</section>
<section className="work-panel recent-panel" aria-labelledby="recent-heading">
<SectionHeading title="Recent activity" description="Latest audited workflow changes" />
{data.recent_automation.length === 0 ? <p className="quiet-empty">No automation activity recorded.</p> : (
<ul className="recent-list">
{data.recent_automation.slice(0, 4).map((run) => (
<li key={run.event_id}><span className="activity-icon"><Icon name="activity" /></span><div><strong>{run.event_type.replace(/_/g, " ")}</strong><span>{run.aggregate_ref}</span></div><StatusBadge status={run.status} /><time dateTime={run.occurred_at}>{localTime(run.occurred_at, true)}</time></li>
))}
</ul>
)}
</section>
</div>
</div>
);
}