Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx
Marshal 47c25d3f3a Add ContractCourtBadge component and integrate into contract pages
- Introduced ContractCourtBadge to display the responsible party for contract actions.
- Updated ContractStatusBadge to include new court badge.
- Enhanced ClearanceDocumentsPage with additional filters for trade direction, freight type, and ownership.
- Modified ContractRequestDetailPage and ContractRequestsPage to utilize ContractCourtBadge.
2026-07-29 13:37:17 +00:00

415 lines
14 KiB
TypeScript

import {
ActionIcon,
Box,
Card,
Group,
Select,
Stack,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { FileText, Inbox, RefreshCw, 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 { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { PageContainer, PageHeader } from "@/components/page";
import { bookingsService } from "@/services/bookings.service";
import type { BookingDetail } from "@/types/booking";
import {
Badge,
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
/**
* 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;
/** Status filter options (values = `statuses` param). */
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" },
];
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" },
];
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() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [bookingStatuses, setBookingStatuses] = useState(
BOOKING_STATUS_OPTIONS[0].value,
);
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 resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: PAGE_SIZE });
}, [setPagination]);
const page = pagination.pageIndex + 1;
const bookingsQuery = useQuery({
queryKey: [
"clearance-documents",
"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,
});
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 customer = b.isGovernment
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "—");
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<User className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{customer}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate 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 truncate 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 pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
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%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search booking, contract or customer…"
leftSection={<Search size={18} />}
value={query}
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"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Group gap="sm" mt="sm" wrap="wrap">
<Select
placeholder="Direction"
data={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"
/>
<DateInput
placeholder="Created from"
value={createdFrom}
onChange={(v) => {
setCreatedFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={createdTo ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created from"
/>
<DateInput
placeholder="Created to"
value={createdTo}
onChange={(v) => {
setCreatedTo(v ? new Date(v) : null);
resetPage();
}}
minDate={createdFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created to"
/>
</Group>
</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" },
})
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
)}
</Stack>
</Card>
</Stack>
</PageContainer>
);
}