From 68c4f69cff6861e761847fd214e05b64c0969b10 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Fri, 10 Jul 2026 20:20:39 +0300 Subject: [PATCH 1/8] Help content updates --- .../portal/src/app/help/page.tsx | 56 +------------------ .../portal/src/components/ThemeToggle.tsx | 24 ++------ 2 files changed, 7 insertions(+), 73 deletions(-) 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/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) { From b50075dc83b0a63c767eaec8dd41622e85aeccfe Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 10 Jul 2026 17:57:27 +0000 Subject: [PATCH 2/8] general contrat --- .github/workflows/deploy.yml | 2 +- .../src/modules/bookings/bookings.module.ts | 1 + .../modules/bookings/bookings.repository.ts | 13 +- .../contract-booking.completion.spec.ts | 1 + .../contract-booking.consolidation.spec.ts | 1 + .../contracts/contract-booking.service.ts | 224 ++++++++++++++++++ .../modules/contracts/contracts.controller.ts | 31 +++ apps/edr-freight-web/backoffice/src/App.tsx | 14 +- .../detail/ClearanceReviewSection.tsx | 25 ++ apps/edr-freight-web/portal/src/App.tsx | 6 + .../ContractCustomerAction.tsx | 98 ++++++++ .../deriveContractCustomerAction.ts | 17 ++ .../portal/src/constants/URLS.ts | 3 + .../components/ClearanceCard.tsx | 104 ++++---- .../bookings/BookingDetailPage/constants.ts | 22 +- .../bookings/clearance/BookingActionModal.tsx | 30 ++- .../bookings/clearance/ClearanceFlow.tsx | 11 +- .../bookings/clearance/useClearanceFlow.ts | 12 + .../pages/contracts/ContractDetailPage.tsx | 18 ++ .../src/pages/contracts/NewShipmentPage.tsx | 36 ++- .../contracts/contract-booking-action.ts | 15 +- .../portal/src/services/api.ts | 18 ++ .../portal/src/services/contracts.service.ts | 26 ++ 23 files changed, 639 insertions(+), 89 deletions(-) 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 d9a326d91..a94f7516a 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -196,12 +196,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 e4c5685fd..964288c87 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 ? ( @@ -1207,11 +1229,20 @@ export default function SearchPage() { {/* Search */} ) : ( @@ -1328,11 +1359,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/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/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index 6ff31aeaf..ca22538be 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -358,7 +358,7 @@ 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' }); } From 05b3cdf1046921f20a9f9a0f4a716f6d6ac82e6e Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 11 Jul 2026 00:16:53 +0300 Subject: [PATCH 5/8] Prod test issues resolution --- .../currencies/currencies.controller.ts | 2 +- .../modules/currencies/currencies.service.ts | 21 +++++----- .../src/modules/payments/payments.service.ts | 4 +- .../src/modules/seats/seats.service.ts | 39 +++++++++++++++++-- .../system-config/system-config.service.ts | 2 + .../src/modules/tickets/tickets.module.ts | 3 +- .../src/modules/tickets/tickets.service.ts | 30 +++++++------- .../backoffice/src/app/bookings/page.tsx | 16 +++++--- .../backoffice/src/app/currencies/page.tsx | 8 +++- .../backoffice/src/app/payments/page.tsx | 8 ++-- .../backoffice/src/app/settings/page.tsx | 20 ++++++++++ .../portal/src/app/booking/search/page.tsx | 2 +- 12 files changed, 110 insertions(+), 45 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts index 87dee3ec8..093436aae 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts @@ -31,7 +31,7 @@ export class CurrenciesController { } @Delete(':id') - @PassengerAdmin() + @PassengerStaff(PASSENGER_PERMS.currencies.manage) @ApiBearerAuth('IAM-auth') deleteCurrency(@Param('id') id: string) { return this.currenciesService.deleteCurrency(id); diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts index 99fee3cdf..388ed2cda 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts @@ -13,7 +13,7 @@ export class CurrenciesService { async getAllCurrencies() { const rates = await this.prisma.currencyExchangeRate.findMany({ distinct: ['toCurrency'], - orderBy: { toCurrency: 'asc' }, + orderBy: { createdAt: 'desc' }, }); const base = { @@ -83,12 +83,14 @@ export class CurrenciesService { throw new BadRequestException('Exchange rate must be positive'); } - const updated = await this.prisma.currencyExchangeRate.update({ - where: { id }, - data: { - rate: dto.exchangeRate, - }, - }); + // Upsert today's record so getRateOrThrow (orderBy effectiveDate desc) picks it up + const updated = await this.currencyService.upsertRate( + existing.fromCurrency, + existing.toCurrency, + dto.exchangeRate ?? Number(existing.rate), + undefined, + 'MANUAL', + ); return { id: updated.id, @@ -112,8 +114,9 @@ export class CurrenciesService { throw new NotFoundException('Currency not found'); } - await this.prisma.currencyExchangeRate.delete({ - where: { id }, + // Delete all records for this currency pair so no stale rates remain + await this.prisma.currencyExchangeRate.deleteMany({ + where: { fromCurrency: existing.fromCurrency, toCurrency: existing.toCurrency }, }); return { message: 'Currency deleted successfully' }; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 5e44038c4..4b0284935 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -99,6 +99,8 @@ export class PaymentsService { priceTierId: true, adultCount: true, childCount: true, + totalMinor: true, + currency: true, priceTier: { select: { priceMinor: true } }, }, }, @@ -129,7 +131,7 @@ export class PaymentsService { id: item.id, reference: item.id.substring(0, 8), bookingId: item.bookingId, - booking: { bookingRef: b?.bookingRef }, + booking: { bookingRef: b?.bookingRef, totalMinor: b?.totalMinor, currency: b?.currency }, amountMinor, currency: item.currency, method: item.method, diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index b26dcb763..3d485d22c 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -837,13 +837,16 @@ export class SeatsService { async removeSeat(seatId: string) { const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); if (!seat) throw new NotFoundException('Seat not found'); - if (!seat.seatNumber) throw new BadRequestException('Seat already removed'); + if (!seat.seatNumber || seat.seatNumber.startsWith('-')) throw new BadRequestException('Seat already removed'); + // Mark as removed, then renumber all active seats in the coach await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: `-${seat.seatNumber}` }, }); + await this.renumberCoachSeats(seat.coachId); + return { removed: true, seatId, originalSeatNumber: seat.seatNumber }; } @@ -854,10 +857,38 @@ export class SeatsService { throw new BadRequestException('Seat is not removed'); } - const originalNumber = seat.seatNumber.slice(1); - await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: originalNumber } }); + // Restore with a temporary placeholder number, then renumber + await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: `__restore__${seatId}` } }); + await this.renumberCoachSeats(seat.coachId); - return { restored: true, seatId, seatNumber: originalNumber }; + const restored = await this.prisma.seat.findUnique({ where: { id: seatId } }); + return { restored: true, seatId, seatNumber: restored?.seatNumber }; + } + + /** + * Renumbers all active (non-removed) seats in a coach sequentially starting from 1, + * ordered by row then col. Removed seats (prefixed with "-") keep their slot but + * are excluded from the numbering sequence so numbers remain continuous. + */ + private async renumberCoachSeats(coachId: string): Promise { + const allSeats = await this.prisma.seat.findMany({ + where: { coachId }, + orderBy: [{ row: 'asc' }, { col: 'asc' }], + select: { id: true, seatNumber: true }, + }); + + const activeSeats = allSeats.filter( + (s) => s.seatNumber && !s.seatNumber.startsWith('-') && !s.seatNumber.startsWith('__restore__'), + ); + + await Promise.all( + activeSeats.map((s, idx) => + this.prisma.seat.update({ + where: { id: s.id }, + data: { seatNumber: String(idx + 1) }, + }), + ), + ); } @Cron(CronExpression.EVERY_MINUTE) diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts index 1661d2027..3cfa1beb8 100644 --- a/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts @@ -4,6 +4,7 @@ import { PrismaService } from '../../common/prisma.service'; export const CONFIG_KEYS = { SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes', HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure', + BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE: 'boarding_window_hours_before_departure', THROTTLE_AUTH_LIMIT: 'throttle_auth_limit', THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms', THROTTLE_STRICT_LIMIT: 'throttle_strict_limit', @@ -15,6 +16,7 @@ export const CONFIG_KEYS = { const DEFAULTS: Record = { [CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5', [CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2', + [CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE]: '4', [CONFIG_KEYS.THROTTLE_AUTH_LIMIT]: '5', [CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000', [CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20', diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts index 972668e18..1ccc2e392 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts @@ -3,9 +3,10 @@ import { TicketsController } from './tickets.controller'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; import { NotificationsModule } from '../notifications/notifications.module'; +import { SystemConfigModule } from '../system-config/system-config.module'; @Module({ - imports: [NotificationsModule], + imports: [NotificationsModule, SystemConfigModule], controllers: [TicketsController], providers: [TicketsService, JwtGuard], exports: [TicketsService, JwtGuard], diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 4a1f7b36e..1a9c88d4e 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import * as QRCode from 'qrcode'; interface OfflineValidation { @@ -20,6 +21,7 @@ export class TicketsService { constructor( private readonly prisma: PrismaService, private readonly notifications: NotificationsService, + private readonly systemConfig: SystemConfigService, @InjectDataSource() private readonly dataSource: DataSource, ) {} @@ -423,26 +425,20 @@ export class TicketsService { // Check if ticket date matches today const today = new Date(); - const todayDateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD format if ((booking as any).schedule?.departureAt) { - const departureDate = new Date((booking as any).schedule.departureAt); - const departureDateStr = departureDate.toISOString().split('T')[0]; - - // Check if ticket is for today - if (departureDateStr !== todayDateStr) { - if (departureDateStr < todayDateStr) { - throw new BadRequestException('Ticket has expired - departure date has passed'); - } else { - throw new BadRequestException('Ticket is for a future date - cannot board early'); - } - } - - // Additional check: ticket expires 4 hours after departure time const departureTime = new Date((booking as any).schedule.departureAt); - const expiryTime = new Date(departureTime.getTime() + 4 * 60 * 60 * 1000); // 4 hours after departure - if (today > expiryTime) { - throw new BadRequestException('Ticket has expired - boarding window closed'); + const boardingWindowHours = await this.systemConfig.getNumber(CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE); + const boardingOpenTime = new Date(departureTime.getTime() - boardingWindowHours * 60 * 60 * 1000); + + if (today < boardingOpenTime) { + throw new BadRequestException( + `Boarding opens ${boardingWindowHours} hour(s) before departure at ${boardingOpenTime.toISOString()}`, + ); + } + + if (today >= departureTime) { + throw new BadRequestException('Boarding is closed — departure time has passed'); } } diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index ea45fe321..3133a87e4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -254,12 +254,16 @@ function BookingsPageContent() { }, { key: 'contact', label: 'Primary contact', - render: (booking: any) => ( -
-
{booking.contactPhone || booking.passenger?.phone}
-
{booking.contactEmail || booking.passenger?.email}
-
- ), + render: (booking: any) => { + const phone = booking.contactPhone || booking.passenger?.phone || '—'; + const email = booking.contactEmail || booking.passenger?.email || '—'; + return ( +
+
{phone}
+
{email}
+
+ ); + }, }, { key: 'paymentStatus', label: 'Payment', diff --git a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx index cee6944f6..edf23692a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx @@ -146,7 +146,13 @@ export default function CurrenciesPage() { const actions = [ { label: 'Edit', onClick: handleEdit, variant: 'secondary' as const, icon: Edit }, - { label: 'Delete', onClick: (c: CurrencyRate) => setDeleteConfirm(c), variant: 'danger' as const, icon: Trash2 }, + { + label: 'Delete', + onClick: (c: CurrencyRate) => setDeleteConfirm(c), + variant: 'danger' as const, + icon: Trash2, + show: (c: CurrencyRate) => c.id !== 'etb-base', + }, ]; return ( diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx index 30639e82d..17eb6d0e6 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx @@ -93,7 +93,7 @@ export default function PaymentsPage() { switch (key) { case 'reference': return payment.reference || payment.id?.substring(0, 8) || ''; case 'booking': return payment.booking?.bookingRef || 'N/A'; - case 'amount': return formatCurrency(payment.amountMinor, payment.currency); + case 'amount': return formatCurrency(payment.booking?.totalMinor ?? payment.amountMinor, 'ETB'); case 'method': return payment.method || ''; case 'status': return payment.status || ''; case 'createdAt': return payment.createdAt ? new Date(payment.createdAt).toLocaleString() : ''; @@ -116,7 +116,7 @@ export default function PaymentsPage() { const columns = [ { key: 'reference', label: 'Reference', render: (payment: any) => {payment.reference || payment.id?.substring(0, 8)} }, { key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' }, - { key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) }, + { key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.booking?.totalMinor ?? payment.amountMinor, 'ETB') }, { key: 'method', label: 'Method', render: (payment: any) => {payment.method} }, { key: 'status', label: 'Status', render: (payment: any) => {payment.status} }, { key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) }, @@ -204,7 +204,7 @@ export default function PaymentsPage() {
{[ - { label: 'Amount', value: formatCurrency(p.amountMinor, p.currency) }, + { label: 'Amount', value: formatCurrency(p.booking?.totalMinor ?? p.amountMinor, 'ETB') }, { label: 'Method', value: p.method || '—' }, { label: 'Booking', value: p.booking?.bookingRef || '—' }, ].map(({ label, value }) => ( @@ -222,7 +222,7 @@ export default function PaymentsPage() {

Amount

-

{formatCurrency(p.amountMinor, p.currency || 'ETB')}

+

{formatCurrency(p.booking?.totalMinor ?? p.amountMinor, 'ETB')}

diff --git a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx index 16f262b8e..3f0a266fd 100644 --- a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx @@ -10,6 +10,7 @@ export default function SettingsPage() { const [activeTab, setActiveTab] = useState('general'); const [seatHoldMinutes, setSeatHoldMinutes] = useState('5'); const [holdCutoffHours, setHoldCutoffHours] = useState('2'); + const [boardingWindowHours, setBoardingWindowHours] = useState('4'); const [throttleAuthLimit, setThrottleAuthLimit] = useState('5'); const [throttleStrictLimit, setThrottleStrictLimit] = useState('20'); const [throttleDefaultLimit, setThrottleDefaultLimit] = useState('100'); @@ -24,6 +25,7 @@ export default function SettingsPage() { .then((data) => { if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes); if (data?.hold_cutoff_hours_before_departure) setHoldCutoffHours(data.hold_cutoff_hours_before_departure); + if (data?.boarding_window_hours_before_departure) setBoardingWindowHours(data.boarding_window_hours_before_departure); if (data?.throttle_auth_limit) setThrottleAuthLimit(data.throttle_auth_limit); if (data?.throttle_strict_limit) setThrottleStrictLimit(data.throttle_strict_limit); if (data?.throttle_default_limit) setThrottleDefaultLimit(data.throttle_default_limit); @@ -39,6 +41,7 @@ export default function SettingsPage() { await systemConfigApi.update({ seat_hold_duration_minutes: seatHoldMinutes, hold_cutoff_hours_before_departure: holdCutoffHours, + boarding_window_hours_before_departure: boardingWindowHours, throttle_auth_limit: throttleAuthLimit, throttle_strict_limit: throttleStrictLimit, throttle_default_limit: throttleDefaultLimit, @@ -184,6 +187,23 @@ export default function SettingsPage() { Seat holds are rejected when this many hours or fewer remain before departure. Default: 2 hours.

+
+ + setBoardingWindowHours(e.target.value)} + /> +

+ Boarding opens this many hours before departure and closes exactly at departure time. Default: 4 hours. +

+
)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index 59ae5f3f8..d1d686142 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -831,7 +831,7 @@ export default function SearchPage() { etc.) are portaled to — see ModernDatePicker — so they aren't capped by this wrapper's own stacking context. ── */}
From c1db1c8a99096252bfba825895181bc03e190a60 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Sat, 11 Jul 2026 00:45:14 +0300 Subject: [PATCH 6/8] Fix voucher ticket number --- .../src/modules/bookings/bookings.service.ts | 14 +- .../src/app/booking/confirmation/page.tsx | 44 ++- .../portal/src/app/booking/detail/page.tsx | 21 +- .../portal/src/app/booking/review/page.tsx | 24 +- .../portal/src/app/booking/search/page.tsx | 295 +++++++++--------- .../src/components/ModernDatePicker.tsx | 6 +- .../portal/src/lib/generate-voucher.ts | 63 +++- 7 files changed, 272 insertions(+), 195 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 9c9384850..b80593cd5 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -1460,7 +1460,7 @@ export class BookingsService { include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, - paymentIntent: true, tickets: { take: 1 }, + paymentIntent: true, tickets: true, priceTier: { select: { priceMinor: true } }, }, }); @@ -1518,7 +1518,7 @@ export class BookingsService { payment: (pkgBooking as any).paymentIntent ? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status } : undefined, - ticket: undefined, + tickets: [], }; } @@ -1557,7 +1557,15 @@ export class BookingsService { }, })), payment: (booking as any).paymentIntent ? { method: (booking as any).paymentIntent.method, status: (booking as any).paymentIntent.status } : undefined, - ticket: (booking as any).tickets?.[0] ? { id: (booking as any).tickets[0].id, qrPayload: (booking as any).tickets[0].qrPayload, barcodePayload: (booking as any).tickets[0].barcodePayload, status: (booking as any).tickets[0].status } : undefined, + // One ticket per passenger — matched on the frontend by passengerName, not array + // position, since tickets are grouped/created independently of the passengers array. + tickets: (booking as any).tickets?.map((t: any) => ({ + id: t.id, + passengerName: t.passengerName, + qrPayload: t.qrPayload, + barcodePayload: t.barcodePayload, + status: t.status, + })) ?? [], }; } diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 719e80f18..8ac4bf046 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -19,10 +19,13 @@ type BookingWithTicket = { totalMinor?: number; createdAt?: string; paymentMethod?: string; - ticket?: { + // One ticket per passenger — match by passengerName, not array position (see + // bookings.service.ts's getByRef). + tickets?: Array<{ + passengerName?: string; barcodePayload?: string; qrPayload?: string; - }; + }>; }; export default function ConfirmationPage() { @@ -36,6 +39,14 @@ export default function ConfirmationPage() { const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); const confirmAttempted = useRef(false); + // Warms the code-split voucher module ahead of the click so the handler's own + // `await import(...)` resolves near-instantly — on iOS Safari, a file save triggered + // too long after the originating click's synchronous execution window is silently + // blocked, and awaiting a cold dynamic import is enough to fall outside that window. + useEffect(() => { + import('@/lib/generate-voucher'); + }, []); + const { data: _booking } = useQuery({ queryKey: ['booking', bookingId], queryFn: async (): Promise => { @@ -142,9 +153,16 @@ export default function ConfirmationPage() { seatClass: inboundSchedule.selectedSeatClassName, } : undefined; + // Separate file per passenger, saved back-to-back with no macrotask (setTimeout) + // between them — a setTimeout delay would push later saves outside the click's + // synchronous user-activation window and risk iOS Safari silently blocking them. for (let i = 0; i < passengers.length; i++) { const p = passengers[i]; - const ticketNumber = `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(i + 1).toString().padStart(2, '0')}`; + // Same match-by-name-then-position as the on-screen ticket list above — no + // fabricated placeholder if there's no backend ticket data (see generate-voucher.ts). + const matchedTicket = + _booking?.tickets?.find((t) => t.passengerName === p.name) ?? _booking?.tickets?.[i] ?? null; + const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued'; await generatePassengerVoucherPDF({ bookingRef: pnr, @@ -163,9 +181,6 @@ export default function ConfirmationPage() { currency: voucherCurrency, createdAt, }); - - // brief pause between downloads so browsers don't block them - if (i < passengers.length - 1) await new Promise(r => setTimeout(r, 400)); } } catch (error) { alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`); @@ -390,10 +405,15 @@ export default function ConfirmationPage() {

Your tickets

{passengers.map((passenger, index) => { - const backendTicket = _booking?.ticket || null; - const ticketNumber = isConfirmed - ? backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}` - : null; + // Match by name first (tickets aren't necessarily created/ordered the same + // way as this passengers array) — fall back to position if no name match. + const backendTicket = + _booking?.tickets?.find((t) => t.passengerName === passenger.name) ?? + _booking?.tickets?.[index] ?? + 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 = isConfirmed ? backendTicket?.barcodePayload || null : null; return (
@@ -414,7 +434,9 @@ export default function ConfirmationPage() {

Ticket Number

-

{ticketNumber || 'Pending payment'}

+

+ {ticketNumber || (isConfirmed ? 'Not yet issued' : 'Pending payment')} +

Date of Birth

diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index a3e7f06fc..92df23f23 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -4,14 +4,13 @@ import { Suspense } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { useQuery, useMutation } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Clock, Users, CheckCircle2, AlertCircle, Download, - Share2, Copy, Check, CreditCard, @@ -49,6 +48,14 @@ function BookingDetailContent() { const [copiedPNR, setCopiedPNR] = useState(false); const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); + // Warms the code-split voucher module ahead of the click so the handler's own + // `await import(...)` resolves near-instantly — on iOS Safari, a file save triggered + // too long after the originating click's synchronous execution window is silently + // blocked, and awaiting a cold dynamic import is enough to fall outside that window. + useEffect(() => { + import('@/lib/generate-voucher'); + }, []); + const { data: booking, isLoading, error, refetch } = useQuery({ queryKey: ['booking-detail', bookingRef], queryFn: async () => { @@ -629,7 +636,7 @@ function BookingDetailContent() {
{isConfirmed && ( <> - - - )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index e4ab0a913..ab93c1cf6 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -313,14 +313,18 @@ export default function ReviewPage() { } // Build booking request for authenticated users - // For package bookings, free children (first child per adult, no seat assigned) - // are excluded from the passengers array — the backend derives them from adultCount/childCount. - const bookingPassengers = passengers.filter((p, i) => { + // Package bookings only: free children (first child per adult) don't go through + // seat selection and have no seatId, so they're excluded here — the backend derives + // them from adultCount/childCount instead. Regular bookings DO seat every passenger + // (including the free child, who still gets a real seatId and a $0 fare handled by + // the backend), so they must stay in the array or that passenger — and their + // ticket/seat/childCount — silently never gets created. + const bookingPassengers = passengers.filter((_p, i) => { if (packageId) { const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount; return !isFreePkgChild; } - return !(isChild(p) && isFirstChild(passengers, i)); + return true; }); bookingData = { @@ -371,14 +375,18 @@ export default function ReviewPage() { if (priceTierId) bookingData.priceTierId = priceTierId; } else { // For guests: send full passenger details array - // For package bookings, free children (first child per adult, no seat assigned) - // are excluded from the passengers array — the backend derives them from adultCount/childCount. - const guestBookingPassengers = passengers.filter((p, i) => { + // Package bookings only: free children (first child per adult) don't go through + // seat selection and have no seatId, so they're excluded here — the backend derives + // them from adultCount/childCount instead. Regular bookings DO seat every passenger + // (including the free child, who still gets a real seatId and a $0 fare handled by + // the backend), so they must stay in the array or that passenger — and their + // ticket/seat/childCount — silently never gets created. + const guestBookingPassengers = passengers.filter((_p, i) => { if (packageId) { const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount; return !isFreePkgChild; } - return !(isChild(p) && isFirstChild(passengers, i)); + return true; }); bookingData = { diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index d8e485fd6..a18aab817 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -806,8 +806,12 @@ export default function SearchPage() { /> )} - {/* ── 90vh hero with banner image ── */} -
+ {/* ── Hero with banner image (desktop only — mobile is content-driven, no + forced height, so it doesn't push the Packages section below the fold). + Desktop height is intentionally short of a full viewport so the Packages + section peeks into view without scrolling — a full 94vh hero was hiding + it entirely on common screen sizes. ── */} +
{/* Background image with zoom - fully isolated */}
{/* Mobile-only heading — desktop keeps the version overlaid on the hero image above */}
-

+

Where are you headed today?

-

- Book your train journey across East Africa -

@@ -897,166 +898,174 @@ export default function SearchPage() {
- {/* Mobile: stacked */} + {/* Mobile: stacked, but From/To and Date/Return Date pair up into two + columns each to save vertical space (station names/dates truncate + rather than wrap) — same fields, same behavior, just denser. */}
-
- - - {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 */} 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 ca22538be..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); @@ -365,9 +380,7 @@ function drawFooter(doc: jsPDF, createdAt: string): void { // ─── 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)); } }; From 8e2b8c9b875025553c45a63517fa9e33319ae6b0 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 11 Jul 2026 00:52:57 +0300 Subject: [PATCH 7/8] Admin rule settings, minor issues resolution --- .../src/modules/agents/agents.controller.ts | 3 +++ .../src/modules/bookings/bookings.controller.ts | 4 +++- .../modules/currencies/currencies.controller.ts | 6 +++--- .../excess-baggage/excess-baggage.controller.ts | 5 +++++ .../src/modules/fare-engine/currency.controller.ts | 5 ++++- .../src/modules/fleet/fleet.controller.ts | 13 +++++++++++++ .../src/modules/packages/packages.controller.ts | 7 ++++--- .../modules/passengers/passengers.controller.ts | 4 +++- .../src/modules/promos/promos.controller.ts | 5 +++-- .../src/modules/schedules/routes.controller.ts | 10 +++++++--- .../src/modules/schedules/schedules.controller.ts | 13 +++++++++---- .../seat-classes/seat-classes.controller.ts | 4 +++- .../src/modules/seat-classes/seat-classes.dto.ts | 5 +++++ .../src/modules/stations/stations.controller.ts | 5 +++-- .../src/modules/tickets/tickets.controller.ts | 4 +++- .../backoffice/src/app/tariff-rates/page.tsx | 1 + .../backoffice/src/components/layout/Sidebar.tsx | 14 +++++++------- 17 files changed, 79 insertions(+), 29 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts index 1f7fcd288..21f71a8c6 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts @@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AgentsService } from './agents.service'; import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Agents') @Controller('agents') @@ -36,6 +37,8 @@ export class AgentsController { } @Delete(':id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete agent profile' }) deleteAgent(@Param('id') id: string) { return this.service.deleteAgent(id); diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 9a2053943..23a3b6c5a 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -7,6 +7,7 @@ import { GuestBookingService } from './guest-booking.service'; import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto'; import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Booking') @Controller('bookings') @@ -466,7 +467,8 @@ export class BookingsController { } @Delete(':id') - @SetMetadata('isPublic', true) + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ description: 'Permanently deletes a booking record' }) diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts index 093436aae..690134388 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts @@ -1,5 +1,5 @@ -import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode } from '@nestjs/common'; -import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { CurrenciesService } from './currencies.service'; import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto'; import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; @@ -31,7 +31,7 @@ export class CurrenciesController { } @Delete(':id') - @PassengerStaff(PASSENGER_PERMS.currencies.manage) + @PassengerAdmin() @ApiBearerAuth('IAM-auth') deleteCurrency(@Param('id') id: string) { return this.currenciesService.deleteCurrency(id); diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts index 551cc8881..842f13ca6 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -8,6 +8,7 @@ import { InitiateExcessPaymentDto, } from './excess-baggage.dto'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; class UpsertBaggageAllowanceDto { @IsString() seatClassId: string; @@ -70,6 +71,8 @@ export class ExcessBaggageAgentController { } @Delete('allowances/:id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete baggage allowance rule' }) deleteAllowance(@Param('id') id: string) { return this.service.deleteAllowance(id); @@ -94,6 +97,8 @@ export class ExcessBaggageAgentController { } @Delete(':id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete excess baggage charge (admin only)' }) deleteCharge(@Param('id') id: string) { return this.service.deleteCharge(id); diff --git a/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts b/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts index 1d35f6b61..0cd1eda71 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts @@ -1,9 +1,10 @@ import { Body, Controller, Delete, Get, Param, Patch, Put, Post } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiParam, ApiProperty, ApiResponse } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiParam, ApiProperty, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { CurrencyService } from '../currency/currency.service'; import { UpsertExchangeRateDto } from './currency.dto'; import { IsNumber, IsPositive, IsOptional, IsString } from 'class-validator'; import { Type } from 'class-transformer'; +import { PassengerAdmin } from '../../common/passenger-guards'; class UpdateExchangeRateDto { @ApiProperty({ example: 3.5 }) @Type(() => Number) @IsNumber() @IsPositive() rate: number; @@ -38,6 +39,8 @@ export class CurrencyController { } @Delete(':id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete an exchange rate record by ID' }) @ApiParam({ name: 'id', description: 'CurrencyExchangeRate UUID' }) @ApiResponse({ status: 200, description: 'Rate deleted' }) diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index 7422e45b0..d3864e2b2 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiR import { FleetService } from './fleet.service'; import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Fleet') @Controller('fleet') @@ -38,6 +39,8 @@ export class FleetController { } @Delete('coach-types/:id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a coach type' }) @ApiParam({ name: 'id', description: 'Coach Type UUID' }) @ApiResponse({ status: 200, description: 'Coach type deleted' }) @@ -74,6 +77,8 @@ export class FleetController { } @Delete('classes/:id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a class' }) @ApiParam({ name: 'id', description: 'Class UUID' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @@ -111,6 +116,8 @@ export class FleetController { } @Delete('seat-classes/:id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' }) @ApiParam({ name: 'id', description: 'Class UUID' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @@ -147,6 +154,8 @@ export class FleetController { } @Delete('trains/:id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a train service' }) @ApiParam({ name: 'id', description: 'Train UUID' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @@ -310,6 +319,8 @@ export class FleetController { } @Delete('coaches/:id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a coach' }) @ApiParam({ name: 'id', description: 'Coach UUID' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @@ -329,6 +340,8 @@ export class FleetController { } @Delete('assignments/:id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Remove a coach assignment' }) @ApiParam({ name: 'id', description: 'Assignment UUID' }) @ApiResponse({ status: 200, description: 'Assignment removed' }) diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts index 07e324336..314f8aeb4 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -6,6 +6,7 @@ import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDt import { IamGuard } from '../../common/iam-adapter'; import { JwtGuard } from '../../common/jwt.guard'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Packages') @Controller('packages') @@ -41,7 +42,7 @@ export class PackagesController { } @Delete('inquiries/:id') - @UseGuards(IamGuard) + @PassengerAdmin() @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete inquiry (backoffice)' }) deleteInquiry(@Param('id') id: string) { @@ -139,7 +140,7 @@ export class PackagesController { } @Delete(':id') - @UseGuards(IamGuard) + @PassengerAdmin() @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete package (admin)' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' }) @@ -180,7 +181,7 @@ export class PackagesController { } @Delete('tiers/:tierId') - @UseGuards(IamGuard) + @PassengerAdmin() @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete price tier (admin)' }) deleteTier(@Param('tierId') tierId: string) { diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index f0b719386..cfa4a8805 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -4,6 +4,7 @@ import { SkipThrottle, Throttle } from '@nestjs/throttler'; import { PassengersService } from './passengers.service'; import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; import { PrismaService } from '../../common/prisma.service'; @@ -503,7 +504,8 @@ Returns saved passenger details with generated IDs and confirmation.`, } @Delete(':id') - @SetMetadata('isPublic', true) + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete passenger (admin only)', description: 'Permanently deletes a passenger record and associated data' diff --git a/apps/edr-passenger-api/src/modules/promos/promos.controller.ts b/apps/edr-passenger-api/src/modules/promos/promos.controller.ts index 8bdf8f868..6a547116e 100644 --- a/apps/edr-passenger-api/src/modules/promos/promos.controller.ts +++ b/apps/edr-passenger-api/src/modules/promos/promos.controller.ts @@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { PromosService } from './promos.service'; import { CreatePromotionDto } from './promos.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Promotions') @Controller('promos') @@ -64,8 +65,8 @@ export class PromosController { } @Delete(':id') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete promo (admin)' }) delete(@Param('id') id: string) { return this.service.delete(id); diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts index f432cf072..e751278ce 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts @@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } import { RoutesService } from './routes.service'; import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Routes') @Controller('routes') @@ -48,7 +49,8 @@ Route stops carry distanceKm for fare-by-distance calculations.`, updateRoute(@Param('id') id: string, @Body() dto: UpdateRouteDto) { return this.service.updateRoute(id, dto); } @Delete(':id') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a route' }) @ApiParam({ name: 'id', description: 'Route UUID' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @@ -75,7 +77,8 @@ Route stops carry distanceKm for fare-by-distance calculations.`, addStop(@Param('id') id: string, @Body() dto: AddRouteStopDto) { return this.service.addStop(id, dto); } @Delete(':id/stops/:sequence') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Remove a stop from a route by sequence number' }) @ApiParam({ name: 'id', description: 'Route UUID' }) @ApiParam({ name: 'sequence', description: 'Stop sequence number to remove' }) @@ -119,7 +122,8 @@ Route stops carry distanceKm for fare-by-distance calculations.`, } @Delete(':id/coaches') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Clear the default coach lineup for this route' }) @ApiParam({ name: 'id', description: 'Route UUID' }) @ApiResponse({ status: 200, description: 'Template cleared' }) diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 3cb95104d..9fc2ba851 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -4,6 +4,7 @@ import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.de import { SchedulesService } from './schedules.service'; import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Schedule') @Controller('schedules') @@ -56,7 +57,8 @@ export class SchedulesController { } @Delete('fares/:id') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a fare rule' }) @ApiParam({ name: 'id', description: 'FareRule UUID' }) @ApiResponse({ status: 200, description: 'Fare rule deleted' }) @@ -80,7 +82,8 @@ export class SchedulesController { updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); } @Delete('segment-fares/:id') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a segment fare rule' }) @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); } @@ -110,7 +113,8 @@ export class SchedulesController { } @Delete(':id') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean }) @@ -203,7 +207,8 @@ export class SchedulesController { getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); } @Delete(':id/coaches/:coachId') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Remove a coach assignment' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'coachId', description: 'Coach UUID' }) diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts index 86eb287a4..4eb6c212b 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts @@ -4,6 +4,7 @@ import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.de import { SeatClassesService } from './seat-classes.service'; import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Seat Classes') @Controller('seat-classes') @@ -42,7 +43,8 @@ export class SeatClassesController { updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); } @Delete(':id') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a seat class' }) @ApiParam({ name: 'id', description: 'Seat class UUID' }) @ApiResponse({ status: 200, description: 'Seat class deleted' }) diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts index 1f061bf53..1ea743aa6 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts @@ -29,6 +29,11 @@ export class CreateSeatClassDto { @IsInt() basePrice: number; + @ApiPropertyOptional({ example: 1200, description: 'Flat insurance fee in minor units' }) + @IsOptional() + @IsInt() + insuranceFeeMinor?: number; + @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() diff --git a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts index 2b1842c2a..0f367043b 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts @@ -4,6 +4,7 @@ import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.de import { StationsService } from './stations.service'; import { CreateStationDto } from './stations.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Stations') @Controller('stations') @@ -133,8 +134,8 @@ export class StationsController { } @Delete(':id') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete station' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @ApiResponse({ status: 200, description: 'Station deleted successfully' }) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 8d85012dd..7f49d77b1 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, Se import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Tickets') @Controller('tickets') @@ -9,7 +10,8 @@ export class TicketsController { constructor(private service: TicketsService) {} @Post('generate/:bookingId') - @SetMetadata('isPublic', true) + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Generate ticket for booking (confirmation page)', description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.' diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx index 30d736e37..590c30848 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx @@ -406,6 +406,7 @@ export default function TariffRatesPage() { name="insuranceFeeMinor" className="input" defaultValue={editingClass ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'} + key={editingClass?.id ?? 'new-insurance'} min="0" step="0.01" placeholder="e.g. 25.00" diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 39fd91ca5..982094cf3 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -90,21 +90,21 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Financial', items: [ - { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin }, + // { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin }, { name: 'Tariff Rates', href: '/tariff-rates', icon: Banknote, permission: PERMS.admin }, - { name: 'Fare Rules', href: '/fare-management', icon: Settings, permission: PERMS.admin }, + // { name: 'Fare Rules', href: '/fare-management', icon: Settings, permission: PERMS.admin }, { name: 'Payments', href: '/payments', icon: CreditCard, permission: PERMS.payments.view }, { name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage }, - { name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin }, + // { name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin }, { name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.payments.view }, - { name: 'Wallet Accounts', href: '/wallet-accounts', icon: Wallet, permission: PERMS.payments.view }, + // { name: 'Wallet Accounts', href: '/wallet-accounts', icon: Wallet, permission: PERMS.payments.view }, ] }, { title: 'Customer Services', items: [ - { name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view }, - { name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view }, + // { name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view }, + // { name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view }, { name: 'Notifications', href: '/notifications', icon: Bell, permission: PERMS.notifications.send }, ] }, @@ -126,7 +126,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'System', items: [ - { name: 'Agents', href: '/agents', icon: Briefcase, permission: PERMS.agents.view }, + // { name: 'Agents', href: '/agents', icon: Briefcase, permission: PERMS.agents.view }, { name: 'Users', href: '/settings/users', icon: Users, permission: PERMS.admin }, { name: 'Settings', href: '/settings', icon: Settings, permission: PERMS.admin }, { name: 'Health', href: '/health', icon: Activity, permission: PERMS.admin }, From d927f543cfd55c3aef817178ced34f75fa797855 Mon Sep 17 00:00:00 2001 From: "Stephanos A." Date: Sat, 11 Jul 2026 01:10:10 +0300 Subject: [PATCH 8/8] Fix checkout action version in deploy workflow --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 68f92eea7..62530611c 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@v4e + uses: actions/checkout@v4 with: fetch-depth: 2