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
+4 -2
View File
@@ -1,6 +1,8 @@
import { useTranslation } from "react-i18next";
export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
const label = severity === "high" ? "High" : severity === "medium" ? "Medium" : "Low";
return <span className={`badge severity-${severity}`}>{label} severity</span>;
const { t } = useTranslation("quality");
return <span className={`badge severity-${severity}`}>{t(`severities.${severity}`)}</span>;
}
export function StatusBadge({ status, label }: { status: string; label?: string }) {
+17 -24
View File
@@ -1,18 +1,13 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { Icon } from "./Icons";
function formatDateTime(value: string | null): string {
if (!value) return "onbekend";
return new Date(value).toLocaleString("nl-BE", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Europe/Brussels",
});
}
export function DemoBadge() {
const { t } = useTranslation("demo");
const { formatDateTime } = useLocaleFormat();
const { manifest } = useDemoManifest();
const [open, setOpen] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
@@ -44,35 +39,33 @@ export function DemoBadge() {
onClick={() => setOpen((v) => !v)}
>
<Icon name="shield" />
<span>Synthetische demo</span>
<span>{t("badge.trigger")}</span>
</button>
{open && (
<div className="demo-badge-popover" role="dialog" aria-label="Over deze demo-omgeving">
<button type="button" className="icon-button demo-badge-close" onClick={() => setOpen(false)} aria-label="Sluiten">
<div className="demo-badge-popover" role="dialog" aria-label={t("badge.dialogLabel")}>
<button type="button" className="icon-button demo-badge-close" onClick={() => setOpen(false)} aria-label={t("badge.close")}>
<Icon name="x" />
</button>
<p>
{manifest ? (
<>
<strong>{manifest.organization_name}</strong> is een fictieve organisatie. Alle
namen, voertuigen en boekingen zijn synthetisch.
</>
<Trans i18nKey="badge.orgIntro" t={t} values={{ orgName: manifest.organization_name }} components={{ strong: <strong /> }} />
) : (
"Alle namen, voertuigen en boekingen in deze omgeving zijn synthetisch."
t("badge.orgIntroFallback")
)}
</p>
<p>
De workflows, controles en automatisering zijn echt geïmplementeerd — enkel de
gegevens zijn verzonnen.
</p>
<p>{t("badge.realWorkflows")}</p>
{manifest && (
<p className="demo-badge-reset">
Laatste reset: <strong>{formatDateTime(manifest.last_reset_at)}</strong> · deze
omgeving is op elk moment herstelbaar.
<Trans
i18nKey="badge.lastReset"
t={t}
values={{ when: manifest.last_reset_at ? formatDateTime(manifest.last_reset_at) : t("badge.unknown") }}
components={{ strong: <strong /> }}
/>
</p>
)}
<Link to="/about" onClick={() => setOpen(false)}>
Over deze demo <Icon name="chevron" />
{t("badge.aboutLink")} <Icon name="chevron" />
</Link>
</div>
)}
+170 -56
View File
@@ -1,13 +1,16 @@
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useViewportTier } from "../hooks/useViewportTier";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "./Icons";
export function DemoGuideTrigger() {
const { t } = useTranslation("demo");
const { user } = useAuth();
const { open, toggleGuide, currentIndex, completed, totalSteps } = useDemoGuide();
@@ -22,17 +25,35 @@ export function DemoGuideTrigger() {
onClick={toggleGuide}
>
<Icon name="spark" />
<span>Demo-gids</span>
<span>{t("guide.trigger")}</span>
<span className="demo-guide-progress-pill">{completed.size}/{totalSteps}</span>
<span className="visually-hidden">, huidige stap {currentIndex + 1}</span>
<span className="visually-hidden">, {t("guide.kicker", { current: currentIndex + 1, total: totalSteps })}</span>
</button>
);
}
function highlightTarget(selector: string | undefined) {
if (!selector) return;
const el = document.querySelector<HTMLElement>(selector);
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
const previousTabIndex = el.getAttribute("tabindex");
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
el.focus({ preventScroll: true });
el.classList.add("demo-guide-highlight");
window.setTimeout(() => {
el.classList.remove("demo-guide-highlight");
if (previousTabIndex === null) el.removeAttribute("tabindex");
}, 2200);
}
export function DemoGuide() {
const { t } = useTranslation("demo");
const navigate = useNavigate();
const location = useLocation();
const { logout } = useAuth();
const { manifest, refresh } = useDemoManifest();
const tier = useViewportTier();
const {
open,
closeGuide,
@@ -42,17 +63,48 @@ export function DemoGuide() {
goToStep,
completeAndAdvance,
restart,
collapsedToChip,
setCollapsedToChip,
} = useDemoGuide();
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState<string | null>(null);
if (!open) return null;
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
const pendingTarget = useRef<string | null>(null);
const step = DEMO_GUIDE_STEPS[currentIndex];
const isLastStep = currentIndex === totalSteps - 1;
useEffect(() => {
if (!open) return;
function handleKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
if (tier === "standard" && !collapsedToChip) {
setCollapsedToChip(true);
} else if (tier === "mobile" && mobileSheetState !== "collapsed") {
setMobileSheetState("collapsed");
} else {
closeGuide();
}
}
document.addEventListener("keydown", handleKeydown);
return () => document.removeEventListener("keydown", handleKeydown);
}, [open, tier, collapsedToChip, mobileSheetState, closeGuide]);
useEffect(() => {
if (!pendingTarget.current) return;
const target = pendingTarget.current;
pendingTarget.current = null;
const raf = requestAnimationFrame(() => highlightTarget(target));
return () => cancelAnimationFrame(raf);
}, [location.pathname]);
if (!open) return null;
function goToStepRoute() {
pendingTarget.current = step.target ?? null;
navigate(step.route(manifest));
if (tier === "standard") setCollapsedToChip(true);
if (tier === "mobile") setMobileSheetState("collapsed");
}
async function handleRestartDemo() {
@@ -66,69 +118,131 @@ export function DemoGuide() {
await logout();
navigate("/login");
} catch (err) {
setResetError(err instanceof ApiError ? err.message : "De demo kon niet hersteld worden.");
setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed"));
} finally {
setResetting(false);
}
}
return (
<aside className="demo-guide-panel" role="dialog" aria-label="Gegidste demo">
<header className="demo-guide-header">
<div>
<p className="demo-guide-kicker">Gegidste demo · stap {currentIndex + 1} van {totalSteps}</p>
<h2>{step.title}</h2>
</div>
<button type="button" className="icon-button" onClick={closeGuide} aria-label="Sluiten">
if (tier === "standard" && collapsedToChip) {
return (
<div className="demo-guide-chip">
<button
type="button"
className="demo-guide-chip-expand"
onClick={() => setCollapsedToChip(false)}
aria-label={`${t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}, ${t("guide.expand")}`}
>
<Icon name="spark" />
{t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}
<Icon name="chevron" />
</button>
<button type="button" className="demo-guide-chip-close" onClick={closeGuide} aria-label={t("guide.close")}>
<Icon name="x" />
</button>
</div>
);
}
const panelClassName = [
"demo-guide-panel",
tier === "wide" ? "is-wide" : "",
tier === "mobile" ? `is-mobile sheet-${mobileSheetState}` : "",
]
.filter(Boolean)
.join(" ");
return (
<aside className={panelClassName} role="dialog" aria-label={t("guide.dialogLabel")}>
{tier === "mobile" && (
<button
type="button"
className="demo-guide-sheet-handle"
onClick={() =>
setMobileSheetState((s) => (s === "collapsed" ? "half" : s === "half" ? "full" : "collapsed"))
}
aria-label={
mobileSheetState === "full"
? t("guide.collapse")
: t("guide.expand")
}
>
<span aria-hidden="true" />
</button>
)}
<header className="demo-guide-header">
<div>
<p className="demo-guide-kicker">{t("guide.kicker", { current: currentIndex + 1, total: totalSteps })}</p>
<h2>{t(`guide.steps.${step.id}.title`)}</h2>
</div>
{tier !== "mobile" && (
<button
type="button"
className="icon-button"
onClick={tier === "standard" ? () => setCollapsedToChip(true) : closeGuide}
aria-label={tier === "standard" ? t("guide.collapse") : t("guide.close")}
>
<Icon name="x" />
</button>
)}
</header>
<div className="demo-guide-progress-bar" aria-hidden="true">
{DEMO_GUIDE_STEPS.map((s, index) => (
<span
key={s.id}
className={
index === currentIndex ? "is-current" : completed.has(s.id) ? "is-done" : ""
}
/>
))}
</div>
{mobileSheetState !== "collapsed" && (
<>
<div className="demo-guide-progress-bar" aria-hidden="true">
{DEMO_GUIDE_STEPS.map((s, index) => (
<span
key={s.id}
className={
index === currentIndex ? "is-current" : completed.has(s.id) ? "is-done" : ""
}
/>
))}
</div>
<div className="demo-guide-body">
<p><strong>Wat je zal zien</strong><br />{step.whatYouWillSee}</p>
<p><strong>Waarom dit relevant is</strong><br />{step.whyItMatters}</p>
<p><strong>Aan de slag</strong><br />{step.startAction}</p>
<p><strong>Verwacht resultaat</strong><br />{step.expectedOutcome}</p>
</div>
{(mobileSheetState !== "half" || tier !== "mobile") && (
<div className="demo-guide-body">
<p><strong>{t("guide.whatYouWillSee")}</strong><br />{t(`guide.steps.${step.id}.whatYouWillSee`)}</p>
<p><strong>{t("guide.whyItMatters")}</strong><br />{t(`guide.steps.${step.id}.whyItMatters`)}</p>
<p><strong>{t("guide.startAction")}</strong><br />{t(`guide.steps.${step.id}.startAction`)}</p>
<p><strong>{t("guide.expectedOutcome")}</strong><br />{t(`guide.steps.${step.id}.expectedOutcome`)}</p>
</div>
)}
<nav className="demo-guide-steps" aria-label="Alle stappen">
{DEMO_GUIDE_STEPS.map((s, index) => (
<button
key={s.id}
type="button"
className={index === currentIndex ? "is-current" : ""}
onClick={() => goToStep(index)}
>
{completed.has(s.id) && <Icon name="check" />}
{s.title}
</button>
))}
</nav>
{tier !== "mobile" && (
<nav className="demo-guide-steps" aria-label={t("guide.allStepsLabel")}>
{DEMO_GUIDE_STEPS.map((s, index) => (
<button
key={s.id}
type="button"
className={index === currentIndex ? "is-current" : ""}
onClick={() => goToStep(index)}
>
{completed.has(s.id) && <Icon name="check" />}
{t(`guide.steps.${s.id}.title`)}
</button>
))}
</nav>
)}
{resetError && <p className="error" role="alert">{resetError}</p>}
{resetError && <p className="error" role="alert">{resetError}</p>}
<footer className="demo-guide-footer">
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
Ga naar deze stap
</button>
<button type="button" className="button button-primary" onClick={completeAndAdvance} disabled={isLastStep}>
Volgende
</button>
<button type="button" className="demo-guide-restart" onClick={handleRestartDemo} disabled={resetting}>
{resetting ? "Bezig met herstellen…" : "Demo opnieuw voorbereiden"}
</button>
</footer>
<footer className="demo-guide-footer">
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
{t("guide.goToStep")}
</button>
<button type="button" className="button button-primary" onClick={completeAndAdvance} disabled={isLastStep}>
{t("guide.next")}
</button>
{tier !== "mobile" && (
<button type="button" className="demo-guide-restart" onClick={handleRestartDemo} disabled={resetting}>
{resetting ? t("guide.restarting") : t("guide.restart")}
</button>
)}
</footer>
</>
)}
</aside>
);
}
@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import {
persistLanguage,
SUPPORTED_LANGUAGES,
type SupportedLanguage,
} from "../i18n/config";
export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
const { t, i18n } = useTranslation("common");
const current = (i18n.language as SupportedLanguage) || "nl-BE";
function handleChange(next: SupportedLanguage) {
void i18n.changeLanguage(next);
persistLanguage(next);
}
return (
<label className={`language-switcher ${compact ? "language-switcher-compact" : ""}`}>
<span className="visually-hidden">{t("language.label")}</span>
<select
value={current}
onChange={(event) => handleChange(event.target.value as SupportedLanguage)}
aria-label={t("language.label")}
>
{SUPPORTED_LANGUAGES.map((lang) => (
<option key={lang} value={lang}>
{t(`language.${lang}`)}
</option>
))}
</select>
</label>
);
}
+48 -42
View File
@@ -1,11 +1,13 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { useAuth } from "../context/AuthContext";
import type { Role, SearchResultItem } from "../api/types";
import { BrandMark, Icon, type IconName } from "./Icons";
import { DemoBadge } from "./DemoBadge";
import { DemoGuide, DemoGuideTrigger } from "./DemoGuide";
import { LanguageSwitcher } from "./LanguageSwitcher";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
@@ -18,36 +20,36 @@ const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
interface NavItem {
to: string;
label: string;
shortLabel: string;
labelKey: string;
icon: IconName;
roles?: Role[];
}
const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [
const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
{
label: "Operate",
labelKey: "groups.operate",
items: [
{ to: "/dashboard", label: "Overview", shortLabel: "Overview", icon: "activity" },
{ to: "/vehicles", label: "Fleet", shortLabel: "Fleet", icon: "fleet" },
{ to: "/bookings", label: "Bookings", shortLabel: "Bookings", icon: "bookings" },
{ to: "/data-quality", label: "Data quality", shortLabel: "Quality", icon: "quality", roles: ["operations_manager"] },
{ to: "/dashboard", labelKey: "items.overview", icon: "activity" },
{ to: "/vehicles", labelKey: "items.fleet", icon: "fleet" },
{ to: "/bookings", labelKey: "items.bookings", icon: "bookings" },
{ to: "/data-quality", labelKey: "items.quality", icon: "quality", roles: ["operations_manager"] },
],
},
{
label: "Assure",
labelKey: "groups.assure",
items: [
{ to: "/knowledge", label: "Knowledge", shortLabel: "Knowledge", icon: "knowledge" },
{ to: "/automation", label: "Integrations", shortLabel: "Systems", icon: "integrations", roles: ["operations_manager"] },
{ to: "/audit", label: "Audit trail", shortLabel: "Audit", icon: "audit", roles: ["operations_manager"] },
{ to: "/knowledge", labelKey: "items.knowledge", icon: "knowledge" },
{ to: "/automation", labelKey: "items.integrations", icon: "integrations", roles: ["operations_manager"] },
{ to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] },
],
},
];
export function Layout() {
const { t } = useTranslation(["navigation", "common", "auth"]);
const { user, logout } = useAuth();
const { manifest } = useDemoManifest();
const { open: guideOpen } = useDemoGuide();
const { open: guideOpen, collapsedToChip: guideCollapsed } = useDemoGuide();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -135,7 +137,7 @@ export function Layout() {
await logout();
navigate("/login");
} catch (err) {
setResetError(err instanceof ApiError ? err.message : "Could not reset demo data.");
setResetError(err instanceof ApiError ? err.message : t("resetFailed"));
setResetConfirming(false);
} finally {
setResetting(false);
@@ -170,23 +172,26 @@ export function Layout() {
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">Skip to main content</a>
<a className="skip-link" href="#main-content">{t("skipToContent")}</a>
<aside className={`sidebar ${mobileOpen ? "is-open" : ""}`}>
<div className="brand-lockup">
<BrandMark className="brand-mark" />
<div><strong>MobilityOps</strong><span>Control centre</span></div>
<div><strong>{t("common:appName")}</strong><span>{t("common:brandTagline")}</span></div>
</div>
<nav aria-label="Primary navigation">
<div className="sidebar-language">
<LanguageSwitcher />
</div>
<nav aria-label={t("primaryNavLabel")}>
{navGroups.map((group) => (
<div className="nav-group" key={group.label}>
<p>{group.label}</p>
<div className="nav-group" key={group.labelKey}>
<p>{t(group.labelKey)}</p>
<ul>
{group.items.map((item) => (
<li key={item.to}>
<NavLink to={item.to} onClick={() => setMobileOpen(false)}>
<Icon name={item.icon} />
<span>{item.label}</span>
<span>{t(item.labelKey)}</span>
</NavLink>
</li>
))}
@@ -196,23 +201,23 @@ export function Layout() {
</nav>
<div className="sidebar-foot">
<span className="environment-dot" />
<div><strong>Demo environment</strong><span>Synthetic data only</span></div>
<div><strong>{t("sidebarEnvironment")}</strong><span>{t("sidebarEnvironmentDetail")}</span></div>
</div>
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
<div className="sidebar-reset">
{resetError && <p className="error" role="alert">{resetError}</p>}
{!resetConfirming ? (
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
Reset demo data
{t("resetDemoData")}
</button>
) : (
<div className="confirm-bar" role="alertdialog" aria-label="Confirm demo reset">
<p>All synthetic changes will be discarded and deterministic demo data restored. You will be signed out.</p>
<div className="confirm-bar" role="alertdialog" aria-label={t("resetConfirmTitle")}>
<p>{t("resetConfirmBody")}</p>
<button type="button" onClick={handleDemoReset} disabled={resetting}>
{resetting ? "Resetting…" : "Yes, reset"}
{resetting ? t("resetting") : t("resetConfirmYes")}
</button>
<button type="button" onClick={() => setResetConfirming(false)} disabled={resetting}>
Cancel
{t("resetCancel")}
</button>
</div>
)}
@@ -220,16 +225,16 @@ export function Layout() {
)}
</aside>
{mobileOpen && <button className="nav-scrim" aria-label="Close navigation" onClick={() => setMobileOpen(false)} />}
{mobileOpen && <button className="nav-scrim" aria-label={t("closeNavigation")} onClick={() => setMobileOpen(false)} />}
<div className={`app-workspace ${guideOpen ? "guide-open" : ""}`}>
<div className={`app-workspace ${guideOpen ? "guide-open" : ""} ${guideOpen && guideCollapsed ? "guide-collapsed" : ""}`}>
<header className="topbar">
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label="Open navigation">
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label={t("openNavigation")}>
<Icon name="menu" />
</button>
<div className="global-search" role="search" ref={searchBox}>
<Icon name="search" />
<label className="visually-hidden" htmlFor="global-search-input">Search MobilityOps</label>
<label className="visually-hidden" htmlFor="global-search-input">{t("searchLabel")}</label>
<input
id="global-search-input"
ref={searchInput}
@@ -240,7 +245,7 @@ export function Layout() {
aria-autocomplete="list"
aria-activedescendant={activeIndex >= 0 ? `search-result-${activeIndex}` : undefined}
value={searchQuery}
placeholder="Search fleet, booking or section…"
placeholder={t("searchPlaceholder")}
onFocus={() => setSearchOpen(true)}
onChange={(event) => {
setSearchQuery(event.target.value);
@@ -248,13 +253,13 @@ export function Layout() {
}}
onKeyDown={handleSearchKeyDown}
/>
<kbd>Ctrl K</kbd>
<kbd>{t("searchShortcutHint")}</kbd>
{searchOpen && searchQuery.trim() && (
<div className="search-results" id="global-search-results" role="listbox">
{searchLoading && <p className="search-status">Searching…</p>}
{!searchLoading && searchError && <p className="search-status">Search is unavailable right now.</p>}
{searchLoading && <p className="search-status">{t("searchSearching")}</p>}
{!searchLoading && searchError && <p className="search-status">{t("searchUnavailable")}</p>}
{!searchLoading && !searchError && searchResults.length === 0 && (
<p className="search-status">No matches for "{searchQuery.trim()}".</p>
<p className="search-status">{t("searchNoResults", { query: searchQuery.trim() })}</p>
)}
{!searchLoading &&
!searchError &&
@@ -280,35 +285,36 @@ export function Layout() {
)}
</div>
<div className="topbar-meta">
<LanguageSwitcher compact />
<DemoGuideTrigger />
<DemoBadge />
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
{user && (
<div className="operator">
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span>
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? "Operations manager" : "Rental employee"}</small></span>
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
</div>
)}
<button className="icon-button" type="button" onClick={handleLogout} aria-label="Switch role" title="Switch demo role">
<button className="icon-button" type="button" onClick={handleLogout} aria-label={t("switchRole")} title={t("switchRoleTitle")}>
<Icon name="logout" />
</button>
</div>
</header>
<main id="main-content" tabIndex={-1}><Outlet /></main>
<footer className="app-footer"><span>MobilityOps PoC</span><span>Europe/Brussels · Synthetic demo data</span></footer>
<footer className="app-footer"><span>{t("common:footer.productLine")}</span><span>{t("common:footer.locale")}</span></footer>
</div>
<nav className="mobile-nav" aria-label="Mobile navigation">
<nav className="mobile-nav" aria-label={t("mobileNavLabel")}>
{mobileItems.map((item) => (
<NavLink key={item.to} to={item.to}>
<Icon name={item.icon} />
<span>{item.shortLabel}</span>
<span>{t(item.labelKey)}</span>
</NavLink>
))}
<button type="button" onClick={() => setMobileOpen(true)}>
<Icon name="menu" />
<span>More</span>
<span>{t("more")}</span>
</button>
</nav>
+9 -4
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Icon, type IconName } from "./Icons";
export function PageHeader({
@@ -28,15 +29,17 @@ export function SectionHeading({
title,
description,
action,
headingId,
}: {
title: string;
description?: string;
action?: ReactNode;
headingId?: string;
}) {
return (
<div className="section-heading">
<div>
<h2>{title}</h2>
<h2 id={headingId}>{title}</h2>
{description && <p>{description}</p>}
</div>
{action}
@@ -44,20 +47,22 @@ export function SectionHeading({
);
}
export function LoadingState({ label = "Loading workspace…" }: { label?: string }) {
export function LoadingState({ label }: { label?: string }) {
const { t } = useTranslation("common");
return (
<div className="state-panel" role="status">
<span className="spinner" aria-hidden="true" />
<p>{label}</p>
<p>{label ?? t("states.loadingDefault")}</p>
</div>
);
}
export function ErrorState({ message }: { message: string }) {
const { t } = useTranslation("common");
return (
<div className="state-panel state-error" role="alert">
<Icon name="alert" />
<div><strong>We couldn’t load this workspace.</strong><p>{message}</p></div>
<div><strong>{t("states.errorTitle")}</strong><p>{message}</p></div>
</div>
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ export function RequireAuth({ children }: { children: ReactNode }) {
if (loading) {
return (
<div className="page">
<LoadingState label="Verifying session…" />
<LoadingState />
</div>
);
}
+50 -53
View File
@@ -1,10 +1,12 @@
import { useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
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 "./Icons";
import { StatusBadge } from "./Badge";
@@ -16,6 +18,7 @@ function newIdempotencyKey(): string {
}
export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) {
const { t } = useTranslation("returns");
const { user } = useAuth();
const navigate = useNavigate();
const { manifest } = useDemoManifest();
@@ -30,12 +33,12 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
return (
<section className="panel return-result success-panel" aria-labelledby="return-result-heading" aria-live="polite">
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">Committed locally</p><h2 id="return-result-heading">Return registered</h2></div></div>
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">{t("result.committedLocally")}</p><h2 id="return-result-heading">{t("result.heading")}</h2></div></div>
<dl className="detail-grid">
<div><dt>Inspection</dt><dd>{result.inspection_ref}</dd></div>
<div><dt>Resulting vehicle status</dt><dd><StatusBadge status={result.resulting_vehicle_status} /></dd></div>
<div><dt>{t("result.inspection")}</dt><dd>{result.inspection_ref}</dd></div>
<div><dt>{t("result.resultingStatus")}</dt><dd><StatusBadge status={result.resulting_vehicle_status} label={t(`fleet:statuses.${result.resulting_vehicle_status}`, { defaultValue: result.resulting_vehicle_status })} /></dd></div>
<div>
<dt>Quality issue</dt>
<dt>{t("result.qualityIssue")}</dt>
<dd>
{result.quality_issue_ref ? (
canSeeQualityIssue ? (
@@ -44,40 +47,36 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
result.quality_issue_ref
)
) : (
"None created"
t("result.noneCreated")
)}
</dd>
</div>
<div>
<dt>Automation event</dt>
<dd>
Queued for delivery ({result.workflow_event_id.slice(0, 8)}) — local commit succeeded;
n8n delivery is asynchronous and not yet confirmed.
</dd>
<dt>{t("result.automationEvent")}</dt>
<dd>{t("result.queuedForDelivery", { ref: result.workflow_event_id.slice(0, 8) })}</dd>
</div>
<div>
<dt>Next booking risk</dt>
<dt>{t("result.nextBookingRisk")}</dt>
<dd>
{result.next_booking_risk
? `${result.next_booking_risk.booking_ref} ${result.next_booking_risk.at_risk ? "— may be affected" : "— low risk"}`
: "No upcoming booking for this vehicle"}
? t("result.nextBookingRiskValue", {
ref: result.next_booking_risk.booking_ref,
status: result.next_booking_risk.at_risk ? t("result.atRisk") : t("result.lowRisk"),
})
: t("result.noUpcomingBooking")}
</dd>
</div>
</dl>
{result.odometer_regression && (
<p className="error" role="alert">
The submitted odometer reading was below the vehicle&apos;s canonical odometer. It was
recorded as-is; the canonical odometer was not changed, and a data-quality issue was
opened for review.
</p>
<p className="error" role="alert">{t("result.odometerRegressionNotice")}</p>
)}
<div className="result-links">
<Link className="button button-secondary" to={`/vehicles/${result.vehicle_ref}`}>View vehicle {result.vehicle_ref}<Icon name="chevron" /></Link>
<Link className="button button-secondary" to="/automation">View automation status<Icon name="chevron" /></Link>
<Link className="button button-secondary" to="/audit">View audit trail<Icon name="chevron" /></Link>
<Link className="button button-secondary" to={`/vehicles/${result.vehicle_ref}`}>{t("result.viewVehicle", { ref: result.vehicle_ref })}<Icon name="chevron" /></Link>
<Link className="button button-secondary" to="/automation">{t("result.viewAutomation")}<Icon name="chevron" /></Link>
<Link className="button button-secondary" to="/audit">{t("result.viewAudit")}<Icon name="chevron" /></Link>
{guideOpen && (
<button type="button" className="button button-primary" onClick={continueDemo}>
Ga verder met de demo <Icon name="chevron" />
{t("result.continueDemo")} <Icon name="chevron" />
</button>
)}
</div>
@@ -94,6 +93,8 @@ export function ReturnForm({
onRegistered: (result: RegisterReturnResult) => void;
suggestedOdometerKm?: number;
}) {
const { t } = useTranslation(["returns", "errors"]);
const { formatNumber, formatDateTime } = useLocaleFormat();
const [odometer, setOdometer] = useState(suggestedOdometerKm !== undefined ? String(suggestedOdometerKm) : "");
const [fuel, setFuel] = useState("50");
const [cleanlinessOk, setCleanlinessOk] = useState(true);
@@ -131,9 +132,7 @@ export function ReturnForm({
setPreview(evaluated);
setStep("review");
} catch (err) {
setError(
err instanceof ApiError ? err.message : "Could not evaluate this return. Please try again.",
);
setError(err instanceof ApiError ? err.message : t("errors:generic"));
} finally {
setPreviewing(false);
}
@@ -149,11 +148,7 @@ export function ReturnForm({
);
onRegistered(registered);
} catch (err) {
if (err instanceof ApiError) {
setError(err.message);
} else {
setError("Could not register the return. Please try again.");
}
setError(err instanceof ApiError ? err.message : t("errors:generic"));
} finally {
setSubmitting(false);
}
@@ -161,13 +156,13 @@ export function ReturnForm({
return (
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
<div className="return-progress" aria-label="Return registration progress"><span className="is-complete"><i>1</i> Capture</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> Review</span><b /><span><i>3</i> Result</span></div>
<div className="section-heading"><div><p className="page-eyebrow">Booking {bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? "Register vehicle return" : "Review return impact"}</h2><p>{step === "capture" ? "Record the hand-back condition. The next step evaluates the exact operational consequences before anything is committed." : "This is the server's authoritative evaluation of what committing will do — confirm before it updates fleet state and queues automation."}</p></div></div>
<div className="return-progress" aria-label={t("progress.ariaLabel")}><span className="is-complete"><i>1</i> {t("progress.capture")}</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> {t("progress.review")}</span><b /><span><i>3</i> {t("progress.result")}</span></div>
<div className="section-heading"><div><p className="page-eyebrow">{bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? t("capture.heading") : t("review.heading")}</h2><p>{step === "capture" ? t("capture.description") : t("review.description")}</p></div></div>
{error && <p className="error" role="alert">{error}</p>}
{step === "capture" ? <div className="return-capture">
<div className="form-grid"><label>
End odometer (km)
{t("capture.endOdometer")}
<input
type="number"
required
@@ -178,7 +173,7 @@ export function ReturnForm({
</label>
<label>
Fuel level (%)
{t("capture.fuelLevel")}
<input
type="number"
required
@@ -189,13 +184,13 @@ export function ReturnForm({
/>
</label></div>
<fieldset className="condition-fieldset"><legend>Vehicle condition</legend><label className="checkbox-label check-card">
<fieldset className="condition-fieldset"><legend>{t("capture.conditionLegend")}</legend><label className="checkbox-label check-card">
<input
type="checkbox"
checked={cleanlinessOk}
onChange={(e) => setCleanlinessOk(e.target.checked)}
/>
Cleanliness acceptable
{t("capture.cleanlinessOk")}
</label>
<label className="checkbox-label check-card">
@@ -204,7 +199,7 @@ export function ReturnForm({
checked={damageReported}
onChange={(e) => setDamageReported(e.target.checked)}
/>
Damage reported
{t("capture.damageReported")}
</label>
<label className="checkbox-label check-card">
@@ -213,48 +208,50 @@ export function ReturnForm({
checked={technicalWarning}
onChange={(e) => setTechnicalWarning(e.target.checked)}
/>
Technical warning
{t("capture.technicalWarning")}
</label></fieldset>
<label>
Notes
{t("capture.notes")}
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} maxLength={2000} rows={3} />
</label></div> : preview && <div className="return-review" aria-live="polite">
<dl className="review-facts"><div><dt>Odometer</dt><dd>{Number(odometer).toLocaleString("en-GB")} km</dd></div><div><dt>Fuel</dt><dd>{fuel}%</dd></div><div><dt>Cleanliness</dt><dd>{cleanlinessOk ? "Accepted" : "Follow-up needed"}</dd></div><div><dt>Damage</dt><dd>{damageReported ? "Reported" : "None reported"}</dd></div><div><dt>Technical warning</dt><dd>{technicalWarning ? "Reported" : "None reported"}</dd></div></dl>
<dl className="review-facts"><div><dt>{t("review.odometer")}</dt><dd>{formatNumber(Number(odometer))} km</dd></div><div><dt>{t("review.fuel")}</dt><dd>{fuel}%</dd></div><div><dt>{t("review.cleanliness")}</dt><dd>{cleanlinessOk ? t("review.cleanlinessAccepted") : t("review.cleanlinessFollowUp")}</dd></div><div><dt>{t("review.damage")}</dt><dd>{damageReported ? t("review.damageReported") : t("review.damageNone")}</dd></div><div><dt>{t("review.technicalWarning")}</dt><dd>{technicalWarning ? t("review.technicalWarningReported") : t("review.technicalWarningNone")}</dd></div></dl>
<div className={`impact-preview ${preview.attention_reasons.length > 0 ? "impact-warning" : "impact-ready"}`}>
<Icon name={preview.attention_reasons.length > 0 ? "alert" : "check"} />
<div>
<strong>Expected fleet state: <StatusBadge status={preview.resulting_vehicle_status} /></strong>
<strong>{t("review.expectedState")} <StatusBadge status={preview.resulting_vehicle_status} label={t(`fleet:statuses.${preview.resulting_vehicle_status}`, { defaultValue: preview.resulting_vehicle_status })} /></strong>
<p>{preview.status_reason}</p>
</div>
</div>
{preview.odometer_regression && (
<p className="error" role="alert">
Submitted odometer ({preview.submitted_odometer_km.toLocaleString("en-GB")} km) is below
the canonical reading ({preview.canonical_odometer_km.toLocaleString("en-GB")} km). The
canonical odometer will not change, and a data-quality issue will be opened.
{t("review.odometerRegressionWarning", {
submitted: formatNumber(preview.submitted_odometer_km),
canonical: formatNumber(preview.canonical_odometer_km),
})}
</p>
)}
{preview.next_booking_risk && (
<p className={preview.next_booking_risk.at_risk ? "error" : undefined} role={preview.next_booking_risk.at_risk ? "alert" : undefined}>
Next booking {preview.next_booking_risk.booking_ref} starts{" "}
{new Date(preview.next_booking_risk.starts_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
{preview.next_booking_risk.at_risk ? " — may be affected by this return." : " — low risk."}
{t(preview.next_booking_risk.at_risk ? "review.nextBookingRisk" : "review.nextBookingLowRisk", {
ref: preview.next_booking_risk.booking_ref,
when: formatDateTime(preview.next_booking_risk.starts_at),
})}
</p>
)}
<ul className="commit-list"><li><Icon name="check" /> Create a return inspection</li><li><Icon name="check" /> Update the booking and vehicle atomically</li><li><Icon name="check" /> Queue n8n delivery after the local commit</li></ul>
<ul className="commit-list"><li><Icon name="check" /> {t("review.commitList.inspection")}</li><li><Icon name="check" /> {t("review.commitList.updateAtomic")}</li><li><Icon name="check" /> {t("review.commitList.queueAutomation")}</li></ul>
</div>}
<div className="form-actions">
{step === "review" && <button className="button button-secondary" type="button" onClick={() => setStep("capture")} disabled={submitting}><Icon name="arrow-left" /> Edit details</button>}
{step === "review" && <button className="button button-secondary" type="button" onClick={() => setStep("capture")} disabled={submitting}><Icon name="arrow-left" /> {t("review.editDetails")}</button>}
<button className="button button-primary" type="submit" disabled={submitting || previewing}>
{step === "capture"
? previewing
? "Evaluating…"
: "Review return"
? t("capture.evaluating")
: t("capture.reviewReturn")
: submitting
? "Registering…"
: "Confirm return"}{" "}
? t("review.registering")
: t("review.confirmReturn")}{" "}
{step === "capture" && !previewing && <Icon name="chevron" />}
</button>
</div>