mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
1952 lines
65 KiB
TypeScript
1952 lines
65 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Navigate,
|
|
useNavigate,
|
|
useParams,
|
|
useSearchParams,
|
|
} from "react-router-dom";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Center,
|
|
FileInput,
|
|
Group,
|
|
Loader,
|
|
Paper,
|
|
Progress,
|
|
RingProgress,
|
|
SimpleGrid,
|
|
Stack,
|
|
Tabs,
|
|
Text,
|
|
Title,
|
|
} from "@mantine/core";
|
|
import {
|
|
AlertTriangle,
|
|
ArrowLeft,
|
|
CalendarClock,
|
|
CheckCircle2,
|
|
ChevronRight,
|
|
Download,
|
|
Eye,
|
|
FileBadge,
|
|
FileSignature,
|
|
FileText,
|
|
Flame,
|
|
Inbox,
|
|
Layers,
|
|
type LucideIcon,
|
|
MapPin,
|
|
Package,
|
|
PackagePlus,
|
|
Ship,
|
|
Snowflake,
|
|
Upload,
|
|
Weight,
|
|
} from "lucide-react";
|
|
import { Modal } from "@mantine/core";
|
|
import { useDisclosure } from "@mantine/hooks";
|
|
import { isViewable, type ViewableFile } from "@edr/ui-common";
|
|
import type { Freight } from "@edr/types";
|
|
import { clearanceWorkflowFileLabel } from "@edr/types";
|
|
import { api } from "@/services/api";
|
|
import { contractsService } from "@/services/contracts.service";
|
|
import { fileViewUrl } from "@/constants/apiConfig";
|
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
|
import toast from "react-hot-toast";
|
|
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
|
import { ContractClearancePanel } from "./ContractClearancePanel";
|
|
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
|
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
|
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
|
import { getContractBookingAction } from "./contract-booking-action";
|
|
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
|
import {
|
|
BORDER,
|
|
ContractStatusBadge,
|
|
GREEN,
|
|
INK,
|
|
MetaItem,
|
|
MUTED,
|
|
} from "./contract-ui";
|
|
|
|
// Statuses where the customer uploads clearance documents on the contract. Used
|
|
// by both paths: Path B (customs, GL-reviewed) and Path A self-clearance
|
|
// (non-customs IMPORT/EXPORT, Operations-reviewed).
|
|
const CLEARANCE_UPLOAD_STATUSES = [
|
|
"AWAITING_CLEARANCE_DOCUMENTS",
|
|
"CLEARANCE_UNDER_REVIEW",
|
|
"CLEARANCE_READY_FOR_BOOKING",
|
|
];
|
|
|
|
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
|
|
|
|
// Business-license document codes — surfaced as their own section so they stand
|
|
// out from the rest of the onboarding/profile set.
|
|
const BUSINESS_LICENSE_DOC_CODES = new Set([
|
|
"business_license",
|
|
"commercial_license",
|
|
"investment_license",
|
|
]);
|
|
|
|
// Onboarding / company-profile document codes seeded in file-upload-settings.
|
|
// These get attached to the contract at creation and belong under "Profile
|
|
// documents" rather than the clearance set.
|
|
const PROFILE_DOC_CODES = new Set([
|
|
"tin_certificate",
|
|
"national_id",
|
|
"national_id_passport",
|
|
"passport",
|
|
]);
|
|
|
|
interface DocGroup {
|
|
key: string;
|
|
title: string;
|
|
files: ContractFile[];
|
|
}
|
|
|
|
/**
|
|
* Split a contract's files into display sections by their `code`: the generated
|
|
* contract PDF, company profile / onboarding documents, and clearance documents
|
|
* (everything else — the clearance set uses dynamic per-contract codes). Empty
|
|
* groups are dropped so the tab only renders sections that have files.
|
|
*/
|
|
function groupContractDocuments(files: ContractFile[]): DocGroup[] {
|
|
const businessLicense: ContractFile[] = [];
|
|
const profile: ContractFile[] = [];
|
|
const clearance: ContractFile[] = [];
|
|
for (const f of files) {
|
|
// The generated contract PDF lives in the contract list / home rows, not
|
|
// here. Signature images are baked into that PDF — skip both.
|
|
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
|
|
else if (BUSINESS_LICENSE_DOC_CODES.has(f.code)) businessLicense.push(f);
|
|
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
|
|
else clearance.push(f);
|
|
}
|
|
return [
|
|
{ key: "clearance", title: "Clearance documents", files: clearance },
|
|
{ key: "businessLicense", title: "Business license", files: businessLicense },
|
|
{ key: "profile", title: "Profile documents", files: profile },
|
|
].filter((g) => g.files.length > 0);
|
|
}
|
|
|
|
export default function ContractDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [tab, setTab] = useState<string>("details");
|
|
const { view, viewer } = useFileViewer();
|
|
const [clearanceOpen, clearanceModal] = useDisclosure(false);
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
// Deep link from the home "needs attention" card: /contracts/:id?action=clearance
|
|
// opens the clearance step modal directly.
|
|
useEffect(() => {
|
|
if (searchParams.get("action") === "clearance") {
|
|
clearanceModal.open();
|
|
searchParams.delete("action");
|
|
setSearchParams(searchParams, { replace: true });
|
|
}
|
|
}, [searchParams, clearanceModal, setSearchParams]);
|
|
|
|
const {
|
|
data: contract,
|
|
isLoading,
|
|
isError,
|
|
} = useQuery(
|
|
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
|
|
);
|
|
|
|
// Shipments booked under this contract.
|
|
const { data: bookingsPage } = useQuery({
|
|
...api.bookings.list.queryOptions({
|
|
input: { page: 1, pageSize: 50, sortBy: "createdAt", sortOrder: "DESC" },
|
|
}),
|
|
enabled: !!id,
|
|
});
|
|
|
|
// Clearance view — drives queries alert, workflow documents, and duty panels.
|
|
const inClearance =
|
|
!!contract && CLEARANCE_UPLOAD_STATUSES.includes(contract.status);
|
|
const isPhasedCustomsClearance =
|
|
!!contract &&
|
|
contract.customsClearingEnabled &&
|
|
contract.contractKind === "ONE_TIME";
|
|
const { data: clearanceView, refetch: refetchClearance } = useQuery({
|
|
...api.contracts.getClearance.queryOptions({ input: { id: id! } }),
|
|
enabled: !!id && (inClearance || isPhasedCustomsClearance),
|
|
});
|
|
const workflowFiles = clearanceView?.workflowFiles ?? [];
|
|
const workflowFileCount = workflowFiles.filter((f) => f.file).length;
|
|
const queriedCount = (clearanceView?.documents ?? []).filter(
|
|
(d) => d.uploadedBy === "customer" && d.reviewStatus === "QUERIED",
|
|
).length;
|
|
|
|
const showShipmentRequests =
|
|
!!contract &&
|
|
contract.contractKind === "GENERAL" &&
|
|
contract.customsClearingEnabled;
|
|
const { data: shipmentRequests = [] } = useQuery({
|
|
queryKey: ["contract-booking-requests", id],
|
|
queryFn: () => contractsService.listBookingRequests(id!),
|
|
enabled: !!id && showShipmentRequests,
|
|
});
|
|
const activeShipmentRequests = shipmentRequests.filter(
|
|
(r) => r.status === "PENDING" || r.status === "ACCEPTED",
|
|
);
|
|
|
|
// Booking windows for this contract's routes — gates the direct "New shipment
|
|
// booking" entry so the customer only sees it while a window is open.
|
|
// Refetched every minute so "Open now" flips without a manual reload.
|
|
const { data: bookingWindows = [] } = useQuery({
|
|
...api.bookings.getContractBookingWindows.queryOptions({
|
|
input: { contractId: id! },
|
|
refetchInterval: 60_000,
|
|
}),
|
|
enabled: !!id,
|
|
});
|
|
// Intercity contracts are never window-gated: the shipment rides a passing
|
|
// import/export train that staff assign later, so booking is always open.
|
|
const bookingWindowOpen =
|
|
contract?.tradeDirection === "DOMESTIC" || hasOpenWindow(bookingWindows);
|
|
|
|
// Draw-down capacity per cargo line (GENERAL contracts only). The backend
|
|
// excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships
|
|
// releases its share and the tracker fills back up. Refetched on window focus so
|
|
// it reflects newly created / cancelled shipments.
|
|
const { data: capacityLines = [] } = useQuery({
|
|
queryKey: ["contract-capacity", id],
|
|
queryFn: () => contractsService.getCapacity(id!),
|
|
enabled: !!id && contract?.contractKind === "GENERAL",
|
|
refetchOnWindowFocus: true,
|
|
});
|
|
|
|
const contractBookings = useMemo(
|
|
() =>
|
|
(bookingsPage?.items ?? []).filter(
|
|
(b) => (b as { contractId?: string }).contractId === id,
|
|
),
|
|
[bookingsPage, id],
|
|
);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Center mih={400} p="xl">
|
|
<Stack align="center" gap="md">
|
|
<Loader color="edr-green" />
|
|
<Text size="sm" c="dimmed">
|
|
Loading contract…
|
|
</Text>
|
|
</Stack>
|
|
</Center>
|
|
);
|
|
}
|
|
|
|
if (isError || !contract) {
|
|
return (
|
|
<Box p="xl">
|
|
<Paper withBorder radius="lg" p="xl" style={{ borderColor: BORDER }}>
|
|
<Text fw={700} mb="xs">
|
|
Contract not found
|
|
</Text>
|
|
<Button variant="default" onClick={() => navigate("/contracts")}>
|
|
Back to contracts
|
|
</Button>
|
|
</Paper>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Not yet submitted (customer saved a draft) or staff returned the contract for
|
|
// changes — send the customer to the full edit wizard (edit any term + replace
|
|
// documents → submit) rather than the read-only detail.
|
|
if (
|
|
contract.status === "DRAFT" ||
|
|
contract.status === "RENEWAL_DRAFT" ||
|
|
contract.status === "CHANGES_REQUESTED"
|
|
) {
|
|
return <Navigate to={`/contracts/${contract.id}/edit`} replace />;
|
|
}
|
|
|
|
const isContainer = contract.freightType === "CONTAINER";
|
|
const isGeneral = contract.contractKind === "GENERAL";
|
|
const routes = contract.routes ?? [];
|
|
const pricing = contract.pricingBreakdown;
|
|
const files = contract.files ?? [];
|
|
const docGroups = groupContractDocuments(files);
|
|
// The generated contract PDF — surfaced via a dedicated "View contract" button
|
|
// in the header (it's excluded from the Documents tab groups).
|
|
const contractPdf = files.find((f) => f.code === "contract");
|
|
const hasContractDocument = Boolean(contractPdf || contract.contractGeneratedAt);
|
|
|
|
const downloadContractPdf = async () => {
|
|
if (!contract?.id) return;
|
|
try {
|
|
const blob = await contractsService.downloadContractDocument(contract.id);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = contractPdf?.name ?? `contract-${contract.reference}.pdf`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch {
|
|
toast.error("Could not download contract PDF.");
|
|
}
|
|
};
|
|
|
|
const canSign = contract.status === "CONTRACT_READY";
|
|
const customsPath = contract.customsClearingEnabled;
|
|
// Only the NON-customs (Path A) customer books himself — once the contract is
|
|
// executed after self-clearance. Customs (Path B) bookings are created by
|
|
// Global Logistics on the customer's behalf, so the customer gets no booking
|
|
// button on a customs contract.
|
|
const clearanceFinalized =
|
|
contract.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
|
|
contract.status === "CLEARANCE_READY_FOR_BOOKING";
|
|
const bookingAction = getContractBookingAction(contract, contractBookings);
|
|
// Whether the customer may open a new self-service booking. Derived from the
|
|
// shared booking-action helper so it honours the ONE_TIME single-slot rule:
|
|
// once a non-terminal booking exists on a ONE_TIME contract there is no free
|
|
// slot, so the action is "none" and no booking button is shown.
|
|
const canBookShipment =
|
|
bookingAction.kind === "book" || bookingAction.kind === "rebook";
|
|
const canRequestShipment = bookingAction.kind === "request";
|
|
// Customs + clearance finalized: GL is preparing the booking — surface a
|
|
// status notice instead of any action.
|
|
const glPreparingBooking = customsPath && clearanceFinalized;
|
|
// The customer uploads clearance documents while in a clearance status, until
|
|
// clearance is finalized.
|
|
const canUploadClearance =
|
|
CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized;
|
|
|
|
return (
|
|
<Box style={{ padding: "28px 32px 40px" }}>
|
|
<Stack gap="lg">
|
|
{/* Header */}
|
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
|
<Group gap="md" align="center" wrap="nowrap">
|
|
<Button
|
|
variant="subtle"
|
|
color="gray"
|
|
radius="md"
|
|
px={8}
|
|
onClick={() => navigate("/contracts")}
|
|
>
|
|
<ArrowLeft size={18} />
|
|
</Button>
|
|
<div>
|
|
<Group gap={10} align="center">
|
|
<Title order={2} fw={800} fz={22} style={{ color: INK }}>
|
|
{contract.reference}
|
|
</Title>
|
|
<ContractStatusBadge status={contract.status} />
|
|
</Group>
|
|
<Text size="sm" c="dimmed" mt={2}>
|
|
{isGeneral ? "General contract" : "One-time contract"} ·{" "}
|
|
{isContainer ? "Containerised" : "Bulk"} ·{" "}
|
|
{contract.tradeDirection ?? "—"}
|
|
</Text>
|
|
</div>
|
|
</Group>
|
|
|
|
<Group gap="sm">
|
|
{canSign ? (
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
size="md"
|
|
leftSection={<FileSignature size={16} />}
|
|
onClick={() => navigate(`/contracts/${contract.id}/view`)}
|
|
>
|
|
View & sign contract
|
|
</Button>
|
|
) : (
|
|
contractPdf && (
|
|
<Button
|
|
variant="default"
|
|
radius="md"
|
|
size="md"
|
|
leftSection={<FileText size={16} />}
|
|
onClick={() =>
|
|
view({
|
|
name: contractPdf.name,
|
|
url: fileViewUrl(contractPdf.id),
|
|
mimeType: contractPdf.mimeType,
|
|
})
|
|
}
|
|
>
|
|
View contract
|
|
</Button>
|
|
)
|
|
)}
|
|
{hasContractDocument && (
|
|
<Button
|
|
variant="default"
|
|
radius="md"
|
|
size="md"
|
|
leftSection={<Download size={16} />}
|
|
onClick={() => void downloadContractPdf()}
|
|
>
|
|
Download PDF
|
|
</Button>
|
|
)}
|
|
{canRequestShipment && (
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
size="md"
|
|
leftSection={<PackagePlus size={16} />}
|
|
onClick={() => navigate(bookingAction.to)}
|
|
>
|
|
Request shipment
|
|
</Button>
|
|
)}
|
|
{canBookShipment &&
|
|
(bookingWindowOpen ? (
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
size="md"
|
|
leftSection={<PackagePlus size={16} />}
|
|
onClick={() =>
|
|
navigate(`/contracts/${contract.id}/bookings/new`)
|
|
}
|
|
>
|
|
New shipment booking
|
|
</Button>
|
|
) : (
|
|
<Paper
|
|
withBorder
|
|
radius="md"
|
|
px="md"
|
|
py={10}
|
|
maw={420}
|
|
style={{ borderColor: BORDER, background: "#F8FAFC" }}
|
|
>
|
|
<Group gap={10} align="flex-start" wrap="nowrap">
|
|
<CalendarClock
|
|
size={16}
|
|
color={MUTED}
|
|
style={{ flexShrink: 0, marginTop: 2 }}
|
|
/>
|
|
<Text fz={13} c="dimmed">
|
|
{closedWindowMessage(bookingWindows)}
|
|
</Text>
|
|
</Group>
|
|
</Paper>
|
|
))}
|
|
{glPreparingBooking && (
|
|
<Badge
|
|
size="lg"
|
|
radius="md"
|
|
variant="light"
|
|
color="teal"
|
|
leftSection={<CheckCircle2 size={14} />}
|
|
>
|
|
Global Logistics is creating your booking
|
|
</Badge>
|
|
)}
|
|
{canUploadClearance && (
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
size="md"
|
|
leftSection={<Upload size={16} />}
|
|
onClick={clearanceModal.open}
|
|
>
|
|
{contract.status === "CLEARANCE_UNDER_REVIEW"
|
|
? "Manage clearance documents"
|
|
: "Upload clearance documents"}
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
</Group>
|
|
|
|
{/* Key facts — one premium gradient card */}
|
|
<Paper
|
|
radius={18}
|
|
withBorder
|
|
style={{
|
|
borderColor: "#DCEBE3",
|
|
overflow: "hidden",
|
|
background:
|
|
"linear-gradient(120deg, #F1FAF5 0%, #F7FCF9 34%, #FFFFFF 78%)",
|
|
boxShadow: "0 8px 24px rgba(14,163,113,0.08)",
|
|
}}
|
|
>
|
|
<SimpleGrid
|
|
cols={{ base: 2, sm: 3, xl: 5 }}
|
|
spacing={0}
|
|
verticalSpacing={0}
|
|
>
|
|
<FactCell
|
|
icon={Layers}
|
|
label="Kind"
|
|
value={isGeneral ? "General" : "One-Time"}
|
|
/>
|
|
<FactCell
|
|
icon={Package}
|
|
label="Cargo"
|
|
value={isContainer ? "Container" : "Bulk"}
|
|
color="violet"
|
|
/>
|
|
<FactCell
|
|
icon={MapPin}
|
|
label="Routes"
|
|
value={String(routes.length || 1)}
|
|
/>
|
|
<FactCell
|
|
icon={Ship}
|
|
label="Trade"
|
|
value={contract.tradeDirection ?? "—"}
|
|
color="orange"
|
|
/>
|
|
<FactCell
|
|
icon={CalendarClock}
|
|
label="Valid until"
|
|
value={
|
|
contract.contractValidUntil
|
|
? new Date(contract.contractValidUntil).toLocaleDateString()
|
|
: "Not active yet"
|
|
}
|
|
/>
|
|
</SimpleGrid>
|
|
</Paper>
|
|
|
|
{/* Tabs: Details · Documents · Bookings (pill style, like the
|
|
backoffice booking-requests page; each tab shows a count badge). */}
|
|
<Tabs
|
|
value={tab}
|
|
onChange={(v) => setTab(v ?? "details")}
|
|
variant="pills"
|
|
color="edr-green"
|
|
keepMounted={false}
|
|
>
|
|
<Tabs.List
|
|
mb="lg"
|
|
style={{
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: 8,
|
|
padding: 6,
|
|
background: "#F1F5F9",
|
|
borderRadius: 16,
|
|
border: "1px solid #E2E8F0",
|
|
width: "fit-content",
|
|
}}
|
|
>
|
|
<DetailTab
|
|
value="details"
|
|
active={tab === "details"}
|
|
icon={<FileText size={16} />}
|
|
label="Details"
|
|
/>
|
|
<DetailTab
|
|
value="documents"
|
|
active={tab === "documents"}
|
|
icon={<Download size={16} />}
|
|
label="Documents"
|
|
count={files.length + (isPhasedCustomsClearance ? workflowFileCount : 0)}
|
|
/>
|
|
<DetailTab
|
|
value="bookings"
|
|
active={tab === "bookings"}
|
|
icon={<Package size={16} />}
|
|
label="Bookings"
|
|
count={contractBookings.length}
|
|
/>
|
|
</Tabs.List>
|
|
|
|
{/* ── Details tab ───────────────────────────────────────────── */}
|
|
<Tabs.Panel value="details">
|
|
<Stack gap="lg">
|
|
{/* Summary meta */}
|
|
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
|
<Group gap={48} wrap="wrap">
|
|
<MetaItem
|
|
label="Primary route"
|
|
icon={<MapPin size={15} color={MUTED} />}
|
|
value={`${routes[0]?.originYard?.label ?? "—"} → ${
|
|
routes[0]?.destinationYard?.label ?? "—"
|
|
}`}
|
|
/>
|
|
<MetaItem
|
|
label="Customs clearance"
|
|
icon={<FileText size={15} color={MUTED} />}
|
|
value={customsPath ? "Included (Global Logistics)" : "Not included"}
|
|
/>
|
|
<MetaItem
|
|
label="Payment currency"
|
|
value={contract.paymentCurrency ?? "—"}
|
|
/>
|
|
</Group>
|
|
</Paper>
|
|
|
|
|
|
{/* Clearance notice (both paths) */}
|
|
{queriedCount > 0 && (
|
|
<Paper
|
|
withBorder
|
|
radius="lg"
|
|
p="lg"
|
|
style={{
|
|
borderColor: "#F0B4B4",
|
|
background: "#FDF4F4",
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<Box
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: 3,
|
|
background: "#D64545",
|
|
}}
|
|
/>
|
|
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
|
<Group gap={10} align="flex-start" wrap="nowrap">
|
|
<AlertTriangle size={18} color="#D64545" style={{ marginTop: 2 }} />
|
|
<div>
|
|
<Text fw={700} fz={15} c="#7A1F1F">
|
|
{queriedCount} document{queriedCount > 1 ? "s" : ""} need
|
|
correction
|
|
</Text>
|
|
<Text fz={13} c="#9A4A4A" mt={2}>
|
|
A reviewer sent back document
|
|
{queriedCount > 1 ? "s" : ""} with a query. Re-upload the
|
|
corrected file{queriedCount > 1 ? "s" : ""} to continue.
|
|
</Text>
|
|
</div>
|
|
</Group>
|
|
<Button
|
|
color="red"
|
|
radius="md"
|
|
size="sm"
|
|
leftSection={<Upload size={15} />}
|
|
onClick={clearanceModal.open}
|
|
>
|
|
Upload corrected documents
|
|
</Button>
|
|
</Group>
|
|
</Paper>
|
|
)}
|
|
|
|
{customsPath && contract.contractKind === "ONE_TIME" ? (
|
|
<ContractClearanceWorkflowBanner contract={contract} />
|
|
) : null}
|
|
|
|
{clearanceView?.riskLevel ? (
|
|
<Paper
|
|
withBorder
|
|
radius="lg"
|
|
p="md"
|
|
style={{ borderColor: BORDER, background: "#FBFDFC" }}
|
|
>
|
|
<Group gap={10} align="center">
|
|
<Text fw={700} fz={14} c={INK}>
|
|
Customs risk level
|
|
</Text>
|
|
<Badge
|
|
color={CUSTOMS_RISK_COLOR[clearanceView.riskLevel] ?? "gray"}
|
|
variant="filled"
|
|
radius="sm"
|
|
>
|
|
{clearanceView.riskLevel}
|
|
</Badge>
|
|
{clearanceView.riskAssignedAt ? (
|
|
<Text fz={12} c="dimmed">
|
|
assigned {new Date(clearanceView.riskAssignedAt).toLocaleString()}
|
|
</Text>
|
|
) : null}
|
|
</Group>
|
|
</Paper>
|
|
) : null}
|
|
|
|
{clearanceView?.secondDuty?.advised && clearanceView?.linkedBookingId ? (
|
|
<SecondDutyDueCard
|
|
duty={clearanceView.secondDuty}
|
|
bookingId={clearanceView.linkedBookingId}
|
|
onView={view}
|
|
onChanged={() => void refetchClearance()}
|
|
/>
|
|
) : null}
|
|
|
|
{clearanceView?.finalInvoice && clearanceView?.linkedBookingId ? (
|
|
<FinalInvoiceDueCard
|
|
invoice={clearanceView.finalInvoice}
|
|
bookingId={clearanceView.linkedBookingId}
|
|
onView={view}
|
|
onChanged={() => void refetchClearance()}
|
|
/>
|
|
) : null}
|
|
|
|
{canUploadClearance && (
|
|
<Paper
|
|
withBorder
|
|
radius="lg"
|
|
p="lg"
|
|
style={{
|
|
borderColor: "#CDEBDD",
|
|
background: "#F6FBF8",
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<Box
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: 3,
|
|
background: GREEN,
|
|
}}
|
|
/>
|
|
<Group gap={10} align="center" mb={6}>
|
|
<Upload size={16} color={GREEN} />
|
|
<Text fw={700} fz={15} c={INK}>
|
|
{customsPath
|
|
? "Customs clearance shipment"
|
|
: "Customs clearance required"}
|
|
</Text>
|
|
</Group>
|
|
<Text fz={13} c="dimmed">
|
|
{customsPath
|
|
? contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
|
? "Upload your clearance documents so Global Logistics can review them. Once they finalize the clearance you can create your shipment booking."
|
|
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
|
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed."
|
|
: "Your documents are cleared. You can now create a shipment booking under this contract."
|
|
: contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
|
? "This service does not include EDR customs clearance. Clear the cargo yourself and upload your clearance documents so the Operations team can review them before you book a shipment."
|
|
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
|
? "The Operations team is reviewing your clearance documents. Re-upload any queried documents to proceed."
|
|
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
|
|
</Text>
|
|
{contract.status !== "CLEARANCE_READY_FOR_BOOKING" && (
|
|
<Button
|
|
mt="md"
|
|
color="edr-green"
|
|
radius="md"
|
|
size="sm"
|
|
leftSection={<Upload size={15} />}
|
|
onClick={clearanceModal.open}
|
|
>
|
|
{contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
|
? "Upload documents"
|
|
: "Manage documents"}
|
|
</Button>
|
|
)}
|
|
</Paper>
|
|
)}
|
|
|
|
{/* Unit-rate schedule */}
|
|
<Card
|
|
withBorder
|
|
radius="lg"
|
|
p="lg"
|
|
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
|
|
>
|
|
<Group justify="space-between" align="center" mb={4}>
|
|
<SectionLabel>Pricing schedule</SectionLabel>
|
|
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
|
Unit rates
|
|
</Badge>
|
|
</Group>
|
|
<Text fz={13} c="dimmed" mb="md">
|
|
Per-unit rates frozen at submission. The final amount on each shipment
|
|
is computed from the quantities you ship.
|
|
</Text>
|
|
{pricing && pricing.lineItems.length > 0 ? (
|
|
<Stack gap={10}>
|
|
{pricing.lineItems.map((item) => (
|
|
<Group
|
|
key={item.code}
|
|
justify="space-between"
|
|
wrap="nowrap"
|
|
p="sm"
|
|
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
|
|
>
|
|
<Box style={{ minWidth: 0 }}>
|
|
<Text fz={14} fw={600} style={{ color: INK }} truncate>
|
|
{item.label}
|
|
</Text>
|
|
{item.containerSize && (
|
|
<Text fz={12} c="dimmed">
|
|
{item.containerSize}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
<Text fz={14} fw={700} style={{ color: GREEN }}>
|
|
{(item.unitPrice ?? 0).toLocaleString()} {pricing.currency}{" "}
|
|
<Text span fz={12} fw={600} c="dimmed">
|
|
/ {formatRateUnit(item.unit)}
|
|
</Text>
|
|
</Text>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
) : (
|
|
<Text fz={13} c="dimmed">
|
|
No pricing schedule available yet.
|
|
</Text>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Routes + cargo scope */}
|
|
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
|
|
<Card
|
|
withBorder
|
|
radius="lg"
|
|
p="lg"
|
|
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
|
|
>
|
|
<Group justify="space-between" align="center" mb="md">
|
|
<SectionLabel>Routes</SectionLabel>
|
|
<Badge size="sm" variant="light" color="violet" radius="sm">
|
|
{routes.length || 1}
|
|
</Badge>
|
|
</Group>
|
|
<Stack gap={10}>
|
|
{routes.length === 0 && (
|
|
<Text fz={13} c="dimmed">
|
|
No routes recorded.
|
|
</Text>
|
|
)}
|
|
{routes.map((route) => (
|
|
<Group
|
|
key={route.id}
|
|
gap={12}
|
|
wrap="nowrap"
|
|
p="sm"
|
|
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
|
|
>
|
|
<MapPin size={16} color={MUTED} style={{ flexShrink: 0 }} />
|
|
<Box style={{ minWidth: 0 }}>
|
|
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
|
{route.originYard?.label ?? route.originYardId} →{" "}
|
|
{route.destinationYard?.label ?? route.destinationYardId}
|
|
</Text>
|
|
{route.km != null && (
|
|
<Text fz={12} c="dimmed">
|
|
{route.km} km
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
</Card>
|
|
|
|
<Card
|
|
withBorder
|
|
radius="lg"
|
|
p="lg"
|
|
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
|
|
>
|
|
<SectionLabel mb="md">Cargo scope</SectionLabel>
|
|
<Stack gap={10}>
|
|
{(contract.cargoScope ?? []).map((scope) => (
|
|
<Group
|
|
key={scope.id}
|
|
gap={12}
|
|
wrap="nowrap"
|
|
p="sm"
|
|
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
|
|
>
|
|
{isContainer ? (
|
|
<Package size={16} color={MUTED} style={{ flexShrink: 0 }} />
|
|
) : (
|
|
<Weight size={16} color={MUTED} style={{ flexShrink: 0 }} />
|
|
)}
|
|
<Text fz={14} fw={600} style={{ color: INK }}>
|
|
{scope.containerSize ??
|
|
scope.cargoFreeText ??
|
|
"Bulk commodity"}
|
|
</Text>
|
|
</Group>
|
|
))}
|
|
{(contract.cargoScope ?? []).length === 0 && (
|
|
<Text fz={13} c="dimmed">
|
|
No cargo scope recorded.
|
|
</Text>
|
|
)}
|
|
<Group gap={10} mt={4}>
|
|
{contract.isHazardous && (
|
|
<Badge
|
|
leftSection={<Flame size={12} />}
|
|
variant="light"
|
|
color="red"
|
|
radius="sm"
|
|
>
|
|
Hazardous
|
|
</Badge>
|
|
)}
|
|
{contract.isReefer && (
|
|
<Badge
|
|
leftSection={<Snowflake size={12} />}
|
|
variant="light"
|
|
color="blue"
|
|
radius="sm"
|
|
>
|
|
Refrigerated
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
</Stack>
|
|
</Card>
|
|
</SimpleGrid>
|
|
|
|
{/* Draw-down capacity — GENERAL contracts with a per-line quantity cap.
|
|
Fills as shipments consume capacity; empties again when a shipment is
|
|
cancelled/rejected/expired (backend releases it). */}
|
|
{isGeneral && capacityLines.length > 0 && (
|
|
<Card
|
|
withBorder
|
|
radius="lg"
|
|
p="lg"
|
|
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
|
|
>
|
|
<SectionLabel mb="md">Contract capacity</SectionLabel>
|
|
<Stack gap="lg">
|
|
{capacityLines.map((line, i) => {
|
|
const cap = line.cap ?? 0;
|
|
const booked = line.booked ?? 0;
|
|
const remaining = line.remaining ?? Math.max(0, cap - booked);
|
|
const usedPct = cap > 0 ? Math.min(100, (booked / cap) * 100) : 0;
|
|
const remainingPct = cap > 0 ? Math.round((remaining / cap) * 100) : 0;
|
|
const unit = capacityUnitLabel(contract, line);
|
|
const label = isContainer
|
|
? `${line.containerSize ?? "Containers"}`
|
|
: (contract.cargoScope ?? []).find(
|
|
(s) => s.cargoTypeId === line.cargoTypeId,
|
|
)?.cargoType?.cargoTypeName ??
|
|
(contract.cargoScope ?? [])[0]?.cargoFreeText ??
|
|
"Bulk commodity";
|
|
return (
|
|
<Group
|
|
key={line.containerSize ?? line.cargoTypeId ?? i}
|
|
align="center"
|
|
wrap="nowrap"
|
|
gap="lg"
|
|
>
|
|
<RingProgress
|
|
size={72}
|
|
thickness={8}
|
|
roundCaps
|
|
sections={[
|
|
{
|
|
value: remainingPct,
|
|
color: remaining === 0 ? "red" : GREEN,
|
|
},
|
|
]}
|
|
label={
|
|
<Text ta="center" fz={13} fw={700} style={{ color: INK }}>
|
|
{remainingPct}%
|
|
</Text>
|
|
}
|
|
/>
|
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
|
<Group justify="space-between" mb={6} wrap="nowrap">
|
|
<Group gap={8} wrap="nowrap">
|
|
{isContainer ? (
|
|
<Package size={16} color={MUTED} />
|
|
) : (
|
|
<Weight size={16} color={MUTED} />
|
|
)}
|
|
<Text fz={14} fw={600} style={{ color: INK }}>
|
|
{label}
|
|
</Text>
|
|
</Group>
|
|
<Text fz={13} c="dimmed">
|
|
{booked} / {cap} {unit} booked
|
|
</Text>
|
|
</Group>
|
|
<Progress
|
|
value={usedPct}
|
|
size="md"
|
|
radius="xl"
|
|
color={remaining === 0 ? "red" : GREEN}
|
|
/>
|
|
<Text fz={12} c="dimmed" mt={6}>
|
|
{remaining} {unit} remaining
|
|
</Text>
|
|
</Box>
|
|
</Group>
|
|
);
|
|
})}
|
|
</Stack>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Signatures */}
|
|
{(contract.signatures ?? []).length > 0 && (
|
|
<Card
|
|
withBorder
|
|
radius="lg"
|
|
p="lg"
|
|
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
|
|
>
|
|
<SectionLabel mb="md">Signatures</SectionLabel>
|
|
<Stack gap={10}>
|
|
{(contract.signatures ?? []).map((sig) => (
|
|
<Group
|
|
key={sig.id}
|
|
justify="space-between"
|
|
wrap="nowrap"
|
|
p="sm"
|
|
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
|
|
>
|
|
<Group gap={12} wrap="nowrap">
|
|
<CheckCircle2
|
|
size={16}
|
|
color={GREEN}
|
|
style={{ flexShrink: 0 }}
|
|
/>
|
|
<Box>
|
|
<Text fz={14} fw={600} style={{ color: INK }}>
|
|
{sig.signerDisplayName}
|
|
</Text>
|
|
<Text fz={12} c="dimmed">
|
|
{sig.role}
|
|
</Text>
|
|
</Box>
|
|
</Group>
|
|
<Text fz={12} c="dimmed">
|
|
{new Date(sig.signedAt).toLocaleDateString()}
|
|
</Text>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
</Card>
|
|
)}
|
|
</Stack>
|
|
</Tabs.Panel>
|
|
|
|
{/* ── Documents tab ─────────────────────────────────────────── */}
|
|
<Tabs.Panel value="documents">
|
|
<Stack gap="lg">
|
|
{isPhasedCustomsClearance ? (
|
|
<ClearanceUploadedDocumentsPanel
|
|
files={workflowFiles}
|
|
tradeDirection={contract.tradeDirection ?? "IMPORT"}
|
|
onView={view}
|
|
onDownload={async (f) => {
|
|
try {
|
|
const a = document.createElement("a");
|
|
a.href = fileViewUrl(f.id, true);
|
|
a.download = f.name;
|
|
a.click();
|
|
} catch {
|
|
toast.error("Could not download file.");
|
|
}
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
<Card
|
|
withBorder
|
|
radius="lg"
|
|
p="lg"
|
|
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
|
|
>
|
|
<Group justify="space-between" align="center" mb="md">
|
|
<SectionLabel>Contract documents</SectionLabel>
|
|
{contract.contractGeneratedAt && (
|
|
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
|
Generated
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
{docGroups.length === 0 ? (
|
|
<Stack align="center" gap={10} py={48}>
|
|
<Box
|
|
style={{
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: 16,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
background: "#F1F5F9",
|
|
}}
|
|
>
|
|
<FileText size={26} color={MUTED} style={{ opacity: 0.6 }} />
|
|
</Box>
|
|
<Text fz={14} fw={600} style={{ color: INK }}>
|
|
No documents yet
|
|
</Text>
|
|
<Text fz={13} c="dimmed" ta="center" maw={420}>
|
|
The signed contract and any uploaded clearance documents will
|
|
appear here.
|
|
</Text>
|
|
</Stack>
|
|
) : (
|
|
<Stack gap="xl">
|
|
{docGroups.map((group) => {
|
|
const accent = DOC_GROUP_ACCENT[group.key] ?? GREEN;
|
|
const Icon = DOC_GROUP_ICON[group.key] ?? FileText;
|
|
return (
|
|
<Stack key={group.key} gap={10}>
|
|
<Group gap={10} align="center">
|
|
<Box
|
|
style={{
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: 8,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
background: `${accent}14`,
|
|
color: accent,
|
|
}}
|
|
>
|
|
<Icon size={15} />
|
|
</Box>
|
|
<Text fz={13} fw={700} style={{ color: INK }}>
|
|
{group.title}
|
|
</Text>
|
|
<Badge
|
|
size="sm"
|
|
variant="light"
|
|
color="gray"
|
|
radius="sm"
|
|
>
|
|
{group.files.length}
|
|
</Badge>
|
|
</Group>
|
|
<Stack gap={10}>
|
|
{group.files.map((file) => (
|
|
<DocFileRow
|
|
key={file.id}
|
|
file={file}
|
|
onView={view}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</Stack>
|
|
);
|
|
})}
|
|
</Stack>
|
|
)}
|
|
</Card>
|
|
</Stack>
|
|
</Tabs.Panel>
|
|
|
|
{/* ── Bookings tab ──────────────────────────────────────────── */}
|
|
<Tabs.Panel value="bookings">
|
|
{showShipmentRequests && (
|
|
<Card
|
|
withBorder
|
|
radius="lg"
|
|
p="lg"
|
|
mb="md"
|
|
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
|
|
>
|
|
<Group justify="space-between" align="center" mb="md">
|
|
<SectionLabel>Shipment requests</SectionLabel>
|
|
{canRequestShipment && (
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
size="xs"
|
|
leftSection={<PackagePlus size={14} />}
|
|
onClick={() => navigate(bookingAction.to)}
|
|
>
|
|
New request
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
{activeShipmentRequests.length === 0 ? (
|
|
<Text fz={13} c="dimmed">
|
|
{canRequestShipment
|
|
? "No pending shipment requests. Submit a request when you are ready to ship."
|
|
: "Shipment requests appear here once the contract is active."}
|
|
</Text>
|
|
) : (
|
|
<Stack gap={10}>
|
|
{activeShipmentRequests.map((req) => (
|
|
<Group
|
|
key={req.id}
|
|
justify="space-between"
|
|
wrap="nowrap"
|
|
p="sm"
|
|
style={{
|
|
borderRadius: 12,
|
|
border: `1px solid ${BORDER}`,
|
|
cursor: req.createdBookingId ? "pointer" : "default",
|
|
}}
|
|
onClick={() => {
|
|
if (req.createdBookingId) {
|
|
navigate(`/bookings/${req.createdBookingId}`);
|
|
}
|
|
}}
|
|
>
|
|
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
<CalendarClock
|
|
size={16}
|
|
color={MUTED}
|
|
style={{ flexShrink: 0 }}
|
|
/>
|
|
<Box style={{ minWidth: 0 }}>
|
|
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
|
{req.reference}
|
|
</Text>
|
|
<Text fz={12} c="dimmed">
|
|
{req.scheduledDate
|
|
? `Preferred date: ${req.scheduledDate}`
|
|
: "No preferred date"}
|
|
{req.notes ? ` · ${req.notes}` : ""}
|
|
</Text>
|
|
</Box>
|
|
</Group>
|
|
<Group gap="xs" wrap="nowrap">
|
|
<Badge
|
|
size="sm"
|
|
radius="md"
|
|
variant="light"
|
|
color={
|
|
req.status === "ACCEPTED"
|
|
? "teal"
|
|
: req.status === "REJECTED"
|
|
? "red"
|
|
: "yellow"
|
|
}
|
|
>
|
|
{req.status === "ACCEPTED" && req.createdBookingId
|
|
? "Booking created"
|
|
: req.status}
|
|
</Badge>
|
|
{req.createdBookingId ? (
|
|
<ChevronRight size={16} color={MUTED} />
|
|
) : null}
|
|
</Group>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Card>
|
|
)}
|
|
<Card
|
|
withBorder
|
|
radius="lg"
|
|
p="lg"
|
|
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
|
|
>
|
|
<Group justify="space-between" align="center" mb="md">
|
|
<SectionLabel>Bookings under this contract</SectionLabel>
|
|
{canBookShipment && bookingWindowOpen && (
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
size="xs"
|
|
leftSection={<PackagePlus size={14} />}
|
|
onClick={() =>
|
|
navigate(`/contracts/${contract.id}/bookings/new`)
|
|
}
|
|
>
|
|
New booking
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
{canBookShipment && !bookingWindowOpen && (
|
|
<Group gap={8} align="flex-start" wrap="nowrap" mb="md">
|
|
<CalendarClock
|
|
size={15}
|
|
color={MUTED}
|
|
style={{ flexShrink: 0, marginTop: 2 }}
|
|
/>
|
|
<Text fz={13} c="dimmed">
|
|
{closedWindowMessage(bookingWindows)}
|
|
</Text>
|
|
</Group>
|
|
)}
|
|
{contractBookings.length === 0 ? (
|
|
<Stack align="center" gap={10} py="xl">
|
|
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
|
|
<Text fz={13} c="dimmed" ta="center" maw={380}>
|
|
{canBookShipment
|
|
? "No bookings yet. Use “New booking” to ship against this contract."
|
|
: customsPath
|
|
? "No bookings yet. After your clearance documents are approved, Global Logistics creates the booking on your behalf."
|
|
: canUploadClearance
|
|
? "No bookings yet. After the Operations team approves your clearance documents, you can create a booking here."
|
|
: "Bookings appear here once the contract is fully executed."}
|
|
</Text>
|
|
</Stack>
|
|
) : (
|
|
<Stack gap={10}>
|
|
{contractBookings.map((booking) => (
|
|
<Group
|
|
key={booking.id}
|
|
justify="space-between"
|
|
wrap="nowrap"
|
|
p="sm"
|
|
style={{
|
|
borderRadius: 12,
|
|
border: `1px solid ${BORDER}`,
|
|
cursor: "pointer",
|
|
}}
|
|
onClick={() => navigate(`/bookings/${booking.id}`)}
|
|
>
|
|
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
<Package
|
|
size={16}
|
|
color={MUTED}
|
|
style={{ flexShrink: 0 }}
|
|
/>
|
|
<Box style={{ minWidth: 0 }}>
|
|
<Text
|
|
fz={14}
|
|
fw={700}
|
|
style={{ color: INK }}
|
|
truncate
|
|
>
|
|
{booking.reference}
|
|
</Text>
|
|
{booking.scheduledDate && (
|
|
<Text fz={12} c="dimmed" truncate>
|
|
Ship{" "}
|
|
{new Date(
|
|
booking.scheduledDate,
|
|
).toLocaleDateString()}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</Group>
|
|
<ContractStatusBadge status={booking.status} />
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Card>
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
</Stack>
|
|
{viewer}
|
|
|
|
<Modal
|
|
opened={clearanceOpen}
|
|
onClose={clearanceModal.close}
|
|
title={
|
|
<Text fw={700} fz={16}>
|
|
Clearance documents
|
|
</Text>
|
|
}
|
|
size="xl"
|
|
radius="md"
|
|
centered
|
|
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
|
>
|
|
<ContractClearancePanel
|
|
contractId={contract.id}
|
|
tradeDirection={contract.tradeDirection ?? "IMPORT"}
|
|
bare
|
|
/>
|
|
</Modal>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Subtle card elevation shared across the detail page sections.
|
|
const CARD_SHADOW = "0 1px 2px rgba(16,24,40,0.04)";
|
|
|
|
// Accent hues matching the contract-ui StatCard rail palette.
|
|
const KEY_FACT_ACCENT: Record<string, string> = {
|
|
"edr-green": GREEN,
|
|
violet: "#6A40B8",
|
|
orange: "#C77F09",
|
|
};
|
|
|
|
// Per-section accent + icon for the Documents tab groups.
|
|
const DOC_GROUP_ACCENT: Record<string, string> = {
|
|
clearance: "#C77F09",
|
|
businessLicense: "#0A6F4D",
|
|
profile: "#2B6CB0",
|
|
};
|
|
const DOC_GROUP_ICON: Record<string, LucideIcon> = {
|
|
clearance: Upload,
|
|
businessLicense: FileBadge,
|
|
profile: FileText,
|
|
};
|
|
|
|
/**
|
|
* A pill-style detail tab matching the backoffice booking-requests tabs: an
|
|
* icon, a label, and an always-visible count badge (shows 0 when empty).
|
|
*/
|
|
function DetailTab({
|
|
value,
|
|
active,
|
|
icon,
|
|
label,
|
|
count,
|
|
}: {
|
|
value: string;
|
|
active: boolean;
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
count?: number;
|
|
}) {
|
|
return (
|
|
<Tabs.Tab
|
|
value={value}
|
|
leftSection={icon}
|
|
rightSection={
|
|
count !== undefined ? (
|
|
<Badge
|
|
size="sm"
|
|
radius="sm"
|
|
variant={active ? "white" : "light"}
|
|
color={active ? "edr-green" : "gray"}
|
|
styles={
|
|
active
|
|
? {
|
|
root: {
|
|
background: "rgba(255,255,255,0.92)",
|
|
color: "#15805F",
|
|
},
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{count}
|
|
</Badge>
|
|
) : undefined
|
|
}
|
|
styles={{
|
|
tab: {
|
|
borderRadius: 11,
|
|
padding: "9px 16px",
|
|
fontWeight: 600,
|
|
},
|
|
}}
|
|
>
|
|
{label}
|
|
</Tabs.Tab>
|
|
);
|
|
}
|
|
|
|
/** A small uppercase eyebrow used as a section heading. */
|
|
function SectionLabel({
|
|
children,
|
|
mb,
|
|
}: {
|
|
children: React.ReactNode;
|
|
mb?: number | string;
|
|
}) {
|
|
return (
|
|
<Text
|
|
fz={11}
|
|
fw={700}
|
|
tt="uppercase"
|
|
c="dimmed"
|
|
mb={mb}
|
|
style={{ letterSpacing: "0.06em" }}
|
|
>
|
|
{children}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Unit noun for a capacity line: "containers" for CONTAINER freight, else the
|
|
* bulk cargo's unit of measure ("tons" for PER_TON, "items" for PER_ITEM).
|
|
*/
|
|
function capacityUnitLabel(
|
|
contract: Freight.IContract,
|
|
line: Freight.ContractCapacityLine,
|
|
): string {
|
|
if (contract.freightType === "CONTAINER") return "containers";
|
|
const scope =
|
|
(contract.cargoScope ?? []).find(
|
|
(s) => s.cargoTypeId === line.cargoTypeId,
|
|
) ?? (contract.cargoScope ?? [])[0];
|
|
return scope?.cargoType?.unitOfMeasure === "PER_ITEM" ? "items" : "tons";
|
|
}
|
|
|
|
/**
|
|
* One document row in the Documents tab: the file's kind (passport, business
|
|
* license, contract, …) derived from its `code` as the primary label, the
|
|
* stored filename and size as secondary text, and view / download actions that
|
|
* stream through the API by file id.
|
|
*/
|
|
/** Extension → a small colored type chip (PDF red, image green, etc.). */
|
|
function fileTypeChip(name: string, mimeType?: string | null): {
|
|
ext: string;
|
|
color: string;
|
|
} {
|
|
const dot = name.lastIndexOf(".");
|
|
let ext = dot >= 0 ? name.slice(dot + 1).toUpperCase() : "";
|
|
if (!ext && mimeType) ext = mimeType.split("/")[1]?.toUpperCase() ?? "FILE";
|
|
if (!ext) ext = "FILE";
|
|
const color =
|
|
ext === "PDF"
|
|
? "#D64545"
|
|
: ["PNG", "JPG", "JPEG", "GIF", "WEBP", "SVG"].includes(ext)
|
|
? "#2F9E6E"
|
|
: ["DOC", "DOCX"].includes(ext)
|
|
? "#2B6CB0"
|
|
: ["XLS", "XLSX", "CSV"].includes(ext)
|
|
? "#2F855A"
|
|
: ["MP4", "WEBM", "MOV"].includes(ext)
|
|
? "#7A40C8"
|
|
: "#6B7C8E";
|
|
return { ext: ext.slice(0, 4), color };
|
|
}
|
|
|
|
function DocFileRow({
|
|
file,
|
|
onView,
|
|
}: {
|
|
file: ContractFile;
|
|
onView: (f: ViewableFile) => void;
|
|
}) {
|
|
const kind =
|
|
clearanceWorkflowFileLabel(file.code) ?? labelForDocCode(file.code);
|
|
const { ext, color } = fileTypeChip(file.name, file.mimeType);
|
|
const viewable = isViewable({
|
|
name: file.name,
|
|
url: fileViewUrl(file.id),
|
|
mimeType: file.mimeType,
|
|
});
|
|
return (
|
|
<Group
|
|
justify="space-between"
|
|
wrap="nowrap"
|
|
p="sm"
|
|
style={{
|
|
borderRadius: 14,
|
|
border: `1px solid ${BORDER}`,
|
|
background: "#fff",
|
|
transition: "border-color 120ms ease, box-shadow 120ms ease",
|
|
}}
|
|
className="hover:border-edr-green-3 hover:shadow-sm"
|
|
>
|
|
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
<Box
|
|
style={{
|
|
width: 40,
|
|
height: 40,
|
|
flexShrink: 0,
|
|
borderRadius: 10,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
background: `${color}14`,
|
|
color,
|
|
}}
|
|
>
|
|
<FileText size={16} />
|
|
<Text fz={8} fw={800} mt={1} style={{ letterSpacing: "0.04em" }}>
|
|
{ext}
|
|
</Text>
|
|
</Box>
|
|
<Box style={{ minWidth: 0 }}>
|
|
<Text fz={14} fw={600} style={{ color: INK }} truncate>
|
|
{kind}
|
|
</Text>
|
|
<Text fz={12} c="dimmed" truncate>
|
|
{file.name}
|
|
{file.size ? ` · ${(file.size / 1024).toFixed(0)} KB` : ""}
|
|
</Text>
|
|
</Box>
|
|
</Group>
|
|
<Group gap={8} wrap="nowrap">
|
|
{viewable && (
|
|
<Button
|
|
variant="light"
|
|
color="edr-green"
|
|
size="xs"
|
|
radius="md"
|
|
leftSection={<Eye size={14} />}
|
|
onClick={() =>
|
|
onView({
|
|
name: file.name,
|
|
url: fileViewUrl(file.id),
|
|
mimeType: file.mimeType,
|
|
})
|
|
}
|
|
>
|
|
View
|
|
</Button>
|
|
)}
|
|
<Button
|
|
component="a"
|
|
href={fileViewUrl(file.id, true)}
|
|
variant="default"
|
|
size="xs"
|
|
radius="md"
|
|
leftSection={<Download size={14} />}
|
|
>
|
|
Download
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* One fact inside the unified key-facts card: a soft accent-tinted icon chip,
|
|
* an uppercase label, and the value. Hairline dividers between cells make the
|
|
* row read as a single card rather than five tiles.
|
|
*/
|
|
function FactCell({
|
|
icon: Icon,
|
|
label,
|
|
value,
|
|
color = "edr-green",
|
|
}: {
|
|
icon: LucideIcon;
|
|
label: string;
|
|
value: React.ReactNode;
|
|
color?: string;
|
|
}) {
|
|
const accent = KEY_FACT_ACCENT[color] ?? GREEN;
|
|
return (
|
|
<Group
|
|
gap={12}
|
|
align="center"
|
|
wrap="nowrap"
|
|
p="lg"
|
|
style={{
|
|
minWidth: 0,
|
|
borderRight: `1px solid rgba(16,24,40,0.06)`,
|
|
borderBottom: `1px solid rgba(16,24,40,0.06)`,
|
|
}}
|
|
>
|
|
<Box
|
|
style={{
|
|
width: 40,
|
|
height: 40,
|
|
flexShrink: 0,
|
|
borderRadius: 12,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
color: accent,
|
|
background: `${accent}14`,
|
|
}}
|
|
>
|
|
<Icon size={19} strokeWidth={2} />
|
|
</Box>
|
|
<Box style={{ minWidth: 0 }}>
|
|
<Text
|
|
fz={10.5}
|
|
fw={700}
|
|
tt="uppercase"
|
|
c="dimmed"
|
|
style={{ letterSpacing: "0.06em" }}
|
|
>
|
|
{label}
|
|
</Text>
|
|
<Text
|
|
fz={16}
|
|
fw={800}
|
|
lh={1.2}
|
|
mt={3}
|
|
truncate
|
|
style={{ color: INK, letterSpacing: "-0.01em" }}
|
|
>
|
|
{value}
|
|
</Text>
|
|
</Box>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Post-offload final invoice from GL Djibouti (export): shows the due amount +
|
|
* invoice document; the customer pays offline and attaches the payment slip
|
|
* here, then GL confirms and the badge flips to PAID.
|
|
*/
|
|
function FinalInvoiceDueCard({
|
|
invoice,
|
|
bookingId,
|
|
onView,
|
|
onChanged,
|
|
}: {
|
|
invoice: NonNullable<Freight.ContractClearanceView["finalInvoice"]>;
|
|
bookingId: string;
|
|
onView: (file: { name: string; url: string }) => void;
|
|
onChanged: () => void;
|
|
}) {
|
|
const [slip, setSlip] = useState<File | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const paid = invoice.status === "PAID";
|
|
|
|
return (
|
|
<Paper
|
|
withBorder
|
|
radius="lg"
|
|
p="lg"
|
|
style={{
|
|
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
|
|
background: paid ? "#F6FBF8" : "#FFFBF2",
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<Box
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: 3,
|
|
background: paid ? GREEN : "#E3A93C",
|
|
}}
|
|
/>
|
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
|
<div>
|
|
<Group gap={8} align="center">
|
|
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
|
|
<Text fw={700} fz={15} c={INK}>
|
|
{paid ? "Final invoice paid" : "Final invoice due"} —{" "}
|
|
{invoice.invoiceNumber}
|
|
</Text>
|
|
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
|
|
{invoice.status}
|
|
</Badge>
|
|
</Group>
|
|
<Text fz={20} fw={800} mt={6} c={INK}>
|
|
{invoice.totalAmount.toLocaleString()} {invoice.currency}
|
|
</Text>
|
|
{invoice.description ? (
|
|
<Text fz={13} c="dimmed" mt={2}>
|
|
{invoice.description}
|
|
</Text>
|
|
) : null}
|
|
{!paid ? (
|
|
<Text fz={13} c="#9A6B1F" mt={6}>
|
|
Pay the amount above and attach your payment slip — Global
|
|
Logistics will confirm the payment.
|
|
</Text>
|
|
) : null}
|
|
</div>
|
|
|
|
<Stack gap="xs" miw={260}>
|
|
{invoice.invoiceFile ? (
|
|
<Button
|
|
variant="default"
|
|
radius="md"
|
|
size="sm"
|
|
leftSection={<Eye size={15} />}
|
|
onClick={() =>
|
|
onView({
|
|
name: invoice.invoiceFile!.name,
|
|
url: invoice.invoiceFile!.url,
|
|
})
|
|
}
|
|
>
|
|
View invoice
|
|
</Button>
|
|
) : null}
|
|
{invoice.slipFile ? (
|
|
<Button
|
|
variant="default"
|
|
radius="md"
|
|
size="sm"
|
|
leftSection={<Eye size={15} />}
|
|
onClick={() =>
|
|
onView({
|
|
name: invoice.slipFile!.name,
|
|
url: invoice.slipFile!.url,
|
|
})
|
|
}
|
|
>
|
|
View payment slip
|
|
</Button>
|
|
) : null}
|
|
{!paid ? (
|
|
<>
|
|
<FileInput
|
|
placeholder={
|
|
invoice.slipFile ? "Replace payment slip" : "Attach payment slip"
|
|
}
|
|
value={slip}
|
|
onChange={setSlip}
|
|
size="sm"
|
|
radius="md"
|
|
/>
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
size="sm"
|
|
loading={uploading}
|
|
disabled={!slip}
|
|
leftSection={<Upload size={15} />}
|
|
onClick={async () => {
|
|
if (!slip) return;
|
|
setUploading(true);
|
|
try {
|
|
await contractsService.uploadFinalInvoiceSlip(bookingId, slip);
|
|
setSlip(null);
|
|
toast.success("Payment slip attached");
|
|
onChanged();
|
|
} catch (e) {
|
|
toast.error(
|
|
e instanceof Error ? e.message : "Upload failed",
|
|
);
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}}
|
|
>
|
|
{invoice.slipFile ? "Replace slip" : "Submit payment slip"}
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
</Stack>
|
|
</Group>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
const CUSTOMS_RISK_COLOR: Record<string, string> = {
|
|
GREEN: "green",
|
|
YELLOW: "yellow",
|
|
RED: "red",
|
|
};
|
|
|
|
/**
|
|
* Post-arrival additional duty/tax round (import): GL advises an extra amount
|
|
* with a notice; the customer pays offline and attaches another slip here.
|
|
*/
|
|
function SecondDutyDueCard({
|
|
duty,
|
|
bookingId,
|
|
onView,
|
|
onChanged,
|
|
}: {
|
|
duty: NonNullable<Freight.ContractClearanceView["secondDuty"]>;
|
|
bookingId: string;
|
|
onView: (file: { name: string; url: string }) => void;
|
|
onChanged: () => void;
|
|
}) {
|
|
const [slip, setSlip] = useState<File | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const paid = duty.paid;
|
|
|
|
return (
|
|
<Paper
|
|
withBorder
|
|
radius="lg"
|
|
p="lg"
|
|
style={{
|
|
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
|
|
background: paid ? "#F6FBF8" : "#FFFBF2",
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<Box
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: 3,
|
|
background: paid ? GREEN : "#E3A93C",
|
|
}}
|
|
/>
|
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
|
<div>
|
|
<Group gap={8} align="center">
|
|
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
|
|
<Text fw={700} fz={15} c={INK}>
|
|
{paid ? "Additional duty & tax paid" : "Additional duty & tax due"}
|
|
</Text>
|
|
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
|
|
{paid ? "PAID" : "DUE"}
|
|
</Badge>
|
|
</Group>
|
|
<Text fz={20} fw={800} mt={6} c={INK}>
|
|
{(duty.amount ?? 0).toLocaleString()} {duty.currency ?? ""}
|
|
</Text>
|
|
{duty.declarationSerial ? (
|
|
<Text fz={13} c="dimmed" mt={2}>
|
|
Payment code: {duty.declarationSerial}
|
|
</Text>
|
|
) : null}
|
|
{!paid ? (
|
|
<Text fz={13} c="#9A6B1F" mt={6}>
|
|
Customs advised additional duty/tax after arrival. Pay the amount
|
|
above and attach your payment slip.
|
|
</Text>
|
|
) : null}
|
|
</div>
|
|
|
|
<Stack gap="xs" miw={260}>
|
|
{duty.noticeFile ? (
|
|
<Button
|
|
variant="default"
|
|
radius="md"
|
|
size="sm"
|
|
leftSection={<Eye size={15} />}
|
|
onClick={() =>
|
|
onView({ name: duty.noticeFile!.name, url: duty.noticeFile!.url })
|
|
}
|
|
>
|
|
View duty notice
|
|
</Button>
|
|
) : null}
|
|
{duty.slipFile ? (
|
|
<Button
|
|
variant="default"
|
|
radius="md"
|
|
size="sm"
|
|
leftSection={<Eye size={15} />}
|
|
onClick={() =>
|
|
onView({ name: duty.slipFile!.name, url: duty.slipFile!.url })
|
|
}
|
|
>
|
|
View payment slip
|
|
</Button>
|
|
) : null}
|
|
{!paid ? (
|
|
<>
|
|
<FileInput
|
|
placeholder={duty.slipFile ? "Replace payment slip" : "Attach payment slip"}
|
|
value={slip}
|
|
onChange={setSlip}
|
|
size="sm"
|
|
radius="md"
|
|
/>
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
size="sm"
|
|
loading={uploading}
|
|
disabled={!slip}
|
|
leftSection={<Upload size={15} />}
|
|
onClick={async () => {
|
|
if (!slip) return;
|
|
setUploading(true);
|
|
try {
|
|
await contractsService.uploadSecondDutySlip(bookingId, slip);
|
|
setSlip(null);
|
|
toast.success("Payment slip attached");
|
|
onChanged();
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Upload failed");
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}}
|
|
>
|
|
{duty.slipFile ? "Replace slip" : "Submit payment slip"}
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
</Stack>
|
|
</Group>
|
|
</Paper>
|
|
);
|
|
}
|