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

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