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
|
* Operations queue: self-clearance (Path A) contracts awaiting Operations
|
||||||
* review of the customer's own clearance documents.
|
* 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> {
|
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({
|
return this.contractsRepository.findAllPaginated({
|
||||||
page: filter.page ?? 1,
|
page: filter.page ?? 1,
|
||||||
pageSize: filter.pageSize ?? 100,
|
pageSize: filter.pageSize ?? 100,
|
||||||
statuses: ['CLEARANCE_UNDER_REVIEW'],
|
statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'],
|
||||||
customsClearingEnabled: false,
|
customsClearingEnabled: false,
|
||||||
search: filter.search,
|
search: filter.search,
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
|
|||||||
@@ -1,137 +1,147 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Select,
|
||||||
SegmentedControl,
|
Stack,
|
||||||
Tabs,
|
Tabs,
|
||||||
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import { Search } from "lucide-react";
|
|
||||||
import {
|
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,
|
DataTable,
|
||||||
DataTableFooter,
|
DataTableFooter,
|
||||||
usePagination,
|
usePagination,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
} from "@edr/ui-common";
|
} 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
|
* Operations "Clearance Documents" hub — worklist for clearance-document
|
||||||
* review on contracts WITHOUT customs clearing (self-clearance / Path A):
|
* review on contracts WITHOUT customs clearing (self-clearance):
|
||||||
*
|
* Contracts tab = contract-level review (one-time flow), General tab =
|
||||||
* - Contracts tab: contracts whose clearance runs at contract level; rows open
|
* per-booking review under GENERAL non-customs contracts. Rows deep-link to
|
||||||
* the contract clearance detail where Operations approves + finalizes.
|
* the existing review detail pages; search / status filter / pagination are
|
||||||
* - General tab: booking instances under GENERAL non-customs contracts (those
|
* all server-side.
|
||||||
* 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 HubTab = "contracts" | "general";
|
||||||
type QueueTab = "queue" | "history";
|
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
/** Booking statuses that mean "docs awaiting review" / "review finished". */
|
/** Status filter options for the Contracts tab (values = `statuses` param). */
|
||||||
const BOOKING_QUEUE_STATUS = "DOCUMENTS_UNDER_REVIEW";
|
const CONTRACT_STATUS_OPTIONS = [
|
||||||
const BOOKING_HISTORY_STATUS = "CLEARANCE_READY";
|
{
|
||||||
|
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 {
|
/** Status filter options for the General (per-booking) tab. */
|
||||||
if (!iso) return "—";
|
const BOOKING_STATUS_OPTIONS = [
|
||||||
const d = new Date(iso);
|
{
|
||||||
if (Number.isNaN(d.getTime())) return "—";
|
value: "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY",
|
||||||
return d.toLocaleDateString(undefined, {
|
label: "All statuses",
|
||||||
day: "2-digit",
|
},
|
||||||
month: "short",
|
{ value: "AWAITING_DOCUMENTS", label: "Awaiting documents" },
|
||||||
year: "numeric",
|
{ value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" },
|
||||||
});
|
{ value: "CLEARANCE_READY", label: "Clearance ready" },
|
||||||
}
|
];
|
||||||
|
|
||||||
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() {
|
export default function ClearanceDocumentsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [hubTab, setHubTab] = useState<HubTab>("contracts");
|
const [hubTab, setHubTab] = useState<HubTab>("contracts");
|
||||||
const [queueTab, setQueueTab] = useState<QueueTab>("queue");
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
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 search = debouncedQuery.trim() || undefined;
|
||||||
|
|
||||||
const contractsPager = usePagination({ pageSize: PAGE_SIZE });
|
const resetPage = useCallback(() => {
|
||||||
const generalPager = usePagination({ pageSize: PAGE_SIZE });
|
setPagination({ pageIndex: 0, pageSize: PAGE_SIZE });
|
||||||
|
}, [setPagination]);
|
||||||
|
|
||||||
// Any search / queue-history / tab switch restarts both lists from page 1.
|
const page = pagination.pageIndex + 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({
|
const contractsQuery = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"clearance-documents",
|
"clearance-documents",
|
||||||
"contracts",
|
"contracts",
|
||||||
queueTab,
|
contractStatuses,
|
||||||
contractsPager.pagination.pageIndex,
|
page,
|
||||||
search,
|
search,
|
||||||
],
|
],
|
||||||
queryFn: () => {
|
queryFn: () =>
|
||||||
const filter = {
|
contractsService.getOpsClearanceQueue({
|
||||||
page: contractsPager.pagination.pageIndex + 1,
|
page,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
|
statuses: contractStatuses,
|
||||||
search,
|
search,
|
||||||
};
|
}),
|
||||||
return isHistory
|
|
||||||
? contractsService.getOpsClearanceHistory(filter)
|
|
||||||
: contractsService.getOpsClearanceQueue(filter);
|
|
||||||
},
|
|
||||||
enabled: hubTab === "contracts",
|
enabled: hubTab === "contracts",
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const generalQuery = useQuery({
|
const generalQuery = useQuery({
|
||||||
queryKey: [
|
queryKey: ["clearance-documents", "general", bookingStatuses, page, search],
|
||||||
"clearance-documents",
|
|
||||||
"general",
|
|
||||||
queueTab,
|
|
||||||
generalPager.pagination.pageIndex,
|
|
||||||
search,
|
|
||||||
],
|
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
bookingsService.list({
|
bookingsService.list({
|
||||||
status: isHistory ? BOOKING_HISTORY_STATUS : BOOKING_QUEUE_STATUS,
|
statuses: bookingStatuses,
|
||||||
bookingType: "GENERAL_CONTRACT",
|
bookingType: "GENERAL_CONTRACT",
|
||||||
customsClearingEnabled: "false",
|
customsClearingEnabled: "false",
|
||||||
page: generalPager.pagination.pageIndex + 1,
|
page,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
search,
|
search,
|
||||||
}),
|
}),
|
||||||
@@ -139,171 +149,355 @@ export default function ClearanceDocumentsPage() {
|
|||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const contractColumns = useMemo(
|
const contractRows = useMemo(
|
||||||
(): ColumnDef<Freight.IContract, unknown>[] => [
|
() => (contractsQuery.data?.items ?? []).map(toContractListRow),
|
||||||
|
[contractsQuery.data?.items],
|
||||||
|
);
|
||||||
|
const bookingRows = generalQuery.data?.items ?? [];
|
||||||
|
|
||||||
|
const contractColumns: ColumnDef<ContractListRow>[] = useMemo(
|
||||||
|
() => [
|
||||||
{
|
{
|
||||||
header: "Reference",
|
id: "contract",
|
||||||
accessorKey: "reference",
|
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",
|
id: "route",
|
||||||
cell: ({ row }) => row.original.company?.name ?? "—",
|
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",
|
id: "kind",
|
||||||
cell: ({ row }) => statusLabel(row.original.contractKind),
|
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",
|
id: "status",
|
||||||
cell: ({ row }) => statusLabel(row.original.tradeDirection),
|
size: 200,
|
||||||
},
|
minSize: 180,
|
||||||
{
|
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||||
header: "Freight",
|
cell: ({ row }) => (
|
||||||
cell: ({ row }) => statusLabel(row.original.freightType),
|
<div className="py-1">
|
||||||
},
|
<ContractStatusBadge
|
||||||
{
|
status={row.original.status}
|
||||||
header: "Status",
|
isRenewal={row.original.isRenewal}
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
/>
|
||||||
},
|
</div>
|
||||||
{
|
),
|
||||||
header: "Created",
|
meta: {
|
||||||
cell: ({ row }) => formatDate(row.original.createdAt),
|
headerClassName: "min-w-[11rem]",
|
||||||
|
cellClassName: "min-w-[11rem]",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const bookingColumns = useMemo(
|
const bookingColumns: ColumnDef<BookingDetail>[] = useMemo(
|
||||||
(): ColumnDef<BookingDetail, unknown>[] => [
|
() => [
|
||||||
{
|
{
|
||||||
header: "Reference",
|
id: "booking",
|
||||||
accessorKey: "reference",
|
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",
|
id: "contractRef",
|
||||||
cell: ({ row }) =>
|
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||||
row.original.isGovernment
|
cell: ({ row }) => (
|
||||||
? (row.original.governmentInstitution ?? "Government")
|
<Text size="sm">{row.original.contractReference ?? "—"}</Text>
|
||||||
: (row.original.company?.name ?? "—"),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Contract",
|
id: "shipment",
|
||||||
cell: ({ row }) => row.original.contractReference ?? "—",
|
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",
|
id: "status",
|
||||||
cell: ({ row }) => statusLabel(row.original.tradeDirection),
|
size: 200,
|
||||||
},
|
minSize: 180,
|
||||||
{
|
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||||
header: "Freight",
|
cell: ({ row }) => (
|
||||||
cell: ({ row }) => statusLabel(row.original.freightType),
|
<div className="py-1">
|
||||||
},
|
<BookingStatusBadge status={row.original.status} />
|
||||||
{
|
</div>
|
||||||
header: "Status",
|
),
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
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 total = activeQuery.data?.total ?? 0;
|
||||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
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
|
const tableStatus = activeQuery.isLoading
|
||||||
? "loading"
|
? "loading"
|
||||||
: activeQuery.isError
|
: activeQuery.isError
|
||||||
? "error"
|
? "error"
|
||||||
: "success";
|
: "success";
|
||||||
|
|
||||||
|
const statusOptions = isContracts
|
||||||
|
? CONTRACT_STATUS_OPTIONS
|
||||||
|
: BOOKING_STATUS_OPTIONS;
|
||||||
|
const statusValue = isContracts ? contractStatuses : bookingStatuses;
|
||||||
|
const setStatusValue = isContracts ? setContractStatuses : setBookingStatuses;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<Stack gap="lg">
|
||||||
title="Clearance Documents"
|
<PageHeader
|
||||||
subtitle="Operations review of customer clearance documents for contracts without customs clearing — contract-level (one-time) and per-booking (general)."
|
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">
|
<Tabs
|
||||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
value={hubTab}
|
||||||
<Tabs
|
onChange={(v) => {
|
||||||
value={hubTab}
|
setHubTab((v as HubTab) ?? "contracts");
|
||||||
onChange={(v) => setHubTab((v as HubTab) ?? "contracts")}
|
resetPage();
|
||||||
>
|
}}
|
||||||
<Tabs.List>
|
>
|
||||||
<Tabs.Tab value="contracts">Contracts</Tabs.Tab>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="general">General</Tabs.Tab>
|
<Tabs.Tab value="contracts">Contracts</Tabs.Tab>
|
||||||
</Tabs.List>
|
<Tabs.Tab value="general">General</Tabs.Tab>
|
||||||
</Tabs>
|
</Tabs.List>
|
||||||
<Group gap="sm">
|
</Tabs>
|
||||||
<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" ? (
|
<Card p={0}>
|
||||||
<DataTable<Freight.IContract, unknown>
|
<Stack gap={0}>
|
||||||
columns={contractColumns}
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
data={contractsQuery.data?.items ?? []}
|
<Group justify="space-between" gap="md" wrap="wrap">
|
||||||
status={tableStatus}
|
<TextInput
|
||||||
onRowClick={(row) =>
|
placeholder={
|
||||||
navigate(`/dashboard/contracts/clearance/${row.id}`)
|
isContracts
|
||||||
}
|
? "Search reference or customer…"
|
||||||
pagination={{
|
: "Search booking, contract or customer…"
|
||||||
pageIndex: contractsPager.pagination.pageIndex,
|
}
|
||||||
pageSize: PAGE_SIZE,
|
leftSection={<Search size={18} />}
|
||||||
pageCount,
|
value={query}
|
||||||
totalCount: total,
|
onChange={(e) => {
|
||||||
}}
|
setQuery(e.target.value);
|
||||||
tableOptions={{
|
resetPage();
|
||||||
state: { pagination: contractsPager.pagination },
|
}}
|
||||||
onPaginationChange: contractsPager.setPagination,
|
rightSection={
|
||||||
manualPagination: true,
|
query && (
|
||||||
pageCount,
|
<ActionIcon
|
||||||
}}
|
size="sm"
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
color="gray"
|
||||||
footer={DataTableFooter}
|
radius="md"
|
||||||
/>
|
variant="transparent"
|
||||||
) : (
|
onClick={() => {
|
||||||
<DataTable<BookingDetail, unknown>
|
setQuery("");
|
||||||
columns={bookingColumns}
|
resetPage();
|
||||||
data={generalQuery.data?.items ?? []}
|
}}
|
||||||
status={tableStatus}
|
>
|
||||||
onRowClick={(row) => navigate(`/dashboard/clearance/${row.id}`)}
|
<X size={16} />
|
||||||
pagination={{
|
</ActionIcon>
|
||||||
pageIndex: generalPager.pagination.pageIndex,
|
)
|
||||||
pageSize: PAGE_SIZE,
|
}
|
||||||
pageCount,
|
style={{ flex: 1, minWidth: "200px" }}
|
||||||
totalCount: total,
|
radius="lg"
|
||||||
}}
|
/>
|
||||||
tableOptions={{
|
<Select
|
||||||
state: { pagination: generalPager.pagination },
|
data={statusOptions}
|
||||||
onPaginationChange: generalPager.setPagination,
|
value={statusValue}
|
||||||
manualPagination: true,
|
onChange={(v) => {
|
||||||
pageCount,
|
setStatusValue(v ?? statusOptions[0].value);
|
||||||
}}
|
resetPage();
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
}}
|
||||||
footer={DataTableFooter}
|
allowDeselect={false}
|
||||||
/>
|
radius="lg"
|
||||||
)}
|
w={220}
|
||||||
</Paper>
|
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>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -461,6 +461,8 @@ export const contractsService = {
|
|||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
/** Comma-separated ops-clearance lifecycle statuses; omitted → under-review queue. */
|
||||||
|
statuses?: string;
|
||||||
}): Promise<PaginatedContracts> => {
|
}): Promise<PaginatedContracts> => {
|
||||||
const response = await client.get<PaginatedContracts>(
|
const response = await client.get<PaginatedContracts>(
|
||||||
C.OPS_CLEARANCE_QUEUE,
|
C.OPS_CLEARANCE_QUEUE,
|
||||||
|
|||||||
@@ -292,6 +292,19 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
|||||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||||
{w.destination ?? "—"}
|
{w.destination ?? "—"}
|
||||||
</Text>
|
</Text>
|
||||||
|
{w.reference && (
|
||||||
|
<Text
|
||||||
|
fz={11}
|
||||||
|
fw={600}
|
||||||
|
style={{
|
||||||
|
color: MUTED,
|
||||||
|
flexShrink: 0,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w.reference}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={5} wrap="nowrap" mt={2}>
|
<Group gap={5} wrap="nowrap" mt={2}>
|
||||||
<CalendarClock size={12} color={MUTED} style={{ flexShrink: 0 }} />
|
<CalendarClock size={12} color={MUTED} style={{ flexShrink: 0 }} />
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
|
|||||||
tile: "edr-amber-soft",
|
tile: "edr-amber-soft",
|
||||||
hint: "Clearance documents needed",
|
hint: "Clearance documents needed",
|
||||||
step: "edr-accent",
|
step: "edr-accent",
|
||||||
badgeLabel: "Docs needed",
|
badgeLabel: "Upload documents",
|
||||||
badgeBg: "edr-amber-soft",
|
badgeBg: "edr-amber-soft",
|
||||||
badgeText: "edr-amber-text",
|
badgeText: "edr-amber-text",
|
||||||
badgeDot: "edr-accent",
|
badgeDot: "edr-accent",
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ export function BookingClearanceWorkflowBanner({
|
|||||||
}: {
|
}: {
|
||||||
booking: Freight.IBooking;
|
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 =
|
const isPhased =
|
||||||
booking.customsClearingEnabled &&
|
booking.customsClearingEnabled &&
|
||||||
booking.bookingType === "GENERAL_CONTRACT";
|
(booking.bookingType === "GENERAL_CONTRACT" ||
|
||||||
|
booking.contractKind === "GENERAL");
|
||||||
|
|
||||||
const { view, viewer } = useFileViewer();
|
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 { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { CreditCard } from "lucide-react";
|
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ import type { Freight } from "@edr/types";
|
|||||||
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||||
import { ActivityCard } from "./components/ActivityCard";
|
import { ActivityCard } from "./components/ActivityCard";
|
||||||
import { ClearanceCard } from "./components/ClearanceCard";
|
import { ClearanceCard } from "./components/ClearanceCard";
|
||||||
|
import { DocumentsTab } from "./components/DocumentsTab";
|
||||||
import { ContainersCard } from "./components/ContainersCard";
|
import { ContainersCard } from "./components/ContainersCard";
|
||||||
import { ContractCard } from "./components/ContractCard";
|
import { ContractCard } from "./components/ContractCard";
|
||||||
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
|
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
|
||||||
@@ -201,50 +202,76 @@ export function ReadonlyBookingView({
|
|||||||
|
|
||||||
<KeyFactsStrip booking={booking} />
|
<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
|
{isClearance && <ClearanceCard booking={booking} />}
|
||||||
left={
|
|
||||||
<>
|
|
||||||
<ShipmentDetailsCard booking={booking} />
|
|
||||||
|
|
||||||
<ContainersCard booking={booking} />
|
<BodyGrid
|
||||||
|
left={
|
||||||
|
<>
|
||||||
|
<ShipmentDetailsCard booking={booking} />
|
||||||
|
|
||||||
<ShipmentTrackingCard bookingId={booking.id} />
|
<ContainersCard booking={booking} />
|
||||||
|
|
||||||
{canAssignCustomerTruck && (
|
<ShipmentTrackingCard bookingId={booking.id} />
|
||||||
<CustomerTruckAssignmentCard
|
|
||||||
booking={booking}
|
|
||||||
onAssigned={onBookingUpdated ?? (() => {})}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<WarehousePaymentsSection bookingId={booking.id} />
|
|
||||||
|
|
||||||
<ActivityCard booking={booking} />
|
{canAssignCustomerTruck && (
|
||||||
|
<CustomerTruckAssignmentCard
|
||||||
|
booking={booking}
|
||||||
|
onAssigned={onBookingUpdated ?? (() => {})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<WarehousePaymentsSection bookingId={booking.id} />
|
||||||
|
|
||||||
<MileSummaryCard booking={booking} />
|
<ActivityCard booking={booking} />
|
||||||
</>
|
|
||||||
}
|
<MileSummaryCard booking={booking} />
|
||||||
right={
|
</>
|
||||||
<>
|
}
|
||||||
<BookingPaymentPanel
|
right={
|
||||||
booking={booking}
|
<>
|
||||||
pricing={pricing}
|
<BookingPaymentPanel
|
||||||
onPay={() => setPayModalOpen(true)}
|
booking={booking}
|
||||||
paying={payMutation.isPending}
|
pricing={pricing}
|
||||||
showCountdown={showCountdown}
|
onPay={() => setPayModalOpen(true)}
|
||||||
|
paying={payMutation.isPending}
|
||||||
|
showCountdown={showCountdown}
|
||||||
|
/>
|
||||||
|
<ScheduleCard
|
||||||
|
booking={booking}
|
||||||
|
title="Consignment & Schedule"
|
||||||
|
consignment
|
||||||
|
/>
|
||||||
|
<SupportCard />
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<ScheduleCard
|
</div>
|
||||||
booking={booking}
|
</Tabs.Panel>
|
||||||
title="Consignment & Schedule"
|
|
||||||
consignment
|
<Tabs.Panel value="documents">
|
||||||
/>
|
<DocumentsTab booking={booking} />
|
||||||
<SupportCard />
|
</Tabs.Panel>
|
||||||
</>
|
</Tabs>
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PaymentMethodModal
|
<PaymentMethodModal
|
||||||
opened={payModalOpen}
|
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 }) {
|
export function KeyFactsStrip({ booking }: { booking: BookingLike }) {
|
||||||
const isContract = booking.bookingType === "GENERAL_CONTRACT";
|
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 freight = booking.freightType === "BULK" ? "Bulk" : "Container";
|
||||||
const payment = paymentStatusLabel(booking.paymentStatus);
|
const payment = paymentStatusLabel(booking.paymentStatus);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard p="lg">
|
<SectionCard p="lg">
|
||||||
<SimpleGrid cols={{ base: 2, md: 3, xl: 6 }} spacing={0} verticalSpacing="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="Cargo" value={freight} />
|
||||||
<Fact
|
<Fact
|
||||||
label="Route"
|
label="Route"
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function StatusHero({
|
|||||||
}
|
}
|
||||||
: isInitiatedInstance
|
: isInitiatedInstance
|
||||||
? {
|
? {
|
||||||
title: "Booking initiated — clearance documents needed",
|
title: "Upload clearance documents",
|
||||||
description:
|
description:
|
||||||
"Upload the required clearance documents to start the review. Once the review is finalized you can book your shipment.",
|
"Upload the required clearance documents to start the review. Once the review is finalized you can book your shipment.",
|
||||||
stage,
|
stage,
|
||||||
@@ -194,7 +194,11 @@ function ProgressTracker({
|
|||||||
} as React.CSSProperties
|
} 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) => {
|
{stages.map((stage, idx) => {
|
||||||
const state =
|
const state =
|
||||||
idx < current ? "done" : idx === current ? "active" : "idle";
|
idx < current ? "done" : idx === current ? "active" : "idle";
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
|
BadgeCheck,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
|
FileSearch,
|
||||||
FileText,
|
FileText,
|
||||||
|
FileUp,
|
||||||
MapPin,
|
MapPin,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
PackageOpen,
|
PackageOpen,
|
||||||
@@ -105,18 +108,25 @@ export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex(
|
|||||||
*/
|
*/
|
||||||
export const CONTRACT_PROGRESS_STAGES = [
|
export const CONTRACT_PROGRESS_STAGES = [
|
||||||
{
|
{
|
||||||
// The instance was initiated with one click and is going through per-booking
|
// The instance was initiated with one click and now sits in the clearance
|
||||||
// clearance (upload → review → ready). One-time drawdowns without clearance
|
// gate: the customer must upload the required clearance documents.
|
||||||
// start here too until they are booked.
|
label: "Upload Documents",
|
||||||
label: "Initiated",
|
icon: FileUp,
|
||||||
icon: FileText,
|
statuses: ["DRAFT", "CHANGES_REQUESTED", "AWAITING_DOCUMENTS"],
|
||||||
statuses: [
|
},
|
||||||
"DRAFT",
|
{
|
||||||
"CHANGES_REQUESTED",
|
// Staff/GL are reviewing the uploaded clearance documents; the customer
|
||||||
"AWAITING_DOCUMENTS",
|
// only re-uploads queried ones.
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
label: "Document Review",
|
||||||
"CLEARANCE_READY",
|
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
|
// The customer (or GL) completed the booking — cargo + shipment day — and it
|
||||||
@@ -360,13 +370,13 @@ export const STATUS_MAP: Record<
|
|||||||
stage: 5,
|
stage: 5,
|
||||||
},
|
},
|
||||||
AWAITING_DOCUMENTS: {
|
AWAITING_DOCUMENTS: {
|
||||||
title: "Clearance documents needed",
|
title: "Upload clearance documents",
|
||||||
description:
|
description:
|
||||||
"Upload the required clearance documents so your shipment can be reviewed.",
|
"Upload the required clearance documents so your shipment can be reviewed.",
|
||||||
stage: 1,
|
stage: 1,
|
||||||
},
|
},
|
||||||
DOCUMENTS_UNDER_REVIEW: {
|
DOCUMENTS_UNDER_REVIEW: {
|
||||||
title: "Documents under review",
|
title: "Clearance documents under review",
|
||||||
description:
|
description:
|
||||||
"Your clearance documents are being reviewed. Re-upload any queried documents to proceed.",
|
"Your clearance documents are being reviewed. Re-upload any queried documents to proceed.",
|
||||||
stage: 2,
|
stage: 2,
|
||||||
|
|||||||
@@ -34,17 +34,20 @@ type BookingLike = Freight.IBooking & {
|
|||||||
paymentStatus?: string;
|
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 }) {
|
export function BookingTypeBadge({ booking }: { booking: BookingLike }) {
|
||||||
const isContract = booking.bookingType === "GENERAL_CONTRACT";
|
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 (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant="light"
|
variant="light"
|
||||||
radius="sm"
|
radius="sm"
|
||||||
color={isContract ? "violet" : "gray"}
|
color={isContract || isGeneralDrawdown ? "violet" : "gray"}
|
||||||
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
|
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
|
||||||
>
|
>
|
||||||
{isContract ? "General Contract" : "One-Time"}
|
{isContract ? "General Contract" : isGeneralDrawdown ? "General" : "One-Time"}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export interface PriceLineItem {
|
|||||||
*/
|
*/
|
||||||
export interface MyBookingWindow {
|
export interface MyBookingWindow {
|
||||||
scheduleId: string;
|
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
|
* 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.
|
* "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;
|
trainScheduleStatus?: TrainScheduleStatus | string | null;
|
||||||
/** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */
|
/** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */
|
||||||
bookingType?: BookingType;
|
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). */
|
/** General contracts only: when ordering closes (null until active / for one-time). */
|
||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
/** Null for general contracts at creation — the date is chosen per order. */
|
/** Null for general contracts at creation — the date is chosen per order. */
|
||||||
|
|||||||
Reference in New Issue
Block a user