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