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

@@ -62,6 +62,16 @@ export default function ContractClearanceDetailPage() {
// Customs (Path B) hub. The customer always creates the booking in the portal
// after GL finalizes clearance — there is no GL "Create booking" action here.
const ready = clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
const clearanceReadOnly = Boolean(
contract?.status &&
[
"ACTIVE_SHIPMENT_IN_PROGRESS",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"CONTRACT_CLOSED",
"EXPIRED",
].includes(contract.status),
);
if (isLoading) {
return (
@@ -160,7 +170,7 @@ export default function ContractClearanceDetailPage() {
contractId={id!}
hideSummary
selfClear={false}
readOnly={ready}
readOnly={clearanceReadOnly}
/>
</Grid.Col>

View File

@@ -6,6 +6,8 @@ import {
Building2,
Calendar,
CalendarClock,
Download,
FileSignature,
FileText,
Files,
Flame,
@@ -56,6 +58,8 @@ import {
useContractDetail,
useContractMutations,
} from "@/hooks/contracts/useContracts";
import { contractsService } from "@/services/contracts.service";
import { fileViewUrl } from "@/constants/apiConfig";
import { downloadBookingFile } from "@/services/files.service";
import type { Freight } from "@edr/types";
@@ -136,6 +140,22 @@ export default function ContractRequestDetailPage() {
}
};
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>
@@ -207,6 +227,14 @@ export default function ContractRequestDetailPage() {
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
const selfClear = !contract.customsClearingEnabled;
const files = contract.files ?? [];
const contractPdf = files.find((f) => f.code === "contract");
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 =
@@ -292,6 +320,48 @@ export default function ContractRequestDetailPage() {
/>
) : 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={() =>
handleViewFile({
...contractPdf,
url: fileViewUrl(contractPdf.id),
})
}
>
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>

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -13,18 +13,17 @@ 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 { QUERY_KEYS } from "@/constants/QUERY_KEYS";
/**
* Staff contract preview + sign. Staff must open and read the generated
* contract here before signing — there is no sign action on the detail page or
* the list table. Signing as STAFF is only possible once the contract has been
* generated and is in CONTRACT_READY / SIGNED_CUSTOMER.
* contract here before signing.
*/
export default function ContractViewPage() {
const { id } = useParams<{ id: string }>();
@@ -33,9 +32,9 @@ 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 staff member's saved signature first; they can draw a fresh one.
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
@@ -58,8 +57,8 @@ export default function ContractViewPage() {
consentText: "I confirm this contract on behalf of EDR.",
}),
onSuccess: () => {
toast.success("Contract signed");
setSignOpen(false);
setSuccessOpen(true);
void refetch();
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT });
@@ -69,6 +68,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]);
const openSign = () => {
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
@@ -124,6 +138,13 @@ export default function ContractViewPage() {
>
Print
</Button>
<Button
variant="default"
leftSection={<Download size={16} />}
onClick={() => void downloadPdf()}
>
Download PDF
</Button>
{data.canSignStaff && (
<Button
color="edr-green"
@@ -217,6 +238,16 @@ export default function ContractViewPage() {
</Group>
</Stack>
</Modal>
<ContractSignSuccessModal
opened={successOpen}
reference={data.reference}
message="The contract has been counter-signed. The customer will be notified of the next steps."
onClose={() => {
setSuccessOpen(false);
navigate(`/dashboard/contract-requests/${data.contractId}`);
}}
/>
</Box>
);
}