mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 05:55:02 +00:00
Implement clearance-first booking flow and completion process for customs contracts
This commit is contained in:
@@ -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 />}
|
||||
|
||||
@@ -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 & 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 & book
|
||||
{completeBookingId ? "Confirm & complete" : "Confirm & book"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 +
|
||||
|
||||
Reference in New Issue
Block a user