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
+55 -101
View File
@@ -1,147 +1,108 @@
import { Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { Icon } from "../components/Icons";
import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
function formatDateTime(value: string | null): string {
if (!value) return "onbekend";
return new Date(value).toLocaleString("nl-BE", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Europe/Brussels",
});
}
const INTEGRATION_ICON: Record<string, "n8n" | "rag" | "mcp"> = {
n8n: "n8n",
ragcore: "rag",
mcp_hub: "mcp",
};
const N8N_STATUS_LABEL_KEY: Record<string, string> = {
disabled: "notConnected",
unavailable: "deliveryFailed",
degraded: "retryAvailable",
operational: "operational",
no_evidence: "prepared",
};
export function AboutDemo() {
const { t } = useTranslation(["demo", "integrations"]);
const { formatDateTime } = useLocaleFormat();
const { manifest, loading } = useDemoManifest();
const { user } = useAuth();
const { openGuide } = useDemoGuide();
function statusLabelKey(key: string, statusCode: string): string {
if (key === "n8n") return N8N_STATUS_LABEL_KEY[statusCode] ?? statusCode;
return statusCode;
}
return (
<div className="page">
<PageHeader
eyebrow="Over deze demo"
title="Wat MobilityOps wel en niet is"
description={
manifest
? `${manifest.organization_name} is een fictieve verhuurorganisatie die dient om deze demo tastbaar te maken — geen bestaand bedrijf.`
: undefined
}
eyebrow={t("about.eyebrow")}
title={t("about.title")}
description={manifest ? t("about.description", { orgName: manifest.organization_name }) : undefined}
/>
{loading && <LoadingState label="Demo-informatie laden…" />}
{loading && <LoadingState label={t("about.loading")} />}
{manifest && (
<>
{user?.role === "operations_manager" && (
<section className="record-surface about-card about-cta">
<div>
<strong>Liever meteen aan de slag?</strong>
<p>De gegidste demo doorloopt alle acht stappen hierboven in de praktijk.</p>
<strong>{t("about.ctaTitle")}</strong>
<p>{t("about.ctaBody")}</p>
</div>
<button type="button" className="button button-primary" onClick={openGuide}>
<Icon name="spark" /> Start begeleide demo
<Icon name="spark" /> {t("about.ctaButton")}
</button>
</section>
)}
<section className="record-surface about-card">
<h2>Het fictieve probleem</h2>
<p>
{manifest.organization_name} verhuurt zo'n 50 campers en bestelwagens vanuit één
hoofdlocatie. Boekingen, retours, klantgegevens en onderhoud kwamen tot nu toe uit
losse spreadsheets en mondelinge afspraken, waardoor fouten (dubbele klanten,
foutieve kilometerstanden, dubbel geboekte voertuigen) laat aan het licht kwamen.
MobilityOps toont hoe één samenhangend systeem die problemen vroeg signaleert en
gecontroleerd laat oplossen.
</p>
<h2>{t("about.problemTitle")}</h2>
<p>{t("about.problemBody", { orgName: manifest.organization_name })}</p>
</section>
<section className="record-surface about-card">
<h2>Voor wie en met welke scope</h2>
<p>
Deze demo is bedoeld voor wie wil zien hoe MobilityOps operationele problemen bij
een kleine verhuurder aanpakt: Operations Managers en Rental Employees, en
iedereen die de aanpak evalueert. De scope is bewust afgebakend tot één
samenhangende proof of concept — geen boekhouding, geen betalingen, geen publieke
reservaties, geen volledig CRM of ERP.
</p>
<h2>{t("about.scopeTitle")}</h2>
<p>{t("about.scopeBody")}</p>
</section>
<section className="record-surface about-card">
<h2>Wat écht werkt</h2>
<p>
Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde
toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met
serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen
oplossingsstap, een volledige audit trail, geautomatiseerde aflevering naar n8n
met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde
testsuite (backend en Playwright end-to-end).
</p>
<h2>{t("about.realTitle")}</h2>
<p>{t("about.realBody")}</p>
</section>
<section className="record-surface about-card">
<h2>Wat synthetisch is</h2>
<p>
De organisatie, alle klanten, voertuigen, boekingen, onderhoudsgeschiedenis,
procedures in de kennisbank en de vooraf ingerichte scenario's zijn volledig
verzonnen. Geen enkel gegeven verwijst naar een bestaand persoon, voertuig of
bedrijf; e-mailadressen gebruiken uitsluitend het testdomein <code>.test</code>.
</p>
<h2>{t("about.syntheticTitle")}</h2>
<p>{t("about.syntheticBody", { testDomain: ".test" })}</p>
</section>
<section className="record-surface about-card">
<h2>Architectuur in het kort</h2>
<p>
Een React/TypeScript-frontend praat met een FastAPI-backend (PostgreSQL via
SQLAlchemy/Alembic-migraties); belangrijke bedrijfsregels leven in de backend, niet
in n8n of in prompts. Retours en andere gebeurtenissen worden eerst lokaal
gecommit en pas daarna asynchroon via een outbox-patroon aan n8n afgeleverd, zodat
een tijdelijke storing in de automatisering nooit een operationele actie blokkeert.
</p>
<h2>{t("about.architectureTitle")}</h2>
<p>{t("about.architectureBody")}</p>
</section>
<section className="record-surface about-card">
<h2>Beveiliging en toegang</h2>
<p>
Toegang verloopt via ondertekende, HTTP-only sessiecookies per rol; elke rol
gebonden aan een set toegestane routes, zowel serverzijdig afgedwongen als in de
navigatie weerspiegeld. Belangrijke statuswijzigingen worden altijd gecontroleerd
en gelogd — nooit stilzwijgend automatisch gecorrigeerd.
</p>
<h2>{t("about.securityTitle")}</h2>
<p>{t("about.securityBody")}</p>
</section>
<section className="record-surface about-card">
<h2>Hoe dit getest is</h2>
<p>
Een geautomatiseerde backend-testsuite dekt bedrijfsregels en API-contracten;
een volledige Playwright-eindtot-eind-suite dekt de gebruikersstromen, inclusief
deze demo-ervaring zelf. Elke wijziging wordt bovendien tegen een schone checkout
(lege database, opnieuw opgebouwd vanaf de seed-data) gevalideerd voor deployment.
</p>
<h2>{t("about.testingTitle")}</h2>
<p>{t("about.testingBody")}</p>
</section>
<section aria-label="Koppelingsstatus" className="record-surface">
<SectionHeading
title="Koppelingen — eerlijk gelabeld"
description="Wat operationeel is, wat demomodus is, en wat nog niet gekoppeld is."
/>
<section aria-label={t("about.integrationsTitle")} className="record-surface">
<SectionHeading title={t("about.integrationsTitle")} description={t("about.integrationsDescription")} />
<div className="integration-cards">
{manifest.integrations.map((integration) => (
<article key={integration.key}>
<IntegrationMark kind={INTEGRATION_ICON[integration.key]} />
<div>
<span className="integration-kicker">{integration.status_label}</span>
<h2>{integration.label}</h2>
<p>{integration.detail}</p>
<span className="integration-kicker">
{t(`integrations:statusLabels.${statusLabelKey(integration.key, integration.status_code)}`)}
</span>
<h2>{t(`integrationSummary.titles.${integration.key}`, { ns: "demo" })}</h2>
<p>{t(`integrationSummary.${integration.detail_code}`, { ns: "demo", ...integration.detail_params })}</p>
</div>
</article>
))}
@@ -149,29 +110,22 @@ export function AboutDemo() {
</section>
<section className="record-surface about-card">
<h2>Demo-omgeving herstellen</h2>
<h2>{t("about.resetTitle")}</h2>
<p>
De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset:{" "}
<strong>{formatDateTime(manifest.last_reset_at)}</strong>.{" "}
{user?.role === "operations_manager" ? (
<>
Gebruik <strong>Reset demo data</strong> in de zijbalk om opnieuw te beginnen.
</>
) : (
<>Een Operations Manager kan de demo-omgeving herstellen via de zijbalk.</>
)}
<Trans
i18nKey={user?.role === "operations_manager" ? "about.resetBodyManager" : "about.resetBodyEmployee"}
t={t}
values={{ when: manifest.last_reset_at ? formatDateTime(manifest.last_reset_at) : t("about.unknown") }}
components={{ strong: <strong /> }}
/>
</p>
</section>
<section className="access-note record-surface" aria-label="Beperkingen">
<section className="access-note record-surface" aria-label={t("about.limitationsTitle")}>
<Icon name="shield" />
<p>
<strong>Beperkingen</strong>
<span>
Dit is een gerichte proof of concept, geen volledig ERP. RAGcore en de ITWorx
MCP Hub zijn nog niet live gekoppeld; de kennisassistent gebruikt een lokale,
afgebakende demokennisbank in plaats van een live RAGcore-omgeving.
</span>
<strong>{t("about.limitationsTitle")}</strong>
<span>{t("about.limitationsBody")}</span>
</p>
</section>
</>
+154 -65
View File
@@ -1,31 +1,71 @@
import { useEffect, useState } from "react";
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 { AuditEvent } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { useLocaleFormat } from "../i18n/format";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
function describeChanges(before: Record<string, unknown> | null, after: Record<string, unknown> | null): string {
if (!before && !after) return "No recorded change detail.";
function humanizeField(field: string): string {
const spaced = field.replace(/_/g, " ");
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
function shortRef(id: string): string {
return `AUD-${id.slice(0, 8).toUpperCase()}`;
}
function actionLabel(t: (key: string, options?: Record<string, unknown>) => string, action: string): string {
return t(`actions.${action}`, { defaultValue: action.replace(/_/g, " ") });
}
function ChangeDiff({
before,
after,
t,
}: {
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
if (!before && !after) return <p className="table-subtext">{t("noChangeDetail")}</p>;
const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]);
const lines: string[] = [];
const lines: { field: string; text: string }[] = [];
for (const key of keys) {
const b = before?.[key];
const a = after?.[key];
if (JSON.stringify(b) === JSON.stringify(a)) continue;
if (b === undefined) lines.push(`${key}: set to ${JSON.stringify(a)}`);
else if (a === undefined) lines.push(`${key}: was ${JSON.stringify(b)}`);
else lines.push(`${key}: ${JSON.stringify(b)} → ${JSON.stringify(a)}`);
const field = humanizeField(key);
if (b === undefined) lines.push({ field, text: t("diff.setTo", { field, value: JSON.stringify(a) }) });
else if (a === undefined) lines.push({ field, text: t("diff.was", { field, value: JSON.stringify(b) }) });
else lines.push({ field, text: t("diff.changed", { field, before: JSON.stringify(b), after: JSON.stringify(a) }) });
}
return lines.length > 0 ? lines.join("; ") : "No field-level change detected.";
if (lines.length === 0) return <p className="table-subtext">{t("noFieldChange")}</p>;
return (
<ul className="change-diff">
{lines.map((line) => (
<li key={line.field}>{line.text}</li>
))}
</ul>
);
}
interface EventGroup {
correlationId: string;
primary: AuditEvent;
related: AuditEvent[];
}
export function Audit() {
const { t } = useTranslation("audit");
const { formatDateTime } = useLocaleFormat();
const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const [events, setEvents] = useState<AuditEvent[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [action, setAction] = useState(searchParams.get("action") ?? "");
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const correlationId = searchParams.get("correlation_id") ?? "";
useEffect(() => {
@@ -38,9 +78,26 @@ export function Audit() {
api
.get<AuditEvent[]>(`/api/v1/audit?${params.toString()}`)
.then(setEvents)
.catch(() => setError("Audit trail is unavailable right now."));
.catch(() => setError(t("unavailable")));
}, [action, correlationId, user]);
const groups = useMemo<EventGroup[]>(() => {
if (!events) return [];
const order: string[] = [];
const byCorrelation = new Map<string, AuditEvent[]>();
for (const event of events) {
if (!byCorrelation.has(event.correlation_id)) {
order.push(event.correlation_id);
byCorrelation.set(event.correlation_id, []);
}
byCorrelation.get(event.correlation_id)!.push(event);
}
return order.map((id) => {
const group = byCorrelation.get(id)!;
return { correlationId: id, primary: group[0], related: group.slice(1) };
});
}, [events]);
function showRelatedEvents(id: string) {
setSearchParams({ correlation_id: id });
}
@@ -49,91 +106,123 @@ export function Audit() {
setSearchParams(action ? { action } : {});
}
function toggleExpanded(id: string) {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
}
if (user?.role !== "operations_manager") {
return (
<div className="page">
<PageHeader eyebrow="Assurance / Immutable history" title="Audit trail" description="The audit trail is visible to Operations Managers only." />
<p>Audit history is visible to Operations Managers only.</p>
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("managerOnly")} />
<p>{t("managerOnlyDetail")}</p>
</div>
);
}
return (
<div className="page">
<PageHeader eyebrow="Assurance / Immutable history" title="Audit trail" description="Trace important state changes, actors and correlation references." />
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
<form className="filters" aria-label="Filter audit events">
<form className="filters" aria-label={t("title")}>
<label>
Action
{t("actionFilterLabel")}
<input
type="text"
value={action}
onChange={(e) => setAction(e.target.value)}
placeholder="e.g. demo_login"
placeholder={t("actionFilterPlaceholder")}
/>
</label>
</form>
{correlationId && (
<p className="quiet-empty" role="status">
Showing only events linked to this action ({events?.length ?? "…"} related events).{" "}
{t("relatedFilterActive", { count: events?.length ?? 0 })}{" "}
<button type="button" className="link-button" onClick={clearCorrelationFilter}>
Clear this filter
{t("clearFilter")}
</button>
</p>
)}
{error && <ErrorState message={error} />}
{!error && !events && <LoadingState label="Loading audit trail…" />}
{events && events.length === 0 && <EmptyState icon="audit" title="No audit events found" detail="Adjust the action filter." />}
{!error && !events && <LoadingState label={t("loading")} />}
{events && events.length === 0 && <EmptyState icon="audit" title={t("empty")} detail={t("emptyDetail")} />}
{events && events.length > 0 && (
<div className="table-shell"><div className="table-meta"><span>{events.length} events</span><span>UTC stored · Brussels rendered</span></div><table className="data-table">
<caption className="visually-hidden">Audit events</caption>
<thead>
<tr>
<th scope="col">When</th>
<th scope="col">Actor</th>
<th scope="col">Action</th>
<th scope="col">Entity</th>
<th scope="col">Change</th>
<th scope="col">Follow-up</th>
<th scope="col">Details</th>
</tr>
</thead>
<tbody>
{events.map((e) => (
<tr key={e.id}>
<td data-label="When">
<time dateTime={e.occurred_at}>
{new Date(e.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
</time>
</td>
<td data-label="Actor"><strong>{e.actor_label}</strong><small className="table-subtext">{e.actor_type}</small></td>
<td data-label="Action">{e.action.replace(/_/g, " ")}</td>
<td data-label="Entity">
{e.entity_link ? (
<Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link>
) : (
e.entity_ref ?? e.entity_type
)}
</td>
<td data-label="Change">{describeChanges(e.before, e.after)}</td>
<td data-label="Follow-up">
<button type="button" className="link-button" onClick={() => showRelatedEvents(e.correlation_id)}>
View related events
</button>
</td>
<td className="mono" data-label="Details">
<details>
<summary>{e.correlation_id.slice(0, 8)}</summary>
<pre>{JSON.stringify({ before: e.before, after: e.after, metadata: e.metadata }, null, 2)}</pre>
{groups.length > 0 && (
<div className="table-shell">
<div className="table-meta"><span>{t("count", { count: events!.length })}</span><span>{t("storedRendered")}</span></div>
<ul className="audit-group-list">
{groups.map((group) => {
const e = group.primary;
const isExpanded = expanded[group.correlationId] ?? false;
return (
<li key={group.correlationId} className="audit-group panel">
<div className="audit-group-summary">
<div className="audit-group-heading">
<strong>{actionLabel(t, e.action)}</strong>
<span className="table-subtext">{formatDateTime(e.occurred_at)}</span>
</div>
<div className="audit-group-meta">
<span><strong>{e.actor_label}</strong> <small className="table-subtext">{e.actor_type}</small></span>
{e.entity_link ? (
<Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link>
) : (
<span>{e.entity_ref ?? e.entity_type}</span>
)}
</div>
<ChangeDiff before={e.before} after={e.after} t={t} />
<div className="audit-group-actions">
{group.related.length > 0 && (
<button type="button" className="link-button" onClick={() => toggleExpanded(group.correlationId)}>
{isExpanded
? t("hideTechnicalEvents")
: t("showTechnicalEvents", { count: group.related.length })}
</button>
)}
{!correlationId && (
<button type="button" className="link-button" onClick={() => showRelatedEvents(group.correlationId)}>
{t("viewRelatedEvents")}
</button>
)}
</div>
</div>
<details className="evidence-disclosure">
<summary>{t("technicalDetails")}</summary>
<dl className="detail-grid audit-technical-grid">
<div><dt>{t("reference")}</dt><dd>{shortRef(e.id)}</dd></div>
<div><dt>{t("fullReference")}</dt><dd className="mono">{e.id}</dd></div>
<div><dt>{t("correlationId")}</dt><dd className="mono">{e.correlation_id}</dd></div>
</dl>
<pre className="evidence-block">{JSON.stringify({ before: e.before, after: e.after, metadata: e.metadata }, null, 2)}</pre>
</details>
</td>
</tr>
))}
</tbody>
</table></div>
{isExpanded && group.related.length > 0 && (
<ul className="audit-related-list">
{group.related.map((related) => (
<li key={related.id}>
<div className="audit-group-heading">
<strong>{actionLabel(t, related.action)}</strong>
<span className="table-subtext">{formatDateTime(related.occurred_at)}</span>
</div>
<ChangeDiff before={related.before} after={related.after} t={t} />
<details className="evidence-disclosure">
<summary>{t("technicalDetails")}</summary>
<dl className="detail-grid audit-technical-grid">
<div><dt>{t("reference")}</dt><dd>{shortRef(related.id)}</dd></div>
<div><dt>{t("fullReference")}</dt><dd className="mono">{related.id}</dd></div>
</dl>
<pre className="evidence-block">{JSON.stringify({ before: related.before, after: related.after, metadata: related.metadata }, null, 2)}</pre>
</details>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
</div>
)}
</div>
);
+151 -90
View File
@@ -1,16 +1,30 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import type { AutomationRun, IntegrationStatus, KnowledgeHealth } from "../api/types";
import { StatusBadge } from "../components/Badge";
import { useAuth } from "../context/AuthContext";
import { useLocaleFormat } from "../i18n/format";
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels";
type ViewFilter = "attention" | "recent" | "succeeded" | "all";
function deriveDisplayRef(run: AutomationRun): string {
const digits = run.aggregate_ref.match(/(\d+)$/)?.[1];
if (digits) return `AUT-RET-${digits}`;
return `AUT-${run.event_id.slice(0, 4).toUpperCase()}`;
}
export function Automation() {
const { t } = useTranslation(["integrations", "common"]);
const { formatDateTime } = useLocaleFormat();
const { user } = useAuth();
const [runs, setRuns] = useState<AutomationRun[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState("");
const [view, setView] = useState<ViewFilter>("attention");
const [expandSucceeded, setExpandSucceeded] = useState(false);
const [retryError, setRetryError] = useState<string | null>(null);
const [retrying, setRetrying] = useState<string | null>(null);
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
@@ -25,13 +39,9 @@ export function Automation() {
.get<AutomationRun[]>(`/api/v1/workflows?${params.toString()}`)
.then(setRuns)
.catch(() =>
setError(
user?.role === "operations_manager"
? "Automation runs are unavailable right now."
: "Automation is only visible to Operations Managers.",
),
setError(user?.role === "operations_manager" ? t("ledger.unavailable") : t("ledger.managerOnly")),
);
}, [status, user]);
}, [status, user, t]);
useEffect(() => {
load();
@@ -60,35 +70,90 @@ export function Automation() {
load();
loadIntegrationStatus();
} catch (err) {
setRetryError(err instanceof ApiError ? err.message : "Could not retry this delivery.");
setRetryError(err instanceof ApiError ? err.message : t("ledger.retryFailed"));
} finally {
setRetrying(null);
}
}
const visibleRuns = useMemo(() => {
if (!runs) return null;
switch (view) {
case "attention":
return runs.filter((r) => r.status === "failed" || r.status === "pending" || r.status === "delivering");
case "recent":
return runs.slice(0, 5);
case "succeeded":
return runs.filter((r) => r.status === "succeeded");
default:
return runs;
}
}, [runs, view]);
const succeededRuns = useMemo(() => (visibleRuns ?? []).filter((r) => r.status === "succeeded"), [visibleRuns]);
const otherRuns = useMemo(() => (visibleRuns ?? []).filter((r) => r.status !== "succeeded"), [visibleRuns]);
const groupSucceeded = view !== "succeeded" && succeededRuns.length > 3;
if (user?.role !== "operations_manager") {
return (
<div className="page">
<PageHeader eyebrow="Assurance / Integrations" title="Integrations" description="Delivery evidence is visible to Operations Managers only." />
<p>Automation delivery status is visible to Operations Managers only.</p>
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("ledger.managerOnly")} />
<p>{t("ledger.managerOnly")}</p>
</div>
);
}
function renderRow(r: AutomationRun) {
return (
<tr key={r.event_id}>
<td className="mono" data-label={t("ledger.columns.event")}>
<details>
<summary>{deriveDisplayRef(r)}</summary>
<span className="table-subtext">{r.event_id}</span>
</details>
</td>
<td data-label={t("ledger.columns.type")}>{t(`ledger.eventTypes.${r.event_type}`, { defaultValue: r.event_type })}</td>
<td data-label={t("ledger.columns.booking")}>{r.aggregate_ref}</td>
<td data-label={t("ledger.columns.status")}>
<StatusBadge status={r.status} label={t(`ledger.status${r.status.charAt(0).toUpperCase()}${r.status.slice(1)}`)} />
</td>
<td data-label={t("ledger.columns.attempts")}>{r.attempts}</td>
<td data-label={t("ledger.columns.lastError")}>{r.last_error ?? "—"}</td>
<td data-label={t("ledger.columns.when")}>
<time dateTime={r.occurred_at}>{formatDateTime(r.occurred_at)}</time>
</td>
<td data-label={t("ledger.columns.action")}>
{r.status === "failed" ? (
<button type="button" onClick={() => handleRetry(r.event_id)} disabled={retrying === r.event_id}>
{retrying === r.event_id ? t("ledger.retrying") : t("ledger.retry")}
</button>
) : (
t("ledger.noAction")
)}
</td>
</tr>
);
}
return (
<div className="page">
<PageHeader eyebrow="Assurance / Integrations" title="Integration control" description="Monitor delivery health, graceful degradation and retryable workflow events." />
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
<section className="integration-cards" aria-label="Integration health">
<section className="integration-cards" aria-label={t("title")}>
<article>
<IntegrationMark kind="n8n" />
<div>
<span className="integration-kicker">Orchestration</span>
<h2>n8n delivery</h2>
<span className="integration-kicker">{t("cards.orchestrationKicker")}</span>
<h2>{t("cards.n8nTitle")}</h2>
<p>
{integrationStatus
? `${integrationStatus.n8n.succeeded} succeeded · ${integrationStatus.n8n.failed} failed · ${integrationStatus.n8n.pending} pending · ${integrationStatus.n8n.delivering} delivering.`
: "Return events are committed locally first and then delivered through the outbox."}
? t("cards.n8nSummary", {
succeeded: integrationStatus.n8n.succeeded,
failed: integrationStatus.n8n.failed,
pending: integrationStatus.n8n.pending,
delivering: integrationStatus.n8n.delivering,
})
: t("cards.n8nFallback")}
</p>
</div>
{(() => {
@@ -96,7 +161,7 @@ export function Automation() {
return (
<StatusBadge
status={meta?.statusClass ?? (runs?.[0]?.status ?? "no_events")}
label={meta?.label}
label={meta ? t(`statusLabels.${meta.labelKey}`) : undefined}
/>
);
})()}
@@ -104,111 +169,107 @@ export function Automation() {
<article>
<IntegrationMark kind="rag" />
<div>
<span className="integration-kicker">Knowledge</span>
<h2>Knowledge assistant</h2>
<span className="integration-kicker">{t("cards.knowledgeKicker")}</span>
<h2>{t("cards.knowledgeTitle")}</h2>
<p>
{knowledge
? `${knowledge.provider === "ragcore" ? "RAGcore" : "Demo knowledge base"} · ${knowledge.document_count} procedures indexed in ${knowledge.collection}.`
: "Health evidence is currently unavailable."}
? t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", {
count: knowledge.document_count,
collection: knowledge.collection,
})
: t("cards.knowledgeUnavailable")}
</p>
</div>
<StatusBadge
status={knowledge?.available ? "available" : "unavailable"}
label={
knowledge?.available
? knowledge.provider === "ragcore"
? "Operational"
: "Demo mode"
: "Unavailable"
? t(`statusLabels.${knowledge.provider === "ragcore" ? "operational" : "demoMode"}`)
: t("statusLabels.unavailable")
}
/>
</article>
<article>
<IntegrationMark kind="mcp" />
<div>
<span className="integration-kicker">Tool gateway</span>
<h2>MCP Hub</h2>
<p>
{integrationStatus?.mcp_hub.registration_enabled
? "Registration is enabled for this deployment."
: "Not yet connected — prepared for future controlled tool calls from the ITWorx MCP Hub."}
</p>
<span className="integration-kicker">{t("cards.gatewayKicker")}</span>
<h2>{t("cards.mcpTitle")}</h2>
<p>{integrationStatus?.mcp_hub.registration_enabled ? t("cards.mcpEnabled") : t("cards.mcpNotConnected")}</p>
</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(`statusLabels.${meta.labelKey}`) : undefined} />;
})()}
</article>
</section>
<SectionHeading title="Delivery ledger" description="Persisted outbox attempts with the latest failure evidence." />
<SectionHeading title={t("ledger.title")} description={t("ledger.description")} />
<form className="filters" aria-label="Filter automation runs">
<form className="filters" aria-label={t("ledger.title")}>
<label>
Status
{t("ledger.filterLabel")}
<select value={view} onChange={(e) => setView(e.target.value as ViewFilter)}>
<option value="attention">{t("ledger.filterNeedsAttention")}</option>
<option value="recent">{t("ledger.filterRecent")}</option>
<option value="succeeded">{t("ledger.filterSucceeded")}</option>
<option value="all">{t("ledger.filterAll")}</option>
</select>
</label>
<label>
{t("ledger.statusFilterLabel")}
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All statuses</option>
<option value="pending">Pending</option>
<option value="delivering">Delivering</option>
<option value="succeeded">Succeeded</option>
<option value="failed">Failed</option>
<option value="">{t("ledger.statusAll")}</option>
<option value="pending">{t("ledger.statusPending")}</option>
<option value="delivering">{t("ledger.statusDelivering")}</option>
<option value="succeeded">{t("ledger.statusSucceeded")}</option>
<option value="failed">{t("ledger.statusFailed")}</option>
</select>
</label>
</form>
{error && <ErrorState message={error} />}
{retryError && <p className="error" role="alert">{retryError}</p>}
{!error && !runs && <LoadingState label="Loading workflow delivery ledger…" />}
{runs && runs.length === 0 && <p>No automation runs match this filter.</p>}
{!error && !runs && <LoadingState label={t("ledger.loading")} />}
{visibleRuns && visibleRuns.length === 0 && <p>{t("ledger.empty")}</p>}
{runs && runs.length > 0 && (
<div className="table-shell"><div className="table-meta"><span>{runs.length} workflow events</span><span>Bounded retries</span></div><table className="data-table">
<caption className="visually-hidden">Automation runs</caption>
<thead>
<tr>
<th scope="col">Event</th>
<th scope="col">Type</th>
<th scope="col">Booking</th>
<th scope="col">Status</th>
<th scope="col">Attempts</th>
<th scope="col">Last error</th>
<th scope="col">When</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
{runs.map((r) => (
<tr key={r.event_id}>
<td className="mono" data-label="Event">{r.event_id.slice(0, 8)}</td>
<td data-label="Type">{r.event_type}</td>
<td data-label="Booking">{r.aggregate_ref}</td>
<td data-label="Status">
<StatusBadge status={r.status} />
</td>
<td data-label="Attempts">{r.attempts}</td>
<td data-label="Last error">{r.last_error ?? "—"}</td>
<td data-label="When">
<time dateTime={r.occurred_at}>
{new Date(r.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
</time>
</td>
<td data-label="Action">
{r.status === "failed" ? (
<button
type="button"
onClick={() => handleRetry(r.event_id)}
disabled={retrying === r.event_id}
>
{retrying === r.event_id ? "Retrying…" : "Retry"}
</button>
) : (
"—"
)}
</td>
{visibleRuns && visibleRuns.length > 0 && (
<div className="table-shell">
<div className="table-meta"><span>{t("ledger.count", { count: visibleRuns.length })}</span><span>{t("ledger.boundedRetries")}</span></div>
<table className="data-table">
<caption className="visually-hidden">{t("ledger.title")}</caption>
<thead>
<tr>
<th scope="col">{t("ledger.columns.event")}</th>
<th scope="col">{t("ledger.columns.type")}</th>
<th scope="col">{t("ledger.columns.booking")}</th>
<th scope="col">{t("ledger.columns.status")}</th>
<th scope="col">{t("ledger.columns.attempts")}</th>
<th scope="col">{t("ledger.columns.lastError")}</th>
<th scope="col">{t("ledger.columns.when")}</th>
<th scope="col">{t("ledger.columns.action")}</th>
</tr>
))}
</tbody>
</table></div>
</thead>
<tbody>
{otherRuns.map(renderRow)}
{groupSucceeded ? (
<>
<tr>
<td colSpan={8}>
<button type="button" className="link-button" onClick={() => setExpandSucceeded((v) => !v)}>
{expandSucceeded
? t("ledger.hideIndividually")
: t("ledger.groupedSucceeded", { count: succeededRuns.length })}
</button>
</td>
</tr>
{expandSucceeded && succeededRuns.map(renderRow)}
</>
) : (
succeededRuns.map(renderRow)
)}
</tbody>
</table>
</div>
)}
</div>
);
+19 -20
View File
@@ -1,14 +1,18 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { Booking, RegisterReturnResult, VehicleDetail } from "../api/types";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge";
import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm";
import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
export function BookingDetail() {
const { t } = useTranslation(["bookings", "returns"]);
const { formatDateTime, formatNumber } = useLocaleFormat();
const { publicRef } = useParams<{ publicRef: string }>();
const { manifest } = useDemoManifest();
const [booking, setBooking] = useState<Booking | null>(null);
@@ -21,7 +25,7 @@ export function BookingDetail() {
api
.get<Booking>(`/api/v1/bookings/${publicRef}`)
.then(setBooking)
.catch(() => setError("This booking could not be found."));
.catch(() => setError(t("detail.notFound")));
}, [publicRef]);
useEffect(() => {
@@ -52,33 +56,28 @@ export function BookingDetail() {
}
if (error) return <ErrorState message={error} />;
if (!booking) return <LoadingState label="Loading booking record…" />;
if (!booking) return <LoadingState label={t("detail.loading")} />;
return (
<div className="page">
<Link className="back-link" to="/bookings"><Icon name="arrow-left" /> Booking ledger</Link>
<PageHeader eyebrow="Bookings / Rental record" title={booking.public_ref} description={`${booking.customer_name} · ${booking.vehicle_ref}`} actions={<StatusBadge status={booking.status} />} />
<section className="record-surface" aria-label="Booking facts"><dl className="detail-grid">
<div><dt>Customer</dt><dd>{booking.customer_name} ({booking.customer_ref})</dd></div>
<div><dt>Vehicle</dt><dd><Link to={`/vehicles/${booking.vehicle_ref}`}>{booking.vehicle_ref}</Link></dd></div>
<div><dt>Starts</dt><dd>{new Date(booking.starts_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}</dd></div>
<div><dt>Ends</dt><dd>{new Date(booking.ends_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}</dd></div>
<div><dt>Start odometer</dt><dd>{booking.start_odometer_km ?? "—"} km</dd></div>
<div><dt>End odometer</dt><dd>{booking.end_odometer_km ?? "—"} km</dd></div>
<div><dt>Requirements complete</dt><dd>{booking.requirements_complete ? "Yes" : "No"}</dd></div>
<Link className="back-link" to="/bookings"><Icon name="arrow-left" /> {t("detail.backLink")}</Link>
<PageHeader eyebrow={t("detail.eyebrow")} title={booking.public_ref} description={`${booking.customer_name} · ${booking.vehicle_ref}`} actions={<StatusBadge status={booking.status} label={t(`statuses.${booking.status}`, { defaultValue: booking.status })} />} />
<section className="record-surface" aria-label={t("detail.eyebrow")}><dl className="detail-grid">
<div><dt>{t("detail.customer")}</dt><dd>{booking.customer_name} ({booking.customer_ref})</dd></div>
<div><dt>{t("detail.vehicle")}</dt><dd><Link to={`/vehicles/${booking.vehicle_ref}`}>{booking.vehicle_ref}</Link></dd></div>
<div><dt>{t("detail.starts")}</dt><dd>{formatDateTime(booking.starts_at)}</dd></div>
<div><dt>{t("detail.ends")}</dt><dd>{formatDateTime(booking.ends_at)}</dd></div>
<div><dt>{t("detail.startOdometer")}</dt><dd>{booking.start_odometer_km !== null ? `${formatNumber(booking.start_odometer_km)} km` : "—"}</dd></div>
<div><dt>{t("detail.endOdometer")}</dt><dd>{booking.end_odometer_km !== null ? `${formatNumber(booking.end_odometer_km)} km` : "—"}</dd></div>
<div><dt>{t("detail.requirementsComplete")}</dt><dd>{booking.requirements_complete ? t("detail.yes") : t("detail.no")}</dd></div>
</dl></section>
{isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && (
<section className="record-surface scenario-callout" aria-label="Demo scenario">
<Icon name="spark" />
<div>
<strong>Demonstratiescenario: afwijkende kilometerstand</strong>
<p>
Dit voertuig staat momenteel op <strong>{canonicalOdometerKm.toLocaleString("en-GB")} km</strong>.
Het onderstaande formulier is vooraf ingevuld met een retourstand die daaronder
ligt — een teken van een foutieve invoer of een verwisseld voertuig. Bevestig de
retour om te zien hoe MobilityOps dit detecteert en afhandelt.
</p>
<strong>{t("returns:scenario.title")}</strong>
<p>{t("returns:scenario.body", { odometer: formatNumber(canonicalOdometerKm) })}</p>
</div>
</section>
)}
@@ -89,7 +88,7 @@ export function BookingDetail() {
from its very first render -- never updated asynchronously after mount, which
previously raced with anyone already typing into the field. */}
{!returnResult && booking.status === "active" && isReturnAnomalyScenario && canonicalOdometerKm === null && (
<LoadingState label="Scenario voorbereiden…" />
<LoadingState label={t("returns:scenario.preparing")} />
)}
{!returnResult && booking.status === "active" && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && (
<ReturnForm
+33 -28
View File
@@ -1,13 +1,17 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { Booking } from "../api/types";
import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"];
export function Bookings() {
const { t } = useTranslation("bookings");
const { formatShortDate } = useLocaleFormat();
const [bookings, setBookings] = useState<Booking[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState("");
@@ -23,7 +27,7 @@ export function Bookings() {
api
.get<Booking[]>(`/api/v1/bookings?${params.toString()}`)
.then(setBookings)
.catch(() => setError("Booking list is unavailable right now."));
.catch(() => setError(t("list.unavailable")));
}, [status]);
useEffect(() => {
@@ -37,20 +41,20 @@ export function Bookings() {
return (
<div className="page">
<PageHeader eyebrow="Operations / Schedule" title="Bookings" description="Review active rental windows and upcoming vehicle commitments." />
<PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("list.description")} />
<form className="filters" aria-label="Filter bookings">
<form className="filters" aria-label={t("list.title")}>
<label>
Search
<input type="text" value={query} onChange={(e) => { setQuery(e.target.value); setPage(1); }} placeholder="Booking, customer or vehicle" />
{t("list.searchLabel")}
<input type="text" value={query} onChange={(e) => { setQuery(e.target.value); setPage(1); }} placeholder={t("list.searchPlaceholder")} />
</label>
<label>
Status
{t("list.statusLabel")}
<select value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }}>
<option value="">All statuses</option>
<option value="">{t("list.statusAll")}</option>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
{t(`statuses.${s}`)}
</option>
))}
</select>
@@ -58,44 +62,45 @@ export function Bookings() {
</form>
{error && <ErrorState message={error} />}
{!error && !bookings && <LoadingState label="Loading booking ledger…" />}
{bookings && bookings.length === 0 && <EmptyState icon="bookings" title="No bookings found" detail="Adjust the booking status filter." />}
{!error && !bookings && <LoadingState label={t("list.loading")} />}
{bookings && bookings.length === 0 && <EmptyState icon="bookings" title={t("list.empty")} detail={t("list.emptyDetail")} />}
{bookings && bookings.length > 0 && (() => {
const filtered = bookings.filter((b) => `${b.public_ref} ${b.customer_name} ${b.vehicle_ref}`.toLowerCase().includes(query.toLowerCase()));
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage));
const visible = filtered.slice((page - 1) * perPage, page * perPage);
return filtered.length === 0 ? <EmptyState icon="search" title="No matching bookings" detail="Try a broader search term." /> : <div className="table-shell"><div className="table-meta"><span>{filtered.length} bookings</span><span>Page {page} of {totalPages}</span></div><table className="data-table">
<caption className="visually-hidden">Bookings</caption>
return filtered.length === 0 ? <EmptyState icon="search" title={t("list.noMatch")} detail={t("list.noMatchDetail")} /> : <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: filtered.length })}</span><span>{t("list.pageOf", { page, total: totalPages })}</span></div><table className="data-table">
<caption className="visually-hidden">{t("list.title")}</caption>
<thead>
<tr>
<th scope="col">Reference</th>
<th scope="col">Customer</th>
<th scope="col">Vehicle</th>
<th scope="col">Window</th>
<th scope="col">Status</th>
<th scope="col">{t("list.columns.reference")}</th>
<th scope="col">{t("list.columns.customer")}</th>
<th scope="col">{t("list.columns.vehicle")}</th>
<th scope="col">{t("list.columns.window")}</th>
<th scope="col">{t("list.columns.status")}</th>
</tr>
</thead>
<tbody>
{visible.map((b) => (
<tr key={b.public_ref}>
<th scope="row" data-label="Reference">
<Link to={`/bookings/${b.public_ref}`}>{b.public_ref}</Link>
<tr key={b.public_ref} className="row-clickable">
<th scope="row" data-label={t("list.columns.reference")}>
{b.public_ref}
<Link className="row-link" to={`/bookings/${b.public_ref}`}><span className="visually-hidden">{b.public_ref}</span></Link>
</th>
<td data-label="Customer">{b.customer_name}</td>
<td data-label="Vehicle">
<Link to={`/vehicles/${b.vehicle_ref}`}>{b.vehicle_ref}</Link>
<td data-label={t("list.columns.customer")}>{b.customer_name}</td>
<td data-label={t("list.columns.vehicle")}>
<Link to={`/vehicles/${b.vehicle_ref}`} className="cell-link">{b.vehicle_ref}</Link>
</td>
<td data-label="Window">
{new Date(b.starts_at).toLocaleDateString("en-GB")} → {new Date(b.ends_at).toLocaleDateString("en-GB")}
<td data-label={t("list.columns.window")}>
{formatShortDate(b.starts_at)} → {formatShortDate(b.ends_at)}
</td>
<td data-label="Status">
<StatusBadge status={b.status} />
<td data-label={t("list.columns.status")}>
<StatusBadge status={b.status} label={t(`statuses.${b.status}`, { defaultValue: b.status })} />
</td>
</tr>
))}
</tbody>
</table><div className="pagination" aria-label="Booking pages"><button type="button" disabled={page === 1} onClick={() => setPage((p) => p - 1)}>Previous</button><span>{(page - 1) * perPage + 1}–{Math.min(page * perPage, filtered.length)} of {filtered.length}</span><button type="button" disabled={page === totalPages} onClick={() => setPage((p) => p + 1)}>Next</button></div></div>;
</table><div className="pagination" aria-label={t("list.paginationLabel")}><button type="button" disabled={page === 1} onClick={() => setPage((p) => p - 1)}>{t("list.previous")}</button><span>{t("list.rangeOf", { from: (page - 1) * perPage + 1, to: Math.min(page * perPage, filtered.length), total: filtered.length })}</span><button type="button" disabled={page === totalPages} onClick={() => setPage((p) => p + 1)}>{t("list.next")}</button></div></div>;
})()}
</div>
);
+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. 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>} />
<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="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> : (
<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>
)}
+51 -46
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import type { DataQualityIssue, ScanResult } from "../api/types";
import { useAuth } from "../context/AuthContext";
@@ -15,6 +16,7 @@ const RULE_TYPES = [
];
export function DataQuality() {
const { t } = useTranslation("quality");
const { user } = useAuth();
const [issues, setIssues] = useState<DataQualityIssue[] | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -36,7 +38,7 @@ export function DataQuality() {
api
.get<DataQualityIssue[]>(`/api/v1/data-quality/issues?${params.toString()}`)
.then(setIssues)
.catch(() => setError("Data-quality issues are unavailable right now."));
.catch(() => setError(t("list.unavailable")));
}, [status, ruleType, user]);
useEffect(() => {
@@ -52,7 +54,7 @@ export function DataQuality() {
setConfirmingScan(false);
load();
} catch (err) {
setScanError(err instanceof ApiError ? err.message : "Could not run the quality scan.");
setScanError(err instanceof ApiError ? err.message : t("list.scanFailed"));
} finally {
setScanning(false);
}
@@ -61,8 +63,8 @@ export function DataQuality() {
if (user?.role !== "operations_manager") {
return (
<div className="page">
<PageHeader eyebrow="Assurance / Workbench" title="Data quality" description="The quality workbench is visible to Operations Managers only." />
<p>Data-quality evidence and resolutions are visible to Operations Managers only.</p>
<PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("detail.managerOnlyDetail")} />
<p>{t("detail.managerOnlyDetail")}</p>
</div>
);
}
@@ -77,22 +79,22 @@ export function DataQuality() {
return (
<div className="page">
<PageHeader
eyebrow="Assurance / Workbench"
title="Data quality"
description="Resolve evidence-backed exceptions before they disrupt operations."
eyebrow={t("list.eyebrow")}
title={t("list.title")}
description={t("list.description")}
actions={
!confirmingScan ? (
<button className="button button-secondary" type="button" onClick={() => setConfirmingScan(true)} disabled={scanning}>
Run quality scan
{t("list.runScan")}
</button>
) : (
<div className="confirm-bar" role="alertdialog" aria-label="Confirm quality scan">
<p>Run the deterministic scan across all five rule types now?</p>
<div className="confirm-bar" role="alertdialog" aria-label={t("list.confirmScanTitle")}>
<p>{t("list.confirmScanBody")}</p>
<button type="button" onClick={handleScan} disabled={scanning}>
{scanning ? "Scanning…" : "Yes, run scan"}
{scanning ? t("list.scanning") : t("list.confirmScanYes")}
</button>
<button type="button" onClick={() => setConfirmingScan(false)} disabled={scanning}>
Cancel
{t("list.cancel")}
</button>
</div>
)
@@ -102,32 +104,34 @@ export function DataQuality() {
{scanError && <p className="error" role="alert">{scanError}</p>}
{scanResult && (
<p className="quiet-empty" role="status">
Scan complete: {scanTotal === 0
? "no new issues found (existing open issues are not recreated)."
: Object.entries(scanResult.created)
.map(([rule, count]) => `${count} new ${rule.replace(/_/g, " ")}`)
.join(", ")}
{t("list.scanComplete", {
summary: scanTotal === 0
? t("list.scanNoNew")
: Object.entries(scanResult.created)
.map(([rule, count]) => `${count} ${t(`ruleTypes.${rule}`, { defaultValue: rule.replace(/_/g, " ") })}`)
.join(", "),
})}
</p>
)}
<form className="filters" aria-label="Filter data-quality issues">
<form className="filters" aria-label={t("list.title")}>
<label>
Status
{t("list.statusLabel")}
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All statuses</option>
<option value="open">Open</option>
<option value="deferred">Deferred</option>
<option value="resolved">Resolved</option>
<option value="rejected">Rejected</option>
<option value="">{t("list.statusAll")}</option>
<option value="open">{t("list.statusOpen")}</option>
<option value="deferred">{t("list.statusDeferred")}</option>
<option value="resolved">{t("list.statusResolved")}</option>
<option value="rejected">{t("list.statusRejected")}</option>
</select>
</label>
<label>
Rule type
{t("list.ruleTypeLabel")}
<select value={ruleType} onChange={(e) => setRuleType(e.target.value)}>
<option value="">All rule types</option>
<option value="">{t("list.ruleTypeAll")}</option>
{RULE_TYPES.map((r) => (
<option key={r} value={r}>
{r.replace(/_/g, " ")}
{t(`ruleTypes.${r}`)}
</option>
))}
</select>
@@ -138,42 +142,43 @@ export function DataQuality() {
checked={demoScenariosOnly}
onChange={(e) => setDemoScenariosOnly(e.target.checked)}
/>
Demo scenario's only
{t("list.demoScenariosOnly")}
</label>
</form>
{error && <ErrorState message={error} />}
{!error && !issues && <LoadingState label="Loading quality workbench…" />}
{issues && issues.length === 0 && <EmptyState icon="check" title="Queue is clear" detail="No issues match the current filters." />}
{!error && !issues && <LoadingState label={t("list.loading")} />}
{issues && issues.length === 0 && <EmptyState icon="check" title={t("list.queueClear")} detail={t("list.noIssuesMatch")} />}
{issues && issues.length > 0 && visibleIssues.length === 0 && (
<EmptyState icon="check" title="No demo-scenario issues match" detail="Uncheck 'Demo scenario's only' to see the full queue." />
<EmptyState icon="check" title={t("list.noDemoIssuesMatch")} detail={t("list.noDemoIssuesMatchDetail")} />
)}
{visibleIssues.length > 0 && (
<div className="table-shell"><div className="table-meta"><span>{visibleIssues.length} issues</span><span>Evidence-backed detection</span></div><table className="data-table">
<caption className="visually-hidden">Data-quality issues</caption>
<div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
<caption className="visually-hidden">{t("list.title")}</caption>
<thead>
<tr>
<th scope="col">Reference</th>
<th scope="col">Rule</th>
<th scope="col">Entity</th>
<th scope="col">Severity</th>
<th scope="col">Status</th>
<th scope="col">{t("list.columns.reference")}</th>
<th scope="col">{t("list.columns.rule")}</th>
<th scope="col">{t("list.columns.entity")}</th>
<th scope="col">{t("list.columns.severity")}</th>
<th scope="col">{t("list.columns.status")}</th>
</tr>
</thead>
<tbody>
{visibleIssues.map((i) => (
<tr key={i.public_ref}>
<th scope="row" data-label="Reference">
<Link to={`/data-quality/${i.public_ref}`}>{i.public_ref}</Link>
<tr key={i.public_ref} className="row-clickable">
<th scope="row" data-label={t("list.columns.reference")}>
{i.public_ref}
<Link className="row-link" to={`/data-quality/${i.public_ref}`}><span className="visually-hidden">{i.public_ref}</span></Link>
</th>
<td data-label="Rule">{i.rule_type.replace(/_/g, " ")}</td>
<td data-label="Entity">{i.entity_ref}</td>
<td data-label="Severity">
<td data-label={t("list.columns.rule")}>{t(`ruleTypes.${i.rule_type}`, { defaultValue: i.rule_type.replace(/_/g, " ") })}</td>
<td data-label={t("list.columns.entity")}>{i.entity_ref}</td>
<td data-label={t("list.columns.severity")}>
<SeverityBadge severity={i.severity} />
</td>
<td data-label="Status">
<StatusBadge status={i.status} />
<td data-label={t("list.columns.status")}>
<StatusBadge status={i.status} label={t(`list.status${i.status.charAt(0).toUpperCase()}${i.status.slice(1)}`, { defaultValue: i.status })} />
</td>
</tr>
))}
+144 -181
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import type {
ApplyRecommendedStatusResult,
@@ -10,70 +11,42 @@ import { SeverityBadge, StatusBadge } from "../components/Badge";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"];
const RULE_EXPLAINERS: Record<string, { whatIsWrong: string; whyItMatters: string }> = {
possible_duplicate_customer: {
whatIsWrong:
"Two customer profiles share identifying details (email, phone or a very similar name) strongly enough that they are likely the same person, registered twice.",
whyItMatters:
"Duplicate customers split booking history across two records, risk duplicate billing, and confuse support conversations.",
},
missing_required_field: {
whatIsWrong:
"This record is missing information that's required for normal operation (for example, a customer with neither an email nor a phone number on file).",
whyItMatters:
"Without this data, the business can't reach the customer, or can't reliably identify the vehicle for compliance and hand-off checks.",
},
odometer_regression: {
whatIsWrong: "A submitted odometer reading is lower than the vehicle's last known (canonical) reading.",
whyItMatters:
"A falling odometer usually means a data-entry mistake or that readings were recorded against the wrong vehicle. Letting it through silently would corrupt maintenance scheduling and resale mileage history.",
},
booking_overlap: {
whatIsWrong: "The same vehicle is committed to two bookings whose date ranges overlap.",
whyItMatters:
"Only one of these bookings can actually be honoured. Left unresolved, a customer would arrive to find their vehicle already out with someone else.",
},
vehicle_status_conflict: {
whatIsWrong:
"This vehicle's stored operational status doesn't match what its own booking and inspection history implies it should be.",
whyItMatters:
"An incorrect status can make an unavailable vehicle appear bookable, or keep an available vehicle hidden from the fleet.",
},
};
function RuleExplainer({ ruleType }: { ruleType: string }) {
const explainer = RULE_EXPLAINERS[ruleType];
if (!explainer) return null;
const { t } = useTranslation("quality");
if (!t(`detail.explainer.${ruleType}.whatIsWrong`, { defaultValue: "" })) return null;
return (
<section className="rule-explainer" aria-label="Why this matters">
<section className="rule-explainer" aria-label={t("detail.explainer.whyItMatters")}>
<div>
<strong>What's wrong</strong>
<p>{explainer.whatIsWrong}</p>
<strong>{t("detail.explainer.whatIsWrong")}</strong>
<p>{t(`detail.explainer.${ruleType}.whatIsWrong`)}</p>
</div>
<div>
<strong>Why it matters</strong>
<p>{explainer.whyItMatters}</p>
<strong>{t("detail.explainer.whyItMatters")}</strong>
<p>{t(`detail.explainer.${ruleType}.whyItMatters`)}</p>
</div>
</section>
);
}
function EvidenceDisclosure({ issue }: { issue: IssueDetail }) {
const { t } = useTranslation("common");
return (
<details className="evidence-disclosure">
<summary>Technical evidence</summary>
<summary>{t("actions.technicalDetails")}</summary>
<pre className="evidence-block">{JSON.stringify(issue.evidence, null, 2)}</pre>
</details>
);
}
function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const { user } = useAuth();
const [survivorRef, setSurvivorRef] = useState(issue.entity_snapshot?.public_ref ?? "");
const [fieldChoices, setFieldChoices] = useState<Record<string, "a" | "b">>({});
@@ -82,7 +55,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
const [confirming, setConfirming] = useState(false);
if (!issue.entity_snapshot || !issue.related_snapshots[0]) {
return <p className="error">Both customers in this comparison could not be loaded.</p>;
return <p className="error">{t("detail.duplicateCustomer.bothMissing")}</p>;
}
const a: EntitySnapshot = issue.entity_snapshot;
const b: EntitySnapshot = issue.related_snapshots[0];
@@ -108,7 +81,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not merge these customers.");
setError(err instanceof ApiError ? err.message : t("detail.duplicateCustomer.mergeFailed"));
setConfirming(false);
} finally {
setSubmitting(false);
@@ -117,44 +90,41 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
if (user?.role !== "operations_manager") {
return (
<p className="panel">
Merging duplicate customers requires the Operations Manager role. Switch role to resolve
this issue.
</p>
<p className="panel">{t("detail.managerOnlyDetail")}</p>
);
}
return (
<section className="panel duplicate-compare" aria-labelledby="compare-heading">
<SectionHeading title="Compare and merge" description="Choose the canonical customer and review each conflicting field." />
<SectionHeading headingId="compare-heading" title={t("detail.duplicateCustomer.heading")} description={t("detail.duplicateCustomer.description")} />
{error && <p className="error" role="alert">{error}</p>}
<fieldset>
<legend>Keep as survivor</legend>
<label className="checkbox-label">
<fieldset className="choice-fieldset">
<legend>{t("detail.duplicateCustomer.keepAsSurvivor")}</legend>
<label className={`choice-card ${survivorRef === a.public_ref ? "is-selected" : ""}`}>
<input
type="radio"
name="survivor"
checked={survivorRef === a.public_ref}
onChange={() => setSurvivorRef(a.public_ref)}
/>
{a.public_ref}
<span className="choice-card-title">{a.public_ref}</span>
</label>
<label className="checkbox-label">
<label className={`choice-card ${survivorRef === b.public_ref ? "is-selected" : ""}`}>
<input
type="radio"
name="survivor"
checked={survivorRef === b.public_ref}
onChange={() => setSurvivorRef(b.public_ref)}
/>
{b.public_ref}
<span className="choice-card-title">{b.public_ref}</span>
</label>
</fieldset>
<table className="data-table compare-table">
<thead>
<tr>
<th scope="col">Field</th>
<th scope="col">{t("detail.duplicateCustomer.fieldColumn")}</th>
<th scope="col">{a.public_ref}</th>
<th scope="col">{b.public_ref}</th>
</tr>
@@ -166,7 +136,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
const differ = valueA !== valueB;
return (
<tr key={field}>
<th scope="row" data-label="Field">{field.replace(/_/g, " ")}{differ ? <span className="difference-mark">Differs</span> : <span className="match-mark">Match</span>}</th>
<th scope="row" data-label="Field">{t(`detail.duplicateCustomer.fields.${field}`)}{differ ? <span className="difference-mark">{t("detail.duplicateCustomer.differs")}</span> : <span className="match-mark">{t("detail.duplicateCustomer.match")}</span>}</th>
<td data-label={a.public_ref}>
{differ ? (
<label className="checkbox-label">
@@ -204,25 +174,22 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
</table>
<p className="merge-preview">
<strong>{loser.public_ref}</strong> will become a tombstone linked to{" "}
<strong>{survivor.public_ref}</strong>; its bookings will be rewired to the survivor.
{t("detail.duplicateCustomer.mergePreview", { loser: loser.public_ref, survivor: survivor.public_ref })}
</p>
{!confirming && (
<button type="button" onClick={() => setConfirming(true)}>
Merge into {survivor.public_ref}
<button type="button" className="button button-primary" onClick={() => setConfirming(true)}>
{t("detail.duplicateCustomer.mergeInto", { ref: survivor.public_ref })}
</button>
)}
{confirming && (
<div className="confirm-bar" role="alertdialog" aria-label="Confirm merge">
<p>
Merge {loser.public_ref} into {survivor.public_ref}? This cannot be undone.
</p>
<button type="button" onClick={handleMerge} disabled={submitting}>
{submitting ? "Merging…" : "Yes, merge"}
<div className="confirm-bar" role="alertdialog" aria-label={t("detail.duplicateCustomer.confirmMergeTitle")}>
<p>{t("detail.duplicateCustomer.confirmMergeBody", { loser: loser.public_ref, survivor: survivor.public_ref })}</p>
<button type="button" className="button button-primary" onClick={handleMerge} disabled={submitting}>
{submitting ? t("detail.duplicateCustomer.merging") : t("detail.duplicateCustomer.confirmMergeYes")}
</button>
<button type="button" onClick={() => setConfirming(false)} disabled={submitting}>
Cancel
<button type="button" className="button button-secondary" onClick={() => setConfirming(false)} disabled={submitting}>
{t("list.cancel")}
</button>
</div>
)}
@@ -230,26 +197,16 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
);
}
const CUSTOMER_FIELD_LABELS: Record<string, string> = {
first_name: "First name",
last_name: "Last name",
email: "Email",
phone: "Phone",
};
const VEHICLE_FIELD_LABELS: Record<string, string> = {
registration_number: "Registration number",
make: "Make",
model: "Model",
location: "Location",
};
function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const isCustomer = issue.entity_type === "customer";
const labels = isCustomer ? CUSTOMER_FIELD_LABELS : VEHICLE_FIELD_LABELS;
const fieldKeys = isCustomer
? ["first_name", "last_name", "email", "phone"]
: ["registration_number", "make", "model", "location"];
const snapshot = issue.entity_snapshot;
const [values, setValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
for (const key of Object.keys(labels)) {
for (const key of fieldKeys) {
initial[key] = snapshot && snapshot[key] ? String(snapshot[key]) : "";
}
return initial;
@@ -268,7 +225,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/provide-fields`, { fields });
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not save these fields.");
setError(err instanceof ApiError ? err.message : t("detail.missingField.saveFailed"));
} finally {
setSubmitting(false);
}
@@ -277,14 +234,15 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="missing-field-heading">
<SectionHeading
title="Provide the missing fields"
description={`Complete the record for ${snapshot?.public_ref ?? issue.entity_ref}. The issue resolves automatically once nothing required is missing.`}
headingId="missing-field-heading"
title={t("detail.missingField.heading")}
description={t("detail.missingField.description", { ref: snapshot?.public_ref ?? issue.entity_ref })}
/>
{error && <p className="error" role="alert">{error}</p>}
<div className="form-grid">
{Object.entries(labels).map(([field, label]) => (
{fieldKeys.map((field) => (
<label key={field}>
{label}
{t(`detail.missingField.fields.${field}`)}
<input
type="text"
value={values[field] ?? ""}
@@ -294,11 +252,11 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
))}
</div>
{isCustomer && (
<p className="table-subtext">At least one of email or phone is required.</p>
<p className="table-subtext">{t("detail.missingField.atLeastOne")}</p>
)}
<div className="form-actions">
<button className="button button-primary" type="submit" disabled={submitting}>
{submitting ? "Saving…" : "Save and re-check"}
{submitting ? t("detail.missingField.saving") : t("detail.missingField.saveAndRecheck")}
</button>
</div>
</form>
@@ -306,6 +264,8 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
}
function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const { formatNumber } = useLocaleFormat();
const bookingSnapshots = issue.related_snapshots.filter((s) => s.entity_type === "booking");
const [decision, setDecision] = useState<"retain_canonical" | "correct_reading">("retain_canonical");
const [bookingRef, setBookingRef] = useState(bookingSnapshots[0]?.public_ref ?? "");
@@ -328,7 +288,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not resolve this issue.");
setError(err instanceof ApiError ? err.message : t("detail.odometerRegression.resolveFailed"));
} finally {
setSubmitting(false);
}
@@ -337,25 +297,29 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="odometer-heading">
<SectionHeading
title="Resolve the odometer regression"
description="The canonical odometer is never lowered automatically -- choose how to reconcile it."
headingId="odometer-heading"
title={t("detail.odometerRegression.heading")}
description={t("detail.odometerRegression.description")}
/>
{error && <p className="error" role="alert">{error}</p>}
<dl className="detail-grid">
<div><dt>Canonical odometer</dt><dd>{Number(issue.entity_snapshot?.odometer_km ?? 0).toLocaleString("en-GB")} km</dd></div>
<div><dt>{t("detail.odometerRegression.canonicalOdometer")}</dt><dd>{formatNumber(Number(issue.entity_snapshot?.odometer_km ?? 0))} km</dd></div>
</dl>
<fieldset>
<legend>Decision</legend>
<label className="checkbox-label check-card">
<fieldset className="choice-fieldset">
<legend>{t("detail.odometerRegression.decisionLegend")}</legend>
<label className={`choice-card ${decision === "retain_canonical" ? "is-selected" : ""}`}>
<input
type="radio"
name="decision"
checked={decision === "retain_canonical"}
onChange={() => setDecision("retain_canonical")}
/>
Retain canonical -- treat the submitted reading as erroneous
<span className="choice-card-body">
<span className="choice-card-title">{t("detail.odometerRegression.retainCanonical")}</span>
<span className="choice-card-detail">{t("detail.odometerRegression.retainCanonicalDetail")}</span>
</span>
</label>
<label className="checkbox-label check-card">
<label className={`choice-card ${decision === "correct_reading" ? "is-selected" : ""} ${bookingSnapshots.length === 0 ? "is-disabled" : ""}`}>
<input
type="radio"
name="decision"
@@ -363,26 +327,29 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
onChange={() => setDecision("correct_reading")}
disabled={bookingSnapshots.length === 0}
/>
Correct the reading -- update the booking and canonical odometer
<span className="choice-card-body">
<span className="choice-card-title">{t("detail.odometerRegression.correctReading")}</span>
<span className="choice-card-detail">{t("detail.odometerRegression.correctReadingDetail")}</span>
</span>
</label>
{bookingSnapshots.length === 0 && (
<p className="table-subtext">No related booking is attached to this issue, so only "retain canonical" is available.</p>
<p className="table-subtext">{t("detail.odometerRegression.noBookingAttached")}</p>
)}
</fieldset>
{decision === "correct_reading" && (
<div className="form-grid">
<label>
Booking
{t("detail.odometerRegression.booking")}
<select value={bookingRef} onChange={(e) => setBookingRef(e.target.value)}>
{bookingSnapshots.map((snap) => (
<option key={snap.public_ref} value={snap.public_ref}>
{snap.public_ref} ({typeof snap.end_odometer_km === "number" ? snap.end_odometer_km.toLocaleString("en-GB") : "—"} km)
{snap.public_ref} ({typeof snap.end_odometer_km === "number" ? formatNumber(snap.end_odometer_km) : "—"} km)
</option>
))}
</select>
</label>
<label>
Corrected odometer (km)
{t("detail.odometerRegression.correctedOdometer")}
<input
type="number"
min={0}
@@ -394,12 +361,12 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
</div>
)}
<label>
Note
{t("detail.odometerRegression.note")}
<textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} rows={2} />
</label>
<div className="form-actions">
<button className="button button-primary" type="submit" disabled={submitting}>
{submitting ? "Resolving…" : "Resolve issue"}
{submitting ? t("detail.odometerRegression.resolving") : t("detail.odometerRegression.resolveIssue")}
</button>
</div>
</form>
@@ -407,6 +374,8 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
}
function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const { formatShortDate } = useLocaleFormat();
const bookings = issue.related_snapshots.filter((s) => s.entity_type === "booking");
const [bookingRef, setBookingRef] = useState(bookings[0]?.public_ref ?? "");
const [note, setNote] = useState("");
@@ -424,7 +393,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not resolve this overlap.");
setError(err instanceof ApiError ? err.message : t("detail.bookingOverlap.resolveFailed"));
} finally {
setSubmitting(false);
}
@@ -433,50 +402,41 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="overlap-heading">
<SectionHeading
title="Resolve the booking overlap"
description="Block one of the two overlapping commitments. The other keeps its current status."
headingId="overlap-heading"
title={t("detail.bookingOverlap.heading")}
description={t("detail.bookingOverlap.description")}
/>
{error && <p className="error" role="alert">{error}</p>}
<table className="data-table compare-table">
<thead>
<tr>
<th scope="col">Booking</th>
<th scope="col">Window</th>
<th scope="col">Status</th>
<th scope="col">Block this one</th>
</tr>
</thead>
<tbody>
{bookings.map((b) => (
<tr key={b.public_ref}>
<th scope="row">{b.public_ref}</th>
<td>
{b.starts_at ? new Date(String(b.starts_at)).toLocaleDateString("en-GB") : "—"} →{" "}
{b.ends_at ? new Date(String(b.ends_at)).toLocaleDateString("en-GB") : "—"}
</td>
<td><StatusBadge status={String(b.status)} /></td>
<td>
<label className="checkbox-label">
<input
type="radio"
name="overlap-booking"
checked={bookingRef === b.public_ref}
onChange={() => setBookingRef(b.public_ref)}
/>
<span className="visually-hidden">Block {b.public_ref}</span>
</label>
</td>
</tr>
))}
</tbody>
</table>
<fieldset className="choice-fieldset choice-fieldset-grid">
<legend className="visually-hidden">{t("detail.bookingOverlap.columns.blockThis")}</legend>
{bookings.map((b) => (
<label key={b.public_ref} className={`choice-card ${bookingRef === b.public_ref ? "is-selected" : ""}`}>
<input
type="radio"
name="overlap-booking"
checked={bookingRef === b.public_ref}
onChange={() => setBookingRef(b.public_ref)}
aria-label={`${t("detail.bookingOverlap.blockLabel", { ref: b.public_ref })}, ${
b.starts_at ? formatShortDate(String(b.starts_at)) : "—"
} → ${b.ends_at ? formatShortDate(String(b.ends_at)) : "—"}, ${b.status}`}
/>
<span className="choice-card-body">
<span className="choice-card-title">{b.public_ref}</span>
<span className="choice-card-detail">
{b.starts_at ? formatShortDate(String(b.starts_at)) : "—"} → {b.ends_at ? formatShortDate(String(b.ends_at)) : "—"}
</span>
<StatusBadge status={String(b.status)} label={t(`bookings:statuses.${b.status}`, { defaultValue: String(b.status) })} />
</span>
</label>
))}
</fieldset>
<label>
Note
{t("detail.bookingOverlap.note")}
<textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} rows={2} />
</label>
<div className="form-actions">
<button className="button button-primary" type="submit" disabled={submitting || !bookingRef}>
{submitting ? "Resolving…" : `Block ${bookingRef || "booking"}`}
{submitting ? t("detail.bookingOverlap.resolving") : t("detail.bookingOverlap.blockButton", { ref: bookingRef || t("detail.bookingOverlap.blockButtonFallback") })}
</button>
</div>
</form>
@@ -484,6 +444,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
}
function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const navigate = useNavigate();
const { manifest } = useDemoManifest();
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
@@ -514,7 +475,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
setResult(applied);
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not apply a recommended status.");
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.applyFailed"));
setConfirming(false);
} finally {
setSubmitting(false);
@@ -524,40 +485,41 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
return (
<section className="panel" aria-labelledby="status-conflict-heading">
<SectionHeading
title="Resolve the status conflict"
description="One authoritative rule recommends a corrected operational status for this vehicle."
headingId="status-conflict-heading"
title={t("detail.vehicleStatusConflict.heading")}
description={t("detail.vehicleStatusConflict.description")}
/>
{error && <p className="error" role="alert">{error}</p>}
<dl className="detail-grid">
<div><dt>Current status</dt><dd><StatusBadge status={String(issue.entity_snapshot?.operational_status ?? "")} /></dd></div>
<div><dt>{t("detail.vehicleStatusConflict.currentStatus")}</dt><dd><StatusBadge status={String(issue.entity_snapshot?.operational_status ?? "")} label={t(`fleet:statuses.${issue.entity_snapshot?.operational_status}`, { defaultValue: String(issue.entity_snapshot?.operational_status ?? "") })} /></dd></div>
</dl>
{result ? (
<>
<p className="quiet-empty">
<Icon name="check" /> Applied <StatusBadge status={result.applied_status} /> — {result.reason}
<Icon name="check" /> {t("detail.vehicleStatusConflict.applied", { status: result.applied_status, reason: result.reason })}
</p>
<div className="result-links">
<Link className="button button-secondary" to="/audit">View audit trail<Icon name="chevron" /></Link>
<Link className="button button-secondary" to={`/vehicles/${issue.entity_ref}`}>View vehicle<Icon name="chevron" /></Link>
<Link className="button button-secondary" to="/audit">{t("detail.resolved.viewAudit")}<Icon name="chevron" /></Link>
<Link className="button button-secondary" to={`/vehicles/${issue.entity_ref}`}>{t("detail.resolved.viewVehicle")}<Icon name="chevron" /></Link>
{guideOpen && (
<button type="button" className="button button-primary" onClick={continueDemo}>
Ga verder met de demo <Icon name="chevron" />
{t("detail.resolved.continueDemo")} <Icon name="chevron" />
</button>
)}
</div>
</>
) : !confirming ? (
<button type="button" onClick={() => setConfirming(true)}>
Calculate and apply recommended status
<button type="button" className="button button-primary" onClick={() => setConfirming(true)}>
{t("detail.vehicleStatusConflict.calculateAndApply")}
</button>
) : (
<div className="confirm-bar" role="alertdialog" aria-label="Confirm status change">
<p>Apply the authoritative recommended status for this vehicle?</p>
<button type="button" onClick={handleApply} disabled={submitting}>
{submitting ? "Applying…" : "Yes, apply"}
<div className="confirm-bar" role="alertdialog" aria-label={t("detail.vehicleStatusConflict.confirmTitle")}>
<p>{t("detail.vehicleStatusConflict.confirmBody")}</p>
<button type="button" className="button button-primary" onClick={handleApply} disabled={submitting}>
{submitting ? t("detail.vehicleStatusConflict.applying") : t("detail.vehicleStatusConflict.confirmYes")}
</button>
<button type="button" onClick={() => setConfirming(false)} disabled={submitting}>
Cancel
<button type="button" className="button button-secondary" onClick={() => setConfirming(false)} disabled={submitting}>
{t("list.cancel")}
</button>
</div>
)}
@@ -566,6 +528,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
}
export function DataQualityIssueDetail() {
const { t } = useTranslation("quality");
const { user } = useAuth();
const navigate = useNavigate();
const { manifest } = useDemoManifest();
@@ -581,7 +544,7 @@ export function DataQualityIssueDetail() {
api
.get<IssueDetail>(`/api/v1/data-quality/issues/${publicRef}`)
.then(setIssue)
.catch(() => setError("This issue could not be found."));
.catch(() => setError(t("detail.notFound")));
}, [publicRef]);
useEffect(() => {
@@ -610,30 +573,30 @@ export function DataQualityIssueDetail() {
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/${action}`);
load();
} catch (err) {
setActionError(err instanceof ApiError ? err.message : `Could not ${action} this issue.`);
setActionError(err instanceof ApiError ? err.message : t(`detail.deferOrReject.${action}Failed`));
}
}
if (user?.role !== "operations_manager") {
return (
<div className="page">
<PageHeader eyebrow="Assurance / Workbench" title="Data quality issue" description="The quality workbench is visible to Operations Managers only." />
<p>Data-quality evidence and resolutions are visible to Operations Managers only.</p>
<PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("detail.managerOnly")} />
<p>{t("detail.managerOnlyDetail")}</p>
</div>
);
}
if (error) return <ErrorState message={error} />;
if (!issue) return <LoadingState label="Loading issue evidence…" />;
if (!issue) return <LoadingState label={t("detail.loading")} />;
return (
<div className="page">
<Link className="back-link" to="/data-quality"><Icon name="arrow-left" /> Quality workbench</Link>
<PageHeader eyebrow={`Quality / ${issue.rule_type.replace(/_/g, " ")}`} title={issue.public_ref} description="Review persisted evidence and record an audited resolution." actions={<div className="status-stack"><SeverityBadge severity={issue.severity} /><StatusBadge status={issue.status} /></div>} />
<section className="record-surface" aria-label="Issue summary"><dl className="detail-grid">
<div><dt>Rule</dt><dd>{issue.rule_type.replace(/_/g, " ")}</dd></div>
<div><dt>Entity</dt><dd>{issue.entity_type === "vehicle" ? <Link to={`/vehicles/${issue.entity_ref}`}>{issue.entity_ref}</Link> : issue.entity_ref}</dd></div>
<div><dt>Evidence summary</dt><dd>{String(issue.evidence.summary ?? "")}</dd></div>
<Link className="back-link" to="/data-quality"><Icon name="arrow-left" /> {t("detail.backLink")}</Link>
<PageHeader eyebrow={t("detail.eyebrow", { rule: t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") }) })} title={issue.public_ref} description={t("detail.title")} actions={<div className="status-stack"><SeverityBadge severity={issue.severity} /><StatusBadge status={issue.status} label={t(`list.status${issue.status.charAt(0).toUpperCase()}${issue.status.slice(1)}`, { defaultValue: issue.status })} /></div>} />
<section className="record-surface" aria-label={t("detail.summary.rule")}><dl className="detail-grid">
<div><dt>{t("detail.summary.rule")}</dt><dd>{t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") })}</dd></div>
<div><dt>{t("detail.summary.entity")}</dt><dd>{issue.entity_type === "vehicle" ? <Link to={`/vehicles/${issue.entity_ref}`}>{issue.entity_ref}</Link> : issue.entity_ref}</dd></div>
<div><dt>{t("detail.summary.evidenceSummary")}</dt><dd>{String(issue.evidence.summary ?? "")}</dd></div>
</dl>
<EvidenceDisclosure issue={issue} /></section>
@@ -643,16 +606,16 @@ export function DataQualityIssueDetail() {
{justResolved && issue.status !== "open" && issue.rule_type !== "vehicle_status_conflict" && (
<section className="panel success-panel" aria-live="polite">
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">Resolved</p><h2>Issue {issue.public_ref} resolved</h2></div></div>
<p>The change has been applied and is recorded in the audit trail.</p>
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">{t("list.statusResolved")}</p><h2>{t("detail.resolved.title", { ref: issue.public_ref })}</h2></div></div>
<p>{t("detail.resolved.body")}</p>
<div className="result-links">
<Link className="button button-secondary" to="/audit">View audit trail<Icon name="chevron" /></Link>
<Link className="button button-secondary" to="/audit">{t("detail.resolved.viewAudit")}<Icon name="chevron" /></Link>
{issue.entity_type === "vehicle" && (
<Link className="button button-secondary" to={`/vehicles/${issue.entity_ref}`}>View vehicle<Icon name="chevron" /></Link>
<Link className="button button-secondary" to={`/vehicles/${issue.entity_ref}`}>{t("detail.resolved.viewVehicle")}<Icon name="chevron" /></Link>
)}
{guideOpen && (
<button type="button" className="button button-primary" onClick={continueDemo}>
Ga verder met de demo <Icon name="chevron" />
{t("detail.resolved.continueDemo")} <Icon name="chevron" />
</button>
)}
</div>
@@ -676,15 +639,15 @@ export function DataQualityIssueDetail() {
)}
{issue.status === "open" && (
<section className="panel" aria-labelledby="resolution-heading">
<h2 id="resolution-heading">Defer or reject</h2>
<p>Defer to review later, or reject if this is not a real issue.</p>
<section className="panel defer-reject-panel" aria-labelledby="resolution-heading">
<h2 id="resolution-heading">{t("detail.deferOrReject.heading")}</h2>
<p>{t("detail.deferOrReject.description")}</p>
<div className="resolution-actions">
<button type="button" onClick={() => handleAction("defer")}>
Defer
<button type="button" className="button-tertiary" onClick={() => handleAction("defer")}>
{t("detail.deferOrReject.defer")}
</button>
<button type="button" onClick={() => handleAction("reject")}>
Reject
<button type="button" className="button-tertiary button-tertiary-destructive" onClick={() => handleAction("reject")}>
{t("detail.deferOrReject.reject")}
</button>
</div>
</section>
+57 -37
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import type { GroundedAnswer, KnowledgeHealth } from "../api/types";
import { Icon } from "../components/Icons";
@@ -9,42 +10,35 @@ interface Exchange {
answer: GroundedAnswer;
}
const EVIDENCE_LABEL: Record<GroundedAnswer["evidence_state"], string> = {
grounded: "Grounded in cited procedures",
insufficient: "Insufficient evidence",
unavailable: "Knowledge service unavailable",
};
const SUGGESTED_QUESTIONS = [
"What must I do when a vehicle returns with damage?",
"When may a vehicle be made available again?",
"Who reviews an unusual odometer reading?",
"Which checks are required before departure?",
];
export function Knowledge() {
const { t, i18n } = useTranslation(["knowledge", "errors"]);
const [status, setStatus] = useState<KnowledgeHealth | null>(null);
const [question, setQuestion] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [exchanges, setExchanges] = useState<Exchange[]>([]);
const language = i18n.language as "nl-BE" | "en-GB" | "fr-BE";
useEffect(() => {
api
.get<KnowledgeHealth>("/api/v1/knowledge/status")
.get<KnowledgeHealth>(`/api/v1/knowledge/status?language=${language}`)
.then(setStatus)
.catch(() => setStatus(null));
}, []);
}, [language]);
async function ask(questionText: string) {
setError(null);
setSubmitting(true);
try {
const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", { question: questionText });
const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", {
question: questionText,
language,
});
setExchanges((prev) => [{ question: questionText, answer }, ...prev]);
setQuestion("");
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not reach the knowledge service.");
setError(err instanceof ApiError ? err.message : t("askFailed"));
} finally {
setSubmitting(false);
}
@@ -56,26 +50,45 @@ export function Knowledge() {
await ask(question);
}
const providerLabel = status?.provider === "ragcore" ? "RAGcore" : "Demo knowledge base";
const providerLabel = status?.provider === "ragcore" ? t("providerRagcore") : t("providerDemo");
const suggestedQuestions = t("suggestedQuestions", { returnObjects: true, defaultValue: [] }) as string[];
return (
<div className="page">
<PageHeader eyebrow="Assurance / Grounded knowledge" title="Procedure knowledge" description={`Ask operational questions. Answers are shown only when the ${providerLabel.toLowerCase()} returns sufficient cited evidence.`} />
<PageHeader
eyebrow={t("eyebrow")}
title={t("title")}
description={t("description", { provider: providerLabel.toLowerCase() })}
/>
{status && (
<div className="knowledge-status"><span className={`health-orb ${status.available ? "is-healthy" : "is-down"}`} /><div><strong>{providerLabel}</strong><span>{status.available ? "Available" : "Unavailable"} · {status.document_count} procedures indexed</span></div><small>{status.collection}</small></div>
<div className="knowledge-status">
<span className={`health-orb ${status.available ? "is-healthy" : "is-down"}`} />
<div>
<strong>{providerLabel}</strong>
<span>
{status.available ? t("statusAvailable") : t("statusUnavailable")} ·{" "}
{t("proceduresIndexed", { count: status.document_count })}
</span>
</div>
<small>{status.collection}</small>
</div>
)}
{status?.provider !== "ragcore" && (
<p className="knowledge-provider-note">
<Icon name="shield" /> This demo answers from a small, fixed set of indexed
procedures — not a live RAGcore connection. A live RAGcore backend will later
take over the same interface without changing how this page works.
<Icon name="shield" /> {t("providerNote")}
</p>
)}
<form className="panel knowledge-form" onSubmit={handleSubmit} aria-labelledby="ask-heading">
<div className="ask-heading"><span><Icon name="spark" /></span><div><h2 id="ask-heading">Ask a procedure question</h2><p>Retrieval → evidence check → grounded answer</p></div></div>
<div className="ask-heading">
<span><Icon name="spark" /></span>
<div>
<h2 id="ask-heading">{t("askHeading")}</h2>
<p>{t("askSubheading")}</p>
</div>
</div>
<label htmlFor="knowledge-question" className="visually-hidden">
Question
{t("questionLabel")}
</label>
<div className="knowledge-input-row">
<input
@@ -83,20 +96,20 @@ export function Knowledge() {
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="e.g. What must I do when a vehicle returns with damage?"
placeholder={t("questionPlaceholder")}
minLength={3}
maxLength={1000}
required
/>
<button className="button button-primary" type="submit" disabled={submitting}>
{submitting ? "Asking…" : "Ask"}
{submitting ? t("asking") : t("ask")}
{!submitting && <Icon name="chevron" />}
</button>
</div>
{error && <p className="error" role="alert">{error}</p>}
<div className="knowledge-suggestions">
<span>Try one:</span>
{SUGGESTED_QUESTIONS.map((q) => (
<span>{t("suggestedLabel")}</span>
{suggestedQuestions.map((q) => (
<button key={q} type="button" className="suggestion-chip" onClick={() => ask(q)} disabled={submitting}>
{q}
</button>
@@ -105,24 +118,31 @@ export function Knowledge() {
</form>
{exchanges.length === 0 && !error && (
<div className="knowledge-empty"><span><Icon name="knowledge" /></span><h2>Evidence before answers</h2><p>Ask about returns, damage, inspections or another indexed procedure. MobilityOps will not invent an answer when evidence is missing.</p><div className="retrieval-flow" aria-hidden="true"><span>Question</span><i /><span>{providerLabel}</span><i /><span>Sources</span><i /><span>Answer</span></div></div>
<div className="knowledge-empty">
<span><Icon name="knowledge" /></span>
<h2>{t("emptyTitle")}</h2>
<p>{t("emptyDescription")}</p>
<div className="retrieval-flow" aria-hidden="true">
<span>{t("retrievalFlow.question")}</span><i />
<span>{providerLabel}</span><i />
<span>{t("retrievalFlow.sources")}</span><i />
<span>{t("retrievalFlow.answer")}</span>
</div>
</div>
)}
<ul className="exchange-list">
{exchanges.map((exchange, index) => (
<li key={index} className="panel exchange">
<p className="exchange-question">
<strong>Question</strong> {exchange.question}
<strong>{t("questionLabelExchange")}</strong> {exchange.question}
</p>
<p className={`evidence-state evidence-${exchange.answer.evidence_state}`}>
{EVIDENCE_LABEL[exchange.answer.evidence_state]}
{t(`evidenceStates.${exchange.answer.evidence_state}`)}
</p>
{exchange.answer.evidence_state === "unavailable" ? (
<p>
The knowledge service is currently unreachable. Operational features are
unaffected — try again later.
</p>
<p>{t("unavailableBody")}</p>
) : (
<p>{exchange.answer.answer}</p>
)}
@@ -132,7 +152,7 @@ export function Knowledge() {
{exchange.answer.sources.map((source) => (
<li key={`${source.document_id}-${source.section}`} className="source-card">
<p className="source-title">
{source.title} <span className="source-version">v{source.version}</span>
{source.title} <span className="source-version">{t("sourceVersion", { version: source.version })}</span>
</p>
<p className="source-section">{source.section}</p>
<p className="source-excerpt">{source.excerpt}</p>
+25 -20
View File
@@ -1,11 +1,14 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "../context/AuthContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { LanguageSwitcher } from "../components/LanguageSwitcher";
import type { Role } from "../api/types";
import { BrandMark, Icon } from "../components/Icons";
export function Login() {
const { t } = useTranslation(["auth", "common"]);
const { loginAs, loading } = useAuth();
const { manifest } = useDemoManifest();
const navigate = useNavigate();
@@ -17,24 +20,23 @@ export function Login() {
await loginAs(role);
navigate(guided ? "/dashboard?guide=start" : "/dashboard");
} catch {
setError("De demo-sessie kon niet gestart worden. De API is mogelijk niet bereikbaar.");
setError(t("loginFailed"));
}
}
const orgName = manifest?.organization_name ?? "Northstar Mobility";
const description =
manifest?.organization_description ??
"MobilityOps brengt voertuig-, boekings- en operationele gegevens samen, ondersteunt " +
"verhuurprocessen, detecteert datakwaliteitsproblemen en automatiseert gecontroleerde " +
"vervolgstappen.";
const orgName = manifest?.organization_name ?? t("common:orgName");
const description = t("defaultDescription");
return (
<main className="login-shell">
<section className="login-story" aria-labelledby="product-name">
<div className="brand-lockup login-brand"><BrandMark className="brand-mark" /><div><strong>MobilityOps</strong><span>Bedieningscentrum</span></div></div>
<div className="brand-lockup login-brand">
<BrandMark className="brand-mark" />
<div><strong>{t("common:appName")}</strong><span>{t("brandTagline")}</span></div>
</div>
<div className="login-message">
<p className="eyebrow">Demo-organisatie: {orgName} (fictief)</p>
<h1 id="product-name">Elke overdracht.<br />Eén helder overzicht.</h1>
<p className="eyebrow">{t("orgLine", { orgName })}</p>
<h1 id="product-name">{t("headline1")}<br />{t("headline2")}</h1>
<p>{description}</p>
</div>
<div className="control-illustration" aria-hidden="true">
@@ -45,14 +47,17 @@ export function Login() {
<span className="illustration-node node-two"><Icon name="bookings" /></span>
<span className="illustration-node node-three"><Icon name="quality" /></span>
</div>
<p className="login-footnote"><Icon name="shield" /> Synthetische demo · geen echte klant- of voertuiggegevens · op elk moment herstelbaar</p>
<p className="login-footnote"><Icon name="shield" /> {t("footnote")}</p>
</section>
<section className="login-access" aria-labelledby="login-heading">
<div className="login-panel">
<p className="page-eyebrow">Demo-toegang</p>
<h2 id="login-heading">Kies hoe je wil starten</h2>
<p className="login-intro">Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving — alle workflows en controles zijn echt geïmplementeerd.</p>
<div className="login-panel-top">
<p className="page-eyebrow">{t("accessEyebrow")}</p>
<LanguageSwitcher />
</div>
<h2 id="login-heading">{t("accessHeading")}</h2>
<p className="login-intro">{t("accessIntro")}</p>
{error && <p className="error" role="alert">{error}</p>}
<button
@@ -62,22 +67,22 @@ export function Login() {
onClick={() => handleLogin("operations_manager", true)}
>
<Icon name="spark" />
Start begeleide demo
{t("startGuidedDemo")}
</button>
<div className="login-options">
<button type="button" aria-label="Verken als Operations Manager" disabled={loading} onClick={() => handleLogin("operations_manager")}>
<button type="button" aria-label={t("exploreAsOperationsManager")} disabled={loading} onClick={() => handleLogin("operations_manager")}>
<span className="role-icon"><Icon name="activity" /></span>
<span><strong>Verken als Operations Manager</strong><small>Volledig overzicht, kwaliteitsoplossing en herpogingen</small></span>
<span><strong>{t("exploreAsOperationsManager")}</strong><small>{t("exploreAsOperationsManagerDetail")}</small></span>
<Icon name="chevron" />
</button>
<button type="button" aria-label="Verken als Rental Employee" disabled={loading} onClick={() => handleLogin("rental_employee")}>
<button type="button" aria-label={t("exploreAsRentalEmployee")} disabled={loading} onClick={() => handleLogin("rental_employee")}>
<span className="role-icon"><Icon name="user" /></span>
<span><strong>Verken als Rental Employee</strong><small>Boekingen, retours, wagenpark en procedures</small></span>
<span><strong>{t("exploreAsRentalEmployee")}</strong><small>{t("exploreAsRentalEmployeeDetail")}</small></span>
<Icon name="chevron" />
</button>
</div>
<div className="access-note"><Icon name="shield" /><p><strong>Veilig ontworpen</strong><span>Elke actie wordt gelogd en is in deze demo herstelbaar.</span></p></div>
<div className="access-note"><Icon name="shield" /><p><strong>{t("safeByDesignTitle")}</strong><span>{t("safeByDesignDetail")}</span></p></div>
</div>
</section>
</main>
+27 -19
View File
@@ -1,51 +1,59 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "../context/AuthContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { Icon } from "../components/Icons";
import { LoadingState, PageHeader } from "../components/PageChrome";
const ROLE_LABEL: Record<string, string> = {
operations_manager: "Operations Manager",
rental_employee: "Rental Employee",
};
export function Scenarios() {
const { t } = useTranslation("demo");
const { manifest, loading } = useDemoManifest();
const { user } = useAuth();
function roleLabel(roles: string[]): string {
const labels = roles.map((r) => t(`scenarios.roles.${r}`, { defaultValue: r }));
return labels.length > 1 ? t("scenarios.roleOr", { a: labels[0], b: labels[1] }) : labels[0];
}
return (
<div className="page">
<PageHeader
eyebrow="Demonstratiescenario's"
title="Probeer een demonstratiescenario"
description="Vijf afgebakende scenario's die telkens dezelfde vaste boekingen, klanten en voertuigen gebruiken — na een reset zijn ze altijd opnieuw te vinden."
eyebrow={t("scenarios.eyebrow")}
title={t("scenarios.title")}
description={t("scenarios.description")}
/>
{loading && <LoadingState label="Scenario's laden…" />}
{loading && <LoadingState label={t("scenarios.loading")} />}
{manifest && (
<div className="scenario-grid">
{manifest.scenarios.map((scenario) => {
const canRun = !user || scenario.required_roles.includes(user.role);
const blockedText = scenario.blocked_reason_code
? t(`scenarios.blockedReasons.${scenario.blocked_reason_code}`, {
resetHint: t("scenarios.blockedReasons.resetHint"),
...scenario.blocked_reason_params,
})
: null;
return (
<article key={scenario.id} className="scenario-card">
<header>
<h2>{scenario.title}</h2>
<h2>{t(`scenarios.items.${scenario.id}.title`)}</h2>
<span className={`badge ${scenario.ready ? "status-available" : "status-unavailable"}`}>
{scenario.ready ? "Klaar voor demo" : "Niet beschikbaar"}
{scenario.ready ? t("scenarios.ready") : t("scenarios.notReady")}
</span>
</header>
<p className="scenario-problem">{scenario.operational_problem}</p>
<p className="scenario-problem">{t(`scenarios.items.${scenario.id}.problem`)}</p>
<dl className="scenario-meta">
<div><dt>Duur</dt><dd>± {scenario.estimated_minutes} min</dd></div>
<div><dt>Rol</dt><dd>{scenario.required_roles.map((r) => ROLE_LABEL[r]).join(" of ")}</dd></div>
<div><dt>{t("scenarios.duration")}</dt><dd>{t("scenarios.durationValue", { minutes: scenario.estimated_minutes })}</dd></div>
<div><dt>{t("scenarios.role")}</dt><dd>{roleLabel(scenario.required_roles)}</dd></div>
</dl>
<p className="scenario-demonstrates"><strong>Toont aan:</strong> {scenario.demonstrates}</p>
{!scenario.ready && scenario.blocked_reason && (
<p className="scenario-blocked"><Icon name="alert" /> {scenario.blocked_reason}</p>
<p className="scenario-demonstrates"><strong>{t("scenarios.demonstrates")}</strong> {t(`scenarios.items.${scenario.id}.demonstrates`)}</p>
{!scenario.ready && blockedText && (
<p className="scenario-blocked"><Icon name="alert" /> {blockedText}</p>
)}
{!canRun && (
<p className="scenario-blocked"><Icon name="alert" /> Vereist rol: {scenario.required_roles.map((r) => ROLE_LABEL[r]).join(" of ")}.</p>
<p className="scenario-blocked"><Icon name="alert" /> {t("scenarios.requiresRole", { roles: roleLabel(scenario.required_roles) })}</p>
)}
<Link
className={`button ${scenario.ready && canRun ? "button-primary" : "button-secondary"}`}
@@ -55,7 +63,7 @@ export function Scenarios() {
if (!scenario.ready || !canRun) event.preventDefault();
}}
>
Start scenario <Icon name="chevron" />
{t("scenarios.startScenario")} <Icon name="chevron" />
</Link>
</article>
);
+36 -32
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { VehicleDetail as VehicleDetailData } from "../api/types";
import { useLocaleFormat } from "../i18n/format";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
@@ -10,6 +12,8 @@ const TABS = ["overview", "bookings", "inspections", "maintenance", "quality"] a
type Tab = (typeof TABS)[number];
export function VehicleDetail() {
const { t } = useTranslation(["fleet", "bookings", "quality"]);
const { formatNumber, formatShortDate } = useLocaleFormat();
const { publicRef } = useParams<{ publicRef: string }>();
const [vehicle, setVehicle] = useState<VehicleDetailData | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -22,52 +26,52 @@ export function VehicleDetail() {
api
.get<VehicleDetailData>(`/api/v1/vehicles/${publicRef}`)
.then(setVehicle)
.catch(() => setError("This vehicle could not be found."));
.catch(() => setError(t("detail.notFound")));
}, [publicRef]);
if (error) return <ErrorState message={error} />;
if (!vehicle) return <LoadingState label="Loading vehicle record…" />;
if (!vehicle) return <LoadingState label={t("detail.loading")} />;
return (
<div className="page">
<Link className="back-link" to="/vehicles"><Icon name="arrow-left" /> Fleet registry</Link>
<PageHeader eyebrow="Fleet / Vehicle record" title={`${vehicle.public_ref} · ${vehicle.make} ${vehicle.model}`} description={`${vehicle.registration_number} · ${vehicle.location}`} actions={<div className="status-stack"><StatusBadge status={vehicle.operational_status} />{vehicle.attention && <span className="badge severity-high">Needs attention</span>}</div>} />
<Link className="back-link" to="/vehicles"><Icon name="arrow-left" /> {t("detail.backLink")}</Link>
<PageHeader eyebrow={t("detail.eyebrow")} title={`${vehicle.public_ref} · ${vehicle.make} ${vehicle.model}`} description={`${vehicle.registration_number} · ${vehicle.location}`} actions={<div className="status-stack"><StatusBadge status={vehicle.operational_status} label={t(`statuses.${vehicle.operational_status}`, { defaultValue: vehicle.operational_status })} />{vehicle.attention && <span className="badge severity-high">{t("detail.needsAttention")}</span>}</div>} />
<div role="tablist" aria-label="Vehicle sections" className="tabs" aria-orientation="horizontal">
{TABS.map((t) => (
<div role="tablist" aria-label={t("detail.tabsAriaLabel")} className="tabs" aria-orientation="horizontal">
{TABS.map((tb) => (
<button
key={t}
key={tb}
role="tab"
type="button"
aria-selected={tab === t}
className={tab === t ? "active" : ""}
onClick={() => setTab(t)}
aria-selected={tab === tb}
className={tab === tb ? "active" : ""}
onClick={() => setTab(tb)}
>
{t.charAt(0).toUpperCase() + t.slice(1)}
{t(`detail.tabs.${tb}`)}
</button>
))}
</div>
{tab === "overview" && (
<section className="record-surface" aria-label="Vehicle overview"><dl className="detail-grid">
<div><dt>Registration</dt><dd>{vehicle.registration_number}</dd></div>
<div><dt>Model year</dt><dd>{vehicle.model_year}</dd></div>
<div><dt>Location</dt><dd>{vehicle.location}</dd></div>
<div><dt>Odometer</dt><dd>{vehicle.odometer_km.toLocaleString("en-GB")} km</dd></div>
<div><dt>Next service</dt><dd>{vehicle.next_service_km.toLocaleString("en-GB")} km</dd></div>
<div><dt>Active</dt><dd>{vehicle.active ? "Yes" : "No"}</dd></div>
<section className="record-surface" aria-label={t("detail.tabs.overview")}><dl className="detail-grid">
<div><dt>{t("detail.overview.registration")}</dt><dd>{vehicle.registration_number}</dd></div>
<div><dt>{t("detail.overview.modelYear")}</dt><dd>{vehicle.model_year}</dd></div>
<div><dt>{t("detail.overview.location")}</dt><dd>{vehicle.location}</dd></div>
<div><dt>{t("detail.overview.odometer")}</dt><dd>{formatNumber(vehicle.odometer_km)} km</dd></div>
<div><dt>{t("detail.overview.nextService")}</dt><dd>{formatNumber(vehicle.next_service_km)} km</dd></div>
<div><dt>{t("detail.overview.active")}</dt><dd>{vehicle.active ? t("detail.yes") : t("detail.no")}</dd></div>
</dl></section>
)}
{tab === "bookings" && (
<ul className="record-list">
{vehicle.bookings.length === 0 && <li>No bookings recorded.</li>}
{vehicle.bookings.length === 0 && <li>{t("detail.noBookings")}</li>}
{vehicle.bookings.map((b) => (
<li key={b.public_ref}>
<Link to={`/bookings/${b.public_ref}`}>{b.public_ref}</Link>
<StatusBadge status={b.status} />
<StatusBadge status={b.status} label={t(`bookings:statuses.${b.status}`, { defaultValue: b.status })} />
<span>
{new Date(b.starts_at).toLocaleDateString("en-GB")} → {new Date(b.ends_at).toLocaleDateString("en-GB")}
{formatShortDate(b.starts_at)} → {formatShortDate(b.ends_at)}
</span>
</li>
))}
@@ -76,15 +80,15 @@ export function VehicleDetail() {
{tab === "inspections" && (
<ul className="record-list">
{vehicle.inspections.length === 0 && <li>No inspections recorded.</li>}
{vehicle.inspections.length === 0 && <li>{t("detail.noInspections")}</li>}
{vehicle.inspections.map((i) => (
<li key={i.public_ref}>
<span>{i.type}</span>
<span>{i.odometer_km.toLocaleString("en-GB")} km</span>
<span>Fuel {i.fuel_level_percent}%</span>
{i.damage_reported && <span className="badge severity-high">Damage</span>}
{i.technical_warning && <span className="badge severity-high">Technical warning</span>}
<time dateTime={i.completed_at}>{new Date(i.completed_at).toLocaleDateString("en-GB")}</time>
<span>{formatNumber(i.odometer_km)} km</span>
<span>{t("detail.fuel", { percent: i.fuel_level_percent })}</span>
{i.damage_reported && <span className="badge severity-high">{t("detail.damage")}</span>}
{i.technical_warning && <span className="badge severity-high">{t("detail.technicalWarning")}</span>}
<time dateTime={i.completed_at}>{formatShortDate(i.completed_at)}</time>
</li>
))}
</ul>
@@ -92,12 +96,12 @@ export function VehicleDetail() {
{tab === "maintenance" && (
<ul className="record-list">
{vehicle.maintenance.length === 0 && <li>No maintenance records.</li>}
{vehicle.maintenance.length === 0 && <li>{t("detail.noMaintenance")}</li>}
{vehicle.maintenance.map((m) => (
<li key={m.public_ref}>
<span>{m.category}</span>
<span>{m.summary}</span>
<time dateTime={m.occurred_at}>{new Date(m.occurred_at).toLocaleDateString("en-GB")}</time>
<time dateTime={m.occurred_at}>{formatShortDate(m.occurred_at)}</time>
</li>
))}
</ul>
@@ -105,13 +109,13 @@ export function VehicleDetail() {
{tab === "quality" && (
<ul className="record-list">
{vehicle.quality_issues.length === 0 && <li>No quality issues recorded.</li>}
{vehicle.quality_issues.length === 0 && <li>{t("detail.noQualityIssues")}</li>}
{vehicle.quality_issues.map((q) => (
<li key={q.public_ref}>
<Link to={`/data-quality/${q.public_ref}`}>{q.public_ref}</Link>
<SeverityBadge severity={q.severity} />
<span>{q.rule_type.replace(/_/g, " ")}</span>
<StatusBadge status={q.status} />
<span>{t(`quality:ruleTypes.${q.rule_type}`, { defaultValue: q.rule_type.replace(/_/g, " ") })}</span>
<StatusBadge status={q.status} label={t(`quality:list.status${q.status.charAt(0).toUpperCase()}${q.status.slice(1)}`, { defaultValue: q.status })} />
</li>
))}
</ul>
+33 -28
View File
@@ -1,13 +1,17 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { Vehicle } from "../api/types";
import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "blocked"];
export function Vehicles() {
const { t } = useTranslation("fleet");
const { formatNumber } = useLocaleFormat();
const [vehicles, setVehicles] = useState<Vehicle[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState("");
@@ -23,25 +27,25 @@ export function Vehicles() {
api
.get<Vehicle[]>(`/api/v1/vehicles?${params.toString()}`)
.then(setVehicles)
.catch(() => setError("Vehicle list is unavailable right now."));
.catch(() => setError(t("list.unavailable")));
}, [status, attentionOnly]);
return (
<div className="page">
<PageHeader eyebrow="Fleet / Registry" title="Vehicle fleet" description="Live operational state, location and service readiness." />
<PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("list.description")} />
<form className="filters" aria-label="Filter vehicles">
<form className="filters" aria-label={t("list.title")}>
<label>
Search
<input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Reference, make or location" />
{t("list.searchLabel")}
<input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t("list.searchPlaceholder")} />
</label>
<label>
Status
{t("list.statusLabel")}
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All statuses</option>
<option value="">{t("list.statusAll")}</option>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
{t(`statuses.${s}`)}
</option>
))}
</select>
@@ -52,43 +56,44 @@ export function Vehicles() {
checked={attentionOnly}
onChange={(e) => setAttentionOnly(e.target.checked)}
/>
Attention only
{t("list.attentionOnly")}
</label>
</form>
{error && <ErrorState message={error} />}
{!error && !vehicles && <LoadingState label="Loading fleet registry…" />}
{vehicles && vehicles.length === 0 && <EmptyState icon="fleet" title="No vehicles found" detail="Adjust the current fleet filters." />}
{!error && !vehicles && <LoadingState label={t("list.loading")} />}
{vehicles && vehicles.length === 0 && <EmptyState icon="fleet" title={t("list.empty")} detail={t("list.emptyDetail")} />}
{vehicles && vehicles.length > 0 && (() => {
const filtered = vehicles.filter((v) => `${v.public_ref} ${v.make} ${v.model} ${v.location}`.toLowerCase().includes(query.toLowerCase()));
return filtered.length === 0 ? <EmptyState icon="search" title="No matching vehicles" detail="Try a broader search term." /> : <div className="table-shell"><div className="table-meta"><span>{filtered.length} vehicles</span><span>Persisted fleet data</span></div><table className="data-table">
<caption className="visually-hidden">Vehicle fleet</caption>
return filtered.length === 0 ? <EmptyState icon="search" title={t("list.noMatch")} detail={t("list.noMatchDetail")} /> : <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: filtered.length })}</span><span>{t("list.persisted")}</span></div><table className="data-table">
<caption className="visually-hidden">{t("list.title")}</caption>
<thead>
<tr>
<th scope="col">Reference</th>
<th scope="col">Make / model</th>
<th scope="col">Location</th>
<th scope="col">Status</th>
<th scope="col">Odometer (km)</th>
<th scope="col">Attention</th>
<th scope="col">{t("list.columns.reference")}</th>
<th scope="col">{t("list.columns.makeModel")}</th>
<th scope="col">{t("list.columns.location")}</th>
<th scope="col">{t("list.columns.status")}</th>
<th scope="col">{t("list.columns.odometer")}</th>
<th scope="col">{t("list.columns.attention")}</th>
</tr>
</thead>
<tbody>
{filtered.map((v) => (
<tr key={v.public_ref} className={v.attention ? "row-attention" : ""}>
<th scope="row" data-label="Reference">
<Link to={`/vehicles/${v.public_ref}`}>{v.public_ref}</Link>
<tr key={v.public_ref} className={`row-clickable ${v.attention ? "row-attention" : ""}`}>
<th scope="row" data-label={t("list.columns.reference")}>
{v.public_ref}
<Link className="row-link" to={`/vehicles/${v.public_ref}`}><span className="visually-hidden">{v.public_ref}</span></Link>
</th>
<td data-label="Make / model">
<td data-label={t("list.columns.makeModel")}>
{v.make} {v.model} ({v.model_year})
</td>
<td data-label="Location">{v.location}</td>
<td data-label="Status">
<StatusBadge status={v.operational_status} />
<td data-label={t("list.columns.location")}>{v.location}</td>
<td data-label={t("list.columns.status")}>
<StatusBadge status={v.operational_status} label={t(`statuses.${v.operational_status}`, { defaultValue: v.operational_status })} />
</td>
<td data-label="Odometer">{v.odometer_km.toLocaleString("en-GB")}</td>
<td data-label="Attention">{v.attention ? <span className="attention-flag">Needs attention</span> : "—"}</td>
<td data-label={t("list.columns.odometer")}>{formatNumber(v.odometer_km)}</td>
<td data-label={t("list.columns.attention")}>{v.attention ? <span className="attention-flag">{t("list.needsAttention")}</span> : "—"}</td>
</tr>
))}
</tbody>