diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e46409ba6..a819f7939 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,5 +1,4 @@ name: Deploy Stacks - on: push: branches: @@ -8,50 +7,129 @@ on: - staging workflow_dispatch: +permissions: + contents: read + concurrency: group: deploy-${{ github.ref_name }} cancel-in-progress: true jobs: + detect-changes: + name: Detect changed services + runs-on: self-hosted + outputs: + matrix: ${{ steps.filter.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Determine changed services + id: filter + run: | + set -euo pipefail + + ALL_SERVICES=( + "freight-api" + "freight-portal" + "freight-backoffice" + "passenger-api" + "passenger-portal" + "passenger-backoffice" + "payment-api" + ) + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .) + echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + CHANGED=$(git diff --name-only HEAD~1 HEAD) + echo "=== Changed files ===" + echo "$CHANGED" + echo "=====================" + + SERVICES=() + + NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$" + + GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" + + DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true) + if [ -z "$DEPLOYABLE" ]; then + echo "Only non-deployable files changed. Skipping deploy." + echo "matrix=[]" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if echo "$CHANGED" | grep -qE "$GLOBAL_PATTERN"; then + echo "Global file(s) changed — deploying all services." + JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .) + echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api") + echo "$CHANGED" | grep -q "^apps/edr-freight-portal/" && SERVICES+=("freight-portal") + echo "$CHANGED" | grep -q "^apps/edr-freight-backoffice/" && SERVICES+=("freight-backoffice") + echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api") + echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") + echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") + echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api") + + SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u)) + + if [ ${#SERVICES[@]} -eq 0 ]; then + echo "No deployable service changes detected." + echo "matrix=[]" >> "$GITHUB_OUTPUT" + else + echo "Services to deploy: ${SERVICES[*]}" + JSON=$(printf '%s\n' "${SERVICES[@]}" | jq -R . | jq -sc .) + echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" + fi + deploy: name: Deploy ${{ matrix.service }} + needs: detect-changes + if: ${{ needs.detect-changes.outputs.matrix != '[]' }} runs-on: self-hosted strategy: fail-fast: false matrix: - include: - - project: edr-freight - build_env_file: freight-web.build.env - service: freight-api - # - project: edr-freight - # build_env_file: freight-web.build.env - # service: freight-portal - # - project: edr-freight - # build_env_file: freight-web.build.env - # service: freight-backoffice - - project: edr-passenger - build_env_file: passenger-web.build.env - service: passenger-api - - project: edr-passenger - build_env_file: passenger-web.build.env - service: passenger-portal - - project: edr-passenger - build_env_file: passenger-web.build.env - service: passenger-backoffice - - project: edr-payment - build_env_file: payment-web.build.env - service: payment-api + service: ${{ fromJson(needs.detect-changes.outputs.matrix) }} env: - PROJECT: ${{ matrix.project }} BRANCH: ${{ github.ref_name }} DEPLOY_USER: tria - BUILD_ENV_FILE: ${{ matrix.build_env_file }} DOCKER_BUILDKIT: "1" COMPOSE_DOCKER_CLI_BUILD: "1" + steps: - name: Checkout uses: actions/checkout@v4 + - name: Resolve project and build env file + run: | + case "${{ matrix.service }}" in + freight-api|freight-portal|freight-backoffice) + echo "PROJECT=edr-freight" >> "$GITHUB_ENV" + echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV" + ;; + passenger-api|passenger-portal|passenger-backoffice) + echo "PROJECT=edr-passenger" >> "$GITHUB_ENV" + echo "BUILD_ENV_FILE=passenger-web.build.env" >> "$GITHUB_ENV" + ;; + payment-api) + echo "PROJECT=edr-payment" >> "$GITHUB_ENV" + echo "BUILD_ENV_FILE=payment-web.build.env" >> "$GITHUB_ENV" + ;; + *) + echo "Unknown service: ${{ matrix.service }}" && exit 1 + ;; + esac + - name: Sync environment from server run: | chmod +x scripts/deploy/*.sh diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index 1a9c71990..b2500160d 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -88,6 +88,19 @@ WAAFI_INSECURE_TLS=false # Payment Configuration PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI +# Browser return targets after a hosted payment page (UX only — payment is confirmed by the +# webhook/queryStatus, never this redirect). Global fallback used when a method-specific URL +# below is unset. Most providers use a single redirect; Waafi takes separate success/failure. +PAYMENT_RETURN_URL= +PAYMENT_FAILURE_URL= +TELEBIRR_RETURN_URL= +WAAFI_SUCCESS_REDIRECT= +WAAFI_FAIL_REDIRECT= +DMONEY_RETURN_URL= +CBE_RETURN_URL= +EBIRR_RETURN_URL= +CARD_RETURN_URL= + # Session Configuration SESSION_INACTIVITY_MINUTES=30 diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 4ff9ba2c6..84ea472d4 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -19,7 +19,6 @@ "prisma:backfill": "ts-node prisma/backfill-fields.ts", "prisma:verify": "ts-node prisma/verify-backfill.ts" }, - "dependencies": { "@edr/types": "workspace:*", "@golevelup/nestjs-rabbitmq": "^5.5.0", @@ -29,6 +28,7 @@ "@nestjs/core": "^11.1.19", "@nestjs/event-emitter": "^2.0.4", "@nestjs/jwt": "^10.2.0", + "@nestjs/microservices": "^11.1.24", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^11.1.19", "@nestjs/schedule": "^6.1.3", @@ -47,7 +47,8 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.0", - "tsconfig-paths": "^4.2.0" + "tsconfig-paths": "^4.2.0", + "uuid": "^10.0.0" }, "devDependencies": { "@edr/eslint-config": "workspace:*", @@ -62,6 +63,7 @@ "@types/passport-jwt": "^4.0.1", "@types/qrcode": "^1.5.5", "@types/supertest": "^6.0.2", + "@types/uuid": "^9.0.0", "jest": "^29.7.0", "prisma": "^6.19.3", "supertest": "^7.0.0", 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..26c9c9eb3 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") } @@ -628,20 +633,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 17283e15b..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, }); @@ -422,6 +496,7 @@ async function seedPaymentMethods() { { type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA' }, { type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA' }, { type: 'EBIRR', displayName: 'eBirr', region: 'ETHIOPIA' }, + { type: 'WAAFI', displayName: 'Waffi', region: 'DJIBOUTI' }, { type: 'CARD', displayName: 'Credit/Debit Card', region: 'GLOBAL' }, { type: 'WALLET', displayName: 'Wallet', region: 'GLOBAL' }, ]; @@ -436,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}}' }, ]; @@ -476,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`); @@ -493,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) { @@ -583,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 b747b77c1..757432c3b 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { PrismaModule } from './common/prisma.module'; +import { AuditModule } from './common/audit.module'; import { I18nModule } from './common/i18n/i18n.module'; import { IamModule } from './common/iam.module'; import { LocaleMiddleware } from './common/i18n/locale.middleware'; @@ -39,6 +40,8 @@ import { FraudModule } from './modules/fraud/fraud.module'; 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: [ @@ -59,6 +62,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module'; ScheduleModule.forRoot(), EventEmitterModule.forRoot(), PrismaModule, + AuditModule, I18nModule, IamModule, AuthModule, @@ -85,6 +89,8 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module'; SeatClassesModule, FareEngineModule, VerifaydaModule, + AuditModuleFeature, + CurrenciesModule, ], }) export class AppModule implements NestModule { diff --git a/apps/edr-passenger-api/src/common/audit.module.ts b/apps/edr-passenger-api/src/common/audit.module.ts new file mode 100644 index 000000000..a4ba9262f --- /dev/null +++ b/apps/edr-passenger-api/src/common/audit.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from './prisma.module'; +import { AuditService } from './audit.service'; + +@Module({ + imports: [PrismaModule], + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/apps/edr-passenger-api/src/common/audit.service.ts b/apps/edr-passenger-api/src/common/audit.service.ts new file mode 100644 index 000000000..342e786bd --- /dev/null +++ b/apps/edr-passenger-api/src/common/audit.service.ts @@ -0,0 +1,92 @@ +import { Injectable, Inject, Optional } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { PrismaService } from './prisma.service'; + +@Injectable() +export class AuditService { + constructor( + private prisma: PrismaService, + @Optional() @Inject(REQUEST) private request?: any, + ) {} + + async log(input: { + userId?: string; + action: 'CREATE' | 'UPDATE' | 'DELETE' | 'LOGIN' | 'LOGOUT' | 'VERIFY' | string; + entityType: string; + entityId?: string; + oldData?: any; + newData?: any; + }) { + try { + const ipAddress = this.getIpAddress(); + const userAgent = this.getUserAgent(); + + await this.prisma.auditLog.create({ + data: { + userId: input.userId, + action: input.action, + entityType: input.entityType, + entityId: input.entityId, + oldData: input.oldData, + newData: input.newData, + ipAddress, + userAgent, + }, + }); + } catch (error) { + console.error('Failed to log audit event:', error); + // Don't throw - audit logging should not break main operations + } + } + + private getIpAddress(): string { + if (!this.request) return ''; + + return ( + this.request.headers['x-forwarded-for']?.split(',')[0].trim() || + this.request.headers['x-real-ip'] || + this.request.connection?.remoteAddress || + this.request.socket?.remoteAddress || + this.request.ip || + '' + ); + } + + private getUserAgent(): string { + return this.request?.headers?.['user-agent'] || ''; + } + + async getLogs(filters: any = {}) { + const where: any = {}; + + if (filters.search) { + where.OR = [ + { entityId: { contains: filters.search, mode: 'insensitive' } }, + { user: { email: { contains: filters.search, mode: 'insensitive' } } }, + { user: { fullName: { contains: filters.search, mode: 'insensitive' } } }, + ]; + } + + if (filters.action) { + where.action = filters.action; + } + + if (filters.entityType) { + where.entityType = filters.entityType; + } + + return this.prisma.auditLog.findMany({ + where, + include: { user: true }, + orderBy: { createdAt: 'desc' }, + take: 500, // Limit to last 500 logs + }); + } + + async getLog(id: string) { + return this.prisma.auditLog.findUnique({ + where: { id }, + include: { user: true }, + }); + } +} 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/audit/audit.controller.ts b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts new file mode 100644 index 000000000..37bc89855 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { AuditService } from '../../common/audit.service'; +import { IamGuard } from '../../common/iam-adapter'; + +@ApiTags('Audit') +@Controller('audit') +@UseGuards(IamGuard) +@ApiBearerAuth('IAM-auth') +export class AuditController { + constructor(private auditService: AuditService) {} + + @Get('logs') + @ApiOperation({ + summary: 'Get audit logs', + description: 'Retrieve system audit logs with optional filtering', + }) + @ApiQuery({ name: 'search', required: false, description: 'Search by user email or entity ID' }) + @ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, etc.)' }) + @ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (Booking, Station, etc.)' }) + async getLogs( + @Query('search') search?: string, + @Query('action') action?: string, + @Query('entityType') entityType?: string, + ) { + const filters = { + search: search || undefined, + action: action || undefined, + entityType: entityType || undefined, + }; + + const items = await this.auditService.getLogs(filters); + return { items }; + } + + @Get('logs/:id') + @ApiOperation({ summary: 'Get audit log by ID' }) + async getLog(@Param('id') id: string) { + return this.auditService.getLog(id); + } +} diff --git a/apps/edr-passenger-api/src/modules/audit/audit.module.ts b/apps/edr-passenger-api/src/modules/audit/audit.module.ts new file mode 100644 index 000000000..8b161d55c --- /dev/null +++ b/apps/edr-passenger-api/src/modules/audit/audit.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { AuditModule } from '../../common/audit.module'; +import { AuditController } from './audit.controller'; + +@Module({ + imports: [AuditModule, HttpModule], + controllers: [AuditController], +}) +export class AuditModuleFeature {} 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..b36790ce2 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -14,6 +14,18 @@ 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 segment seat ID' }) @IsString() outboundSeatId: string; + @ApiProperty({ description: 'Return segment seat ID' }) @IsString() returnSeatId: string; + @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; + @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD)' }) @IsDateString() dateOfBirth: string; + @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; + @ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string; + @ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string; + @ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string; + @ApiPropertyOptional() @IsOptional() @IsString() nationality?: string; +} + export class CreateBookingDto { @ApiProperty() @IsString() passengerId: string; @ApiProperty() @IsString() scheduleId: string; @@ -29,6 +41,29 @@ export class CreateBookingDto { @ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; } +export class CreateRoundTripBookingDto { + @ApiProperty({ description: 'Passenger ID' }) @IsString() passengerId: string; + + @ApiProperty({ description: 'Outbound schedule ID' }) @IsString() outboundScheduleId: string; + @ApiProperty({ description: 'Outbound origin station ID' }) @IsString() outboundOriginStationId: string; + @ApiProperty({ description: 'Outbound destination station ID' }) @IsString() outboundDestinationStationId: string; + @ApiProperty({ description: 'Outbound seat hold ID' }) @IsString() outboundHoldId: string; + + @ApiProperty({ description: 'Return schedule ID' }) @IsString() returnScheduleId: string; + @ApiProperty({ description: 'Return origin station ID (usually same as outbound destination)' }) @IsString() returnOriginStationId: string; + @ApiProperty({ description: 'Return destination station ID (usually same as outbound origin)' }) @IsString() returnDestinationStationId: string; + @ApiProperty({ description: 'Return seat hold ID' }) @IsString() returnHoldId: string; + + @ApiProperty({ type: [RoundTripPassengerDto], description: 'Array of passengers with seats for both outbound and return legs' }) + @IsArray() @ValidateNested({ each: true }) @Type(() => RoundTripPassengerDto) passengers: RoundTripPassengerDto[]; + + @ApiProperty({ description: 'Seat class ID' }) @IsString() seatClassId: string; + + @ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string; + @ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number; + @ApiPropertyOptional({ example: 'DJF', enum: Currency }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; +} + export class ModifyBookingDto { @ApiProperty() @IsString() bookingRef: string; @ApiProperty({ example: 'schedule-uuid' }) @IsString() newScheduleId: string; diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts index f9a3e0ea4..a588e7330 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; +import { AuditModule } from '../../common/audit.module'; import { BookingsController } from './bookings.controller'; import { BookingsService } from './bookings.service'; import { GuestBookingService } from './guest-booking.service'; @@ -8,7 +9,7 @@ import { VerifaydaModule } from '../verifayda/verifayda.module'; import { CurrencyModule } from '../currency/currency.module'; @Module({ - imports: [SeatsModule, VerifaydaModule, CurrencyModule, HttpModule], + imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule], controllers: [BookingsController], providers: [BookingsService, GuestBookingService], exports: [BookingsService, GuestBookingService] 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..3c8928ea6 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -296,7 +296,7 @@ export class BookingsService { } const primaryNationality = passengersData[0]?.nationality; - const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality); + const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality, originStop.sequence, destStop.sequence); const adultFareMinor = baseFareMinor * adultCount; const paidChildrenCount = Math.max(0, childCount - 1); const childFareMinor = baseFareMinor * paidChildrenCount; @@ -363,8 +363,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.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index 043a3531b..84c0ac2a3 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'; } } @@ -253,7 +250,7 @@ export class FleetService { return this.prisma.coach.findMany({ where, include: { coachType: true }, - orderBy: { number: 'asc' }, + orderBy: { sequence: 'asc' }, }); } @@ -263,10 +260,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', @@ -301,33 +306,6 @@ export class FleetService { async deleteCoach(id: string) { const coach = await this.prisma.coach.findUnique({ where: { id } }); 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 } } }); - } - - // 5. Delete all associated seats - 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/notifications/dtos/email.dto.ts b/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts new file mode 100644 index 000000000..63bbc4a5a --- /dev/null +++ b/apps/edr-passenger-api/src/modules/notifications/dtos/email.dto.ts @@ -0,0 +1,8 @@ +export class SendEmail { + to: string; + subject: string; + body: string; + html?: string; + templateKey?: string; + context?: Record; +} diff --git a/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts b/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts new file mode 100644 index 000000000..1897f7794 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/notifications/dtos/sms.dto.ts @@ -0,0 +1,9 @@ +export class SendMessage { + to: string; + message: string; + from?: string; +} + +export class BulkMessagesDto { + messages: SendMessage[]; +} diff --git a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts new file mode 100644 index 000000000..8fcb82394 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts @@ -0,0 +1,28 @@ +import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { ClientProxy } from '@nestjs/microservices'; +import { SendEmail } from './dtos/email.dto'; + +@Injectable() +export class EmailClientService implements OnApplicationBootstrap { + private readonly logger = new Logger(EmailClientService.name); + + constructor( + @Inject('EMAIL_SERVICE') + private readonly emailServiceClient: ClientProxy, + ) {} + + async onApplicationBootstrap() { + this.emailServiceClient + .connect() + .then(() => this.logger.log('Connected to Email service')) + .catch((err) => this.logger.error('Error connecting to Email service', err)); + } + + async sendEmail(dto: SendEmail) { + this.emailServiceClient.emit('send-email', { + ...dto, + appKey: 'EDR-PASSENGER-API', + }); + return {}; + } +} diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts index 9b363c04f..9fd69830c 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts @@ -1,16 +1,24 @@ import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; import { NotificationsService } from './notifications.service'; import { JwtGuard } from '../../common/jwt.guard'; import { IamGuard, IamRoles } from '../../common/iam-adapter'; import { TestNotificationDto } from './notifications.dto'; +import { EmailClientService } from './email-client.service'; +import { SmsClientService } from './sms-client.service'; +import { SendEmail } from './dtos/email.dto'; +import { SendMessage } from './dtos/sms.dto'; @ApiTags('Notifications') @Controller('notifications') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') export class NotificationsController { - constructor(private service: NotificationsService) {} + constructor( + private service: NotificationsService, + private emailClient: EmailClientService, + private smsClient: SmsClientService, + ) {} @Get(':passengerId') @ApiOperation({ summary: 'Get notifications for passenger' }) @@ -30,6 +38,24 @@ export class NotificationsController { return this.service.markAllRead(id); } + @Post('send/email') + @UseGuards(IamGuard) + @IamRoles('ADMIN', 'STAFF') + @ApiOperation({ summary: 'Send a direct email via the email microservice' }) + @ApiBody({ type: SendEmail }) + sendEmail(@Body() dto: SendEmail) { + return this.emailClient.sendEmail(dto); + } + + @Post('send/sms') + @UseGuards(IamGuard) + @IamRoles('ADMIN', 'STAFF') + @ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' }) + @ApiBody({ type: SendMessage }) + sendSms(@Body() dto: SendMessage) { + return this.smsClient.sendSms(dto); + } + @Post('test') @UseGuards(IamGuard) @IamRoles('ADMIN', 'STAFF') diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts index b4c28405f..f209ccf17 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.module.ts @@ -1,13 +1,56 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ClientsModule, Transport } from '@nestjs/microservices'; import { NotificationsController } from './notifications.controller'; import { NotificationsService } from './notifications.service'; import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters'; +import { EmailClientService } from './email-client.service'; +import { SmsClientService } from './sms-client.service'; @Module({ - imports: [HttpModule.register({ timeout: 10_000 })], + imports: [ + HttpModule.register({ timeout: 10_000 }), + ClientsModule.registerAsync([ + { + name: 'EMAIL_SERVICE', + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + transport: Transport.RMQ, + options: { + urls: [config.get('RABBITMQ_URL') ?? 'amqp://localhost:5672'], + queue: config.get('EMAIL_QUEUE') ?? 'email_queue', + queueOptions: { durable: true }, + noAck: true, + }, + }), + }, + { + name: 'SMS_SERVICE', + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + transport: Transport.RMQ, + options: { + urls: [config.get('RABBITMQ_URL') ?? 'amqp://localhost:5672'], + queue: config.get('SMS_QUEUE') ?? 'sms_queue', + queueOptions: { durable: true }, + noAck: true, + }, + }), + }, + ]), + ], controllers: [NotificationsController], - providers: [NotificationsService, EmailAdapter, SmsAdapter, PushAdapter], - exports: [NotificationsService], + providers: [ + NotificationsService, + EmailAdapter, + SmsAdapter, + PushAdapter, + EmailClientService, + SmsClientService, + ], + exports: [NotificationsService, EmailClientService, SmsClientService], }) export class NotificationsModule {} diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index e793a1322..68d76572c 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -1,8 +1,10 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { PrismaService } from '../../common/prisma.service'; import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto'; -import { EmailAdapter, SmsAdapter, PushAdapter, NotificationChannel } from './notification.adapters'; +import { PushAdapter, NotificationChannel } from './notification.adapters'; +import { EmailClientService } from './email-client.service'; +import { SmsClientService } from './sms-client.service'; export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP'; @@ -13,13 +15,13 @@ export class NotificationsService { constructor( private prisma: PrismaService, - private emailAdapter: EmailAdapter, - private smsAdapter: SmsAdapter, + private emailClient: EmailClientService, + private smsClient: SmsClientService, private pushAdapter: PushAdapter, ) { this.channels = new Map([ - ['EMAIL', this.emailAdapter as NotificationChannel], - ['SMS', this.smsAdapter as NotificationChannel], + ['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, body }).then(() => true) }], + ['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then(() => true) }], ['PUSH', this.pushAdapter as NotificationChannel], ]); } @@ -102,11 +104,11 @@ export class NotificationsService { }); if (passenger?.user) { - await this.emailAdapter.send( - passenger.user.email, - this.sanitize(dto.title), - this.sanitize(dto.body), - ); + await this.emailClient.sendEmail({ + to: passenger.user.email, + subject: this.sanitize(dto.title), + body: this.sanitize(dto.body), + }); } return notification; diff --git a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts new file mode 100644 index 000000000..0108e0758 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts @@ -0,0 +1,36 @@ +import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { ClientProxy } from '@nestjs/microservices'; +import { BulkMessagesDto, SendMessage } from './dtos/sms.dto'; + +@Injectable() +export class SmsClientService implements OnApplicationBootstrap { + private readonly logger = new Logger(SmsClientService.name); + + constructor( + @Inject('SMS_SERVICE') + private readonly smsClient: ClientProxy, + ) {} + + async onApplicationBootstrap() { + this.smsClient + .connect() + .then(() => this.logger.log('Connected to SMS service')) + .catch((err) => this.logger.error('Error connecting to SMS service', err)); + } + + async sendSms(dto: SendMessage) { + this.smsClient.emit('send-sms', { + ...dto, + appKey: 'EDR-PASSENGER-API', + }); + return {}; + } + + async sendBulkMessages(dto: BulkMessagesDto) { + this.smsClient.emit('ozeking-bulk-sms', { + ...dto, + appKey: 'EDR-PASSENGER-API', + }); + return {}; + } +} 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/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 59438c4ad..c017c6332 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -42,13 +42,6 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [ @Injectable() export class PaymentsService { private readonly logger = new Logger(PaymentsService.name); - - /** - * DEMO ONLY: when true, a WALLET "payment" is treated as instantly successful — the wallet - * balance check and debit are skipped and the booking is confirmed + ticket issued as if fully - * paid. Lets the happy-path be demoed while a real provider (e.g. Telebirr) is unavailable. - * Never enable in production. Toggle with WALLET_DEMO_AUTO_SUCCEED in the env. - */ private readonly walletDemoAutoSucceed = true; constructor( @@ -136,9 +129,7 @@ export class PaymentsService { return this.initiateWalletPayment(booking); } - // Provider methods go through the payment microservice (docs/payment-service §7.1): - // it owns the intent, the provider session, and the single webhook per provider. - // Re-initiating is safe — the service returns the existing active intent (idempotent). + const { returnUrl, failureUrl } = this.resolveReturnUrls(method); const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.PASSENGER, referenceType: PaymentReferenceType.BOOKING, @@ -148,10 +139,8 @@ export class PaymentsService { currency: booking.currency, provider: method as unknown as ProviderMethod, platform: dto.platform, - // PASSENGER-owned browser bounce-back after the hosted page (freight passes its own). - // UX only — payment is confirmed by the webhook/mark-paid event, never this redirect. - returnUrl: process.env.PAYMENT_RETURN_URL || undefined, - failureUrl: process.env.PAYMENT_FAILURE_URL || undefined, + returnUrl, + failureUrl, }); let intent = await this.syncIntentProjection(booking.id, snapshot); @@ -168,6 +157,40 @@ export class PaymentsService { } return this.formatIntentResponse(intent); } + private resolveReturnUrls(method: PaymentMethodType): { + returnUrl?: string; + failureUrl?: string; + } { + const perMethod: Partial< + Record + > = { + [PaymentMethodType.TELEBIRR]: { + returnUrl: process.env.TELEBIRR_RETURN_URL, + }, + [PaymentMethodType.WAAFI]: { + returnUrl: process.env.WAAFI_SUCCESS_REDIRECT, + failureUrl: process.env.WAAFI_FAIL_REDIRECT, + }, + [PaymentMethodType.DMONEY]: { + returnUrl: process.env.DMONEY_RETURN_URL, + }, + [PaymentMethodType.CBE_BIRR]: { + returnUrl: process.env.CBE_RETURN_URL, + }, + [PaymentMethodType.EBIRR]: { + returnUrl: process.env.EBIRR_RETURN_URL, + }, + [PaymentMethodType.CARD]: { + returnUrl: process.env.CARD_RETURN_URL, + }, + }; + + const m = perMethod[method] ?? {}; + const returnUrl = m.returnUrl || process.env.PAYMENT_RETURN_URL || undefined; + const failureUrl = + m.failureUrl || process.env.PAYMENT_FAILURE_URL || returnUrl; + return { returnUrl, failureUrl }; + } private async syncIntentProjection( bookingId: string, diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 29bc82a55..d1f25dde7 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -8,7 +8,10 @@ export class ReportsService { async generateReport(dto: GenerateReportDto) { const dateFrom = new Date(dto.dateFrom); + dateFrom.setHours(0, 0, 0, 0); + const dateTo = new Date(dto.dateTo); + dateTo.setHours(23, 59, 59, 999); let data: any; switch (dto.reportType) { @@ -44,14 +47,16 @@ export class ReportsService { } private async generateRevenueReport(dateFrom: Date, dateTo: Date) { + // Fetch all bookings in date range, regardless of status const bookings = await this.prisma.booking.findMany({ where: { - createdAt: { gte: dateFrom, lte: dateTo }, - status: { in: ['CONFIRMED', 'COMPLETED'] } + createdAt: { gte: dateFrom, lte: dateTo } }, include: { paymentIntent: true } }); + console.log(`[Reports] Revenue Report: Found ${bookings.length} bookings between ${dateFrom} and ${dateTo}`); + const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0); const byPaymentMethod = bookings.reduce((acc, b) => { const method = b.paymentIntent?.method ?? 'UNKNOWN'; @@ -59,12 +64,25 @@ export class ReportsService { return acc; }, {} as Record); + // Group by date for charts + const byDate = bookings.reduce((acc, b) => { + const date = b.createdAt.toISOString().split('T')[0]; + if (!acc[date]) { + acc[date] = { totalMinor: 0, count: 0 }; + } + acc[date].totalMinor += b.totalMinor; + acc[date].count += 1; + return acc; + }, {} as Record); + return { totalBookings: bookings.length, totalRevenueMinor: totalRevenue, totalRevenue: totalRevenue / 100, currency: 'ETB', - byPaymentMethod + byPaymentMethod, + byDate, + cancellationRate: 0 }; } @@ -73,7 +91,7 @@ export class ReportsService { where: { departureAt: { gte: dateFrom, lte: dateTo } }, include: { coachAssignments: { include: { coach: { include: { seats: true } } } }, - bookings: { where: { status: { in: ['CONFIRMED', 'COMPLETED'] } }, include: { seats: true } }, + bookings: { include: { seats: true } }, }, }); 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..e816ca8e1 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[] }> { 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.module.ts b/apps/edr-passenger-api/src/modules/stations/stations.module.ts index 28ee6d121..bdb62569d 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.module.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.module.ts @@ -1,6 +1,12 @@ import { Module } from '@nestjs/common'; +import { AuditModule } from '../../common/audit.module'; import { StationsController } from './stations.controller'; import { StationsService } from './stations.service'; -@Module({ controllers: [StationsController], providers: [StationsService], exports: [StationsService] }) +@Module({ + imports: [AuditModule], + controllers: [StationsController], + providers: [StationsService], + exports: [StationsService], +}) export class StationsModule {} 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 a3e6624fe..bfa0ab7bb 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -1,5 +1,7 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, Inject, Optional } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; import { PrismaService } from '../../common/prisma.service'; +import { AuditService } from '../../common/audit.service'; import { CreateStationDto } from './stations.dto'; interface StationFilters { @@ -10,7 +12,11 @@ interface StationFilters { @Injectable() export class StationsService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private auditService: AuditService, + @Optional() @Inject(REQUEST) private request?: any, + ) {} findAll(filters: StationFilters = {}) { const where: any = {}; @@ -33,7 +39,7 @@ export class StationsService { return this.prisma.station.findMany({ where, - orderBy: { name: 'asc' } + orderBy: { sequence: 'asc' } }); } @@ -43,20 +49,51 @@ export class StationsService { return s; } - create(dto: CreateStationDto) { - return this.prisma.station.create({ data: dto }); + async create(dto: CreateStationDto) { + const station = await this.prisma.station.create({ data: dto }); + + await this.auditService.log({ + userId: this.request?.user?.id, + action: 'CREATE', + entityType: 'Station', + entityId: station.id, + newData: station, + }); + + return station; } async update(id: string, dto: Partial) { - await this.findOne(id); // Check if exists - return this.prisma.station.update({ - where: { id }, - data: dto + const oldStation = await this.findOne(id); + const updatedStation = await this.prisma.station.update({ + where: { id }, + data: dto, }); + + await this.auditService.log({ + userId: this.request?.user?.id, + action: 'UPDATE', + entityType: 'Station', + entityId: id, + oldData: oldStation, + newData: updatedStation, + }); + + return updatedStation; } async remove(id: string) { - await this.findOne(id); // Check if exists - return this.prisma.station.delete({ where: { id } }); + const station = await this.findOne(id); + const deleted = await this.prisma.station.delete({ where: { id } }); + + await this.auditService.log({ + userId: this.request?.user?.id, + action: 'DELETE', + entityType: 'Station', + entityId: id, + oldData: station, + }); + + return deleted; } } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 5711355e0..e7760b565 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -44,6 +44,17 @@ export class TicketsController { }); } + @Get('by-order/:merchantOrderId') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Get ticket by merchant order ID', + description: 'Looks up the booking ID from the PaymentIntent using merchantOrderId, then returns the full ticket information.' + }) + getByMerchantOrderId(@Param('merchantOrderId') merchantOrderId: string) { + return this.service.getByMerchantOrderId(merchantOrderId); + } + @Get(':bookingRef') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') 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 a77714cf3..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, })), @@ -157,9 +161,14 @@ export class TicketsService { return { success: true, updatedSeats: newSeatIds.length }; } - async getByRef(bookingRef: string) { + async getByMerchantOrderId(merchantOrderId: string) { + const intent = await this.prisma.paymentIntent.findUnique({ + where: { merchantOrderId }, + select: { bookingId: true }, + }); + if (!intent) throw new NotFoundException(`No payment intent found for order ${merchantOrderId}`); const booking = await this.prisma.booking.findUnique({ - where: { bookingRef }, + where: { id: intent.bookingId }, 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'); @@ -170,6 +179,36 @@ export class TicketsService { 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, + }; + } + + 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 + }, + }); + 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, 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/audit/page.tsx b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx index 6f73de13e..06d8a6811 100644 --- a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx @@ -2,27 +2,90 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Search, Eye } from 'lucide-react'; +import { Eye, Download } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import { auditApi } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; +import Modal from '@/components/ui/Modal'; +import ActionButton from '@/components/ui/ActionButton'; export default function AuditLogsPage() { const [filters, setFilters] = useState({ search: '', action: '', entityType: '' }); + const [selectedLog, setSelectedLog] = useState(null); + const [showDetailsModal, setShowDetailsModal] = useState(false); const { data, isLoading } = useQuery({ queryKey: ['audit-logs', filters], queryFn: () => auditApi.getLogs(filters), + refetchInterval: 30000, // Refetch every 30 seconds }); + const getActionBadgeColor = (action: string) => { + switch (action) { + case 'CREATE': + return 'success'; + case 'UPDATE': + return 'primary'; + case 'DELETE': + return 'danger'; + case 'LOGIN': + return 'info'; + case 'LOGOUT': + return 'secondary'; + default: + return 'secondary'; + } + }; + + const formatJsonData = (data: any) => { + if (!data) return 'N/A'; + try { + return JSON.stringify(data, null, 2); + } catch { + return String(data); + } + }; + const columns = [ + { + key: 'createdAt', + label: 'Timestamp', + sortable: true, + render: (log: any) => ( +
+
{formatDateTime(log.createdAt)}
+
{new Date(log.createdAt).toLocaleTimeString()}
+
+ ), + }, { key: 'action', label: 'Action', sortable: true, render: (log: any) => ( - {log.action} + + {log.action} + + ), + }, + { + key: 'entityType', + label: 'Entity Type', + sortable: true, + render: (log: any) => ( + + {log.entityType} + + ), + }, + { + key: 'entityId', + label: 'Entity ID', + render: (log: any) => ( + + {log.entityId ? log.entityId.substring(0, 12) : 'System'} + ), }, { @@ -30,55 +93,74 @@ export default function AuditLogsPage() { label: 'User', render: (log: any) => (
-
{log.user?.fullName || 'System'}
-
{log.user?.email || 'N/A'}
+
{log.user?.fullName || 'System'}
+
{log.user?.email || log.userId || 'N/A'}
), }, { - key: 'entityType', - label: 'Entity Type', - render: (log: any) => log.entityType, - }, - { - key: 'entityId', - label: 'Entity ID', + key: 'ipAddress', + label: 'IP Address', render: (log: any) => ( - {log.entityId?.substring(0, 8)}... + + {log.ipAddress || 'N/A'} + ), }, - { - key: 'createdAt', - label: 'Timestamp', - sortable: true, - render: (log: any) => formatDateTime(log.createdAt), - }, ]; const actions = [ { label: 'View Details', onClick: (log: any) => { - window.location.href = `/audit/${log.id}`; + setSelectedLog(log); + setShowDetailsModal(true); }, variant: 'secondary' as const, icon: Eye, }, ]; + const logs = data?.items || []; + const stats = { + total: logs.length, + creates: logs.filter((l: any) => l.action === 'CREATE').length, + updates: logs.filter((l: any) => l.action === 'UPDATE').length, + deletes: logs.filter((l: any) => l.action === 'DELETE').length, + }; + return (
-
-
-

Audit Logs

-

Track all system activities and changes

+
+

Audit Logs

+

Track all system activities and changes

+
+ + {/* Stats Cards */} +
+
+
Total Logs
+
{stats.total}
+
+
+
Created
+
{stats.creates}
+
+
+
Updated
+
{stats.updates}
+
+
+
Deleted
+
{stats.deletes}
+ {/* Filters */}
-
+
- + setFilters({ ...filters, entityType: e.target.value })} > - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ setFilters({ search: '', action: '', entityType: '' })} + className="w-full" + > + Clear Filters + +
+ {/* Data Table */} + + {/* Details Modal */} + { + setShowDetailsModal(false); + setSelectedLog(null); + }} + title={`${selectedLog?.action} - ${selectedLog?.entityType}`} + size="lg" + > +
+ {/* Basic Info */} +
+
+ +

{formatDateTime(selectedLog?.createdAt)}

+
+
+ +

+ + {selectedLog?.action} + +

+
+
+ +

{selectedLog?.entityType}

+
+
+ +

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

+
+
+ + {/* User Info */} + {selectedLog?.user && ( +
+

User Information

+
+
+ +

{selectedLog?.user?.fullName}

+
+
+ +

{selectedLog?.user?.email}

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

Network Information

+
+ {selectedLog?.ipAddress && ( +
+ +

{selectedLog?.ipAddress}

+
+ )} + {selectedLog?.userAgent && ( +
+ +

+ {selectedLog?.userAgent} +

+
+ )} +
+
+ )} + + {/* Changes */} + {(selectedLog?.oldData || selectedLog?.newData) && ( +
+

Data Changes

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

{selectedLog?.id}

+
+
+
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/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 91856f800..a30d32b72 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Search, Grid3x3, Edit, Trash2 } from 'lucide-react'; +import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; @@ -11,6 +11,135 @@ import { fleetApi, apiClient } from '@/lib/api'; type Tab = 'types' | 'coaches'; +const getBedLabel = (bedPosition: string | null): string => { + if (bedPosition === 'upper') return 'U'; + if (bedPosition === 'middle') return 'M'; + if (bedPosition === 'lower') return 'L'; + return ''; +}; + +const renderBedVisualization = (coach: any) => { + const seats = coach.seats || []; + const validSeats = seats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')); + + if (validSeats.length === 0) { + return
No seats
; + } + + const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); + const isBedCoach = coach.coachType?.name?.toLowerCase().includes('bed'); + + if (!isBedCoach || !hasBedPositionData) { + // Regular seat layout + const arrangement = coach.seatArrangement || coach.arrangement || '2+2'; + const [left, right] = arrangement.split('+').map((p: string) => parseInt(p.trim())); + const cols = new Map(); + + for (const seat of validSeats) { + if (!cols.has(seat.row)) cols.set(seat.row, []); + cols.get(seat.row)!.push(seat); + } + + return ( +
+ {Array.from(cols.entries()).map(([row, rowSeats]) => ( +
+
+ {rowSeats.slice(0, left).map((s: any) => ( +
+ +
+ ))} +
+
+ {rowSeats.slice(left).map((s: any) => ( +
+ +
+ ))} +
+
+ ))} +
+ ); + } + + // Bed layout with pairing + const seatsByRow = new Map(); + for (const seat of validSeats) { + if (!seatsByRow.has(seat.row)) seatsByRow.set(seat.row, []); + seatsByRow.get(seat.row)!.push(seat); + } + + const beds = coach.coachType?.name?.toLowerCase().includes('vip') ? 'w-12' : 'w-10'; + const rows = Array.from(seatsByRow.entries()).map(([r, s]) => s); + + return ( +
+ {rows.map((rowSeats: any[], idx: number) => { + const rowNumber = rowSeats[0]?.row || (idx + 1); + const isFirstInPair = (rowNumber - 1) % 2 === 0; + const isLastRow = idx === rows.length - 1; + const nextRowSeats = !isLastRow ? rows[idx + 1] : null; + + return ( +
+ {/* Row 1 of pair - label above */} + {isFirstInPair && ( +
+ {rowSeats.map((s: any) => ( +
+ {s.seatNumber} +
+ ))} +
+ )} + {/* Row 1 of pair - beds */} +
+ {rowSeats.map((s: any) => ( +
+ +
+ ))} +
+ {/* Numbers between rows */} + {isFirstInPair && nextRowSeats && ( +
+ {rowSeats.map((s: any, idx: number) => { + const nextSeat = nextRowSeats[idx]; + return ( +
+ {nextSeat?.seatNumber} +
+ ); + })} +
+ )} + {/* Row 2 of pair - beds */} + {!isFirstInPair && ( +
+ {rowSeats.map((s: any) => ( +
+ +
+ ))} +
+ )} + {!isFirstInPair &&
} +
+ ); + })} +
+ ); +}; + export default function CoachesPage() { const [activeTab, setActiveTab] = useState('coaches'); const [search, setSearch] = useState(''); @@ -107,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, }; @@ -228,6 +358,15 @@ export default function CoachesPage() { {coach.coachType?.name || 'N/A'} ), }, + { + key: 'visualization', + label: 'Seats/Beds', + render: (coach: any) => ( +
+ {renderBedVisualization(coach)} +
+ ), + }, { key: 'arrangement', label: 'Arrangement', @@ -486,7 +625,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/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index ee4f5b9ce..d61acff41 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -1,13 +1,15 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { Ticket, Users, DollarSign, TrendingUp } from 'lucide-react'; +import { Ticket, Users, DollarSign, Percent } from 'lucide-react'; import StatCard from '@/components/dashboard/StatCard'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import { dashboardApi } from '@/lib/api/dashboard'; import { formatCurrency, formatDateTime } from '@/lib/utils'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; + +const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; export default function DashboardPage() { const { data: stats, isLoading: statsLoading } = useQuery({ @@ -20,22 +22,55 @@ export default function DashboardPage() { queryFn: () => dashboardApi.getRevenueChart(30), }); - const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery({ + const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery({ queryKey: ['recent-bookings'], queryFn: () => dashboardApi.getRecentBookings(10), }); - const recentBookings = Array.isArray(recentBookingsData) - ? recentBookingsData - : recentBookingsData?.items || recentBookingsData?.data || []; + const { data: topAgents, isLoading: agentsLoading } = useQuery({ + queryKey: ['top-agents'], + queryFn: () => dashboardApi.getTopAgents(5), + }); - const columns = [ + const { data: occupancyTrend, isLoading: occupancyLoading } = useQuery({ + queryKey: ['occupancy-trend'], + queryFn: () => dashboardApi.getOccupancyTrend(7), + }); + + const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({ + queryKey: ['upcoming-trips'], + queryFn: () => dashboardApi.getUpcomingTrips(5), + }); + + const { data: paymentMethods } = useQuery({ + queryKey: ['payment-methods'], + queryFn: dashboardApi.getPaymentMethods, + }); + + const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData : []; + + const bookingColumns = [ { key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference }, - { key: 'passenger', label: 'Passenger', render: (item: any) => item.passenger?.fullName || item.contactEmail || 'N/A' }, - { key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') }, { - key: 'status', - label: 'Status', + key: 'passenger', + label: 'Passenger', + render: (item: any) => { + if (item.passenger?.fullName) { + return item.passenger.fullName; + } + if (item.contactEmail) { + return item.contactEmail; + } + if (item.contactPhone) { + return item.contactPhone; + } + return 'N/A'; + } + }, + { key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') }, + { + key: 'status', + label: 'Status', render: (item: any) => ( {item.status} @@ -45,19 +80,43 @@ export default function DashboardPage() { { key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) }, ]; + const agentColumns = [ + { key: 'name', label: 'Agent Name', render: (item: any) => item.name || item.fullName }, + { key: 'bookings', label: 'Bookings', render: (item: any) => item.bookingsCount || item.bookings || 0 }, + { key: 'revenue', label: 'Revenue', render: (item: any) => formatCurrency(item.totalRevenue || item.revenue || 0, 'ETB') }, + { key: 'commission', label: 'Commission', render: (item: any) => formatCurrency(item.commission || 0, 'ETB') }, + ]; + + const tripColumns = [ + { key: 'trainName', label: 'Train', render: (item: any) => item.trainName || item.train?.name }, + { key: 'route', label: 'Route', render: (item: any) => `${item.originStation?.name || item.origin?.name} → ${item.destinationStation?.name || item.destination?.name}` }, + { key: 'departure', label: 'Departure', render: (item: any) => formatDateTime(item.departureAt) }, + { key: 'seats', label: 'Seats', render: (item: any) => `${item.availableSeats || 0}/${item.totalSeats || 0}` }, + { + key: 'status', + label: 'Status', + render: (item: any) => ( + + {item.status} + + ) + }, + ]; + return (

Dashboard

-

Hello, welcome back! Here's what's happening today.

+

Welcome back! Here's your operational summary.

+ {/* Primary Metrics */}
- {!revenueLoading && revenueData && revenueData.length > 0 && ( + {/* Charts Row */} +
+ {/* Revenue Trend */} + {!revenueLoading && revenueData && revenueData.length > 0 && ( +
+

Revenue Trend (Last 30 Days)

+ + + + + + formatCurrency(value, 'ETB')} /> + + + +
+ )} + + {/* Occupancy Trend */} + {!occupancyLoading && occupancyTrend && occupancyTrend.length > 0 && ( +
+

Occupancy Trend (Last 7 Days)

+ + + + + + `${value}%`} /> + + + +
+ )} +
+ + {/* Payment Methods Distribution */} + {paymentMethods && paymentMethods.length > 0 && (
-

Revenue Trend (Last 30 Days)

+

Payment Methods Distribution

- - - - - formatCurrency(value, 'ETB')} /> - - + + + {paymentMethods.map((entry, index) => ( + + ))} + + +
)} + {/* Recent Bookings */}

Recent Bookings

+ + {/* Upcoming Trips */} + {upcomingTrips && upcomingTrips.length > 0 && ( +
+

Upcoming Trips

+ +
+ )} + + {/* Top Agents */} + {topAgents && topAgents.length > 0 && ( +
+

Top Performing Agents

+ +
+ )}
); } 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/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index 4a6138f5a..fe0917aa3 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -1,17 +1,25 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; -import { Train } from 'lucide-react'; +import { useTheme } from '@/lib/theme-store'; +import { Train, Eye, EyeOff, Sun, Moon } from 'lucide-react'; export default function LoginPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [isMounted, setIsMounted] = useState(false); const router = useRouter(); const { login } = useAuthStore(); + const { isDark, toggleTheme } = useTheme(); + + useEffect(() => { + setIsMounted(true); + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -29,77 +37,109 @@ export default function LoginPage() { } }; + if (!isMounted) { + return null; + } + return ( -
- {/* Banner Image Side */} -
-
-
-
-
- +
+ {/* Full Screen Banner Background */} +
+ + {/* Content Overlay */} +
+
+ {/* Login Card with Shadow */} +
+ {/* Card Header with Logo, App Name and Theme Toggle */} +
+
+
+ +
+
+

Ethio-Djibouti Railway

+

Passenger Back-office

+
+
+ + +
+ + {/* Card Body */} +
+
+

Welcome back!

+

Sign in to continue.

+
+ + {error && ( +
+ {error} +
+ )} + + +
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent" + placeholder="name@email.com" + required + /> +
+ +
+ +
+ setPassword(e.target.value)} + className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent" + placeholder="••••••••" + required + /> + +
+
+ + +
-

EDR

-

Passenger Back-office

- - {/* Login Form Side */} -
-
-
-
-
-
- -
-
EDR
-
-

Sign in to get started.

-
- - {error && ( -
- {error} -
- )} - -
-
- - setEmail(e.target.value)} - className="input" - required - /> -
- -
- - setPassword(e.target.value)} - className="input" - required - /> -
- - -
- -
-
-
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx index 339cd591f..29f36dd0a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx @@ -2,64 +2,467 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Download } from 'lucide-react'; +import { Download, Eye, Plus } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; import { reportsApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; -export default function OperationalreportsPage() { +export default function OperationalReportsPage() { const [filters, setFilters] = useState({ search: '', reportType: '' }); - - const { data, isLoading } = useQuery({ - queryKey: ['operational-reports', filters], - queryFn: () => reportsApi.getOperationalReports(filters), + const [selectedReport, setSelectedReport] = useState(null); + const [showDetailsModal, setShowDetailsModal] = useState(false); + const [showGenerateModal, setShowGenerateModal] = useState(false); + const [generateForm, setGenerateForm] = useState({ + reportType: 'REVENUE', + dateFrom: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + dateTo: new Date().toISOString().split('T')[0], }); + const { data, isLoading, refetch } = useQuery({ + queryKey: ['operational-reports', filters], + queryFn: () => reportsApi.listReports(filters.reportType || undefined), + }); + + const handleGenerateReport = async () => { + try { + await reportsApi.generateReport(generateForm); + refetch(); + setShowGenerateModal(false); + } catch (error) { + console.error('Error generating report:', error); + } + }; + + const getReportTypeBadgeColor = (type: string) => { + switch (type) { + case 'REVENUE': + return 'success'; + case 'OCCUPANCY': + return 'primary'; + case 'PERFORMANCE': + return 'info'; + case 'AGENT_SALES': + return 'secondary'; + default: + return 'secondary'; + } + }; + + const formatReportType = (type: string) => { + const typeMap: { [key: string]: string } = { + REVENUE: 'Revenue Report', + OCCUPANCY: 'Occupancy Report', + PERFORMANCE: 'Performance Report', + AGENT_SALES: 'Agent Sales Report', + CANCELLATIONS: 'Cancellations Report', + PAYMENT_METHODS: 'Payment Methods Report', + }; + return typeMap[type] || type; + }; + const columns = [ - { key: 'reportType', label: 'Type', render: (report: any) => {report.reportType} }, - { key: 'period', label: 'Period', render: (report: any) => report.period || 'N/A' }, - { key: 'generatedBy', label: 'Generated By', render: (report: any) => report.generatedBy?.fullName || 'System' }, - { key: 'createdAt', label: 'Generated', render: (report: any) => formatDateTime(report.createdAt) }, - ]; + { + key: 'reportType', + label: 'Report Type', + sortable: true, + render: (report: any) => ( + + {formatReportType(report.reportType)} + + ), + }, + { + key: 'dateFrom', + label: 'Period From', + sortable: true, + render: (report: any) => ( + {new Date(report.dateFrom).toLocaleDateString()} + ), + }, + { + key: 'dateTo', + label: 'Period To', + sortable: true, + render: (report: any) => ( + {new Date(report.dateTo).toLocaleDateString()} + ), + }, + { + key: 'data', + label: 'Summary', + render: (report: any) => { + const data = report.data || {}; + if (report.reportType === 'REVENUE') { + return ( +
+

{formatCurrency(data.totalRevenueMinor || 0, 'ETB')}

+

{data.totalBookings || 0} bookings

+
+ ); + } + if (report.reportType === 'OCCUPANCY') { + return ( +
+

{(data.averageOccupancyRate || 0).toFixed(1)}% occupancy

+

{data.totalSchedules || 0} schedules

+
+ ); + } + if (report.reportType === 'AGENT_SALES') { + return ( +
+

{data.totalAgentBookings || 0} bookings

+

{Object.keys(data.byAgent || {}).length} agents

+
+ ); + } + if (report.reportType === 'CANCELLATIONS') { + return ( +
+

{data.totalCancellations || 0} cancellations

+

Refunded: {formatCurrency(data.totalRefundedMinor || 0, 'ETB')}

+
+ ); + } + if (report.reportType === 'PAYMENT_METHODS') { + return ( +
+

{data.totalPayments || 0} payments

+

{Object.keys(data.byMethod || {}).length} methods

+
+ ); + } + return View details; + }, + }, + { + key: 'createdAt', + label: 'Generated', + sortable: true, + render: (report: any) => ( + {formatDateTime(report.createdAt)} + ), + }, + ]; + + const actions = [ + { + label: 'View Details', + onClick: (report: any) => { + setSelectedReport(report); + setShowDetailsModal(true); + }, + variant: 'secondary' as const, + icon: Eye, + }, + ]; + + const reports = data?.items || data || []; return (
-

Operational Reports

-

View operational reports and analytics

+

Operational Reports

+

View and analyze operational performance

+
+
+ setShowGenerateModal(true)}> + Generate Report + + + Export All +
- Export
+ {/* Filters */}
- -
- - setFilters({ ...filters, search: e.target.value })} /> -
-
- - -
- +
+ + setFilters({ ...filters, search: e.target.value })} + /> +
+
+ + +
+
+ setFilters({ search: '', reportType: '' })} + className="w-full" + > + Clear Filters + +
+ {/* Reports Table */} + + {/* Generate Report Modal */} + setShowGenerateModal(false)} + title="Generate Report" + size="sm" + > +
+
+ + +
+
+ + setGenerateForm({ ...generateForm, dateFrom: e.target.value })} + /> +
+
+ + setGenerateForm({ ...generateForm, dateTo: e.target.value })} + /> +
+
+ + Generate + + setShowGenerateModal(false)} + className="flex-1" + > + Cancel + +
+
+
+ + {/* Details Modal */} + { + setShowDetailsModal(false); + setSelectedReport(null); + }} + title={formatReportType(selectedReport?.reportType)} + size="lg" + > +
+ {/* Report Header */} +
+
+ +

{formatReportType(selectedReport?.reportType)}

+
+
+ +

{formatDateTime(selectedReport?.createdAt)}

+
+
+ +

{new Date(selectedReport?.dateFrom).toLocaleDateString()}

+
+
+ +

{new Date(selectedReport?.dateTo).toLocaleDateString()}

+
+
+ + {/* Revenue Report Data */} + {selectedReport?.reportType === 'REVENUE' && ( +
+

Revenue Metrics

+
+
+

Total Revenue

+

+ {formatCurrency(selectedReport?.data?.totalRevenueMinor || 0, 'ETB')} +

+
+
+

Total Bookings

+

+ {(selectedReport?.data?.totalBookings || 0).toLocaleString()} +

+
+
+ {selectedReport?.data?.byPaymentMethod && ( +
+

By Payment Method

+
+ {Object.entries(selectedReport.data.byPaymentMethod).map(([method, amount]: [string, any]) => ( +
+ {method.toLowerCase().replace('_', ' ')} + {formatCurrency(amount, 'ETB')} +
+ ))} +
+
+ )} +
+ )} + + {/* Occupancy Report Data */} + {selectedReport?.reportType === 'OCCUPANCY' && ( +
+

Occupancy Metrics

+
+
+

Avg Occupancy Rate

+

+ {(selectedReport?.data?.averageOccupancyRate || 0).toFixed(1)}% +

+
+
+

Total Schedules

+

+ {(selectedReport?.data?.totalSchedules || 0).toLocaleString()} +

+
+
+
+ )} + + {/* Agent Sales Report Data */} + {selectedReport?.reportType === 'AGENT_SALES' && ( +
+

Agent Sales Metrics

+
+
+

Total Bookings

+

+ {(selectedReport?.data?.totalAgentBookings || 0).toLocaleString()} +

+
+
+

Active Agents

+

+ {Object.keys(selectedReport?.data?.byAgent || {}).length} +

+
+
+ {selectedReport?.data?.byAgent && ( +
+

By Agent

+
+ {Object.entries(selectedReport.data.byAgent).map(([agent, stats]: [string, any]) => ( +
+

{agent}

+
+

Bookings: {stats.bookings} | Revenue: {formatCurrency(stats.revenueMinor, 'ETB')}

+
+
+ ))} +
+
+ )} +
+ )} + + {/* Cancellations Report Data */} + {selectedReport?.reportType === 'CANCELLATIONS' && ( +
+

Cancellation Metrics

+
+
+

Total Cancellations

+

+ {(selectedReport?.data?.totalCancellations || 0).toLocaleString()} +

+
+
+

Total Refunded

+

+ {formatCurrency(selectedReport?.data?.totalRefundedMinor || 0, 'ETB')} +

+
+
+
+ )} + + {/* Payment Methods Report Data */} + {selectedReport?.reportType === 'PAYMENT_METHODS' && ( +
+

Payment Method Breakdown

+
+

Total Payments

+

+ {(selectedReport?.data?.totalPayments || 0).toLocaleString()} +

+
+ {selectedReport?.data?.byMethod && ( +
+ {Object.entries(selectedReport.data.byMethod).map(([method, stats]: [string, any]) => ( +
+
+

{method.toLowerCase().replace('_', ' ')}

+

{stats.count} transactions

+
+

{formatCurrency(stats.totalMinor, 'ETB')}

+
+ ))} +
+ )} +
+ )} + + {/* Report ID */} +
+ +

{selectedReport?.id}

+
+
+
); } 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/reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx index 7f1f02abe..353b1fd3a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx @@ -1,124 +1,316 @@ 'use client'; import { useState } from 'react'; -import { Download, Calendar } from 'lucide-react'; -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; -import { formatCurrency } from '@/lib/utils'; +import { useQuery } from '@tanstack/react-query'; +import { Download, TrendingUp, Users, DollarSign, AlertCircle } from 'lucide-react'; +import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; +import { bookingsApi } from '@/lib/api'; +import ActionButton from '@/components/ui/ActionButton'; -const revenueByRoute = [ - { route: 'Addis - Djibouti', revenue: 125000000 }, - { route: 'Addis - Dire Dawa', revenue: 85000000 }, - { route: 'Dire Dawa - Djibouti', revenue: 45000000 }, -]; - -const bookingsByClass = [ - { name: 'Economy Regular', value: 65, color: '#3b82f6' }, - { name: 'Economy Bed', value: 25, color: '#10b981' }, - { name: 'VIP Bed', value: 10, color: '#f59e0b' }, -]; - -const occupancyData = [ - { month: 'Jan', rate: 72 }, - { month: 'Feb', rate: 78 }, - { month: 'Mar', rate: 85 }, - { month: 'Apr', rate: 82 }, - { month: 'May', rate: 88 }, - { month: 'Jun', rate: 91 }, -]; +const COLORS = ['#3b82f6', '#10b981', '#f59e0b']; export default function ReportsPage() { - const [dateRange, setDateRange] = useState('last-30-days'); + const [dateRange, setDateRange] = useState('30'); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + + const getDateRange = () => { + const end = new Date(); + end.setHours(23, 59, 59, 999); + const start = new Date(); + + switch (dateRange) { + case '7': + start.setDate(end.getDate() - 7); + break; + case '30': + start.setDate(end.getDate() - 30); + break; + case '90': + start.setDate(end.getDate() - 90); + break; + default: + if (startDate && endDate) { + return { startDate, endDate }; + } + } + + return { + startDate: start.toISOString().split('T')[0], + endDate: end.toISOString().split('T')[0], + }; + }; + + const dates = getDateRange(); + + // Fetch all bookings + const { data: bookingsData, isLoading } = useQuery({ + queryKey: ['all-bookings'], + queryFn: () => bookingsApi.getAll({ pageSize: 1000 }), + }); + + // Filter bookings by date range + const bookings = Array.isArray(bookingsData?.items) + ? bookingsData.items.filter((b: any) => { + const bookingDate = new Date(b.createdAt).toISOString().split('T')[0]; + return bookingDate >= dates.startDate && bookingDate <= dates.endDate; + }) + : []; + + // Calculate metrics + const totalRevenue = bookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0); + const totalBookings = bookings.length; + const avgTicketPrice = totalBookings > 0 ? Math.round(totalRevenue / totalBookings) : 0; + + // Group by date for revenue chart + const byDate = bookings.reduce((acc: Record, b: any) => { + const date = new Date(b.createdAt).toISOString().split('T')[0]; + if (!acc[date]) { + acc[date] = { totalMinor: 0, count: 0 }; + } + acc[date].totalMinor += b.totalMinor || 0; + acc[date].count += 1; + return acc; + }, {} as Record); + + const chartData = Object.entries(byDate) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, d]: [string, any]) => ({ + date: new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + revenue: (d.totalMinor || 0) / 100, + bookings: d.count || 0, + })); return (
-
-
-

Reports & Analytics

-

View detailed reports and analytics

-
-
- - -
-
- -
-
-

Revenue by Route

- - - - - - formatCurrency(value, 'ETB')} /> - - - -
- -
-

Bookings by Class

- - - `${name}: ${value}%`} - outerRadius={100} - fill="#8884d8" - dataKey="value" - > - {bookingsByClass.map((entry, index) => ( - - ))} - - - - -
- -
-

Occupancy Rate Trend

- - - - - - `${value}%`} /> - - - -
+
+

Reports & Analytics

+

View detailed reports and performance metrics

+ {/* Date Range Selector */}
-

Quick Stats

-
-
-

Total Revenue

-

{formatCurrency(255000000, 'ETB')}

+
+
+ +
-
-

Total Bookings

-

1,247

+ + {dateRange === 'custom' && ( + <> +
+ + setStartDate(e.target.value)} + disabled={isLoading} + /> +
+
+ + setEndDate(e.target.value)} + disabled={isLoading} + /> +
+ + )} + + + Export + +
+ {isLoading && ( +

Loading...

+ )} +
+ + {/* Key Metrics */} +
+
+
+
+

Total Revenue

+

ETB {Math.round(totalRevenue / 100).toLocaleString()}

+

Last {dateRange} days

+
+
-
-

Avg. Ticket Price

-

{formatCurrency(42500, 'ETB')}

+
+ +
+
+
+

Total Bookings

+

{totalBookings.toLocaleString()}

+

All bookings

+
+
-
-

Cancellation Rate

-

3.2%

+
+ +
+
+
+

Avg. Ticket Price

+

ETB {(avgTicketPrice / 100).toLocaleString()}

+

Per booking

+
+ +
+
+ +
+
+
+

Avg. Daily Revenue

+

ETB {chartData.length > 0 ? Math.round((totalRevenue / 100) / chartData.length).toLocaleString() : '0'}

+

Daily average

+
+ +
+
+
+ + {/* Charts */} +
+ {/* Revenue Trend */} +
+

Revenue Trend

+ {chartData.length > 0 ? ( + + + + + + `ETB ${Math.round(value).toLocaleString()}`} /> + + + + + ) : ( +
+ No data available +
+ )} +
+ + {/* Daily Bookings */} +
+

Daily Bookings

+ {chartData.length > 0 ? ( + + + + + + + + + + ) : ( +
+ No data available +
+ )} +
+ + {/* Booking Status Distribution */} +
+

Booking Status

+ {bookings.length > 0 ? ( + + + b.status === 'CONFIRMED').length }, + { name: 'Completed', value: bookings.filter((b: any) => b.status === 'COMPLETED').length }, + { name: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length }, + { name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'COMPLETED', 'CANCELLED'].includes(b.status)).length }, + ].filter(d => d.value > 0)} + cx="50%" + cy="50%" + labelLine={false} + label={({ name, value }) => `${name}: ${value}`} + outerRadius={100} + dataKey="value" + > + {COLORS.map((color, idx) => )} + + + + + ) : ( +
+ No data available +
+ )} +
+ + {/* Top Payment Methods */} +
+

Payment Methods

+ {bookings.length > 0 ? ( +
+ {(Object.entries( + bookings.reduce((acc: Record, b: any) => { + const method = b.paymentIntent?.method || 'Unknown'; + acc[method] = (acc[method] || 0) + 1; + return acc; + }, {} as Record) + ) as [string, number][] + ) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([method, count]) => ( +
+ {method.toLowerCase().replace(/_/g, ' ')} + {count} +
+ ))} +
+ ) : ( +
+ No data available +
+ )} +
+
+ + {/* Summary Stats */} +
+

Summary

+
+
+

Total Days with Bookings

+

{chartData.length}

+
+
+

Confirmed Bookings

+

{bookings.filter((b: any) => b.status === 'CONFIRMED').length}

+
+
+

Completed Bookings

+

{bookings.filter((b: any) => b.status === 'COMPLETED').length}

+
+
+

Cancelled Bookings

+

{bookings.filter((b: any) => b.status === 'CANCELLED').length}

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/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index 62056e21a..ba4130b81 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -1,18 +1,24 @@ 'use client'; import { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { seatsApi, schedulesApi } from '@/lib/api'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { seatsApi, schedulesApi, fleetApi } from '@/lib/api'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton' -import { Armchair, Lock, Unlock, Bed, X, RotateCcw } from 'lucide-react'; +import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train } from 'lucide-react'; export default function SeatsPage() { const [selectedSchedule, setSelectedSchedule] = useState(''); + const [expandedCoaches, setExpandedCoaches] = useState>(new Set()); const [showBlockModal, setShowBlockModal] = useState(false); 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({ @@ -26,6 +32,11 @@ export default function SeatsPage() { enabled: !!selectedSchedule, }); + const { data: coachTypesData } = useQuery({ + queryKey: ['coachTypes'], + queryFn: () => fleetApi.getCoaches(), + }); + const blockMutation = useMutation({ mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }), onSuccess: () => { @@ -62,6 +73,43 @@ 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)) { + newExpanded.delete(coachId); + } else { + newExpanded.add(coachId); + } + setExpandedCoaches(newExpanded); + }; + const handleBlock = (seat: any) => { setSelectedSeat(seat); setShowBlockModal(true); @@ -84,6 +132,37 @@ 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) => { + return coach.seats?.some((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'); @@ -126,6 +205,12 @@ export default function SeatsPage() { return ''; }; + const formatBedSeatNumber = (seat: any): string => { + if (!seat.seatNumber || !seat.bedPosition) return seat.seatNumber || ''; + const label = getBedLabel(seat.bedPosition); + return `${seat.seatNumber}${label}`; + }; + const renderCoachSeats = (coach: any, isBedCoach: boolean) => { const allSeats = coach.seats || []; const validSeats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')); @@ -138,14 +223,13 @@ export default function SeatsPage() { const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); if (isBedCoach && hasBedPositionData) { - // Render bed coach with flipping effect and bed position labels const arrangement = parseSeatArrangement(coach.seatArrangement); const seatsPerRow = arrangement[0] + (arrangement[1] || 0); const allSeatsForLayout = [...validSeats, ...removedSeats]; - const rows = []; + const rows: any[][] = []; const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || ''); const isVipBed = seatClassStr.toLowerCase().includes('vip'); - const bedWidth = isVipBed ? 'w-24' : 'w-16'; + const bedWidth = isVipBed ? 'w-20' : 'w-16'; for (let i = 0; i < allSeatsForLayout.length; i += seatsPerRow) { rows.push(allSeatsForLayout.slice(i, i + seatsPerRow)); @@ -154,23 +238,14 @@ export default function SeatsPage() { return (
{rows.map((rowSeats: any[], idx: number) => { - const rowNumber = rowSeats[0]?.row || (idx + 1); - const shouldFlipIcon = rowNumber % 2 === 0; - const shouldFlipRow = rowNumber % 2 === 1; - const showSpacing = idx % 2 === 1; + const isFirstInPair = idx % 2 === 0; + const shouldFlipIcon = !isFirstInPair; + const isLastRow = idx === rows.length - 1; + const nextRowSeats = !isLastRow ? rows[idx + 1] : null; return (
- {shouldFlipIcon && ( -
- {rowSeats.map((seat: any) => ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''} -
- ))} -
- )} -
+
{rowSeats.map((seat: any) => ( ))}
- {!shouldFlipIcon && ( -
- {rowSeats.map((seat: any) => ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''} -
- ))} + {isFirstInPair && nextRowSeats && ( +
+ {rowSeats.map((seat: any, seatIdx: number) => { + const currentSeat = rowSeats[seatIdx]; + const nextSeat = nextRowSeats[seatIdx]; + const currentFormatted = currentSeat ? formatBedSeatNumber(currentSeat) : ''; + const nextFormatted = nextSeat ? formatBedSeatNumber(nextSeat) : ''; + return ( +
+
{currentFormatted}
+
{nextFormatted}
+
+ ); + })}
)} - {showSpacing &&
} + {!isFirstInPair &&
}
); })} @@ -205,7 +287,6 @@ export default function SeatsPage() { ); } - // Regular armchair layout const arrangement = parseSeatArrangement(coach.seatArrangement); const leftCount = arrangement[0]; const rightCount = arrangement[1] || 0; @@ -231,25 +312,24 @@ export default function SeatsPage() { const rightSeats = rowSeats.slice(leftCount); const rowNumber = rowSeats[0]?.row || 1; const shouldFlipArmchair = rowNumber % 2 === 0; - const shouldFlipRow = rowNumber % 2 === 0; const showSpacing = rowIdx % 2 === 1; return (
{shouldFlipArmchair && ( -
+
{leftSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))}
- {rightSeats.length > 0 &&
} + {rightSeats.length > 0 &&
} {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))} @@ -257,7 +337,7 @@ export default function SeatsPage() { )}
)} -
+
{leftSeats.map((seat: any) => ( ))}
- {rightSeats.length > 0 &&
} + {rightSeats.length > 0 &&
} {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( @@ -300,19 +380,19 @@ export default function SeatsPage() {
{!shouldFlipArmchair && ( -
+
{leftSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))}
- {rightSeats.length > 0 &&
} + {rightSeats.length > 0 &&
} {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))} @@ -329,22 +409,22 @@ 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) => (a.sequence || 0) - (b.sequence || 0)); return (
-
-
-

Seat Management

-

View and manage seat availability by schedule

-
+
+

Seat Management

+

View and manage seats by coach

-
-
+ {!selectedSchedule ? ( +
setSelectedSchedule(e.target.value)} + className="input" + > + + {schedules.map((schedule: any) => { + const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A'; + const routeName = schedule.route?.name || 'N/A'; + const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A'; + return ( + + ); + })} +
-
- {coachesWithSeats.map((coach: any) => { - const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) || - (coach.mode && coach.mode.toLowerCase().includes('bed')); - const seats = (coach.seats || []).filter((s: any) => s.seatNumber); +
+

Seat Status

+
+
+
+ Available +
+
+
+ Booked +
+
+
+ Held +
+
+
+ Blocked +
+
+
+ Removed +
+
+
+
- return ( -
-
-

Coach {coach.coachNumber}

-
+
+
+ +
-
- {renderCoachSeats(coach, isBedCoach)} -
+ {coachesWithSeats.map((coach: any, index: number) => { + const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach; + const coachTypeName = coachData?.coachType?.type || 'Coach'; + const isBedCoach = coachTypeName.toLowerCase().includes('bed'); + const seats = (coach.seats || []).filter((s: any) => s.seatNumber); + const isExpanded = expandedCoaches.has(coach.id); + const seatOrBedLabel = isBedCoach ? 'beds' : 'seats'; + + return ( +
+
+ + isCoachBlocked(coach) ? handleUnblockCoach(coach) : handleBlockCoach(coach)} + className="ml-2" + > + {isCoachBlocked(coach) ? 'Unblock' : 'Block'} +
- ); - })} -
+ + {isExpanded && ( +
+
+ {renderCoachSeats(coach, isBedCoach)} +
+
+ )} +
+ ); + })}
- )} -
+
+ )}

- Block seat {selectedSeat?.seatNumber} in Coach{' '} - {selectedSeat?.coach?.coachNumber} + Block seat {selectedSeat?.seatNumber} in Coach {selectedSeat?.coach?.coachNumber}

@@ -485,8 +617,7 @@ export default function SeatsPage() { >

- Remove seat {selectedSeat?.seatNumber} from Coach{' '} - {selectedSeat?.coach?.coachNumber} + Remove seat {selectedSeat?.seatNumber} from Coach {selectedSeat?.coach?.coachNumber}

@@ -514,6 +645,96 @@ export default function SeatsPage() {

+ + { + setShowBlockCoachModal(false); + setSelectedCoach(null); + setBlockCoachReason(''); + }} + title="Block Coach" + size="md" + > +
+

+ Block all seats in Coach {selectedCoach?.coachNumber} +

+
+

+ This will block all {selectedCoach?.seats?.length || 0} seats in this coach. +

+
+
+ +