Files
MobilityOps/frontend/src/components/Layout.tsx
T

183 lines
7.2 KiB
TypeScript

import { FormEvent, useEffect, useRef, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { BrandMark, Icon, type IconName } from "./Icons";
const NAV_GROUPS: Array<{ label: string; items: Array<{ to: string; label: string; shortLabel: string; icon: IconName }> }> = [
{
label: "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" },
],
},
{
label: "Assure",
items: [
{ to: "/knowledge", label: "Knowledge", shortLabel: "Knowledge", icon: "knowledge" },
{ to: "/automation", label: "Integrations", shortLabel: "Systems", icon: "integrations" },
{ to: "/audit", label: "Audit trail", shortLabel: "Audit", icon: "audit" },
],
},
];
const MOBILE_ITEMS = NAV_GROUPS.flatMap((group) => group.items).slice(0, 5);
const SEARCH_DESTINATIONS = [
{ to: "/dashboard", terms: ["overview", "dashboard", "readiness"] },
{ to: "/vehicles", terms: ["fleet", "vehicle", "vehicles"] },
{ to: "/bookings", terms: ["booking", "bookings", "rental"] },
{ to: "/data-quality", terms: ["quality", "data quality", "issues"] },
{ to: "/knowledge", terms: ["knowledge", "procedures"] },
{ to: "/automation", terms: ["automation", "integrations", "systems", "n8n"] },
{ to: "/audit", terms: ["audit", "history"] },
];
export function Layout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchStatus, setSearchStatus] = useState("");
const searchInput = useRef<HTMLInputElement>(null);
useEffect(() => {
function focusGlobalSearch(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
searchInput.current?.focus();
}
}
window.addEventListener("keydown", focusGlobalSearch);
return () => window.removeEventListener("keydown", focusGlobalSearch);
}, []);
function handleLogout() {
logout();
navigate("/login");
}
function handleSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const query = searchQuery.trim();
if (!query) {
setSearchStatus("Enter a section or a vehicle, booking or issue reference.");
return;
}
const publicRef = query.toUpperCase();
let destination: string | undefined;
if (/^MO-\d+$/.test(publicRef)) destination = `/vehicles/${publicRef}`;
else if (/^BK-[A-Z0-9-]+$/.test(publicRef)) destination = `/bookings/${publicRef}`;
else if (/^DQ-[A-Z0-9-]+$/.test(publicRef)) destination = `/data-quality/${publicRef}`;
else {
const normalized = query.toLowerCase();
destination = SEARCH_DESTINATIONS.find(({ terms }) =>
terms.some((term) => term.includes(normalized) || normalized.includes(term)),
)?.to;
}
if (destination) {
setSearchStatus("");
navigate(destination);
return;
}
setSearchStatus(`No destination found for ${query}. Try a vehicle, booking or issue reference.`);
}
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">Skip to main content</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>
<nav aria-label="Primary navigation">
{NAV_GROUPS.map((group) => (
<div className="nav-group" key={group.label}>
<p>{group.label}</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>
</NavLink>
</li>
))}
</ul>
</div>
))}
</nav>
<div className="sidebar-foot">
<span className="environment-dot" />
<div><strong>Demo environment</strong><span>Synthetic data only</span></div>
</div>
</aside>
{mobileOpen && <button className="nav-scrim" aria-label="Close navigation" onClick={() => setMobileOpen(false)} />}
<div className="app-workspace">
<header className="topbar">
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label="Open navigation">
<Icon name="menu" />
</button>
<form className="global-search" role="search" onSubmit={handleSearch}>
<Icon name="search" />
<label className="visually-hidden" htmlFor="global-search-input">Search MobilityOps</label>
<input
id="global-search-input"
ref={searchInput}
type="search"
value={searchQuery}
placeholder="Search fleet, booking or section…"
aria-describedby="global-search-status"
onChange={(event) => {
setSearchQuery(event.target.value);
setSearchStatus("");
}}
/>
<kbd>Ctrl K</kbd>
<span id="global-search-status" className="visually-hidden" aria-live="polite">{searchStatus}</span>
</form>
<div className="topbar-meta">
<span className="timezone"><Icon name="clock" /> Europe/Brussels</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>
</div>
)}
<button className="icon-button" type="button" onClick={handleLogout} aria-label="Switch role" title="Switch demo role">
<Icon name="logout" />
</button>
</div>
</header>
<p className="demo-banner"><Icon name="shield" /> Synthetic demo data · no real customer or vehicle information</p>
<main id="main-content" tabIndex={-1}><Outlet /></main>
<footer className="app-footer"><span>MobilityOps PoC</span><span>Europe/Brussels · Synthetic demo data</span></footer>
</div>
<nav className="mobile-nav" aria-label="Mobile navigation">
{MOBILE_ITEMS.map((item) => (
<NavLink key={item.to} to={item.to}>
<Icon name={item.icon} />
<span>{item.shortLabel}</span>
</NavLink>
))}
<button type="button" onClick={() => setMobileOpen(true)}>
<Icon name="menu" />
<span>More</span>
</button>
</nav>
</div>
);
}