diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 4bd84a247..cef8ca21a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -1,44 +1,32 @@ import { useMyTradeAccess } from "@/hooks/useMyTradeAccess"; import { - ActionIcon, Box, Button, Card, - Checkbox, - Collapse, Group, Modal, - MultiSelect, - Select, Stack, Text, - TextInput, } from "@mantine/core"; -import { DatePickerInput } from "@mantine/dates"; -import { getDateRangePresets } from "@/components/common/dateRangePresets"; -import { useDebouncedValue } from "@mantine/hooks"; import { AlertTriangle, ArrowRight, Calendar, CheckCircle2, Clock, - FilterX, LayoutList, Package, Plus, RefreshCw, - Search, User, - X, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; -import { FilterToggle } from "@/components/common/FilterToggle"; import { formatDate, humanize } from "@/lib/format"; +import { FilterBar, useFilters, type FilterDef } from "@/components/filters"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; // BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs. @@ -63,7 +51,6 @@ import { Badge, DataTable, DataTableFooter, - usePagination, type ColumnDef, } from "@edr/ui-common"; @@ -104,61 +91,11 @@ const OWNERSHIP_OPTIONS = [ { 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() { 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(null); - // Filter controls (empty/null = "all"). - const paramStatuses = searchParams.get("statuses") ?? ""; - const paramDirection = searchParams.get("tradeDirection"); - const [statusFilter, setStatusFilter] = useState(() => - paramStatuses.split(",").filter(Boolean), - ); const { filterOptions } = useMyTradeAccess(); - const [directionFilter, setDirectionFilter] = useState( - paramDirection, - ); - const [freightTypeFilter, setFreightTypeFilter] = useState(null); - const [paymentStatusFilter, setPaymentStatusFilter] = useState(null); - const [ownershipFilter, setOwnershipFilter] = useState(null); - const [originYardFilter, setOriginYardFilter] = useState(null); - const [destinationYardFilter, setDestinationYardFilter] = useState(null); - const [createdFrom, setCreatedFrom] = useState(null); - const [createdTo, setCreatedTo] = useState(null); - const [scheduledFrom, setScheduledFrom] = useState(null); - const [scheduledTo, setScheduledTo] = useState(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 [allocateIds, setAllocateIds] = useState([]); - // 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(null); const [otherDayModal, setOtherDayModal] = useState<{ booking: BookingListRow; @@ -173,77 +110,6 @@ export default function BookingRequestsPage() { }, 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). const { data: yardRefs } = useQuery( api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }), @@ -257,43 +123,62 @@ export default function BookingRequestsPage() { [yardRefs], ); - const resetPage = useCallback(() => { - setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); - }, [setPagination, pagination.pageSize]); + // 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. 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 = - (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); + const controls = useFilters(bookingFilterDefs, { defaultSort: "createdAt:DESC", pageSize: 10 }); - // Badge on the advanced-filters toggle — active filters hidden behind it. - const advancedFilterCount = - activeFilterCount - (kindFilter ? 1 : 0) - (statusFilter.length ? 1 : 0); + const filter: BookingListFilter = useMemo( + () => ({ + ...(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(() => { - setKindFilter(null); - setStatusFilter([]); - setDirectionFilter(null); - setFreightTypeFilter(null); - setPaymentStatusFilter(null); - setPaidUnallocated(false); - setOwnershipFilter(null); - setOriginYardFilter(null); - setDestinationYardFilter(null); - setCreatedFrom(null); - setCreatedTo(null); - setScheduledFrom(null); - setScheduledTo(null); - resetPage(); - }, [resetPage]); + 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); // Search is applied server-side (via the `search` filter param) — no // client-side filtering here. @@ -303,8 +188,7 @@ export default function BookingRequestsPage() { ); const total = data?.total ?? 0; - const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); - const hasSearch = query.trim().length > 0; + const hasSearch = controls.searchText.trim().length > 0; const showEmpty = !isLoading && !isError && rows.length === 0; const metrics = summary?.metrics; @@ -585,195 +469,13 @@ export default function BookingRequestsPage() { - - - - } - value={query} - onChange={(e) => { - setQuery(e.target.value); - resetPage(); - }} - rightSection={ - query && ( - { - setQuery(""); - resetPage(); - }} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - radius="lg" - /> - { - setOriginYardFilter(v); - resetPage(); - }} - clearable - searchable - radius="lg" - style={{ minWidth: 180 }} - /> - { - setDirectionFilter(v); - resetPage(); - }} - clearable - radius="lg" - style={{ minWidth: 150 }} - /> - { - setPaymentStatusFilter(v); - resetPage(); - }} - clearable - radius="lg" - style={{ minWidth: 180 }} - /> - { - setPaidUnallocated(e.currentTarget.checked); - resetPage(); - }} - radius="sm" - style={{ alignSelf: "center" }} - /> -