From a0a02b473d9ea8e186e156a6d46ceef254d3f72b Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 25 Jun 2026 14:20:17 +0300 Subject: [PATCH 01/17] fix: ( my booking ) add get passenger id from the iamuserid --- .../src/modules/bookings/bookings.controller.ts | 16 ++++++++-------- .../src/modules/bookings/bookings.service.ts | 5 +++++ 2 files changed, 13 insertions(+), 8 deletions(-) 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 ad4a7cd20..4c4d399ee 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException, UnauthorizedException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { Throttle } from '@nestjs/throttler'; @@ -36,13 +36,13 @@ export class BookingsController { @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { - const passengerId = req.user?.passengerId; - if (!passengerId) throw new Error('Passenger ID not found in token'); - return this.service.findByPassengerId(passengerId, { - search, - status, - page: page ? parseInt(page) : 1, - pageSize: pageSize ? parseInt(pageSize) : 20 + const iamUserId = req.user?.id; + if (!iamUserId) throw new UnauthorizedException(); + return this.service.findByIamUserId(iamUserId, { + search, + status, + 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 21532fa44..be965d778 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -44,6 +44,11 @@ export class BookingsService { private readonly fareEngine: FareEngineService, ) {} + async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) { + const passenger = await this.prisma.passenger.findUniqueOrThrow({ where: { iamUserId }, select: { id: true } }); + return this.findByPassengerId(passenger.id, filters); + } + async findByPassengerId(passengerId: string, filters: BookingFilters = {}) { const { search, status, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; From 29fd5cec34880b40ffab1ff2d00d0baece4546bb Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 25 Jun 2026 14:46:07 +0300 Subject: [PATCH 02/17] refactor: ( d-money ) update trade_type and business_type --- apps/edr-payment-api/src/config/dmoney.config.ts | 2 +- .../src/providers/dmoney/dmoney.provider.ts | 10 ++++------ .../src/providers/dmoney/dmoney.types.ts | 4 ++-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/edr-payment-api/src/config/dmoney.config.ts b/apps/edr-payment-api/src/config/dmoney.config.ts index d0938eeea..26949d301 100644 --- a/apps/edr-payment-api/src/config/dmoney.config.ts +++ b/apps/edr-payment-api/src/config/dmoney.config.ts @@ -11,7 +11,7 @@ export default registerAs("dmoney", () => ({ returnUrl: process.env.DMONEY_RETURN_URL ?? "", timeoutExpress: process.env.DMONEY_TIMEOUT_EXPRESS ?? "120m", language: process.env.DMONEY_LANGUAGE ?? "en", - currency: process.env.DMONEY_CURRENCY ?? "FDJ", + currency: process.env.DMONEY_CURRENCY ?? "DJF", privateKey: process.env.DMONEY_PRIVATE_KEY ?? "", publicKey: process.env.DMONEY_PUBLIC_KEY ?? "", insecureTls: process.env.DMONEY_INSECURE_TLS === "true", diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index 72651b6d3..e241eec89 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -206,18 +206,16 @@ export class DMoneyProvider implements PaymentProvider { appid: this.merchantAppId, merch_code: this.merchantCode, merch_order_id: input.merchantOrderId, - trade_type: "Checkout" as const, + trade_type: "WebCheckout" as const, + business_type: "OnlineMerchant" as const, title: `${input.orderRef}`, total_amount: totalAmount, - trans_currency: 1 == 1 ? "DJF": this.currency, + trans_currency: this.currency, timeout_express: this.timeoutExpress, ...(redirectUrl ? { redirect_url: redirectUrl } : {}), }, }; - console.log("\n\n\n") - console.log(req) - console.log("\n\n\n") const sign = signRequestObject( req as unknown as Record, this.privateKey, @@ -265,7 +263,7 @@ export class DMoneyProvider implements PaymentProvider { `sign=${sign}`, "sign_type=SHA256WithRSA", "version=1.0", - "trade_type=Checkout", + "trade_type=WebCheckout", `language=${this.language}`, ].join("&"); return `${this.webBaseUrl}/payment/web/paygate?${query}`; diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.types.ts b/packages/payment-providers/src/providers/dmoney/dmoney.types.ts index 72a5f2669..29729870d 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.types.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.types.ts @@ -10,12 +10,12 @@ export interface DMoneyPreOrderBizContent { appid: string; merch_code: string; merch_order_id: string; - trade_type: 'Checkout'; + trade_type: 'WebCheckout'; + business_type: 'OnlineMerchant'; title: string; total_amount: string; trans_currency: string; timeout_express: string; - business_type?: string; redirect_url?: string; callback_info?: string; } From e9831795190f78c482b3d850bfe81a12a0eaf6f6 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 25 Jun 2026 14:49:57 +0300 Subject: [PATCH 03/17] Update bookings.controller.ts --- .../src/modules/bookings/bookings.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 66861aca3..b04be6bb2 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, SetMetadata, BadRequestException, UnauthorizedException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { Throttle } from '@nestjs/throttler'; From 1af5d6d310a1e205ef6d82a84f1edf87e9323e22 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 25 Jun 2026 15:50:27 +0300 Subject: [PATCH 04/17] Update seatmap and add cron job for payment status --- .../migration.sql | 2 + .../migration.sql | 1 + apps/edr-passenger-api/prisma/schema.prisma | 1 + apps/edr-passenger-api/src/app.module.ts | 2 + .../common/filters/http-exception.filter.ts | 6 + .../src/modules/fleet/fleet.service.ts | 2 +- .../src/modules/seats/seats.service.ts | 83 ++++--- .../src/modules/tasks/tasks.module.ts | 10 + .../src/modules/tasks/tasks.service.ts | 205 ++++++++++++++++++ .../src/modules/tickets/tickets.service.ts | 37 +++- apps/edr-passenger-api/tsconfig.json | 8 +- .../portal/src/app/booking/payment/page.tsx | 53 ++--- 12 files changed, 339 insertions(+), 71 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql create mode 100644 apps/edr-passenger-api/src/modules/tasks/tasks.module.ts create mode 100644 apps/edr-passenger-api/src/modules/tasks/tasks.service.ts diff --git a/apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql new file mode 100644 index 000000000..435d95829 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260627_fix_enum_column_sync/migration.sql @@ -0,0 +1,2 @@ +-- Migration already applied directly to the database. +-- This file exists only to satisfy Prisma's migration directory check (P3015). diff --git a/apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql new file mode 100644 index 000000000..34a2e2caa --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260630000000_add_payment_reminder_sent_at/migration.sql @@ -0,0 +1 @@ +ALTER TABLE passenger."Booking" ADD COLUMN IF NOT EXISTS "paymentReminderSentAt" TIMESTAMP(3); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index b01ee7b0c..74965df23 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -534,6 +534,7 @@ model Booking { source String @default("WEB") promoCode String? paidAt DateTime? + paymentReminderSentAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt passenger Passenger @relation(fields: [passengerId], references: [id]) diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index a4bce0d14..9e13ad2f9 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -63,6 +63,7 @@ import { SystemConfigModule } from './modules/system-config/system-config.module import { PackagesModule } from './modules/packages/packages.module'; import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module'; import { HealthModule } from './modules/health/health.module'; +import { TasksModule } from './modules/tasks/tasks.module'; @Module({ imports: [ @@ -131,6 +132,7 @@ import { HealthModule } from './modules/health/health.module'; PackagesModule, ExcessBaggageModule, HealthModule, + TasksModule, ], providers: [ { provide: APP_GUARD, useClass: ThrottlerGuard }, diff --git a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts index e50172db9..7810c6d94 100644 --- a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts +++ b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts @@ -47,6 +47,11 @@ export class HttpExceptionFilter implements ExceptionFilter { this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`); } + // When the thrown body is already a structured object (e.g. { status, message, code }), + // merge it into the envelope so callers receive all custom fields. + const customFields = + typeof messageRaw === 'object' && messageRaw !== null ? messageRaw : {}; + response.status(status).json({ success: false, statusCode: status, @@ -54,6 +59,7 @@ export class HttpExceptionFilter implements ExceptionFilter { error: exception instanceof Error ? exception.name : 'Error', timestamp: new Date().toISOString(), path: request.url, + ...customFields, }); } } diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index 09a1b4ed3..3ebcda273 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -44,7 +44,7 @@ const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = { // Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type function detectBedCategory(coachTypeName: string): BedCategory { const name = coachTypeName.toLowerCase(); - const isBed = name.includes('bed') || name.includes('sleeper') || name.includes('couchette'); + const isBed = name.includes('bed') || name.includes('berth') || name.includes('sleeper') || name.includes('couchette'); if (!isBed) return null; if (name.includes('vip')) return 'VIP_BED'; return 'ECONOMY_BED'; 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 8868bb91d..f67e9b3f3 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -51,27 +51,30 @@ export class SeatsService { : 0; const bedCategory = isBedCoach ? this.getBedCategory(coachTypeName, bedsPerRoom) : null; - const mappedSeats = allSeats.map((s: any) => ({ - id: s.id, - seatNumber: s.seatNumber, - label: s.seatNumber, - status: effectiveStatuses.get(s.id) ?? s.status, - kind: s.kind, - row: s.row, - col: s.col, - isWindow: s.isWindow, - isAisle: s.isAisle, - // Bed-specific fields - ...(isBedCoach ? { - room_id: `${a.coach.id}-R${s.row}`, - category: bedCategory, - position: this.colToPosition(s.col), - bed_type: this.bedPositionToType(s.bedPosition), - bedPosition: s.bedPosition, - } : { - bedPosition: s.bedPosition, - }), - })); + const mappedSeats = allSeats.map((s: any) => { + const resolvedBedPosition = isBedCoach + ? this.resolveBedPosition(s.col, s.bedPosition) + : s.bedPosition; + return { + id: s.id, + seatNumber: s.seatNumber, + label: s.seatNumber, + status: effectiveStatuses.get(s.id) ?? s.status, + kind: s.kind, + row: s.row, + col: s.col, + isWindow: s.isWindow, + isAisle: s.isAisle, + bedPosition: resolvedBedPosition, + // Bed-specific fields (only when coach is a bed coach) + ...(isBedCoach ? { + room_id: `${a.coach.id}-R${s.row}`, + category: bedCategory, + position: this.colToPosition(s.col, a.coach.arrangement), + bed_type: this.bedPositionToType(resolvedBedPosition), + } : {}), + }; + }); const base = { id: a.coach.id, @@ -116,7 +119,7 @@ export class SeatsService { private isBedCoach(coachTypeName: string): boolean { const n = coachTypeName.toLowerCase(); - return n.includes('bed') || n.includes('sleeper') || n.includes('couchette'); + return n.includes('bed') || n.includes('berth') || n.includes('sleeper') || n.includes('couchette'); } private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' { @@ -128,9 +131,23 @@ export class SeatsService { return 'ECONOMY_BED'; } - // col format: L1, L2, L3, R1, R2, R3 - private colToPosition(col: string): 'LEFT' | 'RIGHT' { - return col?.startsWith('R') ? 'RIGHT' : 'LEFT'; + // col format: L1, L2, L3, R1, R2, R3 (new) or A, B, C, D (legacy) + // arrangement e.g. "2+2", "3+3", "2+0" → "leftCount+rightCount" + private colToPosition(col: string, arrangement?: string): 'LEFT' | 'RIGHT' | null { + if (!col) return null; + // New named-col format: L1, L2, R1, R2 … + if (/^L\d+$/.test(col)) return 'LEFT'; + if (/^R\d+$/.test(col)) return 'RIGHT'; + // Legacy single-letter cols (A, B, C, D …): derive from arrangement + const colIndex = col.toUpperCase().charCodeAt(0) - 65; // A=0, B=1, C=2 … + if (arrangement) { + const [leftStr, rightStr] = arrangement.split('+'); + const rightCount = parseInt(rightStr ?? '0', 10); + if (rightCount === 0) return 'LEFT'; // single-side berth coach — all LEFT + const leftCount = parseInt(leftStr, 10) || 0; + return colIndex < leftCount ? 'LEFT' : 'RIGHT'; + } + return 'LEFT'; // safe default when no arrangement info } private bedPositionToType(bedPosition: string | null): 'LOWER' | 'MIDDLE' | 'UPPER' | null { @@ -141,6 +158,22 @@ export class SeatsService { return map[bedPosition.toLowerCase()] ?? null; } + // Derives bedPosition from col when the seat was created with legacy A/B/C columns + // (new coaches use L1/L2/L3/R1/R2/R3 and store bedPosition explicitly). + // Col-to-tier mapping: A → lower, B → middle, C → upper, D → upper (4-tier). + private resolveBedPosition(col: string, storedBedPosition: string | null): string | null { + if (storedBedPosition) return storedBedPosition; + const legacyMap: Record = { A: 'lower', B: 'middle', C: 'upper', D: 'upper' }; + // Also handle numeric suffix in L/R cols: L1→lower, L2→middle, L3→upper + if (/^[LR]\d+$/.test(col)) { + const tier = parseInt(col.slice(1), 10); + if (tier === 1) return 'lower'; + if (tier === 2) return 'middle'; + return 'upper'; + } + return legacyMap[col?.toUpperCase()] ?? null; + } + async resolveEffectiveStatuses( scheduleId: string, seatIds: string[], diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts new file mode 100644 index 000000000..9759ff297 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../common/prisma.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { TasksService } from './tasks.service'; + +@Module({ + imports: [PrismaModule, NotificationsModule], + providers: [TasksService], +}) +export class TasksModule {} diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts new file mode 100644 index 000000000..70009d1d8 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -0,0 +1,205 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { PrismaService } from '../../common/prisma.service'; +import { SmsClientService } from '../notifications/sms-client.service'; + +/** Minutes before departure at which each action fires. */ +const REMINDER_MINUTES = 3 * 60; // 3 h → send payment reminder SMS +const DEADLINE_MINUTES = 2 * 60; // 2 h → cancel unpaid booking + +/** Half-width of the reminder detection window (cron runs every 2 min). */ +const REMINDER_WINDOW_MINUTES = 2; + +function fmtTime(d: Date): string { + return d.toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + timeZone: 'Africa/Addis_Ababa', + }); +} + +@Injectable() +export class TasksService { + private readonly logger = new Logger(TasksService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly sms: SmsClientService, + ) {} + + // ───────────────────────────────────────────────────────────────────────── + // Every 2 min: advance TrainSchedule statuses (departure / arrival). + // ───────────────────────────────────────────────────────────────────────── + @Cron('*/2 * * * *') + async syncScheduleStatuses() { + const now = new Date(); + + const [departed, arrived] = await Promise.all([ + this.prisma.trainSchedule.updateMany({ + where: { status: 'SCHEDULED', departureAt: { lte: now } }, + data: { status: 'EN_ROUTE' }, + }), + this.prisma.trainSchedule.updateMany({ + where: { status: { in: ['EN_ROUTE', 'BOARDING'] }, arrivalAt: { lte: now } }, + data: { status: 'ARRIVED' }, + }), + ]); + + if (departed.count > 0 || arrived.count > 0) { + this.logger.log( + `Schedule sync: ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`, + ); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Every 2 min: payment deadline enforcement. + // + // • 3 h before departure → send one SMS reminder to complete payment. + // • 2 h before departure → cancel booking if payment is still pending + // and notify the passenger by SMS. + // + // Example: train departs 08:00 + // 05:00 → reminder SMS sent ("pay before 06:00 or booking is cancelled") + // 06:00 → booking auto-cancelled, cancellation SMS sent + // ───────────────────────────────────────────────────────────────────────── + @Cron('*/2 * * * *') + async enforcePaymentDeadlines() { + const now = new Date(); + + await Promise.all([ + this.sendPaymentReminders(now), + this.cancelExpiredPendingBookings(now), + ]); + } + + // ── 3-hour reminder ─────────────────────────────────────────────────────── + private async sendPaymentReminders(now: Date) { + // Narrow 4-minute window (±2 min around the 3-hour mark) so each booking + // is caught by exactly one cron tick and paymentReminderSentAt guards re-sends. + const windowMs = REMINDER_WINDOW_MINUTES * 60 * 1000; + const reminderMs = REMINDER_MINUTES * 60 * 1000; + + const windowStart = new Date(now.getTime() + reminderMs - windowMs); + const windowEnd = new Date(now.getTime() + reminderMs + windowMs); + + const bookings = await this.prisma.booking.findMany({ + where: { + status: 'PENDING_PAYMENT', + paymentReminderSentAt: null, + schedule: { departureAt: { gte: windowStart, lte: windowEnd } }, + } as any, + include: { + schedule: { + include: { + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + }, + }, + }); + + for (const booking of bookings) { + try { + const dep = booking.schedule.departureAt as Date; + const deadline = new Date(dep.getTime() - DEADLINE_MINUTES * 60 * 1000); + const origin = booking.schedule.originStation?.name ?? ''; + const dest = booking.schedule.destinationStation?.name ?? ''; + + const message = + `EDR: Your booking ${booking.bookingRef} ` + + `(${origin} → ${dest}) departs at ${fmtTime(dep)}. ` + + `Complete payment by ${fmtTime(deadline)} or your booking will be cancelled.`; + + if (booking.contactPhone) { + await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); + } + + await this.prisma.booking.update({ + where: { id: booking.id }, + data: { paymentReminderSentAt: now } as any, + }); + + this.logger.log( + `Payment reminder sent: ${booking.bookingRef} (departs ${fmtTime(dep)}, deadline ${fmtTime(deadline)})`, + ); + } catch (err) { + this.logger.error( + `Reminder failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + + // ── 2-hour auto-cancel ──────────────────────────────────────────────────── + private async cancelExpiredPendingBookings(now: Date) { + const cutoff = new Date(now.getTime() + DEADLINE_MINUTES * 60 * 1000); // now + 2 h + + const expiredBookings = await this.prisma.booking.findMany({ + where: { + status: 'PENDING_PAYMENT', + schedule: { departureAt: { lte: cutoff } }, + }, + include: { + schedule: { + include: { + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + }, + paymentIntent: { select: { method: true } }, + }, + }); + + for (const booking of expiredBookings) { + try { + // 1. Release held seats (Journey rows are the occupancy source of truth) + await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any }); + + // 2. Audit record (no refund — payment was never completed) + await this.prisma.bookingCancellation.create({ + data: { + bookingId: booking.id, + cancelledBy: 'SYSTEM', + reason: 'Payment not completed before departure deadline', + refundAmount: 0, + refundMethod: booking.paymentIntent?.method ?? 'NONE', + refundStatus: 'NOT_APPLICABLE', + }, + }).catch(() => null); // booking may already have a cancellation record + + // 3. Mark cancelled + await this.prisma.booking.update({ + where: { id: booking.id }, + data: { status: 'CANCELLED' }, + }); + + // 4. Notify passenger + const dep = booking.schedule.departureAt as Date; + const origin = booking.schedule.originStation?.name ?? ''; + const dest = booking.schedule.destinationStation?.name ?? ''; + + const message = + `EDR: Your booking ${booking.bookingRef} ` + + `(${origin} → ${dest}, departs ${fmtTime(dep)}) has been cancelled ` + + `because payment was not completed before the deadline.`; + + if (booking.contactPhone) { + await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); + } + + this.logger.log( + `Auto-cancelled: ${booking.bookingRef} (payment deadline expired, departs ${fmtTime(dep)})`, + ); + } catch (err) { + this.logger.error( + `Auto-cancel failed for ${booking.bookingRef}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if (expiredBookings.length > 0) { + this.logger.log(`Auto-cancelled ${expiredBookings.length} expired pending booking(s)`); + } + } +} 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 13e31c91a..2168306d4 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; @@ -127,12 +127,37 @@ export class TicketsService { }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + // No payment intent record at all + if (!booking.paymentIntent) { + throw new HttpException( + { status: 'error', message: 'Payment not completed', code: 400 }, + HttpStatus.BAD_REQUEST, + ); + } + + // Payment intent exists but not yet succeeded + if (booking.paymentIntent.status !== 'SUCCEEDED') { + throw new HttpException( + { + status: 'error', + message: 'Payment not completed', + code: 400, + detail: `Payment status: ${booking.paymentIntent.status}`, + }, + HttpStatus.BAD_REQUEST, + ); + } + + // Booking not in CONFIRMED state (safety net — should align with SUCCEEDED) if (booking.status !== 'CONFIRMED') { - const paymentStatus = booking.paymentIntent?.status ?? null; - throw new BadRequestException( - `Payment not completed. Please complete your payment before accessing the ticket. ` + - `Booking status: ${booking.status}` + - (paymentStatus ? `. Payment status: ${paymentStatus}` : ''), + throw new HttpException( + { + status: 'error', + message: 'Payment not completed', + code: 400, + detail: `Booking status: ${booking.status}`, + }, + HttpStatus.BAD_REQUEST, ); } diff --git a/apps/edr-passenger-api/tsconfig.json b/apps/edr-passenger-api/tsconfig.json index 49158fb15..f557f861a 100644 --- a/apps/edr-passenger-api/tsconfig.json +++ b/apps/edr-passenger-api/tsconfig.json @@ -7,9 +7,11 @@ "noEmit": false, "incremental": true, "tsBuildInfoFile": "./.tsbuildinfo", - "paths": { "@/*": ["./src/*"] }, - "module": "node16", - "moduleResolution": "node16", + "paths": { + "@/*": ["./src/*"], + "@tria-plc/iamapi-common": ["./node_modules/@tria-plc/iamapi-common/dist/index"], + "@tria-plc/iamapi-common/*": ["./node_modules/@tria-plc/iamapi-common/dist/*"] + }, "strictPropertyInitialization": false, "noUnusedLocals": false, "noUnusedParameters": false diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index fc06005f0..d58b22a98 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -29,6 +29,7 @@ export default function PaymentPage() { usePaymentStore(); const [selectedMethod, setSelectedMethod] = useState(null); const [isProcessing, setIsProcessing] = useState(false); + const [paymentError, setPaymentError] = useState(null); const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; @@ -60,57 +61,37 @@ export default function PaymentPage() { const paymentMutation = useMutation({ mutationFn: async (data: any) => { - // For all payment methods, use the initiate endpoint - try { - return await apiClient.post("/payments/initiate", { - bookingId: data.bookingId, - method: data.method, - paymentMethodId: data.paymentMethodId, - platform: 'web', - }); - } catch (error) { - console.log("Payment API not available, using mock payment"); - // Mock payment response - return { - paymentIntentId: `mock-payment-${Date.now()}`, - status: "PENDING", - amountMinor: data.amountMinor, - currency: data.currency, - method: data.method, - }; - } + return await apiClient.post("/payments/initiate", { + bookingId: data.bookingId, + method: data.method, + paymentMethodId: data.paymentMethodId, + platform: 'web', + }); }, onSuccess: async (data: any) => { - // Handle TELEBIRR/WAAFI redirect response + setPaymentError(null); + if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') { - const redirectUrl = data.clientAction.url; - - // Store the intent ID for later verification setPaymentIntent(data.intentId); updateStatus("REQUIRES_ACTION"); - - // Redirect to payment gateway - window.location.href = redirectUrl; + window.location.href = data.clientAction.url; return; } - + setPaymentIntent(data.paymentIntentId || data.intentId); updateStatus("PROCESSING"); - - // Simulate payment processing await new Promise((resolve) => setTimeout(resolve, 2000)); - updateStatus("SUCCEEDED"); router.push("/booking/confirmation"); }, onError: (error: any) => { console.error("Payment failed:", error); updateStatus("FAILED"); - const errorMessage = + setPaymentError( error?.response?.data?.message || error?.message || - "Payment failed. Please try again."; - alert(errorMessage); + "Payment failed. Please try again.", + ); setIsProcessing(false); }, }); @@ -124,6 +105,7 @@ export default function PaymentPage() { } setIsProcessing(true); + setPaymentError(null); // Find the selected payment method to get its ID const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod); @@ -575,11 +557,10 @@ export default function PaymentPage() { {/* Error Message */} - {paymentMutation.isError && ( + {paymentError && (

- ⚠️ Payment failed. Please try again or contact support if the - problem persists. + ⚠️ {paymentError}

)} From 61f62be31df979103377ebcb7e15207af4619f94 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 25 Jun 2026 16:27:29 +0300 Subject: [PATCH 05/17] feat: ( fayda ) register user after fayda verification --- .../src/modules/verifayda/verifayda.dto.ts | 3 + .../modules/verifayda/verifayda.service.ts | 131 ++++++++++++++++-- .../src/modules/verifayda/verifayda.types.ts | 11 +- 3 files changed, 131 insertions(+), 14 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts index f446b6fb3..4842b479c 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts @@ -59,6 +59,9 @@ export class CompleteVerificationResultDto { @ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' }) gender?: string; + + @ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' }) + userDataSaved?: boolean; } export class VerifaydaCallbackDto { diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts index 795a6f158..407046cb4 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -72,6 +72,7 @@ export interface CompleteVerificationResult { phoneNumber?: string; birthdate?: string; gender?: string; + userDataSaved?: boolean; } @Injectable() @@ -219,8 +220,8 @@ export class VerifaydaService { const login = await this.issueLoginToken(userId); result = { purpose: 'LOGIN', verified: true, ...login }; } else { - // VERIFY — prove identity and hand the verified attributes back to the - // caller. No domain writes; the session row tracks status as usual. + // VERIFY — prove identity, save to IAM, return verified attributes. + const { userDataSaved } = await this.upsertIamUser(normalized); result = { purpose: 'VERIFY', verified: true, @@ -229,6 +230,7 @@ export class VerifaydaService { phoneNumber: normalized.phoneNumber, birthdate: normalized.birthdate, gender: normalized.gender, + userDataSaved, }; } @@ -265,13 +267,13 @@ export class VerifaydaService { } async getVerificationStatus(iamUserId: string): Promise { - const rows = await this.dataSource.query<{ metadata: Record | null; name: { en: string; am: string } | null }[]>( - `SELECT metadata, name FROM iam.users WHERE id = $1 LIMIT 1`, + const rows = await this.dataSource.query<{ verified_by: string | null; updated_at: Date | null; name: { en: string; am: string } | null }[]>( + `SELECT verified_by, updated_at, name FROM iam.users WHERE id = $1 LIMIT 1`, [iamUserId], ); const iam = rows[0] ?? null; - const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true'; - const faydaVerifiedAt = iam?.metadata?.faydaVerifiedAt ? new Date(iam.metadata.faydaVerifiedAt) : undefined; + const faydaVerified = iam?.verified_by === 'fayda'; + const faydaVerifiedAt = faydaVerified && iam?.updated_at ? new Date(iam.updated_at) : undefined; const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined; return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName }; } @@ -387,18 +389,39 @@ export class VerifaydaService { } private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo { + const nameEn = raw['name#en'] as string | undefined; + const nameAm = raw['name#am'] as string | undefined; + const genderEn = raw['gender#en'] as string | undefined; + const genderAm = raw['gender#am'] as string | undefined; + const addressEn = raw['address#en'] as string | undefined; + const addressAm = raw['address#am'] as string | undefined; + const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined; + return { sub: raw.sub, - fullName: raw.name ?? raw['name#en'] ?? raw['name#am'], - phoneNumber: - raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone, - email: raw.email, - gender: raw.gender, - birthdate: raw.birthdate, - picture: raw.picture, + fullName: (raw.name as string | undefined) ?? nameEn ?? nameAm, + phoneNumber: rawPhone ? this.standardizePhoneNumber(rawPhone) : undefined, + rawPhoneNumber: rawPhone, + email: raw.email as string | undefined, + gender: genderEn ?? genderAm ?? (raw.gender as string | undefined), + birthdate: raw.birthdate as string | undefined, + picture: raw.picture as string | undefined, + nameEn, + nameAm, + genderEn, + genderAm, + addressEn, + addressAm, }; } + private standardizePhoneNumber(phone: string): string { + const digits = phone.replace(/\D/g, ''); + if (digits.startsWith('251')) return `+${digits}`; + if (digits.startsWith('0')) return `+251${digits.slice(1)}`; + return `+${digits}`; + } + // LOGIN via Fayda is now handled entirely by the IAM package's own OIDC flow. // This method is kept as a stub so completeVerification() still compiles; // it throws immediately without touching the database. @@ -411,6 +434,88 @@ export class VerifaydaService { }); } + private async upsertIamUser( + normalized: NormalizedFaydaUserInfo, + ): Promise<{ iamUserId: string | null; userDataSaved: boolean }> { + try { + const iamMetadata = { + sub: normalized.sub, + address: { am: normalized.addressAm ?? '', en: normalized.addressEn ?? '' }, + email: normalized.email ?? '', + gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' }, + name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' }, + phoneNumber: normalized.rawPhoneNumber ?? '', + }; + + // Step 1 — already verified with same Fayda sub + const bySub = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`, + [normalized.sub], + ); + if (bySub.length > 0) { + return { iamUserId: bySub[0].id, userDataSaved: true }; + } + + // Step 2 — existing user by phone or email, not yet Fayda-verified + const conditions: string[] = []; + const params: unknown[] = []; + if (normalized.phoneNumber) { + params.push(normalized.phoneNumber); + conditions.push(`phone_number = $${params.length}`); + } + if (normalized.email) { + params.push(normalized.email); + conditions.push(`email = $${params.length}`); + } + if (conditions.length > 0) { + const byContact = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE ${conditions.join(' OR ')} LIMIT 1`, + params, + ); + if (byContact.length > 0) { + const existingId = byContact[0].id; + await this.dataSource.query( + `UPDATE iam.users + SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb, + verified_by = 'fayda', + updated_at = NOW() + WHERE id = $2`, + [JSON.stringify(iamMetadata), existingId], + ); + return { iamUserId: existingId, userDataSaved: true }; + } + } + + // Step 3 — new user + const name = { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' }; + const username = normalized.phoneNumber ?? normalized.email ?? normalized.sub; + const inserted = await this.dataSource.query<{ id: string }[]>( + `INSERT INTO iam.users ( + id, name, username, email, phone_number, metadata, + user_type, status, is_active, has_set_password, + is_phone_number_verified, verified_by, + created_at, updated_at + ) VALUES ( + gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb, + 'individual', 'accepted', true, false, + false, 'fayda', + NOW(), NOW() + ) RETURNING id`, + [ + JSON.stringify(name), + username, + normalized.email ?? null, + normalized.phoneNumber ?? null, + JSON.stringify(iamMetadata), + ], + ); + return { iamUserId: inserted[0].id, userDataSaved: true }; + } catch (err) { + this.logger.error(`Fayda IAM upsert failed: ${(err as Error).message}`); + return { iamUserId: null, userDataSaved: false }; + } + } + private async markSessionFailed( state: string, errorCode: string, diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts index 450bfaff5..442a22d2f 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.types.ts @@ -27,10 +27,19 @@ export interface FaydaUserInfo { export interface NormalizedFaydaUserInfo { sub: string; + // Convenience / display fields fullName?: string; - phoneNumber?: string; + phoneNumber?: string; // standardized e.g. +251911234567 email?: string; gender?: string; birthdate?: string; picture?: string; + // Raw localized fields — preserved for IAM-identical writes + nameEn?: string; + nameAm?: string; + genderEn?: string; + genderAm?: string; + addressEn?: string; + addressAm?: string; + rawPhoneNumber?: string; // unstandardized, stored in IAM metadata } From bd82e95f9fc1c2ce3cd35eca5bdedfb4908119d5 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 25 Jun 2026 19:58:47 +0300 Subject: [PATCH 06/17] Fix test findings --- .../src/app/booking/confirmation/page.tsx | 163 +++++++++++++----- .../src/app/booking/passengers/page.tsx | 55 ++++-- .../portal/src/app/booking/results/page.tsx | 4 +- .../portal/src/app/booking/review/page.tsx | 47 +++-- .../portal/src/app/booking/search/page.tsx | 16 +- .../portal/src/app/booking/seats/page.tsx | 3 +- 6 files changed, 210 insertions(+), 78 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 5644c9b56..29af2b29b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -16,6 +16,8 @@ type BookingWithTicket = { pnr?: string | null; status?: string; totalMinor?: number; + createdAt?: string; + paymentMethod?: string; ticket?: { barcodePayload?: string; qrPayload?: string; @@ -24,7 +26,8 @@ type BookingWithTicket = { export default function ConfirmationPage() { const router = useRouter(); - const { bookingId, pnr, selectedSchedule, passengers, clearBooking } = useBookingStore(); + const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking } = useBookingStore(); + const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; const [copied, setCopied] = useState(false); const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); const confirmAttempted = useRef(false); @@ -193,12 +196,7 @@ export default function ConfirmationPage() {
{/* QR Code Section */}
- +

Scan at gate

@@ -208,44 +206,129 @@ export default function ConfirmationPage() {
-

Trip details

+

+ {isRoundTrip ? 'Round trip details' : 'Trip details'} +

-
-
-
-

Train number

-

{selectedSchedule?.trainNumber}

-
-
-

Route

-

{selectedSchedule?.origin} → {selectedSchedule?.destination}

-
- {selectedSchedule?.selectedSeatClassName && ( -
-

Class

-

{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}

+ + {/* Outbound journey (round trip) or single journey */} + {(() => { + const schedule = isRoundTrip ? outboundSchedule : selectedSchedule; + if (!schedule) return null; + return ( +
+ {isRoundTrip && ( +

Outbound

+ )} +
+
+
+

Train number

+

{schedule.trainNumber}

+
+
+

Route

+

{schedule.origin} → {schedule.destination}

+
+ {schedule.selectedSeatClassName && ( +
+

Class

+

{schedule.selectedSeatClassName.replace(/_/g, ' ')}

+
+ )} +
+
+
+

Departure

+

+ {schedule.departureTime && format(new Date(schedule.departureTime), 'PPp')} +

+
+
+

Arrival

+

+ {schedule.arrivalTime && format(new Date(schedule.arrivalTime), 'PPp')} +

+
+
+

Duration

+

{schedule.duration}

+
+
- )} -
-
-
-

Departure

-

- {selectedSchedule?.departureTime && format(new Date(selectedSchedule.departureTime), 'PPp')} -

-
-

Arrival

-

- {selectedSchedule?.arrivalTime && format(new Date(selectedSchedule.arrivalTime), 'PPp')} -

-
-
-

Duration

-

{selectedSchedule?.duration}

+ ); + })()} + + {/* Return journey (round trip only) */} + {isRoundTrip && inboundSchedule && ( +
+

Return

+
+
+
+

Train number

+

{inboundSchedule.trainNumber}

+
+
+

Route

+

{inboundSchedule.origin} → {inboundSchedule.destination}

+
+ {inboundSchedule.selectedSeatClassName && ( +
+

Class

+

{inboundSchedule.selectedSeatClassName.replace(/_/g, ' ')}

+
+ )} +
+
+
+

Departure

+

+ {inboundSchedule.departureTime && format(new Date(inboundSchedule.departureTime), 'PPp')} +

+
+
+

Arrival

+

+ {inboundSchedule.arrivalTime && format(new Date(inboundSchedule.arrivalTime), 'PPp')} +

+
+
+

Duration

+

{inboundSchedule.duration}

+
+
-
+ )} +
+
+
+ + {/* Booking date & payment summary */} +
+

Booking details

+
+
+

Booking date

+

+ {format(new Date(_booking?.createdAt || new Date()), 'PPp')} +

+
+
+

Status

+

{_booking?.status || 'CONFIRMED'}

+
+
+

Passengers

+

{passengers.length}

+
+
+

Total paid

+

+ ETB {((_booking?.totalMinor || passengers.reduce((s) => s + (selectedSchedule?.baseFareAdult || 0), 0)) / 100).toFixed(2)} +

diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 0a3e1d65b..272992b73 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -378,7 +378,7 @@ type FormData = z.infer; export default function PassengersPage() { const router = useRouter(); - const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore(); + const { searchCriteria, passengers: storedPassengers, setPassengers, setCreateAccount } = useBookingStore(); const { user, isAuthenticated, updateUser } = useAuthStore(); const isInitialized = useAuthStore((s) => s.isInitialized); const [faydaEnabled, setFaydaEnabled] = useState(true); @@ -393,22 +393,43 @@ export default function PassengersPage() { resolver: zodResolver(formSchema as any), mode: 'onChange', defaultValues: { - passengers: Array.from({ length: totalPassengers }, () => ({ - name: '', - dateOfBirth: '', - gender: undefined, - nationality: searchCriteria?.nationality || 'ETHIOPIAN', - phone: '', - email: '', - nationalId: '', - passportNumber: '', - passportCountry: '', - passportIssueDate: '', - passportExpiryDate: '', - passportIssuingAuthority: '', - faydaVerified: false, - formExpanded: false, - })), + passengers: Array.from({ length: totalPassengers }, (_, i) => { + const stored = storedPassengers[i]; + if (stored?.name) { + return { + name: stored.name, + dateOfBirth: stored.dateOfBirth || '', + gender: (stored.gender as any) || undefined, + nationality: stored.nationality || searchCriteria?.nationality || 'ETHIOPIAN', + phone: stored.phone || '', + email: stored.email || '', + nationalId: stored.nationalId || '', + passportNumber: stored.passportNumber || '', + passportCountry: stored.passportCountry || '', + passportIssueDate: stored.passportIssueDate || '', + passportExpiryDate: stored.passportExpiryDate || '', + passportIssuingAuthority: stored.passportIssuingAuthority || '', + faydaVerified: stored.faydaVerified || false, + formExpanded: true, + }; + } + return { + name: '', + dateOfBirth: '', + gender: undefined, + nationality: searchCriteria?.nationality || 'ETHIOPIAN', + phone: '', + email: '', + nationalId: '', + passportNumber: '', + passportCountry: '', + passportIssueDate: '', + passportExpiryDate: '', + passportIssuingAuthority: '', + faydaVerified: false, + formExpanded: false, + }; + }), createAccount: false, }, }); diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 5669240ad..56c293ac4 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -14,7 +14,9 @@ export default function ResultsPage() { const searchParams = useSearchParams(); const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = useBookingStore(); const [selectedCoachTypes, setSelectedCoachTypes] = useState>({}); - const [outboundScheduleData, setOutboundScheduleData] = useState(null); + const [outboundScheduleData, setOutboundScheduleData] = useState( + () => useBookingStore.getState().outboundSchedule, + ); const [classModal, setClassModal] = useState(null); const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null); 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 f0af1b438..7b94503a0 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 @@ -648,24 +648,37 @@ export default function ReviewPage() {

Fare breakdown

-
- {isRoundTrip ? ( - <> -
- Outbound fare - ETB {(outboundBaseFare / 100).toFixed(2)} +
+ {passengers.map((p, i) => { + const outFare = outboundSchedule?.baseFareAdult || 0; + const inFare = inboundSchedule?.baseFareAdult || 0; + const onewayFare = selectedSchedule?.baseFareAdult || 0; + const passengerTotal = isRoundTrip ? outFare + inFare : onewayFare; + return ( +
+
+ + {p.name || `Passenger ${i + 1}`} + + + ETB {(passengerTotal / 100).toFixed(2)} + +
+ {isRoundTrip && ( +
+
+ Outbound + ETB {(outFare / 100).toFixed(2)} +
+
+ Return + ETB {(inFare / 100).toFixed(2)} +
+
+ )}
-
- Return fare - ETB {(inboundBaseFare / 100).toFixed(2)} -
- - ) : ( -
- Base fare - ETB {(baseFare / 100).toFixed(2)} -
- )} + ); + })}
Total ETB {(total / 100).toFixed(2)} diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index a587bc87c..bb6340d0a 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -65,6 +65,16 @@ const searchSchema = z message: "Return date must be after departure date", path: ["returnDate"], }, + ) + .refine( + (d) => + !d.originStationId || + !d.destinationStationId || + d.originStationId !== d.destinationStationId, + { + message: "Departure and destination cannot be the same station", + path: ["destinationStationId"], + }, ); type SearchForm = z.infer; @@ -583,7 +593,7 @@ export default function SearchPage() { adultCount: 1, childCount: 0, nationality: "ETHIOPIAN", - departureDate: new Date().toISOString().split("T")[0], + departureDate: "", promoCode: "", }, }); @@ -609,6 +619,8 @@ export default function SearchPage() { const adults = searchParams.get("adults"); const children = searchParams.get("children"); const nat = searchParams.get("nationality"); + const tripType = searchParams.get("tripType"); + const returnDate = searchParams.get("returnDate"); if (o) setValue("originStationId", o); if (d) setValue("destinationStationId", d); if (date) setValue("departureDate", date); @@ -616,6 +628,8 @@ export default function SearchPage() { if (children) setValue("childCount", parseInt(children)); if (nat) setValue("nationality", nat as "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER"); + if (tripType) setValue("tripType", tripType as "ONE_WAY" | "ROUND_TRIP"); + if (returnDate) setValue("returnDate", returnDate); }, [searchParams, setValue]); useEffect(() => { diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index e088e0303..ba3fe0159 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -133,7 +133,6 @@ export default function SeatsPage() { ? outboundSchedule : selectedSchedule; const coachTypeId = (currentSchedule as any)?.selectedCoachTypeId; - const coachTypeCode = (currentSchedule as any)?.selectedCoachTypeCode; const { data: seatMapData, @@ -142,7 +141,7 @@ export default function SeatsPage() { } = useQuery({ queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType], queryFn: async () => { - const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeCode}`; + const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeId}`; console.log("🪑 Seatmap Request:", { endpoint, }); From cfbbd437e990d47450e1027ab6297b4576fc5326 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 25 Jun 2026 20:10:36 +0300 Subject: [PATCH 07/17] Backoffice UAT results addressed, packages and other updates --- apps/edr-passenger-api/prisma/seed.ts | 21 ++- apps/edr-passenger-api/src/main.ts | 78 ++++++-- .../src/modules/agents/agents.controller.ts | 22 ++- .../src/modules/agents/agents.dto.ts | 6 + .../src/modules/agents/agents.service.ts | 73 +++++++- .../excess-baggage.controller.ts | 34 +++- .../excess-baggage/excess-baggage.service.ts | 26 +++ .../src/modules/fleet/fleet.controller.ts | 9 + .../src/modules/fleet/fleet.dto.ts | 6 + .../src/modules/fleet/fleet.service.ts | 55 ++++-- .../modules/packages/packages.controller.ts | 13 +- .../src/modules/packages/packages.service.ts | 28 ++- .../modules/passengers/passengers.service.ts | 4 +- .../src/modules/schedules/routes.dto.ts | 1 + .../src/modules/schedules/routes.service.ts | 1 + .../modules/schedules/schedules.service.ts | 12 +- .../src/modules/tickets/tickets.controller.ts | 8 + .../src/modules/tickets/tickets.service.ts | 6 + .../backoffice/src/app/agents/page.tsx | 176 +++++++++++++++--- .../backoffice/src/app/bookings/page.tsx | 155 ++++++++++----- .../backoffice/src/app/packages/page.tsx | 29 +++ .../backoffice/src/app/passengers/page.tsx | 136 ++++++++++---- .../backoffice/src/app/pricing/page.tsx | 168 ++++++++++++++++- .../backoffice/src/app/routes/page.tsx | 11 ++ .../backoffice/src/app/stations/page.tsx | 13 ++ .../backoffice/src/app/tickets/page.tsx | 140 +++++++++----- .../backoffice/src/app/trains/page.tsx | 20 +- .../backoffice/src/lib/api/index.ts | 2 + 28 files changed, 1042 insertions(+), 211 deletions(-) diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 9c5a22b0f..aad992cea 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -689,8 +689,15 @@ async function seedKulubbiPackage() { const [outboundSchedule, returnSchedule] = schedules; await prisma.travelPackage.upsert({ - where: { code: 'KULUBBI-2025' }, - update: {}, + where: { code: 'KULUBI-2025' }, + update: { + validFrom: new Date(), + validUntil: new Date(new Date().setFullYear(new Date().getFullYear() + 1)), + boardingTime: new Date(new Date().setMonth(new Date().getMonth() + 1)), + departureTime: new Date(new Date().setMonth(new Date().getMonth() + 1)), + arrivalTime: new Date(new Date().setMonth(new Date().getMonth() + 1)), + status: 'ACTIVE', + }, create: { code: 'KULUBBI-2025', name: 'Kulubbi Gabriel Pilgrimage Package', @@ -699,15 +706,15 @@ async function seedKulubbiPackage() { returnScheduleId: returnSchedule.id, originStationId: addisStation.id, destinationStationId: direDawaStation.id, - boardingTime: new Date('2025-07-24T07:00:00+03:00'), - departureTime: new Date('2025-07-24T09:00:00+03:00'), - arrivalTime: new Date('2025-07-25T06:00:00+03:00'), + boardingTime: new Date(new Date().setMonth(new Date().getMonth() + 1)), + departureTime: new Date(new Date().setMonth(new Date().getMonth() + 1)), + arrivalTime: new Date(new Date().setMonth(new Date().getMonth() + 1)), totalCapacity: 912, coachConfiguration: '1 Locomotive + 2SBC + 2HBC + 6HSC', busTransferIncluded: true, busTransferRoute: 'Dire Dawa ↔ Kulubi Gabriel', - validFrom: new Date('2025-07-01'), - validUntil: new Date('2025-07-24T09:00:00+03:00'), + validFrom: new Date(), + validUntil: new Date(new Date().setFullYear(new Date().getFullYear() + 1)), status: 'ACTIVE', includedServices: [ 'Round-trip train travel (Addis Ababa ↔ Dire Dawa)', diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 707a65fa4..86d33debb 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -1,4 +1,4 @@ -// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM +// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM // modules read process.env at module-load time (e.g. MinioModule.register reads MINIO_ENDPOINT), // which happens before ConfigModule.forRoot() would populate it. Must be the very first import. import "dotenv/config"; @@ -18,7 +18,7 @@ async function bootstrap() { // URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under // `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay - // version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend. + // version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend. app.enableVersioning({ type: VersioningType.URI }); app.enableCors({ @@ -126,7 +126,7 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m - Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps) - Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets - Complete audit trail per leg for compliance and reporting -- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode +- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode ### Booking Type Matrix @@ -236,28 +236,28 @@ For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`ret ### 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: 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\` +- 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 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\` +- **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) @@ -311,6 +311,8 @@ Payment providers send notifications to: "JWT-auth", ) .addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation") + .addTag("Excess Baggage", "IAM-protected agent/supervisor endpoints to log excess baggage charges, waive fees, resend payment links, and manage allowance rules per seat class. Public token-based endpoints let passengers self-pay outstanding charges.") + .addTag("Packages", "Bundled travel packages with tiered pricing. Public endpoints for browsing and booking; JWT-authenticated endpoints for purchase history; IAM-protected endpoints for admin CRUD and tier management.") .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 ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT booking types. returnLegStatus filter for round-trip no-show management") @@ -347,6 +349,50 @@ Payment providers send notifications to: .build(); const document = SwaggerModule.createDocument(app, config); + + // Collapse all IAM / platform-infrastructure tags into one Swagger tag so every + // endpoint from @tria-plc/iamapi-common and @tria-plc/api-common appears under + // a single "Corporate IAM & Platform Infrastructure" section. + const IAM_UNIFIED_TAG = 'Corporate IAM & Platform Infrastructure'; + const IAM_SOURCE_TAGS = new Set([ + 'Auth', 'Sessions', 'API_COMMON_File Settings', + 'IAM_USER__Users', 'IAM_USER__User Document', 'IAM_USER__User Roles', + 'IAM_USER__Roles', 'IAM_USER__Role Permissions', 'IAM_USER__Permissions', + 'IAM_USER__Applications', 'IAM_USER__Account Configurations', 'IAM_USER__Documentary Requirements', + 'IAM_ORGANISATION_STRUCTURE__Organizations', 'IAM_ORGANISATION_STRUCTURE__Organization Types', + 'IAM_ORGANISATION_STRUCTURE__Organization Configurations', + 'IAM_ORGANISATION_STRUCTURE__Global Organization Configurations', + 'IAM_ORGANISATION_STRUCTURE__Organization Settings', + 'IAM_ORGANISATION_STRUCTURE__Units', 'IAM_ORGANISATION_STRUCTURE__Unit Settings', + 'IAM_ORGANISATION_STRUCTURE__Global Unit Configurations', 'IAM_ORGANISATION_STRUCTURE__Unit Clusters', + 'IAM_ORGANISATION_STRUCTURE__Positions', 'IAM_ORGANISATION_STRUCTURE__Position Types', + 'IAM_ORGANISATION_STRUCTURE__Position Configurations', + 'IAM_ORGANISATION_STRUCTURE__Position Type Configurations', + 'IAM_ORGANISATION_STRUCTURE__Position Permissions', 'IAM_ORGANISATION_STRUCTURE__Position Type Permissions', + 'IAM_ORGANISATION_STRUCTURE__Employees', 'IAM_ORGANISATION_STRUCTURE__Employee Positions', + 'IAM_ORGANISATION_STRUCTURE__Locations', 'IAM_ORGANISATION_STRUCTURE__Location Types', + 'IAM_ORGANISATION_STRUCTURE__Default Units', 'IAM_ORGANISATION_STRUCTURE__Default Positions', + 'IAM_ORGANISATION_STRUCTURE__Projects', 'IAM_ORGANISATION_STRUCTURE__Migrate', + 'IAM_RECORD__Headers', 'IAM_RECORD__Footers', 'IAM_RECORD__Seals', + 'IAM_RECORD__Employee Signatures', 'IAM_RECORD__Employee Stamps', + ]); + + // Re-tag every operation whose tags overlap with IAM_SOURCE_TAGS + for (const pathItem of Object.values(document.paths)) { + for (const operation of Object.values(pathItem as Record)) { + if (Array.isArray(operation?.tags)) { + const hasIam = operation.tags.some((t: string) => IAM_SOURCE_TAGS.has(t)); + if (hasIam) operation.tags = [IAM_UNIFIED_TAG]; + } + } + } + + // Replace the individual source tag definitions with the single unified tag + document.tags = [ + ...(document.tags ?? []).filter((t: any) => !IAM_SOURCE_TAGS.has(t.name)), + { name: IAM_UNIFIED_TAG, description: 'Back-office staff authentication, session management, organisation structure, user/role/permission management, and file settings. Provided by @tria-plc/iamapi-common and @tria-plc/api-common.' }, + ]; + SwaggerModule.setup("api-docs", app, document, { customSiteTitle: "EDR Passenger API", swaggerOptions: { @@ -360,7 +406,7 @@ Payment providers send notifications to: const port = process.env.PORT ?? 4000; await app.listen(port); - console.log(`🚀 EDR Passenger API running on port ${port}`); - console.log(`📚 Swagger: http://localhost:${port}/api-docs`); + console.log(`🚀 EDR Passenger API running on port ${port}`); + console.log(`📚 Swagger: http://localhost:${port}/api-docs`); } bootstrap(); diff --git a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts index 139782475..d8be18e74 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts @@ -1,7 +1,7 @@ -import { Body, Controller, Get, Param, Post, Query, Request, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AgentsService } from './agents.service'; -import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; +import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; @ApiTags('Agents') @@ -16,6 +16,24 @@ export class AgentsController { getMe(@Request() req: any) { return this.service.getMe(req.user?.id ?? req.user?.sub); } + + @Get() + @ApiOperation({ summary: 'List all agents' }) + findAll(@Query('search') search?: string, @Query('active') active?: string) { + return this.service.findAll({ search, active }); + } + + @Post() + @ApiOperation({ summary: 'Create agent profile linked to an IAM user' }) + createAgent(@Body() dto: CreateAgentDto) { + return this.service.createAgent(dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update agent profile' }) + updateAgent(@Param('id') id: string, @Body() dto: Partial & { active?: boolean }) { + return this.service.updateAgent(id, dto); + } @Post('bookings') @ApiOperation({ summary: 'Create agent booking with cash payment' }) createBooking(@Body() dto: CreateAgentBookingDto) { diff --git a/apps/edr-passenger-api/src/modules/agents/agents.dto.ts b/apps/edr-passenger-api/src/modules/agents/agents.dto.ts index 75f718d23..427b85b9e 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.dto.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.dto.ts @@ -2,6 +2,12 @@ import { IsString, IsInt, IsBoolean, IsOptional, IsArray, ValidateNested } from import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +export class CreateAgentDto { + @ApiProperty() @IsString() iamUserId: string; + @ApiPropertyOptional() @IsOptional() @IsString() agentCode?: string; + @ApiPropertyOptional() @IsOptional() @IsInt() commissionRate?: number; +} + export class AgentPassengerDto { @ApiProperty() @IsString() fullName: string; @ApiProperty() @IsString() phone: string; diff --git a/apps/edr-passenger-api/src/modules/agents/agents.service.ts b/apps/edr-passenger-api/src/modules/agents/agents.service.ts index 4d4b593fb..cd17da441 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.service.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.service.ts @@ -1,4 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; import { IdDocumentType } from '@prisma/client'; @@ -10,7 +12,49 @@ function generateRef(): string { @Injectable() export class AgentsService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + @InjectDataSource() private readonly dataSource: DataSource, + ) {} + + async findAll(filters: { search?: string; active?: string }) { + const where: any = {}; + if (filters.active !== undefined && filters.active !== '') { + where.active = filters.active === 'true'; + } + const agents = await this.prisma.agent.findMany({ + where, + orderBy: { createdAt: 'desc' }, + }); + // Enrich with IAM user data + const iamUserIds = agents.map(a => a.iamUserId).filter(Boolean) as string[]; + type IamRow = { id: string; email: string; name: any; phone_number: string | null }; + const iamRows: IamRow[] = iamUserIds.length > 0 + ? await this.dataSource.query( + `SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`, + [iamUserIds], + ).catch(() => []) + : []; + const iamMap = new Map(iamRows.map(r => [r.id, r])); + const items = agents + .map(a => { + const iam = a.iamUserId ? iamMap.get(a.iamUserId) ?? null : null; + const fullName = iam?.name?.en ?? iam?.name?.am ?? null; + if (filters.search) { + const q = filters.search.toLowerCase(); + const matches = a.agentCode.toLowerCase().includes(q) + || (iam?.email ?? '').toLowerCase().includes(q) + || (fullName ?? '').toLowerCase().includes(q); + if (!matches) return null; + } + return { + ...a, + user: iam ? { fullName, email: iam.email, phone: iam.phone_number } : null, + }; + }) + .filter(Boolean); + return { items, total: items.length }; + } async createAgentBooking(dto: CreateAgentBookingDto) { const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId } }); @@ -139,4 +183,31 @@ export class AgentsService { if (!agent) throw new NotFoundException('No agent profile found for this user'); return agent; } + + async createAgent(dto: { iamUserId: string; agentCode?: string; commissionRate?: number }) { + const existing = await this.prisma.agent.findUnique({ where: { iamUserId: dto.iamUserId } }); + if (existing) throw new BadRequestException('An agent profile already exists for this user'); + const agentCode = dto.agentCode || `AG${String(Date.now()).slice(-4)}`; + return this.prisma.agent.create({ + data: { + iamUserId: dto.iamUserId, + agentCode, + commissionRate: dto.commissionRate ?? 5, + active: true, + }, + }); + } + + async updateAgent(id: string, dto: { agentCode?: string; commissionRate?: number; active?: boolean }) { + const agent = await this.prisma.agent.findUnique({ where: { id } }); + if (!agent) throw new NotFoundException('Agent not found'); + return this.prisma.agent.update({ + where: { id }, + data: { + ...(dto.agentCode !== undefined && { agentCode: dto.agentCode }), + ...(dto.commissionRate !== undefined && { commissionRate: dto.commissionRate }), + ...(dto.active !== undefined && { active: dto.active }), + }, + }); + } } diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts index 6bbb190d0..55d55b9e7 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -1,5 +1,6 @@ -import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { IsInt, IsPositive, IsString } from 'class-validator'; import { ExcessBaggageService } from './excess-baggage.service'; import { LogExcessBaggageDto, @@ -8,6 +9,13 @@ import { } from './excess-baggage.dto'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +class UpsertBaggageAllowanceDto { + @IsString() seatClassId: string; + @IsInt() @IsPositive() maxWeightKg: number; + @IsInt() @IsPositive() maxPiecesCount: number; + @IsInt() @IsPositive() excessFeePerKg: number; +} + // ── IAM-protected agent/supervisor routes ──────────────────────────────────── @ApiTags('Excess Baggage') @Controller('agents/excess-baggage') @@ -55,6 +63,30 @@ export class ExcessBaggageAgentController { waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) { return this.service.waiveCharge(id, dto); } + + @Get('allowances') + @ApiOperation({ summary: 'List all baggage allowance rules' }) + getAllowances() { + return this.service.getAllowances(); + } + + @Post('allowances') + @ApiOperation({ summary: 'Create baggage allowance rule for a seat class' }) + createAllowance(@Body() dto: UpsertBaggageAllowanceDto) { + return this.service.upsertAllowance(dto); + } + + @Patch('allowances/:id') + @ApiOperation({ summary: 'Update baggage allowance rule' }) + updateAllowance(@Param('id') id: string, @Body() dto: Partial) { + return this.service.updateAllowance(id, dto); + } + + @Delete('allowances/:id') + @ApiOperation({ summary: 'Delete baggage allowance rule' }) + deleteAllowance(@Param('id') id: string) { + return this.service.deleteAllowance(id); + } } // ── Public pay-by-token routes (passenger self-service) ────────────────────── diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts index e868c0092..91396f191 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts @@ -249,4 +249,30 @@ export class ExcessBaggageService { return { items, total, page, pageSize }; } + + async getAllowances() { + const [allowances, seatClasses] = await Promise.all([ + this.prisma.baggageAllowance.findMany({ orderBy: { createdAt: 'asc' } }), + this.prisma.seatClass.findMany({ select: { id: true, name: true } }), + ]); + const scMap = new Map(seatClasses.map(s => [s.id, s])); + return allowances.map(a => ({ ...a, seatClass: scMap.get(a.seatClassId) ?? null })); + } + + async upsertAllowance(dto: { seatClassId: string; maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }) { + return this.prisma.baggageAllowance.upsert({ + where: { seatClassId: dto.seatClassId } as any, + update: { maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg }, + create: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg }, + }); + } + + async updateAllowance(id: string, dto: Partial<{ maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }>) { + return this.prisma.baggageAllowance.update({ where: { id }, data: dto }); + } + + async deleteAllowance(id: string) { + await this.prisma.baggageAllowance.delete({ where: { id } }); + return { deleted: true }; + } } diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index 45c600c67..ce4c64c46 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -153,6 +153,15 @@ export class FleetController { return this.service.deleteTrain(id); } + @Patch('trains/:id/restore') + @ApiOperation({ summary: 'Restore (reactivate) a deactivated train' }) + @ApiParam({ name: 'id', description: 'Train UUID' }) + @ApiResponse({ status: 200, description: 'Train restored' }) + @ApiResponse({ status: 404, description: 'Train not found' }) + restoreTrain(@Param('id') id: string) { + return this.service.restoreTrain(id); + } + // Coach Endpoints @Get('coaches') @ApiOperation({ summary: 'List coaches with seat status summary' }) diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts index 34579c627..12c9c18df 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts @@ -7,6 +7,7 @@ export class CreateTrainDto { @ApiPropertyOptional({ example: 'EDR', description: 'Operator ID (defaults to op_edr)' }) @IsOptional() @IsString() operatorId?: string; @ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string; @ApiPropertyOptional({ example: 'Addis-Djibouti Express' }) @IsOptional() @IsString() description?: string; + @ApiPropertyOptional({ example: true, description: 'Whether the train is active' }) @IsOptional() @IsBoolean() isActive?: boolean; } export class CreateCoachDto { @@ -26,6 +27,8 @@ export class CreateCoachDto { description: 'Beds per compartment/room. Must be even (split equally left/right). Defaults: VIP_BED=4, ECONOMY_BED=6. Only applies when bedCategory is set.', }) @IsOptional() @IsInt() bedsPerRoom?: number; + @ApiPropertyOptional({ example: 1, description: 'Sequence number for ordering coaches in the train' }) + @IsOptional() @IsInt() sequence?: number; } export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) { @@ -66,6 +69,9 @@ export class CreateClassDto { @ApiProperty({ example: 'Economy' }) @IsString() name: string; @IsOptional() @IsString() description?: string; @ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number; + @ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() premiumMinor?: number; + @ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() insuranceFeeMinor?: number; + @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isActive?: boolean; } export class UpdateClassDto { diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index 09a1b4ed3..59c07d742 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -224,6 +224,9 @@ export class FleetService { name: dto.name, description: dto.description, baseFareMinor: dto.baseFareMinor, + ...(dto.premiumMinor !== undefined && { premiumMinor: dto.premiumMinor }), + ...(dto.insuranceFeeMinor !== undefined && { insuranceFeeMinor: dto.insuranceFeeMinor }), + ...(dto.isActive !== undefined && { isActive: dto.isActive }), }, }); } @@ -307,34 +310,54 @@ export class FleetService { } createTrain(dto: CreateTrainDto) { - return this.prisma.train.create({ data: dto }); + return this.prisma.train.create({ + data: { + number: dto.number, + name: dto.name, + operatorId: dto.operatorId, + operatorName: dto.operatorName, + description: dto.description, + isActive: dto.isActive ?? true, + }, + }); } async updateTrain(id: string, dto: CreateTrainDto) { const train = await this.prisma.train.findUnique({ where: { id } }); if (!train) throw new NotFoundException('Train not found'); - return this.prisma.train.update({ where: { id }, data: dto }); + return this.prisma.train.update({ + where: { id }, + data: { + number: dto.number, + name: dto.name, + operatorId: dto.operatorId, + operatorName: dto.operatorName, + description: dto.description, + ...(dto.isActive !== undefined && { isActive: dto.isActive }), + }, + }); } async deleteTrain(id: string) { const train = await this.prisma.train.findUnique({ where: { id }, - include: { - schedules: true, - }, + include: { schedules: true }, }); if (!train) throw new NotFoundException('Train not found'); - - // Check for active schedules if (train.schedules.length > 0) { throw new BadRequestException( `Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.` ); } - return this.prisma.train.delete({ where: { id } }); } + async restoreTrain(id: string) { + const train = await this.prisma.train.findUnique({ where: { id } }); + if (!train) throw new NotFoundException('Train not found'); + return this.prisma.train.update({ where: { id }, data: { isActive: true } }); + } + async getCoach(id: string) { const coach = await this.prisma.coach.findUnique({ where: { id }, @@ -370,18 +393,20 @@ export class FleetService { throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`); } - // Get the next sequence number for this coach type - const lastCoach = await this.prisma.coach.findFirst({ - where: { coachTypeId: dto.coachTypeId }, - orderBy: { sequence: 'desc' }, - }); - const nextSequence = (lastCoach?.sequence ?? 0) + 1; + // Use user-provided sequence or auto-assign the next one + let resolvedSequence = dto.sequence; + if (resolvedSequence === undefined || resolvedSequence === null) { + const lastCoach = await this.prisma.coach.findFirst({ + orderBy: { sequence: 'desc' }, + }); + resolvedSequence = (lastCoach?.sequence ?? 0) + 1; + } const coach = await this.prisma.coach.create({ data: { coachTypeId: dto.coachTypeId, number: dto.number, - sequence: nextSequence, + sequence: resolvedSequence, arrangement: dto.arrangement, capacity: dto.capacity, status: dto.status || 'ACTIVE', diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts index 9da8a759b..6e6551030 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -19,9 +19,8 @@ export class PackagesController { } @Get('all') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'List all packages (admin)' }) + @IsPublic() + @ApiOperation({ summary: 'List all packages' }) listAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) { return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20); } @@ -64,6 +63,14 @@ export class PackagesController { return this.service.update(id, dto); } + @Delete(':id') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Delete package (admin)' }) + remove(@Param('id') id: string) { + return this.service.remove(id); + } + @Patch(':id/activate') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index 671e8b1c5..4a320d3b8 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -117,6 +117,28 @@ export class PackagesService { return this.prisma.packagePriceTier.delete({ where: { id: tierId } }); } + async remove(id: string) { + const pkg = await this.prisma.travelPackage.findUnique({ + where: { id }, + include: { bookings: { select: { id: true, status: true } } }, + }); + if (!pkg) throw new NotFoundException('Package not found'); + const hasActive = pkg.bookings.some((b) => b.status === 'PENDING_PAYMENT' || b.status === 'CONFIRMED'); + if (hasActive) throw new BadRequestException('Cannot delete a package with active bookings'); + + await this.prisma.$transaction(async (tx) => { + const bookingIds = pkg.bookings.map((b) => b.id); + if (bookingIds.length > 0) { + await tx.packageBookingPassenger.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await tx.packagePaymentIntent.deleteMany({ where: { packageBookingId: { in: bookingIds } } }); + await tx.packageBooking.deleteMany({ where: { packageId: id } }); + } + await tx.packagePriceTier.deleteMany({ where: { packageId: id } }); + await tx.travelPackage.delete({ where: { id } }); + }); + return { deleted: true }; + } + async activate(id: string) { const pkg = await this.prisma.travelPackage.findUnique({ where: { id } }); if (!pkg) throw new NotFoundException('Package not found'); @@ -228,7 +250,11 @@ export class PackagesService { this.prisma.travelPackage.findMany({ skip, take: pageSize, - include: { priceTiers: true }, + include: { + priceTiers: true, + outboundSchedule: { include: { originStation: true, destinationStation: true } }, + returnSchedule: { include: { originStation: true, destinationStation: true } }, + }, orderBy: { createdAt: 'desc' }, }), this.prisma.travelPackage.count(), 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 893bdb6ae..34b4a82f5 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -432,10 +432,12 @@ export class PassengersService { this.prisma.notification.deleteMany({ where: { passengerId: id } }), this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }), this.prisma.savedRoute.deleteMany({ where: { passengerId: id } }), - this.prisma.journey.deleteMany({ where: { passengerId: id } }), this.prisma.packageBooking.deleteMany({ where: { passengerId: id } }), + this.prisma.ticket.deleteMany({ where: { booking: { passengerId: id } } }), this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }), this.prisma.booking.deleteMany({ where: { passengerId: id } }), + this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId: id } } }), + this.prisma.journey.deleteMany({ where: { passengerId: id } }), this.prisma.passenger.delete({ where: { id } }), ]); diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index 230dad3f8..21768ab8b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -14,6 +14,7 @@ export class CreateRouteDto { @ApiPropertyOptional({ example: 'Main corridor via Dire Dawa' }) @IsOptional() @IsString() description?: string; @ApiProperty({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsDateString() effectiveFrom: string; @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; + @ApiPropertyOptional({ example: true, description: 'Whether the route is active (defaults to true)' }) @IsOptional() @IsBoolean() active?: boolean; @ApiProperty({ type: [RouteStopInputDto], description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.', diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index cb0099983..f60bba1fb 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -26,6 +26,7 @@ export class RoutesService { code: dto.code, name: dto.name, description: dto.description, + active: dto.active ?? true, effectiveFrom: new Date(dto.effectiveFrom), effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null, stops: { 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 0234824d1..48b0487cb 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -306,8 +306,18 @@ export class SchedulesService { } async deleteSchedule(id: string) { - const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } }); + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id }, + include: { _count: { select: { bookings: true } } }, + }); if (!schedule) throw new NotFoundException('Schedule not found'); + if ((schedule as any)._count.bookings > 0) { + throw new BadRequestException( + `Cannot delete schedule. It has ${(schedule as any)._count.bookings} booking(s). Cancel all bookings before deleting.`, + ); + } + await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } }); + await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } }); return this.prisma.trainSchedule.delete({ where: { id } }); } 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 029be67e2..2db2f9748 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -166,4 +166,12 @@ export class TicketsController { delete(@Param('id') id: string) { return this.service.delete(id); } + + @Patch(':id/restore') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' }) + restore(@Param('id') id: string) { + return this.service.restore(id); + } } 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 13e31c91a..7783e6b2d 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -567,4 +567,10 @@ export class TicketsService { return { deleted: true, ticketId: id }; } + + async restore(id: string) { + const ticket = await this.prisma.ticket.findUnique({ where: { id } }); + if (!ticket) throw new NotFoundException('Ticket not found'); + return this.prisma.ticket.update({ where: { id }, data: { status: 'ACTIVE' } }); + } } diff --git a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx index efb62c0c3..39e02dc3c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx @@ -1,14 +1,15 @@ 'use client'; import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { Plus, Edit, DollarSign, Clock, Eye } from 'lucide-react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Edit, Eye } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Badge from '@/components/ui/Badge'; import Modal from '@/components/ui/Modal'; -import { agentsApi } from '@/lib/api'; +import { agentsApi, apiClient } from '@/lib/api'; import { formatCurrency, formatDateTime } from '@/lib/utils'; +import { useAuthStore } from '@/lib/auth-store'; const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
@@ -24,8 +25,51 @@ const SectionHeader = ({ title }: { title: string }) => ( ); export default function AgentsPage() { + const { user } = useAuthStore(); + const queryClient = useQueryClient(); const [filters, setFilters] = useState({ search: '', active: '' }); const [selected, setSelected] = useState(null); + const [createModal, setCreateModal] = useState(false); + const [createForm, setCreateForm] = useState({ iamUserId: '', agentCode: '', commissionRate: '5' }); + const [createError, setCreateError] = useState(null); + + const createMutation = useMutation({ + mutationFn: (data: any) => apiClient.post('/agents', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agents'] }); + setCreateModal(false); + setCreateError(null); + }, + onError: (e: any) => setCreateError(e?.response?.data?.message || e?.message || 'Failed to create agent'), + }); + + const [editModal, setEditModal] = useState(false); + const [editForm, setEditForm] = useState({ agentCode: '', commissionRate: '5', active: true }); + const [editingAgent, setEditingAgent] = useState(null); + const [editError, setEditError] = useState(null); + + const editMutation = useMutation({ + mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/${id}`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agents'] }); + setEditModal(false); + setEditError(null); + }, + onError: (e: any) => setEditError(e?.response?.data?.message || e?.message || 'Failed to update agent'), + }); + + const openCreateModal = () => { + setCreateForm({ iamUserId: '', agentCode: '', commissionRate: '5' }); + setCreateError(null); + setCreateModal(true); + }; + + const openEditModal = (agent: any) => { + setEditingAgent(agent); + setEditForm({ agentCode: agent.agentCode, commissionRate: String(agent.commissionRate ?? 5), active: agent.active }); + setEditError(null); + setEditModal(true); + }; const { data, isLoading } = useQuery({ queryKey: ['agents', filters], @@ -67,39 +111,27 @@ export default function AgentsPage() { const actions = [ { - label: 'View Details', + label: 'Edit', + onClick: (agent: any) => openEditModal(agent), + variant: 'secondary' as const, + icon: Edit, + }, + { + label: 'Details', onClick: (agent: any) => setSelected(agent), variant: 'secondary' as const, icon: Eye, }, - { - label: 'View Shifts', - onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/shifts`; }, - variant: 'secondary' as const, - icon: Clock, - }, - { - label: 'View Commissions', - onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/commissions`; }, - variant: 'secondary' as const, - icon: DollarSign, - }, - { - label: 'Edit', - onClick: (agent: any) => console.log('Edit', agent), - variant: 'secondary' as const, - icon: Edit, - }, ]; return (
-

Agent Operations

-

Manage booking agents and their operations

+

Agents

+

Manage agents and their operations

- Add Agent + Add Agent
@@ -227,6 +259,100 @@ export default function AgentsPage() { ); })()} + {/* Create Agent Modal */} + setCreateModal(false)} title="Add Agent Profile" size="md"> +
+ {createError && ( +
+ {createError} +
+ )} +
+ + setCreateForm({ ...createForm, iamUserId: e.target.value })} + placeholder="IAM user UUID" + /> + {user?.id && createForm.iamUserId === user.id && ( +

✓ Pre-filled with your logged-in user ID

+ )} +

Links this agent profile to an IAM back-office user

+
+
+
+ + setCreateForm({ ...createForm, agentCode: e.target.value })} + placeholder="e.g. AG0002 (auto-generated if empty)" + /> +
+
+ + setCreateForm({ ...createForm, commissionRate: e.target.value })} + /> +
+
+
+ setCreateModal(false)}>Cancel + createMutation.mutate({ + iamUserId: createForm.iamUserId, + agentCode: createForm.agentCode || undefined, + commissionRate: parseInt(createForm.commissionRate) || 5, + })} + > + Create Agent + +
+
+
+ {/* Edit Agent Modal */} + setEditModal(false)} title="Edit Agent" size="md"> +
+ {editError && ( +
+ {editError} +
+ )} +
+
+ + setEditForm({ ...editForm, agentCode: e.target.value })} /> +
+
+ + setEditForm({ ...editForm, commissionRate: e.target.value })} /> +
+
+
+ + +
+
+ setEditModal(false)}>Cancel + editMutation.mutate({ + id: editingAgent.id, + agentCode: editForm.agentCode, + commissionRate: parseInt(editForm.commissionRate) || 5, + active: editForm.active, + })}>Save Changes +
+
+
); } 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 81581db44..d8d8987ea 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -28,16 +28,19 @@ const SectionHeader = ({ title }: { title: string }) => ( export default function BookingsPage() { const [filters, setFilters] = useState({ page: 1, pageSize: 20, search: '', status: '' }); + const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' }); + const [showExtraFilters, setShowExtraFilters] = useState(false); const [selectedBooking, setSelectedBooking] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [bookingToDelete, setBookingToDelete] = useState(null); const [deleteError, setDeleteError] = useState(null); const [successMessage, setSuccessMessage] = useState(''); const [exportModalOpen, setExportModalOpen] = useState(false); + const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); const [exportDateFrom, setExportDateFrom] = useState(''); const [exportDateTo, setExportDateTo] = useState(''); const [exportColumns, setExportColumns] = useState>({ - bookingRef: true, bookingType: false, passengerNames: true, contactPhone: true, + bookingRef: true, bookingType: true, passengerNames: true, contactPhone: true, contactEmail: true, passengerCount: false, paymentStatus: true, totalMinor: true, status: true, createdAt: true, }); @@ -87,43 +90,56 @@ export default function BookingsPage() { { key: 'status', label: 'Status' }, { key: 'createdAt', label: 'Created At' }, ]; - const confirmExport = () => { + const confirmExport = async () => { const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); if (!cols.length) { alert('Please select at least one column'); return; } - const exportItems = (data?.items || []).filter((b: any) => { + // Fetch all records (not just current page) + const allData = await bookingsApi.getAll({ ...filters, page: 1, pageSize: 9999 }); + const exportItems = (allData?.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 = [ - BOOKING_COLS.map(c => `"${c.label}"`).join(','), - ...exportItems.map((booking: any) => { - const values = BOOKING_COLS.filter(c => cols.includes(c.key)).map(({ key }) => { - switch (key) { - case 'bookingRef': return booking.bookingRef; - case 'journeyType': return booking.bookingType || 'N/A'; - case 'passengerNames': return booking.passengerNames?.join(', ') || 'N/A'; - case 'contactPhone': return booking.contactPhone || 'N/A'; - case 'contactEmail': return booking.contactEmail || 'N/A'; - case 'passengerCount': return (booking.adultCount ?? 0) + (booking.childCount ?? 0); - case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING'; - case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency); - case 'status': return booking.status; - case 'createdAt': return booking.createdAt ? formatDateTime(booking.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 = `bookings-${new Date().toISOString().split('T')[0]}.csv`; - a.click(); + const rows = exportItems.map((booking: any) => + BOOKING_COLS.filter(c => cols.includes(c.key)).map(({ key }) => { + switch (key) { + case 'bookingRef': return booking.bookingRef; + case 'journeyType': return booking.bookingType || 'N/A'; + case 'passengerNames': return booking.passengerNames?.join(', ') || 'N/A'; + case 'contactPhone': return booking.contactPhone || 'N/A'; + case 'contactEmail': return booking.contactEmail || 'N/A'; + case 'passengerCount': return String((booking.adultCount ?? 0) + (booking.childCount ?? 0)); + case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING'; + case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency); + case 'status': return booking.status; + case 'createdAt': return booking.createdAt ? formatDateTime(booking.createdAt) : ''; + default: return ''; + } + }) + ); + const headers = BOOKING_COLS.filter(c => cols.includes(c.key)).map(c => c.label); + const dateStr = new Date().toISOString().split('T')[0]; + if (exportFormat === 'pdf') { + const w = window.open('', '_blank')!; + w.document.write(`Bookings Export`); + w.document.write(`

Bookings Export — ${dateStr}

${headers.map(h => ``).join('')}`); + rows.forEach(r => { w.document.write(`${r.map(v => ``).join('')}`); }); + w.document.write('
${h}
${v}
'); + w.document.close(); + w.print(); + } else if (exportFormat === 'excel') { + const tsv = [headers.join('\t'), ...rows.map(r => r.join('\t'))].join('\n'); + const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); a.href = url; a.download = `bookings-${dateStr}.xls`; a.click(); + } else { + const csv = [headers.map(h => `"${h}"`).join(','), ...rows.map(r => r.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-${dateStr}.csv`; a.click(); + } setExportModalOpen(false); }; @@ -205,19 +221,60 @@ export default function BookingsPage() { Error loading bookings: {error instanceof Error ? error.message : 'Unknown error'}
)} -
-
- setFilters({ ...filters, search: e.target.value, page: 1 })} /> +
+
+
+ setFilters({ ...filters, search: e.target.value, page: 1 })} /> +
+ +
- + {showExtraFilters && ( +
+
+ + +
+
+ + +
+
+ + setExtraFilters({ ...extraFilters, dateFrom: e.target.value })} /> +
+
+ + setExtraFilters({ ...extraFilters, dateTo: e.target.value })} /> +
+
+ )}
{data?.meta && ( @@ -397,9 +454,21 @@ export default function BookingsPage() { ))}
+
+

Export Format

+
+ {(['csv', 'excel', 'pdf'] as const).map(fmt => ( + + ))} +
+
setExportModalOpen(false)}>Cancel - Export CSV + Export
diff --git a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx index 4d84ebdba..70a269549 100644 --- a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx @@ -39,6 +39,8 @@ export default function PackagesPage() { const [tierForm, setTierForm] = useState({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); const [deleteTierConfirm, setDeleteTierConfirm] = useState(null); const [tierError, setTierError] = useState(null); + const [deletePackageConfirm, setDeletePackageConfirm] = useState(null); + const [deletePackageError, setDeletePackageError] = useState(null); const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ @@ -100,6 +102,16 @@ export default function PackagesPage() { onError: (e: any) => setTierError(e?.response?.data?.message || e?.message || 'Failed to update tier'), }); + const deletePackageMutation = useMutation({ + mutationFn: (id: string) => packagesApi.remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['packages'] }); + setDeletePackageConfirm(null); + setDeletePackageError(null); + }, + onError: (e: any) => setDeletePackageError(e?.response?.data?.message || e?.message || 'Failed to delete package'), + }); + const deleteTierMutation = useMutation({ mutationFn: (tierId: string) => packagesApi.deleteTier(tierId), onSuccess: (_, tierId) => { @@ -251,6 +263,10 @@ export default function PackagesPage() { onClick: (p: any) => setActivateConfirm(p), hidden: (p: any) => p.status === 'ACTIVE', }, + { + label: 'Delete', icon: Trash2, variant: 'danger' as const, + onClick: (p: any) => { setDeletePackageError(null); setDeletePackageConfirm(p); }, + }, ]; const isPending = createMutation.isPending || updateMutation.isPending; @@ -401,6 +417,19 @@ export default function PackagesPage() { )} + {/* Delete Package Confirmation */} + { setDeletePackageConfirm(null); setDeletePackageError(null); }} + onConfirm={() => deletePackageMutation.mutate(deletePackageConfirm.id)} + title="Delete Package" + message={`Delete "${deletePackageConfirm?.name}"? This will also remove all price tiers and cannot be undone.`} + confirmText="Delete" + isDanger + isLoading={deletePackageMutation.isPending} + error={deletePackageError ?? undefined} + /> + {/* Delete Tier Confirmation */} = { export default function PassengersPage() { const [filters, setFilters] = useState({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' }); + const [showExtraFilters, setShowExtraFilters] = useState(false); + const [extraFilters, setExtraFilters] = useState({ gender: '', nationality: '', dateFrom: '', dateTo: '' }); const [selectedPassenger, setSelectedPassenger] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null }); const [deleteError, setDeleteError] = useState(null); const [exportModalOpen, setExportModalOpen] = useState(false); + const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); const [exportDateFrom, setExportDateFrom] = useState(''); const [exportDateTo, setExportDateTo] = useState(''); const [exportColumns, setExportColumns] = useState>({ @@ -70,40 +73,52 @@ export default function PassengersPage() { { key: 'nationality', label: 'Nationality' }, { key: 'verified', label: 'Verified' }, ]; - const confirmExportPassengers = () => { + const confirmExportPassengers = async () => { const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); if (!cols.length) { alert('Please select at least one column'); return; } - const exportItems = (data?.items || []).filter((p: any) => { + // Fetch all records + const allData = await passengersApi.getAll({ ...filters, page: 1, pageSize: 9999 }); + const exportItems = (allData?.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 = [ - PASSENGER_COLS.map(c => `"${c.label}"`).join(','), - ...exportItems.map((p: any) => { - const values = PASSENGER_COLS.filter(c => cols.includes(c.key)).map(({ key }) => { - switch (key) { - case 'fullName': return p.fullName; - case 'email': return p.email || ''; - case 'phone': return p.phone || ''; - case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : ''; - case 'gender': return p.gender || ''; - case 'nationality': return p.nationality || ''; - case 'verified': return p.faydaVerified ? 'Yes' : 'No'; - 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 = `passengers-${new Date().toISOString().split('T')[0]}.csv`; - a.click(); + const headers = PASSENGER_COLS.filter(c => cols.includes(c.key)).map(c => c.label); + const rows = exportItems.map((p: any) => + PASSENGER_COLS.filter(c => cols.includes(c.key)).map(({ key }) => { + switch (key) { + case 'fullName': return p.fullName || ''; + case 'email': return p.email || ''; + case 'phone': return p.phone || ''; + case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : ''; + case 'gender': return p.gender || ''; + case 'nationality': return p.nationality || ''; + case 'verified': return p.faydaVerified ? 'Yes' : 'No'; + default: return ''; + } + }) + ); + const dateStr = new Date().toISOString().split('T')[0]; + if (exportFormat === 'pdf') { + const w = window.open('', '_blank')!; + w.document.write(`Passengers Export`); + w.document.write(`

Passengers Export — ${dateStr}

${headers.map(h => ``).join('')}`); + rows.forEach(r => { w.document.write(`${r.map(v => ``).join('')}`); }); + w.document.write('
${h}
${v}
'); + w.document.close(); w.print(); + } else if (exportFormat === 'excel') { + const tsv = [headers.join('\t'), ...rows.map(r => r.join('\t'))].join('\n'); + const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); a.href = url; a.download = `passengers-${dateStr}.xls`; a.click(); + } else { + const csv = [headers.map(h => `"${h}"`).join(','), ...rows.map(r => r.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-${dateStr}.csv`; a.click(); + } setExportModalOpen(false); }; @@ -152,17 +167,52 @@ export default function PassengersPage() { Error loading passengers: {error instanceof Error ? error.message : 'Unknown error'}
)} -
-
- setFilters({ ...filters, search: e.target.value, page: 1 })} /> +
+
+
+ setFilters({ ...filters, search: e.target.value, page: 1 })} /> +
+ +
- + {showExtraFilters && ( +
+
+ + +
+
+ + setExtraFilters({ ...extraFilters, nationality: e.target.value })} /> +
+
+ + setExtraFilters({ ...extraFilters, dateFrom: e.target.value })} /> +
+
+ + setExtraFilters({ ...extraFilters, dateTo: e.target.value })} /> +
+
+ )}
{data?.meta && ( @@ -367,9 +417,21 @@ export default function PassengersPage() { ))}
+
+

Export Format

+
+ {(['csv', 'excel', 'pdf'] as const).map(fmt => ( + + ))} +
+
setExportModalOpen(false)}>Cancel - Export CSV + Export
diff --git a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx index 19fd44620..f83d955e5 100644 --- a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx @@ -34,7 +34,7 @@ interface SeatClass { } export default function PricingPage() { - const [tab, setTab] = useState<'schedule' | 'segment'>('schedule'); + const [tab, setTab] = useState<'schedule' | 'segment' | 'baggage'>('schedule'); const [showModal, setShowModal] = useState(false); const [selectedSchedule, setSelectedSchedule] = useState(''); const [selectedRoute, setSelectedRoute] = useState(''); @@ -67,6 +67,17 @@ export default function PricingPage() { validUntil: '', }); + const [baggageForm, setBaggageForm] = useState({ + seatClassId: '', + maxWeightKg: '', + maxPiecesCount: '', + excessFeePerKg: '', + }); + const [editingAllowance, setEditingAllowance] = useState(null); + const [baggageError, setBaggageError] = useState(null); + const [baggageModal, setBaggageModal] = useState(false); + const [deleteAllowanceConfirm, setDeleteAllowanceConfirm] = useState<{ isOpen: boolean; id: string | null }>({ isOpen: false, id: null }); + const { data: schedules = [] } = useQuery({ queryKey: ['schedules'], queryFn: () => apiClient.get('/schedules'), @@ -109,6 +120,48 @@ export default function PricingPage() { enabled: !!selectedRoute && tab === 'segment', }); + const { data: allowances = [], isLoading: allowancesLoading, refetch: refetchAllowances } = useQuery({ + queryKey: ['baggage-allowances'], + queryFn: () => apiClient.get('/agents/excess-baggage/allowances'), + enabled: tab === 'baggage', + }); + + const createAllowanceMutation = useMutation({ + mutationFn: (data: any) => apiClient.post('/agents/excess-baggage/allowances', data), + onSuccess: () => { refetchAllowances(); setBaggageModal(false); setBaggageError(null); }, + onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to save'), + }); + + const updateAllowanceMutation = useMutation({ + mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/excess-baggage/allowances/${id}`, data), + onSuccess: () => { refetchAllowances(); setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }, + onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to update'), + }); + + const deleteAllowanceMutation = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/agents/excess-baggage/allowances/${id}`), + onSuccess: () => { refetchAllowances(); setDeleteAllowanceConfirm({ isOpen: false, id: null }); }, + onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to delete'), + }); + + const handleSaveAllowance = async () => { + setBaggageError(null); + if (!baggageForm.seatClassId || !baggageForm.maxWeightKg || !baggageForm.maxPiecesCount || !baggageForm.excessFeePerKg) { + setBaggageError('All fields are required'); return; + } + const payload = { + seatClassId: baggageForm.seatClassId, + maxWeightKg: parseInt(baggageForm.maxWeightKg), + maxPiecesCount: parseInt(baggageForm.maxPiecesCount), + excessFeePerKg: Math.round(parseFloat(baggageForm.excessFeePerKg) * 100), + }; + if (editingAllowance) { + await updateAllowanceMutation.mutateAsync({ id: editingAllowance.id, ...payload }); + } else { + await createAllowanceMutation.mutateAsync(payload); + } + }; + const createFareMutation = useMutation({ mutationFn: (data: any) => apiClient.post(`/schedules/fares`, data), onSuccess: () => { @@ -346,6 +399,7 @@ export default function PricingPage() { const stationsArray = Array.isArray(stations) ? stations : (stations as any)?.items || []; const faresArray = Array.isArray(fares) ? fares : (fares as any)?.items || []; const segmentFaresArray = Array.isArray(segmentFares) ? segmentFares : (segmentFares as any)?.items || []; + const allowancesArray = Array.isArray(allowances) ? allowances : (allowances as any)?.items || []; const currentRoute = routesArray.find((r: Route) => r.id === selectedRoute); const fareColumns = [ @@ -498,7 +552,12 @@ export default function PricingPage() { onClick={() => { setError(null); setEditingFare(null); - if (tab === 'schedule') { + if (tab === 'baggage') { + setBaggageForm({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' }); + setEditingAllowance(null); + setBaggageError(null); + setBaggageModal(true); + } else if (tab === 'schedule') { setFareForm({ seatClassId: '', baseFare: '', @@ -523,7 +582,7 @@ export default function PricingPage() { setShowModal(true); }} > - Add Fare Rule + {tab === 'baggage' ? 'Add Allowance Rule' : 'Add Fare Rule'}
@@ -541,16 +600,21 @@ export default function PricingPage() { Schedule Fares +
@@ -658,6 +722,48 @@ export default function PricingPage() { )} )} + {tab === 'baggage' && ( + <> + {allowancesLoading ? ( +
+ ) : allowancesArray.length === 0 ? ( +
+ No baggage allowance rules defined. Click "Add Allowance Rule" to create one. +
+ ) : ( + {a.seatClass?.name ?? a.seatClassId} }, + { key: 'maxWeightKg', label: 'Free Allowance', render: (a: any) => {a.maxWeightKg} kg, {a.maxPiecesCount} pcs }, + { key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => {(a.excessFeePerKg / 100).toFixed(2)} ETB }, + ]} + actions={[ + { + label: 'Edit', icon: Edit, variant: 'secondary' as const, + onClick: (a: any) => { + setEditingAllowance(a); + setBaggageForm({ + seatClassId: a.seatClassId, + maxWeightKg: String(a.maxWeightKg), + maxPiecesCount: String(a.maxPiecesCount), + excessFeePerKg: (a.excessFeePerKg / 100).toFixed(2), + }); + setBaggageError(null); + setBaggageModal(true); + }, + }, + { + label: 'Delete', icon: Trash2, variant: 'danger' as const, + onClick: (a: any) => setDeleteAllowanceConfirm({ isOpen: true, id: a.id }), + }, + ]} + loading={false} + emptyMessage="No allowance rules found." + /> + )} + + )}
@@ -1009,6 +1115,54 @@ export default function PricingPage() {
+ {/* Baggage Allowance Modal */} + { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }} title={editingAllowance ? 'Edit Allowance Rule' : 'Add Allowance Rule'} size="md"> +
+ {baggageError &&
{baggageError}
} +
+ + + {editingAllowance &&

Seat class cannot be changed. Delete and recreate to change.

} +
+
+
+ + setBaggageForm({ ...baggageForm, maxWeightKg: e.target.value })} /> +
+
+ + setBaggageForm({ ...baggageForm, maxPiecesCount: e.target.value })} /> +
+
+
+ + setBaggageForm({ ...baggageForm, excessFeePerKg: e.target.value })} /> +

Amount charged per kg above the free allowance

+
+
+ { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }}>Cancel + + {editingAllowance ? 'Update' : 'Save'} + +
+
+
+ + {/* Delete Allowance Confirm */} + setDeleteAllowanceConfirm({ isOpen: false, id: null })} + onConfirm={() => deleteAllowanceMutation.mutateAsync(deleteAllowanceConfirm.id!)} + title="Delete Allowance Rule" + message="Are you sure you want to delete this baggage allowance rule?" + confirmText="Delete" + isDanger + isLoading={deleteAllowanceMutation.isPending} + warning="The excess baggage fallback rate (50 ETB/kg) will apply until a new rule is created." + /> ); } 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 7c381e90b..18006fc18 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -107,6 +107,7 @@ export default function RoutesPage() { code: formData.get('code') as string, name: formData.get('name') as string, description: formData.get('description') as string || undefined, + active: !editingRoute ? (formData.get('active') !== 'false') : undefined, effectiveFrom: formData.get('effectiveFrom') as string, effectiveUntil: formData.get('effectiveUntil') as string || undefined, stops: stopsArray, @@ -403,6 +404,16 @@ export default function RoutesPage() { /> + {!editingRoute && ( +
+ + +
+ )} +
diff --git a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx index 50d7ab822..82e9f8540 100644 --- a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx @@ -25,6 +25,7 @@ export default function StationsPage() { const [showModal, setShowModal] = useState(false); const [editingStation, setEditingStation] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null }>({ isOpen: false, station: null }); + const [formError, setFormError] = useState(null); const queryClient = useQueryClient(); const { data, isLoading, error } = useQuery({ @@ -38,7 +39,9 @@ export default function StationsPage() { queryClient.invalidateQueries({ queryKey: ['stations'] }); setShowModal(false); setEditingStation(null); + setFormError(null); }, + onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to create station'), }); const updateMutation = useMutation({ @@ -47,7 +50,9 @@ export default function StationsPage() { queryClient.invalidateQueries({ queryKey: ['stations'] }); setShowModal(false); setEditingStation(null); + setFormError(null); }, + onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to update station'), }); const deleteMutation = useMutation({ @@ -152,6 +157,7 @@ export default function StationsPage() { label: 'Edit', onClick: (station: any) => { setEditingStation(station); + setFormError(null); setShowModal(true); }, variant: 'secondary' as const, @@ -176,6 +182,7 @@ export default function StationsPage() { icon={Plus} onClick={() => { setEditingStation(null); + setFormError(null); setShowModal(true); }} > @@ -247,11 +254,17 @@ export default function StationsPage() { onClose={() => { setShowModal(false); setEditingStation(null); + setFormError(null); }} title={`${editingStation ? 'Edit' : 'Add'} Station`} size="lg" >
+ {formError && ( +
+ {formError} +
+ )} {editingStation && (

⚠ Warning

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 68ece3408..6b909fc2d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -14,7 +14,7 @@ import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils import { useAuthStore } from '@/lib/auth-store'; export default function TicketsPage() { - const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' }); + const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '', dateFrom: '', dateTo: '' }); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [ticketToDelete, setTicketToDelete] = useState(null); const [deleteError, setDeleteError] = useState(null); @@ -26,9 +26,10 @@ export default function TicketsPage() { const [detailsModalOpen, setDetailsModalOpen] = useState(false); const [selectedTicket, setSelectedTicket] = useState(null); - const { user } = useAuthStore(); + const [showExtraFilters, setShowExtraFilters] = useState(false); + const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); - // Excess baggage state + const { user } = useAuthStore(); const [excessModalOpen, setExcessModalOpen] = useState(false); const [excessTicket, setExcessTicket] = useState(null); const [excessKg, setExcessKg] = useState(''); @@ -88,7 +89,8 @@ export default function TicketsPage() { }); const boardMutation = useMutation({ - mutationFn: ({ ticketId }: any) => ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString() }), + mutationFn: ({ ticketId, leg }: { ticketId: string; leg?: 'outbound' | 'inbound' }) => + ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString(), leg: leg === 'inbound' ? 'RETURN' : 'OUTBOUND' }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tickets'] }); setBoardConfirmOpen(false); @@ -147,6 +149,16 @@ export default function TicketsPage() { }, }); + const restoreMutation = useMutation({ + mutationFn: (id: string) => apiClient.patch(`/tickets/${id}/restore`, {}), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tickets'] }); + setSuccessMessage('Ticket restored successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + onError: (error: any) => alert(error?.response?.data?.message || error?.message || 'Failed to restore ticket'), + }); + const handleBoard = (ticket: any) => { setTicketToBoard(ticket); setBoardConfirmOpen(true); @@ -154,8 +166,11 @@ export default function TicketsPage() { const handleConfirmBoard = async () => { if (!ticketToBoard) return; - await boardMutation.mutateAsync({ ticketId: ticketToBoard.id }); - printBoardingPass(ticketToBoard, 'outbound'); + const isRoundTrip = ticketToBoard.booking?.bookingType === 'ROUND_TRIP' || ticketToBoard.booking?.bookingType === 'ROUND_TRIP_TRANSIT'; + const outboundDone = !!ticketToBoard.validatedAt || !!ticketToBoard.booking?.outboundBoardedAt; + const leg: 'outbound' | 'inbound' = isRoundTrip && outboundDone ? 'inbound' : 'outbound'; + await boardMutation.mutateAsync({ ticketId: ticketToBoard.id, leg }); + printBoardingPass(ticketToBoard, leg); }; const printBoardingPass = (ticket: any, leg: 'outbound' | 'inbound' = 'outbound') => { @@ -249,54 +264,54 @@ export default function TicketsPage() { { key: 'boarded', label: 'Boarded' }, ]; - 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) => { + const confirmExport = async () => { + const cols = Object.entries(selectedColumns).filter(([, v]) => v).map(([k]) => k); + if (!cols.length) { alert('Please select at least one column'); return; } + const allData = await ticketsApi.getAll({ search: filters.search || undefined, status: filters.status || undefined, skip: 0, take: 9999 }); + const exportItems = (allData?.items || []).filter((ticket: any) => { if (!exportDateFrom && !exportDateTo) return true; - const d = ticket.schedule?.arrivalAt - ? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0] - : null; + 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 = [ - TICKET_COLS.map(c => `"${c.label}"`).join(','), - ...exportItems.map((ticket: any) => { - const values = TICKET_COLS.filter(c => cols.includes(c.key)).map(({ key }) => { - switch (key) { - case 'ticketNumber': return ticket.ticketNumber || 'N/A'; - case 'booking': return ticket.booking?.bookingRef || 'N/A'; - case 'passenger': return ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A'; - case 'trip': return `${ticket.schedule?.originStation?.name || 'N/A'} - ${ticket.schedule?.destinationStation?.name || 'N/A'}`; - case 'coach': return ticket.seat?.coach?.number || 'N/A'; - case 'seat': return ticket.seat?.seatNumber || 'N/A'; - case 'seatClass': return ticket.seat?.coach?.coachType?.type || 'N/A'; - case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB'); - case 'status': return ticket.status || 'N/A'; - case 'boarded': return ticket.boardedAt ? 'Yes' : 'No'; - 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 = `tickets-${new Date().toISOString().split('T')[0]}.csv`; - a.click(); + const headers = TICKET_COLS.filter(c => cols.includes(c.key)).map(c => c.label); + const rows = exportItems.map((ticket: any) => + TICKET_COLS.filter(c => cols.includes(c.key)).map(({ key }) => { + switch (key) { + case 'ticketNumber': return ticket.ticketNumber || 'N/A'; + case 'booking': return ticket.booking?.bookingRef || 'N/A'; + case 'passenger': return ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A'; + case 'trip': return (ticket.schedule?.originStation?.name || 'N/A') + ' - ' + (ticket.schedule?.destinationStation?.name || 'N/A'); + case 'coach': return ticket.seat?.coach?.number || 'N/A'; + case 'seat': return ticket.seat?.seatNumber || 'N/A'; + case 'seatClass': return ticket.seat?.coach?.coachType?.type || 'N/A'; + case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB'); + case 'status': return ticket.status || 'N/A'; + case 'boarded': return ticket.boardedAt ? 'Yes' : 'No'; + default: return ''; + } + }) + ); + const dateStr = new Date().toISOString().split('T')[0]; + if (exportFormat === 'pdf') { + const w = window.open('', '_blank')!; + w.document.write('Tickets Export'); + w.document.write('

Tickets Export - ' + dateStr + '

' + headers.map(h => '').join('') + ''); + rows.forEach(r => { w.document.write('' + r.map(v => '').join('') + ''); }); + w.document.write('
' + h + '
' + v + '
'); + w.document.close(); w.print(); + } else if (exportFormat === 'excel') { + const tsv = [headers.join(' '), ...rows.map(r => r.join(' '))].join(''); + const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); a.href = url; a.download = 'tickets-' + dateStr + '.xls'; a.click(); + } else { + const csv = [headers.map(h => '"' + h + '"').join(','), ...rows.map(r => r.map(v => '"' + v + '"').join(','))].join(''); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); a.href = url; a.download = 'tickets-' + dateStr + '.csv'; a.click(); + } setExportModalOpen(false); }; @@ -358,6 +373,13 @@ export default function TicketsPage() {
), }, + { + key: 'arrivalDate', + label: 'Arrival Date', + render: (ticket: any) => ( + {ticket.schedule?.arrivalAt ? new Date(ticket.schedule.arrivalAt).toLocaleDateString() : '—'} + ), + }, { key: 'boardingTimes', label: 'Boarding Times', @@ -422,6 +444,13 @@ export default function TicketsPage() { variant: 'secondary' as const, icon: ListCollapse, }, + { + label: 'Restore', + onClick: (ticket: any) => restoreMutation.mutate(ticket.id), + variant: 'secondary' as const, + icon: ListCollapse, + show: (ticket: any) => ticket.status === 'CANCELLED', + }, { label: 'Delete', onClick: handleDeleteClick, @@ -836,9 +865,20 @@ export default function TicketsPage() {
+
+

Export Format

+
+ {(['csv', 'excel', 'pdf'] as const).map(fmt => ( + + ))} +
+
setExportModalOpen(false)}>Cancel - Export CSV + Export
diff --git a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx index 9cf98183e..b80ec8782 100644 --- a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Edit, Trash2, Train, Search } from 'lucide-react'; +import { Plus, Edit, Trash2, Train, Search, RotateCcw } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; @@ -59,6 +59,17 @@ export default function TrainsPage() { }, }); + const restoreTrainMutation = useMutation({ + mutationFn: (id: string) => fleetApi.restoreTrain(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['trains'] }); + alert('Train restored successfully'); + }, + onError: (error: any) => { + alert('Error restoring train: ' + (error?.response?.data?.message || 'Unknown error')); + }, + }); + const handleDelete = (train: TrainType) => { setDeleteConfirm({ isOpen: true, train }); }; @@ -156,6 +167,13 @@ export default function TrainsPage() { variant: 'secondary' as const, icon: Edit, }, + { + label: 'Restore', + onClick: (train: TrainType) => restoreTrainMutation.mutate(train.id), + variant: 'secondary' as const, + icon: RotateCcw, + show: (train: TrainType) => !train.isActive, + }, { label: 'Delete', onClick: handleDelete, diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index c17eacfdd..f49fe4626 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -109,6 +109,7 @@ export const fleetApi = { createTrain: (data: any) => apiClient.post('/fleet/trains', data), updateTrain: (id: string, data: any) => apiClient.patch(`/fleet/trains/${id}`, data), deleteTrain: (id: string) => apiClient.delete(`/fleet/trains/${id}`), + restoreTrain: (id: string) => apiClient.patch(`/fleet/trains/${id}/restore`, {}), createCoach: (data: any) => apiClient.post('/fleet/coaches', data), updateCoach: (id: string, data: any) => apiClient.patch(`/fleet/coaches/${id}`, data), deleteCoach: (id: string) => apiClient.delete(`/fleet/coaches/${id}`), @@ -389,6 +390,7 @@ export const packagesApi = { create: (data: any) => apiClient.post('/packages', data), update: (id: string, data: any) => apiClient.patch(`/packages/${id}`, data), activate: (id: string) => apiClient.patch(`/packages/${id}/activate`, {}), + remove: (id: string) => apiClient.delete(`/packages/${id}`), addTier: (packageId: string, data: any) => apiClient.post(`/packages/${packageId}/tiers`, data), updateTier: (tierId: string, data: any) => apiClient.patch(`/packages/tiers/${tierId}`, data), deleteTier: (tierId: string) => apiClient.delete(`/packages/tiers/${tierId}`), From b2111b369563e24fc6be61bece7e8fd659bd38fa Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 25 Jun 2026 20:36:51 +0300 Subject: [PATCH 08/17] Update coachtype id for seat map --- .../portal/src/app/booking/results/page.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 56c293ac4..5c7cb6482 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -144,8 +144,8 @@ export default function ResultsPage() { ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) : outboundSchedules.length > 0; - const handleSelectCoachType = (scheduleId: string, coachTypeCode: string, coachTypeName: string) => { - setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeCode, code: coachTypeCode, name: coachTypeName } })); + const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string) => { + setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName } })); }; const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { @@ -158,7 +158,7 @@ export default function ResultsPage() { } // Find the coach type to get pricing info - const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.id); + const coachType = schedule.coachTypes?.find(ct => ct.coachTypeId === selectedCoachType.id); const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0; const hours = Math.floor((schedule.durationMinutes || 0) / 60); @@ -535,14 +535,14 @@ export default function ResultsPage() { {coachTypes.length > 0 ? (
{coachTypes.map((coachType: any, index: number) => { - const isSelected = selectedCoachType?.id === coachType.coachTypeCode; + const isSelected = selectedCoachType?.id === coachType.coachTypeId; const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; const CoachIcon = getCoachIcon(coachType.coachTypeName); return (