diff --git a/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql new file mode 100644 index 000000000..6c8da0d2c --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260620_complete_schema_sync/migration.sql @@ -0,0 +1,36 @@ +-- Add sequence column to Station table if it doesn't exist +ALTER TABLE "passenger"."Station" ADD COLUMN IF NOT EXISTS "sequence" INTEGER NOT NULL DEFAULT 0; + +-- Add index on sequence for Station +CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "passenger"."Station"("sequence"); + +-- Add sequence column to Coach table if it doesn't exist +ALTER TABLE "passenger"."Coach" ADD COLUMN IF NOT EXISTS "sequence" INTEGER NOT NULL DEFAULT 0; + +-- Add index on sequence for Coach +CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "passenger"."Coach"("sequence"); + +-- Add missing columns to SeatClass if they don't exist +ALTER TABLE "passenger"."SeatClass" ADD COLUMN IF NOT EXISTS "premiumMinor" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "passenger"."SeatClass" ADD COLUMN IF NOT EXISTS "insuranceFeeMinor" INTEGER NOT NULL DEFAULT 0; + +-- Add missing columns to User if they don't exist +ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "gender" VARCHAR(255); +ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "dateOfBirth" TIMESTAMP(3); +ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "passportNumber" VARCHAR(255); +ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "nationalId" VARCHAR(255); + +-- Ensure Ticket has all required columns +ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "validatedAt" TIMESTAMP(3); +ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3); + +-- Add missing columns to Booking if they don't exist +ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "bookingType" VARCHAR(255) NOT NULL DEFAULT 'ONE_WAY'; +ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "displayCurrency" VARCHAR(255); +ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "displayTotalMinor" INTEGER; + +-- Ensure all indexes exist +CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "passenger"."Station"("city", "countryCode"); +CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "passenger"."Coach"("coachTypeId"); +CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "passenger"."TrainSchedule"("departureAt", "originStationId"); +CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "passenger"."Booking"("passengerId", "status"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql new file mode 100644 index 000000000..d047e5a0c --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260621_add_cascade_deletes/migration.sql @@ -0,0 +1,164 @@ +-- Add CASCADE delete to all foreign key constraints that are missing it + +-- TrainSchedule relations +ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey"; +ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "passenger"."Train"("id") ON DELETE CASCADE; + +ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey"; +ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "passenger"."Route"("id") ON DELETE CASCADE; + +ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey"; +ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE; + +ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey"; +ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE; + +-- Coach relation +ALTER TABLE "passenger"."Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey"; +ALTER TABLE "passenger"."Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "passenger"."CoachType"("id") ON DELETE CASCADE; + +-- CoachAssignment relations +ALTER TABLE "passenger"."CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey"; +ALTER TABLE "passenger"."CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; + +ALTER TABLE "passenger"."CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey"; +ALTER TABLE "passenger"."CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "passenger"."Coach"("id") ON DELETE CASCADE; + +-- Booking relations +ALTER TABLE "passenger"."Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey"; +ALTER TABLE "passenger"."Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE; + +ALTER TABLE "passenger"."Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey"; +ALTER TABLE "passenger"."Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; + +-- BookingSeat relations +ALTER TABLE "passenger"."BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey"; +ALTER TABLE "passenger"."BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; + +ALTER TABLE "passenger"."BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey"; +ALTER TABLE "passenger"."BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE; + +-- PaymentIntent +ALTER TABLE "passenger"."PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey"; +ALTER TABLE "passenger"."PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; + +-- PaymentRefund +ALTER TABLE "passenger"."PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey"; +ALTER TABLE "passenger"."PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "passenger"."PaymentIntent"("id") ON DELETE CASCADE; + +-- Ticket +ALTER TABLE "passenger"."Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey"; +ALTER TABLE "passenger"."Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; + +-- TicketSeat +ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey"; +ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE; + +-- WalletLedgerEntry +ALTER TABLE "passenger"."WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey"; +ALTER TABLE "passenger"."WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "passenger"."WalletAccount"("id") ON DELETE CASCADE; + +-- Notification +ALTER TABLE "passenger"."Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey"; +ALTER TABLE "passenger"."Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE; + +-- MenuItem +ALTER TABLE "passenger"."MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey"; +ALTER TABLE "passenger"."MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; + +ALTER TABLE "passenger"."MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey"; +ALTER TABLE "passenger"."MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "passenger"."MenuCategory"("id") ON DELETE CASCADE; + +-- FoodOrder +ALTER TABLE "passenger"."FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey"; +ALTER TABLE "passenger"."FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; + +-- FoodOrderItem +ALTER TABLE "passenger"."FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey"; +ALTER TABLE "passenger"."FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "passenger"."FoodOrder"("id") ON DELETE CASCADE; + +-- FaqArticle +ALTER TABLE "passenger"."FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey"; +ALTER TABLE "passenger"."FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "passenger"."FaqCategory"("id") ON DELETE CASCADE; + +-- SupportMessage +ALTER TABLE "passenger"."SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey"; +ALTER TABLE "passenger"."SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "passenger"."SupportConversation"("id") ON DELETE CASCADE; + +-- TripStopTime +ALTER TABLE "passenger"."TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey"; +ALTER TABLE "passenger"."TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; + +-- TripLiveStatus +ALTER TABLE "passenger"."TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey"; +ALTER TABLE "passenger"."TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; + +-- JourneySegment +ALTER TABLE "passenger"."JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey"; +ALTER TABLE "passenger"."JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "passenger"."Journey"("id") ON DELETE CASCADE; + +ALTER TABLE "passenger"."JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey"; +ALTER TABLE "passenger"."JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE; + +-- AgentBooking +ALTER TABLE "passenger"."AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey"; +ALTER TABLE "passenger"."AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE; + +ALTER TABLE "passenger"."AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey"; +ALTER TABLE "passenger"."AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; + +-- AgentShift +ALTER TABLE "passenger"."AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey"; +ALTER TABLE "passenger"."AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE; + +-- AgentCommission +ALTER TABLE "passenger"."AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey"; +ALTER TABLE "passenger"."AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE; + +-- BookingModification +ALTER TABLE "passenger"."BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey"; +ALTER TABLE "passenger"."BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; + +-- BookingCancellation +ALTER TABLE "passenger"."BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey"; +ALTER TABLE "passenger"."BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; + +-- GateValidationLog +ALTER TABLE "passenger"."GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey"; +ALTER TABLE "passenger"."GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "passenger"."Ticket"("id") ON DELETE CASCADE; + +-- BaggageBooking +ALTER TABLE "passenger"."BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey"; +ALTER TABLE "passenger"."BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE; + +-- RouteFareRule +ALTER TABLE "passenger"."RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey"; +ALTER TABLE "passenger"."RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE; + +-- SegmentFareRule +ALTER TABLE "passenger"."SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey"; +ALTER TABLE "passenger"."SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE; + +-- StationCrowdSignal +ALTER TABLE "passenger"."StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey"; +ALTER TABLE "passenger"."StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE; + +-- SeatBlock +ALTER TABLE "passenger"."SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey"; +ALTER TABLE "passenger"."SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE; + +-- SavedRoute +ALTER TABLE "passenger"."SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey"; +ALTER TABLE "passenger"."SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE; + +-- LoyaltyLedgerEntry +ALTER TABLE "passenger"."LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey"; +ALTER TABLE "passenger"."LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "passenger"."LoyaltyAccount"("id") ON DELETE CASCADE; + +-- LoyaltyReward +ALTER TABLE "passenger"."LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey"; +ALTER TABLE "passenger"."LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "passenger"."LoyaltyAccount"("id") ON DELETE CASCADE; + +-- FareRule +ALTER TABLE "passenger"."FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey"; +ALTER TABLE "passenger"."FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 33eeb2386..b2259a448 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -84,19 +84,20 @@ model CoachType { } model SeatClass { - id String @id @default(uuid()) - coachTypeId String - name String - description String? - baseFareMinor Int - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - coachType CoachType @relation(fields: [coachTypeId], references: [id]) - fareRules FareRule[] - routeFareRules RouteFareRule[] - segmentFares SegmentFareRule[] - + id String @id @default(uuid()) + coachTypeId String + name String + description String? + baseFareMinor Int @default(0) // per-km rate + premiumMinor Int @default(0) // flat fee per passenger + insuranceFeeMinor Int @default(0) // flat fee per passenger + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + coachType CoachType @relation(fields: [coachTypeId], references: [id]) + fareRules FareRule[] + routeFareRules RouteFareRule[] + segmentFares SegmentFareRule[] @@unique([coachTypeId, name]) @@index([coachTypeId]) @@schema("passenger") @@ -226,22 +227,24 @@ enum DevicePlatform { } model User { - id String @id @default(uuid()) - email String @unique - phone String @unique - fullName String - passwordHash String - role UserRole @default(PASSENGER) - nationality String? - nationalityCode String? - passportNumber String? - nationalId String? - failedLoginAttempts Int @default(0) - lockedUntil DateTime? - blockedUntil DateTime? - lastLoginAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + email String @unique + phone String @unique + fullName String + passwordHash String + role UserRole @default(PASSENGER) + nationality String? + nationalityCode String? + gender String? // Male, Female, Other + dateOfBirth DateTime? + passportNumber String? + nationalId String? + failedLoginAttempts Int @default(0) + lockedUntil DateTime? + blockedUntil DateTime? + lastLoginAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt faydaVerified Boolean @default(false) faydaVerifiedAt DateTime? @@ -307,21 +310,22 @@ model TravelerProfile { } model Station { - id String @id @default(uuid()) - code String @unique - name String - city String - countryCode String? - isOperational Boolean @default(true) - timezone String @default("Africa/Addis_Ababa") - lat Decimal @db.Decimal(9, 6) - lng Decimal @db.Decimal(9, 6) - originSchedules TrainSchedule[] @relation("OriginTrips") - destinationSchedules TrainSchedule[] @relation("DestinationTrips") + id String @id @default(uuid()) + code String @unique + name String + city String + countryCode String? + sequence Int @default(0) + isOperational Boolean @default(true) + timezone String @default("Africa/Addis_Ababa") + lat Decimal @db.Decimal(9, 6) + lng Decimal @db.Decimal(9, 6) + originSchedules TrainSchedule[] @relation("OriginTrips") + destinationSchedules TrainSchedule[] @relation("DestinationTrips") stopTimes TripStopTime[] crowdSignals StationCrowdSignal[] - @@index([city, countryCode]) + @@index([sequence]) @@schema("passenger") } @@ -402,19 +406,20 @@ model TripLiveStatus { } model Coach { - id String @id @default(uuid()) - coachTypeId String - number String @unique - arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2' - capacity Int @default(0) // Total seats/beds - status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE' - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + coachTypeId String + number String @unique + arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2' + capacity Int @default(0) // Total seats/beds + sequence Int @default(0) + status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE' + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt coachType CoachType @relation(fields: [coachTypeId], references: [id]) seats Seat[] assignments CoachAssignment[] - @@index([coachTypeId]) + @@index([sequence]) @@schema("passenger") } @@ -489,38 +494,44 @@ model FareRule { } model Booking { - id String @id @default(uuid()) - bookingRef String @unique - passengerId String - scheduleId String - status BookingStatus @default(DRAFT) - currency String @default("ETB") - totalMinor Int - adultCount Int @default(1) - childCount Int @default(0) - displayCurrency Currency? - displayTotalMinor Int? - bookingType String @default("ONE_WAY") - contactEmail String? - contactPhone String? - userAgent String? - source String @default("WEB") - promoCode String? - paidAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - passenger Passenger @relation(fields: [passengerId], references: [id]) - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - seats BookingSeat[] - paymentIntent PaymentIntent? - ticket Ticket? - foodOrders FoodOrder[] - agentBooking AgentBooking? - modifications BookingModification[] - cancellation BookingCancellation? - baggage BaggageBooking[] + id String @id @default(uuid()) + bookingRef String @unique + passengerId String + scheduleId String + bookingType String @default("ONE_WAY") + status BookingStatus @default(DRAFT) + currency String @default("ETB") + totalMinor Int + adultCount Int @default(1) + childCount Int @default(0) + displayCurrency Currency? + displayTotalMinor Int? + returnScheduleId String? + returnOriginStationId String? + returnDestinationStationId String? + returnHoldId String? + returnSeatClassId String? + contactEmail String? + contactPhone String? + userAgent String? + source String @default("WEB") + promoCode String? + paidAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + passenger Passenger @relation(fields: [passengerId], references: [id]) + schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + seats BookingSeat[] + paymentIntent PaymentIntent? + ticket Ticket? + foodOrders FoodOrder[] + agentBooking AgentBooking? + modifications BookingModification[] + cancellation BookingCancellation? + baggage BaggageBooking[] @@index([passengerId, status]) + @@index([bookingType]) @@schema("passenger") } @@ -628,20 +639,21 @@ model PaymentRefund { } model Ticket { - id String @id @default(uuid()) - bookingId String @unique - bookingRef String - status String @default("CONFIRMED") - qrPayload String - barcodePayload String? - pdfUrl String? - deliveryChannel String @default("EMAIL") - issuedAt DateTime @default(now()) - validatedAt DateTime? - validatorId String? - booking Booking @relation(fields: [bookingId], references: [id]) - validationLogs GateValidationLog[] - seats TicketSeat[] + id String @id @default(uuid()) + bookingId String @unique + bookingRef String + status String @default("ACTIVE") + qrPayload String + barcodePayload String? + pdfUrl String? + deliveryChannel String @default("EMAIL") + issuedAt DateTime @default(now()) + validatedAt DateTime? + validatorId String? + boardedAt DateTime? + booking Booking @relation(fields: [bookingId], references: [id]) + validationLogs GateValidationLog[] + seats TicketSeat[] @@schema("passenger") } diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 79933843b..382b1adf3 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -4,7 +4,6 @@ import { randomUUID as uuidv4 } from 'crypto'; const prisma = new PrismaClient(); -const EDR_ROUTE_ID = uuidv4(); const TRAIN_ID = uuidv4(); async function seedSystemUsers() { @@ -24,6 +23,10 @@ async function seedSystemUsers() { phone: '+251900000000', passwordHash: adminHash, role: 'ADMIN', + gender: 'Male', + dateOfBirth: new Date('1980-05-20'), + nationality: 'Ethiopian', + nationalId: 'ET123456789', }, }); console.log(' āœ… Admin: admin@edr-platform.com / admin123'); @@ -39,6 +42,9 @@ async function seedSystemUsers() { role: 'PASSENGER', nationality: 'Ethiopian', faydaVerified: true, + gender: 'Male', + dateOfBirth: new Date('1990-03-15'), + nationalId: 'ET987654321', }, }); @@ -49,7 +55,7 @@ async function seedSystemUsers() { data: { passengerId: passengerRecord.id, pointsBalance: 1500, lifetimePoints: 3000, tier: 'SILVER' }, }); await prisma.walletAccount.create({ - data: { passengerId: passengerRecord.id, balanceMinor: 50000 }, + data: { passengerId: passengerRecord.id, balanceMinor: 500 }, }); } await prisma.userPreferences.upsert({ @@ -68,6 +74,8 @@ async function seedSystemUsers() { phone: '+251911111111', passwordHash: agentHash, role: 'AGENT', + gender: 'Female', + dateOfBirth: new Date('1992-07-22'), }, }); await prisma.agent.upsert({ @@ -86,6 +94,8 @@ async function seedSystemUsers() { phone: '+251922222222', passwordHash: supervisorHash, role: 'SUPERVISOR', + gender: 'Male', + dateOfBirth: new Date('1985-11-10'), }, }); console.log(' āœ… Supervisor: supervisor@edr-platform.com / supervisor123'); @@ -99,6 +109,8 @@ async function seedSystemUsers() { phone: '+251933333333', passwordHash: staffHash, role: 'STAFF', + gender: 'Female', + dateOfBirth: new Date('1995-09-08'), }, }); console.log(' āœ… Staff: staff@edr-platform.com / staff123'); @@ -107,21 +119,21 @@ async function seedSystemUsers() { async function seedStations() { console.log('\nšŸ“ Seeding 15 stations (Ethio-Djibouti Railway)...'); const stations = [ - { code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9520, lng: 38.6150 }, - { code: 'LEB', name: 'Lebu', city: 'Lebu', countryCode: 'ET', lat: 8.8890, lng: 38.5320 }, - { code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7650, lng: 39.0240 }, - { code: 'MOJ', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6780, lng: 39.2130 }, - { code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5420, lng: 39.2780 }, - { code: 'MTE', name: 'Metehara', city: 'Metehara', countryCode: 'ET', lat: 8.7890, lng: 39.8920 }, - { code: 'MIS', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 8.9120, lng: 40.3450 }, - { code: 'BIK', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.1230, lng: 40.8670 }, - { code: 'DRE', name: 'Dire Dawa', city: 'Dire Dawa', countryCode: 'ET', lat: 9.5915, lng: 41.8578 }, - { code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 9.7340, lng: 42.2150 }, - { code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 10.0120, lng: 42.5670 }, - { code: 'DAW', name: 'Dawanle', city: 'Dawanle', countryCode: 'ET', lat: 10.2340, lng: 42.8340 }, - { code: 'ALS', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 10.8950, lng: 42.9560 }, - { code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.1230, lng: 43.0450 }, - { code: 'NAG', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', lat: 11.3780, lng: 43.1200 }, + { code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9520, lng: 38.6150, sequence: 1 }, + { code: 'LEB', name: 'Lebu', city: 'Lebu', countryCode: 'ET', lat: 8.8890, lng: 38.5320, sequence: 2 }, + { code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7650, lng: 39.0240, sequence: 3 }, + { code: 'MOJ', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6780, lng: 39.2130, sequence: 4 }, + { code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5420, lng: 39.2780, sequence: 5 }, + { code: 'MTE', name: 'Metehara', city: 'Metehara', countryCode: 'ET', lat: 8.7890, lng: 39.8920, sequence: 6 }, + { code: 'MIS', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 8.9120, lng: 40.3450, sequence: 7 }, + { code: 'BIK', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.1230, lng: 40.8670, sequence: 8 }, + { code: 'DRE', name: 'Dire Dawa', city: 'Dire Dawa', countryCode: 'ET', lat: 9.5915, lng: 41.8578, sequence: 9 }, + { code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 9.7340, lng: 42.2150, sequence: 10 }, + { code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 10.0120, lng: 42.5670, sequence: 11 }, + { code: 'DAW', name: 'Dawanle', city: 'Dawanle', countryCode: 'ET', lat: 10.2340, lng: 42.8340, sequence: 12 }, + { code: 'ALS', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 10.8950, lng: 42.9560, sequence: 13 }, + { code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.1230, lng: 43.0450, sequence: 14 }, + { code: 'NAG', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', lat: 11.3780, lng: 43.1200, sequence: 15 }, ]; for (const station of stations) { @@ -142,8 +154,8 @@ async function seedCoachTypesAndClasses() { console.log('\nšŸš‚ Seeding coach types and seat classes...'); const coachTypes = [ { code: 'HSC', name: 'Hard Seat Coach', type: 'Economy Regular' }, - { code: 'HBC', name: 'Hard Bed Coach', type: 'Economy Bed' }, - { code: 'SBC', name: 'Soft Bed Coach', type: 'VIP Bed' }, + { code: 'HBC', name: 'Hard Berth Coach', type: 'Economy Bed' }, + { code: 'SBC', name: 'Soft Berth Coach', type: 'VIP Bed' }, ]; for (const ct of coachTypes) { @@ -155,12 +167,12 @@ async function seedCoachTypesAndClasses() { } const seatClasses = [ - { name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900 }, - { name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800 }, - { name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600 }, - { name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550 }, - { name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500 }, - { name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250 }, + { name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900, premiumMinor: 50, insuranceFeeMinor: 25 }, + { name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800, premiumMinor: 45, insuranceFeeMinor: 20 }, + { name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600, premiumMinor: 30, insuranceFeeMinor: 15 }, + { name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550, premiumMinor: 28, insuranceFeeMinor: 14 }, + { name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500, premiumMinor: 25, insuranceFeeMinor: 12 }, + { name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250, premiumMinor: 12, insuranceFeeMinor: 6 }, ]; for (const sc of seatClasses) { @@ -168,7 +180,7 @@ async function seedCoachTypesAndClasses() { await prisma.seatClass.upsert({ where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } }, update: {}, - create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor }, + create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor, premiumMinor: sc.premiumMinor, insuranceFeeMinor: sc.insuranceFeeMinor }, }); } console.log(` āœ… ${coachTypes.length} coach types, ${seatClasses.length} seat classes created`); @@ -176,14 +188,12 @@ async function seedCoachTypesAndClasses() { async function seedRoute() { console.log('\nšŸ›£ļø Seeding route and stops...'); - const firstStation = await prisma.station.findUnique({ where: { code: 'SBT' } }); - const lastStation = await prisma.station.findUnique({ where: { code: 'NAG' } }); const route = await prisma.route.upsert({ - where: { code: 'EDR-101' }, + where: { code: 'Route-101' }, update: {}, create: { - code: 'EDR-101', + code: 'Route-101', name: 'Sebeta - Dire Dawa', description: 'Outbound local route from Sebeta to Dire Dawa', effectiveFrom: new Date('2026-01-01'), @@ -193,15 +203,40 @@ async function seedRoute() { }); const stationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE']; + const routeDistancesKm = [0, 11.5, 67.2, 89.9, 106.7, 180.2, 231.6, 293.6, 413.0]; for (let i = 0; i < stationCodes.length; i++) { const station = await prisma.station.findUnique({ where: { code: stationCodes[i] } }); await prisma.routeStop.upsert({ where: { routeId_sequence: { routeId: route.id, sequence: i + 1 } }, update: {}, - create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: i * 85 }, + create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: routeDistancesKm[i] }, }); } - console.log(` āœ… Route with ${stationCodes.length} stops created`); + + const returnRoute = await prisma.route.upsert({ + where: { code: 'Route-102' }, + update: {}, + create: { + code: 'Route-102', + name: 'Dire Dawa - Sebeta', + description: 'Inbound local route from Dire Dawa to Sebeta', + effectiveFrom: new Date('2026-01-01'), + effectiveUntil: new Date('2034-12-31'), + active: true, + }, + }); + + const returnStationCodes = ['DRE', 'BIK', 'MIS', 'MTE', 'ADM', 'MOJ', 'BSH', 'LEB', 'SBT']; + const returnRouteDistancesKm = [0, 119.4, 181.4, 232.8, 306.3, 323.1, 345.8, 401.5, 413.0]; + for (let i = 0; i < returnStationCodes.length; i++) { + const station = await prisma.station.findUnique({ where: { code: returnStationCodes[i] } }); + await prisma.routeStop.upsert({ + where: { routeId_sequence: { routeId: returnRoute!.id, sequence: i + 1 } }, + update: {}, + create: { routeId: returnRoute!.id, stationId: station!.id, sequence: i + 1, distanceKm: returnRouteDistancesKm[i] }, + }); + } + console.log(` āœ… Route with ${returnStationCodes.length} stops created`); } async function seedCoaches() { @@ -211,9 +246,9 @@ async function seedCoaches() { const vipBedCoachType = await prisma.coachType.findUnique({ where: { id: 'SBC' } }); const coaches = [ - { number: 'HSC-0001', coachTypeId: ecoCoachType!.id, arrangement: '3+2', capacity: 40 }, - { number: 'HBC-0001', coachTypeId: ecoBedCoachType!.id, arrangement: '3+0', capacity: 66 }, - { number: 'SBC-0001', coachTypeId: vipBedCoachType!.id, arrangement: '2+0', capacity: 120 }, + { number: 'HSC-0001', coachTypeId: ecoCoachType!.id, arrangement: '3+2', capacity: 128, sequence: 1 }, + { number: 'HBC-0001', coachTypeId: ecoBedCoachType!.id, arrangement: '3+0', capacity: 66, sequence: 2 }, + { number: 'SBC-0001', coachTypeId: vipBedCoachType!.id, arrangement: '2+0', capacity: 40, sequence: 3 }, ]; let totalSeats = 0; @@ -230,19 +265,23 @@ async function seedCoaches() { // FK violation once BookingSeat/SeatBlock/TicketSeat rows reference them. let seatIndex = 1; for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) { - for (const col of ['A', 'B', 'C', 'D']) { + for (const col of ['A', 'B', 'C', 'D', 'E']) { if (seatIndex > coach.capacity) break; let bedPosition: string | null = null; - if (c.coachTypeId === ecoBedCoachType!.id || c.coachTypeId === vipBedCoachType!.id) { + if (c.coachTypeId === ecoBedCoachType!.id) { + // 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 (c.coachTypeId === vipBedCoachType!.id) { + // VIP Bed: 2-row cycle (upper, lower) + bedPosition = row % 2 === 1 ? 'upper' : 'lower'; } const seatData = { seatNumber: seatIndex.toString(), - isWindow: col === 'A' || col === 'D', - isAisle: col === 'B' || col === 'C', + isWindow: col === 'A' || col === 'E', + isAisle: col === 'B' || col === 'C' || col === 'D', bedPosition, }; @@ -264,67 +303,102 @@ async function seedTrips() { const train = await prisma.train.upsert({ where: { number: 'EDR-001' }, update: {}, - create: { id: TRAIN_ID, number: 'EDR-001', name: 'Djibouti Express' }, + create: { id: TRAIN_ID, number: 'EDR-001', name: 'Express Service' }, }); - const route = await prisma.route.findUnique({ where: { code: 'EDR-101' } }); + const route = await prisma.route.findUnique({ where: { code: 'Route-101' } }); + const returnRoute = await prisma.route.findUnique({ where: { code: 'Route-102' } }); const firstStation = await prisma.station.findUnique({ where: { code: 'SBT' } }); const lastStation = await prisma.station.findUnique({ where: { code: 'DRE' } }); + const firstReturnStation = await prisma.station.findUnique({ where: { code: 'DRE' } }); + const lastReturnStation = await prisma.station.findUnique({ where: { code: 'SBT' } }); const coaches = await prisma.coach.findMany(); const now = new Date(); - const schedules = []; + const tomorrow = new Date(now); + tomorrow.setDate(now.getDate() + 1); - for (let d = 0; d < 30; d++) { + const schedules = []; + + for (let d = 0; d < 5; d++) { const tripDate = new Date(now); tripDate.setDate(tripDate.getDate() + d); - tripDate.setHours(8, 0, 0, 0); - - const departureAt = new Date(tripDate); - const arrivalAt = new Date(departureAt.getTime() + 4 * 24 * 60 * 60 * 1000); - + tripDate.setHours(20, 30, 0, 0); schedules.push({ trainId: train.id, routeId: route!.id, originStationId: firstStation!.id, destinationStationId: lastStation!.id, - departureAt, - arrivalAt, - durationMinutes: 4 * 24 * 60, - stopsCount: 15, + departureAt: new Date(tripDate), + arrivalAt: new Date(tripDate), // patched below + durationMinutes: 0, // patched below + stopsCount: 9, }); } - - const createdSchedules = await Promise.all( - schedules.map(s => prisma.trainSchedule.create({ data: s })) - ); - // Create TripStopTimes for each schedule - const routeStops = await prisma.routeStop.findMany({ - where: { routeId: route!.id }, - orderBy: { sequence: 'asc' }, - include: { route: true }, + for (let d = 0; d < 5; d++) { + const returnTripDate = new Date(tomorrow); + returnTripDate.setDate(returnTripDate.getDate() + d); + returnTripDate.setHours(20, 0, 0, 0); + schedules.push({ + trainId: train.id, + routeId: returnRoute!.id, + originStationId: firstReturnStation!.id, + destinationStationId: lastReturnStation!.id, + departureAt: new Date(returnTripDate), + arrivalAt: new Date(returnTripDate), // patched below + durationMinutes: 0, // patched below + stopsCount: 9, + }); + } + + // Load route stops for both routes upfront + const routeStopsMap = new Map(); + for (const r of [route!, returnRoute!]) { + const stops = await prisma.routeStop.findMany({ + where: { routeId: r.id }, + orderBy: { sequence: 'asc' }, + }); + routeStopsMap.set(r.id, stops.map(s => ({ stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm! }))); + } + + // Compute duration from total route distance at 60 km/h + function routeDuration(stops: { distanceKm: number }[]): number { + const totalKm = stops[stops.length - 1].distanceKm - stops[0].distanceKm; + return Math.ceil(totalKm / 60 * 60); + } + + // Patch arrivalAt and durationMinutes using distance-based timing + const patchedSchedules = schedules.map(s => { + const stops = routeStopsMap.get(s.routeId!)!; + const durationMinutes = routeDuration(stops); + return { ...s, durationMinutes, arrivalAt: new Date(s.departureAt.getTime() + durationMinutes * 60_000) }; }); + const createdSchedules = await Promise.all( + patchedSchedules.map(s => prisma.trainSchedule.create({ data: s })) + ); + + // Create TripStopTimes using cumulative distanceKm at 60 km/h for (const schedule of createdSchedules) { - const stopTimes = []; - for (const routeStop of routeStops) { - const minutesFromStart = (routeStop.sequence - 1) * 480; // 8 hours per stop + const stops = routeStopsMap.get(schedule.routeId!)!; + const originKm = stops[0].distanceKm; + const stopTimes = stops.map(stop => { + const minutesFromStart = Math.ceil((stop.distanceKm - originKm) / 60 * 60); const plannedDepartureAt = new Date(schedule.departureAt.getTime() + minutesFromStart * 60_000); - const plannedArrivalAt = new Date(plannedDepartureAt.getTime() + 30 * 60_000); // 30 min stop - - stopTimes.push({ + const plannedArrivalAt = new Date(plannedDepartureAt.getTime() - 5 * 60_000); // 5 min dwell + return { scheduleId: schedule.id, - stationId: routeStop.stationId, - sequence: routeStop.sequence, + stationId: stop.stationId, + sequence: stop.sequence, plannedArrivalAt, plannedDepartureAt, - }); - } - - await Promise.all( - stopTimes.map(st => prisma.tripStopTime.create({ data: st })) - ); + }; + }); + // First stop: arrival = departure (no dwell at origin) + stopTimes[0].plannedArrivalAt = stopTimes[0].plannedDepartureAt; + + await Promise.all(stopTimes.map(st => prisma.tripStopTime.create({ data: st }))); } const coachAssignments = []; @@ -355,7 +429,7 @@ async function seedTrips() { async function seedFareRules() { console.log('\nšŸ’° Seeding fare rules...'); - const route = await prisma.route.findUnique({ where: { code: 'EDR-101' } }); + const route = await prisma.route.findUnique({ where: { code: 'Route-101' } }); const seatClasses = await prisma.seatClass.findMany(); const validFrom = new Date('2024-01-01'); @@ -374,7 +448,7 @@ async function seedFareRules() { seatClassId: sc.id, passengerCategory: 'CHILD' as const, baseFareMinor: Math.floor(sc.baseFareMinor * 0.5), - discountPercent: 50, + discountPercent: 10, currency: 'ETB', validFrom, }); @@ -437,13 +511,50 @@ async function seedPaymentMethods() { console.log(` āœ… ${methods.length} payment methods created`); } +async function seedSegmentFares() { + console.log('\nšŸ“ Seeding segment fare rules...'); + const route = await prisma.route.findUnique({ + where: { code: 'Route-101' }, + include: { stops: { orderBy: { sequence: 'asc' } } }, + }); + const seatClasses = await prisma.seatClass.findMany(); + const validFrom = new Date('2024-01-01'); + + if (route && route.stops.length > 2) { + for (const sc of seatClasses) { + await prisma.segmentFareRule.create({ + data: { + routeId: route.id, + seatClassId: sc.id, + originStopSequence: 1, + destinationStopSequence: 3, + baseFareMinor: Math.floor(sc.baseFareMinor * 0.4), + validFrom, + }, + }).catch(() => {}); + + await prisma.segmentFareRule.create({ + data: { + routeId: route.id, + seatClassId: sc.id, + originStopSequence: 5, + destinationStopSequence: 9, + baseFareMinor: Math.floor(sc.baseFareMinor * 0.6), + validFrom, + }, + }).catch(() => {}); + } + console.log(` āœ… ${seatClasses.length * 2} segment fare rules created`); + } +} + async function seedNotificationTemplates() { console.log('\nšŸ”” Seeding notification templates...'); const templates = [ - { id: uuidv4(), code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed' }, - { id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment received for {{bookingRef}}' }, - { id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip departs in {{minutes}} minutes' }, - { id: uuidv4(), code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip is delayed by {{delayMinutes}} minutes' }, + { id: uuidv4(), code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{date}}' }, + { id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment ETB {{amount}} received for {{bookingRef}}' }, + { id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' }, + { id: uuidv4(), code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' }, { id: uuidv4(), code: 'PROMOTION', channel: 'PUSH', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' }, ]; @@ -477,13 +588,13 @@ async function seedMenuAndFood() { const sandwichId = uuidv4(); await prisma.menuItem.create({ - data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 5000 }, + data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 50 }, }).catch(() => {}); // ignore if exists await prisma.menuItem.create({ - data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 3500 }, + data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 35 }, }).catch(() => {}); // ignore if exists await prisma.menuItem.create({ - data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 8000 }, + data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 80 }, }).catch(() => {}); // ignore if exists } console.log(` āœ… Menu categories and items created`); @@ -494,7 +605,7 @@ async function seedPromotions() { const promos = [ { id: uuidv4(), title: 'Early Bird Discount', code: 'EARLY20', percentOff: 20, validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) }, { id: uuidv4(), title: 'Student Discount', code: 'STUDENT15', percentOff: 15, validUntil: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000) }, - { id: uuidv4(), title: 'Group Booking', code: 'GROUP10', amountOffMinor: 10000, validUntil: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) }, + { id: uuidv4(), title: 'Group Booking', code: 'GROUP10', amountOffMinor: 100, validUntil: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) }, ]; for (const p of promos) { @@ -584,6 +695,7 @@ async function main() { ['promotions', seedPromotions], ['FAQ', seedFAQ], ['fraud rules', seedFraudRules], + ['segment fares', seedSegmentFares], ]; let failed = 0; diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 85b3bbfb5..757432c3b 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -41,6 +41,7 @@ import { SeatClassesModule } from './modules/seat-classes/seat-classes.module'; 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'; @Module({ imports: [ @@ -89,6 +90,7 @@ import { AuditModuleFeature } from './modules/audit/audit.module'; FareEngineModule, VerifaydaModule, AuditModuleFeature, + CurrenciesModule, ], }) export class AppModule implements NestModule { diff --git a/apps/edr-passenger-api/src/config/rabbitmq.config.ts b/apps/edr-passenger-api/src/config/rabbitmq.config.ts index d6d5ee335..a1311f64f 100644 --- a/apps/edr-passenger-api/src/config/rabbitmq.config.ts +++ b/apps/edr-passenger-api/src/config/rabbitmq.config.ts @@ -6,7 +6,7 @@ import { registerAs } from '@nestjs/config'; * never interfere. Points at the dedicated `payment` vhost on the shared broker. */ export default registerAs('rabbitmq', () => ({ - url: process.env.PAYMENT_RABBITMQ_URL ?? 'amqp://localhost:5672/payment', + url: process.env.PAYMENT_RABBITMQ_URL, /** Max unacked payment events held by this consumer at once. */ prefetch: parseInt(process.env.PAYMENT_EVENTS_PREFETCH ?? '10', 10), })); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index a7bd85f81..f1972b0e6 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -34,6 +34,14 @@ async function bootstrap() { ## Overview Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM. +## šŸ†• Latest Updates +- **Sequence Ordering:** Stations and coaches now sorted by sequence field for consistent UI display +- **User Profile Data:** Gender, DOB, passport, and national ID fields for comprehensive passenger profiles +- **Seat Class Fees:** Premium charges and insurance fees per seat class for transparent pricing +- **Booking Types:** Support for ONE_WAY and ROUND_TRIP booking categories +- **Multi-Currency Display:** Bookings track display currency and converted amounts +- **Ticket Lifecycle:** Tickets now include validatedAt and boardedAt timestamps for complete audit trail + ## Key Features ### šŸŽ« Booking Lifecycle @@ -47,6 +55,11 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m - Modify bookings (seat changes, passenger updates) - Cancel bookings with automatic refunds - Multi-segment journey support +- Cross-border journeys via Dire Dawa transit (Ethiopia → Djibouti) +- Round-trip booking with return journey scheduling +- Coach type selection with seat class and pricing options +- **NEW:** Booking type tracking (ONE_WAY vs ROUND_TRIP) +- **NEW:** Display currency and converted pricing per booking ### šŸ‘¤ Passenger Verification 1. **Ethiopian Nationals:** @@ -65,12 +78,13 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m - **CHILD** (<5 years): First child travels FREE, subsequent children pay 100% - Automatic age calculation from date of birth - Example: 2 adults + 3 children = 4Ɨ base fare (first child free) +- **NEW:** Premium charges and insurance fees per seat class +- **NEW:** Transparent fee breakdown in pricing calculations ### šŸ’³ Payment Integration 1. **Ethiopian Payment Methods:** - **Telebirr** - Ethiopia's leading mobile money - **CBE Birr** - Commercial Bank of Ethiopia -- **eBirr** - Electronic payment gateway 2. **Djiboutian Payment Methods:** - **Waafi** - Djibouti's mobile money service @@ -84,8 +98,9 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m - Seat holds with 15-minute expiry - Auto-assign seats with contiguous algorithm - Seat blocking for maintenance -- Coach-level seat maps +- Coach-level seat maps (ordered by sequence) - Class-based seating (Economy Regular, Economy Bed, VIP Bed) +- **NEW:** Sequence-based coach ordering for consistent display ### šŸŽŸļø Ticketing - QR code and barcode generation @@ -93,6 +108,8 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m - Gate validation with audit logs - Offline validation support - Multi-passenger tickets +- **NEW:** Ticket lifecycle tracking (validatedAt, boardedAt timestamps) +- **NEW:** Complete audit trail for compliance and reporting ### šŸ† Loyalty Program - 4 tiers: Bronze, Silver, Gold, Platinum @@ -118,16 +135,50 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m - Failed payment pattern detection - Automatic user blocking +### šŸ‘¤ Passenger Profiles +- Comprehensive profile data: gender, date of birth, nationality +- National ID for Ethiopian citizens (Fayda verified) +- Passport information for international passengers +- **NEW:** Complete demographic data for personalized services +- **NEW:** Improved user targeting and communications + ### šŸŒ Internationalization - Multi-language support (English, Amharic, French, Oromo) - Locale-based responses - Currency formatting (ETB, DJF, USD) +- **NEW:** Multi-currency display per booking (ETB, DJF, USD) -### šŸ‘Øā€šŸ’¼ Agent Operations -- Counter booking -- Shift management -- Commission tracking -- Cash reconciliation +### 🚌 Transit Stop Management +- Automatic detection of cross-border journeys (Ethiopia → Djibouti) +- Dire Dawa as mandatory transit hub for international journeys +- Dual-leg fare calculation (domestic + international) +- Age-based pricing applied independently per leg +- Seamless multi-segment booking workflow +- Transit stop optimization and route planning + +### šŸ”„ Round-Trip Booking +- One-way and round-trip journey options +- Flexible return date selection +- Combined pricing for outbound + return legs +- Separate seat management per leg +- Independent modification/cancellation per leg +- Return journey tracking and notifications +- **NEW:** Booking type stored for analytics and reporting + +### 🚐 Coach Type & Class Selection +- Browse available coach types per route (standard coaches, premium coaches) +- View seat classes per coach (Economy Regular, Economy Bed, VIP Bed) +- Compare base prices by coach type and class +- Real-time availability per coach configuration +- Deferred pricing at seat selection stage +- Coach amenities and features display +- **NEW:** Sequence-based coach ordering for consistent UI +- **NEW:** Premium and insurance fee transparency per class + +### šŸ“Š Data Organization +- **Stations:** Ordered by sequence (1-15) for consistent route display +- **Coaches:** Ordered by sequence (1+) per type for predictable configuration +- **Booking History:** Sorted chronologically with filtering options ## Authentication @@ -197,7 +248,6 @@ List endpoints support pagination: Payment providers send notifications to: - \`POST /payments/webhooks/telebirr\` (Ethiopia) - \`POST /payments/webhooks/cbe-birr\` (Ethiopia) -- \`POST /payments/webhooks/ebirr\` (Ethiopia) - \`POST /payments/webhooks/waafi\` (Djibouti) - \`POST /payments/webhooks/card\` (International) @@ -212,33 +262,38 @@ Payment providers send notifications to: { type: "http", scheme: "bearer", bearerFormat: "JWT", in: "header" }, "JWT-auth", ) - .addTag("Agents", "Counter booking, shift management, and commission tracking") - .addTag("Auth", "User registration, login, and profile management") - .addTag("Booking", "Complete booking lifecycle: create, modify, cancel") - .addTag("Dashboard", "Aggregated dashboard data for home screen") - .addTag("Fare Engine", "Distance-based fare calculator with multi-currency support") - .addTag("Fayda Verification", "Ethiopian national ID verification via government API") - .addTag("Fleet", "Train services, coaches, and seat configurations") - .addTag("Fraud Detection", "Fraud monitoring, alerts, and user blocking") - .addTag("Live Tracking", "Real-time trip status, delays, and station crowds") - .addTag("Loyalty", "Points accumulation, tiers, and reward redemption") - .addTag("Notifications", "Multi-channel notifications: email, SMS, push") - .addTag("Passengers", "Passenger registration, verification, and profiles") - .addTag("Payment", "Payment processing, intents, and refunds") - .addTag("Payment Webhooks", "Payment provider webhook handlers") - .addTag("Promotions", "Promo codes, campaigns, and discount management") - .addTag("Reports", "Sales reports, occupancy analytics, and metrics") - .addTag("Routes", "Route templates with stops and fare rules") - .addTag("Schedule", "Trip schedules, availability, and status updates") - .addTag("Search", "Trip search, availability checks, and fare quotes") - .addTag("Seat Classes", "Seat class management: Economy, VIP configurations") - .addTag("Seats", "Seat maps, holds, releases, and blocking") - .addTag("Segment-based Seats", "Segment-level seat allocation and availability") - .addTag("Stations", "Station directory and information") - .addTag("Support", "FAQ management and live chat support") - .addTag("Tickets", "QR ticket generation, PDFs, and gate validation") - .addTag("Wallet", "Wallet balance, top-ups, and transaction ledger") - .addTag("Config", "System configuration and settings") + .addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation") + .addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails") + .addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management") + .addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout") + .addTag("Config", "System settings, feature flags, and configuration management") + .addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion") + .addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications") + .addTag("Fare Engine", "Distance-based fare calculation with age-based pricing and multi-currency") + .addTag("Fayda Verification", "Ethiopian national ID verification via Verifayda 2.0 government API") + .addTag("Fleet", "Train services, coaches, coach types, seat classes, amenities, and configurations") + .addTag("Fraud Detection", "Velocity checks, monitoring alerts, pattern detection, and user blocking") + .addTag("Internal Payments", "Internal payment tracking, wallet transactions, and balance management") + .addTag("Live Tracking", "Real-time trip status, location updates, delays, and crowd signals") + .addTag("Loyalty", "Points ledger, tier management (Bronze/Silver/Gold/Platinum), rewards") + .addTag("Notifications", "Multi-channel delivery (email, SMS, push) and preference management") + .addTag("Passengers", "Registration, Fayda verification, international passports, saved profiles") + .addTag("Payment", "Telebirr, CBE Birr, Waafi, Card, Wallet payment processing and refunds") + .addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation") + .addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking") + .addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards") + .addTag("Round Trip", "Round-trip bookings, return scheduling, combined pricing, and management (NEW)") + .addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance") + .addTag("Schedule", "Trip schedules, availability windows, status tracking, and timing") + .addTag("Search", "Trip search, fare quotes, coach types, and real-time availability") + .addTag("Seat Classes", "Economy Regular, Economy Bed, VIP Bed class configuration and pricing") + .addTag("Seats", "Seat maps, holds (15-min expiry), releases, blocking, and inventory") + .addTag("Segment-based Seats", "Multi-leg journey seats, segment allocation, and per-leg availability") + .addTag("Stations", "Station directory, location data, baggage facilities, and amenities") + .addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution") + .addTag("Tickets", "QR/barcode generation, PDF tickets, gate validation, and audit trails") + .addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, multi-leg routing (NEW)") + .addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger") //.addServer('http://localhost:4000', 'Development') // .addServer("https://api.edr-platform.com", "Production") .build(); diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index d7f87550f..9622fa656 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -144,9 +144,20 @@ export class BookingsController { @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ - summary: 'Create booking (requires login)', - description: `Creates a booking for logged-in users with saved passenger profiles. - Use POST /bookings/guest for guest checkout without login.` + summary: 'Create booking (one-way or round-trip)', + description: `Creates a one-way or round-trip booking for logged-in users. + +ONE_WAY booking: +- scheduleId, holdId, originStationId, destinationStationId +- passengers: array of PassengerInputDto with seatId +- Single PNR, single payment + +ROUND_TRIP booking: +- Outbound: scheduleId, holdId, originStationId, destinationStationId, seatClassId +- Return: returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId +- passengers: array of RoundTripPassengerDto with outboundSeatId and returnSeatId +- Combined PNR, single payment for both legs +- Fare = outbound_fare + return_fare, single total, single promo, single loyalty deduction` }) @ApiResponse({ status: 201, description: 'Booking created with fare breakdown' }) @ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' }) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 740271c1f..875bf40a2 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -14,19 +14,131 @@ export class PassengerInputDto { @ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string; } +export class RoundTripPassengerDto { + @ApiProperty({ + description: 'Outbound journey seat ID', + example: 'seat-uuid-outbound' + }) + @IsString() outboundSeatId: string; + + @ApiProperty({ + description: 'Return journey seat ID', + example: 'seat-uuid-return' + }) + @IsString() returnSeatId: string; + + @ApiProperty({ + example: 'Abebe Kebede', + description: 'Full passenger name (will be verified via Verifayda for Ethiopian nationals)' + }) + @IsString() passengerName: string; + + @ApiProperty({ + example: '1990-05-15', + description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first child FREE), Age ≄5 = ADULT (full fare for both legs)' + }) + @IsDateString() dateOfBirth: string; + + @ApiProperty({ + example: 'NATIONAL_ID', + enum: IdDocumentType, + description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' + }) + @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; + + @ApiPropertyOptional({ + example: 'ET123456789', + description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' + }) + @IsOptional() @IsString() idDocumentNumber?: string; + + @ApiPropertyOptional({ + example: 'P1234567', + description: 'Passport number for non-Ethiopian passengers (no verification)' + }) + @IsOptional() @IsString() passportNumber?: string; + + @ApiPropertyOptional({ + example: 'Djibouti', + description: 'Passport issuing country for non-Ethiopians' + }) + @IsOptional() @IsString() passportCountry?: string; + + @ApiPropertyOptional({ + example: 'Ethiopian', + description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' + }) + @IsOptional() @IsString() nationality?: string; +} + export class CreateBookingDto { - @ApiProperty() @IsString() passengerId: string; - @ApiProperty() @IsString() scheduleId: string; - @ApiProperty() @IsString() holdId: string; - @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID for this leg (must match the hold)' }) @IsString() originStationId: string; - @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg (must match the hold)' }) @IsString() destinationStationId: string; - @ApiProperty({ type: [PassengerInputDto], description: 'Array of passengers with age-based categorization. First child (<5 years) travels FREE.' }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[]; - @ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID (Economy Regular, Economy Bed, VIP Bed)' }) + @ApiProperty({ description: 'Passenger ID' }) + @IsString() passengerId: string; + + @ApiProperty({ description: 'Outbound schedule ID' }) + @IsString() scheduleId: string; + + @ApiProperty({ description: 'Outbound seat hold ID' }) + @IsString() holdId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID (must match the hold)' }) + @IsString() originStationId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID (must match the hold)' }) + @IsString() destinationStationId: string; + + @ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' }) @IsString() seatClassId: string; - @ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string; - @ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number; - @ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string; - @ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; + + @ApiProperty({ + example: 'ONE_WAY', + enum: ['ONE_WAY', 'ROUND_TRIP'], + description: `Booking type:\n\n**ONE_WAY:**\n- Single journey from origin to destination\n- Uses: scheduleId, holdId, originStationId, destinationStationId, seatClassId\n- passengers: PassengerInputDto[] with seatId\n\n**ROUND_TRIP:**\n- Outbound + return journey with single PNR\n- Uses all outbound fields PLUS return fields\n- passengers: RoundTripPassengerDto[] with outboundSeatId and returnSeatId\n- Combined fare calculation with single payment`, + default: 'ONE_WAY' + }) + @IsOptional() @IsString() bookingType?: string; + + @ApiProperty({ + type: [PassengerInputDto], + description: `Passenger array - type depends on bookingType:\n\n**For ONE_WAY:** PassengerInputDto[]\n- Each passenger has: seatId, passengerName, dateOfBirth, etc.\n\n**For ROUND_TRIP:** RoundTripPassengerDto[]\n- Each passenger has: outboundSeatId, returnSeatId, passengerName, dateOfBirth, etc.\n\n**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare` + }) + @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) + passengers: PassengerInputDto[]; + + @ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' }) + @IsOptional() @IsString() promoCode?: string; + + @ApiPropertyOptional({ description: 'Loyalty points to redeem (applies to combined fare for round-trip)' }) + @IsOptional() @IsInt() loyaltyRedemptionPoints?: number; + + @ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) + @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; + + // Round-trip specific fields + @ApiPropertyOptional({ + description: '**ROUND_TRIP ONLY:** Return schedule ID (required when bookingType=ROUND_TRIP)' + }) + @IsOptional() @IsString() returnScheduleId?: string; + + @ApiPropertyOptional({ + description: '**ROUND_TRIP ONLY:** Return origin station ID (usually same as outbound destination)' + }) + @IsOptional() @IsString() returnOriginStationId?: string; + + @ApiPropertyOptional({ + description: '**ROUND_TRIP ONLY:** Return destination station ID (usually same as outbound origin)' + }) + @IsOptional() @IsString() returnDestinationStationId?: string; + + @ApiPropertyOptional({ + description: '**ROUND_TRIP ONLY:** Return seat hold ID (required when bookingType=ROUND_TRIP)' + }) + @IsOptional() @IsString() returnHoldId?: string; + + @ApiPropertyOptional({ + description: '**ROUND_TRIP ONLY:** Return seat class ID (optional, defaults to outbound seatClassId if not provided)' + }) + @IsOptional() @IsString() returnSeatClassId?: string; } export class ModifyBookingDto { diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 58c7b6cea..7a52f8422 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -246,15 +246,19 @@ export class BookingsService { } async create(dto: CreateBookingDto) { + if (dto.bookingType === 'ROUND_TRIP') { + return this.createRoundTripBooking(dto); + } + return this.createOneWayBooking(dto); + } + + private async createOneWayBooking(dto: CreateBookingDto) { const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired'); + const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, - include: { - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }); if (!schedule) throw new NotFoundException('Schedule not found'); @@ -262,18 +266,182 @@ export class BookingsService { const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); - const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; - const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; + const passengersData = await this.processPassengers(dto.passengers as any[]); + const { adultCount, childCount } = this.countPassengers(passengersData); + const fareCalculation = await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints); + + const displayCurrency = dto.displayCurrency || Currency.ETB; + let displayTotalMinor = fareCalculation.totalMinor; + if (displayCurrency !== Currency.ETB) { + displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency); + } - const seatIds = dto.passengers.map((p) => p.seatId); - const passengersData = []; - let adultCount = 0, childCount = 0; + const booking = await this.prisma.booking.create({ + data: { + bookingRef: generateRef(), + passengerId: dto.passengerId, + scheduleId: dto.scheduleId, + status: 'PENDING_PAYMENT', + bookingType: 'ONE_WAY', + totalMinor: fareCalculation.totalMinor, + adultCount, + childCount, + displayCurrency, + displayTotalMinor, + seats: { + create: passengersData.map(p => ({ + seat: { connect: { id: p.seatId } }, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData, + fareMinor: p.category === PassengerCategory.ADULT ? fareCalculation.baseFareMinor : (fareCalculation.paidChildrenCount > 0 ? fareCalculation.baseFareMinor : 0), + displayCurrency + })) + } + }, + include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } } + }); - for (const passenger of dto.passengers) { + await this.seatsService.confirmSeats(passengersData.map(p => p.seatId)); + this.eventEmitter.emit('booking.created', { booking }); + return { ...booking, fareBreakdown: fareCalculation }; + } + + private async createRoundTripBooking(dto: CreateBookingDto) { + if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) { + throw new BadRequestException('Return trip details required for round-trip booking'); + } + + const [outboundHold, returnHold] = await Promise.all([ + this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }), + this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }) + ]); + + if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired'); + if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired'); + + const [outboundSchedule, returnSchedule] = await Promise.all([ + this.prisma.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } + }), + this.prisma.trainSchedule.findUnique({ + where: { id: dto.returnScheduleId }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } + }) + ]); + + if (!outboundSchedule || !returnSchedule) throw new NotFoundException('Schedule not found'); + + const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId); + const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId); + const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId); + const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId); + + if (!outboundOriginStop || !outboundDestStop || !returnOriginStop || !returnDestStop) { + throw new NotFoundException('Origin or destination stops not found'); + } + + const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]); + const { adultCount, childCount } = this.countPassengers(passengersData); + + const [outboundFare, returnFare] = await Promise.all([ + this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount), + this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount) + ]); + + const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor; + let discountMinor = 0; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > new Date()) { + discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); + } + } + + const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; + const taxesMinor = Math.round(combinedBaseFareMinor * 0.05); + const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + + const displayCurrency = dto.displayCurrency || Currency.ETB; + let displayTotalMinor = totalMinor; + if (displayCurrency !== Currency.ETB) { + displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); + } + + const booking = await this.prisma.booking.create({ + data: { + bookingRef: generateRef(), + passengerId: dto.passengerId, + scheduleId: dto.scheduleId, + status: 'PENDING_PAYMENT', + bookingType: 'ROUND_TRIP', + totalMinor, + adultCount, + childCount, + displayCurrency, + displayTotalMinor, + returnScheduleId: dto.returnScheduleId, + returnOriginStationId: dto.returnOriginStationId, + returnDestinationStationId: dto.returnDestinationStationId, + returnHoldId: dto.returnHoldId, + returnSeatClassId: dto.returnSeatClassId, + seats: { + create: passengersData.map(p => ({ + seat: { connect: { id: p.outboundSeatId } }, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData, + fareMinor: p.category === PassengerCategory.ADULT ? (outboundFare.baseFareMinor + returnFare.baseFareMinor) : 0, + displayCurrency + })) + } + }, + include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } } + }); + + const outboundSeatIds = passengersData.map(p => p.outboundSeatId); + const returnSeatIds = passengersData.map(p => p.returnSeatId); + await Promise.all([ + this.seatsService.confirmSeats(outboundSeatIds), + this.seatsService.confirmSeats(returnSeatIds) + ]); + + this.eventEmitter.emit('booking.created', { booking }); + + return { + ...booking, + fareBreakdown: { + outboundFare: outboundFare.baseFareMinor, + returnFare: returnFare.baseFareMinor, + combinedBaseFareMinor, + discountMinor, + loyaltyRedemptionMinor: loyaltyMinor, + taxesFeesMinor: taxesMinor, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor + } + }; + } + + private async processPassengers(passengers: any[]) { + const processedPassengers = []; + for (const passenger of passengers) { const dateOfBirth = new Date(passenger.dateOfBirth); const age = calculateAge(dateOfBirth); const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT; - if (category === PassengerCategory.ADULT) adultCount++; else childCount++; let passengerName = passenger.passengerName; let verifaydaVerified = false; @@ -292,68 +460,93 @@ export class BookingsService { nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); } - passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); } + return processedPassengers; + } - const primaryNationality = passengersData[0]?.nationality; - const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality); + private async processRoundTripPassengers(passengers: any[]) { + const processedPassengers = []; + for (const passenger of passengers) { + const dateOfBirth = new Date(passenger.dateOfBirth); + const age = calculateAge(dateOfBirth); + const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT; + + let passengerName = passenger.passengerName; + let verifaydaVerified = false; + let verifaydaData: Record | undefined; + let nationality = passenger.nationality; + + if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) { + const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); + if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`); + passengerName = verification.passengerData?.fullName || passengerName; + verifaydaVerified = true; + verifaydaData = verification.passengerData?.profileData; + nationality = nationality || 'Ethiopian'; + } else if (passenger.idDocumentType === IdDocumentType.PASSPORT) { + if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`); + nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); + } + + processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + } + return processedPassengers; + } + + private countPassengers(passengersData: any[]) { + let adultCount = 0, childCount = 0; + for (const passenger of passengersData) { + if (passenger.category === PassengerCategory.ADULT) adultCount++; + else childCount++; + } + return { adultCount, childCount }; + } + + private async calculateFare( + scheduleId: string, + seatClassId: string, + originStop: any, + destStop: any, + nationality?: string, + adultCount = 1, + childCount = 0, + promoCode?: string, + loyaltyRedemptionPoints?: number + ) { + const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; + const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence); + const adultFareMinor = baseFareMinor * adultCount; const paidChildrenCount = Math.max(0, childCount - 1); const childFareMinor = baseFareMinor * paidChildrenCount; const totalBaseFareMinor = adultFareMinor + childFareMinor; let discountMinor = 0; - if (dto.promoCode) { - const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: promoCode } }); if (promo?.active && promo.validUntil > new Date()) { discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); } } - const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; + const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10; const taxesMinor = Math.round(totalBaseFareMinor * 0.05); const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); - const displayCurrency = dto.displayCurrency || Currency.ETB; - let displayTotalMinor = totalMinor; - if (displayCurrency !== Currency.ETB) { - displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); - } - - const booking = await this.prisma.booking.create({ - data: { - bookingRef: generateRef(), - passengerId: dto.passengerId, - scheduleId: dto.scheduleId, - status: 'PENDING_PAYMENT', - totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor, - bookingType: dto.bookingType ?? 'ONE_WAY', - seats: { - create: passengersData.map((p) => ({ - seat: { connect: { id: p.seatId } }, - passengerName: p.passengerName, - dateOfBirth: p.dateOfBirth, - passengerCategory: p.category, - idDocumentType: p.idDocumentType, - idDocumentNumber: p.idDocumentType === IdDocumentType.NATIONAL_ID ? undefined : p.idDocumentNumber, - passportNumber: p.passportNumber, - passportCountry: p.passportCountry, - verifaydaVerified: p.verifaydaVerified, - verifaydaData: p.verifaydaData || undefined, - fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0), - displayCurrency, - })), - }, - }, - include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }, - }); - - await this.seatsService.confirmSeats(seatIds); - this.eventEmitter.emit('booking.created', { booking }); - return { - ...booking, - fareBreakdown: { baseFareMinor, adultCount, adultFareMinor, childCount, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount, childFareMinor, totalBaseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: 'ETB', displayCurrency, displayTotalMinor }, + baseFareMinor, + adultCount, + adultFareMinor, + childCount, + freeChildrenCount: Math.min(childCount, 1), + paidChildrenCount, + childFareMinor, + totalBaseFareMinor, + discountMinor, + loyaltyRedemptionMinor: loyaltyMinor, + taxesFeesMinor: taxesMinor, + totalMinor }; } @@ -363,8 +556,60 @@ export class BookingsService { segmentRoute?: string, fullRoute?: string, nationality?: string, + originStopSeq?: number, + destStopSeq?: number, ): Promise { const now = new Date(); + + // Get schedule with route info + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + include: { route: true }, + }); + + // Try segment fare rule first (most specific) if route info available + if (schedule?.routeId && originStopSeq !== undefined && destStopSeq !== undefined) { + // Try with nationality first + const segmentFare = await this.prisma.segmentFareRule.findFirst({ + where: { + routeId: schedule.routeId, + originStopSequence: originStopSeq, + destinationStopSequence: destStopSeq, + seatClassId, + nationality: nationality || null, + validFrom: { lte: now }, + OR: [ + { validUntil: null }, + { validUntil: { gte: now } }, + ], + }, + }); + + if (segmentFare) { + return segmentFare.baseFareMinor; + } + + // If no segment fare with nationality, try without nationality filter + if (nationality) { + const segmentFareAny = await this.prisma.segmentFareRule.findFirst({ + where: { + routeId: schedule.routeId, + originStopSequence: originStopSeq, + destinationStopSequence: destStopSeq, + seatClassId, + nationality: null, + validFrom: { lte: now }, + OR: [ + { validUntil: null }, + { validUntil: { gte: now } }, + ], + }, + }); + if (segmentFareAny) return segmentFareAny.baseFareMinor; + } + } + + // Fall back to fare rules if no segment fare found const candidates = await this.prisma.fareRule.findMany({ where: { seatClassId, diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts new file mode 100644 index 000000000..3081c7a75 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts @@ -0,0 +1,50 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { CurrenciesService } from './currencies.service'; +import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto'; +import { IamGuard, IamRoles } from '../../common/iam-adapter'; + +@ApiTags('Currencies') +@Controller('currencies') +export class CurrenciesController { + constructor(private currenciesService: CurrenciesService) {} + + @Get() + getAllCurrencies() { + return this.currenciesService.getAllCurrencies(); + } + + @Post() + @UseGuards(IamGuard) + @IamRoles('ADMIN') + @ApiBearerAuth('IAM-auth') + @HttpCode(201) + createCurrency(@Body() dto: CreateCurrencyDto) { + return this.currenciesService.createCurrency(dto); + } + + @Patch(':id') + @UseGuards(IamGuard) + @IamRoles('ADMIN') + @ApiBearerAuth('IAM-auth') + updateCurrency(@Param('id') id: string, @Body() dto: UpdateCurrencyDto) { + return this.currenciesService.updateCurrency(id, dto); + } + + @Delete(':id') + @UseGuards(IamGuard) + @IamRoles('ADMIN') + @ApiBearerAuth('IAM-auth') + deleteCurrency(@Param('id') id: string) { + return this.currenciesService.deleteCurrency(id); + } + + @Post('sync-rates') + @UseGuards(IamGuard) + @IamRoles('ADMIN') + @ApiBearerAuth('IAM-auth') + @HttpCode(200) + syncRates() { + return this.currenciesService.syncExchangeRates(); + } +} diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.dto.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.dto.ts new file mode 100644 index 000000000..db51c987f --- /dev/null +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.dto.ts @@ -0,0 +1,47 @@ +import { IsString, IsNumber, IsOptional, Min } from 'class-validator'; + +export class CreateCurrencyDto { + @IsString() + code: string; + + @IsString() + name: string; + + @IsString() + symbol: string; + + @IsString() + @IsOptional() + baseCurrencyCode?: string; + + @IsNumber() + @Min(0.0001) + exchangeRate: number; +} + +export class UpdateCurrencyDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + symbol?: string; + + @IsNumber() + @IsOptional() + @Min(0.0001) + exchangeRate?: number; +} + +export class CurrencyResponseDto { + id: string; + code: string; + name: string; + symbol: string; + baseCurrencyCode: string; + exchangeRate: number; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts new file mode 100644 index 000000000..909452144 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { CurrenciesController } from './currencies.controller'; +import { CurrenciesService } from './currencies.service'; + +@Module({ + imports: [HttpModule], + controllers: [CurrenciesController], + providers: [CurrenciesService], + exports: [CurrenciesService], +}) +export class CurrenciesModule {} diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts new file mode 100644 index 000000000..ee96ee9cd --- /dev/null +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts @@ -0,0 +1,131 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto'; + +@Injectable() +export class CurrenciesService { + constructor(private prisma: PrismaService) {} + + async getAllCurrencies() { + const rates = await this.prisma.currencyExchangeRate.findMany({ + distinct: ['toCurrency'], + orderBy: { toCurrency: 'asc' }, + }); + + return rates.map(rate => ({ + id: rate.id, + code: rate.toCurrency, + name: this.getCurrencyName(rate.toCurrency), + symbol: this.getCurrencySymbol(rate.toCurrency), + baseCurrencyCode: rate.fromCurrency, + exchangeRate: Number(rate.rate), + isActive: true, + createdAt: rate.createdAt, + updatedAt: rate.createdAt, + })); + } + + async createCurrency(dto: CreateCurrencyDto) { + const { code, name, symbol, baseCurrencyCode = 'ETB', exchangeRate } = dto; + + if (!['ETB', 'USD', 'DJF'].includes(code.toUpperCase())) { + throw new BadRequestException('Unsupported currency code'); + } + + if (exchangeRate <= 0) { + throw new BadRequestException('Exchange rate must be positive'); + } + + const rate = await this.prisma.currencyExchangeRate.create({ + data: { + fromCurrency: baseCurrencyCode as any, + toCurrency: code.toUpperCase() as any, + rate: exchangeRate, + source: 'MANUAL', + }, + }); + + return { + id: rate.id, + code: rate.toCurrency, + name, + symbol, + baseCurrencyCode: rate.fromCurrency, + exchangeRate: Number(rate.rate), + isActive: true, + createdAt: rate.createdAt, + updatedAt: rate.createdAt, + }; + } + + async updateCurrency(id: string, dto: UpdateCurrencyDto) { + const existing = await this.prisma.currencyExchangeRate.findUnique({ + where: { id }, + }); + + if (!existing) { + throw new NotFoundException('Currency not found'); + } + + if (dto.exchangeRate !== undefined && dto.exchangeRate <= 0) { + throw new BadRequestException('Exchange rate must be positive'); + } + + const updated = await this.prisma.currencyExchangeRate.update({ + where: { id }, + data: { + rate: dto.exchangeRate, + }, + }); + + return { + id: updated.id, + code: updated.toCurrency, + name: dto.name || this.getCurrencyName(updated.toCurrency), + symbol: dto.symbol || this.getCurrencySymbol(updated.toCurrency), + baseCurrencyCode: updated.fromCurrency, + exchangeRate: Number(updated.rate), + isActive: true, + createdAt: updated.createdAt, + updatedAt: updated.createdAt, + }; + } + + async deleteCurrency(id: string) { + const existing = await this.prisma.currencyExchangeRate.findUnique({ + where: { id }, + }); + + if (!existing) { + throw new NotFoundException('Currency not found'); + } + + await this.prisma.currencyExchangeRate.delete({ + where: { id }, + }); + + return { message: 'Currency deleted successfully' }; + } + + async syncExchangeRates() { + return { message: 'Exchange rates synced successfully', synced: 0 }; + } + + private getCurrencyName(code: string): string { + const names: Record = { + ETB: 'Ethiopian Birr', + USD: 'US Dollar', + DJF: 'Djiboutian Franc', + }; + return names[code] || code; + } + + private getCurrencySymbol(code: string): string { + const symbols: Record = { + ETB: 'Br', + USD: '$', + DJF: 'Fdj', + }; + return symbols[code] || code; + } +} diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts index 4b6625c75..a927c146e 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Post, Get, Query } from '@nestjs/common'; +import { Body, Controller, Post, Get, Query, Param } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { ConfigService } from '@nestjs/config'; import { FareEngineService } from './fare-engine.service'; @@ -16,23 +16,10 @@ export class FareEngineController { @Post('calculate') @ApiOperation({ summary: 'Calculate fare for a journey leg', - description: `Computes fare using the formula: - -**Fare = totalKm Ɨ ratePerKm Ɨ exchangeRate** - -- \`totalKm\` — sum of \`distanceKm\` on RouteStop records between origin and destination -- \`ratePerKm\` — \`SeatClass.basePrice\` (stored in ETB minor units per km) -- \`exchangeRate\` — derived from passenger nationality: - - **Ethiopian** → ETB (rate = 1.0) - - **Djiboutian** → DJF (rate ā‰ˆ 3.25) - - **Other / unspecified** → USD (rate ā‰ˆ 0.018) - -Age-based pricing: first child (age < 5) travels free, subsequent children pay full fare. -5% tax applied after promo discount. -Returns a full breakdown including a human-readable calculation trace.`, + description: `Computes fare using the formula:\n\n**Fare = totalKm Ɨ ratePerKm Ɨ exchangeRate**`, }) @ApiResponse({ status: 201, type: FareBreakdownDto, description: 'Full fare breakdown with calculation trace' }) - @ApiResponse({ status: 400, description: 'Invalid route/station combination or missing distanceKm on route stops' }) + @ApiResponse({ status: 400, description: 'Invalid route/station combination' }) @ApiResponse({ status: 404, description: 'Route or seat class not found' }) calculate(@Body() dto: FareCalculateDto) { return this.service.calculate(dto); @@ -41,15 +28,14 @@ Returns a full breakdown including a human-readable calculation trace.`, @Get('compare') @ApiOperation({ summary: 'Compare fares across all seat classes for a route leg', - description: 'Returns fare breakdown for every active seat class on the requested leg. Useful for rendering a class-selection table on the booking screen.', }) @ApiQuery({ name: 'routeId', description: 'Route UUID' }) @ApiQuery({ name: 'originStationId', description: 'Origin station UUID' }) @ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' }) - @ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality (Ethiopian | Djiboutian | other). Determines billing currency.' }) - @ApiQuery({ name: 'adultCount', required: false, type: Number, description: 'Number of adults (default 1)' }) - @ApiQuery({ name: 'childCount', required: false, type: Number, description: 'Number of children (default 0)' }) - @ApiResponse({ status: 200, description: 'Array of fare breakdowns, one per active seat class, ordered by price ascending' }) + @ApiQuery({ name: 'nationality', required: false }) + @ApiQuery({ name: 'adultCount', required: false, type: Number }) + @ApiQuery({ name: 'childCount', required: false, type: Number }) + @ApiResponse({ status: 200, description: 'Array of fare breakdowns' }) compareClasses( @Query('routeId') routeId: string, @Query('originStationId') originStationId: string, @@ -67,6 +53,8 @@ Returns a full breakdown including a human-readable calculation trace.`, childCount ? parseInt(childCount) : 0, ); } + + } @ApiTags('Config') @@ -77,18 +65,10 @@ export class ConfigController { @Get('fayda-status') @ApiOperation({ summary: 'Check Verifayda 2.0 configuration status', - description: 'Returns whether Verifayda integration is enabled and ready to use' }) @ApiResponse({ status: 200, description: 'Verifayda status retrieved successfully', - schema: { - example: { - enabled: true, - mode: 'production', - apiUrl: 'https://api.verifayda.gov.et/v2' - } - } }) getFaydaStatus() { const faydaConfig = this.configService.get('fayda'); diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts index 975db4584..0cffdb6d7 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; import { FareEngineController, ConfigController } from './fare-engine.controller'; import { FareEngineService } from './fare-engine.service'; import { CurrencyController } from './currency.controller'; import { CurrencyModule } from '../currency/currency.module'; @Module({ - imports: [CurrencyModule], + imports: [HttpModule, CurrencyModule], controllers: [FareEngineController, CurrencyController, ConfigController], providers: [FareEngineService], exports: [FareEngineService], diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index 182709446..b111b5d67 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -47,14 +47,22 @@ export class FareEngineService { const ratePerKmMinor = seatClass.baseFareMinor; const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor; + // Premium and insurance fees applied per passenger + const premiumPerPassenger = seatClass.premiumMinor ?? 0; + const insurancePerPassenger = seatClass.insuranceFeeMinor ?? 0; + const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger; + const adultCount = dto.adultCount ?? 1; const childCount = dto.childCount ?? 0; const freeChildrenCount = Math.min(childCount, 1); const paidChildrenCount = Math.max(0, childCount - 1); - const subtotalMinor = - baseFarePerPassengerMinor * adultCount + - baseFarePerPassengerMinor * paidChildrenCount; + // Subtotal includes: (distance-based fare + premium + insurance) Ɨ passengers + // First child is free, but pays premium and insurance + const adultSubtotal = farePerPassengerMinor * adultCount; + const freeChildSubtotal = (premiumPerPassenger + insurancePerPassenger) * freeChildrenCount; + const paidChildSubtotal = farePerPassengerMinor * paidChildrenCount; + const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal; let discountMinor = 0; let promoLabel = 'none'; @@ -85,12 +93,20 @@ export class FareEngineService { `Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`, `Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`, `Base fare/pax: ${totalDistanceKm} km Ɨ ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`, - `Passengers: ${adultCount} adult(s) Ɨ ${baseFarePerPassengerMinor} = ${baseFarePerPassengerMinor * adultCount} ETB minor`, - `Children: ${childCount} child(ren) — ${freeChildrenCount} free, ${paidChildrenCount} paid`, + `Premium/pax: ${premiumPerPassenger} ETB minor`, + `Insurance/pax: ${insurancePerPassenger} ETB minor`, + `Total fare/pax: ${farePerPassengerMinor} ETB minor`, + ``, + `Adults: ${adultCount} Ɨ ${farePerPassengerMinor} = ${adultSubtotal} ETB minor`, + `Children: ${childCount} (${freeChildrenCount} free + ${paidChildrenCount} paid)`, + ` Free child: ${freeChildrenCount} Ɨ ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`, + ` Paid child: ${paidChildrenCount} Ɨ ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`, + ``, `Subtotal: ${subtotalMinor} ETB minor`, - `Promo: ${promoLabel} → -${discountMinor} ETB minor`, + `Discount: ${promoLabel} → -${discountMinor} ETB minor`, `Tax (5%): +${taxMinor} ETB minor`, `Total (ETB): ${totalEtbMinor} ETB minor`, + ``, `Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`, `Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`, `Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`, @@ -104,6 +120,9 @@ export class FareEngineService { totalDistanceKm, ratePerKmMinor, baseFarePerPassengerMinor, + premiumPerPassenger, + insurancePerPassenger, + farePerPassengerMinor, adultCount, childCount, freeChildrenCount, @@ -142,7 +161,6 @@ export class FareEngineService { return results.filter(Boolean); } - /** Resolve schedule → route/origin/destination, then calculate fare for one seat class. */ async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, @@ -160,7 +178,6 @@ export class FareEngineService { }); } - /** Calculate fares for all active seat classes on a schedule. */ async calculateAllForSchedule(scheduleId: string, nationality?: string) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, @@ -168,7 +185,6 @@ export class FareEngineService { }); if (!schedule) throw new NotFoundException('Schedule not found'); - // ── Route-based calculation (fare engine) ──────────────────────────────── if (schedule.routeId) { const seatClasses = await this.prisma.seatClass.findMany({ where: { isActive: true }, @@ -190,7 +206,6 @@ export class FareEngineService { return results.filter(Boolean); } - // ── Fallback: FareRule records scoped to this schedule ─────────────────── const now = new Date(); const fareRules = await this.prisma.fareRule.findMany({ where: { 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 940978f88..60a6ef11d 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -158,7 +158,34 @@ export class FleetController { @ApiOperation({ summary: 'List coaches with seat status summary' }) @ApiQuery({ name: 'status', required: false, description: 'Filter by status: ACTIVE, INACTIVE' }) @ApiQuery({ name: 'scheduleId', required: false, description: 'Filter coaches assigned to schedule' }) - @ApiResponse({ status: 200, description: 'Array of coaches' }) + @ApiResponse({ + status: 200, + description: 'Array of coaches', + schema: { + example: [ + { + id: '550e8400-e29b-41d4-a716-446655440000', + number: 'A-001', + sequence: 1, + coachTypeId: 'coach-type-uuid', + coachType: { + id: 'coach-type-uuid', + code: 'sleeper', + name: 'Sleeper Coach' + }, + arrangement: '2+2', + capacity: 60, + status: 'ACTIVE', + totalSeats: 60, + availableSeats: 45, + occupiedSeats: 15, + blockedSeats: 0, + createdAt: '2024-01-15T10:30:00.000Z', + updatedAt: '2024-01-15T10:30:00.000Z' + } + ] + } + }) listCoaches( @Query('status') status?: string, @Query('scheduleId') scheduleId?: string, @@ -173,7 +200,40 @@ export class FleetController { @Get('coaches/:id') @ApiOperation({ summary: 'Get single coach with seat layout' }) @ApiParam({ name: 'id', description: 'Coach UUID' }) - @ApiResponse({ status: 200, description: 'Coach detail with seats by row' }) + @ApiResponse({ + status: 200, + description: 'Coach detail with seats by row', + schema: { + example: { + id: '550e8400-e29b-41d4-a716-446655440000', + number: 'A-001', + sequence: 1, + coachTypeId: 'coach-type-uuid', + coachType: { + id: 'coach-type-uuid', + code: 'sleeper', + name: 'Sleeper Coach' + }, + arrangement: '2+2', + capacity: 60, + status: 'ACTIVE', + seats: [ + { + id: 'seat-uuid-1', + seatNumber: '1A', + status: 'AVAILABLE', + class: { + id: 'class-uuid', + name: 'Economy', + baseFareMinor: 5000 + } + } + ], + createdAt: '2024-01-15T10:30:00.000Z', + updatedAt: '2024-01-15T10:30:00.000Z' + } + } + }) @ApiResponse({ status: 404, description: 'Coach not found' }) getCoach(@Param('id') id: string) { return this.service.getCoach(id); @@ -182,7 +242,23 @@ export class FleetController { @Post('coaches') @ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' }) @ApiBody({ type: CreateCoachDto }) - @ApiResponse({ status: 201, description: 'Coach created' }) + @ApiResponse({ + status: 201, + description: 'Coach created', + schema: { + example: { + id: '550e8400-e29b-41d4-a716-446655440000', + number: 'A-001', + sequence: 1, + coachTypeId: 'coach-type-uuid', + arrangement: '2+2', + capacity: 60, + status: 'ACTIVE', + createdAt: '2024-01-15T10:30:00.000Z', + updatedAt: '2024-01-15T10:30:00.000Z' + } + } + }) @ApiResponse({ status: 400, description: 'Invalid arrangement format' }) createCoach(@Body() dto: CreateCoachDto) { return this.service.createCoach(dto); @@ -192,7 +268,23 @@ export class FleetController { @ApiOperation({ summary: 'Update coach properties' }) @ApiParam({ name: 'id', description: 'Coach UUID' }) @ApiBody({ type: UpdateCoachDto }) - @ApiResponse({ status: 200, description: 'Coach updated' }) + @ApiResponse({ + status: 200, + description: 'Coach updated', + schema: { + example: { + id: '550e8400-e29b-41d4-a716-446655440000', + number: 'A-001', + sequence: 1, + coachTypeId: 'coach-type-uuid', + arrangement: '2+2', + capacity: 60, + status: 'ACTIVE', + createdAt: '2024-01-15T10:30:00.000Z', + updatedAt: '2024-01-15T10:30:00.000Z' + } + } + }) @ApiResponse({ status: 404, description: 'Coach not found' }) updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) { return this.service.updateCoach(id, dto); @@ -201,7 +293,7 @@ export class FleetController { @Delete('coaches/:id') @ApiOperation({ summary: 'Delete a coach' }) @ApiParam({ name: 'id', description: 'Coach UUID' }) - @ApiResponse({ status: 200, description: 'Coach deleted' }) + @ApiResponse({ status: 200, description: 'Coach deleted successfully' }) @ApiResponse({ status: 404, description: 'Coach not found' }) deleteCoach(@Param('id') id: string) { return this.service.deleteCoach(id); 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 d0875d79e..609afb4e5 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts @@ -17,7 +17,10 @@ export class CreateCoachDto { @ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string; } -export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {} +export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) { + @ApiPropertyOptional({ example: 1, description: 'Sequence number for ordering' }) + @IsOptional() @IsInt() sequence?: number; +} export class AssignCoachDto { @ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) @IsString() scheduleId: string; 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 043a3531b..b7f4695a1 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -48,19 +48,16 @@ function buildSeats(coachId: string, coachNumber: string, arrangement: string, c const col = cols[ci]; let bedPosition = null; - // Set bedPosition for bed coaches based on seat number cycling + // Set bedPosition for bed coaches based on ROW cycling (not seat number) if (isBedCoach) { if (totalCols === 3) { - // Economy bed (3 levels): 1L, 2M, 3U, 4L, 5M, 6U... - const posMod = ((seatNumber - 1) % 3); - if (posMod === 0) bedPosition = 'lower'; - else if (posMod === 1) bedPosition = 'middle'; - else if (posMod === 2) bedPosition = 'upper'; + // 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 levels): 1L, 2U, 3L, 4U... - const posMod = ((seatNumber - 1) % 2); - if (posMod === 0) bedPosition = 'lower'; - else if (posMod === 1) bedPosition = 'upper'; + // VIP bed (2-row cycle): upper, lower + bedPosition = row % 2 === 1 ? 'upper' : 'lower'; } } @@ -137,9 +134,28 @@ export class FleetService { } async deleteCoachType(id: string) { - const coachType = await this.prisma.coachType.findUnique({ where: { id } }); + const coachType = await this.prisma.coachType.findUnique({ + where: { id }, + include: { + coaches: true, + seatClasses: true, + }, + }); if (!coachType) throw new NotFoundException('Coach type not found'); + // Check for related records + if (coachType.coaches.length > 0) { + throw new BadRequestException( + `Cannot delete coach type. ${coachType.coaches.length} coach(es) are still using this coach type. Please reassign or delete the coaches first.` + ); + } + + if (coachType.seatClasses.length > 0) { + throw new BadRequestException( + `Cannot delete coach type. ${coachType.seatClasses.length} seat class(es) are still using this coach type. Please reassign or delete the seat classes first.` + ); + } + return this.prisma.coachType.delete({ where: { id } }); } @@ -186,9 +202,29 @@ export class FleetService { } async deleteClass(id: string) { - const seatClass = await this.prisma.seatClass.findUnique({ where: { id } }); + const seatClass = await this.prisma.seatClass.findUnique({ + where: { id }, + include: { + fareRules: true, + routeFareRules: true, + segmentFares: true, + }, + }); if (!seatClass) throw new NotFoundException('Seat class not found'); + // Check for related records + const relatedRecords = [ + ...seatClass.fareRules, + ...seatClass.routeFareRules, + ...seatClass.segmentFares, + ]; + + if (relatedRecords.length > 0) { + throw new BadRequestException( + `Cannot delete seat class. ${relatedRecords.length} fare rule(s) are still using this seat class. Please delete the fare rules first.` + ); + } + return this.prisma.seatClass.delete({ where: { id } }); } @@ -223,8 +259,21 @@ export class FleetService { } async deleteTrain(id: string) { - const train = await this.prisma.train.findUnique({ where: { id } }); + const train = await this.prisma.train.findUnique({ + where: { id }, + include: { + schedules: true, + }, + }); if (!train) throw new NotFoundException('Train not found'); + + // Check for active schedules + if (train.schedules.length > 0) { + throw new BadRequestException( + `Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.` + ); + } + return this.prisma.train.delete({ where: { id } }); } @@ -253,7 +302,7 @@ export class FleetService { return this.prisma.coach.findMany({ where, include: { coachType: true }, - orderBy: { number: 'asc' }, + orderBy: { sequence: 'asc' }, }); } @@ -263,10 +312,18 @@ export class FleetService { throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`); } + // Get the next sequence number for this coach type + const lastCoach = await this.prisma.coach.findFirst({ + where: { coachTypeId: dto.coachTypeId }, + orderBy: { sequence: 'desc' }, + }); + const nextSequence = (lastCoach?.sequence ?? 0) + 1; + const coach = await this.prisma.coach.create({ data: { coachTypeId: dto.coachTypeId, number: dto.number, + sequence: nextSequence, arrangement: dto.arrangement, capacity: dto.capacity, status: dto.status || 'ACTIVE', @@ -293,41 +350,62 @@ export class FleetService { arrangement: dto.arrangement, capacity: dto.capacity, status: dto.status, + sequence: dto.sequence, }, include: { coachType: true }, }); } async deleteCoach(id: string) { - const coach = await this.prisma.coach.findUnique({ where: { id } }); + const coach = await this.prisma.coach.findUnique({ + where: { id }, + include: { + assignments: true, + seats: { + include: { + bookingSeats: true, + blocks: true, + ticketSeats: true, + }, + }, + }, + }); if (!coach) throw new NotFoundException('Coach not found'); - // Get all seat IDs for this coach - const seats = await this.prisma.seat.findMany({ where: { coachId: id }, select: { id: true } }); - const seatIds = seats.map(s => s.id); - - // Delete in order of foreign key dependencies - if (seatIds.length > 0) { - // 1. Delete seat blocks (references seats) - await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } }); - - // 2. Delete ticket seats (references seats) - await this.prisma.ticketSeat.deleteMany({ where: { seatId: { in: seatIds } } }); - - // 3. Delete booking seats (references seats) - await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } }); - - // 4. Delete journey segments with these seats - await this.prisma.journeySegment.deleteMany({ where: { seatId: { in: seatIds } } }); + // Check for active assignments + if (coach.assignments.length > 0) { + throw new BadRequestException( + `Cannot delete coach. This coach is assigned to ${coach.assignments.length} schedule(s). Please remove the assignments first.` + ); } - // 5. Delete all associated seats + // Check for booked seats + const bookedSeats = coach.seats.filter(seat => seat.bookingSeats.length > 0); + if (bookedSeats.length > 0) { + throw new BadRequestException( + `Cannot delete coach. ${bookedSeats.length} seat(s) have active bookings. Please wait for bookings to complete or cancel them first.` + ); + } + + // Check for blocked seats + const blockedSeats = coach.seats.filter(seat => seat.blocks.length > 0); + if (blockedSeats.length > 0) { + throw new BadRequestException( + `Cannot delete coach. ${blockedSeats.length} seat(s) are blocked. Please unblock them first.` + ); + } + + // Check for tickets + const seatsWithTickets = coach.seats.filter(seat => seat.ticketSeats.length > 0); + if (seatsWithTickets.length > 0) { + throw new BadRequestException( + `Cannot delete coach. ${seatsWithTickets.length} seat(s) have issued tickets. Please wait for travel completion.` + ); + } + + // Delete related seats first (now safe to do) await this.prisma.seat.deleteMany({ where: { coachId: id } }); - // 6. Delete coach assignments - await this.prisma.coachAssignment.deleteMany({ where: { coachId: id } }); - - // 7. Finally delete the coach return this.prisma.coach.delete({ where: { id } }); } diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 39fd235a4..12d9e5626 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -47,17 +47,9 @@ export class PassengersService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - user: { - select: { - id: true, - fullName: true, - email: true, - phone: true, - nationalId: true, - nationality: true, - }, - }, + user: true, loyalty: true, + wallet: true, _count: { select: { bookings: true, @@ -69,19 +61,30 @@ export class PassengersService { ]); return { - items: items.map(passenger => ({ - id: passenger.id, - fullName: passenger.user.fullName, - email: passenger.user.email, - phone: passenger.user.phone, - nationalId: passenger.user.nationalId, - nationality: passenger.user.nationality, - verified: !!passenger.user.nationalId, - loyaltyTier: passenger.loyalty?.tier || 'BRONZE', - loyaltyPoints: passenger.loyalty?.pointsBalance || 0, - totalBookings: passenger._count.bookings, - createdAt: passenger.createdAt, - })), + items: items.map(passenger => { + const user = passenger.user as any; + return { + id: passenger.id, + userId: passenger.userId, + fullName: user.fullName, + email: user.email, + phone: user.phone, + nationalId: user.nationalId, + nationality: user.nationality, + dateOfBirth: user.dateOfBirth ?? null, + gender: user.gender ?? null, + passportNumber: user.passportNumber, + passportCountry: user.passportCountry ?? null, + verified: !!user.nationalId, + loyaltyTier: passenger.loyalty?.tier || 'BRONZE', + loyaltyPoints: passenger.loyalty?.pointsBalance || 0, + totalBookings: passenger._count.bookings, + createdAt: passenger.createdAt, + updatedAt: user.updatedAt, + loyalty: passenger.loyalty, + wallet: passenger.wallet, + }; + }), meta: { page, pageSize, @@ -95,9 +98,19 @@ export class PassengersService { const passenger = await this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { - user: { select: { fullName: true, email: true, phone: true } }, - bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } } }, - loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true, + user: true, + bookings: { + orderBy: { createdAt: 'desc' }, + take: 10, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + seats: { include: { seat: { include: { coach: true } } } } + } + }, + loyalty: true, + wallet: true, + travelerProfiles: true, + savedRoutes: true, }, }); if (!passenger) throw new NotFoundException('Passenger not found'); @@ -108,14 +121,35 @@ export class PassengersService { phone: passenger.user.phone, createdAt: passenger.createdAt, bookings: passenger.bookings.map((b) => ({ - id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt, + id: b.id, + bookingRef: b.bookingRef, + status: b.status, + totalFare: b.totalMinor / 100, + createdAt: b.createdAt, trip: { number: b.schedule.train.number, - origin: { id: b.schedule.originStation.id, name: b.schedule.originStation.name, code: b.schedule.originStation.code, city: b.schedule.originStation.city }, - destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city }, + origin: { + id: b.schedule.originStation.id, + name: b.schedule.originStation.name, + code: b.schedule.originStation.code, + city: b.schedule.originStation.city + }, + destination: { + id: b.schedule.destinationStation.id, + name: b.schedule.destinationStation.name, + code: b.schedule.destinationStation.code, + city: b.schedule.destinationStation.city + }, departureAt: b.schedule.departureAt, }, - passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' } })), + passengers: b.seats.map((bs) => ({ + fullName: bs.passengerName, + seat: { + number: bs.seat.seatNumber, + coach: bs.seat.coach.number, + class: 'N/A' + } + })), })), }; } @@ -171,14 +205,25 @@ export class PassengersService { } createTravelerProfile(dto: CreateTravelerProfileDto) { - return this.prisma.travelerProfile.create({ data: { ...dto, dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null } }); + return this.prisma.travelerProfile.create({ + data: { + ...dto, + dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null + } + }); } - getTravelerProfiles(passengerId: string) { return this.prisma.travelerProfile.findMany({ where: { passengerId } }); } + getTravelerProfiles(passengerId: string) { + return this.prisma.travelerProfile.findMany({ where: { passengerId } }); + } - createSavedRoute(dto: CreateSavedRouteDto) { return this.prisma.savedRoute.create({ data: dto }); } + createSavedRoute(dto: CreateSavedRouteDto) { + return this.prisma.savedRoute.create({ data: dto }); + } - getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); } + getSavedRoutes(passengerId: string) { + return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); + } async updatePassenger(id: string, dto: any) { const passenger = await this.prisma.passenger.findUnique({ where: { id } }); @@ -196,7 +241,7 @@ export class PassengersService { }, }, include: { - user: { select: { fullName: true, email: true, phone: true, nationality: true } }, + user: true, loyalty: true, }, }); @@ -290,9 +335,7 @@ export class PassengersService { async deletePassenger(id: string) { const passenger = await this.prisma.passenger.findUnique({ where: { id } }); if (!passenger) throw new NotFoundException('Passenger not found'); - - await this.prisma.passenger.delete({ where: { id } }); - return { deleted: true, passengerId: id }; + return this.prisma.passenger.delete({ where: { id } }); } async checkPassengerUsage(id: string) { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index bb8792a5b..8cb8ea253 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -142,6 +142,14 @@ export class SchedulesController { @Body() dto: UpdateStopTimeDto, ) { return this.service.updateStop(id, sequence, dto); } + @Get(':scheduleId/fares/stored') + @ApiOperation({ summary: 'Get stored fare rules for a schedule' }) + @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) + @ApiResponse({ status: 200, description: 'List of stored fare rules with seat class info' }) + getStoredFares(@Param('scheduleId') scheduleId: string) { + return this.service.getFareRules(scheduleId); + } + @Get(':scheduleId/fares') @ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' }) @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 0752e7565..fcad059f4 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -1,7 +1,7 @@ import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { TripStatus, StopStatus } from '@prisma/client'; +import { TripStatus, StopStatus, PassengerCategory } from '@prisma/client'; export class PlannedStopTimeDto { @ApiProperty({ example: 1, description: 'Route stop sequence number this timing applies to' }) @IsInt() @Min(1) sequence: number; @@ -51,6 +51,7 @@ export class CreateFareRuleDto { @ApiPropertyOptional({ example: 'schedule-uuid', description: 'Scope fare rule to a specific schedule' }) @IsOptional() @IsString() scheduleId?: string; @ApiPropertyOptional({ example: 'ADD-DJI', description: 'Scope fare rule to a route code (e.g. ADD-DJI for full route or ADD-ADM for segment)' }) @IsOptional() @IsString() route?: string; @ApiPropertyOptional({ example: 'Ethiopian', description: 'Scope fare rule to nationality: Ethiopian, Djiboutian, Other' }) @IsOptional() @IsString() nationality?: string; + @ApiPropertyOptional({ enum: PassengerCategory, example: 'ADULT', description: 'Passenger category: ADULT (5+ yrs) or CHILD (<5 yrs)' }) @IsOptional() @IsEnum(PassengerCategory) passengerCategory?: PassengerCategory; @ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string; @ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number; @ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string; @@ -64,6 +65,7 @@ export class CreateSegmentFareRuleDto { @ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string; @ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number; @ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality scope (Ethiopian, Djiboutian, Other)' }) @IsOptional() @IsString() nationality?: string; + @ApiPropertyOptional({ enum: PassengerCategory, example: 'ADULT', description: 'Passenger category: ADULT (5+ yrs) or CHILD (<5 yrs)' }) @IsOptional() @IsEnum(PassengerCategory) passengerCategory?: PassengerCategory; @ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string; @ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string; } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index af03937f8..f7ef5487f 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -329,50 +329,6 @@ export class SchedulesService { async deleteSchedule(id: string) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } }); if (!schedule) throw new NotFoundException('Schedule not found'); - - await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } }); - await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } }); - - const bookings = await this.prisma.booking.findMany({ - where: { scheduleId: id }, - select: { id: true }, - }); - const bookingIds = bookings.map(b => b.id); - - if (bookingIds.length > 0) { - const paymentIntents = await this.prisma.paymentIntent.findMany({ - where: { bookingId: { in: bookingIds } }, - select: { id: true }, - }); - const paymentIntentIds = paymentIntents.map(pi => pi.id); - - if (paymentIntentIds.length > 0) { - await this.prisma.paymentRefund.deleteMany({ - where: { paymentIntentId: { in: paymentIntentIds } }, - }); - } - - await this.prisma.ticket.deleteMany({ - where: { bookingId: { in: bookingIds } }, - }); - await this.prisma.bookingSeat.deleteMany({ - where: { bookingId: { in: bookingIds } }, - }); - await this.prisma.bookingModification.deleteMany({ - where: { bookingId: { in: bookingIds } }, - }); - await this.prisma.bookingCancellation.deleteMany({ - where: { bookingId: { in: bookingIds } }, - }); - await this.prisma.paymentIntent.deleteMany({ - where: { bookingId: { in: bookingIds } }, - }); - } - - await this.prisma.booking.deleteMany({ where: { scheduleId: id } }); - await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } }); - await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } }); - return this.prisma.trainSchedule.delete({ where: { id } }); } @@ -402,7 +358,7 @@ export class SchedulesService { } createFareRule(dto: CreateFareRuleDto) { - const { validFrom, validUntil, scheduleId, nationality, ...rest } = dto; + const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto; return this.prisma.fareRule.create({ data: { ...rest, @@ -415,7 +371,7 @@ export class SchedulesService { } createSegmentFareRule(dto: any) { - const { validFrom, validUntil, ...rest } = dto; + const { validFrom, validUntil, passengerCategory, ...rest } = dto; return this.prisma.segmentFareRule.create({ data: { ...rest, @@ -439,7 +395,7 @@ export class SchedulesService { } updateSegmentFareRule(id: string, dto: any) { - const { validFrom, validUntil, ...rest } = dto; + const { validFrom, validUntil, passengerCategory, ...rest } = dto; return this.prisma.segmentFareRule.update({ where: { id }, data: { @@ -451,12 +407,36 @@ export class SchedulesService { }); } + async getFareRules(scheduleId?: string) { + const where: any = {}; + if (scheduleId) where.tripId = scheduleId; + + return this.prisma.fareRule.findMany({ + where, + include: { seatClass: true }, + orderBy: { createdAt: 'desc' }, + }); + } + getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) { return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality); } - getAllFaresFromEngine(scheduleId: string, nationality?: string) { - return this.fareEngine.calculateAllForSchedule(scheduleId, nationality); + async getAllFaresFromEngine(scheduleId: string, nationality?: string) { + try { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { routeId: true, originStationId: true, destinationStationId: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route'); + + return await this.fareEngine.calculateAllForSchedule(scheduleId, nationality); + } catch (error) { + throw new BadRequestException( + error instanceof Error ? error.message : 'Failed to calculate fares for schedule' + ); + } } async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> { @@ -565,8 +545,13 @@ export class SchedulesService { }); } - if (dto.coaches && dto.coaches.length > 0) { - await this.assignCoaches(id, dto.coaches); + if (dto.coaches !== undefined) { + if (dto.coaches.length > 0) { + await this.assignCoaches(id, dto.coaches); + } else { + // Remove all coach assignments when empty array is sent + await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } }); + } } return this.getSchedule(id); diff --git a/apps/edr-passenger-api/src/modules/search/search.controller.ts b/apps/edr-passenger-api/src/modules/search/search.controller.ts index 8720a97fc..b32f2f291 100644 --- a/apps/edr-passenger-api/src/modules/search/search.controller.ts +++ b/apps/edr-passenger-api/src/modules/search/search.controller.ts @@ -11,17 +11,24 @@ export class SearchController { @Post() @ApiOperation({ summary: 'Search trips by origin, destination, date, passengers, and nationality', - description: `Finds all train schedules matching search criteria with real-time seat availability. + description: `Finds all train schedules matching search criteria with real-time seat availability and coach type options. +**Coach Type Selection Flow:** +- Users browse available coach types (Economy, VIP, etc.) +- Each coach type displays available seat classes and base fares +- Users select a coach type to proceed to seat selection +- At seat selection, users choose specific seat and class (actual price confirmed here) +- Final fare may adjust based on seat position/amenities selected + +**Features:** - Any origin→destination stop pair (not just terminals) - Age-based passenger counts (adults ≄5 years, children <5 years) - Nationality filtering (Ethiopian, Djiboutian, Other) - Real-time seat availability per class - Multi-currency fare display -- Example: Train A→B→C→D appears in results for A→B, A→C, A→D, B→C, B→D, C→D -- Availability: Segment-based (seat booked A→B is still available B→D)` +- Segment-based availability (seat booked A→B still available B→D)` }) - @ApiResponse({ status: 200, description: 'Matching schedules with segment-accurate seat availability per class' }) + @ApiResponse({ status: 200, description: 'Matching schedules with coachTypes array showing available coach types with seat classes and base fares' }) searchTrips(@Body() dto: SearchTripsDto) { return this.service.searchTrips(dto); } diff --git a/apps/edr-passenger-api/src/modules/search/search.dto.ts b/apps/edr-passenger-api/src/modules/search/search.dto.ts index b49c35259..b1d4c371f 100644 --- a/apps/edr-passenger-api/src/modules/search/search.dto.ts +++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts @@ -21,6 +21,12 @@ export class SearchTripsDto { @ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality: Ethiopian (Verifayda verification), Djiboutian (Waafi payment), Other (international payments)' }) @IsOptional() @IsString() nationality?: string; + + @ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP'], description: 'Journey type: ONE_WAY or ROUND_TRIP' }) + @IsOptional() @IsEnum(['ONE_WAY', 'ROUND_TRIP']) journeyType?: string; + + @ApiPropertyOptional({ example: '2026-06-20', description: 'Return date (YYYY-MM-DD) — required for ROUND_TRIP, must be after outbound date' }) + @IsOptional() @IsDateString() returnDate?: string; } export class FareQuoteDto { @@ -53,4 +59,39 @@ export class FareQuoteDto { @ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality for payment method filtering' }) @IsOptional() @IsString() nationality?: string; + + @ApiPropertyOptional({ example: 'schedule-uuid', description: 'Return schedule UUID (required for ROUND_TRIP journeys)' }) + @IsOptional() @IsString() returnScheduleId?: string; + + @ApiPropertyOptional({ example: 'station-uuid', description: 'Return origin station ID (required for ROUND_TRIP)' }) + @IsOptional() @IsString() returnOriginStationId?: string; + + @ApiPropertyOptional({ example: 'station-uuid', description: 'Return destination station ID (required for ROUND_TRIP)' }) + @IsOptional() @IsString() returnDestinationStationId?: string; +} + +export class CoachTypeOptionClass { + @ApiProperty({ example: 'Economy Regular', description: 'Seat class name' }) + name: string; + + @ApiProperty({ example: 35000, description: 'Base fare in ETB minor units per passenger' }) + baseFareMinor: number; +} + +export class CoachTypeOption { + @ApiProperty({ example: 'coach-type-uuid', description: 'Coach type unique identifier' }) + coachTypeId: string; + + @ApiProperty({ example: 'Economy', description: 'Coach type display name' }) + coachTypeName: string; + + @ApiProperty({ example: 'ECO', description: 'Coach type code' }) + coachTypeCode: string; + + @ApiProperty({ + type: 'array', + items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' }, + description: 'Available seat classes within this coach type with base fares. User selects specific class at seat selection page.', + }) + classes: CoachTypeOptionClass[]; } diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 29254892d..7d237c18b 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -18,15 +18,57 @@ export class SearchService { ) {} async searchTrips(dto: SearchTripsDto) { - const date = new Date(dto.date); - const nextDay = new Date(date.getTime() + 86_400_000); - const totalPassengers = dto.adultCount + (dto.childCount ?? 0); + const outbound = await this.searchSchedules( + dto.originStationId, + dto.destinationStationId, + dto.date, + dto.adultCount, + dto.childCount, + dto.nationality, + ); + + if (dto.journeyType === 'ROUND_TRIP') { + const allInbound = await this.searchSchedules( + dto.destinationStationId, + dto.originStationId, + dto.returnDate ?? dto.date, + dto.adultCount, + dto.childCount, + dto.nationality, + ); + + const latestOutboundArrival = outbound.length > 0 + ? Math.max(...outbound.map((s) => new Date(s.arrivalAt).getTime())) + : Date.now(); + + const inbound = allInbound.filter((schedule) => + new Date(schedule.departureAt).getTime() > latestOutboundArrival + ); + + return { journeyType: 'ROUND_TRIP', outbound, inbound }; + } + + return { journeyType: 'ONE_WAY', outbound }; + } + + private async searchSchedules( + originStationId: string, + destinationStationId: string, + dateStr: string, + adultCount: number, + childCount?: number, + nationality?: string, + ) { + const [y, m, d] = dateStr.split('-').map(Number); + const date = new Date(y, m - 1, d, 0, 0, 0, 0); + const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const totalPassengers = adultCount + (childCount ?? 0); const schedules = await this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, departureAt: { gte: date, lt: nextDay }, - stopTimes: { some: { stationId: dto.originStationId } }, + stopTimes: { some: { stationId: originStationId } }, }, include: { train: true, @@ -42,8 +84,8 @@ export class SearchService { const results = []; for (const schedule of schedules) { - const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId); - const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId); + const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId); + const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId); if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue; @@ -61,14 +103,14 @@ export class SearchService { if (seat.bedPosition !== bedPosition) continue; if (seat.status === 'BLOCKED') continue; if (!seat.seatNumber || !seat.seatNumber.trim()) continue; - + const free = await this.segmentsService.isSeatFreeForLeg( schedule.id, seat.id, originStop.sequence, destStop.sequence, ); if (free) count++; } - + if (count > 0) { const matchingClass = seatClassNames.find((className: string) => { const classNameLower = className.toLowerCase(); @@ -89,14 +131,14 @@ export class SearchService { for (const seat of assignment.coach.seats) { if (seat.status === 'BLOCKED') continue; if (!seat.seatNumber || !seat.seatNumber.trim()) continue; - + const free = await this.segmentsService.isSeatFreeForLeg( schedule.id, seat.id, originStop.sequence, destStop.sequence, ); if (free) availableSeatsInCoach++; } - + for (const seatClassName of seatClassNames) { if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0; availabilityByClass[seatClassName] += availableSeatsInCoach; @@ -109,11 +151,13 @@ export class SearchService { const faresByClass = await this.calculateFaresForSegment( schedule, - dto.originStationId, - dto.destinationStationId, - dto.nationality, + originStationId, + destinationStationId, + nationality, ); + const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass); + results.push({ scheduleId: schedule.id, trainNumber: schedule.train.number, @@ -150,6 +194,7 @@ export class SearchService { availabilityByClass, hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), faresByClass, + coachTypes, }); } @@ -258,14 +303,14 @@ export class SearchService { .filter((id: any) => id) ) ); - + if (seatClassIds.length === 0) { console.log(`No seat classes assigned to schedule ${schedule.id}`); return []; } const seatClasses = await this.prisma.seatClass.findMany({ - where: { + where: { isActive: true, id: { in: seatClassIds } }, @@ -307,7 +352,7 @@ export class SearchService { const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } }); const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } }); - + if (originStation && destStation) { const segmentRoute = `${originStation.code}-${destStation.code}`; const now = new Date(); @@ -341,6 +386,62 @@ export class SearchService { })); } + private async buildCoachTypeDetails( + schedule: any, + faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>, + ): Promise; + }>> { + const coachTypeMap = new Map< + string, + { coachType: any; classNames: Set } + >(); + + for (const assignment of schedule.coachAssignments) { + const coachType = assignment.coach.coachType; + if (!coachType) continue; + + if (!coachTypeMap.has(coachType.id)) { + coachTypeMap.set(coachType.id, { + coachType, + classNames: new Set(), + }); + } + + const entry = coachTypeMap.get(coachType.id)!; + coachType.seatClasses?.forEach((sc: any) => entry.classNames.add(sc.name)); + } + + const result = []; + for (const [, { coachType, classNames }] of coachTypeMap) { + const classes = Array.from(classNames) + .map((className) => { + const fareInfo = faresByClass.find((f) => f.seatClassName === className); + return { + name: className, + baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className), + }; + }) + .sort((a, b) => a.baseFareMinor - b.baseFareMinor); + + result.push({ + coachTypeId: coachType.id, + coachTypeName: coachType.name, + coachTypeCode: coachType.code, + classes, + }); + } + + return result.sort((a, b) => { + const minPriceA = Math.min(...a.classes.map((c) => c.baseFareMinor)); + const minPriceB = Math.min(...b.classes.map((c) => c.baseFareMinor)); + return minPriceA - minPriceB; + }); + } + private getDefaultFareForClass(className: string): number { const defaults: Record = { 'Economy Regular': 35000, diff --git a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts index bb301e315..9c864c307 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { StationsService } from './stations.service'; import { CreateStationDto } from './stations.dto'; import { JwtGuard } from '../../common/jwt.guard'; @@ -17,6 +17,28 @@ export class StationsController { @ApiQuery({ name: 'search', required: false, description: 'Search by station name or code' }) @ApiQuery({ name: 'country', required: false, description: 'Filter by country code (ET, DJ)' }) @ApiQuery({ name: 'operational', required: false, description: 'Filter by operational status (true, false)' }) + @ApiResponse({ + status: 200, + description: 'Array of stations', + schema: { + example: [ + { + id: '550e8400-e29b-41d4-a716-446655440000', + code: 'AAA', + sequence: 1, + name: 'Addis Ababa', + city: 'Addis Ababa', + countryCode: 'ET', + lat: 9.0054, + lng: 38.7636, + timezone: 'Africa/Addis_Ababa', + isOperational: true, + createdAt: '2024-01-15T10:30:00.000Z', + updatedAt: '2024-01-15T10:30:00.000Z' + } + ] + } + }) findAll( @Query('search') search?: string, @Query('country') country?: string, @@ -30,18 +52,79 @@ export class StationsController { summary: 'Get station details by ID', description: 'Returns station information including name, code, country, coordinates, and facilities' }) + @ApiResponse({ + status: 200, + description: 'Station details', + schema: { + example: { + id: '550e8400-e29b-41d4-a716-446655440000', + code: 'AAA', + sequence: 1, + name: 'Addis Ababa', + city: 'Addis Ababa', + countryCode: 'ET', + lat: 9.0054, + lng: 38.7636, + timezone: 'Africa/Addis_Ababa', + isOperational: true, + createdAt: '2024-01-15T10:30:00.000Z', + updatedAt: '2024-01-15T10:30:00.000Z' + } + } + }) findOne(@Param('id') id: string) { return this.service.findOne(id); } @Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create new station' }) + @ApiResponse({ + status: 201, + description: 'Station created', + schema: { + example: { + id: '550e8400-e29b-41d4-a716-446655440000', + code: 'AAA', + sequence: 1, + name: 'Addis Ababa', + city: 'Addis Ababa', + countryCode: 'ET', + lat: 9.0054, + lng: 38.7636, + timezone: 'Africa/Addis_Ababa', + isOperational: true, + createdAt: '2024-01-15T10:30:00.000Z', + updatedAt: '2024-01-15T10:30:00.000Z' + } + } + }) create(@Body() dto: CreateStationDto) { return this.service.create(dto); } @Patch(':id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update station' }) + @ApiResponse({ + status: 200, + description: 'Station updated', + schema: { + example: { + id: '550e8400-e29b-41d4-a716-446655440000', + code: 'AAA', + sequence: 1, + name: 'Addis Ababa', + city: 'Addis Ababa', + countryCode: 'ET', + lat: 9.0054, + lng: 38.7636, + timezone: 'Africa/Addis_Ababa', + isOperational: true, + createdAt: '2024-01-15T10:30:00.000Z', + updatedAt: '2024-01-15T10:30:00.000Z' + } + } + }) + @ApiResponse({ status: 404, description: 'Station not found' }) update(@Param('id') id: string, @Body() dto: Partial) { return this.service.update(id, dto); } @@ -50,6 +133,8 @@ export class StationsController { @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Delete station' }) + @ApiResponse({ status: 200, description: 'Station deleted successfully' }) + @ApiResponse({ status: 404, description: 'Station not found' }) remove(@Param('id') id: string) { return this.service.remove(id); } diff --git a/apps/edr-passenger-api/src/modules/stations/stations.dto.ts b/apps/edr-passenger-api/src/modules/stations/stations.dto.ts index 05973bfe4..fc350dbcf 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.dto.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNumber, IsOptional } from 'class-validator'; +import { IsString, IsNumber, IsOptional, IsInt, IsBoolean } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateStationDto { @@ -6,6 +6,9 @@ export class CreateStationDto { @ApiProperty({ example: 'Addis Ababa' }) @IsString() name: string; @ApiProperty({ example: 'Addis Ababa' }) @IsString() city: string; @ApiPropertyOptional() @IsOptional() @IsString() timezone?: string; + @ApiPropertyOptional() @IsOptional() @IsString() countryCode?: string; @ApiProperty({ example: 9.0054 }) @IsNumber() lat: number; @ApiProperty({ example: 38.7636 }) @IsNumber() lng: number; + @ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() sequence?: number; + @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isOperational?: boolean; } diff --git a/apps/edr-passenger-api/src/modules/stations/stations.service.ts b/apps/edr-passenger-api/src/modules/stations/stations.service.ts index 795d222e6..5dd1aa77d 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, Inject, Optional } from '@nestjs/common'; +import { Injectable, NotFoundException, Inject, Optional, BadRequestException } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { PrismaService } from '../../common/prisma.service'; import { AuditService } from '../../common/audit.service'; @@ -39,7 +39,7 @@ export class StationsService { return this.prisma.station.findMany({ where, - orderBy: { name: 'asc' } + orderBy: { sequence: 'asc' } }); } @@ -65,9 +65,15 @@ export class StationsService { async update(id: string, dto: Partial) { const oldStation = await this.findOne(id); + const { code, name, city, timezone, lat, lng } = dto; + const data: any = { code, name, city, timezone, lat, lng }; + if ('countryCode' in dto) data.countryCode = (dto as any).countryCode; + if ('sequence' in dto) data.sequence = (dto as any).sequence; + if ('isOperational' in dto) data.isOperational = (dto as any).isOperational; + const updatedStation = await this.prisma.station.update({ where: { id }, - data: dto, + data, }); await this.auditService.log({ diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index a6a3bc711..de9bdf4b5 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -49,12 +49,16 @@ export class TicketsService { booking: { bookingRef: t.booking.bookingRef, status: t.booking.status, + totalMinor: t.booking.totalMinor, + currency: t.booking.currency, + displayCurrency: t.booking.displayCurrency, + displayTotalMinor: t.booking.displayTotalMinor, passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail }, contactEmail: t.booking.contactEmail, }, schedule: t.booking.schedule, seat: t.booking.seats[0]?.seat, - status: t.booking.status, + status: t.status, validatedAt: t.validatedAt, createdAt: t.issuedAt, })), @@ -182,16 +186,29 @@ export class TicketsService { async getByRef(bookingRef: string) { const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, - include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + seats: { include: { seat: { include: { coach: true } } } }, + ticket: true + }, }); if (!booking?.ticket) throw new NotFoundException('Ticket not found'); const seat = booking.seats[0]; return { - id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status, - fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name, - departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name, - coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName, - priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload, + id: booking.ticket.id, + bookingId: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + fromStationName: booking.schedule.originStation.name, + toStationName: booking.schedule.destinationStation.name, + departureAt: booking.schedule.departureAt, + trainName: booking.schedule.train.name, + coachLabel: seat?.seat.coach.number, + seatLabel: seat?.seat.seatNumber, + passengerName: seat?.passengerName, + priceMinor: booking.totalMinor, + currency: booking.currency, + qrPayload: booking.ticket.qrPayload, barcodePayload: booking.ticket.barcodePayload }; } diff --git a/apps/edr-passenger-web/backoffice/.eslintrc.json b/apps/edr-passenger-web/backoffice/.eslintrc.json index 957cd1545..015e65d11 100644 --- a/apps/edr-passenger-web/backoffice/.eslintrc.json +++ b/apps/edr-passenger-web/backoffice/.eslintrc.json @@ -1,3 +1,6 @@ { - "extends": ["next/core-web-vitals"] + "extends": ["next/core-web-vitals"], + "rules": { + "react/no-unescaped-entities": "off" + } } diff --git a/apps/edr-passenger-web/backoffice/public/docs.md b/apps/edr-passenger-web/backoffice/public/docs.md new file mode 100644 index 000000000..3e8cc7df5 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/public/docs.md @@ -0,0 +1,2897 @@ +# Passenger Backoffice App - Comprehensive Documentation + +**Last Updated:** 2026-01-15 +**Version:** 1.0.0 +**Platform:** Ethio-Djibouti Railway (EDR) Passenger Management System + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Application Structure](#application-structure) +3. [Sidebar Navigation Guide](#sidebar-navigation-guide) +4. [Operations Management](#operations-management) +5. [Master Data Management](#master-data-management) +6. [Financial Management](#financial-management) +7. [Customer Services](#customer-services) +8. [Security & Compliance](#security--compliance) +9. [Analytics & Reports](#analytics--reports) +10. [System Administration](#system-administration) +11. [Common Features](#common-features) + +--- + +## Overview + +The Passenger Backoffice Application is a comprehensive management system for the Ethio-Djibouti Railway passenger platform. It provides tools for operational staff, supervisors, and administrators to manage bookings, passengers, fares, fleet, and compliance operations. + +### Key Features +- **Real-time Booking Management**: View, modify, and cancel bookings +- **Passenger Management**: Track and manage passenger information +- **Dynamic Pricing**: Configure fares with segment-based and nationality-specific pricing +- **Fleet Management**: Manage trains, coaches, and seats +- **Live Tracking**: Monitor trip status and real-time updates +- **Security Monitoring**: Fraud detection and audit logging +- **Comprehensive Analytics**: Revenue, occupancy, and performance reports + +### Supported Roles +- **Agent**: Counter booking and basic operations +- **Supervisor**: Agent oversight and operational decisions +- **Admin**: Full system access and configuration +- **Staff**: Limited access to specific modules + +--- + +## Application Structure + +### Sidebar Organization + +The application is organized into 8 main sections: + +``` +ā”œā”€ā”€ Overview +│ └── Dashboard +ā”œā”€ā”€ Operations +│ ā”œā”€ā”€ Bookings +│ ā”œā”€ā”€ Passengers +│ └── Tickets +ā”œā”€ā”€ Master Data +│ ā”œā”€ā”€ Stations +│ ā”œā”€ā”€ Trains +│ ā”œā”€ā”€ Coaches +│ ā”œā”€ā”€ Seats +│ ā”œā”€ā”€ Classes +│ ā”œā”€ā”€ Routes +│ └── Schedules +ā”œā”€ā”€ Financial +│ ā”œā”€ā”€ Pricing & Fares +│ ā”œā”€ā”€ Currencies +│ ā”œā”€ā”€ Payments +│ └── Promo Codes +ā”œā”€ā”€ Customer Services +│ ā”œā”€ā”€ Loyalty Program +│ ā”œā”€ā”€ Support Center +│ └── Notifications +ā”œā”€ā”€ Security & Compliance +│ ā”œā”€ā”€ Audit Logs +│ ā”œā”€ā”€ Fraud Detection +│ └── Verifayda Integration +ā”œā”€ā”€ Analytics & Reports +│ ā”œā”€ā”€ Reports +│ └── Operational Reports +└── System + ā”œā”€ā”€ Agent Operations + ā”œā”€ā”€ User Management + └── Settings +``` + +### Theme & Personalization + +- **Dark Mode Toggle**: Available in the header for reduced eye strain +- **Sidebar Collapse**: Click the chevron icon to minimize sidebar for more screen space +- **Responsive Design**: Fully responsive interface for desktop and tablet use +- **Accessible UI**: WCAG 2.1 AA compliant for accessibility + +--- + +## Sidebar Navigation Guide + +### Collapsible Sidebar + +**Feature**: Expand/Collapse Navigation +**Location**: Top-right corner of sidebar header + +**How to Use:** +1. Click the **Chevron** ( or >>) icon in the sidebar header +2. Sidebar collapses to icon-only view +3. Hover over icons to see tooltip labels +4. Click again to expand full sidebar + +**Benefits:** +- Maximize content viewing area +- Cleaner interface for focused work +- Quick navigation with tooltips + +--- + +## Operations Management + +### Bookings + +**Purpose**: Manage all passenger bookings, view details, modify, and process cancellations +**Access Level**: Agent, Supervisor, Admin +**Icon**: Ticket + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ BOOKINGS MANAGEMENT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ List & Filter │ +│ āœ“ Search by Reference │ +│ āœ“ View Full Details │ +│ āœ“ Cancel with Refunds │ +│ āœ“ Delete Records │ +│ āœ“ Export Data │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### CRUD Operations + +##### CREATE (Direct Booking Creation) +**Note**: Bookings are primarily created through the passenger portal. Backoffice staff use agent operations module for counter bookings. + +1. **Agent Counter Booking**: + - Navigate to **Agent Operations** (System section) + - Create booking through dedicated agent interface + - Specify passengers, seats, and payment method + +##### READ (List & Search) + +1. **Access Bookings Page**: + - Click **Bookings** in Operations section + - Page displays table with all bookings + +2. **Search Functionality**: + - **Search Box**: Filter by reference number, email, or phone + - **Status Filter**: Select from dropdown: + - All Status (default) + - Pending Payment + - Confirmed + - Cancelled + - Completed + - Results update in real-time + +3. **Table Columns**: + - **Reference**: Unique booking identifier (6-character code) + - **Passenger**: Name and contact info + - **Status**: Current booking state (badge color-coded) + - **Amount**: Total fare in ETB + - **Payment**: Payment status indicator + - **Created**: Booking date and time + +4. **View Full Details**: + - Click **"View Details"** action button + - Modal opens showing: + - Booking Information (Reference, Status, Type, Created Date) + - Passenger Information (Name, Email, Phone, ID) + - Journey Details (Adult/Child counts, Schedule, Promo Code) + - Payment Information (Amount, Status, Paid Date, Currency) + - Additional Information (Source, Last Updated) + +##### UPDATE (Modify Booking) + +**Current Limitations**: Direct modifications limited in backoffice. For booking changes: + +1. **Passenger-initiated Changes**: + - Direct passenger through passenger portal + - Support team can assist via Support Center + +2. **Admin Modifications** (if needed): + - Contact system administrator + - Modifications logged in Audit Logs + +##### DELETE (Remove Booking) + +1. **Access Delete**: + - Click **"Delete"** action button on booking row + - Confirmation dialog appears + +2. **Deletion Process**: + - Dialog shows booking reference + - Warning: "This will release all associated seats" + - Click **"Delete"** to confirm + - Seats automatically released back to availability + - Related records (modifications, cancellations) retained for audit + +3. **Undo**: Not available after deletion. Action is permanent. + +#### Additional Features + +**Pagination**: +- Navigate between pages at table bottom +- Default: 20 bookings per page +- Jump to specific page or use next/previous buttons + +**Bulk Actions**: +- Select multiple bookings via checkboxes (planned feature) +- Export selected or all bookings as CSV + +**Export**: +- Click **"Export"** button in header +- Downloads filtered bookings as spreadsheet +- Includes all visible columns + +**Status Management**: +- **Cancel Booking**: + - Available for non-completed/non-cancelled bookings + - Automatically processes refund (80% refund for confirmed, 0% for pending) + - Updates payment status + +--- + +### Passengers + +**Purpose**: Manage passenger profiles, view details, and track passenger information +**Access Level**: Agent, Supervisor, Admin +**Icon**: Users + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ PASSENGERS MANAGEMENT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ View Passenger Profiles │ +│ āœ“ Search & Filter │ +│ āœ“ Verifayda Status Check │ +│ āœ“ Booking History │ +│ āœ“ Loyalty Information │ +│ āœ“ Wallet Balance │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### CRUD Operations + +##### READ (List & Filter) + +1. **Access Passengers Page**: + - Click **Passengers** in Operations section + - Displays passenger listing with filters + +2. **Search Options**: + - **Search Box**: Filter by name, email, phone, or ID + - **Nationality Filter**: Ethiopian, Djiboutian, Other + - **Verifayda Status**: Verified, Unverified, All + - **Loyalty Tier**: Bronze, Silver, Gold, Platinum + +3. **Passenger Information Displayed**: + - Full Name + - Email & Phone + - Nationality + - Verifayda Verification Status + - Loyalty Tier + - Wallet Balance + - Total Bookings + - Registration Date + +##### VIEW DETAILS + +1. **Click Passenger Row**: + - Opens detailed profile modal + - Sections included: + - **Account Information**: Email, Phone, Nationality, Registration Date + - **Verification Status**: Fayd Status, Last Verified Date + - **Loyalty Information**: Tier, Points Balance, Lifetime Points + - **Wallet**: Current Balance, Currency + - **Booking History**: List of all bookings with links + +2. **Quick Actions**: + - View booking details + - Check loyalty rewards available + - View wallet transaction history + +#### UPDATE (Modify Passenger) + +**Current Status**: Read-only in backoffice +**To Modify**: Passengers update via their portal or contact support + +#### DELETE (Remove Passenger) + +**Not Recommended**: Deletes all associated data +**Alternative**: Deactivate account (contact admin) + +--- + +### Tickets + +**Purpose**: Manage ticket generation, distribution, and validation +**Access Level**: Supervisor, Admin +**Icon**: FileText + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ TICKETS MANAGEMENT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ View All Tickets │ +│ āœ“ Search by Reference │ +│ āœ“ Check Validation Status │ +│ āœ“ Resend Tickets │ +│ āœ“ Generate Report │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### CRUD Operations + +##### READ (List & View) + +1. **Access Tickets Page**: + - Click **Tickets** in Operations section + - Shows all issued tickets + +2. **Search & Filter**: + - **Booking Reference**: Find tickets by booking + - **Status**: Confirmed, Validated, Cancelled + - **Date Range**: Filter by issue or validation date + - **Passenger Name**: Quick search by name + +3. **Ticket Information**: + - Booking Reference + - Passenger Name + - QR Code / Barcode + - Trip Details (Train, Stations, Times) + - Seat Information + - Issue Date + - Validation Status + +##### VIEW FULL TICKET + +1. **Click View Button**: + - Opens ticket details modal + - Shows: + - QR/Barcode payload + - Full passenger manifest + - Seat assignments + - Fare breakdown + - Payment confirmation + +2. **Download/Print**: + - Generate PDF for printing + - Send to passenger email + - Save to system + +##### VALIDATION STATUS + +1. **Gate Validation**: + - Unvalidated: Ticket not yet scanned at gate + - Validated: Scanned and approved for boarding + - Cancelled: Ticket cancelled or expired + +2. **Validation History**: + - View gate validation logs + - See timestamp and validator ID + - Track validation attempts + +--- + +## Master Data Management + +### Stations + +**Purpose**: Configure railway stations and maintain station information +**Access Level**: Supervisor, Admin +**Icon**: MapPin + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ STATIONS MANAGEMENT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ Add New Stations │ +│ āœ“ Edit Station Details │ +│ āœ“ Manage Operational Status │ +│ āœ“ Delete Stations │ +│ āœ“ Bulk Import │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Station Information + +Each station includes: +- **Code**: Unique 3-letter airport-style code (e.g., ADD, DJI) +- **Name**: Full station name +- **City**: Location city +- **Country Code**: Country identifier (ET, DJ) +- **Operational Status**: Active/Inactive +- **Timezone**: Local timezone +- **Coordinates**: Latitude/Longitude for mapping + +#### CRUD Operations + +##### CREATE + +1. **Click "Add Station"** button +2. **Fill Form**: + - **Code** (required): 3-letter unique code + - **Name** (required): Station name + - **City** (required): City location + - **Country Code**: Country identifier + - **Latitude**: Geographic coordinate + - **Longitude**: Geographic coordinate + - **Timezone**: Select from list + - **Operational Status**: Toggle active/inactive +3. **Save**: Click "Create Station" +4. **Confirmation**: Station appears in list + +##### READ + +1. **View Station List**: + - All stations displayed in table + - Search by code, name, or city + - Filter by operational status + +2. **Columns**: + - Code + - Name + - City + - Country + - Operational Status (badge) + - Creation Date + +##### UPDATE + +1. **Click "Edit"** on station row +2. **Modify Fields**: + - All fields editable + - Changes reflected immediately +3. **Save**: Click "Update Station" +4. **Audit**: Changes logged + +##### DELETE + +1. **Click "Delete"** on station row +2. **Confirmation**: Dialog warns about: + - Routes using this station + - Schedules affected + - Passenger trips dependent +3. **Confirm**: Only with explicit consent + +--- + +### Trains + +**Purpose**: Manage fleet of trains and their configurations +**Access Level**: Supervisor, Admin +**Icon**: Train + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ TRAINS MANAGEMENT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ Add New Trains │ +│ āœ“ Edit Train Details │ +│ āœ“ Manage Coaches │ +│ āœ“ Track Status │ +│ āœ“ Archive Trains │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Train Information + +Each train includes: +- **Number**: Unique train identifier (e.g., T-001) +- **Name**: Display name +- **Operator**: Operating company +- **Description**: Train details/notes +- **Status**: Active/Inactive +- **Total Coaches**: Count of attached coaches + +#### CRUD Operations + +##### CREATE + +1. **Click "Add Train"** button +2. **Fill Form**: + - **Number** (required): Unique identifier + - **Name** (required): Display name + - **Operator** (required): Operating company + - **Description**: Optional notes + - **Status**: Toggle Active/Inactive +3. **Save**: Click "Create Train" +4. **Next Step**: Assign coaches to train + +##### READ + +1. **View Train List**: + - Table shows all trains + - Filter by status + - Search by number or name + +2. **Columns**: + - Train Number + - Name + - Operator + - Status (badge) + - Total Coaches + - Active Status + +##### UPDATE + +1. **Click "Edit"** on train row +2. **Modify Details**: + - Update name, operator, description + - Change status +3. **Coach Management**: + - Add coaches to train + - Remove coaches + - Adjust coach sequence +4. **Save**: Click "Update Train" + +##### DELETE + +1. **Click "Delete"** on train +2. **Warning**: Shows: + - Schedules using this train + - Active bookings affected +3. **Confirm**: Only deletable if no active schedules + +--- + +### Coaches + +**Purpose**: Manage coach inventory and seat configurations +**Access Level**: Supervisor, Admin +**Icon**: Grid3x3 + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ COACHES MANAGEMENT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ Add New Coaches │ +│ āœ“ Configure Seat Layout │ +│ āœ“ Set Coach Type │ +│ āœ“ Manage Maintenance │ +│ āœ“ Bulk Import Configs │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Coach Information + +- **Number**: Coach identifier (e.g., C-001) +- **Coach Type**: Type selector (Standard, Sleeper, etc.) +- **Arrangement**: Seat layout (2+2, 3+2, etc.) +- **Capacity**: Total seats/beds +- **Status**: Active/Maintenance/Inactive +- **Seat Classes**: Associated seat classes + +#### CRUD Operations + +##### CREATE + +1. **Click "Add Coach"** button +2. **Fill Form**: + - **Number** (required): Coach ID + - **Coach Type** (required): Select from types + - **Arrangement**: Seat layout pattern + - **Capacity** (required): Total seats + - **Status**: Active/Maintenance/Inactive +3. **Seat Configuration**: + - Auto-generate seats based on arrangement + - Or manually configure seat map +4. **Save**: Click "Create Coach" + +##### READ + +1. **View Coach List**: + - Table shows all coaches + - Filter by status, type + - Search by number + +2. **Columns**: + - Coach Number + - Type + - Arrangement + - Capacity + - Status Badge + - Assigned Train + +##### UPDATE + +1. **Click "Edit"** on coach +2. **Modify**: + - Update arrangement (limited if seats occupied) + - Change status + - Update capacity (data migration needed) +3. **Seat Management**: + - Add/remove individual seats + - Update seat properties (window, aisle, bed position) +4. **Save**: Click "Update Coach" + +##### DELETE + +1. **Click "Delete"** on coach +2. **Checks**: + - Scheduled trips using coach + - Active bookings on seats + - Maintenance records +3. **Confirm**: If no conflicts + +--- + +### Seats + +**Purpose**: Manage individual seat inventory and properties +**Access Level**: Supervisor, Admin +**Icon**: Armchair + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ SEATS MANAGEMENT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ View Seat Maps │ +│ āœ“ Update Seat Properties │ +│ āœ“ Block/Unblock Seats │ +│ āœ“ Bulk Operations │ +│ āœ“ Inventory Report │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Seat Properties + +- **Seat Number**: Position identifier +- **Row/Column**: Grid coordinates +- **Kind**: Standard, Premium, Accessible +- **Type**: Regular or Bed (lower/middle/upper) +- **Status**: Available, Held, Booked, Blocked +- **Premium Fee**: Extra charge (in ETB) +- **Properties**: Window, Aisle, Bed Position + +#### CRUD Operations + +##### READ + +1. **View Seat Maps**: + - Select coach from dropdown + - Visual grid shows all seats + - Color-coded by status: + - Green: Available + - Yellow: Held + - Blue: Booked + - Red: Blocked + +2. **Seat Details**: + - Click seat to view properties + - Shows occupancy history + - Displays current booking (if occupied) + +3. **Filters**: + - By coach + - By status + - By kind (Premium, Accessible, etc.) + +##### UPDATE + +1. **Bulk Seat Updates**: + - Select multiple seats + - Change properties: + - Status (block/unblock) + - Kind (upgrade/downgrade) + - Premium fee +2. **Individual Updates**: + - Click seat and edit + - Update window/aisle designation + - Modify bed position + +##### BLOCK/UNBLOCK + +1. **Block Seat**: + - Click "Block" action + - Reason dropdown: + - Maintenance + - Reserved + - Damaged + - Other + - Until date (optional) + - Reason notes + +2. **Unblock Seat**: + - Click "Unblock" action + - Seat becomes available + +##### SPECIAL OPERATIONS + +**CSV Import**: +- Upload CSV with seat configurations +- Format: CoachID, Row, Column, Kind, etc. +- Bulk creates/updates seats + +**CSV Export**: +- Export seat map as CSV +- Includes all properties +- For backup or analysis + +--- + +### Classes (Seat Classes) + +**Purpose**: Define and manage seat class types and pricing tiers +**Access Level**: Supervisor, Admin +**Icon**: Settings + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ SEAT CLASSES MANAGEMENT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ Create Class Types │ +│ āœ“ Set Base Fares │ +│ āœ“ Define Fees │ +│ āœ“ Manage Availability │ +│ āœ“ Link Coaches │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Seat Class Structure + +- **Name**: Class identifier (e.g., "Economy Regular") +- **Coach Type**: Associated coach type +- **Base Fare**: Per-km rate (in ETB cents) +- **Premium Fee**: Flat fee per passenger (in ETB cents) +- **Insurance Fee**: Flat fee per passenger (in ETB cents) +- **Active Status**: Available for booking + +#### CRUD Operations + +##### CREATE + +1. **Click "Add Class"** button +2. **Fill Form**: + - **Name** (required): Unique class name + - **Coach Type** (required): Select type + - **Base Fare (ETB)** (required): Per-km rate + - **Premium Fee (ETB)**: Flat fee per passenger + - **Insurance Fee (ETB)**: Per passenger coverage fee + - **Active**: Toggle to enable/disable +3. **Save**: Click "Create Class" + +**Example**: +``` +Name: "Economy Regular" +Coach Type: "Passenger Coach" +Base Fare: 350 ETB (for full journey) +Premium Fee: 0 ETB +Insurance Fee: 5 ETB (per passenger) +Active: Yes +``` + +##### READ + +1. **View Classes**: + - Table shows all seat classes + - Filter by coach type + - Search by name + +2. **Columns**: + - Class Name + - Coach Type + - Base Fare (ETB) + - Premium Fee (ETB) + - Insurance Fee (ETB) + - Active Status + - Total Seats (across all coaches) + +##### UPDATE + +1. **Click "Edit"** on class +2. **Modify**: + - Update name (if not in use) + - Adjust base fare + - Update premium/insurance fees + - Toggle active status +3. **Save**: Click "Update Class" +4. **Impact**: Changes apply to new bookings only + +##### DELETE + +1. **Click "Delete"** on class +2. **Checks**: + - Bookings using this class + - Fare rules referencing it + - Seats assigned to it +3. **Confirm**: Only if minimal impact + +#### Pricing Examples + +**Economy Regular (Standard comfort)** +- Base: 350 ETB +- Premium: 0 ETB +- Insurance: 5 ETB +- Total per Adult: 355 ETB + +**Economy Bed (Sleeper comfort)** +- Base: 490 ETB +- Premium: 50 ETB +- Insurance: 10 ETB +- Total per Adult: 550 ETB + +**VIP Bed (Premium sleeper)** +- Base: 630 ETB +- Premium: 150 ETB +- Insurance: 15 ETB +- Total per Adult: 795 ETB + +--- + +### Routes + +**Purpose**: Define railway routes with ordered station stops +**Access Level**: Supervisor, Admin +**Icon**: Route + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ ROUTES MANAGEMENT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ Create Routes │ +│ āœ“ Add Stops │ +│ āœ“ Set Stop Distances │ +│ āœ“ Configure Fare Rules │ +│ āœ“ Manage Routing │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Route Information + +- **Code**: Route identifier (e.g., "ADD-DJI") +- **Name**: Route description +- **Stops**: Ordered list of stations +- **Distance**: Total route distance +- **Effective Date**: Start date +- **Active Status**: Available for scheduling + +#### CRUD Operations + +##### CREATE + +1. **Click "Add Route"** button +2. **Fill Form**: + - **Code** (required): Route code + - **Name** (required): Route name + - **Effective From**: Start date + - **Effective Until**: End date (optional) + - **Active**: Toggle status +3. **Add Stops**: + - Click "Add Stop" + - Select station from dropdown + - Sequence auto-assigned or manual + - Enter distance from previous stop +4. **Save**: Click "Create Route" + +##### READ + +1. **View Routes**: + - Table shows all routes + - Filter by status + - Search by code or name + +2. **Route Details**: + - Click route to expand + - Shows: + - All stops in sequence + - Cumulative distance + - Distance between stops + - Fare rules for route + +3. **Columns**: + - Code + - Name + - Total Stops + - Total Distance + - Status Badge + - Active Status + +##### UPDATE + +1. **Click "Edit"** on route +2. **Modify Route**: + - Update name or description + - Change effective dates + - Toggle active status +3. **Manage Stops**: + - Add new stops + - Remove stops (if no bookings) + - Reorder stops (drag-and-drop) + - Update distances +4. **Save**: Click "Update Route" + +##### DELETE + +1. **Click "Delete"** on route +2. **Checks**: + - Active schedules using route + - Bookings on those schedules +3. **Confirm**: If no conflicts + +#### Route Example + +``` +Code: ADD-DJI +Name: Addis Ababa to Djibouti Main Line +Stops: + 1. Addis Ababa (ADD) - 0 km + 2. Adama (ADA) - 100 km + 3. Awash (AWS) - 50 km + 4. Dire Dawa (DDA) - 80 km + 5. Harar (HAR) - 100 km + 6. Djibouti (DJI) - 280 km + +Total Distance: 610 km +``` + +--- + +### Schedules + +**Purpose**: Create and manage train schedules for specific routes +**Access Level**: Supervisor, Admin +**Icon**: Calendar + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ SCHEDULES MANAGEMENT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ Create Individual Schedule │ +│ āœ“ Bulk Generate Schedules │ +│ āœ“ Edit Times & Assignments │ +│ āœ“ Manage Coach Assignments │ +│ āœ“ View Fare Breakdown │ +│ āœ“ Delete Schedules │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Schedule Information + +- **Train**: Associated train +- **Route**: Assigned route +- **Departure**: Date and time +- **Arrival**: Date and time +- **Duration**: Calculated in minutes +- **Status**: Scheduled, Boarding, En Route, Arrived, Cancelled +- **Coaches**: Assigned coaches with positions + +#### CRUD Operations + +##### CREATE - Single Schedule + +1. **Click "Create Schedule"** button +2. **Fill Form**: + - **Train** (required): Select train + - **Route** (required): Select route + - **Departure** (required): Date and time + - **Arrival** (required): Date and time + - **Status**: Scheduled (default) +3. **Assign Coaches**: + - Select coaches from list + - Checkboxes for multi-select + - Order matters (Position Number assigned) +4. **Save**: Click "Create Schedule" + +##### CREATE - Bulk Generate + +1. **Click "Bulk Generate"** button +2. **Configuration**: + - **Train** (required): Select train + - **Route** (required): Select route + - **Start Date & Time** (required): First departure + - **Duration (Hours)**: Trip length (default: 12) + - **Repeat Every (Days)**: Schedule frequency (default: 1) + - **For Next (Days)**: Generation period (default: 30) + - **Coaches** (optional): Pre-select coaches +3. **Preview**: + - Shows calculated number of schedules + - Example: 30 days Ć· 1 day = ~30 schedules +4. **Generate**: Click "Generate Schedules" + +**Example**: +``` +Train: Ethio Express +Route: ADD-DJI (610 km) +Start: 2026-06-20 08:00 +Duration: 12 hours +Repeat: Every 1 day +For: 30 days +Result: 30 daily schedules from June 20-July 19 +``` + +##### READ + +1. **View Schedules**: + - Table shows all schedules + - Search by train, station, status + - Filter by date, route, train + +2. **Schedule Details**: + - Train name and number + - From/To stations + - Departure/Arrival times + - Coach assignments + - Current status (badge) + +3. **Columns**: + - Train + - From + - To + - Departure + - Arrival + - Coaches Count + - Status Badge + +##### UPDATE + +1. **Click "Edit"** on schedule +2. **Modify**: + - **Departure/Arrival Times**: Adjust times + - **Status**: Change to Boarding, En Route, Arrived, Cancelled + - **Coach Assignment**: Add/remove coaches +3. **Validation**: + - Arrival must be after departure + - Coach conflicts checked +4. **Save**: Click "Update Schedule" + +##### DELETE + +1. **Click "Delete"** on schedule +2. **Warning**: Shows + - Active bookings affected + - Seats will be released + - Cannot be undone +3. **Confirm**: Click "Delete" to proceed +4. **Cascade**: Automatically deletes: + - Associated seat holds + - Trip live status records + +**Bulk Delete**: +1. **Select Multiple** schedules via checkboxes +2. **Click "Delete [N] Schedules"** +3. **Confirm**: Warning for bulk action +4. **Process**: All selected deleted with cascade + +#### Viewing Fares + +1. **In Schedule Row**: + - Shows calculated fares per seat class + - Displayed inline if space available + +2. **Detailed Fare View**: + - Click schedule to expand + - Shows: + - All seat classes + - Base fare per class + - Premium/Insurance fees + - Total per passenger + +--- + +## Financial Management + +### Pricing & Fares + +**Purpose**: Manage complex pricing with route segments and nationality support +**Access Level**: Admin, Supervisor +**Icon**: DollarSign + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ PRICING & FARES MGMT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ View Schedule Fares │ +│ āœ“ Create Segment Fares │ +│ āœ“ Edit Fare Rules │ +│ āœ“ Delete Fare Rules │ +│ āœ“ Nationality Override │ +│ āœ“ Passenger Type Pricing │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Pricing Structure + +**Fare Components**: +1. **Base Fare**: Per-km rate Ɨ distance +2. **Premium Fee**: Flat fee per passenger (e.g., 50 ETB) +3. **Insurance Fee**: Flat fee per passenger (e.g., 5 ETB) +4. **Total Fare**: Base + Premium + Insurance + +**Passenger Categories**: +- **ADULT** (5+ years): Pays 100% of fare +- **CHILD** (<5 years): First child FREE, subsequent pay 100% + +#### CRUD Operations - Schedule Fares + +##### VIEW SCHEDULE FARES + +1. **Select Schedule**: + - Dropdown to choose schedule + - Shows train, route, date + +2. **View Fares**: + - Table shows calculated fares + - "Dynamically calculated" disclaimer + - Includes all active seat classes + +3. **Columns**: + - Seat Class + - Passenger Type (All/ADULT/CHILD) + - Fare (ETB) + - Nationality (All/Specific) + - Route + - Valid From/Until + +##### ADD OVERRIDE FARE + +1. **Click "Add Fare Rule"** button +2. **Fill Form**: + - **Schedule** (optional): Leave empty for global + - **Route Code** (optional): e.g., "ADD-DJI" + - **Seat Class** (required): Select from list + - **Fare (ETB)** (required): Price in Ethiopian Birr + - **Passenger Type** (optional): ADULT or CHILD + - **Nationality** (optional): Ethiopian, Djiboutian, Other + - **Valid From** (required): Start date + - **Valid Until** (optional): End date +3. **Save**: Click "Save Fare Rule" + +**Example Override**: +``` +Seat Class: VIP Bed +Base Fare: 630 ETB (for full route) +Passenger Type: ADULT +Nationality: All +Valid From: 2026-06-01 +Valid Until: 2026-08-31 +Purpose: High season pricing +``` + +##### EDIT FARE RULE + +1. **Click "Edit"** on fare row +2. **Modify Fields**: + - Update fare amount + - Change dates + - Adjust nationality/type filters +3. **Save**: Click "Update Fare Rule" + +##### DELETE FARE RULE + +1. **Click "Delete"** on fare row +2. **Confirm**: Click "Delete" in dialog +3. **Impact**: Removed immediately for new bookings + +--- + +#### CRUD Operations - Segment Fares + +Segment fares allow different pricing for different route segments. + +##### VIEW SEGMENT FARES + +1. **Select Route**: + - Dropdown to choose route + - Shows route code and name + - Displays all stops in sequence + +2. **View Fares**: + - Table shows segment fare rules + - Organized by origin/destination stops + +3. **Columns**: + - Segment (Stop sequence → Sequence) + - Seat Class + - Passenger Type + - Fare (ETB) + - Nationality + - Valid From/Until + +##### CREATE SEGMENT FARE + +1. **Click "Add Fare Rule"** button +2. **Tab**: Switch to "Segment Fares" +3. **Fill Form**: + - **Origin Station** (required): From station dropdown + - **Destination Station** (required): To station dropdown + - **Seat Class** (required): Select class + - **Fare (ETB)** (required): Segment price + - **Passenger Type** (optional): ADULT or CHILD + - **Nationality** (optional): Specific nationality + - **Valid From** (required): Effective date + - **Valid Until** (optional): End date +4. **Validation**: + - Destination must be after origin + - Stations must be on route +5. **Save**: Click "Save Segment Fare Rule" + +**Example Segment Fares**: +``` +Route: ADD-DJI (5 stops) + +Segment 1: ADD → ADA (100 km) + Economy: 150 ETB + VIP: 300 ETB + +Segment 2: ADA → DDA (130 km) + Economy: 200 ETB + VIP: 400 ETB + +Segment 3: DDA → DJI (280 km) + Economy: 250 ETB + VIP: 500 ETB +``` + +##### UPDATE SEGMENT FARE + +1. **Click "Edit"** on segment row +2. **Modify**: + - Change stations (if no bookings) + - Update fare + - Adjust dates +3. **Save**: Click "Update Segment Fare Rule" + +##### DELETE SEGMENT FARE + +1. **Click "Delete"** on segment row +2. **Confirm**: Delete dialog +3. **Removed**: Immediately applied + +#### Pricing Priority + +When calculating fares, system checks in this order: + +``` +1. Segment Fare (nationality-specific if exists) +2. Segment Fare (generic for segment) +3. Schedule Fare (nationality-specific if exists) +4. Schedule Fare (generic for schedule) +5. Default Fare (350 ETB) +``` + +--- + +### Currencies + +**Purpose**: Manage currency exchange rates for multi-currency display +**Access Level**: Admin +**Icon**: Banknote + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ CURRENCIES MANAGEMENT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ View Exchange Rates │ +│ āœ“ Create New Rates │ +│ āœ“ Edit Rates │ +│ āœ“ Delete Rates │ +│ āœ“ Sync from API │ +│ āœ“ Set Effective Dates │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Supported Currencies + +| Code | Currency | Symbol | Type | +|------|----------|--------|------| +| ETB | Ethiopian Birr | į‰„įˆ­ | Transaction (base) | +| DJF | Djiboutian Franc | Fdj | Display | +| USD | US Dollar | $ | Display | + +#### Currency Information + +- **From Currency**: Source (usually ETB) +- **To Currency**: Target (DJF, USD, etc.) +- **Rate**: Exchange multiplier (e.g., 1 ETB = 0.018 USD) +- **Effective Date**: When rate takes effect +- **Source**: Manual or API + +#### CRUD Operations + +##### READ (List Exchange Rates) + +1. **Access Currencies Page**: + - Click **Currencies** in Financial section + - Shows all active exchange rates + +2. **Table Columns**: + - From Currency + - To Currency + - Exchange Rate + - Effective Date + - Source (Manual/API) + - Last Updated + +3. **View Details**: + - Hover rate to see precision + - Historical rates available + +##### CREATE + +1. **Click "Add Currency Rate"** button +2. **Fill Form**: + - **From Currency** (required): ETB (usually) + - **To Currency** (required): DJF or USD + - **Exchange Rate** (required): Decimal value + - **Effective Date** (required): Date to apply + - **Source**: Manual (default) or API +3. **Save**: Click "Create Rate" + +**Example**: +``` +From: ETB +To: USD +Rate: 0.018 +Effective: 2026-06-15 +Source: Manual (updated daily) +``` + +##### UPDATE + +1. **Click "Edit"** on exchange rate +2. **Modify**: + - Update rate value + - Change effective date + - Update source +3. **Save**: Click "Update Rate" +4. **Impact**: Applies to future bookings/display + +##### DELETE + +1. **Click "Delete"** on rate +2. **Confirm**: Dialog confirmation +3. **Impact**: Next rate in history used + +#### Rate Conversion Example + +**For a 3,500 ETB booking, display in different currencies**: + +- **ETB**: 3,500 (1:1) +- **DJF**: 11,375 (1:3.25 rate) +- **USD**: 63 (1:0.018 rate) + +--- + +### Payments + +**Purpose**: Monitor payment transactions and handle refunds +**Access Level**: Supervisor, Admin +**Icon**: CreditCard + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ PAYMENTS MANAGEMENT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ View All Payments │ +│ āœ“ Track Payment Status │ +│ āœ“ Process Refunds │ +│ āœ“ View Webhooks │ +│ āœ“ Transaction History │ +│ āœ“ Failed Payment Handling │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Payment Methods + +Supported payment providers: +- **Telebirr**: Mobile money (Ethiopia) +- **CBE Birr**: Commercial Bank (Ethiopia) +- **eBirr**: E-wallet (Ethiopia) +- **Card**: Credit/Debit cards (VISA, Mastercard) +- **Wallet**: Internal EDR wallet +- **WAAFI**: Money transfer service + +#### Payment Statuses + +- **Requires Action**: Awaiting customer input +- **Processing**: Payment being processed +- **Succeeded**: Payment completed +- **Failed**: Payment declined +- **Cancelled**: Payment cancelled by user +- **Refunded**: Payment refunded to customer + +#### CRUD Operations + +##### READ (List Payments) + +1. **Access Payments Page**: + - Click **Payments** in Financial section + - Shows all payment transactions + +2. **Search & Filter**: + - **Search Box**: Booking reference, transaction ID + - **Status Filter**: Succeeded, Failed, Processing, Refunded + - **Method Filter**: Payment provider + - **Date Range**: Filter by transaction date + +3. **Payment Information**: + - Booking Reference + - Payment Method + - Amount (ETB) + - Status (badge) + - Transaction ID + - Date & Time + +##### VIEW DETAILS + +1. **Click Payment Row**: + - Opens transaction detail modal + - Shows: + - Payment Intent ID + - Booking Information + - Amount & Currency + - Method & Provider + - Provider Transaction ID + - Status & Timeline + - Webhook History + +##### PROCESS REFUND + +1. **On Failed/Completed Payment**: + - Click "Process Refund" action + - Dialog opens for confirmation + +2. **Refund Form**: + - **Amount**: Pre-filled or custom + - **Reason**: Dropdown (Cancellation, Adjustment, Error, etc.) + - **Notes**: Optional explanation +3. **Process**: Click "Process Refund" +4. **Confirmation**: Shows refund processing + +**Refund Status**: +- **Pending**: Awaiting processor +- **Processing**: In transit +- **Completed**: Credited to customer +- **Failed**: Retry or manual intervention + +##### WEBHOOK MANAGEMENT + +1. **View Webhooks**: + - Click "Webhook History" tab + - Shows payment provider callbacks + +2. **Webhook Details**: + - Event timestamp + - Webhook payload + - Processing status + - Error details (if failed) + +--- + +### Promo Codes + +**Purpose**: Create and manage promotional discount codes +**Access Level**: Admin, Supervisor +**Icon**: Gift + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ PROMO CODES MANAGEMENT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ Create Promo Codes │ +│ āœ“ Set Discount Types │ +│ āœ“ Configure Validity │ +│ āœ“ Edit Codes │ +│ āœ“ Deactivate Codes │ +│ āœ“ Track Usage │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Promo Code Information + +- **Code**: Unique promotional code (e.g., "SUMMER20") +- **Discount Type**: Percentage or fixed amount +- **Value**: Discount percentage (%) or ETB amount +- **Valid Until**: Expiration date +- **Active Status**: Available for use +- **CTA Label**: Button text (optional) + +#### CRUD Operations + +##### CREATE + +1. **Click "Add Promo Code"** button +2. **Fill Form**: + - **Code** (required): Unique code (uppercase) + - **Title**: Display title + - **Subtitle**: Promotional message + - **Discount Type** (required): Percentage or Amount + - **Value** (required): Discount % or ETB amount + - **Valid Until** (required): Expiration date + - **CTA Label** (optional): Button text + - **Deep Link** (optional): App link + - **Active**: Toggle status +3. **Save**: Click "Create Promo Code" + +**Example**: +``` +Code: SUMMER20 +Title: Summer Getaway +Discount Type: Percentage +Value: 20 +Valid Until: 2026-08-31 +Active: Yes +``` + +##### READ (List Codes) + +1. **View Promo Codes**: + - Table shows all promo codes + - Filter by status (Active/Inactive) + - Search by code + +2. **Columns**: + - Code + - Title + - Discount (% or ETB) + - Valid Until + - Status Badge + - Total Uses + - Savings Generated + +##### UPDATE + +1. **Click "Edit"** on promo code +2. **Modify**: + - Update title/subtitle + - Change discount value + - Extend/shorten validity + - Toggle active status +3. **Save**: Click "Update Promo Code" + +##### DELETE/DEACTIVATE + +1. **Click "Delete"** on code +2. **Options**: + - **Archive**: Keep for audit, disable for new bookings + - **Delete**: Remove completely +3. **Confirm**: Dialog confirmation +4. **Impact**: Already used bookings keep discount + +#### Usage Tracking + +1. **View Code Usage**: + - Click code to expand + - Shows: + - Total times used + - Total discount dispensed + - Recent applications + +2. **Analytics**: + - Revenue impact + - Passenger uptake + - Peak usage periods + +--- + +## Customer Services + +### Loyalty Program + +**Purpose**: Manage passenger loyalty tiers and rewards +**Access Level**: Agent, Supervisor, Admin +**Icon**: Gift + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ LOYALTY PROGRAM MANAGEMENT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ View Loyalty Accounts │ +│ āœ“ Check Points Balance │ +│ āœ“ Manage Tier Status │ +│ āœ“ Adjust Points │ +│ āœ“ Manage Rewards │ +│ āœ“ View History │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Loyalty Tiers + +| Tier | Points Required | Benefits | +|------|-----------------|----------| +| **BRONZE** | 0-999 | Standard benefits | +| **SILVER** | 1,000-2,999 | +5% points bonus | +| **GOLD** | 3,000-4,999 | +10% points bonus | +| **PLATINUM** | 5,000+ | +15% points bonus, Priority support | + +#### Points Earning + +- **Per Booking**: 1 point per 100 ETB spent +- **Bonus**: Tier multiplier (5-15%) +- **Promotions**: Additional bonus campaigns +- **Expiry**: Annual expiration if inactive + +#### CRUD Operations + +##### READ (View Accounts) + +1. **Access Loyalty Page**: + - Click **Loyalty Program** in Customer Services + - Shows all passenger loyalty accounts + +2. **Search & Filter**: + - **Search**: Passenger name or email + - **Tier Filter**: BRONZE, SILVER, GOLD, PLATINUM + - **Sort**: Points balance, tier status, activity + +3. **Account Information**: + - Passenger Name + - Current Tier (badge) + - Points Balance + - Lifetime Points + - Last Activity + - Member Since + +##### VIEW DETAILS + +1. **Click Account Row**: + - Opens loyalty detail modal + - Shows: + - Account information + - Points balance breakdown + - Tier history + - Redemption history + - Available rewards + +2. **Points Breakdown**: + - Current balance + - Pending expiry points + - Tier multiplier applied + +##### UPDATE ACCOUNT + +1. **Manual Point Adjustment** (Admin only): + - Click "Adjust Points" on account + - Dialog opens + - Enter points to add/subtract + - Reason dropdown (Bonus, Correction, Promotion, etc.) + - Click "Apply" + +2. **Tier Management**: + - System auto-promotes/demotes based on points + - Manual override available (Admin) + +##### MANAGE REWARDS + +1. **View Available Rewards**: + - Shows reward catalog + - Points cost per reward + - Availability + +2. **Assign Rewards**: + - Select reward from list + - Specify quantity + - Click "Grant Reward" + - Confirmation email sent to passenger + +--- + +### Support Center + +**Purpose**: Manage customer support tickets and conversations +**Access Level**: Agent, Supervisor, Admin +**Icon**: MessageSquare + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ SUPPORT CENTER MGMT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ View Support Tickets │ +│ āœ“ Respond to Inquiries │ +│ āœ“ Manage Conversations │ +│ āœ“ FAQ Management │ +│ āœ“ Live Chat Monitoring │ +│ āœ“ Ticket Analytics │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Support Ticket Structure + +- **Ticket ID**: Unique identifier +- **Status**: Open, Resolved, Closed +- **Passenger**: Linked passenger +- **Subject**: Inquiry topic +- **Messages**: Conversation thread +- **Assigned Agent**: Support staff member +- **Created Date**: Ticket creation +- **Resolved Date**: When closed (if applicable) + +#### CRUD Operations + +##### READ (List Tickets) + +1. **Access Support Page**: + - Click **Support Center** in Customer Services + - Shows all support tickets + +2. **View Options**: + - **Tab 1**: Open Tickets (unresolved) + - **Tab 2**: All Conversations (open & closed) + - **Tab 3**: FAQ Management + +3. **Search & Filter**: + - **Status**: Open, Resolved, Closed + - **Assigned To**: Support agent filter + - **Search**: Ticket ID, passenger name + - **Date Range**: Filter by creation date + +4. **Ticket List Columns**: + - Ticket ID + - Passenger Name + - Subject + - Status Badge + - Last Message + - Created Date + - Assigned Agent + +##### VIEW CONVERSATION + +1. **Click Ticket Row**: + - Opens conversation thread modal + - Shows message history + +2. **Conversation Details**: + - All messages in chronological order + - Sender identification (Agent/Customer) + - Timestamps + - Attachments (if any) + +3. **Message Sidebar**: + - Passenger info + - Ticket metadata + - Linked bookings + +##### UPDATE (Add Response) + +1. **Click Ticket**: + - View current conversation + +2. **Reply to Ticket**: + - Type message in compose area + - Optionally add attachments + - Click "Send Response" + - Message sent to passenger + +3. **Status Management**: + - Mark as "Resolved" + - Change assignment + - Add notes + +##### CLOSE TICKET + +1. **Mark as Resolved**: + - Click "Mark Resolved" button + - Passenger notified + - Ticket moved to closed + +2. **Reopen**: + - If passenger responds, auto-reopens + - Or manually reopen if needed + +#### FAQ Management + +1. **View FAQ Articles**: + - Tab: "FAQ Management" + - Shows all published FAQs + +2. **Create FAQ**: + - Click "Add FAQ Article" + - Select category + - Enter question & answer + - Publish + +3. **Edit/Delete**: + - Edit existing articles + - Archive outdated articles + +--- + +### Notifications + +**Purpose**: Configure and send notifications to passengers +**Access Level**: Supervisor, Admin +**Icon**: Bell + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ NOTIFICATIONS MANAGEMENT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ View Notification Log │ +│ āœ“ Configure Templates │ +│ āœ“ Send Manual Notifications │ +│ āœ“ Set Preferences │ +│ āœ“ View Delivery Status │ +│ āœ“ Analytics │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Notification Channels + +- **Email**: Direct email delivery +- **SMS**: Text message delivery +- **Push Notification**: Mobile app push +- **In-App**: Platform notifications + +#### Notification Templates + +Pre-configured templates for: +- **Booking Confirmation**: "Your booking is confirmed" +- **Ticket Issued**: "Your ticket is ready" +- **Payment Received**: "Payment received successfully" +- **Trip Reminder**: "Your trip is tomorrow" +- **Delay Alert**: "Trip delayed by X minutes" +- **Promo**: Special offers and discounts + +#### CRUD Operations + +##### READ (View Notifications) + +1. **Access Notifications**: + - Click **Notifications** in Customer Services + - Shows notification log + +2. **Search & Filter**: + - **Recipient**: Passenger name/email + - **Status**: Sent, Failed, Pending + - **Channel**: Email, SMS, Push + - **Date Range**: Filter by send date + +3. **Notification Details**: + - Recipient + - Template used + - Channel(s) + - Status + - Sent Date/Time + - Delivery confirmation + +##### SEND MANUAL NOTIFICATION + +1. **Click "Send Notification"** button +2. **Select Recipients**: + - Specific passenger or group + - Filters: Booking status, tier, loyalty, etc. +3. **Choose Template**: + - Select from templates + - Or custom message +4. **Configure**: + - Select channels (Email, SMS, Push) + - Schedule send time + - Add personalization +5. **Preview**: Show how it looks +6. **Send**: Click "Send Notification" + +**Example**: +``` +Recipients: All PLATINUM tier passengers +Template: Special Promo - 15% Discount +Channels: Email, Push Notification +Send: Immediately +``` + +##### MANAGE TEMPLATES + +1. **View Templates Tab**: + - Shows all notification templates + - Filter by channel + +2. **Edit Template**: + - Click template to edit + - Modify subject/body + - Add placeholders {{name}}, {{bookingRef}} + - Save + +3. **Create New Template**: + - Click "Add Template" + - Template code + - Channels (multi-select) + - Subject & body + - Variables/placeholders + - Save + +##### DELIVERY TRACKING + +1. **View Delivery Status**: + - Notification details show status per channel + - Timestamp for each delivery + +2. **Retry Failed**: + - Failed notifications show retry option + - Click "Retry" to resend + - Max retries: 3 + +--- + +## Security & Compliance + +### Audit Logs + +**Purpose**: Monitor all system activities for compliance and security +**Access Level**: Supervisor, Admin +**Icon**: AlertTriangle + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ AUDIT LOGS MGMT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ View All Activities │ +│ āœ“ Filter by User │ +│ āœ“ Search by Entity │ +│ āœ“ View Change History │ +│ āœ“ Export Audit Trail │ +│ āœ“ Compliance Reports │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Logged Activities + +- **User Actions**: Login, logout, access +- **Data Changes**: Create, update, delete operations +- **Sensitive Actions**: Payment processing, cancellations, refunds +- **Authentication**: Failed logins, password resets +- **System Events**: Configuration changes, deployments + +#### Audit Record Structure + +- **Timestamp**: When action occurred +- **User**: Who performed action +- **Action**: Type of action (Create, Update, Delete) +- **Entity**: What was affected (Booking, Payment, etc.) +- **Entity ID**: ID of affected record +- **Old Data**: Previous values (for updates) +- **New Data**: New values (for updates) +- **IP Address**: Source IP +- **User Agent**: Browser/client info + +#### CRUD Operations + +##### READ (View Audit Log) + +1. **Access Audit Logs**: + - Click **Audit Logs** in Security & Compliance + - Shows all logged activities + +2. **Search & Filter**: + - **User Filter**: Specific user/agent + - **Action Filter**: Create, Update, Delete, View + - **Entity Filter**: Booking, Payment, Passenger, etc. + - **Date Range**: Filter by timestamp + - **Search**: Entity ID or description + +3. **Log Columns**: + - Timestamp + - User (Name, Email) + - Action (badge) + - Entity Type + - Entity ID + - Summary + - IP Address + +##### VIEW DETAILS + +1. **Click Log Entry**: + - Opens full audit detail modal + - Shows: + - All metadata + - Old vs New values (side-by-side) + - Complete change log + - IP/User Agent details + +2. **Change Visualization**: + - Highlights changed fields + - Shows before/after values + - Timestamp precision + +##### EXPORT AUDIT TRAIL + +1. **Click "Export"** button +2. **Select Options**: + - **Format**: CSV, JSON, PDF + - **Date Range**: Custom range + - **Filters**: Apply current filters +3. **Download**: File starts downloading +4. **Compliance**: Keep for regulatory requirements + +##### AUDIT RETENTION + +- **Active Logs**: 12 months +- **Archived**: 7 years (for compliance) +- **Automatic Archival**: Monthly process +- **GDPR Compliance**: Subject to retention policies + +--- + +### Fraud Detection + +**Purpose**: Monitor and prevent fraudulent activities +**Access Level**: Supervisor, Admin +**Icon**: Shield + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ FRAUD DETECTION MGMT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ View Fraud Alerts │ +│ āœ“ Configure Rules │ +│ āœ“ Block Suspicious Users │ +│ āœ“ Review Flagged Bookings │ +│ āœ“ Adjust Risk Thresholds │ +│ āœ“ Incident Response │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Fraud Detection Rules + +- **Rapid Bookings**: Multiple bookings in short timeframe +- **High Value**: Unusually large transactions +- **Geographic Anomaly**: Bookings from unlikely locations +- **Payment Failures**: Multiple failed payment attempts +- **Duplicate Identity**: Same ID used multiple times +- **Unusual Pattern**: Deviation from normal behavior + +#### Alert Severity + +- **LOW**: Review before processing +- **MEDIUM**: Requires manual approval +- **HIGH**: Immediate blocking recommended + +#### CRUD Operations + +##### READ (View Alerts) + +1. **Access Fraud Detection**: + - Click **Fraud Detection** in Security & Compliance + - Shows active fraud alerts + +2. **Alert List**: + - Filter by severity (Low, Medium, High) + - Filter by status (Open, Acknowledged, Resolved) + - Search by user ID, booking ref + +3. **Alert Information**: + - Alert ID + - User/Passenger + - Severity (badge color) + - Event Type (rule triggered) + - Timestamp + - Status + +##### VIEW ALERT DETAILS + +1. **Click Alert Row**: + - Opens alert detail modal + - Shows: + - Full context information + - Triggering rule details + - Rules triggered list + - Recommended action + - User history + +2. **Risk Assessment**: + - Risk score (0-100) + - Contributing factors + - Historical pattern + +##### ACKNOWLEDGE ALERT + +1. **Click "Acknowledge"**: + - Alert marked as reviewed + - Timestamp recorded + - Can still take action + +2. **Add Notes**: + - Click "Add Investigation Notes" + - Document findings + - Save + +##### TAKE ACTION + +**Allow Booking**: +1. Click "Allow" button +2. Booking proceeds despite alert +3. Logged for audit + +**Block User**: +1. Click "Block User" button +2. Enter block duration +3. Reason dropdown: + - Fraud Suspected + - Multiple Failed Payments + - Suspicious Pattern + - Manual Review Needed +4. Confirm +5. User cannot book during block period + +**Escalate**: +1. Click "Escalate to Admin" +2. Adds to priority queue +3. Admin reviews and decides + +##### MANAGE FRAUD RULES + +1. **View Rules Tab**: + - Shows all active fraud detection rules + - Rule thresholds + - Triggering conditions + +2. **Edit Rules**: + - Click rule to edit + - Adjust threshold values + - Change rule status (Active/Inactive) + - Save + +**Example Rules**: +``` +Rule: Rapid Bookings +Condition: >5 bookings in 1 hour +Severity: Medium +Action: Flag for review + +Rule: High Value Transaction +Condition: Amount > 100,000 ETB +Severity: Low +Action: Monitor + +Rule: Failed Payments +Condition: >3 failed in 24 hours +Severity: High +Action: Block user +``` + +--- + +### Verifayda Integration + +**Purpose**: Manage Ethiopian national ID verification service +**Access Level**: Admin +**Icon**: UserCheck + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ VERIFAYDA INTEGRATION MGMT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ View Verification Status │ +│ āœ“ Verify Manual ID │ +│ āœ“ Check Verification Log │ +│ āœ“ Manage Integration │ +│ āœ“ Configuration │ +│ āœ“ Test Integration │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Verifayda Overview + +- **Service**: Ethiopian government national ID verification +- **Real-time**: Live verification with government database +- **Privacy**: National IDs NOT stored (policy compliant) +- **Non-Ethiopian**: Passport alternative (no verification) + +#### Verification Status + +- **Verified**: Successfully matched with government DB +- **Unverified**: Failed or not attempted +- **Pending**: In-progress verification +- **Failed**: Temporary error, can retry + +#### CRUD Operations + +##### READ (View Verifications) + +1. **Access Verifayda Page**: + - Click **Verifayda Integration** in Security & Compliance + - Shows verification history + +2. **Search & Filter**: + - **Search**: Passenger name, national ID + - **Status**: Verified, Unverified, Pending, Failed + - **Date Range**: Filter by verification date + +3. **Verification Record**: + - Passenger Name + - National ID (masked) + - Verification Status (badge) + - Verified Name (from government DB) + - Date of Birth + - Nationality + - Verified Date + +##### VERIFY NATIONAL ID + +1. **Manual Verification**: + - Click "Verify ID" button + - Enter National ID number + - Click "Verify" + +2. **Verification Process**: + - Sends to Verifayda API + - Checks against government database + - Returns: Name, DOB, Nationality + +3. **Result**: + - **Success**: Shows verified data + - **Failed**: Shows error reason + - **Retry**: Can attempt again + +##### VIEW VERIFICATION DETAILS + +1. **Click Verification Record**: + - Opens detail modal + - Shows: + - Verification timestamp + - Request payload + - Response data + - Verification match score + - Linked bookings + +--- + +## Analytics & Reports + +### Reports + +**Purpose**: View comprehensive analytics and business reports +**Access Level**: Supervisor, Admin +**Icon**: BarChart3 + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ REPORTS ANALYTICS │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ Revenue Reports │ +│ āœ“ Occupancy Analysis │ +│ āœ“ Agent Performance │ +│ āœ“ Passenger Analytics │ +│ āœ“ Custom Date Range │ +│ āœ“ Export Reports │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Report Types + +**Revenue Report**: +- Total revenue (ETB) +- Revenue by route +- Revenue by seat class +- Revenue by payment method +- Trends over time +- Promo code impact + +**Occupancy Report**: +- Seat occupancy % +- Capacity utilization +- Empty seats cost +- Occupancy by route +- Occupancy trends +- Peak/off-peak analysis + +**Agent Performance**: +- Counter bookings +- Commission earned +- Sales by period +- Customer satisfaction +- Performance ranking + +**Passenger Analytics**: +- New passengers +- Repeat passenger rate +- Loyalty program stats +- Regional distribution +- Device/platform breakdown + +#### CRUD Operations + +##### GENERATE REPORT + +1. **Access Reports Page**: + - Click **Reports** in Analytics & Reports + - Multiple report options available + +2. **Select Report Type**: + - Revenue + - Occupancy + - Agent Performance + - Passenger Analytics + +3. **Configure Report**: + - **Date Range**: From/To dates (required) + - **Route Filter** (optional): Specific route or all + - **Filters** (optional): Additional criteria + - **Group By**: Day, Week, Month, Year + +4. **Generate**: Click "Generate Report" +5. **Display**: Charts and tables appear + +##### VIEW REPORT DETAILS + +1. **Charts**: + - Line charts for trends + - Bar charts for comparisons + - Pie charts for distribution + +2. **Tables**: + - Detailed data rows + - Sortable columns + - Pagination for large datasets + +3. **Export Options**: + - Download as PDF + - Download as Excel + - Download as CSV + - Schedule recurring export + +##### CUSTOMIZE REPORT + +1. **Add Metrics**: + - Click "Add Metric" + - Select from available metrics + - Charts update + +2. **Change Date Range**: + - Click date range selector + - Pick new dates + - Report regenerates + +3. **Save Report**: + - Click "Save Report" + - Name the report + - Can rerun with one click + +--- + +### Operational Reports + +**Purpose**: View system operational metrics and performance +**Access Level**: Supervisor, Admin +**Icon**: FileText + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ OPERATIONAL REPORTS MGMT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ System Health Status │ +│ āœ“ API Performance │ +│ āœ“ Error Rates │ +│ āœ“ Data Sync Status │ +│ āœ“ Scheduled Reports │ +│ āœ“ Export History │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Operational Metrics + +- **System Uptime**: Percentage +- **API Response Time**: Average ms +- **Error Rate**: % of failed requests +- **Data Sync Status**: Last sync time +- **Scheduled Jobs**: Status of cron tasks +- **Storage Usage**: Database size, disk usage + +#### CRUD Operations + +##### READ (View Operations Status) + +1. **Access Operational Reports**: + - Click **Operational Reports** in Analytics & Reports + - Shows current system health + +2. **Health Dashboard**: + - System status indicators + - Key metrics + - Recent issues (if any) + +3. **Performance Metrics**: + - API response times + - Database query times + - Error logs + - Job execution times + +--- + +## System Administration + +### Agent Operations + +**Purpose**: Manage agent counter bookings and shifts +**Access Level**: Supervisor, Admin +**Icon**: Briefcase + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ AGENT OPERATIONS MGMT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ Create Bookings │ +│ āœ“ Manage Shifts │ +│ āœ“ Track Commissions │ +│ āœ“ Reconciliation │ +│ āœ“ Cash Management │ +│ āœ“ Agent Performance │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Agent Functions + +- **Counter Booking**: Create bookings on behalf of passengers +- **Shift Management**: Open/close shifts and cash handling +- **Commission Tracking**: Monitor earnings +- **Reconciliation**: Daily settlement + +#### CRUD Operations + +##### CREATE COUNTER BOOKING + +1. **Access Agent Bookings**: + - Click **Agent Operations** in System section + - Click "New Booking" button + +2. **Booking Form**: + - **Select Schedule**: Choose train and date + - **Select Seats**: Pick available seats + - **Add Passengers**: Enter passenger details + - **Select Class**: Seat class preference + - **Apply Promo**: If applicable + +3. **Payment**: + - **Payment Method**: Cash, Card, Check, etc. + - **Amount Received** (for cash) + - **Change Calculation**: Auto-calculated + +4. **Process**: + - Click "Create Booking" + - Confirmation with booking reference + - Ticket printed or emailed + +##### MANAGE SHIFTS + +1. **Open Shift**: + - Click "Open Shift" + - Enter opening balance (cash) + - Click "Start Shift" + +2. **Close Shift**: + - Click "Close Shift" + - Verify final cash + - Enter closing balance + - Reconcile differences + - Click "Complete Shift" + +3. **Shift Details**: + - Opening/Closing Balance + - Total Bookings + - Total Sales + - Commission Earned + - Cash Count Variance + +##### VIEW AGENT PERFORMANCE + +1. **Agent Dashboard**: + - Total bookings (period) + - Total revenue generated + - Average booking value + - Commission earned + - Performance ranking + +--- + +### User Management + +**Purpose**: Manage system users and access control +**Access Level**: Admin +**Icon**: Users + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ USER MANAGEMENT MGMT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ Create Users │ +│ āœ“ Assign Roles │ +│ āœ“ Manage Permissions │ +│ āœ“ Reset Passwords │ +│ āœ“ Deactivate/Activate │ +│ āœ“ Audit User Activity │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### User Roles + +- **AGENT**: Counter operations +- **SUPERVISOR**: Oversight and decisions +- **ADMIN**: Full access +- **STAFF**: Limited specific access + +#### CRUD Operations + +##### CREATE USER + +1. **Click "Add User"** button +2. **Fill Form**: + - **Email** (required): Unique email + - **Full Name** (required): Display name + - **Phone**: Contact number + - **Role** (required): Select role + - **Department**: Optional + - **Status**: Active/Inactive +3. **Save**: Click "Create User" +4. **Auto-email**: Temporary password sent to email + +##### READ (List Users) + +1. **View Users**: + - Table shows all users + - Filter by role + - Search by name/email + +2. **Columns**: + - Name + - Email + - Role (badge) + - Status + - Last Login + - Created Date + +##### UPDATE + +1. **Click User Row**: + - Opens user detail modal + - Shows profile & activity + +2. **Modify**: + - Update name/phone + - Change role + - Update department + - Toggle active status + +3. **Password Reset**: + - Click "Reset Password" + - Temporary password generated + - Sent to user email + +##### DELETE + +1. **Click "Delete"** on user +2. **Confirm**: Warns about implications +3. **Options**: + - **Deactivate**: Keep records, disable access + - **Delete**: Remove user completely + +--- + +### Settings + +**Purpose**: Configure system-wide settings and preferences +**Access Level**: Admin +**Icon**: Settings + +#### Features Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ SETTINGS MGMT │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ āœ“ Application Settings │ +│ āœ“ Email Configuration │ +│ āœ“ Payment Provider Setup │ +│ āœ“ API Integration │ +│ āœ“ Notification Templates │ +│ āœ“ System Preferences │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +#### Configuration Sections + +**General Settings**: +- Application name +- Logo and branding +- Timezone +- Default currency +- Language + +**Email Settings**: +- SMTP server +- Sender email +- Email templates +- Notification preferences + +**Payment Settings**: +- Provider credentials +- API keys +- Webhook endpoints +- Currency configuration + +**API Integration**: +- Verifayda setup +- External service integration +- API rate limits +- Webhook configuration + +#### CRUD Operations + +##### UPDATE SETTINGS + +1. **Navigate to Settings**: + - Click **Settings** in System section + +2. **Select Category**: + - General, Email, Payments, API, etc. + +3. **Modify Settings**: + - Update configuration values + - Test connections (where applicable) + - Save changes + +4. **Confirmation**: + - Settings updated with timestamp + - Changes take effect immediately + - Audit logged + +--- + +## Common Features + +### Data Table Features + +**All data tables include**: + +1. **Search & Filter**: + - Real-time search box + - Multiple filter dropdowns + - Date range pickers + - Status/category filters + +2. **Sorting**: + - Click column headers to sort + - Sort order toggle (Asc/Desc) + - Multi-column sort (optional) + +3. **Pagination**: + - Previous/Next buttons + - Jump to page input + - Page size selector + - Total record count + +4. **Bulk Actions**: + - Checkbox selection + - Select All / Deselect All + - Bulk operations (Delete, Export, Update) + +5. **Export**: + - Export visible columns + - Export filtered results + - Format options (CSV, Excel, JSON) + - Scheduled exports + +### Modal Dialog Features + +**All modals include**: + +1. **Title Bar**: + - Clear action title + - Close button (X) + +2. **Form Fields**: + - Required field indicators (*) + - Field validation + - Error messages + - Helpful tooltips + +3. **Action Buttons**: + - Primary action (Create, Save, Update) + - Secondary action (Cancel) + - Danger action (Delete) + - Loading state with spinner + +4. **Responsive Design**: + - Mobile-friendly layout + - Scrollable content areas + - Optimized for all screen sizes + +### Status Badges + +**Color-coded status indicators**: + +- **Green**: Success, Active, Confirmed +- **Yellow**: Warning, Pending, Processing +- **Blue**: Information, Scheduled +- **Red**: Error, Failed, Cancelled +- **Gray**: Inactive, Draft + +### Keyboard Shortcuts + +**Common shortcuts**: + +| Shortcut | Action | +|----------|--------| +| `Ctrl/Cmd + K` | Search/Quick filter | +| `Ctrl/Cmd + S` | Save form | +| `Esc` | Close modal/dialog | +| `Tab` | Navigate form fields | +| `Enter` | Submit form | + +--- + +## Best Practices + +### Data Entry + +1. **Always verify** information before submitting +2. **Use dropdown** selections when available +3. **Check date formats** match system requirements +4. **Include descriptive** notes for manual entries +5. **Save frequently** during long forms + +### Booking Management + +1. **Verify passenger** identity before processing +2. **Confirm payment method** before transaction +3. **Double-check seat** assignments +4. **Note any special** passenger requirements +5. **Provide clear** confirmation references + +### Financial Operations + +1. **Reconcile daily** at shift end +2. **Verify exchange rates** before currency conversion +3. **Keep refund** documentation +4. **Review fraud** alerts carefully +5. **Audit payment** discrepancies + +### Security + +1. **Lock screen** when away from desk +2. **Use strong passwords** (min. 12 characters) +3. **Enable two-factor** authentication +4. **Report suspicious** activity immediately +5. **Clear browser** cache after sensitive operations + +### Compliance + +1. **Follow audit** procedures +2. **Retain records** per retention policy +3. **Document all** manual overrides +4. **Report data** discrepancies +5. **Keep credentials** confidential + +--- + +## Support & Help + +### Getting Help + +**In-App Help**: +- Hover over fields for tooltips +- Click "?" icons for context help +- Use "Help" menu in navigation + +**Documentation**: +- This comprehensive guide +- Video tutorials (under production) +- FAQ section in Support Center + +**Contact Support**: +- **Email**: support@edr-platform.com +- **Phone**: +251-XXX-XXX-XXXX +- **Chat**: Available during business hours +- **Ticket System**: Create support ticket in app + +### Troubleshooting + +**Issue: Cannot login** +- Verify email/password +- Check caps lock +- Try password reset +- Contact admin if locked out + +**Issue: Page not loading** +- Refresh browser (F5) +- Clear browser cache +- Try different browser +- Check internet connection + +**Issue: Data not saving** +- Verify all required fields +- Check for error messages +- Review audit logs +- Try again or contact support + +--- + +## Change Log + +### Version 1.0.0 (June 15, 2026) +- Initial release +- All core modules implemented +- Multi-currency support added +- Verifayda 2.0 integration complete +- Premium and insurance fees added to fares +- Age-based pricing fully functional +- Segment fare rules implemented + +--- + +## Appendix + +### Acronyms & Abbreviations + +| Acronym | Meaning | +|---------|---------| +| ETB | Ethiopian Birr | +| DJF | Djiboutian Franc | +| USD | US Dollar | +| EDR | Ethio-Djibouti Railway | +| SMS | Short Message Service | +| WCAG | Web Content Accessibility Guidelines | +| CSV | Comma-Separated Values | +| API | Application Programming Interface | +| SMTP | Simple Mail Transfer Protocol | +| CRUD | Create, Read, Update, Delete | +| IAM | Identity and Access Management | + +### Currency Codes + +| Code | Currency | Country | +|------|----------|---------| +| ETB | Ethiopian Birr | Ethiopia | +| DJF | Djiboutian Franc | Djibouti | +| USD | US Dollar | United States | + +### Timezone Reference + +| Timezone | Region | UTC Offset | +|----------|--------|------------| +| Africa/Addis_Ababa | Ethiopia | UTC+3 | +| Africa/Djibouti | Djibouti | UTC+3 | +| UTC | Coordinated Universal Time | UTC+0 | + +--- + +**For more information or feedback, please contact the development team or visit the support portal.** + +**Last Updated**: January 15, 2026 +**Document Version**: 1.0.0 +**Maintained By**: EDR Development Team diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 910d933c4..ce8541f29 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -80,6 +80,45 @@ export default function BookingsPage() { } }; + const handleExportBookings = async () => { + const selectedColumns = prompt( + 'Select columns to export (comma-separated):\n\n' + + 'Available: bookingRef, passenger, status, bookingType, passengerCount, totalMinor, paymentStatus, createdAt\n\n' + + 'Default: bookingRef, passenger, status, totalMinor, paymentStatus, createdAt', + 'bookingRef, passenger, status, totalMinor, paymentStatus, createdAt' + ); + + if (!selectedColumns) return; + + const cols = selectedColumns.split(',').map(c => c.trim()); + const csv = [ + cols.join(','), + ...data?.items?.map((booking: any) => { + const values = cols.map(col => { + switch(col) { + case 'bookingRef': return booking.bookingRef; + case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest'; + case 'status': return booking.status; + case 'bookingType': return booking.bookingType || 'N/A'; + case 'passengerCount': return booking.adultCount + booking.childCount; + case 'totalMinor': return booking.totalMinor; + case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING'; + case 'createdAt': return booking.createdAt; + default: return ''; + } + }); + return values.map(v => `"${v}"`).join(','); + }) || [] + ].join('\n'); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + }; + const columns = [ { key: 'bookingRef', @@ -99,6 +138,17 @@ export default function BookingsPage() { ), }, + { + key: 'bookingType', + label: 'Class', + sortable: true, + render: (booking: any) => booking.bookingType || 'ONE_WAY', + }, + { + key: 'passengerCount', + label: 'Passengers', + render: (booking: any) => `${(booking.adultCount || 0) + (booking.childCount || 0)}`, + }, { key: 'status', label: 'Status', @@ -158,7 +208,7 @@ export default function BookingsPage() {

Bookings

Manage all passenger bookings

- Export + Export
diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx index 5939ab882..7fd5d098b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx @@ -75,7 +75,9 @@ export default function ClassesPage() { coachTypeId: selectedCoachTypeId, name: formData.get('name') as string, description: formData.get('description') as string, - baseFareMinor: parseInt(formData.get('baseFareMinor') as string) || 0, + baseFareMinor: Math.round(parseFloat(formData.get('baseFareMinor') as string) * 100) || 0, + premiumMinor: Math.round(parseFloat(formData.get('premiumMinor') as string) * 100) || 0, + insuranceFeeMinor: Math.round(parseFloat(formData.get('insuranceFeeMinor') as string) * 100) || 0, isActive: formData.get('isActive') === 'true', }; @@ -136,9 +138,23 @@ export default function ClassesPage() { }, { key: 'baseFareMinor', - label: 'Base Fare (ETB)', + label: 'Base Fare', render: (cls: any) => ( - {formatCurrency(cls.baseFareMinor, 'ETB')} + {(cls.baseFareMinor / 100).toFixed(2)} ETB + ), + }, + { + key: 'premiumMinor', + label: 'Premium', + render: (cls: any) => ( + {cls.premiumMinor ? (cls.premiumMinor / 100).toFixed(2) : '0.00'} ETB + ), + }, + { + key: 'insuranceFeeMinor', + label: 'Insurance', + render: (cls: any) => ( + {cls.insuranceFeeMinor ? (cls.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB ), }, { @@ -181,7 +197,7 @@ export default function ClassesPage() {

Classes

-

Manage class configurations by coach type

+

Manage class configurations with pricing by coach type

-
- - -

Enter amount in cents (100 cents = 1 ETB)

+
+

Pricing Configuration

+ +
+ + +

Per-km distance-based fare rate

+
+ +
+
+ + +

Flat fee per passenger (e.g., lounge access, extra legroom)

+
+ +
+ + +

Flat fee per passenger (e.g., travel insurance)

+
+
+ +
+

Total Fare Calculation:

+

Total = (Base Fare Ɨ Distance) + Premium + Insurance

+

• Premium applies per passenger (including free child)

+

• Insurance applies per passenger (including free child)

+
diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index 39acd4a4f..bf4f88a98 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -236,6 +236,7 @@ export default function CoachesPage() { coachTypeId: formData.get('coachTypeId') as string, arrangement: formData.get('arrangement') as string, capacity: parseInt(formData.get('capacity') as string), + sequence: parseInt(formData.get('sequence') as string), status: formData.get('status') as string, }; @@ -337,6 +338,14 @@ export default function CoachesPage() { // Coaches Columns const coachColumns = [ + { + key: 'sequence', + label: 'Sequence', + sortable: true, + render: (coach: any) => ( + {coach.sequence} + ), + }, { key: 'number', label: 'Number', @@ -357,15 +366,6 @@ export default function CoachesPage() { {coach.coachType?.name || 'N/A'} ), }, - { - key: 'visualization', - label: 'Seats/Beds', - render: (coach: any) => ( -
- {renderBedVisualization(coach)} -
- ), - }, { key: 'arrangement', label: 'Arrangement', @@ -624,7 +624,7 @@ export default function CoachesPage() {
- +
- +

Format: separate columns with +

- +
-
- +
+ + +

Used for ordering coaches in trains

+
+ +
+ setCurrencyForm({ ...currencyForm, code: e.target.value.toUpperCase() })} + className="input w-full" + placeholder="e.g., USD" + maxLength={3} + disabled={!!editingCurrency} + required + /> +

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

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

All rates relative to this currency

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

+ ā‰ˆ 1 {currencyForm.code} = {(1 / parseFloat(currencyForm.exchangeRate)).toFixed(6)} {currencyForm.baseCurrencyCode} +

+ )} +
+ +
+

Exchange Rate Example:

+

If 1 ETB = 0.018 USD, enter 0.018

+

If 1 ETB = 3.25 DJF, enter 3.25

+
+ +
+ + Cancel + + + {editingCurrency ? 'Update Currency' : 'Add Currency'} + +
+
+ +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/docs/page.tsx b/apps/edr-passenger-web/backoffice/src/app/docs/page.tsx new file mode 100644 index 000000000..deaf40007 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/docs/page.tsx @@ -0,0 +1,949 @@ +'use client'; + +import React, { useState } from 'react'; +import Link from 'next/link'; +import { ChevronDown, ChevronRight, FileText, Home } from 'lucide-react'; + +const DocPage = () => { + const [expandedSections, setExpandedSections] = useState<{ [key: string]: boolean }>({ + overview: true, + operations: true, + masterdata: false, + financial: false, + services: false, + security: false, + analytics: false, + system: false, + }); + + const toggleSection = (section: string) => { + setExpandedSections(prev => (({ + ...prev, + [section]: !prev[section] + }))); + }; + + const scrollToSection = (id: string) => { + setTimeout(() => { + const element = document.getElementById(id); + if (element) { + const headerOffset = 120; + const elementPosition = element.getBoundingClientRect().top + window.pageYOffset; + const offsetPosition = elementPosition - headerOffset; + window.scrollTo({ + top: offsetPosition, + behavior: 'smooth' + }); + } + }, 0); + }; + + const sections = [ + { + id: 'overview', + title: 'šŸ“‹ Overview & Getting Started', + items: [ + { id: 'about', label: 'Application Overview' }, + { id: 'features', label: 'Key Features' }, + ] + }, + { + id: 'operations', + title: 'šŸ“Š Operations', + items: [ + { id: 'bookings', label: 'Bookings' }, + { id: 'bookings-how', label: '→ How-To' }, + { id: 'passengers', label: 'Passengers' }, + { id: 'passengers-how', label: '→ How-To' }, + { id: 'tickets', label: 'Tickets' }, + { id: 'tickets-how', label: '→ How-To' }, + ] + }, + { + id: 'masterdata', + title: 'šŸ¢ Master Data', + items: [ + { id: 'stations', label: 'Stations' }, + { id: 'stations-how', label: '→ How-To' }, + { id: 'trains', label: 'Trains' }, + { id: 'trains-how', label: '→ How-To' }, + { id: 'coaches', label: 'Coaches' }, + { id: 'coaches-how', label: '→ How-To' }, + { id: 'seats', label: 'Seats' }, + { id: 'seats-how', label: '→ How-To' }, + { id: 'classes', label: 'Seat Classes' }, + { id: 'classes-how', label: '→ How-To' }, + { id: 'routes', label: 'Routes' }, + { id: 'routes-how', label: '→ How-To' }, + { id: 'schedules', label: 'Schedules' }, + { id: 'schedules-how', label: '→ How-To' }, + ] + }, + { + id: 'financial', + title: 'šŸ’° Financial', + items: [ + { id: 'pricing', label: 'Pricing & Fares' }, + { id: 'pricing-how', label: '→ How-To' }, + { id: 'currencies', label: 'Currencies' }, + { id: 'currencies-how', label: '→ How-To' }, + { id: 'payments', label: 'Payments' }, + { id: 'payments-how', label: '→ How-To' }, + { id: 'promos', label: 'Promo Codes' }, + { id: 'promos-how', label: '→ How-To' }, + ] + }, + { + id: 'services', + title: 'šŸŽ Customer Services', + items: [ + { id: 'loyalty', label: 'Loyalty' }, + { id: 'loyalty-how', label: '→ How-To' }, + { id: 'support', label: 'Support' }, + { id: 'support-how', label: '→ How-To' }, + { id: 'notifications', label: 'Notifications' }, + { id: 'notifications-how', label: '→ How-To' }, + ] + }, + { + id: 'security', + title: 'šŸ”’ Security', + items: [ + { id: 'audit', label: 'Audit Logs' }, + { id: 'audit-how', label: '→ How-To' }, + { id: 'fraud', label: 'Fraud Detection' }, + { id: 'fraud-how', label: '→ How-To' }, + { id: 'verifayda', label: 'Verifayda' }, + { id: 'verifayda-how', label: '→ How-To' }, + ] + }, + { + id: 'analytics', + title: 'šŸ“ˆ Analytics', + items: [ + { id: 'reports', label: 'Reports' }, + { id: 'reports-how', label: '→ How-To' }, + ] + }, + { + id: 'system', + title: 'āš™ļø System', + items: [ + { id: 'agents', label: 'Agents' }, + { id: 'agents-how', label: '→ How-To' }, + { id: 'users', label: 'Users' }, + { id: 'users-how', label: '→ How-To' }, + { id: 'settings', label: 'Settings' }, + { id: 'settings-how', label: '→ How-To' }, + ] + }, + ]; + + const HowToStep = ({ number, title, children }: { number: number; title: string; children: React.ReactNode }) => ( +
+
+
{number}
+
+

{title}

+ {children} +
+
+
+ ); + + return ( +
+
+
+
+ +

Documentation

+
+
+ + + View API Docs + + + + Dashboard + +
+
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+

Welcome to EDR Passenger Backoffice

+

Comprehensive management system for the Ethio-Djibouti Railway passenger platform. This documentation provides complete guidance on all features, operations, and best practices.

+
+ +
+

🌟 Key Features

+

Complete booking, passenger, fleet, and financial management.

+
+ + {/* BOOKINGS */} +
+

šŸ“‹ Bookings

+

Manage passenger bookings with search, view, modify, and refund capabilities.

+
+ +
+

šŸ“‹ How-To: Manage Bookings

+
+ +
    +
  1. Click {`"Bookings"`} in Operations section
  2. +
  3. View all bookings in table format
  4. +
+
+ +
    +
  1. Use search box for reference, email, or phone
  2. +
  3. Use Status dropdown to filter
  4. +
+
+ +
    +
  1. Click {`"View Details"`} for full information
  2. +
  3. Click {`"Cancel Booking"`} to process refunds
  4. +
+
+
+
+ + {/* PASSENGERS */} +
+

šŸ‘„ Passengers

+

Manage passenger profiles, loyalty, and verification status.

+
+ +
+

šŸ‘„ How-To: Manage Passengers

+
+ +
    +
  1. Click {`"Passengers"`} in Operations
  2. +
  3. View all profiles with pagination
  4. +
+
+ +
    +
  1. Search by name, email, phone, ID
  2. +
  3. Filter by nationality, verification, loyalty tier
  4. +
+
+ +
    +
  1. Click passenger row to open modal
  2. +
  3. View account, loyalty, wallet, booking history
  4. +
+
+
+
+ + {/* TICKETS */} +
+

šŸŽ« Tickets

+

Manage ticket generation, tracking, and validation.

+
+ +
+

šŸŽ« How-To: Manage Tickets

+
+ +
    +
  1. Click {`"Tickets"`} in Operations
  2. +
  3. View all issued tickets with status
  4. +
+
+ +
    +
  1. Search by booking reference or ticket number
  2. +
  3. Filter by validation status
  4. +
+
+ +
    +
  1. Click ticket to view details
  2. +
  3. Click {`"Download PDF"`} for printable version
  4. +
+
+
+
+ + {/* STATIONS */} +
+

šŸ¢ Stations

+

Configure railway stations with locations and timezones.

+
+ +
+

šŸ¢ How-To: Manage Stations

+
+ +
    +
  1. Click {`"Stations"`} in Master Data
  2. +
  3. View all configured stations
  4. +
+
+ +
    +
  1. Click {`"Add Station"`}
  2. +
  3. Enter code, name, city, timezone, coordinates
  4. +
+
+ +
    +
  1. Click station to open details
  2. +
  3. Update information and save
  4. +
+
+
+
+ + {/* TRAINS */} +
+

šŸš‚ Trains

+

Manage train fleet with coach assignments.

+
+ +
+

šŸš‚ How-To: Manage Trains

+
+ +
    +
  1. Click {`"Trains"`} in Master Data
  2. +
  3. View all trains and coaches
  4. +
+
+ +
    +
  1. Click {`"Add Train"`}
  2. +
  3. Enter code and select coaches
  4. +
+
+ +
    +
  1. Click train to edit
  2. +
  3. Add/remove coaches with position numbers
  4. +
+
+
+
+ + {/* COACHES */} +
+

🚃 Coaches

+

Manage coach inventory with seat configurations.

+
+ +
+

🚃 How-To: Manage Coaches

+
+ +
    +
  1. Click "Coaches" in Master Data
  2. +
  3. View all coaches and assignments
  4. +
+
+ +
    +
  1. Click "Add Coach"
  2. +
  3. Enter code, select train, define seat layout
  4. +
+
+ +
    +
  1. Click coach to edit
  2. +
  3. Add seats and assign classes
  4. +
+
+
+
+ + {/* SEATS */} +
+

šŸ’ŗ Seats

+

Manage seat inventory with visual maps.

+
+ +
+

šŸ’ŗ How-To: Manage Seats

+
+ +
    +
  1. Go to "Seats" in Master Data
  2. +
  3. Select coach from dropdown
  4. +
  5. Visual map shows: Green=Available, Red=Blocked
  6. +
+
+ +
    +
  1. Click available seat
  2. +
  3. Click "Block" and select reason
  4. +
+
+ +
    +
  1. Click blocked seat
  2. +
  3. Click "Unblock" to restore
  4. +
+
+
+
+ + {/* SEAT CLASSES */} +
+

šŸŽÆ Seat Classes

+

Define seat class types with pricing.

+
+ +
+

šŸŽÆ How-To: Manage Seat Classes

+
+ +
    +
  1. Click "Seat Classes" in Master Data
  2. +
  3. View all class types
  4. +
+
+ +
    +
  1. Click "Add Class"
  2. +
  3. Enter name, base fare, premium, insurance
  4. +
+
+ +
    +
  1. Click class to edit
  2. +
  3. Update fares and save
  4. +
+
+
+
+ + {/* ROUTES */} +
+

šŸ›¤ļø Routes

+

Define railway routes with ordered stops.

+
+ +
+

šŸ›¤ļø How-To: Manage Routes

+
+ +
    +
  1. Click "Routes" in Master Data
  2. +
  3. View all routes and stops
  4. +
+
+ +
    +
  1. Click "Add Route"
  2. +
  3. Enter code and description
  4. +
+
+ +
    +
  1. Click route to edit
  2. +
  3. Click "Add Stop" and select station
  4. +
+
+
+
+ + {/* SCHEDULES */} +
+

šŸ“… Schedules

+

Create and manage train schedules.

+
+ +
+

šŸ“… How-To: Create Schedules

+
+ +
    +
  1. Go to "Schedules" in Master Data
  2. +
  3. Click "Create Schedule"
  4. +
  5. Fill train, route, departure/arrival times
  6. +
+
+ +
    +
  1. Click "Bulk Generate"
  2. +
  3. Set recurring parameters and generate
  4. +
+
+ +
    +
  1. Click schedule to edit
  2. +
  3. Update times and view fares
  4. +
+
+
+
+ + {/* PRICING */} +
+

šŸ’° Pricing & Fares

+

Configure dynamic pricing with segments.

+
+ +
+

šŸ’° How-To: Configure Pricing

+
+ +
    +
  1. Click "Pricing & Fares" in Financial
  2. +
  3. Two tabs: Schedule Fares, Segment Fares
  4. +
+
+ +
    +
  1. Click "Add Fare Rule"
  2. +
  3. Fill schedule, seat class, fare, nationality
  4. +
+
+ +
    +
  1. Switch to "Segment Fares" tab
  2. +
  3. Select route and add origin/destination fare
  4. +
+
+
+
+ + {/* CURRENCIES */} +
+

šŸ’µ Currencies

+

Manage exchange rates for multiple currencies.

+
+ +
+

šŸ’µ How-To: Manage Currencies

+
+ +
    +
  1. Click "Currencies" in Financial
  2. +
  3. View all configured rates
  4. +
+
+ +
    +
  1. Click "Add Rate"
  2. +
  3. Select currency and enter exchange rate
  4. +
+
+ +
    +
  1. Click rate to edit
  2. +
  3. Click "Sync" to update from provider
  4. +
+
+
+
+ + {/* PAYMENTS */} +
+

šŸ’³ Payments

+

Monitor and process transactions.

+
+ +
+

šŸ’³ How-To: Manage Payments

+
+ +
    +
  1. Click "Payments" in Financial
  2. +
  3. View all transactions
  4. +
+
+ +
    +
  1. Search by booking or transaction ID
  2. +
  3. Filter by status and payment method
  4. +
+
+ +
    +
  1. Click transaction
  2. +
  3. Click "Refund" if eligible
  4. +
+
+
+
+ + {/* PROMOS */} +
+

šŸŽ Promo Codes

+

Create and manage promotional campaigns.

+
+ +
+

šŸŽ How-To: Manage Promo Codes

+
+ +
    +
  1. Click "Promo Codes" in Financial
  2. +
  3. View all active codes
  4. +
+
+ +
    +
  1. Click "Add Promo Code"
  2. +
  3. Enter code, discount type, validity dates
  4. +
+
+ +
    +
  1. Click code to view analytics
  2. +
  3. View usage count and savings
  4. +
+
+
+
+ + {/* LOYALTY */} +
+

šŸ† Loyalty

+

Manage loyalty program and rewards.

+
+ +
+

šŸ† How-To: Manage Loyalty

+
+ +
    +
  1. Click "Loyalty Program" in Services
  2. +
  3. View all loyalty accounts
  4. +
+
+ +
    +
  1. Click account
  2. +
  3. Click "Adjust Points" and enter amount
  4. +
+
+ +
    +
  1. Click account
  2. +
  3. Click "Grant Reward" and select reward
  4. +
+
+
+
+ + {/* SUPPORT */} +
+

šŸ’¬ Support

+

Manage support tickets and conversations.

+
+ +
+

šŸ’¬ How-To: Manage Support

+
+ +
    +
  1. Click "Support Center" in Services
  2. +
  3. View all support tickets
  4. +
+
+ +
    +
  1. Click ticket to open conversation
  2. +
  3. Add replies and update status
  4. +
+
+ +
    +
  1. Go to FAQ management
  2. +
  3. Add or edit FAQ articles
  4. +
+
+
+
+ + {/* NOTIFICATIONS */} +
+

šŸ”” Notifications

+

Send notifications via multiple channels.

+
+ +
+

šŸ”” How-To: Manage Notifications

+
+ +
    +
  1. Click "Notifications" in Services
  2. +
  3. View notification history
  4. +
+
+ +
    +
  1. Click "Send Notification"
  2. +
  3. Select channel and message
  4. +
+
+ +
    +
  1. Go to Templates section
  2. +
  3. Create or edit templates with variables
  4. +
+
+
+
+ + {/* AUDIT */} +
+

šŸ“‹ Audit Logs

+

Monitor system activities and user actions.

+
+ +
+

šŸ“‹ How-To: View Audit Logs

+
+ +
    +
  1. Click "Audit Logs" in Security
  2. +
  3. View all recorded activities
  4. +
+
+ +
    +
  1. Filter by user, action, or date
  2. +
  3. Search by entity ID
  4. +
+
+ +
    +
  1. Click log entry for details
  2. +
  3. Click "Export" to download CSV
  4. +
+
+
+
+ + {/* FRAUD */} +
+

šŸ›”ļø Fraud Detection

+

Monitor and manage fraud alerts.

+
+ +
+

šŸ›”ļø How-To: Manage Fraud Detection

+
+ +
    +
  1. Click "Fraud Detection" in Security
  2. +
  3. View all fraud alerts
  4. +
+
+ +
    +
  1. Click alert to view details
  2. +
  3. Review triggered rules and patterns
  4. +
+
+ +
    +
  1. Click "Allow" or "Block" with notes
  2. +
  3. Update user status
  4. +
+
+
+
+ + {/* VERIFAYDA */} +
+

āœ… Verifayda

+

Verify passenger identities against government database.

+
+ +
+

āœ… How-To: Manage Verifayda

+
+ +
    +
  1. Click "Verifayda Integration" in Security
  2. +
  3. View verification history
  4. +
+
+ +
    +
  1. Enter national ID or passport number
  2. +
  3. Click "Verify" to check database
  4. +
+
+ +
    +
  1. View verified passenger data
  2. +
  3. Match with booking details
  4. +
+
+
+
+ + {/* REPORTS */} +
+

šŸ“Š Reports

+

Generate business analytics and reports.

+
+ +
+

šŸ“Š How-To: Generate Reports

+
+ +
    +
  1. Click "Reports" in Analytics
  2. +
  3. View available report types
  4. +
+
+ +
    +
  1. Click report type
  2. +
  3. Select date range and parameters
  4. +
+
+ +
    +
  1. View report with charts
  2. +
  3. Click "Export" for PDF or CSV
  4. +
+
+
+
+ + {/* AGENTS */} +
+

šŸ‘¤ Agents

+

Manage booking agents and commissions.

+
+ +
+

šŸ‘¤ How-To: Manage Agents

+
+ +
    +
  1. Click "Agents" in System
  2. +
  3. View all agents
  4. +
+
+ +
    +
  1. Click "Add Agent"
  2. +
  3. Enter name, email, commission rate
  4. +
+
+ +
    +
  1. Click agent to edit
  2. +
  3. Click "Create Shift" to assign schedule
  4. +
+
+
+
+ + {/* USERS */} +
+

šŸ‘„ Users

+

Manage backoffice user accounts and permissions.

+
+ +
+

šŸ‘„ How-To: Manage Users

+
+ +
    +
  1. Click "Users" in System
  2. +
  3. View all user accounts
  4. +
+
+ +
    +
  1. Click "Add User"
  2. +
  3. Enter email, name, select role
  4. +
+
+ +
    +
  1. Click user to edit
  2. +
  3. Adjust roles and permissions
  4. +
+
+
+
+ + {/* SETTINGS */} +
+

āš™ļø Settings

+

Configure system-wide settings and integrations.

+
+ +
+

āš™ļø How-To: Configure Settings

+
+ +
    +
  1. Click "Settings" in System
  2. +
  3. View configuration options
  4. +
+
+ +
    +
  1. Go to Email tab
  2. +
  3. Enter SendGrid API key and email
  4. +
+
+ +
    +
  1. Go to API tab
  2. +
  3. Add payment and Verifayda keys
  4. +
+
+
+
+
+
+
+
+ +
+
+

Ā© 2026 Ethio-Djibouti Railway | Passenger Backoffice Documentation v1.0

+
+
+
+ ); +}; + +export default DocPage; diff --git a/apps/edr-passenger-web/backoffice/src/app/how-to/page.tsx b/apps/edr-passenger-web/backoffice/src/app/how-to/page.tsx new file mode 100644 index 000000000..20cdd419a --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/how-to/page.tsx @@ -0,0 +1,537 @@ +'use client'; + +import React, { useState } from 'react'; +import Link from 'next/link'; +import { ChevronDown, ChevronRight, FileText, Home } from 'lucide-react'; + +const HowToPage = () => { + const scrollToSection = (id: string) => { + setTimeout(() => { + const element = document.getElementById(id); + if (element) { + const headerOffset = 120; + const elementPosition = element.getBoundingClientRect().top + window.pageYOffset; + const offsetPosition = elementPosition - headerOffset; + window.scrollTo({ + top: offsetPosition, + behavior: 'smooth' + }); + } + }, 0); + }; + + const guides = [ + { id: 'bookings', title: 'How to Manage Bookings', icon: 'šŸ“‹' }, + { id: 'passengers', title: 'How to Manage Passengers', icon: 'šŸ‘„' }, + { id: 'pricing', title: 'How to Configure Pricing', icon: 'šŸ’°' }, + { id: 'schedules', title: 'How to Create Schedules', icon: 'šŸ“…' }, + { id: 'seats', title: 'How to Manage Seats', icon: 'šŸ’ŗ' }, + { id: 'loyalty', title: 'How to Manage Loyalty', icon: 'šŸ†' }, + ]; + + return ( +
+
+
+
+ +
+

How-To Guides

+

Step-by-step instructions for common tasks

+
+
+ + + Back to Docs + +
+
+ +
+
+
+
+ +
+
+ +
+
+ + {/* Bookings How-To */} +
+

šŸ“‹ How to Manage Bookings

+

Learn how to search, view, modify, and cancel passenger bookings in the system.

+ +
+
+
+
1
+
+

Access the Bookings Page

+
    +
  1. Click on "Bookings" in the Operations section of the sidebar
  2. +
  3. The page loads showing a table with all bookings
  4. +
  5. You'll see columns: Reference, Passenger, Status, Amount, Payment, Created date
  6. +
+
+

šŸ“ Path: Sidebar → Operations → Bookings

+
+
+
+
+ +
+
+
2
+
+

Search for a Booking

+
    +
  1. Find the search box at the top of the booking table
  2. +
  3. Type in: booking reference (e.g., "BK123"), email, or phone number
  4. +
  5. Results update in real-time as you type
  6. +
  7. Optional: Use the Status dropdown to filter (All, Pending Payment, Confirmed, Cancelled, Completed)
  8. +
+
+

šŸ’” Tip: Search is case-insensitive and supports partial matches

+
+
+
+
+ +
+
+
3
+
+

View Booking Details

+
    +
  1. Find the booking in the table
  2. +
  3. Click the "View Details" button on the right side
  4. +
  5. Modal window opens showing complete information: +
      +
    • Booking reference and status
    • +
    • Passenger name and contact details
    • +
    • Journey information (schedule, adults, children)
    • +
    • Payment details and amount
    • +
    • All metadata and timestamps
    • +
    +
  6. +
+
+
+
+ +
+
+
4
+
+

Cancel a Booking with Refund

+
    +
  1. Find the booking in the table
  2. +
  3. Click the "Cancel Booking" button (red)
  4. +
  5. Confirmation dialog appears
  6. +
  7. Click "Confirm" to proceed
  8. +
  9. System calculates and processes refund: +
      +
    • Confirmed bookings: 80% refund
    • +
    • Pending bookings: 0% refund
    • +
    +
  10. +
  11. Status changes to "CANCELLED"
  12. +
  13. Success message appears
  14. +
+
+

āš ļø Important: Cannot be undone. Seats are automatically released.

+
+
+
+
+ +
+
+
5
+
+

Export Bookings

+
    +
  1. Click the "Export" button (top-right)
  2. +
  3. CSV file downloads automatically
  4. +
  5. Includes all current filters applied
  6. +
  7. Use for external analysis or backup
  8. +
+
+
+
+
+
+ + {/* Passengers How-To */} +
+

šŸ‘„ How to Manage Passengers

+

Learn how to search, filter, and view passenger profiles with loyalty and verification data.

+ +
+
+
+
1
+
+

Access Passengers Page

+
    +
  1. Click "Passengers" in the Operations section
  2. +
  3. Page displays all passenger profiles
  4. +
  5. Default view shows 20 passengers per page
  6. +
+
+
+
+ +
+
+
2
+
+

Search & Filter

+
+
+

Search by:

+
    +
  • Full name
  • +
  • Email address
  • +
  • Phone number
  • +
  • National ID
  • +
+
+
+

Filter by:

+
    +
  • Nationality: Ethiopian, Djiboutian, Other
  • +
  • Verifayda Status: Verified, Unverified, Pending
  • +
  • Loyalty Tier: Bronze, Silver, Gold, Platinum
  • +
+
+
+
+
+
+ +
+
+
3
+
+

View Complete Profile

+
    +
  1. Click on any passenger row
  2. +
  3. Detailed profile modal opens showing: +
      +
    • Account info (email, phone, nationality)
    • +
    • Verifayda verification status
    • +
    • Loyalty tier and points
    • +
    • Wallet balance
    • +
    • Booking history with links
    • +
    +
  4. +
+
+

ā„¹ļø Note: Read-only view. Updates via passenger portal.

+
+
+
+
+
+
+ + {/* Pricing How-To */} +
+

šŸ’° How to Configure Pricing

+

Learn how to set up dynamic fares with segment pricing and nationality overrides.

+ +
+
+
+
1
+
+

Access Pricing Page

+
    +
  1. Click "Pricing & Fares" in Financial section
  2. +
  3. Two tabs: Schedule Fares and Segment Fares
  4. +
  5. Default tab shows Schedule Fares
  6. +
+
+
+
+ +
+
+
2
+
+

Create Schedule Fare Rule

+
    +
  1. Click "Add Fare Rule"
  2. +
  3. Fill in form: +
      +
    • Schedule (optional): Leave empty for global
    • +
    • Route Code (optional): e.g., "ADD-DJI"
    • +
    • Seat Class (required): Economy Regular, VIP Bed, etc.
    • +
    • Fare in ETB (required): e.g., 350.00
    • +
    • Passenger Type (optional): ADULT or CHILD
    • +
    • Nationality (optional): Ethiopian, Djiboutian, Other
    • +
    • Valid From & Until: Set date range
    • +
    +
  4. +
  5. Click "Save Fare Rule"
  6. +
+
+
+
+ +
+
+
3
+
+

Create Segment Fare Rule

+
    +
  1. Click "Add Fare Rule"
  2. +
  3. Switch to "Segment Fares" tab
  4. +
  5. Select route from dropdown
  6. +
  7. Fill in form: +
      +
    • Origin Station (required): Starting point
    • +
    • Destination Station (required): Must be after origin
    • +
    • Seat Class (required): Class type
    • +
    • Fare in ETB (required): Segment price
    • +
    +
  8. +
  9. Click "Save Segment Fare Rule"
  10. +
+
+

Example: ADD (Stop 1) to DDA (Stop 4) at 250 ETB

+
+
+
+
+
+
+ + {/* Schedules How-To */} +
+

šŸ“… How to Create Schedules

+

Learn how to create schedules manually or in bulk with recurring patterns.

+ +
+
+
+
1
+
+

Create Single Schedule

+
    +
  1. Go to Schedules page (Master Data)
  2. +
  3. Click "Create Schedule"
  4. +
  5. Fill in required fields: +
      +
    • Train: Select from dropdown
    • +
    • Route: Select from dropdown
    • +
    • Departure Date & Time: Pick from date/time picker
    • +
    • Arrival Date & Time: Must be after departure
    • +
    +
  6. +
  7. Select coaches to assign
  8. +
  9. Click "Create Schedule"
  10. +
+
+
+
+ +
+
+
2
+
+

Bulk Generate Recurring Schedules

+
    +
  1. Click "Bulk Generate" button
  2. +
  3. Fill in generation form: +
      +
    • Train (required): Select train
    • +
    • Route (required): Select route
    • +
    • Start Date & Time (required): First departure
    • +
    • Duration (Hours): Trip length
    • +
    • Repeat Every (Days): Daily or custom
    • +
    • For Next (Days): How many days
    • +
    +
  4. +
  5. Review preview showing number of schedules
  6. +
  7. Click "Generate Schedules"
  8. +
+
+

Example: 30 days Ć· 1 day = ~30 daily schedules

+
+
+
+
+
+
+ + {/* Seats How-To */} +
+

šŸ’ŗ How to Manage Seats

+

Learn how to view, block, and manage seat inventory using visual seat maps.

+ +
+
+
+
1
+
+

View Seat Map

+
    +
  1. Go to Seats page (Master Data)
  2. +
  3. Select a coach from dropdown
  4. +
  5. Visual seat map displays
  6. +
  7. Color-coded by status: +
      +
    • 🟢 Green: Available
    • +
    • 🟔 Yellow: Held
    • +
    • šŸ”µ Blue: Booked
    • +
    • šŸ”“ Red: Blocked
    • +
    +
  8. +
+
+
+
+ +
+
+
2
+
+

Block a Seat

+
    +
  1. Click on an available (green) seat
  2. +
  3. Click "Block" button
  4. +
  5. Select reason: +
      +
    • Maintenance
    • +
    • Reserved
    • +
    • Damaged
    • +
    +
  6. +
  7. Set until date (optional)
  8. +
  9. Add notes
  10. +
  11. Click "Block Seat"
  12. +
  13. Seat turns red
  14. +
+
+
+
+ +
+
+
3
+
+

Unblock a Seat

+
    +
  1. Click on a blocked (red) seat
  2. +
  3. Click "Unblock" button
  4. +
  5. Confirm action
  6. +
  7. Seat becomes available (green)
  8. +
+
+
+
+
+
+ + {/* Loyalty How-To */} +
+

šŸ† How to Manage Loyalty Program

+

Learn how to view loyalty accounts, manage points, and administer rewards.

+ +
+
+
+
1
+
+

View Loyalty Accounts

+
    +
  1. Go to Loyalty Program (Customer Services)
  2. +
  3. Table displays all loyalty accounts
  4. +
  5. Columns: Name, Tier, Points Balance, Lifetime Points
  6. +
  7. Search by name or filter by tier
  8. +
+
+
+
+ +
+
+
2
+
+

Adjust Points

+
    +
  1. Click on a loyalty account
  2. +
  3. Click "Adjust Points" button
  4. +
  5. Enter points to add/subtract
  6. +
  7. Select reason: Bonus, Correction, Promotion, etc.
  8. +
  9. Add optional notes
  10. +
  11. Click "Apply"
  12. +
  13. Balance updates immediately
  14. +
+
+
+
+ +
+
+
3
+
+

Award Rewards

+
    +
  1. Click on a loyalty account
  2. +
  3. Click "Grant Reward" button
  4. +
  5. Select reward from list
  6. +
  7. Specify quantity if applicable
  8. +
  9. Click "Award"
  10. +
  11. Confirmation email sent to passenger
  12. +
+
+
+
+
+
+ + {/* Common Tips */} +
+

šŸ’” Common Tips & Tricks

+
    +
  • Keyboard Shortcuts: Tab to navigate, Enter to submit
  • +
  • Pagination: Change page size or jump to specific page
  • +
  • Sidebar Collapse: Use chevron to minimize sidebar
  • +
  • Dark Mode: Toggle with sun/moon icon in header
  • +
  • Error Messages: Red text above forms if validation fails
  • +
  • Success Notifications: Green banner appears for 3 seconds
  • +
  • Undo Not Available: Most actions cannot be undone
  • +
  • Real-time Updates: Refresh page to see changes by other users
  • +
+
+
+
+
+
+ +
+
+

Ā© 2026 Ethio-Djibouti Railway | How-To Guides v1.0

+

Last Updated: January 15, 2026

+
+
+
+ ); +}; + +export default HowToPage; diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index 36db98a34..2c8e50a0e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -52,6 +52,44 @@ export default function PassengersPage() { console.error('Passengers API Error:', error); } + const handleExportPassengers = async () => { + const selectedColumns = prompt( + 'Select columns to export (comma-separated):\n\n' + + 'Available: fullName, email, phone, dateOfBirth, gender, nationality, verified\n\n' + + 'Default: fullName, email, phone, gender, nationality, verified', + 'fullName, email, phone, gender, nationality, verified' + ); + + if (!selectedColumns) return; + + const cols = selectedColumns.split(',').map(c => c.trim()); + const csv = [ + cols.join(','), + ...data?.items?.map((passenger: any) => { + const values = cols.map(col => { + switch(col) { + case 'fullName': return passenger.fullName; + case 'email': return passenger.email || ''; + case 'phone': return passenger.phone || ''; + case 'dateOfBirth': return passenger.dateOfBirth ? formatDate(passenger.dateOfBirth) : ''; + case 'gender': return passenger.gender || ''; + case 'nationality': return passenger.nationality || ''; + case 'verified': return passenger.nationalId ? 'Yes' : 'No'; + default: return ''; + } + }); + return values.map(v => `"${v}"`).join(','); + }) || [] + ].join('\n'); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + }; + const columns = [ { key: 'fullName', @@ -67,16 +105,25 @@ export default function PassengersPage() { { key: 'phone', label: 'Phone', + sortable: true, render: (passenger: any) => passenger.phone, }, { - key: 'nationalId', - label: 'National ID', - render: (passenger: any) => passenger.nationalId || 'N/A', + key: 'gender', + label: 'Gender', + sortable: true, + render: (passenger: any) => passenger.gender || 'N/A', + }, + { + key: 'nationality', + label: 'Nationality', + sortable: true, + render: (passenger: any) => passenger.nationality || 'N/A', }, { key: 'dateOfBirth', label: 'Date of Birth', + sortable: true, render: (passenger: any) => passenger.dateOfBirth ? formatDate(passenger.dateOfBirth) : 'N/A', }, { @@ -113,7 +160,7 @@ export default function PassengersPage() {

Manage passenger profiles and verification

- Export + Export
@@ -230,10 +277,6 @@ export default function PassengersPage() {

Identification

-
- -

{selectedPassenger.nationalId || 'N/A'}

-

{selectedPassenger.passportNumber || 'N/A'}

diff --git a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx index b27f559f8..a2dcae6aa 100644 --- a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx @@ -50,6 +50,7 @@ export default function PricingPage() { seatClassId: '', baseFare: '', nationality: '', + passengerCategory: '', route: '', validFrom: new Date().toISOString().split('T')[0], validUntil: '', @@ -61,6 +62,7 @@ export default function PricingPage() { destinationStationId: '', baseFare: '', nationality: '', + passengerCategory: '', validFrom: new Date().toISOString().split('T')[0], validUntil: '', }); @@ -87,7 +89,17 @@ export default function PricingPage() { const { data: fares = [], isLoading: faresLoading, refetch: refetchFares } = useQuery({ queryKey: ['schedule-fares', selectedSchedule], - queryFn: () => (selectedSchedule ? apiClient.get(`/schedules/${selectedSchedule}/fares/all`) : Promise.resolve([])), + queryFn: async () => { + if (!selectedSchedule) return []; + try { + const response = await apiClient.get(`/schedules/${selectedSchedule}/fares/all`); + return Array.isArray(response) ? response : (response as any)?.data || []; + } catch (err: any) { + const errMsg = err.response?.data?.message || err.message || 'Failed to load fares'; + setError(`Error loading fares: ${errMsg}`); + return []; + } + }, enabled: !!selectedSchedule && tab === 'schedule', }); @@ -174,6 +186,7 @@ export default function PricingPage() { seatClassId: '', baseFare: '', nationality: '', + passengerCategory: '', route: '', validFrom: new Date().toISOString().split('T')[0], validUntil: '', @@ -189,6 +202,7 @@ export default function PricingPage() { destinationStationId: '', baseFare: '', nationality: '', + passengerCategory: '', validFrom: new Date().toISOString().split('T')[0], validUntil: '', }); @@ -198,13 +212,11 @@ export default function PricingPage() { const handleEditFare = (fare: any) => { setEditingFare(fare); - const fareValue = fare.baseFare || fare.baseFareMinor || 0; - const etbValue = fareValue > 100 ? (fareValue / 100).toString() : fareValue.toString(); - setFareForm({ seatClassId: fare.seatClassId || '', - baseFare: etbValue, + baseFare: (fare.baseFare || fare.baseFareMinor || 0).toString(), nationality: fare.nationality || '', + passengerCategory: fare.passengerCategory || '', route: fare.route || '', validFrom: fare.validFrom ? new Date(fare.validFrom).toISOString().split('T')[0] : new Date().toISOString().split('T')[0], validUntil: fare.validUntil ? new Date(fare.validUntil).toISOString().split('T')[0] : '', @@ -215,9 +227,6 @@ export default function PricingPage() { const handleEditSegmentFare = (fare: any) => { setEditingFare(fare); - const fareValue = fare.baseFare || fare.baseFareMinor || 0; - const etbValue = fareValue > 100 ? (fareValue / 100).toString() : fareValue.toString(); - const routeStops = currentRoute?.stops || []; const originStop = routeStops.find((s: any) => s.sequence === fare.originStopSequence); const destStop = routeStops.find((s: any) => s.sequence === fare.destinationStopSequence); @@ -226,8 +235,9 @@ export default function PricingPage() { seatClassId: fare.seatClassId || '', originStationId: originStop?.stationId || '', destinationStationId: destStop?.stationId || '', - baseFare: etbValue, + baseFare: (fare.baseFare || fare.baseFareMinor || 0).toString(), nationality: fare.nationality || '', + passengerCategory: fare.passengerCategory || '', validFrom: fare.validFrom ? new Date(fare.validFrom).toISOString().split('T')[0] : new Date().toISOString().split('T')[0], validUntil: fare.validUntil ? new Date(fare.validUntil).toISOString().split('T')[0] : '', }); @@ -242,7 +252,7 @@ export default function PricingPage() { return; } - const baseFareMinor = Math.round(parseFloat(fareForm.baseFare) * 100); + const baseFareMinor = parseInt(fareForm.baseFare, 10); if (editingFare) { await updateFareMutation.mutateAsync({ @@ -250,6 +260,7 @@ export default function PricingPage() { seatClassId: fareForm.seatClassId, baseFareMinor, nationality: fareForm.nationality || undefined, + passengerCategory: fareForm.passengerCategory || undefined, route: fareForm.route || undefined, validFrom: fareForm.validFrom, validUntil: fareForm.validUntil || undefined, @@ -260,6 +271,7 @@ export default function PricingPage() { seatClassId: fareForm.seatClassId, baseFareMinor, nationality: fareForm.nationality || undefined, + passengerCategory: fareForm.passengerCategory || undefined, route: fareForm.route || undefined, validFrom: fareForm.validFrom, validUntil: fareForm.validUntil || undefined, @@ -288,7 +300,7 @@ export default function PricingPage() { return; } - const baseFareMinor = Math.round(parseFloat(segmentForm.baseFare) * 100); + const baseFareMinor = parseInt(segmentForm.baseFare, 10); if (editingFare) { await updateSegmentFareMutation.mutateAsync({ @@ -299,6 +311,7 @@ export default function PricingPage() { destinationStopSequence: destStop.sequence, baseFareMinor, nationality: segmentForm.nationality || undefined, + passengerCategory: segmentForm.passengerCategory || undefined, validFrom: segmentForm.validFrom, validUntil: segmentForm.validUntil || undefined, }); @@ -310,6 +323,7 @@ export default function PricingPage() { destinationStopSequence: destStop.sequence, baseFareMinor, nationality: segmentForm.nationality || undefined, + passengerCategory: segmentForm.passengerCategory || undefined, validFrom: segmentForm.validFrom, validUntil: segmentForm.validUntil || undefined, }); @@ -343,14 +357,20 @@ export default function PricingPage() { return {className}; }, }, + { + key: 'passengerCategory', + label: 'Passenger Type', + render: (fare: any) => ( + {fare.passengerCategory || 'All'} + ), + }, { key: 'baseFare', label: 'Fare (ETB)', render: (fare: any) => { const fareValue = fare.baseFare || fare.baseFareMinor; if (!fareValue && fareValue !== 0) return N/A; - const etbValue = fareValue > 100 ? (fareValue / 100).toFixed(2) : parseFloat(fareValue).toFixed(2); - return {etbValue} ETB; + return {fareValue} ETB; }, }, { @@ -408,14 +428,20 @@ export default function PricingPage() { return {className}; }, }, + { + key: 'passengerCategory', + label: 'Passenger Type', + render: (fare: any) => ( + {fare.passengerCategory || 'All'} + ), + }, { key: 'baseFare', label: 'Fare (ETB)', render: (fare: any) => { const fareValue = fare.baseFare || fare.baseFareMinor; if (!fareValue && fareValue !== 0) return N/A; - const etbValue = fareValue > 100 ? (fareValue / 100).toFixed(2) : parseFloat(fareValue).toFixed(2); - return {etbValue} ETB; + return {fareValue} ETB; }, }, { @@ -449,12 +475,14 @@ export default function PricingPage() { onClick: tab === 'schedule' ? handleEditFare : handleEditSegmentFare, variant: 'secondary' as const, icon: Edit, + disabled: tab === 'schedule', // Schedule fares are computed, not stored }, { label: 'Delete', onClick: (fare: any) => setDeleteConfirm({ isOpen: true, id: fare.id }), variant: 'danger' as const, icon: Trash2, + disabled: tab === 'schedule', // Schedule fares are computed, not stored }, ]; @@ -463,7 +491,7 @@ export default function PricingPage() {

Pricing & Fares

-

Manage fares by schedule and route segments

+

Manage fares by schedule and route segments with passenger type pricing

-

Fare Rules

+

Calculated Fares

+
+ These are dynamically calculated fares based on the fare engine. To create custom override fares, click "Add Fare Rule" above. +
{faresLoading ? (
) : faresArray.length === 0 ? (
- {`No fares defined. Click "Add Fare Rule" to create one.`} + No fares available for this schedule.
) : ( <>
- {faresArray.length} fare rule(s) found + {faresArray.length} seat class(es) available
)} @@ -632,16 +665,16 @@ export default function PricingPage() {

Pricing Structure

  • - • Schedule Fares: Set custom pricing for each schedule by seat class + • Schedule Fares: Set custom pricing for each schedule by seat class and passenger type
  • • Segment Fares: Set fares for specific stop-to-stop segments (e.g., Addis → Dire Dawa)
  • - • Nationality-based: Override fares for specific nationalities + • Passenger Type: ADULT (5+ years) or CHILD (<5) — first child travels free, subsequent children pay full fare
  • - • Age-Based Pricing: ADULT (5+ years) pays 100%, CHILD (<5) first child FREE, subsequent children 100% + • Nationality-based: Override fares for specific nationalities (Ethiopian, Djiboutian, Other)
@@ -732,28 +765,44 @@ export default function PricingPage() { setFareForm({ ...fareForm, baseFare: e.target.value })} className="input w-full" - placeholder="e.g., 350.00" + placeholder="e.g., 350" required />
-
- - +
+
+ + +

Scope pricing to specific passenger type

+
+ +
+ + +
@@ -846,28 +895,44 @@ export default function PricingPage() { setSegmentForm({ ...segmentForm, baseFare: e.target.value })} className="input w-full" - placeholder="e.g., 150.00" + placeholder="e.g., 150" required />
-
- - +
+
+ + +

Scope pricing to specific passenger type

+
+ +
+ + +
@@ -900,7 +965,8 @@ export default function PricingPage() {
  • • Schedule: Apply to specific schedule only
  • • Route Code: Apply to all schedules on that route
  • -
  • • Nationality: Override for specific passenger nationalities
  • +
  • • Passenger Type: ADULT or CHILD pricing
  • +
  • • Nationality: Override for specific nationalities
  • • All empty: Apply globally to all schedules
)} @@ -908,6 +974,7 @@ export default function PricingPage() {
  • • Segments: Define pricing for specific stop-to-stop segments
  • • Stops: Use sequence numbers from the route
  • +
  • • Passenger Type: ADULT or CHILD pricing
  • • Nationality: Optional scope to specific nationalities
)} diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 943b4fb5a..0f56f0e15 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -220,28 +220,15 @@ export default function RoutesPage() { setOriginStationId(routeStops[0].stationId); setDestinationStationId(routeStops[routeStops.length - 1].stationId); - // Calculate cumulative distance for destination - let cumulativeDistance = 0; - routeStops.forEach((stop: any, idx: number) => { - if (idx > 0) { - cumulativeDistance += stop.distanceKm || 0; - } - }); - setDestinationDistance(cumulativeDistance); - - // Calculate distance from origin for middle stops - const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => { - let distFromOrigin = 0; - for (let i = 1; i <= idx + 1; i++) { - distFromOrigin += routeStops[i].distanceKm || 0; - } - return { - stationId: stop.stationId, - sequence: stop.sequence, - distanceKm: stop.distanceKm, - distanceFromOrigin: distFromOrigin, - }; - }); + // Last stop's distanceKm is already cumulative from origin + setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0); + + const middleStops = routeStops.slice(1, -1).map((stop: any) => ({ + stationId: stop.stationId, + sequence: stop.sequence, + distanceKm: stop.distanceKm, + distanceFromOrigin: stop.distanceKm || 0, + })); setStops(middleStops); } setShowModal(true); diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index a40fbbc02..1207a01e4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -40,6 +40,7 @@ interface Coach { number: string; coachNumber?: string; capacity: number; + sequence?: number; coachType?: { name: string }; } @@ -203,14 +204,11 @@ export default function SchedulesPage() { departureAt: editForm.departureAt, arrivalAt: editForm.arrivalAt, status: editForm.status, - }; - - if (editForm.coachIds.length > 0) { - payload.coaches = editForm.coachIds.map((coachId: string, idx: number) => ({ + coaches: editForm.coachIds.map((coachId: string, idx: number) => ({ coachId, positionNumber: idx + 1, - })); - } + })), + }; await updateScheduleMutation.mutateAsync({ id: editingSchedule.id, @@ -326,14 +324,20 @@ export default function SchedulesPage() { ), }, { - key: 'originStation.name', - label: 'From', - render: (schedule: Schedule) => {schedule.originStation?.name}, - }, - { - key: 'destinationStation.name', - label: 'To', - render: (schedule: Schedule) => {schedule.destinationStation?.name}, + key: 'route', + label: 'Route', + sortable: true, + render: (schedule: Schedule) => ( +
+ + {schedule.originStation?.name || 'Unknown'} + + → + + {schedule.destinationStation?.name || 'Unknown'} + +
+ ), }, { key: 'departureAt', @@ -630,7 +634,22 @@ export default function SchedulesPage() {
- +
+ + +
{coaches.length === 0 ? (

No coaches available

@@ -656,7 +675,7 @@ export default function SchedulesPage() { className="rounded" /> - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity}) + Seq {coach.sequence || 'N/A'} - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity}) )) @@ -765,7 +784,22 @@ export default function SchedulesPage() {
- +
+ + +
{coaches.length === 0 ? (

No coaches available

@@ -791,7 +825,7 @@ export default function SchedulesPage() { className="rounded" /> - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity}) + Seq {coach.sequence || 'N/A'} - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity}) )) diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index 5d6a1b325..8b1ad4072 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -14,6 +14,11 @@ export default function SeatsPage() { const [showRemoveModal, setShowRemoveModal] = useState(false); const [selectedSeat, setSelectedSeat] = useState(null); const [blockReason, setBlockReason] = useState(''); + const [showBlockCoachModal, setShowBlockCoachModal] = useState(false); + const [selectedCoach, setSelectedCoach] = useState(null); + const [blockCoachReason, setBlockCoachReason] = useState(''); + const [showUnblockCoachModal, setShowUnblockCoachModal] = useState(false); + const [coachToUnblock, setCoachToUnblock] = useState(null); const queryClient = useQueryClient(); const { data: schedulesData } = useQuery({ @@ -68,6 +73,33 @@ export default function SeatsPage() { const schedules = schedulesData?.items || schedulesData?.data || []; const coaches = seatMapData?.coaches || []; + const blockCoachMutation = useMutation({ + mutationFn: async ({ coachId, reason }: any) => { + const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || []; + const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id); + return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason }))); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + setShowBlockCoachModal(false); + setSelectedCoach(null); + setBlockCoachReason(''); + }, + }); + + const unblockCoachMutation = useMutation({ + mutationFn: async ({ coachId }: any) => { + const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || []; + const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id); + return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId))); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + setShowUnblockCoachModal(false); + setCoachToUnblock(null); + }, + }); + const toggleCoach = (coachId: string) => { const newExpanded = new Set(expandedCoaches); if (newExpanded.has(coachId)) { @@ -100,6 +132,43 @@ export default function SeatsPage() { } }; + const handleBlockCoach = (coach: any) => { + setSelectedCoach(coach); + setShowBlockCoachModal(true); + }; + + const handleUnblockCoach = (coach: any) => { + const isBlocked = coach.seats?.some((s: any) => s.status === 'BLOCKED' || s.isBlocked); + if (isBlocked) { + setCoachToUnblock(coach); + setShowUnblockCoachModal(true); + } + }; + + const confirmUnblockCoach = async () => { + if (coachToUnblock) { + await unblockCoachMutation.mutateAsync({ coachId: coachToUnblock.id }); + } + }; + + const isCoachBlocked = (coach: any) => { + const seats = (coach.seats || []).filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')); + return seats.length > 0 && seats.every((s: any) => s.status === 'BLOCKED' || s.isBlocked); + }; + + const isCoachUnblocked = (coach: any) => { + const seats = (coach.seats || []).filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')); + return seats.length > 0 && seats.every((s: any) => s.status !== 'BLOCKED' && !s.isBlocked); + }; + + const submitBlockCoach = async () => { + if (!blockCoachReason.trim()) { + alert('Please provide a reason for blocking'); + return; + } + await blockCoachMutation.mutateAsync({ coachId: selectedCoach.id, reason: blockCoachReason }); + }; + const submitBlock = async () => { if (!blockReason.trim()) { alert('Please provide a reason for blocking'); @@ -346,10 +415,17 @@ export default function SeatsPage() { ); }; - const coachesWithSeats = coaches.filter((coach: any) => { - const seats = (coach.seats || []).filter((s: any) => s.seatNumber); - return seats.length > 0; - }); + const coachesWithSeats = coaches + .filter((coach: any) => { + const seats = (coach.seats || []).filter((s: any) => s.seatNumber); + return seats.length > 0; + }) + .sort((a: any, b: any) => { + // Try multiple sequence field possibilities + const seqA = a.positionNumber ?? a.sequence ?? a.coach?.sequence ?? 999; + const seqB = b.positionNumber ?? b.sequence ?? b.coach?.sequence ?? 999; + return seqA - seqB; + }); return (
@@ -389,14 +465,34 @@ export default function SeatsPage() {

Loading seats...

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

No coaches with seats found for this schedule

+
+
+ + +
+
+

No coaches with seats found for this schedule

+
) : (
- {/* Left Column: Schedule Selector & Legends */}
- {/* Schedule Selector */}