feat(filter-bar): migrate BookingRequestsPage (richest Family A page)

13 useState fields -> 11 FilterDef entries + useFilters. Notable pieces:

- the deep-link-follow useEffect is gone entirely — controls.values reads
  live off the URL every render, so a link opened while the page is
  already mounted (the header document-review alarm) just works, and
  every filter auto-pins its own pill once it has a value (FilterBar's
  secondary/pinned split), so a deep link can never land behind "More
  filters" hidden. Confirmed the encoding stays back-compatible with the
  existing ?statuses=A,B&tradeDirection=IMPORT links via url.test.ts.
- "Paid, not allocated" (a checkbox that overrides the payment-status
  filter) became a boolean FilterDef with a custom toParams — ordered
  after paymentStatus in the defs array so its toParams wins on merge,
  matching the original override semantics exactly.
- booking kind and status stay pinned pills (always visible, matching the
  old primary row); direction/freight/payment/ownership/yards/dates are
  secondary (behind "More filters", matching the old advanced collapse).
This commit is contained in:
Nathnael
2026-08-14 14:08:08 +00:00
parent d94c444a51
commit fa32fa6a96

View File

@@ -1,44 +1,32 @@
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess"; import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
import { import {
ActionIcon,
Box, Box,
Button, Button,
Card, Card,
Checkbox,
Collapse,
Group, Group,
Modal, Modal,
MultiSelect,
Select,
Stack, Stack,
Text, Text,
TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import { import {
AlertTriangle, AlertTriangle,
ArrowRight, ArrowRight,
Calendar, Calendar,
CheckCircle2, CheckCircle2,
Clock, Clock,
FilterX,
LayoutList, LayoutList,
Package, Package,
Plus, Plus,
RefreshCw, RefreshCw,
Search,
User, User,
X,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { FilterToggle } from "@/components/common/FilterToggle";
import { formatDate, humanize } from "@/lib/format"; import { formatDate, humanize } from "@/lib/format";
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs. // BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
@@ -63,7 +51,6 @@ import {
Badge, Badge,
DataTable, DataTable,
DataTableFooter, DataTableFooter,
usePagination,
type ColumnDef, type ColumnDef,
} from "@edr/ui-common"; } from "@edr/ui-common";
@@ -104,61 +91,11 @@ const OWNERSHIP_OPTIONS = [
{ value: "false", label: "Private" }, { value: "false", label: "Private" },
]; ];
/** Local start-of-day → ISO, for inclusive "from" date filters. */
function startOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x.toISOString();
}
/** Local end-of-day → ISO, for inclusive "to" date filters. */
function endOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x.toISOString();
}
export default function BookingRequestsPage() { export default function BookingRequestsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
// the header's document-review alarm opens exactly the undecided requests it
// is counting down for. Read once as the initial state so staff can then
// change the filters like any other visit.
const [searchParams] = useSearchParams();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
// Booking kind is a filter now — one list holds both kinds (null = "all").
const [kindFilter, setKindFilter] = useState<BookingKind | null>(null);
// Filter controls (empty/null = "all").
const paramStatuses = searchParams.get("statuses") ?? "";
const paramDirection = searchParams.get("tradeDirection");
const [statusFilter, setStatusFilter] = useState<string[]>(() =>
paramStatuses.split(",").filter(Boolean),
);
const { filterOptions } = useMyTradeAccess(); const { filterOptions } = useMyTradeAccess();
const [directionFilter, setDirectionFilter] = useState<string | null>(
paramDirection,
);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [paymentStatusFilter, setPaymentStatusFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
const [originYardFilter, setOriginYardFilter] = useState<string | null>(null);
const [destinationYardFilter, setDestinationYardFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null);
const [scheduledFrom, setScheduledFrom] = useState<Date | null>(null);
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
// Direction is the only deep-linkable advanced filter — open the panel so a
// deep link never hides its own filter.
const [showAdvanced, setShowAdvanced] = useState(() =>
Boolean(paramDirection),
);
const [allocateOpen, setAllocateOpen] = useState(false); const [allocateOpen, setAllocateOpen] = useState(false);
const [allocateIds, setAllocateIds] = useState<string[]>([]); const [allocateIds, setAllocateIds] = useState<string[]>([]);
// Paid bookings with no train attached (staff removed them or a sweep
// detached them) — the queue the per-row Allocate action works through.
const [paidUnallocated, setPaidUnallocated] = useState(false);
const [allocatingId, setAllocatingId] = useState<string | null>(null); const [allocatingId, setAllocatingId] = useState<string | null>(null);
const [otherDayModal, setOtherDayModal] = useState<{ const [otherDayModal, setOtherDayModal] = useState<{
booking: BookingListRow; booking: BookingListRow;
@@ -173,77 +110,6 @@ export default function BookingRequestsPage() {
}, 400); }, 400);
}, []); }, []);
// Follow the URL when a deep link arrives while the page is already open
// (clicking the header alarm from this very list). Same-value writes are
// dropped so a manual filter change is never undone.
useEffect(() => {
const next = paramStatuses.split(",").filter(Boolean);
setStatusFilter((prev) => (prev.join(",") === next.join(",") ? prev : next));
setDirectionFilter(paramDirection);
if (paramDirection) setShowAdvanced(true);
}, [paramStatuses, paramDirection]);
const filter: BookingListFilter = useMemo(() => {
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
// React Query cache key per kind selection ("ALL" when unfiltered).
tab: kindFilter ?? "ALL",
...(kindFilter ? { bookingType: kindFilter } : {}),
// Server-side free-text search (booking ref, customer, contract ref).
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}),
// Wins over the payment-status select — the queue is by definition PAID.
...(paidUnallocated
? { paymentStatus: "PAID", assignedToSchedule: "false" as const }
: {}),
...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" }
: {}),
...(originYardFilter ? { originYardId: originYardFilter } : {}),
...(destinationYardFilter
? { destinationYardId: destinationYardFilter }
: {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
...(scheduledFrom ? { scheduledFrom: startOfDayIso(scheduledFrom) } : {}),
...(scheduledTo ? { scheduledTo: endOfDayIso(scheduledTo) } : {}),
};
}, [
pagination.pageIndex,
pagination.pageSize,
kindFilter,
debouncedQuery,
statusFilter,
directionFilter,
freightTypeFilter,
paymentStatusFilter,
paidUnallocated,
ownershipFilter,
originYardFilter,
destinationYardFilter,
createdFrom,
createdTo,
scheduledFrom,
scheduledTo,
]);
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
const primaryAllocateId = allocateIds[0];
const { data: allocateBooking } = useBookingDetail(
allocateOpen ? primaryAllocateId : undefined,
);
const {
data: summary,
isLoading: summaryLoading,
refetch: refetchSummary,
} = useBookingListSummary(filter);
// Yard options for the origin/destination filters (shared routes reference list). // Yard options for the origin/destination filters (shared routes reference list).
const { data: yardRefs } = useQuery( const { data: yardRefs } = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }), api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
@@ -257,43 +123,62 @@ export default function BookingRequestsPage() {
[yardRefs], [yardRefs],
); );
const resetPage = useCallback(() => { // Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); // the header's document-review alarm opens exactly the undecided requests
}, [setPagination, pagination.pageSize]); // it is counting down for. No sync effect needed any more: controls.values
// reads live off the URL every render, so a link opened while this page is
// already mounted just works, and every filter — direction included —
// auto-pins its own pill the moment it has a value (FilterBar's `secondary`
// split), so a deep link can never land behind "More filters" unseen.
const bookingFilterDefs: FilterDef[] = useMemo(
() => [
{ key: "bookingType", label: "Kind", type: "enum", multiple: false, options: BOOKING_KIND_OPTIONS },
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{
key: "tradeDirection", label: "Direction", type: "enum", multiple: false,
options: filterOptions(TRADE_DIRECTION_OPTIONS), secondary: true,
},
{ key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS, secondary: true },
{ key: "paymentStatus", label: "Payment", type: "enum", multiple: false, options: PAYMENT_STATUS_OPTIONS, secondary: true },
{
// Wins over the `paymentStatus` filter above — the queue is by
// definition PAID — because it's later in this array: toApiParams
// merges defs in order, so a later toParams overwrites an earlier one.
key: "paidUnallocated", label: "Allocation", type: "boolean", secondary: true,
trueLabel: "Paid, not allocated",
toParams: (v) => (v.v[0] === "true" ? { paymentStatus: "PAID", assignedToSchedule: "false" } : {}),
},
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true },
{ key: "originYardId", label: "Origin", type: "enum", multiple: false, options: yardOptions, secondary: true },
{ key: "destinationYardId", label: "Destination", type: "enum", multiple: false, options: yardOptions, secondary: true },
{ key: "created", label: "Created", type: "date", secondary: true, toParams: ({ v }) => ({ createdFrom: v[0], createdTo: v[1] }) },
{ key: "scheduled", label: "Scheduled", type: "date", secondary: true, toParams: ({ v }) => ({ scheduledFrom: v[0], scheduledTo: v[1] }) },
],
[filterOptions, yardOptions],
);
const activeFilterCount = const controls = useFilters(bookingFilterDefs, { defaultSort: "createdAt:DESC", pageSize: 10 });
(kindFilter ? 1 : 0) +
(statusFilter.length ? 1 : 0) +
(directionFilter ? 1 : 0) +
(freightTypeFilter ? 1 : 0) +
(paymentStatusFilter ? 1 : 0) +
(paidUnallocated ? 1 : 0) +
(ownershipFilter ? 1 : 0) +
(originYardFilter ? 1 : 0) +
(destinationYardFilter ? 1 : 0) +
(createdFrom || createdTo ? 1 : 0) +
(scheduledFrom || scheduledTo ? 1 : 0);
// Badge on the advanced-filters toggle — active filters hidden behind it. const filter: BookingListFilter = useMemo(
const advancedFilterCount = () => ({
activeFilterCount - (kindFilter ? 1 : 0) - (statusFilter.length ? 1 : 0); ...(controls.params as unknown as BookingListFilter),
// React Query cache key per kind selection ("ALL" when unfiltered) —
// kept as a param the API ignores, matching the pre-migration cache key.
tab: (controls.values.bookingType?.v[0] as BookingKind | undefined) ?? "ALL",
}),
[controls.params, controls.values.bookingType],
);
const clearFilters = useCallback(() => { const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
setKindFilter(null); const primaryAllocateId = allocateIds[0];
setStatusFilter([]); const { data: allocateBooking } = useBookingDetail(
setDirectionFilter(null); allocateOpen ? primaryAllocateId : undefined,
setFreightTypeFilter(null); );
setPaymentStatusFilter(null); const {
setPaidUnallocated(false); data: summary,
setOwnershipFilter(null); isLoading: summaryLoading,
setOriginYardFilter(null); refetch: refetchSummary,
setDestinationYardFilter(null); } = useBookingListSummary(filter);
setCreatedFrom(null);
setCreatedTo(null);
setScheduledFrom(null);
setScheduledTo(null);
resetPage();
}, [resetPage]);
// Search is applied server-side (via the `search` filter param) — no // Search is applied server-side (via the `search` filter param) — no
// client-side filtering here. // client-side filtering here.
@@ -303,8 +188,7 @@ export default function BookingRequestsPage() {
); );
const total = data?.total ?? 0; const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); const hasSearch = controls.searchText.trim().length > 0;
const hasSearch = query.trim().length > 0;
const showEmpty = !isLoading && !isError && rows.length === 0; const showEmpty = !isLoading && !isError && rows.length === 0;
const metrics = summary?.metrics; const metrics = summary?.metrics;
@@ -585,195 +469,13 @@ export default function BookingRequestsPage() {
<Card p={0}> <Card p={0}>
<Stack gap={0}> <Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%"> <Box px="md" pt="sm" pb="xs" w="100%">
<Stack gap="sm"> <FilterBar
<Group gap="sm" wrap="wrap"> defs={bookingFilterDefs}
<TextInput controls={controls}
placeholder="Search booking, contract or customer…" searchPlaceholder="Search booking, contract or customer…"
leftSection={<Search size={18} />} viewId="booking-requests"
value={query} />
onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
placeholder="All booking types"
data={BOOKING_KIND_OPTIONS}
value={kindFilter}
onChange={(v) => {
setKindFilter((v as BookingKind | null) ?? null);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 190 }}
/>
<MultiSelect
placeholder={statusFilter.length ? undefined : "All statuses"}
data={STATUS_OPTIONS}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 220 }}
/>
<FilterToggle
count={advancedFilterCount}
expanded={showAdvanced}
onClick={() => setShowAdvanced((v) => !v)}
/>
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={clearFilters}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group>
<Collapse expanded={showAdvanced}>
<Group gap="sm" wrap="wrap">
<Select
placeholder="All origins"
data={yardOptions}
value={originYardFilter}
onChange={(v) => {
setOriginYardFilter(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 180 }}
/>
<Select
placeholder="All destinations"
data={yardOptions}
value={destinationYardFilter}
onChange={(v) => {
setDestinationYardFilter(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 180 }}
/>
<Select
placeholder="All directions"
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 150 }}
/>
<Select
placeholder="All freight types"
data={FREIGHT_TYPE_OPTIONS}
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 150 }}
/>
<Select
placeholder="All payment statuses"
data={PAYMENT_STATUS_OPTIONS}
value={paymentStatusFilter}
onChange={(v) => {
setPaymentStatusFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 180 }}
/>
<Checkbox
label="Paid, not allocated"
checked={paidUnallocated}
onChange={(e) => {
setPaidUnallocated(e.currentTarget.checked);
resetPage();
}}
radius="sm"
style={{ alignSelf: "center" }}
/>
<Select
placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS}
value={ownershipFilter}
onChange={(v) => {
setOwnershipFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
/>
<DatePickerInput
type="range"
placeholder="Created date range"
value={[createdFrom, createdTo]}
onChange={([from, to]) => {
setCreatedFrom(from ? new Date(from) : null);
setCreatedTo(to ? new Date(to) : null);
resetPage();
}}
presets={getDateRangePresets()}
clearable
radius="lg"
style={{ minWidth: 220 }}
/>
<DatePickerInput
type="range"
placeholder="Scheduled date range"
value={[scheduledFrom, scheduledTo]}
onChange={([from, to]) => {
setScheduledFrom(from ? new Date(from) : null);
setScheduledTo(to ? new Date(to) : null);
resetPage();
}}
presets={getDateRangePresets()}
clearable
radius="lg"
style={{ minWidth: 230 }}
/>
</Group>
</Collapse>
</Stack>
</Box> </Box>
{showEmpty ? ( {showEmpty ? (
@@ -791,18 +493,7 @@ export default function BookingRequestsPage() {
data={rows} data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"} status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={handleRowClick} onRowClick={handleRowClick}
pagination={{ {...controls.tableProps(total)}
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent" containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter} footer={DataTableFooter}
/> />