|
|
|
@@ -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>
|
|
|
|
|