mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 14:48:18 +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
|
* Customer uploads clearance documents on the contract. When every required
|
||||||
* input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET.
|
* input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET.
|
||||||
@@ -294,19 +347,7 @@ export class ContractClearanceService {
|
|||||||
note?: string,
|
note?: string,
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
// Reviewing is allowed both while the batch is UNDER_REVIEW and after it has
|
this.assertClearanceReviewableStatus(contract);
|
||||||
// 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}".`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (status === 'QUERIED' && !note?.trim()) {
|
if (status === 'QUERIED' && !note?.trim()) {
|
||||||
throw new BadRequestException('A note is required when querying a document');
|
throw new BadRequestException('A note is required when querying a document');
|
||||||
}
|
}
|
||||||
@@ -348,6 +389,8 @@ export class ContractClearanceService {
|
|||||||
if (cycle) {
|
if (cycle) {
|
||||||
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
||||||
}
|
}
|
||||||
|
} else if (status === 'APPROVED') {
|
||||||
|
await this.bumpToUnderReviewWhenFullyApproved(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
@@ -359,11 +402,7 @@ export class ContractClearanceService {
|
|||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
this.assertClearanceOutputUploadableStatus(contract);
|
||||||
throw new ConflictException(
|
|
||||||
`Cannot upload output documents on status "${contract.status}".`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const { outputCode } = contractClearanceCodes(contract);
|
const { outputCode } = contractClearanceCodes(contract);
|
||||||
if (!outputCode) {
|
if (!outputCode) {
|
||||||
throw new BadRequestException('This contract has no customs output documents');
|
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.',
|
'Self-clearance (Path A) contracts are finalized by Operations, not GL.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
this.assertClearanceFinalizableStatus(contract);
|
||||||
throw new ConflictException(
|
|
||||||
`Cannot finalize clearance on status "${contract.status}".`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const approved = await this.isClearanceFullyApproved(contract);
|
const approved = await this.isClearanceFullyApproved(contract);
|
||||||
if (!approved) {
|
if (!approved) {
|
||||||
@@ -452,11 +487,7 @@ export class ContractClearanceService {
|
|||||||
'Operations finalize applies only to self-clearance (non-customs) contracts.',
|
'Operations finalize applies only to self-clearance (non-customs) contracts.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
this.assertClearanceFinalizableStatus(contract);
|
||||||
throw new ConflictException(
|
|
||||||
`Cannot finalize clearance on status "${contract.status}".`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const approved = await this.isClearanceFullyApproved(contract);
|
const approved = await this.isClearanceFullyApproved(contract);
|
||||||
if (!approved) {
|
if (!approved) {
|
||||||
|
|||||||
@@ -345,6 +345,14 @@ export class ContractTransitionService {
|
|||||||
return { view, html, signatures: view.signatures };
|
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
|
* Rebuild the stored `contract` PDF from the current aggregate (now including
|
||||||
* the latest signatures) so the downloaded/viewed file matches the live HTML
|
* the latest signatures) so the downloaded/viewed file matches the live HTML
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
Res,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||||
|
import type { Response } from 'express';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiBody,
|
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')
|
@Post(':id/contract/sign')
|
||||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||||
signContract(
|
signContract(
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetail
|
|||||||
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
||||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
||||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||||
|
import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage";
|
||||||
import FirstMilePage from "./pages/operations/FirstMilePage";
|
import FirstMilePage from "./pages/operations/FirstMilePage";
|
||||||
import LastMilePage from "./pages/operations/LastMilePage";
|
import LastMilePage from "./pages/operations/LastMilePage";
|
||||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||||
@@ -343,6 +344,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <Boxes />,
|
icon: <Boxes />,
|
||||||
children: [
|
children: [
|
||||||
...getCategorySidebarChildren("configuration"),
|
...getCategorySidebarChildren("configuration"),
|
||||||
|
{
|
||||||
|
label: "Contract validity",
|
||||||
|
href: "/dashboard/configuration/contract-validity-periods",
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// label: "Train scheduling rules",
|
// label: "Train scheduling rules",
|
||||||
// href: "/dashboard/configuration/train-scheduling-rules",
|
// href: "/dashboard/configuration/train-scheduling-rules",
|
||||||
@@ -811,6 +816,14 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</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" element={<CargoTypesPage />} />
|
||||||
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
|
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
|
||||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||||
|
|||||||
@@ -419,6 +419,7 @@ function DocReviewCard({
|
|||||||
const status = doc.reviewStatus ?? "PENDING";
|
const status = doc.reviewStatus ?? "PENDING";
|
||||||
const meta = STATUS_META[status];
|
const meta = STATUS_META[status];
|
||||||
const hasFile = !!doc.file;
|
const hasFile = !!doc.file;
|
||||||
|
const isApproved = status === "APPROVED";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -514,16 +515,18 @@ function DocReviewCard({
|
|||||||
>
|
>
|
||||||
Open query
|
Open query
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{!isApproved && (
|
||||||
size="compact-sm"
|
<Button
|
||||||
color="edr-green"
|
size="compact-sm"
|
||||||
radius="md"
|
color="edr-green"
|
||||||
leftSection={<CheckCircle2 size={14} />}
|
radius="md"
|
||||||
disabled={busy}
|
leftSection={<CheckCircle2 size={14} />}
|
||||||
onClick={onApprove}
|
disabled={busy}
|
||||||
>
|
onClick={onApprove}
|
||||||
Approve
|
>
|
||||||
</Button>
|
Approve
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
) : (
|
) : (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -583,7 +583,7 @@ function DocReviewCard({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!readOnly && hasFile && !isApproved && (
|
{!readOnly && hasFile && (
|
||||||
<Box mt="sm">
|
<Box mt="sm">
|
||||||
{!queryOpen ? (
|
{!queryOpen ? (
|
||||||
<Group justify="flex-end" gap={8}>
|
<Group justify="flex-end" gap={8}>
|
||||||
@@ -598,16 +598,18 @@ function DocReviewCard({
|
|||||||
>
|
>
|
||||||
Open query
|
Open query
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{!isApproved && (
|
||||||
size="sm"
|
<Button
|
||||||
color="edr-green"
|
size="sm"
|
||||||
radius="md"
|
color="edr-green"
|
||||||
leftSection={<CheckCircle2 size={15} />}
|
radius="md"
|
||||||
disabled={busy}
|
leftSection={<CheckCircle2 size={15} />}
|
||||||
onClick={onApprove}
|
disabled={busy}
|
||||||
>
|
onClick={onApprove}
|
||||||
Approve
|
>
|
||||||
</Button>
|
Approve
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
) : (
|
) : (
|
||||||
<Box
|
<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",
|
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",
|
prefix: "/dashboard/configuration/train-scheduling-rules",
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export const URL_CONSTANTS = {
|
|||||||
`/contracts/${id}/approval-steps/${stepId}/approve`,
|
`/contracts/${id}/approval-steps/${stepId}/approve`,
|
||||||
CONTRACT_GENERATE: (id: string) => `/contracts/${id}/contract/generate`,
|
CONTRACT_GENERATE: (id: string) => `/contracts/${id}/contract/generate`,
|
||||||
CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`,
|
CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`,
|
||||||
|
CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`,
|
||||||
CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`,
|
CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`,
|
||||||
CLEARANCE_QUEUE: "/contracts/clearance/queue",
|
CLEARANCE_QUEUE: "/contracts/clearance/queue",
|
||||||
CLEARANCE: (id: string) => `/contracts/${id}/clearance`,
|
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
|
// 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.
|
// after GL finalizes clearance — there is no GL "Create booking" action here.
|
||||||
const ready = clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -160,7 +170,7 @@ export default function ContractClearanceDetailPage() {
|
|||||||
contractId={id!}
|
contractId={id!}
|
||||||
hideSummary
|
hideSummary
|
||||||
selfClear={false}
|
selfClear={false}
|
||||||
readOnly={ready}
|
readOnly={clearanceReadOnly}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Calendar,
|
Calendar,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
Download,
|
||||||
|
FileSignature,
|
||||||
FileText,
|
FileText,
|
||||||
Files,
|
Files,
|
||||||
Flame,
|
Flame,
|
||||||
@@ -56,6 +58,8 @@ import {
|
|||||||
useContractDetail,
|
useContractDetail,
|
||||||
useContractMutations,
|
useContractMutations,
|
||||||
} from "@/hooks/contracts/useContracts";
|
} from "@/hooks/contracts/useContracts";
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
import { downloadBookingFile } from "@/services/files.service";
|
import { downloadBookingFile } from "@/services/files.service";
|
||||||
import type { Freight } from "@edr/types";
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -207,6 +227,14 @@ export default function ContractRequestDetailPage() {
|
|||||||
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
|
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
|
||||||
const selfClear = !contract.customsClearingEnabled;
|
const selfClear = !contract.customsClearingEnabled;
|
||||||
const files = contract.files ?? [];
|
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
|
// 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).
|
// requested tab isn't available for this contract (e.g. clearance pre-phase).
|
||||||
const currentTab =
|
const currentTab =
|
||||||
@@ -292,6 +320,48 @@ export default function ContractRequestDetailPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Group>
|
</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>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -13,18 +13,17 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} 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 toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
|
||||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Staff contract preview + sign. Staff must open and read the generated
|
* 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
|
* contract here before signing.
|
||||||
* the list table. Signing as STAFF is only possible once the contract has been
|
|
||||||
* generated and is in CONTRACT_READY / SIGNED_CUSTOMER.
|
|
||||||
*/
|
*/
|
||||||
export default function ContractViewPage() {
|
export default function ContractViewPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -33,9 +32,9 @@ export default function ContractViewPage() {
|
|||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
const [signOpen, setSignOpen] = useState(false);
|
const [signOpen, setSignOpen] = useState(false);
|
||||||
|
const [successOpen, setSuccessOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
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 [drawNew, setDrawNew] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
@@ -58,8 +57,8 @@ export default function ContractViewPage() {
|
|||||||
consentText: "I confirm this contract on behalf of EDR.",
|
consentText: "I confirm this contract on behalf of EDR.",
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Contract signed");
|
|
||||||
setSignOpen(false);
|
setSignOpen(false);
|
||||||
|
setSuccessOpen(true);
|
||||||
void refetch();
|
void refetch();
|
||||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) });
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) });
|
||||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT });
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT });
|
||||||
@@ -69,6 +68,21 @@ export default function ContractViewPage() {
|
|||||||
|
|
||||||
const handlePrint = () => iframeRef.current?.contentWindow?.print();
|
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 = () => {
|
const openSign = () => {
|
||||||
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
|
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
|
||||||
setSignatureData(null);
|
setSignatureData(null);
|
||||||
@@ -124,6 +138,13 @@ export default function ContractViewPage() {
|
|||||||
>
|
>
|
||||||
Print
|
Print
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftSection={<Download size={16} />}
|
||||||
|
onClick={() => void downloadPdf()}
|
||||||
|
>
|
||||||
|
Download PDF
|
||||||
|
</Button>
|
||||||
{data.canSignStaff && (
|
{data.canSignStaff && (
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
@@ -217,6 +238,16 @@ export default function ContractViewPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,13 @@ export const contractsService = {
|
|||||||
return unwrap(response.data) as ContractView;
|
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) =>
|
signContract: (id: string, payload: SignContractPayload) =>
|
||||||
postContract<Freight.IContract>(C.CONTRACT_SIGN(id), payload),
|
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`,
|
CONFIRM_SUBMIT: (id: string) => `/api/contracts/${id}/confirm-submit`,
|
||||||
CONTRACT_GENERATE: (id: string) => `/api/contracts/${id}/contract/generate`,
|
CONTRACT_GENERATE: (id: string) => `/api/contracts/${id}/contract/generate`,
|
||||||
CONTRACT_VIEW: (id: string) => `/api/contracts/${id}/contract/view`,
|
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`,
|
CONTRACT_SIGN: (id: string) => `/api/contracts/${id}/contract/sign`,
|
||||||
RENEW: (id: string) => `/api/contracts/${id}/renew`,
|
RENEW: (id: string) => `/api/contracts/${id}/renew`,
|
||||||
CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`,
|
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 { isViewable, type ViewableFile } from "@edr/ui-common";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
||||||
import { ContractClearancePanel } from "./ContractClearancePanel";
|
import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
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
|
// The generated contract PDF — surfaced via a dedicated "View contract" button
|
||||||
// in the header (it's excluded from the Documents tab groups).
|
// in the header (it's excluded from the Documents tab groups).
|
||||||
const contractPdf = files.find((f) => f.code === "contract");
|
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 canSign = contract.status === "CONTRACT_READY";
|
||||||
const customsPath = contract.customsClearingEnabled;
|
const customsPath = contract.customsClearingEnabled;
|
||||||
@@ -303,6 +321,17 @@ export default function ContractDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{hasContractDocument && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
leftSection={<Download size={16} />}
|
||||||
|
onClick={() => void downloadContractPdf()}
|
||||||
|
>
|
||||||
|
Download PDF
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{canBookShipment && (
|
{canBookShipment && (
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
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 { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
Image,
|
||||||
Loader,
|
Loader,
|
||||||
@@ -13,18 +15,20 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} 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 toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
|
||||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { api } from "@/services/api";
|
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
|
* Customer contract preview + sign. Customers must scroll through the full
|
||||||
* contract here before signing — there is no sign action on the detail page or
|
* contract and accept the terms before signing.
|
||||||
* the contract list. Signing is only possible once the contract is generated
|
|
||||||
* and ready (CONTRACT_READY).
|
|
||||||
*/
|
*/
|
||||||
export default function ContractViewPage() {
|
export default function ContractViewPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -33,11 +37,12 @@ export default function ContractViewPage() {
|
|||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
const [signOpen, setSignOpen] = useState(false);
|
const [signOpen, setSignOpen] = useState(false);
|
||||||
|
const [successOpen, setSuccessOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
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 [drawNew, setDrawNew] = useState(false);
|
||||||
|
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
|
||||||
|
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ["contract-view", id],
|
queryKey: ["contract-view", id],
|
||||||
@@ -47,6 +52,48 @@ export default function ContractViewPage() {
|
|||||||
|
|
||||||
const savedSignatureImage = data?.savedSignature?.signatureImageUrl ?? null;
|
const savedSignatureImage = data?.savedSignature?.signatureImageUrl ?? null;
|
||||||
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
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({
|
const signMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@@ -56,11 +103,11 @@ export default function ContractViewPage() {
|
|||||||
? (savedSignatureImage as string)
|
? (savedSignatureImage as string)
|
||||||
: (signatureData as string),
|
: (signatureData as string),
|
||||||
signerDisplayName: signerName.trim(),
|
signerDisplayName: signerName.trim(),
|
||||||
consentText: "I agree to the terms of this contract.",
|
consentText: CONSENT_TEXT,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Contract signed successfully");
|
|
||||||
setSignOpen(false);
|
setSignOpen(false);
|
||||||
|
setSuccessOpen(true);
|
||||||
void refetch();
|
void refetch();
|
||||||
void qc.invalidateQueries({
|
void qc.invalidateQueries({
|
||||||
queryKey: api.contracts.get.queryKey({ id: id! }),
|
queryKey: api.contracts.get.queryKey({ id: id! }),
|
||||||
@@ -70,6 +117,7 @@ export default function ContractViewPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const openSign = () => {
|
const openSign = () => {
|
||||||
|
if (!canProceedToSign) return;
|
||||||
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
|
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
|
||||||
setSignatureData(null);
|
setSignatureData(null);
|
||||||
setDrawNew(false);
|
setDrawNew(false);
|
||||||
@@ -85,6 +133,21 @@ export default function ContractViewPage() {
|
|||||||
|
|
||||||
const handlePrint = () => iframeRef.current?.contentWindow?.print();
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Group justify="center" mih="40vh" align="center">
|
<Group justify="center" mih="40vh" align="center">
|
||||||
@@ -105,7 +168,7 @@ export default function ContractViewPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box p={{ base: "md", md: "xl" }}>
|
<Box p={{ base: "md", md: "xl" }} pb={data.canSignCustomer ? 120 : undefined}>
|
||||||
<Box maw={920} mx="auto">
|
<Box maw={920} mx="auto">
|
||||||
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
|
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
|
||||||
<Button
|
<Button
|
||||||
@@ -124,23 +187,28 @@ export default function ContractViewPage() {
|
|||||||
>
|
>
|
||||||
Print
|
Print
|
||||||
</Button>
|
</Button>
|
||||||
{data.canSignCustomer && (
|
<Button
|
||||||
<Button
|
variant="default"
|
||||||
color="edr-green"
|
leftSection={<Download size={16} />}
|
||||||
leftSection={<FileSignature size={16} />}
|
onClick={() => void downloadPdf()}
|
||||||
onClick={openSign}
|
>
|
||||||
>
|
Download PDF
|
||||||
{usingSaved ? "Approve & sign" : "Sign contract"}
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
</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" }}>
|
<Paper withBorder radius="lg" p={0} style={{ overflow: "hidden" }}>
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
srcDoc={data.html}
|
srcDoc={data.html}
|
||||||
title="Contract document"
|
title="Contract document"
|
||||||
|
onLoad={handleIframeLoad}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
minHeight: "80vh",
|
minHeight: "80vh",
|
||||||
@@ -151,6 +219,49 @@ export default function ContractViewPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</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
|
<Modal
|
||||||
opened={signOpen}
|
opened={signOpen}
|
||||||
onClose={() => setSignOpen(false)}
|
onClose={() => setSignOpen(false)}
|
||||||
@@ -217,6 +328,16 @@ export default function ContractViewPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,9 +39,11 @@ import {
|
|||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import {
|
import {
|
||||||
CONTRACT_STEPS,
|
CONTRACT_STEPS,
|
||||||
|
EDIT_CONTRACT_STEPS,
|
||||||
ContractFormInputValues,
|
ContractFormInputValues,
|
||||||
contractFormSchema,
|
contractFormSchema,
|
||||||
contractStepFields,
|
contractStepFields,
|
||||||
|
editContractStepFields,
|
||||||
initialContractFormValues,
|
initialContractFormValues,
|
||||||
OPERATION_TYPES,
|
OPERATION_TYPES,
|
||||||
type ContractFormValues,
|
type ContractFormValues,
|
||||||
@@ -212,7 +214,7 @@ export default function NewContractPage({
|
|||||||
clearContractDraft();
|
clearContractDraft();
|
||||||
setPriceModalMode(null);
|
setPriceModalMode(null);
|
||||||
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
||||||
navigate(`/contracts/${priceContractId}`);
|
navigate("/contracts");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,7 +228,7 @@ export default function NewContractPage({
|
|||||||
setPriceChangeResult(null);
|
setPriceChangeResult(null);
|
||||||
setPriceModalMode(null);
|
setPriceModalMode(null);
|
||||||
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
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 destinationYard = form.watch("destinationYard");
|
||||||
const operationType = form.watch("operationType");
|
const operationType = form.watch("operationType");
|
||||||
|
|
||||||
const visibleSteps = useMemo(() => CONTRACT_STEPS, []);
|
const visibleSteps = useMemo(
|
||||||
|
() => (isEdit ? EDIT_CONTRACT_STEPS : CONTRACT_STEPS),
|
||||||
|
[isEdit],
|
||||||
|
);
|
||||||
const visibleStepIds = useMemo<number[]>(
|
const visibleStepIds = useMemo<number[]>(
|
||||||
() => visibleSteps.map((s) => s.id),
|
() => visibleSteps.map((s) => s.id),
|
||||||
[visibleSteps],
|
[visibleSteps],
|
||||||
@@ -463,10 +468,24 @@ export default function NewContractPage({
|
|||||||
}, [auth.company, auth.activeCompanyProfileId]);
|
}, [auth.company, auth.activeCompanyProfileId]);
|
||||||
|
|
||||||
async function handleContinue() {
|
async function handleContinue() {
|
||||||
const valid = await form.trigger(contractStepFields[step], {
|
const stepFields = isEdit ? editContractStepFields : contractStepFields;
|
||||||
shouldFocus: true,
|
const fields = stepFields[step];
|
||||||
});
|
if (fields.length > 0) {
|
||||||
if (!valid) return;
|
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);
|
goToStep(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -753,9 +772,34 @@ export default function NewContractPage({
|
|||||||
</StepCard>
|
</StepCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2 — Documents. */}
|
{/* Step 2 (edit) — Documents. */}
|
||||||
{/* Step 2 — Review & Submit. */}
|
{step === 2 && isEdit && editContract && (
|
||||||
{step === 2 && (
|
<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
|
<Step8Review
|
||||||
form={form}
|
form={form}
|
||||||
setStep={setStep}
|
setStep={setStep}
|
||||||
@@ -781,26 +825,6 @@ export default function NewContractPage({
|
|||||||
persistAndPriceMutation.variables?.mode === "submit"
|
persistAndPriceMutation.variables?.mode === "submit"
|
||||||
}
|
}
|
||||||
isEdit={isEdit}
|
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>
|
</Box>
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ export const CONTRACT_STEPS = [
|
|||||||
{ id: 2, label: "Review & Submit", short: "Review" },
|
{ id: 2, label: "Review & Submit", short: "Review" },
|
||||||
] as const;
|
] 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 = [
|
export const OPERATION_TYPES = [
|
||||||
"import",
|
"import",
|
||||||
"export",
|
"export",
|
||||||
@@ -317,3 +325,15 @@ export const contractStepFields: Record<
|
|||||||
// company profile documents are attached to the contract automatically.)
|
// company profile documents are attached to the contract automatically.)
|
||||||
2: ["notes"],
|
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 lastMileEnabled = form.watch("lastMile.enabled");
|
||||||
|
|
||||||
const prevServiceType = useRef(serviceType);
|
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(() => {
|
useEffect(() => {
|
||||||
const prev = prevServiceType.current;
|
const prev = prevServiceType.current;
|
||||||
prevServiceType.current = serviceType;
|
prevServiceType.current = serviceType;
|
||||||
if (!prev || prev === serviceType) return;
|
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) {
|
if (includesCustoms) {
|
||||||
form.setValue("customsClearingEnabled", true, { shouldDirty: true });
|
form.setValue("customsClearingEnabled", true, { shouldDirty: true });
|
||||||
form.setValue("customsClearingAgent", "", { shouldDirty: true });
|
form.setValue("customsClearingAgent", "", { shouldDirty: true });
|
||||||
@@ -275,7 +286,7 @@ export function Step2ServiceType({
|
|||||||
form.setValue("customsClearingEnabled", false, { shouldDirty: true });
|
form.setValue("customsClearingEnabled", false, { shouldDirty: true });
|
||||||
form.setValue("customsClearingAgent", "", { shouldDirty: true });
|
form.setValue("customsClearingAgent", "", { shouldDirty: true });
|
||||||
}
|
}
|
||||||
}, [serviceTypeId, form]);
|
}, [serviceType, includesFirstMile, includesLastMile, includesCustoms, form]);
|
||||||
|
|
||||||
const showServiceSections =
|
const showServiceSections =
|
||||||
serviceType != null || includesFirstMile || includesLastMile;
|
serviceType != null || includesFirstMile || includesLastMile;
|
||||||
|
|||||||
@@ -73,13 +73,6 @@ export function Step3CargoScope({
|
|||||||
}
|
}
|
||||||
}, [parentId, form]);
|
}, [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(() => {
|
const freightTypeGroups = useMemo(() => {
|
||||||
if (!referenceData?.cargo_type) return [];
|
if (!referenceData?.cargo_type) return [];
|
||||||
return referenceData.cargo_type.filter(
|
return referenceData.cargo_type.filter(
|
||||||
|
|||||||
@@ -409,6 +409,16 @@ export function Step8Review({
|
|||||||
: "Not requested"
|
: "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
|
<SummaryItem
|
||||||
icon={<FileText size={18} />}
|
icon={<FileText size={18} />}
|
||||||
label="Documents"
|
label="Documents"
|
||||||
|
|||||||
@@ -203,6 +203,13 @@ export const contractsService = {
|
|||||||
return data.data ?? data;
|
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 (
|
signContract: async (
|
||||||
id: string,
|
id: string,
|
||||||
payload: SignContractPayload,
|
payload: SignContractPayload,
|
||||||
|
|||||||
Reference in New Issue
Block a user