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

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