Replace the err instanceof ApiError ? err.message : t(fallback) anti-
pattern -- which showed raw English backend text for the common case and
only used the localized fallback for the rare network-failure case -- at
all 13 call sites across 7 files.
New frontend/src/api/errorMessages.ts (describeApiError) resolves a
caught error to a localized {title, explanation, nextStep?, technical}
by checking the 32 known AppError codes first, then known HTTP statuses
(401/403/404/409/422/500), then a fully generic fallback. New
ApiErrorNotice (PageChrome.tsx) renders title/explanation/nextStep with
the raw text demoted to a "Technical details"/"Details techniques"
disclosure -- never shown as the primary message.
ApiError itself is split out of client.ts into a standalone
api/apiError.ts with no import.meta.env dependency, so errorMessages.ts
(and its tests) can be loaded outside a Vite/browser context.
353 lines
14 KiB
TypeScript
353 lines
14 KiB
TypeScript
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<SearchResultItem["type"], IconName> = {
|
|
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, unknown>) => 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, unknown>) => 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<SearchResultItem[]>([]);
|
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
const [resetConfirming, setResetConfirming] = useState(false);
|
|
const [resetting, setResetting] = useState(false);
|
|
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
|
|
const searchInput = useRef<HTMLInputElement>(null);
|
|
const searchBox = useRef<HTMLDivElement>(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<HTMLInputElement>) {
|
|
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 (
|
|
<div className="app-shell">
|
|
<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>{PRODUCT_NAME}</strong><span>{t("common:brandTagline")}</span></div>
|
|
</div>
|
|
<div className="sidebar-language">
|
|
<LanguageSwitcher />
|
|
</div>
|
|
<nav aria-label={t("primaryNavLabel")}>
|
|
{navGroups.map((group) => (
|
|
<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>{t(item.labelKey)}</span>
|
|
</NavLink>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</nav>
|
|
<div className="sidebar-foot">
|
|
<span className="environment-dot" />
|
|
<div><strong>{t("sidebarEnvironment")}</strong><span>{t("sidebarEnvironmentDetail")}</span></div>
|
|
</div>
|
|
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
|
|
<div className="sidebar-reset">
|
|
<ApiErrorNotice error={resetError} />
|
|
{!resetConfirming ? (
|
|
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
|
|
{t("resetDemoData")}
|
|
</button>
|
|
) : (
|
|
<div className="confirm-bar" role="alertdialog" aria-label={t("resetConfirmTitle")}>
|
|
<p>{t("resetConfirmBody")}</p>
|
|
<button type="button" onClick={handleDemoReset} disabled={resetting}>
|
|
{resetting ? t("resetting") : t("resetConfirmYes")}
|
|
</button>
|
|
<button type="button" onClick={() => setResetConfirming(false)} disabled={resetting}>
|
|
{t("resetCancel")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</aside>
|
|
|
|
{mobileOpen && <button className="nav-scrim" aria-label={t("closeNavigation")} onClick={() => setMobileOpen(false)} />}
|
|
|
|
<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={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">{t("searchLabel", { productName: PRODUCT_NAME })}</label>
|
|
<input
|
|
id="global-search-input"
|
|
ref={searchInput}
|
|
type="search"
|
|
role="combobox"
|
|
aria-expanded={searchOpen}
|
|
aria-controls="global-search-results"
|
|
aria-autocomplete="list"
|
|
aria-activedescendant={activeIndex >= 0 ? `search-result-${activeIndex}` : undefined}
|
|
value={searchQuery}
|
|
placeholder={t("searchPlaceholder")}
|
|
onFocus={() => setSearchOpen(true)}
|
|
onChange={(event) => {
|
|
setSearchQuery(event.target.value);
|
|
setSearchOpen(true);
|
|
}}
|
|
onKeyDown={handleSearchKeyDown}
|
|
/>
|
|
<kbd>{t("searchShortcutHint")}</kbd>
|
|
{searchOpen && searchQuery.trim() && (
|
|
<div className="search-results" id="global-search-results" role="listbox">
|
|
{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">{t("searchNoResults", { query: searchQuery.trim() })}</p>
|
|
)}
|
|
{!searchLoading &&
|
|
!searchError &&
|
|
searchResults.map((item, index) => (
|
|
<button
|
|
key={`${item.type}-${item.link}`}
|
|
id={`search-result-${index}`}
|
|
role="option"
|
|
aria-selected={index === activeIndex}
|
|
type="button"
|
|
className={`search-result ${index === activeIndex ? "is-active" : ""}`}
|
|
onMouseEnter={() => setActiveIndex(index)}
|
|
onClick={() => selectResult(item)}
|
|
>
|
|
<Icon name={SEARCH_ICON[item.type]} />
|
|
<span className="search-result-copy">
|
|
<strong>{searchResultLabel(t, item)}</strong>
|
|
<small>{searchResultDetail(t, item)}</small>
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="topbar-meta">
|
|
<LanguageSwitcher compact />
|
|
<DemoGuideTrigger />
|
|
<DemoBadge />
|
|
<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" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
|
|
</div>
|
|
)}
|
|
<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>{t("common:footer.productLine", { productName: PRODUCT_NAME })}</span><span>{t("common:footer.locale")}</span></footer>
|
|
</div>
|
|
|
|
<nav className="mobile-nav" aria-label={t("mobileNavLabel")}>
|
|
{mobileItems.map((item) => (
|
|
<NavLink key={item.to} to={item.to}>
|
|
<Icon name={item.icon} />
|
|
<span>{t(item.labelKey)}</span>
|
|
</NavLink>
|
|
))}
|
|
<button type="button" onClick={() => setMobileOpen(true)}>
|
|
<Icon name="menu" />
|
|
<span>{t("more")}</span>
|
|
</button>
|
|
</nav>
|
|
|
|
<DemoGuide />
|
|
</div>
|
|
);
|
|
}
|