Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx
Nathnael 7aa4405423 Merge branch 'dev' into freight/nati-2
Conflict in ClearanceDocumentsPage: this branch migrated the page to the
pill FilterBar, dev added filters to the Select stack it replaced. Kept
the FilterBar and carried dev's additions across as a "Booked by"
(customerKind) FilterDef plus the shipping-line search placeholder; dev's
startOfDayIso/endOfDayIso went away because dateRangeParams already does
that. The Ship icon import is needed by dev's shipping-line customer cell,
which merged cleanly on its own.
2026-08-17 12:43:35 +00:00

304 lines
10 KiB
TypeScript

import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
import {
ActionIcon,
Box,
Card,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { FileText, Inbox, RefreshCw, Ship, User } from "lucide-react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { PageContainer, PageHeader } from "@/components/page";
import { bookingsService, type BookingListFilter } from "@/services/bookings.service";
import type { BookingDetail } from "@/types/booking";
import {
Badge,
DataTable,
DataTableFooter,
type ColumnDef,
} from "@edr/ui-common";
import { dateRangeParams, FilterBar, useFilters, type FilterDef } from "@/components/filters";
/**
* Operations "Clearance Documents" hub — the worklist for self-clearance
* (non-customs) document review. Clearance is always per SHIPMENT: the customer
* uploads his documents on the booking he initiated, whatever kind of contract
* it draws on, so this hub lists bookings only. Rows deep-link to the booking
* clearance review page; search / status filter / pagination are server-side.
*/
const PAGE_SIZE = 10;
/**
* The hub's baseline scope — FULLY_EXECUTED is the post-approval status of
* intercity (domestic) bookings, kept in as history so an approved intercity
* row doesn't just vanish. Sent whenever the Status pill has no narrower pick.
*/
const DEFAULT_STATUSES =
"AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED";
const STATUS_OPTIONS = [
{ value: "AWAITING_DOCUMENTS", label: "Awaiting documents" },
{ value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" },
{ value: "CLEARANCE_READY", label: "Clearance ready" },
{ value: "FULLY_EXECUTED", label: "Approved (history)" },
];
const TRADE_DIRECTION_OPTIONS = [
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
{ value: "DOMESTIC", label: "Domestic" },
];
const FREIGHT_TYPE_OPTIONS = [
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
];
const OWNERSHIP_OPTIONS = [
{ value: "true", label: "Government" },
{ value: "false", label: "Private" },
];
/** Who booked: shipping lines own bookings via `shippingLineCompanyId`, not a customer company. */
const CUSTOMER_KIND_OPTIONS = [
{ value: "SHIPPING_LINE", label: "Shipping line" },
{ value: "CUSTOMER", label: "Customer" },
];
export default function ClearanceDocumentsPage() {
const navigate = useNavigate();
const { filterOptions } = useMyTradeAccess();
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: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS },
{
key: "created",
label: "Created",
type: "date",
toParams: dateRangeParams("createdFrom", "createdTo"),
},
],
[filterOptions],
);
const controls = useFilters(filterDefs, { pageSize: PAGE_SIZE });
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({
queryKey: ["clearance-documents", "bookings", filter],
queryFn: () => bookingsService.list(filter),
placeholderData: keepPreviousData,
});
const bookingRows = bookingsQuery.data?.items ?? [];
const bookingColumns: ColumnDef<BookingDetail>[] = useMemo(
() => [
{
id: "booking",
header: () => <span className={bookingTable.headerCell}>Customer</span>,
cell: ({ row }) => {
const b = row.original;
const isShippingLine = Boolean(b.shippingLineCompany ?? b.shippingLineCompanyId);
const customer = isShippingLine
? (b.shippingLineCompany?.name ?? "Shipping line")
: b.isGovernment
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "—");
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
{isShippingLine ? (
<Ship className="size-4" strokeWidth={1.75} />
) : (
<User className="size-4" strokeWidth={1.75} />
)}
</div>
<div className="min-w-0">
<p className="flex items-center gap-1.5 font-medium text-foreground">
{customer}
{isShippingLine ? (
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
Shipping line
</Badge>
) : null}
</p>
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<FileText className="size-3 shrink-0 opacity-70" />
{b.reference}
</p>
</div>
</div>
);
},
},
{
id: "contractRef",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => {
const b = row.original;
return b.contractId && b.contractReference ? (
<ContractReferenceLink
contractId={b.contractId}
contractReference={b.contractReference}
className="block text-sm text-foreground underline underline-offset-2 hover:text-muted-foreground"
/>
) : (
<Text size="sm"></Text>
);
},
},
{
id: "shipment",
header: () => <span className={bookingTable.headerCell}>Shipment</span>,
cell: ({ row }) => {
const b = row.original;
return (
<div className="flex gap-1.5 py-1">
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{b.tradeDirection ?? "—"}
</Badge>
<Badge
variant="secondary"
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
>
{b.freightType ?? "—"}
</Badge>
</div>
);
},
},
{
id: "status",
size: 200,
minSize: 180,
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<div className="py-1">
<BookingStatusBadge status={row.original.status} />
</div>
),
meta: {
headerClassName: "min-w-[11rem]",
cellClassName: "min-w-[11rem]",
},
},
],
[],
);
const total = bookingsQuery.data?.total ?? 0;
const showEmpty =
!bookingsQuery.isLoading && !bookingsQuery.isError && bookingRows.length === 0;
const tableStatus = bookingsQuery.isLoading
? "loading"
: bookingsQuery.isError
? "error"
: "success";
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Clearance Documents"
subtitle="Operations review of the clearance documents customers upload on their shipments (services without customs clearing)."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
loading={bookingsQuery.isFetching}
onClick={() => void bookingsQuery.refetch()}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FilterBar
defs={filterDefs}
controls={controls}
searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="clearance-documents"
/>
</Box>
{showEmpty ? (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">No shipments match this view.</Text>
</Stack>
) : (
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={bookingColumns}
data={bookingRows}
status={tableStatus}
// `from` so the detail page's Back returns to THIS hub, not
// to whichever worklist the fallback would guess.
onRowClick={(row) =>
navigate(`/dashboard/clearance/${row.id}`, {
state: { from: "/dashboard/contracts/clearance-documents" },
})
}
{...controls.tableProps(total)}
containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words"
footer={DataTableFooter}
/>
</Box>
)}
</Stack>
</Card>
</Stack>
</PageContainer>
);
}