Merge pull request #641 from Tria-plc/freight_feature/usermanagement

enhance booking and contract management features
This commit is contained in:
marshal
2026-07-13 09:44:36 +03:00
committed by GitHub
14 changed files with 1149 additions and 275 deletions

View File

@@ -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,

View File

@@ -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>
); );
} }

View File

@@ -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,

View File

@@ -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 }} />

View File

@@ -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",

View File

@@ -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();

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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";

View File

@@ -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,

View File

@@ -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>
); );
} }

View File

@@ -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.

View File

@@ -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. */