mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #641 from Tria-plc/freight_feature/usermanagement
enhance booking and contract management features
This commit is contained in:
@@ -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<PaginatedContracts> {
|
||||
// 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,
|
||||
|
||||
@@ -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 (
|
||||
<Badge variant="light" color={done ? "edr-green" : "yellow"} radius="sm">
|
||||
{statusLabel(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
/** 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<HubTab>("contracts");
|
||||
const [queueTab, setQueueTab] = useState<QueueTab>("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<Freight.IContract, unknown>[] => [
|
||||
const contractRows = useMemo(
|
||||
() => (contractsQuery.data?.items ?? []).map(toContractListRow),
|
||||
[contractsQuery.data?.items],
|
||||
);
|
||||
const bookingRows = generalQuery.data?.items ?? [];
|
||||
|
||||
const contractColumns: ColumnDef<ContractListRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: "Reference",
|
||||
accessorKey: "reference",
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Customer</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
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">
|
||||
{c.customerLabel}
|
||||
</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" />
|
||||
{c.reference}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Customer",
|
||||
cell: ({ row }) => row.original.company?.name ?? "—",
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<div className="space-y-1 py-1">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<span className="max-w-[8rem] truncate">{c.originLabel}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-[8rem] truncate">
|
||||
{c.destinationLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
|
||||
>
|
||||
{c.tradeDirection}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
||||
>
|
||||
{c.freightType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Kind",
|
||||
cell: ({ row }) => statusLabel(row.original.contractKind),
|
||||
id: "kind",
|
||||
header: () => <span className={bookingTable.headerCell}>Kind</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
|
||||
>
|
||||
{row.original.contractKind === "GENERAL" ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Repeat className="size-3" /> General
|
||||
</span>
|
||||
) : (
|
||||
"One-time"
|
||||
)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
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),
|
||||
id: "status",
|
||||
size: 200,
|
||||
minSize: 180,
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="py-1">
|
||||
<ContractStatusBadge
|
||||
status={row.original.status}
|
||||
isRenewal={row.original.isRenewal}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerClassName: "min-w-[11rem]",
|
||||
cellClassName: "min-w-[11rem]",
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const bookingColumns = useMemo(
|
||||
(): ColumnDef<BookingDetail, unknown>[] => [
|
||||
const bookingColumns: ColumnDef<BookingDetail>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: "Reference",
|
||||
accessorKey: "reference",
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Customer",
|
||||
cell: ({ row }) =>
|
||||
row.original.isGovernment
|
||||
? (row.original.governmentInstitution ?? "Government")
|
||||
: (row.original.company?.name ?? "—"),
|
||||
id: "contractRef",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{row.original.contractReference ?? "—"}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Contract",
|
||||
cell: ({ row }) => row.original.contractReference ?? "—",
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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} />,
|
||||
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 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 (
|
||||
<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)."
|
||||
/>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Clearance Documents"
|
||||
subtitle="Operations review of customer clearance documents for contracts without customs clearing."
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
loading={activeQuery.isFetching}
|
||||
onClick={() => void activeQuery.refetch()}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<Tabs
|
||||
value={hubTab}
|
||||
onChange={(v) => {
|
||||
setHubTab((v as HubTab) ?? "contracts");
|
||||
resetPage();
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="contracts">Contracts</Tabs.Tab>
|
||||
<Tabs.Tab value="general">General</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
{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>
|
||||
<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={
|
||||
isContracts
|
||||
? "Search reference or customer…"
|
||||
: "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={statusOptions}
|
||||
value={statusValue}
|
||||
onChange={(v) => {
|
||||
setStatusValue(v ?? statusOptions[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>
|
||||
</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 {isContracts ? "contracts" : "bookings"} match this view.
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
{isContracts ? (
|
||||
<DataTable
|
||||
columns={contractColumns}
|
||||
data={contractRows}
|
||||
status={tableStatus}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/dashboard/contracts/clearance/${row.id}`)
|
||||
}
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={bookingColumns}
|
||||
data={bookingRows}
|
||||
status={tableStatus}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/dashboard/clearance/${row.id}`)
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -461,6 +461,8 @@ export const contractsService = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
/** Comma-separated ops-clearance lifecycle statuses; omitted → under-review queue. */
|
||||
statuses?: string;
|
||||
}): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(
|
||||
C.OPS_CLEARANCE_QUEUE,
|
||||
|
||||
@@ -292,6 +292,19 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||
{w.destination ?? "—"}
|
||||
</Text>
|
||||
{w.reference && (
|
||||
<Text
|
||||
fz={11}
|
||||
fw={600}
|
||||
style={{
|
||||
color: MUTED,
|
||||
flexShrink: 0,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{w.reference}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap={5} wrap="nowrap" mt={2}>
|
||||
<CalendarClock size={12} color={MUTED} style={{ flexShrink: 0 }} />
|
||||
|
||||
@@ -172,7 +172,7 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
|
||||
tile: "edr-amber-soft",
|
||||
hint: "Clearance documents needed",
|
||||
step: "edr-accent",
|
||||
badgeLabel: "Docs needed",
|
||||
badgeLabel: "Upload documents",
|
||||
badgeBg: "edr-amber-soft",
|
||||
badgeText: "edr-amber-text",
|
||||
badgeDot: "edr-accent",
|
||||
|
||||
@@ -18,9 +18,12 @@ export function BookingClearanceWorkflowBanner({
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
}) {
|
||||
// Drawdown bookings under a GENERAL contract keep bookingType = ONE_TIME —
|
||||
// the denormalized contractKind is what marks them as phased (Path B).
|
||||
const isPhased =
|
||||
booking.customsClearingEnabled &&
|
||||
booking.bookingType === "GENERAL_CONTRACT";
|
||||
(booking.bookingType === "GENERAL_CONTRACT" ||
|
||||
booking.contractKind === "GENERAL");
|
||||
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Group } from "@mantine/core";
|
||||
import { Group, Tabs } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Freight } from "@edr/types";
|
||||
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||
import { ActivityCard } from "./components/ActivityCard";
|
||||
import { ClearanceCard } from "./components/ClearanceCard";
|
||||
import { DocumentsTab } from "./components/DocumentsTab";
|
||||
import { ContainersCard } from "./components/ContainersCard";
|
||||
import { ContractCard } from "./components/ContractCard";
|
||||
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
|
||||
@@ -201,50 +202,76 @@ export function ReadonlyBookingView({
|
||||
|
||||
<KeyFactsStrip booking={booking} />
|
||||
|
||||
<ContractCard booking={booking} />
|
||||
<Tabs
|
||||
defaultValue="overview"
|
||||
keepMounted={false}
|
||||
styles={{
|
||||
list: { gap: 6, borderBottom: "1px solid #E6ECF2" },
|
||||
tab: { borderRadius: "10px 10px 0 0", fontWeight: 700, padding: "10px 16px" },
|
||||
}}
|
||||
>
|
||||
<Tabs.List mb="lg">
|
||||
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}>
|
||||
Overview
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="documents" leftSection={<FileText size={15} />}>
|
||||
Documents
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{isClearance && <ClearanceCard booking={booking} />}
|
||||
<Tabs.Panel value="overview">
|
||||
<div className="flex flex-col gap-6">
|
||||
<ContractCard booking={booking} />
|
||||
|
||||
<BodyGrid
|
||||
left={
|
||||
<>
|
||||
<ShipmentDetailsCard booking={booking} />
|
||||
{isClearance && <ClearanceCard booking={booking} />}
|
||||
|
||||
<ContainersCard booking={booking} />
|
||||
<BodyGrid
|
||||
left={
|
||||
<>
|
||||
<ShipmentDetailsCard booking={booking} />
|
||||
|
||||
<ShipmentTrackingCard bookingId={booking.id} />
|
||||
<ContainersCard booking={booking} />
|
||||
|
||||
{canAssignCustomerTruck && (
|
||||
<CustomerTruckAssignmentCard
|
||||
booking={booking}
|
||||
onAssigned={onBookingUpdated ?? (() => {})}
|
||||
/>
|
||||
)}
|
||||
<WarehousePaymentsSection bookingId={booking.id} />
|
||||
<ShipmentTrackingCard bookingId={booking.id} />
|
||||
|
||||
<ActivityCard booking={booking} />
|
||||
{canAssignCustomerTruck && (
|
||||
<CustomerTruckAssignmentCard
|
||||
booking={booking}
|
||||
onAssigned={onBookingUpdated ?? (() => {})}
|
||||
/>
|
||||
)}
|
||||
<WarehousePaymentsSection bookingId={booking.id} />
|
||||
|
||||
<MileSummaryCard booking={booking} />
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<>
|
||||
<BookingPaymentPanel
|
||||
booking={booking}
|
||||
pricing={pricing}
|
||||
onPay={() => setPayModalOpen(true)}
|
||||
paying={payMutation.isPending}
|
||||
showCountdown={showCountdown}
|
||||
<ActivityCard booking={booking} />
|
||||
|
||||
<MileSummaryCard booking={booking} />
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<>
|
||||
<BookingPaymentPanel
|
||||
booking={booking}
|
||||
pricing={pricing}
|
||||
onPay={() => setPayModalOpen(true)}
|
||||
paying={payMutation.isPending}
|
||||
showCountdown={showCountdown}
|
||||
/>
|
||||
<ScheduleCard
|
||||
booking={booking}
|
||||
title="Consignment & Schedule"
|
||||
consignment
|
||||
/>
|
||||
<SupportCard />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ScheduleCard
|
||||
booking={booking}
|
||||
title="Consignment & Schedule"
|
||||
consignment
|
||||
/>
|
||||
<SupportCard />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="documents">
|
||||
<DocumentsTab booking={booking} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={payModalOpen}
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Alert, Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Building2,
|
||||
Check,
|
||||
ClipboardList,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
|
||||
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
|
||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||
|
||||
import { fmtDate } from "../utils";
|
||||
import { IconSquare } from "./Documents";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
const BORDER = "#E6ECF2";
|
||||
|
||||
type AnyFile = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
url: string;
|
||||
signedUrl?: string | null;
|
||||
};
|
||||
|
||||
// ── shared row chrome ─────────────────────────────────────────────────────────
|
||||
|
||||
type PillTone = "green" | "red" | "blue" | "gray";
|
||||
|
||||
const PILL_TONES: Record<PillTone, { bg: string; fg: string; border?: string }> =
|
||||
{
|
||||
green: { bg: "#ECF6F1", fg: "#0A6F4D", border: "#CDEBDD" },
|
||||
red: { bg: "#FBEAE7", fg: "#A93226", border: "#F3C8C1" },
|
||||
blue: { bg: "#EAF1FB", fg: "#2E5B96" },
|
||||
gray: { bg: "#F1F4F7", fg: "#6B7C8E" },
|
||||
};
|
||||
|
||||
function Pill({ tone, label }: { tone: PillTone; label: string }) {
|
||||
const t = PILL_TONES[tone];
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
flexShrink: 0,
|
||||
borderRadius: 999,
|
||||
backgroundColor: t.bg,
|
||||
border: t.border ? `1px solid ${t.border}` : undefined,
|
||||
padding: "4px 10px",
|
||||
fontSize: 11.5,
|
||||
fontWeight: 700,
|
||||
color: t.fg,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function reviewPill(doc: Freight.ClearanceDocument) {
|
||||
if (!doc.file) return <Pill tone="gray" label="Not uploaded" />;
|
||||
switch (doc.reviewStatus) {
|
||||
case "APPROVED":
|
||||
return <Pill tone="green" label="Approved" />;
|
||||
case "QUERIED":
|
||||
return <Pill tone="red" label="Query — re-upload" />;
|
||||
default:
|
||||
return <Pill tone="blue" label="Pending review" />;
|
||||
}
|
||||
}
|
||||
|
||||
/** One document row: icon tile, title/meta, status pill, view/download. */
|
||||
function FileRow({
|
||||
title,
|
||||
meta,
|
||||
file,
|
||||
pill,
|
||||
last,
|
||||
onView,
|
||||
}: {
|
||||
title: string;
|
||||
meta?: string | null;
|
||||
file: { id: string; name: string } | null;
|
||||
pill?: React.ReactNode;
|
||||
last?: boolean;
|
||||
onView: (f: { name: string; url: string }) => void;
|
||||
}) {
|
||||
const viewUrl = file ? fileViewUrl(file.id) : null;
|
||||
return (
|
||||
<Group
|
||||
gap={13}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
py={12}
|
||||
style={{ borderBottom: last ? undefined : "1px solid #F2F5F8" }}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 40,
|
||||
height: 40,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 10,
|
||||
backgroundColor: file ? "#EAF1FB" : "#F1F4F7",
|
||||
color: file ? "#2E5B96" : "#9AA8B5",
|
||||
}}
|
||||
>
|
||||
<FileText size={20} />
|
||||
</Box>
|
||||
<Box miw={0} flex={1}>
|
||||
<Text truncate fz="13.5px" fw={700} c={file ? "#10202F" : "#6B7C8E"}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text truncate fz="12px" c="#9AA8B5">
|
||||
{meta || file?.name || "—"}
|
||||
</Text>
|
||||
</Box>
|
||||
{pill}
|
||||
{file && viewUrl && isViewable({ name: file.name, url: viewUrl }) && (
|
||||
<IconSquare
|
||||
icon={<Eye size={15} />}
|
||||
onClick={() => onView({ name: file.name, url: viewUrl })}
|
||||
/>
|
||||
)}
|
||||
{file && <IconSquare href={fileViewUrl(file.id, true)} icon={<Download size={15} />} />}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// ── customs clearance progress timeline ──────────────────────────────────────
|
||||
|
||||
const REGION_LABELS: Record<string, string> = {
|
||||
CUST: "You",
|
||||
ET: "GL Ethiopia",
|
||||
DJ: "GL Djibouti",
|
||||
OPS: "Operations",
|
||||
};
|
||||
|
||||
function MilestoneTimeline({
|
||||
milestones,
|
||||
}: {
|
||||
milestones: Freight.IClearanceMilestone[];
|
||||
}) {
|
||||
const ordered = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
const currentIdx = ordered.findIndex((m) => m.status === "PENDING");
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
{ordered.map((m, idx) => {
|
||||
const done = m.status === "COMPLETED";
|
||||
const skipped = m.status === "SKIPPED";
|
||||
const active = idx === currentIdx;
|
||||
const last = idx === ordered.length - 1;
|
||||
return (
|
||||
<Group key={m.id} gap={12} align="stretch" wrap="nowrap">
|
||||
{/* rail: circle + connector */}
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
width: 26,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: done
|
||||
? "#0EA371"
|
||||
: active
|
||||
? "#0C1A2B"
|
||||
: "#EEF2F6",
|
||||
color: done || active ? "#fff" : "#9AA8B5",
|
||||
boxShadow: active ? "0 0 0 4px #D9E0E7" : undefined,
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{done ? <Check size={13} strokeWidth={3} /> : idx + 1}
|
||||
</Box>
|
||||
{!last && (
|
||||
<Box
|
||||
style={{
|
||||
width: 2,
|
||||
flex: 1,
|
||||
minHeight: 14,
|
||||
backgroundColor: done ? "#0EA371" : "#E1E7EE",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box pb={last ? 0 : 14} miw={0} flex={1}>
|
||||
<Group gap={8} wrap="nowrap" align="center">
|
||||
<Text
|
||||
fz="13.5px"
|
||||
fw={active ? 800 : 700}
|
||||
c={done || active ? "#10202F" : "#9AA8B5"}
|
||||
td={skipped ? "line-through" : undefined}
|
||||
truncate
|
||||
>
|
||||
{m.milestoneLabel}
|
||||
</Text>
|
||||
{m.ownerRegion && REGION_LABELS[m.ownerRegion] && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={m.ownerRegion === "CUST" ? "orange" : "gray"}
|
||||
radius="sm"
|
||||
tt="none"
|
||||
>
|
||||
{REGION_LABELS[m.ownerRegion]}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz="11.5px" c="#9AA8B5" mt={1}>
|
||||
{skipped
|
||||
? "Skipped"
|
||||
: done
|
||||
? `Completed${m.triggeredAt ? ` · ${fmtDate(m.triggeredAt)}` : ""}`
|
||||
: active
|
||||
? "Current step"
|
||||
: "Upcoming"}
|
||||
{m.note ? ` · ${m.note}` : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── contract & profile document grouping ─────────────────────────────────────
|
||||
|
||||
// Mirrors ContractDetailPage's grouping: onboarding/profile codes seeded by
|
||||
// file-upload-settings vs the generated contract PDF vs everything else.
|
||||
const BUSINESS_LICENSE_DOC_CODES = new Set([
|
||||
"business_license",
|
||||
"commercial_license",
|
||||
"investment_license",
|
||||
]);
|
||||
const PROFILE_DOC_CODES = new Set([
|
||||
"tin_certificate",
|
||||
"national_id",
|
||||
"national_id_passport",
|
||||
"passport",
|
||||
]);
|
||||
|
||||
const KNOWN_FILE_LABELS: Record<string, string> = {
|
||||
contract: "Signed contract",
|
||||
tin_certificate: "TIN certificate",
|
||||
business_license: "Business licence",
|
||||
commercial_license: "Commercial licence",
|
||||
investment_license: "Investment licence",
|
||||
national_id: "National ID",
|
||||
national_id_passport: "National ID / Passport",
|
||||
passport: "Passport",
|
||||
commercial_invoice: "Commercial Invoice",
|
||||
packing_list: "Packing List",
|
||||
certificate_of_origin: "Certificate of Origin",
|
||||
letter_of_credit: "Letter of Credit / LC",
|
||||
};
|
||||
|
||||
function fileLabel(f: AnyFile) {
|
||||
if (KNOWN_FILE_LABELS[f.code]) return KNOWN_FILE_LABELS[f.code];
|
||||
// Ad-hoc uploads carry a generated code (custom_<ts>_<i>) — use the filename.
|
||||
if (f.code.startsWith("custom_")) return f.name;
|
||||
return f.code
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (m) => m.toUpperCase());
|
||||
}
|
||||
|
||||
// ── the tab ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Documents tab of the booking detail page. Pulls together every document that
|
||||
* belongs to this shipment, in sections:
|
||||
*
|
||||
* 1. Clearance documents — the per-booking clearance grid (your uploads with
|
||||
* their review state, GL output documents), the customs progress timeline,
|
||||
* and the phased customs files (declaration, duty, transit permit, DO).
|
||||
* 2. Contract & company documents — the signed contract plus the company
|
||||
* documents the contract carries from your profile (TIN certificate,
|
||||
* business licence, national ID, …).
|
||||
* 3. Other booking documents — anything uploaded directly on the booking that
|
||||
* isn't already shown above.
|
||||
*/
|
||||
export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const hasContract = Boolean(booking.contractId);
|
||||
|
||||
const { data: clearance } = useQuery(
|
||||
api.bookings.getClearance.queryOptions({
|
||||
input: { id: booking.id },
|
||||
enabled: hasContract,
|
||||
}),
|
||||
);
|
||||
|
||||
const { data: contract } = useQuery(
|
||||
api.contracts.get.queryOptions({
|
||||
input: { id: booking.contractId ?? "" },
|
||||
enabled: hasContract,
|
||||
}),
|
||||
);
|
||||
|
||||
const customerDocs = useMemo(
|
||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
||||
[clearance],
|
||||
);
|
||||
const glDocs = useMemo(
|
||||
() =>
|
||||
(clearance?.documents ?? []).filter(
|
||||
(d) => d.uploadedBy === "gl" && d.file,
|
||||
),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
// The clearance action button (upload / re-upload / view) mirrors the
|
||||
// Overview tab's ClearanceCard so documents can be managed from here too.
|
||||
const action = getBookingNextAction(booking);
|
||||
const showManage = action && action.kind !== "BOOK";
|
||||
|
||||
const contractFiles = (contract?.files ?? []) as AnyFile[];
|
||||
const contractPdf = contractFiles.find((f) => f.code === "contract");
|
||||
const licenseFiles = contractFiles.filter((f) =>
|
||||
BUSINESS_LICENSE_DOC_CODES.has(f.code),
|
||||
);
|
||||
const profileFiles = contractFiles.filter((f) => PROFILE_DOC_CODES.has(f.code));
|
||||
|
||||
// Booking files not already represented in the clearance grid (those rows
|
||||
// show review state, so they win) and not contract signatures/PDF.
|
||||
const clearanceKeys = useMemo(
|
||||
() => new Set((clearance?.documents ?? []).map((d) => d.fileKey)),
|
||||
[clearance],
|
||||
);
|
||||
const otherBookingFiles = ((booking.files ?? []) as AnyFile[]).filter(
|
||||
(f) => !clearanceKeys.has(f.code) && !f.code.startsWith("signature_"),
|
||||
);
|
||||
|
||||
const company = contract?.company;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* ── 1. Clearance documents ──────────────────────────────────────── */}
|
||||
{hasContract && clearance && (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb={4}>
|
||||
<CardTitle>Clearance documents</CardTitle>
|
||||
{showManage && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="xs"
|
||||
leftSection={<ClipboardList size={14} />}
|
||||
onClick={() => setModalOpen(true)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz="12.5px" c="dimmed" mb="sm">
|
||||
Documents required to clear this shipment, with their review state.
|
||||
</Text>
|
||||
|
||||
{customerDocs.length > 0 ? (
|
||||
<Stack gap={0}>
|
||||
{customerDocs.map((doc, i) => (
|
||||
<FileRow
|
||||
key={doc.fileKey}
|
||||
title={`${doc.label}${doc.required ? " *" : ""}`}
|
||||
meta={doc.note ? `Note: ${doc.note}` : doc.file?.name}
|
||||
file={doc.file}
|
||||
pill={reviewPill(doc)}
|
||||
last={i === customerDocs.length - 1 && glDocs.length === 0}
|
||||
onView={view}
|
||||
/>
|
||||
))}
|
||||
{glDocs.map((doc, i) => (
|
||||
<FileRow
|
||||
key={doc.fileKey}
|
||||
title={doc.label}
|
||||
meta="Provided by Global Logistics"
|
||||
file={doc.file}
|
||||
pill={<Pill tone="green" label="Available" />}
|
||||
last={i === glDocs.length - 1}
|
||||
onView={view}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Alert color="gray" radius="md" icon={<Info size={16} />}>
|
||||
No clearance documents are required for this booking.
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── customs progress (Path B phased clearance) ──────────────────── */}
|
||||
{(clearance?.milestones?.length ?? 0) > 0 && (
|
||||
<SectionCard>
|
||||
<CardTitle>Customs clearance progress</CardTitle>
|
||||
<Text fz="12.5px" c="dimmed" mt={4} mb="md">
|
||||
Every customs step for this shipment — completed steps are ticked,
|
||||
the highlighted one is where it currently stands.
|
||||
</Text>
|
||||
<MilestoneTimeline milestones={clearance!.milestones!} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{(clearance?.workflowFiles?.length ?? 0) > 0 && (
|
||||
<SectionCard>
|
||||
<CardTitle>Customs documents</CardTitle>
|
||||
<Box mt="md">
|
||||
<ClearanceUploadedDocumentsPanel
|
||||
embedded
|
||||
files={clearance!.workflowFiles ?? []}
|
||||
tradeDirection={booking.tradeDirection}
|
||||
onView={(f) => view(f)}
|
||||
onDownload={({ id, name }) => {
|
||||
const a = document.createElement("a");
|
||||
a.href = fileViewUrl(id, true);
|
||||
a.download = name;
|
||||
a.click();
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── 2. Contract & company documents ─────────────────────────────── */}
|
||||
{hasContract && contract && (
|
||||
<SectionCard>
|
||||
<Group gap={10} align="center" mb={4}>
|
||||
<Building2 size={16} color="#6B7C8E" />
|
||||
<CardTitle>Contract & company documents</CardTitle>
|
||||
</Group>
|
||||
<Text fz="12.5px" c="dimmed" mb="sm">
|
||||
Attached to contract {contract.reference} — including the company
|
||||
documents it carries from your profile (TIN, licence, ID).
|
||||
</Text>
|
||||
|
||||
{(company?.name || company?.tin) && (
|
||||
<Group
|
||||
gap={24}
|
||||
mb="sm"
|
||||
px={14}
|
||||
py={10}
|
||||
style={{ borderRadius: 10, border: `1px solid ${BORDER}` }}
|
||||
>
|
||||
{company?.name && (
|
||||
<Box>
|
||||
<Text fz="10.5px" fw={700} c="#6B7C8E" tt="uppercase">
|
||||
Company
|
||||
</Text>
|
||||
<Text fz="13.5px" fw={700} c="#10202F">
|
||||
{company.name}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{company?.tin && (
|
||||
<Box>
|
||||
<Text fz="10.5px" fw={700} c="#6B7C8E" tt="uppercase">
|
||||
TIN number
|
||||
</Text>
|
||||
<Text fz="13.5px" fw={700} c="#10202F">
|
||||
{company.tin}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{contractPdf || licenseFiles.length || profileFiles.length ? (
|
||||
<Stack gap={0}>
|
||||
{contractPdf && (
|
||||
<FileRow
|
||||
title="Signed contract"
|
||||
meta={contractPdf.name}
|
||||
file={contractPdf}
|
||||
pill={<Pill tone="green" label="Executed" />}
|
||||
last={!licenseFiles.length && !profileFiles.length}
|
||||
onView={view}
|
||||
/>
|
||||
)}
|
||||
{[...licenseFiles, ...profileFiles].map((f, i, arr) => (
|
||||
<FileRow
|
||||
key={f.id}
|
||||
title={fileLabel(f)}
|
||||
meta={
|
||||
PROFILE_DOC_CODES.has(f.code) ||
|
||||
BUSINESS_LICENSE_DOC_CODES.has(f.code)
|
||||
? "From your company profile"
|
||||
: f.name
|
||||
}
|
||||
file={f}
|
||||
pill={<Pill tone="green" label="On file" />}
|
||||
last={i === arr.length - 1}
|
||||
onView={view}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Alert color="gray" radius="md" icon={<Info size={16} />}>
|
||||
No documents are attached to this contract yet.
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── 3. Other booking documents ──────────────────────────────────── */}
|
||||
{otherBookingFiles.length > 0 && (
|
||||
<SectionCard>
|
||||
<CardTitle>Other booking documents</CardTitle>
|
||||
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
|
||||
Documents uploaded directly on this booking.
|
||||
</Text>
|
||||
<Stack gap={0}>
|
||||
{otherBookingFiles.map((f, i) => (
|
||||
<FileRow
|
||||
key={f.id}
|
||||
title={fileLabel(f)}
|
||||
meta={f.name}
|
||||
file={f}
|
||||
pill={<Pill tone="green" label="Uploaded" />}
|
||||
last={i === otherBookingFiles.length - 1}
|
||||
onView={view}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{!hasContract && otherBookingFiles.length === 0 && (
|
||||
<SectionCard>
|
||||
<Alert color="gray" radius="md" icon={<Info size={16} />}>
|
||||
No documents have been uploaded for this booking yet.
|
||||
</Alert>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{showManage && (
|
||||
<BookingActionModal
|
||||
booking={booking}
|
||||
opened={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{viewer}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -39,13 +39,25 @@ function Fact({ label, value }: { label: string; value: ReactNode }) {
|
||||
*/
|
||||
export function KeyFactsStrip({ booking }: { booking: BookingLike }) {
|
||||
const isContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
// A drawdown under a GENERAL contract stays bookingType = ONE_TIME; its
|
||||
// "General" nature lives in the denormalized contractKind.
|
||||
const isGeneralDrawdown = !isContract && booking.contractKind === "GENERAL";
|
||||
const freight = booking.freightType === "BULK" ? "Bulk" : "Container";
|
||||
const payment = paymentStatusLabel(booking.paymentStatus);
|
||||
|
||||
return (
|
||||
<SectionCard p="lg">
|
||||
<SimpleGrid cols={{ base: 2, md: 3, xl: 6 }} spacing={0} verticalSpacing="lg">
|
||||
<Fact label="Type" value={isContract ? "General Contract" : "One-Time"} />
|
||||
<Fact
|
||||
label="Type"
|
||||
value={
|
||||
isContract
|
||||
? "General Contract"
|
||||
: isGeneralDrawdown
|
||||
? "General"
|
||||
: "One-Time"
|
||||
}
|
||||
/>
|
||||
<Fact label="Cargo" value={freight} />
|
||||
<Fact
|
||||
label="Route"
|
||||
|
||||
@@ -104,7 +104,7 @@ export function StatusHero({
|
||||
}
|
||||
: isInitiatedInstance
|
||||
? {
|
||||
title: "Booking initiated — clearance documents needed",
|
||||
title: "Upload clearance documents",
|
||||
description:
|
||||
"Upload the required clearance documents to start the review. Once the review is finalized you can book your shipment.",
|
||||
stage,
|
||||
@@ -194,7 +194,11 @@ function ProgressTracker({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="flex items-start" style={{ minWidth: 640 }}>
|
||||
{/* ~84px per stage keeps 2-word labels readable; the box scrolls on mobile. */}
|
||||
<div
|
||||
className="flex items-start"
|
||||
style={{ minWidth: Math.max(640, stages.length * 84) }}
|
||||
>
|
||||
{stages.map((stage, idx) => {
|
||||
const state =
|
||||
idx < current ? "done" : idx === current ? "active" : "idle";
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
BadgeCheck,
|
||||
ClipboardCheck,
|
||||
FileSearch,
|
||||
FileText,
|
||||
FileUp,
|
||||
MapPin,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
@@ -105,18 +108,25 @@ export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex(
|
||||
*/
|
||||
export const CONTRACT_PROGRESS_STAGES = [
|
||||
{
|
||||
// The instance was initiated with one click and is going through per-booking
|
||||
// clearance (upload → review → ready). One-time drawdowns without clearance
|
||||
// start here too until they are booked.
|
||||
label: "Initiated",
|
||||
icon: FileText,
|
||||
statuses: [
|
||||
"DRAFT",
|
||||
"CHANGES_REQUESTED",
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
"CLEARANCE_READY",
|
||||
],
|
||||
// The instance was initiated with one click and now sits in the clearance
|
||||
// gate: the customer must upload the required clearance documents.
|
||||
label: "Upload Documents",
|
||||
icon: FileUp,
|
||||
statuses: ["DRAFT", "CHANGES_REQUESTED", "AWAITING_DOCUMENTS"],
|
||||
},
|
||||
{
|
||||
// Staff/GL are reviewing the uploaded clearance documents; the customer
|
||||
// only re-uploads queried ones.
|
||||
label: "Document Review",
|
||||
icon: FileSearch,
|
||||
statuses: ["DOCUMENTS_UNDER_REVIEW"],
|
||||
},
|
||||
{
|
||||
// Clearance finished — the customer books (Path A) or GL completes the
|
||||
// booking on their behalf (Path B customs).
|
||||
label: "Cleared",
|
||||
icon: BadgeCheck,
|
||||
statuses: ["CLEARANCE_READY"],
|
||||
},
|
||||
{
|
||||
// The customer (or GL) completed the booking — cargo + shipment day — and it
|
||||
@@ -360,13 +370,13 @@ export const STATUS_MAP: Record<
|
||||
stage: 5,
|
||||
},
|
||||
AWAITING_DOCUMENTS: {
|
||||
title: "Clearance documents needed",
|
||||
title: "Upload clearance documents",
|
||||
description:
|
||||
"Upload the required clearance documents so your shipment can be reviewed.",
|
||||
stage: 1,
|
||||
},
|
||||
DOCUMENTS_UNDER_REVIEW: {
|
||||
title: "Documents under review",
|
||||
title: "Clearance documents under review",
|
||||
description:
|
||||
"Your clearance documents are being reviewed. Re-upload any queried documents to proceed.",
|
||||
stage: 2,
|
||||
|
||||
@@ -34,17 +34,20 @@ type BookingLike = Freight.IBooking & {
|
||||
paymentStatus?: string;
|
||||
};
|
||||
|
||||
/** One-Time vs General Contract. */
|
||||
/** One-Time vs General Contract (or a drawdown under a GENERAL contract). */
|
||||
export function BookingTypeBadge({ booking }: { booking: BookingLike }) {
|
||||
const isContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
// Drawdown bookings keep bookingType = ONE_TIME; the parent contract's kind
|
||||
// (denormalized onto the booking) is what makes them "General".
|
||||
const isGeneralDrawdown = !isContract && booking.contractKind === "GENERAL";
|
||||
return (
|
||||
<Badge
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={isContract ? "violet" : "gray"}
|
||||
color={isContract || isGeneralDrawdown ? "violet" : "gray"}
|
||||
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
|
||||
>
|
||||
{isContract ? "General Contract" : "One-Time"}
|
||||
{isContract ? "General Contract" : isGeneralDrawdown ? "General" : "One-Time"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface PriceLineItem {
|
||||
*/
|
||||
export interface MyBookingWindow {
|
||||
scheduleId: string;
|
||||
/** Train schedule reference (e.g. TS-2026-000123), shown on the window card. */
|
||||
reference: string | null;
|
||||
/**
|
||||
* The customer's active contract on this lane, when they hold one — enables
|
||||
* "Book now" to target it. Null for lanes they have no contract on.
|
||||
|
||||
@@ -497,6 +497,12 @@ export interface IBooking extends BaseEntity {
|
||||
trainScheduleStatus?: TrainScheduleStatus | string | null;
|
||||
/** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */
|
||||
bookingType?: BookingType;
|
||||
/**
|
||||
* Denormalized kind of the parent contract (ONE_TIME | GENERAL). Drawdown
|
||||
* bookings under a GENERAL contract keep bookingType = ONE_TIME, so this is
|
||||
* the field UIs must read to label a booking "General".
|
||||
*/
|
||||
contractKind?: `${import("./contracts").ContractKind}` | null;
|
||||
/** General contracts only: when ordering closes (null until active / for one-time). */
|
||||
expiresAt?: string | null;
|
||||
/** Null for general contracts at creation — the date is chosen per order. */
|
||||
|
||||
Reference in New Issue
Block a user