Files
MobilityOps/frontend/src/pages/BookingDetail.tsx
T
NuklearRabbitandClaude Sonnet 5 6deb95524d fix: safe status-recommendation flow, MO-016 order independence, brand constant, message codes
- 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>
2026-08-03 21:37:34 +02:00

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>
);
}