diff --git a/backend/app/api/routers/bookings.py b/backend/app/api/routers/bookings.py index 68afa27..5ee060f 100644 --- a/backend/app/api/routers/bookings.py +++ b/backend/app/api/routers/bookings.py @@ -1,7 +1,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response -from sqlalchemy import select +from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.api.deps import get_current_user, get_db @@ -10,6 +10,7 @@ from app.models.customer import Customer from app.models.vehicle import Vehicle from app.schemas import ( BookingOut, + BookingPageOut, CurrentUser, NextBookingRisk, RegisterReturnRequest, @@ -35,13 +36,16 @@ def _to_out(booking: Booking, customer: Customer, vehicle: Vehicle) -> BookingOu ) -@router.get("", response_model=list[BookingOut]) +@router.get("", response_model=list[BookingOut] | BookingPageOut) def list_bookings( status: str | None = Query(default=None), vehicle_ref: str | None = Query(default=None), + query: str | None = Query(default=None, min_length=1, max_length=100), + page: int | None = Query(default=None, ge=1), + page_size: int = Query(default=25, ge=1, le=25), db: Session = Depends(get_db), _user: CurrentUser = Depends(get_current_user), -) -> list[BookingOut]: +) -> list[BookingOut] | BookingPageOut: stmt = select(Booking).order_by(Booking.starts_at.desc()) if status: stmt = stmt.where(Booking.status == status) @@ -50,10 +54,38 @@ def list_bookings( if vehicle is None: return [] stmt = stmt.where(Booking.vehicle_id == vehicle.id) - bookings = db.scalars(stmt).all() + if query: + term = f"%{query.strip()}%" + stmt = ( + stmt.join(Customer, Booking.customer_id == Customer.id) + .join(Vehicle, Booking.vehicle_id == Vehicle.id) + .where( + or_( + Booking.public_ref.ilike(term), + Customer.first_name.ilike(term), + Customer.last_name.ilike(term), + Vehicle.public_ref.ilike(term), + ) + ) + ) + total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + page_number = page or 1 + bookings = db.scalars( + stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size) + ).all() customers = {c.id: c for c in db.scalars(select(Customer)).all()} vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()} - return [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings] + items = [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings] + if page is None: + return items + total_pages = max(1, (total + page_size - 1) // page_size) + return BookingPageOut( + items=items, + page=min(page_number, total_pages), + page_size=page_size, + total=total, + total_pages=total_pages, + ) @router.get("/{public_ref}", response_model=BookingOut) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f5062a8..78dda18 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -56,6 +56,14 @@ class BookingOut(BookingSummaryOut): customer_name: str +class BookingPageOut(BaseModel): + items: list[BookingOut] + page: int + page_size: int + total: int + total_pages: int + + class RegisterReturnRequest(BaseModel): end_odometer_km: Annotated[int, Field(ge=0)] fuel_level_percent: Annotated[int, Field(ge=0, le=100)] diff --git a/backend/tests/test_bookings.py b/backend/tests/test_bookings.py index 802cdf7..86ed0f3 100644 --- a/backend/tests/test_bookings.py +++ b/backend/tests/test_bookings.py @@ -6,6 +6,20 @@ def test_list_bookings_filters_by_vehicle(ops_client): assert all(b["vehicle_ref"] == "MO-024" for b in bookings) +def test_list_bookings_supports_bounded_search_pages(ops_client): + response = ops_client.get( + "/api/v1/bookings", + params={"query": "BK-", "page": 1, "page_size": 25}, + ) + assert response.status_code == 200 + body = response.json() + assert body["page"] == 1 + assert body["page_size"] == 25 + assert body["total"] > 25 + assert body["total_pages"] > 1 + assert len(body["items"]) == 25 + + def test_get_booking_detail(ops_client): response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN") assert response.status_code == 200 diff --git a/frontend/src/pages/Bookings.tsx b/frontend/src/pages/Bookings.tsx index c659044..5f34e85 100644 --- a/frontend/src/pages/Bookings.tsx +++ b/frontend/src/pages/Bookings.tsx @@ -1,43 +1,45 @@ import { useEffect, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { api } from "../api/client"; -import type { Booking } from "../api/types"; +import type { Booking, Page } from "../api/types"; import { useLocaleFormat } from "../i18n/format"; import { StatusBadge } from "../components/Badge"; import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; +import { Pagination } from "../components/Pagination"; const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"]; export function Bookings() { const { t } = useTranslation("bookings"); const { formatShortDate } = useLocaleFormat(); - const [bookings, setBookings] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + const [bookings, setBookings] = useState | null>(null); const [error, setError] = useState(null); - const [status, setStatus] = useState(""); - const [query, setQuery] = useState(""); - const [page, setPage] = useState(1); - const perPage = 25; + const status = searchParams.get("status") ?? ""; + const query = searchParams.get("q") ?? ""; + const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1); + + function updateFilters(updates: Record) { + const next = new URLSearchParams(searchParams); + Object.entries(updates).forEach(([key, value]) => { + if (value === null || value === "") next.delete(key); + else next.set(key, String(value)); + }); + setSearchParams(next); + } useEffect(() => { setBookings(null); setError(null); - const params = new URLSearchParams(); + const params = new URLSearchParams({ page: String(page), page_size: "25" }); if (status) params.set("status", status); + if (query) params.set("query", query); api - .get(`/api/v1/bookings?${params.toString()}`) + .get>(`/api/v1/bookings?${params.toString()}`) .then(setBookings) .catch(() => setError(t("list.unavailable"))); - }, [status]); - - useEffect(() => { - if (!bookings) return; - const filteredCount = bookings.filter((b) => - `${b.public_ref} ${b.customer_name} ${b.vehicle_ref}`.toLowerCase().includes(query.toLowerCase()), - ).length; - const totalPages = Math.max(1, Math.ceil(filteredCount / perPage)); - setPage((p) => Math.min(p, totalPages)); - }, [bookings, query]); + }, [page, query, status, t]); return (
@@ -46,62 +48,36 @@ export function Bookings() {
{error && } {!error && !bookings && } - {bookings && bookings.length === 0 && } + {bookings && bookings.items.length === 0 && } - {bookings && bookings.length > 0 && (() => { - const filtered = bookings.filter((b) => `${b.public_ref} ${b.customer_name} ${b.vehicle_ref}`.toLowerCase().includes(query.toLowerCase())); - const totalPages = Math.max(1, Math.ceil(filtered.length / perPage)); - const visible = filtered.slice((page - 1) * perPage, page * perPage); - return filtered.length === 0 ? :
{t("list.count", { count: filtered.length })}{t("list.pageOf", { page, total: totalPages })}
- - - - - - - - + {bookings && bookings.items.length > 0 &&
{t("list.count", { count: bookings.total })}{t("list.pageOf", { page: bookings.page, total: bookings.total_pages })}
{t("list.title")}
{t("list.columns.reference")}{t("list.columns.customer")}{t("list.columns.vehicle")}{t("list.columns.window")}{t("list.columns.status")}
+ + + + {bookings.items.map((booking) => ( + + + + + + - - - {visible.map((b) => ( - - - - - - - - ))} - -
{t("list.title")}
{t("list.columns.reference")}{t("list.columns.customer")}{t("list.columns.vehicle")}{t("list.columns.window")}{t("list.columns.status")}
{booking.public_ref}{booking.public_ref}{booking.customer_name}{booking.vehicle_ref}{formatShortDate(booking.starts_at)} → {formatShortDate(booking.ends_at)}
- {b.public_ref} - {b.public_ref} - {b.customer_name} - {b.vehicle_ref} - - {formatShortDate(b.starts_at)} → {formatShortDate(b.ends_at)} - - -
{t("list.rangeOf", { from: (page - 1) * perPage + 1, to: Math.min(page * perPage, filtered.length), total: filtered.length })}
; - })()} + ))} + + updateFilters({ page: nextPage })} />
} ); }