diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index d728eff5d..58f82856e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -866,11 +866,36 @@ export class ContractClearanceService { * Operations queue: self-clearance (Path A) contracts awaiting Operations * review of the customer's own clearance documents. */ + /** + * Statuses a non-customs contract passes through around Operations + * clearance review — the set a caller may narrow {@link opsQueue} to. + */ + private static readonly OPS_CLEARANCE_STATUSES = [ + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_UNDER_REVIEW', + 'CLEARANCE_READY_FOR_BOOKING', + 'FULLY_EXECUTED', + 'CONTRACT_ACTIVE', + 'ACTIVE_SHIPMENT_IN_PROGRESS', + 'CONTRACT_CLOSED', + 'CANCELLED', + ]; + async opsQueue(filter: FilterContractDto): Promise { + // Callers may narrow to any subset of the ops-clearance lifecycle (the + // hub's status filter sends an explicit list); anything outside the + // whitelist is dropped so this endpoint can't become a general contract + // browser. No statuses given → the original under-review queue. + const requested = (filter.statuses ?? filter.status ?? '') + .split(',') + .map((s) => s.trim()) + .filter((s) => + ContractClearanceService.OPS_CLEARANCE_STATUSES.includes(s), + ); return this.contractsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 100, - statuses: ['CLEARANCE_UNDER_REVIEW'], + statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'], customsClearingEnabled: false, search: filter.search, sortBy: filter.sortBy, diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx index 530e22c55..5b365bc9d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx @@ -1,137 +1,147 @@ -import { useEffect, useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; import { - Badge, + ActionIcon, + Box, + Card, Group, - Paper, - SegmentedControl, + Select, + Stack, Tabs, + Text, TextInput, + ThemeIcon, } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { Search } from "lucide-react"; import { + ArrowRight, + FileText, + Inbox, + RefreshCw, + Repeat, + Search, + User, + X, +} from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import { bookingTable } from "@/components/bookings/booking-ui.styles"; +import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"; +import { PageContainer, PageHeader } from "@/components/page"; +import { + toContractListRow, + type ContractListRow, +} from "@/features/contracts/mapContractListRow"; +import { bookingsService } from "@/services/bookings.service"; +import { contractsService } from "@/services/contracts.service"; +import type { BookingDetail } from "@/types/booking"; +import { + Badge, DataTable, DataTableFooter, usePagination, type ColumnDef, } from "@edr/ui-common"; -import type { Freight } from "@edr/types"; - -import { PageContainer } from "@/components/page/PageContainer"; -import { PageHeader } from "@/components/page/PageHeader"; -import { contractsService } from "@/services/contracts.service"; -import { bookingsService } from "@/services/bookings.service"; -import type { BookingDetail } from "@/types/booking"; /** - * Operations "Clearance Documents" hub — the worklist for clearance-document - * review on contracts WITHOUT customs clearing (self-clearance / Path A): - * - * - Contracts tab: contracts whose clearance runs at contract level; rows open - * the contract clearance detail where Operations approves + finalizes. - * - General tab: booking instances under GENERAL non-customs contracts (those - * clear per booking); rows open the booking clearance review page. - * - * The hub only lists — all review/approve/finalize actions live on the - * existing detail pages it links to. + * Operations "Clearance Documents" hub — worklist for clearance-document + * review on contracts WITHOUT customs clearing (self-clearance): + * Contracts tab = contract-level review (one-time flow), General tab = + * per-booking review under GENERAL non-customs contracts. Rows deep-link to + * the existing review detail pages; search / status filter / pagination are + * all server-side. */ type HubTab = "contracts" | "general"; -type QueueTab = "queue" | "history"; const PAGE_SIZE = 10; -/** Booking statuses that mean "docs awaiting review" / "review finished". */ -const BOOKING_QUEUE_STATUS = "DOCUMENTS_UNDER_REVIEW"; -const BOOKING_HISTORY_STATUS = "CLEARANCE_READY"; +/** Status filter options for the Contracts tab (values = `statuses` param). */ +const CONTRACT_STATUS_OPTIONS = [ + { + value: [ + "AWAITING_CLEARANCE_DOCUMENTS", + "CLEARANCE_UNDER_REVIEW", + "CLEARANCE_READY_FOR_BOOKING", + "FULLY_EXECUTED", + "CONTRACT_ACTIVE", + "ACTIVE_SHIPMENT_IN_PROGRESS", + "CONTRACT_CLOSED", + "CANCELLED", + ].join(","), + label: "All statuses", + }, + { value: "AWAITING_CLEARANCE_DOCUMENTS", label: "Awaiting documents" }, + { value: "CLEARANCE_UNDER_REVIEW", label: "Under review" }, + { value: "CLEARANCE_READY_FOR_BOOKING", label: "Ready for booking" }, + { value: "FULLY_EXECUTED,CONTRACT_ACTIVE", label: "Finalized" }, + { + value: "ACTIVE_SHIPMENT_IN_PROGRESS,CONTRACT_CLOSED", + label: "In progress / closed", + }, + { value: "CANCELLED", label: "Cancelled" }, +]; -function formatDate(iso?: string | null): string { - if (!iso) return "—"; - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return "—"; - return d.toLocaleDateString(undefined, { - day: "2-digit", - month: "short", - year: "numeric", - }); -} - -function statusLabel(status?: string | null): string { - return (status ?? "—").replaceAll("_", " "); -} - -function StatusBadge({ status }: { status?: string | null }) { - const done = - status === "CLEARANCE_READY" || - status === "CLEARANCE_READY_FOR_BOOKING" || - status === "ACTIVE" || - status === "CONTRACT_ACTIVE" || - status === "FULLY_EXECUTED"; - return ( - - {statusLabel(status)} - - ); -} +/** Status filter options for the General (per-booking) tab. */ +const BOOKING_STATUS_OPTIONS = [ + { + value: "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY", + label: "All statuses", + }, + { value: "AWAITING_DOCUMENTS", label: "Awaiting documents" }, + { value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" }, + { value: "CLEARANCE_READY", label: "Clearance ready" }, +]; export default function ClearanceDocumentsPage() { const navigate = useNavigate(); const [hubTab, setHubTab] = useState("contracts"); - const [queueTab, setQueueTab] = useState("queue"); const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); + const [contractStatuses, setContractStatuses] = useState( + CONTRACT_STATUS_OPTIONS[0].value, + ); + const [bookingStatuses, setBookingStatuses] = useState( + BOOKING_STATUS_OPTIONS[0].value, + ); + const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE }); + const search = debouncedQuery.trim() || undefined; - const contractsPager = usePagination({ pageSize: PAGE_SIZE }); - const generalPager = usePagination({ pageSize: PAGE_SIZE }); + const resetPage = useCallback(() => { + setPagination({ pageIndex: 0, pageSize: PAGE_SIZE }); + }, [setPagination]); - // Any search / queue-history / tab switch restarts both lists from page 1. - useEffect(() => { - contractsPager.setPagination((p) => ({ ...p, pageIndex: 0 })); - generalPager.setPagination((p) => ({ ...p, pageIndex: 0 })); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedQuery, queueTab, hubTab]); - - const isHistory = queueTab === "history"; + const page = pagination.pageIndex + 1; const contractsQuery = useQuery({ queryKey: [ "clearance-documents", "contracts", - queueTab, - contractsPager.pagination.pageIndex, + contractStatuses, + page, search, ], - queryFn: () => { - const filter = { - page: contractsPager.pagination.pageIndex + 1, + queryFn: () => + contractsService.getOpsClearanceQueue({ + page, pageSize: PAGE_SIZE, + statuses: contractStatuses, search, - }; - return isHistory - ? contractsService.getOpsClearanceHistory(filter) - : contractsService.getOpsClearanceQueue(filter); - }, + }), enabled: hubTab === "contracts", placeholderData: keepPreviousData, }); const generalQuery = useQuery({ - queryKey: [ - "clearance-documents", - "general", - queueTab, - generalPager.pagination.pageIndex, - search, - ], + queryKey: ["clearance-documents", "general", bookingStatuses, page, search], queryFn: () => bookingsService.list({ - status: isHistory ? BOOKING_HISTORY_STATUS : BOOKING_QUEUE_STATUS, + statuses: bookingStatuses, bookingType: "GENERAL_CONTRACT", customsClearingEnabled: "false", - page: generalPager.pagination.pageIndex + 1, + page, pageSize: PAGE_SIZE, search, }), @@ -139,171 +149,355 @@ export default function ClearanceDocumentsPage() { placeholderData: keepPreviousData, }); - const contractColumns = useMemo( - (): ColumnDef[] => [ + const contractRows = useMemo( + () => (contractsQuery.data?.items ?? []).map(toContractListRow), + [contractsQuery.data?.items], + ); + const bookingRows = generalQuery.data?.items ?? []; + + const contractColumns: ColumnDef[] = useMemo( + () => [ { - header: "Reference", - accessorKey: "reference", + id: "contract", + header: () => Customer, + cell: ({ row }) => { + const c = row.original; + return ( +
+
+ +
+
+

+ {c.customerLabel} +

+

+ + {c.reference} +

+
+
+ ); + }, }, { - header: "Customer", - cell: ({ row }) => row.original.company?.name ?? "—", + id: "route", + header: () => Route, + cell: ({ row }) => { + const c = row.original; + return ( +
+
+ {c.originLabel} + + + {c.destinationLabel} + +
+
+ + {c.tradeDirection} + + + {c.freightType} + +
+
+ ); + }, }, { - header: "Kind", - cell: ({ row }) => statusLabel(row.original.contractKind), + id: "kind", + header: () => Kind, + cell: ({ row }) => ( + + {row.original.contractKind === "GENERAL" ? ( + + General + + ) : ( + "One-time" + )} + + ), }, { - header: "Direction", - cell: ({ row }) => statusLabel(row.original.tradeDirection), - }, - { - header: "Freight", - cell: ({ row }) => statusLabel(row.original.freightType), - }, - { - header: "Status", - cell: ({ row }) => , - }, - { - header: "Created", - cell: ({ row }) => formatDate(row.original.createdAt), + id: "status", + size: 200, + minSize: 180, + header: () => Status, + cell: ({ row }) => ( +
+ +
+ ), + meta: { + headerClassName: "min-w-[11rem]", + cellClassName: "min-w-[11rem]", + }, }, ], [], ); - const bookingColumns = useMemo( - (): ColumnDef[] => [ + const bookingColumns: ColumnDef[] = useMemo( + () => [ { - header: "Reference", - accessorKey: "reference", + id: "booking", + header: () => Customer, + cell: ({ row }) => { + const b = row.original; + const customer = b.isGovernment + ? (b.governmentInstitution ?? "Government") + : (b.company?.name ?? "—"); + return ( +
+
+ +
+
+

+ {customer} +

+

+ + {b.reference} +

+
+
+ ); + }, }, { - header: "Customer", - cell: ({ row }) => - row.original.isGovernment - ? (row.original.governmentInstitution ?? "Government") - : (row.original.company?.name ?? "—"), + id: "contractRef", + header: () => Contract, + cell: ({ row }) => ( + {row.original.contractReference ?? "—"} + ), }, { - header: "Contract", - cell: ({ row }) => row.original.contractReference ?? "—", + id: "shipment", + header: () => Shipment, + cell: ({ row }) => { + const b = row.original; + return ( +
+ + {b.tradeDirection ?? "—"} + + + {b.freightType ?? "—"} + +
+ ); + }, }, { - header: "Direction", - cell: ({ row }) => statusLabel(row.original.tradeDirection), - }, - { - header: "Freight", - cell: ({ row }) => statusLabel(row.original.freightType), - }, - { - header: "Status", - cell: ({ row }) => , + id: "status", + size: 200, + minSize: 180, + header: () => Status, + cell: ({ row }) => ( +
+ +
+ ), + meta: { + headerClassName: "min-w-[11rem]", + cellClassName: "min-w-[11rem]", + }, }, ], [], ); - const activeQuery = hubTab === "contracts" ? contractsQuery : generalQuery; + const isContracts = hubTab === "contracts"; + const activeQuery = isContracts ? contractsQuery : generalQuery; const total = activeQuery.data?.total ?? 0; const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); - + const showEmpty = + !activeQuery.isLoading && + !activeQuery.isError && + (isContracts ? contractRows.length : bookingRows.length) === 0; const tableStatus = activeQuery.isLoading ? "loading" : activeQuery.isError ? "error" : "success"; + const statusOptions = isContracts + ? CONTRACT_STATUS_OPTIONS + : BOOKING_STATUS_OPTIONS; + const statusValue = isContracts ? contractStatuses : bookingStatuses; + const setStatusValue = isContracts ? setContractStatuses : setBookingStatuses; + return ( - + + void activeQuery.refetch()} + aria-label="Refresh" + > + + + } + /> - - - setHubTab((v as HubTab) ?? "contracts")} - > - - Contracts - General - - - - setQueueTab(v as QueueTab)} - data={[ - { value: "queue", label: "Queue" }, - { value: "history", label: "History" }, - ]} - size="xs" - /> - setQuery(e.currentTarget.value)} - placeholder={ - hubTab === "contracts" - ? "Search reference or customer…" - : "Search booking, customer or contract…" - } - leftSection={} - w={260} - /> - - + { + setHubTab((v as HubTab) ?? "contracts"); + resetPage(); + }} + > + + Contracts + General + + - {hubTab === "contracts" ? ( - - columns={contractColumns} - data={contractsQuery.data?.items ?? []} - status={tableStatus} - onRowClick={(row) => - navigate(`/dashboard/contracts/clearance/${row.id}`) - } - pagination={{ - pageIndex: contractsPager.pagination.pageIndex, - pageSize: PAGE_SIZE, - pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination: contractsPager.pagination }, - onPaginationChange: contractsPager.setPagination, - manualPagination: true, - pageCount, - }} - containerClassName="border-0 shadow-none bg-transparent" - footer={DataTableFooter} - /> - ) : ( - - columns={bookingColumns} - data={generalQuery.data?.items ?? []} - status={tableStatus} - onRowClick={(row) => navigate(`/dashboard/clearance/${row.id}`)} - pagination={{ - pageIndex: generalPager.pagination.pageIndex, - pageSize: PAGE_SIZE, - pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination: generalPager.pagination }, - onPaginationChange: generalPager.setPagination, - manualPagination: true, - pageCount, - }} - containerClassName="border-0 shadow-none bg-transparent" - footer={DataTableFooter} - /> - )} - + + + + + } + value={query} + onChange={(e) => { + setQuery(e.target.value); + resetPage(); + }} + rightSection={ + query && ( + { + setQuery(""); + resetPage(); + }} + > + + + ) + } + style={{ flex: 1, minWidth: "200px" }} + radius="lg" + /> +