From ccd5d6de31141b5fafb798efa4aa129f7f28eea6 Mon Sep 17 00:00:00 2001 From: marshal Date: Wed, 1 Jul 2026 07:27:53 +0300 Subject: [PATCH] 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. --- .../contracts/contract-clearance.service.ts | 87 +++++++--- .../contracts/contract-transition.service.ts | 8 + .../modules/contracts/contracts.controller.ts | 22 +++ apps/edr-freight-web/backoffice/src/App.tsx | 13 ++ .../detail/ClearanceReviewSection.tsx | 23 +-- .../ContractClearanceReviewSection.tsx | 24 +-- .../contracts/ContractSignSuccessModal.tsx | 43 +++++ .../src/components/layout/route-meta.ts | 7 + .../backoffice/src/constants/URLS.ts | 1 + .../ContractValidityPeriodsPage.tsx | 145 ++++++++++++++++ .../contracts/ContractClearanceDetailPage.tsx | 12 +- .../contracts/ContractRequestDetailPage.tsx | 70 ++++++++ .../src/pages/contracts/ContractViewPage.tsx | 45 ++++- .../src/services/contracts.service.ts | 7 + .../contracts/ContractSignSuccessModal.tsx | 43 +++++ .../portal/src/constants/URLS.ts | 1 + .../pages/contracts/ContractDetailPage.tsx | 29 ++++ .../src/pages/contracts/ContractViewPage.tsx | 161 +++++++++++++++--- .../src/pages/contracts/NewContractPage.tsx | 84 +++++---- .../contracts/new-contract-form/schema.ts | 20 +++ .../new-contract-form/step2-service-type.tsx | 49 +++--- .../new-contract-form/step3-cargo-scope.tsx | 7 - .../new-contract-form/step8-review.tsx | 10 ++ .../portal/src/services/contracts.service.ts | 7 + 24 files changed, 785 insertions(+), 133 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ContractSignSuccessModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/configuration/ContractValidityPeriodsPage.tsx create mode 100644 apps/edr-freight-web/portal/src/components/contracts/ContractSignSuccessModal.tsx diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 71cfa0580..a57ca275b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -154,6 +154,59 @@ export class ContractClearanceService { ); } + /** Staff may approve/query documents during review, after a query cycle, or post-finalize re-query. */ + private assertClearanceReviewableStatus(contract: Contract): void { + const allowed = [ + 'CLEARANCE_UNDER_REVIEW', + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_READY_FOR_BOOKING', + ]; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot review clearance documents on status "${contract.status}".`, + ); + } + } + + /** Finalize when docs are under review or all approved after a partial query cycle. */ + private assertClearanceFinalizableStatus(contract: Contract): void { + const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot finalize clearance on status "${contract.status}".`, + ); + } + } + + private assertClearanceOutputUploadableStatus(contract: Contract): void { + const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot upload output documents on status "${contract.status}".`, + ); + } + } + + private async bumpToUnderReviewWhenFullyApproved(contractId: string): Promise { + const refreshed = await this.contractsService.findById(contractId); + const allApproved = await this.isClearanceFullyApproved(refreshed); + if ( + !allApproved || + (refreshed.status !== 'AWAITING_CLEARANCE_DOCUMENTS' && + refreshed.status !== 'CLEARANCE_READY_FOR_BOOKING') + ) { + return; + } + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_UNDER_REVIEW', + clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', + } as never); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); + } + } + /** * Customer uploads clearance documents on the contract. When every required * input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET. @@ -294,19 +347,7 @@ export class ContractClearanceService { note?: string, ): Promise { const contract = await this.contractsService.findById(contractId); - // 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}".`, - ); - } + this.assertClearanceReviewableStatus(contract); if (status === 'QUERIED' && !note?.trim()) { throw new BadRequestException('A note is required when querying a document'); } @@ -348,6 +389,8 @@ export class ContractClearanceService { if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); } + } else if (status === 'APPROVED') { + await this.bumpToUnderReviewWhenFullyApproved(contractId); } return this.contractsService.findById(contractId); @@ -359,11 +402,7 @@ export class ContractClearanceService { files: Express.Multer.File[], ): Promise { const contract = await this.contractsService.findById(contractId); - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot upload output documents on status "${contract.status}".`, - ); - } + this.assertClearanceOutputUploadableStatus(contract); const { outputCode } = contractClearanceCodes(contract); if (!outputCode) { throw new BadRequestException('This contract has no customs output documents'); @@ -394,11 +433,7 @@ export class ContractClearanceService { 'Self-clearance (Path A) contracts are finalized by Operations, not GL.', ); } - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot finalize clearance on status "${contract.status}".`, - ); - } + this.assertClearanceFinalizableStatus(contract); const approved = await this.isClearanceFullyApproved(contract); if (!approved) { @@ -452,11 +487,7 @@ export class ContractClearanceService { 'Operations finalize applies only to self-clearance (non-customs) contracts.', ); } - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot finalize clearance on status "${contract.status}".`, - ); - } + this.assertClearanceFinalizableStatus(contract); const approved = await this.isClearanceFullyApproved(contract); if (!approved) { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 4fcbc9a6c..8bcce1250 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -345,6 +345,14 @@ export class ContractTransitionService { return { view, html, signatures: view.signatures }; } + /** Lazy-generate (or refresh) the stored contract PDF and stream it for download. */ + async streamContractPdf(contractId: string) { + const contract = await this.contractsService.findById(contractId); + const { view } = await this.documentViewModelBuilder.build(contractId); + const record = await this.upsertContractPdf(contractId, contract.reference, view); + return this.filesService.streamById(record.id); + } + /** * Rebuild the stored `contract` PDF from the current aggregate (now including * the latest signatures) so the downloaded/viewed file matches the live HTML diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 0ef013347..100183e64 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -9,6 +9,7 @@ import { Patch, Post, Query, + Res, UnauthorizedException, UploadedFiles, UseInterceptors, @@ -16,6 +17,7 @@ import { import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; import { ApiBearerAuth, ApiBody, @@ -417,6 +419,26 @@ export class ContractsController { }; } + @Get(':id/contract/document') + @ApiOperation({ summary: 'Download contract PDF' }) + async downloadContractDocument( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ): Promise { + const contract = await this.contractsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } + const { stream, record } = await this.transitionService.streamContractPdf(id); + res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${record.name}"`, + ); + stream.pipe(res); + } + @Post(':id/contract/sign') @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) signContract( diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 542a848a4..6526fb6cb 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -67,6 +67,7 @@ import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetail import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; @@ -343,6 +344,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , children: [ ...getCategorySidebarChildren("configuration"), + { + label: "Contract validity", + href: "/dashboard/configuration/contract-validity-periods", + }, // { // label: "Train scheduling rules", // href: "/dashboard/configuration/train-scheduling-rules", @@ -811,6 +816,14 @@ const App = () => { } /> + + + + } + /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index 16bd6ed27..0f9f42217 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -419,6 +419,7 @@ function DocReviewCard({ const status = doc.reviewStatus ?? "PENDING"; const meta = STATUS_META[status]; const hasFile = !!doc.file; + const isApproved = status === "APPROVED"; return ( Open query - + {!isApproved && ( + + )} ) : ( )} - {!readOnly && hasFile && !isApproved && ( + {!readOnly && hasFile && ( {!queryOpen ? ( @@ -598,16 +598,18 @@ function DocReviewCard({ > Open query - + {!isApproved && ( + + )} ) : ( void; + reference: string; + message?: string; + confirmLabel?: string; +} + +export function ContractSignSuccessModal({ + opened, + onClose, + reference, + message = "The contract has been signed and recorded.", + confirmLabel = "Back to contract request", +}: ContractSignSuccessModalProps) { + return ( + + + + + + {reference} + + {message} + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index b400ddba4..9b75f35f5 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -184,6 +184,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ subtitle: "Manage dropdown options used across the platform", }, }, + { + prefix: "/dashboard/configuration/contract-validity-periods", + meta: { + title: "Contract validity periods", + subtitle: "Validity options staff choose when accepting a submitted contract", + }, + }, { prefix: "/dashboard/configuration/train-scheduling-rules", meta: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 2b767d082..3acacd36b 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -137,6 +137,7 @@ export const URL_CONSTANTS = { `/contracts/${id}/approval-steps/${stepId}/approve`, CONTRACT_GENERATE: (id: string) => `/contracts/${id}/contract/generate`, CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`, + CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`, CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`, CLEARANCE_QUEUE: "/contracts/clearance/queue", CLEARANCE: (id: string) => `/contracts/${id}/clearance`, diff --git a/apps/edr-freight-web/backoffice/src/pages/configuration/ContractValidityPeriodsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/configuration/ContractValidityPeriodsPage.tsx new file mode 100644 index 000000000..ee6bb5ebd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/configuration/ContractValidityPeriodsPage.tsx @@ -0,0 +1,145 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Badge, + Button, + Center, + Group, + Loader, + Paper, + Stack, + Table, + Text, + Title, +} from "@mantine/core"; +import { CalendarClock, Pencil } from "lucide-react"; + +import { PageContainer } from "@/components/page"; +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { api } from "@/services/api"; +import ManageDropdownOptionsDialog from "@/pages/dropdown_settings/ManageDropdownOptionsDialog"; + +const CONTRACT_VALIDITY_PERIODS_CODE = "contract_validity_periods"; + +/** + * Admin UI for contract validity options used when staff accepts a submitted + * contract (SUBMITTED → PENDING_APPROVAL). Backed by dropdown_settings. + */ +export default function ContractValidityPeriodsPage() { + const [editOpen, setEditOpen] = useState(false); + + const { data: setting, isLoading, isError } = useQuery( + api.dropdownSettings.getByCode.queryOptions({ + input: { code: CONTRACT_VALIDITY_PERIODS_CODE }, + }), + ); + + const options = useMemo( + () => + [...(setting?.children ?? [])].sort( + (a, b) => (a.order ?? 0) - (b.order ?? 0), + ), + [setting], + ); + + return ( + + + + + + + Contract validity periods + + Options shown when line staff accepts a submitted contract. Each + value is the number of days the contract stays valid from the + accept date. + + + {setting && ( + + )} + + + + {isLoading ? ( +
+ +
+ ) : isError || !setting ? ( + + Could not load contract validity settings. Ensure{" "} + + {CONTRACT_VALIDITY_PERIODS_CODE} + {" "} + is seeded in dropdown settings. + + ) : options.length === 0 ? ( + + + No validity periods configured yet. + + + ) : ( + + + + Label + Days (value) + Order + Status + + + + {options.map((opt) => ( + + {opt.label} + + + {opt.value} + + + {opt.order ?? "—"} + + + {opt.disabled ? "Disabled" : "Active"} + + + + ))} + +
+ )} +
+
+ + {setting ? ( + + ) : null} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 63f011ea6..08f0ed0eb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -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} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx index 44d46c652..64737c3eb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx @@ -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 ( @@ -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} + {hasContractDocument && ( + + {canViewSign && ( + + )} + {contractPdf && ( + + )} + + + )}
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx index ecb8f1c62..7e8f06e0b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx @@ -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(null); const [signOpen, setSignOpen] = useState(false); + const [successOpen, setSuccessOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(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 + {data.canSignStaff && ( + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 22f2a441b..cf974ceab 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -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`, diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 538c58fe7..a25bfe489 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -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() { ) )} + {hasContractDocument && ( + + )} {canBookShipment && ( - {data.canSignCustomer && ( - - )} + + {data.canSignCustomer && !hasScrolledToBottom && ( + + Please scroll through the entire contract before signing. + + )} +