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
* review of the customer's own clearance documents.
*/
/**
* Statuses a non-customs contract passes through around Operations
* clearance review — the set a caller may narrow {@link opsQueue} to.
*/
private static readonly OPS_CLEARANCE_STATUSES = [
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'ACTIVE_SHIPMENT_IN_PROGRESS',
'CONTRACT_CLOSED',
'CANCELLED',
];
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
// Callers may narrow to any subset of the ops-clearance lifecycle (the
// hub's status filter sends an explicit list); anything outside the
// whitelist is dropped so this endpoint can't become a general contract
// browser. No statuses given → the original under-review queue.
const requested = (filter.statuses ?? filter.status ?? '')
.split(',')
.map((s) => s.trim())
.filter((s) =>
ContractClearanceService.OPS_CLEARANCE_STATUSES.includes(s),
);
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100,
statuses: ['CLEARANCE_UNDER_REVIEW'],
statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'],
customsClearingEnabled: false,
search: filter.search,
sortBy: filter.sortBy,

View File

@@ -1,137 +1,147 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Badge,
ActionIcon,
Box,
Card,
Group,
Paper,
SegmentedControl,
Select,
Stack,
Tabs,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react";
import {
ArrowRight,
FileText,
Inbox,
RefreshCw,
Repeat,
Search,
User,
X,
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import { PageContainer, PageHeader } from "@/components/page";
import {
toContractListRow,
type ContractListRow,
} from "@/features/contracts/mapContractListRow";
import { bookingsService } from "@/services/bookings.service";
import { contractsService } from "@/services/contracts.service";
import type { BookingDetail } from "@/types/booking";
import {
Badge,
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { contractsService } from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import type { BookingDetail } from "@/types/booking";
/**
* Operations "Clearance Documents" hub — the worklist for clearance-document
* review on contracts WITHOUT customs clearing (self-clearance / Path A):
*
* - Contracts tab: contracts whose clearance runs at contract level; rows open
* the contract clearance detail where Operations approves + finalizes.
* - General tab: booking instances under GENERAL non-customs contracts (those
* clear per booking); rows open the booking clearance review page.
*
* The hub only lists — all review/approve/finalize actions live on the
* existing detail pages it links to.
* Operations "Clearance Documents" hub — worklist for clearance-document
* review on contracts WITHOUT customs clearing (self-clearance):
* Contracts tab = contract-level review (one-time flow), General tab =
* per-booking review under GENERAL non-customs contracts. Rows deep-link to
* the existing review detail pages; search / status filter / pagination are
* all server-side.
*/
type HubTab = "contracts" | "general";
type QueueTab = "queue" | "history";
const PAGE_SIZE = 10;
/** Booking statuses that mean "docs awaiting review" / "review finished". */
const BOOKING_QUEUE_STATUS = "DOCUMENTS_UNDER_REVIEW";
const BOOKING_HISTORY_STATUS = "CLEARANCE_READY";
/** Status filter options for the Contracts tab (values = `statuses` param). */
const CONTRACT_STATUS_OPTIONS = [
{
value: [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"ACTIVE_SHIPMENT_IN_PROGRESS",
"CONTRACT_CLOSED",
"CANCELLED",
].join(","),
label: "All statuses",
},
{ value: "AWAITING_CLEARANCE_DOCUMENTS", label: "Awaiting documents" },
{ value: "CLEARANCE_UNDER_REVIEW", label: "Under review" },
{ value: "CLEARANCE_READY_FOR_BOOKING", label: "Ready for booking" },
{ value: "FULLY_EXECUTED,CONTRACT_ACTIVE", label: "Finalized" },
{
value: "ACTIVE_SHIPMENT_IN_PROGRESS,CONTRACT_CLOSED",
label: "In progress / closed",
},
{ value: "CANCELLED", label: "Cancelled" },
];
function formatDate(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString(undefined, {
day: "2-digit",
month: "short",
year: "numeric",
});
}
function statusLabel(status?: string | null): string {
return (status ?? "—").replaceAll("_", " ");
}
function StatusBadge({ status }: { status?: string | null }) {
const done =
status === "CLEARANCE_READY" ||
status === "CLEARANCE_READY_FOR_BOOKING" ||
status === "ACTIVE" ||
status === "CONTRACT_ACTIVE" ||
status === "FULLY_EXECUTED";
return (
<Badge variant="light" color={done ? "edr-green" : "yellow"} radius="sm">
{statusLabel(status)}
</Badge>
);
}
/** Status filter options for the General (per-booking) tab. */
const BOOKING_STATUS_OPTIONS = [
{
value: "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY",
label: "All statuses",
},
{ value: "AWAITING_DOCUMENTS", label: "Awaiting documents" },
{ value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" },
{ value: "CLEARANCE_READY", label: "Clearance ready" },
];
export default function ClearanceDocumentsPage() {
const navigate = useNavigate();
const [hubTab, setHubTab] = useState<HubTab>("contracts");
const [queueTab, setQueueTab] = useState<QueueTab>("queue");
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [contractStatuses, setContractStatuses] = useState(
CONTRACT_STATUS_OPTIONS[0].value,
);
const [bookingStatuses, setBookingStatuses] = useState(
BOOKING_STATUS_OPTIONS[0].value,
);
const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
const search = debouncedQuery.trim() || undefined;
const contractsPager = usePagination({ pageSize: PAGE_SIZE });
const generalPager = usePagination({ pageSize: PAGE_SIZE });
const resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: PAGE_SIZE });
}, [setPagination]);
// Any search / queue-history / tab switch restarts both lists from page 1.
useEffect(() => {
contractsPager.setPagination((p) => ({ ...p, pageIndex: 0 }));
generalPager.setPagination((p) => ({ ...p, pageIndex: 0 }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedQuery, queueTab, hubTab]);
const isHistory = queueTab === "history";
const page = pagination.pageIndex + 1;
const contractsQuery = useQuery({
queryKey: [
"clearance-documents",
"contracts",
queueTab,
contractsPager.pagination.pageIndex,
contractStatuses,
page,
search,
],
queryFn: () => {
const filter = {
page: contractsPager.pagination.pageIndex + 1,
queryFn: () =>
contractsService.getOpsClearanceQueue({
page,
pageSize: PAGE_SIZE,
statuses: contractStatuses,
search,
};
return isHistory
? contractsService.getOpsClearanceHistory(filter)
: contractsService.getOpsClearanceQueue(filter);
},
}),
enabled: hubTab === "contracts",
placeholderData: keepPreviousData,
});
const generalQuery = useQuery({
queryKey: [
"clearance-documents",
"general",
queueTab,
generalPager.pagination.pageIndex,
search,
],
queryKey: ["clearance-documents", "general", bookingStatuses, page, search],
queryFn: () =>
bookingsService.list({
status: isHistory ? BOOKING_HISTORY_STATUS : BOOKING_QUEUE_STATUS,
statuses: bookingStatuses,
bookingType: "GENERAL_CONTRACT",
customsClearingEnabled: "false",
page: generalPager.pagination.pageIndex + 1,
page,
pageSize: PAGE_SIZE,
search,
}),
@@ -139,171 +149,355 @@ export default function ClearanceDocumentsPage() {
placeholderData: keepPreviousData,
});
const contractColumns = useMemo(
(): ColumnDef<Freight.IContract, unknown>[] => [
const contractRows = useMemo(
() => (contractsQuery.data?.items ?? []).map(toContractListRow),
[contractsQuery.data?.items],
);
const bookingRows = generalQuery.data?.items ?? [];
const contractColumns: ColumnDef<ContractListRow>[] = useMemo(
() => [
{
header: "Reference",
accessorKey: "reference",
id: "contract",
header: () => <span className={bookingTable.headerCell}>Customer</span>,
cell: ({ row }) => {
const c = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<User className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{c.customerLabel}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<FileText className="size-3 shrink-0 opacity-70" />
{c.reference}
</p>
</div>
</div>
);
},
},
{
header: "Customer",
cell: ({ row }) => row.original.company?.name ?? "—",
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => {
const c = row.original;
return (
<div className="space-y-1 py-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="max-w-[8rem] truncate">{c.originLabel}</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="max-w-[8rem] truncate">
{c.destinationLabel}
</span>
</div>
<div className="flex gap-1.5">
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{c.tradeDirection}
</Badge>
<Badge
variant="secondary"
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
>
{c.freightType}
</Badge>
</div>
</div>
);
},
},
{
header: "Kind",
cell: ({ row }) => statusLabel(row.original.contractKind),
id: "kind",
header: () => <span className={bookingTable.headerCell}>Kind</span>,
cell: ({ row }) => (
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
>
{row.original.contractKind === "GENERAL" ? (
<span className="inline-flex items-center gap-1">
<Repeat className="size-3" /> General
</span>
) : (
"One-time"
)}
</Badge>
),
},
{
header: "Direction",
cell: ({ row }) => statusLabel(row.original.tradeDirection),
},
{
header: "Freight",
cell: ({ row }) => statusLabel(row.original.freightType),
},
{
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
header: "Created",
cell: ({ row }) => formatDate(row.original.createdAt),
id: "status",
size: 200,
minSize: 180,
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<div className="py-1">
<ContractStatusBadge
status={row.original.status}
isRenewal={row.original.isRenewal}
/>
</div>
),
meta: {
headerClassName: "min-w-[11rem]",
cellClassName: "min-w-[11rem]",
},
},
],
[],
);
const bookingColumns = useMemo(
(): ColumnDef<BookingDetail, unknown>[] => [
const bookingColumns: ColumnDef<BookingDetail>[] = useMemo(
() => [
{
header: "Reference",
accessorKey: "reference",
id: "booking",
header: () => <span className={bookingTable.headerCell}>Customer</span>,
cell: ({ row }) => {
const b = row.original;
const customer = b.isGovernment
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "—");
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<User className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{customer}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<FileText className="size-3 shrink-0 opacity-70" />
{b.reference}
</p>
</div>
</div>
);
},
},
{
header: "Customer",
cell: ({ row }) =>
row.original.isGovernment
? (row.original.governmentInstitution ?? "Government")
: (row.original.company?.name ?? "—"),
id: "contractRef",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => (
<Text size="sm">{row.original.contractReference ?? "—"}</Text>
),
},
{
header: "Contract",
cell: ({ row }) => row.original.contractReference ?? "—",
id: "shipment",
header: () => <span className={bookingTable.headerCell}>Shipment</span>,
cell: ({ row }) => {
const b = row.original;
return (
<div className="flex gap-1.5 py-1">
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{b.tradeDirection ?? "—"}
</Badge>
<Badge
variant="secondary"
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
>
{b.freightType ?? "—"}
</Badge>
</div>
);
},
},
{
header: "Direction",
cell: ({ row }) => statusLabel(row.original.tradeDirection),
},
{
header: "Freight",
cell: ({ row }) => statusLabel(row.original.freightType),
},
{
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
id: "status",
size: 200,
minSize: 180,
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<div className="py-1">
<BookingStatusBadge status={row.original.status} />
</div>
),
meta: {
headerClassName: "min-w-[11rem]",
cellClassName: "min-w-[11rem]",
},
},
],
[],
);
const activeQuery = hubTab === "contracts" ? contractsQuery : generalQuery;
const isContracts = hubTab === "contracts";
const activeQuery = isContracts ? contractsQuery : generalQuery;
const total = activeQuery.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const showEmpty =
!activeQuery.isLoading &&
!activeQuery.isError &&
(isContracts ? contractRows.length : bookingRows.length) === 0;
const tableStatus = activeQuery.isLoading
? "loading"
: activeQuery.isError
? "error"
: "success";
const statusOptions = isContracts
? CONTRACT_STATUS_OPTIONS
: BOOKING_STATUS_OPTIONS;
const statusValue = isContracts ? contractStatuses : bookingStatuses;
const setStatusValue = isContracts ? setContractStatuses : setBookingStatuses;
return (
<PageContainer>
<PageHeader
title="Clearance Documents"
subtitle="Operations review of customer clearance documents for contracts without customs clearing — contract-level (one-time) and per-booking (general)."
/>
<Stack gap="lg">
<PageHeader
title="Clearance Documents"
subtitle="Operations review of customer clearance documents for contracts without customs clearing."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
loading={activeQuery.isFetching}
onClick={() => void activeQuery.refetch()}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Tabs
value={hubTab}
onChange={(v) => setHubTab((v as HubTab) ?? "contracts")}
>
<Tabs.List>
<Tabs.Tab value="contracts">Contracts</Tabs.Tab>
<Tabs.Tab value="general">General</Tabs.Tab>
</Tabs.List>
</Tabs>
<Group gap="sm">
<SegmentedControl
value={queueTab}
onChange={(v) => setQueueTab(v as QueueTab)}
data={[
{ value: "queue", label: "Queue" },
{ value: "history", label: "History" },
]}
size="xs"
/>
<TextInput
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
placeholder={
hubTab === "contracts"
? "Search reference or customer…"
: "Search booking, customer or contract…"
}
leftSection={<Search size={14} />}
w={260}
/>
</Group>
</Group>
<Tabs
value={hubTab}
onChange={(v) => {
setHubTab((v as HubTab) ?? "contracts");
resetPage();
}}
>
<Tabs.List>
<Tabs.Tab value="contracts">Contracts</Tabs.Tab>
<Tabs.Tab value="general">General</Tabs.Tab>
</Tabs.List>
</Tabs>
{hubTab === "contracts" ? (
<DataTable<Freight.IContract, unknown>
columns={contractColumns}
data={contractsQuery.data?.items ?? []}
status={tableStatus}
onRowClick={(row) =>
navigate(`/dashboard/contracts/clearance/${row.id}`)
}
pagination={{
pageIndex: contractsPager.pagination.pageIndex,
pageSize: PAGE_SIZE,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination: contractsPager.pagination },
onPaginationChange: contractsPager.setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
) : (
<DataTable<BookingDetail, unknown>
columns={bookingColumns}
data={generalQuery.data?.items ?? []}
status={tableStatus}
onRowClick={(row) => navigate(`/dashboard/clearance/${row.id}`)}
pagination={{
pageIndex: generalPager.pagination.pageIndex,
pageSize: PAGE_SIZE,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination: generalPager.pagination },
onPaginationChange: generalPager.setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
)}
</Paper>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder={
isContracts
? "Search reference or customer…"
: "Search booking, contract or customer…"
}
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
data={statusOptions}
value={statusValue}
onChange={(v) => {
setStatusValue(v ?? statusOptions[0].value);
resetPage();
}}
allowDeselect={false}
radius="lg"
w={220}
aria-label="Filter by status"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
</Box>
{showEmpty ? (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">
No {isContracts ? "contracts" : "bookings"} match this view.
</Text>
</Stack>
) : (
<Box style={{ overflowX: "auto" }} w="100%">
{isContracts ? (
<DataTable
columns={contractColumns}
data={contractRows}
status={tableStatus}
onRowClick={(row) =>
navigate(`/dashboard/contracts/clearance/${row.id}`)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
) : (
<DataTable
columns={bookingColumns}
data={bookingRows}
status={tableStatus}
onRowClick={(row) =>
navigate(`/dashboard/clearance/${row.id}`)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
)}
</Box>
)}
</Stack>
</Card>
</Stack>
</PageContainer>
);
}

View File

@@ -461,6 +461,8 @@ export const contractsService = {
page?: number;
pageSize?: number;
search?: string;
/** Comma-separated ops-clearance lifecycle statuses; omitted → under-review queue. */
statuses?: string;
}): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(
C.OPS_CLEARANCE_QUEUE,

View File

@@ -292,6 +292,19 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
<Text fz={14} fw={700} style={{ color: INK }} truncate>
{w.destination ?? "—"}
</Text>
{w.reference && (
<Text
fz={11}
fw={600}
style={{
color: MUTED,
flexShrink: 0,
fontVariantNumeric: "tabular-nums",
}}
>
{w.reference}
</Text>
)}
</Group>
<Group gap={5} wrap="nowrap" mt={2}>
<CalendarClock size={12} color={MUTED} style={{ flexShrink: 0 }} />

View File

@@ -172,7 +172,7 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
tile: "edr-amber-soft",
hint: "Clearance documents needed",
step: "edr-accent",
badgeLabel: "Docs needed",
badgeLabel: "Upload documents",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",

View File

@@ -18,9 +18,12 @@ export function BookingClearanceWorkflowBanner({
}: {
booking: Freight.IBooking;
}) {
// Drawdown bookings under a GENERAL contract keep bookingType = ONE_TIME —
// the denormalized contractKind is what marks them as phased (Path B).
const isPhased =
booking.customsClearingEnabled &&
booking.bookingType === "GENERAL_CONTRACT";
(booking.bookingType === "GENERAL_CONTRACT" ||
booking.contractKind === "GENERAL");
const { view, viewer } = useFileViewer();

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 { CreditCard } from "lucide-react";
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -14,6 +14,7 @@ import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
import { ActivityCard } from "./components/ActivityCard";
import { ClearanceCard } from "./components/ClearanceCard";
import { DocumentsTab } from "./components/DocumentsTab";
import { ContainersCard } from "./components/ContainersCard";
import { ContractCard } from "./components/ContractCard";
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
@@ -201,50 +202,76 @@ export function ReadonlyBookingView({
<KeyFactsStrip booking={booking} />
<ContractCard booking={booking} />
<Tabs
defaultValue="overview"
keepMounted={false}
styles={{
list: { gap: 6, borderBottom: "1px solid #E6ECF2" },
tab: { borderRadius: "10px 10px 0 0", fontWeight: 700, padding: "10px 16px" },
}}
>
<Tabs.List mb="lg">
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}>
Overview
</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<FileText size={15} />}>
Documents
</Tabs.Tab>
</Tabs.List>
{isClearance && <ClearanceCard booking={booking} />}
<Tabs.Panel value="overview">
<div className="flex flex-col gap-6">
<ContractCard booking={booking} />
<BodyGrid
left={
<>
<ShipmentDetailsCard booking={booking} />
{isClearance && <ClearanceCard booking={booking} />}
<ContainersCard booking={booking} />
<BodyGrid
left={
<>
<ShipmentDetailsCard booking={booking} />
<ShipmentTrackingCard bookingId={booking.id} />
<ContainersCard booking={booking} />
{canAssignCustomerTruck && (
<CustomerTruckAssignmentCard
booking={booking}
onAssigned={onBookingUpdated ?? (() => {})}
/>
)}
<WarehousePaymentsSection bookingId={booking.id} />
<ShipmentTrackingCard bookingId={booking.id} />
<ActivityCard booking={booking} />
{canAssignCustomerTruck && (
<CustomerTruckAssignmentCard
booking={booking}
onAssigned={onBookingUpdated ?? (() => {})}
/>
)}
<WarehousePaymentsSection bookingId={booking.id} />
<MileSummaryCard booking={booking} />
</>
}
right={
<>
<BookingPaymentPanel
booking={booking}
pricing={pricing}
onPay={() => setPayModalOpen(true)}
paying={payMutation.isPending}
showCountdown={showCountdown}
<ActivityCard booking={booking} />
<MileSummaryCard booking={booking} />
</>
}
right={
<>
<BookingPaymentPanel
booking={booking}
pricing={pricing}
onPay={() => setPayModalOpen(true)}
paying={payMutation.isPending}
showCountdown={showCountdown}
/>
<ScheduleCard
booking={booking}
title="Consignment & Schedule"
consignment
/>
<SupportCard />
</>
}
/>
<ScheduleCard
booking={booking}
title="Consignment & Schedule"
consignment
/>
<SupportCard />
</>
}
/>
</div>
</Tabs.Panel>
<Tabs.Panel value="documents">
<DocumentsTab booking={booking} />
</Tabs.Panel>
</Tabs>
<PaymentMethodModal
opened={payModalOpen}

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 }) {
const isContract = booking.bookingType === "GENERAL_CONTRACT";
// A drawdown under a GENERAL contract stays bookingType = ONE_TIME; its
// "General" nature lives in the denormalized contractKind.
const isGeneralDrawdown = !isContract && booking.contractKind === "GENERAL";
const freight = booking.freightType === "BULK" ? "Bulk" : "Container";
const payment = paymentStatusLabel(booking.paymentStatus);
return (
<SectionCard p="lg">
<SimpleGrid cols={{ base: 2, md: 3, xl: 6 }} spacing={0} verticalSpacing="lg">
<Fact label="Type" value={isContract ? "General Contract" : "One-Time"} />
<Fact
label="Type"
value={
isContract
? "General Contract"
: isGeneralDrawdown
? "General"
: "One-Time"
}
/>
<Fact label="Cargo" value={freight} />
<Fact
label="Route"

View File

@@ -104,7 +104,7 @@ export function StatusHero({
}
: isInitiatedInstance
? {
title: "Booking initiated — clearance documents needed",
title: "Upload clearance documents",
description:
"Upload the required clearance documents to start the review. Once the review is finalized you can book your shipment.",
stage,
@@ -194,7 +194,11 @@ function ProgressTracker({
} as React.CSSProperties
}
>
<div className="flex items-start" style={{ minWidth: 640 }}>
{/* ~84px per stage keeps 2-word labels readable; the box scrolls on mobile. */}
<div
className="flex items-start"
style={{ minWidth: Math.max(640, stages.length * 84) }}
>
{stages.map((stage, idx) => {
const state =
idx < current ? "done" : idx === current ? "active" : "idle";

View File

@@ -1,6 +1,9 @@
import {
BadgeCheck,
ClipboardCheck,
FileSearch,
FileText,
FileUp,
MapPin,
PackageCheck,
PackageOpen,
@@ -105,18 +108,25 @@ export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex(
*/
export const CONTRACT_PROGRESS_STAGES = [
{
// The instance was initiated with one click and is going through per-booking
// clearance (upload → review → ready). One-time drawdowns without clearance
// start here too until they are booked.
label: "Initiated",
icon: FileText,
statuses: [
"DRAFT",
"CHANGES_REQUESTED",
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY",
],
// The instance was initiated with one click and now sits in the clearance
// gate: the customer must upload the required clearance documents.
label: "Upload Documents",
icon: FileUp,
statuses: ["DRAFT", "CHANGES_REQUESTED", "AWAITING_DOCUMENTS"],
},
{
// Staff/GL are reviewing the uploaded clearance documents; the customer
// only re-uploads queried ones.
label: "Document Review",
icon: FileSearch,
statuses: ["DOCUMENTS_UNDER_REVIEW"],
},
{
// Clearance finished — the customer books (Path A) or GL completes the
// booking on their behalf (Path B customs).
label: "Cleared",
icon: BadgeCheck,
statuses: ["CLEARANCE_READY"],
},
{
// The customer (or GL) completed the booking — cargo + shipment day — and it
@@ -360,13 +370,13 @@ export const STATUS_MAP: Record<
stage: 5,
},
AWAITING_DOCUMENTS: {
title: "Clearance documents needed",
title: "Upload clearance documents",
description:
"Upload the required clearance documents so your shipment can be reviewed.",
stage: 1,
},
DOCUMENTS_UNDER_REVIEW: {
title: "Documents under review",
title: "Clearance documents under review",
description:
"Your clearance documents are being reviewed. Re-upload any queried documents to proceed.",
stage: 2,

View File

@@ -34,17 +34,20 @@ type BookingLike = Freight.IBooking & {
paymentStatus?: string;
};
/** One-Time vs General Contract. */
/** One-Time vs General Contract (or a drawdown under a GENERAL contract). */
export function BookingTypeBadge({ booking }: { booking: BookingLike }) {
const isContract = booking.bookingType === "GENERAL_CONTRACT";
// Drawdown bookings keep bookingType = ONE_TIME; the parent contract's kind
// (denormalized onto the booking) is what makes them "General".
const isGeneralDrawdown = !isContract && booking.contractKind === "GENERAL";
return (
<Badge
variant="light"
radius="sm"
color={isContract ? "violet" : "gray"}
color={isContract || isGeneralDrawdown ? "violet" : "gray"}
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
>
{isContract ? "General Contract" : "One-Time"}
{isContract ? "General Contract" : isGeneralDrawdown ? "General" : "One-Time"}
</Badge>
);
}

View File

@@ -73,6 +73,8 @@ export interface PriceLineItem {
*/
export interface MyBookingWindow {
scheduleId: string;
/** Train schedule reference (e.g. TS-2026-000123), shown on the window card. */
reference: string | null;
/**
* The customer's active contract on this lane, when they hold one — enables
* "Book now" to target it. Null for lanes they have no contract on.

View File

@@ -497,6 +497,12 @@ export interface IBooking extends BaseEntity {
trainScheduleStatus?: TrainScheduleStatus | string | null;
/** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */
bookingType?: BookingType;
/**
* Denormalized kind of the parent contract (ONE_TIME | GENERAL). Drawdown
* bookings under a GENERAL contract keep bookingType = ONE_TIME, so this is
* the field UIs must read to label a booking "General".
*/
contractKind?: `${import("./contracts").ContractKind}` | null;
/** General contracts only: when ordering closes (null until active / for one-time). */
expiresAt?: string | null;
/** Null for general contracts at creation — the date is chosen per order. */