diff --git a/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql new file mode 100644 index 000000000..f252642f6 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260626_fix_missing_booking_columns/migration.sql @@ -0,0 +1,68 @@ +-- Create passenger schema if it doesn't exist +CREATE SCHEMA IF NOT EXISTS passenger; + +-- Move all enums from public to passenger schema +DO $$ +DECLARE + e text; +BEGIN + FOR e IN + SELECT typname FROM pg_type + JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace + WHERE pg_namespace.nspname = 'public' AND pg_type.typtype = 'e' + LOOP + EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e); + END LOOP; +END $$; + +-- Move all tables from public to passenger schema +DO $$ +DECLARE + t text; +BEGIN + FOR t IN + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' AND tablename NOT IN ('_prisma_migrations') + LOOP + EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t); + END LOOP; +END $$; + +-- Add missing columns to Booking +ALTER TABLE "passenger"."Booking" + ADD COLUMN IF NOT EXISTS "returnScheduleId" TEXT, + ADD COLUMN IF NOT EXISTS "returnOriginStationId" TEXT, + ADD COLUMN IF NOT EXISTS "returnDestinationStationId" TEXT, + ADD COLUMN IF NOT EXISTS "returnHoldId" TEXT, + ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT, + ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT, + ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT, + ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT, + ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT, + ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT, + ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT, + ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT, + ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT, + ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3); + +-- Add ReturnLegStatus enum and column +DO $$ BEGIN + CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ( + 'NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED' + ); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +ALTER TABLE "passenger"."Booking" + ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE'; + +-- Add missing columns to other tables +ALTER TABLE "passenger"."GateValidationLog" ADD COLUMN IF NOT EXISTS "leg" TEXT; +ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1; +ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "scheduleId" TEXT; +ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3); + +ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0; +ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; + +CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType"); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 1b98bd3e1..423a03ec9 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1,5 +1,6 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + previewFeatures = ["multiSchema"] } datasource db { @@ -79,7 +80,6 @@ model CoachType { updatedAt DateTime @updatedAt coaches Coach[] seatClasses SeatClass[] - @@schema("passenger") } @@ -116,11 +116,11 @@ enum BookingStatus { } enum ReturnLegStatus { - NOT_APPLICABLE // one-way booking - BOTH_USED // passenger used both legs - OUTBOUND_ONLY // return leg not used (no-show on return) - INBOUND_ONLY // outbound leg not used, return leg used - NEITHER_USED // neither leg boarded yet + NOT_APPLICABLE + BOTH_USED + OUTBOUND_ONLY + INBOUND_ONLY + NEITHER_USED @@schema("passenger") } @@ -269,7 +269,6 @@ model User { fraudAlerts FraudAlert[] faydaVerificationSessions FaydaVerificationSession[] - @@schema("passenger") } @@ -283,7 +282,6 @@ model Session { lastActivityAt DateTime @default(now()) createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@schema("passenger") } @@ -315,7 +313,6 @@ model TravelerProfile { notes String? createdAt DateTime @default(now()) passenger Passenger @relation(fields: [passengerId], references: [id]) - @@schema("passenger") } @@ -350,7 +347,6 @@ model Train { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt schedules TrainSchedule[] - @@schema("passenger") } @@ -412,7 +408,6 @@ model TripLiveStatus { platformLabel String? updatedAt DateTime @updatedAt schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - @@schema("passenger") } @@ -500,7 +495,6 @@ model FareRule { validFrom DateTime validUntil DateTime? createdAt DateTime @default(now()) - @@schema("passenger") } @@ -584,7 +578,6 @@ model BookingSeat { displayFareMinor Int? booking Booking @relation(fields: [bookingId], references: [id]) seat Seat @relation(fields: [seatId], references: [id]) - @@schema("passenger") } @@ -600,7 +593,6 @@ model PaymentMethod { sortOrder Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@schema("passenger") } @@ -661,7 +653,6 @@ model PaymentRefund { status String createdAt DateTime @default(now()) paymentIntent PaymentIntent @relation(fields: [paymentIntentId], references: [id]) - @@schema("passenger") } @@ -681,7 +672,6 @@ model Ticket { booking Booking @relation(fields: [bookingId], references: [id]) validationLogs GateValidationLog[] seats TicketSeat[] - @@schema("passenger") } @@ -709,7 +699,6 @@ model LoyaltyAccount { passenger Passenger @relation(fields: [passengerId], references: [id]) ledger LoyaltyLedgerEntry[] rewards LoyaltyReward[] - @@schema("passenger") } @@ -722,7 +711,6 @@ model LoyaltyLedgerEntry { balanceAfter Int createdAt DateTime @default(now()) account LoyaltyAccount @relation(fields: [accountId], references: [id]) - @@schema("passenger") } @@ -734,7 +722,6 @@ model LoyaltyReward { available Boolean @default(true) description String? account LoyaltyAccount @relation(fields: [accountId], references: [id]) - @@schema("passenger") } @@ -763,7 +750,6 @@ model WalletLedgerEntry { relatedBookingId String? createdAt DateTime @default(now()) wallet WalletAccount @relation(fields: [walletId], references: [id]) - @@schema("passenger") } @@ -778,7 +764,6 @@ model Notification { metadata Json? createdAt DateTime @default(now()) passenger Passenger @relation(fields: [passengerId], references: [id]) - @@schema("passenger") } @@ -794,7 +779,6 @@ model Promotion { deepLink String? active Boolean @default(true) createdAt DateTime @default(now()) - @@schema("passenger") } @@ -808,7 +792,6 @@ model StationCrowdSignal { observedAt DateTime? updatedAt DateTime @updatedAt station Station @relation(fields: [stationId], references: [id]) - @@schema("passenger") } @@ -820,7 +803,6 @@ model WeatherAlert { message String validUntil DateTime createdAt DateTime @default(now()) - @@schema("passenger") } @@ -828,7 +810,6 @@ model MenuCategory { id String @id @default(uuid()) name String items MenuItem[] - @@schema("passenger") } @@ -843,7 +824,6 @@ model MenuItem { availableUntil DateTime? schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) category MenuCategory @relation(fields: [categoryId], references: [id]) - @@schema("passenger") } @@ -858,7 +838,6 @@ model FoodOrder { createdAt DateTime @default(now()) booking Booking @relation(fields: [bookingId], references: [id]) items FoodOrderItem[] - @@schema("passenger") } @@ -871,7 +850,6 @@ model FoodOrderItem { unitPriceMinor Int? lineTotalMinor Int order FoodOrder @relation(fields: [orderId], references: [id]) - @@schema("passenger") } @@ -880,7 +858,6 @@ model FaqCategory { title String iconKey String? articles FaqArticle[] - @@schema("passenger") } @@ -891,7 +868,6 @@ model FaqArticle { answerMarkdown String rank Int @default(0) category FaqCategory @relation(fields: [categoryId], references: [id]) - @@schema("passenger") } @@ -902,7 +878,6 @@ model SupportConversation { status SupportConversationStatus @default(OPEN) createdAt DateTime @default(now()) messages SupportMessage[] - @@schema("passenger") } @@ -914,7 +889,6 @@ model SupportMessage { attachments Json? createdAt DateTime @default(now()) conversation SupportConversation @relation(fields: [conversationId], references: [id]) - @@schema("passenger") } @@ -934,7 +908,6 @@ model UserPreferences { darkMode Boolean @default(false) language String @default("en") user User @relation(fields: [userId], references: [id]) - @@schema("passenger") } @@ -947,7 +920,6 @@ model Device { trusted Boolean @default(false) lastSeenAt DateTime @default(now()) user User @relation(fields: [userId], references: [id]) - @@schema("passenger") } @@ -961,7 +933,6 @@ model SavedRoute { tripCount Int @default(0) createdAt DateTime @default(now()) passenger Passenger @relation(fields: [passengerId], references: [id]) - @@schema("passenger") } @@ -973,7 +944,6 @@ model Journey { currency String @default("ETB") createdAt DateTime @default(now()) journeySegments JourneySegment[] - @@schema("passenger") } @@ -988,7 +958,6 @@ model JourneySegment { arrivalStationId String journey Journey @relation(fields: [journeyId], references: [id]) schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - @@schema("passenger") } @@ -1032,7 +1001,6 @@ model Route { fareRules RouteFareRule[] segmentFares SegmentFareRule[] schedules TrainSchedule[] - @@schema("passenger") } @@ -1102,7 +1070,6 @@ model Agent { bookings AgentBooking[] shifts AgentShift[] commissions AgentCommission[] - @@schema("passenger") } @@ -1117,7 +1084,6 @@ model AgentBooking { createdAt DateTime @default(now()) agent Agent @relation(fields: [agentId], references: [id]) booking Booking @relation(fields: [bookingId], references: [id]) - @@schema("passenger") } @@ -1177,7 +1143,6 @@ model BookingCancellation { processedAt DateTime? createdAt DateTime @default(now()) booking Booking @relation(fields: [bookingId], references: [id]) - @@schema("passenger") } @@ -1205,7 +1170,6 @@ model BaggageAllowance { excessFeePerKg Int currency String @default("ETB") createdAt DateTime @default(now()) - @@schema("passenger") } @@ -1249,7 +1213,6 @@ model NotificationTemplate { bodyTemplate String active Boolean @default(true) createdAt DateTime @default(now()) - @@schema("passenger") } @@ -1288,7 +1251,6 @@ model FraudRule { config Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@schema("passenger") } diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 14907f87e..9e1fffa44 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -212,7 +212,7 @@ async function seedRoute() { create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: routeDistancesKm[i] }, }); } - + const returnRoute = await prisma.route.upsert({ where: { code: 'Route-102' }, update: {}, @@ -317,7 +317,7 @@ async function seedTrips() { const now = new Date(); const tomorrow = new Date(now); tomorrow.setDate(now.getDate() + 1); - + const schedules = []; for (let d = 0; d < 5; d++) { @@ -403,7 +403,7 @@ async function seedTrips() { const coachAssignments = []; const liveStatuses = []; - + for (const schedule of createdSchedules) { for (let p = 0; p < coaches.length; p++) { coachAssignments.push({ @@ -418,12 +418,12 @@ async function seedTrips() { progressPercent: 0, }); } - + await Promise.all([ ...coachAssignments.map(ca => prisma.coachAssignment.create({ data: ca })), ...liveStatuses.map(ls => prisma.tripLiveStatus.create({ data: ls })), ]); - + console.log(` ✅ Train with ${createdSchedules.length} upcoming trips created`); } @@ -453,7 +453,7 @@ async function seedFareRules() { validFrom, }); } - + await Promise.all( fareRules.map(fr => prisma.routeFareRule.create({ data: fr })) ); @@ -518,7 +518,7 @@ async function seedSegmentFares() { include: { stops: { orderBy: { sequence: 'asc' } } }, }); const seatClasses = await prisma.seatClass.findMany(); - const validFrom = new Date('2024-01-01'); + const validFrom = new Date('2026-01-01'); if (route && route.stops.length > 2) { for (const sc of seatClasses) { @@ -531,7 +531,7 @@ async function seedSegmentFares() { baseFareMinor: Math.floor(sc.baseFareMinor * 0.4), validFrom, }, - }).catch(() => {}); + }).catch(() => { }); await prisma.segmentFareRule.create({ data: { @@ -542,7 +542,7 @@ async function seedSegmentFares() { baseFareMinor: Math.floor(sc.baseFareMinor * 0.6), validFrom, }, - }).catch(() => {}); + }).catch(() => { }); } console.log(` ✅ ${seatClasses.length * 2} segment fare rules created`); } @@ -586,16 +586,16 @@ async function seedMenuAndFood() { const coffeeId = uuidv4(); const juiceId = uuidv4(); const sandwichId = uuidv4(); - + await prisma.menuItem.create({ data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 50 }, - }).catch(() => {}); // ignore if exists + }).catch(() => { }); // ignore if exists await prisma.menuItem.create({ data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 35 }, - }).catch(() => {}); // ignore if exists + }).catch(() => { }); // ignore if exists await prisma.menuItem.create({ data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 80 }, - }).catch(() => {}); // ignore if exists + }).catch(() => { }); // ignore if exists } console.log(` ✅ Menu categories and items created`); } @@ -682,6 +682,9 @@ async function main() { const steps: Array<[string, () => Promise]> = [ ['system users', seedSystemUsers], + ['fare rules', seedFareRules], + ['segment fares', seedSegmentFares], + ['currency', seedCurrency] ]; let failed = 0; diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 3e0d6a61a..b40a5c0ee 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -202,7 +202,7 @@ export class BookingsService { } if (status) where.status = status; - if (returnLegStatus) where.returnLegStatus = returnLegStatus; + if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus; const [items, total] = await Promise.all([ this.prisma.booking.findMany({ @@ -233,6 +233,8 @@ export class BookingsService { contactPhone: booking.contactPhone, bookingType: booking.bookingType, returnLegStatus: (booking as any).returnLegStatus ?? null, + adultCount: booking.adultCount, + childCount: booking.childCount, createdAt: booking.createdAt, passenger: booking.passenger?.user, schedule: { diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts index 536ddaa3b..5d3a288cd 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts @@ -47,6 +47,9 @@ export class FareCalculateDto { @ApiPropertyOptional({ example: 'WEEKEND15', description: 'Promo code for discount' }) @IsOptional() @IsString() promoCode?: string; + + @ApiPropertyOptional({ example: 'schedule-uuid', description: 'Schedule UUID — used to match schedule-scoped FareRules first' }) + @IsOptional() @IsString() scheduleId?: string; } export class FareBreakdownDto { diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index b111b5d67..9071f0535 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -32,20 +32,59 @@ export class FareEngineService { s => s.sequence > originStop.sequence && s.sequence <= destStop.sequence, ); - const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined); - if (missingDistance.length > 0) - throw new BadRequestException( - `Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`, - ); - - const totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0); - const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } }); if (!seatClass) throw new NotFoundException('Seat class not found'); if (!seatClass.isActive) throw new BadRequestException('Seat class is not active'); - const ratePerKmMinor = seatClass.baseFareMinor; - const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor; + // Resolve fare: FareRule (schedule-scoped → route-scoped) takes precedence over distance×rate + const now = new Date(); + const [originStation, destStation] = await Promise.all([ + this.prisma.station.findUnique({ where: { id: dto.originStationId } }), + this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }), + ]); + const segmentRoute = originStation && destStation + ? `${originStation.code}-${destStation.code}` : null; + const fullRoute = `${route.code}`; + + const fareRuleCandidates = await this.prisma.fareRule.findMany({ + where: { + seatClassId: dto.seatClassId, + validFrom: { lte: now }, + OR: [{ validUntil: null }, { validUntil: { gte: now } }], + }, + }); + + const fareRule = this.pickBestFareRule( + fareRuleCandidates, + dto.scheduleId, + segmentRoute, + fullRoute, + dto.nationality, + ); + + let baseFarePerPassengerMinor: number; + let ratePerKmMinor: number; + let totalDistanceKm: number; + let fareSource: string; + + if (fareRule) { + // Flat fare from FareRule — distance is informational only + baseFarePerPassengerMinor = fareRule.baseFareMinor; + totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0); + ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; + fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE'; + } else { + // Distance × rate fallback + const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined); + if (missingDistance.length > 0) + throw new BadRequestException( + `Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`, + ); + totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0); + ratePerKmMinor = seatClass.baseFareMinor; + baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor; + fareSource = 'DISTANCE_RATE'; + } // Premium and insurance fees applied per passenger const premiumPerPassenger = seatClass.premiumMinor ?? 0; @@ -84,11 +123,6 @@ export class FareEngineService { const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); const totalInBillingCurrency = Math.round(totalEtbMinor * exchangeRate); - const [originStation, destStation] = await Promise.all([ - this.prisma.station.findUnique({ where: { id: dto.originStationId } }), - this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }), - ]); - const calculation = [ `Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`, `Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`, @@ -110,9 +144,11 @@ export class FareEngineService { `Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`, `Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`, `Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`, + `Fare source: ${fareSource}`, ].join('\n'); return { + fareSource, routeCode: route.code, originName: originStation?.name ?? dto.originStationId, destinationName: destStation?.name ?? dto.destinationStationId, @@ -161,6 +197,37 @@ export class FareEngineService { return results.filter(Boolean); } + private pickBestFareRule( + candidates: any[], + scheduleId?: string, + segmentRoute?: string | null, + fullRoute?: string, + nationality?: string, + ): any | null { + const nat = nationality ?? null; + const priorities = [ + { tripId: scheduleId, route: segmentRoute, nationality: nat }, + { tripId: scheduleId, route: segmentRoute, nationality: null }, + { tripId: scheduleId, route: fullRoute, nationality: nat }, + { tripId: scheduleId, route: fullRoute, nationality: null }, + { tripId: scheduleId, route: null, nationality: nat }, + { tripId: scheduleId, route: null, nationality: null }, + { tripId: null, route: segmentRoute, nationality: nat }, + { tripId: null, route: segmentRoute, nationality: null }, + { tripId: null, route: fullRoute, nationality: nat }, + { tripId: null, route: fullRoute, nationality: null }, + { tripId: null, route: null, nationality: nat }, + { tripId: null, route: null, nationality: null }, + ]; + for (const p of priorities) { + const match = candidates.find( + c => c.tripId === p.tripId && c.route === p.route && c.nationality === p.nationality, + ); + if (match) return match; + } + return null; + } + async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, @@ -175,6 +242,7 @@ export class FareEngineService { destinationStationId: schedule.destinationStationId, seatClassId, nationality, + scheduleId, }); } @@ -199,6 +267,7 @@ export class FareEngineService { destinationStationId: schedule.destinationStationId, seatClassId: sc.id, nationality, + scheduleId, }).catch(() => null), ), ); diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 12d9e5626..7171b56ad 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -21,10 +21,11 @@ export class PassengersService { const { search, verified, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - const where: any = {}; + const where: any = { user: { role: 'PASSENGER' } }; if (search) { where.user = { + ...where.user, OR: [ { fullName: { contains: search, mode: 'insensitive' } }, { email: { contains: search, mode: 'insensitive' } }, @@ -68,7 +69,7 @@ export class PassengersService { userId: passenger.userId, fullName: user.fullName, email: user.email, - phone: user.phone, + phone: user.phone?.startsWith('+guest-') ? null : user.phone, nationalId: user.nationalId, nationality: user.nationality, dateOfBirth: user.dateOfBirth ?? null, diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 8cb8ea253..378bc8fa7 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -12,34 +12,22 @@ export class SchedulesController { @Post('bulk-generate') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Bulk generate repetitive schedules', - description: 'Creates multiple schedules automatically by repeating every X days for the next Y days. Example: repeat every 2 days for 30 days = 15 schedules.', - }) - @ApiResponse({ status: 201, description: 'Schedules generated successfully' }) - @ApiResponse({ status: 400, description: 'Invalid parameters or route not found' }) + @ApiOperation({ summary: 'Bulk generate repetitive schedules' }) bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) { return this.service.bulkGenerateSchedules(dto); } @Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Create a train schedule from a route template', - description: `Creates a schedule by referencing a Route (routeId).\nStops are automatically copied from the route's RouteStop definitions.\nYou supply the actual planned arrival/departure times per stop sequence.\nOrigin and destination are derived from the first and last route stop — no need to specify them manually.`, - }) - @ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' }) - @ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' }) - @ApiResponse({ status: 404, description: 'Train or route not found' }) + @ApiOperation({ summary: 'Create a train schedule from a route template' }) createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); } @Get() @ApiOperation({ summary: 'List schedules with optional filters' }) - @ApiQuery({ name: 'date', required: false, example: '2026-06-15', description: 'Departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' }) - @ApiQuery({ name: 'routeId', required: false, description: 'Filter by route UUID' }) - @ApiQuery({ name: 'trainId', required: false, description: 'Filter by train UUID' }) - @ApiQuery({ name: 'status', required: false, enum: TripStatus, description: 'Filter by schedule status' }) - @ApiResponse({ status: 200, description: 'Array of schedules ordered by departureAt, each with train, origin/destination, stops, and booking/assignment counts' }) + @ApiQuery({ name: 'date', required: false }) + @ApiQuery({ name: 'routeId', required: false }) + @ApiQuery({ name: 'trainId', required: false }) + @ApiQuery({ name: 'status', required: false, enum: TripStatus }) listSchedules( @Query('date') date?: string, @Query('routeId') routeId?: string, @@ -57,57 +45,63 @@ export class SchedulesController { @ApiResponse({ status: 201, description: 'Fare rule created' }) createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); } + @Patch('fares/:id') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update a fare rule' }) + @ApiParam({ name: 'id', description: 'FareRule UUID' }) + @ApiResponse({ status: 200, description: 'Fare rule updated' }) + updateFareRule(@Param('id') id: string, @Body() dto: Partial) { + return this.service.updateFareRule(id, dto); + } + + @Delete('fares/:id') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Delete a fare rule' }) + @ApiParam({ name: 'id', description: 'FareRule UUID' }) + @ApiResponse({ status: 200, description: 'Fare rule deleted' }) + deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); } + @Post('segment-fares') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Create a segment fare rule (stop-to-stop pricing on a route)' }) - @ApiResponse({ status: 201, description: 'Segment fare rule created' }) + @ApiOperation({ summary: 'Create a segment fare rule' }) createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); } @Get('routes/:routeId/segment-fares') @ApiOperation({ summary: 'List all segment fare rules for a route' }) @ApiParam({ name: 'routeId', description: 'Route UUID' }) - @ApiResponse({ status: 200, description: 'List of segment fare rules' }) getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); } @Patch('segment-fares/:id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update a segment fare rule' }) @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) - @ApiResponse({ status: 200, description: 'Segment fare rule updated' }) updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); } @Delete('segment-fares/:id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Delete a segment fare rule' }) @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) - @ApiResponse({ status: 200, description: 'Segment fare rule deleted' }) deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); } // ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) ===== @Get(':id') - @ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' }) + @ApiOperation({ summary: 'Get schedule detail' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'Full schedule detail including route stops with station info' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); } @Patch(':id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Update a schedule (partial update - times, status, coaches)' }) + @ApiOperation({ summary: 'Update a schedule (partial)' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'Schedule updated' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) { return this.service.updateSchedulePartial(id, dto); } @Patch(':id/status') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Update schedule status (SCHEDULED → BOARDING → EN_ROUTE → ARRIVED)' }) + @ApiOperation({ summary: 'Update schedule status' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'Status updated' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) { return this.service.updateScheduleStatus(id, dto); } @@ -116,26 +110,18 @@ export class SchedulesController { @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Delete a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'Schedule deleted' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) - deleteSchedule(@Param('id') id: string) { - return this.service.deleteSchedule(id); - } + deleteSchedule(@Param('id') id: string) { return this.service.deleteSchedule(id); } @Get(':id/stops') - @ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' }) + @ApiOperation({ summary: 'List all stops for a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'Ordered stop list with station details and planned/actual times' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) getStops(@Param('id') id: string) { return this.service.getStops(id); } @Patch(':id/stops/:sequence') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Update planned times or live status of a specific stop' }) + @ApiOperation({ summary: 'Update a stop time' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'sequence', description: 'Stop sequence number' }) - @ApiResponse({ status: 200, description: 'Stop updated' }) - @ApiResponse({ status: 404, description: 'Stop not found on schedule' }) updateStop( @Param('id') id: string, @Param('sequence', ParseIntPipe) sequence: number, @@ -145,19 +131,26 @@ export class SchedulesController { @Get(':scheduleId/fares/stored') @ApiOperation({ summary: 'Get stored fare rules for a schedule' }) @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'List of stored fare rules with seat class info' }) getStoredFares(@Param('scheduleId') scheduleId: string) { return this.service.getFareRules(scheduleId); } - @Get(':scheduleId/fares') - @ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' }) + @Get(':scheduleId/fares/all') + @ApiOperation({ summary: 'Get fares for all active seat classes from the fare engine' }) @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) - @ApiQuery({ name: 'seatClassId', required: true, description: 'SeatClass UUID' }) - @ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency (Ethiopian→ETB, Djiboutian→DJF, other→USD)' }) - @ApiResponse({ status: 200, description: 'Live fare breakdown from fare engine' }) - @ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' }) - @ApiResponse({ status: 404, description: 'Schedule or seat class not found' }) + @ApiQuery({ name: 'nationality', required: false }) + getAllFares( + @Param('scheduleId') scheduleId: string, + @Query('nationality') nationality?: string, + ) { + return this.service.getAllFaresFromEngine(scheduleId, nationality); + } + + @Get(':scheduleId/fares') + @ApiOperation({ summary: 'Get fare for a specific seat class from the fare engine' }) + @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) + @ApiQuery({ name: 'seatClassId', required: true }) + @ApiQuery({ name: 'nationality', required: false }) getFare( @Param('scheduleId') scheduleId: string, @Query('seatClassId') seatClassId: string, @@ -166,42 +159,15 @@ export class SchedulesController { return this.service.getFareFromEngine(scheduleId, seatClassId, nationality); } - @Get(':scheduleId/fares/all') - @ApiOperation({ summary: 'Get fares for all active seat classes on a schedule' }) - @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) - @ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency' }) - @ApiResponse({ status: 200, description: 'Array of fare breakdowns for every active seat class, ordered by price ascending' }) - @ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) - getAllFares( - @Param('scheduleId') scheduleId: string, - @Query('nationality') nationality?: string, - ) { - return this.service.getAllFaresFromEngine(scheduleId, nationality); - } - @Post(':id/fares/sync') - @ApiOperation({ - summary: 'Sync fares from fare engine', - description: 'Recalculates fares for all active seat classes using the fare engine (km × ratePerKm + tax) and upserts them as FareRule records scoped to this schedule. Previous active rules are expired.', - }) + @ApiOperation({ summary: 'Sync fares from fare engine' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 201, description: 'Fares synced — returns count of synced rules and any errors' }) - @ApiResponse({ status: 400, description: 'Schedule has no associated route or missing distanceKm on stops' }) - @ApiResponse({ status: 404, description: 'Schedule not found' }) - syncFares(@Param('id') id: string) { - return this.service.syncFaresFromEngine(id); - } + syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); } @Post(':id/coaches') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Assign coaches to a schedule', - description: 'Assigns selected coaches to a schedule with their position numbers. Replaces any existing coach assignments.' - }) + @ApiOperation({ summary: 'Assign coaches to a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 201, description: 'Coaches assigned successfully' }) - @ApiResponse({ status: 404, description: 'Schedule or coach not found' }) assignCoaches( @Param('id') id: string, @Body() dto: { coaches: Array<{ coachId: string; positionNumber: number }> }, @@ -212,21 +178,14 @@ export class SchedulesController { @Get(':id/coaches') @ApiOperation({ summary: 'Get assigned coaches for a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - @ApiResponse({ status: 200, description: 'List of assigned coaches with seat details' }) - getAssignedCoaches(@Param('id') id: string) { - return this.service.getAssignedCoaches(id); - } + getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); } @Delete(':id/coaches/:coachId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Remove a coach assignment from a schedule' }) + @ApiOperation({ summary: 'Remove a coach assignment' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'coachId', description: 'Coach UUID' }) - @ApiResponse({ status: 200, description: 'Coach assignment removed' }) - removeCoachAssignment( - @Param('id') id: string, - @Param('coachId') coachId: string, - ) { + removeCoachAssignment(@Param('id') id: string, @Param('coachId') coachId: string) { return this.service.removeCoachAssignment(id, coachId); } } 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 fd0776141..0234824d1 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -18,7 +18,6 @@ export class SchedulesService { const errors: string[] = []; const scheduleIds: string[] = []; - // Validate route and get stops for plannedTimes generation const route = await this.prisma.route.findUnique({ where: { id: dto.routeId }, include: { stops: { orderBy: { sequence: 'asc' } } }, @@ -45,7 +44,6 @@ export class SchedulesService { const schedule = await this.createSchedule(createDto); scheduleIds.push(schedule.id); - // Assign coaches if provided if (dto.coachIds && dto.coachIds.length > 0) { await this.assignCoaches( schedule.id, @@ -58,15 +56,10 @@ export class SchedulesService { errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`); } - // Move to next repetition currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000); } - return { - schedulesCreated: scheduleCount, - errors, - scheduleIds, - }; + return { schedulesCreated: scheduleCount, errors, scheduleIds }; } async listSchedules(dto: ListSchedulesDto) { @@ -104,7 +97,6 @@ export class SchedulesService { const arr = new Date(dto.arrivalAt); if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt'); - // Validate route exists and has stops const route = await this.prisma.route.findUnique({ where: { id: dto.routeId }, include: { stops: { orderBy: { sequence: 'asc' } } }, @@ -113,21 +105,13 @@ export class SchedulesService { if (!route.active) throw new BadRequestException('Route is not active'); if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops'); - // Check for duplicate schedule with same train, route, and date const depDate = new Date(dep); depDate.setHours(0, 0, 0, 0); const nextDay = new Date(depDate); nextDay.setDate(nextDay.getDate() + 1); const existingSchedule = await this.prisma.trainSchedule.findFirst({ - where: { - trainId: dto.trainId, - routeId: dto.routeId, - departureAt: { - gte: depDate, - lt: nextDay, - }, - }, + where: { trainId: dto.trainId, routeId: dto.routeId, departureAt: { gte: depDate, lt: nextDay } }, }); if (existingSchedule) { @@ -136,7 +120,6 @@ export class SchedulesService { ); } - // Auto-generate plannedTimes if not provided or empty let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { const totalDuration = arr.getTime() - dep.getTime(); @@ -144,7 +127,6 @@ export class SchedulesService { plannedTimes = route.stops.map((stop, index) => { let stopTime: Date; - if (index === 0) { stopTime = dep; } else if (index === route.stops.length - 1) { @@ -154,7 +136,6 @@ export class SchedulesService { const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); stopTime = new Date(dep.getTime() + totalDuration * progress); } - return { sequence: stop.sequence, plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), @@ -163,14 +144,12 @@ export class SchedulesService { }); } - // Validate all route stop sequences are covered by plannedTimes const providedSeqs = new Set(plannedTimes.map(t => t.sequence)); const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq)); if (missingSeqs.length > 0) { throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`); } - // Derive origin and destination from first and last route stop const firstStop = route.stops[0]; const lastStop = route.stops[route.stops.length - 1]; @@ -188,9 +167,7 @@ export class SchedulesService { include: { train: true, originStation: true, destinationStation: true }, }); - const plannedTimesMap = Object.fromEntries( - plannedTimes.map(t => [t.sequence, t]), - ); + const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap); return this.getSchedule(schedule.id); @@ -230,10 +207,7 @@ export class SchedulesService { }; } - private async resolveEffectiveStatuses( - scheduleId: string, - seatIds: string[], - ): Promise> { + private async resolveEffectiveStatuses(scheduleId: string, seatIds: string[]): Promise> { const statusMap = new Map(); if (seatIds.length === 0) return statusMap; @@ -304,7 +278,6 @@ export class SchedulesService { plannedTimes = route.stops.map((stop, index) => { let stopTime: Date; - if (index === 0) { stopTime = dep; } else if (index === route.stops.length - 1) { @@ -314,7 +287,6 @@ export class SchedulesService { const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); stopTime = new Date(dep.getTime() + totalDuration * progress); } - return { sequence: stop.sequence, plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), @@ -323,9 +295,7 @@ export class SchedulesService { }); } - const plannedTimesMap = Object.fromEntries( - plannedTimes.map(t => [t.sequence, t]), - ); + const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap); return this.getSchedule(id); @@ -376,9 +346,35 @@ export class SchedulesService { validFrom: new Date(validFrom), validUntil: validUntil ? new Date(validUntil) : null, }, + include: { seatClass: true }, }); } + async updateFareRule(id: string, dto: Partial) { + const existing = await this.prisma.fareRule.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('Fare rule not found'); + + const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto; + return this.prisma.fareRule.update({ + where: { id }, + data: { + ...rest, + ...(scheduleId !== undefined && { tripId: scheduleId }), + ...(nationality !== undefined && { nationality }), + ...(validFrom && { validFrom: new Date(validFrom) }), + ...(validUntil !== undefined && { validUntil: validUntil ? new Date(validUntil) : null }), + }, + include: { seatClass: true }, + }); + } + + async deleteFareRule(id: string) { + const existing = await this.prisma.fareRule.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('Fare rule not found'); + await this.prisma.fareRule.delete({ where: { id } }); + return { deleted: true, id }; + } + createSegmentFareRule(dto: any) { const { validFrom, validUntil, passengerCategory, ...rest } = dto; return this.prisma.segmentFareRule.create({ @@ -419,7 +415,6 @@ export class SchedulesService { async getFareRules(scheduleId?: string) { const where: any = {}; if (scheduleId) where.tripId = scheduleId; - return this.prisma.fareRule.findMany({ where, include: { seatClass: true }, @@ -439,11 +434,10 @@ export class SchedulesService { }); if (!schedule) throw new NotFoundException('Schedule not found'); if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route'); - return await this.fareEngine.calculateAllForSchedule(scheduleId, nationality); } catch (error) { throw new BadRequestException( - error instanceof Error ? error.message : 'Failed to calculate fares for schedule' + error instanceof Error ? error.message : 'Failed to calculate fares for schedule', ); } } @@ -483,20 +477,13 @@ export class SchedulesService { return { synced, errors }; } - async assignCoaches( - scheduleId: string, - coaches: Array<{ coachId: string; positionNumber: number }>, - ) { + async assignCoaches(scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }); if (!schedule) throw new NotFoundException('Schedule not found'); const coachIds = coaches.map(c => c.coachId); - const existingCoaches = await this.prisma.coach.findMany({ - where: { id: { in: coachIds } }, - }); - if (existingCoaches.length !== coachIds.length) { - throw new NotFoundException('One or more coaches not found'); - } + const existingCoaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } }); + if (existingCoaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found'); await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } }); @@ -508,20 +495,13 @@ export class SchedulesService { })); await this.prisma.coachAssignment.createMany({ data }); - return { message: 'Coaches assigned successfully', count: coaches.length }; } async getAssignedCoaches(scheduleId: string) { return this.prisma.coachAssignment.findMany({ where: { scheduleId }, - include: { - coach: { - include: { - seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, - }, - }, - }, + include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } }, orderBy: { positionNumber: 'asc' }, }); } @@ -535,30 +515,22 @@ export class SchedulesService { if (dto.departureAt || dto.arrivalAt) { const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt); const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt); - if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time'); - updateData.departureAt = dep; updateData.arrivalAt = arr; updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000); } - if (dto.status) { - updateData.status = dto.status; - } + if (dto.status) updateData.status = dto.status; if (Object.keys(updateData).length > 0) { - await this.prisma.trainSchedule.update({ - where: { id }, - data: updateData, - }); + await this.prisma.trainSchedule.update({ where: { id }, data: updateData }); } if (dto.coaches !== undefined) { if (dto.coaches.length > 0) { await this.assignCoaches(id, dto.coaches); } else { - // Remove all coach assignments when empty array is sent await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } }); } } @@ -567,12 +539,9 @@ export class SchedulesService { } async removeCoachAssignment(scheduleId: string, coachId: string) { - const assignment = await this.prisma.coachAssignment.findFirst({ - where: { scheduleId, coachId }, - }); + const assignment = await this.prisma.coachAssignment.findFirst({ where: { scheduleId, coachId } }); if (!assignment) throw new NotFoundException('Coach assignment not found'); - await this.prisma.coachAssignment.delete({ where: { id: assignment.id } }); return { message: 'Coach assignment removed' }; } -} \ No newline at end of file +} diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 9646c20ea..0ca9a8723 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -441,6 +441,7 @@ export class SearchService { destinationStationId, seatClassId: sc.id, nationality, + scheduleId: schedule.id, }); return { seatClassName: fare.seatClassName, diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 88f9666bd..21060b595 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -547,16 +547,14 @@ export class SeatsService { @Cron(CronExpression.EVERY_MINUTE) async expireHolds() { - const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } }); - for (const hold of expired) { + const now = new Date(); + const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } }); + if (expired.length === 0) return; + + const expiredIds = expired.map(h => h.id); + for (const hold of expired) { await this.releaseSeats(hold.seatIds); - try { - await this.prisma.seatHold.delete({ where: { id: hold.id } }); - } catch (err) { - if (err instanceof Error && !err.message.includes('P2025')) { - throw err; - } - } } + await this.prisma.seatHold.deleteMany({ where: { id: { in: expiredIds } } }); } } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 568801213..d4d68ad1f 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -31,18 +31,27 @@ export class TicketsController { @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'List all tickets with optional filters' }) @ApiQuery({ name: 'search', required: false }) - @ApiQuery({ name: 'status', required: false, description: 'ACTIVE | USED | CANCELLED' }) + @ApiQuery({ name: 'status', required: false }) + @ApiQuery({ name: 'originStationId', required: false }) + @ApiQuery({ name: 'destinationStationId', required: false }) + @ApiQuery({ name: 'arrivalDate', required: false }) @ApiQuery({ name: 'skip', required: false }) @ApiQuery({ name: 'take', required: false }) listTickets( @Query('search') search?: string, @Query('status') status?: string, + @Query('originStationId') originStationId?: string, + @Query('destinationStationId') destinationStationId?: string, + @Query('arrivalDate') arrivalDate?: string, @Query('skip') skip?: string, @Query('take') take?: string, ) { return this.service.listTickets({ search, status, + originStationId, + destinationStationId, + arrivalDate, skip: skip ? parseInt(skip) : 0, take: take ? parseInt(take) : 50, }); @@ -60,17 +69,8 @@ export class TicketsController { } @Get(':bookingRef') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') @ApiOperation({ - summary: 'Get ticket with QR code and passenger details', - description: `Returns ticket information including: -- QR code for gate scanning -- Barcode for offline validation -- Passenger details (name, age category, nationality) -- Journey details (origin, destination, seat, coach) -- Fare breakdown with currency -- PDF download link` + summary: 'Get ticket with QR code and passenger details (public)', }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 83abf0cc2..2c57a59e3 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -14,7 +14,7 @@ interface OfflineValidation { export class TicketsService { constructor(private prisma: PrismaService) {} - async listTickets(filters: { search?: string; status?: string; skip: number; take: number }) { + async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) { const where: any = {}; if (filters.search) { where.OR = [ @@ -24,7 +24,19 @@ export class TicketsService { ]; } if (filters.status) { - where.booking = { status: filters.status }; + where.booking = { ...where.booking, status: filters.status }; + } + if (filters.originStationId) { + where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } }; + } + if (filters.destinationStationId) { + where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } }; + } + if (filters.arrivalDate) { + const start = new Date(filters.arrivalDate); + const end = new Date(filters.arrivalDate); + end.setDate(end.getDate() + 1); + where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } }; } const tickets = await this.prisma.ticket.findMany({ where, @@ -32,7 +44,7 @@ export class TicketsService { booking: { include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, - seats: { include: { seat: { include: { coach: true } } } }, + seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, passenger: { include: { user: true } }, }, }, @@ -235,7 +247,16 @@ export class TicketsService { }; } - async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: string) { + async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) { + // Accept either a ticket UUID or a bookingRef + let bookingRef = ticketIdOrRef; + const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ticketIdOrRef); + if (isUuid) { + const ticket = await this.prisma.ticket.findUnique({ where: { id: ticketIdOrRef }, select: { bookingRef: true } }); + if (!ticket) throw new NotFoundException('Ticket not found'); + bookingRef = ticket.bookingRef; + } + const resolvedValidatorId = validatorId || 'BACKOFFICE'; const booking = await this.prisma.booking.findUnique({ where: { bookingRef } }); if (!booking) throw new NotFoundException('Booking not found'); const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } }); @@ -247,11 +268,10 @@ export class TicketsService { // ── ONE_WAY / TRANSIT (single scan) ─────────────────────────────────── if (type === 'ONE_WAY') { if (ticket.validatedAt) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } }); - throw new BadRequestException('Ticket already validated'); + return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true }; } - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } }); + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); return { validated: true, ticketId: ticket.id, validatedAt: now }; } @@ -264,28 +284,32 @@ export class TicketsService { const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); const alreadyValidated = logs.some(l => l.leg === resolvedLeg); if (alreadyValidated) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); throw new BadRequestException(`${resolvedLeg} already validated`); } - if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } // ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ──────────────────────── if (type === 'ROUND_TRIP') { - const resolvedLeg = (leg ?? 'OUTBOUND').toUpperCase(); + let resolvedLeg = (leg ?? '').toUpperCase(); + // Auto-detect next unused leg when called from backoffice without a leg param + if (!resolvedLeg) { + resolvedLeg = !(booking as any).outboundBoardedAt ? 'OUTBOUND' : 'RETURN'; + } const bookingData: Record = {}; if (resolvedLeg === 'OUTBOUND') { if ((booking as any).outboundBoardedAt) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any }); throw new BadRequestException('Outbound leg already used'); } bookingData.outboundBoardedAt = now; - if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); + if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); } else if (resolvedLeg === 'RETURN') { if ((booking as any).returnBoardedAt) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any }); throw new BadRequestException('Return leg already used'); } bookingData.returnBoardedAt = now; @@ -298,7 +322,7 @@ export class TicketsService { else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY'; await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } @@ -311,7 +335,7 @@ export class TicketsService { } const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); if (logs.some(l => l.leg === resolvedLeg)) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); throw new BadRequestException(`${resolvedLeg} already validated`); } const bookingData: Record = {}; @@ -327,18 +351,17 @@ export class TicketsService { else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY'; if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); - if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } // Fallback for unknown booking types — single scan if (ticket.validatedAt) { - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } }); - throw new BadRequestException('Ticket already validated'); + return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true }; } - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); - await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } }); + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } }); return { validated: true, ticketId: ticket.id, validatedAt: now }; } @@ -354,7 +377,7 @@ export class TicketsService { where: { scheduleId: tripId, status: 'CONFIRMED' }, include: { ticket: true, - seats: { include: { seat: { include: { coach: true } } } }, + seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, passenger: { include: { user: true } }, }, }); diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 36fd20281..7b43fb677 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -24,6 +24,19 @@ export default function BookingsPage() { const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [bookingToDelete, setBookingToDelete] = useState(null); const [successMessage, setSuccessMessage] = useState(''); + const [exportModalOpen, setExportModalOpen] = useState(false); + const [exportDateFrom, setExportDateFrom] = useState(''); + const [exportDateTo, setExportDateTo] = useState(''); + const [exportColumns, setExportColumns] = useState>({ + bookingRef: true, + passenger: true, + status: true, + bookingType: false, + passengerCount: false, + totalMinor: true, + paymentStatus: true, + createdAt: true, + }); const queryClient = useQueryClient(); @@ -80,22 +93,23 @@ export default function BookingsPage() { } }; - const handleExportBookings = async () => { - const selectedColumns = prompt( - 'Select columns to export (comma-separated):\n\n' + - 'Available: bookingRef, passenger, status, bookingType, passengerCount, totalMinor, paymentStatus, createdAt\n\n' + - 'Default: bookingRef, passenger, status, totalMinor, paymentStatus, createdAt', - 'bookingRef, passenger, status, totalMinor, paymentStatus, createdAt' - ); - - if (!selectedColumns) return; - - const cols = selectedColumns.split(',').map(c => c.trim()); + const confirmExport = () => { + const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); + if (cols.length === 0) { alert('Please select at least one column'); return; } + + const exportItems = (data?.items || []).filter((b: any) => { + if (!exportDateFrom && !exportDateTo) return true; + const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null; + if (exportDateFrom && (!d || d < exportDateFrom)) return false; + if (exportDateTo && (!d || d > exportDateTo)) return false; + return true; + }); + const csv = [ cols.join(','), - ...data?.items?.map((booking: any) => { + ...exportItems.map((booking: any) => { const values = cols.map(col => { - switch(col) { + switch (col) { case 'bookingRef': return booking.bookingRef; case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest'; case 'status': return booking.status; @@ -108,28 +122,29 @@ export default function BookingsPage() { } }); return values.map(v => `"${v}"`).join(','); - }) || [] + }), ].join('\n'); - + const blob = new Blob([csv], { type: 'text/csv' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`; a.click(); + setExportModalOpen(false); }; const columns = [ - { - key: 'bookingRef', + { + key: 'bookingRef', label: 'Reference', sortable: true, render: (booking: any) => ( {booking.bookingRef} ), }, - { - key: 'passenger', + { + key: 'passenger', label: 'Passenger', render: (booking: any) => (
@@ -138,32 +153,39 @@ export default function BookingsPage() {
), }, - { - key: 'bookingType', + { + key: 'bookingType', label: 'Type', sortable: true, render: (booking: any) => booking.bookingType || 'ONE_WAY', }, - { + { key: 'passengerCount', label: 'Passengers', - render: (booking: any) => `${(booking.adultCount || 0) + (booking.childCount || 0)}`, + render: (booking: any) => { + const adults = booking.adultCount || 0; + const children = booking.childCount || 0; + if (adults === 0 && children === 0) return '—'; + const parts = [`Adult: ${adults}`]; + if (children > 0) parts.push(`Child: ${children}`); + return parts.join(' / '); + }, }, - { - key: 'status', + { + key: 'status', label: 'Status', render: (booking: any) => ( {booking.status} ), }, - { - key: 'totalMinor', + { + key: 'totalMinor', label: 'Amount', sortable: true, render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency), }, - { - key: 'paymentStatus', + { + key: 'paymentStatus', label: 'Payment', render: (booking: any) => ( @@ -171,8 +193,8 @@ export default function BookingsPage() { ), }, - { - key: 'createdAt', + { + key: 'createdAt', label: 'Created', sortable: true, render: (booking: any) => formatDateTime(booking.createdAt), @@ -208,7 +230,7 @@ export default function BookingsPage() {

Bookings

Manage all passenger bookings

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

Passenger Information

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

Journey Details

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

Payment Information

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

Additional Information

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

Select Columns

+
+ {[ + { key: 'bookingRef', label: 'Booking Reference' }, + { key: 'passenger', label: 'Passenger' }, + { key: 'status', label: 'Status' }, + { key: 'bookingType', label: 'Booking Type' }, + { key: 'passengerCount', label: 'Passenger Count' }, + { key: 'totalMinor', label: 'Amount' }, + { key: 'paymentStatus', label: 'Payment Status' }, + { key: 'createdAt', label: 'Created At' }, + ].map((col) => ( + + ))} +
+
+ +
+ setExportModalOpen(false)}>Cancel + Export CSV +
+
+
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx index d57a67c83..8de163da9 100644 --- a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx @@ -2,14 +2,13 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Plus, Trash2, Loader2, Edit, RefreshCw } from 'lucide-react'; +import { Edit, Loader2, RefreshCw } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; -import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { apiClient } from '@/lib/api-client'; -interface Currency { +interface CurrencyRate { id: string; code: string; name: string; @@ -18,203 +17,104 @@ interface Currency { exchangeRate: number; isActive: boolean; createdAt: string; - updatedAt: string; } +const CURRENCY_META: Record = { + ETB: { name: 'Ethiopian Birr', symbol: 'Br' }, + DJF: { name: 'Djiboutian Franc', symbol: 'Fdj' }, + USD: { name: 'US Dollar', symbol: '$' }, +}; + export default function CurrenciesPage() { - const [showModal, setShowModal] = useState(false); - const [editingCurrency, setEditingCurrency] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null }>({ - isOpen: false, - id: null, - }); + const [editingRate, setEditingRate] = useState(null); + const [rateInput, setRateInput] = useState(''); const [error, setError] = useState(null); const queryClient = useQueryClient(); - const [currencyForm, setCurrencyForm] = useState({ - code: '', - name: '', - symbol: '', - baseCurrencyCode: 'ETB', - exchangeRate: '', - }); - - const { data: currencies = [], isLoading } = useQuery({ + const { data: currencies = [], isLoading } = useQuery({ queryKey: ['currencies'], queryFn: () => apiClient.get('/currencies'), }); - const createMutation = useMutation({ - mutationFn: (data: any) => apiClient.post('/currencies', data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['currencies'] }); - resetForm(); - setError(null); - }, - onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to create currency'); - }, - }); - const updateMutation = useMutation({ - mutationFn: (data: any) => apiClient.patch(`/currencies/${data.id}`, data), + mutationFn: ({ id, exchangeRate }: { id: string; exchangeRate: number }) => + apiClient.patch(`/currencies/${id}`, { exchangeRate }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['currencies'] }); - setEditingCurrency(null); - resetForm(); + setEditingRate(null); setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to update currency'); + setError(err.response?.data?.message || 'Failed to update exchange rate'); }, }); - const deleteMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/currencies/${id}`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['currencies'] }); - setDeleteConfirm({ isOpen: false, id: null }); - }, - onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to delete currency'); - }, - }); - - const syncRatesMutation = useMutation({ + const syncMutation = useMutation({ mutationFn: () => apiClient.post('/currencies/sync-rates', {}), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['currencies'] }); - setError(null); - }, - onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to sync exchange rates'); - }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['currencies'] }), + onError: (err: any) => setError(err.response?.data?.message || 'Failed to sync rates'), }); - const resetForm = () => { - setCurrencyForm({ - code: '', - name: '', - symbol: '', - baseCurrencyCode: 'ETB', - exchangeRate: '', - }); - setEditingCurrency(null); - setShowModal(false); + const handleEdit = (currency: CurrencyRate) => { + setEditingRate(currency); + setRateInput(currency.exchangeRate.toString()); setError(null); }; - const handleEditCurrency = (currency: Currency) => { - setEditingCurrency(currency); - setCurrencyForm({ - code: currency.code, - name: currency.name, - symbol: currency.symbol, - baseCurrencyCode: currency.baseCurrencyCode, - exchangeRate: currency.exchangeRate.toString(), - }); - setError(null); - setShowModal(true); - }; - - const handleSaveCurrency = async () => { - setError(null); - if (!currencyForm.code || !currencyForm.name || !currencyForm.symbol || !currencyForm.exchangeRate) { - setError('All fields are required'); - return; - } - - const rate = parseFloat(currencyForm.exchangeRate); + const handleSave = async () => { + const rate = parseFloat(rateInput); if (isNaN(rate) || rate <= 0) { setError('Exchange rate must be a positive number'); return; } - - const payload = { - code: currencyForm.code.toUpperCase(), - name: currencyForm.name, - symbol: currencyForm.symbol, - baseCurrencyCode: currencyForm.baseCurrencyCode, - exchangeRate: rate, - }; - - if (editingCurrency) { - await updateMutation.mutateAsync({ id: editingCurrency.id, ...payload }); - } else { - await createMutation.mutateAsync(payload); - } + await updateMutation.mutateAsync({ id: editingRate!.id, exchangeRate: rate }); }; - const confirmDelete = async () => { - if (deleteConfirm.id) { - await deleteMutation.mutateAsync(deleteConfirm.id); - } - }; - - const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items || []; + const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items ?? []; const columns = [ { key: 'code', - label: 'Code', - render: (currency: Currency) => ( - {currency.code} - ), - }, - { - key: 'name', - label: 'Name', - render: (currency: Currency) => ( - {currency.name} - ), - }, - { - key: 'symbol', - label: 'Symbol', - render: (currency: Currency) => ( - {currency.symbol} - ), - }, - { - key: 'baseCurrencyCode', - label: 'Base Currency', - render: (currency: Currency) => ( - {currency.baseCurrencyCode} - ), - }, - { - key: 'exchangeRate', - label: 'Exchange Rate', - render: (currency: Currency) => ( -
-
- 1 {currency.baseCurrencyCode} = {currency.exchangeRate.toFixed(4)} {currency.code} -
-
- 1 {currency.code} = {(1 / currency.exchangeRate).toFixed(6)} {currency.baseCurrencyCode} + label: 'Currency', + render: (c: CurrencyRate) => ( +
+ + {CURRENCY_META[c.code]?.symbol ?? c.symbol} + +
+
{c.code}
+
{CURRENCY_META[c.code]?.name ?? c.name}
), }, { - key: 'isActive', - label: 'Status', - render: (currency: Currency) => ( - - {currency.isActive ? 'Active' : 'Inactive'} - + key: 'baseCurrencyCode', + label: 'Base', + render: (c: CurrencyRate) => ( + {c.baseCurrencyCode} ), }, { - key: 'updatedAt', + key: 'exchangeRate', + label: 'Exchange Rate', + render: (c: CurrencyRate) => ( +
+
+ 1 {c.baseCurrencyCode} = {c.exchangeRate} {c.code} +
+
+ 1 {c.code} = {(1 / c.exchangeRate).toFixed(6)} {c.baseCurrencyCode} +
+
+ ), + }, + { + key: 'createdAt', label: 'Last Updated', - render: (currency: Currency) => ( + render: (c: CurrencyRate) => ( - {new Date(currency.updatedAt).toLocaleDateString()} + {new Date(c.createdAt).toLocaleDateString()} ), }, @@ -222,140 +122,93 @@ export default function CurrenciesPage() { const actions = [ { - label: 'Edit', - onClick: handleEditCurrency, + label: 'Edit Rate', + onClick: handleEdit, variant: 'secondary' as const, icon: Edit, }, - { - label: 'Delete', - onClick: (currency: Currency) => setDeleteConfirm({ isOpen: true, id: currency.id }), - variant: 'danger' as const, - icon: Trash2, - }, ]; return (
-

Currencies

-

Manage exchange rates and display currencies

-
-
- syncRatesMutation.mutate()} - loading={syncRatesMutation.isPending} - > - Sync Rates - - { - setError(null); - setEditingCurrency(null); - setCurrencyForm({ - code: '', - name: '', - symbol: '', - baseCurrencyCode: 'ETB', - exchangeRate: '', - }); - setShowModal(true); - }} - > - Add Currency - +

Exchange Rates

+

+ Manage ETB exchange rates for display currencies (DJF, USD) +

+ syncMutation.mutate()} + loading={syncMutation.isPending} + > + Sync Rates +
+ {error && !editingRate && ( +
+ {error} +
+ )} +
-
-
-
-
Total Currencies
-
- {currenciesArray.length} +
+ {(['ETB', 'DJF', 'USD'] as const).map((code) => { + const entry = currenciesArray.find((c: CurrencyRate) => c.code === code); + return ( +
+
+
{CURRENCY_META[code].name}
+
{code}
+
+
+ {entry ? ( + <> +
{entry.exchangeRate}
+
per ETB
+ + ) : ( + Not configured + )} +
-
-
-
Active
-
- {currenciesArray.filter((c: Currency) => c.isActive).length} -
-
-
-
Base Currency
-
ETB
-
-
-
Last Sync
-
- {currenciesArray.length > 0 - ? new Date(currenciesArray[0]?.updatedAt).toLocaleDateString() - : 'N/A'} -
-
-
- - {isLoading ? ( -
- -
- ) : currenciesArray.length === 0 ? ( -
-

No currencies configured. Click "Add Currency" to create one.

-
- ) : ( - - )} + ); + })}
+ + {isLoading ? ( +
+ +
+ ) : ( + + )}
-
-

Currency Management

-
    -
  • - • Base Currency: All exchange rates are calculated relative to this currency (typically ETB) -
  • -
  • - • Exchange Rate: How many units of the currency equal 1 unit of the base currency -
  • -
  • - • Display Currencies: Configure which currencies customers can view prices in -
  • -
  • - • Sync Rates: Automatically update exchange rates from external sources -
  • -
+
+

How it works

+

• ETB is the transaction currency — all fares are stored in ETB minor units (1 ETB = 100 minor)

+

• DJF and USD rates are used to display prices to passengers in their preferred currency

+

• Rates apply globally; changes take effect immediately on the next booking or fare quote

- {/* Delete Confirmation */} - setDeleteConfirm({ isOpen: false, id: null })} - onConfirm={confirmDelete} - title="Delete Currency" - message="Are you sure you want to delete this currency? This action cannot be undone." - confirmText="Delete" - isDanger={true} - warning="This will remove the currency from the system." - /> - - {/* Add/Edit Modal */} { setEditingRate(null); setError(null); }} + title={`Update Rate — ${editingRate?.code}`} + size="sm" >
{error && ( @@ -364,108 +217,38 @@ export default function CurrenciesPage() {
)} -
-
- - setCurrencyForm({ ...currencyForm, code: e.target.value.toUpperCase() })} - className="input w-full" - placeholder="e.g., USD" - maxLength={3} - disabled={!!editingCurrency} - required - /> -

3-letter ISO code (e.g., USD, DJF, GBP)

-
- -
- - setCurrencyForm({ ...currencyForm, name: e.target.value })} - className="input w-full" - placeholder="e.g., United States Dollar" - required - /> -
-
- -
-
- - setCurrencyForm({ ...currencyForm, symbol: e.target.value })} - className="input w-full" - placeholder="e.g., $" - maxLength={3} - required - /> -
- -
- - -

All rates relative to this currency

-
+
+ Currency: + {editingRate?.code} — {CURRENCY_META[editingRate?.code ?? '']?.name}
- -
- setCurrencyForm({ ...currencyForm, exchangeRate: e.target.value })} - className="input w-full" - placeholder="e.g., 0.018" - required - /> -
- 1 {currencyForm.baseCurrencyCode} = ? {currencyForm.code} -
-
- {currencyForm.exchangeRate && parseFloat(currencyForm.exchangeRate) > 0 && ( -

- ≈ 1 {currencyForm.code} = {(1 / parseFloat(currencyForm.exchangeRate)).toFixed(6)} {currencyForm.baseCurrencyCode} + + setRateInput(e.target.value)} + className="input w-full" + placeholder="e.g., 3.25" + autoFocus + /> + {rateInput && parseFloat(rateInput) > 0 && ( +

+ ≈ 1 {editingRate?.code} = {(1 / parseFloat(rateInput)).toFixed(6)} {editingRate?.baseCurrencyCode}

)}
-
-

Exchange Rate Example:

-

If 1 ETB = 0.018 USD, enter 0.018

-

If 1 ETB = 3.25 DJF, enter 3.25

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

Manage passenger profiles and verification

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

Select Columns

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

Payments

Manage payment transactions and refunds

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

Select Columns

+
+ {[ + { key: 'reference', label: 'Reference' }, + { key: 'booking', label: 'Booking Reference' }, + { key: 'amount', label: 'Amount' }, + { key: 'method', label: 'Payment Method' }, + { key: 'status', label: 'Status' }, + { key: 'createdAt', label: 'Created At' }, + ].map((col) => ( + + ))} +
+
+ +
+ setExportModalOpen(false)}>Cancel + Export CSV +
+
+
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx index a2dcae6aa..19fd44620 100644 --- a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx @@ -731,14 +731,19 @@ export default function PricingPage() {
- setFareForm({ ...fareForm, route: e.target.value })} className="input w-full" - placeholder="e.g., ADD-DJI" - /> -

e.g., ADD-DJI for full route

+ > + + {routesArray.map((route: Route) => ( + + ))} + +

Scope this fare to a specific route

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

Tickets

Manage tickets and validations

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

Select which columns to include in the export

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

Select Columns

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