Publish curated MobilityOps source snapshot
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user