diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 74531eb..3def7b0 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -2296,3 +2296,21 @@ evidence yet." 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. + +## Complete daily operations cycle (2026-08-10) + +- Reserved bookings now have an audited checkout inspection. A safe inspection atomically + activates the booking and marks the vehicle rented; odometer regression, dirt, damage + or a technical warning blocks the booking and routes the vehicle to cleaning or + maintenance without an unsafe activation. +- Operations Managers can register persisted maintenance evidence, advance service and + odometer values, explicitly release a vehicle only when no active rental or open + high-severity vehicle issue remains, and create/activate/deactivate operational users. + Self-deactivation and self-demotion are prevented. Rental employees receive 403 for + manager actions. +- Added localized web workflows for checkout, maintenance/release and user access + administration. All actions use persisted API state and expose actionable errors. +- Evidence: frontend lint and production build passed; ruff, mypy and diff check passed; + focused Unraid contracts **19 passed** and the full suite **208 passed, 1 warning**. +- Exact next action: harden MCP per-client authorization and evidence completeness, then + replace inferred n8n status with explicit heartbeat/execution telemetry. diff --git a/backend/app/api/routers/bookings.py b/backend/app/api/routers/bookings.py index a89e7e3..49ed3c5 100644 --- a/backend/app/api/routers/bookings.py +++ b/backend/app/api/routers/bookings.py @@ -1,7 +1,7 @@ from __future__ import annotations import uuid -from datetime import datetime +from datetime import UTC, datetime from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response from sqlalchemy import func, or_, select @@ -10,12 +10,15 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_user, get_db from app.models.booking import Booking from app.models.customer import Customer +from app.models.inspection import Inspection from app.models.vehicle import Vehicle from app.schemas import ( AvailableVehicleOut, BookingOut, BookingPageOut, CancelBookingRequest, + CheckoutBookingRequest, + CheckoutBookingResult, CreateBookingRequest, CurrentUser, NextBookingRisk, @@ -154,6 +157,100 @@ def create_booking( return _to_out(booking, customer, vehicle) +@router.post("/{public_ref}/checkout", response_model=CheckoutBookingResult) +def checkout_booking( + public_ref: str, + body: CheckoutBookingRequest, + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> CheckoutBookingResult: + 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 checked out") + if not booking.requirements_complete: + raise HTTPException(status_code=409, detail="Booking requirements are incomplete") + vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update()) + if vehicle is None: + raise HTTPException(status_code=500, detail="Booking references a missing vehicle") + if not vehicle.active or vehicle.operational_status in {"maintenance", "blocked", "rented"}: + raise HTTPException(status_code=409, detail="Vehicle is not ready for checkout") + active_conflict = db.scalar( + select(Booking.id).where( + Booking.vehicle_id == vehicle.id, + Booking.status == "active", + Booking.id != booking.id, + ) + ) + if active_conflict is not None: + raise HTTPException(status_code=409, detail="Vehicle already has an active booking") + + attention_reasons: list[str] = [] + if body.start_odometer_km < vehicle.odometer_km: + attention_reasons.append("odometer_regression") + if not body.cleanliness_ok: + attention_reasons.append("cleanliness") + if body.damage_reported: + attention_reasons.append("damage") + if body.technical_warning: + attention_reasons.append("technical_warning") + + inspection = Inspection( + public_ref=f"INSP-{uuid.uuid4().hex[:10].upper()}", + booking_id=booking.id, + vehicle_id=vehicle.id, + type="checkout", + fuel_level_percent=body.fuel_level_percent, + cleanliness_ok=body.cleanliness_ok, + damage_reported=body.damage_reported, + technical_warning=body.technical_warning, + notes=body.notes, + odometer_km=body.start_odometer_km, + completed_at=datetime.now(UTC), + completed_by=user.display_name, + ) + db.add(inspection) + if attention_reasons: + booking.status = "blocked" + vehicle.operational_status = ( + "maintenance" if body.damage_reported or body.technical_warning else "cleaning" + ) + else: + booking.status = "active" + booking.start_odometer_km = body.start_odometer_km + vehicle.odometer_km = max(vehicle.odometer_km, body.start_odometer_km) + vehicle.operational_status = "rented" + vehicle.version += 1 + db.flush() + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="booking_checkout_recorded", + entity_type="booking", + entity_id=booking.id, + after={ + "inspection_ref": inspection.public_ref, + "booking_status": booking.status, + "vehicle_status": vehicle.operational_status, + "attention_reasons": attention_reasons, + }, + ) + db.commit() + return CheckoutBookingResult( + booking_ref=booking.public_ref, + vehicle_ref=vehicle.public_ref, + inspection_ref=inspection.public_ref, + booking_status=booking.status, + resulting_vehicle_status=vehicle.operational_status, + activated=booking.status == "active", + attention_reasons=attention_reasons, + ) + + @router.get("/availability", response_model=list[AvailableVehicleOut]) def list_available_vehicles( starts_at: datetime, diff --git a/backend/app/api/routers/users.py b/backend/app/api/routers/users.py new file mode 100644 index 0000000..ae79717 --- /dev/null +++ b/backend/app/api/routers/users.py @@ -0,0 +1,100 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import get_db, require_operations_manager +from app.core.security import hash_password +from app.models.user import User +from app.schemas import CreateUserRequest, CurrentUser, UpdateUserRequest, UserOut +from app.services.audit import record_audit_event + +router = APIRouter(prefix="/api/v1/users", tags=["users"]) + + +def _to_out(user: User) -> UserOut: + return UserOut( + public_ref=user.public_ref, + email=user.email, + display_name=user.display_name, + role=user.role, # type: ignore[arg-type] + active=user.active, + ) + + +@router.get("", response_model=list[UserOut]) +def list_users( + db: Session = Depends(get_db), + _manager: CurrentUser = Depends(require_operations_manager), +) -> list[UserOut]: + return [_to_out(user) for user in db.scalars(select(User).order_by(User.display_name)).all()] + + +@router.post("", response_model=UserOut, status_code=201) +def create_user( + body: CreateUserRequest, + db: Session = Depends(get_db), + manager: CurrentUser = Depends(require_operations_manager), +) -> UserOut: + email = body.email.strip().lower() + if db.scalar(select(User.id).where(User.email == email)) is not None: + raise HTTPException(status_code=409, detail="A user with this email already exists") + user = User( + public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}", + email=email, + password_hash=hash_password(body.password), + display_name=body.display_name.strip(), + role=body.role, + active=True, + ) + db.add(user) + db.flush() + record_audit_event( + db, + actor_type="user", + actor_label=manager.display_name, + action="user_created", + entity_type="user", + entity_id=user.id, + after={"public_ref": user.public_ref, "role": user.role, "active": user.active}, + ) + db.commit() + return _to_out(user) + + +@router.patch("/{public_ref}", response_model=UserOut) +def update_user( + public_ref: str, + body: UpdateUserRequest, + db: Session = Depends(get_db), + manager: CurrentUser = Depends(require_operations_manager), +) -> UserOut: + user = db.scalar(select(User).where(User.public_ref == public_ref).with_for_update()) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + if user.public_ref == manager.public_ref and body.active is False: + raise HTTPException(status_code=409, detail="You cannot deactivate your own account") + if user.public_ref == manager.public_ref and body.role not in (None, "operations_manager"): + raise HTTPException(status_code=409, detail="You cannot remove your own manager role") + before = {"display_name": user.display_name, "role": user.role, "active": user.active} + if body.display_name is not None: + user.display_name = body.display_name.strip() + if body.role is not None: + user.role = body.role + if body.active is not None: + user.active = body.active + if body.password is not None: + user.password_hash = hash_password(body.password) + record_audit_event( + db, + actor_type="user", + actor_label=manager.display_name, + action="user_updated", + entity_type="user", + entity_id=user.id, + before=before, + after={"display_name": user.display_name, "role": user.role, "active": user.active}, + ) + db.commit() + return _to_out(user) diff --git a/backend/app/api/routers/vehicles.py b/backend/app/api/routers/vehicles.py index d832852..535e3fc 100644 --- a/backend/app/api/routers/vehicles.py +++ b/backend/app/api/routers/vehicles.py @@ -1,10 +1,12 @@ from __future__ import annotations +import uuid + from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, or_, select from sqlalchemy.orm import Session -from app.api.deps import get_current_user, get_db +from app.api.deps import get_current_user, get_db, require_operations_manager from app.models.booking import Booking from app.models.customer import Customer from app.models.data_quality import DataQualityIssue @@ -13,14 +15,17 @@ from app.models.maintenance import MaintenanceRecord from app.models.vehicle import Vehicle from app.schemas import ( BookingSummaryOut, + CreateMaintenanceRequest, CurrentUser, DataQualityIssueOut, InspectionOut, MaintenanceOut, + ReleaseVehicleRequest, VehicleDetailOut, VehicleOut, VehiclePageOut, ) +from app.services.audit import record_audit_event router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"]) @@ -192,3 +197,104 @@ def get_vehicle( for q in issues ], ) + + +@router.post("/{public_ref}/maintenance", response_model=MaintenanceOut, status_code=201) +def create_maintenance_record( + public_ref: str, + body: CreateMaintenanceRequest, + db: Session = Depends(get_db), + user: CurrentUser = Depends(require_operations_manager), +) -> MaintenanceOut: + vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update()) + if vehicle is None: + raise HTTPException(status_code=404, detail="Vehicle not found") + record = MaintenanceRecord( + public_ref=f"MAINT-{uuid.uuid4().hex[:8].upper()}", + vehicle_id=vehicle.id, + occurred_at=body.occurred_at, + odometer_km=body.odometer_km, + category=body.category, + summary=body.summary.strip(), + ) + db.add(record) + vehicle.odometer_km = max(vehicle.odometer_km, body.odometer_km) + if body.next_service_km is not None: + if body.next_service_km < vehicle.odometer_km: + raise HTTPException(status_code=422, detail="Next service must not be below odometer") + vehicle.next_service_km = body.next_service_km + if body.mark_maintenance: + vehicle.operational_status = "maintenance" + vehicle.version += 1 + db.flush() + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="maintenance_record_created", + entity_type="vehicle", + entity_id=vehicle.id, + after={"maintenance_ref": record.public_ref, "status": vehicle.operational_status}, + ) + db.commit() + return MaintenanceOut( + public_ref=record.public_ref, + occurred_at=record.occurred_at, + odometer_km=record.odometer_km, + category=record.category, + summary=record.summary, + ) + + +@router.post("/{public_ref}/release", response_model=VehicleOut) +def release_vehicle( + public_ref: str, + body: ReleaseVehicleRequest, + db: Session = Depends(get_db), + user: CurrentUser = Depends(require_operations_manager), +) -> VehicleOut: + vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update()) + if vehicle is None: + raise HTTPException(status_code=404, detail="Vehicle not found") + if vehicle.operational_status not in {"cleaning", "maintenance", "blocked"}: + raise HTTPException(status_code=409, detail="Vehicle does not require release") + active_booking = db.scalar( + select(Booking.id).where(Booking.vehicle_id == vehicle.id, Booking.status == "active") + ) + open_high_issue = db.scalar( + select(DataQualityIssue.id).where( + DataQualityIssue.entity_type == "vehicle", + DataQualityIssue.entity_id == vehicle.id, + DataQualityIssue.status == "open", + DataQualityIssue.severity == "high", + ) + ) + if active_booking is not None or open_high_issue is not None: + raise HTTPException(status_code=409, detail="Vehicle still has a blocking condition") + before = {"status": vehicle.operational_status} + vehicle.operational_status = "available" + vehicle.version += 1 + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="vehicle_released", + entity_type="vehicle", + entity_id=vehicle.id, + before=before, + after={"status": "available", "reason": body.reason.strip()}, + ) + db.commit() + return VehicleOut( + public_ref=vehicle.public_ref, + make=vehicle.make, + model=vehicle.model, + model_year=vehicle.model_year, + registration_number=vehicle.registration_number, + location=vehicle.location, + operational_status=vehicle.operational_status, + odometer_km=vehicle.odometer_km, + next_service_km=vehicle.next_service_km, + active=vehicle.active, + attention=False, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 97caff3..e019834 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,6 +18,7 @@ from app.api.routers import ( knowledge, mcp_integrations, search, + users, vehicles, workflows, ) @@ -100,3 +101,4 @@ app.include_router(knowledge.router) app.include_router(mcp_integrations.router) app.include_router(search.router) app.include_router(integration_status.router) +app.include_router(users.router) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index d4d6e69..00ff855 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -17,6 +17,28 @@ class PasswordLoginRequest(BaseModel): password: str = Field(min_length=8, max_length=256) +class UserOut(BaseModel): + public_ref: str + email: str | None + display_name: str + role: Role + active: bool + + +class CreateUserRequest(BaseModel): + email: str = Field(min_length=3, max_length=320) + display_name: str = Field(min_length=2, max_length=120) + role: Role + password: str = Field(min_length=8, max_length=256) + + +class UpdateUserRequest(BaseModel): + display_name: str | None = Field(default=None, min_length=2, max_length=120) + role: Role | None = None + active: bool | None = None + password: str | None = Field(default=None, min_length=8, max_length=256) + + class CurrentUser(BaseModel): public_ref: str display_name: str @@ -88,6 +110,25 @@ class CancelBookingRequest(BaseModel): reason: str = Field(min_length=3, max_length=500) +class CheckoutBookingRequest(BaseModel): + start_odometer_km: Annotated[int, Field(ge=0)] + fuel_level_percent: Annotated[int, Field(ge=0, le=100)] + cleanliness_ok: bool + damage_reported: bool = False + technical_warning: bool = False + notes: str | None = Field(default=None, max_length=2000) + + +class CheckoutBookingResult(BaseModel): + booking_ref: str + vehicle_ref: str + inspection_ref: str + booking_status: str + resulting_vehicle_status: str + activated: bool + attention_reasons: list[str] + + class BookingPageOut(BaseModel): items: list[BookingOut] page: int @@ -158,6 +199,19 @@ class MaintenanceOut(BaseModel): summary: str +class CreateMaintenanceRequest(BaseModel): + occurred_at: datetime + odometer_km: Annotated[int, Field(ge=0)] + category: Literal["periodic_service", "repair", "inspection", "tyres", "other"] + summary: str = Field(min_length=3, max_length=2000) + next_service_km: Annotated[int | None, Field(default=None, ge=0)] + mark_maintenance: bool = True + + +class ReleaseVehicleRequest(BaseModel): + reason: str = Field(min_length=3, max_length=500) + + class DataQualityIssueOut(BaseModel): public_ref: str rule_type: str diff --git a/backend/tests/test_bookings.py b/backend/tests/test_bookings.py index c82f9f8..eed730c 100644 --- a/backend/tests/test_bookings.py +++ b/backend/tests/test_bookings.py @@ -133,6 +133,33 @@ def test_concurrent_bookings_only_reserve_vehicle_once(): assert results.count(409) == 1 +def test_checkout_records_inspection_and_activates_safe_booking(ops_client): + window = {"starts_at": "2050-09-01T10:00:00Z", "ends_at": "2050-09-02T12:00:00Z"} + available = ops_client.get("/api/v1/bookings/availability", params=window).json() + vehicle_option = next(item for item in available if item["operational_status"] == "available") + 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() + response = ops_client.post( + f"/api/v1/bookings/{booking['public_ref']}/checkout", + json={ + "start_odometer_km": vehicle["odometer_km"], + "fuel_level_percent": 95, + "cleanliness_ok": True, + "damage_reported": False, + "technical_warning": False, + }, + ) + assert response.status_code == 200 + assert response.json()["activated"] is True + assert response.json()["booking_status"] == "active" + updated_vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json() + assert updated_vehicle["operational_status"] == "rented" + assert any(item["type"] == "checkout" for item in updated_vehicle["inspections"]) + + 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/backend/tests/test_users.py b/backend/tests/test_users.py new file mode 100644 index 0000000..fbdede0 --- /dev/null +++ b/backend/tests/test_users.py @@ -0,0 +1,25 @@ +def test_manager_can_create_and_update_user(ops_client): + created = ops_client.post( + "/api/v1/users", + json={ + "email": "planner@example.test", + "display_name": "Fleet Planner", + "role": "rental_employee", + "password": "a-secure-demo-password", + }, + ) + assert created.status_code == 201 + public_ref = created.json()["public_ref"] + assert created.json()["active"] is True + updated = ops_client.patch( + f"/api/v1/users/{public_ref}", + json={"display_name": "Senior Fleet Planner", "active": False}, + ) + assert updated.status_code == 200 + assert updated.json()["display_name"] == "Senior Fleet Planner" + assert updated.json()["active"] is False + assert any(user["public_ref"] == public_ref for user in ops_client.get("/api/v1/users").json()) + + +def test_employee_cannot_manage_users(employee_client): + assert employee_client.get("/api/v1/users").status_code == 403 diff --git a/backend/tests/test_vehicles.py b/backend/tests/test_vehicles.py index bf92105..dabf05c 100644 --- a/backend/tests/test_vehicles.py +++ b/backend/tests/test_vehicles.py @@ -37,3 +37,44 @@ def test_vehicle_detail_includes_related_records(ops_client): def test_vehicle_detail_404_for_unknown_ref(ops_client): response = ops_client.get("/api/v1/vehicles/MO-999") assert response.status_code == 404 + + +def test_manager_can_record_maintenance_and_release_vehicle(ops_client): + vehicle = ops_client.get("/api/v1/vehicles", params={"status": "available"}).json()[0] + response = ops_client.post( + f"/api/v1/vehicles/{vehicle['public_ref']}/maintenance", + json={ + "occurred_at": "2051-01-15T09:00:00Z", + "odometer_km": vehicle["odometer_km"] + 100, + "category": "periodic_service", + "summary": "Periodic service completed and safety checks passed.", + "next_service_km": vehicle["odometer_km"] + 20100, + "mark_maintenance": True, + }, + ) + assert response.status_code == 201 + detail = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json() + assert detail["operational_status"] == "maintenance" + assert any( + item["public_ref"] == response.json()["public_ref"] + for item in detail["maintenance"] + ) + released = ops_client.post( + f"/api/v1/vehicles/{vehicle['public_ref']}/release", + json={"reason": "Service completed and vehicle inspected"}, + ) + assert released.status_code == 200 + assert released.json()["operational_status"] == "available" + + +def test_employee_cannot_record_maintenance(employee_client): + response = employee_client.post( + "/api/v1/vehicles/MO-001/maintenance", + json={ + "occurred_at": "2051-01-15T09:00:00Z", + "odometer_km": 100, + "category": "repair", + "summary": "Unauthorised attempt", + }, + ) + assert response.status_code == 403 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 04fed09..ca85aeb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import { Knowledge } from "./pages/Knowledge"; import { Audit } from "./pages/Audit"; import { AboutDemo } from "./pages/AboutDemo"; import { Scenarios } from "./pages/Scenarios"; +import { Users } from "./pages/Users"; export function App() { return ( @@ -44,6 +45,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4543a34..757d274 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -51,4 +51,6 @@ export const api = { get: (path: string) => request(path), post: (path: string, body?: unknown, headers?: Record) => request(path, { method: "POST", body: body ? JSON.stringify(body) : undefined, headers }), + patch: (path: string, body: unknown) => + request(path, { method: "PATCH", body: JSON.stringify(body) }), }; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 8c8042d..b614093 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -66,6 +66,24 @@ export interface AvailableVehicle { operational_status: string; } +export interface CheckoutBookingResult { + booking_ref: string; + vehicle_ref: string; + inspection_ref: string; + booking_status: string; + resulting_vehicle_status: string; + activated: boolean; + attention_reasons: string[]; +} + +export interface UserRecord { + public_ref: string; + email: string | null; + display_name: string; + role: Role; + active: boolean; +} + export interface Inspection { public_ref: string; booking_ref: string; diff --git a/frontend/src/components/CheckoutForm.tsx b/frontend/src/components/CheckoutForm.tsx new file mode 100644 index 0000000..c18df51 --- /dev/null +++ b/frontend/src/components/CheckoutForm.tsx @@ -0,0 +1,65 @@ +import { FormEvent, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { api } from "../api/client"; +import { describeApiError, type ApiErrorInfo } from "../api/errorMessages"; +import type { CheckoutBookingResult, VehicleDetail } from "../api/types"; +import { ApiErrorNotice, LoadingState, SectionHeading } from "./PageChrome"; + +export function CheckoutForm({ bookingRef, vehicleRef, onRecorded }: { bookingRef: string; vehicleRef: string; onRecorded: (result: CheckoutBookingResult) => void }) { + const { t } = useTranslation(["bookings", "errors"]); + const [odometer, setOdometer] = useState(null); + const [fuel, setFuel] = useState(100); + const [clean, setClean] = useState(true); + const [damage, setDamage] = useState(false); + const [warning, setWarning] = useState(false); + const [notes, setNotes] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + api.get(`/api/v1/vehicles/${vehicleRef}`) + .then((vehicle) => setOdometer(vehicle.odometer_km)) + .catch((err) => setError(describeApiError(t, err))); + }, [t, vehicleRef]); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (odometer === null) return; + setSaving(true); + setError(null); + try { + const result = await api.post(`/api/v1/bookings/${bookingRef}/checkout`, { + start_odometer_km: odometer, + fuel_level_percent: fuel, + cleanliness_ok: clean, + damage_reported: damage, + technical_warning: warning, + notes: notes || null, + }); + onRecorded(result); + } catch (err) { + setError(describeApiError(t, err, "bookings:checkout.failed")); + } finally { + setSaving(false); + } + } + + if (odometer === null && !error) return ; + return
+ +
+ +
+ + +
+
{t("checkout.condition")} + + + +
+