M33: enforce booking readiness workflow
This commit is contained in:
@@ -2750,3 +2750,15 @@ evidence yet."
|
||||
all five scenarios ready; the last action after reset was the read-only Hub proof.
|
||||
- Exact next action: none for the requested scope; keep the public synthetic demo online
|
||||
and monitor its existing health, backup and integration evidence surfaces.
|
||||
|
||||
## M33 — explicit booking readiness and Brussels-safe planning (2026-08-10)
|
||||
|
||||
- New reservations now default to incomplete requirements. Checkout remains unavailable
|
||||
until an operator records a deliberate requirements confirmation; that transition and
|
||||
its bounded evidence are persisted in the audit trail.
|
||||
- Booking creation and availability use Europe/Brussels wall-clock conversion independent
|
||||
of the visitor's browser timezone. Customer and vehicle searches cancel stale requests,
|
||||
use a bounded timeout and expose server-side vehicle filtering up to 50 results.
|
||||
- Validation: isolated booking API suite **12 passed**; backend Ruff clean; frontend
|
||||
TypeScript lint and production build passed.
|
||||
- Exact next action: add database invariants/indexes and harden the shared public demo reset.
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.schemas import (
|
||||
CancelBookingRequest,
|
||||
CheckoutBookingRequest,
|
||||
CheckoutBookingResult,
|
||||
CompleteBookingRequirementsRequest,
|
||||
CreateBookingRequest,
|
||||
CurrentUser,
|
||||
NextBookingRisk,
|
||||
@@ -342,6 +343,42 @@ def get_booking(
|
||||
return _to_out(booking, customer, vehicle)
|
||||
|
||||
|
||||
@router.post("/{public_ref}/complete-requirements", response_model=BookingOut)
|
||||
def complete_booking_requirements(
|
||||
public_ref: str,
|
||||
body: CompleteBookingRequirementsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> BookingOut:
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
|
||||
if booking is None:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
if booking.status != "reserved":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Requirements can only be confirmed for a reserved booking",
|
||||
)
|
||||
customer = db.get(Customer, booking.customer_id)
|
||||
vehicle = db.get(Vehicle, booking.vehicle_id)
|
||||
if customer is None or vehicle is None:
|
||||
raise HTTPException(status_code=500, detail="Booking references a missing record")
|
||||
if not booking.requirements_complete:
|
||||
booking.requirements_complete = True
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="booking_requirements_completed",
|
||||
entity_type="booking",
|
||||
entity_id=booking.id,
|
||||
before={"requirements_complete": False},
|
||||
after={"requirements_complete": True},
|
||||
metadata={"confirmation": body.confirmation.strip()},
|
||||
)
|
||||
db.commit()
|
||||
return _to_out(booking, customer, vehicle)
|
||||
|
||||
|
||||
@router.post("/{public_ref}/cancel", response_model=BookingOut)
|
||||
def cancel_booking(
|
||||
public_ref: str,
|
||||
|
||||
@@ -26,4 +26,4 @@ class Booking(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
start_odometer_km: Mapped[int | None] = mapped_column(Integer)
|
||||
end_odometer_km: Mapped[int | None] = mapped_column(Integer)
|
||||
requirements_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
requirements_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
@@ -92,7 +92,11 @@ class CreateBookingRequest(BaseModel):
|
||||
vehicle_ref: str = Field(min_length=3, max_length=20)
|
||||
starts_at: datetime
|
||||
ends_at: datetime
|
||||
requirements_complete: bool = True
|
||||
requirements_complete: bool = False
|
||||
|
||||
|
||||
class CompleteBookingRequirementsRequest(BaseModel):
|
||||
confirmation: str = Field(min_length=3, max_length=500)
|
||||
|
||||
|
||||
class CustomerOptionOut(BaseModel):
|
||||
|
||||
@@ -73,6 +73,37 @@ def test_create_booking_rejects_overlap_and_audits_valid_booking(ops_client):
|
||||
body = created.json()
|
||||
assert body["status"] == "reserved"
|
||||
assert body["vehicle_ref"] == available_vehicle
|
||||
assert body["requirements_complete"] is False
|
||||
|
||||
|
||||
def test_booking_requirements_are_explicit_and_audited(ops_client):
|
||||
window = {"starts_at": "2031-09-01T10:00:00Z", "ends_at": "2031-09-02T12:00:00Z"}
|
||||
vehicle = ops_client.get("/api/v1/bookings/availability", params=window).json()[0]
|
||||
booking = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={"customer_ref": "CUS-0001", "vehicle_ref": vehicle["public_ref"], **window},
|
||||
).json()
|
||||
checkout = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/checkout",
|
||||
json={
|
||||
"start_odometer_km": 100000,
|
||||
"fuel_level_percent": 90,
|
||||
"cleanliness_ok": True,
|
||||
"damage_reported": False,
|
||||
"technical_warning": False,
|
||||
},
|
||||
)
|
||||
assert checkout.status_code == 409
|
||||
|
||||
confirmed = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
|
||||
json={"confirmation": "Licence and rental conditions checked"},
|
||||
)
|
||||
assert confirmed.status_code == 200
|
||||
assert confirmed.json()["requirements_complete"] is True
|
||||
audit = ops_client.get("/api/v1/audit", params={"action": "booking_requirements_completed"})
|
||||
assert audit.status_code == 200
|
||||
assert any(item["entity_ref"] == booking["public_ref"] for item in audit.json())
|
||||
|
||||
|
||||
def test_customer_search_returns_canonical_customers(ops_client):
|
||||
@@ -158,7 +189,12 @@ def test_checkout_records_inspection_and_activates_safe_booking(ops_client):
|
||||
vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json()
|
||||
booking = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={"customer_ref": "CUS-0001", "vehicle_ref": vehicle["public_ref"], **window},
|
||||
json={
|
||||
"customer_ref": "CUS-0001",
|
||||
"vehicle_ref": vehicle["public_ref"],
|
||||
"requirements_complete": True,
|
||||
**window,
|
||||
},
|
||||
).json()
|
||||
response = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/checkout",
|
||||
|
||||
@@ -12,15 +12,33 @@ export function onUnauthorized(listener: UnauthorizedListener): () => void {
|
||||
return () => unauthorizedListeners.delete(listener);
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const timeoutController = new AbortController();
|
||||
const timeout = window.setTimeout(() => timeoutController.abort("timeout"), DEFAULT_TIMEOUT_MS);
|
||||
const signal = init?.signal
|
||||
? AbortSignal.any([init.signal, timeoutController.signal])
|
||||
: timeoutController.signal;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
signal,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (timeoutController.signal.aborted && !init?.signal?.aborted) {
|
||||
throw new ApiError(408, "REQUEST_TIMEOUT", "The request took too long.", "");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let body: { error?: { code: string; message: string; correlation_id: string } } | undefined;
|
||||
@@ -48,7 +66,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
get: <T>(path: string, init?: Pick<RequestInit, "signal">) => request<T>(path, init),
|
||||
post: <T>(path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||
request<T>(path, { method: "POST", body: body ? JSON.stringify(body) : undefined, headers }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const BRUSSELS_TIME_ZONE = "Europe/Brussels";
|
||||
|
||||
const partsFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: BRUSSELS_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
|
||||
function partsAt(value: Date): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
partsFormatter.formatToParts(value)
|
||||
.filter((part) => part.type !== "literal")
|
||||
.map((part) => [part.type, part.value]),
|
||||
);
|
||||
}
|
||||
|
||||
export function toBrusselsDateTimeLocal(value: Date): string {
|
||||
const parts = partsAt(value);
|
||||
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}`;
|
||||
}
|
||||
|
||||
export function brusselsDateTimeFromNow(hours: number): string {
|
||||
const value = new Date(Date.now() + hours * 60 * 60 * 1000);
|
||||
value.setUTCMinutes(0, 0, 0);
|
||||
return toBrusselsDateTimeLocal(value);
|
||||
}
|
||||
|
||||
/** Convert a Fleet Ops Europe/Brussels wall-clock value to an unambiguous UTC instant. */
|
||||
export function brusselsLocalToIso(value: string): string {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value);
|
||||
if (!match) throw new Error("Invalid Brussels date-time");
|
||||
const [, year, month, day, hour, minute] = match;
|
||||
const wallClockUtc = Date.UTC(+year, +month - 1, +day, +hour, +minute);
|
||||
let candidate = wallClockUtc;
|
||||
for (let iteration = 0; iteration < 3; iteration += 1) {
|
||||
const parts = partsAt(new Date(candidate));
|
||||
const represented = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute);
|
||||
candidate += wallClockUtc - represented;
|
||||
}
|
||||
return new Date(candidate).toISOString();
|
||||
}
|
||||
@@ -53,6 +53,9 @@
|
||||
"searchFirst": "Search for a customer first",
|
||||
"chooseCustomer": "Select a customer",
|
||||
"vehicle": "Available vehicle",
|
||||
"vehicleSearch": "Search vehicle",
|
||||
"vehiclePlaceholder": "Reference, make, model, registration or location",
|
||||
"locationUnknown": "location unknown",
|
||||
"loadingVehicles": "Checking availability…",
|
||||
"chooseVehicle": "Select a vehicle",
|
||||
"noVehicles": "No vehicle available for this window",
|
||||
@@ -102,6 +105,13 @@
|
||||
"startOdometerPending": "Not yet recorded — trip hasn't started yet",
|
||||
"endOdometerPending": "Not yet recorded — not yet closed",
|
||||
"requirementsComplete": "Requirements complete",
|
||||
"requirementsAction": "Check requirements",
|
||||
"requirementsActionDetail": "The departure inspection remains locked until the driving licence and rental requirements are explicitly confirmed.",
|
||||
"requirementsConfirmation": "Check evidence",
|
||||
"requirementsConfirmationPlaceholder": "Record what was checked",
|
||||
"requirementsConfirm": "Confirm requirements",
|
||||
"requirementsSaving": "Confirming…",
|
||||
"requirementsFailed": "The requirements could not be confirmed.",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"cancelAction": "Cancel booking",
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
"searchFirst": "Recherchez d'abord un client",
|
||||
"chooseCustomer": "Sélectionnez un client",
|
||||
"vehicle": "Véhicule disponible",
|
||||
"vehicleSearch": "Rechercher un véhicule",
|
||||
"vehiclePlaceholder": "Référence, marque, modèle, plaque ou site",
|
||||
"locationUnknown": "site inconnu",
|
||||
"loadingVehicles": "Vérification des disponibilités…",
|
||||
"chooseVehicle": "Sélectionnez un véhicule",
|
||||
"noVehicles": "Aucun véhicule disponible pour cette période",
|
||||
@@ -102,6 +105,13 @@
|
||||
"startOdometerPending": "Pas encore enregistré — trajet pas encore commencé",
|
||||
"endOdometerPending": "Pas encore enregistré — pas encore clôturé",
|
||||
"requirementsComplete": "Exigences complètes",
|
||||
"requirementsAction": "Contrôler les exigences",
|
||||
"requirementsActionDetail": "L’inspection de départ reste verrouillée jusqu’à la confirmation explicite du permis et des exigences de location.",
|
||||
"requirementsConfirmation": "Preuve du contrôle",
|
||||
"requirementsConfirmationPlaceholder": "Notez ce qui a été contrôlé",
|
||||
"requirementsConfirm": "Confirmer les exigences",
|
||||
"requirementsSaving": "Confirmation…",
|
||||
"requirementsFailed": "Les exigences n’ont pas pu être confirmées.",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"cancelAction": "Annuler la réservation",
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
"searchFirst": "Zoek eerst een klant",
|
||||
"chooseCustomer": "Selecteer een klant",
|
||||
"vehicle": "Beschikbaar voertuig",
|
||||
"vehicleSearch": "Voertuig zoeken",
|
||||
"vehiclePlaceholder": "Referentie, merk, model, nummerplaat of locatie",
|
||||
"locationUnknown": "locatie onbekend",
|
||||
"loadingVehicles": "Beschikbaarheid controleren…",
|
||||
"chooseVehicle": "Selecteer een voertuig",
|
||||
"noVehicles": "Geen beschikbaar voertuig in deze periode",
|
||||
@@ -102,6 +105,13 @@
|
||||
"startOdometerPending": "Nog niet vastgelegd — rit nog niet gestart",
|
||||
"endOdometerPending": "Nog niet vastgelegd — nog niet afgesloten",
|
||||
"requirementsComplete": "Vereisten volledig",
|
||||
"requirementsAction": "Vereisten controleren",
|
||||
"requirementsActionDetail": "De vertrekinspectie blijft vergrendeld totdat rijbewijs en huurvereisten expliciet zijn bevestigd.",
|
||||
"requirementsConfirmation": "Controlebewijs",
|
||||
"requirementsConfirmationPlaceholder": "Noteer wat gecontroleerd werd",
|
||||
"requirementsConfirm": "Vereisten bevestigen",
|
||||
"requirementsSaving": "Bevestigen…",
|
||||
"requirementsFailed": "De vereisten konden niet worden bevestigd.",
|
||||
"yes": "Ja",
|
||||
"no": "Nee",
|
||||
"cancelAction": "Boeking annuleren",
|
||||
|
||||
@@ -6,13 +6,7 @@ import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||
import type { AvailableVehicle, Booking, CustomerOption } from "../api/types";
|
||||
import { ApiErrorNotice, PageHeader } from "../components/PageChrome";
|
||||
import { Icon } from "../components/Icons";
|
||||
|
||||
function localDateTime(hoursFromNow: number): string {
|
||||
const value = new Date(Date.now() + hoursFromNow * 60 * 60 * 1000);
|
||||
value.setMinutes(0, 0, 0);
|
||||
const offset = value.getTimezoneOffset() * 60_000;
|
||||
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
|
||||
}
|
||||
import { brusselsDateTimeFromNow, brusselsLocalToIso } from "../i18n/brusselsDateTime";
|
||||
|
||||
export function BookingCreate() {
|
||||
const { t } = useTranslation(["bookings", "errors"]);
|
||||
@@ -20,17 +14,18 @@ export function BookingCreate() {
|
||||
const [customerQuery, setCustomerQuery] = useState("");
|
||||
const [customers, setCustomers] = useState<CustomerOption[]>([]);
|
||||
const [customerRef, setCustomerRef] = useState("");
|
||||
const [startsAt, setStartsAt] = useState(() => localDateTime(2));
|
||||
const [endsAt, setEndsAt] = useState(() => localDateTime(26));
|
||||
const [startsAt, setStartsAt] = useState(() => brusselsDateTimeFromNow(2));
|
||||
const [endsAt, setEndsAt] = useState(() => brusselsDateTimeFromNow(26));
|
||||
const [vehicles, setVehicles] = useState<AvailableVehicle[]>([]);
|
||||
const [vehicleRef, setVehicleRef] = useState("");
|
||||
const [requirementsComplete, setRequirementsComplete] = useState(true);
|
||||
const [requirementsComplete, setRequirementsComplete] = useState(false);
|
||||
const [vehicleQuery, setVehicleQuery] = useState("");
|
||||
const [loadingVehicles, setLoadingVehicles] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
|
||||
const windowValid = useMemo(
|
||||
() => Boolean(startsAt && endsAt && new Date(endsAt) > new Date(startsAt)),
|
||||
() => Boolean(startsAt && endsAt && brusselsLocalToIso(endsAt) > brusselsLocalToIso(startsAt)),
|
||||
[endsAt, startsAt],
|
||||
);
|
||||
|
||||
@@ -39,15 +34,21 @@ export function BookingCreate() {
|
||||
setCustomers([]);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => {
|
||||
api.get<CustomerOption[]>(`/api/v1/customers?query=${encodeURIComponent(customerQuery.trim())}`)
|
||||
api.get<CustomerOption[]>(`/api/v1/customers?query=${encodeURIComponent(customerQuery.trim())}`, { signal: controller.signal })
|
||||
.then((result) => {
|
||||
setCustomers(result);
|
||||
if (!result.some((customer) => customer.public_ref === customerRef)) setCustomerRef("");
|
||||
})
|
||||
.catch((err) => setError(describeApiError(t, err)));
|
||||
.catch((err) => {
|
||||
if (!controller.signal.aborted) setError(describeApiError(t, err));
|
||||
});
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
controller.abort();
|
||||
};
|
||||
}, [customerQuery, customerRef, t]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -56,19 +57,32 @@ export function BookingCreate() {
|
||||
setVehicleRef("");
|
||||
return;
|
||||
}
|
||||
setLoadingVehicles(true);
|
||||
const params = new URLSearchParams({
|
||||
starts_at: new Date(startsAt).toISOString(),
|
||||
ends_at: new Date(endsAt).toISOString(),
|
||||
});
|
||||
api.get<AvailableVehicle[]>(`/api/v1/bookings/availability?${params.toString()}`)
|
||||
.then((result) => {
|
||||
setVehicles(result);
|
||||
setVehicleRef((current) => result.some((vehicle) => vehicle.public_ref === current) ? current : "");
|
||||
})
|
||||
.catch((err) => setError(describeApiError(t, err)))
|
||||
.finally(() => setLoadingVehicles(false));
|
||||
}, [endsAt, startsAt, t, windowValid]);
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => {
|
||||
setLoadingVehicles(true);
|
||||
const params = new URLSearchParams({
|
||||
starts_at: brusselsLocalToIso(startsAt),
|
||||
ends_at: brusselsLocalToIso(endsAt),
|
||||
});
|
||||
if (vehicleQuery.trim()) params.set("query", vehicleQuery.trim());
|
||||
params.set("limit", "50");
|
||||
api.get<AvailableVehicle[]>(`/api/v1/bookings/availability?${params.toString()}`, { signal: controller.signal })
|
||||
.then((result) => {
|
||||
setVehicles(result);
|
||||
setVehicleRef((current) => result.some((vehicle) => vehicle.public_ref === current) ? current : "");
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!controller.signal.aborted) setError(describeApiError(t, err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoadingVehicles(false);
|
||||
});
|
||||
}, 250);
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
controller.abort();
|
||||
};
|
||||
}, [endsAt, startsAt, t, vehicleQuery, windowValid]);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
@@ -78,8 +92,8 @@ export function BookingCreate() {
|
||||
const booking = await api.post<Booking>("/api/v1/bookings", {
|
||||
customer_ref: customerRef,
|
||||
vehicle_ref: vehicleRef,
|
||||
starts_at: new Date(startsAt).toISOString(),
|
||||
ends_at: new Date(endsAt).toISOString(),
|
||||
starts_at: brusselsLocalToIso(startsAt),
|
||||
ends_at: brusselsLocalToIso(endsAt),
|
||||
requirements_complete: requirementsComplete,
|
||||
});
|
||||
navigate(`/bookings/${booking.public_ref}`);
|
||||
@@ -101,7 +115,8 @@ export function BookingCreate() {
|
||||
<label>{t("create.endsAt")}<input type="datetime-local" required min={startsAt} value={endsAt} onChange={(event) => setEndsAt(event.target.value)} /></label>
|
||||
<label>{t("create.customerSearch")}<input type="search" value={customerQuery} onChange={(event) => setCustomerQuery(event.target.value)} placeholder={t("create.customerPlaceholder")} /></label>
|
||||
<label>{t("create.customer")}<select required value={customerRef} onChange={(event) => setCustomerRef(event.target.value)} disabled={customers.length === 0}><option value="">{t(customers.length ? "create.chooseCustomer" : "create.searchFirst")}</option>{customers.map((customer) => <option key={customer.public_ref} value={customer.public_ref}>{customer.display_name} · {customer.public_ref}{customer.email ? ` · ${customer.email}` : ""}</option>)}</select></label>
|
||||
<label>{t("create.vehicle")}<select required value={vehicleRef} onChange={(event) => setVehicleRef(event.target.value)} disabled={!windowValid || loadingVehicles}><option value="">{t(loadingVehicles ? "create.loadingVehicles" : vehicles.length ? "create.chooseVehicle" : "create.noVehicles")}</option>{vehicles.map((vehicle) => <option key={vehicle.public_ref} value={vehicle.public_ref}>{vehicle.public_ref} · {vehicle.make} {vehicle.model} · {vehicle.location}</option>)}</select></label>
|
||||
<label>{t("create.vehicleSearch")}<input type="search" value={vehicleQuery} onChange={(event) => setVehicleQuery(event.target.value)} placeholder={t("create.vehiclePlaceholder")} /></label>
|
||||
<label>{t("create.vehicle")}<select required value={vehicleRef} onChange={(event) => setVehicleRef(event.target.value)} disabled={!windowValid || loadingVehicles}><option value="">{t(loadingVehicles ? "create.loadingVehicles" : vehicles.length ? "create.chooseVehicle" : "create.noVehicles")}</option>{vehicles.map((vehicle) => <option key={vehicle.public_ref} value={vehicle.public_ref}>{vehicle.public_ref} · {vehicle.make} {vehicle.model} · {vehicle.location || t("create.locationUnknown")}</option>)}</select></label>
|
||||
</div>
|
||||
<label className="check-card"><input type="checkbox" checked={requirementsComplete} onChange={(event) => setRequirementsComplete(event.target.checked)} /> <span>{t("create.requirementsComplete")}</span></label>
|
||||
<div className="form-actions"><Link className="button button-secondary" to="/bookings">{t("create.cancel")}</Link><button className="button button-primary" type="submit" disabled={submitting || !windowValid || !customerRef || !vehicleRef}>{submitting ? t("create.saving") : t("create.save")}</button></div>
|
||||
|
||||
@@ -27,6 +27,8 @@ export function BookingDetail() {
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
|
||||
const [checkoutResult, setCheckoutResult] = useState<CheckoutBookingResult | null>(null);
|
||||
const [requirementsConfirmation, setRequirementsConfirmation] = useState("");
|
||||
const [confirmingRequirements, setConfirmingRequirements] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!publicRef) return;
|
||||
@@ -84,6 +86,24 @@ export function BookingDetail() {
|
||||
load();
|
||||
}
|
||||
|
||||
async function confirmRequirements(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!publicRef) return;
|
||||
setConfirmingRequirements(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const updated = await api.post<Booking>(`/api/v1/bookings/${publicRef}/complete-requirements`, {
|
||||
confirmation: requirementsConfirmation,
|
||||
});
|
||||
setBooking(updated);
|
||||
setRequirementsConfirmation("");
|
||||
} catch (err) {
|
||||
setActionError(describeApiError(t, err, "bookings:detail.requirementsFailed"));
|
||||
} finally {
|
||||
setConfirmingRequirements(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />;
|
||||
if (!booking) return <LoadingState label={t("detail.loading")} />;
|
||||
|
||||
@@ -101,6 +121,18 @@ export function BookingDetail() {
|
||||
<div><dt>{t("detail.requirementsComplete")}</dt><dd>{booking.requirements_complete ? t("detail.yes") : t("detail.no")}</dd></div>
|
||||
</dl></section>
|
||||
|
||||
{booking.status === "reserved" && !booking.requirements_complete ? (
|
||||
<form className="record-surface requirements-confirmation" onSubmit={confirmRequirements}>
|
||||
<div>
|
||||
<h2>{t("detail.requirementsAction")}</h2>
|
||||
<p>{t("detail.requirementsActionDetail")}</p>
|
||||
</div>
|
||||
<ApiErrorNotice error={actionError} />
|
||||
<label>{t("detail.requirementsConfirmation")}<textarea required minLength={3} maxLength={500} value={requirementsConfirmation} onChange={(event) => setRequirementsConfirmation(event.target.value)} placeholder={t("detail.requirementsConfirmationPlaceholder")} /></label>
|
||||
<div className="form-actions"><button className="button button-primary" type="submit" disabled={confirmingRequirements || requirementsConfirmation.trim().length < 3}>{confirmingRequirements ? t("detail.requirementsSaving") : t("detail.requirementsConfirm")}</button></div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{booking.status === "reserved" && <form className="record-surface booking-cancel-form" onSubmit={cancelBooking}>
|
||||
<h2>{t("detail.cancelAction")}</h2>
|
||||
<ApiErrorNotice error={actionError} />
|
||||
@@ -109,7 +141,7 @@ export function BookingDetail() {
|
||||
</form>}
|
||||
|
||||
{checkoutResult && <section className={`record-surface checkout-result ${checkoutResult.activated ? "success" : "warning"}`} role="status"><h2>{t(checkoutResult.activated ? "checkout.activatedTitle" : "checkout.blockedTitle")}</h2><p>{t(checkoutResult.activated ? "checkout.activatedDetail" : "checkout.blockedDetail", { inspection: checkoutResult.inspection_ref })}</p></section>}
|
||||
{!checkoutResult && booking.status === "reserved" && <CheckoutForm bookingRef={booking.public_ref} vehicleRef={booking.vehicle_ref} onRecorded={handleCheckout} />}
|
||||
{!checkoutResult && booking.status === "reserved" && booking.requirements_complete && <CheckoutForm bookingRef={booking.public_ref} vehicleRef={booking.vehicle_ref} onRecorded={handleCheckout} />}
|
||||
|
||||
{isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && (
|
||||
<section className="record-surface scenario-callout" aria-label={t("returns:scenario.ariaLabel")}>
|
||||
|
||||
Reference in New Issue
Block a user