- Add a single shared, pure vehicle-status evaluator (app/services/vehicle_status.py)
used identically by the data-quality scanner, a new non-mutating status-recommendation
preview endpoint, and a transactional apply endpoint with optimistic-concurrency token
revalidation -- eliminates the old opaque "calculate and apply" action and the unsafe
"maintenance + active booking -> auto rented" shortcut. Frontend
DataQualityIssueDetail.tsx now shows a review/decide/confirm panel with localized
why/evidence/consequence text in nl-BE/en-GB/fr-BE, with an exact "Change status to
<status>" confirm action per the brief.
- Fix MO-016 issue-order dependency: resolving the booking-overlap issue before vs.
after the status-conflict issue now converges on the same final vehicle status,
proven by test_mo_016_status_conflict_recommendation_is_order_independent.
- Make "Fleet Ops" a non-localizable brand constant (frontend/src/product.ts,
backend PRODUCT_NAME) via {{productName}} interpolation everywhere the brand name
appeared in locale prose; add a permanent test guarding against a translation file
ever defining the brand name or an "appName" key again.
- Convert dynamic backend prose to stable message codes + params: return status
reasons, audit field/actor-type labels, automation last_error, and search
section/vehicle/booking/issue results all now carry codes the frontend localizes,
with raw technical text demoted to a "Technical details" disclosure.
- docs/fleet-ops-correction/: gap audit, i18n inventory, and the vehicle-status
decision table documenting the evaluator's rules and safe-status principles.
148 backend tests + Ruff + mypy green; Alembic migration verified upgrade/downgrade;
frontend tsc/build and the i18n-coverage Playwright suite green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
104 lines
5.3 KiB
TypeScript
104 lines
5.3 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { Link, useParams } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { api } from "../api/client";
|
|
import type { Booking, RegisterReturnResult, VehicleDetail } from "../api/types";
|
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
|
import { useLocaleFormat } from "../i18n/format";
|
|
import { StatusBadge } from "../components/Badge";
|
|
import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm";
|
|
import { Icon } from "../components/Icons";
|
|
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
|
import { PRODUCT_NAME } from "../product";
|
|
|
|
export function BookingDetail() {
|
|
const { t } = useTranslation(["bookings", "returns"]);
|
|
const { formatDateTime, formatNumber } = useLocaleFormat();
|
|
const { publicRef } = useParams<{ publicRef: string }>();
|
|
const { manifest } = useDemoManifest();
|
|
const [booking, setBooking] = useState<Booking | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [returnResult, setReturnResult] = useState<RegisterReturnResult | null>(null);
|
|
const [canonicalOdometerKm, setCanonicalOdometerKm] = useState<number | null>(null);
|
|
|
|
const load = useCallback(() => {
|
|
if (!publicRef) return;
|
|
api
|
|
.get<Booking>(`/api/v1/bookings/${publicRef}`)
|
|
.then(setBooking)
|
|
.catch(() => setError(t("detail.notFound")));
|
|
}, [publicRef]);
|
|
|
|
useEffect(() => {
|
|
setBooking(null);
|
|
setError(null);
|
|
setReturnResult(null);
|
|
load();
|
|
}, [load]);
|
|
|
|
// The odometer-regression demo scenario supplies its own suspicious reading (per the
|
|
// brief: never ask a demo visitor to invent one) -- only fetched for that one known
|
|
// scenario booking, not for every return.
|
|
const isReturnAnomalyScenario =
|
|
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path === `/bookings/${publicRef}`;
|
|
|
|
useEffect(() => {
|
|
setCanonicalOdometerKm(null);
|
|
if (!isReturnAnomalyScenario || !booking) return;
|
|
api
|
|
.get<VehicleDetail>(`/api/v1/vehicles/${booking.vehicle_ref}`)
|
|
.then((vehicle) => setCanonicalOdometerKm(vehicle.odometer_km))
|
|
.catch(() => setCanonicalOdometerKm(null));
|
|
}, [isReturnAnomalyScenario, booking]);
|
|
|
|
function handleRegistered(result: RegisterReturnResult) {
|
|
setReturnResult(result);
|
|
load();
|
|
}
|
|
|
|
if (error) return <ErrorState message={error} />;
|
|
if (!booking) return <LoadingState label={t("detail.loading")} />;
|
|
|
|
return (
|
|
<div className="page">
|
|
<Link className="back-link" to="/bookings"><Icon name="arrow-left" /> {t("detail.backLink")}</Link>
|
|
<PageHeader eyebrow={t("detail.eyebrow")} title={booking.public_ref} description={`${booking.customer_name} · ${booking.vehicle_ref}`} actions={<StatusBadge status={booking.status} label={t(`statuses.${booking.status}`, { defaultValue: booking.status })} />} />
|
|
<section className="record-surface" aria-label={t("detail.eyebrow")}><dl className="detail-grid">
|
|
<div><dt>{t("detail.customer")}</dt><dd>{booking.customer_name} ({booking.customer_ref})</dd></div>
|
|
<div><dt>{t("detail.vehicle")}</dt><dd><Link to={`/vehicles/${booking.vehicle_ref}`}>{booking.vehicle_ref}</Link></dd></div>
|
|
<div><dt>{t("detail.starts")}</dt><dd>{formatDateTime(booking.starts_at)}</dd></div>
|
|
<div><dt>{t("detail.ends")}</dt><dd>{formatDateTime(booking.ends_at)}</dd></div>
|
|
<div><dt>{t("detail.startOdometer")}</dt><dd>{booking.start_odometer_km !== null ? `${formatNumber(booking.start_odometer_km)} km` : "—"}</dd></div>
|
|
<div><dt>{t("detail.endOdometer")}</dt><dd>{booking.end_odometer_km !== null ? `${formatNumber(booking.end_odometer_km)} km` : "—"}</dd></div>
|
|
<div><dt>{t("detail.requirementsComplete")}</dt><dd>{booking.requirements_complete ? t("detail.yes") : t("detail.no")}</dd></div>
|
|
</dl></section>
|
|
|
|
{isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && (
|
|
<section className="record-surface scenario-callout" aria-label={t("returns:scenario.ariaLabel")}>
|
|
<Icon name="spark" />
|
|
<div>
|
|
<strong>{t("returns:scenario.title")}</strong>
|
|
<p>{t("returns:scenario.body", { odometer: formatNumber(canonicalOdometerKm), productName: PRODUCT_NAME })}</p>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{returnResult && <ReturnResultPanel result={returnResult} />}
|
|
{/* Wait for the scenario's canonical odometer before mounting the form at all when
|
|
this is the known demo scenario, so ReturnForm's suggestedOdometerKm is correct
|
|
from its very first render -- never updated asynchronously after mount, which
|
|
previously raced with anyone already typing into the field. */}
|
|
{!returnResult && booking.status === "active" && isReturnAnomalyScenario && canonicalOdometerKm === null && (
|
|
<LoadingState label={t("returns:scenario.preparing")} />
|
|
)}
|
|
{!returnResult && booking.status === "active" && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && (
|
|
<ReturnForm
|
|
bookingRef={booking.public_ref}
|
|
onRegistered={handleRegistered}
|
|
suggestedOdometerKm={isReturnAnomalyScenario && canonicalOdometerKm !== null ? Math.max(0, canonicalOdometerKm - 50) : undefined}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|