diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 8b6e8a2f8..7b1522446 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -988,6 +988,15 @@ export class BookingTransitionService { "OPERATION_CHANGES_REQUESTED", ]); + // A bare initiated instance (clearance-first flow) carries no cargo or + // price — it must go through the contract completion endpoint, which + // persists cargo, prices, invoices and only then lands here itself. + if (booking.contractId && !(Number(booking.totalAmount) > 0)) { + throw new BadRequestException( + "This booking must be completed (cargo and shipment day) before requesting operation.", + ); + } + const date = new Date(scheduledDate); if (Number.isNaN(date.getTime())) { throw new BadRequestException("A valid schedule date is required"); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts index 7d4ff199c..b2937e98d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts @@ -7,6 +7,7 @@ function mockQueryBuilder() { const qb = { leftJoinAndSelect: jest.fn().mockReturnThis(), leftJoin: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), @@ -15,6 +16,8 @@ function mockQueryBuilder() { take: jest.fn().mockReturnThis(), getMany: jest.fn(), getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + getCount: jest.fn().mockResolvedValue(0), + getRawAndEntities: jest.fn().mockResolvedValue({ entities: [], raw: [] }), }; return qb; } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 17270c738..1005409e0 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -113,6 +113,17 @@ export class BookingRequestService { }, }; + // Clearance-first flow: the request immediately initiates a BARE booking + // instance (no cargo, no date, no price) that enters per-booking phased + // customs clearance. GL no longer screens the request up front — it + // reviews the documents in the clearance queue and completes the booking + // (container numbers, VGM, shipment day) once clearance is ready. The + // instance is created first so a failure leaves no half-linked request. + const booking = await this.contractBookingService.initiateForShipmentRequest( + contract, + { contractRouteId: dto.contractRouteId, userId }, + ); + const reference = await this.generateReference(); const request = await this.repo.create({ reference, @@ -120,7 +131,8 @@ export class BookingRequestService { requestedByUserId: userId ?? null, contractRouteId: dto.contractRouteId ?? null, scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, - status: 'PENDING', + status: 'ACCEPTED', + createdBookingId: booking.id, requestedLines, notes: dto.notes ?? null, } as never); diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index 81ed305e4..ed3597d57 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -58,6 +58,31 @@ export class ClearanceMilestoneService { await this.seed(postBooking, { bookingId }); } + /** + * Seed whichever pre/post-booking milestones the booking is still missing, + * keyed by milestoneCode. Plain seeding is a blind insert, so paths that can + * run more than once (completing an initiated instance whose pre-booking + * milestones were seeded at initiation, or a consolidation pairing replay) + * must go through this instead — a duplicate timeline breaks the phase + * derivation. + */ + async ensureBookingMilestones( + bookingId: string, + tradeDirection: string, + ): Promise { + const existing = await this.repo.find({ where: { bookingId } }); + const have = new Set(existing.map((m) => m.milestoneCode)); + const { preBooking, postBooking } = splitMilestones(tradeDirection); + await this.seed( + preBooking.filter((d) => !have.has(d.code)), + { bookingId }, + ); + await this.seed( + postBooking.filter((d) => !have.has(d.code)), + { bookingId }, + ); + } + private async seed( defs: MilestoneDef[], scope: { contractId?: string; clearanceCycleId?: string; bookingId?: string }, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index fa851b409..7225d6e3f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -36,6 +36,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => { const milestoneService = { seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined), seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined), + ensureBookingMilestones: jest.fn().mockResolvedValue(undefined), ...overrides.milestoneService, }; const contractsRepository = { @@ -144,9 +145,13 @@ describe('ContractBookingService — drawdown consolidation gate', () => { await service.onConsolidationPaired({ bookingIds: ['b-1'] }); expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); - // GENERAL customs → per-booking pre + post milestones. - expect(milestoneService.seedPreBookingMilestonesOnBooking).toHaveBeenCalled(); - expect(milestoneService.seedPostBookingMilestones).toHaveBeenCalled(); + // GENERAL customs → per-booking milestones, via the idempotent ensure so a + // pairing replay (or an initiated instance's pre-seeded timeline) never + // duplicates rows. + expect(milestoneService.ensureBookingMilestones).toHaveBeenCalledWith( + 'b-1', + 'EXPORT', + ); }); it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 74d9cb1ea..a0da43784 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -426,17 +426,100 @@ export class ContractBookingService { } /** - * Complete a bare initiated booking after Operations finalized its per-booking - * clearance (CLEARANCE_READY) or returned it for changes + * Initiate a BARE booking instance for a GENERAL + customs shipment request + * (Path B, clearance-first). Called by BookingRequestService.submit AFTER it + * validated the contract (general customs, active, capacity) — the request + * itself carries the quantities; the instance carries none. Pre-booking + * customs milestones are seeded immediately so the instance enters the same + * phased ET/DJ clearance a ONE_TIME customs contract runs, just per booking. + * GL completes the booking (cargo + day) via {@link completeUnderContract} + * once the clearance reaches CLEARANCE_READY. + */ + async initiateForShipmentRequest( + contract: Contract, + opts: { contractRouteId?: string; userId?: string | null }, + ): Promise { + const generalCustoms = + contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); + if (!generalCustoms) { + throw new BadRequestException( + 'Shipment-request initiation applies only to general customs contracts.', + ); + } + if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { + throw new BadRequestException('Contract validity has expired — no new bookings.'); + } + + const route = await this.resolveRoute(contract, opts.contractRouteId); + + const booking = await insertWithGeneratedReference( + () => this.generateReference(), + (reference) => + this.bookingsRepository.create({ + reference, + companyId: contract.companyId ?? null, + companyProfileId: contract.companyProfileId ?? null, + isGovernment: contract.isGovernment, + governmentInstitution: contract.governmentInstitution ?? null, + status: 'AWAITING_DOCUMENTS', + bookingType: 'ONE_TIME', + contractId: contract.id, + contractRouteId: route?.id ?? null, + contractKind: contract.contractKind, + createdByRole: 'CUSTOMER', + createdByUserId: opts.userId ?? null, + scheduledDate: null, + serviceTypeId: contract.serviceTypeId, + paymentCurrency: contract.paymentCurrency, + contractType: 'NEW', + customsClearingEnabled: contract.customsClearingEnabled, + customsClearingAgent: contract.customsClearingAgent ?? null, + equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN', + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, + tradeDirection: contract.tradeDirection, + freightType: contract.freightType, + cargoTypeId: this.resolveCargoTypeId(contract, {}), + isHazardous: contract.isHazardous, + isReefer: contract.isReefer, + cargoTotalWeightVgm: 0, + firstMilePickupAddress: contract.firstMilePickupAddress ?? null, + firstMilePickupLat: contract.firstMilePickupLat ?? null, + firstMilePickupLng: contract.firstMilePickupLng ?? null, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, + lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null, + lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null, + } as never), + ); + + // Pre-booking phase only — the post-booking milestones (loading, transit) + // are seeded when GL completes the booking, mirroring the ONE_TIME flow + // where GL's booking creation seeds them. + await this.milestoneService.seedPreBookingMilestonesOnBooking( + booking.id, + contract.tradeDirection, + ); + + return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking; + } + + /** + * Complete a bare initiated booking after its per-booking clearance is + * finalized (CLEARANCE_READY) or operations returned it for changes * (OPERATION_CHANGES_REQUESTED). This is the deferred half of * {@link createUnderContract}: cargo lines, quantity-cap drawdown, booking * window + open-departure checks, pricing, consolidation and invoicing all run * here — the same gates a one-time shipment passes at creation. + * + * Actor rules mirror {@link assertGate}: a customs (Path B) instance is + * completed by GL Ethiopia only; a non-customs (Path A) instance by the + * customer (or staff). */ async completeUnderContract( contractId: string, bookingId: string, dto: CreateBookingUnderContractDto, + actorPermissions?: unknown, ): Promise { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); @@ -450,6 +533,18 @@ export class ContractBookingService { 'Clearance must be finalized before the booking can be completed.', ); } + // Path B: only GL Ethiopia completes a customs instance — the customer + // never enters shipment data on a customs contract. + if (contract.customsClearingEnabled) { + const isGlActor = + actorPermissions != null && + hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); + if (!isGlActor) { + throw new ForbiddenException( + 'Customs-clearance bookings are completed by Global Logistics on behalf of the customer.', + ); + } + } if (!dto.scheduledDate) { throw new BadRequestException('A binding shipment day is required'); } @@ -457,6 +552,15 @@ export class ContractBookingService { throw new BadRequestException('Contract validity has expired — no new bookings.'); } + // Completion is booking time: the route's booking window must be open — + // the same config-driven gate a direct one-time booking passes at create. + await this.trainSchedulingService.assertBookingWindowOpen({ + originYardId: booking.originYardId ?? null, + destinationYardId: booking.destinationYardId ?? null, + scheduledDate: dto.scheduledDate, + direction: contract.tradeDirection ?? null, + }); + const freightType = contract.freightType; const hasCargo = (booking.bookingContainers?.length ?? 0) > 0 || @@ -541,8 +645,13 @@ export class ContractBookingService { } } - // Invoice the now-priced booking (idempotent, non-blocking). - await this.finalizeContractBooking(booking.id, contract, false); + // Invoice the now-priced booking and, for a customs instance, seed the + // post-booking milestones (pre-booking ones exist since initiation — + // ensure* fills only what is missing). Idempotent, non-blocking. + const generalCustoms = + contract.contractKind === 'GENERAL' && + Boolean(contract.customsClearingEnabled); + await this.finalizeContractBooking(booking.id, contract, generalCustoms); await this.maybeCompleteContract(contract); } @@ -627,12 +736,11 @@ export class ContractBookingService { clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS', } as never); } else if (generalCustoms) { - // Per-booking clearance: seed full milestone timeline on the booking. - await this.milestoneService.seedPreBookingMilestonesOnBooking( - bookingId, - contract.tradeDirection, - ); - await this.milestoneService.seedPostBookingMilestones( + // Per-booking clearance: seed the full milestone timeline on the booking. + // ensure* skips codes that already exist — an initiated instance carries + // its pre-booking milestones from initiation, and a consolidation pairing + // replay must not duplicate the timeline. + await this.milestoneService.ensureBookingMilestones( bookingId, contract.tradeDirection, ); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index d5dc9793e..95b734c60 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -826,8 +826,16 @@ export class ContractsController { @Param('id', ParseUUIDPipe) id: string, @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: CreateBookingUnderContractDto, + @CurrentUser() user: AuthUserPayload, ) { - return this.contractBookingService.completeUnderContract(id, bookingId, dto); + // Customs (Path B) instances may only be completed by GL Ethiopia — the + // service checks the actor's contracts:create_booking permission. + return this.contractBookingService.completeUnderContract( + id, + bookingId, + dto, + user, + ); } @Post(':id/validate-shipment') diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index a94f7516a..d529c2817 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -865,6 +865,18 @@ const App = () => { } /> + {/* Completion of an initiated (bare) instance after per-booking + clearance — same form, submits to the complete endpoint. */} + + + + } + /> } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 32ec2c511..bb94fea08 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -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() { - New Shipment Booking + {completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"} - 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}.`} @@ -1421,7 +1466,10 @@ export default function GlCreateBookingForm() { color="edr-green" radius="md" leftSection={} - 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"} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index c6f98c8a2..ca9f6342d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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. diff --git a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts index 69e4e1739..076316114 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts @@ -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, }; } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx index 2cb5404e7..efe9692dc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -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() { ) } + action={ + canCompleteBooking ? ( + + ) : undefined + } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx index 7f2bf52d8..4eb93cafd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx @@ -402,7 +402,7 @@ export default function ShipmentRequestsPage() { => { + 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 + diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx index 32ea4f654..cbb163509 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx @@ -106,7 +106,10 @@ function BookingActionModalBody({ Complete booking ) : ( - flow.isReady && ( + // Customs bare instances await GL completion — no customer + // proceed button (the server rejects it anyway). + flow.isReady && + !flow.awaitingGlCompletion && (