diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 62530611c..68f92eea7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,7 +18,7 @@ jobs: matrix: ${{ steps.filter.outputs.matrix }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v4e with: fetch-depth: 2 diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 96ed3b701..e7806c13c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -118,6 +118,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingPricingService, BookingInvoiceService, BookingLifecycleNotifierService, + BookingTransitionService, ConsolidationService, CustomerTruckService, ContainerReceiptService, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 46b40d212..a3009ec3c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -804,9 +804,16 @@ export class BookingsRepository extends BaseRepository { }); } if (options.bookingType) { - qb.andWhere('booking.bookingType = :bookingType', { - bookingType: options.bookingType, - }); + // The stored booking_type column is 'ONE_TIME' for every row (contract + // drawdowns included — see contract-booking.service create), so the + // one-time vs general split keys on the denormalized contract_kind: + // GENERAL_CONTRACT tab = bookings under a GENERAL contract, ONE_TIME tab + // = everything else (ONE_TIME contracts and legacy contract-less rows). + if (options.bookingType === 'GENERAL_CONTRACT') { + qb.andWhere("booking.contract_kind = 'GENERAL'"); + } else { + qb.andWhere("booking.contract_kind IS DISTINCT FROM 'GENERAL'"); + } } if (options.createdFrom) { qb.andWhere('booking.created_at >= :createdFrom', { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index fb57be209..4e21078ff 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -28,6 +28,7 @@ describe('ContractBookingService — quantity-cap completion', () => { {} as never, // invoiceService {} as never, // dataSource {} as never, // trainSchedulingService + {} as never, // bookingTransitionService ); return { service, contractsRepository }; } 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 a83837350..fa851b409 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 @@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => { invoiceService as never, {} as never, // dataSource {} as never, // trainSchedulingService + {} as never, // bookingTransitionService ); return { service, 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 9a03bfe8a..74d9cb1ea 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 @@ -16,6 +16,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity' import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { BookingTransitionService } from '../bookings/booking-transition.service'; import { ConsolidationService } from '../bookings/consolidation.service'; import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; @@ -72,6 +73,8 @@ export class ContractBookingService { private readonly dataSource: DataSource, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, + @Inject(forwardRef(() => BookingTransitionService)) + private readonly bookingTransitionService: BookingTransitionService, ) {} async createUnderContract( @@ -331,6 +334,227 @@ export class ContractBookingService { return { booking: result ?? booking, warnings }; } + /** + * Initiate a BARE booking instance under a GENERAL non-customs contract + * (Path A per-booking self-clearance). One click, zero input: no schedule + * date, no cargo, no window check, no pricing. The instance starts in the + * clearance gate (AWAITING_DOCUMENTS); the customer uploads clearance docs, + * Operations reviews and finalizes, and only then does the customer complete + * the booking (cargo + binding day + window check) via + * {@link completeUnderContract} — the same machinery a one-time shipment uses. + */ + async initiateUnderContract( + contractId: string, + dto: Pick, + user?: { id?: string } | null, + actorPermissions?: unknown, + ): Promise { + const contract = await this.contractsRepository.findByIdWithRelations(contractId); + if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); + + const generalSelfClear = + contract.contractKind === 'GENERAL' && + !contract.customsClearingEnabled && + contract.tradeDirection !== 'DOMESTIC'; + if (!generalSelfClear) { + throw new BadRequestException( + 'Initiate booking applies only to general import/export contracts without customs clearing.', + ); + } + + if (contract.status === 'CONTRACT_CLOSED') { + throw new BadRequestException( + 'This contract is completed — the full contracted quantity has been booked.', + ); + } + + const isGlActor = + actorPermissions != null && + hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); + const createdByRole = await this.assertGate(contract, isGlActor); + + if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { + throw new BadRequestException('Contract validity has expired — no new bookings.'); + } + + const route = await this.resolveRoute(contract, dto.contractRouteId); + + // Bare instance: no cargo, no date, no price. Draws no contract capacity + // until the customer completes it after clearance. + 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, + createdByUserId: user?.id ?? 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), + ); + + const result = await this.bookingsRepository.findByIdWithFiles(booking.id); + return { booking: result ?? booking, warnings: [] }; + } + + /** + * Complete a bare initiated booking after Operations finalized its per-booking + * clearance (CLEARANCE_READY) or 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. + */ + async completeUnderContract( + contractId: string, + bookingId: string, + dto: CreateBookingUnderContractDto, + ): Promise { + const contract = await this.contractsRepository.findByIdWithRelations(contractId); + if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); + + const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); + if (!booking || booking.contractId !== contract.id) { + throw new NotFoundException(`Booking ${bookingId} not found on this contract`); + } + if (!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED'].includes(booking.status)) { + throw new BadRequestException( + 'Clearance must be finalized before the booking can be completed.', + ); + } + if (!dto.scheduledDate) { + throw new BadRequestException('A binding shipment day is required'); + } + if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { + throw new BadRequestException('Contract validity has expired — no new bookings.'); + } + + const freightType = contract.freightType; + const hasCargo = + (booking.bookingContainers?.length ?? 0) > 0 || + Number(booking.cargoTotalWeightVgm) > 0; + const warnings: string[] = []; + + // First completion persists cargo and draws contract capacity; a resubmit + // after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks + // the shipment day. + if (!hasCargo) { + await this.assertWithinQuantityCap(contract, dto); + if (freightType === 'CONTAINER') { + await this.assertWithinMaxCapacity(contract, dto); + await this.assert20ftPairableAtCreate(dto); + await this.persistContainers(booking.id, contract, dto); + } + await this.bookingsRepository.update(booking.id, { + cargoTypeId: this.resolveCargoTypeId(contract, dto), + cargoTotalWeightVgm: this.resolveBulkTons(dto), + ...(dto.equipmentReturn ? { equipmentReturn: dto.equipmentReturn } : {}), + } as never); + + const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); + if (loaded) { + if (freightType === 'CONTAINER') { + await this.applyWeightResults(loaded); + } + const computed = await this.bookingPricingService.computePriceForBooking(loaded); + // A zero price means no contract rate matches — roll the cargo back so + // the instance stays CLEARANCE_READY and can be completed again once + // the contract rates are fixed (the clearance work is not lost). + if (!(computed.totalAmount > 0)) { + await this.bookingsRepository.deleteContainers(booking.id); + await this.bookingsRepository.update(booking.id, { + cargoTotalWeightVgm: 0, + } as never); + throw new BadRequestException( + 'Booking price came out as 0 — no contract rate matches this ' + + 'route/cargo. Set the contract rate and try again.', + ); + } + await this.bookingsRepository.update(booking.id, { + totalAmount: computed.totalAmount, + priorityScore: computed.priorityScore, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + await this.bookingPricingService.createPricingSnapshots( + booking.id, + computed.usedRates, + computed.appliedModifiers, + ); + warnings.push(...computed.warnings); + } + + // Wagon consolidation gate — a partial-wagon 20ft set parks for a partner + // exactly like a drawdown created with cargo does. The shipment day is + // stored first so the pairing event can resume straight into the + // operations queue. + const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id); + if ( + withContainers && + freightType === 'CONTAINER' && + (await this.consolidationService.needsConsolidationFromBooking(withContainers)) + ) { + await this.bookingsRepository.update(booking.id, { + scheduledDate: new Date(dto.scheduledDate), + } as never); + const parked = await this.consolidateDrawdown( + withContainers, + 'OPERATION_REQUEST_PENDING', + ); + warnings.push(parked.message); + if (!parked.paired) { + await this.maybeCompleteContract(contract); + const pendingResult = await this.bookingsRepository.findByIdWithFiles(booking.id); + return { booking: pendingResult ?? booking, warnings }; + } + } + + // Invoice the now-priced booking (idempotent, non-blocking). + await this.finalizeContractBooking(booking.id, contract, false); + await this.maybeCompleteContract(contract); + } + + // Binding day + open-departure validation, status OPERATION_REQUEST_PENDING + // and the staff notification — the exact machine a one-time booking uses. + const completed = await this.bookingTransitionService.requestOperation( + booking.id, + dto.scheduledDate, + ); + return { booking: completed, warnings }; + } + /** * Search for a complementary partner for a parked-eligible drawdown, pair it or * park it in PENDING_CONSOLIDATION with the resume status it should return to. 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 7f75b12c8..d5dc9793e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -799,6 +799,37 @@ export class ContractsController { ); } + @Post(':id/bookings/initiate') + @ApiOperation({ + summary: + 'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).', + }) + initiateBooking( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CreateBookingUnderContractDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.contractBookingService.initiateUnderContract( + id, + { contractRouteId: dto?.contractRouteId }, + { id: user?.id ?? user?.sub }, + user, + ); + } + + @Post(':id/bookings/:bookingId/complete') + @ApiOperation({ + summary: + 'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.', + }) + completeBooking( + @Param('id', ParseUUIDPipe) id: string, + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: CreateBookingUnderContractDto, + ) { + return this.contractBookingService.completeUnderContract(id, bookingId, dto); + } + @Post(':id/validate-shipment') @ApiOperation({ summary: diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index ca0fda7a3..56480bad5 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -197,12 +197,14 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.createBooking, }, - // { - // label: "Self-Clearance Review", - // href: "/dashboard/contracts/ops-clearance", - // icon: , - // permission: FREIGHT_PERMS.contracts.opsClearanceReview, - // }, + // Operations Path A queue: per-booking self-clearance review for + // GENERAL non-customs booking instances (and legacy self-clear bookings). + { + label: "Self-Clearance Review", + href: "/dashboard/contracts/ops-clearance", + icon: , + permission: FREIGHT_PERMS.contracts.opsClearanceReview, + }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index 74f9cdb03..1b2c46bac 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -380,6 +380,31 @@ export function ClearanceReviewSection({ + ) : clearance.status !== "DOCUMENTS_UNDER_REVIEW" ? ( + // Finalize is only valid from DOCUMENTS_UNDER_REVIEW (the API rejects + // any other status with a 409) — once the booking moved on, show the + // finalized state instead of a button that can only fail. + + + + {clearance.allApproved ? ( + + ) : ( + + )} + + + {clearance.allApproved + ? "Clearance has been finalized. The customer can now pick a shipment day and proceed to operation." + : "Finalization unlocks once the customer submits their documents and every required document is approved."} + + + ) : ( diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index b091567ee..637a42816 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -308,6 +308,12 @@ const App = () => { path="/contracts/:id/bookings/new" element={} /> + {/* Completion of an initiated (bare) booking after per-booking + clearance — same form, submits to the complete endpoint. */} + } + /> } diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx index 9d1b6718e..9e73dcb58 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx +++ b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx @@ -1,10 +1,14 @@ import { Button, Group, type ButtonProps } from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; +import toast from "react-hot-toast"; import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; import { PayNowButton } from "@/pages/bookings/payments/PayNowButton"; +import { api } from "@/services/api"; import { ContractClearanceAction } from "./ContractClearanceAction"; import { deriveContractCustomerAction } from "./deriveContractCustomerAction"; @@ -56,6 +60,18 @@ export function ContractCustomerAction({ return ; } + if (action.type === "initiate") { + return ( + + ); + } + const Icon = action.icon; const variant = action.primary ? "filled" : "light"; @@ -80,6 +96,88 @@ export function ContractCustomerAction({ ); } +/** + * One-click bare booking instance under a GENERAL non-customs contract. No + * form, no date, no window gate — the new instance lands in per-booking + * clearance (AWAITING_DOCUMENTS) and the customer is taken straight to it. + */ +export function InitiateBookingButton({ + contract, + label = "Initiate booking", + icon: Icon, + size = "xs", + listStyle = false, + fullWidth = false, +}: { + contract: Freight.IContract; + label?: string; + icon: LucideIcon; + size?: ButtonProps["size"]; + listStyle?: boolean; + fullWidth?: boolean; +}) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: () => + api.contracts.initiateBookingUnderContract.call({ + id: contract.id, + // Multi-route contracts must name a route; single-route auto-selects. + contractRouteId: + (contract.routes?.length ?? 0) > 1 + ? contract.routes![0].id + : undefined, + }), + onSuccess: (booking) => { + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + queryClient.invalidateQueries({ + queryKey: api.contracts.get.queryKey({ id: contract.id }), + }); + toast.success( + "Booking initiated — upload your clearance documents to start the review.", + ); + navigate(`/bookings/${booking.id}`); + }, + onError: (e: Error) => + toast.error(e.message || "Could not initiate the booking"), + }); + + return ( + + ); +} + /** Action column cell: doc button + primary customer action. */ export function ContractCustomerActionCell({ contract, diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts b/apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts index 5d9a53c91..ddd64d3e8 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts +++ b/apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts @@ -72,6 +72,14 @@ export type ContractCustomerAction = label: string; primary: boolean; icon: LucideIcon; + } + | { + /** One-click bare booking instance (GENERAL non-customs) — mutation, not navigation. */ + type: "initiate"; + contract: Freight.IContract; + label: string; + primary: boolean; + icon: LucideIcon; }; function findPayableBookingForContract( @@ -210,6 +218,15 @@ export function deriveContractCustomerAction( } const bookingAction = getContractBookingAction(contract, bookings); + if (bookingAction.kind === "initiate") { + return { + type: "initiate", + contract, + label: "Initiate booking", + primary: true, + icon: PackagePlus, + }; + } if (bookingAction.kind === "book") { return { type: "navigate", diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index be4a2a3a8..54af7b150 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -142,6 +142,9 @@ export const URL_CONSTANTS = { `/api/contracts/${id}/clearance/documents`, CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`, BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`, + BOOKINGS_INITIATE: (id: string) => `/api/contracts/${id}/bookings/initiate`, + BOOKINGS_COMPLETE: (id: string, bookingId: string) => + `/api/contracts/${id}/bookings/${bookingId}/complete`, VALIDATE_SHIPMENT: (id: string) => `/api/contracts/${id}/validate-shipment`, MILESTONES: (id: string) => `/api/contracts/${id}/milestones`, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx index 8f055e79a..5bbece55e 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx @@ -1,29 +1,28 @@ -import { Alert, Button, Group } from "@mantine/core"; -import { CheckCircle2, Upload } from "lucide-react"; -import { useNavigate } from "react-router-dom"; +import { useState } from "react"; +import { Alert, Button, Group, Text } from "@mantine/core"; +import { CheckCircle2, ClipboardList, Clock, Upload } from "lucide-react"; import type { Freight } from "@edr/types"; -import { ClearanceFlow } from "@/pages/bookings/clearance/ClearanceFlow"; -import { useClearanceFlow } from "@/pages/bookings/clearance/useClearanceFlow"; +import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal"; +import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction"; import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner"; import { CardTitle, SectionCard } from "./layout"; /** - * Customer-facing clearance section on the booking detail page: shows the - * resolved document grid, lets the customer (re)upload pending/queried documents - * plus ad-hoc named documents, and proceed to operation once Global Logistics - * marks the booking CLEARANCE_READY. - * - * The flow body, calendar, and mutations are shared with the home-page action - * modal via `useClearanceFlow` / `ClearanceFlow`. + * Customer-facing clearance section on the booking detail page: a compact + * status summary with a single action button. The document grid, re-uploads, + * and the shipment-day picker all live in the shared {@link BookingActionModal} + * (the same modal the My Shipments list uses), so the flow behaves identically + * from both entry points. */ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { - const navigate = useNavigate(); - const flow = useClearanceFlow(booking); + const [modalOpen, setModalOpen] = useState(false); + const status = booking.status as string; + const action = getBookingNextAction(booking); - if (flow.status === "OPERATION_REQUESTED") { + if (status === "OPERATION_REQUESTED") { return ( Operation @@ -34,56 +33,51 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { ); } - if (flow.isLoading || !flow.clearance) { - return ( - - Clearance documents - + const summary = + status === "CLEARANCE_READY" ? ( + }> + Clearance is complete. Pick a shipment day and proceed to operation. + + ) : status === "DOCUMENTS_UNDER_REVIEW" ? ( + }> + Your documents are being reviewed. Re-upload any queried documents to + proceed — approved documents stay as they are. + + ) : ( + }> + Upload the required clearance documents so your shipment can be + reviewed. + ); - } return ( Clearance documents + {action && ( + + )} - + Use “{action?.label ?? "the action button"}” to manage your clearance + documents. + + + - {flow.canUpload && ( - - )} - {flow.isReady && ( - - )} - - } + opened={modalOpen} + onClose={() => setModalOpen(false)} /> ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index 7b91f8250..e4fac9ca7 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -16,14 +16,23 @@ export const PROGRESS_STAGES = [ statuses: ["DRAFT", "CHANGES_REQUESTED"], }, { + // AWAITING_DOCUMENTS: a fresh contract-drawdown booking lands here — the + // customer just submitted it and now uploads clearance documents. label: "Submitted", icon: ClipboardCheck, - statuses: ["SUBMITTED"], + statuses: ["SUBMITTED", "AWAITING_DOCUMENTS"], }, { + // Clearance review is the approval step for contract-drawdown bookings. label: "Approval", icon: ShieldCheck, - statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE", "APPROVED"], + statuses: [ + "PENDING_APPROVAL", + "APPROVED_PENDING_SIGNATURE", + "APPROVED", + "DOCUMENTS_UNDER_REVIEW", + "CLEARANCE_READY", + ], }, { label: "Contract", @@ -37,6 +46,7 @@ export const PROGRESS_STAGES = [ "FULLY_EXECUTED", "SELECTED_FOR_BATCH", "PAYMENT_VERIFICATION_IN_PROGRESS", + "OPERATION_REQUESTED", ], }, { @@ -234,24 +244,24 @@ export const STATUS_MAP: Record< title: "Clearance documents needed", description: "Upload the required clearance documents so your shipment can be reviewed.", - stage: 5, + stage: 1, }, DOCUMENTS_UNDER_REVIEW: { title: "Documents under review", description: "Your clearance documents are being reviewed. Re-upload any queried documents to proceed.", - stage: 5, + stage: 2, }, CLEARANCE_READY: { title: "Cleared — choose a shipment day", description: "Clearance is complete. Pick a shipment day and proceed to operation.", - stage: 5, + stage: 2, }, OPERATION_REQUESTED: { title: "Operation requested", description: "Operation requested. An operator will take your shipment forward.", - stage: 5, + stage: 4, }, CONTRACT_ACTIVE: { title: "Contract active", 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 0cabf0878..32ea4f654 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 @@ -1,5 +1,6 @@ import { Box, Button, Group, Modal, Text } from "@mantine/core"; -import { CheckCircle2, Upload } from "lucide-react"; +import { CheckCircle2, PackagePlus, Upload } from "lucide-react"; +import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; @@ -40,6 +41,7 @@ function BookingActionModalBody({ }) { const action = getBookingNextAction(booking); const flow = useClearanceFlow(booking); + const navigate = useNavigate(); const reference = booking.reference; const handleSubmit = () => flow.submitDocuments({ onSuccess: onClose }); @@ -91,17 +93,31 @@ function BookingActionModalBody({ Submit documents )} - {flow.isReady && ( + {flow.needsCompletion && flow.completeTo ? ( + ) : ( + flow.isReady && ( + + ) )} } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx index 86566eba0..604e2c1c9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx @@ -54,6 +54,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) { customerDocs, glDocs, isReady, + needsCompletion, canUpload, isInitialUpload, status, @@ -78,9 +79,11 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) { {isReady ? ( } mb="md"> - {clearance.includesCustoms - ? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation." - : "Clearance is ready. You can now proceed to operation."} + {needsCompletion + ? "Clearance is finalized. Complete your booking now — enter the cargo details and pick a shipment day inside an open booking window." + : clearance.includesCustoms + ? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation." + : "Clearance is ready. You can now proceed to operation."} ) : status === "DOCUMENTS_UNDER_REVIEW" ? ( } mb="md"> @@ -208,7 +211,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) { )} - {isReady && ( + {isReady && !needsCompletion && ( Choose your shipment day diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts index ab9560ac6..0ccc6d004 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts @@ -67,6 +67,16 @@ export function useClearanceFlow(booking: Freight.IBooking) { ); const isReady = status === "CLEARANCE_READY"; + // Bare initiated instance (GENERAL non-customs "Initiate booking"): created + // with no cargo and no price. Once ready it is COMPLETED on the full booking + // form (cargo + shipment day + window check), not date-only proceed. + const needsCompletion = + isReady && + Boolean(booking.contractId) && + !(Number(booking.totalAmount ?? 0) > 0); + const completeTo = needsCompletion + ? `/contracts/${booking.contractId}/bookings/${booking.id}/complete` + : null; const canUpload = status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW"; // The very first upload (nothing in review yet). Here every required document @@ -146,6 +156,8 @@ export function useClearanceFlow(booking: Freight.IBooking) { customerDocs, glDocs, isReady, + needsCompletion, + completeTo, canUpload, isInitialUpload, // staged upload state diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 2fed7e7a0..0eb06ea79 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -61,6 +61,7 @@ import { labelForDocCode } from "@/pages/bookings/resubmit"; import { ContractClearancePanel } from "./ContractClearancePanel"; import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; +import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction"; import { formatRateUnit } from "./new-contract-form/unit-rates"; import { getContractBookingAction } from "./contract-booking-action"; import { closedWindowMessage, hasOpenWindow } from "./booking-window"; @@ -348,6 +349,9 @@ export default function ContractDetailPage() { const canBookShipment = bookingAction.kind === "book" || bookingAction.kind === "rebook"; const canRequestShipment = bookingAction.kind === "request"; + // GENERAL non-customs import/export: one-click bare booking instance — the + // per-booking clearance runs first, so no window gate applies here. + const canInitiateBooking = bookingAction.kind === "initiate"; // Customs + clearance finalized: GL is preparing the booking — surface a // status notice instead of any action. const glPreparingBooking = customsPath && clearanceFinalized; @@ -438,6 +442,13 @@ export default function ContractDetailPage() { Request shipment )} + {canInitiateBooking && ( + + )} {canBookShipment && (bookingWindowOpen ? (