From 24ee3b88f524cc32285b1142522d34c992c44e30 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 17 Jun 2026 13:42:20 +0300 Subject: [PATCH] Round trip booking and additional enhancements --- apps/edr-passenger-api/.env.example | 7 + .../migration.sql | 18 ++ apps/edr-passenger-api/prisma/schema.prisma | 20 +- apps/edr-passenger-api/src/app.module.ts | 2 +- apps/edr-passenger-api/src/main.ts | 197 +++++++++--------- .../modules/bookings/bookings.controller.ts | 7 +- .../src/modules/bookings/bookings.service.ts | 54 ++++- .../notifications/email-client.service.ts | 9 +- .../notifications/notifications.module.ts | 105 ++++++---- .../notifications/sms-client.service.ts | 15 +- .../src/modules/schedules/schedules.dto.ts | 4 + .../modules/schedules/schedules.service.ts | 9 + .../src/modules/seats/seats.service.spec.ts | 2 +- .../src/modules/tickets/tickets.controller.ts | 50 ++++- .../src/modules/tickets/tickets.service.ts | 99 ++++++++- .../backoffice/src/app/bookings/page.tsx | 2 +- .../backoffice/src/app/coaches/page.tsx | 19 +- .../backoffice/src/app/routes/page.tsx | 23 +- .../backoffice/src/app/schedules/page.tsx | 4 +- .../backoffice/src/app/seats/page.tsx | 7 +- .../backoffice/src/app/tickets/page.tsx | 46 +++- .../src/components/ui/ConfirmDialog.tsx | 8 + 22 files changed, 502 insertions(+), 205 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index b2500160d..482a0df2c 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -130,6 +130,13 @@ FAYDA_SESSION_TTL_MINUTES=10 GITHUB_PACKAGE_TOKEN= +# --- Notification broker (RabbitMQ) ----------------------------------------------------------------- +# Set RABBITMQ_ENABLED=false to skip connection entirely (dev without a local broker). +RABBITMQ_ENABLED=false +RABBITMQ_URL=amqp://localhost:5672 +EMAIL_QUEUE=email_queue +SMS_QUEUE=sms_queue + # --- Payment event consumer (RabbitMQ) ------------------------------------------------------- # Consumes payment.succeeded / payment.failed events from the payment microservice. Separate # from any RABBITMQ_URL used by the IAM/notification modules so the two connections are diff --git a/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql new file mode 100644 index 000000000..e93fb8320 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260625_add_return_leg_status/migration.sql @@ -0,0 +1,18 @@ +-- CreateEnum +CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'); + +-- AlterTable: add return leg tracking columns to Booking +ALTER TABLE "passenger"."Booking" + ADD COLUMN "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE', + ADD COLUMN "outboundBoardedAt" TIMESTAMP(3), + ADD COLUMN "returnBoardedAt" TIMESTAMP(3); + +-- Set NEITHER_USED for existing confirmed round-trip bookings +UPDATE "passenger"."Booking" +SET "returnLegStatus" = 'NEITHER_USED' +WHERE "bookingType" = 'ROUND_TRIP' + AND "status" IN ('CONFIRMED', 'COMPLETED'); + +-- AlterTable: add leg column to GateValidationLog +ALTER TABLE "passenger"."GateValidationLog" + ADD COLUMN "leg" TEXT; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index b2259a448..3c5d18f3a 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -115,6 +115,16 @@ enum BookingStatus { @@schema("passenger") } +enum ReturnLegStatus { + NOT_APPLICABLE // one-way booking + BOTH_USED // passenger used both legs + OUTBOUND_ONLY // return leg not used (no-show on return) + INBOUND_ONLY // outbound leg not used, return leg used + NEITHER_USED // neither leg boarded yet + + @@schema("passenger") +} + enum PaymentRegion { ETHIOPIA DJIBOUTI @@ -364,7 +374,8 @@ model TrainSchedule { originStation Station @relation("OriginTrips", fields: [originStationId], references: [id]) destinationStation Station @relation("DestinationTrips", fields: [destinationStationId], references: [id]) coachAssignments CoachAssignment[] - bookings Booking[] + bookings Booking[] @relation("OutboundSchedule") + returnBookings Booking[] @relation("ReturnSchedule") stopTimes TripStopTime[] liveStatus TripLiveStatus? menuItems MenuItem[] @@ -511,6 +522,9 @@ model Booking { returnDestinationStationId String? returnHoldId String? returnSeatClassId String? + returnLegStatus ReturnLegStatus @default(NOT_APPLICABLE) + outboundBoardedAt DateTime? + returnBoardedAt DateTime? contactEmail String? contactPhone String? userAgent String? @@ -520,7 +534,8 @@ model Booking { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt passenger Passenger @relation(fields: [passengerId], references: [id]) - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id]) + returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id]) seats BookingSeat[] paymentIntent PaymentIntent? ticket Ticket? @@ -1159,6 +1174,7 @@ model GateValidationLog { ticketId String validatorId String gateId String? + leg String? // 'OUTBOUND' | 'RETURN' — for round-trip tickets status String reason String? validatedAt DateTime @default(now()) diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 757432c3b..f0edd6cba 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -75,7 +75,7 @@ import { CurrenciesModule } from './modules/currencies/currencies.module'; PaymentsModule, TicketsModule, PassengersModule, - NotificationsModule, + NotificationsModule.register(), LoyaltyModule, WalletModule, PromosModule, diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index f1972b0e6..298e37c01 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -34,19 +34,23 @@ async function bootstrap() { ## Overview Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM. -## 🆕 Latest Updates -- **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 and boardedAt timestamps for complete audit trail +## 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). +- **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. ## Key Features -### 🎫 Booking Lifecycle +### Booking Lifecycle - Search trips with real-time availability -- Age-based passenger categorization (Adult ≥5 years, Child <5 years) +- Age-based passenger categorization (Adult 5+ years, Child under 5) - Nationality-based verification (Ethiopian Fayda, International Passport) - Passenger information collection with verification - Coach and seat selection with real-time availability @@ -55,130 +59,129 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m - Modify bookings (seat changes, passenger updates) - Cancel bookings with automatic refunds - Multi-segment journey support -- Cross-border journeys via Dire Dawa transit (Ethiopia → Djibouti) +- Cross-border journeys via Dire Dawa transit (Ethiopia to Djibouti) - Round-trip booking with return journey scheduling - 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: 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 -### 👤 Passenger Verification -1. **Ethiopian Nationals:** -- Automatic Fayda verification for adults (≥5 years) +### Passenger Verification +1. Ethiopian Nationals: +- Automatic Fayda verification for adults (5+ years) - Real-time national ID verification via government database - Retrieves verified passenger data (name, DOB, gender) - National IDs not stored (policy compliant) -2. **International Passengers:** +2. International Passengers: - Passport information collection - Manual verification for Djiboutian and other nationals - No government database verification required -### 💰 Age-Based Pricing -- **ADULT** (≥5 years): Pay 100% of base fare -- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100% +### Age-Based Pricing +- ADULT (5+ years): Pay 100% of base fare +- CHILD (under 5): First child travels FREE, subsequent children pay 100% - Automatic age calculation from date of birth -- Example: 2 adults + 3 children = 4× base fare (first child free) -- **NEW:** Premium charges and insurance fees per seat class -- **NEW:** Transparent fee breakdown in pricing calculations +- Example: 2 adults + 3 children = 4x base fare (first child free) +- NEW: Premium charges and insurance fees per seat class +- NEW: Transparent fee breakdown in pricing calculations -### 💳 Payment Integration -1. **Ethiopian Payment Methods:** -- **Telebirr** - Ethiopia's leading mobile money -- **CBE Birr** - Commercial Bank of Ethiopia +### Payment Integration +1. Ethiopian Payment Methods: Telebirr, CBE Birr +2. Djiboutian Payment Methods: Waafi +3. International Payment Methods: Card, Wallet -2. **Djiboutian Payment Methods:** -- **Waafi** - Djibouti's mobile money service - -3. **International Payment Methods:** -- **Card** - International card payments (Visa, Mastercard) -- **Wallet** - Internal wallet system - -### 🪑 Seat Management +### Seat Management - Real-time seat availability by coach and class - Seat holds with 15-minute expiry - Auto-assign seats with contiguous algorithm - Seat blocking for maintenance - Coach-level seat maps (ordered by sequence) - Class-based seating (Economy Regular, Economy Bed, VIP Bed) -- **NEW:** Sequence-based coach ordering for consistent display +- NEW: Sequence-based coach ordering for consistent display -### 🎟️ Ticketing +### Ticketing - QR code and barcode generation - PDF ticket generation - Gate validation with audit logs - Offline validation support - Multi-passenger tickets -- **NEW:** Ticket lifecycle tracking (validatedAt, boardedAt timestamps) -- **NEW:** Complete audit trail for compliance and reporting +- NEW: Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps) +- NEW: Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets +- NEW: Complete audit trail per leg for compliance and reporting -### 🏆 Loyalty Program -- 4 tiers: Bronze, Silver, Gold, Platinum -- Points accumulation on trips -- Reward redemption -- Tier-based benefits +### Round-Trip Leg Tracking (NEW) +- 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) +- 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 -### 💰 Wallet System -- Top-up via payment methods -- Pay with wallet balance -- Transaction ledger -- Refund to wallet - -### 📍 Live Tracking -- Real-time trip status -- Location updates -- Delay notifications -- Station crowd signals - -### 🔒 Fraud Detection -- Velocity checks (multiple bookings) -- High-value transaction monitoring -- Failed payment pattern detection -- Automatic user blocking - -### 👤 Passenger Profiles -- Comprehensive profile data: gender, date of birth, nationality -- National ID for Ethiopian citizens (Fayda verified) -- Passport information for international passengers -- **NEW:** Complete demographic data for personalized services -- **NEW:** Improved user targeting and communications - -### 🌍 Internationalization -- Multi-language support (English, Amharic, French, Oromo) -- Locale-based responses -- Currency formatting (ETB, DJF, USD) -- **NEW:** Multi-currency display per booking (ETB, DJF, USD) - -### 🚌 Transit Stop Management -- Automatic detection of cross-border journeys (Ethiopia → Djibouti) -- Dire Dawa as mandatory transit hub for international journeys -- Dual-leg fare calculation (domestic + international) -- Age-based pricing applied independently per leg -- Seamless multi-segment booking workflow -- Transit stop optimization and route planning - -### 🔄 Round-Trip 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 +- NEW: Booking type stored for analytics and reporting -### 🚐 Coach Type & Class Selection -- Browse available coach types per route (standard coaches, premium coaches) +### Loyalty Program +- 4 tiers: Bronze, Silver, Gold, Platinum +- Points accumulation on trips +- Reward redemption +- Tier-based benefits + +### Wallet System +- Top-up via payment methods +- Pay with wallet balance +- Transaction ledger +- Refund to wallet + +### Live Tracking +- Real-time trip status +- Location updates +- Delay notifications +- Station crowd signals + +### Fraud Detection +- Velocity checks (multiple bookings) +- High-value transaction monitoring +- Failed payment pattern detection +- Automatic user blocking + +### Passenger Profiles +- Comprehensive profile data: gender, date of birth, nationality +- National ID for Ethiopian citizens (Fayda verified) +- Passport information for international passengers +- NEW: Complete demographic data for personalized services + +### Internationalization +- Multi-language support (English, Amharic, French, Oromo) +- Locale-based responses +- Currency formatting (ETB, DJF, USD) +- NEW: Multi-currency display per booking (ETB, DJF, USD) + +### Transit Stop Management +- Automatic detection of cross-border journeys (Ethiopia to Djibouti) +- Dire Dawa as mandatory transit hub for international journeys +- Dual-leg fare calculation (domestic + international) +- Age-based pricing applied independently per leg +- Seamless multi-segment booking workflow + +### Coach Type & Class Selection +- Browse available coach types per route - View seat classes per coach (Economy Regular, Economy Bed, VIP Bed) - Compare base prices by coach type and class - Real-time availability per coach configuration -- Deferred pricing at seat selection stage -- Coach amenities and features display -- **NEW:** Sequence-based coach ordering for consistent UI -- **NEW:** Premium and insurance fee transparency per class +- NEW: Sequence-based coach ordering for consistent UI +- NEW: Premium and insurance fee transparency per class -### 📊 Data Organization -- **Stations:** Ordered by sequence (1-15) for consistent route display -- **Coaches:** Ordered by sequence (1+) per type for predictable configuration -- **Booking History:** Sorted chronologically with filtering options +### Data Organization +- Stations ordered by sequence (1-15) for consistent route display +- Coaches ordered by sequence (1+) per type for predictable configuration +- Booking history sorted chronologically with filtering options ## Authentication @@ -202,7 +205,7 @@ Used for agent, fraud, and reporting endpoints. Requires corporate IAM token. ### 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 @@ -265,7 +268,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") + .addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports 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") @@ -291,7 +294,7 @@ 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, and audit trails") + .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("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger") //.addServer('http://localhost:4000', 'Development') 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 9622fa656..561e36c43 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -75,21 +75,24 @@ export class BookingsController { @Get() @ApiOperation({ summary: 'List all bookings with filters (Admin/Agent)', - description: 'Returns paginated list of bookings with search and status filters' + description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.' }) @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' }) @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' }) + @ApiQuery({ name: 'returnLegStatus', required: false, description: 'Filter round-trip leg usage: NEITHER_USED | OUTBOUND_ONLY | INBOUND_ONLY | BOTH_USED | NOT_APPLICABLE' }) @ApiQuery({ name: 'page', required: false, description: 'Page number' }) @ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' }) findAll( @Query('search') search?: string, @Query('status') status?: string, + @Query('returnLegStatus') returnLegStatus?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { return this.service.findAll({ search, - status, + status, + returnLegStatus, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 20 }); 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 7a52f8422..28dc5e97c 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -24,6 +24,7 @@ function calculateAge(dateOfBirth: Date): number { interface BookingFilters { search?: string; status?: string; + returnLegStatus?: string; page?: number; pageSize?: number; } @@ -82,6 +83,8 @@ export class BookingsService { displayTotalMinor: booking.displayTotalMinor, adultCount: booking.adultCount, childCount: booking.childCount, + bookingType: booking.bookingType, + returnLegStatus: (booking as any).returnLegStatus ?? null, createdAt: booking.createdAt, schedule: { train: booking.schedule.train, @@ -159,6 +162,8 @@ export class BookingsService { displayTotalMinor: booking.displayTotalMinor, adultCount: booking.adultCount, childCount: booking.childCount, + bookingType: booking.bookingType, + returnLegStatus: (booking as any).returnLegStatus ?? null, createdAt: booking.createdAt, schedule: { train: booking.schedule.train, @@ -180,7 +185,7 @@ export class BookingsService { } async findAll(filters: BookingFilters = {}) { - const { search, status, page = 1, pageSize = 20 } = filters; + const { search, status, returnLegStatus, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; const where: any = {}; @@ -194,9 +199,8 @@ export class BookingsService { ]; } - if (status) { - where.status = status; - } + if (status) where.status = status; + if (returnLegStatus) where.returnLegStatus = returnLegStatus; const [items, total] = await Promise.all([ this.prisma.booking.findMany({ @@ -225,6 +229,8 @@ export class BookingsService { displayTotalMinor: booking.displayTotalMinor, contactEmail: booking.contactEmail, contactPhone: booking.contactPhone, + bookingType: booking.bookingType, + returnLegStatus: (booking as any).returnLegStatus ?? null, createdAt: booking.createdAt, passenger: booking.passenger?.user, schedule: { @@ -391,6 +397,7 @@ export class BookingsService { returnDestinationStationId: dto.returnDestinationStationId, returnHoldId: dto.returnHoldId, returnSeatClassId: dto.returnSeatClassId, + returnLegStatus: 'NEITHER_USED', seats: { create: passengersData.map(p => ({ seat: { connect: { id: p.outboundSeatId } }, @@ -406,7 +413,7 @@ export class BookingsService { displayCurrency })) } - }, + } as any, include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } } }); @@ -646,7 +653,11 @@ export class BookingsService { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount, displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined, - bookingType: booking.bookingType, createdAt: booking.createdAt, + bookingType: booking.bookingType, + returnLegStatus: (booking as any).returnLegStatus ?? null, + outboundBoardedAt: (booking as any).outboundBoardedAt ?? null, + returnBoardedAt: (booking as any).returnBoardedAt ?? null, + createdAt: booking.createdAt, schedule: { number: booking.schedule.train.number, origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city }, @@ -751,6 +762,37 @@ export class BookingsService { } } + // Mark round-trip bookings where the return train has departed but the return leg + // was never scanned. Runs every minute; only acts on CONFIRMED bookings whose + // returnSchedule.departureAt is in the past and returnBoardedAt is still null. + @Cron(CronExpression.EVERY_MINUTE) + async markReturnLegNoShows() { + const now = new Date(); + const graceCutoff = new Date(now.getTime() - 30 * 60 * 1000); + + const candidates = await this.prisma.booking.findMany({ + where: { + bookingType: 'ROUND_TRIP', + status: 'CONFIRMED', + returnLegStatus: 'NEITHER_USED' as any, + outboundBoardedAt: { not: null }, + returnBoardedAt: null, + returnScheduleId: { not: null }, + }, + include: { returnSchedule: { select: { departureAt: true } } }, + } as any); + + for (const b of candidates) { + const returnDep: Date | undefined = (b as any).returnSchedule?.departureAt; + if (returnDep && returnDep < graceCutoff) { + await this.prisma.booking.update({ + where: { id: b.id }, + data: { returnLegStatus: 'OUTBOUND_ONLY' } as any, + }); + } + } + } + private selectBestFareRule( candidates: any[], scheduleId: string, diff --git a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts index 8fcb82394..f925920cb 100644 --- a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts @@ -11,7 +11,10 @@ export class EmailClientService implements OnApplicationBootstrap { private readonly emailServiceClient: ClientProxy, ) {} + private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false'; + async onApplicationBootstrap() { + if (!this.enabled) return; this.emailServiceClient .connect() .then(() => this.logger.log('Connected to Email service')) @@ -19,10 +22,8 @@ export class EmailClientService implements OnApplicationBootstrap { } async sendEmail(dto: SendEmail) { - this.emailServiceClient.emit('send-email', { - ...dto, - appKey: 'EDR-PASSENGER-API', - }); + if (!this.enabled) return {}; + this.emailServiceClient.emit('send-email', { ...dto, appKey: 'EDR-PASSENGER-API' }); return {}; } } diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts index f209ccf17..21dfaf159 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { DynamicModule, Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { ClientsModule, Transport } from '@nestjs/microservices'; @@ -8,49 +8,64 @@ import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters'; import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; -@Module({ - imports: [ - HttpModule.register({ timeout: 10_000 }), - ClientsModule.registerAsync([ - { - name: 'EMAIL_SERVICE', - imports: [ConfigModule], - inject: [ConfigService], - useFactory: (config: ConfigService) => ({ - transport: Transport.RMQ, - options: { - urls: [config.get('RABBITMQ_URL') ?? 'amqp://localhost:5672'], - queue: config.get('EMAIL_QUEUE') ?? 'email_queue', - queueOptions: { durable: true }, - noAck: true, - }, - }), +const rmqClientsModule = ClientsModule.registerAsync([ + { + name: 'EMAIL_SERVICE', + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + transport: Transport.RMQ, + options: { + urls: [config.get('RABBITMQ_URL') ?? 'amqp://localhost:5672'], + queue: config.get('EMAIL_QUEUE') ?? 'email_queue', + queueOptions: { durable: true }, + noAck: true, }, - { - name: 'SMS_SERVICE', - imports: [ConfigModule], - inject: [ConfigService], - useFactory: (config: ConfigService) => ({ - transport: Transport.RMQ, - options: { - urls: [config.get('RABBITMQ_URL') ?? 'amqp://localhost:5672'], - queue: config.get('SMS_QUEUE') ?? 'sms_queue', - queueOptions: { durable: true }, - noAck: true, - }, - }), + }), + }, + { + name: 'SMS_SERVICE', + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + transport: Transport.RMQ, + options: { + urls: [config.get('RABBITMQ_URL') ?? 'amqp://localhost:5672'], + queue: config.get('SMS_QUEUE') ?? 'sms_queue', + queueOptions: { durable: true }, + noAck: true, }, - ]), - ], - controllers: [NotificationsController], - providers: [ - NotificationsService, - EmailAdapter, - SmsAdapter, - PushAdapter, - EmailClientService, - SmsClientService, - ], - exports: [NotificationsService, EmailClientService, SmsClientService], -}) -export class NotificationsModule {} + }), + }, +]); + +@Module({}) +export class NotificationsModule { + static register(): DynamicModule { + const rmqEnabled = process.env.RABBITMQ_ENABLED !== 'false'; + + return { + module: NotificationsModule, + imports: [ + HttpModule.register({ timeout: 10_000 }), + ...(rmqEnabled ? [rmqClientsModule] : []), + ], + controllers: [NotificationsController], + providers: [ + NotificationsService, + EmailAdapter, + SmsAdapter, + PushAdapter, + ...(rmqEnabled + ? [EmailClientService, SmsClientService] + : [ + { provide: 'EMAIL_SERVICE', useValue: null }, + { provide: 'SMS_SERVICE', useValue: null }, + EmailClientService, + SmsClientService, + ]), + ], + exports: [NotificationsService, EmailClientService, SmsClientService], + }; + } +} diff --git a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts index 0108e0758..fa19d1514 100644 --- a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts @@ -11,7 +11,10 @@ export class SmsClientService implements OnApplicationBootstrap { private readonly smsClient: ClientProxy, ) {} + private readonly enabled = process.env.RABBITMQ_ENABLED !== 'false'; + async onApplicationBootstrap() { + if (!this.enabled) return; this.smsClient .connect() .then(() => this.logger.log('Connected to SMS service')) @@ -19,18 +22,14 @@ export class SmsClientService implements OnApplicationBootstrap { } async sendSms(dto: SendMessage) { - this.smsClient.emit('send-sms', { - ...dto, - appKey: 'EDR-PASSENGER-API', - }); + if (!this.enabled) return {}; + this.smsClient.emit('send-sms', { ...dto, appKey: 'EDR-PASSENGER-API' }); return {}; } async sendBulkMessages(dto: BulkMessagesDto) { - this.smsClient.emit('ozeking-bulk-sms', { - ...dto, - appKey: 'EDR-PASSENGER-API', - }); + if (!this.enabled) return {}; + this.smsClient.emit('ozeking-bulk-sms', { ...dto, appKey: 'EDR-PASSENGER-API' }); return {}; } } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index fcad059f4..4e422f2ad 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -98,6 +98,10 @@ export class BulkCreateSchedulesDto { @ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) plannedTimes?: PlannedStopTimeDto[]; + + @ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' }) + @IsOptional() @IsArray() @IsString({ each: true }) + coachIds?: string[]; } export class BulkSchedulesResponseDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index f7ef5487f..fd0776141 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -44,6 +44,15 @@ export class SchedulesService { const schedule = await this.createSchedule(createDto); scheduleIds.push(schedule.id); + + // Assign coaches if provided + if (dto.coachIds && dto.coachIds.length > 0) { + await this.assignCoaches( + schedule.id, + dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })), + ); + } + scheduleCount++; } catch (error) { errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`); diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts index 2a3b84539..2c4215dc8 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts @@ -62,7 +62,7 @@ describe('SeatsService - Auto Assign', () => { mockPrisma.seat.findMany.mockResolvedValue(mockSeats); - const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR', 'ACCESSIBLE'); + const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR'); expect(result).toHaveLength(2); }); 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 e7760b565..7d4acea76 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; @@ -30,6 +30,10 @@ export class TicketsController { @UseGuards(JwtGuard) @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: 'skip', required: false }) + @ApiQuery({ name: 'take', required: false }) listTickets( @Query('search') search?: string, @Query('status') status?: string, @@ -77,14 +81,26 @@ export class TicketsController { @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Validate ticket at gate with audit logging', - description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.' + description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.' + }) + @ApiBody({ + schema: { + type: 'object', + required: ['validatorId'], + properties: { + validatorId: { type: 'string', example: 'agent-uuid' }, + gateId: { type: 'string', example: 'gate-01' }, + leg: { type: 'string', enum: ['OUTBOUND', 'RETURN'], description: 'Required for round-trip bookings' }, + }, + }, }) validate( @Param('bookingRef') ref: string, @Body('validatorId') validatorId: string, - @Body('gateId') gateId?: string + @Body('gateId') gateId?: string, + @Body('leg') leg?: 'OUTBOUND' | 'RETURN', ) { - return this.service.validate(ref, validatorId, gateId); + return this.service.validate(ref, validatorId, gateId, leg); } @Get(':ticketId/validation-logs') @@ -106,7 +122,31 @@ export class TicketsController { @Post('validate/offline') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Batch import offline validations' }) + @ApiOperation({ + summary: 'Batch import offline validations', + description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.' + }) + @ApiBody({ + schema: { + type: 'object', + properties: { + validations: { + type: 'array', + items: { + type: 'object', + required: ['bookingRef', 'validatorId', 'validatedAt'], + properties: { + bookingRef: { type: 'string' }, + validatorId: { type: 'string' }, + gateId: { type: 'string' }, + validatedAt: { type: 'string', format: 'date-time' }, + leg: { type: 'string', enum: ['OUTBOUND', 'RETURN'] }, + }, + }, + }, + }, + }, + }) validateOfflineBatch(@Body() body: { validations: any[] }) { return this.service.validateOfflineBatch(body.validations); } 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 de9bdf4b5..fa4508dd1 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -7,6 +7,7 @@ interface OfflineValidation { validatorId: string; gateId?: string; validatedAt: string; + leg?: 'OUTBOUND' | 'RETURN'; } @Injectable() @@ -49,6 +50,10 @@ export class TicketsService { booking: { bookingRef: t.booking.bookingRef, status: t.booking.status, + bookingType: t.booking.bookingType, + returnLegStatus: (t.booking as any).returnLegStatus ?? null, + outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null, + returnBoardedAt: (t.booking as any).returnBoardedAt ?? null, totalMinor: t.booking.totalMinor, currency: t.booking.currency, displayCurrency: t.booking.displayCurrency, @@ -213,22 +218,67 @@ export class TicketsService { }; } - async validate(bookingRef: string, validatorId: string, gateId?: string) { + async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: 'OUTBOUND' | 'RETURN') { 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 } }); if (!ticket) throw new NotFoundException('Ticket not found'); - if (ticket.validatedAt) { + + const isRoundTrip = booking.bookingType === 'ROUND_TRIP'; + // For one-way bookings use the original single-validation guard + if (!isRoundTrip) { + 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'); + } + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } }); await this.prisma.gateValidationLog.create({ - data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } + data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' }, }); - throw new BadRequestException('Ticket already validated'); + return { validated: true, ticketId: ticket.id, validatedAt: new Date() }; } - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } }); + + // Round-trip: track which leg is being boarded + const resolvedLeg = leg ?? 'OUTBOUND'; + const now = new Date(); + 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, + }); + throw new BadRequestException('Outbound leg already used'); + } + bookingData.outboundBoardedAt = now; + // Stamp the ticket's first validation + if (!ticket.validatedAt) { + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); + } + } else { + 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, + }); + throw new BadRequestException('Return leg already used'); + } + bookingData.returnBoardedAt = now; + } + + // Derive the new composite status + const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt; + const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt; + if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED'; + else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; // return pending/no-show + 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, status: 'APPROVED' } + data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any, }); - return { validated: true, ticketId: ticket.id, validatedAt: new Date() }; + return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } async getValidationLogs(ticketId: string) { @@ -256,6 +306,8 @@ export class TicketsService { coachLabel: b.seats[0]?.seat.coach.number, qrPayload: b.ticket?.qrPayload, status: b.status, + bookingType: b.bookingType, + returnLegStatus: (b as any).returnLegStatus ?? null, validatedAt: b.ticket?.validatedAt, })); } @@ -265,11 +317,13 @@ export class TicketsService { const processedRefs = new Set(); for (const v of validations) { - if (processedRefs.has(v.bookingRef)) { + const offlineLeg = v.leg; + const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef; + if (processedRefs.has(dedupKey)) { results.duplicate++; continue; } - processedRefs.add(v.bookingRef); + processedRefs.add(dedupKey); try { const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } }); @@ -286,11 +340,21 @@ export class TicketsService { continue; } - if (ticket.validatedAt) { + if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP') { results.duplicate++; continue; } + // For round-trip, check per-leg duplication + if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) { + const alreadyUsed = + offlineLeg === 'OUTBOUND' ? !!(booking as any).outboundBoardedAt : !!(booking as any).returnBoardedAt; + if (alreadyUsed) { + results.duplicate++; + continue; + } + } + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId }, @@ -301,11 +365,24 @@ export class TicketsService { ticketId: ticket.id, validatorId: v.validatorId, gateId: v.gateId, + leg: v.leg ?? null, status: 'APPROVED', validatedAt: new Date(v.validatedAt), - }, + } as any, }); + // update returnLegStatus for round-trip offline validations + if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) { + const bookingData: Record = + offlineLeg === 'OUTBOUND' ? { outboundBoardedAt: new Date(v.validatedAt) } : { returnBoardedAt: new Date(v.validatedAt) }; + const outboundUsed = offlineLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt; + const returnUsed = offlineLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt; + if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED'; + 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 }); + } + results.success++; } catch (err) { results.failed++; 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 ce8541f29..36fd20281 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -140,7 +140,7 @@ export default function BookingsPage() { }, { key: 'bookingType', - label: 'Class', + label: 'Type', sortable: true, render: (booking: any) => booking.bookingType || 'ONE_WAY', }, diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index bf4f88a98..d755e5d2f 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -145,7 +145,7 @@ export default function CoachesPage() { const [search, setSearch] = useState(''); const [showModal, setShowModal] = useState(false); const [editingItem, setEditingItem] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null }>({ isOpen: false, item: null }); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null }); const queryClient = useQueryClient(); // Coach Types Queries @@ -252,12 +252,17 @@ export default function CoachesPage() { }; const confirmDelete = async () => { - if (deleteConfirm.item?.isCoachType) { - await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id); - } else { - await deleteCoachMutation.mutateAsync(deleteConfirm.item.id); + try { + if (deleteConfirm.item?.isCoachType) { + await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id); + } else { + await deleteCoachMutation.mutateAsync(deleteConfirm.item.id); + } + setDeleteConfirm({ isOpen: false, item: null }); + } catch (err: any) { + const msg = err?.response?.data?.message || err?.message || 'Delete failed'; + setDeleteConfirm((prev) => ({ ...prev, error: msg })); } - setDeleteConfirm({ isOpen: false, item: null }); }; const coachTypesArray = Array.isArray(coachTypesData) ? coachTypesData : (coachTypesData as any)?.items || (coachTypesData as any)?.data || []; @@ -536,6 +541,8 @@ export default function CoachesPage() { message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`} confirmText="Delete" isDanger={true} + isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending} + error={deleteConfirm.error} warning={ deleteConfirm.item?.isCoachType ? 'This coach type may have coaches assigned. Deleting it may impact these systems.' diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 0f56f0e15..7c381e90b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -362,12 +362,16 @@ export default function RoutesPage() { type="text" name="code" className="input" - value={generateRouteCode(originStationId, destinationStationId)} - readOnly + defaultValue={editingRoute ? editingRoute.code : undefined} + key={editingRoute ? `code-edit-${editingRoute.id}` : `code-new-${originStationId}-${destinationStationId}`} + placeholder={generateRouteCode(originStationId, destinationStationId) || 'e.g. ADD-DJI'} required - placeholder="Select stations to generate" - disabled={!!editingRoute} /> + {!editingRoute && originStationId && destinationStationId && ( +

+ Suggested: +

+ )}
@@ -375,11 +379,16 @@ export default function RoutesPage() { type="text" name="name" className="input" - value={generateRouteName(originStationId, destinationStationId)} - readOnly + defaultValue={editingRoute ? editingRoute.name : undefined} + key={editingRoute ? `name-edit-${editingRoute.id}` : `name-new-${originStationId}-${destinationStationId}`} + placeholder={generateRouteName(originStationId, destinationStationId) || 'e.g. Addis Ababa - Djibouti'} required - placeholder="Select stations to generate" /> + {!editingRoute && originStationId && destinationStationId && ( +

+ Suggested: +

+ )}
diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 1207a01e4..33a03e8f5 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -318,8 +318,8 @@ export default function SchedulesPage() { label: 'Train', sortable: true, render: (schedule: Schedule) => ( -
- {schedule.train?.name} ({schedule.train?.number}) +
+ {schedule.train?.number}
), }, 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 8b1ad4072..78879e96e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -548,8 +548,11 @@ export default function SeatsPage() { {coachesWithSeats.map((coach: any, index: number) => { const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach; - const coachTypeName = coachData?.coachType?.type || 'Coach'; - const isBedCoach = coachTypeName.toLowerCase().includes('bed'); + const coachTypeName = coachData?.coachType?.type || coachData?.coachType?.name || 'Coach'; + const seatClassName = coachData?.seatClass?.name || coach?.seatClass?.name || coach?.coachClass || ''; + const isBedCoach = seatClassName.toLowerCase().includes('bed') || + coachTypeName.toLowerCase().includes('bed') || + (coach.seats || []).some((s: any) => s.bedPosition); const seats = (coach.seats || []).filter((s: any) => s.seatNumber); const isExpanded = expandedCoaches.has(coach.id); const seatOrBedLabel = isBedCoach ? 'beds' : 'seats'; 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 e1dc12a33..6a0364473 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -204,15 +204,31 @@ export default function TicketsPage() { key: 'boarded', label: 'Boarded', render: (ticket: any) => ( - ticket.boardedAt ? ( + ticket.validatedAt ? (
- {formatDateTime(ticket.boardedAt)} + {formatDateTime(ticket.validatedAt)}
) : ( Not boarded ) ), }, + { + key: 'returnLegStatus', + label: 'Return Leg', + render: (ticket: any) => { + const status = ticket.booking?.returnLegStatus; + if (!status || status === 'NOT_APPLICABLE') return ; + const map: Record = { + NEITHER_USED: { label: 'Neither Used', cls: 'edr-badge-warning' }, + OUTBOUND_ONLY: { label: 'Outbound Only', cls: 'edr-badge-info' }, + INBOUND_ONLY: { label: 'Inbound Only', cls: 'edr-badge-danger' }, + BOTH_USED: { label: 'Both Used', cls: 'edr-badge-success' }, + }; + const entry = map[status] ?? { label: status, cls: 'edr-badge-info' }; + return {entry.label}; + }, + }, ]; const actions = [ @@ -444,10 +460,30 @@ export default function TicketsPage() {
- {selectedTicket.boardedAt && ( + {selectedTicket.validatedAt && (
-

Boarded At

-

{formatDateTime(selectedTicket.boardedAt)}

+

Validated At

+

{formatDateTime(selectedTicket.validatedAt)}

+
+ )} + + {selectedTicket.booking?.returnLegStatus && selectedTicket.booking.returnLegStatus !== 'NOT_APPLICABLE' && ( +
+

Round-Trip Leg Status

+
+
+

Leg Status

+

{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}

+
+
+

Outbound Boarded

+

{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}

+
+
+

Return Boarded

+

{selectedTicket.booking.returnBoardedAt ? formatDateTime(selectedTicket.booking.returnBoardedAt) : '—'}

+
+
)} diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx index ec84f0c9a..0318c3783 100644 --- a/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx @@ -15,6 +15,7 @@ interface ConfirmDialogProps { isLoading?: boolean; isDanger?: boolean; warning?: string; + error?: string; } export default function ConfirmDialog({ @@ -28,6 +29,7 @@ export default function ConfirmDialog({ isLoading = false, isDanger = false, warning, + error, }: ConfirmDialogProps) { return ( @@ -47,6 +49,12 @@ export default function ConfirmDialog({ )} + {error && ( +
+ +

{error}

+
+ )}
{cancelText}