183 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|