mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
- 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.
415 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|