mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 08:32:54 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user