Implement clearance-first booking flow and completion process for customs contracts

This commit is contained in:
Marshal
2026-07-10 18:56:39 +00:00
parent b50075dc83
commit 9ed473c309
18 changed files with 369 additions and 39 deletions

View File

@@ -865,6 +865,18 @@ const App = () => {
</RequirePermission>
}
/>
{/* Completion of an initiated (bare) instance after per-booking
clearance — same form, submits to the complete endpoint. */}
<Route
path="contracts/:id/bookings/:bookingId/complete"
element={
<RequirePermission
permission={FREIGHT_PERMS.contracts.createBooking}
>
<GlCreateBookingForm />
</RequirePermission>
}
/>
<Route
path="bookings/:id/milestones"
element={<BookingMilestonesRedirect />}

View File

@@ -145,13 +145,37 @@ function bulkUnitOfMeasure(
}
export default function GlCreateBookingForm() {
const { id } = useParams<{ id: string }>();
// With `bookingId` the form runs in COMPLETION mode: the bare instance
// (auto-initiated by the customer's shipment request) already finished its
// per-booking customs clearance, and this form supplies the deferred cargo
// (container numbers, VGM) + binding shipment day. Same window gate, same
// validation and price confirmation — the submit completes the existing
// booking instead of creating a new one.
const { id, bookingId: completeBookingId } = useParams<{
id: string;
bookingId?: string;
}>();
const [searchParams] = useSearchParams();
const requestId = searchParams.get("requestId");
const requestIdParam = searchParams.get("requestId");
const navigate = useNavigate();
const { data: contract, isLoading } = useContractDetail(id);
const mutations = useContractMutations(id ?? "");
// Completion mode without an explicit ?requestId=: find the shipment request
// that initiated this instance so the quantities still prefill.
const { data: contractRequests } = useQuery({
queryKey: ["shipment-requests-for-contract", id],
queryFn: () => contractsService.listBookingRequests(id!),
enabled: Boolean(id) && Boolean(completeBookingId) && !requestIdParam,
});
const requestId =
requestIdParam ??
(completeBookingId
? (contractRequests?.find(
(r) => r.createdBookingId === completeBookingId,
)?.id ?? null)
: null);
const { data: bookingRequest } = useQuery({
queryKey: ["shipment-request", requestId],
queryFn: () => contractsService.getBookingRequest(requestId!),
@@ -636,6 +660,18 @@ export default function GlCreateBookingForm() {
const payload = buildPayload();
if (!payload) return;
if (completeBookingId) {
// Completion mode: cargo + day land on the already-cleared instance —
// the request was linked and accepted at submission time.
mutations.completeBooking.mutate(
{ bookingId: completeBookingId, payload },
{
onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`),
},
);
return;
}
mutations.createBooking.mutate(payload, {
onSuccess: async (booking) => {
if (requestId) {
@@ -685,10 +721,12 @@ export default function GlCreateBookingForm() {
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md" mb="lg">
<Box>
<Text fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
New Shipment Booking
{completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"}
</Text>
<Text size="sm" c="dimmed" mt={4}>
Book a shipment on behalf of the customer for contract {contract.reference}.
{completeBookingId
? `Clearance is finalized — enter the cargo details and shipment day to complete the booking under contract ${contract.reference}.`
: `Book a shipment on behalf of the customer for contract ${contract.reference}.`}
</Text>
</Box>
<Button
@@ -1261,7 +1299,11 @@ export default function GlCreateBookingForm() {
<Modal
opened={priceOpen}
onClose={() => {
if (!mutations.createBooking.isPending) setPriceOpen(false);
if (
!mutations.createBooking.isPending &&
!mutations.completeBooking.isPending
)
setPriceOpen(false);
}}
centered
radius="lg"
@@ -1413,7 +1455,10 @@ export default function GlCreateBookingForm() {
radius="md"
leftSection={<X size={16} />}
onClick={() => setPriceOpen(false)}
disabled={mutations.createBooking.isPending}
disabled={
mutations.createBooking.isPending ||
mutations.completeBooking.isPending
}
>
Reject &amp; edit
</Button>
@@ -1421,7 +1466,10 @@ export default function GlCreateBookingForm() {
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
loading={mutations.createBooking.isPending}
loading={
mutations.createBooking.isPending ||
mutations.completeBooking.isPending
}
disabled={
validateShipmentMutation.isPending ||
pairingErrors.length > 0 ||
@@ -1429,7 +1477,7 @@ export default function GlCreateBookingForm() {
}
onClick={handleSubmit}
>
Confirm &amp; book
{completeBookingId ? "Confirm & complete" : "Confirm & book"}
</Button>
</Group>
</Stack>

View File

@@ -211,6 +211,8 @@ export const URL_CONSTANTS = {
CLEARANCE_HISTORY: "/contracts/clearance/history",
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
`/contracts/${id}/bookings/${bookingId}/complete`,
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.

View File

@@ -214,6 +214,27 @@ export function useContractMutations(contractId: string) {
onError: () => toast.error("Failed to create booking"),
});
const completeBooking = useMutation({
mutationFn: ({
bookingId,
payload,
}: {
bookingId: string;
payload: Freight.CreateBookingUnderContractDto;
}) =>
contractsService.completeBookingUnderContract(
contractId,
bookingId,
payload,
),
onSuccess: () => {
toast.success("Booking completed");
void invalidateContractDetail(qc, contractId);
},
onError: (e: Error) =>
toast.error(e.message || "Failed to complete booking"),
});
const isPending =
staffAccept.isPending ||
requestChanges.isPending ||
@@ -233,6 +254,7 @@ export function useContractMutations(contractId: string) {
generateContract,
signContract,
createBooking,
completeBooking,
isPending,
};
}

View File

@@ -1,10 +1,11 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import {
Alert,
Badge,
Box,
Button,
Grid,
Group,
Loader,
@@ -21,6 +22,7 @@ import {
CheckCircle2,
Clock,
PackageCheck,
PackagePlus,
ShieldCheck,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -38,10 +40,14 @@ import { useBookingMilestones } from "@/hooks/contracts/useContracts";
import { bookingsService } from "@/services/bookings.service";
import { downloadBookingFile } from "@/services/files.service";
import { useBookingDetail } from "@/hooks/bookings/useBookings";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
export default function DocumentClearanceDetailPage() {
const params = useParams<{ id?: string; bookingId?: string }>();
const id = params.id ?? params.bookingId;
const navigate = useNavigate();
const { user } = useAuth();
const { view, viewer } = useFileViewer();
const { data: booking } = useBookingDetail(id);
@@ -76,6 +82,15 @@ export default function DocumentClearanceDetailPage() {
booking?.contractKind === "GENERAL" &&
Boolean(clearance?.phase);
// Bare initiated instance whose clearance is done: GL completes the booking
// (container numbers, VGM, shipment day) via the completion form.
const canCompleteBooking =
booking?.status === "CLEARANCE_READY" &&
Boolean(booking?.contractId) &&
Boolean(booking?.customsClearingEnabled) &&
!(Number(booking?.totalAmount ?? 0) > 0) &&
hasPermission(user, FREIGHT_PERMS.contracts.createBooking);
const docsPhaseComplete =
clearance?.milestones?.some(
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
@@ -146,6 +161,22 @@ export default function DocumentClearanceDetailPage() {
</Badge>
)
}
action={
canCompleteBooking ? (
<Button
color="edr-green"
radius="md"
leftSection={<PackagePlus size={16} />}
onClick={() =>
navigate(
`/dashboard/contracts/${booking!.contractId}/bookings/${id}/complete`,
)
}
>
Complete booking
</Button>
) : undefined
}
/>
<ClearanceHero booking={booking} clearance={clearance} stats={stats} />

View File

@@ -402,7 +402,7 @@ export default function ShipmentRequestsPage() {
<Stack gap="lg">
<PageHeader
title="Shipment Requests"
subtitle="Customer requests to ship under general customs contracts. Accept one to create the booking and start its clearance."
subtitle="Customer requests to ship under general customs contracts. Each request starts its booking's clearance immediately — complete the booking from the clearance page once it is ready."
meta={
<Badge
variant="light"

View File

@@ -502,6 +502,31 @@ export const contractsService = {
};
},
/**
* Complete a bare initiated booking instance once its per-booking clearance
* is CLEARANCE_READY — same payload as create; the API persists cargo,
* prices, invoices, checks the booking window and moves the booking to the
* operations queue.
*/
completeBookingUnderContract: async (
id: string,
bookingId: string,
payload: Freight.CreateBookingUnderContractDto,
): Promise<{ id: string; reference: string; warnings?: string[] }> => {
const result = await postContract<{
booking?: { id: string; reference: string };
id?: string;
reference?: string;
warnings?: string[];
}>(C.BOOKINGS_COMPLETE(id, bookingId), payload);
const booking = result.booking ?? result;
return {
id: booking.id ?? "",
reference: booking.reference ?? "",
warnings: result.warnings,
};
},
/**
* Pre-create validation + authoritative price preview: the same
* BookingPricingService pass that prices the booking on create (rail +