feat: enhance contract detail page with document grouping and clearance modal

- Added functionality to group contract documents into categories: Contract, Profile, and Clearance.
- Implemented a modal for uploading and managing clearance documents.
- Created a new ActionNeededSection component to display pending customer actions.
- Introduced deriveActionItems function to generate actionable items based on contract and booking statuses.
- Developed ContractClearancePanel for handling clearance document uploads and reviews.
- Added support for ad-hoc document uploads in the clearance panel.
This commit is contained in:
Marshal
2026-06-28 15:25:22 +00:00
parent da533a488d
commit 2de1c2e7be
8 changed files with 921 additions and 494 deletions

View File

@@ -294,7 +294,15 @@ export class ContractClearanceService {
note?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
// Reviewing is allowed both while the batch is UNDER_REVIEW and after it has
// dropped back to AWAITING_CLEARANCE_DOCUMENTS — querying one document flips
// the contract to "awaiting" (the customer must re-upload), but the reviewer
// may still be working through the rest of the batch. Restricting to
// UNDER_REVIEW only would 409 every review after the first query.
if (
contract.status !== 'CLEARANCE_UNDER_REVIEW' &&
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS'
) {
throw new ConflictException(
`Cannot review clearance documents on status "${contract.status}".`,
);

View File

@@ -5,6 +5,7 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import {
ActionNeededSection,
FreightVolumeSection,
HelloSection,
InvoicesSection,
@@ -13,6 +14,7 @@ import {
ShipmentsSection,
StatsSection,
} from "./components";
import { deriveActionItems } from "./actions";
import { useMyPortalData } from "./hooks";
export default function MyPortalPage() {
@@ -25,6 +27,7 @@ export default function MyPortalPage() {
bookingsQuery,
dashboardQuery,
contractsQuery,
allContracts,
recentContracts,
activeContractsCount,
allBookings,
@@ -45,6 +48,8 @@ export default function MyPortalPage() {
label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`,
}));
const actionItems = deriveActionItems(allContracts, allBookings);
const handleBookingClick = (id: string) => {
navigate(`/bookings/${id}`);
};
@@ -53,6 +58,8 @@ export default function MyPortalPage() {
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
<HelloSection greeting={greeting} companyName={companyName} />
<ActionNeededSection items={actionItems} />
{serviceOptions.length > 1 && (
<Group justify="flex-end">
<Select

View File

@@ -0,0 +1,96 @@
import type { Freight } from "@edr/types";
/** A pending customer action surfaced on the home "needs attention" card. */
export interface ActionItem {
id: string;
/** What the customer must do — drives the icon, label and modal. */
kind: "clearance" | "sign" | "book" | "pay";
/** The contract/booking reference for display. */
reference: string;
/** Short human description of the action. */
description: string;
/** Contract id (clearance/sign/book) or booking id (pay). */
targetId: string;
/** True for queried clearance (a document was sent back for correction). */
urgent?: boolean;
}
const CLEARANCE_UPLOAD_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
];
/**
* Derive the list of pending customer actions from the customer's contracts and
* bookings, using status alone (no extra per-record fetch). A contract back in
* AWAITING_CLEARANCE_DOCUMENTS after review means a document was queried and
* needs the customer's attention — flagged urgent.
*/
export function deriveActionItems(
contracts: Freight.IContract[],
bookings: Freight.IBooking[],
): ActionItem[] {
const items: ActionItem[] = [];
for (const c of contracts) {
if (c.status === "CONTRACT_READY") {
items.push({
id: `sign-${c.id}`,
kind: "sign",
reference: c.reference,
description: "Contract ready to sign",
targetId: c.id,
});
continue;
}
if (CLEARANCE_UPLOAD_STATUSES.includes(c.status)) {
const queried = c.status === "AWAITING_CLEARANCE_DOCUMENTS";
items.push({
id: `clearance-${c.id}`,
kind: "clearance",
reference: c.reference,
description: queried
? "Clearance document needs correction"
: "Upload clearance documents",
targetId: c.id,
urgent: queried,
});
continue;
}
// Path A transport-only: customer may create the shipment booking.
if (
!c.customsClearingEnabled &&
(c.status === "FULLY_EXECUTED" || c.status === "CONTRACT_ACTIVE")
) {
items.push({
id: `book-${c.id}`,
kind: "book",
reference: c.reference,
description: "Ready to book a shipment",
targetId: c.id,
});
}
}
for (const b of bookings) {
const isGeneral = b.bookingType === "GENERAL_CONTRACT";
const canPay =
b.paymentStatus !== "PAID" &&
(isGeneral
? b.status === "FULLY_EXECUTED"
: b.status === "SELECTED_FOR_BATCH");
if (canPay) {
items.push({
id: `pay-${b.id}`,
kind: "pay",
reference: b.reference,
description: "Payment due for this shipment",
targetId: b.id,
urgent: true,
});
}
}
// Surface the most pressing (urgent) actions first.
return items.sort((a, b) => Number(b.urgent ?? 0) - Number(a.urgent ?? 0));
}

View File

@@ -0,0 +1,205 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { Badge, Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
import {
AlertTriangle,
CreditCard,
FilePlus2,
FileSignature,
PackagePlus,
Upload,
} from "lucide-react";
import { api } from "@/services/api";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel";
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
import { Card } from "./Card";
import type { ActionItem } from "../actions";
const KIND_META: Record<
ActionItem["kind"],
{ icon: typeof Upload; label: string; color: string }
> = {
clearance: { icon: Upload, label: "Clearance", color: "edr-green" },
sign: { icon: FileSignature, label: "Sign", color: "blue" },
book: { icon: PackagePlus, label: "Book", color: "violet" },
pay: { icon: CreditCard, label: "Payment", color: "orange" },
};
export interface ActionNeededSectionProps {
items: ActionItem[];
}
/**
* Home "needs your attention" card. Lists pending customer actions across
* contracts and bookings. Clearance and payment open in a modal right here;
* sign and book navigate to the relevant page.
*/
export function ActionNeededSection({ items }: ActionNeededSectionProps) {
const navigate = useNavigate();
const [clearanceId, setClearanceId] = useState<string | null>(null);
const [payItem, setPayItem] = useState<ActionItem | null>(null);
// Mirror ReadonlyBookingView: POST /payments/initiate returns the provider's
// redirect (clientAction.url); fall back to the public checkout page.
const payMutation = useMutation({
mutationFn: (method: PaymentMethod) =>
api.payments.initiate.call({ bookingId: payItem!.targetId, method }),
onSuccess: (data, method) => {
const bookingId = payItem!.targetId;
const url =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrl({ bookingId, method });
window.location.href = url;
},
});
if (items.length === 0) return null;
const handleClick = (item: ActionItem) => {
switch (item.kind) {
case "clearance":
setClearanceId(item.targetId);
break;
case "pay":
setPayItem(item);
break;
case "sign":
navigate(`/contracts/${item.targetId}/view`);
break;
case "book":
navigate(`/contracts/${item.targetId}/bookings/new`);
break;
}
};
return (
<Card padding={0}>
<Group justify="space-between" align="center" px={24} pt={20} pb={12}>
<Group gap={8}>
<AlertTriangle size={18} className="text-amber-500" />
<Text fw={700} fz={16} c="edr-text">
Needs your attention
</Text>
</Group>
<Badge color="orange" variant="light" radius="sm">
{items.length}
</Badge>
</Group>
<Stack gap={0}>
{items.map((item, i) => {
const meta = KIND_META[item.kind];
const Icon = meta.icon;
return (
<Group
key={item.id}
justify="space-between"
wrap="nowrap"
px={24}
py={14}
style={{
borderTop:
i === 0
? "none"
: "1px solid var(--mantine-color-gray-2)",
cursor: "pointer",
}}
onClick={() => handleClick(item)}
className="hover:bg-edr-soft"
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
width: 36,
height: 36,
flexShrink: 0,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: `var(--mantine-color-${meta.color}-light)`,
color: `var(--mantine-color-${meta.color}-filled)`,
}}
>
<Icon size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={600} fz={14} c="edr-text" truncate>
{item.reference}
</Text>
{item.urgent && (
<Badge size="xs" color="red" variant="light" radius="sm">
Action required
</Badge>
)}
</Group>
<Text fz={12.5} c="dimmed" truncate>
{item.description}
</Text>
</Box>
</Group>
<Button
size="compact-sm"
variant="light"
color={meta.color}
radius="md"
leftSection={<FilePlus2 size={14} />}
>
{item.kind === "pay"
? "Pay"
: item.kind === "sign"
? "Sign"
: item.kind === "book"
? "Book"
: "Resolve"}
</Button>
</Group>
);
})}
</Stack>
<Modal
opened={clearanceId !== null}
onClose={() => setClearanceId(null)}
title={
<Text fw={700} fz={16}>
Clearance documents
</Text>
}
size="xl"
radius="md"
centered
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
>
{clearanceId && (
<ContractClearancePanel contractId={clearanceId} bare />
)}
</Modal>
<PaymentMethodModal
opened={payItem !== null}
onClose={() => {
if (!payMutation.isPending) {
setPayItem(null);
payMutation.reset();
}
}}
currency={undefined}
processing={payMutation.isPending}
error={
payMutation.isError
? payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again."
: null
}
onConfirm={(method) => payMutation.mutate(method)}
/>
</Card>
);
}

View File

@@ -1,3 +1,4 @@
export { ActionNeededSection } from "./ActionNeededSection";
export { ActivityRow } from "./ActivityRow";
export { BookingRow } from "./BookingRow";
export { Card } from "./Card";

View File

@@ -1,186 +1,43 @@
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Center,
FileButton,
Group,
Loader,
Paper,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import {
AlertCircle,
ArrowLeft,
CheckCircle2,
Clock,
Download,
Eye,
FileText,
Plus,
Upload,
} from "lucide-react";
import { ArrowLeft, Upload } from "lucide-react";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig";
import { useFileViewer } from "@/hooks/useFileViewer";
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
import { BORDER, ContractStatusBadge, INK } from "./contract-ui";
const GREEN = "#0A6F4D";
type AdHocDoc = { name: string; file: File | null };
function StatusPill({ doc }: { doc: Freight.ContractClearanceDocument }) {
if (doc.reviewStatus === "APPROVED") {
return (
<Group gap={6} c={GREEN}>
<CheckCircle2 size={15} />
<Text fz="12px" fw={600} c={GREEN}>
Approved
</Text>
</Group>
);
}
if (doc.reviewStatus === "QUERIED") {
return (
<Group gap={6} c="#C0392B">
<AlertCircle size={15} />
<Text fz="12px" fw={600} c="#C0392B">
Queried
</Text>
</Group>
);
}
if (doc.file) {
return (
<Group gap={6} c="#2E5B96">
<Clock size={15} />
<Text fz="12px" fw={600} c="#2E5B96">
Pending review
</Text>
</Group>
);
}
return (
<Text fz="12px" fw={600} c="#9AA8B5">
Not uploaded
</Text>
);
}
import { ContractStatusBadge, INK } from "./contract-ui";
import { ContractClearancePanel } from "./ContractClearancePanel";
/**
* Path B customer clearance upload on the CONTRACT (doc §8.3). Mirrors the
* booking `ClearanceFlow` but targets the contract clearance endpoints — no
* booking exists yet. The customer uploads required documents (and duty/tax
* slips when advised), Global Logistics reviews them, and after approval GL
* creates the booking on the customer's behalf.
* Customer clearance upload page for a CONTRACT (doc §8.3). The document grid +
* upload logic live in {@link ContractClearancePanel} so the same workspace can
* render here (full page) or inside the contract action modal.
*/
export default function ContractClearanceFlow() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [pending, setPending] = useState<Record<string, File>>({});
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
const { view, viewer } = useFileViewer();
const { data: contract } = useQuery(
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
);
const clearanceQuery = useQuery(
const { data: clearance } = useQuery(
api.contracts.getClearance.queryOptions({
input: { id: id! },
enabled: !!id,
}),
);
const clearance = clearanceQuery.data;
const uploadMutation = useMutation({
...api.contracts.uploadClearanceDocuments.mutationOptions(),
onSuccess: () => {
setPending({});
setAdHoc([]);
queryClient.invalidateQueries({
queryKey: api.contracts.getClearance.queryKey({ id: id! }),
});
queryClient.invalidateQueries({
queryKey: api.contracts.get.queryKey({ id: id! }),
});
},
});
const customerDocs = useMemo(
() =>
(clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance],
);
const glDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy !== "customer"),
[clearance],
);
const status = clearance?.clearanceStatus ?? "AWAITING_DOCUMENTS";
const isUnderReview = status === "DOCUMENTS_UNDER_REVIEW";
const isReady =
status === "CLEARANCE_READY_FOR_BOOKING" ||
status === "SELF_CLEARED" ||
status === "ACTIVE_SHIPMENT_IN_PROGRESS";
const canUpload = status === "AWAITING_DOCUMENTS" || isUnderReview;
const isInitialUpload = status === "AWAITING_DOCUMENTS";
// Path B (customs) is reviewed by Global Logistics and GL creates the booking;
// Path A self-clearance is reviewed by the Operations team and the customer
// creates the booking himself afterward.
const customsPath = clearance?.includesCustoms ?? true;
const reviewer = customsPath ? "Global Logistics" : "the Operations team";
const missingRequired = useMemo(
() => customerDocs.filter((d) => d.required && !d.file && !pending[d.fileKey]),
[customerDocs, pending],
);
const hasStagedFiles =
Object.keys(pending).length > 0 || adHoc.some((r) => r.file);
const canSubmit = isInitialUpload
? hasStagedFiles && missingRequired.length === 0
: hasStagedFiles;
const stagePending = (fileKey: string, file: File) =>
setPending((p) => ({ ...p, [fileKey]: file }));
const submitDocuments = () => {
const files: Record<string, File | null> = { ...pending };
adHoc.forEach((row, i) => {
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
});
if (Object.keys(files).length === 0) return;
uploadMutation.mutate({ id: id!, files });
};
if (clearanceQuery.isLoading) {
return (
<Center mih={400} p="xl">
<Loader color="edr-green" />
</Center>
);
}
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
@@ -212,277 +69,8 @@ export default function ContractClearanceFlow() {
</Group>
</Group>
<Paper withBorder radius={20} p="lg" style={{ borderColor: BORDER }}>
<Stack gap={0}>
{isReady ? (
<Alert
color="teal"
radius="md"
icon={<CheckCircle2 size={18} />}
mb="md"
>
{customsPath
? "Your clearance documents are approved. Global Logistics will create your booking — you will be notified when payment is due."
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
</Alert>
) : isUnderReview ? (
<Alert
color="blue"
radius="md"
icon={<Clock size={18} />}
mb="md"
>
{reviewer.charAt(0).toUpperCase() + reviewer.slice(1)} is
reviewing your documents. Only re-upload the documents flagged
with a query below approved documents stay as they are.
</Alert>
) : (
<Alert
color="yellow"
radius="md"
icon={<AlertCircle size={18} />}
mb="md"
>
{customsPath
? "Upload every required clearance document (marked *) below to start the review. Global Logistics will clear your shipment and create the booking for you."
: "This service does not include EDR customs clearance — clear the cargo yourself and upload every required clearance document (marked *) below. The Operations team will review them before you can book a shipment."}
</Alert>
)}
{isInitialUpload && missingRequired.length > 0 && (
<Alert color="yellow" variant="light" radius="md" mb="md" p="xs">
<Text fz="12px" c="#9A5B00">
Still required:{" "}
{missingRequired.map((d) => d.label).join(", ")}
</Text>
</Alert>
)}
{/* Required customer documents */}
<Stack gap={10}>
{customerDocs.map((doc) => (
<Box
key={doc.fileKey}
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 12 }}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<Box c="#2E5B96">
<FileText size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz="13.5px" fw={600} c="#10202F" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
{doc.file && (
<Text fz="12px" c="dimmed" truncate>
{doc.file.name}
</Text>
)}
</Box>
</Group>
<Group gap={10} wrap="nowrap">
<StatusPill doc={doc} />
{doc.file &&
isViewable({
name: doc.file.name,
url: fileViewUrl(doc.file.id),
}) && (
<IconSquare
icon={<Eye size={15} />}
onClick={() =>
view({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
})
}
/>
)}
{doc.file && (
<IconSquare
href={fileViewUrl(doc.file.id, true)}
icon={<Download size={15} />}
/>
)}
{canUpload && doc.reviewStatus !== "APPROVED" && (
<FileButton
onChange={(f) => f && stagePending(doc.fileKey, f)}
accept="application/pdf,image/*"
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Upload size={13} />}
>
{pending[doc.fileKey] ? "Selected" : "Upload"}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
{doc.reviewStatus === "QUERIED" && doc.note && (
<Text fz="12px" c="#C0392B" mt={6}>
Query: {doc.note}
</Text>
)}
{pending[doc.fileKey] && (
<Text fz="12px" c={GREEN} mt={6}>
Ready to upload: {pending[doc.fileKey].name}
</Text>
)}
</Box>
))}
{customerDocs.length === 0 && (
<Text fz="sm" c="dimmed">
No clearance documents are configured for this contract yet.
</Text>
)}
</Stack>
{/* GL output documents (read-only). */}
{glDocs.length > 0 && (
<>
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
Customs output documents
</Text>
<Stack gap={8}>
{glDocs.map((doc) => (
<Group
key={doc.fileKey}
justify="space-between"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 10 }}
>
<Text fz="13px" c="#10202F" truncate>
{doc.label}
</Text>
{doc.file ? (
<Group gap={8} wrap="nowrap">
{isViewable({
name: doc.file.name,
url: fileViewUrl(doc.file.id),
}) && (
<IconSquare
icon={<Eye size={15} />}
onClick={() =>
view({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
})
}
/>
)}
<IconSquare
href={fileViewUrl(doc.file.id, true)}
icon={<Download size={15} />}
/>
</Group>
) : (
<Text fz="12px" c="#9AA8B5">
Pending
</Text>
)}
</Group>
))}
</Stack>
</>
)}
{/* Ad-hoc / additional documents. */}
{canUpload && (
<Box mt="lg">
<Group justify="space-between" align="center" mb={8}>
<Text fz="12.5px" fw={700} c="#10202F">
Additional documents
</Text>
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Plus size={13} />}
onClick={() =>
setAdHoc((r) => [...r, { name: "", file: null }])
}
>
Add document
</Button>
</Group>
<Stack gap={8}>
{adHoc.map((row, i) => (
<Group key={i} gap={8} wrap="nowrap">
<TextInput
placeholder="Document name"
value={row.name}
onChange={(e) =>
setAdHoc((rows) =>
rows.map((r, j) =>
j === i
? { ...r, name: e.currentTarget.value }
: r,
),
)
}
style={{ flex: 1 }}
radius="md"
/>
<FileButton
onChange={(f) =>
setAdHoc((rows) =>
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
)
}
accept="application/pdf,image/*"
>
{(props) => (
<Button {...props} variant="default" radius="md">
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
</Button>
)}
</FileButton>
</Group>
))}
</Stack>
</Box>
)}
{uploadMutation.isError && (
<Alert
color="red"
radius="md"
icon={<AlertCircle size={16} />}
mt="md"
>
{uploadMutation.error instanceof Error
? uploadMutation.error.message
: "Upload failed. Please try again."}
</Alert>
)}
{canUpload && (
<Group justify="flex-end" mt="lg">
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
disabled={!canSubmit}
loading={uploadMutation.isPending}
onClick={submitDocuments}
>
{isInitialUpload ? "Submit documents" : "Re-upload documents"}
</Button>
</Group>
)}
</Stack>
</Paper>
{id && <ContractClearancePanel contractId={id} />}
</Stack>
{viewer}
</Box>
);
}

View File

@@ -0,0 +1,423 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Center,
FileButton,
Group,
Loader,
Paper,
Stack,
Text,
TextInput,
} from "@mantine/core";
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
Eye,
FileText,
Plus,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig";
import { useFileViewer } from "@/hooks/useFileViewer";
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
const GREEN = "#0A6F4D";
const BORDER = "#E6ECF2";
type AdHocDoc = { name: string; file: File | null };
function StatusPill({ doc }: { doc: Freight.ContractClearanceDocument }) {
if (doc.reviewStatus === "APPROVED") {
return (
<Group gap={6} c={GREEN}>
<CheckCircle2 size={15} />
<Text fz="12px" fw={600} c={GREEN}>
Approved
</Text>
</Group>
);
}
if (doc.reviewStatus === "QUERIED") {
return (
<Group gap={6} c="#C0392B">
<AlertCircle size={15} />
<Text fz="12px" fw={600} c="#C0392B">
Query
</Text>
</Group>
);
}
if (doc.file) {
return (
<Text fz="12px" fw={600} c="#6B7C8E">
Uploaded
</Text>
);
}
return null;
}
export interface ContractClearancePanelProps {
contractId: string;
/** Show the loading state without the surrounding Paper (e.g. inside a modal). */
bare?: boolean;
}
/**
* The clearance document workspace for a contract: shows the customer-input doc
* grid + GL output docs, lets the customer upload / re-upload (only queried docs
* after first submission), and surfaces query notes. Rendered both on the
* standalone clearance page and inside the contract action modal. See
* docs/new-doc.md §8.3.
*/
export function ContractClearancePanel({
contractId,
bare,
}: ContractClearancePanelProps) {
const queryClient = useQueryClient();
const [pending, setPending] = useState<Record<string, File>>({});
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
const { view, viewer } = useFileViewer();
const clearanceQuery = useQuery(
api.contracts.getClearance.queryOptions({
input: { id: contractId },
enabled: !!contractId,
}),
);
const clearance = clearanceQuery.data;
const uploadMutation = useMutation({
...api.contracts.uploadClearanceDocuments.mutationOptions(),
onSuccess: () => {
setPending({});
setAdHoc([]);
queryClient.invalidateQueries({
queryKey: api.contracts.getClearance.queryKey({ id: contractId }),
});
queryClient.invalidateQueries({
queryKey: api.contracts.get.queryKey({ id: contractId }),
});
},
});
const customerDocs = useMemo(
() =>
(clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance],
);
const glDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy !== "customer"),
[clearance],
);
const status = clearance?.clearanceStatus ?? "AWAITING_DOCUMENTS";
const isUnderReview = status === "DOCUMENTS_UNDER_REVIEW";
const isReady =
status === "CLEARANCE_READY_FOR_BOOKING" ||
status === "SELF_CLEARED" ||
status === "ACTIVE_SHIPMENT_IN_PROGRESS";
const canUpload = status === "AWAITING_DOCUMENTS" || isUnderReview;
const isInitialUpload = status === "AWAITING_DOCUMENTS";
const customsPath = clearance?.includesCustoms ?? true;
const reviewer = customsPath ? "Global Logistics" : "the Operations team";
const missingRequired = useMemo(
() => customerDocs.filter((d) => d.required && !d.file && !pending[d.fileKey]),
[customerDocs, pending],
);
const hasStagedFiles =
Object.keys(pending).length > 0 || adHoc.some((r) => r.file);
const canSubmit = isInitialUpload
? hasStagedFiles && missingRequired.length === 0
: hasStagedFiles;
const stagePending = (fileKey: string, file: File) =>
setPending((p) => ({ ...p, [fileKey]: file }));
const submitDocuments = () => {
const files: Record<string, File | null> = { ...pending };
adHoc.forEach((row, i) => {
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
});
if (Object.keys(files).length === 0) return;
uploadMutation.mutate({ id: contractId, files });
};
if (clearanceQuery.isLoading) {
return (
<Center mih={bare ? 200 : 400} p="xl">
<Loader color="edr-green" />
</Center>
);
}
const body = (
<Stack gap={0}>
{isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
{customsPath
? "Your clearance documents are approved. Global Logistics will create your booking — you will be notified when payment is due."
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
</Alert>
) : isUnderReview ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
{reviewer.charAt(0).toUpperCase() + reviewer.slice(1)} is reviewing
your documents. Only re-upload the documents flagged with a query
below approved documents stay as they are.
</Alert>
) : (
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
{customsPath
? "Upload every required clearance document (marked *) below to start the review. Global Logistics will clear your shipment and create the booking for you."
: "This service does not include EDR customs clearance — clear the cargo yourself and upload every required clearance document (marked *) below. The Operations team will review them before you can book a shipment."}
</Alert>
)}
{isInitialUpload && missingRequired.length > 0 && (
<Alert color="yellow" variant="light" radius="md" mb="md" p="xs">
<Text fz="12px" c="#9A5B00">
Still required: {missingRequired.map((d) => d.label).join(", ")}
</Text>
</Alert>
)}
{/* Required customer documents */}
<Stack gap={10}>
{customerDocs.map((doc) => (
<Box
key={doc.fileKey}
className="rounded-xl"
style={{ border: `1px solid ${BORDER}`, padding: 12 }}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<Box c="#2E5B96">
<FileText size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz="13.5px" fw={600} c="#10202F" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
{doc.file && (
<Text fz="12px" c="dimmed" truncate>
{doc.file.name}
</Text>
)}
</Box>
</Group>
<Group gap={10} wrap="nowrap">
<StatusPill doc={doc} />
{doc.file &&
isViewable({
name: doc.file.name,
url: fileViewUrl(doc.file.id),
}) && (
<IconSquare
icon={<Eye size={15} />}
onClick={() =>
view({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
})
}
/>
)}
{doc.file && (
<IconSquare
href={fileViewUrl(doc.file.id, true)}
icon={<Download size={15} />}
/>
)}
{canUpload && doc.reviewStatus !== "APPROVED" && (
<FileButton
onChange={(f) => f && stagePending(doc.fileKey, f)}
accept="application/pdf,image/*"
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Upload size={13} />}
>
{pending[doc.fileKey] ? "Selected" : "Upload"}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
{doc.reviewStatus === "QUERIED" && doc.note && (
<Text fz="12px" c="#C0392B" mt={6}>
Query: {doc.note}
</Text>
)}
{pending[doc.fileKey] && (
<Text fz="12px" c={GREEN} mt={6}>
Ready to upload: {pending[doc.fileKey].name}
</Text>
)}
</Box>
))}
{customerDocs.length === 0 && (
<Text fz="sm" c="dimmed">
No clearance documents are configured for this contract yet.
</Text>
)}
</Stack>
{/* GL output documents (read-only). */}
{glDocs.length > 0 && (
<>
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
Customs output documents
</Text>
<Stack gap={8}>
{glDocs.map((doc) => (
<Group
key={doc.fileKey}
justify="space-between"
wrap="nowrap"
className="rounded-xl"
style={{ border: `1px solid ${BORDER}`, padding: 10 }}
>
<Text fz="13px" c="#10202F" truncate>
{doc.label}
</Text>
{doc.file ? (
<Group gap={8} wrap="nowrap">
{isViewable({
name: doc.file.name,
url: fileViewUrl(doc.file.id),
}) && (
<IconSquare
icon={<Eye size={15} />}
onClick={() =>
view({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
})
}
/>
)}
<IconSquare
href={fileViewUrl(doc.file.id, true)}
icon={<Download size={15} />}
/>
</Group>
) : (
<Text fz="12px" c="#9AA8B5">
Pending
</Text>
)}
</Group>
))}
</Stack>
</>
)}
{/* Ad-hoc / additional documents. */}
{canUpload && (
<Box mt="lg">
<Group justify="space-between" align="center" mb={8}>
<Text fz="12.5px" fw={700} c="#10202F">
Additional documents
</Text>
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Plus size={13} />}
onClick={() => setAdHoc((r) => [...r, { name: "", file: null }])}
>
Add document
</Button>
</Group>
<Stack gap={8}>
{adHoc.map((row, i) => (
<Group key={i} gap={8} wrap="nowrap">
<TextInput
placeholder="Document name"
value={row.name}
onChange={(e) =>
setAdHoc((rows) =>
rows.map((r, j) =>
j === i ? { ...r, name: e.currentTarget.value } : r,
),
)
}
style={{ flex: 1 }}
radius="md"
/>
<FileButton
onChange={(f) =>
setAdHoc((rows) =>
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
)
}
accept="application/pdf,image/*"
>
{(props) => (
<Button {...props} variant="default" radius="md">
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
</Button>
)}
</FileButton>
</Group>
))}
</Stack>
</Box>
)}
{uploadMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
{uploadMutation.error instanceof Error
? uploadMutation.error.message
: "Upload failed. Please try again."}
</Alert>
)}
{canUpload && (
<Group justify="flex-end" mt="lg">
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
disabled={!canSubmit}
loading={uploadMutation.isPending}
onClick={submitDocuments}
>
{isInitialUpload ? "Submit documents" : "Re-upload documents"}
</Button>
</Group>
)}
{viewer}
</Stack>
);
if (bare) return body;
return (
<Paper withBorder radius={20} p="lg" style={{ borderColor: BORDER }}>
{body}
</Paper>
);
}
export default ContractClearancePanel;

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Badge,
@@ -36,10 +36,15 @@ import {
Upload,
Weight,
} from "lucide-react";
import { isViewable } from "@edr/ui-common";
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 { api } from "@/services/api";
import { fileViewUrl } from "@/constants/apiConfig";
import { useFileViewer } from "@/hooks/useFileViewer";
import { labelForDocCode } from "@/pages/bookings/resubmit";
import { ContractClearancePanel } from "./ContractClearancePanel";
import { formatRateUnit } from "./new-contract-form/unit-rates";
import {
BORDER,
@@ -63,11 +68,66 @@ const CLEARANCE_UPLOAD_STATUSES = [
"CLEARANCE_READY_FOR_BOOKING",
];
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
// 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",
"commercial_license",
"business_license",
"investment_license",
"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 contract: ContractFile[] = [];
const profile: ContractFile[] = [];
const clearance: ContractFile[] = [];
for (const f of files) {
if (f.code === "contract") contract.push(f);
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
else clearance.push(f);
}
return [
{ key: "contract", title: "Contract document", files: contract },
{ key: "profile", title: "Profile documents", files: profile },
{ key: "clearance", title: "Clearance documents", files: clearance },
].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,
@@ -126,6 +186,7 @@ export default function ContractDetailPage() {
const routes = contract.routes ?? [];
const pricing = contract.pricingBreakdown;
const files = contract.files ?? [];
const docGroups = groupContractDocuments(files);
const canSign = contract.status === "CONTRACT_READY";
const customsPath = contract.customsClearingEnabled;
@@ -197,9 +258,11 @@ export default function ContractDetailPage() {
radius="md"
size="md"
leftSection={<Upload size={16} />}
onClick={() => navigate(`/contracts/${contract.id}/clearance`)}
onClick={clearanceModal.open}
>
Upload clearance documents
{contract.status === "CLEARANCE_UNDER_REVIEW"
? "Manage clearance documents"
: "Upload clearance documents"}
</Button>
)}
</Group>
@@ -610,74 +673,23 @@ export default function ContractDetailPage() {
</Text>
</Stack>
) : (
<Stack gap={10}>
{files.map((file) => (
<Group
key={file.id}
justify="space-between"
wrap="nowrap"
p="sm"
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText
size={16}
color={MUTED}
style={{ flexShrink: 0 }}
/>
<Box style={{ minWidth: 0 }}>
<Text
fz={14}
fw={600}
style={{ color: INK }}
truncate
>
{file.name}
</Text>
<Text fz={12} c="dimmed">
{file.mimeType?.split("/")[1]?.toUpperCase() ??
"FILE"}
{file.size
? ` · ${(file.size / 1024).toFixed(0)} KB`
: ""}
</Text>
</Box>
<Stack gap="lg">
{docGroups.map((group) => (
<Stack key={group.key} gap={8}>
<Group justify="space-between" align="center">
<Text fz={12} fw={700} c="dimmed" tt="uppercase">
{group.title}
</Text>
<Badge size="xs" variant="light" color="gray" radius="sm">
{group.files.length}
</Badge>
</Group>
<Group gap={8} wrap="nowrap">
{isViewable({
name: file.name,
url: fileViewUrl(file.id),
mimeType: file.mimeType,
}) && (
<Button
variant="light"
color="edr-green"
size="xs"
radius="md"
leftSection={<Eye size={14} />}
onClick={() =>
view({
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>
<Stack gap={10}>
{group.files.map((file) => (
<DocFileRow key={file.id} file={file} onView={view} />
))}
</Stack>
</Stack>
))}
</Stack>
)}
@@ -771,6 +783,22 @@ export default function ContractDetailPage() {
</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} bare />
</Modal>
</Box>
);
}
@@ -863,6 +891,77 @@ function SectionLabel({
);
}
/**
* 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.
*/
function DocFileRow({
file,
onView,
}: {
file: ContractFile;
onView: (f: ViewableFile) => void;
}) {
const kind = labelForDocCode(file.code);
return (
<Group
justify="space-between"
wrap="nowrap"
p="sm"
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color={MUTED} style={{ flexShrink: 0 }} />
<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">
{isViewable({
name: file.name,
url: fileViewUrl(file.id),
mimeType: file.mimeType,
}) && (
<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