polish: rebrand to Fleet Ops, add trilingual i18n, adaptive demo guide, and UX overhaul

Rebrands the product from MobilityOps to Fleet Ops across the UI, backend defaults and
knowledge base, and makes nl-BE/en-GB/fr-BE full first-class languages: i18next with
eager-bundled per-namespace resources, a persisted accessible language switcher (topbar
and mobile drawer), locale-aware date/number formatting, and a coverage test that fails
the build on any missing or empty translation key.

Backend dynamic content (demo scenarios, blocked-reason text, integration status) moves
from fixed English/Dutch prose to stable message codes + params so the frontend can
localize it; the demo knowledge base gains a fully translated NL/EN/FR procedure corpus
(11 documents each) with per-language retrieval and localized evidence-state messages.

The Demo Guide becomes breakpoint-adaptive: a docked rail on extra-wide desktop, a
floating panel that auto-collapses to a persistent, closable progress chip on standard
desktop/tablet, and a collapsed/half/full bottom sheet on mobile -- with scroll+focus+
highlight on "go to this step", Escape handling, and reduced-motion support.

The Data Quality Workbench gets accessible choice-card decisions with a clear primary/
secondary/tertiary action hierarchy; the Automation ledger groups repeated successes and
uses meaningful short refs; the Audit trail groups events by correlation id with human
action labels and readable before/after diffs. Attention Queue, Today's movements,
Vehicles, Bookings and Data Quality rows are fully clickable (stretched-link pattern)
with independent secondary links, keyboard support and mobile touch targets.

Fixes a topbar overflow on mobile caused by the new language switcher (moved into the
mobile drawer at <=960px) and two dangling aria-labelledby references introduced this
session. Updates all affected Playwright specs for the new nl-BE default and the new
Audit/DemoGuide DOM structure, and adds new i18n-coverage, demo-guide-adaptive and
clickable-rows specs. 131 backend tests, Ruff and mypy, and 71 Playwright tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
NuklearRabbit
2026-08-03 18:33:22 +02:00
co-authored by Claude Sonnet 5
parent 257a4cf6c0
commit 337f8716bb
127 changed files with 5529 additions and 1287 deletions
+88 -73
View File
@@ -1,33 +1,36 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
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 { useLocaleFormat } from "../i18n/format";
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" },
const FLEET_METRIC_KEYS: Array<{ key: keyof DashboardData["metrics"]; labelKey: string; tone: string }> = [
{ key: "available", labelKey: "readiness.available", tone: "ready" },
{ key: "rented", labelKey: "readiness.rented", tone: "neutral" },
{ key: "cleaning", labelKey: "readiness.cleaning", tone: "neutral" },
{ key: "maintenance", labelKey: "readiness.maintenance", tone: "warning" },
{ key: "blocked", labelKey: "readiness.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",
});
function attentionItemTitle(
t: (key: string, options?: Record<string, unknown>) => string,
item: { rule_type: string; link_ref: string },
): string {
const ruleLabel = t(`quality:ruleTypes.${item.rule_type}`, { defaultValue: item.rule_type.replace(/_/g, " ") });
return `${ruleLabel}${item.link_ref}`;
}
export function Dashboard() {
const { t } = useTranslation(["dashboard", "common", "integrations"]);
const { formatTime, formatShortDate } = useLocaleFormat();
const { user } = useAuth();
const { manifest } = useDemoManifest();
const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide();
@@ -52,7 +55,7 @@ export function Dashboard() {
const [query, setQuery] = useState("");
useEffect(() => {
api.get<DashboardData>("/api/v1/dashboard").then(setData).catch(() => setError("Dashboard data is unavailable right now."));
api.get<DashboardData>("/api/v1/dashboard").then(setData).catch(() => setError(t("common:status.error")));
api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null));
}, []);
@@ -66,96 +69,104 @@ export function Dashboard() {
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();
const haystack = `${attentionItemTitle(t, item)} ${item.detail} ${item.link_ref}`.toLowerCase();
return matchesSeverity && haystack.includes(query.toLowerCase());
}) ?? [], [data, query, severity]);
}) ?? [], [data, query, severity, t]);
if (error) return <ErrorState message={error} />;
if (!data) return <LoadingState label="Loading operations overview…" />;
if (!data) return <LoadingState label={t("common:status.loading")} />;
const latestRun = data.recent_automation[0];
const n8nState = !latestRun ? "No delivery yet" : latestRun.status === "failed" ? "Needs attention" : latestRun.status;
const readyCount = manifest?.scenarios.filter((s) => s.ready).length ?? 0;
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>} />
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> {t("viewFleet")}</Link>} />
<section className="demo-start-panel" aria-label="Demo starten">
<section className="demo-start-panel" aria-label={t("demoStart.title")}>
<div>
<Icon name="spark" />
<div>
<strong>Probeer een demonstratiescenario</strong>
<strong>{t("demoStart.title")}</strong>
<span>
{manifest ? `${manifest.scenarios.filter((s) => s.ready).length} van ${manifest.scenarios.length} scenario's klaar voor demo.` : "Vijf afgebakende scenario's."}
{manifest ? t("demoStart.readyCount", { ready: readyCount, total: manifest.scenarios.length }) : t("demoStart.readyCountFallback")}
</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"}
<Icon name="spark" /> {completed.size > 0 ? t("demoStart.resumeGuide", { current: currentIndex + 1, total: totalSteps }) : t("demoStart.startGuide")}
</button>
)}
<Link className="button button-primary" to="/scenarios">Bekijk scenario's <Icon name="chevron" /></Link>
<Link className="button button-primary" to="/scenarios">{t("demoStart.viewScenarios")} <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><h2 id="readiness-heading">{t("readiness.title")}</h2><p>{t("readiness.description")}</p></div>
</div>
<dl className="readiness-metrics">
{FLEET_METRICS.map((metric) => (
{FLEET_METRIC_KEYS.map((metric) => (
<div key={metric.key} className={`metric-cell metric-${metric.tone}`}>
<dt>{metric.label}</dt><dd>{data.metrics[metric.key]}</dd>
<dt>{t(metric.labelKey)}</dt><dd>{data.metrics[metric.key]}</dd>
</div>
))}
</dl>
<Link className="inline-action" to="/vehicles">Open fleet <Icon name="chevron" /></Link>
<Link className="inline-action" to="/vehicles">{t("readiness.openFleet")} <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} />
<SectionHeading headingId="attention-heading" title={t("attention.title")} description={t("attention.description", { openIssues: data.metrics.open_quality_issues, workflowExceptions: data.metrics.pending_or_failed_workflows })} action={canSeeQuality ? <Link to="/data-quality">{t("attention.reviewQueue")} <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>
<label className="compact-search"><Icon name="search" /><span className="visually-hidden">{t("attention.filterAriaLabel")}</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("attention.filterPlaceholder")} /></label>
<label><span className="visually-hidden">{t("attention.severityAriaLabel")}</span><select value={severity} onChange={(event) => setSeverity(event.target.value)}><option value="all">{t("attention.severityAll")}</option><option value="high">{t("attention.severityHigh")}</option><option value="medium">{t("attention.severityMedium")}</option><option value="low">{t("attention.severityLow")}</option></select></label>
</div>
{attention.length === 0 ? <p className="quiet-empty"><Icon name="check" /> No issues match this filter.</p> : (
{attention.length === 0 ? <p className="quiet-empty"><Icon name="check" /> {t("attention.empty")}</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>
))}
{attention.slice(0, 6).map((item, index) => {
const href = item.issue_ref && canSeeQuality
? `/data-quality/${item.issue_ref}`
: item.link_type === "vehicle"
? `/vehicles/${item.link_ref}`
: null;
const title = attentionItemTitle(t, item);
return (
<li key={`${item.link_ref}-${index}`} className={href ? "row-clickable" : ""}>
<SeverityBadge severity={item.severity} />
<div className="queue-copy">
<p className="attention-title">{title}</p>
<p className="attention-detail">{item.detail}</p>
</div>
<span className="queue-ref">{item.link_ref}</span>
<Icon name="chevron" className="row-chevron" />
{href && (
<Link className="row-link" to={href} aria-label={t("attention.openRecord", { title })}>
<span className="visually-hidden">{title}</span>
</Link>
)}
</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> : (
<SectionHeading headingId="today-heading" title={t("movements.title")} description={t("movements.description")} action={<Link to="/bookings">{t("movements.allBookings")} <Icon name="chevron" /></Link>} />
{data.today.length === 0 ? <p className="quiet-empty"><Icon name="clock" /> {t("movements.empty")}</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>
{data.today.map((item) => (
<li key={`${item.kind}-${item.booking_ref}`} className="row-clickable">
<time dateTime={item.scheduled_at}>{formatTime(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>
<div><span className="movement-kind">{item.kind === "return" ? t("movements.return") : t("movements.departure")}</span><span className="movement-ref">{item.booking_ref}</span><small>{item.vehicle_ref}</small></div>
<Link className="row-link" to={`/bookings/${item.booking_ref}`} aria-label={t("movements.openBooking", { ref: item.booking_ref })}>
<span className="visually-hidden">{item.booking_ref}</span>
</Link>
</li>
))}
</ol>
@@ -165,26 +176,26 @@ export function Dashboard() {
<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} />
<SectionHeading headingId="integration-heading" title={t("integrationPulse.title")} description={t("integrationPulse.description")} action={canSeeAutomation ? <Link to="/automation">{t("integrationPulse.systemDetail")} <Icon name="chevron" /></Link> : undefined} />
<ul className="integration-list">
<li>
<IntegrationMark kind="n8n" />
<div>
<strong>n8n delivery</strong>
<strong>{t("integrationPulse.n8nTitle")}</strong>
<span>
{integrationStatus
? `${integrationStatus.n8n.succeeded} succeeded · ${integrationStatus.n8n.failed} failed`
? t("integrationPulse.n8nSummary", { succeeded: integrationStatus.n8n.succeeded, failed: integrationStatus.n8n.failed })
: latestRun
? `Latest event ${latestRun.aggregate_ref}`
: "No workflow evidence recorded"}
? t("integrationPulse.n8nLatest", { ref: latestRun.aggregate_ref })
: t("integrationPulse.n8nNoEvidence")}
</span>
</div>
{(() => {
const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null;
return (
<StatusBadge
status={meta?.statusClass ?? n8nState.toLowerCase().replace(/ /g, "_")}
label={meta?.label}
status={meta?.statusClass ?? "no_events"}
label={meta ? t(`integrations:statusLabels.${meta.labelKey}`) : undefined}
/>
);
})()}
@@ -192,34 +203,38 @@ export function Dashboard() {
<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>
<strong>{t("integrationPulse.knowledgeTitle")}</strong>
<span>{knowledge ? t("integrationPulse.knowledgeSummary", { count: knowledge.document_count }) : t("integrationPulse.knowledgeUnavailable")}</span>
</div>
<StatusBadge
status={knowledge?.available ? "available" : "unavailable"}
label={knowledge?.available ? (knowledge.provider === "ragcore" ? "Operational" : "Demo mode") : "Unavailable"}
label={
knowledge?.available
? t(`integrations:statusLabels.${knowledge.provider === "ragcore" ? "operational" : "demoMode"}`)
: t("integrations:statusLabels.unavailable")
}
/>
</li>
<li>
<IntegrationMark kind="mcp" />
<div>
<strong>MCP Hub</strong>
<span>{integrationStatus?.mcp_hub.registration_enabled ? "Registration enabled" : "Not yet connected"}</span>
<strong>{t("integrationPulse.mcpTitle")}</strong>
<span>{integrationStatus?.mcp_hub.registration_enabled ? t("integrationPulse.mcpEnabled") : t("integrationPulse.mcpNotConnected")}</span>
</div>
{(() => {
const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null;
return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta?.label} />;
return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta ? t(`integrations:statusLabels.${meta.labelKey}`) : undefined} />;
})()}
</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> : (
<SectionHeading headingId="recent-heading" title={t("recent.title")} description={t("recent.description")} />
{data.recent_automation.length === 0 ? <p className="quiet-empty">{t("recent.empty")}</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>
<li key={run.event_id}><span className="activity-icon"><Icon name="activity" /></span><div><strong>{t(`integrations:ledger.eventTypes.${run.event_type}`, { defaultValue: run.event_type.replace(/_/g, " ") })}</strong><span>{run.aggregate_ref}</span></div><StatusBadge status={run.status} label={t(`integrations:ledger.status${run.status.charAt(0).toUpperCase()}${run.status.slice(1)}`, { defaultValue: run.status })} /><time dateTime={run.occurred_at}>{formatShortDate(run.occurred_at)}</time></li>
))}
</ul>
)}