mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 17:50:54 +00:00
implement Global Logistics booking process for customs contracts and enhance contract document handling
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 & 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 & book
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 /, "");
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user