feat(returns): add authoritative return preview

The return-review step predicted operational consequences independently in
the frontend, and got it wrong: damage or a technical warning was described
as routing to "maintenance" when the actual domain rule (returns.py) routes
it to "blocked", and the no-contradiction case was described as becoming
"available" when the vehicle actually always goes to "cleaning" first
(only reaching "maintenance" if the service threshold was crossed).

Extract the evaluation returns.py already performed inline into a pure
evaluate_return() function with no writes -- resulting status (with an
explanation), odometer regression, would-create-quality-issue,
next-booking-risk -- and share it between a new non-mutating
POST /bookings/{ref}/return-preview endpoint and the existing commit path,
so preview and commit can never drift apart again. The result screen also
now distinguishes local commit success from n8n delivery (still queued/
unconfirmed) instead of implying both succeeded, and links to any created
quality issue for Operations Manager.
This commit is contained in:
NuklearRabbit
2026-08-02 05:33:12 +02:00
parent 62ac9f825c
commit f5212959b4
8 changed files with 435 additions and 77 deletions
+18
View File
@@ -145,6 +145,20 @@ export interface RegisterReturnResult {
next_booking_risk: NextBookingRisk | null;
}
export interface ReturnPreviewResult {
booking_ref: string;
vehicle_ref: string;
canonical_odometer_km: number;
submitted_odometer_km: number;
odometer_regression: boolean;
resulting_odometer_km: number;
resulting_vehicle_status: string;
status_reason: string;
would_create_quality_issue: boolean;
attention_reasons: string[];
next_booking_risk: NextBookingRisk | null;
}
export interface EntitySnapshot {
public_ref: string;
[key: string]: unknown;
@@ -200,7 +214,11 @@ export interface AuditEvent {
action: string;
entity_type: string;
entity_id: string | null;
entity_ref: string | null;
entity_link: string | null;
correlation_id: string;
occurred_at: string;
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
metadata: Record<string, unknown> | null;
}
+83 -20
View File
@@ -1,7 +1,8 @@
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";
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { Icon } from "./Icons";
import { StatusBadge } from "./Badge";
@@ -12,6 +13,8 @@ function newIdempotencyKey(): string {
}
export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) {
const { user } = useAuth();
const canSeeQualityIssue = user?.role === "operations_manager";
return (
<section className="panel return-result success-panel" aria-labelledby="return-result-heading" aria-live="polite">
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">Committed locally</p><h2 id="return-result-heading">Return registered</h2></div></div>
@@ -20,9 +23,25 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
<div><dt>Resulting vehicle status</dt><dd><StatusBadge status={result.resulting_vehicle_status} /></dd></div>
<div>
<dt>Quality issue</dt>
<dd>{result.quality_issue_ref ?? "None created"}</dd>
<dd>
{result.quality_issue_ref ? (
canSeeQualityIssue ? (
<Link to={`/data-quality/${result.quality_issue_ref}`}>{result.quality_issue_ref}</Link>
) : (
result.quality_issue_ref
)
) : (
"None created"
)}
</dd>
</div>
<div>
<dt>Automation event</dt>
<dd>
Queued for delivery ({result.workflow_event_id.slice(0, 8)}) local commit succeeded;
n8n delivery is asynchronous and not yet confirmed.
</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>
@@ -60,30 +79,50 @@ export function ReturnForm({
const [technicalWarning, setTechnicalWarning] = useState(false);
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [previewing, setPreviewing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [idempotencyKey] = useState(newIdempotencyKey);
const [step, setStep] = useState<"capture" | "review">("capture");
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
function currentBody(): RegisterReturnRequest {
return {
end_odometer_km: Number(odometer),
fuel_level_percent: Number(fuel),
cleanliness_ok: cleanlinessOk,
damage_reported: damageReported,
technical_warning: technicalWarning,
notes: notes || undefined,
};
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (step === "capture") {
setStep("review");
setError(null);
setPreviewing(true);
try {
const evaluated = await api.post<ReturnPreviewResult>(
`/api/v1/bookings/${bookingRef}/return-preview`,
currentBody(),
);
setPreview(evaluated);
setStep("review");
} catch (err) {
setError(
err instanceof ApiError ? err.message : "Could not evaluate this return. Please try again.",
);
} finally {
setPreviewing(false);
}
return;
}
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,
currentBody(),
{ "Idempotency-Key": idempotencyKey },
);
onRegistered(registered);
@@ -101,7 +140,7 @@ export function ReturnForm({
return (
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
<div className="return-progress" aria-label="Return registration progress"><span className="is-complete"><i>1</i> Capture</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> Review</span><b /><span><i>3</i> Result</span></div>
<div className="section-heading"><div><p className="page-eyebrow">Booking {bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? "Register vehicle return" : "Review return impact"}</h2><p>{step === "capture" ? "Record the hand-back condition. Operational consequences are calculated on commit." : "Confirm the inspection facts before they update fleet state and queue automation."}</p></div></div>
<div className="section-heading"><div><p className="page-eyebrow">Booking {bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? "Register vehicle return" : "Review return impact"}</h2><p>{step === "capture" ? "Record the hand-back condition. The next step evaluates the exact operational consequences before anything is committed." : "This is the server's authoritative evaluation of what committing will do — confirm before it updates fleet state and queues automation."}</p></div></div>
{error && <p className="error" role="alert">{error}</p>}
{step === "capture" ? <div className="return-capture">
@@ -158,19 +197,43 @@ export function ReturnForm({
<label>
Notes
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} maxLength={2000} rows={3} />
</label></div> : <div className="return-review" aria-live="polite">
</label></div> : preview && <div className="return-review" aria-live="polite">
<dl className="review-facts"><div><dt>Odometer</dt><dd>{Number(odometer).toLocaleString("en-GB")} km</dd></div><div><dt>Fuel</dt><dd>{fuel}%</dd></div><div><dt>Cleanliness</dt><dd>{cleanlinessOk ? "Accepted" : "Follow-up needed"}</dd></div><div><dt>Damage</dt><dd>{damageReported ? "Reported" : "None reported"}</dd></div><div><dt>Technical warning</dt><dd>{technicalWarning ? "Reported" : "None reported"}</dd></div></dl>
<div className={`impact-preview ${damageReported || technicalWarning || !cleanlinessOk ? "impact-warning" : "impact-ready"}`}>
<Icon name={damageReported || technicalWarning || !cleanlinessOk ? "alert" : "check"} />
<div><strong>Expected fleet state</strong><p>{damageReported || technicalWarning ? "The vehicle will move to maintenance and may put its next booking at risk." : !cleanlinessOk ? "The vehicle will move to cleaning before it becomes available." : "The vehicle is expected to become available after the return is committed."}</p></div>
<div className={`impact-preview ${preview.attention_reasons.length > 0 ? "impact-warning" : "impact-ready"}`}>
<Icon name={preview.attention_reasons.length > 0 ? "alert" : "check"} />
<div>
<strong>Expected fleet state: <StatusBadge status={preview.resulting_vehicle_status} /></strong>
<p>{preview.status_reason}</p>
</div>
</div>
{preview.odometer_regression && (
<p className="error" role="alert">
Submitted odometer ({preview.submitted_odometer_km.toLocaleString("en-GB")} km) is below
the canonical reading ({preview.canonical_odometer_km.toLocaleString("en-GB")} km). The
canonical odometer will not change, and a data-quality issue will be opened.
</p>
)}
{preview.next_booking_risk && (
<p className={preview.next_booking_risk.at_risk ? "error" : undefined} role={preview.next_booking_risk.at_risk ? "alert" : undefined}>
Next booking {preview.next_booking_risk.booking_ref} starts{" "}
{new Date(preview.next_booking_risk.starts_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
{preview.next_booking_risk.at_risk ? " — may be affected by this return." : " — low risk."}
</p>
)}
<ul className="commit-list"><li><Icon name="check" /> Create a return inspection</li><li><Icon name="check" /> Update the booking and vehicle atomically</li><li><Icon name="check" /> Queue n8n delivery after the local commit</li></ul>
</div>}
<div className="form-actions">
{step === "review" && <button className="button button-secondary" type="button" onClick={() => setStep("capture")} disabled={submitting}><Icon name="arrow-left" /> Edit details</button>}
<button className="button button-primary" type="submit" disabled={submitting}>
{submitting ? "Registering…" : step === "capture" ? "Review return" : "Confirm return"} {step === "capture" && <Icon name="chevron" />}
<button className="button button-primary" type="submit" disabled={submitting || previewing}>
{step === "capture"
? previewing
? "Evaluating…"
: "Review return"
: submitting
? "Registering…"
: "Confirm return"}{" "}
{step === "capture" && !previewing && <Icon name="chevron" />}
</button>
</div>
</form>