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 } from "../api/client"; import { describeApiError, type ApiErrorInfo } from "../api/errorMessages"; 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"; import { PRODUCT_NAME } from "../product"; import { ApiErrorNotice } from "./PageChrome"; const SEARCH_ICON: Record = { vehicle: "fleet", booking: "bookings", data_quality_issue: "quality", section: "chevron", }; // The backend only ever sends a stable code + raw data params (never English prose) -- // see app/api/routers/search.py. Localizing here means the label/detail always follow // the operator's selected locale, in every namespace search results can point to. function searchResultLabel(t: (key: string, opts?: Record) => string, item: SearchResultItem): string { if (item.type === "section") return t(`navigation:items.${item.label}`, { defaultValue: item.label }); return item.label; } function searchResultDetail(t: (key: string, opts?: Record) => string, item: SearchResultItem): string { switch (item.type) { case "section": return t(`searchSections.${item.detail_code}`, { defaultValue: item.detail_code }); case "vehicle": return t("searchVehicleSummary", { ...item.detail_params }); case "booking": return t(`bookings:statuses.${item.detail_code}`, { defaultValue: item.detail_code }); case "data_quality_issue": return t(`quality:ruleTypes.${item.detail_code}`, { defaultValue: item.detail_code.replace(/_/g, " "), }); default: return item.detail_code; } } interface NavItem { to: string; labelKey: string; icon: IconName; roles?: Role[]; } const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [ { labelKey: "groups.operate", items: [ { 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"] }, ], }, { labelKey: "groups.assure", items: [ { 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, collapsedToChip: guideCollapsed } = useDemoGuide(); const navigate = useNavigate(); const [mobileOpen, setMobileOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchOpen, setSearchOpen] = useState(false); const [searchLoading, setSearchLoading] = useState(false); const [searchError, setSearchError] = useState(false); const [searchResults, setSearchResults] = useState([]); const [activeIndex, setActiveIndex] = useState(-1); const [resetConfirming, setResetConfirming] = useState(false); const [resetting, setResetting] = useState(false); const [resetError, setResetError] = useState(null); const searchInput = useRef(null); const searchBox = useRef(null); const navGroups = useMemo( () => NAV_GROUPS.map((group) => ({ ...group, items: group.items.filter((item) => !item.roles || (user && item.roles.includes(user.role))), })).filter((group) => group.items.length > 0), [user], ); const mobileItems = useMemo(() => navGroups.flatMap((group) => group.items).slice(0, 5), [navGroups]); 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); }, []); useEffect(() => { const query = searchQuery.trim(); if (!query) { setSearchResults([]); setSearchLoading(false); setSearchError(false); setActiveIndex(-1); return; } setSearchLoading(true); setSearchError(false); const timeout = window.setTimeout(() => { api .get<{ query: string; results: SearchResultItem[] }>( `/api/v1/search?q=${encodeURIComponent(query)}`, ) .then((response) => { setSearchResults(response.results); setActiveIndex(-1); }) .catch(() => setSearchError(true)) .finally(() => setSearchLoading(false)); }, 250); return () => window.clearTimeout(timeout); }, [searchQuery]); useEffect(() => { function handleOutsideClick(event: MouseEvent) { if (searchBox.current && !searchBox.current.contains(event.target as Node)) { setSearchOpen(false); } } document.addEventListener("mousedown", handleOutsideClick); return () => document.removeEventListener("mousedown", handleOutsideClick); }, []); async function handleLogout() { await logout(); navigate("/login"); } async function handleDemoReset() { setResetError(null); setResetting(true); try { await api.post("/api/v1/demo/reset"); // The server invalidates the acting session as part of reset; drop local state the // same way an explicit logout would and return to the login screen. await logout(); navigate("/login"); } catch (err) { setResetError(describeApiError(t, err, "resetFailed")); setResetConfirming(false); } finally { setResetting(false); } } function selectResult(item: SearchResultItem) { setSearchOpen(false); setSearchQuery(""); setSearchResults([]); navigate(item.link); } function handleSearchKeyDown(event: ReactKeyboardEvent) { if (event.key === "Escape") { setSearchOpen(false); return; } if (!searchOpen || searchResults.length === 0) return; if (event.key === "ArrowDown") { event.preventDefault(); setActiveIndex((i) => (i + 1) % searchResults.length); } else if (event.key === "ArrowUp") { event.preventDefault(); setActiveIndex((i) => (i <= 0 ? searchResults.length - 1 : i - 1)); } else if (event.key === "Enter") { event.preventDefault(); const target = searchResults[activeIndex] ?? searchResults[0]; if (target) selectResult(target); } } return (
{t("skipToContent")} {mobileOpen &&
= 0 ? `search-result-${activeIndex}` : undefined} value={searchQuery} placeholder={t("searchPlaceholder")} onFocus={() => setSearchOpen(true)} onChange={(event) => { setSearchQuery(event.target.value); setSearchOpen(true); }} onKeyDown={handleSearchKeyDown} /> {t("searchShortcutHint")} {searchOpen && searchQuery.trim() && (
{searchLoading &&

{t("searchSearching")}

} {!searchLoading && searchError &&

{t("searchUnavailable")}

} {!searchLoading && !searchError && searchResults.length === 0 && (

{t("searchNoResults", { query: searchQuery.trim() })}

)} {!searchLoading && !searchError && searchResults.map((item, index) => ( ))}
)}
{t("common:timezone")} {user && (
{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)} {user.display_name}{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}
)}
{t("common:footer.productLine", { productName: PRODUCT_NAME })}{t("common:footer.locale")}
); }