feat(filters): migrate ClearanceDocumentsPage to the pill filter bar

Mechanical Family-A port — same server contract BookingRequestsPage
already uses (tradeDirection/freightType/isGovernment/createdFrom/
createdTo/search/page/pageSize all already accepted).

One wrinkle: this hub has no true "unfiltered" state — it always
scopes to a fixed 4-status baseline (customsClearingEnabled=false
self-clearance bookings), with the old status Select's "All statuses"
row just being that baseline spelled out as an option. Modeled as a
normal optional Status enum pill (4 individual statuses, no synthetic
"All" entry) whose absence falls back to the baseline in the query
build, not in the filter defs themselves — `customsClearingEnabled`
stays a fixed, non-user-facing param the same way.
This commit is contained in:
Nathnael
2026-08-15 08:42:14 +00:00
parent 8ba8376f45
commit aae0066a5d

View File

@@ -3,34 +3,28 @@ import {
ActionIcon, ActionIcon,
Box, Box,
Card, Card,
Group,
Select,
Stack, Stack,
Text, Text,
TextInput,
ThemeIcon, ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react"; import { FileText, Inbox, RefreshCw, User } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { PageContainer, PageHeader } from "@/components/page"; import { PageContainer, PageHeader } from "@/components/page";
import { bookingsService } from "@/services/bookings.service"; import { bookingsService, type BookingListFilter } from "@/services/bookings.service";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { import {
Badge, Badge,
DataTable, DataTable,
DataTableFooter, DataTableFooter,
usePagination,
type ColumnDef, type ColumnDef,
} from "@edr/ui-common"; } from "@edr/ui-common";
import { dateRangeParams, FilterBar, useFilters, type FilterDef } from "@/components/filters";
/** /**
* Operations "Clearance Documents" hub — the worklist for self-clearance * Operations "Clearance Documents" hub — the worklist for self-clearance
@@ -43,16 +37,14 @@ import {
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
/** /**
* Status filter options (values = `statuses` param). FULLY_EXECUTED is the * The hub's baseline scope — FULLY_EXECUTED is the post-approval status of
* post-approval status of intercity (domestic) bookings kept in the list as * intercity (domestic) bookings, kept in as history so an approved intercity
* history, otherwise an approved intercity row vanishes from the hub. * row doesn't just vanish. Sent whenever the Status pill has no narrower pick.
*/ */
const BOOKING_STATUS_OPTIONS = [ const DEFAULT_STATUSES =
{ "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED";
value:
"AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED", const STATUS_OPTIONS = [
label: "All statuses",
},
{ value: "AWAITING_DOCUMENTS", label: "Awaiting documents" }, { value: "AWAITING_DOCUMENTS", label: "Awaiting documents" },
{ value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" }, { value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" },
{ value: "CLEARANCE_READY", label: "Clearance ready" }, { value: "CLEARANCE_READY", label: "Clearance ready" },
@@ -75,72 +67,58 @@ const OWNERSHIP_OPTIONS = [
{ value: "false", label: "Private" }, { value: "false", label: "Private" },
]; ];
function startOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x.toISOString();
}
function endOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x.toISOString();
}
export default function ClearanceDocumentsPage() { export default function ClearanceDocumentsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [bookingStatuses, setBookingStatuses] = useState(
BOOKING_STATUS_OPTIONS[0].value,
);
const { filterOptions } = useMyTradeAccess(); const { filterOptions } = useMyTradeAccess();
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
const search = debouncedQuery.trim() || undefined; const filterDefs: FilterDef[] = useMemo(
() => [
{
key: "status",
label: "Status",
type: "enum",
multiple: false,
options: STATUS_OPTIONS,
// No pick ⇒ no `statuses` param at all; the query fills in
// DEFAULT_STATUSES itself, same as the old Select's "All statuses" row.
toParams: ({ v }) => ({ statuses: v[0] }),
},
{
key: "tradeDirection",
label: "Direction",
type: "enum",
multiple: false,
options: filterOptions(TRADE_DIRECTION_OPTIONS),
},
{ key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS },
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS },
{
key: "created",
label: "Created",
type: "date",
toParams: dateRangeParams("createdFrom", "createdTo"),
},
],
[filterOptions],
);
const resetPage = useCallback(() => { const controls = useFilters(filterDefs, { pageSize: PAGE_SIZE });
setPagination({ pageIndex: 0, pageSize: PAGE_SIZE });
}, [setPagination]);
const page = pagination.pageIndex + 1; const filter: BookingListFilter = useMemo(
() => ({
...(controls.params as unknown as BookingListFilter),
// Self-clearance instances carry bookingType=ONE_TIME whatever their
// contract kind, so customsClearingEnabled=false + the status scope
// above are what isolate exactly this worklist.
customsClearingEnabled: "false",
statuses: (controls.params.statuses as string | undefined) ?? DEFAULT_STATUSES,
}),
[controls.params],
);
const bookingsQuery = useQuery({ const bookingsQuery = useQuery({
queryKey: [ queryKey: ["clearance-documents", "bookings", filter],
"clearance-documents", queryFn: () => bookingsService.list(filter),
"bookings",
bookingStatuses,
directionFilter,
freightTypeFilter,
ownershipFilter,
createdFrom,
createdTo,
page,
search,
],
queryFn: () =>
// Self-clearance instances carry bookingType=ONE_TIME whatever their
// contract kind, so customsClearingEnabled=false + the three per-booking
// clearance statuses are what isolate exactly this worklist.
bookingsService.list({
statuses: bookingStatuses,
customsClearingEnabled: "false",
page,
pageSize: PAGE_SIZE,
search,
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" }
: {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
}),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
@@ -233,7 +211,6 @@ export default function ClearanceDocumentsPage() {
); );
const total = bookingsQuery.data?.total ?? 0; const total = bookingsQuery.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const showEmpty = const showEmpty =
!bookingsQuery.isLoading && !bookingsQuery.isError && bookingRows.length === 0; !bookingsQuery.isLoading && !bookingsQuery.isError && bookingRows.length === 0;
const tableStatus = bookingsQuery.isLoading const tableStatus = bookingsQuery.isLoading
@@ -265,103 +242,12 @@ export default function ClearanceDocumentsPage() {
<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="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap"> <FilterBar
<TextInput defs={filterDefs}
placeholder="Search booking, contract or customer…" controls={controls}
leftSection={<Search size={18} />} searchPlaceholder="Search booking, contract or customer…"
value={query} viewId="clearance-documents"
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
data={BOOKING_STATUS_OPTIONS}
value={bookingStatuses}
onChange={(v) => {
setBookingStatuses(v ?? BOOKING_STATUS_OPTIONS[0].value);
resetPage();
}}
allowDeselect={false}
radius="lg"
w={220}
aria-label="Filter by status"
/>
</Group>
<Group gap="sm" mt="sm" wrap="wrap">
<Select
placeholder="Direction"
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 130 }}
aria-label="Filter by direction"
/>
<Select
placeholder="Freight type"
data={FREIGHT_TYPE_OPTIONS}
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by freight type"
/>
<Select
placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS}
value={ownershipFilter}
onChange={(v) => {
setOwnershipFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by ownership"
/>
<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 }}
aria-label="Created date range"
/>
</Group>
</Box> </Box>
{showEmpty ? ( {showEmpty ? (
@@ -384,18 +270,7 @@ export default function ClearanceDocumentsPage() {
state: { from: "/dashboard/contracts/clearance-documents" }, state: { from: "/dashboard/contracts/clearance-documents" },
}) })
} }
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 [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words" containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
footer={DataTableFooter} footer={DataTableFooter}
/> />