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 ? ( - - )} diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index d389bd9cb..34cf1fe5d 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -72,6 +72,36 @@ function clearPendingFaydaIndex() { window.sessionStorage.removeItem(FAYDA_PENDING_INDEX_KEY); } +// Verification is a full-page redirect out to Fayda and back (same flow on desktop and mobile — +// no popup). The in-progress form only lives in React memory, which the reload wipes, so we +// snapshot it to sessionStorage before leaving and restore it (in the form's defaultValues) on +// return. sessionStorage survives a same-tab navigation, including the cross-origin round trip. +const FAYDA_FORM_SNAPSHOT_KEY = 'edr_fayda_form_snapshot'; + +function saveFaydaFormSnapshot(snapshot: unknown) { + if (typeof window === 'undefined') return; + try { + window.sessionStorage.setItem(FAYDA_FORM_SNAPSHOT_KEY, JSON.stringify(snapshot)); + } catch { + // sessionStorage full/unavailable — verification still works, only unsaved fields are lost. + } +} + +function getFaydaFormSnapshot(): { passengers?: any[]; createAccount?: boolean } | null { + if (typeof window === 'undefined') return null; + try { + const raw = window.sessionStorage.getItem(FAYDA_FORM_SNAPSHOT_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function clearFaydaFormSnapshot() { + if (typeof window === 'undefined') return; + window.sessionStorage.removeItem(FAYDA_FORM_SNAPSHOT_KEY); +} + // Fayda may return gender as "MALE"/"M" etc — normalize to the form's expected values function normalizeFaydaGender(raw: unknown): 'Male' | 'Female' | '' { const g = String(raw || '').trim().toUpperCase(); @@ -85,11 +115,13 @@ function DobPickerModal({ onChange, error, passengerType = 'ADULT', + disabled = false, }: { value: string; onChange: (iso: string) => void; error?: string; passengerType?: 'ADULT' | 'CHILD'; + disabled?: boolean; }) { const [open, setOpen] = useState(false); const [manualMode, setManualMode] = useState(false); @@ -286,9 +318,10 @@ function DobPickerModal({ - {hasInteracted && errors.originStationId && ( -

- {errors.originStationId.message} -

- )} - -
-
- -
- - {hasInteracted && errors.destinationStationId && ( -

- {errors.destinationStationId.message} -

- )} -
-
- -
- { - setValue( - "departureDate", - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, - ); - trigger("departureDate"); + onClick={() => { + setHasInteracted(true); + window.scrollTo({ + top: 0, + behavior: "instant" as ScrollBehavior, + }); + setStationModal("origin"); }} - minDate={new Date()} - placeholder="Select date" - error={!!errors.departureDate} - /> + className="w-full" + > +
+ + + {originStation?.name ?? "Departure"} + +
+ + {hasInteracted && errors.originStationId && ( +

+ {errors.originStationId.message} +

+ )} +
+
+
+ + +
+ + {hasInteracted && errors.destinationStationId && ( +

+ {errors.destinationStationId.message} +

+ )}
- {errors.departureDate && ( -

- {errors.departureDate.message} -

- )}
- {tripType === "ROUND_TRIP" && ( -
+
+
{ setValue( - "returnDate", + "departureDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, ); - trigger("returnDate"); + trigger("departureDate"); }} - minDate={ - departureDate - ? new Date(departureDate + "T00:00:00") - : new Date() - } - placeholder="Select return date" - error={!!errors.returnDate} + minDate={new Date()} + placeholder="Departure date" + error={!!errors.departureDate} />
- {errors.returnDate && ( + {errors.departureDate && (

- {errors.returnDate.message} + {errors.departureDate.message}

)}
- )} + {tripType === "ROUND_TRIP" && ( +
+ +
+ { + setValue( + "returnDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); + trigger("returnDate"); + }} + minDate={ + departureDate + ? new Date(departureDate + "T00:00:00") + : new Date() + } + placeholder="Return date" + error={!!errors.returnDate} + /> +
+ {errors.returnDate && ( +

+ {errors.returnDate.message} +

+ )} +
+ )} +
{/* Pax + Nationality combined trigger */}
@@ -1207,11 +1238,20 @@ export default function SearchPage() { {/* Search */} ) : ( @@ -1282,7 +1322,7 @@ export default function SearchPage() { trigger("returnDate"); }} minDate={new Date()} - placeholder="Select date" + placeholder="Departure date" /> {errors.departureDate && (

{errors.departureDate.message}

@@ -1298,7 +1338,7 @@ export default function SearchPage() { trigger("returnDate"); }} minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()} - placeholder="Select date" + placeholder="Return date" /> {errors.returnDate && (

{errors.returnDate.message}

@@ -1328,11 +1368,20 @@ export default function SearchPage() { {/* Search */} )} diff --git a/apps/edr-passenger-web/portal/src/app/contact/page.tsx b/apps/edr-passenger-web/portal/src/app/contact/page.tsx index 669a99d0b..9f0d2d474 100644 --- a/apps/edr-passenger-web/portal/src/app/contact/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/contact/page.tsx @@ -275,8 +275,8 @@ export default function Contact() { }; const contactInfo = [ - { icon: Phone, title: t('contact.phone'), value: '+251 911 000 000', link: 'tel:+251911000000' }, - { icon: Mail, title: t('contact.email'), value: 'support@edr.et', link: 'mailto:support@edr.et' }, + { icon: Phone, title: t('contact.phone'), value: '9546', link: 'tel:9546' }, + { icon: Mail, title: t('contact.email'), value: 'edr_@edrsc.com', link: 'mailto:edr_@edrsc.com' }, { icon: MapPin, title: t('contact.address'), value: 'Addis Ababa, Ethiopia', link: '#' }, ]; diff --git a/apps/edr-passenger-web/portal/src/app/help/page.tsx b/apps/edr-passenger-web/portal/src/app/help/page.tsx index eb8a6a150..c3f84ed91 100644 --- a/apps/edr-passenger-web/portal/src/app/help/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/help/page.tsx @@ -60,16 +60,6 @@ const FAQ_CATEGORIES: FAQCategory[] = [ answer: 'Yes. When booking as a logged-in user you can save passenger profiles. On subsequent bookings you can select a saved passenger instead of re-entering their details.', }, - { - question: 'How do I modify my booking?', - answer: - 'Log in and go to your profile, find the booking, and select Modify. Changes are allowed up to 24 hours before departure. Fare differences may apply.', - }, - { - question: 'What is the cancellation policy?', - answer: - 'Cancellations made at least 48 hours before departure receive a full refund. Cancellations within 48 hours may be subject to a fee. Refunds are returned to your original payment method or wallet.', - }, ], }, { @@ -84,28 +74,13 @@ const FAQ_CATEGORIES: FAQCategory[] = [ { question: 'What are the passenger age categories?', answer: - 'Adults are passengers aged 5 years and above and pay 100% of the fare. Children are passengers under 5 years old — the first child in a booking travels free, and any additional children pay the full fare.', + 'Adults are passengers aged 5 years and above and pay 100% of the fare. Children are passengers under 5 years old — the first child per adult travels free, and any additional children pay the full fare.', }, { question: 'How is a child\'s age determined?', answer: 'Age is calculated automatically from the date of birth you enter for each passenger. Make sure to enter the correct date of birth so the right fare is applied.', }, - { - question: 'Example: how much does a family of 2 adults + 3 children pay?', - answer: - 'The first child is free, so you pay for 2 adults + 2 children = 4× the base fare for that seat class and distance.', - }, - { - question: 'What seat classes are available?', - answer: - 'Three classes are available: Economy Regular (standard seating), Economy Bed (sleeping berth in economy), and VIP Bed (premium sleeping berth). Each has its own base fare.', - }, - { - question: 'What is the nationality field for?', - answer: - 'Nationality determines which ID verification path applies. Ethiopian nationals are verified via the Verifayda national ID system. Djiboutian and other international passengers use their passport instead.', - }, ], }, { @@ -153,11 +128,6 @@ const FAQ_CATEGORIES: FAQCategory[] = [ answer: 'On the search page, tap the From or To field and browse or search the full list of stations. Each station shows its code and country.', }, - { - question: 'Are prices shown in my local currency?', - answer: - 'All transactions are processed in Ethiopian Birr (ETB). You can view prices in ETB, Djiboutian Franc (DJF), or US Dollar (USD) by selecting your preferred display currency on the fare or booking screen.', - }, ], }, { @@ -167,22 +137,12 @@ const FAQ_CATEGORIES: FAQCategory[] = [ { question: 'What payment methods are accepted?', answer: - 'We accept Telebirr, CBE Birr, eBirr, credit/debit cards, and EDR Wallet balance. You can choose your preferred method at checkout.', - }, - { - question: 'What is the EDR Wallet?', - answer: - 'The EDR Wallet is a stored-value account linked to your profile. You can top it up and use it to pay for tickets instantly. Your wallet balance and transaction history are available in your profile.', - }, - { - question: 'When will I receive my refund?', - answer: - 'Refunds are processed within 5–7 business days to your original payment method. If you paid via EDR Wallet, the refund is credited to your wallet immediately.', + 'We accept Telebirr, Waafi, D-Money, CBE Birr, and more. You can choose your preferred method at checkout.', }, { question: 'Is my payment information secure?', answer: - 'Yes. We do not store card details. All payments are processed through certified payment providers. Transactions are encrypted end-to-end.', + 'Yes. All payments are processed through certified payment providers. Transactions are encrypted end-to-end.', }, ], }, @@ -226,16 +186,6 @@ const FAQ_CATEGORIES: FAQCategory[] = [ answer: 'Tap "Forgot password" on the login page, enter your registered email, and follow the reset link sent to your inbox.', }, - { - question: 'How do I set up Verifayda on my account?', - answer: - 'Go to your profile and find the Fayda Setup section. Enter your national ID to link your verified identity to your account. This enables faster booking as your details are pre-filled.', - }, - { - question: 'Can I use the app in multiple languages?', - answer: - 'Yes. The app supports English, Amharic (አማርኛ), Afaan Oromoo, and French. Change your language from the navigation bar.', - }, ], }, ]; diff --git a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx index 6d47b8b5a..0acb5d9ea 100644 --- a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx @@ -14,12 +14,18 @@ import { } from 'lucide-react'; import Link from 'next/link'; import Image from 'next/image'; +import dynamic from 'next/dynamic'; import { usePathname } from 'next/navigation'; import { useEffect, useState } from 'react'; import { useAuthStore } from '@/lib/auth-store'; -import ChangePasswordModal from '@/components/ChangePasswordModal'; import { BOOKING_STEPS } from '@/components/ProgressIndicator'; +// AppSidebar renders on every page via the root layout, so anything imported +// here ships to every visitor's first load — but this modal is only ever +// reachable by an already-authenticated user opening the account dropdown. +// Code-split it out instead of paying for it on every page/every visitor. +const ChangePasswordModal = dynamic(() => import('@/components/ChangePasswordModal'), { ssr: false }); + // Mirrors booking/layout.tsx's stepMap — the linear booking flow routes that // get a vertical step list instead of the standard nav highlighting. const BOOKING_STEP_MAP: Record = { @@ -203,10 +209,12 @@ export default function AppSidebar() { )} - setShowChangePassword(false)} - /> + {showChangePassword && ( + setShowChangePassword(false)} + /> + )} ); } diff --git a/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx b/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx index 674755516..20923278c 100644 --- a/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx +++ b/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx @@ -286,14 +286,14 @@ export default function ModernDatePicker({ diff --git a/apps/edr-passenger-web/portal/src/components/ThemeToggle.tsx b/apps/edr-passenger-web/portal/src/components/ThemeToggle.tsx index 501c85005..5368876d2 100644 --- a/apps/edr-passenger-web/portal/src/components/ThemeToggle.tsx +++ b/apps/edr-passenger-web/portal/src/components/ThemeToggle.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Moon, Sun, Monitor } from 'lucide-react'; +import { Moon, Sun } from 'lucide-react'; import { useTheme } from './ThemeProvider'; import { useEffect, useState } from 'react'; @@ -12,27 +12,11 @@ export default function ThemeToggle() { setMounted(true); }, []); - const cycleTheme = () => { - if (theme === 'light') { - setTheme('dark'); - } else if (theme === 'dark') { - setTheme('system'); - } else { - setTheme('light'); - } - }; + const cycleTheme = () => setTheme(theme === 'light' ? 'dark' : 'light'); - const getIcon = () => { - if (theme === 'light') return ; - if (theme === 'dark') return ; - return ; - }; + const getIcon = () => theme === 'dark' ? : ; - const getLabel = () => { - if (theme === 'light') return 'Light'; - if (theme === 'dark') return 'Dark'; - return 'System'; - }; + const getLabel = () => theme === 'dark' ? 'Dark' : 'Light'; // Prevent hydration mismatch by not rendering until mounted if (!mounted) { diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index 6ff31aeaf..7ab72e1a5 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -103,6 +103,29 @@ function hairline(doc: jsPDF, x1: number, y: number, x2: number): void { // ─── header ──────────────────────────────────────────────────────────────── +// Fetched once and reused for the lifetime of the page — re-fetching this same static +// asset on every passenger/every voucher adds a real network round-trip in the middle of +// what needs to stay close to the original click's synchronous execution window (iOS +// Safari silently blocks a file save triggered too long after user activation). +let logoCache: Promise<{ dataUrl: string; width: number; height: number }> | null = null; +function loadLogo(): Promise<{ dataUrl: string; width: number; height: number }> { + if (!logoCache) { + logoCache = (async () => { + const logoImg = await fetch('/edr-logo.png'); + const logoBlob = await logoImg.blob(); + const dataUrl = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(logoBlob); + }); + const img = new Image(); + await new Promise((resolve) => { img.onload = resolve; img.src = dataUrl; }); + return { dataUrl, width: img.width, height: img.height }; + })(); + } + return logoCache; +} + async function drawHeader(doc: jsPDF, margin: number): Promise { const pageWidth = doc.internal.pageSize.getWidth(); const bandHeight = 24; @@ -111,17 +134,9 @@ async function drawHeader(doc: jsPDF, margin: number): Promise { doc.rect(0, 0, pageWidth, bandHeight, 'F'); try { - const logoImg = await fetch('/edr-logo.png'); - const logoBlob = await logoImg.blob(); - const logoDataUrl = await new Promise((resolve) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result as string); - reader.readAsDataURL(logoBlob); - }); - const img = new Image(); - await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; }); + const { dataUrl: logoDataUrl, width, height } = await loadLogo(); const logoH = 13; - const logoW = (img.width / img.height) * logoH; + const logoW = (width / height) * logoH; const textX = margin + logoW + 5; doc.addImage(logoDataUrl, 'PNG', margin, (bandHeight - logoH) / 2, logoW, logoH); doc.setTextColor(255, 255, 255); @@ -358,16 +373,14 @@ function drawFooter(doc: jsPDF, createdAt: string): void { hairline(doc, PAGE_MARGIN, footerY, pageWidth - PAGE_MARGIN); doc.setFontSize(7.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal'); - doc.text('support@edr.com · +251-11-XXX-XXXX · www.edr.com', pageWidth / 2, footerY + 6, { align: 'center' }); + doc.text('edr_@edrsc.com · 9546 · www.edr.com', pageWidth / 2, footerY + 6, { align: 'center' }); doc.setFontSize(6.5); doc.text(`Issued ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 10.5, { align: 'center' }); } // ─── public API ────────────────────────────────────────────────────────────── -/** Generates and downloads one PDF voucher for a single passenger. */ -export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise => { - const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); +async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData): Promise { const pageW = doc.internal.pageSize.getWidth(); const margin = PAGE_MARGIN; @@ -388,6 +401,12 @@ export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): P y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW); drawInstructions(doc, y, margin, pageW); drawFooter(doc, data.createdAt); +} + +/** Generates and downloads one PDF voucher for a single passenger. */ +export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise => { + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + await drawPassengerVoucherPage(doc, data); const safeName = (data.passengerName || 'Passenger').replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, ''); doc.save(`Voucher_${safeName}.pdf`); @@ -404,14 +423,28 @@ interface VoucherData { currency: string; bookingType: string; createdAt: string; + // One ticket per passenger, matched below by passengerName — see bookings.service.ts's + // getByRef(). Optional/absent falls back to a client-generated placeholder number. + tickets?: Array<{ passengerName?: string; barcodePayload?: string }>; } export const generateVoucherPDF = async (booking: VoucherData): Promise => { + // Separate file per passenger, saved back-to-back with no macrotask (setTimeout) between + // them — a setTimeout delay here would push later saves outside the click's synchronous + // user-activation window and risk iOS Safari silently blocking them. The awaited work + // inside generatePassengerVoucherPDF is itself just microtasks (cached logo, QR encode), + // which doesn't have that effect. for (let i = 0; i < booking.passengers.length; i++) { const p = booking.passengers[i]; + const matchedTicket = + booking.tickets?.find((t) => t.passengerName === p.fullName) ?? booking.tickets?.[i] ?? null; + // No fabricated placeholder — a made-up TKT-... number reads as real and is misleading + // if it doesn't match what's actually on file. + const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued'; + await generatePassengerVoucherPDF({ bookingRef: booking.bookingRef, - ticketNumber: `TKT-${booking.bookingRef}-${(i + 1).toString().padStart(2, '0')}`, + ticketNumber, passengerName: p.fullName, seatNumber: p.seat?.number, status: booking.status, @@ -421,7 +454,5 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => currency: booking.currency, createdAt: booking.createdAt, }); - // small delay so browsers don't block multiple sequential downloads - if (i < booking.passengers.length - 1) await new Promise(r => setTimeout(r, 400)); } };