From 46d332e1531fc562592341105d4d2a151174b01c Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 17 Jun 2026 20:20:40 +0300 Subject: [PATCH 1/3] Seat hold updates --- apps/edr-passenger-api/src/main.ts | 82 +++-- .../modules/bookings/bookings.controller.ts | 323 +++++++++++++++--- .../src/modules/bookings/bookings.dto.ts | 36 +- .../src/modules/bookings/guest-booking.dto.ts | 28 +- .../portal/src/app/booking/review/page.tsx | 2 + 5 files changed, 389 insertions(+), 82 deletions(-) diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 298e37c01..928789c04 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -35,14 +35,16 @@ async function bootstrap() { Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM. ## Latest Updates -- **Round-Trip Leg Tracking:** returnLegStatus on every booking tracks outbound/return leg usage (NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED). Gate validation accepts a leg field (OUTBOUND or RETURN). +- **TRANSIT & ROUND_TRIP_TRANSIT Booking Types:** Full multi-leg booking support. TRANSIT = single journey via connecting train (single PNR). ROUND_TRIP_TRANSIT = round trip where one or both directions use a connecting train (4 holds, 4 seat sets). +- **returnSeatId on Passenger Payloads:** For ROUND_TRIP and ROUND_TRIP_TRANSIT bookings each passenger object must include \`returnSeatId\` (the seat on the return leg-1). Guest and authenticated booking endpoints both enforce this. +- **Unified Booking Type Matrix:** bookingType field on Booking now accepts ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT across all create endpoints (POST /bookings and POST /bookings/guest). +- **Round-Trip Leg Tracking:** returnLegStatus on every booking tracks outbound/return leg usage (NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED). Gate validation accepts a leg field (OUTBOUND | RETURN | LEG1 | LEG2 | OUTBOUND_LEG1 | OUTBOUND_LEG2 | RETURN_LEG1 | RETURN_LEG2). - **Auto No-Show Detection:** Cron marks OUTBOUND_ONLY 30 min after return departure when return leg was never scanned. - **Offline Batch Validation:** validateOfflineBatch now accepts leg per entry and handles both legs of a round-trip in one batch. - **Booking Filters:** GET /bookings now accepts ?returnLegStatus= to filter no-show/inbound-only cases in back-office. - **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display. - **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles. - **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing. -- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories. - **Multi-Currency Display:** Bookings track display currency and converted amounts. - **Ticket Lifecycle:** Tickets now include validatedAt, outboundBoardedAt, returnBoardedAt for complete audit trail. @@ -61,11 +63,13 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m - Multi-segment journey support - Cross-border journeys via Dire Dawa transit (Ethiopia to Djibouti) - Round-trip booking with return journey scheduling +- Transit booking (single journey via connecting train, single PNR, single ticket) +- Round-trip transit booking (round trip where one or both directions use a connecting train) - Coach type selection with seat class and pricing options -- NEW: Booking type tracking (ONE_WAY vs ROUND_TRIP) -- NEW: Display currency and converted pricing per booking -- NEW: returnLegStatus field tracks which legs of a round-trip were used -- NEW: GET /bookings?returnLegStatus=OUTBOUND_ONLY filters no-show returns in back-office +- Booking type field: ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT +- Display currency and converted pricing per booking +- returnLegStatus field tracks which legs of a round-trip were used +- GET /bookings?returnLegStatus=OUTBOUND_ONLY filters no-show returns in back-office ### Passenger Verification 1. Ethiopian Nationals: @@ -111,21 +115,33 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m - NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets - NEW: Complete audit trail per leg for compliance and reporting -### Round-Trip Leg Tracking (NEW) +### Booking Type Matrix + +| bookingType | Holds required | Passenger seat fields | Legs in DB | +|---|---|---|---| +| ONE_WAY | holdId | seatId | 1 | +| ROUND_TRIP | holdId + returnHoldId | seatId + returnSeatId | 2 (leg=1 outbound, leg=2 return) | +| TRANSIT | holdId + leg2HoldId | seatId + leg2SeatId | 2 (leg=1, leg=2 on same direction) | +| ROUND_TRIP_TRANSIT | holdId + leg2HoldId + returnHoldId + returnLeg2HoldId | seatId + leg2SeatId + returnSeatId + returnLeg2SeatId | 4 | + +### Round-Trip Leg Tracking - returnLegStatus on Booking: NOT_APPLICABLE, NEITHER_USED, OUTBOUND_ONLY, INBOUND_ONLY, BOTH_USED -- Gate validation POST /tickets/:ref/validate accepts optional leg field (OUTBOUND or RETURN) +- Gate validation POST /tickets/:ref/validate accepts optional leg field: + - ONE_WAY: omit + - TRANSIT: LEG1 | LEG2 + - ROUND_TRIP: OUTBOUND | RETURN + - ROUND_TRIP_TRANSIT: OUTBOUND_LEG1 | OUTBOUND_LEG2 | RETURN_LEG1 | RETURN_LEG2 - Auto no-show cron: sets OUTBOUND_ONLY 30 min after return departure when return leg unscanned - Back-office filter: GET /bookings?returnLegStatus=OUTBOUND_ONLY surfaces no-shows - Offline batch: validateOfflineBatch accepts leg per entry, handles both legs of same booking -### Round-Trip Booking -- One-way and round-trip journey options -- Flexible return date selection -- Combined pricing for outbound + return legs -- Separate seat management per leg -- Independent modification/cancellation per leg -- Return journey tracking and notifications -- NEW: Booking type stored for analytics and reporting +### Round-Trip & Transit Bookings +- ONE_WAY and ROUND_TRIP for direct routes +- TRANSIT for single connecting journey (Dire Dawa hub), single PNR +- ROUND_TRIP_TRANSIT for round trips via connecting trains +- Combined pricing: total = sum of all leg base fares, single promo/loyalty deduction +- Separate seat management per leg; each leg stored with its scheduleId and leg number +- returnLegStatus tracks which legs have been boarded for no-show management ### Loyalty Program - 4 tiers: Bronze, Silver, Gold, Platinum @@ -198,26 +214,37 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token. ## Passenger Booking Flow ### Step 1: Search Trips -\`POST /search\` with origin, destination, date, passenger counts, and nationality +\`POST /search\` with origin, destination, date, passenger counts, and nationality. +For round-trips also pass \`journeyType=ROUND_TRIP\` and \`returnDate\`. ### Step 2: Get Fare Quote -\`POST /search/fare-quote\` with passenger counts and display currency +\`POST /search/fare-quote\` with passenger counts and display currency. +For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`returnDestinationStationId\`. ### Step 3: Passenger Information & Verification **For Ethiopian Passengers:** -\`POST /passengers/verify-fayda\` - Automatic Fayda verification for adults (5+ years) +\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years) **For International Passengers:** -\`POST /passengers/register-international\` - Passport information collection +\`POST /passengers/register-international\` — Passport information collection ### Step 4: View Seat Map -\`GET /seats/seatmap/{scheduleId}\` - Show available coaches and seats +\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats. +For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId. -### Step 5: Login & Hold Seats -\`POST /auth/login\` then \`POST /seats/hold\` to reserve seats for 15 minutes +### Step 5: Hold Seats +\`POST /seats/hold\` to reserve seats for 15 minutes. +- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\` +- TRANSIT leg-2: second hold call → \`leg2HoldId\` +- ROUND_TRIP return: second hold call → \`returnHoldId\` +- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\` ### Step 6: Create Booking -\`POST /bookings/guest\` with verified passenger details and held seats +Choose the right endpoint and bookingType: +- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\` +- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\` +- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\` +- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\` ### Step 7: Process Payment \`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian) @@ -268,7 +295,7 @@ Payment providers send notifications to: .addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation") .addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails") .addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management") - .addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports returnLegStatus filter for round-trip no-show management") + .addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT booking types. returnLegStatus filter for round-trip no-show management") .addTag("Config", "System settings, feature flags, and configuration management") .addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion") .addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications") @@ -285,7 +312,6 @@ Payment providers send notifications to: .addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation") .addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking") .addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards") - .addTag("Round Trip", "Round-trip bookings, return scheduling, combined pricing, and management (NEW)") .addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance") .addTag("Schedule", "Trip schedules, availability windows, status tracking, and timing") .addTag("Search", "Trip search, fare quotes, coach types, and real-time availability") @@ -294,8 +320,8 @@ Payment providers send notifications to: .addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability") .addTag("Stations", "Station directory, location data, baggage facilities, and amenities") .addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution") - .addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN), and audit trails") - .addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)") + .addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation with per-leg tracking (OUTBOUND/RETURN/LEG1/LEG2/OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2), and audit trails") + .addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, TRANSIT and ROUND_TRIP_TRANSIT bookings") .addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger") //.addServer('http://localhost:4000', 'Development') // .addServer("https://api.edr-platform.com", "Production") 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 561e36c43..54dcea344 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger'; import { BookingsService } from './bookings.service'; import { GuestBookingService } from './guest-booking.service'; import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto'; @@ -100,35 +100,153 @@ export class BookingsController { @Post('guest') @ApiOperation({ - summary: 'Create guest booking without login (optional account creation)', - description: `Creates a booking without requiring login. Features: - -**Guest Checkout:** -- No login required -- Contact details from first passenger -- Booking confirmation sent to email/phone + summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)', + description: `Creates a booking without requiring login. Supports all four booking types. -**Optional Account Creation:** -- Set createAccount=true with password -- Account created using first passenger details -- Automatic login after booking -- Loyalty points and wallet created +**bookingType: ONE_WAY (default)** +- scheduleId, holdId, originStationId, destinationStationId, seatClassId +- passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … } -**Passenger Details Storage:** -- savePassengerDetails=true: Save for future bookings -- Stored by userId (if account created) or deviceId -- Retrieve saved passengers for quick booking +**bookingType: ROUND_TRIP** +- Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId +- passengers[]: each must include returnSeatId (seat on the return leg) -**Verifayda Verification:** -- Ethiopian nationals: National ID verified via Verifayda -- Other nationals: Passport details (no verification) +**bookingType: TRANSIT** +- scheduleId/holdId (leg-1) + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId +- passengers[]: each must include leg2SeatId -**Age-Based Pricing:** -- ADULT (≥5 years): Full fare -- CHILD (<5 years): First child FREE, subsequent children full fare` +**bookingType: ROUND_TRIP_TRANSIT** +- All TRANSIT outbound fields + returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId/returnLeg2ScheduleId/returnLeg2HoldId/returnTransitStationId/returnLeg2DestinationStationId +- passengers[]: each must include leg2SeatId, returnSeatId, returnLeg2SeatId + +**Optional account creation:** set createAccount=true with password — creates account from first passenger details, loyalty + wallet initialised. + +**Verifayda:** Ethiopian nationals verified; international passengers require passportNumber + passportCountry.` }) - @ApiResponse({ status: 201, description: 'Booking created successfully' }) - @ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid data' }) + @ApiBody({ + type: CreateGuestBookingDto, + examples: { + ONE_WAY: { + summary: 'ONE_WAY — single direct journey (guest)', + value: { + scheduleId: 'schedule-uuid', + holdId: 'hold-uuid', + originStationId: 'station-uuid', + destinationStationId: 'station-uuid', + seatClassId: 'seat-class-uuid', + bookingType: 'ONE_WAY', + displayCurrency: 'ETB', + passengers: [{ + seatId: 'seat-uuid', + passengerName: 'Abebe Kebede', + dateOfBirth: '1990-05-15', + idDocumentType: 'NATIONAL_ID', + idDocumentNumber: 'ET123456789', + nationality: 'Ethiopian', + phone: '+251911234567', + email: 'abebe@email.com', + }], + savePassengerDetails: true, + deviceId: 'device-uuid-123', + }, + }, + ROUND_TRIP: { + summary: 'ROUND_TRIP — outbound + return, single PNR (guest)', + value: { + scheduleId: 'outbound-schedule-uuid', + holdId: 'outbound-hold-uuid', + originStationId: 'addis-station-uuid', + destinationStationId: 'djibouti-station-uuid', + seatClassId: 'seat-class-uuid', + bookingType: 'ROUND_TRIP', + returnScheduleId: 'return-schedule-uuid', + returnHoldId: 'return-hold-uuid', + returnOriginStationId: 'djibouti-station-uuid', + returnDestinationStationId: 'addis-station-uuid', + returnSeatClassId: 'seat-class-uuid', + displayCurrency: 'ETB', + passengers: [{ + seatId: 'outbound-seat-uuid', + returnSeatId: 'return-seat-uuid', + passengerName: 'Abebe Kebede', + dateOfBirth: '1990-05-15', + idDocumentType: 'NATIONAL_ID', + idDocumentNumber: 'ET123456789', + nationality: 'Ethiopian', + phone: '+251911234567', + }], + savePassengerDetails: true, + deviceId: 'device-uuid-123', + }, + }, + TRANSIT: { + summary: 'TRANSIT — connecting train, single PNR (guest)', + value: { + scheduleId: 'leg1-schedule-uuid', + holdId: 'leg1-hold-uuid', + originStationId: 'addis-station-uuid', + destinationStationId: 'diredawa-station-uuid', + seatClassId: 'seat-class-uuid', + bookingType: 'TRANSIT', + leg2ScheduleId: 'leg2-schedule-uuid', + leg2HoldId: 'leg2-hold-uuid', + transitStationId: 'diredawa-station-uuid', + leg2DestinationStationId: 'djibouti-station-uuid', + displayCurrency: 'ETB', + passengers: [{ + seatId: 'leg1-seat-uuid', + leg2SeatId: 'leg2-seat-uuid', + passengerName: 'Abebe Kebede', + dateOfBirth: '1990-05-15', + idDocumentType: 'NATIONAL_ID', + idDocumentNumber: 'ET123456789', + nationality: 'Ethiopian', + phone: '+251911234567', + }], + deviceId: 'device-uuid-123', + }, + }, + ROUND_TRIP_TRANSIT: { + summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds (guest)', + value: { + scheduleId: 'ob-leg1-schedule-uuid', + holdId: 'ob-leg1-hold-uuid', + originStationId: 'addis-station-uuid', + destinationStationId: 'diredawa-station-uuid', + seatClassId: 'seat-class-uuid', + bookingType: 'ROUND_TRIP_TRANSIT', + leg2ScheduleId: 'ob-leg2-schedule-uuid', + leg2HoldId: 'ob-leg2-hold-uuid', + transitStationId: 'diredawa-station-uuid', + leg2DestinationStationId: 'djibouti-station-uuid', + returnScheduleId: 'ret-leg1-schedule-uuid', + returnHoldId: 'ret-leg1-hold-uuid', + returnOriginStationId: 'djibouti-station-uuid', + returnDestinationStationId: 'diredawa-station-uuid', + returnLeg2ScheduleId: 'ret-leg2-schedule-uuid', + returnLeg2HoldId: 'ret-leg2-hold-uuid', + returnTransitStationId: 'diredawa-station-uuid', + returnLeg2DestinationStationId: 'addis-station-uuid', + displayCurrency: 'ETB', + passengers: [{ + seatId: 'ob-leg1-seat-uuid', + leg2SeatId: 'ob-leg2-seat-uuid', + returnSeatId: 'ret-leg1-seat-uuid', + returnLeg2SeatId: 'ret-leg2-seat-uuid', + passengerName: 'Abebe Kebede', + dateOfBirth: '1990-05-15', + idDocumentType: 'NATIONAL_ID', + idDocumentNumber: 'ET123456789', + nationality: 'Ethiopian', + phone: '+251911234567', + }], + deviceId: 'device-uuid-123', + }, + }, + }, + }) + @ApiResponse({ status: 201, description: 'Booking created successfully with fareBreakdown' }) + @ApiResponse({ status: 400, description: 'Missing required seat IDs for bookingType, or Verifayda verification failed' }) createGuest(@Body() dto: CreateGuestBookingDto) { return this.guestService.createGuestBooking(dto); } @@ -147,24 +265,149 @@ export class BookingsController { @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ - summary: 'Create booking (one-way or round-trip)', - description: `Creates a one-way or round-trip booking for logged-in users. - -ONE_WAY booking: -- scheduleId, holdId, originStationId, destinationStationId -- passengers: array of PassengerInputDto with seatId -- Single PNR, single payment + summary: 'Create booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT', + description: `Creates a booking for a logged-in passenger. bookingType controls which fields are required. -ROUND_TRIP booking: -- Outbound: scheduleId, holdId, originStationId, destinationStationId, seatClassId -- Return: returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId -- passengers: array of RoundTripPassengerDto with outboundSeatId and returnSeatId -- Combined PNR, single payment for both legs -- Fare = outbound_fare + return_fare, single total, single promo, single loyalty deduction` +**ONE_WAY** +- scheduleId, holdId, originStationId, destinationStationId, seatClassId +- passengers[]: { seatId, passengerName, dateOfBirth, idDocumentType, … } + +**ROUND_TRIP** +- Above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId +- passengers[]: { seatId (outbound), returnSeatId (return), passengerName, … } +- Combined fare = outbound fare + return fare; single promo/loyalty deduction + +**TRANSIT** (connecting train, single PNR) +- scheduleId/holdId for leg-1 + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId +- passengers[]: { seatId (leg-1), leg2SeatId (leg-2), passengerName, … } + +**ROUND_TRIP_TRANSIT** (round trip, each direction via connecting train) +- All TRANSIT outbound fields + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnLeg2ScheduleId, returnLeg2HoldId, returnTransitStationId, returnLeg2DestinationStationId +- passengers[]: { seatId, leg2SeatId, returnSeatId, returnLeg2SeatId, passengerName, … } +- 4 holds required, 4 seat sets per passenger, single PNR, single payment + +**Age-Based Pricing (all types)** +- ADULT (≥5 years): full fare per leg +- CHILD (<5 years): first child FREE per booking, subsequent children full fare` + }) + @ApiBody({ + type: CreateBookingDto, + examples: { + ONE_WAY: { + summary: 'ONE_WAY — single direct journey', + value: { + passengerId: 'passenger-uuid', + scheduleId: 'schedule-uuid', + holdId: 'hold-uuid', + originStationId: 'station-uuid', + destinationStationId: 'station-uuid', + seatClassId: 'seat-class-uuid', + bookingType: 'ONE_WAY', + displayCurrency: 'ETB', + passengers: [{ + seatId: 'seat-uuid', + passengerName: 'Abebe Kebede', + dateOfBirth: '1990-05-15', + idDocumentType: 'NATIONAL_ID', + idDocumentNumber: 'ET123456789', + nationality: 'Ethiopian', + }], + }, + }, + ROUND_TRIP: { + summary: 'ROUND_TRIP — outbound + return, single PNR', + value: { + passengerId: 'passenger-uuid', + scheduleId: 'outbound-schedule-uuid', + holdId: 'outbound-hold-uuid', + originStationId: 'addis-station-uuid', + destinationStationId: 'djibouti-station-uuid', + seatClassId: 'seat-class-uuid', + bookingType: 'ROUND_TRIP', + returnScheduleId: 'return-schedule-uuid', + returnHoldId: 'return-hold-uuid', + returnOriginStationId: 'djibouti-station-uuid', + returnDestinationStationId: 'addis-station-uuid', + returnSeatClassId: 'seat-class-uuid', + displayCurrency: 'ETB', + passengers: [{ + seatId: 'outbound-seat-uuid', + returnSeatId: 'return-seat-uuid', + passengerName: 'Abebe Kebede', + dateOfBirth: '1990-05-15', + idDocumentType: 'NATIONAL_ID', + idDocumentNumber: 'ET123456789', + nationality: 'Ethiopian', + }], + }, + }, + TRANSIT: { + summary: 'TRANSIT — connecting train, single PNR', + value: { + passengerId: 'passenger-uuid', + scheduleId: 'leg1-schedule-uuid', + holdId: 'leg1-hold-uuid', + originStationId: 'addis-station-uuid', + destinationStationId: 'diredawa-station-uuid', + seatClassId: 'seat-class-uuid', + bookingType: 'TRANSIT', + leg2ScheduleId: 'leg2-schedule-uuid', + leg2HoldId: 'leg2-hold-uuid', + transitStationId: 'diredawa-station-uuid', + leg2DestinationStationId: 'djibouti-station-uuid', + displayCurrency: 'ETB', + passengers: [{ + seatId: 'leg1-seat-uuid', + leg2SeatId: 'leg2-seat-uuid', + passengerName: 'Abebe Kebede', + dateOfBirth: '1990-05-15', + idDocumentType: 'NATIONAL_ID', + idDocumentNumber: 'ET123456789', + nationality: 'Ethiopian', + }], + }, + }, + ROUND_TRIP_TRANSIT: { + summary: 'ROUND_TRIP_TRANSIT — round trip via connecting trains, 4 holds', + value: { + passengerId: 'passenger-uuid', + scheduleId: 'ob-leg1-schedule-uuid', + holdId: 'ob-leg1-hold-uuid', + originStationId: 'addis-station-uuid', + destinationStationId: 'diredawa-station-uuid', + seatClassId: 'seat-class-uuid', + bookingType: 'ROUND_TRIP_TRANSIT', + leg2ScheduleId: 'ob-leg2-schedule-uuid', + leg2HoldId: 'ob-leg2-hold-uuid', + transitStationId: 'diredawa-station-uuid', + leg2DestinationStationId: 'djibouti-station-uuid', + returnScheduleId: 'ret-leg1-schedule-uuid', + returnHoldId: 'ret-leg1-hold-uuid', + returnOriginStationId: 'djibouti-station-uuid', + returnDestinationStationId: 'diredawa-station-uuid', + returnLeg2ScheduleId: 'ret-leg2-schedule-uuid', + returnLeg2HoldId: 'ret-leg2-hold-uuid', + returnTransitStationId: 'diredawa-station-uuid', + returnLeg2DestinationStationId: 'addis-station-uuid', + displayCurrency: 'ETB', + passengers: [{ + seatId: 'ob-leg1-seat-uuid', + leg2SeatId: 'ob-leg2-seat-uuid', + returnSeatId: 'ret-leg1-seat-uuid', + returnLeg2SeatId: 'ret-leg2-seat-uuid', + passengerName: 'Abebe Kebede', + dateOfBirth: '1990-05-15', + idDocumentType: 'NATIONAL_ID', + idDocumentNumber: 'ET123456789', + nationality: 'Ethiopian', + }], + }, + }, + }, }) @ApiResponse({ status: 201, description: 'Booking created with fare breakdown' }) - @ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' }) - @ApiResponse({ status: 404, description: 'Trip or seat hold not found' }) + @ApiResponse({ status: 400, description: 'Missing required fields for bookingType, or Verifayda verification failed' }) + @ApiResponse({ status: 404, description: 'Schedule or seat hold not found' }) create(@Body() dto: CreateBookingDto) { return this.service.create(dto); } diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index a5ebf717c..52603e776 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -4,8 +4,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Currency, IdDocumentType } from '@prisma/client'; export class PassengerInputDto { - @ApiProperty() @IsString() seatId: string; - @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Seat ID on leg-2 schedule' }) @IsOptional() @IsString() leg2SeatId?: string; + @ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' }) @IsString() seatId: string; + @ApiPropertyOptional({ example: 'leg2-seat-uuid', description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' }) @IsOptional() @IsString() leg2SeatId?: string; + @ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID' }) @IsOptional() @IsString() returnSeatId?: string; + @ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string; @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first free), Age ≥5 = ADULT (full fare)' }) @IsDateString() dateOfBirth: string; @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; @@ -76,16 +78,16 @@ export class CreateBookingDto { @ApiProperty({ description: 'Passenger ID' }) @IsString() passengerId: string; - @ApiProperty({ description: 'Outbound schedule ID' }) + @ApiProperty({ description: 'Outbound / leg-1 schedule ID' }) @IsString() scheduleId: string; - @ApiProperty({ description: 'Outbound seat hold ID' }) + @ApiProperty({ description: 'Outbound / leg-1 seat hold ID' }) @IsString() holdId: string; - @ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID (must match the hold)' }) + @ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID' }) @IsString() originStationId: string; - @ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID (must match the hold)' }) + @ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID' }) @IsString() destinationStationId: string; @ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' }) @@ -94,14 +96,32 @@ export class CreateBookingDto { @ApiProperty({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'], - description: `Booking type:\n\n**ONE_WAY:** Single journey\n\n**ROUND_TRIP:** Outbound + return, single PNR\n\n**TRANSIT:** Single journey via connecting train, single PNR, single ticket\n\n**ROUND_TRIP_TRANSIT:** Round trip where one or both directions use a connecting train`, + description: `Booking type: + +**ONE_WAY:** Single direct journey — needs: scheduleId, holdId. Passenger: seatId. + +**ROUND_TRIP:** Outbound + return, single PNR — needs above + returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId. Passenger: seatId + returnSeatId. + +**TRANSIT:** Single journey via connecting train, single PNR — needs above + leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId. Passenger: seatId + leg2SeatId. + +**ROUND_TRIP_TRANSIT:** Round trip via connecting trains — needs all 4 hold sets + all station fields. Passenger: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId.`, default: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string; @ApiProperty({ type: [PassengerInputDto], - description: `Passenger array - type depends on bookingType:\n\n**For ONE_WAY:** PassengerInputDto[]\n- Each passenger has: seatId, passengerName, dateOfBirth, etc.\n\n**For ROUND_TRIP:** RoundTripPassengerDto[]\n- Each passenger has: outboundSeatId, returnSeatId, passengerName, dateOfBirth, etc.\n\n**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare` + description: `Passenger array — required seat fields vary by bookingType: + +**ONE_WAY:** { seatId, passengerName, dateOfBirth, idDocumentType, … } + +**ROUND_TRIP:** { seatId (outbound leg-1), returnSeatId (return leg-1), passengerName, … } + +**TRANSIT:** { seatId (leg-1), leg2SeatId (leg-2), passengerName, … } + +**ROUND_TRIP_TRANSIT:** { seatId, leg2SeatId, returnSeatId, returnLeg2SeatId, passengerName, … } + +**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare.` }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[]; diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts index ba4af04b4..d3d1e206d 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts @@ -4,16 +4,16 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Currency, IdDocumentType } from '@prisma/client'; export class GuestPassengerDto { - @ApiProperty({ example: 'seat-id-uuid', description: 'Outbound seat ID (or only seat for ONE_WAY)' }) + @ApiProperty({ example: 'seat-uuid', description: 'Outbound / leg-1 seat ID (all booking types)' }) @IsString() seatId: string; - @ApiPropertyOptional({ example: 'seat-id-uuid', description: 'Return seat ID (ROUND_TRIP / ROUND_TRIP_TRANSIT outbound leg-1)' }) + @ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID. Required for ROUND_TRIP and ROUND_TRIP_TRANSIT.' }) @IsOptional() @IsString() returnSeatId?: string; - @ApiPropertyOptional({ example: 'seat-id-uuid', description: 'Leg-2 seat ID (TRANSIT / ROUND_TRIP_TRANSIT outbound leg-2)' }) + @ApiPropertyOptional({ example: 'leg2-seat-uuid', description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID. Required for TRANSIT and ROUND_TRIP_TRANSIT.' }) @IsOptional() @IsString() leg2SeatId?: string; - @ApiPropertyOptional({ example: 'seat-id-uuid', description: 'ROUND_TRIP_TRANSIT: return journey leg-2 seat ID' }) + @ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID. Required for ROUND_TRIP_TRANSIT.' }) @IsOptional() @IsString() returnLeg2SeatId?: string; @ApiProperty({ example: 'Abebe Kebede' }) @@ -45,7 +45,16 @@ export class GuestPassengerDto { } export class CreateGuestBookingDto { - @ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'], default: 'ONE_WAY' }) + @ApiPropertyOptional({ + example: 'ONE_WAY', + enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'], + default: 'ONE_WAY', + description: `Booking type: +**ONE_WAY:** scheduleId + holdId. Passenger: seatId. +**ROUND_TRIP:** above + returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId. Passenger: seatId + returnSeatId. +**TRANSIT:** above + leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId. Passenger: seatId + leg2SeatId. +**ROUND_TRIP_TRANSIT:** all 4 hold sets + all station fields. Passenger: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId.` + }) @IsOptional() @IsString() bookingType?: 'ONE_WAY' | 'ROUND_TRIP' | 'TRANSIT' | 'ROUND_TRIP_TRANSIT'; @ApiProperty({ example: 'schedule-uuid', description: 'Outbound / leg-1 schedule UUID' }) @@ -108,7 +117,14 @@ export class CreateGuestBookingDto { @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 seat class UUID' }) @IsOptional() @IsString() returnLeg2SeatClassId?: string; - @ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. For ROUND_TRIP each passenger must include returnSeatId.' }) + @ApiProperty({ + type: [GuestPassengerDto], + description: `Passenger array. Required seat fields vary by bookingType: +- ONE_WAY: seatId +- ROUND_TRIP: seatId + returnSeatId +- TRANSIT: seatId + leg2SeatId +- ROUND_TRIP_TRANSIT: seatId + leg2SeatId + returnSeatId + returnLeg2SeatId` + }) @IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[]; @ApiPropertyOptional({ example: 'WEEKEND15' }) 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 d3e75d7d9..d51410dff 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 @@ -285,6 +285,7 @@ export default function ReviewPage() { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), + ...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }), passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', @@ -323,6 +324,7 @@ export default function ReviewPage() { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; return { seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), + ...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }), passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', From b3904caacbe6079f548bac0d90961862fdc96331 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 17 Jun 2026 20:23:32 +0300 Subject: [PATCH 2/3] Refactor expireHolds to optimize expired seat hold deletion --- .../src/modules/seats/seats.service.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) 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 88f9666bd..21060b595 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -547,16 +547,14 @@ export class SeatsService { @Cron(CronExpression.EVERY_MINUTE) async expireHolds() { - const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } }); - for (const hold of expired) { + const now = new Date(); + const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } }); + if (expired.length === 0) return; + + const expiredIds = expired.map(h => h.id); + for (const hold of expired) { await this.releaseSeats(hold.seatIds); - try { - await this.prisma.seatHold.delete({ where: { id: hold.id } }); - } catch (err) { - if (err instanceof Error && !err.message.includes('P2025')) { - throw err; - } - } } + await this.prisma.seatHold.deleteMany({ where: { id: { in: expiredIds } } }); } } From a25ee133395eea6cc845f54a9cdb6f448b54502d Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 17 Jun 2026 22:23:24 +0300 Subject: [PATCH 3/3] Updated to address requests and UAT feedback --- .../src/modules/bookings/bookings.service.ts | 4 +- .../modules/passengers/passengers.service.ts | 5 +- .../src/modules/tickets/tickets.controller.ts | 22 +-- .../src/modules/tickets/tickets.service.ts | 71 +++++--- .../backoffice/src/app/bookings/page.tsx | 163 ++++++++++++------ .../backoffice/src/app/passengers/page.tsx | 85 +++++++-- .../backoffice/src/app/payments/page.tsx | 158 ++++++++++++++--- .../backoffice/src/app/seats/page.tsx | 2 +- .../backoffice/src/app/tickets/page.tsx | 151 ++++++++-------- 9 files changed, 450 insertions(+), 211 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 3e0d6a61a..b40a5c0ee 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -202,7 +202,7 @@ export class BookingsService { } if (status) where.status = status; - if (returnLegStatus) where.returnLegStatus = returnLegStatus; + if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus; const [items, total] = await Promise.all([ this.prisma.booking.findMany({ @@ -233,6 +233,8 @@ export class BookingsService { contactPhone: booking.contactPhone, bookingType: booking.bookingType, returnLegStatus: (booking as any).returnLegStatus ?? null, + adultCount: booking.adultCount, + childCount: booking.childCount, createdAt: booking.createdAt, passenger: booking.passenger?.user, schedule: { diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 12d9e5626..7171b56ad 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -21,10 +21,11 @@ export class PassengersService { const { search, verified, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - const where: any = {}; + const where: any = { user: { role: 'PASSENGER' } }; if (search) { where.user = { + ...where.user, OR: [ { fullName: { contains: search, mode: 'insensitive' } }, { email: { contains: search, mode: 'insensitive' } }, @@ -68,7 +69,7 @@ export class PassengersService { userId: passenger.userId, fullName: user.fullName, email: user.email, - phone: user.phone, + phone: user.phone?.startsWith('+guest-') ? null : user.phone, nationalId: user.nationalId, nationality: user.nationality, dateOfBirth: user.dateOfBirth ?? null, 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 568801213..d4d68ad1f 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -31,18 +31,27 @@ export class TicketsController { @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'List all tickets with optional filters' }) @ApiQuery({ name: 'search', required: false }) - @ApiQuery({ name: 'status', required: false, description: 'ACTIVE | USED | CANCELLED' }) + @ApiQuery({ name: 'status', required: false }) + @ApiQuery({ name: 'originStationId', required: false }) + @ApiQuery({ name: 'destinationStationId', required: false }) + @ApiQuery({ name: 'arrivalDate', required: false }) @ApiQuery({ name: 'skip', required: false }) @ApiQuery({ name: 'take', required: false }) listTickets( @Query('search') search?: string, @Query('status') status?: string, + @Query('originStationId') originStationId?: string, + @Query('destinationStationId') destinationStationId?: string, + @Query('arrivalDate') arrivalDate?: string, @Query('skip') skip?: string, @Query('take') take?: string, ) { return this.service.listTickets({ search, status, + originStationId, + destinationStationId, + arrivalDate, skip: skip ? parseInt(skip) : 0, take: take ? parseInt(take) : 50, }); @@ -60,17 +69,8 @@ export class TicketsController { } @Get(':bookingRef') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') @ApiOperation({ - summary: 'Get ticket with QR code and passenger details', - description: `Returns ticket information including: -- QR code for gate scanning -- Barcode for offline validation -- Passenger details (name, age category, nationality) -- Journey details (origin, destination, seat, coach) -- Fare breakdown with currency -- PDF download link` + summary: 'Get ticket with QR code and passenger details (public)', }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); 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 83abf0cc2..2c57a59e3 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -14,7 +14,7 @@ interface OfflineValidation { export class TicketsService { constructor(private prisma: PrismaService) {} - async listTickets(filters: { search?: string; status?: string; skip: number; take: number }) { + async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) { const where: any = {}; if (filters.search) { where.OR = [ @@ -24,7 +24,19 @@ export class TicketsService { ]; } if (filters.status) { - where.booking = { status: filters.status }; + where.booking = { ...where.booking, status: filters.status }; + } + if (filters.originStationId) { + where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } }; + } + if (filters.destinationStationId) { + where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } }; + } + if (filters.arrivalDate) { + const start = new Date(filters.arrivalDate); + const end = new Date(filters.arrivalDate); + end.setDate(end.getDate() + 1); + where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } }; } const tickets = await this.prisma.ticket.findMany({ where, @@ -32,7 +44,7 @@ export class TicketsService { booking: { include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, - seats: { include: { seat: { include: { coach: true } } } }, + seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, passenger: { include: { user: true } }, }, }, @@ -235,7 +247,16 @@ export class TicketsService { }; } - async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: string) { + async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) { + // Accept either a ticket UUID or a bookingRef + let bookingRef = ticketIdOrRef; + const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ticketIdOrRef); + if (isUuid) { + const ticket = await this.prisma.ticket.findUnique({ where: { id: ticketIdOrRef }, select: { bookingRef: true } }); + if (!ticket) throw new NotFoundException('Ticket not found'); + bookingRef = ticket.bookingRef; + } + const resolvedValidatorId = validatorId || 'BACKOFFICE'; const booking = await this.prisma.booking.findUnique({ where: { bookingRef } }); if (!booking) throw new NotFoundException('Booking not found'); const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } }); @@ -247,11 +268,10 @@ export class TicketsService { // ── ONE_WAY / TRANSIT (single scan) ─────────────────────────────────── if (type === 'ONE_WAY') { if (ticket.validatedAt) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } }); - throw new BadRequestException('Ticket already validated'); + return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true }; } - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } }); + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); return { validated: true, ticketId: ticket.id, validatedAt: now }; } @@ -264,28 +284,32 @@ export class TicketsService { const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); const alreadyValidated = logs.some(l => l.leg === resolvedLeg); if (alreadyValidated) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); throw new BadRequestException(`${resolvedLeg} already validated`); } - if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } // ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ──────────────────────── if (type === 'ROUND_TRIP') { - const resolvedLeg = (leg ?? 'OUTBOUND').toUpperCase(); + let resolvedLeg = (leg ?? '').toUpperCase(); + // Auto-detect next unused leg when called from backoffice without a leg param + if (!resolvedLeg) { + resolvedLeg = !(booking as any).outboundBoardedAt ? 'OUTBOUND' : 'RETURN'; + } const bookingData: Record = {}; if (resolvedLeg === 'OUTBOUND') { if ((booking as any).outboundBoardedAt) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any }); throw new BadRequestException('Outbound leg already used'); } bookingData.outboundBoardedAt = now; - if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); + if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); } else if (resolvedLeg === 'RETURN') { if ((booking as any).returnBoardedAt) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any }); throw new BadRequestException('Return leg already used'); } bookingData.returnBoardedAt = now; @@ -298,7 +322,7 @@ export class TicketsService { else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY'; await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } @@ -311,7 +335,7 @@ export class TicketsService { } const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); if (logs.some(l => l.leg === resolvedLeg)) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); throw new BadRequestException(`${resolvedLeg} already validated`); } const bookingData: Record = {}; @@ -327,18 +351,17 @@ export class TicketsService { else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY'; if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); - if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } // Fallback for unknown booking types — single scan if (ticket.validatedAt) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } }); - throw new BadRequestException('Ticket already validated'); + return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true }; } - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } }); + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); return { validated: true, ticketId: ticket.id, validatedAt: now }; } @@ -354,7 +377,7 @@ export class TicketsService { where: { scheduleId: tripId, status: 'CONFIRMED' }, include: { ticket: true, - seats: { include: { seat: { include: { coach: true } } } }, + seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, passenger: { include: { user: true } }, }, }); 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 36fd20281..7b43fb677 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -24,6 +24,19 @@ export default function BookingsPage() { const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [bookingToDelete, setBookingToDelete] = useState(null); const [successMessage, setSuccessMessage] = useState(''); + const [exportModalOpen, setExportModalOpen] = useState(false); + const [exportDateFrom, setExportDateFrom] = useState(''); + const [exportDateTo, setExportDateTo] = useState(''); + const [exportColumns, setExportColumns] = useState>({ + bookingRef: true, + passenger: true, + status: true, + bookingType: false, + passengerCount: false, + totalMinor: true, + paymentStatus: true, + createdAt: true, + }); const queryClient = useQueryClient(); @@ -80,22 +93,23 @@ export default function BookingsPage() { } }; - const handleExportBookings = async () => { - const selectedColumns = prompt( - 'Select columns to export (comma-separated):\n\n' + - 'Available: bookingRef, passenger, status, bookingType, passengerCount, totalMinor, paymentStatus, createdAt\n\n' + - 'Default: bookingRef, passenger, status, totalMinor, paymentStatus, createdAt', - 'bookingRef, passenger, status, totalMinor, paymentStatus, createdAt' - ); - - if (!selectedColumns) return; - - const cols = selectedColumns.split(',').map(c => c.trim()); + const confirmExport = () => { + const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); + if (cols.length === 0) { alert('Please select at least one column'); return; } + + const exportItems = (data?.items || []).filter((b: any) => { + if (!exportDateFrom && !exportDateTo) return true; + const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null; + if (exportDateFrom && (!d || d < exportDateFrom)) return false; + if (exportDateTo && (!d || d > exportDateTo)) return false; + return true; + }); + const csv = [ cols.join(','), - ...data?.items?.map((booking: any) => { + ...exportItems.map((booking: any) => { const values = cols.map(col => { - switch(col) { + switch (col) { case 'bookingRef': return booking.bookingRef; case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest'; case 'status': return booking.status; @@ -108,28 +122,29 @@ export default function BookingsPage() { } }); return values.map(v => `"${v}"`).join(','); - }) || [] + }), ].join('\n'); - + const blob = new Blob([csv], { type: 'text/csv' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`; a.click(); + setExportModalOpen(false); }; const columns = [ - { - key: 'bookingRef', + { + key: 'bookingRef', label: 'Reference', sortable: true, render: (booking: any) => ( {booking.bookingRef} ), }, - { - key: 'passenger', + { + key: 'passenger', label: 'Passenger', render: (booking: any) => (
@@ -138,32 +153,39 @@ export default function BookingsPage() {
), }, - { - key: 'bookingType', + { + key: 'bookingType', label: 'Type', sortable: true, render: (booking: any) => booking.bookingType || 'ONE_WAY', }, - { + { key: 'passengerCount', label: 'Passengers', - render: (booking: any) => `${(booking.adultCount || 0) + (booking.childCount || 0)}`, + render: (booking: any) => { + const adults = booking.adultCount || 0; + const children = booking.childCount || 0; + if (adults === 0 && children === 0) return '—'; + const parts = [`Adult: ${adults}`]; + if (children > 0) parts.push(`Child: ${children}`); + return parts.join(' / '); + }, }, - { - key: 'status', + { + key: 'status', label: 'Status', render: (booking: any) => ( {booking.status} ), }, - { - key: 'totalMinor', + { + key: 'totalMinor', label: 'Amount', sortable: true, render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency), }, - { - key: 'paymentStatus', + { + key: 'paymentStatus', label: 'Payment', render: (booking: any) => ( @@ -171,8 +193,8 @@ export default function BookingsPage() { ), }, - { - key: 'createdAt', + { + key: 'createdAt', label: 'Created', sortable: true, render: (booking: any) => formatDateTime(booking.createdAt), @@ -208,7 +230,7 @@ export default function BookingsPage() {

Bookings

Manage all passenger bookings

- Export + setExportModalOpen(true)}>Export
@@ -253,7 +275,7 @@ export default function BookingsPage() { loading={isLoading} emptyMessage="No bookings found" /> - + {data?.meta && ( {/* Booking Details Modal */} - setSelectedBooking(null)} - title="Booking Details" - size="xl" - > + setSelectedBooking(null)} title="Booking Details" size="xl"> {selectedBooking && (
- {/* Booking Information */}
@@ -281,9 +297,7 @@ export default function BookingsPage() {
- - {selectedBooking.status} - + {selectedBooking.status}
@@ -298,7 +312,6 @@ export default function BookingsPage() {
- {/* Passenger Information */}

Passenger Information

@@ -323,7 +336,6 @@ export default function BookingsPage() {
- {/* Booking Details */}

Journey Details

@@ -348,7 +360,6 @@ export default function BookingsPage() {
- {/* Payment Information */}

Payment Information

@@ -377,7 +388,6 @@ export default function BookingsPage() {
- {/* Additional Information */}

Additional Information

@@ -393,12 +403,7 @@ export default function BookingsPage() {
- setSelectedBooking(null)} - > - Close - + setSelectedBooking(null)}>Close
)} @@ -407,10 +412,7 @@ export default function BookingsPage() { {/* Delete Confirmation Dialog */} { - setDeleteConfirmOpen(false); - setBookingToDelete(null); - }} + onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); }} onConfirm={handleConfirmDelete} title="Delete Booking" message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`} @@ -419,6 +421,53 @@ export default function BookingsPage() { isLoading={deleteMutation.isPending} isDanger={true} /> + + {/* Export Modal */} + setExportModalOpen(false)} title="Export Bookings" size="md"> +
+
+
+ + setExportDateFrom(e.target.value)} /> +
+
+ + setExportDateTo(e.target.value)} /> +
+
+ +
+

Select Columns

+
+ {[ + { key: 'bookingRef', label: 'Booking Reference' }, + { key: 'passenger', label: 'Passenger' }, + { key: 'status', label: 'Status' }, + { key: 'bookingType', label: 'Booking Type' }, + { key: 'passengerCount', label: 'Passenger Count' }, + { key: 'totalMinor', label: 'Amount' }, + { key: 'paymentStatus', label: 'Payment Status' }, + { key: 'createdAt', label: 'Created At' }, + ].map((col) => ( + + ))} +
+
+ +
+ setExportModalOpen(false)}>Cancel + Export CSV +
+
+
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index 2c8e50a0e..ab588b085 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -22,6 +22,12 @@ export default function PassengersPage() { }); const [selectedPassenger, setSelectedPassenger] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null }); + const [exportModalOpen, setExportModalOpen] = useState(false); + const [exportDateFrom, setExportDateFrom] = useState(''); + const [exportDateTo, setExportDateTo] = useState(''); + const [exportColumns, setExportColumns] = useState>({ + fullName: true, email: true, phone: true, gender: true, nationality: true, verified: true, + }); const queryClient = useQueryClient(); @@ -52,22 +58,23 @@ export default function PassengersPage() { console.error('Passengers API Error:', error); } - const handleExportPassengers = async () => { - const selectedColumns = prompt( - 'Select columns to export (comma-separated):\n\n' + - 'Available: fullName, email, phone, dateOfBirth, gender, nationality, verified\n\n' + - 'Default: fullName, email, phone, gender, nationality, verified', - 'fullName, email, phone, gender, nationality, verified' - ); - - if (!selectedColumns) return; - - const cols = selectedColumns.split(',').map(c => c.trim()); + const confirmExportPassengers = () => { + const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); + if (cols.length === 0) { alert('Please select at least one column'); return; } + + const exportItems = (data?.items || []).filter((p: any) => { + if (!exportDateFrom && !exportDateTo) return true; + const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null; + if (exportDateFrom && (!d || d < exportDateFrom)) return false; + if (exportDateTo && (!d || d > exportDateTo)) return false; + return true; + }); + const csv = [ cols.join(','), - ...data?.items?.map((passenger: any) => { + ...exportItems.map((passenger: any) => { const values = cols.map(col => { - switch(col) { + switch (col) { case 'fullName': return passenger.fullName; case 'email': return passenger.email || ''; case 'phone': return passenger.phone || ''; @@ -79,15 +86,16 @@ export default function PassengersPage() { } }); return values.map(v => `"${v}"`).join(','); - }) || [] + }), ].join('\n'); - + const blob = new Blob([csv], { type: 'text/csv' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`; a.click(); + setExportModalOpen(false); }; const columns = [ @@ -160,7 +168,7 @@ export default function PassengersPage() {

Manage passenger profiles and verification

- Export + setExportModalOpen(true)}>Export
@@ -381,6 +389,51 @@ export default function PassengersPage() {
)} + {/* Export Modal */} + setExportModalOpen(false)} title="Export Passengers" size="md"> +
+
+
+ + setExportDateFrom(e.target.value)} /> +
+
+ + setExportDateTo(e.target.value)} /> +
+
+ +
+

Select Columns

+
+ {[ + { key: 'fullName', label: 'Full Name' }, + { key: 'email', label: 'Email' }, + { key: 'phone', label: 'Phone' }, + { key: 'dateOfBirth', label: 'Date of Birth' }, + { key: 'gender', label: 'Gender' }, + { key: 'nationality', label: 'Nationality' }, + { key: 'verified', label: 'Verified' }, + ].map((col) => ( + + ))} +
+
+ +
+ setExportModalOpen(false)}>Cancel + Export CSV +
+
+
); } 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 69ef22859..2c1c8bcf5 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx @@ -6,27 +6,76 @@ import { Download } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; import { paymentsApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; export default function PaymentsPage() { const [filters, setFilters] = useState({ search: '', status: '', method: '' }); + const [exportModalOpen, setExportModalOpen] = useState(false); + const [exportDateFrom, setExportDateFrom] = useState(''); + const [exportDateTo, setExportDateTo] = useState(''); + const [exportColumns, setExportColumns] = useState>({ + reference: true, booking: true, amount: true, method: true, status: true, createdAt: true, + }); const { data, isLoading } = useQuery({ queryKey: ['payments', filters], - queryFn: () => paymentsApi.getAll(filters), + queryFn: () => paymentsApi.getAll({ + search: filters.search || undefined, + status: filters.status || undefined, + method: filters.method || undefined, + }), }); - 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: '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) }, - ]; + const confirmExport = () => { + const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); + if (cols.length === 0) { alert('Please select at least one column'); return; } - const actions: any[] = []; + const items = ((data as any)?.items || (Array.isArray(data) ? data : [])) as any[]; + const exportItems = items.filter((p: any) => { + if (!exportDateFrom && !exportDateTo) return true; + const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null; + if (exportDateFrom && (!d || d < exportDateFrom)) return false; + if (exportDateTo && (!d || d > exportDateTo)) return false; + return true; + }); + + const csv = [ + cols.join(','), + ...exportItems.map((payment: any) => { + const values = cols.map(col => { + switch (col) { + 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 'method': return payment.method || ''; + case 'status': return payment.status || ''; + case 'createdAt': return payment.createdAt || ''; + default: return ''; + } + }); + return values.map(v => `"${v}"`).join(','); + }), + ].join('\n'); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `payments-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + setExportModalOpen(false); + }; + + 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: '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) }, + ]; return (
@@ -35,36 +84,91 @@ export default function PaymentsPage() {

Payments

Manage payment transactions and refunds

- Export + setExportModalOpen(true)}>Export
- -
- - setFilters({ ...filters, search: e.target.value })} /> -
-
- - -
- +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+
+ + +
+
+ + +
+ + {/* Export Modal */} + setExportModalOpen(false)} title="Export Payments" size="md"> +
+
+
+ + setExportDateFrom(e.target.value)} /> +
+
+ + setExportDateTo(e.target.value)} /> +
+
+ +
+

Select Columns

+
+ {[ + { key: 'reference', label: 'Reference' }, + { key: 'booking', label: 'Booking Reference' }, + { key: 'amount', label: 'Amount' }, + { key: 'method', label: 'Payment Method' }, + { key: 'status', label: 'Status' }, + { key: 'createdAt', label: 'Created At' }, + ].map((col) => ( + + ))} +
+
+ +
+ setExportModalOpen(false)}>Cancel + Export CSV +
+
+
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index 78879e96e..cb26bd431 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -228,7 +228,7 @@ export default function SeatsPage() { const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); - if (isBedCoach && hasBedPositionData) { + if (isBedCoach) { const arrangement = parseSeatArrangement(coach.seatArrangement); const seatsPerRow = arrangement[0] + (arrangement[1] || 0); const allSeatsForLayout = [...validSeats, ...removedSeats]; diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index 6a0364473..39bcdb64a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -9,11 +9,11 @@ import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import Modal from '@/components/ui/Modal'; -import { ticketsApi, apiClient, schedulesApi, stationsApi } from '@/lib/api'; +import { ticketsApi, apiClient, stationsApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; export default function TicketsPage() { - const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', tripDate: '' }); + const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' }); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [ticketToDelete, setTicketToDelete] = useState(null); const [boardConfirmOpen, setBoardConfirmOpen] = useState(false); @@ -22,6 +22,8 @@ export default function TicketsPage() { const [detailsModalOpen, setDetailsModalOpen] = useState(false); const [selectedTicket, setSelectedTicket] = useState(null); const [exportModalOpen, setExportModalOpen] = useState(false); + const [exportDateFrom, setExportDateFrom] = useState(''); + const [exportDateTo, setExportDateTo] = useState(''); const [selectedColumns, setSelectedColumns] = useState>({ ticketNumber: true, booking: true, @@ -35,7 +37,15 @@ export default function TicketsPage() { const { data, isLoading, error } = useQuery({ queryKey: ['tickets', filters], - queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }), + queryFn: () => ticketsApi.getAll({ + search: filters.search || undefined, + status: filters.status || undefined, + originStationId: filters.originStationId || undefined, + destinationStationId: filters.destinationStationId || undefined, + arrivalDate: filters.arrivalDate || undefined, + skip: 0, + take: 50, + }), }); const { data: stationsData } = useQuery({ @@ -94,25 +104,31 @@ export default function TicketsPage() { } }; - const handleExportTickets = async () => { - setExportModalOpen(true); - }; - const confirmExport = () => { const cols = Object.entries(selectedColumns) .filter(([, selected]) => selected) .map(([col]) => col); - + if (cols.length === 0) { alert('Please select at least one column'); return; } + const exportItems = (data?.items || []).filter((ticket: any) => { + if (!exportDateFrom && !exportDateTo) return true; + const d = ticket.schedule?.arrivalAt + ? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0] + : null; + if (exportDateFrom && (!d || d < exportDateFrom)) return false; + if (exportDateTo && (!d || d > exportDateTo)) return false; + return true; + }); + const csv = [ cols.join(','), - ...data?.items?.map((ticket: any) => { + ...exportItems.map((ticket: any) => { const values = cols.map(col => { - switch(col) { + switch (col) { case 'ticketNumber': return ticket.ticketNumber || ''; case 'booking': return ticket.booking?.bookingRef || ''; case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`; @@ -126,9 +142,9 @@ export default function TicketsPage() { } }); return values.map(v => `"${v}"`).join(','); - }) || [] + }), ].join('\n'); - + const blob = new Blob([csv], { type: 'text/csv' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); @@ -175,12 +191,12 @@ export default function TicketsPage() { }, { key: 'seat', - label: 'Seat', + label: 'Seat/Bed', sortable: true, render: (ticket: any) => (
-
Coach {ticket.seat?.coach?.number || 'N/A'} - Seat {ticket.seat?.seatNumber || 'N/A'}
-
{ticket.seat?.coach?.coachType?.name || 'N/A'}
+
{ticket.seat?.coach?.number || 'N/A'} - {ticket.seat?.seatNumber || 'N/A'}
+
{ticket.seat?.coach?.coachType?.type || 'N/A'}
), }, @@ -264,7 +280,7 @@ export default function TicketsPage() {

Tickets

Manage tickets and validations

- Export + setExportModalOpen(true)}>Export
{/* Filters */} @@ -317,12 +333,12 @@ export default function TicketsPage() {
- + setFilters({ ...filters, tripDate: e.target.value })} + value={filters.arrivalDate} + onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })} />
@@ -353,10 +369,7 @@ export default function TicketsPage() { {/* Board Confirmation Dialog */} { - setBoardConfirmOpen(false); - setTicketToBoard(null); - }} + onClose={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }} onConfirm={handleConfirmBoard} title="Board Ticket" message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`} @@ -368,10 +381,7 @@ export default function TicketsPage() { {/* Delete Confirmation Dialog */} { - setDeleteConfirmOpen(false); - setTicketToDelete(null); - }} + onClose={() => { setDeleteConfirmOpen(false); setTicketToDelete(null); }} onConfirm={handleConfirmDelete} title="Delete Ticket" message={`Are you sure you want to permanently delete ticket ${ticketToDelete?.ticketNumber}? This action cannot be undone.`} @@ -384,10 +394,7 @@ export default function TicketsPage() { {/* Ticket Details Modal */} { - setDetailsModalOpen(false); - setSelectedTicket(null); - }} + onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }} title="Ticket Details" size="lg" > @@ -488,13 +495,7 @@ export default function TicketsPage() { )}
- { - setDetailsModalOpen(false); - setSelectedTicket(null); - }} - > + { setDetailsModalOpen(false); setSelectedTicket(null); }}> Close
@@ -502,49 +503,55 @@ export default function TicketsPage() { )}
- {/* Export Columns Modal */} + {/* Export Modal */} setExportModalOpen(false)} - title="Export Tickets - Select Columns" + title="Export Tickets" size="md" >
-

Select which columns to include in the export

- -
- {[ - { key: 'ticketNumber', label: 'Ticket Number' }, - { key: 'booking', label: 'Booking Reference & Passenger' }, - { key: 'trip', label: 'Trip (Origin → Destination)' }, - { key: 'coach', label: 'Coach Number' }, - { key: 'seat', label: 'Seat Number' }, - { key: 'seatClass', label: 'Seat Class' }, - { key: 'amount', label: 'Amount' }, - { key: 'status', label: 'Status' }, - { key: 'boarded', label: 'Boarded Status' }, - ].map((col) => ( - - ))} +
+
+ + setExportDateFrom(e.target.value)} /> +
+
+ + setExportDateTo(e.target.value)} /> +
+
+ +
+

Select Columns

+
+ {[ + { key: 'ticketNumber', label: 'Ticket Number' }, + { key: 'booking', label: 'Booking Reference & Passenger' }, + { key: 'trip', label: 'Trip (Origin → Destination)' }, + { key: 'coach', label: 'Coach Number' }, + { key: 'seat', label: 'Seat Number' }, + { key: 'seatClass', label: 'Seat Class' }, + { key: 'amount', label: 'Amount' }, + { key: 'status', label: 'Status' }, + { key: 'boarded', label: 'Boarded Status' }, + ].map((col) => ( + + ))} +
- setExportModalOpen(false)}> - Cancel - - - Export CSV - + setExportModalOpen(false)}>Cancel + Export CSV