diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index d1820a6..74531eb 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -2272,3 +2272,27 @@ evidence yet." - Evidence: local web build, ruff and mypy clean; deployed Unraid Compose test suite **195 passed**. API/web/db healthy, deterministic demo seed restored. Live source revision: `948d5eb6a60a138dcfc539fd9e36f885200190de`. + +## Knowledge trust and persistent integration telemetry (2026-08-10) + +- RAGcore fallback now refuses unrelated questions, ranks multilingual domain evidence + before answering and returns `insufficient` with no answer when no MobilityOps concept + is present. A live damage question is grounded in `damage-procedure.md`; an unrelated + football question is explicitly insufficient. +- Demo reset preserves operational MCP, n8n and knowledge telemetry while CLI/test reset + remains fully deterministic by default. +- Evidence: deployed Unraid suite **199 passed**; ruff and mypy clean. Committed as + `de15191`, with follow-up deterministic test corrections through `3f13912`. + +## Operational booking lifecycle (2026-08-10) + +- Added authenticated canonical-customer search, interval-aware vehicle availability, + booking creation and audited cancellation. The web app now provides a localized, + responsive creation flow and cancellation action instead of requiring direct API use. +- Booking creation obtains a PostgreSQL row lock on the selected vehicle before checking + overlap. The concurrent contract test proves two simultaneous requests yield exactly + one reservation and one conflict. +- Evidence: production web build passed; ruff and mypy clean; the modified code passed + the full Unraid Compose suite: **203 passed, 1 warning**. +- Exact next action: implement audited checkout/activation, maintenance capture and + operations-manager user administration, then repeat the complete validation gate. diff --git a/backend/app/api/routers/bookings.py b/backend/app/api/routers/bookings.py index a577459..a89e7e3 100644 --- a/backend/app/api/routers/bookings.py +++ b/backend/app/api/routers/bookings.py @@ -1,6 +1,7 @@ from __future__ import annotations import uuid +from datetime import datetime from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response from sqlalchemy import func, or_, select @@ -11,8 +12,10 @@ from app.models.booking import Booking from app.models.customer import Customer from app.models.vehicle import Vehicle from app.schemas import ( + AvailableVehicleOut, BookingOut, BookingPageOut, + CancelBookingRequest, CreateBookingRequest, CurrentUser, NextBookingRisk, @@ -104,7 +107,11 @@ def create_booking( customer = db.scalar(select(Customer).where(Customer.public_ref == body.customer_ref)) if customer is None or customer.merged_into_customer_id is not None: raise HTTPException(status_code=422, detail="Customer is unavailable for booking") - vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == body.vehicle_ref)) + # Serialise booking creation per vehicle. The overlap check must run after + # acquiring this lock, otherwise two concurrent requests can both pass it. + vehicle = db.scalar( + select(Vehicle).where(Vehicle.public_ref == body.vehicle_ref).with_for_update() + ) if ( vehicle is None or not vehicle.active @@ -147,6 +154,56 @@ def create_booking( return _to_out(booking, customer, vehicle) +@router.get("/availability", response_model=list[AvailableVehicleOut]) +def list_available_vehicles( + starts_at: datetime, + ends_at: datetime, + query: str | None = Query(default=None, max_length=100), + limit: int = Query(default=25, ge=1, le=50), + db: Session = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +) -> list[AvailableVehicleOut]: + if ends_at <= starts_at: + raise HTTPException(status_code=422, detail="Booking end must be after its start") + overlapping_vehicle_ids = select(Booking.vehicle_id).where( + Booking.status.in_(("reserved", "active")), + Booking.starts_at < ends_at, + Booking.ends_at > starts_at, + ) + stmt = ( + select(Vehicle) + .where( + Vehicle.active.is_(True), + Vehicle.operational_status.not_in(("maintenance", "blocked")), + Vehicle.id.not_in(overlapping_vehicle_ids), + ) + .order_by(Vehicle.location, Vehicle.public_ref) + .limit(limit) + ) + if query and query.strip(): + term = f"%{query.strip()}%" + stmt = stmt.where( + or_( + Vehicle.public_ref.ilike(term), + Vehicle.make.ilike(term), + Vehicle.model.ilike(term), + Vehicle.registration_number.ilike(term), + Vehicle.location.ilike(term), + ) + ) + return [ + AvailableVehicleOut( + public_ref=vehicle.public_ref, + make=vehicle.make, + model=vehicle.model, + registration_number=vehicle.registration_number, + location=vehicle.location, + operational_status=vehicle.operational_status, + ) + for vehicle in db.scalars(stmt).all() + ] + + @router.get("/{public_ref}", response_model=BookingOut) def get_booking( public_ref: str, @@ -163,6 +220,40 @@ def get_booking( return _to_out(booking, customer, vehicle) +@router.post("/{public_ref}/cancel", response_model=BookingOut) +def cancel_booking( + public_ref: str, + body: CancelBookingRequest, + 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="Only a reserved booking can be cancelled") + 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") + before = {"status": booking.status} + booking.status = "cancelled" + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="booking_cancelled", + entity_type="booking", + entity_id=booking.id, + before=before, + after={"status": booking.status, "reason": body.reason.strip()}, + ) + db.commit() + return _to_out(booking, customer, vehicle) + + @router.post("/{public_ref}/return-preview", response_model=ReturnPreviewResult) def preview_return( public_ref: str, diff --git a/backend/app/api/routers/customers.py b/backend/app/api/routers/customers.py new file mode 100644 index 0000000..5365999 --- /dev/null +++ b/backend/app/api/routers/customers.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user, get_db +from app.models.customer import Customer +from app.schemas import CurrentUser, CustomerOptionOut + +router = APIRouter(prefix="/api/v1/customers", tags=["customers"]) + + +@router.get("", response_model=list[CustomerOptionOut]) +def search_customers( + query: str = Query(min_length=2, max_length=100), + limit: int = Query(default=20, ge=1, le=50), + db: Session = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +) -> list[CustomerOptionOut]: + term = f"%{query.strip()}%" + customers = db.scalars( + select(Customer) + .where( + Customer.merged_into_customer_id.is_(None), + or_( + Customer.public_ref.ilike(term), + Customer.first_name.ilike(term), + Customer.last_name.ilike(term), + Customer.email.ilike(term), + ), + ) + .order_by(Customer.last_name, Customer.first_name) + .limit(limit) + ).all() + return [ + CustomerOptionOut( + public_ref=customer.public_ref, + display_name=f"{customer.first_name} {customer.last_name}", + email=customer.email, + ) + for customer in customers + ] diff --git a/backend/app/main.py b/backend/app/main.py index daadf8d..97caff3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,7 @@ from app.api.routers import ( audit, auth, bookings, + customers, dashboard, data_quality, demo, @@ -90,6 +91,7 @@ app.include_router(auth.router) app.include_router(dashboard.router) app.include_router(vehicles.router) app.include_router(bookings.router) +app.include_router(customers.router) app.include_router(audit.router) app.include_router(data_quality.router) app.include_router(workflows.router) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index a65763d..d4d6e69 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -69,6 +69,25 @@ class CreateBookingRequest(BaseModel): requirements_complete: bool = True +class CustomerOptionOut(BaseModel): + public_ref: str + display_name: str + email: str | None + + +class AvailableVehicleOut(BaseModel): + public_ref: str + make: str + model: str + registration_number: str + location: str + operational_status: str + + +class CancelBookingRequest(BaseModel): + reason: str = Field(min_length=3, max_length=500) + + class BookingPageOut(BaseModel): items: list[BookingOut] page: int diff --git a/backend/tests/test_bookings.py b/backend/tests/test_bookings.py index d505ca1..c82f9f8 100644 --- a/backend/tests/test_bookings.py +++ b/backend/tests/test_bookings.py @@ -1,3 +1,10 @@ +import threading + +from fastapi.testclient import TestClient + +from app.main import app + + def test_list_bookings_filters_by_vehicle(ops_client): response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-024"}) assert response.status_code == 200 @@ -50,6 +57,82 @@ def test_create_booking_rejects_overlap_and_audits_valid_booking(ops_client): assert body["vehicle_ref"] == available_vehicle +def test_customer_search_returns_canonical_customers(ops_client): + response = ops_client.get("/api/v1/customers", params={"query": "CUS-"}) + assert response.status_code == 200 + assert response.json() + assert all(item["public_ref"].startswith("CUS-") for item in response.json()) + + +def test_booking_availability_excludes_overlapping_vehicle(ops_client): + existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json() + response = ops_client.get( + "/api/v1/bookings/availability", + params={"starts_at": existing["starts_at"], "ends_at": existing["ends_at"]}, + ) + assert response.status_code == 200 + assert existing["vehicle_ref"] not in {item["public_ref"] for item in response.json()} + + +def test_reserved_booking_can_be_cancelled_once(ops_client): + window = {"starts_at": "2032-09-01T10:00:00Z", "ends_at": "2032-09-02T12:00:00Z"} + available = ops_client.get("/api/v1/bookings/availability", params=window).json() + assert available + create_response = ops_client.post( + "/api/v1/bookings", + json={ + "customer_ref": "CUS-0001", + "vehicle_ref": available[0]["public_ref"], + **window, + }, + ) + assert create_response.status_code == 201 + created = create_response.json() + response = ops_client.post( + f"/api/v1/bookings/{created['public_ref']}/cancel", + json={"reason": "Customer changed plans"}, + ) + assert response.status_code == 200 + assert response.json()["status"] == "cancelled" + repeated = ops_client.post( + f"/api/v1/bookings/{created['public_ref']}/cancel", + json={"reason": "Customer changed plans"}, + ) + assert repeated.status_code == 409 + + +def test_concurrent_bookings_only_reserve_vehicle_once(): + results: list[int] = [] + seed_client = TestClient(app) + seed_client.post("/api/v1/demo/login", json={"role": "operations_manager"}) + window = {"starts_at": "2040-09-01T10:00:00Z", "ends_at": "2040-09-02T12:00:00Z"} + available = seed_client.get("/api/v1/bookings/availability", params=window).json() + assert available + vehicle_ref = available[0]["public_ref"] + + def submit() -> None: + client = TestClient(app) + client.post("/api/v1/demo/login", json={"role": "operations_manager"}) + response = client.post( + "/api/v1/bookings", + json={ + "customer_ref": "CUS-0001", + "vehicle_ref": vehicle_ref, + **window, + }, + ) + results.append(response.status_code) + + threads = [threading.Thread(target=submit) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert results.count(201) == 1 + assert results.count(409) == 1 + + def test_get_booking_detail(ops_client): response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN") assert response.status_code == 200 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3cefb0f..04fed09 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { Vehicles } from "./pages/Vehicles"; import { VehicleDetail } from "./pages/VehicleDetail"; import { Bookings } from "./pages/Bookings"; import { BookingDetail } from "./pages/BookingDetail"; +import { BookingCreate } from "./pages/BookingCreate"; import { DataQuality } from "./pages/DataQuality"; import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail"; import { Automation } from "./pages/Automation"; @@ -36,6 +37,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 05e468f..8c8042d 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -51,6 +51,21 @@ export interface Booking extends BookingSummary { customer_name: string; } +export interface CustomerOption { + public_ref: string; + display_name: string; + email: string | null; +} + +export interface AvailableVehicle { + public_ref: string; + make: string; + model: string; + registration_number: string; + location: string; + operational_status: string; +} + export interface Inspection { public_ref: string; booking_ref: string; diff --git a/frontend/src/i18n/locales/en-GB/bookings.json b/frontend/src/i18n/locales/en-GB/bookings.json index 98acfd1..34298b3 100644 --- a/frontend/src/i18n/locales/en-GB/bookings.json +++ b/frontend/src/i18n/locales/en-GB/bookings.json @@ -3,6 +3,7 @@ "eyebrow": "Operations / Schedule", "title": "Bookings", "description": "Review active rental windows and upcoming vehicle commitments.", + "create": "New booking", "searchLabel": "Search", "searchPlaceholder": "Booking, customer or vehicle", "statusLabel": "Status", @@ -27,6 +28,28 @@ "next": "Next", "rangeOf": "{{from}}–{{to}} of {{total}}" }, + "create": { + "backLink": "Booking ledger", + "eyebrow": "Bookings / New reservation", + "title": "Create booking", + "description": "Select a customer and a vehicle that is available for the full rental window.", + "startsAt": "Starts", + "endsAt": "Ends", + "customerSearch": "Search customer", + "customerPlaceholder": "Name, email or customer reference", + "customer": "Customer", + "searchFirst": "Search for a customer first", + "chooseCustomer": "Select a customer", + "vehicle": "Available vehicle", + "loadingVehicles": "Checking availability…", + "chooseVehicle": "Select a vehicle", + "noVehicles": "No vehicle available for this window", + "requirementsComplete": "Driving licence and rental requirements have been checked", + "cancel": "Cancel", + "save": "Create booking", + "saving": "Saving booking…", + "failed": "The booking could not be created. Check the window and availability." + }, "statuses": { "reserved": "reserved", "active": "active", @@ -49,6 +72,12 @@ "endOdometerPending": "Not yet recorded — not yet closed", "requirementsComplete": "Requirements complete", "yes": "Yes", - "no": "No" + "no": "No", + "cancelAction": "Cancel booking", + "cancelReason": "Reason", + "cancelReasonPlaceholder": "Why is this booking being cancelled?", + "confirmCancel": "Confirm cancellation", + "cancelling": "Cancelling…", + "cancelFailed": "The booking could not be cancelled." } } diff --git a/frontend/src/i18n/locales/fr-BE/bookings.json b/frontend/src/i18n/locales/fr-BE/bookings.json index d16d664..76a0e66 100644 --- a/frontend/src/i18n/locales/fr-BE/bookings.json +++ b/frontend/src/i18n/locales/fr-BE/bookings.json @@ -3,6 +3,7 @@ "eyebrow": "Exploitation / Planning", "title": "Réservations", "description": "Consultez les périodes de location actives et les engagements de véhicules à venir.", + "create": "Nouvelle réservation", "searchLabel": "Rechercher", "searchPlaceholder": "Réservation, client ou véhicule", "statusLabel": "Statut", @@ -27,6 +28,28 @@ "next": "Suivant", "rangeOf": "{{from}}–{{to}} sur {{total}}" }, + "create": { + "backLink": "Registre des réservations", + "eyebrow": "Réservations / Nouvelle réservation", + "title": "Créer une réservation", + "description": "Sélectionnez un client et un véhicule disponible pendant toute la période de location.", + "startsAt": "Début", + "endsAt": "Fin", + "customerSearch": "Rechercher un client", + "customerPlaceholder": "Nom, e-mail ou référence client", + "customer": "Client", + "searchFirst": "Recherchez d'abord un client", + "chooseCustomer": "Sélectionnez un client", + "vehicle": "Véhicule disponible", + "loadingVehicles": "Vérification des disponibilités…", + "chooseVehicle": "Sélectionnez un véhicule", + "noVehicles": "Aucun véhicule disponible pour cette période", + "requirementsComplete": "Le permis de conduire et les exigences de location ont été vérifiés", + "cancel": "Annuler", + "save": "Créer la réservation", + "saving": "Enregistrement…", + "failed": "La réservation n'a pas pu être créée. Vérifiez la période et les disponibilités." + }, "statuses": { "reserved": "réservé", "active": "actif", @@ -49,6 +72,12 @@ "endOdometerPending": "Pas encore enregistré — pas encore clôturé", "requirementsComplete": "Exigences complètes", "yes": "Oui", - "no": "Non" + "no": "Non", + "cancelAction": "Annuler la réservation", + "cancelReason": "Motif", + "cancelReasonPlaceholder": "Pourquoi cette réservation est-elle annulée ?", + "confirmCancel": "Confirmer l'annulation", + "cancelling": "Annulation…", + "cancelFailed": "La réservation n'a pas pu être annulée." } } diff --git a/frontend/src/i18n/locales/nl-BE/bookings.json b/frontend/src/i18n/locales/nl-BE/bookings.json index a87ce46..0bc1cb4 100644 --- a/frontend/src/i18n/locales/nl-BE/bookings.json +++ b/frontend/src/i18n/locales/nl-BE/bookings.json @@ -3,6 +3,7 @@ "eyebrow": "Uitvoeren / Planning", "title": "Boekingen", "description": "Bekijk actieve verhuurperiodes en aankomende voertuigverbintenissen.", + "create": "Nieuwe boeking", "searchLabel": "Zoeken", "searchPlaceholder": "Boeking, klant of voertuig", "statusLabel": "Status", @@ -27,6 +28,28 @@ "next": "Volgende", "rangeOf": "{{from}}–{{to}} van {{total}}" }, + "create": { + "backLink": "Boekingsoverzicht", + "eyebrow": "Boekingen / Nieuwe reservatie", + "title": "Boeking aanmaken", + "description": "Selecteer een klant en een voertuig dat voor de volledige huurperiode beschikbaar is.", + "startsAt": "Start", + "endsAt": "Einde", + "customerSearch": "Klant zoeken", + "customerPlaceholder": "Naam, e-mail of klantreferentie", + "customer": "Klant", + "searchFirst": "Zoek eerst een klant", + "chooseCustomer": "Selecteer een klant", + "vehicle": "Beschikbaar voertuig", + "loadingVehicles": "Beschikbaarheid controleren…", + "chooseVehicle": "Selecteer een voertuig", + "noVehicles": "Geen beschikbaar voertuig in deze periode", + "requirementsComplete": "Rijbewijs- en huurvereisten zijn gecontroleerd", + "cancel": "Annuleren", + "save": "Boeking aanmaken", + "saving": "Boeking opslaan…", + "failed": "De boeking kon niet worden aangemaakt. Controleer de periode en beschikbaarheid." + }, "statuses": { "reserved": "gereserveerd", "active": "actief", @@ -49,6 +72,12 @@ "endOdometerPending": "Nog niet vastgelegd — nog niet afgesloten", "requirementsComplete": "Vereisten volledig", "yes": "Ja", - "no": "Nee" + "no": "Nee", + "cancelAction": "Boeking annuleren", + "cancelReason": "Reden", + "cancelReasonPlaceholder": "Waarom wordt deze boeking geannuleerd?", + "confirmCancel": "Annulering bevestigen", + "cancelling": "Annuleren…", + "cancelFailed": "De boeking kon niet worden geannuleerd." } } diff --git a/frontend/src/pages/BookingCreate.tsx b/frontend/src/pages/BookingCreate.tsx new file mode 100644 index 0000000..8608f40 --- /dev/null +++ b/frontend/src/pages/BookingCreate.tsx @@ -0,0 +1,111 @@ +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { api } from "../api/client"; +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); +} + +export function BookingCreate() { + const { t } = useTranslation(["bookings", "errors"]); + const navigate = useNavigate(); + const [customerQuery, setCustomerQuery] = useState(""); + const [customers, setCustomers] = useState([]); + const [customerRef, setCustomerRef] = useState(""); + const [startsAt, setStartsAt] = useState(() => localDateTime(2)); + const [endsAt, setEndsAt] = useState(() => localDateTime(26)); + const [vehicles, setVehicles] = useState([]); + const [vehicleRef, setVehicleRef] = useState(""); + const [requirementsComplete, setRequirementsComplete] = useState(true); + const [loadingVehicles, setLoadingVehicles] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const windowValid = useMemo( + () => Boolean(startsAt && endsAt && new Date(endsAt) > new Date(startsAt)), + [endsAt, startsAt], + ); + + useEffect(() => { + if (customerQuery.trim().length < 2) { + setCustomers([]); + return; + } + const timeout = window.setTimeout(() => { + api.get(`/api/v1/customers?query=${encodeURIComponent(customerQuery.trim())}`) + .then((result) => { + setCustomers(result); + if (!result.some((customer) => customer.public_ref === customerRef)) setCustomerRef(""); + }) + .catch((err) => setError(describeApiError(t, err))); + }, 250); + return () => window.clearTimeout(timeout); + }, [customerQuery, customerRef, t]); + + useEffect(() => { + if (!windowValid) { + setVehicles([]); + setVehicleRef(""); + return; + } + setLoadingVehicles(true); + const params = new URLSearchParams({ + starts_at: new Date(startsAt).toISOString(), + ends_at: new Date(endsAt).toISOString(), + }); + api.get(`/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]); + + async function submit(event: FormEvent) { + event.preventDefault(); + setError(null); + setSubmitting(true); + try { + const booking = await api.post("/api/v1/bookings", { + customer_ref: customerRef, + vehicle_ref: vehicleRef, + starts_at: new Date(startsAt).toISOString(), + ends_at: new Date(endsAt).toISOString(), + requirements_complete: requirementsComplete, + }); + navigate(`/bookings/${booking.public_ref}`); + } catch (err) { + setError(describeApiError(t, err, "bookings:create.failed")); + } finally { + setSubmitting(false); + } + } + + return ( +
+ {t("create.backLink")} + + +
+
+ + + + + +
+ +
{t("create.cancel")}
+
+
+ ); +} diff --git a/frontend/src/pages/BookingDetail.tsx b/frontend/src/pages/BookingDetail.tsx index 1991464..538cd9d 100644 --- a/frontend/src/pages/BookingDetail.tsx +++ b/frontend/src/pages/BookingDetail.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { FormEvent, useCallback, useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { api } from "../api/client"; @@ -10,9 +10,11 @@ import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm"; import { Icon } from "../components/Icons"; import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; import { PRODUCT_NAME } from "../product"; +import { describeApiError, type ApiErrorInfo } from "../api/errorMessages"; +import { ApiErrorNotice } from "../components/PageChrome"; export function BookingDetail() { - const { t } = useTranslation(["bookings", "returns"]); + const { t } = useTranslation(["bookings", "returns", "errors"]); const { formatDateTime, formatNumber } = useLocaleFormat(); const { publicRef } = useParams<{ publicRef: string }>(); const { manifest } = useDemoManifest(); @@ -20,6 +22,9 @@ export function BookingDetail() { const [error, setError] = useState(null); const [returnResult, setReturnResult] = useState(null); const [canonicalOdometerKm, setCanonicalOdometerKm] = useState(null); + const [cancelReason, setCancelReason] = useState(""); + const [cancelling, setCancelling] = useState(false); + const [actionError, setActionError] = useState(null); const load = useCallback(() => { if (!publicRef) return; @@ -56,6 +61,22 @@ export function BookingDetail() { load(); } + async function cancelBooking(event: FormEvent) { + event.preventDefault(); + if (!publicRef) return; + setCancelling(true); + setActionError(null); + try { + const updated = await api.post(`/api/v1/bookings/${publicRef}/cancel`, { reason: cancelReason }); + setBooking(updated); + setCancelReason(""); + } catch (err) { + setActionError(describeApiError(t, err, "bookings:detail.cancelFailed")); + } finally { + setCancelling(false); + } + } + if (error) return ; if (!booking) return ; @@ -73,6 +94,13 @@ export function BookingDetail() {
{t("detail.requirementsComplete")}
{booking.requirements_complete ? t("detail.yes") : t("detail.no")}
+ {booking.status === "reserved" &&
+

{t("detail.cancelAction")}

+ +