UX: paginate booking operations
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_user, get_db
|
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.models.vehicle import Vehicle
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
BookingOut,
|
BookingOut,
|
||||||
|
BookingPageOut,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
NextBookingRisk,
|
NextBookingRisk,
|
||||||
RegisterReturnRequest,
|
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(
|
def list_bookings(
|
||||||
status: str | None = Query(default=None),
|
status: str | None = Query(default=None),
|
||||||
vehicle_ref: 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),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(get_current_user),
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
) -> list[BookingOut]:
|
) -> list[BookingOut] | BookingPageOut:
|
||||||
stmt = select(Booking).order_by(Booking.starts_at.desc())
|
stmt = select(Booking).order_by(Booking.starts_at.desc())
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(Booking.status == status)
|
stmt = stmt.where(Booking.status == status)
|
||||||
@@ -50,10 +54,38 @@ def list_bookings(
|
|||||||
if vehicle is None:
|
if vehicle is None:
|
||||||
return []
|
return []
|
||||||
stmt = stmt.where(Booking.vehicle_id == vehicle.id)
|
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()}
|
customers = {c.id: c for c in db.scalars(select(Customer)).all()}
|
||||||
vehicles = {v.id: v for v in db.scalars(select(Vehicle)).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)
|
@router.get("/{public_ref}", response_model=BookingOut)
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ class BookingOut(BookingSummaryOut):
|
|||||||
customer_name: str
|
customer_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class BookingPageOut(BaseModel):
|
||||||
|
items: list[BookingOut]
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
class RegisterReturnRequest(BaseModel):
|
class RegisterReturnRequest(BaseModel):
|
||||||
end_odometer_km: Annotated[int, Field(ge=0)]
|
end_odometer_km: Annotated[int, Field(ge=0)]
|
||||||
fuel_level_percent: Annotated[int, Field(ge=0, le=100)]
|
fuel_level_percent: Annotated[int, Field(ge=0, le=100)]
|
||||||
|
|||||||
@@ -6,6 +6,20 @@ def test_list_bookings_filters_by_vehicle(ops_client):
|
|||||||
assert all(b["vehicle_ref"] == "MO-024" for b in bookings)
|
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):
|
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
|
||||||
|
|||||||
@@ -1,43 +1,45 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { Booking } from "../api/types";
|
import type { Booking, Page } from "../api/types";
|
||||||
import { useLocaleFormat } from "../i18n/format";
|
import { useLocaleFormat } from "../i18n/format";
|
||||||
import { StatusBadge } from "../components/Badge";
|
import { StatusBadge } from "../components/Badge";
|
||||||
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||||
|
import { Pagination } from "../components/Pagination";
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"];
|
const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"];
|
||||||
|
|
||||||
export function Bookings() {
|
export function Bookings() {
|
||||||
const { t } = useTranslation("bookings");
|
const { t } = useTranslation("bookings");
|
||||||
const { formatShortDate } = useLocaleFormat();
|
const { formatShortDate } = useLocaleFormat();
|
||||||
const [bookings, setBookings] = useState<Booking[] | null>(null);
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [bookings, setBookings] = useState<Page<Booking> | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [status, setStatus] = useState("");
|
const status = searchParams.get("status") ?? "";
|
||||||
const [query, setQuery] = useState("");
|
const query = searchParams.get("q") ?? "";
|
||||||
const [page, setPage] = useState(1);
|
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
|
||||||
const perPage = 25;
|
|
||||||
|
function updateFilters(updates: Record<string, string | number | null>) {
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
setBookings(null);
|
setBookings(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams({ page: String(page), page_size: "25" });
|
||||||
if (status) params.set("status", status);
|
if (status) params.set("status", status);
|
||||||
|
if (query) params.set("query", query);
|
||||||
api
|
api
|
||||||
.get<Booking[]>(`/api/v1/bookings?${params.toString()}`)
|
.get<Page<Booking>>(`/api/v1/bookings?${params.toString()}`)
|
||||||
.then(setBookings)
|
.then(setBookings)
|
||||||
.catch(() => setError(t("list.unavailable")));
|
.catch(() => setError(t("list.unavailable")));
|
||||||
}, [status]);
|
}, [page, query, status, t]);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
@@ -46,62 +48,36 @@ export function Bookings() {
|
|||||||
<form className="filters" aria-label={t("list.title")}>
|
<form className="filters" aria-label={t("list.title")}>
|
||||||
<label>
|
<label>
|
||||||
{t("list.searchLabel")}
|
{t("list.searchLabel")}
|
||||||
<input type="text" value={query} onChange={(e) => { setQuery(e.target.value); setPage(1); }} placeholder={t("list.searchPlaceholder")} />
|
<input type="search" value={query} onChange={(e) => updateFilters({ q: e.target.value, page: 1 })} placeholder={t("list.searchPlaceholder")} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("list.statusLabel")}
|
{t("list.statusLabel")}
|
||||||
<select value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }}>
|
<select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
|
||||||
<option value="">{t("list.statusAll")}</option>
|
<option value="">{t("list.statusAll")}</option>
|
||||||
{STATUS_OPTIONS.map((s) => (
|
{STATUS_OPTIONS.map((value) => <option key={value} value={value}>{t(`statuses.${value}`)}</option>)}
|
||||||
<option key={s} value={s}>
|
|
||||||
{t(`statuses.${s}`)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{error && <ErrorState message={error} />}
|
{error && <ErrorState message={error} />}
|
||||||
{!error && !bookings && <LoadingState label={t("list.loading")} />}
|
{!error && !bookings && <LoadingState label={t("list.loading")} />}
|
||||||
{bookings && bookings.length === 0 && <EmptyState icon="bookings" title={t("list.empty")} detail={t("list.emptyDetail")} />}
|
{bookings && bookings.items.length === 0 && <EmptyState icon={query ? "search" : "bookings"} title={query ? t("list.noMatch") : t("list.empty")} detail={query ? t("list.noMatchDetail") : t("list.emptyDetail")} />}
|
||||||
|
|
||||||
{bookings && bookings.length > 0 && (() => {
|
{bookings && bookings.items.length > 0 && <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: bookings.total })}</span><span>{t("list.pageOf", { page: bookings.page, total: bookings.total_pages })}</span></div><table className="data-table">
|
||||||
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 ? <EmptyState icon="search" title={t("list.noMatch")} detail={t("list.noMatchDetail")} /> : <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: filtered.length })}</span><span>{t("list.pageOf", { page, total: totalPages })}</span></div><table className="data-table">
|
|
||||||
<caption className="visually-hidden">{t("list.title")}</caption>
|
<caption className="visually-hidden">{t("list.title")}</caption>
|
||||||
<thead>
|
<thead><tr><th scope="col">{t("list.columns.reference")}</th><th scope="col">{t("list.columns.customer")}</th><th scope="col">{t("list.columns.vehicle")}</th><th scope="col">{t("list.columns.window")}</th><th scope="col">{t("list.columns.status")}</th></tr></thead>
|
||||||
<tr>
|
|
||||||
<th scope="col">{t("list.columns.reference")}</th>
|
|
||||||
<th scope="col">{t("list.columns.customer")}</th>
|
|
||||||
<th scope="col">{t("list.columns.vehicle")}</th>
|
|
||||||
<th scope="col">{t("list.columns.window")}</th>
|
|
||||||
<th scope="col">{t("list.columns.status")}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{visible.map((b) => (
|
{bookings.items.map((booking) => (
|
||||||
<tr key={b.public_ref} className="row-clickable">
|
<tr key={booking.public_ref} className="row-clickable">
|
||||||
<th scope="row" data-label={t("list.columns.reference")}>
|
<th scope="row" data-label={t("list.columns.reference")}>{booking.public_ref}<Link className="row-link" to={`/bookings/${booking.public_ref}`}><span className="visually-hidden">{booking.public_ref}</span></Link></th>
|
||||||
{b.public_ref}
|
<td data-label={t("list.columns.customer")}>{booking.customer_name}</td>
|
||||||
<Link className="row-link" to={`/bookings/${b.public_ref}`}><span className="visually-hidden">{b.public_ref}</span></Link>
|
<td data-label={t("list.columns.vehicle")}><Link to={`/vehicles/${booking.vehicle_ref}`} className="cell-link">{booking.vehicle_ref}</Link></td>
|
||||||
</th>
|
<td data-label={t("list.columns.window")}>{formatShortDate(booking.starts_at)} → {formatShortDate(booking.ends_at)}</td>
|
||||||
<td data-label={t("list.columns.customer")}>{b.customer_name}</td>
|
<td data-label={t("list.columns.status")}><StatusBadge status={booking.status} label={t(`statuses.${booking.status}`, { defaultValue: booking.status })} /></td>
|
||||||
<td data-label={t("list.columns.vehicle")}>
|
|
||||||
<Link to={`/vehicles/${b.vehicle_ref}`} className="cell-link">{b.vehicle_ref}</Link>
|
|
||||||
</td>
|
|
||||||
<td data-label={t("list.columns.window")}>
|
|
||||||
{formatShortDate(b.starts_at)} → {formatShortDate(b.ends_at)}
|
|
||||||
</td>
|
|
||||||
<td data-label={t("list.columns.status")}>
|
|
||||||
<StatusBadge status={b.status} label={t(`statuses.${b.status}`, { defaultValue: b.status })} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table><div className="pagination" aria-label={t("list.paginationLabel")}><button type="button" disabled={page === 1} onClick={() => setPage((p) => p - 1)}>{t("list.previous")}</button><span>{t("list.rangeOf", { from: (page - 1) * perPage + 1, to: Math.min(page * perPage, filtered.length), total: filtered.length })}</span><button type="button" disabled={page === totalPages} onClick={() => setPage((p) => p + 1)}>{t("list.next")}</button></div></div>;
|
</table><Pagination page={bookings.page} totalPages={bookings.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} /></div>}
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user