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:
co-authored by
Claude Sonnet 5
parent
257a4cf6c0
commit
337f8716bb
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user