implement Global Logistics booking process for customs contracts and enhance contract document handling

This commit is contained in:
Marshal
2026-06-29 08:08:10 +00:00
parent 4be4cc9054
commit aeb5e0046e
13 changed files with 436 additions and 104 deletions

View File

@@ -1,5 +1,6 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -191,15 +192,20 @@ export class ContractBookingService {
*/
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
if (contract.customsClearingEnabled) {
// Path B — the customer creates the booking once GL has finalized the
// pre-booking clearance (GL "create booking" was removed; clearance ends
// at finalize and hands the booking back to the customer).
// Path B — Global Logistics creates the booking ON BEHALF OF the customer
// once GL has finalized the pre-booking clearance. The customer never
// books a customs contract himself.
if (!isGlActor) {
throw new ForbiddenException(
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
);
}
if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') {
throw new BadRequestException(
'Contract clearance is not ready for booking yet.',
);
}
return isGlActor ? 'GL_ET' : 'CUSTOMER';
return 'GL_ET';
}
// Path A — customer (or staff) once the contract is executed.

View File

@@ -8,7 +8,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { FilesService } from '../files/files.service';
@@ -220,9 +220,55 @@ export class ContractsService {
}
}
// Attach the company profile's onboarding / business-license documents to the
// contract by reference. The separate "Documents" intake step was removed —
// the profile documents are simply carried onto every contract automatically.
await this.attachProfileDocuments(contract.id, companyProfileId);
return { contract: await this.findById(contract.id), warnings };
}
/**
* Copy a company profile's stored business-license / onboarding documents onto
* a contract by reference (no byte re-upload). Codes are slugged from each
* document name so they group under "Profile documents" on the contract detail
* page. No-op when the contract has no profile or the profile has no documents.
*/
private async attachProfileDocuments(
contractId: string,
companyProfileId: string | null,
): Promise<void> {
if (!companyProfileId) return;
const profile = await this.dataSource
.getRepository(CompanyProfile)
.findOne({ where: { id: companyProfileId } });
const docs = profile?.businessLicenseFiles ?? [];
if (docs.length === 0) return;
const slug = (name: string) =>
name
.toLowerCase()
.replace(/\.[a-z0-9]+$/, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'profile_document';
try {
await this.filesService.attachExistingFiles(
contractId,
'contracts',
docs.map((d, i) => ({
code: `${slug(d.name)}_${i + 1}`,
name: d.name,
url: d.url,
size: d.size,
mimeType: d.mimeType,
})),
);
} catch {
// Non-fatal — the contract is still valid without the carried documents.
}
}
private async persistRoutes(
contractId: string,
routes: CreateContractDto['routes'],

View File

@@ -77,6 +77,13 @@ export class WagonsService {
async update(id: string, dto: UpdateWagonDto): Promise<Wagon> {
const wagon = await this.findById(id);
Object.assign(wagon, dto);
// `findById` eager-loads `currentYard`; when the DTO changes the scalar FK
// TypeORM otherwise re-derives `current_yard_id` from the STALE relation
// object (the old yard) on save and silently reverts the change. Drop the
// relation so the scalar `currentYardId` wins.
if (dto.currentYardId !== undefined) {
wagon.currentYard = null;
}
await this.wagonRepo.save(wagon);
// Re-read with the relation so the response reflects the new yard label
// instead of the stale relation object loaded before the assign.

View File

@@ -11,19 +11,25 @@ import {
Grid,
Group,
Loader,
Modal,
NumberInput,
Paper,
Select,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
} from "@mantine/core";
import {
CheckCircle2,
Container as ContainerIcon,
FileText,
Package,
Plus,
Receipt,
Trash2,
X,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -36,6 +42,11 @@ import {
useContractMutations,
} from "@/hooks/contracts/useContracts";
import { Boxes } from "lucide-react";
import {
computeGlShipmentTotal,
formatRateUnit,
type GlShipmentQuantities,
} from "./gl-booking-form/total";
interface UnitDraft {
containerNumber: string;
@@ -73,6 +84,9 @@ export default function GlCreateBookingForm() {
const [notes, setNotes] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
// Price-confirm modal — GL reviews the estimate before booking on behalf of
// the customer, mirroring the portal customer flow.
const [priceOpen, setPriceOpen] = useState(false);
const isContainer = contract?.freightType === "CONTAINER";
const routes = useMemo(
@@ -104,6 +118,34 @@ export default function GlCreateBookingForm() {
const defaultBulkCargoTypeId = bulkCargoOptions[0]?.value ?? "";
// Normalized quantities for the client-side price estimate (same source the
// portal customer sees: the contract's frozen unit rates × entered qty).
const quantities: GlShipmentQuantities = useMemo(
() => ({
isContainer,
containers: containerLines.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
hazardousQuantity: Number(l.hazardousQuantity || 0),
reeferQuantity: Number(l.reeferQuantity || 0),
})),
bulkQuantity: bulkLines.reduce(
(s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0),
0,
),
bulkHazardousQuantity: bulkLines.reduce(
(s, l) => s + Number(l.hazardousQuantity || 0),
0,
),
}),
[isContainer, containerLines, bulkLines],
);
const priceTotal = useMemo(
() => (contract ? computeGlShipmentTotal(contract, quantities) : null),
[contract, quantities],
);
if (isLoading) {
return (
<PageContainer>
@@ -119,7 +161,7 @@ export default function GlCreateBookingForm() {
<PageContainer>
<PageHeader
title="Contract not found"
backTo="/dashboard/clearance"
backTo="/dashboard/contracts/clearance"
/>
</PageContainer>
);
@@ -233,12 +275,15 @@ export default function GlCreateBookingForm() {
<PageHeader
title="Create booking (GL)"
subtitle={`Enter the shipment details on behalf of the customer for contract ${contract.reference}.`}
backTo={`/dashboard/clearance/${contract.id}`}
backTo={`/dashboard/contracts/clearance/${contract.id}`}
breadcrumbs={[
{ label: "Document Clearance", href: "/dashboard/clearance" },
{
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{
label: contract.reference,
href: `/dashboard/clearance/${contract.id}`,
href: `/dashboard/contracts/clearance/${contract.id}`,
},
{ label: "Create booking" },
]}
@@ -578,20 +623,120 @@ export default function GlCreateBookingForm() {
<Group justify="flex-end">
<Button
variant="default"
onClick={() => navigate(`/dashboard/clearance/${contract.id}`)}
onClick={() =>
navigate(`/dashboard/contracts/clearance/${contract.id}`)
}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
disabled={!canSubmit}
loading={mutations.createBooking.isPending}
onClick={handleSubmit}
onClick={() => setPriceOpen(true)}
>
Create booking
Review price &amp; book
</Button>
</Group>
</Stack>
{/* Price-confirm — GL reviews the estimate, then books on behalf of the
customer. The server recomputes the authoritative total on submit. */}
<Modal
opened={priceOpen}
onClose={() => {
if (!mutations.createBooking.isPending) setPriceOpen(false);
}}
centered
radius="lg"
size="lg"
title={
<Group gap={10}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<Receipt size={18} />
</ThemeIcon>
<Box>
<Text fw={800} fz={16}>
Confirm shipment price
</Text>
<Text fz="xs" c="dimmed">
Booking on behalf of the customer for {contract.reference}.
</Text>
</Box>
</Group>
}
>
{priceTotal ? (
<Stack gap="md">
<Paper withBorder radius={16} p="lg">
<Stack gap={10}>
{priceTotal.lines.map((line, i) => (
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ minWidth: 0 }}>
<Text fz="sm" fw={500}>
{line.label}
</Text>
<Text fz="xs" c="dimmed">
{line.quantity.toLocaleString()} ×{" "}
{line.unitPrice.toLocaleString()} {priceTotal.currency} ·{" "}
{formatRateUnit(line.unit)}
</Text>
</Box>
<Text fz="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
{line.amount.toLocaleString()} {priceTotal.currency}
</Text>
</Group>
))}
{priceTotal.lines.length === 0 && (
<Text fz="sm" c="dimmed">
No priced lines check the cargo details.
</Text>
)}
</Stack>
<Divider my="md" />
<Group justify="space-between" align="flex-end">
<Text
fz="xs"
fw={700}
tt="uppercase"
c="edr-green"
style={{ letterSpacing: "0.06em" }}
>
Total
</Text>
<Text fw={800} fz={28}>
{priceTotal.total.toLocaleString()}{" "}
<Text span fz={16} fw={700} c="dimmed">
{priceTotal.currency}
</Text>
</Text>
</Group>
</Paper>
<Group justify="space-between" mt="xs">
<Button
variant="default"
radius="md"
leftSection={<X size={16} />}
onClick={() => setPriceOpen(false)}
disabled={mutations.createBooking.isPending}
>
Back to edit
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
loading={mutations.createBooking.isPending}
onClick={handleSubmit}
>
Confirm &amp; book
</Button>
</Group>
</Stack>
) : null}
</Modal>
</PageContainer>
);
}

View File

@@ -0,0 +1,132 @@
import type { Freight } from "@edr/types";
export interface GlShipmentTotalLine {
label: string;
unitPrice: number;
unit: Freight.ContractRateUnit | string;
quantity: number;
amount: number;
}
export interface GlShipmentTotal {
currency: string;
lines: GlShipmentTotalLine[];
total: number;
}
/** A normalized view of the form quantities, freight-shape agnostic. */
export interface GlShipmentQuantities {
isContainer: boolean;
/** Container lines: size + total qty + hazardous/reefer qty. */
containers: Array<{
containerSize: string;
quantity: number;
hazardousQuantity: number;
reeferQuantity: number;
}>;
/** Bulk: tons (or item count) + hazardous qty. */
bulkQuantity: number;
bulkHazardousQuantity: number;
}
/**
* Compute the booking total client-side from the contract's frozen unit rates ×
* the quantities GL enters. Mirrors the portal customer estimate
* (new-shipment-form/total.ts) — the server recomputes the authoritative total
* on submit. Shown in the price-confirm modal before GL books on behalf of the
* customer.
*/
export function computeGlShipmentTotal(
contract: Freight.IContract,
q: GlShipmentQuantities,
): GlShipmentTotal {
const breakdown = contract.pricingBreakdown;
const currency = breakdown?.currency ?? contract.paymentCurrency ?? "ETB";
const items = breakdown?.lineItems ?? [];
const lines: GlShipmentTotalLine[] = [];
const rateFor = (
predicate: (i: Freight.ContractUnitRateLineItem) => boolean,
) => items.find(predicate);
if (q.isContainer) {
let hazardTotalQty = 0;
let reeferTotalQty = 0;
for (const line of q.containers) {
const qty = line.quantity;
if (qty <= 0) continue;
const rate =
rateFor(
(i) =>
i.containerSize === line.containerSize &&
i.unit === "per_container" &&
!i.conditionalOn,
) ?? rateFor((i) => i.containerSize === line.containerSize);
if (rate) {
lines.push({
label: rate.label,
unitPrice: rate.unitPrice,
unit: rate.unit,
quantity: qty,
amount: rate.unitPrice * qty,
});
}
hazardTotalQty += line.hazardousQuantity;
reeferTotalQty += line.reeferQuantity;
}
if (contract.isHazardous && hazardTotalQty > 0) {
const hz = rateFor((i) => i.conditionalOn === "is_hazardous");
if (hz) {
lines.push({
label: hz.label,
unitPrice: hz.unitPrice,
unit: hz.unit,
quantity: hazardTotalQty,
amount: hz.unitPrice * hazardTotalQty,
});
}
}
if (contract.isReefer && reeferTotalQty > 0) {
const rf = rateFor((i) => i.conditionalOn === "is_reefer");
if (rf) {
lines.push({
label: rf.label,
unitPrice: rf.unitPrice,
unit: rf.unit,
quantity: reeferTotalQty,
amount: rf.unitPrice * reeferTotalQty,
});
}
}
} else {
const qty = q.bulkQuantity;
const rate =
rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0];
if (rate && qty > 0) {
lines.push({
label: rate.label,
unitPrice: rate.unitPrice,
unit: rate.unit,
quantity: qty,
amount: rate.unitPrice * qty,
});
}
}
const total = lines.reduce((s, l) => s + l.amount, 0);
return { currency, lines, total };
}
/** Human-readable label for a contract unit-rate's charge unit. */
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
const map: Record<string, string> = {
per_container: "container",
per_ton: "ton",
per_item: "item",
per_km: "km",
flat: "flat",
};
return map[unit] ?? unit.replace(/_/g, " ").replace(/^per /, "");
}

View File

@@ -4,6 +4,7 @@ import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
SegmentedControl,
@@ -20,6 +21,7 @@ import {
Inbox,
LayoutGrid,
PackageCheck,
PackagePlus,
RefreshCw,
Search,
ShieldCheck,
@@ -293,15 +295,33 @@ export default function ContractClearanceListPage() {
},
{
id: "go",
size: 56,
cell: () => (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
</Group>
),
size: 150,
cell: ({ row }) =>
row.original.ready ? (
<Group justify="flex-end" pr="xs">
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<PackagePlus size={14} />}
onClick={(e) => {
e.stopPropagation();
navigate(
`/dashboard/contracts/${row.original.id}/create-booking`,
);
}}
>
Create booking
</Button>
</Group>
) : (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
</Group>
),
},
],
[],
[navigate],
);
return (

View File

@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
@@ -21,7 +20,6 @@ import {
Download,
Eye,
FileText,
PackagePlus,
Plus,
Upload,
} from "lucide-react";
@@ -87,7 +85,6 @@ export function ContractClearancePanel({
bare,
}: ContractClearancePanelProps) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [pending, setPending] = useState<Record<string, File>>({});
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
const { view, viewer } = useFileViewer();
@@ -195,8 +192,9 @@ export function ContractClearancePanel({
)}
{isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
Your clearance documents are approved. You can now create a shipment
booking under this contract.
{customsPath
? "Your clearance documents are approved. Global Logistics will create your booking on your behalf — you will be notified when payment is due."
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
</Alert>
) : isUnderReview ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
@@ -443,19 +441,6 @@ export function ContractClearancePanel({
</Group>
)}
{/* Clearance finalized → the customer creates the booking himself. */}
{isReady && status === "CLEARANCE_READY_FOR_BOOKING" && (
<Group justify="flex-end" mt="lg">
<Button
color="edr-green"
radius="md"
leftSection={<PackagePlus size={16} />}
onClick={() => navigate(`/contracts/${contractId}/bookings/new`)}
>
Create booking
</Button>
</Group>
)}
{viewer}
</Stack>
);

View File

@@ -205,19 +205,20 @@ export default function ContractDetailPage() {
const canSign = contract.status === "CONTRACT_READY";
const customsPath = contract.customsClearingEnabled;
// The customer creates the booking himself on BOTH paths:
// - Path A (no customs): once the contract is executed after self-clearance.
// - Path B (customs): once GL finalizes the pre-booking clearance
// (CLEARANCE_READY_FOR_BOOKING). GL "create booking" was removed.
// Only the NON-customs (Path A) customer books himself — once the contract is
// executed after self-clearance. Customs (Path B) bookings are created by
// Global Logistics on the customer's behalf, so the customer gets no booking
// button on a customs contract.
const clearanceFinalized =
contract.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
contract.status === "CLEARANCE_READY_FOR_BOOKING";
const canBookShipment = customsPath
? clearanceFinalized
: PATH_A_BOOKABLE.includes(contract.status);
// The customer uploads clearance documents while in a clearance status — but
// once clearance is finalized the upload step is done; the action becomes
// "create booking" instead.
const canBookShipment =
!customsPath && PATH_A_BOOKABLE.includes(contract.status);
// Customs + clearance finalized: GL is preparing the booking — surface a
// status notice instead of any action.
const glPreparingBooking = customsPath && clearanceFinalized;
// The customer uploads clearance documents while in a clearance status, until
// clearance is finalized.
const canUploadClearance =
CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized;
@@ -292,6 +293,17 @@ export default function ContractDetailPage() {
New shipment booking
</Button>
)}
{glPreparingBooking && (
<Badge
size="lg"
radius="md"
variant="light"
color="teal"
leftSection={<CheckCircle2 size={14} />}
>
Global Logistics is creating your booking
</Badge>
)}
{canUploadClearance && (
<Button
color="edr-green"

View File

@@ -108,6 +108,21 @@ function getCustomerRowAction(
icon: Upload,
};
}
// Customs (Path B), clearance finalized: Global Logistics creates the booking
// on the customer's behalf — the customer only views the contract.
if (
contract.customsClearingEnabled &&
["CLEARANCE_READY_FOR_BOOKING", "ACTIVE_SHIPMENT_IN_PROGRESS"].includes(
contract.status,
)
) {
return {
label: "View",
to: `/contracts/${id}`,
primary: false,
icon: Eye,
};
}
const booking = getContractBookingAction(contract, bookings);
if (booking.kind === "book") {
return {

View File

@@ -26,7 +26,7 @@ import {
Send,
XCircle,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
@@ -57,7 +57,6 @@ import {
Step3CargoScope,
Step4Route,
Step8Review,
StepDocuments,
} from "./new-contract-form/steps";
import { StepCard, StepHeader } from "./new-contract-form/shared";
import { formatRateUnit } from "./new-contract-form/unit-rates";
@@ -261,27 +260,11 @@ export default function NewContractPage() {
return active?.licenseFiles ?? [];
}, [auth.company, auth.activeCompanyProfileId]);
// The documents step validates required uploads imperatively (the requirement
// set is async-loaded), so it registers a validator we call before advancing.
const docsValidatorRef = useRef<(() => boolean) | null>(null);
async function handleContinue() {
const valid = await form.trigger(contractStepFields[step], {
shouldFocus: true,
});
if (!valid) {
// TEMP DEBUG — surface which step-1 fields block Continue.
// eslint-disable-next-line no-console
console.warn("[contract continue blocked] step", step, {
errors: JSON.parse(JSON.stringify(form.formState.errors)),
values: form.getValues(),
});
return;
}
// Step 2 — Documents: every required document must be on file or uploaded.
if (step === 2 && docsValidatorRef.current && !docsValidatorRef.current()) {
return;
}
if (!valid) return;
goToStep(1);
}
@@ -540,12 +523,8 @@ export default function NewContractPage() {
)}
{/* Step 2 — Documents. */}
{/* Step 2 — Review & Submit. */}
{step === 2 && (
<StepDocuments form={form} validatorRef={docsValidatorRef} />
)}
{/* Step 3 — Review & Submit. */}
{step === 3 && (
<Step8Review
form={form}
setStep={setStep}

View File

@@ -112,24 +112,21 @@ export default function NewShipmentPage() {
);
}
// Customs (Path B) contracts can only be booked once GL has finalized the
// pre-booking clearance. Before that, send the customer to the clearance step.
// (GL "create booking" was removed — the customer books once cleared.)
const clearanceFinalized =
contract.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
contract.status === "CLEARANCE_READY_FOR_BOOKING" ||
contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS";
if (contract.customsClearingEnabled && !clearanceFinalized) {
// Customs (Path B) contracts are booked by Global Logistics on behalf of the
// customer — the customer never books one himself. Block the form entirely and
// point back to the clearance workspace.
if (contract.customsClearingEnabled) {
return (
<Box p="xl">
<Alert color="orange" icon={<AlertCircle size={18} />} radius="md">
<Alert color="blue" icon={<AlertCircle size={18} />} radius="md">
<Text fw={700} mb="xs">
Clearance not finalized yet
Global Logistics handles bookings for this contract
</Text>
<Text size="sm" mb="md">
This contract includes customs clearance. Upload your clearance
documents once Global Logistics finalizes the clearance you can
create your shipment booking here.
documents once Global Logistics finalizes the clearance, they
create the booking on your behalf and you will be notified when
payment is due.
</Text>
<Button
color="edr-green"

View File

@@ -15,16 +15,6 @@ export const TERMINAL_BOOKING_STATUSES = [
/** Path A statuses where a customer (no customs) may book against the contract. */
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
/**
* Path B (customs): the customer books once GL has finalized the pre-booking
* clearance. ACTIVE_SHIPMENT_IN_PROGRESS is included so GENERAL contracts can
* re-book after a prior shipment. (GL "create booking" was removed.)
*/
const PATH_B_BOOKABLE = [
"CLEARANCE_READY_FOR_BOOKING",
"ACTIVE_SHIPMENT_IN_PROGRESS",
];
export type ContractBookingActionKind = "book" | "rebook" | "none";
export interface ContractBookingAction {
@@ -45,10 +35,10 @@ export function getContractBookingAction(
contract: Freight.IContract,
bookings: Freight.IBooking[],
): ContractBookingAction {
const bookable = contract.customsClearingEnabled
? PATH_B_BOOKABLE.includes(contract.status)
: PATH_A_BOOKABLE.includes(contract.status);
if (!bookable) return { kind: "none", to: "" };
// Customs (Path B) contracts are booked by Global Logistics on behalf of the
// customer — the customer never gets a Book button for them.
if (contract.customsClearingEnabled) return { kind: "none", to: "" };
if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" };
const to = `/contracts/${contract.id}/bookings/new`;

View File

@@ -8,8 +8,7 @@ import * as z from "zod";
export const 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" },
{ id: 2, label: "Review & Submit", short: "Review" },
] as const;
export const OPERATION_TYPES = [
@@ -331,8 +330,7 @@ export const contractStepFields: Record<
"extraRoutes",
"estimatedShipmentDate",
],
// Step 2 — Documents.
2: ["documents"],
// Step 3 — Review & Submit.
3: ["notes"],
// Step 2 — Review & Submit. (The separate Documents step was removed — the
// company profile documents are attached to the contract automatically.)
2: ["notes"],
};