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.
231 lines
12 KiB
TypeScript
231 lines
12 KiB
TypeScript
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. Here’s the fleet." description="Readiness, exceptions and hand-offs across today’s 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="Today’s 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>
|
||
);
|
||
}
|