M11: implement operational booking lifecycle

This commit is contained in:
NuklearRabbit
2026-08-10 03:06:30 +02:00
parent 3f13912739
commit 4a3c3bd0a9
15 changed files with 516 additions and 7 deletions
+24
View File
@@ -2272,3 +2272,27 @@ evidence yet."
- Evidence: local web build, ruff and mypy clean; deployed Unraid Compose test suite - 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 **195 passed**. API/web/db healthy, deterministic demo seed restored. Live source
revision: `948d5eb6a60a138dcfc539fd9e36f885200190de`. 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.
+92 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
from sqlalchemy import func, or_, select 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.customer import Customer
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import ( from app.schemas import (
AvailableVehicleOut,
BookingOut, BookingOut,
BookingPageOut, BookingPageOut,
CancelBookingRequest,
CreateBookingRequest, CreateBookingRequest,
CurrentUser, CurrentUser,
NextBookingRisk, NextBookingRisk,
@@ -104,7 +107,11 @@ def create_booking(
customer = db.scalar(select(Customer).where(Customer.public_ref == body.customer_ref)) 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: if customer is None or customer.merged_into_customer_id is not None:
raise HTTPException(status_code=422, detail="Customer is unavailable for booking") 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 ( if (
vehicle is None vehicle is None
or not vehicle.active or not vehicle.active
@@ -147,6 +154,56 @@ def create_booking(
return _to_out(booking, customer, vehicle) 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) @router.get("/{public_ref}", response_model=BookingOut)
def get_booking( def get_booking(
public_ref: str, public_ref: str,
@@ -163,6 +220,40 @@ def get_booking(
return _to_out(booking, customer, vehicle) 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) @router.post("/{public_ref}/return-preview", response_model=ReturnPreviewResult)
def preview_return( def preview_return(
public_ref: str, public_ref: str,
+41
View File
@@ -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
]
+2
View File
@@ -9,6 +9,7 @@ from app.api.routers import (
audit, audit,
auth, auth,
bookings, bookings,
customers,
dashboard, dashboard,
data_quality, data_quality,
demo, demo,
@@ -90,6 +91,7 @@ app.include_router(auth.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(vehicles.router) app.include_router(vehicles.router)
app.include_router(bookings.router) app.include_router(bookings.router)
app.include_router(customers.router)
app.include_router(audit.router) app.include_router(audit.router)
app.include_router(data_quality.router) app.include_router(data_quality.router)
app.include_router(workflows.router) app.include_router(workflows.router)
+19
View File
@@ -69,6 +69,25 @@ class CreateBookingRequest(BaseModel):
requirements_complete: bool = True 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): class BookingPageOut(BaseModel):
items: list[BookingOut] items: list[BookingOut]
page: int page: int
+83
View File
@@ -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): def test_list_bookings_filters_by_vehicle(ops_client):
response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-024"}) response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-024"})
assert response.status_code == 200 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 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): def test_get_booking_detail(ops_client):
response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN") response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN")
assert response.status_code == 200 assert response.status_code == 200
+2
View File
@@ -10,6 +10,7 @@ import { Vehicles } from "./pages/Vehicles";
import { VehicleDetail } from "./pages/VehicleDetail"; import { VehicleDetail } from "./pages/VehicleDetail";
import { Bookings } from "./pages/Bookings"; import { Bookings } from "./pages/Bookings";
import { BookingDetail } from "./pages/BookingDetail"; import { BookingDetail } from "./pages/BookingDetail";
import { BookingCreate } from "./pages/BookingCreate";
import { DataQuality } from "./pages/DataQuality"; import { DataQuality } from "./pages/DataQuality";
import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail"; import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail";
import { Automation } from "./pages/Automation"; import { Automation } from "./pages/Automation";
@@ -36,6 +37,7 @@ export function App() {
<Route path="/vehicles" element={<Vehicles />} /> <Route path="/vehicles" element={<Vehicles />} />
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} /> <Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
<Route path="/bookings" element={<Bookings />} /> <Route path="/bookings" element={<Bookings />} />
<Route path="/bookings/new" element={<BookingCreate />} />
<Route path="/bookings/:publicRef" element={<BookingDetail />} /> <Route path="/bookings/:publicRef" element={<BookingDetail />} />
<Route path="/data-quality" element={<DataQuality />} /> <Route path="/data-quality" element={<DataQuality />} />
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} /> <Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} />
+15
View File
@@ -51,6 +51,21 @@ export interface Booking extends BookingSummary {
customer_name: string; 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 { export interface Inspection {
public_ref: string; public_ref: string;
booking_ref: string; booking_ref: string;
+30 -1
View File
@@ -3,6 +3,7 @@
"eyebrow": "Operations / Schedule", "eyebrow": "Operations / Schedule",
"title": "Bookings", "title": "Bookings",
"description": "Review active rental windows and upcoming vehicle commitments.", "description": "Review active rental windows and upcoming vehicle commitments.",
"create": "New booking",
"searchLabel": "Search", "searchLabel": "Search",
"searchPlaceholder": "Booking, customer or vehicle", "searchPlaceholder": "Booking, customer or vehicle",
"statusLabel": "Status", "statusLabel": "Status",
@@ -27,6 +28,28 @@
"next": "Next", "next": "Next",
"rangeOf": "{{from}}{{to}} of {{total}}" "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": { "statuses": {
"reserved": "reserved", "reserved": "reserved",
"active": "active", "active": "active",
@@ -49,6 +72,12 @@
"endOdometerPending": "Not yet recorded — not yet closed", "endOdometerPending": "Not yet recorded — not yet closed",
"requirementsComplete": "Requirements complete", "requirementsComplete": "Requirements complete",
"yes": "Yes", "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."
} }
} }
+30 -1
View File
@@ -3,6 +3,7 @@
"eyebrow": "Exploitation / Planning", "eyebrow": "Exploitation / Planning",
"title": "Réservations", "title": "Réservations",
"description": "Consultez les périodes de location actives et les engagements de véhicules à venir.", "description": "Consultez les périodes de location actives et les engagements de véhicules à venir.",
"create": "Nouvelle réservation",
"searchLabel": "Rechercher", "searchLabel": "Rechercher",
"searchPlaceholder": "Réservation, client ou véhicule", "searchPlaceholder": "Réservation, client ou véhicule",
"statusLabel": "Statut", "statusLabel": "Statut",
@@ -27,6 +28,28 @@
"next": "Suivant", "next": "Suivant",
"rangeOf": "{{from}}{{to}} sur {{total}}" "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": { "statuses": {
"reserved": "réservé", "reserved": "réservé",
"active": "actif", "active": "actif",
@@ -49,6 +72,12 @@
"endOdometerPending": "Pas encore enregistré — pas encore clôturé", "endOdometerPending": "Pas encore enregistré — pas encore clôturé",
"requirementsComplete": "Exigences complètes", "requirementsComplete": "Exigences complètes",
"yes": "Oui", "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."
} }
} }
+30 -1
View File
@@ -3,6 +3,7 @@
"eyebrow": "Uitvoeren / Planning", "eyebrow": "Uitvoeren / Planning",
"title": "Boekingen", "title": "Boekingen",
"description": "Bekijk actieve verhuurperiodes en aankomende voertuigverbintenissen.", "description": "Bekijk actieve verhuurperiodes en aankomende voertuigverbintenissen.",
"create": "Nieuwe boeking",
"searchLabel": "Zoeken", "searchLabel": "Zoeken",
"searchPlaceholder": "Boeking, klant of voertuig", "searchPlaceholder": "Boeking, klant of voertuig",
"statusLabel": "Status", "statusLabel": "Status",
@@ -27,6 +28,28 @@
"next": "Volgende", "next": "Volgende",
"rangeOf": "{{from}}{{to}} van {{total}}" "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": { "statuses": {
"reserved": "gereserveerd", "reserved": "gereserveerd",
"active": "actief", "active": "actief",
@@ -49,6 +72,12 @@
"endOdometerPending": "Nog niet vastgelegd — nog niet afgesloten", "endOdometerPending": "Nog niet vastgelegd — nog niet afgesloten",
"requirementsComplete": "Vereisten volledig", "requirementsComplete": "Vereisten volledig",
"yes": "Ja", "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."
} }
} }
+111
View File
@@ -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<CustomerOption[]>([]);
const [customerRef, setCustomerRef] = useState("");
const [startsAt, setStartsAt] = useState(() => localDateTime(2));
const [endsAt, setEndsAt] = useState(() => localDateTime(26));
const [vehicles, setVehicles] = useState<AvailableVehicle[]>([]);
const [vehicleRef, setVehicleRef] = useState("");
const [requirementsComplete, setRequirementsComplete] = useState(true);
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)),
[endsAt, startsAt],
);
useEffect(() => {
if (customerQuery.trim().length < 2) {
setCustomers([]);
return;
}
const timeout = window.setTimeout(() => {
api.get<CustomerOption[]>(`/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<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]);
async function submit(event: FormEvent) {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
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(),
requirements_complete: requirementsComplete,
});
navigate(`/bookings/${booking.public_ref}`);
} catch (err) {
setError(describeApiError(t, err, "bookings:create.failed"));
} finally {
setSubmitting(false);
}
}
return (
<div className="page">
<Link className="back-link" to="/bookings"><Icon name="arrow-left" /> {t("create.backLink")}</Link>
<PageHeader eyebrow={t("create.eyebrow")} title={t("create.title")} description={t("create.description")} />
<ApiErrorNotice error={error} />
<form className="record-surface booking-create-form" onSubmit={submit}>
<div className="form-grid">
<label>{t("create.startsAt")}<input type="datetime-local" required value={startsAt} onChange={(event) => setStartsAt(event.target.value)} /></label>
<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>
</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>
</form>
</div>
);
}
+30 -2
View File
@@ -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 { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api } from "../api/client"; import { api } from "../api/client";
@@ -10,9 +10,11 @@ import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm";
import { Icon } from "../components/Icons"; import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { PRODUCT_NAME } from "../product"; import { PRODUCT_NAME } from "../product";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { ApiErrorNotice } from "../components/PageChrome";
export function BookingDetail() { export function BookingDetail() {
const { t } = useTranslation(["bookings", "returns"]); const { t } = useTranslation(["bookings", "returns", "errors"]);
const { formatDateTime, formatNumber } = useLocaleFormat(); const { formatDateTime, formatNumber } = useLocaleFormat();
const { publicRef } = useParams<{ publicRef: string }>(); const { publicRef } = useParams<{ publicRef: string }>();
const { manifest } = useDemoManifest(); const { manifest } = useDemoManifest();
@@ -20,6 +22,9 @@ export function BookingDetail() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [returnResult, setReturnResult] = useState<RegisterReturnResult | null>(null); const [returnResult, setReturnResult] = useState<RegisterReturnResult | null>(null);
const [canonicalOdometerKm, setCanonicalOdometerKm] = useState<number | null>(null); const [canonicalOdometerKm, setCanonicalOdometerKm] = useState<number | null>(null);
const [cancelReason, setCancelReason] = useState("");
const [cancelling, setCancelling] = useState(false);
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
const load = useCallback(() => { const load = useCallback(() => {
if (!publicRef) return; if (!publicRef) return;
@@ -56,6 +61,22 @@ export function BookingDetail() {
load(); load();
} }
async function cancelBooking(event: FormEvent) {
event.preventDefault();
if (!publicRef) return;
setCancelling(true);
setActionError(null);
try {
const updated = await api.post<Booking>(`/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 <ErrorState message={error} />; if (error) return <ErrorState message={error} />;
if (!booking) return <LoadingState label={t("detail.loading")} />; if (!booking) return <LoadingState label={t("detail.loading")} />;
@@ -73,6 +94,13 @@ export function BookingDetail() {
<div><dt>{t("detail.requirementsComplete")}</dt><dd>{booking.requirements_complete ? t("detail.yes") : t("detail.no")}</dd></div> <div><dt>{t("detail.requirementsComplete")}</dt><dd>{booking.requirements_complete ? t("detail.yes") : t("detail.no")}</dd></div>
</dl></section> </dl></section>
{booking.status === "reserved" && <form className="record-surface booking-cancel-form" onSubmit={cancelBooking}>
<h2>{t("detail.cancelAction")}</h2>
<ApiErrorNotice error={actionError} />
<label>{t("detail.cancelReason")}<textarea required minLength={3} maxLength={500} value={cancelReason} onChange={(event) => setCancelReason(event.target.value)} placeholder={t("detail.cancelReasonPlaceholder")} /></label>
<div className="form-actions"><button className="button button-danger" type="submit" disabled={cancelling || cancelReason.trim().length < 3}>{cancelling ? t("detail.cancelling") : t("detail.confirmCancel")}</button></div>
</form>}
{isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && ( {isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && (
<section className="record-surface scenario-callout" aria-label={t("returns:scenario.ariaLabel")}> <section className="record-surface scenario-callout" aria-label={t("returns:scenario.ariaLabel")}>
<Icon name="spark" /> <Icon name="spark" />
+1 -1
View File
@@ -43,7 +43,7 @@ export function Bookings() {
return ( return (
<div className="page"> <div className="page">
<PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("list.description")} /> <PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("list.description")} actions={<Link className="button button-primary" to="/bookings/new">{t("list.create")}</Link>} />
<form className="filters" aria-label={t("list.title")}> <form className="filters" aria-label={t("list.title")}>
<label> <label>
+6
View File
@@ -274,6 +274,12 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.return-progress span { display: flex; align-items: center; gap: 6px; white-space: nowrap; }.return-progress i { width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid var(--line-strong); border-radius: 50%; font-style: normal; } .return-progress span { display: flex; align-items: center; gap: 6px; white-space: nowrap; }.return-progress i { width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid var(--line-strong); border-radius: 50%; font-style: normal; }
.return-progress b { width: 54px; height: 1px; background: var(--line-strong); }.return-progress .is-active, .return-progress .is-complete { color: var(--teal-dark); }.return-progress .is-active i, .return-progress .is-complete i { color: white; background: var(--teal-dark); border-color: var(--teal-dark); } .return-progress b { width: 54px; height: 1px; background: var(--line-strong); }.return-progress .is-active, .return-progress .is-complete { color: var(--teal-dark); }.return-progress .is-active i, .return-progress .is-complete i { color: white; background: var(--teal-dark); border-color: var(--teal-dark); }
.return-capture, .return-review { padding: 22px; }.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 17px; } .return-capture, .return-review { padding: 22px; }.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 17px; }
.booking-create-form, .booking-cancel-form { padding: 22px; }
.booking-create-form .check-card { display: flex; align-items: center; gap: 9px; margin-bottom: 17px; }
.booking-cancel-form { margin-top: 18px; }
.booking-cancel-form h2 { margin: 0 0 14px; font-size: 1rem; }
.booking-cancel-form label { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }
.booking-cancel-form textarea { min-height: 92px; padding: 10px 11px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font: inherit; }
.condition-fieldset { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 0 0 17px; padding: 0; border: 0; }.condition-fieldset legend { margin-bottom: 8px; color: var(--ink-soft); font-size: .67rem; font-weight: 700; }.check-card { min-height: 48px; padding: 10px 12px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); } .condition-fieldset { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 0 0 17px; padding: 0; border: 0; }.condition-fieldset legend { margin-bottom: 8px; color: var(--ink-soft); font-size: .67rem; font-weight: 700; }.check-card { min-height: 48px; padding: 10px 12px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
.return-form textarea { resize: vertical; min-height: 86px; }.form-actions { display: flex; justify-content: flex-end; gap: 9px; padding: 15px 22px; background: var(--surface-subtle); border-top: 1px solid var(--line); } .return-form textarea { resize: vertical; min-height: 86px; }.form-actions { display: flex; justify-content: flex-end; gap: 9px; padding: 15px 22px; background: var(--surface-subtle); border-top: 1px solid var(--line); }
.review-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; margin: 0 0 16px; background: var(--line); border: 1px solid var(--line); }.review-facts div { padding: 12px; background: white; }.review-facts dt { color: var(--muted); font-size: var(--type-label); text-transform: uppercase; }.review-facts dd { margin: 6px 0 0; font-size: var(--type-body); font-weight: 700; } .review-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; margin: 0 0 16px; background: var(--line); border: 1px solid var(--line); }.review-facts div { padding: 12px; background: white; }.review-facts dt { color: var(--muted); font-size: var(--type-label); text-transform: uppercase; }.review-facts dd { margin: 6px 0 0; font-size: var(--type-body); font-weight: 700; }