From e25066d6d58996749a416c6b8d10fb6dc9791e25 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 23 Jun 2026 19:45:15 +0300 Subject: [PATCH 1/2] Extra luggage, tourism package, manage my trip and other updates --- .../20260623073543_config/migration.sql | 282 ++++++++++++++ apps/edr-passenger-api/prisma/schema.prisma | 135 +++++++ apps/edr-passenger-api/prisma/seed.ts | 67 +++- apps/edr-passenger-api/src/app.module.ts | 4 + .../modules/packages/packages.controller.ts | 70 ++++ .../src/modules/packages/packages.dto.ts | 94 +++++ .../src/modules/packages/packages.module.ts | 13 + .../src/modules/packages/packages.service.ts | 191 ++++++++++ .../src/modules/seats/seats.module.ts | 3 +- .../src/modules/seats/seats.service.ts | 5 +- .../system-config/system-config.controller.ts | 24 ++ .../system-config/system-config.module.ts | 13 + .../system-config/system-config.service.ts | 44 +++ .../backoffice/src/app/agents/page.tsx | 124 ++++++- .../backoffice/src/app/audit/page.tsx | 209 ++++++----- .../backoffice/src/app/fraud/page.tsx | 178 +++++++-- .../backoffice/src/app/login/page.tsx | 346 +++++++++++++----- .../backoffice/src/app/loyalty/page.tsx | 171 +++++++-- .../backoffice/src/app/payments/page.tsx | 114 +++++- .../backoffice/src/app/settings/page.tsx | 122 ++++-- .../backoffice/src/app/tickets/page.tsx | 212 ++++++----- .../backoffice/src/app/verifayda/page.tsx | 167 +++++++-- .../backoffice/src/app/wallet/page.tsx | 146 +++++++- .../backoffice/src/lib/api/index.ts | 6 + .../backoffice/src/styles/globals.css | 10 + pnpm-lock.yaml | 35 ++ 26 files changed, 2374 insertions(+), 411 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql create mode 100644 apps/edr-passenger-api/src/modules/packages/packages.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/packages/packages.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/packages/packages.module.ts create mode 100644 apps/edr-passenger-api/src/modules/packages/packages.service.ts create mode 100644 apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/system-config/system-config.module.ts create mode 100644 apps/edr-passenger-api/src/modules/system-config/system-config.service.ts diff --git a/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql new file mode 100644 index 000000000..7e086e649 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260623073543_config/migration.sql @@ -0,0 +1,282 @@ +-- DropForeignKey +ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey"; + +-- DropForeignKey +ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey"; + +-- DropForeignKey +ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey"; + +-- DropForeignKey +ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey"; + +-- DropForeignKey +ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey"; + +-- DropForeignKey +ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey"; + +-- DropForeignKey +ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey"; + +-- DropForeignKey +ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey"; + +-- DropForeignKey +ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey"; + +-- DropForeignKey +ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey"; + +-- DropForeignKey +ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey"; + +-- DropForeignKey +ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey"; + +-- DropForeignKey +ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey"; + +-- DropForeignKey +ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey"; + +-- DropForeignKey +ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey"; + +-- DropForeignKey +ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey"; + +-- DropForeignKey +ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey"; + +-- DropForeignKey +ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey"; + +-- DropForeignKey +ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey"; + +-- DropForeignKey +ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey"; + +-- DropForeignKey +ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey"; + +-- DropForeignKey +ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey"; + +-- DropForeignKey +ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey"; + +-- DropForeignKey +ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey"; + +-- DropForeignKey +ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey"; + +-- DropForeignKey +ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey"; + +-- DropForeignKey +ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey"; + +-- DropForeignKey +ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey"; + +-- DropForeignKey +ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey"; + +-- DropForeignKey +ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey"; + +-- DropForeignKey +ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey"; + +-- DropForeignKey +ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey"; + +-- DropForeignKey +ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey"; + +-- DropForeignKey +ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey"; + +-- DropForeignKey +ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey"; + +-- DropForeignKey +ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey"; + +-- DropIndex +DROP INDEX "Journey_bookingId_idx"; + +-- AlterTable +ALTER TABLE "FaydaVerificationSession" ALTER COLUMN "purpose" SET DEFAULT 'VERIFY'; + +-- CreateTable +CREATE TABLE "SystemConfig" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key"); + +-- AddForeignKey +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Journey" ADD CONSTRAINT "Journey_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index c39af29ae..33fb53610 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -298,6 +298,7 @@ model Passenger { notifications Notification[] travelerProfiles TravelerProfile[] savedRoutes SavedRoute[] + packageBookings PackageBooking[] @@index([userId]) @@schema("passenger") @@ -376,6 +377,8 @@ model TrainSchedule { liveStatus TripLiveStatus? menuItems MenuItem[] journeySegments JourneySegment[] + outboundPackages TravelPackage[] @relation("PackageOutbound") + returnPackages TravelPackage[] @relation("PackageReturn") @@index([departureAt, originStationId]) @@schema("passenger") @@ -1349,3 +1352,135 @@ model FaydaVerificationSession { @@index([expiresAt]) @@schema("passenger") } + +model SystemConfig { + id String @id @default(uuid()) + key String @unique + value String + updatedAt DateTime @updatedAt + + @@schema("passenger") +} + +enum PackageStatus { + DRAFT + ACTIVE + SOLD_OUT + EXPIRED + CANCELLED + + @@schema("passenger") +} + +model TravelPackage { + id String @id @default(uuid()) + code String @unique + name String + description String? + status PackageStatus @default(DRAFT) + outboundScheduleId String + returnScheduleId String + originStationId String + destinationStationId String + boardingTime DateTime + departureTime DateTime + arrivalTime DateTime + totalCapacity Int + bookedCount Int @default(0) + includedServices Json + coachConfiguration String? + busTransferIncluded Boolean @default(false) + busTransferRoute String? + validFrom DateTime + validUntil DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + outboundSchedule TrainSchedule @relation("PackageOutbound", fields: [outboundScheduleId], references: [id]) + returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id]) + priceTiers PackagePriceTier[] + bookings PackageBooking[] + + @@index([status, validFrom]) + @@schema("passenger") +} + +model PackagePriceTier { + id String @id @default(uuid()) + packageId String + seatType String + label String + priceMinor Int + currency String @default("ETB") + availableSeats Int @default(0) + bookedSeats Int @default(0) + + package TravelPackage @relation(fields: [packageId], references: [id]) + bookings PackageBooking[] + + @@unique([packageId, seatType]) + @@schema("passenger") +} + +model PackageBooking { + id String @id @default(uuid()) + bookingRef String @unique + packageId String + priceTierId String + passengerId String? + contactEmail String? + contactPhone String? + status BookingStatus @default(PENDING_PAYMENT) + passengerCount Int @default(1) + totalMinor Int + currency String @default("ETB") + displayCurrency Currency? + displayTotalMinor Int? + promoCode String? + source String @default("WEB") + paidAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + package TravelPackage @relation(fields: [packageId], references: [id]) + priceTier PackagePriceTier @relation(fields: [priceTierId], references: [id]) + passenger Passenger? @relation(fields: [passengerId], references: [id]) + passengers PackageBookingPassenger[] + paymentIntent PackagePaymentIntent? + + @@index([packageId, status]) + @@schema("passenger") +} + +model PackageBookingPassenger { + id String @id @default(uuid()) + bookingId String + passengerName String + dateOfBirth DateTime? + idDocumentType IdDocumentType? + idDocumentNumber String? + passportNumber String? + passportCountry String? + seatLabel String? + + booking PackageBooking @relation(fields: [bookingId], references: [id]) + + @@schema("passenger") +} + +model PackagePaymentIntent { + id String @id @default(uuid()) + packageBookingId String @unique + amountMinor Int + currency String @default("ETB") + method PaymentMethodType + status PaymentIntentStatus @default(REQUIRES_ACTION) + providerRef String? + paidAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + packageBooking PackageBooking @relation(fields: [packageBookingId], references: [id]) + + @@schema("passenger") +} diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 34e5e1dc8..50e05fa6d 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -670,6 +670,70 @@ async function seedFraudRules() { console.log(` āœ… ${rules.length} fraud detection rules created`); } +async function seedKulubbiPackage() { + console.log('\nšŸš† Seeding Kulubbi Gabriel 2025 package...'); + + const addisStation = await prisma.station.findFirst({ where: { code: 'SBT' } }); + const direDawaStation = await prisma.station.findFirst({ where: { code: 'DRE' } }); + if (!addisStation || !direDawaStation) { + console.log(' āš ļø Stations not found, skipping Kulubbi package seed'); + return; + } + + // Use the first two schedules as outbound/return (or create dedicated ones) + const schedules = await prisma.trainSchedule.findMany({ take: 2, orderBy: { departureAt: 'asc' } }); + if (schedules.length < 2) { + console.log(' āš ļø Not enough schedules found, skipping Kulubbi package seed'); + return; + } + const [outboundSchedule, returnSchedule] = schedules; + + await prisma.travelPackage.upsert({ + where: { code: 'KULUBBI-2025' }, + update: {}, + create: { + code: 'KULUBBI-2025', + name: 'Kulubbi Gabriel Pilgrimage Package', + description: 'Annual pilgrimage round-trip package to Kulubi Gabriel Church. Includes train travel, bus transfer, meals, and entertainment.', + outboundScheduleId: outboundSchedule.id, + returnScheduleId: returnSchedule.id, + originStationId: addisStation.id, + destinationStationId: direDawaStation.id, + boardingTime: new Date('2025-07-24T07:00:00+03:00'), + departureTime: new Date('2025-07-24T09:00:00+03:00'), + arrivalTime: new Date('2025-07-25T06:00:00+03:00'), + totalCapacity: 912, + coachConfiguration: '1 Locomotive + 2SBC + 2HBC + 6HSC', + busTransferIncluded: true, + busTransferRoute: 'Dire Dawa ↔ Kulubi Gabriel', + validFrom: new Date('2025-07-01'), + validUntil: new Date('2025-07-24T09:00:00+03:00'), + status: 'ACTIVE', + includedServices: [ + 'Round-trip train travel (Addis Ababa ↔ Dire Dawa)', + 'Lunch served on board', + 'Refreshments and bottled water', + 'Round-trip bus transfer (Dire Dawa ↔ Kulubi Gabriel)', + 'Onboard first aid and medical support', + 'Entertainment (audio/video)', + 'Service briefing and pilgrimage guidance', + 'Pick-up and drop-off coordination', + ], + priceTiers: { + create: [ + { seatType: 'HSC', label: 'Regular Seat (HSC)', priceMinor: 1023200, availableSeats: 550 }, + { seatType: 'ECU', label: 'Economic Bed Upper (ECU)', priceMinor: 1295200, availableSeats: 80 }, + { seatType: 'ECM', label: 'Economic Bed Middle (ECM)', priceMinor: 1364000, availableSeats: 80 }, + { seatType: 'ECL', label: 'Economic Bed Lower (ECL)', priceMinor: 1430200, availableSeats: 80 }, + { seatType: 'VIU', label: 'VIP Bed Upper (VIU)', priceMinor: 1243500, availableSeats: 61 }, + { seatType: 'VIL', label: 'VIP Bed Lower (VIL)', priceMinor: 1643500, availableSeats: 61 }, + ], + }, + }, + }); + console.log(' āœ… Kulubbi Gabriel 2025 package created'); +} + // Run a seed step in isolation: if it throws (FK conflict, duplicate row, // missing record, etc.) log the error and keep going so the rest of the seed — // and the API startup that follows it — are never blocked by one bad step. @@ -691,7 +755,8 @@ async function main() { ['fare rules', seedFareRules], ['segment fares', seedSegmentFares], ['currency', seedCurrency], - ['notification templates', seedNotificationTemplates] + ['notification templates', seedNotificationTemplates], + ['kulubbi package', seedKulubbiPackage], ]; let failed = 0; diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 757432c3b..7fab3d082 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -42,6 +42,8 @@ import { FareEngineModule } from './modules/fare-engine/fare-engine.module'; import { VerifaydaModule } from './modules/verifayda/verifayda.module'; import { AuditModuleFeature } from './modules/audit/audit.module'; import { CurrenciesModule } from './modules/currencies/currencies.module'; +import { SystemConfigModule } from './modules/system-config/system-config.module'; +import { PackagesModule } from './modules/packages/packages.module'; @Module({ imports: [ @@ -91,6 +93,8 @@ import { CurrenciesModule } from './modules/currencies/currencies.module'; VerifaydaModule, AuditModuleFeature, CurrenciesModule, + SystemConfigModule, + PackagesModule, ], }) export class AppModule implements NestModule { diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts new file mode 100644 index 000000000..c2bc883b6 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -0,0 +1,70 @@ +import { Body, Controller, Get, Param, Post, Patch, UseGuards, Request, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { PackagesService } from './packages.service'; +import { CreatePackageDto, BookPackageDto } from './packages.dto'; +import { JwtGuard } from '../../common/jwt.guard'; +import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; + +@ApiTags('Packages') +@Controller('packages') +export class PackagesController { + constructor(private readonly service: PackagesService) {} + + @Get() + @ApiOperation({ summary: 'List active packages' }) + listActive() { + return this.service.listActive(); + } + + @Get('all') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'List all packages (admin)' }) + listAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) { + return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20); + } + + @Get('my-bookings') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Get my package bookings' }) + myBookings(@Request() req: any) { + return this.service.getMyBookings(req.user.passengerId); + } + + @Get('booking/:ref') + @ApiOperation({ summary: 'Get package booking by reference' }) + getBookingByRef(@Param('ref') ref: string) { + return this.service.getBookingByRef(ref); + } + + @Get(':id') + @ApiOperation({ summary: 'Get package details' }) + getById(@Param('id') id: string) { + return this.service.getById(id); + } + + @Post() + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Create package (admin)' }) + create(@Body() dto: CreatePackageDto) { + return this.service.create(dto); + } + + @Patch(':id/activate') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Activate package (admin)' }) + activate(@Param('id') id: string) { + return this.service.activate(id); + } + + @Post('book') + @UseGuards(OptionalJwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Book a package (public or authenticated)' }) + book(@Body() dto: BookPackageDto, @Request() req: any) { + return this.service.book(dto, req.user?.passengerId); + } +} diff --git a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts new file mode 100644 index 000000000..ad7ed8265 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts @@ -0,0 +1,94 @@ +import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreatePriceTierDto { + @ApiProperty({ example: 'HSC' }) + @IsString() seatType: string; + + @ApiProperty({ example: 'Regular Seat (HSC)' }) + @IsString() label: string; + + @ApiProperty({ example: 1023200 }) + @IsInt() @Min(0) priceMinor: number; + + @ApiProperty({ example: 100 }) + @IsInt() @Min(0) availableSeats: number; +} + +export class CreatePackageDto { + @ApiProperty({ example: 'KULUBBI-2025' }) + @IsString() code: string; + + @ApiProperty({ example: 'Kulubbi Gabriel Pilgrimage Package' }) + @IsString() name: string; + + @ApiPropertyOptional() + @IsOptional() @IsString() description?: string; + + @ApiProperty() @IsUUID() outboundScheduleId: string; + @ApiProperty() @IsUUID() returnScheduleId: string; + @ApiProperty() @IsUUID() originStationId: string; + @ApiProperty() @IsUUID() destinationStationId: string; + + @ApiProperty({ example: '2025-07-24T07:00:00Z' }) + @IsDateString() boardingTime: string; + + @ApiProperty({ example: '2025-07-24T09:00:00Z' }) + @IsDateString() departureTime: string; + + @ApiProperty({ example: '2025-07-25T06:00:00Z' }) + @IsDateString() arrivalTime: string; + + @ApiProperty({ example: 912 }) + @IsInt() @Min(1) totalCapacity: number; + + @ApiPropertyOptional({ example: '1 Locomotive + 2SBC + 2HBC + 6HSC' }) + @IsOptional() @IsString() coachConfiguration?: string; + + @ApiProperty({ type: [String] }) + @IsArray() @IsString({ each: true }) includedServices: string[]; + + @ApiPropertyOptional() @IsOptional() @IsBoolean() busTransferIncluded?: boolean; + @ApiPropertyOptional() @IsOptional() @IsString() busTransferRoute?: string; + + @ApiProperty({ example: '2025-07-01T00:00:00Z' }) + @IsDateString() validFrom: string; + + @ApiProperty({ example: '2025-07-24T09:00:00Z' }) + @IsDateString() validUntil: string; + + @ApiProperty({ type: [CreatePriceTierDto] }) + @IsArray() @ValidateNested({ each: true }) @Type(() => CreatePriceTierDto) + priceTiers: CreatePriceTierDto[]; +} + +export class BookPackagePassengerDto { + @ApiProperty() @IsString() passengerName: string; + @ApiPropertyOptional() @IsOptional() @IsDateString() dateOfBirth?: string; + @ApiPropertyOptional() @IsOptional() @IsString() idDocumentType?: string; + @ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string; + @ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string; + @ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string; +} + +export class BookPackageDto { + @ApiProperty() @IsUUID() packageId: string; + @ApiProperty() @IsUUID() priceTierId: string; + + @ApiPropertyOptional() + @IsOptional() @IsString() displayCurrency?: string; + + @ApiPropertyOptional() + @IsOptional() @IsString() contactEmail?: string; + + @ApiPropertyOptional() + @IsOptional() @IsString() contactPhone?: string; + + @ApiPropertyOptional() + @IsOptional() @IsString() promoCode?: string; + + @ApiProperty({ type: [BookPackagePassengerDto] }) + @IsArray() @ValidateNested({ each: true }) @Type(() => BookPackagePassengerDto) + passengers: BookPackagePassengerDto[]; +} diff --git a/apps/edr-passenger-api/src/modules/packages/packages.module.ts b/apps/edr-passenger-api/src/modules/packages/packages.module.ts new file mode 100644 index 000000000..f84a23781 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/packages/packages.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../common/prisma.module'; +import { PackagesController } from './packages.controller'; +import { PackagesService } from './packages.service'; +import { CurrencyModule } from '../currency/currency.module'; + +@Module({ + imports: [PrismaModule, CurrencyModule], + controllers: [PackagesController], + providers: [PackagesService], + exports: [PackagesService], +}) +export class PackagesModule {} diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts new file mode 100644 index 000000000..95aaf013a --- /dev/null +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -0,0 +1,191 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { CurrencyService } from '../currency/currency.service'; +import { CreatePackageDto, BookPackageDto } from './packages.dto'; +import { Currency } from '@prisma/client'; + +function generateRef(): string { + return 'PKG-' + Array.from({ length: 6 }, () => + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(Math.random() * 26)], + ).join(''); +} + +@Injectable() +export class PackagesService { + constructor( + private readonly prisma: PrismaService, + private readonly currencyService: CurrencyService, + ) {} + + listActive() { + const now = new Date(); + return this.prisma.travelPackage.findMany({ + where: { status: 'ACTIVE', validFrom: { lte: now }, validUntil: { gte: now } }, + include: { + priceTiers: true, + outboundSchedule: { include: { originStation: true, destinationStation: true } }, + returnSchedule: { include: { originStation: true, destinationStation: true } }, + }, + orderBy: { validFrom: 'asc' }, + }); + } + + async getById(id: string) { + const pkg = await this.prisma.travelPackage.findUnique({ + where: { id }, + include: { + priceTiers: true, + outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } }, + returnSchedule: { include: { originStation: true, destinationStation: true, train: true } }, + }, + }); + if (!pkg) throw new NotFoundException('Package not found'); + return pkg; + } + + create(dto: CreatePackageDto) { + return this.prisma.travelPackage.create({ + data: { + code: dto.code, + name: dto.name, + description: dto.description, + outboundScheduleId: dto.outboundScheduleId, + returnScheduleId: dto.returnScheduleId, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + boardingTime: new Date(dto.boardingTime), + departureTime: new Date(dto.departureTime), + arrivalTime: new Date(dto.arrivalTime), + totalCapacity: dto.totalCapacity, + coachConfiguration: dto.coachConfiguration, + includedServices: dto.includedServices, + busTransferIncluded: dto.busTransferIncluded ?? false, + busTransferRoute: dto.busTransferRoute, + validFrom: new Date(dto.validFrom), + validUntil: new Date(dto.validUntil), + status: 'DRAFT', + priceTiers: { create: dto.priceTiers }, + }, + include: { priceTiers: true }, + }); + } + + async activate(id: string) { + const pkg = await this.prisma.travelPackage.findUnique({ where: { id } }); + if (!pkg) throw new NotFoundException('Package not found'); + return this.prisma.travelPackage.update({ where: { id }, data: { status: 'ACTIVE' } }); + } + + async book(dto: BookPackageDto, passengerId?: string) { + const pkg = await this.prisma.travelPackage.findUnique({ + where: { id: dto.packageId }, + include: { priceTiers: true }, + }); + if (!pkg) throw new NotFoundException('Package not found'); + if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking'); + if (new Date() > pkg.validUntil) throw new BadRequestException('Package has expired'); + + const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId); + if (!tier) throw new NotFoundException('Price tier not found'); + + const passengerCount = dto.passengers.length; + const remaining = tier.availableSeats - tier.bookedSeats; + if (passengerCount > remaining) { + throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`); + } + + const totalMinor = tier.priceMinor * passengerCount; + const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB; + const displayTotalMinor = + displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + const [booking] = await this.prisma.$transaction([ + this.prisma.packageBooking.create({ + data: { + bookingRef: generateRef(), + packageId: dto.packageId, + priceTierId: dto.priceTierId, + passengerId: passengerId ?? null, + contactEmail: dto.contactEmail, + contactPhone: dto.contactPhone, + promoCode: dto.promoCode, + passengerCount, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor, + status: 'PENDING_PAYMENT', + passengers: { + create: dto.passengers.map((p) => ({ + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth ? new Date(p.dateOfBirth) : undefined, + idDocumentType: p.idDocumentType as any, + idDocumentNumber: p.idDocumentNumber, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + })), + }, + }, + include: { + passengers: true, + priceTier: true, + package: { + include: { + outboundSchedule: { include: { originStation: true, destinationStation: true } }, + returnSchedule: { include: { originStation: true, destinationStation: true } }, + }, + }, + }, + }), + this.prisma.packagePriceTier.update({ + where: { id: dto.priceTierId }, + data: { bookedSeats: { increment: passengerCount } }, + }), + ]); + + return booking; + } + + getMyBookings(passengerId: string) { + return this.prisma.packageBooking.findMany({ + where: { passengerId }, + include: { package: true, priceTier: true, passengers: true, paymentIntent: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + async getBookingByRef(bookingRef: string) { + const booking = await this.prisma.packageBooking.findUnique({ + where: { bookingRef }, + include: { + package: { + include: { + outboundSchedule: { include: { originStation: true, destinationStation: true } }, + returnSchedule: { include: { originStation: true, destinationStation: true } }, + }, + }, + priceTier: true, + passengers: true, + paymentIntent: true, + }, + }); + if (!booking) throw new NotFoundException('Package booking not found'); + return booking; + } + + async listAll(page = 1, pageSize = 20) { + const skip = (page - 1) * pageSize; + const [items, total] = await Promise.all([ + this.prisma.travelPackage.findMany({ + skip, + take: pageSize, + include: { priceTiers: true }, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.travelPackage.count(), + ]); + return { items, total, page, pageSize }; + } +} diff --git a/apps/edr-passenger-api/src/modules/seats/seats.module.ts b/apps/edr-passenger-api/src/modules/seats/seats.module.ts index a21797e0f..99bbadaa2 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.module.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.module.ts @@ -4,9 +4,10 @@ import { SeatsController } from './seats.controller'; import { SeatsService } from './seats.service'; import { SegmentsModule } from '../segments/segments.module'; import { IamModule } from '../../common/iam.module'; +import { SystemConfigModule } from '../system-config/system-config.module'; @Module({ - imports: [SegmentsModule, HttpModule, IamModule], + imports: [SegmentsModule, HttpModule, IamModule, SystemConfigModule], controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService], 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 0dbc1c653..00167b52f 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -3,12 +3,14 @@ import { PrismaService } from '../../common/prisma.service'; import { HoldSeatsDto } from './seats.dto'; import { Cron, CronExpression } from '@nestjs/schedule'; import { SegmentsService } from '../segments/segments.service'; +import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; @Injectable() export class SeatsService { constructor( private prisma: PrismaService, private segmentsService: SegmentsService, + private systemConfig: SystemConfigService, ) {} async getSeatMap(scheduleId: string, coachId?: string, originStationId?: string, destinationStationId?: string) { @@ -169,7 +171,8 @@ export class SeatsService { if (new Set(seatIds).size !== seatIds.length) throw new BadRequestException('Duplicate seatId in passengers list'); - const expiresAt = new Date(Date.now() + 5 * 60 * 1000); + const holdMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES); + const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000); const hold = await this.prisma.$transaction(async (tx) => { const seats = await tx.seat.findMany({ diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts new file mode 100644 index 000000000..88fe21ffd --- /dev/null +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts @@ -0,0 +1,24 @@ +import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { SystemConfigService } from './system-config.service'; +import { IamGuard } from '../../common/iam-adapter'; +import { Roles } from '../../common/roles.decorator'; + +@ApiTags('System Config') +@ApiBearerAuth('IAM-auth') +@UseGuards(IamGuard) +@Roles('ADMIN') +@Controller('system-config') +export class SystemConfigController { + constructor(private service: SystemConfigService) {} + + @Get() + getAll() { + return this.service.getAll(); + } + + @Patch() + update(@Body() body: Record) { + return this.service.updateMany(body); + } +} diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.module.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.module.ts new file mode 100644 index 000000000..f6b71fcd3 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { SystemConfigService } from './system-config.service'; +import { SystemConfigController } from './system-config.controller'; +import { PrismaModule } from '../../common/prisma.module'; + +@Module({ + imports: [PrismaModule, HttpModule], + controllers: [SystemConfigController], + providers: [SystemConfigService], + exports: [SystemConfigService], +}) +export class SystemConfigModule {} diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts new file mode 100644 index 000000000..918ac7d3b --- /dev/null +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; + +export const CONFIG_KEYS = { + SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes', +} as const; + +const DEFAULTS: Record = { + [CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5', +}; + +@Injectable() +export class SystemConfigService { + constructor(private prisma: PrismaService) {} + + async getAll(): Promise> { + const rows = await this.prisma.systemConfig.findMany(); + const result: Record = { ...DEFAULTS }; + for (const row of rows) result[row.key] = row.value; + return result; + } + + async getValue(key: string): Promise { + const row = await this.prisma.systemConfig.findUnique({ where: { key } }); + return row?.value ?? DEFAULTS[key] ?? ''; + } + + async getNumber(key: string): Promise { + return parseInt(await this.getValue(key), 10) || parseInt(DEFAULTS[key] ?? '0', 10); + } + + async set(key: string, value: string): Promise { + await this.prisma.systemConfig.upsert({ + where: { key }, + update: { value }, + create: { key, value }, + }); + } + + async updateMany(entries: Record): Promise> { + await Promise.all(Object.entries(entries).map(([k, v]) => this.set(k, v))); + return this.getAll(); + } +} diff --git a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx index d220bdd1a..efb62c0c3 100644 --- a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx @@ -2,15 +2,30 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Plus, Edit, DollarSign, Clock } from 'lucide-react'; +import { Plus, Edit, DollarSign, Clock, Eye } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Badge from '@/components/ui/Badge'; +import Modal from '@/components/ui/Modal'; import { agentsApi } from '@/lib/api'; import { formatCurrency, formatDateTime } from '@/lib/utils'; +const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( +
+

{label}

+

{value || '—'}

+
+); + +const SectionHeader = ({ title }: { title: string }) => ( +

+ {title} +

+); + export default function AgentsPage() { const [filters, setFilters] = useState({ search: '', active: '' }); + const [selected, setSelected] = useState(null); const { data, isLoading } = useQuery({ queryKey: ['agents', filters], @@ -30,7 +45,7 @@ export default function AgentsPage() { render: (agent: any) => (
{agent.user?.fullName || 'N/A'}
-
{agent.user?.email}
+
{agent.user?.email}
), }, @@ -51,19 +66,21 @@ export default function AgentsPage() { ]; const actions = [ + { + label: 'View Details', + onClick: (agent: any) => setSelected(agent), + variant: 'secondary' as const, + icon: Eye, + }, { label: 'View Shifts', - onClick: (agent: any) => { - window.location.href = `/agents/${agent.id}/shifts`; - }, + onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/shifts`; }, variant: 'secondary' as const, icon: Clock, }, { label: 'View Commissions', - onClick: (agent: any) => { - window.location.href = `/agents/${agent.id}/commissions`; - }, + onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/commissions`; }, variant: 'secondary' as const, icon: DollarSign, }, @@ -119,6 +136,97 @@ export default function AgentsPage() { loading={isLoading} emptyMessage="No agents found" /> + + {/* Agent Details Modal */} + setSelected(null)} title="Agent Details" size="xl"> + {selected && (() => { + const a = selected; + const initials = (a.user?.fullName || a.agentCode || '?').split(' ').map((w: string) => w[0]).join('').slice(0, 2).toUpperCase(); + return ( +
+
+
+
+ {initials} +
+
+

{a.user?.fullName || 'N/A'}

+

{a.agentCode}

+
+
+ + {a.active ? 'Active' : 'Inactive'} + +
+
+
+ {[ + { label: 'Agent Code', value: a.agentCode || '—' }, + { label: 'Commission Rate', value: `${a.commissionRate ?? 0}%` }, + { label: 'Total Bookings', value: (a.totalBookings ?? 0).toLocaleString() }, + ].map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+
+ +
+
+ +
+ + + +
+

Status

+ + {a.active ? 'Active' : 'Inactive'} + +
+
+
+ +
+ +
+ + + + + +
+
+ +
+ +
+ + + + +
+
+ +
+ +
+ + + +
+
+
+ +
+ setSelected(null)}>Close +
+
+ ); + })()} +
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx index 06d8a6811..0153887ae 100644 --- a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx @@ -252,111 +252,132 @@ export default function AuditLogsPage() { {/* Details Modal */} { - setShowDetailsModal(false); - setSelectedLog(null); - }} - title={`${selectedLog?.action} - ${selectedLog?.entityType}`} - size="lg" + onClose={() => { setShowDetailsModal(false); setSelectedLog(null); }} + title="Audit Log Details" + size="xl" > -
- {/* Basic Info */} -
-
- -

{formatDateTime(selectedLog?.createdAt)}

-
-
- -

- - {selectedLog?.action} - -

-
-
- -

{selectedLog?.entityType}

-
-
- -

- {selectedLog?.entityId || 'System'} -

-
-
+ {selectedLog && (() => { + const l = selectedLog; + const actionColor: Record = { + CREATE: 'from-emerald-600 to-emerald-700', + UPDATE: 'from-blue-600 to-blue-700', + DELETE: 'from-red-600 to-red-700', + LOGIN: 'from-violet-600 to-violet-700', + LOGOUT: 'from-gray-600 to-gray-700', + }; + const gradient = actionColor[l.action] || 'from-gray-600 to-gray-700'; - {/* User Info */} - {selectedLog?.user && ( -
-

User Information

-
-
- -

{selectedLog?.user?.fullName}

+ const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( +
+

{label}

+

{value || '—'}

+
+ ); + + const SectionHeader = ({ title }: { title: string }) => ( +

+ {title} +

+ ); + + return ( +
+
+
+
+

Action

+

{l.action}

+
+
+ {l.entityType} +

{formatDateTime(l.createdAt)}

+
-
- -

{selectedLog?.user?.email}

+
+
+

User

+

{l.user?.fullName || 'System'}

+
+
+

IP Address

+

{l.ipAddress || 'N/A'}

+
-
- )} - {/* Network Info */} - {(selectedLog?.ipAddress || selectedLog?.userAgent) && ( -
-

Network Information

-
- {selectedLog?.ipAddress && ( -
- -

{selectedLog?.ipAddress}

+
+
+ +
+ + + +
+
+ + {l.user && ( +
+ +
+ + + +
+
)} - {selectedLog?.userAgent && ( -
- -

- {selectedLog?.userAgent} -

+ + {(l.ipAddress || l.userAgent) && ( +
+ +
+ +
+

User Agent

+

{l.userAgent || '—'}

+
+
+
+ )} + + {(l.oldData || l.newData) && ( +
+ +
+ {l.oldData && ( +
+

← Before

+
+                            {formatJsonData(l.oldData)}
+                          
+
+ )} + {l.newData && ( +
+

→ After

+
+                            {formatJsonData(l.newData)}
+                          
+
+ )} +
+
+ )} + +
+ +
+
- )} +
+
+ +
+ { setShowDetailsModal(false); setSelectedLog(null); }}>Close
- )} - - {/* Changes */} - {(selectedLog?.oldData || selectedLog?.newData) && ( -
-

Data Changes

-
- {selectedLog?.oldData && ( -
- -
-                      {formatJsonData(selectedLog?.oldData)}
-                    
-
- )} - {selectedLog?.newData && ( -
- -
-                      {formatJsonData(selectedLog?.newData)}
-                    
-
- )} -
-
- )} - - {/* Raw Log ID */} -
- -

{selectedLog?.id}

-
-
+ ); + })()}
); diff --git a/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx b/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx index 242f9bfd9..cc8ac9e7b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx @@ -2,15 +2,37 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { AlertTriangle, CheckCircle, Ban } from 'lucide-react'; +import { AlertTriangle, CheckCircle, Ban, Eye } 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 { fraudApi } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; +const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( +
+

{label}

+

{value || '—'}

+
+); + +const SectionHeader = ({ title }: { title: string }) => ( +

+ {title} +

+); + +const SEVERITY_GRAD: Record = { + CRITICAL: 'from-red-700 to-red-800', + HIGH: 'from-red-600 to-red-700', + MEDIUM: 'from-amber-500 to-amber-600', + LOW: 'from-blue-500 to-blue-600', +}; + export default function FraudDetectionPage() { const [filters, setFilters] = useState({ search: '', severity: '', status: '' }); + const [selected, setSelected] = useState(null); const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ @@ -40,10 +62,7 @@ export default function FraudDetectionPage() { const handleBlockUser = async (alert: any) => { if (confirm(`Block user ${alert.user?.email}?`)) { - await blockUserMutation.mutateAsync({ - userId: alert.userId, - reason: `Fraud alert: ${alert.ruleType}`, - }); + await blockUserMutation.mutateAsync({ userId: alert.userId, reason: `Fraud alert: ${alert.ruleType}` }); } }; @@ -52,7 +71,7 @@ export default function FraudDetectionPage() { key: 'severity', label: 'Severity', render: (alert: any) => ( - + {alert.severity} ), @@ -62,7 +81,7 @@ export default function FraudDetectionPage() { label: 'Rule Type', render: (alert: any) => (
- + {alert.ruleType}
), @@ -80,9 +99,7 @@ export default function FraudDetectionPage() { { key: 'description', label: 'Description', - render: (alert: any) => ( - {alert.description || alert.details} - ), + render: (alert: any) => {alert.description || alert.details}, }, { key: 'status', @@ -102,6 +119,12 @@ export default function FraudDetectionPage() { ]; const actions = [ + { + label: 'View Details', + onClick: (alert: any) => setSelected(alert), + variant: 'secondary' as const, + icon: Eye, + }, { label: 'Acknowledge', onClick: handleAcknowledge, @@ -130,21 +153,11 @@ export default function FraudDetectionPage() {
- setFilters({ ...filters, search: e.target.value })} - /> + setFilters({ ...filters, search: e.target.value })} />
- setFilters({ ...filters, severity: e.target.value })}> @@ -154,11 +167,7 @@ export default function FraudDetectionPage() {
- setFilters({ ...filters, status: e.target.value })}> @@ -174,6 +183,121 @@ export default function FraudDetectionPage() { loading={isLoading} emptyMessage="No fraud alerts found" /> + + {/* Fraud Alert Details Modal */} + setSelected(null)} title="Fraud Alert Details" size="xl"> + {selected && (() => { + const al = selected; + const grad = SEVERITY_GRAD[al.severity] || 'from-gray-600 to-gray-700'; + return ( +
+
+
+
+

Fraud Alert

+

{al.ruleType}

+
+
+ + {al.severity} + +
+ + {al.acknowledged ? 'Acknowledged' : 'Pending'} + +
+

{formatDateTime(al.createdAt)}

+
+
+
+ {[ + { label: 'Severity', value: al.severity || '—' }, + { label: 'Rule Type', value: al.ruleType || '—' }, + { label: 'User', value: al.user?.fullName || al.user?.email || '—' }, + ].map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+
+ +
+
+ +
+ + +
+

Status

+ + {al.acknowledged ? 'Acknowledged' : 'Pending'} + +
+ +
+

Description

+

{al.description || al.details || '—'}

+
+
+
+ +
+ +
+ + + + +
+

Blocked

+ + {al.user?.isBlocked ? 'Blocked' : 'Not Blocked'} + +
+
+
+ + {al.bookingId && ( +
+ +
+ + + +
+
+ )} + + {al.acknowledged && ( +
+ +
+ + + +
+
+ )} + +
+ +
+ + + +
+
+
+ +
+ setSelected(null)}>Close +
+
+ ); + })()} +
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index fe0917aa3..0e821873e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -4,141 +4,295 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; import { useTheme } from '@/lib/theme-store'; -import { Train, Eye, EyeOff, Sun, Moon } from 'lucide-react'; +import { + Eye, EyeOff, Sun, Moon, ArrowRight, Loader2, + TicketCheck, Users, TrendingUp, ShieldCheck, +} from 'lucide-react'; + +const EDR_GREEN = 'rgb(20, 113, 76)'; + +const features = [ + { icon: TicketCheck, label: 'Booking Management', desc: 'Full lifecycle booking operations' }, + { icon: Users, label: 'Passenger Services', desc: 'Profiles, loyalty & wallet' }, + { icon: TrendingUp, label: 'Revenue Analytics', desc: 'Real-time reports & insights' }, + { icon: ShieldCheck, label: 'Fraud Detection', desc: 'Automated risk monitoring' }, +]; export default function LoginPage() { - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); const [showPassword, setShowPassword] = useState(false); const [isMounted, setIsMounted] = useState(false); - const router = useRouter(); - const { login } = useAuthStore(); + const [emailFocused, setEmailFocused] = useState(false); + const [passwordFocused, setPasswordFocused] = useState(false); + + const router = useRouter(); + const { login } = useAuthStore(); const { isDark, toggleTheme } = useTheme(); - useEffect(() => { - setIsMounted(true); - }, []); + useEffect(() => { setIsMounted(true); }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(''); - try { await login(email, password); router.push('/dashboard'); } catch (err: any) { - const message = err.response?.data?.message || err.message || 'Login failed. Please check your credentials.'; - setError(message); + setError(err.response?.data?.message || err.message || 'Invalid credentials. Please try again.'); } finally { setLoading(false); } }; - if (!isMounted) { - return null; - } + if (!isMounted) return null; return ( -
- {/* Full Screen Banner Background */} -
+
- {/* Content Overlay */} -
-
- {/* Login Card with Shadow */} -
- {/* Card Header with Logo, App Name and Theme Toggle */} -
-
-
- -
-
-

Ethio-Djibouti Railway

-

Passenger Back-office

-
-
- - + {/* ── LEFT PANEL — form ── */} +
+ + {/* Top bar */} +
+ {/* Logo — always visible on the form panel */} +
+
+ + + + + + +
+
+
ETHIO-DJIBOUTI
+
Railway
+
+
+ + +
+ + {/* Form area */} +
+
+ + {/* Heading */} +
+

+ Sign in to continue +

+

+ Enter your credentials to access the back-office. +

- {/* Card Body */} -
-
-

Welcome back!

-

Sign in to continue.

-
- - {error && ( -
- {error} + {/* Error */} + {error && (
+
+
+ !
- )} +

{error}

+
+ )} -
-
- + + + {/* Email field */} +
+ +
setEmail(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent" - placeholder="name@email.com" + onChange={(e) => { setEmail(e.target.value); setError(''); }} + onFocus={() => setEmailFocused(true)} + onBlur={() => setEmailFocused(false)} + className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none" + placeholder="name@edr.com" required + autoComplete="email" />
+
-
- -
- setPassword(e.target.value)} - className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent" - placeholder="••••••••" - required - /> - -
+ {/* Password field */} +
+
+ +
+
+ { setPassword(e.target.value); setError(''); }} + onFocus={() => setPasswordFocused(true)} + onBlur={() => setPasswordFocused(false)} + className="w-full px-4 py-3 pr-11 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none" + placeholder="••••••••••" + required + autoComplete="current-password" + /> + +
+
- - + {/* Submit */} + + + + {/* Divider */} +
+
+ +

+ Access is restricted to authorised EDR staff only. All sessions are logged and audited. +

+
+ + {/* Bottom bar */} +
+ + Back-office Ā· v1.0 + + + Need help? support@edr.com + +
+
+ + {/* ── RIGHT PANEL — photo ── */} +
+ {/* Layer 1 — base photo, desaturated */} +
+ + {/* Layer 2 — brand green color wash */} +
+ + {/* Content */} +
+ {/* Badge */} +
+
+
+ Back-office Portal v1.0 +
+
+ + {/* Hero text */} +
+

+ Passenger
+ + Management + +
System +

+

+ Unified platform for booking operations, passenger services, revenue analytics, and real-time train management. +

+
+ + {/* Feature grid */} +
+ {features.map(({ icon: Icon, label, desc }) => ( +
+
+ +
+
+
{label}
+
{desc}
+
+
+ ))} +
+ + {/* Bottom rule */} +
+ Ā© 2026 Ethio-Djibouti Railway S.C. — Secure Ā· Encrypted Ā· Monitored +
+
); diff --git a/apps/edr-passenger-web/backoffice/src/app/loyalty/page.tsx b/apps/edr-passenger-web/backoffice/src/app/loyalty/page.tsx index 75d52f940..7fbfffbf0 100644 --- a/apps/edr-passenger-web/backoffice/src/app/loyalty/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/loyalty/page.tsx @@ -2,15 +2,44 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Download } from 'lucide-react'; +import { Download, Eye, Star } 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 { loyaltyApi } from '@/lib/api'; -import { formatDateTime, formatCurrency } from '@/lib/utils'; +import { formatDateTime } from '@/lib/utils'; + +const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( +
+

{label}

+

{value || '—'}

+
+); + +const SectionHeader = ({ title }: { title: string }) => ( +

+ {title} +

+); + +const TIER_COLORS: Record = { + BRONZE: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 border-orange-200 dark:border-orange-800', + SILVER: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600', + GOLD: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800', + PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800', +}; + +const TIER_GRAD: Record = { + BRONZE: 'from-orange-500 to-orange-600', + SILVER: 'from-gray-500 to-gray-600', + GOLD: 'from-yellow-500 to-yellow-600', + PLATINUM: 'from-indigo-600 to-indigo-700', +}; export default function LoyaltyPage() { const [filters, setFilters] = useState({ search: '', tier: '' }); + const [selected, setSelected] = useState(null); const { data, isLoading } = useQuery({ queryKey: ['loyalty', filters], @@ -18,11 +47,24 @@ export default function LoyaltyPage() { }); const columns = [ - { key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' }, - { key: 'tier', label: 'Tier', render: (account: any) => {account.tier} }, - { key: 'pointsBalance', label: 'Points', render: (account: any) => account.pointsBalance?.toLocaleString() || 0 }, - { key: 'lifetimePoints', label: 'Lifetime Points', render: (account: any) => account.lifetimePoints?.toLocaleString() || 0 }, - ]; + { key: 'passenger', label: 'Passenger', render: (account: any) => ( +
+
{account.passenger?.fullName || account.user?.fullName || 'N/A'}
+
{account.passenger?.email || account.user?.email || ''}
+
+ )}, + { key: 'tier', label: 'Tier', render: (account: any) => ( + + {account.tier} + + )}, + { key: 'pointsBalance', label: 'Points', render: (account: any) => (account.pointsBalance ?? 0).toLocaleString() }, + { key: 'lifetimePoints', label: 'Lifetime Points', render: (account: any) => (account.lifetimePoints ?? 0).toLocaleString() }, + ]; + + const actions = [ + { label: 'View Details', onClick: (a: any) => setSelected(a), variant: 'secondary' as const, icon: Eye }, + ]; return (
@@ -36,31 +78,112 @@ export default function LoyaltyPage() {
- -
- - setFilters({ ...filters, search: e.target.value })} /> -
-
- - -
- +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+
+ + +
+ + {/* Loyalty Details Modal */} + setSelected(null)} title="Loyalty Account Details" size="xl"> + {selected && (() => { + const a = selected; + const tier = a.tier || 'BRONZE'; + const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE; + const grad = TIER_GRAD[tier] || 'from-gray-600 to-gray-700'; + const passengerName = a.passenger?.fullName || a.user?.fullName || 'N/A'; + return ( +
+
+
+
+ +
+
+

{passengerName}

+

{a.passenger?.email || a.user?.email || ''}

+
+
+ + {tier} + +
+
+
+ {[ + { label: 'Points Balance', value: (a.pointsBalance ?? 0).toLocaleString() }, + { label: 'Lifetime Points', value: (a.lifetimePoints ?? 0).toLocaleString() }, + { label: 'Points Redeemed', value: (a.pointsRedeemed ?? 0).toLocaleString() }, + ].map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+
+ +
+
+ +
+ + + + + + + + +
+
+ +
+ +
+ + + + +
+
+ +
+ +
+ + + +
+
+
+ +
+ setSelected(null)}>Close +
+
+ ); + })()} +
); } 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 c6a5cbc5b..8bd4400cc 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Download } from 'lucide-react'; +import { Download, Eye } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; @@ -10,8 +10,22 @@ import Modal from '@/components/ui/Modal'; import { paymentsApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; +const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( +
+

{label}

+

{value || '\u2014'}

+
+); + +const SectionHeader = ({ title }: { title: string }) => ( +

+ {title} +

+); + export default function PaymentsPage() { const [filters, setFilters] = useState({ search: '', status: '', method: '' }); + const [selectedPayment, setSelectedPayment] = useState(null); const [exportModalOpen, setExportModalOpen] = useState(false); const [exportDateFrom, setExportDateFrom] = useState(''); const [exportDateTo, setExportDateTo] = useState(''); @@ -86,6 +100,10 @@ export default function PaymentsPage() { { key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) }, ]; + const paymentActions = [ + { label: 'View Details', onClick: (p: any) => setSelectedPayment(p), variant: 'secondary' as const, icon: Eye }, + ]; + return (
@@ -129,11 +147,103 @@ export default function PaymentsPage() { + {/* Payment Details Modal */} + setSelectedPayment(null)} title="Payment Details" size="xl"> + {selectedPayment && (() => { + const p = selectedPayment; + const statusGrad: Record = { + COMPLETED: 'from-emerald-600 to-emerald-700', + FAILED: 'from-red-600 to-red-700', + PENDING: 'from-amber-500 to-amber-600', + REFUNDED: 'from-blue-600 to-blue-700', + }; + const grad = statusGrad[p.status] || 'from-gray-600 to-gray-700'; + return ( +
+
+
+
+

Payment Reference

+

{p.reference || p.id?.substring(0, 8)}

+
+
+ {p.status} +

{formatDateTime(p.createdAt)}

+
+
+
+ {[ + { label: 'Amount', value: formatCurrency(p.amountMinor, p.currency) }, + { label: 'Method', value: p.method || '—' }, + { label: 'Booking', value: p.booking?.bookingRef || '—' }, + ].map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+
+ +
+
+ +
+
+

Amount

+

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

+
+ + + + + + +
+
+ +
+ +
+ + + + +
+
+ + {(p.failureReason || p.failureCode) && ( +
+ +
+ + +
+
+ )} + +
+ +
+ + +
+
+
+ +
+ setSelectedPayment(null)}>Close +
+
+ ); + })()} +
+ {/* Export Modal */} setExportModalOpen(false)} title="Export Payments" size="md">
diff --git a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx index 6f473c2fb..529c1cffd 100644 --- a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx @@ -1,11 +1,48 @@ 'use client'; -import { useState } from 'react'; -import { Save, Users } from 'lucide-react'; -import Link from 'next/link'; +import { useState, useEffect } from 'react'; +import { Save } from 'lucide-react'; +import { systemConfigApi } from '@/lib/api'; + +type Tab = 'general' | 'payment' | 'integrations' | 'configurations'; export default function SettingsPage() { - const [activeTab, setActiveTab] = useState<'general' | 'payment' | 'integrations'>('general'); + const [activeTab, setActiveTab] = useState('general'); + const [seatHoldMinutes, setSeatHoldMinutes] = useState('5'); + const [configLoading, setConfigLoading] = useState(false); + const [configSaving, setConfigSaving] = useState(false); + const [configMessage, setConfigMessage] = useState(''); + + useEffect(() => { + if (activeTab !== 'configurations') return; + setConfigLoading(true); + systemConfigApi.getAll() + .then((data) => { + if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes); + }) + .catch(() => {}) + .finally(() => setConfigLoading(false)); + }, [activeTab]); + + const saveConfigurations = async () => { + setConfigSaving(true); + setConfigMessage(''); + try { + await systemConfigApi.update({ seat_hold_duration_minutes: seatHoldMinutes }); + setConfigMessage('Saved successfully.'); + } catch { + setConfigMessage('Failed to save.'); + } finally { + setConfigSaving(false); + } + }; + + const tabs: { id: Tab; label: string }[] = [ + { id: 'general', label: 'General' }, + { id: 'payment', label: 'Payment' }, + { id: 'integrations', label: 'Integrations' }, + { id: 'configurations', label: 'Configurations' }, + ]; return (
@@ -14,31 +51,24 @@ export default function SettingsPage() {

Settings

Manage system settings and configurations

- + {activeTab !== 'configurations' && ( + + )}
- - - + {tabs.map((tab) => ( + + ))}
{activeTab === 'general' && ( @@ -139,6 +169,46 @@ export default function SettingsPage() {
)} + + {activeTab === 'configurations' && ( +
+

Seat Booking

+ {configLoading ? ( +

Loading...

+ ) : ( +
+ + setSeatHoldMinutes(e.target.value)} + /> +

+ How long a seat hold remains active before it expires automatically. Default: 5 minutes. +

+
+ )} +
+ + {configMessage && ( + {configMessage} + )} +
+
+ )}
); } 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 8c32905f6..d8be38c31 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -23,6 +23,19 @@ export default function TicketsPage() { const [successMessage, setSuccessMessage] = useState(''); const [detailsModalOpen, setDetailsModalOpen] = useState(false); const [selectedTicket, setSelectedTicket] = useState(null); + + const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( +
+

{label}

+

{value || '—'}

+
+ ); + + const SectionHeader = ({ title }: { title: string }) => ( +

+ {title} +

+ ); const [exportModalOpen, setExportModalOpen] = useState(false); const [exportDateFrom, setExportDateFrom] = useState(''); const [exportDateTo, setExportDateTo] = useState(''); @@ -534,111 +547,116 @@ export default function TicketsPage() { isOpen={detailsModalOpen} onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }} title="Ticket Details" - size="lg" + size="xl" > - {selectedTicket && ( -
-
-
-

Ticket Number

-

{selectedTicket.ticketNumber}

-
-
-

Status

-
- - {selectedTicket.status || 'ACTIVE'} - -
-
-
- -
-

Booking Information

-
-
-

Booking Reference

-

{selectedTicket.booking?.bookingRef || 'N/A'}

-
-
-

Passenger

-

{selectedTicket.booking?.passenger?.fullName || selectedTicket.booking?.contactEmail || 'N/A'}

-
-
-

Amount

-

{formatCurrency(selectedTicket.booking?.totalMinor || 0, selectedTicket.booking?.currency || 'ETB')}

-
-
-
- -
-

Trip Information

-
-
-

Route

-

- {selectedTicket.schedule?.originStation?.name || 'N/A'} → {selectedTicket.schedule?.destinationStation?.name || 'N/A'} -

-
-
-

Departure

-

{selectedTicket.schedule?.departureAt ? formatDateTime(selectedTicket.schedule.departureAt) : 'N/A'}

-
-
-
- -
-

Seat Information

-
-
-

Coach

-

{selectedTicket.seat?.coach?.number || 'N/A'}

-
-
-

Seat Number

-

{selectedTicket.seat?.seatNumber || 'N/A'}

-
-
-

Class

-

{selectedTicket.seat?.coach?.coachType?.name || 'N/A'}

-
-
-
- - {selectedTicket.validatedAt && ( -
-

Validated At

-

{formatDateTime(selectedTicket.validatedAt)}

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

Round-Trip Leg Status

-
+ {selectedTicket && (() => { + const t = selectedTicket; + const b = t.booking; + const isRoundTrip = b?.bookingType === 'ROUND_TRIP' || b?.bookingType === 'ROUND_TRIP_TRANSIT'; + const passengerName = b?.passenger?.fullName || b?.contactEmail || 'Guest'; + return ( +
+ {/* Gradient header */} +
+
-

Leg Status

-

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

+

Ticket Number

+

{t.ticketNumber || '—'}

-
-

Outbound Boarded

-

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

-
-
-

Return Boarded

-

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

+
+ {t.status || 'ACTIVE'} + {t.validatedAt &&

Validated {formatDateTime(t.validatedAt)}

}
+
+ {[ + { label: 'Passenger', value: passengerName }, + { label: 'Route', value: `${t.schedule?.originStation?.name || '?'} → ${t.schedule?.destinationStation?.name || '?'}` }, + { label: 'Amount', value: formatCurrency(b?.totalMinor || 0, b?.currency || 'ETB') }, + ].map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
- )} -
- { setDetailsModalOpen(false); setSelectedTicket(null); }}> - Close - +
+ {/* Booking */} +
+ +
+ + + + + + + + +
+
+ + {/* Trip */} +
+ +
+ + + + + + +
+
+ + {/* Seat */} +
+ +
+
+

Seat

+

{t.seat?.seatNumber || '—'}

+
+ + + +
+
+ + {/* Round-trip */} + {isRoundTrip && ( +
+ +
+ + + +
+
+ )} + + {/* Validation */} +
+ +
+ + + + + + +
+
+
+ +
+ { setDetailsModalOpen(false); setSelectedTicket(null); }}>Close +
-
- )} + ); + })()} {/* Export Modal */} diff --git a/apps/edr-passenger-web/backoffice/src/app/verifayda/page.tsx b/apps/edr-passenger-web/backoffice/src/app/verifayda/page.tsx index 00a286ea3..a65e4566b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/verifayda/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/verifayda/page.tsx @@ -2,15 +2,30 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Download } from 'lucide-react'; +import { Download, Eye, ShieldCheck, ShieldOff } 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 { verifaydaApi } from '@/lib/api'; -import { formatDateTime, formatCurrency } from '@/lib/utils'; +import { formatDateTime } from '@/lib/utils'; + +const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( +
+

{label}

+

{value || '—'}

+
+); + +const SectionHeader = ({ title }: { title: string }) => ( +

+ {title} +

+); export default function VerifaydaPage() { const [filters, setFilters] = useState({ search: '', verified: '' }); + const [selected, setSelected] = useState(null); const { data, isLoading } = useQuery({ queryKey: ['verifayda', filters], @@ -18,11 +33,19 @@ export default function VerifaydaPage() { }); const columns = [ - { key: 'nationalId', label: 'National ID', render: (ver: any) => {ver.nationalId} }, - { key: 'fullName', label: 'Name', render: (ver: any) => ver.fullName || 'N/A' }, - { key: 'verified', label: 'Status', render: (ver: any) => {ver.verified ? 'Verified' : 'Failed'} }, - { key: 'createdAt', label: 'Verified At', render: (ver: any) => formatDateTime(ver.createdAt) }, - ]; + { key: 'nationalId', label: 'National ID', render: (ver: any) => {ver.nationalId} }, + { key: 'fullName', label: 'Name', render: (ver: any) => ver.fullName || ver.returnedName || 'N/A' }, + { key: 'verified', label: 'Status', render: (ver: any) => ( + + {ver.verified ? 'Verified' : 'Failed'} + + )}, + { key: 'createdAt', label: 'Verified At', render: (ver: any) => formatDateTime(ver.createdAt) }, + ]; + + const actions = [ + { label: 'View Details', onClick: (v: any) => setSelected(v), variant: 'secondary' as const, icon: Eye }, + ]; return (
@@ -36,29 +59,129 @@ export default function VerifaydaPage() {
- -
- - setFilters({ ...filters, search: e.target.value })} /> -
-
- - -
- +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+
+ + +
+ + {/* Verifayda Details Modal */} + setSelected(null)} title="Verification Details" size="xl"> + {selected && (() => { + const v = selected; + const isVerified = !!v.verified; + const grad = isVerified ? 'from-emerald-600 to-emerald-700' : 'from-red-600 to-red-700'; + const name = v.fullName || v.returnedName || 'N/A'; + return ( +
+
+
+
+ {isVerified + ? + : } +
+
+

{name}

+

{v.nationalId}

+
+
+ + {isVerified ? 'āœ“ Verified' : 'āœ— Failed'} + +

{formatDateTime(v.createdAt)}

+
+
+
+ {[ + { label: 'National ID', value: v.nationalId || '—' }, + { label: 'Date of Birth', value: v.dateOfBirth || v.returnedDob || '—' }, + { label: 'Nationality', value: v.nationality || 'Ethiopian' }, + ].map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+
+ +
+
+ +
+
+

Status

+
+ {isVerified + ? + : } + + {isVerified ? 'Verified' : 'Failed'} + +
+
+ + + +
+
+ +
+ +
+ + + + + + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + + +
+
+
+ +
+ setSelected(null)}>Close +
+
+ ); + })()} +
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/wallet/page.tsx b/apps/edr-passenger-web/backoffice/src/app/wallet/page.tsx index f22446b95..9d817acd2 100644 --- a/apps/edr-passenger-web/backoffice/src/app/wallet/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/wallet/page.tsx @@ -2,15 +2,30 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Download } from 'lucide-react'; +import { Download, Eye, Wallet } 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 { walletApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; +const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( +
+

{label}

+

{value || '—'}

+
+); + +const SectionHeader = ({ title }: { title: string }) => ( +

+ {title} +

+); + export default function WalletPage() { const [filters, setFilters] = useState({ search: '' }); + const [selected, setSelected] = useState(null); const { data, isLoading } = useQuery({ queryKey: ['wallet', filters], @@ -18,10 +33,25 @@ export default function WalletPage() { }); const columns = [ - { key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' }, - { key: 'balanceMinor', label: 'Balance', render: (account: any) => formatCurrency(account.balanceMinor, 'ETB') }, - { key: 'status', label: 'Status', render: (account: any) => {account.isActive ? 'Active' : 'Inactive'} }, - ]; + { key: 'passenger', label: 'Passenger', render: (account: any) => ( +
+
{account.passenger?.fullName || account.user?.fullName || 'N/A'}
+
{account.passenger?.email || account.user?.email || ''}
+
+ )}, + { key: 'balanceMinor', label: 'Balance', render: (account: any) => ( + {formatCurrency(account.balanceMinor, account.currency || 'ETB')} + )}, + { key: 'status', label: 'Status', render: (account: any) => ( + + {account.isActive ? 'Active' : 'Inactive'} + + )}, + ]; + + const actions = [ + { label: 'View Details', onClick: (a: any) => setSelected(a), variant: 'secondary' as const, icon: Eye }, + ]; return (
@@ -35,21 +65,113 @@ export default function WalletPage() {
- -
- - setFilters({ ...filters, search: e.target.value })} /> -
- +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+ + {/* Wallet Details Modal */} + setSelected(null)} title="Wallet Account Details" size="xl"> + {selected && (() => { + const w = selected; + const balance = w.balanceMinor ?? 0; + const passengerName = w.passenger?.fullName || w.user?.fullName || 'N/A'; + return ( +
+
+
+
+ +
+
+

{passengerName}

+

{w.passenger?.email || w.user?.email || ''}

+
+
+ + {w.isActive ? 'Active' : 'Inactive'} + +
+
+
+ {[ + { label: 'Current Balance', value: formatCurrency(balance, w.currency || 'ETB') }, + { label: 'Currency', value: w.currency || 'ETB' }, + { label: 'Total Topped Up', value: formatCurrency(w.totalTopUp ?? 0, w.currency || 'ETB') }, + ].map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+
+ +
+
+ +
+
+

Current Balance

+

{formatCurrency(balance, w.currency || 'ETB')}

+
+ + +
+
+ +
+ +
+ +
+

Status

+ + {w.isActive ? 'Active' : 'Inactive'} + +
+ + +
+
+ +
+ +
+ + + + +
+
+ +
+ +
+ + + +
+
+
+ +
+ setSelected(null)}>Close +
+
+ ); + })()} +
); } diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 85eaa6afd..ac90bb19f 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -373,3 +373,9 @@ export const reportsApi = { return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : { items: [] }; }, }; + +// System Config API +export const systemConfigApi = { + getAll: () => apiClient.get>('/system-config'), + update: (data: Record) => apiClient.patch>('/system-config', data), +}; diff --git a/apps/edr-passenger-web/backoffice/src/styles/globals.css b/apps/edr-passenger-web/backoffice/src/styles/globals.css index 1ad505b16..019343f40 100644 --- a/apps/edr-passenger-web/backoffice/src/styles/globals.css +++ b/apps/edr-passenger-web/backoffice/src/styles/globals.css @@ -60,6 +60,16 @@ } } +@layer utilities { + @keyframes fade-up { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } + } + .animate-fade-up { + animation: fade-up 0.4s cubic-bezier(0.22, 1, 0.36, 1) both; + } +} + @layer components { .card { background-color: hsl(var(--card)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59c0e1d63..f3d3231c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -634,6 +634,9 @@ importers: '@tanstack/react-query': specifier: ^5.59.0 version: 5.101.0(react@18.3.1) + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 axios: specifier: ^1.7.7 version: 1.17.0 @@ -643,12 +646,21 @@ importers: date-fns: specifier: ^3.0.0 version: 3.6.0 + jspdf: + specifier: ^4.2.1 + version: 4.2.1 + jspdf-autotable: + specifier: ^5.0.8 + version: 5.0.8(jspdf@4.2.1) lucide-react: specifier: ^0.446.0 version: 0.446.0(react@18.3.1) next: specifier: ^14.2.0 version: 14.2.35(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 qrcode.react: specifier: ^3.1.0 version: 3.2.0(react@18.3.1) @@ -8646,9 +8658,17 @@ packages: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} + jspdf-autotable@5.0.8: + resolution: {integrity: sha512-Hy05N86yBO7CXBrnSLOge7i1ZYpKH2DjQ94iybaP7vBhSInjvRBgDc99ngKzSbSO8Jc98ZCally8I6n0tj2RJQ==} + peerDependencies: + jspdf: ^2 || ^3 || ^4 + jspdf@3.0.4: resolution: {integrity: sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==} + jspdf@4.2.1: + resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} + jsprim@1.4.2: resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} engines: {node: '>=0.6.0'} @@ -22968,6 +22988,10 @@ snapshots: ms: 2.1.3 semver: 7.8.2 + jspdf-autotable@5.0.8(jspdf@4.2.1): + dependencies: + jspdf: 4.2.1 + jspdf@3.0.4: dependencies: '@babel/runtime': 7.29.7 @@ -22979,6 +23003,17 @@ snapshots: dompurify: 3.4.8 html2canvas: 1.4.1 + jspdf@4.2.1: + dependencies: + '@babel/runtime': 7.29.7 + fast-png: 6.4.0 + fflate: 0.8.3 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.49.0 + dompurify: 3.4.8 + html2canvas: 1.4.1 + jsprim@1.4.2: dependencies: assert-plus: 1.0.0 From 9320e0e9abecb85377f2ae14c25989694127bd75 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Wed, 24 Jun 2026 11:50:44 +0300 Subject: [PATCH 2/2] Update seatmap configuration --- .../src/modules/fleet/fleet.controller.ts | 35 +- .../src/modules/fleet/fleet.dto.ts | 26 +- .../src/modules/fleet/fleet.service.ts | 158 +- .../src/modules/payments/payments.service.ts | 2 +- .../src/modules/seats/seats.controller.ts | 22 +- .../src/modules/seats/seats.service.ts | 137 +- apps/edr-passenger-web/portal/public/bed.png | Bin 0 -> 14282 bytes .../portal/src/app/booking/results/page.tsx | 12 +- .../portal/src/app/booking/review/page.tsx | 3 +- .../portal/src/app/booking/seats/page.tsx | 1310 +++++++++++++---- 10 files changed, 1316 insertions(+), 389 deletions(-) create mode 100644 apps/edr-passenger-web/portal/public/bed.png diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index 60a6ef11d..45c600c67 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger'; import { FleetService } from './fleet.service'; -import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto'; +import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto'; import { JwtGuard } from '../../common/jwt.guard'; @ApiTags('Fleet') @@ -317,6 +317,39 @@ export class FleetController { return this.service.removeAssignment(id); } + @Post('seatmap/generate') + @ApiOperation({ + summary: 'Preview bed seat map — ECONOMY_BED or VIP_BED', + description: `Generates a structured seat map for bed coaches without persisting anything. + +**ECONOMY_BED**: 6 beds per room — Left(Lower/Middle/Upper) + Right(Lower/Middle/Upper) + +**VIP_BED**: 4 beds per room — Left(Lower/Upper) + Right(Lower/Upper) + +Use this to preview the full flat seat list before creating coaches.`, + }) + @ApiBody({ type: GenerateSeatMapDto }) + @ApiResponse({ + status: 201, + description: 'Generated seat map preview', + schema: { + example: { + coachCount: 1, roomsPerCoach: 2, roomType: 'ECONOMY_BED', bedsPerRoom: 6, totalBeds: 12, + seats: [ + { seat_id: 'C1-C1-R1-S1', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'LOWER', sequence_number: 1, status: 'AVAILABLE' }, + { seat_id: 'C1-C1-R1-S2', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'MIDDLE', sequence_number: 2, status: 'AVAILABLE' }, + { seat_id: 'C1-C1-R1-S3', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'UPPER', sequence_number: 3, status: 'AVAILABLE' }, + { seat_id: 'C1-C1-R1-S4', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'LOWER', sequence_number: 4, status: 'AVAILABLE' }, + { seat_id: 'C1-C1-R1-S5', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'MIDDLE', sequence_number: 5, status: 'AVAILABLE' }, + { seat_id: 'C1-C1-R1-S6', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'UPPER', sequence_number: 6, status: 'AVAILABLE' }, + ], + }, + }, + }) + generateSeatMap(@Body() dto: GenerateSeatMapDto) { + return this.service.generateSeatMapPreview(dto); + } + @Get('analytics') @ApiOperation({ summary: 'Fleet analytics and occupancy metrics' }) @ApiResponse({ status: 200, description: 'Occupancy statistics' }) diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts index 609afb4e5..34579c627 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts @@ -12,9 +12,20 @@ export class CreateTrainDto { export class CreateCoachDto { @ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string; @ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string; - @ApiProperty({ example: '2+2', description: 'Seat arrangement (e.g., "2+2", "3+2")' }) @IsString() arrangement: string; - @ApiProperty({ example: 60, description: 'Total seat capacity' }) @IsInt() capacity: number; + @ApiProperty({ example: '2+2', description: 'Seat arrangement for regular coaches (e.g., "2+2", "3+2"). Ignored for bed coaches.' }) @IsString() arrangement: string; + @ApiProperty({ example: 60, description: 'Total seat/bed capacity' }) @IsInt() capacity: number; @ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string; + @ApiPropertyOptional({ + enum: ['ECONOMY_BED', 'VIP_BED'], + description: 'Bed coach category. Set to generate bed/sleeper compartments instead of regular seats. Overrides name-based detection.', + example: 'VIP_BED', + }) + @IsOptional() @IsString() bedCategory?: 'ECONOMY_BED' | 'VIP_BED'; + @ApiPropertyOptional({ + example: 4, + description: 'Beds per compartment/room. Must be even (split equally left/right). Defaults: VIP_BED=4, ECONOMY_BED=6. Only applies when bedCategory is set.', + }) + @IsOptional() @IsInt() bedsPerRoom?: number; } export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) { @@ -67,3 +78,14 @@ export class UpdateClassDto { @IsBoolean() isActive?: boolean; } + +export class GenerateSeatMapDto { + @ApiProperty({ example: 2, description: 'Number of coaches' }) + @IsInt() coachCount: number; + + @ApiProperty({ example: 9, description: 'Number of rooms (compartments) per coach' }) + @IsInt() roomsPerCoach: number; + + @ApiProperty({ enum: ['ECONOMY_BED', 'VIP_BED'], example: 'ECONOMY_BED', description: 'ECONOMY_BED = 6 beds/room (L/M/U Ɨ Left/Right), VIP_BED = 4 beds/room (L/U Ɨ Left/Right)' }) + @IsString() roomType: 'ECONOMY_BED' | 'VIP_BED'; +} diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index b7f4695a1..09a1b4ed3 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; -import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto'; +import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto'; import { SeatKind } from '@prisma/client'; // Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2] @@ -33,41 +33,97 @@ function isAisleCol(colIndex: number, groups: number[]): boolean { return false; } -function buildSeats(coachId: string, coachNumber: string, arrangement: string, capacity: number, seatClass?: string): SeatRow[] { +type BedCategory = 'ECONOMY_BED' | 'VIP_BED' | null; + +// Default beds per room for each category when not explicitly configured +const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = { + VIP_BED: 4, + ECONOMY_BED: 6, +}; + +// Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type +function detectBedCategory(coachTypeName: string): BedCategory { + const name = coachTypeName.toLowerCase(); + const isBed = name.includes('bed') || name.includes('sleeper') || name.includes('couchette'); + if (!isBed) return null; + if (name.includes('vip')) return 'VIP_BED'; + return 'ECONOMY_BED'; +} + +// Resolves bed type names per side from beds-per-side count: +// 2/side → ['LOWER','UPPER'] (VIP style) +// 3/side → ['LOWER','MIDDLE','UPPER'] (Economy style) +function resolveBedTypes(bedsPerSide: number): string[] { + if (bedsPerSide === 1) return ['LOWER']; + if (bedsPerSide === 2) return ['LOWER', 'UPPER']; + if (bedsPerSide === 3) return ['LOWER', 'MIDDLE', 'UPPER']; + return Array.from({ length: bedsPerSide }, (_, i) => { + if (i === 0) return 'LOWER'; + if (i === bedsPerSide - 1) return 'UPPER'; + return 'MIDDLE'; + }); +} + +// Generates the flat seat/bed list for a bed coach. +// Row = room number; col = position-relative label (L1, L2 … R1, R2 …). +function buildBedSeats( + coachId: string, + capacity: number, + bedsPerRoom: number, +): SeatRow[] { + const bedsPerSide = bedsPerRoom / 2; + const bedTypeNames = resolveBedTypes(bedsPerSide); + const layout: Array<{ position: 'LEFT' | 'RIGHT'; bedType: string }> = [ + ...bedTypeNames.map(bt => ({ position: 'LEFT' as const, bedType: bt })), + ...bedTypeNames.map(bt => ({ position: 'RIGHT' as const, bedType: bt })), + ]; + + const roomCount = Math.ceil(capacity / bedsPerRoom); + const seats: SeatRow[] = []; + let seatNumber = 1; + + for (let room = 1; room <= roomCount; room++) { + const posCount: Record = {}; + for (let slot = 0; slot < bedsPerRoom && seats.length < capacity; slot++) { + const { position, bedType } = layout[slot]; + posCount[position] = (posCount[position] ?? 0) + 1; + const col = `${position[0]}${posCount[position]}`; + seats.push({ + coachId, + row: room, + col, + seatNumber: `${seatNumber}`, + kind: SeatKind.STANDARD, + bedPosition: bedType.toLowerCase(), + isWindow: false, + isAisle: false, + }); + seatNumber++; + } + } + return seats; +} + +function buildRegularSeats(coachId: string, arrangement: string, capacity: number): SeatRow[] { const cols = seatCols(arrangement); const groups = parseArrangement(arrangement); const seats: SeatRow[] = []; let row = 1; let seatNumber = 1; let seatIndex = 0; - const isBedCoach = seatClass?.toLowerCase().includes('bed'); - const totalCols = cols.length; while (seatIndex < capacity) { for (let ci = 0; ci < cols.length && seatIndex < capacity; ci++) { const col = cols[ci]; - let bedPosition = null; - - // Set bedPosition for bed coaches based on ROW cycling (not seat number) - if (isBedCoach) { - if (totalCols === 3) { - // Economy bed (3-row cycle): upper, middle, lower - if (row % 3 === 1) bedPosition = 'upper'; - else if (row % 3 === 2) bedPosition = 'middle'; - else bedPosition = 'lower'; - } else if (totalCols === 2) { - // VIP bed (2-row cycle): upper, lower - bedPosition = row % 2 === 1 ? 'upper' : 'lower'; - } - } - seats.push({ coachId, row, col, seatNumber: `${seatNumber}`, kind: SeatKind.STANDARD, - bedPosition, + bedPosition: null, + isWindow: isWindowCol(ci, groups), + isAisle: isAisleCol(ci, groups), }); seatNumber++; seatIndex++; @@ -84,6 +140,8 @@ type SeatRow = { seatNumber: string; kind: SeatKind; bedPosition?: string | null; + isWindow?: boolean; + isAisle?: boolean; }; @Injectable() @@ -332,8 +390,20 @@ export class FleetService { }); if (dto.capacity > 0) { - const seatClass = coach.coachType?.name || ''; - const seats = buildSeats(coach.id, coach.number, dto.arrangement, dto.capacity, seatClass); + // dto.bedCategory takes priority; fall back to name-based detection + const bedCategory: BedCategory = dto.bedCategory ?? detectBedCategory(coach.coachType?.name || ''); + let seats: SeatRow[]; + + if (bedCategory) { + const bedsPerRoom = dto.bedsPerRoom ?? DEFAULT_BEDS_PER_ROOM[bedCategory]; + if (bedsPerRoom < 2 || bedsPerRoom % 2 !== 0) { + throw new BadRequestException('bedsPerRoom must be an even number ≄ 2'); + } + seats = buildBedSeats(coach.id, dto.capacity, bedsPerRoom); + } else { + seats = buildRegularSeats(coach.id, dto.arrangement, dto.capacity); + } + await this.prisma.seat.createMany({ data: seats }); } @@ -425,6 +495,50 @@ export class FleetService { return this.prisma.coachAssignment.delete({ where: { id } }); } + async generateSeatMapPreview(dto: GenerateSeatMapDto) { + const { coachCount, roomsPerCoach, roomType } = dto; + const bedsPerRoom = DEFAULT_BEDS_PER_ROOM[roomType]; + const bedTypeNames = resolveBedTypes(bedsPerRoom / 2); + const layout: Array<{ position: 'LEFT' | 'RIGHT'; bedType: string }> = [ + ...bedTypeNames.map(bt => ({ position: 'LEFT' as const, bedType: bt })), + ...bedTypeNames.map(bt => ({ position: 'RIGHT' as const, bedType: bt })), + ]; + const seats: object[] = []; + let globalSeq = 1; + + for (let c = 1; c <= coachCount; c++) { + const coachLabel = `C${c}`; + for (let r = 1; r <= roomsPerCoach; r++) { + const roomLabel = `R${r}`; + const posCount: Record = {}; + for (let s = 0; s < bedsPerRoom; s++) { + const { position, bedType } = layout[s]; + posCount[position] = (posCount[position] ?? 0) + 1; + seats.push({ + seat_id: `${coachLabel}-${roomLabel}-S${globalSeq}`, + coach_id: coachLabel, + room_id: `${coachLabel}-${roomLabel}`, + category: roomType, + position, + col: `${position[0]}${posCount[position]}`, + bed_type: bedType, + sequence_number: globalSeq, + status: 'AVAILABLE', + }); + globalSeq++; + } + } + } + return { + coachCount, + roomsPerCoach, + roomType, + bedsPerRoom, + totalBeds: seats.length, + seats, + }; + } + async getAnalytics() { const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([ this.prisma.train.count(), diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index d9fa4efd7..7453e1c06 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -835,7 +835,7 @@ export class PaymentsService { status: 'CONFIRMED', totalMinor: booking.totalMinor, currency: booking.currency, - }, + } as any, }); const journeySegments: any[] = []; diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 8914ca465..4d0784f23 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -12,19 +12,19 @@ export class SeatsController { // ── Seat Map ────────────────────────────────────────────────────────────── @Get('seatmap/:scheduleId') - @ApiOperation({ - summary: 'Get seat map with real-time availability by class', - description: `Returns seat map for a schedule with availability by seat class: -- Economy Regular -- Economy Bed -- VIP Bed - -Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED` + @ApiOperation({ + summary: 'Get seat map filtered by coach type', + description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches.` }) @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) - @ApiQuery({ name: 'coachId', required: false, description: 'Filter by coach UUID' }) - @ApiResponse({ status: 200, description: 'Returns coaches with seats and seat class info' }) - getSeatMap(@Param('scheduleId') scheduleId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(scheduleId, coachId); } + @ApiQuery({ name: 'coachTypeId', required: false, description: 'Filter by CoachType UUID — returns all coaches of that type (e.g. all Economy coaches)' }) + @ApiResponse({ status: 200, description: 'List of coaches of the given type with their seats and availability' }) + getSeatMap( + @Param('scheduleId') scheduleId: string, + @Query('coachTypeId') coachTypeId?: string, + ) { + return this.service.getSeatMap(scheduleId, coachTypeId); + } // ── Hold / Release ──────────────────────────────────────────────────────── @Get('holds') 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 0dbc1c653..109adf178 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -11,9 +11,18 @@ export class SeatsService { private segmentsService: SegmentsService, ) {} - async getSeatMap(scheduleId: string, coachId?: string, originStationId?: string, destinationStationId?: string) { + async getSeatMap(scheduleId: string, coachTypeId?: string) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { originStationId: true, destinationStationId: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + const assignments = await this.prisma.coachAssignment.findMany({ - where: { scheduleId, ...(coachId ? { coachId } : {}) }, + where: { + scheduleId, + ...(coachTypeId ? { coach: { coachTypeId } } : {}), + }, include: { coach: { include: { @@ -26,48 +35,110 @@ export class SeatsService { }); const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id)); - const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, originStationId, destinationStationId); + const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, schedule.originStationId, schedule.destinationStationId); return { coaches: assignments.map((a) => { - const allSeats = a.coach.seats; + const allSeats = a.coach.seats; + const coachTypeName = a.coach.coachType?.name ?? ''; const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name); + const isBedCoach = this.isBedCoach(coachTypeName); + // Compute actual beds-per-room from first room to correctly identify VIP (4) vs Economy (6) + const bedsPerRoom = isBedCoach + ? allSeats.filter((s: any) => s.row === (allSeats[0] as any)?.row).length + : 0; + const bedCategory = isBedCoach ? this.getBedCategory(coachTypeName, bedsPerRoom) : null; - return { - id: a.coach.id, - assignmentId: a.id, - coachNumber: a.coach.number, - label: a.coach.number, - mode: a.coach.status, - name: `Coach ${a.coach.number}`, - seatClasses: seatClassNames, - seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard', + const mappedSeats = allSeats.map((s: any) => ({ + id: s.id, + seatNumber: s.seatNumber, + label: s.seatNumber, + status: effectiveStatuses.get(s.id) ?? s.status, + kind: s.kind, + row: s.row, + col: s.col, + isWindow: s.isWindow, + isAisle: s.isAisle, + // Bed-specific fields + ...(isBedCoach ? { + room_id: `${a.coach.id}-R${s.row}`, + category: bedCategory, + position: this.colToPosition(s.col), + bed_type: this.bedPositionToType(s.bedPosition), + bedPosition: s.bedPosition, + } : { + bedPosition: s.bedPosition, + }), + })); + + const base = { + id: a.coach.id, + assignmentId: a.id, + coachNumber: a.coach.number, + label: a.coach.number, + mode: a.coach.status, + name: `Coach ${a.coach.number}`, + coachTypeName, + isBedCoach, + bedCategory, + seatClasses: seatClassNames, + seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard', positionNumber: a.positionNumber, seatArrangement: a.coach.arrangement, - totalSeats: a.coach.capacity, - seats: allSeats.map((s) => ({ - id: s.id, - seatNumber: s.seatNumber, - number: s.seatNumber, - label: s.seatNumber, - status: effectiveStatuses.get(s.id) ?? s.status, - kind: s.kind, - row: s.row, - col: s.col, - isWindow: s.isWindow, - isAisle: s.isAisle, - bedPosition: s.bedPosition, - coach: { - id: a.coach.id, - coachNumber: a.coach.number, - label: a.coach.number, - }, - })), + totalSeats: a.coach.capacity, }; + + if (isBedCoach) { + // Group seats into rooms; row = room number + const roomMap = new Map(); + for (const seat of mappedSeats) { + if (!roomMap.has(seat.row)) roomMap.set(seat.row, []); + roomMap.get(seat.row)!.push(seat); + } + const rooms = Array.from(roomMap.entries()) + .sort(([a], [b]) => a - b) + .map(([roomNumber, beds]) => ({ + room_id: `${a.coach.id}-R${roomNumber}`, + roomNumber, + category: bedCategory, + totalBeds: beds.length, + beds, + })); + return { ...base, rooms, seats: mappedSeats }; + } + + return { ...base, seats: mappedSeats }; }), }; } + private isBedCoach(coachTypeName: string): boolean { + const n = coachTypeName.toLowerCase(); + return n.includes('bed') || n.includes('sleeper') || n.includes('couchette'); + } + + private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' { + const n = coachTypeName.toLowerCase(); + // Explicit VIP name check first + if (n.includes('vip')) return 'VIP_BED'; + // Fall back to actual beds-per-room count: 4 = VIP, 6 = Economy + if (bedsPerRoom === 4) return 'VIP_BED'; + return 'ECONOMY_BED'; + } + + // col format: L1, L2, L3, R1, R2, R3 + private colToPosition(col: string): 'LEFT' | 'RIGHT' { + return col?.startsWith('R') ? 'RIGHT' : 'LEFT'; + } + + private bedPositionToType(bedPosition: string | null): 'LOWER' | 'MIDDLE' | 'UPPER' | null { + if (!bedPosition) return null; + const map: Record = { + lower: 'LOWER', middle: 'MIDDLE', upper: 'UPPER', + }; + return map[bedPosition.toLowerCase()] ?? null; + } + async resolveEffectiveStatuses( scheduleId: string, seatIds: string[], @@ -426,7 +497,7 @@ export class SeatsService { // Delete the Journey (and its JourneySegments) scoped to this booking. async releaseSeats(bookingId: string) { - await this.prisma.journey.deleteMany({ where: { bookingId } }); + await this.prisma.journey.deleteMany({ where: { bookingId } as any }); } async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise { diff --git a/apps/edr-passenger-web/portal/public/bed.png b/apps/edr-passenger-web/portal/public/bed.png new file mode 100644 index 0000000000000000000000000000000000000000..0fc5c6d2cb0989fed2ed25f44bb31950615a5bc2 GIT binary patch literal 14282 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 literal 0 HcmV?d00001 diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 1e906be2c..5669240ad 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -142,8 +142,8 @@ export default function ResultsPage() { ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) : outboundSchedules.length > 0; - const handleSelectCoachType = (scheduleId: string, coachId: string, coachTypeCode: string, coachTypeName: string) => { - setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachId, code: coachTypeCode, name: coachTypeName } })); + const handleSelectCoachType = (scheduleId: string, coachTypeCode: string, coachTypeName: string) => { + setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeCode, code: coachTypeCode, name: coachTypeName } })); }; const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { @@ -156,7 +156,7 @@ export default function ResultsPage() { } // Find the coach type to get pricing info - const coachType = schedule.coachTypes?.find(ct => ct.coachId === selectedCoachType.id); + const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.id); const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0; const hours = Math.floor((schedule.durationMinutes || 0) / 60); @@ -175,7 +175,7 @@ export default function ResultsPage() { baseFareChild: minFare, selectedSeatClass: selectedCoachType.name, selectedSeatClassName: selectedCoachType.name, - selectedCoachId: selectedCoachType.id, + selectedCoachTypeId: selectedCoachType.id, selectedCoachTypeCode: selectedCoachType.code, selectedCoachTypeName: selectedCoachType.name, }; @@ -533,14 +533,14 @@ export default function ResultsPage() { {coachTypes.length > 0 ? (
{coachTypes.map((coachType: any, index: number) => { - const isSelected = selectedCoachType?.id === coachType.coachId; + const isSelected = selectedCoachType?.id === coachType.coachTypeCode; const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; const CoachIcon = getCoachIcon(coachType.coachTypeName); return ( -
+ ); }); -SeatButton.displayName = 'SeatButton'; +BedCard.displayName = "BedCard"; + +const SeatButton = memo( + ({ + seat, + isSelected, + onToggle, + isBedCoach, + bedLabel, + coachSeatClass, + }: any) => { + const seatLabel = seat.number || seat.label || seat.seatNumber || "?"; + const bedWidth = "w-24"; + const width = isBedCoach ? bedWidth : "w-10"; + + return ( +
+ +
+ ); + }, +); + +SeatButton.displayName = "SeatButton"; export default function SeatsPage() { const router = useRouter(); - const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore(); + const { + selectedSchedule, + outboundSchedule, + inboundSchedule, + passengers, + setSeatHold, + setPassengers, + searchCriteria, + bookingId, + } = useBookingStore(); const [selectedSeats, setSelectedSeats] = useState([]); const [selectedCoach, setSelectedCoach] = useState(null); - const [currentJourneyType, setCurrentJourneyType] = useState<'outbound' | 'inbound'>('outbound'); + const [currentJourneyType, setCurrentJourneyType] = useState< + "outbound" | "inbound" + >("outbound"); const [modalState, setModalState] = useState({ isOpen: false, - title: '', - message: '', - type: 'info' as 'warning' | 'error' | 'success' | 'info', + title: "", + message: "", + type: "info" as "warning" | "error" | "success" | "info", }); - const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; - const currentSchedule = isRoundTrip && currentJourneyType === 'inbound' ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule); - const coachId = (currentSchedule as any)?.selectedCoachId; + const isRoundTrip = searchCriteria?.tripType === "ROUND_TRIP"; + const currentSchedule = + isRoundTrip && currentJourneyType === "inbound" + ? inboundSchedule + : isRoundTrip + ? outboundSchedule + : selectedSchedule; + const coachTypeId = (currentSchedule as any)?.selectedCoachTypeId; const coachTypeCode = (currentSchedule as any)?.selectedCoachTypeCode; - const { data: seatMapData, isLoading, error } = useQuery({ - queryKey: ['seatmap', currentSchedule?.id, coachId, currentJourneyType], + const { + data: seatMapData, + isLoading, + error, + } = useQuery({ + queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType], queryFn: async () => { - const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachId=${coachId}`; - console.log('šŸŖ‘ Seatmap Request:', { + const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeCode}`; + console.log("šŸŖ‘ Seatmap Request:", { endpoint, - scheduleId: currentSchedule?.id, - coachId, - coachTypeCode, - currentJourneyType, }); - + const response = await apiClient.get(endpoint); - - console.log('āœ… Seatmap Response:', { + + console.log("āœ… Seatmap Response:", { endpoint, fullResponse: response, dataCoaches: (response as any)?.data?.coaches?.length || 0, rootCoaches: (response as any)?.coaches?.length || 0, }); - + const finalData = (response as any)?.data || response; - console.log('šŸŽÆ Final data structure:', finalData); + console.log("šŸŽÆ Final data structure:", finalData); return finalData; }, - enabled: !!currentSchedule?.id && !!coachId, + enabled: !!currentSchedule?.id && !!coachTypeId, }); const holdMutation = useMutation({ mutationFn: async (seatIds: string[]) => { - const passengersForHold = passengers.slice(0, seatIds.length).map((_, i) => ({ - passengerId: `temp-${Date.now()}-${i}`, - seatId: seatIds[i], - })); - + const passengersForHold = passengers + .slice(0, seatIds.length) + .map((_, i) => ({ + passengerId: `temp-${Date.now()}-${i}`, + seatId: seatIds[i], + })); + // For round trip inbound, swap origin and destination - const isInbound = isRoundTrip && currentJourneyType === 'inbound'; - const originId = isInbound ? searchCriteria?.destinationStationId : searchCriteria?.originStationId; - const destinationId = isInbound ? searchCriteria?.originStationId : searchCriteria?.destinationStationId; - + const isInbound = isRoundTrip && currentJourneyType === "inbound"; + const originId = isInbound + ? searchCriteria?.destinationStationId + : searchCriteria?.originStationId; + const destinationId = isInbound + ? searchCriteria?.originStationId + : searchCriteria?.destinationStationId; + return apiClient.post(`/seats/hold`, { scheduleId: currentSchedule?.id, originStationId: originId, @@ -111,13 +189,13 @@ export default function SeatsPage() { }); }, onSuccess: (data: any) => { - const isInbound = isRoundTrip && currentJourneyType === 'inbound'; + const isInbound = isRoundTrip && currentJourneyType === "inbound"; if (isInbound) { // Merge the return hold into the existing outbound hold const current = useBookingStore.getState().seatHold; setSeatHold({ - holdId: current?.holdId || '', - expiresAt: current?.expiresAt || '', + holdId: current?.holdId || "", + expiresAt: current?.expiresAt || "", returnHoldId: data.holdId || data.id, returnExpiresAt: data.expiresAt, }); @@ -134,27 +212,33 @@ export default function SeatsPage() { mutationFn: async (seatIds: string[]) => { return Promise.all( seatIds.map((seatId) => - apiClient.patch(`/seats/${seatId}`, { - status: 'BOOKED', - }).catch(() => null) - ) + apiClient + .patch(`/seats/${seatId}`, { + status: "BOOKED", + }) + .catch(() => null), + ), ); }, }); const coaches = useMemo(() => { - const rawCoaches = (seatMapData as any)?.coaches || (seatMapData as any)?.data?.coaches || []; - console.log('šŸ“¦ Raw coaches data:', { + const rawCoaches = + (seatMapData as any)?.coaches || + (seatMapData as any)?.data?.coaches || + []; + console.log("šŸ“¦ Raw coaches data:", { fromRoot: (seatMapData as any)?.coaches?.length || 0, fromData: (seatMapData as any)?.data?.coaches?.length || 0, using: rawCoaches.length, - seatMapData + hasRooms: rawCoaches.some((c: any) => c.rooms?.length > 0), + sampleRooms: rawCoaches[0]?.rooms?.length || 0, }); return rawCoaches; }, [seatMapData]); - + const filteredCoaches = useMemo(() => { - console.log('šŸ” Filtering coaches:', { + console.log("šŸ” Filtering coaches:", { totalCoaches: coaches.length, selectedSeatClass: currentSchedule?.selectedSeatClass, coachesData: coaches.map((c: any) => ({ @@ -163,90 +247,145 @@ export default function SeatsPage() { label: c.label, seatClass: c.seatClass, seatClasses: c.seatClasses, - seatsCount: c.seats?.length || 0 - })) + seatsCount: c.seats?.length || 0, + })), }); - const coachesWithSeats = coaches.filter((c: any) => c.seats && c.seats.length > 0); - + const coachesWithSeats = coaches.filter( + (c: any) => c.seats && c.seats.length > 0, + ); + if (!currentSchedule?.selectedSeatClass) { - console.log('āœ… No filter applied, returning all coaches:', coachesWithSeats.length); + console.log( + "āœ… No filter applied, returning all coaches:", + coachesWithSeats.length, + ); return coachesWithSeats; } - - console.log('āœ… No seat class filter - returning all coaches with seats:', coachesWithSeats.length); + + console.log( + "āœ… No seat class filter - returning all coaches with seats:", + coachesWithSeats.length, + ); return coachesWithSeats; }, [coaches, currentSchedule?.selectedSeatClass]); - useEffect(() => { - if (filteredCoaches.length > 0 && !selectedCoach) { - setSelectedCoach(filteredCoaches[0].id); - } - }, [filteredCoaches, selectedCoach]); - const selectedCoachData = useMemo(() => filteredCoaches.find((c: any) => c.id === selectedCoach), [filteredCoaches, selectedCoach]); - const allSeats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]); - + const selectedCoachData = useMemo( + () => filteredCoaches.find((c: any) => c.id === selectedCoach), + [filteredCoaches, selectedCoach], + ); + const allSeats = useMemo( + () => selectedCoachData?.seats || [], + [selectedCoachData], + ); + const getBedPosition = (selectedClass: string): string | null => { const lowerClass = selectedClass.toLowerCase(); - if (lowerClass.includes('upper')) return 'upper'; - if (lowerClass.includes('middle')) return 'middle'; - if (lowerClass.includes('lower')) return 'lower'; + if (lowerClass.includes("upper")) return "upper"; + if (lowerClass.includes("middle")) return "middle"; + if (lowerClass.includes("lower")) return "lower"; return null; }; const validSeats = useMemo(() => { + // If coach has rooms, extract all beds from rooms + if (selectedCoachData?.rooms?.length > 0) { + const allBeds: any[] = []; + selectedCoachData.rooms.forEach((room: any) => { + if (room.beds) { + allBeds.push(...room.beds); + } + }); + + let beds = allBeds.filter((s: any) => { + const seatLabel = s.label || s.number || s.seatNumber || ""; + return seatLabel && !seatLabel.startsWith("-"); + }); + + const isBedCoach = + selectedCoachData?.seatClass?.toLowerCase().includes("bed") || + selectedCoachData?.mode?.toLowerCase().includes("bed"); + + if (isBedCoach && currentSchedule?.selectedSeatClass) { + const selectedBedPosition = getBedPosition( + currentSchedule.selectedSeatClass, + ); + if (selectedBedPosition) { + beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition); + } + } + + return beds; + } + + // Fallback to old seat structure let seats = allSeats.filter((s: any) => { - const seatLabel = s.label || s.number || s.seatNumber || ''; - return seatLabel && !seatLabel.startsWith('-'); + const seatLabel = s.label || s.number || s.seatNumber || ""; + return seatLabel && !seatLabel.startsWith("-"); }); - const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed'); - + const isBedCoach = + selectedCoachData?.seatClass?.toLowerCase().includes("bed") || + selectedCoachData?.mode?.toLowerCase().includes("bed"); + if (isBedCoach && currentSchedule?.selectedSeatClass) { - const selectedBedPosition = getBedPosition(currentSchedule.selectedSeatClass); + const selectedBedPosition = getBedPosition( + currentSchedule.selectedSeatClass, + ); if (selectedBedPosition) { seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition); } } - + return seats; }, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]); - const handleSeatClick = useCallback((seatId: string) => { - setSelectedSeats(prev => { - if (prev.length < passengers.length) { - return [...prev, seatId]; - } else { - return [seatId]; - } - }); - }, [passengers.length]); + const handleSeatClick = useCallback( + (seatId: string) => { + setSelectedSeats((prev) => { + if (prev.length < passengers.length) { + return [...prev, seatId]; + } else { + return [seatId]; + } + }); + }, + [passengers.length], + ); const handleContinue = async () => { - if (isRoundTrip && currentJourneyType === 'outbound') { + if (isRoundTrip && currentJourneyType === "outbound") { if (selectedSeats.length > 0) { try { await holdMutation.mutateAsync(selectedSeats); const updatedPassengers = passengers.map((p, i) => { - const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]); + const seatData = validSeats?.find( + (s: any) => s.id === selectedSeats[i], + ); return { ...p, outboundSeatId: selectedSeats[i], - outboundSeatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '', + outboundSeatNumber: + seatData?.number || + seatData?.label || + seatData?.seatNumber || + "", }; }); setPassengers(updatedPassengers); } catch (error: any) { setModalState({ isOpen: true, - title: 'Seat Hold Failed', - message: error?.response?.data?.message || 'Failed to hold seats. Please try again.', - type: 'error', + title: "Seat Hold Failed", + message: + error?.response?.data?.message || + "Failed to hold seats. Please try again.", + type: "error", }); return; } } - setCurrentJourneyType('inbound'); + setCurrentJourneyType("inbound"); setSelectedSeats([]); setSelectedCoach(null); return; @@ -256,49 +395,61 @@ export default function SeatsPage() { try { await holdMutation.mutateAsync(selectedSeats); const updatedPassengers = passengers.map((p, i) => { - const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]); - if (isRoundTrip && currentJourneyType === 'inbound') { + const seatData = validSeats?.find( + (s: any) => s.id === selectedSeats[i], + ); + if (isRoundTrip && currentJourneyType === "inbound") { return { ...p, inboundSeatId: selectedSeats[i], - inboundSeatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '', + inboundSeatNumber: + seatData?.number || + seatData?.label || + seatData?.seatNumber || + "", }; } return { ...p, seatId: selectedSeats[i], - seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '', + seatNumber: + seatData?.number || seatData?.label || seatData?.seatNumber || "", }; }); setPassengers(updatedPassengers); } catch (error: any) { setModalState({ isOpen: true, - title: 'Seat Hold Failed', - message: error?.response?.data?.message || 'Failed to hold seats. Please try again.', - type: 'error', + title: "Seat Hold Failed", + message: + error?.response?.data?.message || + "Failed to hold seats. Please try again.", + type: "error", }); return; } } - router.push('/booking/review'); + router.push("/booking/review"); }; const handleAutoAssign = async () => { - const availableSeats = validSeats?.filter((s: any) => s.status === 'AVAILABLE') || []; + const availableSeats = + validSeats?.filter((s: any) => s.status === "AVAILABLE") || []; if (availableSeats.length < passengers.length) { setModalState({ isOpen: true, - title: 'Not Enough Seats', + title: "Not Enough Seats", message: `Only ${availableSeats.length} seat(s) available in this coach, but you need ${passengers.length} seat(s). Please select another coach.`, - type: 'warning', + type: "warning", }); return; } - - const autoSelectedSeats = availableSeats.slice(0, passengers.length).map((s: any) => s.id); + + const autoSelectedSeats = availableSeats + .slice(0, passengers.length) + .map((s: any) => s.id); setSelectedSeats(autoSelectedSeats); - + try { await holdMutation.mutateAsync(autoSelectedSeats); const updatedPassengers = passengers.map((p, i) => { @@ -306,134 +457,430 @@ export default function SeatsPage() { return { ...p, seatId: autoSelectedSeats[i], - seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '', + seatNumber: + seatData?.number || seatData?.label || seatData?.seatNumber || "", }; }); setPassengers(updatedPassengers); - router.push('/booking/review'); + router.push("/booking/review"); } catch (error: any) { - console.error('Failed to hold seats:', error); + console.error("Failed to hold seats:", error); setModalState({ isOpen: true, - title: 'Seat Hold Failed', - message: error?.response?.data?.message || 'Failed to hold seats. Please try again.', - type: 'error', + title: "Seat Hold Failed", + message: + error?.response?.data?.message || + "Failed to hold seats. Please try again.", + type: "error", }); } }; const handleBackToPassengers = () => { - router.push('/booking/passengers'); + router.push("/booking/passengers"); }; useEffect(() => { if (isRoundTrip) { if (!outboundSchedule || !inboundSchedule || !passengers.length) { - router.push('/booking/search'); + router.push("/booking/search"); } } else { if (!selectedSchedule || !passengers.length) { - router.push('/booking/search'); + router.push("/booking/search"); } } - }, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, router]); + }, [ + isRoundTrip, + selectedSchedule, + outboundSchedule, + inboundSchedule, + passengers.length, + router, + ]); useEffect(() => { if (bookingId && selectedSeats.length > 0) { bookSeatsMutation.mutate(selectedSeats); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [bookingId]); - const parseSeatArrangement = (arrangement: string | null, seatClasses?: string[]): number[] => { + const parseSeatArrangement = ( + arrangement: string | null, + seatClasses?: string[], + ): number[] => { if (!arrangement) return [2, 2]; - + // Check if this is a bed coach based on seat classes - const isBedCoach = seatClasses?.some(sc => sc?.toLowerCase().includes('bed')); - + const isBedCoach = seatClasses?.some((sc) => + sc?.toLowerCase().includes("bed"), + ); + if (isBedCoach) { // For bed coaches, arrangement like "3+0" means 3 beds stacked vertically // We want to render them as single column, so return [1] - const parts = arrangement.split('+').map(p => parseInt(p.trim())).filter(n => !isNaN(n) && n > 0); + const parts = arrangement + .split("+") + .map((p) => parseInt(p.trim())) + .filter((n) => !isNaN(n) && n > 0); return parts.length > 0 ? [Math.max(...parts)] : [3]; } - + // For regular seats, parse normally (e.g., "3+2" -> [3, 2]) - const parts = arrangement.split('+').map(p => parseInt(p.trim())).filter(n => !isNaN(n) && n > 0); + const parts = arrangement + .split("+") + .map((p) => parseInt(p.trim())) + .filter((n) => !isNaN(n) && n > 0); return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2]; }; const getBedLabel = (bedPosition: string | null): string => { - if (bedPosition === 'upper') return 'U'; - if (bedPosition === 'middle') return 'M'; - if (bedPosition === 'lower') return 'L'; - return ''; + if (bedPosition === "upper") return "U"; + if (bedPosition === "middle") return "M"; + if (bedPosition === "lower") return "L"; + return ""; }; const renderCoachSeats = (coach: any, isBedCoach: boolean) => { - const arrangement = parseSeatArrangement(coach.seatArrangement, coach.seatClasses || [coach.seatClass]); + const arrangement = parseSeatArrangement( + coach.seatArrangement, + coach.seatClasses || [coach.seatClass], + ); if (validSeats.length === 0) { return
No seats
; } const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); - const seatClassStr = typeof selectedCoachData?.seatClass === 'string' ? selectedCoachData.seatClass : (selectedCoachData?.seatClass?.name || ''); + const seatClassStr = + typeof selectedCoachData?.seatClass === "string" + ? selectedCoachData.seatClass + : selectedCoachData?.seatClass?.name || ""; // Bed coach with bed positions (Upper, Middle, Lower) if (isBedCoach && hasBedPositionData) { - // Group by the base seat number (column), not by row - // For beds, seats with same number but different positions should be grouped together + // Check if this is VIP_BED or ECONOMY_BED based on room data + const rooms = (coach as any).rooms || []; + const hasRooms = rooms.length > 0; + + if (hasRooms) { + // Room-based layout (VIP_BED with 4 beds, ECONOMY_BED with 6 beds) + return ( +
+ {rooms.map((room: any) => { + const isVipBed = + room.category === "VIP_BED" || room.totalBeds === 4; + const isEconomyBed = + room.category === "ECONOMY_BED" || room.totalBeds === 6; + + // Sort beds by position and column + const sortedBeds = [...(room.beds || [])].sort((a, b) => { + const posOrder = { upper: 3, middle: 2, lower: 1 }; + const posA = + posOrder[a.bedPosition as keyof typeof posOrder] || 0; + const posB = + posOrder[b.bedPosition as keyof typeof posOrder] || 0; + if (posA !== posB) return posA - posB; + return (a.col || "").localeCompare(b.col || ""); + }); + + return ( +
+ {/* Room Header */} +
+
+

+ Room {room.roomNumber} +

+

+ {room.category === "VIP_BED" + ? "VIP BED" + : room.category === "ECONOMY_BED" + ? "ECONOMY BED" + : room.category} +

+
+
+ {room.totalBeds} beds +
+
+ + {/* Legend */} +
+
+
+ + Available + +
+
+
+ + Booked + +
+
+ + {/* VIP BED Layout (2x2 grid) */} + {isVipBed && ( +
+ {/* Upper Berths */} +
+
+ Upper +
+ Berth +
+
+ {sortedBeds + .filter((b: any) => b.bedPosition === "upper") + .map((bed: any) => ( +
+ +
+ ))} + {/* Vertical aisle indicator */} +
+
+
+ + {/* Lower Berths */} +
+
+ Lower +
+ Berth +
+
+ {sortedBeds + .filter((b: any) => b.bedPosition === "lower") + .map((bed: any) => ( +
+ +
+ ))} + {/* Vertical aisle indicator */} +
+
+
+
+ )} + + {/* ECONOMY BED Layout (3 rows: Lower, Middle, Upper on both sides) */} + {isEconomyBed && ( +
+ {/* Lower Berths */} +
+
+ Lower +
+ Berth +
+
+ {sortedBeds + .filter( + (b: any) => + b.bedPosition === "lower" && + (b.col === "A" || b.position === "LEFT"), + ) + .map((bed: any) => ( + + ))} + {/* Vertical aisle */} +
+ {sortedBeds + .filter( + (b: any) => + b.bedPosition === "lower" && + b.col !== "A" && + b.position !== "LEFT", + ) + .map((bed: any) => ( + + ))} +
+
+ + {/* Middle Berths */} +
+
+ Middle +
+ Berth +
+
+ {sortedBeds + .filter( + (b: any) => + b.bedPosition === "middle" && + (b.col === "B" || b.position === "LEFT"), + ) + .map((bed: any) => ( + + ))} + {/* Vertical aisle */} +
+ {sortedBeds + .filter( + (b: any) => + b.bedPosition === "middle" && + b.col !== "B" && + b.position !== "LEFT", + ) + .map((bed: any) => ( + + ))} +
+
+ + {/* Upper Berths */} +
+
+ Upper +
+ Berth +
+
+ {sortedBeds + .filter( + (b: any) => + b.bedPosition === "upper" && + (b.col === "C" || b.position === "LEFT"), + ) + .map((bed: any) => ( + + ))} + {/* Vertical aisle */} +
+ {sortedBeds + .filter( + (b: any) => + b.bedPosition === "upper" && + b.col !== "C" && + b.position !== "LEFT", + ) + .map((bed: any) => ( + + ))} +
+
+
+ )} +
+ ); + })} +
+ ); + } + + // Fallback: Old layout for beds without room data const seatGroups = new Map(); - + for (const seat of validSeats) { - const baseNumber = seat.seatNumber || seat.number || seat.label || ''; + const baseNumber = seat.seatNumber || seat.number || seat.label || ""; if (!seatGroups.has(baseNumber)) { seatGroups.set(baseNumber, []); } seatGroups.get(baseNumber)!.push(seat); } - // Sort groups by seat number - const sortedGroups = Array.from(seatGroups.entries()) - .sort(([a], [b]) => { - const numA = parseInt(a) || 0; - const numB = parseInt(b) || 0; - return numA - numB; - }); + const sortedGroups = Array.from(seatGroups.entries()).sort(([a], [b]) => { + const numA = parseInt(a) || 0; + const numB = parseInt(b) || 0; + return numA - numB; + }); return (
{sortedGroups.map(([seatNumber, beds], idx) => { const shouldFlipIcon = idx % 2 === 0; - - // Order: lower, middle, upper (bottom to top) - const orderedBeds = ['lower', 'middle', 'upper'] - .map(pos => beds.find(seat => seat.bedPosition === pos)) - .filter(seat => seat !== undefined); - + + const orderedBeds = ["lower", "middle", "upper"] + .map((pos) => beds.find((seat) => seat.bedPosition === pos)) + .filter((seat) => seat !== undefined); + if (orderedBeds.length === 0) return null; - + return ( -
+
{shouldFlipIcon && (
{orderedBeds.map((seat: any) => { - const seatLabel = seat.seatNumber || seat.number || seat.label || ''; - const bedLabelFull = seat.bedPosition ? ( - seat.bedPosition === 'upper' ? 'Upper' : - seat.bedPosition === 'middle' ? 'Middle' : 'Lower' - ) : ''; + const seatLabel = + seat.seatNumber || seat.number || seat.label || ""; + const bedLabelFull = seat.bedPosition + ? seat.bedPosition === "upper" + ? "Upper" + : seat.bedPosition === "middle" + ? "Middle" + : "Lower" + : ""; return ( -
- {seatLabel ? `${seatLabel} ${bedLabelFull}` : ''} +
+ {seatLabel ? `${seatLabel} ${bedLabelFull}` : ""}
); })}
)} - +
{orderedBeds.map((seat: any) => ( ))}
- + {!shouldFlipIcon && (
{orderedBeds.map((seat: any) => { - const seatLabel = seat.seatNumber || seat.number || seat.label || ''; - const bedLabelFull = seat.bedPosition ? ( - seat.bedPosition === 'upper' ? 'Upper' : - seat.bedPosition === 'middle' ? 'Middle' : 'Lower' - ) : ''; + const seatLabel = + seat.seatNumber || seat.number || seat.label || ""; + const bedLabelFull = seat.bedPosition + ? seat.bedPosition === "upper" + ? "Upper" + : seat.bedPosition === "middle" + ? "Middle" + : "Lower" + : ""; return ( -
- {seatLabel ? `${seatLabel} ${bedLabelFull}` : ''} +
+ {seatLabel ? `${seatLabel} ${bedLabelFull}` : ""}
); })} @@ -489,7 +943,7 @@ export default function SeatsPage() {
{rows.map((rowSeats: any[], rowIdx: number) => { const groups: any[][] = []; - + // Split seats into groups based on arrangement if (arrangement.length === 1) { // Single group (all seats together) @@ -497,8 +951,12 @@ export default function SeatsPage() { } else { // Multiple groups with aisle separation arrangement.forEach((_groupSize, groupIdx) => { - const startIdx = arrangement.slice(0, groupIdx).reduce((sum, size) => sum + size, 0); - const endIdx = arrangement.slice(0, groupIdx + 1).reduce((sum, size) => sum + size, 0); + const startIdx = arrangement + .slice(0, groupIdx) + .reduce((sum, size) => sum + size, 0); + const endIdx = arrangement + .slice(0, groupIdx + 1) + .reduce((sum, size) => sum + size, 0); const currentGroup = rowSeats.slice(startIdx, endIdx); if (currentGroup.length > 0) groups.push(currentGroup); }); @@ -513,11 +971,18 @@ export default function SeatsPage() { {shouldFlipArmchair && (
{groups.map((group, gIdx) => ( -
+
{group.map((seat: any) => { - const seatLabel = seat.label || seat.number || seat.seatNumber || ''; + const seatLabel = + seat.label || seat.number || seat.seatNumber || ""; return ( -
+
{seatLabel}
); @@ -547,11 +1012,18 @@ export default function SeatsPage() { {!shouldFlipArmchair && (
{groups.map((group, gIdx) => ( -
+
{group.map((seat: any) => { - const seatLabel = seat.label || seat.number || seat.seatNumber || ''; + const seatLabel = + seat.label || seat.number || seat.seatNumber || ""; return ( -
+
{seatLabel}
); @@ -561,7 +1033,9 @@ export default function SeatsPage() {
)} - {showSpacing &&
} + {showSpacing && ( +
+ )}
); })} @@ -569,16 +1043,25 @@ export default function SeatsPage() { ); }; - if (isRoundTrip ? (!outboundSchedule || !inboundSchedule || !passengers.length) : (!selectedSchedule || !passengers.length)) return null; + if ( + isRoundTrip + ? !outboundSchedule || !inboundSchedule || !passengers.length + : !selectedSchedule || !passengers.length + ) + return null; - if (!coachId) { + if (!coachTypeId) { return (
-

No coach selected

-

Please go back and select a coach type

+

+ No coach type selected +

+

+ Please go back and select a coach type +

- {isRoundTrip - ? (currentJourneyType === 'outbound' ? 'Select Outbound Seats' : 'Select Return Seats') - : 'Select Seats' - } + {isRoundTrip + ? currentJourneyType === "outbound" + ? "Select Outbound Seats" + : "Select Return Seats" + : "Select Seats"}

{selectedSeats.length}/{passengers.length} @@ -712,92 +1232,261 @@ export default function SeatsPage() {
- {/* ── Seat map panel ── */}
{isLoading ? (
-

Loading seat map...

+

+ Loading seat map... +

) : error ? (
-

Error loading seats

-

{(error as any)?.message || 'Please try again'}

+

+ Error loading seats +

+

+ {(error as any)?.message || "Please try again"} +

) : filteredCoaches.length === 0 ? (
-

No coaches available for {selectedSchedule?.selectedSeatClass}

+

+ No coaches available for{" "} + {selectedSchedule?.selectedSeatClass} +

) : ( - <> - {/* Coach selector */} -
-

Coach

-
- {filteredCoaches.map((coach: any) => { - const coachSeats = coach.seats?.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')) || []; - const isBed = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed'); - let fSeats = coachSeats; - if (isBed && currentSchedule?.selectedSeatClass) { - const bedPos = getBedPosition(currentSchedule.selectedSeatClass); - if (bedPos) fSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos); - } - const available = fSeats.filter((s: any) => s.status === 'AVAILABLE').length; - const isActive = selectedCoach === coach.id; - return ( - - ); - })} -
-
+
- {/* Seat map */} -
-
-

- {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber} -

- {selectedCoachData?.seatArrangement} -
- - {/* Legend */} -
- {[ - { color: 'bg-green-500', label: 'Available' }, - { color: 'bg-[rgb(20,113,76)]', label: 'Selected' }, - { color: 'bg-yellow-500', label: 'Held' }, - { color: 'bg-gray-400', label: 'Booked' }, - ].map(({ color, label }) => ( -
-
- {label} + {/* ── Locomotive head ── */} +
+
+ {/* Decorative top stripe */} +
+
+
+

+ EDR Express +

+

+ {filteredCoaches.length} Coach{filteredCoaches.length !== 1 ? "es" : ""} +

- ))} -
- -
-
- {validSeats.length === 0 ? ( -

No seats in this coach

- ) : ( - renderCoachSeats(selectedCoachData, isBedCoach) - )} + {/* Cabin windows */} +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+
+ {/* Headlight bar */} +
+
+
+
+ {/* Nose / front bumper */} +
- + + {/* ── Coach list ── */} + {filteredCoaches.map((coach: any, index: number) => { + let fSeats: any[] = []; + if (coach.rooms?.length > 0) { + coach.rooms.forEach((room: any) => { + if (room.beds) { + fSeats.push( + ...room.beds.filter( + (b: any) => b.label && !b.label.startsWith("-"), + ), + ); + } + }); + } else { + fSeats = + coach.seats?.filter( + (s: any) => s.seatNumber && !s.seatNumber.startsWith("-"), + ) || []; + } + + const isBed = + coach.seatClass?.toLowerCase().includes("bed") || + coach.mode?.toLowerCase().includes("bed"); + + if (isBed && currentSchedule?.selectedSeatClass) { + const bedPos = getBedPosition(currentSchedule.selectedSeatClass); + if (bedPos) + fSeats = fSeats.filter((s: any) => s.bedPosition === bedPos); + } + + const available = fSeats.filter((s: any) => s.status === "AVAILABLE").length; + const total = fSeats.length; + const isExpanded = selectedCoach === coach.id; + const coachLabel = + coach.label || coach.name || coach.coachNumber || `Coach ${index + 1}`; + + return ( +
+ {/* Coupling joint */} +
+
+
+
+
+
+
+ + {/* Coach car */} +
+ {/* Top colour stripe — brand rail */} +
+ + {/* Clickable header */} + + + {/* Expanded seat map */} + {isExpanded && ( +
+
+ {[ + { + color: "bg-green-50 border border-green-300", + label: "Available", + }, + { + color: "bg-blue-50 border-2 border-blue-500", + label: "Selected", + }, + { + color: "bg-red-50 border border-red-300", + label: "Booked", + }, + ].map(({ color, label }) => ( +
+
+ + {label} + +
+ ))} +
+ +
+
+ {validSeats.length === 0 ? ( +

+ No seats in this coach +

+ ) : ( + renderCoachSeats(selectedCoachData, isBedCoach) + )} +
+
+
+ )} + + {/* Bottom colour stripe */} +
+
+
+ ); + })} + + {/* ── Rear end cap ── */} +
+
+
+
+
+
+
+
+
+ +
)}
@@ -807,7 +1496,6 @@ export default function SeatsPage() {
-
{/* Mobile spacer so bottom-sheet doesn't cover last seat */}