add customs clearing filter and enhance clearance documents page for non-customs contracts

This commit is contained in:
Marshal
2026-07-12 14:14:21 +00:00
parent 84ba56f7d0
commit e7e5b93ffc
10 changed files with 417 additions and 10 deletions

View File

@@ -0,0 +1,309 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Badge,
Group,
Paper,
SegmentedControl,
Tabs,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react";
import {
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.
*/
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";
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 (
<Badge variant="light" color={done ? "edr-green" : "yellow"} radius="sm">
{statusLabel(status)}
</Badge>
);
}
export default function ClearanceDocumentsPage() {
const navigate = useNavigate();
const [hubTab, setHubTab] = useState<HubTab>("contracts");
const [queueTab, setQueueTab] = useState<QueueTab>("queue");
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const search = debouncedQuery.trim() || undefined;
const contractsPager = usePagination({ pageSize: PAGE_SIZE });
const generalPager = usePagination({ pageSize: PAGE_SIZE });
// 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 contractsQuery = useQuery({
queryKey: [
"clearance-documents",
"contracts",
queueTab,
contractsPager.pagination.pageIndex,
search,
],
queryFn: () => {
const filter = {
page: contractsPager.pagination.pageIndex + 1,
pageSize: PAGE_SIZE,
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,
],
queryFn: () =>
bookingsService.list({
status: isHistory ? BOOKING_HISTORY_STATUS : BOOKING_QUEUE_STATUS,
bookingType: "GENERAL_CONTRACT",
customsClearingEnabled: "false",
page: generalPager.pagination.pageIndex + 1,
pageSize: PAGE_SIZE,
search,
}),
enabled: hubTab === "general",
placeholderData: keepPreviousData,
});
const contractColumns = useMemo(
(): ColumnDef<Freight.IContract, unknown>[] => [
{
header: "Reference",
accessorKey: "reference",
},
{
header: "Customer",
cell: ({ row }) => row.original.company?.name ?? "—",
},
{
header: "Kind",
cell: ({ row }) => statusLabel(row.original.contractKind),
},
{
header: "Direction",
cell: ({ row }) => statusLabel(row.original.tradeDirection),
},
{
header: "Freight",
cell: ({ row }) => statusLabel(row.original.freightType),
},
{
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
header: "Created",
cell: ({ row }) => formatDate(row.original.createdAt),
},
],
[],
);
const bookingColumns = useMemo(
(): ColumnDef<BookingDetail, unknown>[] => [
{
header: "Reference",
accessorKey: "reference",
},
{
header: "Customer",
cell: ({ row }) =>
row.original.isGovernment
? (row.original.governmentInstitution ?? "Government")
: (row.original.company?.name ?? "—"),
},
{
header: "Contract",
cell: ({ row }) => row.original.contractReference ?? "—",
},
{
header: "Direction",
cell: ({ row }) => statusLabel(row.original.tradeDirection),
},
{
header: "Freight",
cell: ({ row }) => statusLabel(row.original.freightType),
},
{
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
],
[],
);
const activeQuery = hubTab === "contracts" ? contractsQuery : generalQuery;
const total = activeQuery.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const tableStatus = activeQuery.isLoading
? "loading"
: activeQuery.isError
? "error"
: "success";
return (
<PageContainer>
<PageHeader
title="Clearance Documents"
subtitle="Operations review of customer clearance documents for contracts without customs clearing — contract-level (one-time) and per-booking (general)."
/>
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Tabs
value={hubTab}
onChange={(v) => setHubTab((v as HubTab) ?? "contracts")}
>
<Tabs.List>
<Tabs.Tab value="contracts">Contracts</Tabs.Tab>
<Tabs.Tab value="general">General</Tabs.Tab>
</Tabs.List>
</Tabs>
<Group gap="sm">
<SegmentedControl
value={queueTab}
onChange={(v) => setQueueTab(v as QueueTab)}
data={[
{ value: "queue", label: "Queue" },
{ value: "history", label: "History" },
]}
size="xs"
/>
<TextInput
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
placeholder={
hubTab === "contracts"
? "Search reference or customer…"
: "Search booking, customer or contract…"
}
leftSection={<Search size={14} />}
w={260}
/>
</Group>
</Group>
{hubTab === "contracts" ? (
<DataTable<Freight.IContract, unknown>
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}
/>
) : (
<DataTable<BookingDetail, unknown>
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}
/>
)}
</Paper>
</PageContainer>
);
}