M11: implement operational booking lifecycle
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user