- 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>
126 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
}
|