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

@@ -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<void> {
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<Contract> {
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<Contract> {
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) {

View File

@@ -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

View File

@@ -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<void> {
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(

View File

@@ -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: <Boxes />,
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 = () => {
</RequirePermission>
}
/>
<Route
path="configuration/contract-validity-periods"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<ContractValidityPeriodsPage />
</RequirePermission>
}
/>
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />

View File

@@ -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 (
<Paper
@@ -514,16 +515,18 @@ function DocReviewCard({
>
Open query
</Button>
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
{!isApproved && (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
)}
</Group>
) : (
<Box

View File

@@ -583,7 +583,7 @@ function DocReviewCard({
</Alert>
)}
{!readOnly && hasFile && !isApproved && (
{!readOnly && hasFile && (
<Box mt="sm">
{!queryOpen ? (
<Group justify="flex-end" gap={8}>
@@ -598,16 +598,18 @@ function DocReviewCard({
>
Open query
</Button>
<Button
size="sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={15} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
{!isApproved && (
<Button
size="sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={15} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
)}
</Group>
) : (
<Box

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 = "The contract has been signed and recorded.",
confirmLabel = "Back to contract request",
}: 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

@@ -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: {

View File

@@ -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`,

View File

@@ -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 (
<PageContainer>
<Breadcrumbs
items={[
{ label: "Configuration", href: "/dashboard/configuration" },
{ label: "Contract validity periods" },
]}
/>
<Stack gap="lg" mt="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={4}>
<Title order={2}>Contract validity periods</Title>
<Text size="sm" c="dimmed" maw={560}>
Options shown when line staff accepts a submitted contract. Each
value is the number of days the contract stays valid from the
accept date.
</Text>
</Stack>
{setting && (
<Button
leftSection={<Pencil size={16} />}
color="edr-green"
onClick={() => setEditOpen(true)}
>
Edit options
</Button>
)}
</Group>
<Paper withBorder radius="lg" p="lg">
{isLoading ? (
<Center py="xl">
<Loader color="edr-green" />
</Center>
) : isError || !setting ? (
<Text c="dimmed">
Could not load contract validity settings. Ensure{" "}
<Text span ff="monospace" size="sm">
{CONTRACT_VALIDITY_PERIODS_CODE}
</Text>{" "}
is seeded in dropdown settings.
</Text>
) : options.length === 0 ? (
<Stack align="center" gap="md" py="xl">
<CalendarClock size={32} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">No validity periods configured yet.</Text>
<Button
variant="light"
color="edr-green"
onClick={() => setEditOpen(true)}
>
Add options
</Button>
</Stack>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Label</Table.Th>
<Table.Th>Days (value)</Table.Th>
<Table.Th>Order</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{options.map((opt) => (
<Table.Tr key={opt.id}>
<Table.Td>{opt.label}</Table.Td>
<Table.Td>
<Text ff="monospace" size="sm">
{opt.value}
</Text>
</Table.Td>
<Table.Td>{opt.order ?? "—"}</Table.Td>
<Table.Td>
<Badge
color={opt.disabled ? "gray" : "edr-green"}
variant="light"
>
{opt.disabled ? "Disabled" : "Active"}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Paper>
</Stack>
{setting ? (
<ManageDropdownOptionsDialog
setting={setting}
open={editOpen}
onOpenChange={setEditOpen}
/>
) : null}
</PageContainer>
);
}

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

View File

@@ -159,6 +159,13 @@ export const contractsService = {
return unwrap(response.data) as ContractView;
},
downloadContractDocument: async (id: string): Promise<Blob> => {
const response = await client.get(C.CONTRACT_DOCUMENT(id), {
responseType: "blob",
});
return response.data as Blob;
},
signContract: (id: string, payload: SignContractPayload) =>
postContract<Freight.IContract>(C.CONTRACT_SIGN(id), payload),

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,