Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx
2026-08-06 20:05:13 +00:00

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 &amp; 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>
);
}