Implement contract document download functionality and enhance clearance review status checks

- Added methods to assert clearance reviewable, finalizable, and uploadable statuses in the ContractClearanceService.
- Introduced a new endpoint in ContractsController for downloading contract PDFs.
- Implemented download functionality in the contracts service for both backoffice and portal applications.
- Updated UI components to include download buttons for contract PDFs in relevant pages.
- Enhanced contract request and view pages to support contract document downloads.
This commit is contained in:
marshal
2026-07-01 07:27:53 +03:00
parent 7654b18385
commit ccd5d6de31
24 changed files with 785 additions and 133 deletions

View File

@@ -0,0 +1,43 @@
import { Button, Group, Modal, Stack, Text, ThemeIcon } from "@mantine/core";
import { CheckCircle2 } from "lucide-react";
export interface ContractSignSuccessModalProps {
opened: boolean;
onClose: () => void;
reference: string;
message?: string;
confirmLabel?: string;
}
export function ContractSignSuccessModal({
opened,
onClose,
reference,
message = "Your signature has been recorded on the contract.",
confirmLabel = "Back to contract",
}: ContractSignSuccessModalProps) {
return (
<Modal
opened={opened}
onClose={onClose}
title="Contract signed successfully"
centered
radius="lg"
>
<Stack gap="md" align="center" ta="center">
<ThemeIcon size={56} radius="xl" color="edr-green" variant="light">
<CheckCircle2 size={28} />
</ThemeIcon>
<Text fw={600}>{reference}</Text>
<Text size="sm" c="dimmed">
{message}
</Text>
<Group justify="center" mt="xs">
<Button color="edr-green" onClick={onClose}>
{confirmLabel}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -118,6 +118,7 @@ export const URL_CONSTANTS = {
CONFIRM_SUBMIT: (id: string) => `/api/contracts/${id}/confirm-submit`,
CONTRACT_GENERATE: (id: string) => `/api/contracts/${id}/contract/generate`,
CONTRACT_VIEW: (id: string) => `/api/contracts/${id}/contract/view`,
CONTRACT_DOCUMENT: (id: string) => `/api/contracts/${id}/contract/document`,
CONTRACT_SIGN: (id: string) => `/api/contracts/${id}/contract/sign`,
RENEW: (id: string) => `/api/contracts/${id}/renew`,
CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`,

View File

@@ -48,8 +48,10 @@ 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 { 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 { formatRateUnit } from "./new-contract-form/unit-rates";
@@ -223,6 +225,22 @@ export default function ContractDetailPage() {
// 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;
@@ -303,6 +321,17 @@ export default function ContractDetailPage() {
</Button>
)
)}
{hasContractDocument && (
<Button
variant="default"
radius="md"
size="md"
leftSection={<Download size={16} />}
onClick={() => void downloadContractPdf()}
>
Download PDF
</Button>
)}
{canBookShipment && (
<Button
color="edr-green"

View File

@@ -1,9 +1,11 @@
import { useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Checkbox,
Group,
Image,
Loader,
@@ -13,18 +15,20 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { ArrowLeft, FileSignature, Printer } from "lucide-react";
import { ArrowLeft, Download, FileSignature, Printer } from "lucide-react";
import toast from "react-hot-toast";
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { contractsService } from "@/services/contracts.service";
import { api } from "@/services/api";
const CONSENT_TEXT =
"I have read the entire contract and agree to its terms.";
/**
* Customer contract preview + sign. Customers must open and read the generated
* contract here before signing — there is no sign action on the detail page or
* the contract list. Signing is only possible once the contract is generated
* and ready (CONTRACT_READY).
* Customer contract preview + sign. Customers must scroll through the full
* contract and accept the terms before signing.
*/
export default function ContractViewPage() {
const { id } = useParams<{ id: string }>();
@@ -33,11 +37,12 @@ export default function ContractViewPage() {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [signOpen, setSignOpen] = useState(false);
const [successOpen, setSuccessOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
// Offer the saved signature for approval first; the customer can draw a fresh
// one instead.
const [drawNew, setDrawNew] = useState(false);
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
const [agreedToTerms, setAgreedToTerms] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["contract-view", id],
@@ -47,6 +52,48 @@ export default function ContractViewPage() {
const savedSignatureImage = data?.savedSignature?.signatureImageUrl ?? null;
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
const canProceedToSign = hasScrolledToBottom && agreedToTerms;
const checkScrollBottom = useCallback(() => {
try {
const win = iframeRef.current?.contentWindow;
if (!win?.document?.documentElement) return;
const el = win.document.documentElement;
const threshold = 48;
if (el.scrollHeight <= el.clientHeight + threshold) {
setHasScrolledToBottom(true);
return;
}
if (el.scrollTop + el.clientHeight >= el.scrollHeight - threshold) {
setHasScrolledToBottom(true);
}
} catch {
/* srcDoc is same-origin; ignore edge cases */
}
}, []);
const handleIframeLoad = () => {
checkScrollBottom();
try {
const win = iframeRef.current?.contentWindow;
win?.addEventListener("scroll", checkScrollBottom);
} catch {
/* ignore */
}
};
useEffect(() => {
return () => {
try {
iframeRef.current?.contentWindow?.removeEventListener(
"scroll",
checkScrollBottom,
);
} catch {
/* ignore */
}
};
}, [checkScrollBottom]);
const signMutation = useMutation({
mutationFn: () =>
@@ -56,11 +103,11 @@ export default function ContractViewPage() {
? (savedSignatureImage as string)
: (signatureData as string),
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
consentText: CONSENT_TEXT,
}),
onSuccess: () => {
toast.success("Contract signed successfully");
setSignOpen(false);
setSuccessOpen(true);
void refetch();
void qc.invalidateQueries({
queryKey: api.contracts.get.queryKey({ id: id! }),
@@ -70,6 +117,7 @@ export default function ContractViewPage() {
});
const openSign = () => {
if (!canProceedToSign) return;
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
setDrawNew(false);
@@ -85,6 +133,21 @@ export default function ContractViewPage() {
const handlePrint = () => iframeRef.current?.contentWindow?.print();
const downloadPdf = useCallback(async () => {
if (!id) return;
try {
const blob = await contractsService.downloadContractDocument(id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `contract-${data?.reference ?? id}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch {
toast.error("Could not download contract PDF.");
}
}, [id, data?.reference]);
if (isLoading) {
return (
<Group justify="center" mih="40vh" align="center">
@@ -105,7 +168,7 @@ export default function ContractViewPage() {
}
return (
<Box p={{ base: "md", md: "xl" }}>
<Box p={{ base: "md", md: "xl" }} pb={data.canSignCustomer ? 120 : undefined}>
<Box maw={920} mx="auto">
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
<Button
@@ -124,23 +187,28 @@ export default function ContractViewPage() {
>
Print
</Button>
{data.canSignCustomer && (
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
onClick={openSign}
>
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
)}
<Button
variant="default"
leftSection={<Download size={16} />}
onClick={() => void downloadPdf()}
>
Download PDF
</Button>
</Group>
</Group>
{data.canSignCustomer && !hasScrolledToBottom && (
<Alert color="blue" variant="light" radius="md" mb="md">
Please scroll through the entire contract before signing.
</Alert>
)}
<Paper withBorder radius="lg" p={0} style={{ overflow: "hidden" }}>
<iframe
ref={iframeRef}
srcDoc={data.html}
title="Contract document"
onLoad={handleIframeLoad}
style={{
width: "100%",
minHeight: "80vh",
@@ -151,6 +219,49 @@ export default function ContractViewPage() {
</Paper>
</Box>
{data.canSignCustomer && hasScrolledToBottom && (
<Paper
withBorder
radius="lg"
p="md"
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
zIndex: 100,
borderTop: "1px solid var(--mantine-color-gray-3)",
background: "var(--mantine-color-body)",
}}
>
<Box maw={920} mx="auto">
<Stack gap="sm">
<Checkbox
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.currentTarget.checked)}
disabled={!hasScrolledToBottom}
label={CONSENT_TEXT}
description={
hasScrolledToBottom
? "You may now sign the contract."
: "Read the full contract above before you can agree and sign."
}
/>
<Group justify="flex-end">
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
disabled={!canProceedToSign}
onClick={openSign}
>
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
</Group>
</Stack>
</Box>
</Paper>
)}
<Modal
opened={signOpen}
onClose={() => setSignOpen(false)}
@@ -217,6 +328,16 @@ export default function ContractViewPage() {
</Group>
</Stack>
</Modal>
<ContractSignSuccessModal
opened={successOpen}
reference={data.reference}
message="Your signature has been recorded. EDR staff will counter-sign to complete the contract."
onClose={() => {
setSuccessOpen(false);
navigate(`/contracts/${id}`);
}}
/>
</Box>
);
}

View File

@@ -39,9 +39,11 @@ import {
import useAuth from "@/hooks/useAuth";
import {
CONTRACT_STEPS,
EDIT_CONTRACT_STEPS,
ContractFormInputValues,
contractFormSchema,
contractStepFields,
editContractStepFields,
initialContractFormValues,
OPERATION_TYPES,
type ContractFormValues,
@@ -212,7 +214,7 @@ export default function NewContractPage({
clearContractDraft();
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
navigate(`/contracts/${priceContractId}`);
navigate("/contracts");
},
});
@@ -226,7 +228,7 @@ export default function NewContractPage({
setPriceChangeResult(null);
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
navigate(`/contracts/${priceContractId}`);
navigate("/contracts");
},
});
@@ -282,7 +284,10 @@ export default function NewContractPage({
const destinationYard = form.watch("destinationYard");
const operationType = form.watch("operationType");
const visibleSteps = useMemo(() => CONTRACT_STEPS, []);
const visibleSteps = useMemo(
() => (isEdit ? EDIT_CONTRACT_STEPS : CONTRACT_STEPS),
[isEdit],
);
const visibleStepIds = useMemo<number[]>(
() => visibleSteps.map((s) => s.id),
[visibleSteps],
@@ -463,10 +468,24 @@ export default function NewContractPage({
}, [auth.company, auth.activeCompanyProfileId]);
async function handleContinue() {
const valid = await form.trigger(contractStepFields[step], {
shouldFocus: true,
});
if (!valid) return;
const stepFields = isEdit ? editContractStepFields : contractStepFields;
const fields = stepFields[step];
if (fields.length > 0) {
const valid = await form.trigger(fields, { shouldFocus: true });
if (!valid) return;
}
if (isEdit && step === 2 && editContract) {
const missing = missingRequiredDocKeys(
editDocSettingQuery.data,
editContract,
editDocuments,
);
if (missing.length > 0) {
setShowDocErrors(true);
return;
}
setShowDocErrors(false);
}
goToStep(1);
}
@@ -753,9 +772,34 @@ export default function NewContractPage({
</StepCard>
)}
{/* Step 2 — Documents. */}
{/* Step 2 — Review & Submit. */}
{step === 2 && (
{/* Step 2 (edit) — Documents. */}
{step === 2 && isEdit && editContract && (
<StepCard>
<StepHeader
title="Contract Documents"
description="Upload or replace the documents required for this contract before resubmitting."
/>
<ContractDocsEditor
contract={editContract}
value={editDocuments}
onChange={setEditDocuments}
errors={
showDocErrors
? Object.fromEntries(
missingRequiredDocKeys(
editDocSettingQuery.data,
editContract,
editDocuments,
).map((k) => [k, "Required"]),
)
: {}
}
/>
</StepCard>
)}
{/* Step 2 (create) / Step 3 (edit) — Review & Submit. */}
{((step === 2 && !isEdit) || (step === 3 && isEdit)) && (
<Step8Review
form={form}
setStep={setStep}
@@ -781,26 +825,6 @@ export default function NewContractPage({
persistAndPriceMutation.variables?.mode === "submit"
}
isEdit={isEdit}
documentsEditor={
isEdit && editContract ? (
<ContractDocsEditor
contract={editContract}
value={editDocuments}
onChange={setEditDocuments}
errors={
showDocErrors
? Object.fromEntries(
missingRequiredDocKeys(
editDocSettingQuery.data,
editContract,
editDocuments,
).map((k) => [k, "Required"]),
)
: {}
}
/>
) : undefined
}
/>
)}
</Box>

View File

@@ -11,6 +11,14 @@ export const CONTRACT_STEPS = [
{ id: 2, label: "Review & Submit", short: "Review" },
] as const;
/** Edit flow (CHANGES_REQUESTED): documents on step 2, review on step 3. */
export const EDIT_CONTRACT_STEPS = [
{ id: 0, label: "Setup", short: "Setup" },
{ id: 1, label: "Cargo & Route", short: "Cargo & Route" },
{ id: 2, label: "Documents", short: "Documents" },
{ id: 3, label: "Review & Submit", short: "Review" },
] as const;
export const OPERATION_TYPES = [
"import",
"export",
@@ -317,3 +325,15 @@ export const contractStepFields: Record<
// company profile documents are attached to the contract automatically.)
2: ["notes"],
};
/** Field validation per step when editing a CHANGES_REQUESTED contract. */
export const editContractStepFields: Record<
number,
Array<Path<ContractFormValues>>
> = {
0: contractStepFields[0],
1: contractStepFields[1],
// Step 2 — Documents: validated via missingRequiredDocKeys in the wizard.
2: [],
3: ["notes"],
};

View File

@@ -245,29 +245,40 @@ export function Step2ServiceType({
const lastMileEnabled = form.watch("lastMile.enabled");
const prevServiceType = useRef(serviceType);
useEffect(() => {
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
{ shouldValidate: true },
);
}, [includesFirstMile]);
useEffect(() => {
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
{ shouldValidate: true },
);
}, [includesLastMile]);
// Customs clearance bundling is driven by the service: includesCustoms → the
// contract follows Path B (clearance docs after sign); otherwise Path A.
useEffect(() => {
const prev = prevServiceType.current;
prevServiceType.current = serviceType;
if (!prev || prev === serviceType) return;
if (!includesFirstMile) {
form.setValue(
"firstMile",
{
enabled: false,
pickUpAddress: "",
exactLocation: "",
lat: null,
lng: null,
},
{ shouldValidate: true },
);
}
if (!includesLastMile) {
form.setValue(
"lastMile",
{
enabled: false,
deliveryAddress: "",
exactLocation: "",
lat: null,
lng: null,
},
{ shouldValidate: true },
);
}
// Customs clearance bundling is driven by the service: includesCustoms → the
// contract follows Path B (clearance docs after sign); otherwise Path A.
if (includesCustoms) {
form.setValue("customsClearingEnabled", true, { shouldDirty: true });
form.setValue("customsClearingAgent", "", { shouldDirty: true });
@@ -275,7 +286,7 @@ export function Step2ServiceType({
form.setValue("customsClearingEnabled", false, { shouldDirty: true });
form.setValue("customsClearingAgent", "", { shouldDirty: true });
}
}, [serviceTypeId, form]);
}, [serviceType, includesFirstMile, includesLastMile, includesCustoms, form]);
const showServiceSections =
serviceType != null || includesFirstMile || includesLastMile;

View File

@@ -73,13 +73,6 @@ export function Step3CargoScope({
}
}, [parentId, form]);
// Clear reefer when switching to container (container reefer is per-booking).
useEffect(() => {
if (cargoType !== "bulk" && form.getValues("isRefrigerated")) {
form.setValue("isRefrigerated", false);
}
}, [cargoType, form]);
const freightTypeGroups = useMemo(() => {
if (!referenceData?.cargo_type) return [];
return referenceData.cargo_type.filter(

View File

@@ -409,6 +409,16 @@ export function Step8Review({
: "Not requested"
}
/>
<SummaryItem
icon={<Package size={18} />}
label="Hazardous cargo"
value={values.isHazardous ? "Yes" : "No"}
/>
<SummaryItem
icon={<Package size={18} />}
label="Refrigerated"
value={values.isRefrigerated ? "Yes" : "No"}
/>
<SummaryItem
icon={<FileText size={18} />}
label="Documents"

View File

@@ -203,6 +203,13 @@ export const contractsService = {
return data.data ?? data;
},
downloadContractDocument: async (id: string): Promise<Blob> => {
const { data } = await client.get(C.CONTRACT_DOCUMENT(id), {
responseType: "blob",
});
return data;
},
signContract: async (
id: string,
payload: SignContractPayload,