UX: paginate booking operations

This commit is contained in:
NuklearRabbit
2026-08-10 01:47:35 +02:00
parent 2648cef8e3
commit 0ef4a6fa98
4 changed files with 98 additions and 68 deletions
+39 -63
View File
@@ -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<Booking[] | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const [bookings, setBookings] = useState<Page<Booking> | null>(null);
const [error, setError] = useState<string | null>(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<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(() => {
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<Booking[]>(`/api/v1/bookings?${params.toString()}`)
.get<Page<Booking>>(`/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 (
<div className="page">
@@ -46,62 +48,36 @@ export function Bookings() {
<form className="filters" aria-label={t("list.title")}>
<label>
{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>
{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>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{t(`statuses.${s}`)}
</option>
))}
{STATUS_OPTIONS.map((value) => <option key={value} value={value}>{t(`statuses.${value}`)}</option>)}
</select>
</label>
</form>
{error && <ErrorState message={error} />}
{!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 && (() => {
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>
<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>
{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">
<caption className="visually-hidden">{t("list.title")}</caption>
<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>
{bookings.items.map((booking) => (
<tr key={booking.public_ref} className="row-clickable">
<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>
<td data-label={t("list.columns.customer")}>{booking.customer_name}</td>
<td data-label={t("list.columns.vehicle")}><Link to={`/vehicles/${booking.vehicle_ref}`} className="cell-link">{booking.vehicle_ref}</Link></td>
<td data-label={t("list.columns.window")}>{formatShortDate(booking.starts_at)} {formatShortDate(booking.ends_at)}</td>
<td data-label={t("list.columns.status")}><StatusBadge status={booking.status} label={t(`statuses.${booking.status}`, { defaultValue: booking.status })} /></td>
</tr>
</thead>
<tbody>
{visible.map((b) => (
<tr key={b.public_ref} className="row-clickable">
<th scope="row" data-label={t("list.columns.reference")}>
{b.public_ref}
<Link className="row-link" to={`/bookings/${b.public_ref}`}><span className="visually-hidden">{b.public_ref}</span></Link>
</th>
<td data-label={t("list.columns.customer")}>{b.customer_name}</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>
))}
</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>;
})()}
))}
</tbody>
</table><Pagination page={bookings.page} totalPages={bookings.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} /></div>}
</div>
);
}