Publish curated MobilityOps source snapshot

This commit is contained in:
Jens committed 2026-09-29 22:03:33 +02:00
commit 7adc072f5e
317 files changed
+48851

No files matched your search

@@ -0,0 +1,104 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Icon, type IconName } from "./Icons";
import "../styles-architecture-flow.css";
type ArchitectureStepId = "Frontend" | "Api" | "Database" | "Outbox" | "External";
type ArchitectureZone = "intent" | "transaction" | "edge";
const ARCHITECTURE_STEPS: Array<{
id: ArchitectureStepId;
icon: IconName;
zone: ArchitectureZone;
}> = [
{ id: "Frontend", icon: "user", zone: "intent" },
{ id: "Api", icon: "shield", zone: "transaction" },
{ id: "Database", icon: "audit", zone: "transaction" },
{ id: "Outbox", icon: "activity", zone: "transaction" },
{ id: "External", icon: "integrations", zone: "edge" },
];
const ZONE_TRANSLATION_KEYS: Record<ArchitectureZone, string> = {
intent: "architectureZoneIntent",
transaction: "architectureZoneTransaction",
edge: "architectureZoneEdge",
};
export function ArchitectureFlow() {
const { t } = useTranslation("demo");
const [activeStepId, setActiveStepId] = useState<ArchitectureStepId>("Database");
const activeIndex = ARCHITECTURE_STEPS.findIndex((step) => step.id === activeStepId);
const activeStep = ARCHITECTURE_STEPS[activeIndex];
return (
<div className="architecture-explorer">
<div className="architecture-zones" aria-label={t("about.architectureZonesLabel")}>
<span className="zone-intent"><Icon name="user" /> {t("about.architectureZoneIntent")}</span>
<span className="zone-transaction"><Icon name="shield" /> {t("about.architectureZoneTransaction")}</span>
<span className="zone-edge"><Icon name="integrations" /> {t("about.architectureZoneEdge")}</span>
</div>
<ol className="architecture-flow" aria-label={t("about.architectureFlowLabel")}>
{ARCHITECTURE_STEPS.map((step, index) => {
const isActive = step.id === activeStepId;
const isCommitBoundary = step.id === "Outbox";
return (
<li
className={`architecture-step architecture-step-${step.zone}${isActive ? " is-active" : ""}`}
key={step.id}
>
<button
type="button"
aria-pressed={isActive}
aria-controls="architecture-active-detail"
onClick={() => setActiveStepId(step.id)}
>
<span className="architecture-step-top">
<span className="architecture-step-number">0{index + 1}</span>
<span className="architecture-step-icon"><Icon name={step.icon} /></span>
</span>
<strong>{t(`about.architecture${step.id}`)}</strong>
<small>{t(`about.architectureDetails.${step.id}.summary`)}</small>
<span className="architecture-step-meta">
<span className="architecture-zone-tag">{t(`about.${ZONE_TRANSLATION_KEYS[step.zone]}`)}</span>
{isCommitBoundary ? <span className="architecture-commit-note">→ {t("about.architectureCommitBoundary")}</span> : null}
</span>
</button>
{index < ARCHITECTURE_STEPS.length - 1 ? (
<span className={`architecture-connector${isCommitBoundary ? " is-commit-boundary" : ""}`} aria-hidden="true">
<span className="architecture-signal" />
</span>
) : null}
</li>
);
})}
</ol>
<p className="architecture-interaction-hint">{t("about.architectureInteractionHint")}</p>
<section
className={`architecture-active-detail architecture-active-detail-${activeStep.zone}`}
id="architecture-active-detail"
aria-live="polite"
>
<div className="architecture-active-copy">
<span>{t("about.architectureSelectedStep", { current: activeIndex + 1, total: ARCHITECTURE_STEPS.length })}</span>
<h3>{t(`about.architecture${activeStep.id}`)}</h3>
<p>{t(`about.architectureDetails.${activeStep.id}.body`)}</p>
</div>
<dl className="architecture-active-evidence">
<div>
<dt>{t("about.architectureBoundaryLabel")}</dt>
<dd>{t(`about.${ZONE_TRANSLATION_KEYS[activeStep.zone]}`)}</dd>
</div>
<div>
<dt>{t("about.architectureEvidenceLabel")}</dt>
<dd>
<span>{t(`about.architectureDetails.${activeStep.id}.proofOne`)}</span>
<span>{t(`about.architectureDetails.${activeStep.id}.proofTwo`)}</span>
</dd>
</div>
</dl>
</section>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { useTranslation } from "react-i18next";
export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
const { t } = useTranslation("quality");
return <span className={`badge severity-${severity}`}>{t(`severities.${severity}`)}</span>;
}
export function StatusBadge({ status, label }: { status: string; label?: string }) {
return <span className={`badge status-${status}`}>{label ?? status.replace(/_/g, " ")}</span>;
}
+65
View File
@@ -0,0 +1,65 @@
import { FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { CheckoutBookingResult, VehicleDetail } from "../api/types";
import { ApiErrorNotice, LoadingState, SectionHeading } from "./PageChrome";
export function CheckoutForm({ bookingRef, vehicleRef, onRecorded }: { bookingRef: string; vehicleRef: string; onRecorded: (result: CheckoutBookingResult) => void }) {
const { t } = useTranslation(["bookings", "errors"]);
const [odometer, setOdometer] = useState<number | null>(null);
const [fuel, setFuel] = useState(100);
const [clean, setClean] = useState(true);
const [damage, setDamage] = useState(false);
const [warning, setWarning] = useState(false);
const [notes, setNotes] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
useEffect(() => {
api.get<VehicleDetail>(`/api/v1/vehicles/${vehicleRef}`)
.then((vehicle) => setOdometer(vehicle.odometer_km))
.catch((err) => setError(describeApiError(t, err)));
}, [t, vehicleRef]);
async function submit(event: FormEvent) {
event.preventDefault();
if (odometer === null) return;
setSaving(true);
setError(null);
try {
const result = await api.post<CheckoutBookingResult>(`/api/v1/bookings/${bookingRef}/checkout`, {
start_odometer_km: odometer,
fuel_level_percent: fuel,
cleanliness_ok: clean,
damage_reported: damage,
technical_warning: warning,
notes: notes || null,
});
onRecorded(result);
} catch (err) {
setError(describeApiError(t, err, "bookings:checkout.failed"));
} finally {
setSaving(false);
}
}
if (odometer === null && !error) return <LoadingState label={t("checkout.preparing")} />;
return <form className="return-form record-surface" onSubmit={submit}>
<SectionHeading title={t("checkout.title")} description={t("checkout.description")} />
<div className="return-capture">
<ApiErrorNotice error={error} />
<div className="form-grid">
<label>{t("checkout.odometer")}<input type="number" min={0} required value={odometer ?? ""} onChange={(event) => setOdometer(Number(event.target.value))} /></label>
<label>{t("checkout.fuel")}<input type="number" min={0} max={100} required value={fuel} onChange={(event) => setFuel(Number(event.target.value))} /></label>
</div>
<fieldset className="condition-fieldset"><legend>{t("checkout.condition")}</legend>
<label className="check-card"><input type="checkbox" checked={clean} onChange={(event) => setClean(event.target.checked)} /> {t("checkout.clean")}</label>
<label className="check-card"><input type="checkbox" checked={damage} onChange={(event) => setDamage(event.target.checked)} /> {t("checkout.damage")}</label>
<label className="check-card"><input type="checkbox" checked={warning} onChange={(event) => setWarning(event.target.checked)} /> {t("checkout.warning")}</label>
</fieldset>
<label>{t("checkout.notes")}<textarea maxLength={2000} value={notes} onChange={(event) => setNotes(event.target.value)} /></label>
</div>
<div className="form-actions"><button type="submit" className="button button-primary" disabled={saving || odometer === null}>{saving ? t("checkout.saving") : t("checkout.submit")}</button></div>
</form>;
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { Icon } from "./Icons";
export function DemoBadge() {
const { t } = useTranslation("demo");
const { formatDateTime } = useLocaleFormat();
const { manifest } = useDemoManifest();
const [open, setOpen] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleOutsideClick(event: MouseEvent) {
if (boxRef.current && !boxRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", handleOutsideClick);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleOutsideClick);
document.removeEventListener("keydown", handleEscape);
};
}, []);
return (
<div className="demo-badge" ref={boxRef}>
<button
type="button"
className="demo-badge-trigger"
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => setOpen((v) => !v)}
>
<Icon name="shield" />
<span>{t("badge.trigger")}</span>
</button>
{open && (
<div className="demo-badge-popover" role="dialog" aria-label={t("badge.dialogLabel")}>
<button type="button" className="icon-button demo-badge-close" onClick={() => setOpen(false)} aria-label={t("badge.close")}>
<Icon name="x" />
</button>
<p>
{manifest ? (
<Trans i18nKey="badge.orgIntro" t={t} values={{ orgName: manifest.organization_name }} components={{ strong: <strong /> }} />
) : (
t("badge.orgIntroFallback")
)}
</p>
<p>{t("badge.realWorkflows")}</p>
{manifest && (
<p className="demo-badge-reset">
<Trans
i18nKey="badge.lastReset"
t={t}
values={{ when: manifest.last_reset_at ? formatDateTime(manifest.last_reset_at) : t("badge.unknown") }}
components={{ strong: <strong /> }}
/>
</p>
)}
<Link to="/about" onClick={() => setOpen(false)}>
{t("badge.aboutLink")} <Icon name="chevron" />
</Link>
</div>
)}
</div>
);
}
+296
View File
@@ -0,0 +1,296 @@
import { useNavigate } from "react-router-dom";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useViewportTier } from "../hooks/useViewportTier";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "./Icons";
import { ApiErrorNotice } from "./PageChrome";
import { PRODUCT_NAME } from "../product";
const GUIDE_TRIGGER_ID = "demo-guide-trigger";
export function DemoGuideTrigger() {
const { t } = useTranslation("demo");
const { user } = useAuth();
const { open, toggleGuide, currentIndex, completed, totalSteps } = useDemoGuide();
if (user?.role !== "operations_manager") return null;
return (
<button
id={GUIDE_TRIGGER_ID}
type="button"
className="demo-guide-trigger"
aria-expanded={open}
aria-haspopup="dialog"
onClick={toggleGuide}
>
<Icon name="spark" />
<span>{t("guide.trigger")}</span>
<span className="demo-guide-progress-pill">{completed.size}/{totalSteps}</span>
<span className="visually-hidden">, {t("guide.kicker", { current: currentIndex + 1, total: totalSteps })}</span>
</button>
);
}
function highlightTarget(selector: string): (() => void) | null {
const el = document.querySelector<HTMLElement>(selector);
if (!el) return null;
el.scrollIntoView({ behavior: "smooth", block: "center" });
const previousTabIndex = el.getAttribute("tabindex");
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
el.focus({ preventScroll: true });
el.classList.add("demo-guide-highlight");
const timeout = window.setTimeout(() => {
el.classList.remove("demo-guide-highlight");
if (previousTabIndex === null) el.removeAttribute("tabindex");
}, 2200);
return () => {
window.clearTimeout(timeout);
el.classList.remove("demo-guide-highlight");
if (previousTabIndex === null) el.removeAttribute("tabindex");
};
}
export function DemoGuide() {
const { t } = useTranslation("demo");
const navigate = useNavigate();
const { logout } = useAuth();
const { manifest, refresh } = useDemoManifest();
const tier = useViewportTier();
const {
open,
closeGuide,
currentIndex,
completed,
totalSteps,
goToStep,
completeAndAdvance,
restart,
collapsedToChip,
setCollapsedToChip,
} = useDemoGuide();
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
const pendingTarget = useRef<string | null>(null);
const [targetRequest, setTargetRequest] = useState(0);
const step = DEMO_GUIDE_STEPS[currentIndex];
const isLastStep = currentIndex === totalSteps - 1;
const closeAndRestoreFocus = useCallback(() => {
pendingTarget.current = null;
closeGuide();
window.requestAnimationFrame(() => {
document.getElementById(GUIDE_TRIGGER_ID)?.focus();
});
}, [closeGuide]);
useEffect(() => {
if (!open) return;
function handleKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
if (tier === "standard" && !collapsedToChip) {
setCollapsedToChip(true);
} else if (tier === "mobile" && mobileSheetState !== "collapsed") {
setMobileSheetState("collapsed");
} else {
closeAndRestoreFocus();
}
}
document.addEventListener("keydown", handleKeydown);
return () => document.removeEventListener("keydown", handleKeydown);
}, [open, tier, collapsedToChip, mobileSheetState, closeAndRestoreFocus, setCollapsedToChip]);
useEffect(() => {
if (!open) pendingTarget.current = null;
}, [open]);
useEffect(() => {
if (!open || !pendingTarget.current) return;
const target = pendingTarget.current;
let attempts = 0;
let timer = 0;
let cancelled = false;
let removeHighlight: (() => void) | null = null;
const findAndHighlight = () => {
if (cancelled) return;
removeHighlight = highlightTarget(target);
if (removeHighlight) {
pendingTarget.current = null;
return;
}
attempts += 1;
if (attempts < 40) timer = window.setTimeout(findAndHighlight, 50);
else pendingTarget.current = null;
};
timer = window.setTimeout(findAndHighlight, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
removeHighlight?.();
};
}, [open, targetRequest]);
if (!open) return null;
function goToStepRoute() {
pendingTarget.current = step.target ?? null;
navigate(step.route(manifest));
// A navigation to the already-active route does not necessarily change pathname.
// This request counter guarantees the bounded target lookup still runs.
setTargetRequest((request) => request + 1);
if (tier === "standard") setCollapsedToChip(true);
if (tier === "mobile") setMobileSheetState("collapsed");
}
function handlePrimaryAction() {
completeAndAdvance();
if (isLastStep) closeAndRestoreFocus();
}
async function handleRestartDemo() {
setResetError(null);
setResetting(true);
try {
await api.post("/api/v1/demo/reset");
restart();
refresh();
closeAndRestoreFocus();
await logout();
navigate("/login");
} catch (err) {
setResetError(describeApiError(t, err, "guide.restartFailed"));
} finally {
setResetting(false);
}
}
if (tier === "standard" && collapsedToChip) {
return (
<div className="demo-guide-chip">
<button
type="button"
className="demo-guide-chip-expand"
onClick={() => setCollapsedToChip(false)}
aria-label={`${t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}, ${t("guide.expand")}`}
>
<Icon name="spark" />
{t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}
<Icon name="chevron" />
</button>
<button type="button" className="demo-guide-chip-close" onClick={closeAndRestoreFocus} aria-label={t("guide.close")}>
<Icon name="x" />
</button>
</div>
);
}
const panelClassName = [
"demo-guide-panel",
tier === "wide" ? "is-wide" : "",
tier === "mobile" ? `is-mobile sheet-${mobileSheetState}` : "",
]
.filter(Boolean)
.join(" ");
return (
<aside className={panelClassName} role="dialog" aria-label={t("guide.dialogLabel")}>
{tier === "mobile" && (
<button
type="button"
className="demo-guide-sheet-handle"
onClick={() =>
setMobileSheetState((s) => (s === "collapsed" ? "half" : s === "half" ? "full" : "collapsed"))
}
aria-label={
mobileSheetState === "full"
? t("guide.collapse")
: t("guide.expand")
}
>
<span aria-hidden="true" />
</button>
)}
<header className="demo-guide-header">
<div>
<p className="demo-guide-kicker">{t("guide.kicker", { current: currentIndex + 1, total: totalSteps })}</p>
<h2>{t(`guide.steps.${step.id}.title`)}</h2>
</div>
{tier !== "mobile" && (
<button
type="button"
className="icon-button"
onClick={tier === "standard" ? () => setCollapsedToChip(true) : closeAndRestoreFocus}
aria-label={tier === "standard" ? t("guide.collapse") : t("guide.close")}
>
<Icon name="x" />
</button>
)}
</header>
{mobileSheetState !== "collapsed" && (
<>
<div className="demo-guide-progress-bar" aria-hidden="true">
{DEMO_GUIDE_STEPS.map((s, index) => (
<span
key={s.id}
className={
index === currentIndex ? "is-current" : completed.has(s.id) ? "is-done" : ""
}
/>
))}
</div>
{(mobileSheetState !== "half" || tier !== "mobile") && (
<div className="demo-guide-body">
<p><strong>{t("guide.whatYouWillSee")}</strong><br />{t(`guide.steps.${step.id}.whatYouWillSee`)}</p>
<p><strong>{t("guide.whyItMatters")}</strong><br />{t(`guide.steps.${step.id}.whyItMatters`)}</p>
<p><strong>{t("guide.startAction")}</strong><br />{t(`guide.steps.${step.id}.startAction`)}</p>
<p><strong>{t("guide.expectedOutcome")}</strong><br />{t(`guide.steps.${step.id}.expectedOutcome`, { productName: PRODUCT_NAME })}</p>
</div>
)}
{tier !== "mobile" && (
<nav className="demo-guide-steps" aria-label={t("guide.allStepsLabel")}>
{DEMO_GUIDE_STEPS.map((s, index) => (
<button
key={s.id}
type="button"
className={index === currentIndex ? "is-current" : ""}
onClick={() => goToStep(index)}
>
{completed.has(s.id) && <Icon name="check" />}
{t(`guide.steps.${s.id}.title`)}
</button>
))}
</nav>
)}
<ApiErrorNotice error={resetError} />
<footer className="demo-guide-footer">
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
{t("guide.goToStep")}
</button>
<button type="button" className="button button-primary" onClick={handlePrimaryAction}>
{t(isLastStep ? "guide.finish" : "guide.next")}
</button>
{tier !== "mobile" && (
<button type="button" className="demo-guide-restart" onClick={handleRestartDemo} disabled={resetting}>
{resetting ? t("guide.restarting") : t("guide.restart")}
</button>
)}
</footer>
</>
)}
</aside>
);
}
+72
View File
@@ -0,0 +1,72 @@
import type { SVGProps } from "react";
export type IconName =
| "activity"
| "alert"
| "arrow-left"
| "audit"
| "bookings"
| "check"
| "chevron"
| "clock"
| "fleet"
| "integrations"
| "knowledge"
| "logout"
| "menu"
| "quality"
| "search"
| "shield"
| "spark"
| "user"
| "x";
const paths: Record<IconName, JSX.Element> = {
activity: <><path d="M3 12h4l2.5-6 5 12 2.5-6h4" /></>,
alert: <><path d="M10.3 3.4 2.6 17a2 2 0 0 0 1.7 3h15.4a2 2 0 0 0 1.7-3L13.7 3.4a2 2 0 0 0-3.4 0Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></>,
"arrow-left": <><path d="m15 18-6-6 6-6" /></>,
audit: <><path d="M9 4h6" /><path d="M9 2h6v4H9z" /><path d="M7 4H5a2 2 0 0 0-2 2v14h18V6a2 2 0 0 0-2-2h-2" /><path d="m8 13 2 2 5-5" /></>,
bookings: <><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M16 3v4M8 3v4M3 10h18" /></>,
check: <><path d="m5 12 4 4L19 6" /></>,
chevron: <><path d="m9 18 6-6-6-6" /></>,
clock: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>,
fleet: <><path d="m5 16-1 3h2" /><path d="m19 16 1 3h-2" /><path d="M4 16h16l-1.4-6.2A2.3 2.3 0 0 0 16.4 8H7.6a2.3 2.3 0 0 0-2.2 1.8L4 16Z" /><path d="M6 13h12" /><circle cx="7" cy="16" r="1" /><circle cx="17" cy="16" r="1" /></>,
integrations: <><circle cx="12" cy="12" r="3" /><circle cx="5" cy="6" r="2" /><circle cx="19" cy="6" r="2" /><circle cx="12" cy="20" r="2" /><path d="m7 7.5 2.7 2.3m4.6 0L17 7.5M12 15v3" /></>,
knowledge: <><path d="M4 5.5A3.5 3.5 0 0 1 7.5 2H11v18H7.5A3.5 3.5 0 0 0 4 23V5.5Z" /><path d="M20 5.5A3.5 3.5 0 0 0 16.5 2H13v18h3.5A3.5 3.5 0 0 1 20 23V5.5Z" /></>,
logout: <><path d="M10 17l5-5-5-5M15 12H3" /><path d="M14 4h5a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-5" /></>,
menu: <><path d="M4 7h16M4 12h16M4 17h16" /></>,
quality: <><path d="m12 3 2.2 4.5 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5-3.6-3.5 5-.7L12 3Z" /></>,
search: <><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>,
shield: <><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" /><path d="m9 12 2 2 4-4" /></>,
spark: <><path d="m12 3 1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3Z" /><path d="m19 15 .8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8L19 15Z" /></>,
user: <><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></>,
x: <><path d="m6 6 12 12M18 6 6 18" /></>,
};
export function Icon({ name, ...props }: { name: IconName } & SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
focusable="false"
{...props}
>
{paths[name]}
</svg>
);
}
export function BrandMark({ className = "" }: { className?: string }) {
return (
<svg className={className} aria-hidden="true" viewBox="0 0 32 32" fill="none">
<path d="M16 25.5c-4.4-5.1-6-8.2-6-11a6 6 0 1 1 12 0c0 2.8-1.6 5.9-6 11Z" fill="currentColor" />
<circle cx="16" cy="14.3" r="2.1" fill="#2dd4bf" />
<rect x="3" y="20.6" width="26" height="2.4" rx="1.2" fill="#2dd4bf" />
</svg>
);
}
@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import {
persistLanguage,
SUPPORTED_LANGUAGES,
type SupportedLanguage,
} from "../i18n/config";
export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
const { t, i18n } = useTranslation("common");
const current = (i18n.language as SupportedLanguage) || "nl-BE";
function handleChange(next: SupportedLanguage) {
void i18n.changeLanguage(next);
persistLanguage(next);
}
return (
<label className={`language-switcher ${compact ? "language-switcher-compact" : ""}`}>
<span className="visually-hidden">{t("language.label")}</span>
<select
value={current}
onChange={(event) => handleChange(event.target.value as SupportedLanguage)}
aria-label={t("language.label")}
>
{SUPPORTED_LANGUAGES.map((lang) => (
<option key={lang} value={lang}>
{t(`language.${lang}`)}
</option>
))}
</select>
</label>
);
}
+376
View File
@@ -0,0 +1,376 @@
import { useEffect, useLayoutEffect, 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"] },
{ to: "/users", labelKey: "items.users", icon: "activity", roles: ["operations_manager"] },
{ to: "/privacy", labelKey: "items.privacy", icon: "shield", roles: ["operations_manager"] },
{ to: "/about", labelKey: "items.engineering", icon: "spark" },
],
},
];
export function Layout() {
const { t } = useTranslation(["navigation", "common", "auth"]);
const { user, logout, demoMode } = useAuth();
const { manifest } = useDemoManifest();
const { open: guideOpen, collapsedToChip: guideCollapsed, restart: restartGuide } = 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]);
useLayoutEffect(() => {
function focusGlobalSearch(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
searchInput.current?.focus();
}
}
// Capture the shortcut before a nested control can consume it, and register before
// paint so Ctrl/Cmd+K is available as soon as the visible shell is interactive.
document.addEventListener("keydown", focusGlobalSearch, true);
return () => document.removeEventListener("keydown", focusGlobalSearch, true);
}, []);
useEffect(() => {
const query = searchQuery.trim();
if (!query) {
setSearchResults([]);
setSearchLoading(false);
setSearchError(false);
setActiveIndex(-1);
return;
}
setSearchLoading(true);
setSearchError(false);
// Abort the in-flight request when the query changes so a slow response for "a"
// can never overwrite the results for "ab" (or flip the loading state early).
const controller = new AbortController();
const timeout = window.setTimeout(() => {
api
.get<{ query: string; results: SearchResultItem[] }>(
`/api/v1/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal },
)
.then((response) => {
if (controller.signal.aborted) return;
setSearchResults(response.results);
setActiveIndex(-1);
})
.catch(() => {
if (!controller.signal.aborted) setSearchError(true);
})
.finally(() => {
if (!controller.signal.aborted) setSearchLoading(false);
});
}, 250);
return () => {
window.clearTimeout(timeout);
controller.abort();
};
}, [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");
// Reset guide progress only after the server has confirmed that the data reset
// committed. A failed reset must leave the visitor's current walkthrough intact.
restartGuide();
// 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>
{demoMode && 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">
{demoMode && <DemoGuideTrigger />}
{demoMode && <DemoBadge />}
{user && <LanguageSwitcher compact />}
{user && (
<details className="operator-menu">
<summary 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>
</summary>
<div className="operator-popover">
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
<button className="operator-logout" type="button" onClick={handleLogout}>
<Icon name="logout" /> {t("switchRole")}
</button>
</div>
</details>
)}
</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>
{demoMode && <DemoGuide />}
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Icon, type IconName } from "./Icons";
export function PageHeader({
eyebrow,
title,
description,
actions,
}: {
eyebrow: string;
title: string;
description?: string;
actions?: ReactNode;
}) {
return (
<header className="page-header">
<div>
<p className="page-eyebrow">{eyebrow}</p>
<h1>{title}</h1>
{description && <p className="page-description">{description}</p>}
</div>
{actions && <div className="page-actions">{actions}</div>}
</header>
);
}
export function SectionHeading({
title,
description,
action,
headingId,
}: {
title: string;
description?: string;
action?: ReactNode;
headingId?: string;
}) {
return (
<div className="section-heading">
<div>
<h2 id={headingId}>{title}</h2>
{description && <p>{description}</p>}
</div>
{action}
</div>
);
}
export function LoadingState({ label }: { label?: string }) {
const { t } = useTranslation("common");
return (
<div className="state-panel" role="status">
<span className="spinner" aria-hidden="true" />
<p>{label ?? t("states.loadingDefault")}</p>
</div>
);
}
export function ErrorState({
message,
onRetry,
retryLabel,
}: {
message: string;
onRetry?: () => void;
retryLabel?: string;
}) {
const { t } = useTranslation("common");
return (
<div className="state-panel state-error" role="alert">
<Icon name="alert" />
<div>
<strong>{t("states.errorTitle")}</strong>
<p>{message}</p>
{onRetry ? <button type="button" className="button button-secondary state-retry" onClick={onRetry}>{retryLabel ?? t("actions.retry")}</button> : null}
</div>
</div>
);
}
// Shared rendering for describeApiError()'s output: a localized title + explanation +
// optional next step, with the raw backend/network text demoted to a "Technical
// details" disclosure -- never shown as the primary message. See
// frontend/src/api/errorMessages.ts and docs/fleet-ops-final-localization/audit.md.
export function ApiErrorNotice({ error }: { error: import("../api/errorMessages").ApiErrorInfo | null }) {
const { t } = useTranslation("common");
if (!error) return null;
return (
<div className="error api-error-notice" role="alert">
<strong>{error.title}</strong>
<p>{error.explanation}</p>
{error.nextStep && <p className="api-error-next-step">{error.nextStep}</p>}
<details className="evidence-disclosure">
<summary>{t("actions.technicalDetails")}</summary>
<pre className="evidence-block">{error.technical}</pre>
</details>
</div>
);
}
export function EmptyState({
icon = "check",
title,
detail,
}: {
icon?: IconName;
title: string;
detail: string;
}) {
return (
<div className="state-panel state-empty">
<Icon name={icon} />
<div><strong>{title}</strong><p>{detail}</p></div>
</div>
);
}
export function IntegrationMark({ kind }: { kind: "n8n" | "rag" | "mcp" }) {
const labels = { n8n: "n8n", rag: "RA", mcp: "MC" };
return <span className={`integration-mark integration-${kind}`} aria-hidden="true">{labels[kind]}</span>;
}
+25
View File
@@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next";
export function Pagination({
page,
totalPages,
onPageChange,
}: {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}) {
const { t } = useTranslation("common");
if (totalPages <= 1) return null;
return (
<nav className="pagination" aria-label={t("pagination.label")}>
<button type="button" onClick={() => onPageChange(page - 1)} disabled={page <= 1}>
{t("pagination.previous")}
</button>
<span aria-current="page">{t("pagination.pageOf", { page, total: totalPages })}</span>
<button type="button" onClick={() => onPageChange(page + 1)} disabled={page >= totalPages}>
{t("pagination.next")}
</button>
</nav>
);
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { LoadingState } from "./PageChrome";
export function RequireAuth({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="page">
<LoadingState />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
+272
View File
@@ -0,0 +1,272 @@
import { useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "./Icons";
import { StatusBadge } from "./Badge";
import { ApiErrorNotice } from "./PageChrome";
function newIdempotencyKey(): string {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `key-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) {
const { t } = useTranslation("returns");
const { user } = useAuth();
const navigate = useNavigate();
const { manifest } = useDemoManifest();
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
const canSeeManagerEvidence = user?.role === "operations_manager";
function continueDemo() {
completeAndAdvance();
const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest));
}
return (
<section className="panel return-result success-panel" aria-labelledby="return-result-heading" aria-live="polite">
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">{t("result.committedLocally")}</p><h2 id="return-result-heading">{t("result.heading")}</h2></div></div>
<dl className="detail-grid">
<div><dt>{t("result.inspection")}</dt><dd>{result.inspection_ref}</dd></div>
<div><dt>{t("result.resultingStatus")}</dt><dd><StatusBadge status={result.resulting_vehicle_status} label={t(`fleet:statuses.${result.resulting_vehicle_status}`, { defaultValue: result.resulting_vehicle_status })} /></dd></div>
<div>
<dt>{t("result.qualityIssue")}</dt>
<dd>
{result.quality_issue_ref ? (
canSeeManagerEvidence ? (
<Link to={`/data-quality/${result.quality_issue_ref}`}>{result.quality_issue_ref}</Link>
) : (
result.quality_issue_ref
)
) : (
t("result.noneCreated")
)}
</dd>
</div>
<div>
<dt>{t("result.automationEvent")}</dt>
<dd>{t("result.queuedForDelivery", { ref: result.workflow_event_id.slice(0, 8) })}</dd>
</div>
<div>
<dt>{t("result.nextBookingRisk")}</dt>
<dd>
{result.next_booking_risk
? t("result.nextBookingRiskValue", {
ref: result.next_booking_risk.booking_ref,
status: result.next_booking_risk.at_risk ? t("result.atRisk") : t("result.lowRisk"),
})
: t("result.noUpcomingBooking")}
</dd>
</div>
</dl>
{result.odometer_regression && (
<p className="error" role="alert">{t("result.odometerRegressionNotice")}</p>
)}
<div className="result-links">
<Link className="button button-secondary" to={`/vehicles/${result.vehicle_ref}`}>{t("result.viewVehicle", { ref: result.vehicle_ref })}<Icon name="chevron" /></Link>
{canSeeManagerEvidence && <Link className="button button-secondary" to="/automation">{t("result.viewAutomation")}<Icon name="chevron" /></Link>}
{canSeeManagerEvidence && <Link className="button button-secondary" to={`/audit?correlation_id=${result.correlation_id}`}>{t("result.viewTrace")}<Icon name="chevron" /></Link>}
{guideOpen && (
<button type="button" className="button button-primary" onClick={continueDemo}>
{t("result.continueDemo")} <Icon name="chevron" />
</button>
)}
</div>
</section>
);
}
export function ReturnForm({
bookingRef,
onRegistered,
suggestedOdometerKm,
}: {
bookingRef: string;
onRegistered: (result: RegisterReturnResult) => void;
suggestedOdometerKm?: number;
}) {
const { t } = useTranslation(["returns", "errors"]);
const { formatNumber, formatDateTime } = useLocaleFormat();
const [odometer, setOdometer] = useState(suggestedOdometerKm !== undefined ? String(suggestedOdometerKm) : "");
const [fuel, setFuel] = useState("50");
const [cleanlinessOk, setCleanlinessOk] = useState(true);
const [damageReported, setDamageReported] = useState(false);
const [technicalWarning, setTechnicalWarning] = useState(false);
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [previewing, setPreviewing] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [idempotencyKey] = useState(newIdempotencyKey);
const [step, setStep] = useState<"capture" | "review">("capture");
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
function currentBody(): RegisterReturnRequest {
return {
end_odometer_km: Number(odometer),
fuel_level_percent: Number(fuel),
cleanliness_ok: cleanlinessOk,
damage_reported: damageReported,
technical_warning: technicalWarning,
notes: notes || undefined,
};
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (step === "capture") {
setError(null);
setPreviewing(true);
try {
const evaluated = await api.post<ReturnPreviewResult>(
`/api/v1/bookings/${bookingRef}/return-preview`,
currentBody(),
);
setPreview(evaluated);
setStep("review");
} catch (err) {
setError(describeApiError(t, err));
} finally {
setPreviewing(false);
}
return;
}
setError(null);
setSubmitting(true);
try {
const registered = await api.post<RegisterReturnResult>(
`/api/v1/bookings/${bookingRef}/return`,
currentBody(),
{ "Idempotency-Key": idempotencyKey },
);
onRegistered(registered);
} catch (err) {
setError(describeApiError(t, err));
} finally {
setSubmitting(false);
}
}
return (
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
<div className="return-progress" aria-label={t("progress.ariaLabel")}><span className="is-complete"><i>1</i> {t("progress.capture")}</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> {t("progress.review")}</span><b /><span><i>3</i> {t("progress.result")}</span></div>
<div className="section-heading"><div><p className="page-eyebrow">{bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? t("capture.heading") : t("review.heading")}</h2><p>{step === "capture" ? t("capture.description") : t("review.description")}</p></div></div>
<ApiErrorNotice error={error} />
{step === "capture" ? <div className="return-capture">
<div className="form-grid"><label>
{t("capture.endOdometer")}
<input
type="number"
required
min={0}
value={odometer}
onChange={(e) => setOdometer(e.target.value)}
/>
</label>
<label>
{t("capture.fuelLevel")}
<input
type="number"
required
min={0}
max={100}
value={fuel}
onChange={(e) => setFuel(e.target.value)}
/>
</label></div>
<fieldset className="condition-fieldset"><legend>{t("capture.conditionLegend")}</legend><label className="checkbox-label check-card">
<input
type="checkbox"
checked={cleanlinessOk}
onChange={(e) => setCleanlinessOk(e.target.checked)}
/>
{t("capture.cleanlinessOk")}
</label>
<label className="checkbox-label check-card">
<input
type="checkbox"
checked={damageReported}
onChange={(e) => setDamageReported(e.target.checked)}
/>
{t("capture.damageReported")}
</label>
<label className="checkbox-label check-card">
<input
type="checkbox"
checked={technicalWarning}
onChange={(e) => setTechnicalWarning(e.target.checked)}
/>
{t("capture.technicalWarning")}
</label></fieldset>
<label>
{t("capture.notes")}
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} maxLength={2000} rows={3} />
</label></div> : preview && <div className="return-review" aria-live="polite">
<dl className="review-facts"><div><dt>{t("review.odometer")}</dt><dd>{formatNumber(Number(odometer))} km</dd></div><div><dt>{t("review.fuel")}</dt><dd>{fuel}%</dd></div><div><dt>{t("review.cleanliness")}</dt><dd>{cleanlinessOk ? t("review.cleanlinessAccepted") : t("review.cleanlinessFollowUp")}</dd></div><div><dt>{t("review.damage")}</dt><dd>{damageReported ? t("review.damageReported") : t("review.damageNone")}</dd></div><div><dt>{t("review.technicalWarning")}</dt><dd>{technicalWarning ? t("review.technicalWarningReported") : t("review.technicalWarningNone")}</dd></div></dl>
<div className={`impact-preview ${preview.attention_reasons.length > 0 ? "impact-warning" : "impact-ready"}`}>
<Icon name={preview.attention_reasons.length > 0 ? "alert" : "check"} />
<div>
<strong>{t("review.expectedState")} <StatusBadge status={preview.resulting_vehicle_status} label={t(`fleet:statuses.${preview.resulting_vehicle_status}`, { defaultValue: preview.resulting_vehicle_status })} /></strong>
<p>{t(`review.reasonCodes.${preview.status_reason_code}`, {
defaultValue: preview.status_reason,
threshold_km:
typeof preview.status_reason_params.threshold_km === "number"
? formatNumber(preview.status_reason_params.threshold_km)
: undefined,
})}</p>
<details className="evidence-disclosure">
<summary>{t("common:actions.technicalDetails")}</summary>
<pre className="evidence-block">{preview.status_reason}</pre>
</details>
</div>
</div>
{preview.odometer_regression && (
<p className="error" role="alert">
{t("review.odometerRegressionWarning", {
submitted: formatNumber(preview.submitted_odometer_km),
canonical: formatNumber(preview.canonical_odometer_km),
})}
</p>
)}
{preview.next_booking_risk && (
<p className={preview.next_booking_risk.at_risk ? "error" : undefined} role={preview.next_booking_risk.at_risk ? "alert" : undefined}>
{t(preview.next_booking_risk.at_risk ? "review.nextBookingRisk" : "review.nextBookingLowRisk", {
ref: preview.next_booking_risk.booking_ref,
when: formatDateTime(preview.next_booking_risk.starts_at),
})}
</p>
)}
<ul className="commit-list"><li><Icon name="check" /> {t("review.commitList.inspection")}</li><li><Icon name="check" /> {t("review.commitList.updateAtomic")}</li><li><Icon name="check" /> {t("review.commitList.queueAutomation")}</li></ul>
</div>}
<div className="form-actions">
{step === "review" && <button className="button button-secondary" type="button" onClick={() => setStep("capture")} disabled={submitting}><Icon name="arrow-left" /> {t("review.editDetails")}</button>}
<button className="button button-primary" type="submit" disabled={submitting || previewing}>
{step === "capture"
? previewing
? t("capture.evaluating")
: t("capture.reviewReturn")
: submitting
? t("review.registering")
: t("review.confirmReturn")}{" "}
{step === "capture" && !previewing && <Icon name="chevron" />}
</button>
</div>
</form>
);
}
@@ -0,0 +1,57 @@
import { FormEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { MaintenanceRecord, VehicleDetail } from "../api/types";
import { ApiErrorNotice } from "./PageChrome";
import { brusselsLocalToIso, toBrusselsDate } from "../i18n/brusselsDateTime";
export function VehicleMaintenanceActions({ vehicle, onSaved }: { vehicle: VehicleDetail; onSaved: () => void }) {
const { t } = useTranslation(["fleet", "errors"]);
const [showRecord, setShowRecord] = useState(false);
const [occurredAt, setOccurredAt] = useState(() => toBrusselsDate(new Date()));
const [odometer, setOdometer] = useState(vehicle.odometer_km);
const [category, setCategory] = useState("periodic_service");
const [summary, setSummary] = useState("");
const [nextService, setNextService] = useState(vehicle.next_service_km);
const [releaseReason, setReleaseReason] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
async function recordMaintenance(event: FormEvent) {
event.preventDefault(); setSaving(true); setError(null);
try {
await api.post<MaintenanceRecord>(`/api/v1/vehicles/${vehicle.public_ref}/maintenance`, {
occurred_at: brusselsLocalToIso(`${occurredAt}T12:00`), odometer_km: odometer,
category, summary, next_service_km: nextService, mark_maintenance: true,
});
setShowRecord(false); setSummary(""); onSaved();
} catch (err) { setError(describeApiError(t, err, "fleet:maintenance.failed")); }
finally { setSaving(false); }
}
async function release(event: FormEvent) {
event.preventDefault(); setSaving(true); setError(null);
try {
await api.post(`/api/v1/vehicles/${vehicle.public_ref}/release`, { reason: releaseReason });
setReleaseReason(""); onSaved();
} catch (err) { setError(describeApiError(t, err, "fleet:maintenance.releaseFailed")); }
finally { setSaving(false); }
}
return <section className="record-surface maintenance-actions">
<div className="section-heading"><div><h2>{t("maintenance.actionsTitle")}</h2><p>{t("maintenance.actionsDescription")}</p></div>{!showRecord && <button type="button" className="button button-secondary" onClick={() => setShowRecord(true)}>{t("maintenance.add")}</button>}</div>
<ApiErrorNotice error={error} />
{showRecord && <form onSubmit={recordMaintenance}>
<div className="form-grid">
<label>{t("maintenance.date")}<input type="date" required value={occurredAt} onChange={(event) => setOccurredAt(event.target.value)} /></label>
<label>{t("maintenance.odometer")}<input type="number" min={vehicle.odometer_km} required value={odometer} onChange={(event) => setOdometer(Number(event.target.value))} /></label>
<label>{t("maintenance.category")}<select value={category} onChange={(event) => setCategory(event.target.value)}>{["periodic_service", "repair", "inspection", "tyres", "other"].map((value) => <option value={value} key={value}>{t(`detail.maintenanceCategories.${value}`, { defaultValue: value })}</option>)}</select></label>
<label>{t("maintenance.nextService")}<input type="number" min={odometer} required value={nextService} onChange={(event) => setNextService(Number(event.target.value))} /></label>
</div>
<label>{t("maintenance.summary")}<textarea required minLength={3} maxLength={2000} value={summary} onChange={(event) => setSummary(event.target.value)} /></label>
<div className="form-actions"><button type="button" className="button button-secondary" onClick={() => setShowRecord(false)}>{t("maintenance.cancel")}</button><button className="button button-primary" disabled={saving}>{saving ? t("maintenance.saving") : t("maintenance.save")}</button></div>
</form>}
{["cleaning", "maintenance", "blocked"].includes(vehicle.operational_status) && <form className="release-form" onSubmit={release}><label>{t("maintenance.releaseReason")}<input required minLength={3} maxLength={500} value={releaseReason} onChange={(event) => setReleaseReason(event.target.value)} placeholder={t("maintenance.releasePlaceholder")} /></label><button className="button button-primary" disabled={saving || releaseReason.trim().length < 3}>{t("maintenance.release")}</button></form>}
</section>;
}