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:
@@ -118,6 +118,32 @@ export interface Dashboard {
|
||||
recent_automation: AutomationRun[];
|
||||
}
|
||||
|
||||
export interface RegisterReturnRequest {
|
||||
end_odometer_km: number;
|
||||
fuel_level_percent: number;
|
||||
cleanliness_ok: boolean;
|
||||
damage_reported: boolean;
|
||||
technical_warning: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface NextBookingRisk {
|
||||
booking_ref: string;
|
||||
starts_at: string;
|
||||
at_risk: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterReturnResult {
|
||||
booking_ref: string;
|
||||
vehicle_ref: string;
|
||||
inspection_ref: string;
|
||||
resulting_vehicle_status: string;
|
||||
odometer_regression: boolean;
|
||||
quality_issue_ref: string | null;
|
||||
workflow_event_id: string;
|
||||
next_booking_risk: NextBookingRisk | null;
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
actor_type: string;
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,36 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { Booking } from "../api/types";
|
||||
import type { Booking, RegisterReturnResult } from "../api/types";
|
||||
import { StatusBadge } from "../components/Badge";
|
||||
import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm";
|
||||
|
||||
export function BookingDetail() {
|
||||
const { publicRef } = useParams<{ publicRef: string }>();
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [returnResult, setReturnResult] = useState<RegisterReturnResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = useCallback(() => {
|
||||
if (!publicRef) return;
|
||||
setBooking(null);
|
||||
setError(null);
|
||||
api
|
||||
.get<Booking>(`/api/v1/bookings/${publicRef}`)
|
||||
.then(setBooking)
|
||||
.catch(() => setError("This booking could not be found."));
|
||||
}, [publicRef]);
|
||||
|
||||
useEffect(() => {
|
||||
setBooking(null);
|
||||
setError(null);
|
||||
setReturnResult(null);
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
function handleRegistered(result: RegisterReturnResult) {
|
||||
setReturnResult(result);
|
||||
load();
|
||||
}
|
||||
|
||||
if (error) return <p className="error" role="alert">{error}</p>;
|
||||
if (!booking) return <p>Loading booking…</p>;
|
||||
|
||||
@@ -36,6 +48,11 @@ export function BookingDetail() {
|
||||
<div><dt>End odometer</dt><dd>{booking.end_odometer_km ?? "—"} km</dd></div>
|
||||
<div><dt>Requirements complete</dt><dd>{booking.requirements_complete ? "Yes" : "No"}</dd></div>
|
||||
</dl>
|
||||
|
||||
{returnResult && <ReturnResultPanel result={returnResult} />}
|
||||
{!returnResult && booking.status === "active" && (
|
||||
<ReturnForm bookingRef={booking.public_ref} onRegistered={handleRegistered} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,6 +172,22 @@ a { color: #1f5c8f; }
|
||||
.login-options button:hover, .login-options button:focus-visible { background: #1f5c8f; }
|
||||
.login-options p { margin: 0 0 8px; color: #607084; font-size: 0.9rem; }
|
||||
|
||||
.return-form, .return-result { margin-top: 24px; max-width: 520px; }
|
||||
.return-form label {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
font-weight: 600; color: #375065; font-size: 0.9rem; margin-bottom: 14px;
|
||||
}
|
||||
.return-form input[type="number"], .return-form textarea {
|
||||
padding: 8px 10px; border: 1px solid #cfd8e2; border-radius: 8px; font-size: 0.95rem; font-family: inherit;
|
||||
}
|
||||
.return-form .checkbox-label { flex-direction: row; align-items: center; gap: 8px; }
|
||||
.return-form button {
|
||||
padding: 12px 20px; border-radius: 10px; border: none;
|
||||
background: #14324f; color: white; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.return-form button:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.return-result .error { margin-top: 12px; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.app-header { flex-direction: column; align-items: flex-start; }
|
||||
.user-badge { margin-left: 0; }
|
||||
|
||||
Reference in New Issue
Block a user