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,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"],
};