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>
126 lines
5.7 KiB
TypeScript
126 lines
5.7 KiB
TypeScript
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";
|
|
|
|
const TABS = ["overview", "bookings", "inspections", "maintenance", "quality"] as const;
|
|
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);
|
|
const [tab, setTab] = useState<Tab>("overview");
|
|
|
|
useEffect(() => {
|
|
if (!publicRef) return;
|
|
setVehicle(null);
|
|
setError(null);
|
|
api
|
|
.get<VehicleDetailData>(`/api/v1/vehicles/${publicRef}`)
|
|
.then(setVehicle)
|
|
.catch(() => setError(t("detail.notFound")));
|
|
}, [publicRef]);
|
|
|
|
if (error) return <ErrorState message={error} />;
|
|
if (!vehicle) return <LoadingState label={t("detail.loading")} />;
|
|
|
|
return (
|
|
<div className="page">
|
|
<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={t("detail.tabsAriaLabel")} className="tabs" aria-orientation="horizontal">
|
|
{TABS.map((tb) => (
|
|
<button
|
|
key={tb}
|
|
role="tab"
|
|
type="button"
|
|
aria-selected={tab === tb}
|
|
className={tab === tb ? "active" : ""}
|
|
onClick={() => setTab(tb)}
|
|
>
|
|
{t(`detail.tabs.${tb}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === "overview" && (
|
|
<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>{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} label={t(`bookings:statuses.${b.status}`, { defaultValue: b.status })} />
|
|
<span>
|
|
{formatShortDate(b.starts_at)} → {formatShortDate(b.ends_at)}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{tab === "inspections" && (
|
|
<ul className="record-list">
|
|
{vehicle.inspections.length === 0 && <li>{t("detail.noInspections")}</li>}
|
|
{vehicle.inspections.map((i) => (
|
|
<li key={i.public_ref}>
|
|
<span>{i.type}</span>
|
|
<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>
|
|
)}
|
|
|
|
{tab === "maintenance" && (
|
|
<ul className="record-list">
|
|
{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}>{formatShortDate(m.occurred_at)}</time>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{tab === "quality" && (
|
|
<ul className="record-list">
|
|
{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>{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>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|