mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
929 lines
32 KiB
TypeScript
929 lines
32 KiB
TypeScript
import { directionLabel } from "@/lib/utils";
|
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
AlertTriangle,
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
Box as BoxIcon,
|
|
Building2,
|
|
Calendar,
|
|
CalendarClock,
|
|
Download,
|
|
FileSignature,
|
|
FileText,
|
|
Files,
|
|
Flame,
|
|
History,
|
|
Info,
|
|
LayoutGrid,
|
|
Milestone,
|
|
Package,
|
|
Receipt,
|
|
RefreshCw,
|
|
Route as RouteIcon,
|
|
ShieldCheck,
|
|
Snowflake,
|
|
Users,
|
|
} from "lucide-react";
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Center,
|
|
Container,
|
|
Grid,
|
|
Group,
|
|
Loader,
|
|
Paper,
|
|
SimpleGrid,
|
|
Stack,
|
|
Tabs,
|
|
Text,
|
|
Title,
|
|
} from "@mantine/core";
|
|
|
|
import toast from "react-hot-toast";
|
|
|
|
import "@/components/overview/overview.css";
|
|
import { PageContainer } from "@/components/page";
|
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
|
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
|
|
import {
|
|
ContractCourtBadge,
|
|
ContractStatusBadge,
|
|
} from "@/components/contracts/ContractStatusBadge";
|
|
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
|
|
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
|
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
|
import { HazardDeclarationPanel } from "@/components/contracts/HazardDeclarationPanel";
|
|
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
|
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
|
|
import { ContractMilestonesTimeline } from "@/components/contracts/ContractMilestonesTimeline";
|
|
import {
|
|
ContractCustomerCard,
|
|
ContractDocumentsCard,
|
|
} from "@/components/contracts/detail/ContractDetailTabCards";
|
|
import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
|
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
|
import {
|
|
useContractDetail,
|
|
useContractMutations,
|
|
} from "@/hooks/contracts/useContracts";
|
|
import { contractsService } from "@/services/contracts.service";
|
|
import { api } from "@/services/api";
|
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
|
import {
|
|
downloadBookingFile,
|
|
fetchViewableFile,
|
|
} from "@/services/files.service";
|
|
import type { CustomerDocument } from "@/types/customer";
|
|
import type { Freight } from "@edr/types";
|
|
|
|
// Clearance phase — actionable (docs approve / query / finalize on the hub).
|
|
const CLEARANCE_ACTIVE_STATUSES = [
|
|
"AWAITING_CLEARANCE_DOCUMENTS",
|
|
"CLEARANCE_UNDER_REVIEW",
|
|
"CLEARANCE_READY_FOR_BOOKING",
|
|
];
|
|
|
|
// Clearance is done — its documents are still worth loading (read-only record).
|
|
const CLEARANCE_DONE_STATUSES = [
|
|
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
|
"FULLY_EXECUTED",
|
|
"CONTRACT_ACTIVE",
|
|
"CONTRACT_CLOSED",
|
|
"EXPIRED",
|
|
];
|
|
|
|
// Contract is in (or past) its clearance phase — load the clearance view so the
|
|
// Documents tab can show customs workflow files, and surface the "Review
|
|
// clearance" deep-link to the Operations hub.
|
|
const CLEARANCE_REVIEW_STATUSES = [
|
|
...CLEARANCE_ACTIVE_STATUSES,
|
|
...CLEARANCE_DONE_STATUSES,
|
|
];
|
|
|
|
function formatDate(value: string | null | undefined): string {
|
|
if (!value) return "—";
|
|
const d = new Date(value);
|
|
return Number.isNaN(d.getTime())
|
|
? "—"
|
|
: d.toLocaleDateString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
/** Same, plus the clock — for values the staff pick to the minute. */
|
|
function formatDateTime(value: string | null | undefined): string {
|
|
if (!value) return "—";
|
|
const d = new Date(value);
|
|
return Number.isNaN(d.getTime())
|
|
? "—"
|
|
: d.toLocaleString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
export default function ContractRequestDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const {
|
|
data: contract,
|
|
isLoading,
|
|
isError,
|
|
refetch,
|
|
isFetching,
|
|
} = useContractDetail(id);
|
|
const mutations = useContractMutations(id ?? "");
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const { view, viewer } = useFileViewer();
|
|
const requestedTab = searchParams.get("tab");
|
|
const setTab = (tab: string) =>
|
|
setSearchParams(
|
|
(prev) => {
|
|
const next = new URLSearchParams(prev);
|
|
if (tab === "details") next.delete("tab");
|
|
else next.set("tab", tab);
|
|
return next;
|
|
},
|
|
{ replace: true },
|
|
);
|
|
|
|
const handleViewFile = (file: NonNullable<Freight.IContract["files"]>[number]) =>
|
|
view({
|
|
name: file.name,
|
|
url: file.signedUrl ?? file.url,
|
|
mimeType: file.mimeType,
|
|
});
|
|
|
|
const handleDownloadFile = async (
|
|
file: NonNullable<Freight.IContract["files"]>[number],
|
|
) => {
|
|
try {
|
|
await downloadBookingFile(file.id, file.name);
|
|
} catch {
|
|
toast.error("Could not download file.");
|
|
}
|
|
};
|
|
|
|
const hasClearancePhase = Boolean(
|
|
contract && CLEARANCE_REVIEW_STATUSES.includes(contract.status),
|
|
);
|
|
const { data: clearanceView } = useQuery({
|
|
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
|
queryFn: () => contractsService.getClearance(id!),
|
|
enabled: Boolean(id) && hasClearancePhase,
|
|
});
|
|
|
|
// Customer profile documents (national ID, TIN, import/business license) for
|
|
// the company this contract belongs to. Shown as a separate section in the
|
|
// Documents tab, alongside the contract's own attached files.
|
|
const companyId = contract?.companyId ?? "";
|
|
const profileDocumentsQuery = useQuery(
|
|
api.customers.documents.queryOptions({
|
|
input: { id: companyId },
|
|
enabled: Boolean(companyId),
|
|
}),
|
|
);
|
|
const profileDocumentsRaw = Array.isArray(profileDocumentsQuery.data)
|
|
? profileDocumentsQuery.data
|
|
: [];
|
|
// Reshape to the contract-file shape so we can reuse ContractDocumentsCard.
|
|
const profileDocuments = profileDocumentsRaw.map(
|
|
(doc: CustomerDocument) =>
|
|
({
|
|
id: doc.id,
|
|
code: doc.code,
|
|
name: doc.name,
|
|
url: doc.url ?? "",
|
|
mimeType: doc.mimeType,
|
|
size: doc.size,
|
|
resourceId: companyId,
|
|
resource: "company",
|
|
}) satisfies NonNullable<Freight.IContract["files"]>[number],
|
|
);
|
|
|
|
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;
|
|
const contractPdf = contract.files?.find((f) => f.code === "contract");
|
|
a.download = contractPdf?.name ?? `contract-${contract.reference}.pdf`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch {
|
|
toast.error("Could not download contract PDF.");
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<PageContainer>
|
|
<Center mih="60vh">
|
|
<Stack align="center" gap="md">
|
|
<Loader color="gray" />
|
|
<Text size="sm" c="dimmed" fw={500}>
|
|
Loading contract…
|
|
</Text>
|
|
</Stack>
|
|
</Center>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
if (isError || !contract) {
|
|
return (
|
|
<PageContainer>
|
|
<Container size="sm" py="xl">
|
|
<Paper radius="md" withBorder p="xl" ta="center" style={detailStyles.card}>
|
|
<Center>
|
|
<Box
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: 16,
|
|
background: "var(--mantine-color-gray-1)",
|
|
color: "var(--mantine-color-gray-6)",
|
|
}}
|
|
>
|
|
<FileText size={32} />
|
|
</Box>
|
|
</Center>
|
|
<Text fw={700} size="lg" mt="lg">
|
|
Contract not found
|
|
</Text>
|
|
<Text size="sm" c="dimmed" mt={4}>
|
|
This request may have been removed or the link is invalid.
|
|
</Text>
|
|
<Button
|
|
variant="default"
|
|
mt="lg"
|
|
leftSection={<ArrowLeft size={16} />}
|
|
onClick={() => navigate("/dashboard/contract-requests")}
|
|
>
|
|
Back to contract requests
|
|
</Button>
|
|
</Paper>
|
|
</Container>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
const statusMeta = getContractStatusMeta(contract.status);
|
|
const routes = [...(contract.routes ?? [])].sort(
|
|
(a, b) => a.sortOrder - b.sortOrder,
|
|
);
|
|
const showApprovalCard =
|
|
contract.status === "PENDING_APPROVAL" ||
|
|
contract.status === "APPROVED" ||
|
|
contract.status === "APPROVED_PENDING_SIGNATURE" ||
|
|
contract.status === "REJECTED";
|
|
|
|
// Clearance review + finalize now lives solely on the Operations "Clearance
|
|
// Documents" hub. The Staff-actions "Review clearance" button deep-links there
|
|
// while the contract is in a clearance-review status — no embedded tab here.
|
|
const inClearanceReview = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
|
|
const files = contract.files ?? [];
|
|
const contractPdf = files.find((f) => f.code === "contract");
|
|
// Signature files (code `signature_<role>`) are baked into the contract PDF —
|
|
// don't list them as standalone documents in the Documents tab.
|
|
const contractDocuments = files.filter(
|
|
(f) => !f.code.startsWith("signature_"),
|
|
);
|
|
const hasContractDocument = Boolean(
|
|
contractPdf || contract.contractGeneratedAt,
|
|
);
|
|
const canViewSign =
|
|
(contract.status === "CONTRACT_READY" ||
|
|
contract.status === "SIGNED_CUSTOMER") &&
|
|
Boolean(contract.contractGeneratedAt);
|
|
// Resolve the active tab from the URL, falling back to details when the
|
|
// requested tab isn't available for this contract (e.g. clearance pre-phase).
|
|
const currentTab =
|
|
requestedTab === "documents"
|
|
? "documents"
|
|
: requestedTab === "customer"
|
|
? "customer"
|
|
: requestedTab === "history"
|
|
? "history"
|
|
: "details";
|
|
|
|
const customerLabel = contract.isGovernment
|
|
? (contract.governmentInstitution ?? "Government")
|
|
: (contract.company?.name ?? "—");
|
|
|
|
return (
|
|
<PageContainer>
|
|
<Breadcrumbs
|
|
items={[
|
|
{ label: "Contract requests", href: "/dashboard/contract-requests" },
|
|
{ label: contract.reference },
|
|
]}
|
|
/>
|
|
|
|
<Stack gap="lg">
|
|
{/* Hero */}
|
|
<Paper radius="xl" p="xl" style={{ position: "relative", overflow: "hidden" }}>
|
|
<Stack gap="lg">
|
|
<Group justify="space-between" align="flex-start" wrap="wrap">
|
|
<Button
|
|
variant="default"
|
|
size="compact-sm"
|
|
radius="lg"
|
|
leftSection={<ArrowLeft size={16} />}
|
|
onClick={() => navigate("/dashboard/contract-requests")}
|
|
>
|
|
Back to list
|
|
</Button>
|
|
<Button
|
|
variant="light"
|
|
color="edr-green"
|
|
size="compact-sm"
|
|
radius="lg"
|
|
leftSection={<RefreshCw size={15} />}
|
|
loading={isFetching}
|
|
onClick={() => refetch()}
|
|
>
|
|
Refresh
|
|
</Button>
|
|
</Group>
|
|
|
|
<Stack gap="sm">
|
|
<Text
|
|
size="xs"
|
|
fw={700}
|
|
tt="uppercase"
|
|
style={{ letterSpacing: 1, color: "#B26C09" }}
|
|
>
|
|
Contract reference
|
|
</Text>
|
|
<Group gap="sm" align="center" wrap="wrap">
|
|
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
|
{contract.reference}
|
|
</Title>
|
|
<ContractStatusBadge
|
|
status={contract.status}
|
|
isRenewal={Boolean(contract.renewalOfId)}
|
|
/>
|
|
<ContractCourtBadge status={contract.status} />
|
|
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
|
{contract.contractKind === "GENERAL" ? "General" : "One-time"}
|
|
</Badge>
|
|
</Group>
|
|
<Group gap="lg" mt={4}>
|
|
<MetaItem icon={Building2} text={customerLabel} />
|
|
<MetaItem
|
|
icon={Calendar}
|
|
text={`Created ${formatDate(contract.createdAt)}`}
|
|
/>
|
|
{contract.contractValidUntil ? (
|
|
<MetaItem
|
|
icon={CalendarClock}
|
|
// Validity is accepted to the minute — show the time.
|
|
text={`Valid until ${formatDateTime(contract.contractValidUntil)}`}
|
|
/>
|
|
) : null}
|
|
</Group>
|
|
{hasContractDocument && (
|
|
<Group gap="sm" mt="sm">
|
|
{canViewSign && (
|
|
<Button
|
|
color="edr-green"
|
|
size="compact-sm"
|
|
radius="lg"
|
|
leftSection={<FileSignature size={15} />}
|
|
onClick={() =>
|
|
navigate(`/dashboard/contract-requests/${contract.id}/view`)
|
|
}
|
|
>
|
|
View & sign contract
|
|
</Button>
|
|
)}
|
|
{contractPdf && (
|
|
<Button
|
|
variant="default"
|
|
size="compact-sm"
|
|
radius="lg"
|
|
leftSection={<FileText size={15} />}
|
|
onClick={() =>
|
|
void fetchViewableFile(contractPdf.id, contractPdf.name).then(
|
|
view,
|
|
)
|
|
}
|
|
>
|
|
View contract
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="default"
|
|
size="compact-sm"
|
|
radius="lg"
|
|
leftSection={<Download size={15} />}
|
|
onClick={() => void downloadContractPdf()}
|
|
>
|
|
Download PDF
|
|
</Button>
|
|
</Group>
|
|
)}
|
|
</Stack>
|
|
</Stack>
|
|
</Paper>
|
|
|
|
<ContractWorkflowStepper
|
|
status={contract.status}
|
|
title={statusMeta.title}
|
|
description={statusMeta.description}
|
|
/>
|
|
|
|
{/* A contract resting in APPROVED means the automatic PDF generation on
|
|
final approval failed — on success it moves straight to
|
|
CONTRACT_READY. Offer the manual retry. */}
|
|
{contract.status === "APPROVED" ? (
|
|
<Alert
|
|
color="orange"
|
|
radius="md"
|
|
icon={<AlertTriangle size={18} />}
|
|
title="Contract document was not generated"
|
|
>
|
|
<Stack gap="sm" align="flex-start">
|
|
<Text size="sm">
|
|
All approvals are complete, but generating the contract PDF
|
|
failed. Retry the generation below.
|
|
</Text>
|
|
<Button
|
|
color="edr-green"
|
|
size="compact-sm"
|
|
radius="lg"
|
|
leftSection={<RefreshCw size={15} />}
|
|
loading={mutations.generateContract.isPending}
|
|
onClick={() => mutations.generateContract.mutate()}
|
|
>
|
|
Regenerate contract
|
|
</Button>
|
|
</Stack>
|
|
</Alert>
|
|
) : null}
|
|
|
|
{contract.status === "REJECTED" && contract.latestRejectionNote ? (
|
|
<Alert
|
|
color="red"
|
|
radius="md"
|
|
icon={<AlertTriangle size={18} />}
|
|
title="Rejection reason"
|
|
>
|
|
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
|
{contract.latestRejectionNote}
|
|
</Text>
|
|
</Alert>
|
|
) : null}
|
|
|
|
{contract.status === "PENDING_APPROVAL" && contract.latestSendBackNote ? (
|
|
<Alert
|
|
color="orange"
|
|
radius="md"
|
|
icon={<AlertTriangle size={18} />}
|
|
title="Sent back in the approval chain"
|
|
>
|
|
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
|
{contract.latestSendBackNote}
|
|
</Text>
|
|
</Alert>
|
|
) : null}
|
|
|
|
<Tabs
|
|
value={currentTab}
|
|
onChange={(v) => setTab(v ?? "details")}
|
|
variant="pills"
|
|
color="edr-green"
|
|
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
|
>
|
|
<Tabs.List>
|
|
<Tabs.Tab value="details" leftSection={<LayoutGrid size={16} />}>
|
|
Details
|
|
</Tabs.Tab>
|
|
<Tabs.Tab
|
|
value="documents"
|
|
leftSection={<Files size={16} />}
|
|
rightSection={
|
|
contractDocuments.length + profileDocuments.length > 0 ? (
|
|
<Badge size="xs" variant="light" color="gray" radius="sm">
|
|
{contractDocuments.length + profileDocuments.length}
|
|
</Badge>
|
|
) : null
|
|
}
|
|
>
|
|
Documents
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="customer" leftSection={<Users size={16} />}>
|
|
Customer
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="history" leftSection={<History size={16} />}>
|
|
History
|
|
</Tabs.Tab>
|
|
</Tabs.List>
|
|
</Tabs>
|
|
|
|
<Grid gap="lg">
|
|
{/* LEFT — primary content */}
|
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
|
{currentTab === "documents" ? (
|
|
<Stack gap="lg">
|
|
<ContractDocumentsCard
|
|
files={contractDocuments}
|
|
onView={handleViewFile}
|
|
onDownload={handleDownloadFile}
|
|
/>
|
|
<ContractDocumentsCard
|
|
files={profileDocuments}
|
|
title="Customer profile documents"
|
|
emptyText={
|
|
profileDocumentsQuery.isLoading
|
|
? "Loading customer documents…"
|
|
: "No profile documents on file for this customer."
|
|
}
|
|
onView={handleViewFile}
|
|
onDownload={handleDownloadFile}
|
|
/>
|
|
{(clearanceView?.workflowFiles?.length ?? 0) > 0 ? (
|
|
<ClearanceWorkflowFilesPanel
|
|
files={clearanceView!.workflowFiles!}
|
|
title="Customs workflow documents"
|
|
onView={view}
|
|
onDownload={(f) => void handleDownloadFile({ id: f.id, name: f.name } as never)}
|
|
/>
|
|
) : null}
|
|
</Stack>
|
|
) : currentTab === "history" ? (
|
|
<Stack gap="lg">
|
|
<SectionCard
|
|
icon={Milestone}
|
|
title="Key milestones"
|
|
subtitle="Submission, approval, signatures and validity — the dated record of this contract."
|
|
>
|
|
<ContractMilestonesTimeline contract={contract} />
|
|
</SectionCard>
|
|
<SectionCard
|
|
icon={History}
|
|
title="Change history"
|
|
subtitle="Every recorded edit to this contract — who changed what, and when."
|
|
>
|
|
<ContractRevisionTimeline contractId={contract.id} bare />
|
|
</SectionCard>
|
|
</Stack>
|
|
) : currentTab === "customer" ? (
|
|
<ContractCustomerCard contract={contract} />
|
|
) : (
|
|
<Stack gap="lg">
|
|
<SectionCard
|
|
icon={Info}
|
|
title="Contract information"
|
|
subtitle="Full commercial and operational detail for this contract."
|
|
>
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
|
<InfoRow
|
|
label="Service type"
|
|
value={contract.serviceType?.serviceName ?? "—"}
|
|
/>
|
|
<InfoRow
|
|
label="Payment currency"
|
|
value={contract.paymentCurrency ?? "—"}
|
|
/>
|
|
<InfoRow
|
|
label="Customs clearing"
|
|
value={
|
|
contract.customsClearingEnabled
|
|
? "Included automatically"
|
|
: contract.customsClearingAgent
|
|
? `Customer's agent — ${contract.customsClearingAgent}`
|
|
: "Not included"
|
|
}
|
|
/>
|
|
{contract.equipmentReturn ? (
|
|
<InfoRow
|
|
label="Equipment return"
|
|
value={
|
|
contract.equipmentReturn === "WITH_RETURN"
|
|
? "With return"
|
|
: "Without return"
|
|
}
|
|
/>
|
|
) : null}
|
|
<InfoRow
|
|
label="Contract type"
|
|
value={contract.contractType ?? "Standard"}
|
|
/>
|
|
{contract.contractValidityDays != null ? (
|
|
<InfoRow
|
|
label="Validity period"
|
|
value={`${contract.contractValidityDays} days`}
|
|
/>
|
|
) : null}
|
|
{contract.estimatedShipmentDate ? (
|
|
<InfoRow
|
|
label="Estimated shipment date"
|
|
value={formatDate(contract.estimatedShipmentDate)}
|
|
/>
|
|
) : null}
|
|
{contract.firstMilePickupAddress ? (
|
|
<InfoRow
|
|
label="First-mile pickup"
|
|
value={contract.firstMilePickupAddress}
|
|
/>
|
|
) : null}
|
|
{contract.lastMileDeliveryAddress ? (
|
|
<InfoRow
|
|
label="Last-mile delivery"
|
|
value={contract.lastMileDeliveryAddress}
|
|
/>
|
|
) : null}
|
|
</SimpleGrid>
|
|
{contract.financialTerms ? (
|
|
<Box
|
|
mt="md"
|
|
pt="md"
|
|
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
|
|
>
|
|
<Text
|
|
size="xs"
|
|
c="dimmed"
|
|
fw={600}
|
|
tt="uppercase"
|
|
mb={4}
|
|
style={{ letterSpacing: 0.3 }}
|
|
>
|
|
Financial terms
|
|
</Text>
|
|
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
|
{contract.financialTerms}
|
|
</Text>
|
|
</Box>
|
|
) : null}
|
|
</SectionCard>
|
|
|
|
<SectionCard
|
|
icon={ShieldCheck}
|
|
title="Approval & signing timeline"
|
|
subtitle="Every dated step in this contract's approval chain, plus signatures — the same record kept in the sidebar, always visible here."
|
|
>
|
|
<ContractMilestonesTimeline contract={contract} />
|
|
</SectionCard>
|
|
|
|
<SectionCard icon={RouteIcon} title="Routes">
|
|
{routes.length === 0 ? (
|
|
<Text size="sm" c="dimmed">
|
|
No routes on this contract.
|
|
</Text>
|
|
) : (
|
|
<Stack gap="sm">
|
|
{routes.map((r) => (
|
|
<Group
|
|
key={r.id}
|
|
justify="space-between"
|
|
wrap="nowrap"
|
|
px="sm"
|
|
py="xs"
|
|
style={{
|
|
borderRadius: 8,
|
|
border: "1px solid var(--mantine-color-gray-2)",
|
|
}}
|
|
>
|
|
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
<Text size="sm" fw={600} truncate maw={160}>
|
|
{r.originYard?.label ??
|
|
r.originYard?.code ??
|
|
"Origin"}
|
|
</Text>
|
|
<ArrowRight
|
|
size={15}
|
|
className="shrink-0 text-muted-foreground"
|
|
/>
|
|
<Text size="sm" fw={600} truncate maw={160}>
|
|
{r.destinationYard?.label ??
|
|
r.destinationYard?.code ??
|
|
"Destination"}
|
|
</Text>
|
|
</Group>
|
|
{r.km != null ? (
|
|
<Badge variant="light" color="gray" radius="sm">
|
|
{r.km} km
|
|
</Badge>
|
|
) : null}
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</SectionCard>
|
|
|
|
<SectionCard icon={Package} title="Cargo scope">
|
|
<Group gap="sm" mb="md">
|
|
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
|
{directionLabel(contract.tradeDirection)}
|
|
</Badge>
|
|
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
|
{contract.freightType}
|
|
</Badge>
|
|
{contract.isHazardous ? (
|
|
<Badge
|
|
variant="light"
|
|
color="orange"
|
|
radius="sm"
|
|
leftSection={<Flame size={12} />}
|
|
>
|
|
Hazardous
|
|
</Badge>
|
|
) : null}
|
|
{contract.isReefer ? (
|
|
<Badge
|
|
variant="light"
|
|
color="cyan"
|
|
radius="sm"
|
|
leftSection={<Snowflake size={12} />}
|
|
>
|
|
Reefer
|
|
</Badge>
|
|
) : null}
|
|
</Group>
|
|
{contract.isHazardous ? (
|
|
<Box mb="md">
|
|
<HazardDeclarationPanel contract={contract} />
|
|
</Box>
|
|
) : null}
|
|
{(contract.cargoScope ?? []).length === 0 ? (
|
|
<Text size="sm" c="dimmed">
|
|
No cargo scope lines.
|
|
</Text>
|
|
) : (
|
|
<Stack gap="sm">
|
|
{(contract.cargoScope ?? []).map((s) => {
|
|
const isContainer = Boolean(s.containerSize);
|
|
// Bulk lines carry their commodity detail (name + unit);
|
|
// container lines carry the size (20ft / 40ft).
|
|
const title = isContainer
|
|
? `${s.containerSize} container`
|
|
: (s.cargoType?.cargoTypeName ??
|
|
s.cargoFreeText ??
|
|
s.cargoType?.code ??
|
|
"Bulk cargo");
|
|
// quantityCap unit: containers for a size line, else the
|
|
// cargo type's unit of measure (tons / items / …), default tons.
|
|
const capUnit = isContainer
|
|
? "containers"
|
|
: (s.cargoType?.unitOfMeasure?.toLowerCase() ?? "tons");
|
|
return (
|
|
<Group key={s.id} gap={8} wrap="nowrap" align="flex-start">
|
|
<BoxIcon
|
|
size={15}
|
|
color="var(--mantine-color-edr-green-6)"
|
|
style={{ marginTop: 2, flexShrink: 0 }}
|
|
/>
|
|
<div>
|
|
<Text size="sm" fw={500}>
|
|
{title}
|
|
</Text>
|
|
<Group gap={6} mt={2}>
|
|
<Badge
|
|
variant="light"
|
|
color={isContainer ? "blue" : "grape"}
|
|
radius="sm"
|
|
size="xs"
|
|
tt="uppercase"
|
|
>
|
|
{isContainer ? "Container" : "Bulk"}
|
|
</Badge>
|
|
{s.cargoType?.code ? (
|
|
<Text size="xs" c="dimmed">
|
|
Code: {s.cargoType.code}
|
|
</Text>
|
|
) : null}
|
|
<Text size="xs" c="dimmed">
|
|
{s.quantityCap != null
|
|
? `Cap: ${s.quantityCap} ${capUnit}`
|
|
: "Cap: uncapped"}
|
|
</Text>
|
|
</Group>
|
|
</div>
|
|
</Group>
|
|
);
|
|
})}
|
|
</Stack>
|
|
)}
|
|
</SectionCard>
|
|
|
|
{contract.pricingBreakdown?.lineItems?.length ? (
|
|
<SectionCard icon={Receipt} title="Unit rates">
|
|
<Stack gap="xs">
|
|
{contract.pricingBreakdown.lineItems.map((li) => (
|
|
<Group
|
|
key={li.code}
|
|
justify="space-between"
|
|
wrap="nowrap"
|
|
>
|
|
<Text size="sm" truncate>
|
|
{li.label}
|
|
{li.containerSize ? ` · ${li.containerSize}` : ""}
|
|
</Text>
|
|
<Text size="sm" fw={600}>
|
|
{contract.pricingBreakdown?.currency} {li.unitPrice} /{" "}
|
|
{li.unit}
|
|
</Text>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
</SectionCard>
|
|
) : null}
|
|
|
|
{contract.contractSummary ? (
|
|
<SectionCard icon={FileText} title="Contract summary">
|
|
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
|
{contract.contractSummary}
|
|
</Text>
|
|
</SectionCard>
|
|
) : null}
|
|
</Stack>
|
|
)}
|
|
</Grid.Col>
|
|
|
|
{/* RIGHT — sticky action rail */}
|
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
|
<Box style={{ position: "sticky", top: 24 }}>
|
|
<Stack gap="lg">
|
|
<ContractActionsToolbar
|
|
contract={contract}
|
|
mutations={mutations}
|
|
onReviewClearance={
|
|
inClearanceReview
|
|
? () =>
|
|
navigate(
|
|
`/dashboard/contracts/clearance-documents/${contract.id}`,
|
|
)
|
|
: undefined
|
|
}
|
|
/>
|
|
{showApprovalCard && (
|
|
<ContractApprovalStepsCard
|
|
contract={contract}
|
|
mutations={mutations}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
</Grid.Col>
|
|
</Grid>
|
|
</Stack>
|
|
|
|
{viewer}
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div>
|
|
<Text
|
|
size="xs"
|
|
c="dimmed"
|
|
fw={600}
|
|
tt="uppercase"
|
|
style={{ letterSpacing: 0.3 }}
|
|
>
|
|
{label}
|
|
</Text>
|
|
<Text size="sm" fw={500} mt={2}>
|
|
{value}
|
|
</Text>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetaItem({
|
|
icon: Icon,
|
|
text,
|
|
}: {
|
|
icon: typeof Building2;
|
|
text: string;
|
|
}) {
|
|
return (
|
|
<Group gap={6} wrap="nowrap">
|
|
<Icon size={14} color="var(--mantine-color-gray-5)" />
|
|
<Text size="sm" fw={600} c="dark">
|
|
{text}
|
|
</Text>
|
|
</Group>
|
|
);
|
|
}
|