Files
MobilityOps/frontend/src/pages/VehicleDetail.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

126 lines
5.8 KiB
TypeScript

import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { VehicleDetail as VehicleDetailData } from "../api/types";
import { useLocaleFormat } from "../i18n/format";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
const TABS = ["overview", "bookings", "inspections", "maintenance", "quality"] as const;
type Tab = (typeof TABS)[number];
export function VehicleDetail() {
const { t } = useTranslation(["fleet", "bookings", "quality"]);
const { formatNumber, formatShortDate } = useLocaleFormat();
const { publicRef } = useParams<{ publicRef: string }>();
const [vehicle, setVehicle] = useState<VehicleDetailData | null>(null);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("overview");
useEffect(() => {
if (!publicRef) return;
setVehicle(null);
setError(null);
api
.get<VehicleDetailData>(`/api/v1/vehicles/${publicRef}`)
.then(setVehicle)
.catch(() => setError(t("detail.notFound")));
}, [publicRef]);
if (error) return <ErrorState message={error} />;
if (!vehicle) return <LoadingState label={t("detail.loading")} />;
return (
<div className="page">
<Link className="back-link" to="/vehicles"><Icon name="arrow-left" /> {t("detail.backLink")}</Link>
<PageHeader eyebrow={t("detail.eyebrow")} title={`${vehicle.public_ref} · ${vehicle.make} ${vehicle.model}`} description={`${vehicle.registration_number} · ${vehicle.location}`} actions={<div className="status-stack"><StatusBadge status={vehicle.operational_status} label={t(`statuses.${vehicle.operational_status}`, { defaultValue: vehicle.operational_status })} />{vehicle.attention && <span className="badge severity-high">{t("detail.needsAttention")}</span>}</div>} />
<div role="tablist" aria-label={t("detail.tabsAriaLabel")} className="tabs" aria-orientation="horizontal">
{TABS.map((tb) => (
<button
key={tb}
role="tab"
type="button"
aria-selected={tab === tb}
className={tab === tb ? "active" : ""}
onClick={() => setTab(tb)}
>
{t(`detail.tabs.${tb}`)}
</button>
))}
</div>
{tab === "overview" && (
<section className="record-surface" aria-label={t("detail.tabs.overview")}><dl className="detail-grid">
<div><dt>{t("detail.overview.registration")}</dt><dd>{vehicle.registration_number}</dd></div>
<div><dt>{t("detail.overview.modelYear")}</dt><dd>{vehicle.model_year}</dd></div>
<div><dt>{t("detail.overview.location")}</dt><dd>{vehicle.location}</dd></div>
<div><dt>{t("detail.overview.odometer")}</dt><dd>{formatNumber(vehicle.odometer_km)} km</dd></div>
<div><dt>{t("detail.overview.nextService")}</dt><dd>{formatNumber(vehicle.next_service_km)} km</dd></div>
<div><dt>{t("detail.overview.active")}</dt><dd>{vehicle.active ? t("detail.yes") : t("detail.no")}</dd></div>
</dl></section>
)}
{tab === "bookings" && (
<ul className="record-list">
{vehicle.bookings.length === 0 && <li>{t("detail.noBookings")}</li>}
{vehicle.bookings.map((b) => (
<li key={b.public_ref}>
<Link to={`/bookings/${b.public_ref}`}>{b.public_ref}</Link>
<StatusBadge status={b.status} label={t(`bookings:statuses.${b.status}`, { defaultValue: b.status })} />
<span>
{formatShortDate(b.starts_at)} {formatShortDate(b.ends_at)}
</span>
</li>
))}
</ul>
)}
{tab === "inspections" && (
<ul className="record-list">
{vehicle.inspections.length === 0 && <li>{t("detail.noInspections")}</li>}
{vehicle.inspections.map((i) => (
<li key={i.public_ref}>
<span>{t(`fleet:detail.inspectionTypes.${i.type}`, { defaultValue: i.type })}</span>
<span>{formatNumber(i.odometer_km)} km</span>
<span>{t("detail.fuel", { percent: i.fuel_level_percent })}</span>
{i.damage_reported && <span className="badge severity-high">{t("detail.damage")}</span>}
{i.technical_warning && <span className="badge severity-high">{t("detail.technicalWarning")}</span>}
<time dateTime={i.completed_at}>{formatShortDate(i.completed_at)}</time>
</li>
))}
</ul>
)}
{tab === "maintenance" && (
<ul className="record-list">
{vehicle.maintenance.length === 0 && <li>{t("detail.noMaintenance")}</li>}
{vehicle.maintenance.map((m) => (
<li key={m.public_ref}>
<span>{t(`fleet:detail.maintenanceCategories.${m.category}`, { defaultValue: m.category })}</span>
<span>{m.summary}</span>
<time dateTime={m.occurred_at}>{formatShortDate(m.occurred_at)}</time>
</li>
))}
</ul>
)}
{tab === "quality" && (
<ul className="record-list">
{vehicle.quality_issues.length === 0 && <li>{t("detail.noQualityIssues")}</li>}
{vehicle.quality_issues.map((q) => (
<li key={q.public_ref}>
<Link to={`/data-quality/${q.public_ref}`}>{q.public_ref}</Link>
<SeverityBadge severity={q.severity} />
<span>{t(`quality:ruleTypes.${q.rule_type}`, { defaultValue: q.rule_type.replace(/_/g, " ") })}</span>
<StatusBadge status={q.status} label={t(`quality:list.status${q.status.charAt(0).toUpperCase()}${q.status.slice(1)}`, { defaultValue: q.status })} />
</li>
))}
</ul>
)}
</div>
);
}