M2: implement vehicle return vertical slice

Transactional return command with idempotency, row-lock concurrency control, odometer-regression handling, vehicle status derivation, outbox event, audit trail. Result-summary UI on booking detail. 26 backend tests passing, ruff clean. Verified end-to-end via browser against S1 demo scenario; fixed two real defects found only through browser testing (UI state loss on status transition, unflushed UUID default).
This commit is contained in:
NuklearRabbit
2026-08-01 21:49:46 +02:00
parent 03c5b60235
commit 0091c57c7f
14 changed files with 763 additions and 10 deletions
+159
View File
@@ -0,0 +1,159 @@
import { useState, type FormEvent } from "react";
import { Link } from "react-router-dom";
import { api, ApiError } from "../api/client";
import type { RegisterReturnRequest, RegisterReturnResult } from "../api/types";
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 }) {
return (
<section className="panel return-result" aria-labelledby="return-result-heading">
<h2 id="return-result-heading">Return registered</h2>
<dl className="detail-grid">
<div><dt>Inspection</dt><dd>{result.inspection_ref}</dd></div>
<div><dt>Resulting vehicle status</dt><dd>{result.resulting_vehicle_status}</dd></div>
<div>
<dt>Quality issue</dt>
<dd>{result.quality_issue_ref ?? "None created"}</dd>
</div>
<div><dt>Automation event</dt><dd>Queued ({result.workflow_event_id.slice(0, 8)})</dd></div>
<div>
<dt>Next booking risk</dt>
<dd>
{result.next_booking_risk
? `${result.next_booking_risk.booking_ref} ${result.next_booking_risk.at_risk ? "— may be affected" : "— low risk"}`
: "No upcoming booking for this vehicle"}
</dd>
</div>
</dl>
{result.odometer_regression && (
<p className="error" role="alert">
The submitted odometer reading was below the vehicle&apos;s canonical odometer. It was
recorded as-is; the canonical odometer was not changed, and a data-quality issue was
opened for review.
</p>
)}
<p>
<Link to={`/vehicles/${result.vehicle_ref}`}>View vehicle {result.vehicle_ref}</Link>
</p>
</section>
);
}
export function ReturnForm({
bookingRef,
onRegistered,
}: {
bookingRef: string;
onRegistered: (result: RegisterReturnResult) => void;
}) {
const [odometer, setOdometer] = useState("");
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 [error, setError] = useState<string | null>(null);
const [idempotencyKey] = useState(newIdempotencyKey);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const body: RegisterReturnRequest = {
end_odometer_km: Number(odometer),
fuel_level_percent: Number(fuel),
cleanliness_ok: cleanlinessOk,
damage_reported: damageReported,
technical_warning: technicalWarning,
notes: notes || undefined,
};
const registered = await api.post<RegisterReturnResult>(
`/api/v1/bookings/${bookingRef}/return`,
body,
{ "Idempotency-Key": idempotencyKey },
);
onRegistered(registered);
} catch (err) {
if (err instanceof ApiError) {
setError(err.message);
} else {
setError("Could not register the return. Please try again.");
}
} finally {
setSubmitting(false);
}
}
return (
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
<h2 id="return-form-heading">Register return</h2>
{error && <p className="error" role="alert">{error}</p>}
<label>
End odometer (km)
<input
type="number"
required
min={0}
value={odometer}
onChange={(e) => setOdometer(e.target.value)}
/>
</label>
<label>
Fuel level (%)
<input
type="number"
required
min={0}
max={100}
value={fuel}
onChange={(e) => setFuel(e.target.value)}
/>
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={cleanlinessOk}
onChange={(e) => setCleanlinessOk(e.target.checked)}
/>
Cleanliness acceptable
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={damageReported}
onChange={(e) => setDamageReported(e.target.checked)}
/>
Damage reported
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={technicalWarning}
onChange={(e) => setTechnicalWarning(e.target.checked)}
/>
Technical warning
</label>
<label>
Notes
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} maxLength={2000} rows={3} />
</label>
<button type="submit" disabled={submitting}>
{submitting ? "Registering…" : "Register return"}
</button>
</form>
);
}