112 lines
7.1 KiB
TypeScript
112 lines
7.1 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Link, useSearchParams } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { api } from "../api/client";
|
|
import type { Booking, Page } from "../api/types";
|
|
import { brusselsDayStartIso, brusselsNextDayStartIso, toBrusselsDateTimeLocal } from "../i18n/brusselsDateTime";
|
|
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"];
|
|
|
|
/** Today's Brussels calendar day (YYYY-MM-DD) -- the operational "today", not the browser's. */
|
|
function brusselsToday(): string {
|
|
return toBrusselsDateTimeLocal(new Date()).slice(0, 10);
|
|
}
|
|
|
|
export function Bookings() {
|
|
const { t } = useTranslation("bookings");
|
|
const { formatShortDate } = useLocaleFormat();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const [bookings, setBookings] = useState<Page<Booking> | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const status = searchParams.get("status") ?? "";
|
|
const query = searchParams.get("q") ?? "";
|
|
const startsFrom = searchParams.get("from") ?? "";
|
|
const startsTo = searchParams.get("to") ?? "";
|
|
const location = searchParams.get("location") ?? "";
|
|
const sort = searchParams.get("sort") ?? "operational";
|
|
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, { replace: true });
|
|
}
|
|
|
|
useEffect(() => {
|
|
setBookings(null);
|
|
setError(null);
|
|
const params = new URLSearchParams({ page: String(page), page_size: "25" });
|
|
if (status) params.set("status", status);
|
|
if (query) params.set("query", query);
|
|
// Date filters are Brussels calendar days regardless of the browser's own time zone.
|
|
if (startsFrom) params.set("starts_from", brusselsDayStartIso(startsFrom));
|
|
if (startsTo) params.set("starts_to", brusselsNextDayStartIso(startsTo));
|
|
if (location) params.set("location", location);
|
|
params.set("sort", sort);
|
|
const controller = new AbortController();
|
|
api
|
|
.get<Page<Booking>>(`/api/v1/bookings?${params.toString()}`, { signal: controller.signal })
|
|
.then(setBookings)
|
|
.catch(() => {
|
|
if (!controller.signal.aborted) setError(t("list.unavailable"));
|
|
});
|
|
return () => controller.abort();
|
|
}, [page, query, status, startsFrom, startsTo, location, sort, t]);
|
|
|
|
return (
|
|
<div className="page">
|
|
<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")}>
|
|
<label>
|
|
{t("list.searchLabel")}
|
|
<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) => updateFilters({ status: e.target.value, page: 1 })}>
|
|
<option value="">{t("list.statusAll")}</option>
|
|
{STATUS_OPTIONS.map((value) => <option key={value} value={value}>{t(`statuses.${value}`)}</option>)}
|
|
</select>
|
|
</label>
|
|
<label>{t("list.fromLabel")}<input type="date" value={startsFrom} onChange={(event) => updateFilters({ from: event.target.value, page: 1 })} /></label>
|
|
<label>{t("list.toLabel")}<input type="date" value={startsTo} onChange={(event) => updateFilters({ to: event.target.value, page: 1 })} /></label>
|
|
<label>{t("list.locationLabel")}<input value={location} onChange={(event) => updateFilters({ location: event.target.value, page: 1 })} placeholder={t("list.locationPlaceholder")} /></label>
|
|
<label>{t("list.sortLabel")}<select value={sort} onChange={(event) => updateFilters({ sort: event.target.value, page: 1 })}><option value="operational">{t("list.sortOperational")}</option><option value="starts_asc">{t("list.sortAscending")}</option><option value="starts_desc">{t("list.sortDescending")}</option></select></label>
|
|
</form>
|
|
<div className="filter-presets" aria-label={t("list.presetsLabel")}>
|
|
<button type="button" onClick={() => updateFilters({ from: brusselsToday(), to: brusselsToday(), status: null, page: 1 })}>{t("list.todayPreset")}</button>
|
|
<button type="button" onClick={() => updateFilters({ from: brusselsToday(), to: null, status: "reserved", sort: "starts_asc", page: 1 })}>{t("list.upcomingPreset")}</button>
|
|
<button type="button" onClick={() => setSearchParams(new URLSearchParams())}>{t("list.clearFilters")}</button>
|
|
</div>
|
|
|
|
{error && <ErrorState message={error} />}
|
|
{!error && !bookings && <LoadingState label={t("list.loading")} />}
|
|
{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.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>
|
|
))}
|
|
</tbody>
|
|
</table><Pagination page={bookings.page} totalPages={bookings.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} /></div>}
|
|
</div>
|
|
);
|
|
}
|