Merge branch 'dev' into freight/develop

This commit is contained in:
Nathnael Wondisha
2026-06-16 16:22:38 +03:00
committed by GitHub
88 changed files with 10850 additions and 1987 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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");

View File

@@ -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;

View File

@@ -88,7 +88,9 @@ model SeatClass {
coachTypeId String
name String
description String?
baseFareMinor Int
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
@@ -96,7 +98,6 @@ model SeatClass {
fareRules FareRule[]
routeFareRules RouteFareRule[]
segmentFares SegmentFareRule[]
@@unique([coachTypeId, name])
@@index([coachTypeId])
@@schema("passenger")
@@ -234,6 +235,8 @@ model User {
role UserRole @default(PASSENGER)
nationality String?
nationalityCode String?
gender String? // Male, Female, Other
dateOfBirth DateTime?
passportNumber String?
nationalId String?
failedLoginAttempts Int @default(0)
@@ -312,6 +315,7 @@ model Station {
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)
@@ -320,8 +324,8 @@ model Station {
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
stopTimes TripStopTime[]
crowdSignals StationCrowdSignal[]
@@index([city, countryCode])
@@index([sequence])
@@schema("passenger")
}
@@ -407,14 +411,15 @@ model Coach {
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")
}
@@ -631,7 +636,7 @@ model Ticket {
id String @id @default(uuid())
bookingId String @unique
bookingRef String
status String @default("CONFIRMED")
status String @default("ACTIVE")
qrPayload String
barcodePayload String?
pdfUrl String?
@@ -639,6 +644,7 @@ model Ticket {
issuedAt DateTime @default(now())
validatedAt DateTime?
validatorId String?
boardedAt DateTime?
booking Booking @relation(fields: [bookingId], references: [id])
validationLogs GateValidationLog[]
seats TicketSeat[]

View File

@@ -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 tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
const schedules = [];
for (let d = 0; d < 30; d++) {
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,
});
}
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<string, { stationId: string; sequence: number; distanceKm: number }[]>();
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(
schedules.map(s => prisma.trainSchedule.create({ data: s }))
patchedSchedules.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 },
});
// 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,
};
});
}
// 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 }))
);
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;

View File

@@ -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 {

View File

@@ -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 {}

View File

@@ -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 },
});
}
}

View File

@@ -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),
}));

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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;

View File

@@ -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]

View File

@@ -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<number> {
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,

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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<string, string> = {
ETB: 'Ethiopian Birr',
USD: 'US Dollar',
DJF: 'Djiboutian Franc',
};
return names[code] || code;
}
private getCurrencySymbol(code: string): string {
const symbols: Record<string, string> = {
ETB: 'Br',
USD: '$',
DJF: 'Fdj',
};
return symbols[code] || code;
}
}

View File

@@ -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<FaydaConfig>('fayda');

View File

@@ -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],

View File

@@ -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: {

View File

@@ -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);

View File

@@ -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 } });
}

View File

@@ -0,0 +1,8 @@
export class SendEmail {
to: string;
subject: string;
body: string;
html?: string;
templateKey?: string;
context?: Record<string, unknown>;
}

View File

@@ -0,0 +1,9 @@
export class SendMessage {
to: string;
message: string;
from?: string;
}
export class BulkMessagesDto {
messages: SendMessage[];
}

View File

@@ -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 {};
}
}

View File

@@ -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')

View File

@@ -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<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
queue: config.get<string>('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<string>('RABBITMQ_URL') ?? 'amqp://localhost:5672'],
queue: config.get<string>('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 {}

View File

@@ -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<NotificationChannelType, NotificationChannel>([
['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;

View File

@@ -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 {};
}
}

View File

@@ -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 => ({
items: items.map(passenger => {
const user = passenger.user as any;
return {
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,
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) {

View File

@@ -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, { returnUrl?: string; failureUrl?: string }>
> = {
[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,

View File

@@ -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<string, number>);
// 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<string, any>);
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 } },
},
});

View File

@@ -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' })

View File

@@ -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;
}

View File

@@ -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[] }> {

View File

@@ -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);
}

View File

@@ -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[];
}

View File

@@ -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;
@@ -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,
});
}
@@ -341,6 +386,62 @@ export class SearchService {
}));
}
private async buildCoachTypeDetails(
schedule: any,
faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>,
): Promise<Array<{
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
classes: Array<{ name: string; baseFareMinor: number }>;
}>> {
const coachTypeMap = new Map<
string,
{ coachType: any; classNames: Set<string> }
>();
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<string, number> = {
'Economy Regular': 35000,

View File

@@ -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<CreateStationDto>) {
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);
}

View File

@@ -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 {}

View File

@@ -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<CreateStationDto>) {
await this.findOne(id); // Check if exists
return this.prisma.station.update({
const oldStation = await this.findOne(id);
const updatedStation = await this.prisma.station.update({
where: { id },
data: dto
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;
}
}

View File

@@ -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')

View File

@@ -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
};
}

View File

@@ -1,3 +1,6 @@
{
"extends": ["next/core-web-vitals"]
"extends": ["next/core-web-vitals"],
"rules": {
"react/no-unescaped-entities": "off"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<any>(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) => (
<div className="text-sm">
<div className="font-medium">{formatDateTime(log.createdAt)}</div>
<div className="text-xs text-muted-foreground">{new Date(log.createdAt).toLocaleTimeString()}</div>
</div>
),
},
{
key: 'action',
label: 'Action',
sortable: true,
render: (log: any) => (
<Badge>{log.action}</Badge>
<Badge className={getActionBadgeColor(log.action)}>
{log.action}
</Badge>
),
},
{
key: 'entityType',
label: 'Entity Type',
sortable: true,
render: (log: any) => (
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-xs font-medium">
{log.entityType}
</span>
),
},
{
key: 'entityId',
label: 'Entity ID',
render: (log: any) => (
<span className="font-mono text-xs text-muted-foreground">
{log.entityId ? log.entityId.substring(0, 12) : 'System'}
</span>
),
},
{
@@ -30,55 +93,74 @@ export default function AuditLogsPage() {
label: 'User',
render: (log: any) => (
<div>
<div className="font-medium">{log.user?.fullName || 'System'}</div>
<div className="text-sm text-muted-foreground">{log.user?.email || 'N/A'}</div>
<div className="font-medium text-sm">{log.user?.fullName || 'System'}</div>
<div className="text-xs text-muted-foreground">{log.user?.email || log.userId || 'N/A'}</div>
</div>
),
},
{
key: 'entityType',
label: 'Entity Type',
render: (log: any) => log.entityType,
},
{
key: 'entityId',
label: 'Entity ID',
key: 'ipAddress',
label: 'IP Address',
render: (log: any) => (
<span className="font-mono text-sm">{log.entityId?.substring(0, 8)}...</span>
<span className="text-xs text-muted-foreground font-mono">
{log.ipAddress || 'N/A'}
</span>
),
},
{
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Audit Logs</h1>
<p className="text-muted-foreground">Track all system activities and changes</p>
<h1 className="text-3xl font-bold text-foreground">Audit Logs</h1>
<p className="text-muted-foreground mt-1">Track all system activities and changes</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card">
<div className="text-muted-foreground text-sm font-medium">Total Logs</div>
<div className="text-2xl font-bold mt-2">{stats.total}</div>
</div>
<div className="card">
<div className="text-green-600 text-sm font-medium">Created</div>
<div className="text-2xl font-bold mt-2">{stats.creates}</div>
</div>
<div className="card">
<div className="text-blue-600 text-sm font-medium">Updated</div>
<div className="text-2xl font-bold mt-2">{stats.updates}</div>
</div>
<div className="card">
<div className="text-red-600 text-sm font-medium">Deleted</div>
<div className="text-2xl font-bold mt-2">{stats.deletes}</div>
</div>
</div>
{/* Filters */}
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="label">Search</label>
<label className="label">Search (User/Entity ID)</label>
<input
type="text"
placeholder="Search logs..."
@@ -110,22 +192,172 @@ export default function AuditLogsPage() {
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
>
<option value="">All Types</option>
<optgroup label="Master Data">
<option value="Station">Station</option>
<option value="Route">Route</option>
<option value="RouteStop">Route Stop</option>
<option value="Train">Train</option>
<option value="TrainSchedule">Train Schedule</option>
<option value="Coach">Coach</option>
<option value="CoachType">Coach Type</option>
<option value="SeatClass">Seat Class</option>
<option value="FareRule">Fare Rule</option>
<option value="RouteFareRule">Route Fare Rule</option>
<option value="SegmentFareRule">Segment Fare Rule</option>
<option value="BaggageAllowance">Baggage Allowance</option>
</optgroup>
<optgroup label="Operations">
<option value="Booking">Booking</option>
<option value="User">User</option>
<option value="Payment">Payment</option>
<option value="Ticket">Ticket</option>
<option value="Seat">Seat</option>
<option value="SeatBlock">Seat Block</option>
</optgroup>
<optgroup label="Users & Access">
<option value="User">User</option>
<option value="Agent">Agent</option>
<option value="Passenger">Passenger</option>
</optgroup>
<optgroup label="System & Features">
<option value="Notification">Notification</option>
<option value="Promotion">Promotion</option>
<option value="Loyalty">Loyalty</option>
<option value="Wallet">Wallet</option>
<option value="FraudAlert">Fraud Alert</option>
<option value="FraudRule">Fraud Rule</option>
</optgroup>
</select>
</div>
<div className="flex items-end">
<ActionButton
variant="secondary"
onClick={() => setFilters({ search: '', action: '', entityType: '' })}
className="w-full"
>
Clear Filters
</ActionButton>
</div>
</div>
</div>
{/* Data Table */}
<DataTable
data={data?.items || []}
data={logs}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No audit logs found"
/>
{/* Details Modal */}
<Modal
isOpen={showDetailsModal}
onClose={() => {
setShowDetailsModal(false);
setSelectedLog(null);
}}
title={`${selectedLog?.action} - ${selectedLog?.entityType}`}
size="lg"
>
<div className="space-y-4">
{/* Basic Info */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-muted-foreground">Timestamp</label>
<p className="text-sm mt-1">{formatDateTime(selectedLog?.createdAt)}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Action</label>
<p className="text-sm mt-1">
<Badge className={getActionBadgeColor(selectedLog?.action)}>
{selectedLog?.action}
</Badge>
</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Entity Type</label>
<p className="text-sm mt-1 font-mono">{selectedLog?.entityType}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Entity ID</label>
<p className="text-sm mt-1 font-mono text-muted-foreground">
{selectedLog?.entityId || 'System'}
</p>
</div>
</div>
{/* User Info */}
{selectedLog?.user && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-2">User Information</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-muted-foreground">Name</label>
<p className="text-sm mt-1">{selectedLog?.user?.fullName}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Email</label>
<p className="text-sm mt-1">{selectedLog?.user?.email}</p>
</div>
</div>
</div>
)}
{/* Network Info */}
{(selectedLog?.ipAddress || selectedLog?.userAgent) && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-2">Network Information</h4>
<div className="space-y-2">
{selectedLog?.ipAddress && (
<div>
<label className="text-xs font-semibold text-muted-foreground">IP Address</label>
<p className="text-sm mt-1 font-mono">{selectedLog?.ipAddress}</p>
</div>
)}
{selectedLog?.userAgent && (
<div>
<label className="text-xs font-semibold text-muted-foreground">User Agent</label>
<p className="text-xs mt-1 font-mono break-all text-muted-foreground">
{selectedLog?.userAgent}
</p>
</div>
)}
</div>
</div>
)}
{/* Changes */}
{(selectedLog?.oldData || selectedLog?.newData) && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-2">Data Changes</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedLog?.oldData && (
<div>
<label className="text-xs font-semibold text-red-600">Old Data</label>
<pre className="text-xs mt-1 p-2 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-900 overflow-auto max-h-48 text-muted-foreground">
{formatJsonData(selectedLog?.oldData)}
</pre>
</div>
)}
{selectedLog?.newData && (
<div>
<label className="text-xs font-semibold text-green-600">New Data</label>
<pre className="text-xs mt-1 p-2 bg-green-50 dark:bg-green-950/20 rounded border border-green-200 dark:border-green-900 overflow-auto max-h-48 text-muted-foreground">
{formatJsonData(selectedLog?.newData)}
</pre>
</div>
)}
</div>
</div>
)}
{/* Raw Log ID */}
<div className="border-t pt-4">
<label className="text-xs font-semibold text-muted-foreground">Log ID</label>
<p className="text-xs mt-1 font-mono text-muted-foreground break-all">{selectedLog?.id}</p>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -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() {
</div>
),
},
{
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() {
<h1 className="text-2xl font-bold">Bookings</h1>
<p className="text-muted-foreground">Manage all passenger bookings</p>
</div>
<ActionButton variant="export" icon={Download}>Export</ActionButton>
<ActionButton variant="export" icon={Download} onClick={handleExportBookings}>Export</ActionButton>
</div>
<div className="card">

View File

@@ -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) => (
<span className="font-mono text-sm">{formatCurrency(cls.baseFareMinor, 'ETB')}</span>
<span className="font-mono text-sm">{(cls.baseFareMinor / 100).toFixed(2)} ETB</span>
),
},
{
key: 'premiumMinor',
label: 'Premium',
render: (cls: any) => (
<span className="font-mono text-sm">{cls.premiumMinor ? (cls.premiumMinor / 100).toFixed(2) : '0.00'} ETB</span>
),
},
{
key: 'insuranceFeeMinor',
label: 'Insurance',
render: (cls: any) => (
<span className="font-mono text-sm">{cls.insuranceFeeMinor ? (cls.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB</span>
),
},
{
@@ -181,7 +197,7 @@ export default function ClassesPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Classes</h1>
<p className="text-muted-foreground">Manage class configurations by coach type</p>
<p className="text-muted-foreground">Manage class configurations with pricing by coach type</p>
</div>
<ActionButton
icon={Plus}
@@ -278,18 +294,60 @@ export default function ClassesPage() {
/>
</div>
<div className="border-t pt-4">
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
<div>
<label className="label">Base Fare (ETB cents) *</label>
<label className="label">Base Fare (ETB) *</label>
<input
type="number"
name="baseFareMinor"
className="input"
defaultValue={editingClass?.baseFareMinor || ''}
defaultValue={editingClass?.baseFareMinor ? (editingClass.baseFareMinor / 100).toFixed(2) : ''}
required
min="0"
placeholder="e.g., 45000 (450 ETB)"
step="0.01"
placeholder="e.g., 350.00"
/>
<p className="text-xs text-muted-foreground mt-1">Enter amount in cents (100 cents = 1 ETB)</p>
<p className="text-xs text-muted-foreground mt-1">Per-km distance-based fare rate</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<label className="label">Premium Fee (ETB)</label>
<input
type="number"
name="premiumMinor"
className="input"
defaultValue={editingClass?.premiumMinor ? (editingClass.premiumMinor / 100).toFixed(2) : '0.00'}
min="0"
step="0.01"
placeholder="e.g., 50.00"
/>
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., lounge access, extra legroom)</p>
</div>
<div>
<label className="label">Insurance Fee (ETB)</label>
<input
type="number"
name="insuranceFeeMinor"
className="input"
defaultValue={editingClass?.insuranceFeeMinor ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'}
min="0"
step="0.01"
placeholder="e.g., 25.00"
/>
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
</div>
</div>
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">Total Fare Calculation:</p>
<p>Total = (Base Fare × Distance) + Premium + Insurance</p>
<p className="mt-2 text-xs"> Premium applies per passenger (including free child)</p>
<p className="text-xs"> Insurance applies per passenger (including free child)</p>
</div>
</div>
<div>

View File

@@ -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 <div className="text-xs text-muted-foreground">No seats</div>;
}
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<number, any[]>();
for (const seat of validSeats) {
if (!cols.has(seat.row)) cols.set(seat.row, []);
cols.get(seat.row)!.push(seat);
}
return (
<div className="space-y-1">
{Array.from(cols.entries()).map(([row, rowSeats]) => (
<div key={row} className="flex gap-3 justify-start">
<div className="flex gap-0.5">
{rowSeats.slice(0, left).map((s: any) => (
<div key={s.id} className="w-6 h-6 rounded bg-green-500 flex items-center justify-center">
<Armchair className="w-3 h-3 text-white" />
</div>
))}
</div>
<div className="flex gap-0.5">
{rowSeats.slice(left).map((s: any) => (
<div key={s.id} className="w-6 h-6 rounded bg-green-500 flex items-center justify-center">
<Armchair className="w-3 h-3 text-white" />
</div>
))}
</div>
</div>
))}
</div>
);
}
// Bed layout with pairing
const seatsByRow = new Map<number, any[]>();
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 (
<div className="space-y-1">
{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 (
<div key={`row-${idx}`}>
{/* Row 1 of pair - label above */}
{isFirstInPair && (
<div className="flex gap-0.5 text-xs text-gray-500 mb-0.5">
{rowSeats.map((s: any) => (
<div key={`label-${s.id}`} className={`${beds} h-2 flex items-center justify-center text-xs font-bold leading-3`}>
{s.seatNumber}
</div>
))}
</div>
)}
{/* Row 1 of pair - beds */}
<div className="flex gap-0.5">
{rowSeats.map((s: any) => (
<div
key={s.id}
className={`${beds} h-5 rounded flex items-center justify-center bg-green-500`}
style={isFirstInPair ? { transform: 'scaleY(-1)' } : undefined}
>
<Bed className="w-3 h-3 text-white" />
</div>
))}
</div>
{/* Numbers between rows */}
{isFirstInPair && nextRowSeats && (
<div className="flex gap-0.5 text-xs text-gray-500 my-0.5">
{rowSeats.map((s: any, idx: number) => {
const nextSeat = nextRowSeats[idx];
return (
<div key={`between-${s.id}`} className={`${beds} h-2 flex items-center justify-center text-xs font-bold leading-3`}>
{nextSeat?.seatNumber}
</div>
);
})}
</div>
)}
{/* Row 2 of pair - beds */}
{!isFirstInPair && (
<div className="flex gap-0.5">
{rowSeats.map((s: any) => (
<div
key={s.id}
className={`${beds} h-5 rounded flex items-center justify-center bg-green-500`}
>
<Bed className="w-3 h-3 text-white" />
</div>
))}
</div>
)}
{!isFirstInPair && <div className="h-1" />}
</div>
);
})}
</div>
);
};
export default function CoachesPage() {
const [activeTab, setActiveTab] = useState<Tab>('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() {
<span className="text-sm">{coach.coachType?.name || 'N/A'}</span>
),
},
{
key: 'visualization',
label: 'Seats/Beds',
render: (coach: any) => (
<div className="bg-gray-50 dark:bg-gray-900/30 rounded p-2 max-w-xs overflow-x-auto">
{renderBedVisualization(coach)}
</div>
),
},
{
key: 'arrangement',
label: 'Arrangement',
@@ -486,7 +625,7 @@ export default function CoachesPage() {
<form onSubmit={handleCoachSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Coach Type</label>
<label className="label">Coach Type *</label>
<select
name="coachTypeId"
className="input"
@@ -503,7 +642,7 @@ export default function CoachesPage() {
</div>
<div>
<label className="label">Number</label>
<label className="label">Number *</label>
<input
type="text"
name="number"
@@ -515,20 +654,20 @@ export default function CoachesPage() {
</div>
<div>
<label className="label">Arrangement</label>
<label className="label">Arrangement *</label>
<input
type="text"
name="arrangement"
className="input"
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement || '2+2'}
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement }
required
placeholder="e.g., 2+2, 3+2"
placeholder="e.g., 3+2, 3+0, 2+0"
/>
<p className="text-xs text-muted-foreground mt-1">Format: separate columns with +</p>
</div>
<div>
<label className="label">Capacity</label>
<label className="label">Capacity *</label>
<input
type="number"
name="capacity"
@@ -540,12 +679,27 @@ export default function CoachesPage() {
/>
</div>
<div className="md:col-span-2">
<label className="label">Status</label>
<div>
<label className="label">Sequence Number *</label>
<input
type="number"
name="sequence"
className="input"
defaultValue={editingItem?.sequence || 0}
min="0"
required
placeholder="e.g., 1"
/>
<p className="text-xs text-muted-foreground mt-1">Used for ordering coaches in trains</p>
</div>
<div>
<label className="label">Status *</label>
<select
name="status"
className="input"
defaultValue={editingItem?.status || 'ACTIVE'}
required
>
<option value="ACTIVE">Active</option>
<option value="MAINTENANCE">Maintenance</option>

View File

@@ -0,0 +1,54 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { useAuthStore } from '@/lib/auth-store';
export default function CurrenciesLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { isAuthenticated } = useAuthStore();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsLoading(false);
}, 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, router, isLoading]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950 p-6">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,475 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Loader2, Edit, RefreshCw } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client';
interface Currency {
id: string;
code: string;
name: string;
symbol: string;
baseCurrencyCode: string;
exchangeRate: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export default function CurrenciesPage() {
const [showModal, setShowModal] = useState(false);
const [editingCurrency, setEditingCurrency] = useState<Currency | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null }>({
isOpen: false,
id: null,
});
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
const [currencyForm, setCurrencyForm] = useState({
code: '',
name: '',
symbol: '',
baseCurrencyCode: 'ETB',
exchangeRate: '',
});
const { data: currencies = [], isLoading } = useQuery({
queryKey: ['currencies'],
queryFn: () => apiClient.get('/currencies'),
});
const createMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/currencies', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
resetForm();
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to create currency');
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => apiClient.patch(`/currencies/${data.id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setEditingCurrency(null);
resetForm();
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to update currency');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/currencies/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setDeleteConfirm({ isOpen: false, id: null });
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to delete currency');
},
});
const syncRatesMutation = useMutation({
mutationFn: () => apiClient.post('/currencies/sync-rates', {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to sync exchange rates');
},
});
const resetForm = () => {
setCurrencyForm({
code: '',
name: '',
symbol: '',
baseCurrencyCode: 'ETB',
exchangeRate: '',
});
setEditingCurrency(null);
setShowModal(false);
setError(null);
};
const handleEditCurrency = (currency: Currency) => {
setEditingCurrency(currency);
setCurrencyForm({
code: currency.code,
name: currency.name,
symbol: currency.symbol,
baseCurrencyCode: currency.baseCurrencyCode,
exchangeRate: currency.exchangeRate.toString(),
});
setError(null);
setShowModal(true);
};
const handleSaveCurrency = async () => {
setError(null);
if (!currencyForm.code || !currencyForm.name || !currencyForm.symbol || !currencyForm.exchangeRate) {
setError('All fields are required');
return;
}
const rate = parseFloat(currencyForm.exchangeRate);
if (isNaN(rate) || rate <= 0) {
setError('Exchange rate must be a positive number');
return;
}
const payload = {
code: currencyForm.code.toUpperCase(),
name: currencyForm.name,
symbol: currencyForm.symbol,
baseCurrencyCode: currencyForm.baseCurrencyCode,
exchangeRate: rate,
};
if (editingCurrency) {
await updateMutation.mutateAsync({ id: editingCurrency.id, ...payload });
} else {
await createMutation.mutateAsync(payload);
}
};
const confirmDelete = async () => {
if (deleteConfirm.id) {
await deleteMutation.mutateAsync(deleteConfirm.id);
}
};
const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items || [];
const columns = [
{
key: 'code',
label: 'Code',
render: (currency: Currency) => (
<span className="font-mono font-semibold text-primary">{currency.code}</span>
),
},
{
key: 'name',
label: 'Name',
render: (currency: Currency) => (
<span className="font-medium">{currency.name}</span>
),
},
{
key: 'symbol',
label: 'Symbol',
render: (currency: Currency) => (
<span className="text-lg">{currency.symbol}</span>
),
},
{
key: 'baseCurrencyCode',
label: 'Base Currency',
render: (currency: Currency) => (
<span className="font-mono text-sm">{currency.baseCurrencyCode}</span>
),
},
{
key: 'exchangeRate',
label: 'Exchange Rate',
render: (currency: Currency) => (
<div className="space-y-1">
<div className="font-mono font-semibold">
1 {currency.baseCurrencyCode} = {currency.exchangeRate.toFixed(4)} {currency.code}
</div>
<div className="text-xs text-muted-foreground">
1 {currency.code} = {(1 / currency.exchangeRate).toFixed(6)} {currency.baseCurrencyCode}
</div>
</div>
),
},
{
key: 'isActive',
label: 'Status',
render: (currency: Currency) => (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
currency.isActive
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-gray-100 dark:bg-gray-900/20 text-gray-800 dark:text-gray-300'
}`}>
{currency.isActive ? 'Active' : 'Inactive'}
</span>
),
},
{
key: 'updatedAt',
label: 'Last Updated',
render: (currency: Currency) => (
<span className="text-sm text-muted-foreground">
{new Date(currency.updatedAt).toLocaleDateString()}
</span>
),
},
];
const actions = [
{
label: 'Edit',
onClick: handleEditCurrency,
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: (currency: Currency) => setDeleteConfirm({ isOpen: true, id: currency.id }),
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Currencies</h1>
<p className="text-muted-foreground mt-1">Manage exchange rates and display currencies</p>
</div>
<div className="flex gap-3">
<ActionButton
icon={RefreshCw}
variant="secondary"
onClick={() => syncRatesMutation.mutate()}
loading={syncRatesMutation.isPending}
>
Sync Rates
</ActionButton>
<ActionButton
icon={Plus}
onClick={() => {
setError(null);
setEditingCurrency(null);
setCurrencyForm({
code: '',
name: '',
symbol: '',
baseCurrencyCode: 'ETB',
exchangeRate: '',
});
setShowModal(true);
}}
>
Add Currency
</ActionButton>
</div>
</div>
<div className="card">
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="p-4 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-900/10 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="text-sm text-blue-600 dark:text-blue-400 font-medium">Total Currencies</div>
<div className="text-2xl font-bold text-blue-900 dark:text-blue-200 mt-2">
{currenciesArray.length}
</div>
</div>
<div className="p-4 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-900/10 rounded-lg border border-green-200 dark:border-green-800">
<div className="text-sm text-green-600 dark:text-green-400 font-medium">Active</div>
<div className="text-2xl font-bold text-green-900 dark:text-green-200 mt-2">
{currenciesArray.filter((c: Currency) => c.isActive).length}
</div>
</div>
<div className="p-4 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-900/10 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="text-sm text-purple-600 dark:text-purple-400 font-medium">Base Currency</div>
<div className="text-2xl font-bold text-purple-900 dark:text-purple-200 mt-2">ETB</div>
</div>
<div className="p-4 bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-900/10 rounded-lg border border-orange-200 dark:border-orange-800">
<div className="text-sm text-orange-600 dark:text-orange-400 font-medium">Last Sync</div>
<div className="text-lg font-bold text-orange-900 dark:text-orange-200 mt-2">
{currenciesArray.length > 0
? new Date(currenciesArray[0]?.updatedAt).toLocaleDateString()
: 'N/A'}
</div>
</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : currenciesArray.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>No currencies configured. Click "Add Currency" to create one.</p>
</div>
) : (
<DataTable
data={currenciesArray}
columns={columns}
actions={actions}
loading={false}
emptyMessage="No currencies found."
/>
)}
</div>
</div>
<div className="card bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-3">Currency Management</h3>
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-2">
<li>
<strong>Base Currency:</strong> All exchange rates are calculated relative to this currency (typically ETB)
</li>
<li>
<strong>Exchange Rate:</strong> How many units of the currency equal 1 unit of the base currency
</li>
<li>
<strong>Display Currencies:</strong> Configure which currencies customers can view prices in
</li>
<li>
<strong>Sync Rates:</strong> Automatically update exchange rates from external sources
</li>
</ul>
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, id: null })}
onConfirm={confirmDelete}
title="Delete Currency"
message="Are you sure you want to delete this currency? This action cannot be undone."
confirmText="Delete"
isDanger={true}
warning="This will remove the currency from the system."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={resetForm}
title={`${editingCurrency ? 'Edit' : 'Add'} Currency`}
size="lg"
>
<div className="space-y-4">
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Currency Code *</label>
<input
type="text"
value={currencyForm.code}
onChange={(e) => setCurrencyForm({ ...currencyForm, code: e.target.value.toUpperCase() })}
className="input w-full"
placeholder="e.g., USD"
maxLength={3}
disabled={!!editingCurrency}
required
/>
<p className="text-xs text-muted-foreground mt-1">3-letter ISO code (e.g., USD, DJF, GBP)</p>
</div>
<div>
<label className="label">Currency Name *</label>
<input
type="text"
value={currencyForm.name}
onChange={(e) => setCurrencyForm({ ...currencyForm, name: e.target.value })}
className="input w-full"
placeholder="e.g., United States Dollar"
required
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Symbol *</label>
<input
type="text"
value={currencyForm.symbol}
onChange={(e) => setCurrencyForm({ ...currencyForm, symbol: e.target.value })}
className="input w-full"
placeholder="e.g., $"
maxLength={3}
required
/>
</div>
<div>
<label className="label">Base Currency *</label>
<select
value={currencyForm.baseCurrencyCode}
onChange={(e) => setCurrencyForm({ ...currencyForm, baseCurrencyCode: e.target.value })}
className="input w-full"
disabled
>
<option value="ETB">ETB (Ethiopian Birr)</option>
<option value="USD">USD (US Dollar)</option>
<option value="DJF">DJF (Djiboutian Franc)</option>
</select>
<p className="text-xs text-muted-foreground mt-1">All rates relative to this currency</p>
</div>
</div>
<div>
<label className="label">Exchange Rate *</label>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
step="0.0001"
value={currencyForm.exchangeRate}
onChange={(e) => setCurrencyForm({ ...currencyForm, exchangeRate: e.target.value })}
className="input w-full"
placeholder="e.g., 0.018"
required
/>
<div className="text-sm text-muted-foreground whitespace-nowrap">
1 {currencyForm.baseCurrencyCode} = ? {currencyForm.code}
</div>
</div>
{currencyForm.exchangeRate && parseFloat(currencyForm.exchangeRate) > 0 && (
<p className="text-xs text-muted-foreground mt-2">
1 {currencyForm.code} = {(1 / parseFloat(currencyForm.exchangeRate)).toFixed(6)} {currencyForm.baseCurrencyCode}
</p>
)}
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-3 rounded-lg text-xs text-blue-800 dark:text-blue-200">
<p className="font-semibold mb-1">Exchange Rate Example:</p>
<p>If 1 ETB = 0.018 USD, enter 0.018</p>
<p>If 1 ETB = 3.25 DJF, enter 3.25</p>
</div>
<div className="flex gap-2 justify-end pt-4">
<ActionButton
variant="secondary"
onClick={resetForm}
type="button"
>
Cancel
</ActionButton>
<ActionButton
onClick={handleSaveCurrency}
loading={createMutation.isPending || updateMutation.isPending}
>
{editingCurrency ? 'Update Currency' : 'Add Currency'}
</ActionButton>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -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,18 +22,51 @@ export default function DashboardPage() {
queryFn: () => dashboardApi.getRevenueChart(30),
});
const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery<any>({
const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery<any[]>({
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: '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',
@@ -45,13 +80,37 @@ 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) => (
<Badge variant="status" status={item.status}>
{item.status}
</Badge>
)
},
];
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground mt-1">Hello, welcome back! Here&apos;s what&apos;s happening today.</p>
<p className="text-muted-foreground mt-1">Welcome back! Here&apos;s your operational summary.</p>
</div>
{/* Primary Metrics */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Total Bookings"
@@ -74,11 +133,14 @@ export default function DashboardPage() {
<StatCard
title="Occupancy Rate"
value={statsLoading ? '...' : `${stats?.occupancyRate || 0}%`}
icon={TrendingUp}
color="green"
icon={Percent}
color="orange"
/>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Revenue Trend */}
{!revenueLoading && revenueData && revenueData.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Revenue Trend (Last 30 Days)</h2>
@@ -94,15 +156,84 @@ export default function DashboardPage() {
</div>
)}
{/* Occupancy Trend */}
{!occupancyLoading && occupancyTrend && occupancyTrend.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Occupancy Trend (Last 7 Days)</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={occupancyTrend}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
<Tooltip formatter={(value: number) => `${value}%`} />
<Bar dataKey="occupancyRate" fill="#10b981" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
{/* Payment Methods Distribution */}
{paymentMethods && paymentMethods.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Payment Methods Distribution</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={paymentMethods}
dataKey="count"
nameKey="method"
cx="50%"
cy="50%"
outerRadius={80}
label
>
{paymentMethods.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
)}
{/* Recent Bookings */}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Recent Bookings</h2>
<DataTable
data={recentBookings}
columns={columns}
columns={bookingColumns}
loading={bookingsLoading}
emptyMessage="No recent bookings"
/>
</div>
{/* Upcoming Trips */}
{upcomingTrips && upcomingTrips.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Upcoming Trips</h2>
<DataTable
data={upcomingTrips}
columns={tripColumns}
loading={tripsLoading}
emptyMessage="No upcoming trips"
/>
</div>
)}
{/* Top Agents */}
{topAgents && topAgents.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Top Performing Agents</h2>
<DataTable
data={topAgents}
columns={agentColumns}
loading={agentsLoading}
emptyMessage="No agent data"
/>
</div>
)}
</div>
);
}

View File

@@ -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 }) => (
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">{number}</div>
<div className="flex-1">
<h4 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">{title}</h4>
{children}
</div>
</div>
</div>
);
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<div className="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<FileText className="h-8 w-8 text-emerald-600" />
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Documentation</h1>
</div>
<div className="flex items-center gap-2">
<a href="http://localhost:4000/api-docs" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition" title="Opens API documentation in new tab">
<FileText className="h-4 w-4" />
View API Docs
</a>
<Link href="/dashboard" target="_blank" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 transition">
<Home className="h-4 w-4" />
Dashboard
</Link>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-1">
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 sticky top-24 h-fit">
<nav>
{sections.map(section => (
<div key={section.id}>
<button onClick={() => toggleSection(section.id)} className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-700 border-b border-slate-100 dark:border-slate-700">
<span>{section.title}</span>
{expandedSections[section.id] ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{expandedSections[section.id] && (
<div className="bg-slate-50 dark:bg-slate-700/50">
{section.items.map(item => (
<button key={item.id} onClick={() => scrollToSection(item.id)} className="w-full text-left px-6 py-2 text-sm text-slate-600 dark:text-slate-300 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-white dark:hover:bg-slate-700 transition">
{item.label}
</button>
))}
</div>
)}
</div>
))}
</nav>
</div>
</div>
<div className="lg:col-span-3">
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-8 space-y-12">
<div id="about">
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-4">Welcome to EDR Passenger Backoffice</h2>
<p className="text-slate-600 dark:text-slate-300">Comprehensive management system for the Ethio-Djibouti Railway passenger platform. This documentation provides complete guidance on all features, operations, and best practices.</p>
</div>
<div id="features" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🌟 Key Features</h2>
<p className="text-slate-600 dark:text-slate-300">Complete booking, passenger, fleet, and financial management.</p>
</div>
{/* BOOKINGS */}
<div id="bookings" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📋 Bookings</h2>
<p className="text-slate-600 dark:text-slate-300">Manage passenger bookings with search, view, modify, and refund capabilities.</p>
</div>
<div id="bookings-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📋 How-To: Manage Bookings</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Bookings">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Bookings"`} in Operations section</li>
<li>View all bookings in table format</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Search & Filter">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Use search box for reference, email, or phone</li>
<li>Use Status dropdown to filter</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Manage Bookings">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"View Details"`} for full information</li>
<li>Click {`"Cancel Booking"`} to process refunds</li>
</ol>
</HowToStep>
</div>
</div>
{/* PASSENGERS */}
<div id="passengers" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">👥 Passengers</h2>
<p className="text-slate-600 dark:text-slate-300">Manage passenger profiles, loyalty, and verification status.</p>
</div>
<div id="passengers-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">👥 How-To: Manage Passengers</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Passengers">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Passengers"`} in Operations</li>
<li>View all profiles with pagination</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Search & Filter">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Search by name, email, phone, ID</li>
<li>Filter by nationality, verification, loyalty tier</li>
</ol>
</HowToStep>
<HowToStep number={3} title="View Profile">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click passenger row to open modal</li>
<li>View account, loyalty, wallet, booking history</li>
</ol>
</HowToStep>
</div>
</div>
{/* TICKETS */}
<div id="tickets" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🎫 Tickets</h2>
<p className="text-slate-600 dark:text-slate-300">Manage ticket generation, tracking, and validation.</p>
</div>
<div id="tickets-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🎫 How-To: Manage Tickets</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Tickets">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Tickets"`} in Operations</li>
<li>View all issued tickets with status</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Search Tickets">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Search by booking reference or ticket number</li>
<li>Filter by validation status</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Download PDF">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click ticket to view details</li>
<li>Click {`"Download PDF"`} for printable version</li>
</ol>
</HowToStep>
</div>
</div>
{/* STATIONS */}
<div id="stations" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🏢 Stations</h2>
<p className="text-slate-600 dark:text-slate-300">Configure railway stations with locations and timezones.</p>
</div>
<div id="stations-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🏢 How-To: Manage Stations</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Stations">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Stations"`} in Master Data</li>
<li>View all configured stations</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Station">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Add Station"`}</li>
<li>Enter code, name, city, timezone, coordinates</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Edit Station">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click station to open details</li>
<li>Update information and save</li>
</ol>
</HowToStep>
</div>
</div>
{/* TRAINS */}
<div id="trains" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🚂 Trains</h2>
<p className="text-slate-600 dark:text-slate-300">Manage train fleet with coach assignments.</p>
</div>
<div id="trains-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🚂 How-To: Manage Trains</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Trains">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Trains"`} in Master Data</li>
<li>View all trains and coaches</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Train">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Add Train"`}</li>
<li>Enter code and select coaches</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Assign Coaches">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click train to edit</li>
<li>Add/remove coaches with position numbers</li>
</ol>
</HowToStep>
</div>
</div>
{/* COACHES */}
<div id="coaches" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🚃 Coaches</h2>
<p className="text-slate-600 dark:text-slate-300">Manage coach inventory with seat configurations.</p>
</div>
<div id="coaches-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🚃 How-To: Manage Coaches</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Coaches">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Coaches" in Master Data</li>
<li>View all coaches and assignments</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Coach">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Coach"</li>
<li>Enter code, select train, define seat layout</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Configure Seats">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click coach to edit</li>
<li>Add seats and assign classes</li>
</ol>
</HowToStep>
</div>
</div>
{/* SEATS */}
<div id="seats" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💺 Seats</h2>
<p className="text-slate-600 dark:text-slate-300">Manage seat inventory with visual maps.</p>
</div>
<div id="seats-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💺 How-To: Manage Seats</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Seat Map">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to "Seats" in Master Data</li>
<li>Select coach from dropdown</li>
<li>Visual map shows: Green=Available, Red=Blocked</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Block Seat">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click available seat</li>
<li>Click "Block" and select reason</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Unblock Seat">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click blocked seat</li>
<li>Click "Unblock" to restore</li>
</ol>
</HowToStep>
</div>
</div>
{/* SEAT CLASSES */}
<div id="classes" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🎯 Seat Classes</h2>
<p className="text-slate-600 dark:text-slate-300">Define seat class types with pricing.</p>
</div>
<div id="classes-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🎯 How-To: Manage Seat Classes</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Classes">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Seat Classes" in Master Data</li>
<li>View all class types</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Class">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Class"</li>
<li>Enter name, base fare, premium, insurance</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Update Pricing">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click class to edit</li>
<li>Update fares and save</li>
</ol>
</HowToStep>
</div>
</div>
{/* ROUTES */}
<div id="routes" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🛤 Routes</h2>
<p className="text-slate-600 dark:text-slate-300">Define railway routes with ordered stops.</p>
</div>
<div id="routes-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🛤 How-To: Manage Routes</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Routes">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Routes" in Master Data</li>
<li>View all routes and stops</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Route">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Route"</li>
<li>Enter code and description</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Add Stops">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click route to edit</li>
<li>Click "Add Stop" and select station</li>
</ol>
</HowToStep>
</div>
</div>
{/* SCHEDULES */}
<div id="schedules" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📅 Schedules</h2>
<p className="text-slate-600 dark:text-slate-300">Create and manage train schedules.</p>
</div>
<div id="schedules-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📅 How-To: Create Schedules</h3>
<div className="space-y-4">
<HowToStep number={1} title="Create Single">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to "Schedules" in Master Data</li>
<li>Click "Create Schedule"</li>
<li>Fill train, route, departure/arrival times</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Bulk Generate">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Bulk Generate"</li>
<li>Set recurring parameters and generate</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Manage Status">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click schedule to edit</li>
<li>Update times and view fares</li>
</ol>
</HowToStep>
</div>
</div>
{/* PRICING */}
<div id="pricing" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💰 Pricing & Fares</h2>
<p className="text-slate-600 dark:text-slate-300">Configure dynamic pricing with segments.</p>
</div>
<div id="pricing-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💰 How-To: Configure Pricing</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Pricing">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Pricing & Fares" in Financial</li>
<li>Two tabs: Schedule Fares, Segment Fares</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Schedule Fares">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Fare Rule"</li>
<li>Fill schedule, seat class, fare, nationality</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Segment Fares">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Switch to "Segment Fares" tab</li>
<li>Select route and add origin/destination fare</li>
</ol>
</HowToStep>
</div>
</div>
{/* CURRENCIES */}
<div id="currencies" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💵 Currencies</h2>
<p className="text-slate-600 dark:text-slate-300">Manage exchange rates for multiple currencies.</p>
</div>
<div id="currencies-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💵 How-To: Manage Currencies</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Rates">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Currencies" in Financial</li>
<li>View all configured rates</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Add Rate">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Rate"</li>
<li>Select currency and enter exchange rate</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Sync Rates">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click rate to edit</li>
<li>Click "Sync" to update from provider</li>
</ol>
</HowToStep>
</div>
</div>
{/* PAYMENTS */}
<div id="payments" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💳 Payments</h2>
<p className="text-slate-600 dark:text-slate-300">Monitor and process transactions.</p>
</div>
<div id="payments-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💳 How-To: Manage Payments</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Transactions">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Payments" in Financial</li>
<li>View all transactions</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Search & Filter">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Search by booking or transaction ID</li>
<li>Filter by status and payment method</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Process Refunds">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click transaction</li>
<li>Click "Refund" if eligible</li>
</ol>
</HowToStep>
</div>
</div>
{/* PROMOS */}
<div id="promos" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🎁 Promo Codes</h2>
<p className="text-slate-600 dark:text-slate-300">Create and manage promotional campaigns.</p>
</div>
<div id="promos-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🎁 How-To: Manage Promo Codes</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Promos">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Promo Codes" in Financial</li>
<li>View all active codes</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Code">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Promo Code"</li>
<li>Enter code, discount type, validity dates</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Track Usage">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click code to view analytics</li>
<li>View usage count and savings</li>
</ol>
</HowToStep>
</div>
</div>
{/* LOYALTY */}
<div id="loyalty" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🏆 Loyalty</h2>
<p className="text-slate-600 dark:text-slate-300">Manage loyalty program and rewards.</p>
</div>
<div id="loyalty-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🏆 How-To: Manage Loyalty</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Accounts">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Loyalty Program" in Services</li>
<li>View all loyalty accounts</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Adjust Points">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click account</li>
<li>Click "Adjust Points" and enter amount</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Award Rewards">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click account</li>
<li>Click "Grant Reward" and select reward</li>
</ol>
</HowToStep>
</div>
</div>
{/* SUPPORT */}
<div id="support" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💬 Support</h2>
<p className="text-slate-600 dark:text-slate-300">Manage support tickets and conversations.</p>
</div>
<div id="support-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💬 How-To: Manage Support</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Tickets">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Support Center" in Services</li>
<li>View all support tickets</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Manage Ticket">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click ticket to open conversation</li>
<li>Add replies and update status</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Manage FAQ">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to FAQ management</li>
<li>Add or edit FAQ articles</li>
</ol>
</HowToStep>
</div>
</div>
{/* NOTIFICATIONS */}
<div id="notifications" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🔔 Notifications</h2>
<p className="text-slate-600 dark:text-slate-300">Send notifications via multiple channels.</p>
</div>
<div id="notifications-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🔔 How-To: Manage Notifications</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Notifications">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Notifications" in Services</li>
<li>View notification history</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Send Notification">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Send Notification"</li>
<li>Select channel and message</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Manage Templates">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to Templates section</li>
<li>Create or edit templates with variables</li>
</ol>
</HowToStep>
</div>
</div>
{/* AUDIT */}
<div id="audit" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📋 Audit Logs</h2>
<p className="text-slate-600 dark:text-slate-300">Monitor system activities and user actions.</p>
</div>
<div id="audit-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📋 How-To: View Audit Logs</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Logs">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Audit Logs" in Security</li>
<li>View all recorded activities</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Filter Logs">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Filter by user, action, or date</li>
<li>Search by entity ID</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Export Logs">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click log entry for details</li>
<li>Click "Export" to download CSV</li>
</ol>
</HowToStep>
</div>
</div>
{/* FRAUD */}
<div id="fraud" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🛡 Fraud Detection</h2>
<p className="text-slate-600 dark:text-slate-300">Monitor and manage fraud alerts.</p>
</div>
<div id="fraud-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🛡 How-To: Manage Fraud Detection</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Alerts">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Fraud Detection" in Security</li>
<li>View all fraud alerts</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Investigate">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click alert to view details</li>
<li>Review triggered rules and patterns</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Take Action">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Allow" or "Block" with notes</li>
<li>Update user status</li>
</ol>
</HowToStep>
</div>
</div>
{/* VERIFAYDA */}
<div id="verifayda" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4"> Verifayda</h2>
<p className="text-slate-600 dark:text-slate-300">Verify passenger identities against government database.</p>
</div>
<div id="verifayda-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2"> How-To: Manage Verifayda</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Verification">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Verifayda Integration" in Security</li>
<li>View verification history</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Verify Passenger">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Enter national ID or passport number</li>
<li>Click "Verify" to check database</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Review Results">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>View verified passenger data</li>
<li>Match with booking details</li>
</ol>
</HowToStep>
</div>
</div>
{/* REPORTS */}
<div id="reports" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📊 Reports</h2>
<p className="text-slate-600 dark:text-slate-300">Generate business analytics and reports.</p>
</div>
<div id="reports-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📊 How-To: Generate Reports</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Reports">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Reports" in Analytics</li>
<li>View available report types</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Generate Report">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click report type</li>
<li>Select date range and parameters</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Export Report">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>View report with charts</li>
<li>Click "Export" for PDF or CSV</li>
</ol>
</HowToStep>
</div>
</div>
{/* AGENTS */}
<div id="agents" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">👤 Agents</h2>
<p className="text-slate-600 dark:text-slate-300">Manage booking agents and commissions.</p>
</div>
<div id="agents-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">👤 How-To: Manage Agents</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Agents">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Agents" in System</li>
<li>View all agents</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Agent">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Agent"</li>
<li>Enter name, email, commission rate</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Create Shift">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click agent to edit</li>
<li>Click "Create Shift" to assign schedule</li>
</ol>
</HowToStep>
</div>
</div>
{/* USERS */}
<div id="users" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">👥 Users</h2>
<p className="text-slate-600 dark:text-slate-300">Manage backoffice user accounts and permissions.</p>
</div>
<div id="users-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">👥 How-To: Manage Users</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Users">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Users" in System</li>
<li>View all user accounts</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create User">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add User"</li>
<li>Enter email, name, select role</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Manage Permissions">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click user to edit</li>
<li>Adjust roles and permissions</li>
</ol>
</HowToStep>
</div>
</div>
{/* SETTINGS */}
<div id="settings" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4"> Settings</h2>
<p className="text-slate-600 dark:text-slate-300">Configure system-wide settings and integrations.</p>
</div>
<div id="settings-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2"> How-To: Configure Settings</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Settings">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Settings" in System</li>
<li>View configuration options</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Configure Email">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to Email tab</li>
<li>Enter SendGrid API key and email</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Configure API Keys">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to API tab</li>
<li>Add payment and Verifayda keys</li>
</ol>
</HowToStep>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="mt-12 bg-slate-900 text-white py-8">
<div className="max-w-7xl mx-auto px-4 text-center text-slate-400">
<p>© 2026 Ethio-Djibouti Railway | Passenger Backoffice Documentation v1.0</p>
</div>
</div>
</div>
);
};
export default DocPage;

View File

@@ -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 (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<div className="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<FileText className="h-8 w-8 text-emerald-600" />
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">How-To Guides</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">Step-by-step instructions for common tasks</p>
</div>
</div>
<Link href="/docs" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 transition">
<FileText className="h-4 w-4" />
Back to Docs
</Link>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-1">
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 sticky top-24 h-fit">
<nav className="p-4 space-y-2">
{guides.map(guide => (
<button
key={guide.id}
onClick={() => scrollToSection(guide.id)}
className="w-full text-left px-4 py-3 rounded-lg text-sm font-medium text-slate-600 dark:text-slate-300 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-slate-50 dark:hover:bg-slate-700 transition"
>
{guide.icon} {guide.title}
</button>
))}
</nav>
</div>
</div>
<div className="lg:col-span-3">
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-8 space-y-12">
{/* Bookings How-To */}
<div id="bookings" className="pt-4">
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">📋 How to Manage Bookings</h2>
<p className="text-slate-600 dark:text-slate-300 mb-6">Learn how to search, view, modify, and cancel passenger bookings in the system.</p>
<div className="space-y-8">
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">1</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Access the Bookings Page</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click on <strong>"Bookings"</strong> in the Operations section of the sidebar</li>
<li>The page loads showing a table with all bookings</li>
<li>You&apos;ll see columns: Reference, Passenger, Status, Amount, Payment, Created date</li>
</ol>
<div className="mt-4 p-3 bg-white dark:bg-slate-800 rounded border-l-4 border-blue-600">
<p className="text-sm font-mono text-slate-600 dark:text-slate-400">📍 Path: Sidebar Operations Bookings</p>
</div>
</div>
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-6 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-green-600 text-white font-bold flex-shrink-0">2</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Search for a Booking</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Find the search box at the top of the booking table</li>
<li>Type in: booking reference (e.g., "BK123"), email, or phone number</li>
<li>Results update in real-time as you type</li>
<li>Optional: Use the Status dropdown to filter (All, Pending Payment, Confirmed, Cancelled, Completed)</li>
</ol>
<div className="mt-4 p-3 bg-white dark:bg-slate-800 rounded">
<p className="text-sm"><strong>💡 Tip:</strong> Search is case-insensitive and supports partial matches</p>
</div>
</div>
</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/20 p-6 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-purple-600 text-white font-bold flex-shrink-0">3</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">View Booking Details</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Find the booking in the table</li>
<li>Click the <strong>"View Details"</strong> button on the right side</li>
<li>Modal window opens showing complete information:
<ul className="list-disc pl-8 mt-2 space-y-1">
<li>Booking reference and status</li>
<li>Passenger name and contact details</li>
<li>Journey information (schedule, adults, children)</li>
<li>Payment details and amount</li>
<li>All metadata and timestamps</li>
</ul>
</li>
</ol>
</div>
</div>
</div>
<div className="bg-orange-50 dark:bg-orange-900/20 p-6 rounded-lg border border-orange-200 dark:border-orange-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-orange-600 text-white font-bold flex-shrink-0">4</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Cancel a Booking with Refund</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Find the booking in the table</li>
<li>Click the <strong>"Cancel Booking"</strong> button (red)</li>
<li>Confirmation dialog appears</li>
<li>Click <strong>"Confirm"</strong> to proceed</li>
<li>System calculates and processes refund:
<ul className="list-disc pl-8 mt-2 text-sm">
<li>Confirmed bookings: 80% refund</li>
<li>Pending bookings: 0% refund</li>
</ul>
</li>
<li>Status changes to <strong>&quot;CANCELLED&quot;</strong></li>
<li>Success message appears</li>
</ol>
<div className="mt-4 p-3 bg-white dark:bg-slate-800 rounded border-l-4 border-orange-600">
<p className="text-sm"><strong> Important:</strong> Cannot be undone. Seats are automatically released.</p>
</div>
</div>
</div>
</div>
<div className="bg-red-50 dark:bg-red-900/20 p-6 rounded-lg border border-red-200 dark:border-red-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-red-600 text-white font-bold flex-shrink-0">5</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Export Bookings</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click the <strong>"Export"</strong> button (top-right)</li>
<li>CSV file downloads automatically</li>
<li>Includes all current filters applied</li>
<li>Use for external analysis or backup</li>
</ol>
</div>
</div>
</div>
</div>
</div>
{/* Passengers How-To */}
<div id="passengers" className="border-t pt-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">👥 How to Manage Passengers</h2>
<p className="text-slate-600 dark:text-slate-300 mb-6">Learn how to search, filter, and view passenger profiles with loyalty and verification data.</p>
<div className="space-y-8">
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">1</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Access Passengers Page</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click <strong>"Passengers"</strong> in the Operations section</li>
<li>Page displays all passenger profiles</li>
<li>Default view shows 20 passengers per page</li>
</ol>
</div>
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-6 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-green-600 text-white font-bold flex-shrink-0">2</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Search & Filter</h3>
<div className="space-y-3 text-slate-700 dark:text-slate-300">
<div>
<p className="font-semibold mb-2">Search by:</p>
<ul className="list-disc pl-5 space-y-1 text-sm">
<li>Full name</li>
<li>Email address</li>
<li>Phone number</li>
<li>National ID</li>
</ul>
</div>
<div>
<p className="font-semibold mb-2">Filter by:</p>
<ul className="list-disc pl-5 space-y-1 text-sm">
<li><strong>Nationality:</strong> Ethiopian, Djiboutian, Other</li>
<li><strong>Verifayda Status:</strong> Verified, Unverified, Pending</li>
<li><strong>Loyalty Tier:</strong> Bronze, Silver, Gold, Platinum</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/20 p-6 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-purple-600 text-white font-bold flex-shrink-0">3</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">View Complete Profile</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click on any passenger row</li>
<li>Detailed profile modal opens showing:
<ul className="list-disc pl-8 mt-2 space-y-1 text-sm">
<li>Account info (email, phone, nationality)</li>
<li>Verifayda verification status</li>
<li>Loyalty tier and points</li>
<li>Wallet balance</li>
<li>Booking history with links</li>
</ul>
</li>
</ol>
<div className="mt-4 p-3 bg-white dark:bg-slate-800 rounded">
<p className="text-sm"><strong> Note:</strong> Read-only view. Updates via passenger portal.</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Pricing How-To */}
<div id="pricing" className="border-t pt-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">💰 How to Configure Pricing</h2>
<p className="text-slate-600 dark:text-slate-300 mb-6">Learn how to set up dynamic fares with segment pricing and nationality overrides.</p>
<div className="space-y-8">
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">1</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Access Pricing Page</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click <strong>"Pricing & Fares"</strong> in Financial section</li>
<li>Two tabs: <strong>Schedule Fares</strong> and <strong>Segment Fares</strong></li>
<li>Default tab shows Schedule Fares</li>
</ol>
</div>
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-6 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-green-600 text-white font-bold flex-shrink-0">2</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Create Schedule Fare Rule</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click <strong>"Add Fare Rule"</strong></li>
<li>Fill in form:
<ul className="list-disc pl-8 mt-2 space-y-1 text-sm">
<li><strong>Schedule (optional):</strong> Leave empty for global</li>
<li><strong>Route Code (optional):</strong> e.g., "ADD-DJI"</li>
<li><strong>Seat Class (required):</strong> Economy Regular, VIP Bed, etc.</li>
<li><strong>Fare in ETB (required):</strong> e.g., 350.00</li>
<li><strong>Passenger Type (optional):</strong> ADULT or CHILD</li>
<li><strong>Nationality (optional):</strong> Ethiopian, Djiboutian, Other</li>
<li><strong>Valid From & Until:</strong> Set date range</li>
</ul>
</li>
<li>Click <strong>"Save Fare Rule"</strong></li>
</ol>
</div>
</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/20 p-6 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-purple-600 text-white font-bold flex-shrink-0">3</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Create Segment Fare Rule</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click <strong>"Add Fare Rule"</strong></li>
<li>Switch to <strong>"Segment Fares"</strong> tab</li>
<li>Select route from dropdown</li>
<li>Fill in form:
<ul className="list-disc pl-8 mt-2 space-y-1 text-sm">
<li><strong>Origin Station (required):</strong> Starting point</li>
<li><strong>Destination Station (required):</strong> Must be after origin</li>
<li><strong>Seat Class (required):</strong> Class type</li>
<li><strong>Fare in ETB (required):</strong> Segment price</li>
</ul>
</li>
<li>Click <strong>"Save Segment Fare Rule"</strong></li>
</ol>
<div className="mt-4 p-3 bg-white dark:bg-slate-800 rounded">
<p className="text-sm"><strong>Example:</strong> ADD (Stop 1) to DDA (Stop 4) at 250 ETB</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Schedules How-To */}
<div id="schedules" className="border-t pt-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">📅 How to Create Schedules</h2>
<p className="text-slate-600 dark:text-slate-300 mb-6">Learn how to create schedules manually or in bulk with recurring patterns.</p>
<div className="space-y-8">
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">1</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Create Single Schedule</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Go to <strong>Schedules</strong> page (Master Data)</li>
<li>Click <strong>"Create Schedule"</strong></li>
<li>Fill in required fields:
<ul className="list-disc pl-8 mt-2 space-y-1 text-sm">
<li><strong>Train:</strong> Select from dropdown</li>
<li><strong>Route:</strong> Select from dropdown</li>
<li><strong>Departure Date & Time:</strong> Pick from date/time picker</li>
<li><strong>Arrival Date & Time:</strong> Must be after departure</li>
</ul>
</li>
<li>Select coaches to assign</li>
<li>Click <strong>"Create Schedule"</strong></li>
</ol>
</div>
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-6 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-green-600 text-white font-bold flex-shrink-0">2</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Bulk Generate Recurring Schedules</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click <strong>"Bulk Generate"</strong> button</li>
<li>Fill in generation form:
<ul className="list-disc pl-8 mt-2 space-y-1 text-sm">
<li><strong>Train (required):</strong> Select train</li>
<li><strong>Route (required):</strong> Select route</li>
<li><strong>Start Date & Time (required):</strong> First departure</li>
<li><strong>Duration (Hours):</strong> Trip length</li>
<li><strong>Repeat Every (Days):</strong> Daily or custom</li>
<li><strong>For Next (Days):</strong> How many days</li>
</ul>
</li>
<li>Review preview showing number of schedules</li>
<li>Click <strong>"Generate Schedules"</strong></li>
</ol>
<div className="mt-4 p-3 bg-white dark:bg-slate-800 rounded border-l-4 border-green-600">
<p className="text-sm"><strong>Example:</strong> 30 days ÷ 1 day = ~30 daily schedules</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Seats How-To */}
<div id="seats" className="border-t pt-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">💺 How to Manage Seats</h2>
<p className="text-slate-600 dark:text-slate-300 mb-6">Learn how to view, block, and manage seat inventory using visual seat maps.</p>
<div className="space-y-8">
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">1</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">View Seat Map</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Go to <strong>Seats</strong> page (Master Data)</li>
<li>Select a coach from dropdown</li>
<li>Visual seat map displays</li>
<li>Color-coded by status:
<ul className="list-disc pl-8 mt-2 text-sm">
<li>🟢 Green: Available</li>
<li>🟡 Yellow: Held</li>
<li>🔵 Blue: Booked</li>
<li>🔴 Red: Blocked</li>
</ul>
</li>
</ol>
</div>
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-6 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-green-600 text-white font-bold flex-shrink-0">2</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Block a Seat</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click on an available (green) seat</li>
<li>Click <strong>"Block"</strong> button</li>
<li>Select reason:
<ul className="list-disc pl-8 mt-2 text-sm">
<li>Maintenance</li>
<li>Reserved</li>
<li>Damaged</li>
</ul>
</li>
<li>Set until date (optional)</li>
<li>Add notes</li>
<li>Click <strong>"Block Seat"</strong></li>
<li>Seat turns red</li>
</ol>
</div>
</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/20 p-6 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-purple-600 text-white font-bold flex-shrink-0">3</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Unblock a Seat</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click on a blocked (red) seat</li>
<li>Click <strong>"Unblock"</strong> button</li>
<li>Confirm action</li>
<li>Seat becomes available (green)</li>
</ol>
</div>
</div>
</div>
</div>
</div>
{/* Loyalty How-To */}
<div id="loyalty" className="border-t pt-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">🏆 How to Manage Loyalty Program</h2>
<p className="text-slate-600 dark:text-slate-300 mb-6">Learn how to view loyalty accounts, manage points, and administer rewards.</p>
<div className="space-y-8">
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">1</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">View Loyalty Accounts</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Go to <strong>Loyalty Program</strong> (Customer Services)</li>
<li>Table displays all loyalty accounts</li>
<li>Columns: Name, Tier, Points Balance, Lifetime Points</li>
<li>Search by name or filter by tier</li>
</ol>
</div>
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-6 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-green-600 text-white font-bold flex-shrink-0">2</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Adjust Points</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click on a loyalty account</li>
<li>Click <strong>"Adjust Points"</strong> button</li>
<li>Enter points to add/subtract</li>
<li>Select reason: Bonus, Correction, Promotion, etc.</li>
<li>Add optional notes</li>
<li>Click <strong>"Apply"</strong></li>
<li>Balance updates immediately</li>
</ol>
</div>
</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/20 p-6 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-purple-600 text-white font-bold flex-shrink-0">3</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Award Rewards</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click on a loyalty account</li>
<li>Click <strong>"Grant Reward"</strong> button</li>
<li>Select reward from list</li>
<li>Specify quantity if applicable</li>
<li>Click <strong>"Award"</strong></li>
<li>Confirmation email sent to passenger</li>
</ol>
</div>
</div>
</div>
</div>
</div>
{/* Common Tips */}
<div className="bg-gradient-to-r from-emerald-50 to-teal-50 dark:from-emerald-900/20 dark:to-teal-900/20 p-6 rounded-lg border border-emerald-200 dark:border-emerald-800 border-t pt-8">
<h3 className="text-lg font-semibold text-emerald-900 dark:text-emerald-200 mb-4">💡 Common Tips & Tricks</h3>
<ul className="list-disc pl-6 space-y-2 text-emerald-800 dark:text-emerald-300 text-sm">
<li><strong>Keyboard Shortcuts:</strong> Tab to navigate, Enter to submit</li>
<li><strong>Pagination:</strong> Change page size or jump to specific page</li>
<li><strong>Sidebar Collapse:</strong> Use chevron to minimize sidebar</li>
<li><strong>Dark Mode:</strong> Toggle with sun/moon icon in header</li>
<li><strong>Error Messages:</strong> Red text above forms if validation fails</li>
<li><strong>Success Notifications:</strong> Green banner appears for 3 seconds</li>
<li><strong>Undo Not Available:</strong> Most actions cannot be undone</li>
<li><strong>Real-time Updates:</strong> Refresh page to see changes by other users</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div className="mt-12 bg-slate-900 text-white py-8">
<div className="max-w-7xl mx-auto px-4 text-center text-slate-400">
<p>© 2026 Ethio-Djibouti Railway | How-To Guides v1.0</p>
<p className="text-sm mt-2">Last Updated: January 15, 2026</p>
</div>
</div>
</div>
);
};
export default HowToPage;

View File

@@ -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,74 +37,106 @@ export default function LoginPage() {
}
};
if (!isMounted) {
return null;
}
return (
<div className="flex min-h-screen">
{/* Banner Image Side */}
<div className="hidden lg:flex lg:w-1/2 relative bg-gradient-to-br from-[rgb(20,113,76)] to-[rgb(15,85,57)] items-center justify-center">
<div className="absolute inset-0 bg-[url('/banner.jpg')] bg-cover bg-center opacity-20"></div>
<div className="relative z-10 text-center px-12">
<div className="flex justify-center mb-6">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-white/10 backdrop-blur-sm shadow-2xl">
<Train className="h-12 w-12 text-white" />
<div className="flex min-h-screen relative bg-gradient-to-br from-[rgb(20,113,76)] to-[rgb(15,85,57)]">
{/* Full Screen Banner Background */}
<div className="absolute inset-0 bg-[url('/banner.jpg')] bg-cover bg-center opacity-50"></div>
{/* Content Overlay */}
<div className="relative z-10 flex items-center justify-start w-full px-4 lg:px-16">
<div className="w-full max-w-sm">
{/* Login Card with Shadow */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-white/20 dark:border-gray-700/50 overflow-hidden backdrop-blur-sm">
{/* Card Header with Logo, App Name and Theme Toggle */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700/50 bg-gray-50 dark:bg-gray-700/50">
<div className="flex items-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-[rgb(20,113,76)] shadow-md">
<Train className="h-9 w-9 text-white" />
</div>
</div>
<h1 className="text-5xl font-bold text-white mb-4">EDR</h1>
<p className="text-lg text-white/80">Passenger Back-office</p>
<div>
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Ethio-Djibouti Railway</h2>
<p className="text-lg text-gray-600 dark:text-gray-400">Passenger Back-office</p>
</div>
</div>
{/* Login Form Side */}
<div className="flex w-full lg:w-1/2 items-center justify-center bg-gray-100 dark:bg-gray-900 p-8">
<div className="w-full max-w-md">
<div className="card">
<div className="mb-6">
<div className="mb-4 flex justify-center lg:hidden">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[rgb(20,113,76)] shadow-lg">
<Train className="h-6 w-6 text-white" />
<button
onClick={toggleTheme}
className="p-2 rounded-lg bg-white/80 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
aria-label="Toggle theme"
>
{isDark ? (
<Sun className="w-5 h-5 text-yellow-500" />
) : (
<Moon className="w-5 h-5 text-gray-700" />
)}
</button>
</div>
<div className="text-4xl font-bold text-gray-900 dark:text-white ps-4">EDR</div>
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Sign in to get started.</h2>
{/* Card Body */}
<div className="p-6">
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Welcome back!</h2>
<p className="text-xl text-gray-900 dark:text-white">Sign in to continue.</p>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Email</label>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input"
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
/>
</div>
<div>
<label className="label">Password</label>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Password</label>
<div className="relative">
<input
type="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input"
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
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
aria-label="Toggle password visibility"
>
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="btn btn-primary w-full disabled:opacity-50"
className="w-full mt-6 py-2 bg-[rgb(20,113,76)] text-white font-semibold rounded-lg border-2 border-[rgb(20,113,76)] hover:bg-[rgb(16,90,61)] hover:border-[rgb(16,90,61)] disabled:opacity-50 transition-all duration-200"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
</div>
</div>

View File

@@ -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<any>(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) => <Badge>{report.reportType}</Badge> },
{ 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) => (
<Badge className={getReportTypeBadgeColor(report.reportType)}>
{formatReportType(report.reportType)}
</Badge>
),
},
{
key: 'dateFrom',
label: 'Period From',
sortable: true,
render: (report: any) => (
<span className="text-sm">{new Date(report.dateFrom).toLocaleDateString()}</span>
),
},
{
key: 'dateTo',
label: 'Period To',
sortable: true,
render: (report: any) => (
<span className="text-sm">{new Date(report.dateTo).toLocaleDateString()}</span>
),
},
{
key: 'data',
label: 'Summary',
render: (report: any) => {
const data = report.data || {};
if (report.reportType === 'REVENUE') {
return (
<div className="text-sm">
<p className="font-medium">{formatCurrency(data.totalRevenueMinor || 0, 'ETB')}</p>
<p className="text-xs text-muted-foreground">{data.totalBookings || 0} bookings</p>
</div>
);
}
if (report.reportType === 'OCCUPANCY') {
return (
<div className="text-sm">
<p className="font-medium">{(data.averageOccupancyRate || 0).toFixed(1)}% occupancy</p>
<p className="text-xs text-muted-foreground">{data.totalSchedules || 0} schedules</p>
</div>
);
}
if (report.reportType === 'AGENT_SALES') {
return (
<div className="text-sm">
<p className="font-medium">{data.totalAgentBookings || 0} bookings</p>
<p className="text-xs text-muted-foreground">{Object.keys(data.byAgent || {}).length} agents</p>
</div>
);
}
if (report.reportType === 'CANCELLATIONS') {
return (
<div className="text-sm">
<p className="font-medium">{data.totalCancellations || 0} cancellations</p>
<p className="text-xs text-muted-foreground">Refunded: {formatCurrency(data.totalRefundedMinor || 0, 'ETB')}</p>
</div>
);
}
if (report.reportType === 'PAYMENT_METHODS') {
return (
<div className="text-sm">
<p className="font-medium">{data.totalPayments || 0} payments</p>
<p className="text-xs text-muted-foreground">{Object.keys(data.byMethod || {}).length} methods</p>
</div>
);
}
return <span className="text-sm text-muted-foreground">View details</span>;
},
},
{
key: 'createdAt',
label: 'Generated',
sortable: true,
render: (report: any) => (
<span className="text-sm">{formatDateTime(report.createdAt)}</span>
),
},
];
const actions = [
{
label: 'View Details',
onClick: (report: any) => {
setSelectedReport(report);
setShowDetailsModal(true);
},
variant: 'secondary' as const,
icon: Eye,
},
];
const reports = data?.items || data || [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Operational Reports</h1>
<p className="text-muted-foreground">View operational reports and analytics</p>
<h1 className="text-3xl font-bold text-foreground">Operational Reports</h1>
<p className="text-muted-foreground mt-1">View and analyze operational performance</p>
</div>
<div className="flex gap-2">
<ActionButton icon={Plus} variant="primary" onClick={() => setShowGenerateModal(true)}>
Generate Report
</ActionButton>
<ActionButton icon={Download} variant="secondary">
Export All
</ActionButton>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
</div>
{/* Filters */}
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
<label className="label">Search (Report ID/Type)</label>
<input
type="text"
placeholder="Search reports..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
<div>
<label className="label">Report Type</label>
<select className="input" value={filters.reportType} onChange={(e) => setFilters({ ...filters, reportType: e.target.value })}>
<select
className="input"
value={filters.reportType}
onChange={(e) => setFilters({ ...filters, reportType: e.target.value })}
>
<option value="">All Types</option>
<option value="REVENUE">Revenue</option>
<option value="OCCUPANCY">Occupancy</option>
<option value="PERFORMANCE">Performance</option>
<option value="REVENUE">Revenue Report</option>
<option value="OCCUPANCY">Occupancy Report</option>
<option value="AGENT_SALES">Agent Sales Report</option>
<option value="CANCELLATIONS">Cancellations Report</option>
<option value="PAYMENT_METHODS">Payment Methods Report</option>
</select>
</div>
<div className="flex items-end">
<ActionButton
variant="secondary"
onClick={() => setFilters({ search: '', reportType: '' })}
className="w-full"
>
Clear Filters
</ActionButton>
</div>
</div>
</div>
{/* Reports Table */}
<DataTable
data={data?.items || data || []}
data={reports}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No operational reports found"
/>
{/* Generate Report Modal */}
<Modal
isOpen={showGenerateModal}
onClose={() => setShowGenerateModal(false)}
title="Generate Report"
size="sm"
>
<div className="space-y-4">
<div>
<label className="label">Report Type</label>
<select
className="input"
value={generateForm.reportType}
onChange={(e) => setGenerateForm({ ...generateForm, reportType: e.target.value })}
>
<option value="REVENUE">Revenue Report</option>
<option value="OCCUPANCY">Occupancy Report</option>
<option value="AGENT_SALES">Agent Sales Report</option>
<option value="CANCELLATIONS">Cancellations Report</option>
<option value="PAYMENT_METHODS">Payment Methods Report</option>
</select>
</div>
<div>
<label className="label">Date From</label>
<input
type="date"
className="input"
value={generateForm.dateFrom}
onChange={(e) => setGenerateForm({ ...generateForm, dateFrom: e.target.value })}
/>
</div>
<div>
<label className="label">Date To</label>
<input
type="date"
className="input"
value={generateForm.dateTo}
onChange={(e) => setGenerateForm({ ...generateForm, dateTo: e.target.value })}
/>
</div>
<div className="flex gap-2 pt-4">
<ActionButton
variant="primary"
onClick={handleGenerateReport}
className="flex-1"
>
Generate
</ActionButton>
<ActionButton
variant="secondary"
onClick={() => setShowGenerateModal(false)}
className="flex-1"
>
Cancel
</ActionButton>
</div>
</div>
</Modal>
{/* Details Modal */}
<Modal
isOpen={showDetailsModal}
onClose={() => {
setShowDetailsModal(false);
setSelectedReport(null);
}}
title={formatReportType(selectedReport?.reportType)}
size="lg"
>
<div className="space-y-4">
{/* Report Header */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-muted-foreground">Report Type</label>
<p className="text-sm mt-1 font-medium">{formatReportType(selectedReport?.reportType)}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Generated</label>
<p className="text-sm mt-1">{formatDateTime(selectedReport?.createdAt)}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Period From</label>
<p className="text-sm mt-1">{new Date(selectedReport?.dateFrom).toLocaleDateString()}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Period To</label>
<p className="text-sm mt-1">{new Date(selectedReport?.dateTo).toLocaleDateString()}</p>
</div>
</div>
{/* Revenue Report Data */}
{selectedReport?.reportType === 'REVENUE' && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-3">Revenue Metrics</h4>
<div className="grid grid-cols-2 gap-4">
<div className="bg-blue-50 dark:bg-blue-950/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Revenue</p>
<p className="text-lg font-bold mt-1">
{formatCurrency(selectedReport?.data?.totalRevenueMinor || 0, 'ETB')}
</p>
</div>
<div className="bg-green-50 dark:bg-green-950/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Bookings</p>
<p className="text-lg font-bold mt-1">
{(selectedReport?.data?.totalBookings || 0).toLocaleString()}
</p>
</div>
</div>
{selectedReport?.data?.byPaymentMethod && (
<div className="mt-4">
<p className="text-xs font-semibold mb-2 text-muted-foreground">By Payment Method</p>
<div className="space-y-2">
{Object.entries(selectedReport.data.byPaymentMethod).map(([method, amount]: [string, any]) => (
<div key={method} className="flex justify-between text-sm">
<span className="text-muted-foreground capitalize">{method.toLowerCase().replace('_', ' ')}</span>
<span className="font-medium">{formatCurrency(amount, 'ETB')}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Occupancy Report Data */}
{selectedReport?.reportType === 'OCCUPANCY' && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-3">Occupancy Metrics</h4>
<div className="grid grid-cols-2 gap-4">
<div className="bg-blue-50 dark:bg-blue-950/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Avg Occupancy Rate</p>
<p className="text-lg font-bold mt-1">
{(selectedReport?.data?.averageOccupancyRate || 0).toFixed(1)}%
</p>
</div>
<div className="bg-green-50 dark:bg-green-950/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Schedules</p>
<p className="text-lg font-bold mt-1">
{(selectedReport?.data?.totalSchedules || 0).toLocaleString()}
</p>
</div>
</div>
</div>
)}
{/* Agent Sales Report Data */}
{selectedReport?.reportType === 'AGENT_SALES' && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-3">Agent Sales Metrics</h4>
<div className="grid grid-cols-2 gap-4">
<div className="bg-blue-50 dark:bg-blue-950/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Bookings</p>
<p className="text-lg font-bold mt-1">
{(selectedReport?.data?.totalAgentBookings || 0).toLocaleString()}
</p>
</div>
<div className="bg-green-50 dark:bg-green-950/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Active Agents</p>
<p className="text-lg font-bold mt-1">
{Object.keys(selectedReport?.data?.byAgent || {}).length}
</p>
</div>
</div>
{selectedReport?.data?.byAgent && (
<div className="mt-4">
<p className="text-xs font-semibold mb-2 text-muted-foreground">By Agent</p>
<div className="space-y-2 max-h-64 overflow-y-auto">
{Object.entries(selectedReport.data.byAgent).map(([agent, stats]: [string, any]) => (
<div key={agent} className="text-sm border-b pb-2 last:border-0">
<p className="font-medium">{agent}</p>
<div className="text-xs text-muted-foreground mt-1">
<p>Bookings: {stats.bookings} | Revenue: {formatCurrency(stats.revenueMinor, 'ETB')}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Cancellations Report Data */}
{selectedReport?.reportType === 'CANCELLATIONS' && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-3">Cancellation Metrics</h4>
<div className="grid grid-cols-2 gap-4">
<div className="bg-blue-50 dark:bg-blue-950/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Cancellations</p>
<p className="text-lg font-bold mt-1">
{(selectedReport?.data?.totalCancellations || 0).toLocaleString()}
</p>
</div>
<div className="bg-green-50 dark:bg-green-950/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Refunded</p>
<p className="text-lg font-bold mt-1">
{formatCurrency(selectedReport?.data?.totalRefundedMinor || 0, 'ETB')}
</p>
</div>
</div>
</div>
)}
{/* Payment Methods Report Data */}
{selectedReport?.reportType === 'PAYMENT_METHODS' && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-3">Payment Method Breakdown</h4>
<div className="bg-blue-50 dark:bg-blue-950/20 rounded-lg p-3 mb-4">
<p className="text-xs text-muted-foreground">Total Payments</p>
<p className="text-lg font-bold mt-1">
{(selectedReport?.data?.totalPayments || 0).toLocaleString()}
</p>
</div>
{selectedReport?.data?.byMethod && (
<div className="space-y-2">
{Object.entries(selectedReport.data.byMethod).map(([method, stats]: [string, any]) => (
<div key={method} className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-900 rounded">
<div>
<p className="text-sm font-medium capitalize">{method.toLowerCase().replace('_', ' ')}</p>
<p className="text-xs text-muted-foreground">{stats.count} transactions</p>
</div>
<p className="font-bold">{formatCurrency(stats.totalMinor, 'ETB')}</p>
</div>
))}
</div>
)}
</div>
)}
{/* Report ID */}
<div className="border-t pt-4">
<label className="text-xs font-semibold text-muted-foreground">Report ID</label>
<p className="text-xs mt-1 font-mono text-muted-foreground break-all">{selectedReport?.id}</p>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -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() {
<p className="text-muted-foreground">Manage passenger profiles and verification</p>
</div>
<div className="flex gap-2">
<ActionButton variant="export" icon={Download}>Export</ActionButton>
<ActionButton variant="export" icon={Download} onClick={handleExportPassengers}>Export</ActionButton>
</div>
</div>
@@ -230,10 +277,6 @@ export default function PassengersPage() {
<div>
<h3 className="text-lg font-semibold mb-3">Identification</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">National ID</label>
<p className="text-lg font-mono font-semibold">{selectedPassenger.nationalId || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Passport Number</label>
<p className="text-lg font-mono font-semibold">{selectedPassenger.passportNumber || 'N/A'}</p>

View File

@@ -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 <span className="font-medium">{className}</span>;
},
},
{
key: 'passengerCategory',
label: 'Passenger Type',
render: (fare: any) => (
<span className="text-sm">{fare.passengerCategory || 'All'}</span>
),
},
{
key: 'baseFare',
label: 'Fare (ETB)',
render: (fare: any) => {
const fareValue = fare.baseFare || fare.baseFareMinor;
if (!fareValue && fareValue !== 0) return <span>N/A</span>;
const etbValue = fareValue > 100 ? (fareValue / 100).toFixed(2) : parseFloat(fareValue).toFixed(2);
return <span className="font-mono font-medium">{etbValue} ETB</span>;
return <span className="font-mono font-medium">{fareValue} ETB</span>;
},
},
{
@@ -408,14 +428,20 @@ export default function PricingPage() {
return <span className="font-medium">{className}</span>;
},
},
{
key: 'passengerCategory',
label: 'Passenger Type',
render: (fare: any) => (
<span className="text-sm">{fare.passengerCategory || 'All'}</span>
),
},
{
key: 'baseFare',
label: 'Fare (ETB)',
render: (fare: any) => {
const fareValue = fare.baseFare || fare.baseFareMinor;
if (!fareValue && fareValue !== 0) return <span>N/A</span>;
const etbValue = fareValue > 100 ? (fareValue / 100).toFixed(2) : parseFloat(fareValue).toFixed(2);
return <span className="font-mono font-medium">{etbValue} ETB</span>;
return <span className="font-mono font-medium">{fareValue} ETB</span>;
},
},
{
@@ -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() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Pricing & Fares</h1>
<p className="text-muted-foreground mt-1">Manage fares by schedule and route segments</p>
<p className="text-muted-foreground mt-1">Manage fares by schedule and route segments with passenger type pricing</p>
</div>
<ActionButton
icon={Plus}
@@ -475,6 +503,7 @@ export default function PricingPage() {
seatClassId: '',
baseFare: '',
nationality: '',
passengerCategory: '',
route: '',
validFrom: new Date().toISOString().split('T')[0],
validUntil: '',
@@ -486,6 +515,7 @@ export default function PricingPage() {
destinationStationId: '',
baseFare: '',
nationality: '',
passengerCategory: '',
validFrom: new Date().toISOString().split('T')[0],
validUntil: '',
});
@@ -547,26 +577,29 @@ export default function PricingPage() {
{selectedSchedule && (
<div>
<h3 className="text-lg font-semibold mb-4">Fare Rules</h3>
<h3 className="text-lg font-semibold mb-4">Calculated Fares</h3>
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
These are <strong>dynamically calculated</strong> fares based on the fare engine. To create custom override fares, click "Add Fare Rule" above.
</div>
{faresLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : faresArray.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{`No fares defined. Click "Add Fare Rule" to create one.`}
No fares available for this schedule.
</div>
) : (
<>
<div className="mb-4 p-3 bg-muted/50 rounded text-sm text-muted-foreground">
{faresArray.length} fare rule(s) found
{faresArray.length} seat class(es) available
</div>
<DataTable
data={faresArray}
columns={fareColumns}
actions={fareActions}
loading={false}
emptyMessage="No fares found."
emptyMessage="No fares available."
/>
</>
)}
@@ -632,16 +665,16 @@ export default function PricingPage() {
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-3">Pricing Structure</h3>
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-2">
<li>
<strong>Schedule Fares:</strong> Set custom pricing for each schedule by seat class
<strong>Schedule Fares:</strong> Set custom pricing for each schedule by seat class and passenger type
</li>
<li>
<strong>Segment Fares:</strong> Set fares for specific stop-to-stop segments (e.g., Addis Dire Dawa)
</li>
<li>
<strong>Nationality-based:</strong> Override fares for specific nationalities
<strong>Passenger Type:</strong> ADULT (5+ years) or CHILD (&lt;5) first child travels free, subsequent children pay full fare
</li>
<li>
<strong>Age-Based Pricing:</strong> ADULT (5+ years) pays 100%, CHILD (&lt;5) first child FREE, subsequent children 100%
<strong>Nationality-based:</strong> Override fares for specific nationalities (Ethiopian, Djiboutian, Other)
</li>
</ul>
</div>
@@ -732,16 +765,31 @@ export default function PricingPage() {
<input
type="number"
min="0"
step="0.01"
step="1"
value={fareForm.baseFare}
onChange={(e) => setFareForm({ ...fareForm, baseFare: e.target.value })}
className="input w-full"
placeholder="e.g., 350.00"
placeholder="e.g., 350"
required
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Passenger Type (Optional)</label>
<select
value={fareForm.passengerCategory}
onChange={(e) => setFareForm({ ...fareForm, passengerCategory: e.target.value })}
className="input w-full"
>
<option value="">All Passenger Types</option>
<option value="ADULT">Adult (5+ years)</option>
<option value="CHILD">Child (Less than 5 years)</option>
</select>
<p className="text-xs text-muted-foreground mt-1">Scope pricing to specific passenger type</p>
</div>
<div>
<label className="label">Nationality (Optional)</label>
<select
@@ -755,6 +803,7 @@ export default function PricingPage() {
<option value="Other">Other</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
@@ -846,16 +895,31 @@ export default function PricingPage() {
<input
type="number"
min="0"
step="0.01"
step="1"
value={segmentForm.baseFare}
onChange={(e) => setSegmentForm({ ...segmentForm, baseFare: e.target.value })}
className="input w-full"
placeholder="e.g., 150.00"
placeholder="e.g., 150"
required
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Passenger Type (Optional)</label>
<select
value={segmentForm.passengerCategory}
onChange={(e) => setSegmentForm({ ...segmentForm, passengerCategory: e.target.value })}
className="input w-full"
>
<option value="">All Passenger Types</option>
<option value="ADULT">Adult (5+ years)</option>
<option value="CHILD">Child (Less than 5 years)</option>
</select>
<p className="text-xs text-muted-foreground mt-1">Scope pricing to specific passenger type</p>
</div>
<div>
<label className="label">Nationality (Optional)</label>
<select
@@ -869,6 +933,7 @@ export default function PricingPage() {
<option value="Other">Other</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
@@ -900,7 +965,8 @@ export default function PricingPage() {
<ul className="space-y-1">
<li> <strong>Schedule:</strong> Apply to specific schedule only</li>
<li> <strong>Route Code:</strong> Apply to all schedules on that route</li>
<li> <strong>Nationality:</strong> Override for specific passenger nationalities</li>
<li> <strong>Passenger Type:</strong> ADULT or CHILD pricing</li>
<li> <strong>Nationality:</strong> Override for specific nationalities</li>
<li> <strong>All empty:</strong> Apply globally to all schedules</li>
</ul>
)}
@@ -908,6 +974,7 @@ export default function PricingPage() {
<ul className="space-y-1">
<li> <strong>Segments:</strong> Define pricing for specific stop-to-stop segments</li>
<li> <strong>Stops:</strong> Use sequence numbers from the route</li>
<li> <strong>Passenger Type:</strong> ADULT or CHILD pricing</li>
<li> <strong>Nationality:</strong> Optional scope to specific nationalities</li>
</ul>
)}

View File

@@ -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<string, any>, 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<string, any>);
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Reports & Analytics</h1>
<p className="text-gray-600">View detailed reports and analytics</p>
<h1 className="text-3xl font-bold text-foreground">Reports & Analytics</h1>
<p className="text-muted-foreground mt-1">View detailed reports and performance metrics</p>
</div>
<div className="flex gap-2">
<select className="input w-48" value={dateRange} onChange={(e) => setDateRange(e.target.value)}>
<option value="last-7-days">Last 7 Days</option>
<option value="last-30-days">Last 30 Days</option>
<option value="last-90-days">Last 90 Days</option>
{/* Date Range Selector */}
<div className="card">
<div className="flex items-end gap-4 flex-wrap">
<div>
<label className="label">Date Range</label>
<select
className="input"
value={dateRange}
onChange={(e) => setDateRange(e.target.value)}
disabled={isLoading}
>
<option value="7">Last 7 Days</option>
<option value="30">Last 30 Days</option>
<option value="90">Last 90 Days</option>
<option value="custom">Custom Range</option>
</select>
<button className="btn btn-primary flex items-center gap-2">
<Download className="h-4 w-4" />
Export Report
</button>
</div>
{dateRange === 'custom' && (
<>
<div>
<label className="label">Start Date</label>
<input
type="date"
className="input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
disabled={isLoading}
/>
</div>
<div>
<label className="label">End Date</label>
<input
type="date"
className="input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
disabled={isLoading}
/>
</div>
</>
)}
<ActionButton icon={Download} variant="secondary" disabled={isLoading}>
Export
</ActionButton>
</div>
{isLoading && (
<p className="text-xs text-muted-foreground mt-2">Loading...</p>
)}
</div>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Total Revenue</p>
<p className="text-2xl font-bold mt-2">ETB {Math.round(totalRevenue / 100).toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">Last {dateRange} days</p>
</div>
<DollarSign className="h-8 w-8 text-blue-500 opacity-20" />
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="card">
<h3 className="mb-4 text-lg font-semibold text-gray-900">Revenue by Route</h3>
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Total Bookings</p>
<p className="text-2xl font-bold mt-2">{totalBookings.toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">All bookings</p>
</div>
<Users className="h-8 w-8 text-green-500 opacity-20" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Avg. Ticket Price</p>
<p className="text-2xl font-bold mt-2">ETB {(avgTicketPrice / 100).toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">Per booking</p>
</div>
<TrendingUp className="h-8 w-8 text-purple-500 opacity-20" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Avg. Daily Revenue</p>
<p className="text-2xl font-bold mt-2">ETB {chartData.length > 0 ? Math.round((totalRevenue / 100) / chartData.length).toLocaleString() : '0'}</p>
<p className="text-xs text-muted-foreground mt-1">Daily average</p>
</div>
<AlertCircle className="h-8 w-8 text-orange-500 opacity-20" />
</div>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Revenue Trend */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Revenue Trend</h3>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={revenueByRoute}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="route" tick={{ fontSize: 12 }} />
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip formatter={(value: number) => formatCurrency(value, 'ETB')} />
<Bar dataKey="revenue" fill="#2563eb" />
<Tooltip formatter={(value: number) => `ETB ${Math.round(value).toLocaleString()}`} />
<Legend />
<Line type="monotone" dataKey="revenue" stroke="#3b82f6" dot={{ r: 5 }} activeDot={{ r: 7 }} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
</div>
)}
</div>
{/* Daily Bookings */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Daily Bookings</h3>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Bar dataKey="bookings" fill="#10b981" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
</div>
)}
</div>
{/* Booking Status Distribution */}
<div className="card">
<h3 className="mb-4 text-lg font-semibold text-gray-900">Bookings by Class</h3>
<h3 className="text-lg font-semibold mb-4">Booking Status</h3>
{bookings.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={bookingsByClass}
data={[
{ name: 'Confirmed', value: bookings.filter((b: any) => 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}%`}
label={({ name, value }) => `${name}: ${value}`}
outerRadius={100}
fill="#8884d8"
dataKey="value"
>
{bookingsByClass.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
{COLORS.map((color, idx) => <Cell key={idx} fill={color} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
</div>
)}
</div>
<div className="card lg:col-span-2">
<h3 className="mb-4 text-lg font-semibold text-gray-900">Occupancy Rate Trend</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={occupancyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip formatter={(value: number) => `${value}%`} />
<Bar dataKey="rate" fill="#10b981" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Top Payment Methods */}
<div className="card">
<h3 className="mb-4 text-lg font-semibold text-gray-900">Quick Stats</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="rounded-lg bg-blue-50 p-4">
<p className="text-sm text-blue-600">Total Revenue</p>
<p className="mt-1 text-2xl font-bold text-blue-900">{formatCurrency(255000000, 'ETB')}</p>
<h3 className="text-lg font-semibold mb-4">Payment Methods</h3>
{bookings.length > 0 ? (
<div className="space-y-3">
{(Object.entries(
bookings.reduce((acc: Record<string, number>, b: any) => {
const method = b.paymentIntent?.method || 'Unknown';
acc[method] = (acc[method] || 0) + 1;
return acc;
}, {} as Record<string, number>)
) as [string, number][]
)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([method, count]) => (
<div key={method} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-900 rounded">
<span className="text-sm capitalize">{method.toLowerCase().replace(/_/g, ' ')}</span>
<span className="font-semibold">{count}</span>
</div>
<div className="rounded-lg bg-green-50 p-4">
<p className="text-sm text-green-600">Total Bookings</p>
<p className="mt-1 text-2xl font-bold text-green-900">1,247</p>
))}
</div>
<div className="rounded-lg bg-purple-50 p-4">
<p className="text-sm text-purple-600">Avg. Ticket Price</p>
<p className="mt-1 text-2xl font-bold text-purple-900">{formatCurrency(42500, 'ETB')}</p>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
</div>
<div className="rounded-lg bg-green-50 p-4">
<p className="text-sm text-[rgb(20,113,76)]">Cancellation Rate</p>
<p className="mt-1 text-2xl font-bold text-green-900">3.2%</p>
)}
</div>
</div>
{/* Summary Stats */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Summary</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Total Days with Bookings</p>
<p className="text-xl font-bold mt-2">{chartData.length}</p>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Confirmed Bookings</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'CONFIRMED').length}</p>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Completed Bookings</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'COMPLETED').length}</p>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Cancelled Bookings</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'CANCELLED').length}</p>
</div>
</div>
</div>

View File

@@ -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);
// Last stop's distanceKm is already cumulative from origin
setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0);
// 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 {
const middleStops = routeStops.slice(1, -1).map((stop: any) => ({
stationId: stop.stationId,
sequence: stop.sequence,
distanceKm: stop.distanceKm,
distanceFromOrigin: distFromOrigin,
};
});
distanceFromOrigin: stop.distanceKm || 0,
}));
setStops(middleStops);
}
setShowModal(true);

View File

@@ -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<Set<string>>(new Set());
const [showBlockModal, setShowBlockModal] = useState(false);
const [showRemoveModal, setShowRemoveModal] = useState(false);
const [selectedSeat, setSelectedSeat] = useState<any>(null);
const [blockReason, setBlockReason] = useState('');
const [showBlockCoachModal, setShowBlockCoachModal] = useState(false);
const [selectedCoach, setSelectedCoach] = useState<any>(null);
const [blockCoachReason, setBlockCoachReason] = useState('');
const [showUnblockCoachModal, setShowUnblockCoachModal] = useState(false);
const [coachToUnblock, setCoachToUnblock] = useState<any>(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 (
<div className="space-y-0">
{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 (
<div key={`bed-row-${idx}`}>
{shouldFlipIcon && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
{rowSeats.map((seat: any) => (
<div key={`num-before-${seat.id}`} className={`${bedWidth} h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground`}>
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
))}
</div>
)}
<div className="flex gap-0.5 justify-start">
<div className="flex gap-0.5 justify-center">
{rowSeats.map((seat: any) => (
<SeatIcon
key={seat.id}
@@ -188,16 +263,23 @@ export default function SeatsPage() {
/>
))}
</div>
{!shouldFlipIcon && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
{rowSeats.map((seat: any) => (
<div key={`num-after-${seat.id}`} className={`${bedWidth} h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground`}>
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
{isFirstInPair && nextRowSeats && (
<div className="flex gap-0.5 justify-center text-xs my-1">
{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 (
<div key={`num-between-${seat.id}`} className={`${bedWidth} flex flex-col items-center justify-center text-xs font-bold mb-1 leading-3 text-foreground`}>
<div className="mb-1">{currentFormatted}</div>
<div className="mt-1">{nextFormatted}</div>
</div>
))}
);
})}
</div>
)}
{showSpacing && <div className="h-2" />}
{!isFirstInPair && <div className="h-2" />}
</div>
);
})}
@@ -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 (
<div key={`row-${rowSeats[0]?.id}`}>
{shouldFlipArmchair && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5 justify-center text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && <div className="w-8" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
@@ -257,7 +337,7 @@ export default function SeatsPage() {
)}
</div>
)}
<div className="flex gap-0.5 justify-start">
<div className="flex gap-0.5 justify-center">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<SeatIcon
@@ -276,7 +356,7 @@ export default function SeatsPage() {
/>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && <div className="w-8" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
@@ -300,19 +380,19 @@ export default function SeatsPage() {
</div>
{!shouldFlipArmchair && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5 justify-center text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && <div className="w-8" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
@@ -329,22 +409,58 @@ export default function SeatsPage() {
);
};
const coachesWithSeats = coaches.filter((coach: any) => {
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Seat Management</h1>
<p className="text-muted-foreground mt-1">View and manage seat availability by schedule</p>
</div>
<p className="text-muted-foreground mt-1">View and manage seats by coach</p>
</div>
{!selectedSchedule ? (
<div className="card">
<div className="mb-6">
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
onChange={(e) => setSelectedSchedule(e.target.value)}
className="input"
>
<option value="">Select a schedule...</option>
{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 (
<option key={schedule.id} value={schedule.id}>
{trainNumber} - {routeName} - {date}
</option>
);
})}
</select>
<div className="text-center py-12 text-muted-foreground mt-8">
<Armchair className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>Select a schedule to view seat map</p>
</div>
</div>
) : isLoading ? (
<div className="card text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-3">Loading seats...</p>
</div>
) : coachesWithSeats.length === 0 ? (
<div className="card text-center py-12 text-muted-foreground">
<p>No coaches with seats found for this schedule</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card h-fit sticky top-6 space-y-6">
<div>
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
@@ -365,67 +481,84 @@ export default function SeatsPage() {
</select>
</div>
{!selectedSchedule ? (
<div className="text-center py-12 text-muted-foreground">
<Armchair className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>Select a schedule to view seat map</p>
<div className="space-y-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-sm text-foreground">Seat Status</h3>
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded bg-green-500"></div>
<span className="text-sm text-muted-foreground">Available</span>
</div>
) : isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-3">Loading seats...</p>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded bg-red-500"></div>
<span className="text-sm text-muted-foreground">Booked</span>
</div>
) : coachesWithSeats.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>No coaches with seats found for this schedule</p>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded bg-yellow-500"></div>
<span className="text-sm text-muted-foreground">Held</span>
</div>
) : (
<div className="space-y-6">
<div className="flex items-center gap-6 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-green-500"></div>
<span className="text-sm">Available</span>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded bg-gray-500"></div>
<span className="text-sm text-muted-foreground">Blocked</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-red-500"></div>
<span className="text-sm">Booked</span>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded border-2 border-dashed border-gray-400"></div>
<span className="text-sm text-muted-foreground">Removed</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-yellow-500"></div>
<span className="text-sm">Held</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-gray-500"></div>
<span className="text-sm">Blocked</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-dashed border-gray-400"></div>
<span className="text-sm">Removed</span>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{coachesWithSeats.map((coach: any) => {
const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) ||
(coach.mode && coach.mode.toLowerCase().includes('bed'));
<div className="space-y-4 w-80">
<div className="bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(15,85,57)] rounded-lg border-2 border-[rgb(20,113,76)] flex items-center justify-center shadow-lg p-6 h-24">
<Train className="w-14 h-14 text-white" />
</div>
{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 (
<div key={coach.id} className="flex flex-col gap-4">
<div className="mb-3">
<h3 className="font-semibold text-sm">Coach {coach.coachNumber}</h3>
<div key={coach.id} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800/50 shadow-md hover:shadow-lg transition-shadow">
<div className="px-4 py-3 bg-gradient-to-r from-[rgb(20,113,76)]/10 to-[rgb(20,113,76)]/5 dark:from-[rgb(20,113,76)]/20 dark:to-[rgb(20,113,76)]/10 border-b border-[rgb(20,113,76)]/20 dark:border-[rgb(20,113,76)]/30 flex items-center justify-between">
<button
onClick={() => toggleCoach(coach.id)}
className="flex-1 flex items-center gap-3 hover:opacity-75 transition-opacity"
>
<div className={`transform transition-transform ${isExpanded ? 'rotate-180' : ''}`}>
<ChevronDown className="w-5 h-5 text-[rgb(20,113,76)]" />
</div>
<div className="text-left">
<p className="font-semibold text-foreground">Coach {coach.coachNumber}</p>
<p className="text-xs text-muted-foreground">{coachTypeName} {seats.length} {seatOrBedLabel}</p>
</div>
</button>
<ActionButton
variant={isCoachBlocked(coach) ? 'danger' : 'secondary'}
size="sm"
onClick={() => isCoachBlocked(coach) ? handleUnblockCoach(coach) : handleBlockCoach(coach)}
className="ml-2"
>
{isCoachBlocked(coach) ? 'Unblock' : 'Block'}
</ActionButton>
</div>
<div className="bg-gray-50 dark:bg-gray-900/30 rounded-lg w-64 border border-gray-200 dark:border-gray-700 p-2">
{isExpanded && (
<div className="px-4 py-4 bg-white dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700">
<div className="bg-gray-50 dark:bg-gray-900/30 rounded-lg p-3 inline-block">
{renderCoachSeats(coach, isBedCoach)}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
<Modal
isOpen={showBlockModal}
@@ -439,8 +572,7 @@ export default function SeatsPage() {
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Block seat <strong>{selectedSeat?.seatNumber}</strong> in Coach{' '}
<strong>{selectedSeat?.coach?.coachNumber}</strong>
Block seat <strong>{selectedSeat?.seatNumber}</strong> in Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
</p>
<div>
<label className="label">Reason for Blocking *</label>
@@ -485,8 +617,7 @@ export default function SeatsPage() {
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Remove seat <strong>{selectedSeat?.seatNumber}</strong> from Coach{' '}
<strong>{selectedSeat?.coach?.coachNumber}</strong>
Remove seat <strong>{selectedSeat?.seatNumber}</strong> from Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p className="text-sm text-yellow-800">
@@ -514,6 +645,96 @@ export default function SeatsPage() {
</div>
</div>
</Modal>
<Modal
isOpen={showBlockCoachModal}
onClose={() => {
setShowBlockCoachModal(false);
setSelectedCoach(null);
setBlockCoachReason('');
}}
title="Block Coach"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Block all seats in Coach <strong>{selectedCoach?.coachNumber}</strong>
</p>
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-sm text-red-800">
This will block all {selectedCoach?.seats?.length || 0} seats in this coach.
</p>
</div>
<div>
<label className="label">Reason for Blocking *</label>
<textarea
className="input"
rows={3}
value={blockCoachReason}
onChange={(e) => setBlockCoachReason(e.target.value)}
placeholder="e.g., Major maintenance, Safety inspection, Temporary withdrawal"
/>
</div>
<div className="flex justify-end gap-2">
<ActionButton
variant="secondary"
onClick={() => {
setShowBlockCoachModal(false);
setSelectedCoach(null);
setBlockCoachReason('');
}}
>
Cancel
</ActionButton>
<ActionButton
variant="danger"
onClick={submitBlockCoach}
loading={blockCoachMutation.isPending}
disabled={!blockCoachReason.trim()}
>
Block Coach
</ActionButton>
</div>
</div>
</Modal>
<Modal
isOpen={showUnblockCoachModal}
onClose={() => {
setShowUnblockCoachModal(false);
setCoachToUnblock(null);
}}
title="Unblock Coach"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Unblock all seats in Coach <strong>{coachToUnblock?.coachNumber}</strong>
</p>
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<p className="text-sm text-green-800">
This will unblock all {coachToUnblock?.seats?.filter((s: any) => s.status === 'BLOCKED' || s.isBlocked).length || 0} blocked seats in this coach.
</p>
</div>
<div className="flex justify-end gap-2">
<ActionButton
variant="secondary"
onClick={() => {
setShowUnblockCoachModal(false);
setCoachToUnblock(null);
}}
>
Cancel
</ActionButton>
<ActionButton
onClick={confirmUnblockCoach}
loading={unblockCoachMutation.isPending}
>
Unblock Coach
</ActionButton>
</div>
</div>
</Modal>
</div>
);
}
@@ -581,7 +802,7 @@ function SeatIcon({
return (
<div className="relative group flex flex-col items-center">
{!hideNumber && (
<span className="text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
<span className="text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber}
</span>
)}
@@ -590,7 +811,7 @@ function SeatIcon({
<div
className={`${width} h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}`}
style={seat.row % 2 === 1 ? { transform: 'scaleY(-1)' } : undefined}
style={!shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined}
>
<Bed className="w-7 h-7 text-white" />
</div>

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { MapPin, Globe, Plus, Edit, Trash2 } from 'lucide-react';
import { Globe, Plus, Edit, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
@@ -11,6 +11,15 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { stationsApi } from '@/lib/api';
import { Station } from '@/types';
const TIMEZONES = [
'Africa/Addis_Ababa',
'Africa/Johannesburg',
'Africa/Cairo',
'Africa/Lagos',
'Asia/Kolkata',
'UTC',
];
export default function StationsPage() {
const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
const [showModal, setShowModal] = useState(false);
@@ -51,6 +60,13 @@ export default function StationsPage() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const sequence = parseInt(formData.get('sequence') as string);
if (isNaN(sequence)) {
alert('Sequence Number is required');
return;
}
const stationData = {
code: formData.get('code') as string,
name: formData.get('name') as string,
@@ -59,6 +75,8 @@ export default function StationsPage() {
lat: parseFloat(formData.get('lat') as string) || null,
lng: parseFloat(formData.get('lng') as string) || null,
timezone: formData.get('timezone') as string,
distance: parseFloat(formData.get('distance') as string) || 0,
sequence,
isOperational: formData.get('isOperational') === 'true',
};
@@ -81,6 +99,14 @@ export default function StationsPage() {
};
const columns = [
{
key: 'sequence',
label: 'Sequence',
sortable: true,
render: (station: any) => (
<span className="font-mono font-semibold text-sm">{station.sequence || 0}</span>
),
},
{
key: 'code',
label: 'Code',
@@ -111,24 +137,11 @@ export default function StationsPage() {
),
},
{
key: 'coordinates',
label: 'Coordinates',
render: (station: any) => {
const lat = station.lat ? parseFloat(station.lat) : null;
const lng = station.lng ? parseFloat(station.lng) : null;
return (
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-mono">
{lat && lng && !isNaN(lat) && !isNaN(lng)
? `${lat.toFixed(4)}, ${lng.toFixed(4)}`
: 'N/A'
}
</span>
</div>
);
},
key: 'distance',
label: 'Distance (km)',
render: (station: any) => (
<span className="font-mono text-sm">{station.distance ? `${station.distance}` : '0'}</span>
),
},
{
key: 'isOperational',
@@ -139,13 +152,6 @@ export default function StationsPage() {
</Badge>
),
},
{
key: 'timezone',
label: 'Timezone',
render: (station: any) => (
<span className="text-sm">{station.timezone || 'N/A'}</span>
),
},
];
const actions = [
@@ -328,15 +334,43 @@ export default function StationsPage() {
/>
</div>
<div>
<label className="label">Timezone</label>
<input
type="text"
<label className="label">Timezone *</label>
<select
name="timezone"
className="input"
defaultValue={editingStation?.timezone || 'Africa/Addis_Ababa'}
placeholder="e.g., Africa/Addis_Ababa"
required
>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>{tz}</option>
))}
</select>
</div>
<div>
<label className="label">Distance from Previous (km)</label>
<input
type="number"
name="distance"
className="input"
defaultValue={editingStation?.distance || 0}
min="0"
step="0.1"
placeholder="e.g., 150.5"
/>
</div>
<div>
<label className="label">Sequence Number *</label>
<input
type="number"
name="sequence"
className="input"
defaultValue={editingStation?.sequence || 0}
min="0"
required
placeholder="e.g., 1"
/>
<p className="text-xs text-muted-foreground mt-1">Used for ordering stations in routes</p>
</div>
<div>
<label className="label">Status</label>
<select

View File

@@ -2,19 +2,35 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, RefreshCw, CheckCircle, Trash2 } from 'lucide-react';
import { LogIn, Trash2 } from 'lucide-react';
import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { ticketsApi, apiClient } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
import Modal from '@/components/ui/Modal';
import { ticketsApi, apiClient, schedulesApi, stationsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '' });
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', tripDate: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
const [ticketToBoard, setTicketToBoard] = useState<any>(null);
const [successMessage, setSuccessMessage] = useState('');
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [selectedColumns, setSelectedColumns] = useState<Record<string, boolean>>({
ticketNumber: true,
booking: true,
trip: true,
seat: true,
seatClass: true,
amount: true,
status: true,
});
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
@@ -22,27 +38,22 @@ export default function TicketsPage() {
queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }),
});
const regenerateMutation = useMutation({
mutationFn: ticketsApi.regenerate,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
setSuccessMessage('Ticket regenerated successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
alert(`Error: ${error.message || 'Failed to regenerate ticket'}`);
},
const { data: stationsData } = useQuery({
queryKey: ['stations'],
queryFn: () => stationsApi.getAll(),
});
const validateMutation = useMutation({
mutationFn: ({ ticketId, data }: any) => ticketsApi.validate(ticketId, data),
const boardMutation = useMutation({
mutationFn: ({ ticketId }: any) => ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString() }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
setSuccessMessage('Ticket validated successfully');
setBoardConfirmOpen(false);
setTicketToBoard(null);
setSuccessMessage('Ticket boarded successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
alert(`Error: ${error.message || 'Failed to validate ticket'}`);
alert(`Error: ${error.message || 'Failed to board ticket'}`);
},
});
@@ -61,17 +72,15 @@ export default function TicketsPage() {
},
});
const handleRegenerate = async (ticket: any) => {
if (window.confirm(`Regenerate QR code for ticket ${ticket.ticketNumber}?`)) {
await regenerateMutation.mutateAsync(ticket.id);
}
const handleBoard = (ticket: any) => {
setTicketToBoard(ticket);
setBoardConfirmOpen(true);
};
const handleValidate = async (ticket: any) => {
await validateMutation.mutateAsync({
ticketId: ticket.id,
data: { validatedAt: new Date().toISOString() },
});
const handleConfirmBoard = async () => {
if (ticketToBoard) {
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id });
}
};
const handleDeleteClick = (ticket: any) => {
@@ -85,6 +94,50 @@ export default function TicketsPage() {
}
};
const handleExportTickets = async () => {
setExportModalOpen(true);
};
const confirmExport = () => {
const cols = Object.entries(selectedColumns)
.filter(([, selected]) => selected)
.map(([col]) => col);
if (cols.length === 0) {
alert('Please select at least one column');
return;
}
const csv = [
cols.join(','),
...data?.items?.map((ticket: any) => {
const values = cols.map(col => {
switch(col) {
case 'ticketNumber': return ticket.ticketNumber || '';
case 'booking': return ticket.booking?.bookingRef || '';
case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`;
case 'coach': return ticket.seat?.coach?.number || '';
case 'seat': return ticket.seat?.seatNumber || '';
case 'seatClass': return ticket.seat?.coach?.coachType?.name || '';
case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB');
case 'status': return ticket.status || '';
case 'boarded': return ticket.boardedAt ? 'Yes' : 'No';
default: return '';
}
});
return values.map(v => `"${v}"`).join(',');
}) || []
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tickets-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
setExportModalOpen(false);
};
const columns = [
{
key: 'ticketNumber',
@@ -123,54 +176,60 @@ export default function TicketsPage() {
{
key: 'seat',
label: 'Seat',
sortable: true,
render: (ticket: any) => (
<span className="font-mono">{ticket.seat?.seatNumber || 'N/A'}</span>
<div>
<div className="font-mono font-semibold">Coach {ticket.seat?.coach?.number || 'N/A'} - Seat {ticket.seat?.seatNumber || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.name || 'N/A'}</div>
</div>
),
},
{
key: 'amount',
label: 'Amount',
sortable: true,
render: (ticket: any) => formatCurrency(ticket.booking?.totalMinor || 0, ticket.booking?.currency || 'ETB'),
},
{
key: 'status',
label: 'Status',
sortable: true,
render: (ticket: any) => (
<Badge variant="status" status={ticket.status || 'PENDING'}>
{ticket.status || 'PENDING'}
<Badge variant="status" status={ticket.status || 'ACTIVE'}>
{ticket.status || 'ACTIVE'}
</Badge>
),
},
{
key: 'validated',
label: 'Validated',
key: 'boarded',
label: 'Boarded',
render: (ticket: any) => (
ticket.validatedAt ? (
ticket.boardedAt ? (
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CheckCircle className="h-4 w-4" />
<span className="text-sm">{formatDateTime(ticket.validatedAt)}</span>
<span className="text-sm">{formatDateTime(ticket.boardedAt)}</span>
</div>
) : (
<span className="text-sm text-muted-foreground">Not validated</span>
<span className="text-sm text-muted-foreground">Not boarded</span>
)
),
},
{
key: 'createdAt',
label: 'Created',
sortable: true,
render: (ticket: any) => formatDateTime(ticket.createdAt),
},
];
const actions = [
{
label: 'Check-in',
onClick: handleValidate,
label: 'Board',
onClick: handleBoard,
variant: 'primary' as const,
icon: CheckCircle,
show: (ticket: any) => !ticket.validatedAt,
icon: LogIn,
show: (ticket: any) => ticket.status !== 'USED' && !ticket.boardedAt,
},
{
label: 'Regenerate',
onClick: handleRegenerate,
label: 'Details',
onClick: (ticket: any) => {
setSelectedTicket(ticket);
setDetailsModalOpen(true);
},
variant: 'secondary' as const,
icon: RefreshCw,
},
{
label: 'Delete',
@@ -180,6 +239,8 @@ export default function TicketsPage() {
},
];
const stations = stationsData?.items || [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -187,7 +248,7 @@ export default function TicketsPage() {
<h1 className="text-2xl font-bold text-foreground">Tickets</h1>
<p className="text-muted-foreground">Manage tickets and validations</p>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
<ActionButton icon={Download} variant="secondary" onClick={handleExportTickets}>Export</ActionButton>
</div>
{/* Filters */}
@@ -202,17 +263,52 @@ export default function TicketsPage() {
Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<div>
<label className="label">Search</label>
<input
type="text"
placeholder="Search by ticket number or booking ref..."
placeholder="Search by ticket number..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
<div>
<label className="label">Origin</label>
<select
className="input"
value={filters.originStationId}
onChange={(e) => setFilters({ ...filters, originStationId: e.target.value })}
>
<option value="">All Origins</option>
{stations.map((station: any) => (
<option key={station.id} value={station.id}>{station.name}</option>
))}
</select>
</div>
<div>
<label className="label">Destination</label>
<select
className="input"
value={filters.destinationStationId}
onChange={(e) => setFilters({ ...filters, destinationStationId: e.target.value })}
>
<option value="">All Destinations</option>
{stations.map((station: any) => (
<option key={station.id} value={station.id}>{station.name}</option>
))}
</select>
</div>
<div>
<label className="label">Trip Date</label>
<input
type="date"
className="input"
value={filters.tripDate}
onChange={(e) => setFilters({ ...filters, tripDate: e.target.value })}
/>
</div>
<div>
<label className="label">Status</label>
<select
@@ -224,7 +320,6 @@ export default function TicketsPage() {
<option value="ACTIVE">Active</option>
<option value="USED">Used</option>
<option value="CANCELLED">Cancelled</option>
<option value="EXPIRED">Expired</option>
</select>
</div>
</div>
@@ -239,6 +334,21 @@ export default function TicketsPage() {
emptyMessage="No tickets found"
/>
{/* Board Confirmation Dialog */}
<ConfirmDialog
isOpen={boardConfirmOpen}
onClose={() => {
setBoardConfirmOpen(false);
setTicketToBoard(null);
}}
onConfirm={handleConfirmBoard}
title="Board Ticket"
message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`}
confirmText="Board"
cancelText="Cancel"
isLoading={boardMutation.isPending}
/>
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={deleteConfirmOpen}
@@ -254,6 +364,154 @@ export default function TicketsPage() {
isLoading={deleteMutation.isPending}
isDanger={true}
/>
{/* Ticket Details Modal */}
<Modal
isOpen={detailsModalOpen}
onClose={() => {
setDetailsModalOpen(false);
setSelectedTicket(null);
}}
title="Ticket Details"
size="lg"
>
{selectedTicket && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<p className="text-sm text-muted-foreground">Ticket Number</p>
<p className="font-mono font-semibold text-lg">{selectedTicket.ticketNumber}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Status</p>
<div className="mt-1">
<Badge variant="status" status={selectedTicket.status || 'ACTIVE'}>
{selectedTicket.status || 'ACTIVE'}
</Badge>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Booking Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Booking Reference</p>
<p className="font-medium">{selectedTicket.booking?.bookingRef || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Passenger</p>
<p className="font-medium">{selectedTicket.booking?.passenger?.fullName || selectedTicket.booking?.contactEmail || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Amount</p>
<p className="font-medium">{formatCurrency(selectedTicket.booking?.totalMinor || 0, selectedTicket.booking?.currency || 'ETB')}</p>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Trip Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Route</p>
<p className="font-medium">
{selectedTicket.schedule?.originStation?.name || 'N/A'} {selectedTicket.schedule?.destinationStation?.name || 'N/A'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Departure</p>
<p className="font-medium">{selectedTicket.schedule?.departureAt ? formatDateTime(selectedTicket.schedule.departureAt) : 'N/A'}</p>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Seat Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Coach</p>
<p className="font-mono font-semibold">{selectedTicket.seat?.coach?.number || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Seat Number</p>
<p className="font-mono font-semibold">{selectedTicket.seat?.seatNumber || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Class</p>
<p className="font-medium">{selectedTicket.seat?.coach?.coachType?.name || 'N/A'}</p>
</div>
</div>
</div>
{selectedTicket.boardedAt && (
<div className="border-t pt-4 bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Boarded At</p>
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.boardedAt)}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<ActionButton
variant="secondary"
onClick={() => {
setDetailsModalOpen(false);
setSelectedTicket(null);
}}
>
Close
</ActionButton>
</div>
</div>
)}
</Modal>
{/* Export Columns Modal */}
<Modal
isOpen={exportModalOpen}
onClose={() => setExportModalOpen(false)}
title="Export Tickets - Select Columns"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Select which columns to include in the export</p>
<div className="space-y-3 max-h-96 overflow-y-auto">
{[
{ key: 'ticketNumber', label: 'Ticket Number' },
{ key: 'booking', label: 'Booking Reference & Passenger' },
{ key: 'trip', label: 'Trip (Origin → Destination)' },
{ key: 'coach', label: 'Coach Number' },
{ key: 'seat', label: 'Seat Number' },
{ key: 'seatClass', label: 'Seat Class' },
{ key: 'amount', label: 'Amount' },
{ key: 'status', label: 'Status' },
{ key: 'boarded', label: 'Boarded Status' },
].map((col) => (
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
<input
type="checkbox"
checked={selectedColumns[col.key] || false}
onChange={(e) =>
setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>
Cancel
</ActionButton>
<ActionButton onClick={confirmExport}>
Export CSV
</ActionButton>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { Bell, LogOut, Moon, Sun, ChevronDown } from 'lucide-react';
import { Bell, LogOut, Moon, Sun, ChevronDown, HelpCircle } from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store';
import { useTheme } from '@/lib/theme-store';
import { useState } from 'react';
@@ -19,6 +19,16 @@ export default function Header() {
</div>
<div className="flex items-center gap-3">
{/* Help Documentation */}
<Link
href="/docs"
className="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
title="View Documentation"
target="_blank"
>
<HelpCircle className="h-5 w-5 text-[rgb(20,113,76)] dark:text-slate-400" />
</Link>
{/* Notifications */}
<div className="relative">
<button

View File

@@ -32,7 +32,8 @@ import {
Moon,
Sun,
Armchair,
Grid3x3
Grid3x3,
Banknote
} from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils';
@@ -69,6 +70,7 @@ const navigationSections = [
title: 'Financial',
items: [
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign },
{ name: 'Currencies', href: '/currencies', icon: Banknote },
{ name: 'Payments', href: '/payments', icon: CreditCard },
{ name: 'Promo Codes', href: '/promos', icon: Gift },
]
@@ -79,15 +81,14 @@ const navigationSections = [
{ name: 'Loyalty Program', href: '/loyalty', icon: Gift },
{ name: 'Support Center', href: '/support', icon: MessageSquare },
{ name: 'Notifications', href: '/notifications', icon: Bell },
{ name: 'Food & Dining', href: '/food', icon: Utensils },
]
},
{
title: 'Security & Compliance',
items: [
{ name: 'Audit Logs', href: '/audit', icon: AlertTriangle },
{ name: 'Fraud Detection', href: '/fraud', icon: Shield },
{ name: 'Verifayda Integration', href: '/verifayda', icon: UserCheck },
{ name: 'Audit Logs', href: '/audit', icon: AlertTriangle },
]
},
{

View File

@@ -10,6 +10,7 @@ interface Column<T> {
key: string;
label: string;
sortable?: boolean;
filterable?: boolean;
render?: (item: T) => ReactNode;
width?: string;
}

View File

@@ -2,15 +2,186 @@ import { apiClient } from '@/lib/api-client';
import { DashboardStats, RevenueData } from '@/types';
export const dashboardApi = {
getStats: () => {
return apiClient.get<DashboardStats>('/dashboard/stats');
getStats: async () => {
try {
// Fetch bookings and passengers data in parallel
const [bookingsRes, passengersRes] = await Promise.all([
apiClient.get<any>('/bookings?pageSize=1'),
apiClient.get<any>('/passengers?pageSize=1'),
]);
const bookingsTotal = bookingsRes?.meta?.total || 0;
const passengersTotal = passengersRes?.meta?.total || 0;
// Calculate revenue from bookings
const allBookingsRes = await apiClient.get<any>('/bookings?pageSize=100');
const allBookings = Array.isArray(allBookingsRes) ? allBookingsRes : allBookingsRes?.items || [];
const totalRevenue = allBookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0);
// Calculate average occupancy (placeholder - would need dedicated endpoint)
const occupancyRate = Math.floor(Math.random() * 100); // Replace with actual data
return {
totalBookings: bookingsTotal,
totalRevenue: totalRevenue,
totalPassengers: passengersTotal,
occupancyRate: occupancyRate,
totalTripsToday: 0,
activeTrips: 0,
cancelledBookings: 0,
averageTicketPrice: allBookings.length > 0 ? totalRevenue / allBookings.length : 0,
};
} catch (error) {
console.error('Failed to fetch dashboard stats:', error);
return {
totalBookings: 0,
totalRevenue: 0,
totalPassengers: 0,
occupancyRate: 0,
totalTripsToday: 0,
activeTrips: 0,
cancelledBookings: 0,
averageTicketPrice: 0,
};
}
},
getRevenueChart: (days: number = 30) => {
return apiClient.get<RevenueData[]>(`/dashboard/revenue?days=${days}`);
getRevenueChart: async (days: number = 30) => {
try {
const response = await apiClient.get<RevenueData[]>(`/dashboard/revenue?days=${days}`);
return response;
} catch (error) {
console.error('Failed to fetch revenue chart:', error);
return [];
}
},
getRecentBookings: (limit: number = 10) => {
return apiClient.get<any[]>(`/dashboard/recent-bookings?limit=${limit}`);
getRecentBookings: async (limit: number = 10) => {
try {
const response = await apiClient.get<any>(`/bookings?pageSize=${limit}`);
// Extract items from paginated response
const bookings = Array.isArray(response) ? response : response?.items || [];
return bookings.map((booking: any) => ({
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.totalMinor,
currency: booking.currency || 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
createdAt: booking.createdAt,
passenger: booking.passenger ? {
id: booking.passenger.id,
fullName: booking.passenger.fullName,
email: booking.passenger.email,
} : null,
schedule: booking.schedule,
paymentIntent: booking.paymentIntent,
}));
} catch (error) {
console.error('Failed to fetch recent bookings:', error);
return [];
}
},
getTopAgents: async (limit: number = 5) => {
try {
const response = await apiClient.get<any[]>(`/agents/top?limit=${limit}`);
return response || [];
} catch (error) {
console.error('Failed to fetch top agents:', error);
return [];
}
},
getOccupancyTrend: async (days: number = 7) => {
try {
const response = await apiClient.get<any[]>(`/dashboard/occupancy?days=${days}`);
return response || [];
} catch (error) {
console.error('Failed to fetch occupancy trend:', error);
return [];
}
},
getUpcomingTrips: async (limit: number = 5) => {
try {
const response = await apiClient.get<any[]>(`/schedules/upcoming?limit=${limit}`);
return response || [];
} catch (error) {
console.error('Failed to fetch upcoming trips:', error);
return [];
}
},
getPaymentMethods: async () => {
try {
const response = await apiClient.get<any[]>('/dashboard/payment-methods');
return response || [];
} catch (error) {
console.error('Failed to fetch payment methods:', error);
return [];
}
},
getPassengerStats: async () => {
try {
const response = await apiClient.get<any>('/dashboard/passenger-stats');
return response || {
totalPassengers: 0,
newPassengersToday: 0,
activePassengers: 0,
loyaltyPoints: 0,
};
} catch (error) {
console.error('Failed to fetch passenger stats:', error);
return {
totalPassengers: 0,
newPassengersToday: 0,
activePassengers: 0,
loyaltyPoints: 0,
};
}
},
getTransactionSummary: async (days: number = 30) => {
try {
const response = await apiClient.get<any>(`/dashboard/transactions?days=${days}`);
return response || {
totalTransactions: 0,
successfulTransactions: 0,
failedTransactions: 0,
totalAmount: 0,
};
} catch (error) {
console.error('Failed to fetch transaction summary:', error);
return {
totalTransactions: 0,
successfulTransactions: 0,
failedTransactions: 0,
totalAmount: 0,
};
}
},
getLiveMetrics: async () => {
try {
const response = await apiClient.get<any>('/dashboard/live-metrics');
return response || {
onlineUsers: 0,
activeBookings: 0,
activePayments: 0,
};
} catch (error) {
console.error('Failed to fetch live metrics:', error);
return {
onlineUsers: 0,
activeBookings: 0,
activePayments: 0,
};
}
},
};

View File

@@ -364,14 +364,12 @@ export const foodApi = {
// Reports API
export const reportsApi = {
getOperationalReports: async (params?: any) => {
const query = new URLSearchParams(params as Record<string, string>).toString();
const response = await apiClient.get<any>(`/reports/operational${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
generateReport: (data: any) => apiClient.post<any>('/reports/generate', data),
getReport: (reportId: string) => apiClient.get<any>(`/reports/${reportId}`),
listReports: async (reportType?: string) => {
const query = reportType ? `?type=${reportType}` : '';
const response = await apiClient.get<any>(`/reports${query}`);
if (Array.isArray(response)) return { items: response };
return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : { items: [] };
},
getRevenue: (params?: any) => apiClient.get<any>('/reports/revenue', { params }),
getOccupancy: (params?: any) => apiClient.get<any>('/reports/occupancy', { params }),
};

View File

@@ -0,0 +1,250 @@
# TELEBIRR & WAAFI Payment Integration Flow
## Overview
Complete payment flow for TELEBIRR and WAAFI integration using the `/payments/initiate` endpoint.
## Payment Flow
### 1. Payment Method Selection
- User selects TELEBIRR or WAAFI from available payment methods
- Payment methods fetched from `/payments/methods`
- Extracts payment method ID for the request
### 2. Payment Initiation
**Endpoint:** `POST /payments/initiate`
**Request:**
```json
{
"bookingId": "booking-uuid",
"method": "TELEBIRR" | "WAAFI",
"paymentMethodId": "payment-method-uuid",
"platform": "web"
}
```
**Response:**
```json
{
"success": true,
"data": {
"intentId": "66aa30e2-52a2-4ad0-9043-df6df4a6fa4a",
"status": "REQUIRES_ACTION",
"clientAction": {
"url": "https://sandbox.waafipay.net/v2/hpp/token/2B68686270593243495535774B317263683930574A413D3D",
"type": "REDIRECT"
},
"merchantOrderId": "1781588440170af93c3b9"
},
"timestamp": "2026-06-16T05:40:41.004Z"
}
```
### 3. User Redirect
- App stores `intentId` in payment store
- Updates payment status to `REQUIRES_ACTION`
- Redirects user to `clientAction.url`
- User completes payment on payment gateway
### 4. Callback Handling
#### TELEBIRR Success Callback
**URL:** `/booking/payment/telebirr/success`
**Query Parameters:**
- `merchantOrderId` - Merchant order ID (primary reference)
- `trxRef` or `outTradeNo` - Transaction reference
- `resultCode` or `code` - Result code
- `resultMsg` or `message` - Result message
- `msisdn` - Phone number (optional)
- `bookingId` - Booking UUID
**Actions:**
1. Logs all query parameters
2. Calls `PATCH /bookings/{bookingId}/confirm` with:
```json
{
"paymentReference": "merchantOrderId or trxRef",
"paymentMethod": "TELEBIRR"
}
```
3. Updates payment status to `SUCCEEDED`
4. Redirects to `/booking/confirmation`
#### TELEBIRR Failure Callback
**URL:** `/booking/payment/telebirr/failure`
**Query Parameters:**
- `merchantOrderId` - Merchant order ID
- `trxRef` or `outTradeNo` - Transaction reference
- `resultCode` or `code` - Error code
- `resultMsg` or `message` - Error message
**Actions:**
1. Logs all query parameters
2. Updates payment status to `FAILED`
3. Shows error message to user
4. Provides options to retry or go back
#### WAAFI Success Callback
**URL:** `/booking/payment/waafi/success`
**Query Parameters:**
- `accountNo` - Account number (e.g., "25377111111")
- `cardNo` - Card number
- `currency` - Currency code (e.g., "DJF")
- `orderId` - Order ID (e.g., "1209631")
- `referenceId` - Reference ID (e.g., "17815888579838ddc23b3")
- `responseCode` - Response code ("0" for success)
- `responseMsg` - Response message (e.g., "Approved (sandbox mode)")
- `state` - Transaction state (e.g., "APPROVED")
- `transactionId` - Transaction ID (e.g., "1318559")
- `txAmount` - Transaction amount (e.g., "367.50")
- `paymentMethod` - Payment method type (e.g., "MWALLET_ACCOUNT")
- `timestamp` - Transaction timestamp
- `bookingId` - Booking UUID
**Example:**
```
?accountNo=25377111111
&cardNo=25377111111
&currency=DJF
&orderId=1209631
&referenceId=17815888579838ddc23b3
&responseCode=0
&responseMsg=Approved+(sandbox+mode)
&state=APPROVED
&transactionId=1318559
&txAmount=367.50
&paymentMethod=MWALLET_ACCOUNT
&timestamp=2026-06-16T08:48:01+03:00
```
**Actions:**
1. Logs all query parameters
2. Calls `PATCH /bookings/{bookingId}/confirm` with:
```json
{
"paymentReference": "referenceId or transactionId",
"paymentMethod": "WAAFI",
"transactionDetails": {
"transactionId": "1318559",
"orderId": "1209631",
"accountNo": "25377111111",
"amount": "367.50",
"currency": "DJF",
"state": "APPROVED",
"timestamp": "2026-06-16T08:48:01+03:00"
}
}
```
3. Updates payment status to `SUCCEEDED`
4. Redirects to `/booking/confirmation`
#### WAAFI Failure Callback
**URL:** `/booking/payment/waafi/failure`
**Query Parameters:**
- `referenceId` - Reference ID
- `responseCode` - Error code
- `responseMsg` - Error message
- `orderId` - Order ID
- `transactionId` - Transaction ID
- `state` - Transaction state
- `txAmount` - Transaction amount
- `currency` - Currency code
**Actions:**
1. Logs all query parameters
2. Updates payment status to `FAILED`
3. Shows error message to user
4. Provides options to retry or go back
## Console Logs
When TELEBIRR or WAAFI payment is initiated, check browser console for:
```
=== TELEBIRR PAYMENT INITIATION ===
Request payload: {
bookingId: "...",
method: "TELEBIRR",
paymentMethodId: "...",
platform: "web"
}
=== TELEBIRR PAYMENT RESPONSE ===
Full response: {...}
Intent ID: "66aa30e2-52a2-4ad0-9043-df6df4a6fa4a"
Status: "REQUIRES_ACTION"
Client Action: {url: "...", type: "REDIRECT"}
Redirect URL: "https://sandbox.waafipay.net/v2/hpp/token/..."
Merchant Order ID: "1781588440170af93c3b9"
====================================
=== REDIRECTING TO TELEBIRR PAYMENT GATEWAY ===
Intent ID: 66aa30e2-52a2-4ad0-9043-df6df4a6fa4a
Status: REQUIRES_ACTION
Merchant Order ID: 1781588440170af93c3b9
Redirect URL: https://sandbox.waafipay.net/v2/hpp/token/...
=======================================
```
## Callback URLs to Share
### TELEBIRR Callback URLs:
- **Success:** `http://localhost:5174/booking/payment/telebirr/success` (dev)
- **Failure:** `http://localhost:5174/booking/payment/telebirr/failure` (dev)
- **Success:** `https://your-domain.com/booking/payment/telebirr/success` (prod)
- **Failure:** `https://your-domain.com/booking/payment/telebirr/failure` (prod)
### WAAFI Callback URLs:
- **Success:** `http://localhost:5174/booking/payment/waafi/success` (dev)
- **Failure:** `http://localhost:5174/booking/payment/waafi/failure` (dev)
- **Success:** `https://your-domain.com/booking/payment/waafi/success` (prod)
- **Failure:** `https://your-domain.com/booking/payment/waafi/failure` (prod)
## Files Modified
1. **`src/app/booking/payment/page.tsx`**
- Added TELEBIRR and WAAFI payment initiation
- Handles redirect response
- Logs all payment data
2. **`src/lib/payment-store.ts`**
- Added `REQUIRES_ACTION` status
3. **`src/types/index.ts`**
- Updated `PaymentMethod` interface
4. **`src/app/booking/payment/telebirr/success/page.tsx`**
- Handles TELEBIRR success callback with merchantOrderId
5. **`src/app/booking/payment/telebirr/failure/page.tsx`**
- Handles TELEBIRR failure callback with merchantOrderId
6. **`src/app/booking/payment/waafi/success/page.tsx`**
- Handles WAAFI success callback with full transaction details
7. **`src/app/booking/payment/waafi/failure/page.tsx`**
- Handles WAAFI failure callback
## Testing Checklist
- [ ] Payment methods load from API
- [ ] TELEBIRR appears in payment options
- [ ] WAAFI appears in payment options
- [ ] Selecting TELEBIRR calls `/payments/initiate`
- [ ] Selecting WAAFI calls `/payments/initiate`
- [ ] Console logs show correct request/response
- [ ] User redirects to payment gateway
- [ ] Success callback confirms booking
- [ ] Failure callback shows error
- [ ] User can retry after failure
## Notes
- Only TELEBIRR and WAAFI use `/payments/initiate` endpoint
- Other payment methods use `/payments/intent` endpoint
- Payment store supports `REQUIRES_ACTION` status
- All callback query parameters are logged for debugging
- TELEBIRR uses `merchantOrderId` as primary reference
- WAAFI uses `referenceId` or `transactionId` as primary reference

View File

@@ -0,0 +1,148 @@
# TELEBIRR Payment Integration Flow
## Overview
Complete payment flow for TELEBIRR integration using the `/payments/initiate` endpoint.
## Payment Flow
### 1. Payment Method Selection
- User selects TELEBIRR from available payment methods
- Payment methods fetched from `/payments/methods`
- Extracts payment method ID for the request
### 2. Payment Initiation
**Endpoint:** `POST /payments/initiate`
**Request:**
```json
{
"bookingId": "booking-uuid",
"method": "TELEBIRR",
"paymentMethodId": "payment-method-uuid",
"platform": "web"
}
```
**Response:**
```json
{
"success": true,
"data": {
"intentId": "66aa30e2-52a2-4ad0-9043-df6df4a6fa4a",
"status": "REQUIRES_ACTION",
"clientAction": {
"url": "https://sandbox.waafipay.net/v2/hpp/token/2B68686270593243495535774B317263683930574A413D3D",
"type": "REDIRECT"
},
"merchantOrderId": "1781588440170af93c3b9"
},
"timestamp": "2026-06-16T05:40:41.004Z"
}
```
### 3. User Redirect
- App stores `intentId` in payment store
- Updates payment status to `REQUIRES_ACTION`
- Redirects user to `clientAction.url`
- User completes payment on WaafiPay gateway
### 4. Callback Handling
#### Success Callback
**URL:** `/booking/payment/telebirr/success`
**Query Parameters:**
- `trxRef` or `outTradeNo` - Transaction reference
- `resultCode` or `code` - Result code
- `resultMsg` or `message` - Result message
- `msisdn` - Phone number (optional)
- `bookingId` - Booking UUID
**Actions:**
1. Logs all query parameters
2. Calls `PATCH /bookings/{bookingId}/confirm` with:
```json
{
"paymentReference": "trxRef",
"paymentMethod": "TELEBIRR"
}
```
3. Updates payment status to `SUCCEEDED`
4. Redirects to `/booking/confirmation`
#### Failure Callback
**URL:** `/booking/payment/telebirr/failure`
**Query Parameters:**
- `trxRef` or `outTradeNo` - Transaction reference
- `resultCode` or `code` - Error code
- `resultMsg` or `message` - Error message
**Actions:**
1. Logs all query parameters
2. Updates payment status to `FAILED`
3. Shows error message to user
4. Provides options to retry or go back
## Console Logs
When TELEBIRR payment is initiated, check browser console for:
```
=== TELEBIRR PAYMENT INITIATION ===
Request payload: {
bookingId: "...",
method: "TELEBIRR",
paymentMethodId: "...",
platform: "web"
}
=== TELEBIRR PAYMENT RESPONSE ===
Full response: {...}
Intent ID: "66aa30e2-52a2-4ad0-9043-df6df4a6fa4a"
Status: "REQUIRES_ACTION"
Client Action: {url: "...", type: "REDIRECT"}
Redirect URL: "https://sandbox.waafipay.net/v2/hpp/token/..."
Merchant Order ID: "1781588440170af93c3b9"
====================================
=== REDIRECTING TO PAYMENT GATEWAY ===
Intent ID: 66aa30e2-52a2-4ad0-9043-df6df4a6fa4a
Status: REQUIRES_ACTION
Merchant Order ID: 1781588440170af93c3b9
Redirect URL: https://sandbox.waafipay.net/v2/hpp/token/...
=======================================
```
## Files Modified
1. **`src/app/booking/payment/page.tsx`**
- Added TELEBIRR-specific payment initiation
- Handles redirect response
- Logs all payment data
2. **`src/lib/payment-store.ts`**
- Added `REQUIRES_ACTION` status
3. **`src/types/index.ts`**
- Updated `PaymentMethod` interface
4. **Existing Callback Pages:**
- `src/app/booking/payment/telebirr/success/page.tsx`
- `src/app/booking/payment/telebirr/failure/page.tsx`
## Testing Checklist
- [ ] Payment methods load from API
- [ ] TELEBIRR appears in payment options
- [ ] Selecting TELEBIRR calls `/payments/initiate`
- [ ] Console logs show correct request/response
- [ ] User redirects to WaafiPay gateway
- [ ] Success callback confirms booking
- [ ] Failure callback shows error
- [ ] User can retry after failure
## Notes
- Other payment methods still use `/payments/intent` endpoint
- Only TELEBIRR uses the new `/payments/initiate` flow
- Payment store now supports `REQUIRES_ACTION` status
- All callback query parameters are logged for debugging

View File

@@ -378,13 +378,13 @@ type FormData = z.infer<typeof formSchema>;
export default function PassengersPage() {
const router = useRouter();
const { searchCriteria, setPassengers, setCreateAccount, clearBooking } = useBookingStore();
const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore();
const { user, isAuthenticated, updateUser } = useAuthStore();
const [faydaEnabled, setFaydaEnabled] = useState(true);
const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({});
const [saving, setSaving] = useState(false);
const [formInitialized, setFormInitialized] = useState(false);
const [nationalityMismatch, setNationalityMismatch] = useState(false);
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
@@ -451,19 +451,7 @@ export default function PassengersPage() {
return;
}
const userNationality = (passengerData?.nationality || user.nationality || '').toUpperCase().trim();
const searchNationality = (searchCriteria?.nationality || '').toUpperCase().trim();
console.log('Nationalities:', { userNationality, searchNationality });
// Check for nationality mismatch
if (userNationality !== searchNationality) {
console.log('Nationality mismatch detected');
setNationalityMismatch(true);
setFormInitialized(true);
return;
}
// Only populate if nationalities match
// Only populate first passenger
console.log('Setting passenger 0 values');
setValue('passengers.0.name', passengerData?.fullName || user.fullName || '');
setValue('passengers.0.dateOfBirth', passengerData?.dateOfBirth || user.dateOfBirth || '');
@@ -489,15 +477,6 @@ export default function PassengersPage() {
populateForm();
}, [isAuthenticated, user, searchCriteria, setValue]);
useEffect(() => {
if (nationalityMismatch && formInitialized) {
setTimeout(() => {
const element = document.getElementById('nationality-mismatch');
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
}, [nationalityMismatch, formInitialized]);
const openFaydaVerification = async (index: number) => {
if (typeof window === 'undefined') return;
@@ -621,45 +600,6 @@ export default function PassengersPage() {
if (!searchCriteria) return null;
if (nationalityMismatch && formInitialized) {
const searchLabel: Record<string, string> = { ETHIOPIAN: 'Ethiopian', DJIBOUTIAN: 'Djiboutian', OTHER: 'Other' };
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-lg mx-auto">
<div className="card border-red-300 dark:border-red-700" id="nationality-mismatch">
<div className="flex items-start gap-4">
<div className="text-red-500 text-2xl mt-0.5"></div>
<div>
<h2 className="text-lg font-semibold text-red-700 dark:text-red-400 mb-2">Nationality Mismatch</h2>
<p className="text-gray-700 dark:text-gray-300 text-sm mb-3">
You searched for an <strong>{searchLabel[searchCriteria.nationality] ?? searchCriteria.nationality}</strong> passenger,
but your account is registered as <strong>{user?.nationality}</strong>.
</p>
<p className="text-gray-600 dark:text-gray-400 text-sm mb-5">
You cannot proceed with this booking. Please restart and select the correct nationality on the search page.
</p>
<button
onClick={() => {
clearBooking();
if (typeof window !== 'undefined') {
window.location.href = '/booking/search';
}
}}
className="btn-primary w-full flex items-center justify-center gap-2"
>
<ExternalLink className="w-5 h-5" />
Restart Booking
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
if (!formInitialized) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">

View File

@@ -1,74 +1,72 @@
'use client';
"use client";
import { useRouter } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store';
import { usePaymentStore } from '@/lib/payment-store';
import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useState, useEffect } from 'react';
import { CreditCard, Smartphone, Wallet, Loader2, CheckCircle } from 'lucide-react';
import { useRouter } from "next/navigation";
import { useBookingStore } from "@/lib/booking-store";
import { usePaymentStore } from "@/lib/payment-store";
import { useMutation, useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
import { useState, useEffect } from "react";
import { PaymentMethod } from "@/types";
import {
CreditCard,
Smartphone,
Wallet,
Loader2,
CheckCircle,
} from "lucide-react";
// Mock payment methods with Ethiopian providers
const paymentMethods = [
{
id: 'TELEBIRR',
name: 'Telebirr',
icon: Smartphone,
description: 'Pay with Telebirr mobile money',
color: 'bg-orange-50 border-orange-200 hover:border-orange-400'
},
{
id: 'CBE_BIRR',
name: 'CBE Birr',
icon: Smartphone,
description: 'Pay with CBE Birr',
color: 'bg-blue-50 border-blue-200 hover:border-blue-400'
},
{
id: 'EBIRR',
name: 'eBirr',
icon: Smartphone,
description: 'Pay with eBirr',
color: 'bg-green-50 border-green-200 hover:border-green-400'
},
{
id: 'CARD',
name: 'Card Payment',
icon: CreditCard,
description: 'Pay with credit/debit card',
color: 'bg-purple-50 border-purple-200 hover:border-purple-400'
},
{
id: 'WALLET',
name: 'Wallet',
icon: Wallet,
description: 'Pay from your wallet balance',
color: 'bg-indigo-50 border-indigo-200 hover:border-indigo-400'
},
];
const getIconForMethod = (methodId: string) => {
if (methodId.includes('CARD')) return CreditCard;
if (methodId.includes('WALLET')) return Wallet;
return Smartphone;
};
export default function PaymentPage() {
const router = useRouter();
const { bookingId, pnr, selectedSchedule, passengers } = useBookingStore();
const { selectedCurrency, setPaymentIntent, updateStatus } = usePaymentStore();
const { selectedCurrency, setPaymentIntent, updateStatus } =
usePaymentStore();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery<PaymentMethod[]>({
queryKey: ['paymentMethods'],
queryFn: async () => {
const response = await apiClient.get<PaymentMethod[]>('/payments/methods');
return Array.isArray(response) ? response : [];
},
});
// Calculate total amount
const baseFare = passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0);
const baseFare = passengers.reduce(
(sum) => sum + (selectedSchedule?.baseFareAdult || 0),
0,
);
const totalAmount = baseFare;
const paymentMutation = useMutation({
mutationFn: async (data: any) => {
// Try to call the real API, fallback to mock if it fails
// For TELEBIRR and WAAFI, use the initiate endpoint
if (data.method === 'TELEBIRR' || data.method === 'WAAFI') {
const response = await apiClient.post('/payments/initiate', {
bookingId: data.bookingId,
method: data.method,
paymentMethodId: data.paymentMethodId,
platform: 'web'
});
return response;
}
// For other payment methods, try the regular payment intent API
try {
return await apiClient.post('/payments/intent', data);
return await apiClient.post("/payments/intent", data);
} catch (error) {
console.log('Payment API not available, using mock payment');
console.log("Payment API not available, using mock payment");
// Mock payment response
return {
paymentIntentId: `mock-payment-${Date.now()}`,
status: 'PENDING',
status: "PENDING",
amountMinor: data.amountMinor,
currency: data.currency,
method: data.method,
@@ -76,57 +74,63 @@ export default function PaymentPage() {
}
},
onSuccess: async (data: any) => {
setPaymentIntent(data.paymentIntentId);
updateStatus('PROCESSING');
// Handle TELEBIRR/WAAFI redirect response
if ((selectedMethod === 'TELEBIRR' || selectedMethod === 'WAAFI') && data?.clientAction?.type === 'REDIRECT') {
const redirectUrl = data.clientAction.url;
// Store the intent ID for later verification
setPaymentIntent(data.intentId);
updateStatus("REQUIRES_ACTION");
// Redirect to payment gateway
window.location.href = redirectUrl;
return;
}
setPaymentIntent(data.paymentIntentId || data.intentId);
updateStatus("PROCESSING");
// Simulate payment processing
await new Promise(resolve => setTimeout(resolve, 2000));
await new Promise((resolve) => setTimeout(resolve, 2000));
// Generate tickets after successful payment
try {
await generateTickets();
updateStatus('SUCCEEDED');
router.push('/booking/confirmation');
} catch (error) {
console.error('Ticket generation failed:', error);
// Still proceed to confirmation even if ticket generation fails
updateStatus('SUCCEEDED');
router.push('/booking/confirmation');
}
updateStatus("SUCCEEDED");
router.push("/booking/confirmation");
},
onError: (error: any) => {
console.error('Payment failed:', error);
updateStatus('FAILED');
const errorMessage = error?.response?.data?.message || error?.message || 'Payment failed. Please try again.';
console.error("Payment failed:", error);
updateStatus("FAILED");
const errorMessage =
error?.response?.data?.message ||
error?.message ||
"Payment failed. Please try again.";
alert(errorMessage);
setIsProcessing(false);
},
});
const generateTickets = async () => {
// Try to generate tickets via API, fallback to mock
try {
await apiClient.post('/tickets/generate', {
bookingId,
pnr,
});
} catch (error) {
console.log('Ticket API not available, tickets will be generated on confirmation page');
// Mock ticket generation - tickets will be displayed on confirmation page
}
};
const handlePayment = async () => {
if (!selectedMethod || !bookingId) {
alert('Please select a payment method');
alert("Please select a payment method");
return;
}
setIsProcessing(true);
// Find the selected payment method to get its ID
const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod);
if (!selectedPaymentMethod) {
alert("Invalid payment method selected");
setIsProcessing(false);
return;
}
paymentMutation.mutate({
bookingId,
method: selectedMethod,
paymentMethodId: selectedPaymentMethod.id,
currency: selectedCurrency,
amountMinor: totalAmount,
});
@@ -137,9 +141,11 @@ export default function PaymentPage() {
// Add a small delay to allow state to be set from previous page
const timer = setTimeout(() => {
if (!bookingId || !pnr) {
console.log('Payment page: Missing booking data, redirecting to search');
console.log('bookingId:', bookingId, 'pnr:', pnr);
router.push('/booking/search');
console.log(
"Payment page: Missing booking data, redirecting to search",
);
console.log("bookingId:", bookingId, "pnr:", pnr);
router.push("/booking/search");
}
}, 500);
@@ -161,9 +167,12 @@ export default function PaymentPage() {
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100">Complete payment</h1>
<h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100">
Complete payment
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Booking reference: <span className="font-bold text-primary">{pnr}</span>
Booking reference:{" "}
<span className="font-bold text-primary">{pnr}</span>
</p>
{/* Payment Processing Overlay */}
@@ -173,15 +182,23 @@ export default function PaymentPage() {
{paymentMutation.isSuccess ? (
<>
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Payment successful!</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">Generating your tickets...</p>
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
Payment successful!
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Redirecting to confirmation...
</p>
<Loader2 className="w-8 h-8 text-primary animate-spin mx-auto" />
</>
) : (
<>
<Loader2 className="w-16 h-16 text-primary animate-spin mx-auto mb-4" />
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">Processing payment</h3>
<p className="text-gray-600 dark:text-gray-400">Please wait while we process your payment...</p>
<h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
Processing payment
</h3>
<p className="text-gray-600 dark:text-gray-400">
Please wait while we process your payment...
</p>
</>
)}
</div>
@@ -190,29 +207,46 @@ export default function PaymentPage() {
{/* Order Summary */}
<div className="card mb-6">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Order summary</h2>
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
Order summary
</h2>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Route</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule?.origin} {selectedSchedule?.destination}</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{selectedSchedule?.origin} {selectedSchedule?.destination}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Train</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule?.trainNumber}</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{selectedSchedule?.trainNumber}
</span>
</div>
{selectedSchedule?.selectedSeatClassName && (
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Class</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span>
<span className="text-gray-600 dark:text-gray-400">
Class
</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{selectedSchedule.selectedSeatClassName.replace(/_/g, " ")}
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Passengers</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{passengers.length} passenger{passengers.length !== 1 ? 's' : ''}</span>
<span className="text-gray-600 dark:text-gray-400">
Passengers
</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{passengers.length} passenger
{passengers.length !== 1 ? "s" : ""}
</span>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<div className="flex justify-between text-lg font-bold">
<span className="text-gray-900 dark:text-gray-100">Total amount</span>
<span className="text-gray-900 dark:text-gray-100">
Total amount
</span>
<span className="text-primary dark:text-gray-100">
ETB {(totalAmount / 100).toFixed(2)}
</span>
@@ -223,31 +257,61 @@ export default function PaymentPage() {
{/* Payment Methods */}
<div className="card mb-6">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Select payment method</h2>
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
Select payment method
</h2>
{loadingMethods ? (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 text-primary animate-spin" />
<p className="ml-2 text-gray-600 dark:text-gray-400">Loading payment methods...</p>
</div>
) : error ? (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-800 dark:text-red-200 text-sm">
Failed to load payment methods. Please refresh the page.
</p>
</div>
) : paymentMethods.length === 0 ? (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-yellow-800 dark:text-yellow-200 text-sm">
No payment methods available at the moment.
</p>
</div>
) : (
<div className="space-y-3">
{paymentMethods.map((method) => {
const Icon = method.icon;
const isSelected = selectedMethod === method.id;
const Icon = getIconForMethod(method.type);
const isSelected = selectedMethod === method.type;
return (
<button
key={method.id}
onClick={() => setSelectedMethod(method.id)}
disabled={isProcessing}
onClick={() => setSelectedMethod(method.type)}
disabled={isProcessing || !method.enabled}
className={`w-full p-4 rounded-lg border-2 transition-all text-left ${
isSelected
? 'border-primary bg-primary/10 dark:bg-primary/20 shadow-md'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary dark:hover:border-primary'
} ${isProcessing ? 'opacity-50 cursor-not-allowed' : ''}`}
? "border-primary bg-primary/10 dark:bg-primary/20 shadow-md"
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary dark:hover:border-primary"
} ${isProcessing || !method.enabled ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div className="flex items-center gap-3">
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
isSelected ? 'bg-primary' : 'bg-gray-100 dark:bg-gray-700'
}`}>
<Icon className={`w-6 h-6 ${isSelected ? 'text-white' : 'text-primary'}`} />
<div
className={`w-12 h-12 rounded-lg flex items-center justify-center ${
isSelected
? "bg-primary"
: "bg-gray-100 dark:bg-gray-700"
}`}
>
<Icon
className={`w-6 h-6 ${isSelected ? "text-white" : "text-primary"}`}
/>
</div>
<div className="flex-1">
<p className="font-semibold text-gray-900 dark:text-gray-100">{method.name}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">{method.description}</p>
<p className="font-semibold text-gray-900 dark:text-gray-100">
{method.displayName}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{method.region} · {method.currency}
</p>
</div>
{isSelected && (
<div className="w-6 h-6 bg-primary rounded-full flex items-center justify-center">
@@ -259,6 +323,7 @@ export default function PaymentPage() {
);
})}
</div>
)}
</div>
{/* Action Buttons */}
@@ -267,7 +332,9 @@ export default function PaymentPage() {
onClick={handlePayment}
disabled={!selectedMethod || isProcessing}
className={`btn-primary w-full py-4 text-lg font-semibold ${
!selectedMethod || isProcessing ? 'opacity-50 cursor-not-allowed' : ''
!selectedMethod || isProcessing
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
{isProcessing ? (
@@ -293,7 +360,8 @@ export default function PaymentPage() {
{paymentMutation.isError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-4">
<p className="text-red-800 dark:text-red-200 text-sm font-medium">
Payment failed. Please try again or contact support if the problem persists.
Payment failed. Please try again or contact support if the
problem persists.
</p>
</div>
)}
@@ -301,7 +369,8 @@ export default function PaymentPage() {
{/* Security Notice */}
<div className="mt-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<p className="text-xs text-gray-600 dark:text-gray-400 text-center">
🔒 Your payment is secure and encrypted. We do not store your payment information.
🔒 Your payment is secure and encrypted. We do not store your
payment information.
</p>
</div>
</div>

View File

@@ -0,0 +1,54 @@
'use client';
import { useSearchParams, useRouter } from 'next/navigation';
import { usePaymentStore } from '@/lib/payment-store';
import { useEffect, Suspense } from 'react';
import { XCircle, Loader2, RefreshCw } from 'lucide-react';
function TelebirrFailureContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { updateStatus } = usePaymentStore();
const merchantOrderId = searchParams.get('merchantOrderId') || '';
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
const resultCode = searchParams.get('resultCode') || searchParams.get('code') || '';
const resultMsg = searchParams.get('resultMsg') || searchParams.get('message') || 'Payment was not completed.';
useEffect(() => {
updateStatus('FAILED');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<XCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Failed</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">{resultMsg}</p>
{resultCode && <p className="text-xs text-gray-400 mb-1">Code: {resultCode}</p>}
{merchantOrderId && <p className="text-xs text-gray-400 mb-1">Order ID: {merchantOrderId}</p>}
{trxRef && <p className="text-xs text-gray-400 mb-4">Ref: {trxRef}</p>}
<div className="flex flex-col gap-3 mt-4">
<button onClick={() => router.push('/booking/payment')}
className="btn-primary w-full flex items-center justify-center gap-2">
<RefreshCw className="w-4 h-4" />
Try Again
</button>
<button onClick={() => router.push('/booking/review')}
className="btn-secondary w-full">
Back to Review
</button>
</div>
</div>
</div>
);
}
export default function TelebirrFailurePage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><Loader2 className="w-10 h-10 animate-spin text-primary" /></div>}>
<TelebirrFailureContent />
</Suspense>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store';
import { usePaymentStore } from '@/lib/payment-store';
import { apiClient } from '@/lib/api-client';
import { CheckCircle, Loader2 } from 'lucide-react';
import { Suspense } from 'react';
function TelebirrSuccessContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { bookingId } = useBookingStore();
const { updateStatus } = usePaymentStore();
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
// Telebirr callback query params
const merchantOrderId = searchParams.get('merchantOrderId') || '';
const trxRef = searchParams.get('trxRef') || searchParams.get('outTradeNo') || '';
const bookingIdQp = searchParams.get('bookingId') || bookingId || '';
useEffect(() => {
const confirm = async () => {
try {
if (bookingIdQp) {
await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, {
paymentReference: merchantOrderId || trxRef,
paymentMethod: 'TELEBIRR',
});
}
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
} catch (err: any) {
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
}
};
confirm();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
{status === 'processing' && (
<>
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Confirming payment</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">Please wait while we confirm your Telebirr payment.</p>
</>
)}
{status === 'done' && (
<>
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Successful!</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Your Telebirr payment was received.</p>
{merchantOrderId && <p className="text-xs text-gray-400">Order ID: {merchantOrderId}</p>}
{trxRef && <p className="text-xs text-gray-400">Transaction Ref: {trxRef}</p>}
<p className="text-xs text-gray-400 mt-3">Redirecting to your booking confirmation</p>
</>
)}
{status === 'error' && (
<>
<div className="w-14 h-14 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<span className="text-3xl"></span>
</div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Something went wrong</h1>
<p className="text-sm text-red-500 mb-4">Unable to confirm payment</p>
<button onClick={() => router.push('/booking/confirmation')}
className="btn-primary w-full">Go to confirmation</button>
</>
)}
</div>
</div>
);
}
export default function TelebirrSuccessPage() {
return <Suspense fallback={<div className="min-h-screen flex items-center justify-center"><Loader2 className="w-10 h-10 animate-spin text-primary" /></div>}><TelebirrSuccessContent /></Suspense>;
}

View File

@@ -0,0 +1,57 @@
'use client';
import { useSearchParams, useRouter } from 'next/navigation';
import { usePaymentStore } from '@/lib/payment-store';
import { useEffect, Suspense } from 'react';
import { XCircle, Loader2, RefreshCw } from 'lucide-react';
function WaafiFailureContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { updateStatus } = usePaymentStore();
const referenceId = searchParams.get('referenceId') || '';
const responseCode = searchParams.get('responseCode') || '';
const responseMsg = searchParams.get('responseMsg') || 'Payment was not completed.';
const transactionId = searchParams.get('transactionId') || '';
const state = searchParams.get('state') || '';
useEffect(() => {
updateStatus('FAILED');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<XCircle className="w-14 h-14 text-red-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Failed</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">{responseMsg}</p>
{responseCode && <p className="text-xs text-gray-400 mb-1">Code: {responseCode}</p>}
{state && <p className="text-xs text-gray-400 mb-1">State: {state}</p>}
{(referenceId || transactionId) && (
<p className="text-xs text-gray-400 mb-4">Ref: {referenceId || transactionId}</p>
)}
<div className="flex flex-col gap-3 mt-4">
<button onClick={() => router.push('/booking/payment')}
className="btn-primary w-full flex items-center justify-center gap-2">
<RefreshCw className="w-4 h-4" />
Try Again
</button>
<button onClick={() => router.push('/booking/review')}
className="btn-secondary w-full">
Back to Review
</button>
</div>
</div>
</div>
);
}
export default function WaafiFailurePage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><Loader2 className="w-10 h-10 animate-spin text-primary" /></div>}>
<WaafiFailureContent />
</Suspense>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import { useEffect, useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store';
import { usePaymentStore } from '@/lib/payment-store';
import { apiClient } from '@/lib/api-client';
import { CheckCircle, Loader2 } from 'lucide-react';
function WaafiSuccessContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { bookingId } = useBookingStore();
const { updateStatus } = usePaymentStore();
const [status, setStatus] = useState<'processing' | 'done' | 'error'>('processing');
// Waafi callback query params
const accountNo = searchParams.get('accountNo') || '';
const currency = searchParams.get('currency') || '';
const referenceId = searchParams.get('referenceId') || '';
const state = searchParams.get('state') || '';
const transactionId = searchParams.get('transactionId') || '';
const txAmount = searchParams.get('txAmount') || '';
const timestamp = searchParams.get('timestamp') || '';
const bookingIdQp = searchParams.get('bookingId') || bookingId || '';
useEffect(() => {
const confirm = async () => {
try {
if (bookingIdQp) {
await apiClient.patch(`/bookings/${bookingIdQp}/confirm`, {
paymentReference: referenceId || transactionId,
paymentMethod: 'WAAFI',
transactionDetails: {
transactionId,
accountNo,
amount: txAmount,
currency,
state,
timestamp,
},
});
}
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
} catch (err: any) {
updateStatus('SUCCEEDED');
setStatus('done');
setTimeout(() => router.push('/booking/confirmation'), 1500);
}
};
confirm();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
{status === 'processing' && (
<>
<Loader2 className="w-14 h-14 text-primary animate-spin mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Confirming payment</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">Please wait while we confirm your Waafi payment.</p>
</>
)}
{status === 'done' && (
<>
<CheckCircle className="w-14 h-14 text-green-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Payment Successful!</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Your Waafi payment was received.</p>
{transactionId && <p className="text-xs text-gray-400">Transaction ID: {transactionId}</p>}
{referenceId && <p className="text-xs text-gray-400">Reference: {referenceId}</p>}
{txAmount && currency && (
<p className="text-xs text-gray-400">Amount: {txAmount} {currency}</p>
)}
<p className="text-xs text-gray-400 mt-3">Redirecting to your booking confirmation</p>
</>
)}
</div>
</div>
);
}
export default function WaafiSuccessPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><Loader2 className="w-10 h-10 animate-spin text-primary" /></div>}>
<WaafiSuccessContent />
</Suspense>
);
}

View File

@@ -10,7 +10,7 @@ import { apiClient } from '@/lib/api-client';
import { useBookingStore } from '@/lib/booking-store';
import { Station } from '@/types';
import {
Train, MapPin, ArrowRight, ArrowLeftRight, Plus, Minus, Search,
MapPin, ArrowRight, ArrowLeftRight, Plus, Minus, Search,
Users, ChevronDown, Gift, Check, X, ChevronLeft, Clock, Zap,
} from 'lucide-react';
import { useEffect, useRef, useState, useCallback } from 'react';
@@ -169,14 +169,18 @@ function StationModal({
function PassengerModal({
adultCount,
childCount,
nationality,
onChangeAdult,
onChangeChild,
onChangeNationality,
onClose,
}: {
adultCount: number;
childCount: number;
nationality: string;
onChangeAdult: (n: number) => void;
onChangeChild: (n: number) => void;
onChangeNationality: (v: string) => void;
onClose: () => void;
}) {
const rows = [
@@ -184,30 +188,31 @@ function PassengerModal({
{ label: 'Children', sub: '< 5 years • First child free', val: childCount, min: 0, max: 9, onChange: onChangeChild },
];
const natOptions = [
{ value: 'ETHIOPIAN', label: '🇪🇹 Ethiopian' },
{ value: 'DJIBOUTIAN', label: '🇩🇯 Djiboutian' },
{ value: 'OTHER', label: '🌍 Other' },
];
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[99] bg-black/40" onClick={onClose} />
{/* Bottom sheet */}
<div
className="fixed inset-x-0 bottom-0 z-[100] bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl"
className="fixed inset-x-0 bottom-0 sm:inset-auto sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2 z-[100] bg-white dark:bg-gray-900 rounded-t-2xl sm:rounded-2xl shadow-2xl w-full sm:w-96"
style={{ animation: 'pax-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)' }}
>
{/* Handle */}
<div className="flex justify-center pt-3 pb-1">
<div className="w-10 h-1 rounded-full bg-gray-300 dark:bg-gray-600" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100 dark:border-gray-800">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-primary" />
<h2 className="text-base font-bold text-gray-900 dark:text-white">Passengers</h2>
<h2 className="text-base font-bold text-gray-900 dark:text-white">Passengers &amp; Nationality</h2>
</div>
<button type="button" onClick={onClose} className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<button type="button" onClick={onClose} className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800">
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Rows */}
<div className="px-5 py-4 space-y-5">
{rows.map(({ label, sub, val, min, max, onChange }, i) => (
<div key={label}>
@@ -218,35 +223,38 @@ function PassengerModal({
<p className="text-xs text-gray-400 mt-0.5">{sub}</p>
</div>
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => val > min && onChange(val - 1)}
disabled={val <= min}
className="w-10 h-10 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center hover:border-primary hover:text-primary transition-colors disabled:opacity-30"
>
<button type="button" onClick={() => val > min && onChange(val - 1)} disabled={val <= min}
className="w-10 h-10 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center hover:border-primary hover:text-primary disabled:opacity-30">
<Minus className="w-4 h-4" />
</button>
<span className="w-6 text-center text-lg font-bold text-gray-900 dark:text-white tabular-nums">{val}</span>
<button
type="button"
onClick={() => val < max && onChange(val + 1)}
disabled={val >= max}
className="w-10 h-10 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center hover:border-primary hover:text-primary transition-colors disabled:opacity-30"
>
<button type="button" onClick={() => val < max && onChange(val + 1)} disabled={val >= max}
className="w-10 h-10 rounded-full border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center hover:border-primary hover:text-primary disabled:opacity-30">
<Plus className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
<div className="border-t border-gray-100 dark:border-gray-800 -mx-5 pt-5 px-5">
<p className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Nationality</p>
<div className="grid grid-cols-3 gap-2">
{natOptions.map((opt) => (
<button key={opt.value} type="button" onClick={() => onChangeNationality(opt.value)}
className={`py-2.5 px-2 rounded-xl border-2 text-xs font-semibold transition-all ${
nationality === opt.value
? 'border-primary bg-primary/5 text-primary'
: 'border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300'
}`}>
{opt.label}
</button>
))}
</div>
</div>
</div>
{/* Done */}
<div className="px-5 pb-8 pt-2">
<button
type="button"
onClick={onClose}
className="w-full py-3.5 bg-[rgb(20,113,76)] text-white font-bold text-sm rounded-xl"
>
<button type="button" onClick={onClose}
className="w-full py-3.5 bg-[rgb(20,113,76)] text-white font-bold text-sm rounded-xl">
Done {adultCount + childCount} Passenger{adultCount + childCount !== 1 ? 's' : ''}
</button>
</div>
@@ -265,6 +273,7 @@ function StationDropdown({
onSelect,
error,
recentIds,
onOpen,
}: {
stations: Station[];
value: string;
@@ -273,6 +282,7 @@ function StationDropdown({
onSelect: (s: Station) => void;
error?: string;
recentIds: string[];
onOpen?: () => void;
}) {
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
@@ -315,7 +325,7 @@ function StationDropdown({
ref={inputRef}
value={displayValue}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => { setQuery(''); setOpen(true); }}
onFocus={() => { setQuery(''); setOpen(true); onOpen?.(); }}
placeholder={placeholder}
className="w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm text-gray-900 dark:text-white placeholder-gray-400"
/>
@@ -331,7 +341,7 @@ function StationDropdown({
</div>
{open && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-50 max-h-56 overflow-y-auto overflow-x-hidden scrollbar-hide">
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-[200] max-h-56 overflow-y-auto overflow-x-hidden scrollbar-hide">
{!query && recentIds.length > 0 && (
<div className="px-3 pt-2 pb-1">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide mb-1">Recent</p>
@@ -383,7 +393,6 @@ export default function SearchPage() {
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
const { user, isAuthenticated } = useAuthStore();
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
const [passengerModalOpen, setPassengerModalOpen] = useState(false);
const [promoVisible, setPromoVisible] = useState(false);
const [promoCode, setPromoCode] = useState('');
@@ -395,13 +404,23 @@ export default function SearchPage() {
try { return JSON.parse(localStorage.getItem('edr_recent_stations') || '[]'); } catch { return []; }
});
const passengerRef = useRef<HTMLDivElement>(null);
const widgetRef = useRef<HTMLDivElement>(null);
const scrollWidgetIntoView = () => {
const el = widgetRef.current;
if (!el) return;
const headerHeight = 64;
const marginTop = 24;
const top = el.getBoundingClientRect().top + window.scrollY - headerHeight - marginTop;
window.scrollTo({ top, behavior: 'smooth' });
};
const { data: stations = [], isLoading, error } = useQuery<Station[]>({
queryKey: ['stations'],
queryFn: async () => await apiClient.get('/stations') as Station[],
});
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
const { handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
resolver: zodResolver(searchSchema as any),
defaultValues: {
adultCount: 1,
@@ -437,7 +456,7 @@ export default function SearchPage() {
useEffect(() => {
const handler = (e: MouseEvent) => {
if (passengerRef.current && !passengerRef.current.contains(e.target as Node)) {
setIsPassengerOpen(false);
setPassengerModalOpen(false);
}
};
document.addEventListener('mousedown', handler);
@@ -517,14 +536,16 @@ export default function SearchPage() {
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="bg-gray-50 dark:bg-gray-950">
{/* Passenger modal (mobile) */}
{passengerModalOpen && (
<PassengerModal
adultCount={adultCount || 1}
childCount={childCount || 0}
nationality={watch('nationality')}
onChangeAdult={(n) => setValue('adultCount', n)}
onChangeChild={(n) => setValue('childCount', n)}
onChangeNationality={(v) => setValue('nationality', v as any)}
onClose={() => setPassengerModalOpen(false)}
/>
)}
@@ -557,268 +578,187 @@ export default function SearchPage() {
/>
)}
{/* Hero Banner */}
<div className="relative bg-gradient-to-br from-[rgb(14,80,54)] via-[rgb(20,113,76)] to-[rgb(16,140,90)] overflow-hidden">
<div className="absolute inset-0 opacity-10">
<div className="absolute top-4 right-8 w-32 h-32 border-2 border-white rounded-full" />
<div className="absolute top-12 right-20 w-20 h-20 border border-white rounded-full" />
<div className="absolute -bottom-6 left-10 w-40 h-40 border border-white rounded-full" />
</div>
<div className="container mx-auto px-4 pt-8 pb-20 md:pt-10 md:pb-24 relative z-10">
<div className="max-w-6xl mx-auto">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white leading-tight">
Where are you headed?
{/* ── 90vh hero with banner image ── */}
<section
className="relative"
style={{ height: '90vh', minHeight: '560px' }}
>
{/* Background image */}
<div
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: 'url(/banner.jpg)' }}
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-black/70 via-black/40 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
{/* Hero headline — top area */}
<div className="relative z-10 pt-16 md:pt-20 px-6 md:px-12 max-w-6xl mx-auto">
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white leading-tight drop-shadow-lg max-w-2xl">
Where are you<br className="hidden sm:block" /> headed today?
</h1>
<p className="text-white/70 text-sm md:text-base mt-2">Search and book train tickets fast &amp; easy</p>
</div>
</div>
<p className="text-white/60 text-sm md:text-base mt-3">Book your train journey across East Africa</p>
</div>
{/* Search Card — pulled up over the hero */}
<div className="container mx-auto px-4 -mt-14 relative z-20 pb-8">
{/* ── Widget — absolutely positioned at bottom with margin ── */}
<div className="absolute bottom-8 left-0 right-0 z-[50] px-4 md:px-6" ref={widgetRef}>
<div className="max-w-6xl mx-auto">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-100 dark:border-gray-800 overflow-visible">
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-white/20 overflow-visible">
{/* Error banner */}
{error && (
<div className="flex items-center gap-2 px-5 py-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm border-b border-red-100 dark:border-red-900/30 rounded-t-2xl">
<div className="flex items-center gap-2 px-5 py-3 bg-red-50 text-red-600 text-sm border-b border-red-100 rounded-t-2xl">
<span></span>
<span>Unable to load stations. Please check your connection.</span>
</div>
)}
<div className="p-5 md:p-6 space-y-3">
<div className="p-4 md:p-5">
{/* ── Row 1 (mobile stacked): Stations + Date ── */}
{/* Mobile station fields */}
{/* Mobile: stacked */}
<div className="flex flex-col gap-3 md:hidden">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">From</label>
<button type="button" onClick={() => setStationModal('origin')} className="w-full">
<div className={`flex items-center gap-2.5 px-3.5 py-3.5 border-2 rounded-xl transition-all ${
errors.originStationId ? 'border-red-400' : originId ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-700'
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
<button type="button" onClick={() => { window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior }); setStationModal('origin'); }} className="w-full">
<div className={`flex items-center gap-2.5 px-3.5 py-3 border-2 rounded-xl transition-all ${
errors.originStationId ? 'border-red-400' : originId ? 'border-primary bg-primary/5' : 'border-gray-200'
}`}>
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<span className={`text-sm ${originStation ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-400'}`}>
<span className={`text-sm ${originStation ? 'font-semibold text-gray-900' : 'text-gray-400'}`}>
{originStation?.name ?? 'Select departure'}
</span>
</div>
</button>
{errors.originStationId && <p className="text-xs text-red-500">{errors.originStationId.message}</p>}
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">To</label>
<button type="button" onClick={handleSwap} disabled={!originId || !destId} className="flex items-center gap-1 text-xs text-primary font-medium disabled:opacity-30">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
<button type="button" onClick={handleSwap} disabled={!originId || !destId}
className="flex items-center gap-1 text-xs text-primary font-medium disabled:opacity-30">
<ArrowLeftRight className={`w-3.5 h-3.5 transition-transform duration-300 ${swapping ? 'rotate-180' : ''}`} />
Swap
</button>
</div>
<button type="button" onClick={() => setStationModal('destination')} className="w-full">
<div className={`flex items-center gap-2.5 px-3.5 py-3.5 border-2 rounded-xl transition-all ${
errors.destinationStationId ? 'border-red-400' : destId ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-700'
<button type="button" onClick={() => { window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior }); setStationModal('destination'); }} className="w-full">
<div className={`flex items-center gap-2.5 px-3.5 py-3 border-2 rounded-xl transition-all ${
errors.destinationStationId ? 'border-red-400' : destId ? 'border-primary bg-primary/5' : 'border-gray-200'
}`}>
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<span className={`text-sm ${destStation ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-400'}`}>
<span className={`text-sm ${destStation ? 'font-semibold text-gray-900' : 'text-gray-400'}`}>
{destStation?.name ?? 'Select destination'}
</span>
</div>
</button>
{errors.destinationStationId && <p className="text-xs text-red-500">{errors.destinationStationId.message}</p>}
</div>
{/* Date (mobile) */}
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Departure Date</label>
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Date</label>
<div className="relative z-30">
<ModernDatePicker
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
minDate={new Date()}
placeholder="Select date"
minDate={new Date()} placeholder="Select date"
/>
</div>
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</p>}
</div>
</div>
{/* Desktop Row 1: From [swap] To + Date (3 equal cols) */}
<div className="hidden md:grid md:grid-cols-3 gap-3 items-end">
{/* From + To with swap */}
<div className="col-span-2 flex items-end gap-2">
<div className="flex-1 space-y-1.5">
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">From</label>
<StationDropdown stations={stations} value={originId} excludeId={destId} placeholder="Select departure" recentIds={recentStationIds} onSelect={(s) => { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} />
{errors.originStationId && <p className="text-xs text-red-500">{errors.originStationId.message}</p>}
</div>
<button
type="button"
onClick={handleSwap}
disabled={!originId || !destId}
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all duration-200 disabled:opacity-30 ${swapping ? 'rotate-180' : ''}`}
title="Swap stations"
>
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
</button>
<div className="flex-1 space-y-1.5">
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">To</label>
<StationDropdown stations={stations} value={destId} excludeId={originId} placeholder="Select destination" recentIds={recentStationIds} onSelect={(s) => { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} />
{errors.destinationStationId && <p className="text-xs text-red-500">{errors.destinationStationId.message}</p>}
</div>
</div>
{/* Date */}
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Departure Date</label>
<div className="relative z-30">
<ModernDatePicker
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
minDate={new Date()}
placeholder="Select date"
/>
</div>
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</p>}
</div>
</div>
{/* Route preview pill */}
{originStation && destStation && (
<div className="flex items-center gap-2 px-3 py-2 bg-primary/5 dark:bg-primary/10 rounded-lg text-xs text-primary font-medium animate-bounce-in">
<Train className="w-3.5 h-3.5" />
<span>{originStation.name}</span>
<ArrowRight className="w-3 h-3" />
<span>{destStation.name}</span>
</div>
)}
{/* ── Row 2: Passengers | Nationality | Search Button ── */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 items-end">
{/* Passengers */}
<div className="space-y-1.5" ref={passengerRef}>
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Passengers</label>
{/* Mobile: opens bottom-sheet modal */}
<button
type="button"
onClick={() => setPassengerModalOpen(true)}
className="sm:hidden w-full flex items-center justify-between px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800"
>
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
{/* Pax + Nationality combined trigger */}
<button type="button" onClick={() => setPassengerModalOpen(true)}
className="w-full flex items-center justify-between px-3.5 py-3 border-2 border-gray-200 rounded-xl bg-white">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900">
<Users className="w-4 h-4 text-primary" />
{totalPassengers} Passenger{totalPassengers !== 1 ? 's' : ''}
{childCount > 0 && <span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded-md">{childCount} child</span>}
{totalPassengers} Pax · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
</span>
<ChevronDown className="w-4 h-4 text-primary" />
</button>
{/* sm+: inline dropdown */}
<div className="hidden sm:block relative z-20">
<button
type="button"
onClick={() => setIsPassengerOpen(!isPassengerOpen)}
className={`w-full flex items-center justify-between px-3.5 py-3.5 border-2 rounded-xl transition-all duration-200 bg-white dark:bg-gray-800 ${
isPassengerOpen ? 'border-primary ring-2 ring-primary/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'
}`}
>
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<Users className="w-4 h-4 text-primary" />
{totalPassengers} Pax
{childCount > 0 && <span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded-md">{childCount} child</span>}
</span>
<ChevronDown className={`w-4 h-4 text-primary transition-transform duration-200 ${isPassengerOpen ? 'rotate-180' : ''}`} />
</button>
{isPassengerOpen && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-50 p-4 space-y-4">
{[
{ label: 'Adults', sub: '≥ 5 years', key: 'adultCount' as const, val: adultCount || 1, min: 1, max: 9 },
{ label: 'Children', sub: '< 5 years • First free', key: 'childCount' as const, val: childCount || 0, min: 0, max: 9 },
].map(({ label, sub, key, val, min, max }, i) => (
<div key={key}>
{i > 0 && <div className="border-t border-gray-100 dark:border-gray-700 -mx-4 mb-4" />}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
<p className="text-xs text-gray-400 mt-0.5">{sub}</p>
</div>
<div className="flex items-center gap-3">
<button type="button" onClick={() => val > min && setValue(key, val - 1)} disabled={val <= min} className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-600 flex items-center justify-center hover:border-primary hover:text-primary transition-colors disabled:opacity-30"><Minus className="w-3.5 h-3.5" /></button>
<span className="w-6 text-center font-bold text-gray-900 dark:text-white tabular-nums">{val}</span>
<button type="button" onClick={() => val < max && setValue(key, val + 1)} disabled={val >= max} className="w-8 h-8 rounded-full border-2 border-gray-200 dark:border-gray-600 flex items-center justify-center hover:border-primary hover:text-primary transition-colors disabled:opacity-30"><Plus className="w-3.5 h-3.5" /></button>
</div>
</div>
</div>
))}
<button type="button" onClick={() => setIsPassengerOpen(false)} className="w-full py-2.5 bg-primary text-white text-sm font-semibold rounded-lg">Done</button>
</div>
)}
</div>
</div>
{/* Nationality */}
<div className="space-y-1.5">
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Nationality</label>
<select
{...register('nationality')}
className="w-full px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white transition-all"
>
<option value="ETHIOPIAN">🇪🇹 Ethiopian</option>
<option value="DJIBOUTIAN">🇩🇯 Djiboutian</option>
<option value="OTHER">🌍 Other</option>
</select>
</div>
{/* Search Button */}
<button
type="submit"
disabled={isLoading}
className="w-full flex items-center justify-center gap-2.5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
<button type="submit" disabled={isLoading}
className="w-full flex items-center justify-center gap-2 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg disabled:opacity-50">
<Search className="w-5 h-5" />
Search
</button>
</div>
{/* ── Promo Code (collapsed by default) ── */}
<div>
{/* Desktop: single row — From [swap] To | Date | Pax+Nat | Search */}
<div className="hidden md:flex items-end gap-2">
{/* From */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
<StationDropdown stations={stations} value={originId} excludeId={destId} placeholder="Departure station"
recentIds={recentStationIds} onSelect={(s) => { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} />
{errors.originStationId && <p className="text-xs text-red-500">{errors.originStationId.message}</p>}
</div>
{/* Swap */}
<button type="button" onClick={handleSwap} disabled={!originId || !destId}
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? 'rotate-180' : ''}`}>
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
</button>
{/* To */}
<div className="flex-1 min-w-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
<StationDropdown stations={stations} value={destId} excludeId={originId} placeholder="Destination station"
recentIds={recentStationIds} onSelect={(s) => { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} />
{errors.destinationStationId && <p className="text-xs text-red-500">{errors.destinationStationId.message}</p>}
</div>
{/* Divider */}
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
{/* Date */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Date</label>
<div className="relative z-30">
<ModernDatePicker
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
onChange={(date) => setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)}
minDate={new Date()} placeholder="Select date"
/>
</div>
{errors.departureDate && <p className="text-xs text-red-500">{errors.departureDate.message}</p>}
</div>
{/* Divider */}
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
{/* Pax + Nationality combined — opens shared modal */}
<div className="w-44 flex-shrink-0 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Passengers</label>
<button type="button" onClick={() => setPassengerModalOpen(true)}
className="w-full flex items-center justify-between px-3 py-3.5 border-2 border-gray-200 rounded-xl bg-white hover:border-gray-300 transition-all">
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 truncate">
<Users className="w-4 h-4 text-primary flex-shrink-0" />
{totalPassengers} Pax · {watch('nationality') === 'ETHIOPIAN' ? '🇪🇹' : watch('nationality') === 'DJIBOUTIAN' ? '🇩🇯' : '🌍'}
</span>
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
</button>
</div>
{/* Search */}
<button type="submit" disabled={isLoading}
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50">
<Search className="w-5 h-5" />
Search
</button>
</div>
{/* Promo */}
<div className="mt-3">
{!promoVisible ? (
<button
type="button"
onClick={() => setPromoVisible(true)}
className="flex items-center gap-1.5 text-xs text-primary font-medium hover:underline transition-colors"
>
<button type="button" onClick={() => setPromoVisible(true)}
className="flex items-center gap-1.5 text-xs text-primary font-medium hover:underline">
<Gift className="w-3.5 h-3.5" />
Apply Promo Code
</button>
) : (
<div className="flex flex-col gap-1.5 animate-bounce-in">
<div className="flex flex-col gap-1.5">
<div className="flex gap-2">
<div className="flex-1 relative">
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
<input
type="text"
value={promoCode}
<input type="text" value={promoCode}
onChange={(e) => { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }}
placeholder="Enter promo code"
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())}
className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 transition-all"
autoFocus
/>
className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400"
autoFocus />
</div>
<button
type="button"
onClick={handleValidatePromo}
disabled={!promoCode || promoLoading}
className="px-4 py-2.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-40 text-sm font-semibold"
>
<button type="button" onClick={handleValidatePromo} disabled={!promoCode || promoLoading}
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 disabled:opacity-40 text-sm font-semibold">
{promoLoading ? '...' : 'Apply'}
</button>
<button
type="button"
onClick={() => { setPromoVisible(false); setPromoCode(''); setPromoValidation(null); }}
className="p-2.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<button type="button" onClick={() => { setPromoVisible(false); setPromoCode(''); setPromoValidation(null); }}
className="p-2.5 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100">
<X className="w-4 h-4" />
</button>
</div>
@@ -835,21 +775,22 @@ export default function SearchPage() {
</div>
</div>
</form>
</div>
</div>
</section>
{/* ── Popular Routes ── */}
<div className="mt-8">
{/* Popular Routes — below hero */}
<div className="bg-gray-50 dark:bg-gray-950 py-10">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="flex items-center gap-2 mb-4">
<Zap className="w-4 h-4 text-primary" />
<h2 className="text-base font-bold text-gray-900 dark:text-white">Popular Routes</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{POPULAR_ROUTES.map((route, idx) => (
<button
key={idx}
type="button"
onClick={() => handlePopularRoute(route.from, route.to)}
className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4 hover:border-primary hover:shadow-md transition-all text-left group active:scale-95"
>
<button key={idx} type="button" onClick={() => handlePopularRoute(route.from, route.to)}
className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4 hover:border-primary hover:shadow-md transition-all text-left active:scale-95">
<div className="text-xl mb-2">{route.icon}</div>
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-900 dark:text-white">
<span>{route.from}</span>
@@ -867,14 +808,92 @@ export default function SearchPage() {
</div>
</div>
{/* Promotions Section */}
<div className="bg-white dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="flex items-center gap-2 mb-6">
<span className="text-lg">🎁</span>
<h2 className="text-base font-bold text-gray-900 dark:text-white">Offers &amp; Promotions</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{/* Wide promo card */}
<div className="md:col-span-2 relative rounded-2xl overflow-hidden min-h-[220px] group cursor-pointer">
<div className="absolute inset-0 bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)]" />
<div className="absolute inset-0 opacity-10" style={{
backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 20px, rgba(255,255,255,0.3) 20px, rgba(255,255,255,0.3) 21px)'
}} />
<div className="absolute inset-0 bg-gradient-to-r from-black/30 to-transparent" />
<div className="relative z-10 p-7 flex flex-col justify-between h-full min-h-[220px]">
<div>
<span className="inline-block px-3 py-1 bg-white/20 text-white text-xs font-semibold rounded-full mb-3 backdrop-blur-sm">
Limited Time
</span>
<h3 className="text-2xl font-extrabold text-white leading-tight mb-2">
20% Off Weekend<br />Travel
</h3>
<p className="text-white/70 text-sm max-w-xs">
Book any weekend journey and save 20%. Valid for all seat classes.
</p>
</div>
<div className="flex items-center justify-between mt-4">
<span className="text-white/60 text-xs">Valid until 31 Dec 2024</span>
<span className="flex items-center gap-1.5 text-white text-sm font-semibold group-hover:gap-3 transition-all">
Book now <ArrowRight className="w-4 h-4" />
</span>
</div>
</div>
</div>
{/* Narrow promo cards */}
<div className="flex flex-col gap-5">
<div className="relative rounded-2xl overflow-hidden min-h-[100px] group cursor-pointer">
<div className="absolute inset-0 bg-gradient-to-br from-amber-500 to-orange-600" />
<div className="absolute inset-0 bg-gradient-to-r from-black/20 to-transparent" />
<div className="relative z-10 p-5 flex flex-col justify-between h-full min-h-[100px]">
<div>
<span className="inline-block px-2.5 py-0.5 bg-white/25 text-white text-xs font-semibold rounded-full mb-2 backdrop-blur-sm">New</span>
<h3 className="text-lg font-bold text-white leading-tight">Family Package</h3>
<p className="text-white/75 text-xs mt-1">4 tickets for the price of 3</p>
</div>
<div className="flex items-center justify-end mt-3">
<span className="flex items-center gap-1 text-white text-xs font-semibold group-hover:gap-2 transition-all">
Learn more <ArrowRight className="w-3.5 h-3.5" />
</span>
</div>
</div>
</div>
<div className="relative rounded-2xl overflow-hidden min-h-[100px] group cursor-pointer">
<div className="absolute inset-0 bg-gradient-to-br from-blue-600 to-indigo-700" />
<div className="absolute inset-0 bg-gradient-to-r from-black/20 to-transparent" />
<div className="relative z-10 p-5 flex flex-col justify-between h-full min-h-[100px]">
<div>
<span className="inline-block px-2.5 py-0.5 bg-white/25 text-white text-xs font-semibold rounded-full mb-2 backdrop-blur-sm">Student</span>
<h3 className="text-lg font-bold text-white leading-tight">Student Discount</h3>
<p className="text-white/75 text-xs mt-1">15% off with valid student ID</p>
</div>
<div className="flex items-center justify-end mt-3">
<span className="flex items-center gap-1 text-white text-xs font-semibold group-hover:gap-2 transition-all">
Learn more <ArrowRight className="w-3.5 h-3.5" />
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style jsx>{`
@keyframes slide-up {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.animate-slide-up {
animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1);
}
.animate-slide-up { animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1); }
`}</style>
</div>
);

View File

@@ -1,438 +1,10 @@
'use client';
import { useEffect, useState } from 'react';
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
import Link from 'next/link';
import { SearchWidget } from '@/components/SearchWidget';
import { Zap, Heart, Shield, Clock, ArrowRight, Train, MapPin, Calendar } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { Station } from '@/types';
const styles = `
.hero-section {
position: relative;
overflow: hidden;
background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
padding: 80px 20px;
text-align: center;
color: #111827;
}
.dark .hero-section {
background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
color: #f3f4f6;
}
.hero-content {
max-width: 100%;
margin: 0 auto;
padding: 0 20px;
}
.hero-heading {
font-size: clamp(1rem, 3vw, 2.75rem);
font-weight: 700;
margin-bottom: 24px;
color: #ffffff;
line-height: 1.2;
}
.dark .hero-heading {
color: #ffffff;
}
.hero-subheading {
font-size: clamp(1rem, 3vw, 1.5rem);
color: #4b5563;
margin-bottom: 32px;
}
.dark .hero-subheading {
color: #9ca3af;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding: 32px 16px;
border-top: 1px solid #e5e7eb;
border-bottom: 1px solid #e5e7eb;
margin-top: 48px;
}
.dark .stats-grid {
border-top-color: #374151;
border-bottom-color: #374151;
}
.stat-item {
text-align: center;
}
.stat-number {
font-size: 2rem;
font-weight: 700;
color: rgb(20, 113, 76);
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
}
.dark .stat-label {
color: #9ca3af;
}
.features-section {
padding: 80px 20px;
background-color: #ffffff;
}
.dark .features-section {
background-color: #111827;
}
.section-title {
text-align: center;
font-size: 2rem;
font-weight: 700;
margin-bottom: 40px;
color: #111827;
}
.dark .section-title {
color: #f3f4f6;
}
.features-grid {
max-width: 72rem;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
}
.feature-card {
background-color: white;
border: 2px solid #f3f4f6;
border-radius: 18px;
padding: 24px;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
text-align: left;
}
.dark .feature-card {
background-color: #1f2937;
border-color: #374151;
}
.feature-card:hover {
border-color: rgb(20, 113, 76);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
transform: translateY(-8px);
}
.feature-icon-bg {
width: 48px;
height: 48px;
background-color: rgb(20, 113, 76);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.feature-title {
font-weight: 700;
color: #111827;
margin-bottom: 8px;
font-size: 1.125rem;
}
.dark .feature-title {
color: #f3f4f6;
}
.feature-desc {
font-size: 0.875rem;
color: #6b7280;
}
.dark .feature-desc {
color: #9ca3af;
}
.cta-section {
padding: 80px 20px;
background: #f3f4f6;
text-align: center;
}
.dark .cta-section {
background: #111827;
}
.cta-content {
max-width: 42rem;
margin: 0 auto;
}
.cta-title {
font-size: 2rem;
font-weight: 700;
margin-bottom: 24px;
color: #111827;
}
.dark .cta-title {
color: white;
}
.cta-text {
font-size: 1.125rem;
color: #4b5563;
margin-bottom: 32px;
}
.dark .cta-text {
color: #e0e7ff;
}
.cta-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 16px 32px;
background-color: white;
color: rgb(20 113 76 / var(--tw-bg-opacity, 1));
font-weight: 700;
border-radius: 12px;
border: none;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
text-decoration: none;
}
.cta-button:hover {
background-color: #f0f9ff;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15);
transform: scale(1.05);
}
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.bounce {
animation: bounce 2s infinite;
}
.bounce:nth-child(2) {
animation-delay: 0.2s;
}
.bounce:nth-child(3) {
animation-delay: 0.4s;
}
.search-widget-transparent {
background-color: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(10px);
border-color: rgba(255, 255, 255, 0.2) !important;
}
.dark .search-widget-transparent {
background-color: rgba(31, 41, 55, 0.95) !important;
border-color: rgba(55, 65, 81, 0.2) !important;
}
`;
import { Suspense } from 'react';
import SearchPage from '@/app/booking/search/page';
export default function Home() {
const [lang, setLang] = useState<Language>('en');
const { getLang } = useLanguage();
const t = (key: string) => getTranslation(lang, key);
useEffect(() => {
setLang(getLang());
const handleLanguageChange = (e: any) => setLang(e.detail);
window.addEventListener('languageChange', handleLanguageChange);
return () => window.removeEventListener('languageChange', handleLanguageChange);
}, [getLang]);
const { data: stations } = useQuery<Station[]>({
queryKey: ['stations'],
queryFn: async () => await apiClient.get('/stations') as Station[],
});
const getStationByName = (name: string) => {
if (!stations) return null;
const exactMatch = stations.find(s => s.name.toLowerCase() === name.toLowerCase());
if (exactMatch) return exactMatch;
return stations.find(s => s.name.toLowerCase().includes(name.toLowerCase()));
};
const handlePopularRoute = (fromName: string, toName: string) => {
const origin = getStationByName(fromName);
const destination = getStationByName(toName);
if (origin && destination) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const popularRoutes = [
{ from: 'Sebeta', to: 'Nagad', duration: '12h' },
{ from: 'Sebeta', to: 'Diredawa', duration: '8h' },
{ from: 'Diredawa', to: 'Nagad', duration: '4h' },
];
const features = [
{
icon: Heart,
title: t('home.comfortable'),
desc: t('home.comfortDesc'),
},
{
icon: Zap,
title: t('home.affordable'),
desc: t('home.affordableDesc'),
},
{
icon: Shield,
title: t('home.safe'),
desc: t('home.safeDesc'),
},
{
icon: Clock,
title: t('home.fast'),
desc: t('home.fastDesc'),
},
];
const highlights = [
{
icon: Train,
title: 'Modern fleet',
desc: 'Comfortable trains with modern amenities',
},
{
icon: MapPin,
title: '21 stations',
desc: 'Connecting Ethiopia and Djibouti',
},
{
icon: Calendar,
title: 'Easy booking',
desc: 'Book tickets in just a few clicks',
},
];
return (
<>
<style>{styles}</style>
<main>
{/* Hero Section */}
<section className="hero-section">
<div className="hero-content">
<h1 className="hero-heading">{t('home.hero')}</h1>
<p className="hero-subheading">{t('home.heroSub')}</p>
<SearchWidget />
{/* Highlights */}
<div className="grid md:grid-cols-3 gap-6 mt-12 max-w-6xl mx-auto">
{highlights.map((highlight, idx) => {
const Icon = highlight.icon;
return (
<div key={idx} className="feature-card">
<div className="feature-icon-bg">
<Icon size={24} color="white" />
</div>
<h3 className="feature-title">{highlight.title}</h3>
<p className="feature-desc">{highlight.desc}</p>
</div>
);
})}
</div>
</div>
</section>
{/* Popular Routes Section */}
<section className="py-16 bg-gray-50 dark:bg-gray-900">
<div className="max-w-6xl mx-auto px-4">
<h2 className="section-title">Popular Routes</h2>
<div className="grid md:grid-cols-3 gap-4">
{popularRoutes.map((route, idx) => (
<button
key={idx}
type="button"
onClick={() => handlePopularRoute(route.from, route.to)}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-5 hover:border-primary hover:shadow-md transition-all text-left group"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="font-semibold text-gray-900 dark:text-gray-100 mb-1">{route.from}</div>
<ArrowRight className="w-4 h-4 text-primary my-2" />
<div className="font-semibold text-gray-900 dark:text-gray-100">{route.to}</div>
</div>
<Train className="w-5 h-5 text-primary opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{route.duration} journey</div>
</button>
))}
</div>
</div>
</section>
{/* Features Section */}
<section className="features-section">
<h2 className="section-title">{t('home.features')}</h2>
<div className="features-grid">
{features.map((feature, idx) => {
const Icon = feature.icon;
return (
<div key={idx} className="feature-card">
<div className="feature-icon-bg">
<Icon size={24} color="white" />
</div>
<h3 className="feature-title">{feature.title}</h3>
<p className="feature-desc">{feature.desc}</p>
</div>
);
})}
</div>
</section>
{/* CTA Section */}
<section className="cta-section">
<div className="cta-content">
<h2 className="cta-title">Ready to start your journey?</h2>
<p className="cta-text">Book your train tickets in just a few minutes and enjoy a comfortable ride.</p>
<Link href="/booking/search" className="cta-button">
{t('home.cta')}
<ArrowRight size={20} />
</Link>
</div>
</section>
</main>
</>
<Suspense>
<SearchPage />
</Suspense>
);
}

View File

@@ -2,11 +2,11 @@ import { create } from 'zustand';
interface PaymentState {
paymentIntentId: string | null;
paymentStatus: 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED' | null;
paymentStatus: 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED' | null;
selectedCurrency: 'ETB' | 'DJF' | 'USD';
setPaymentIntent: (id: string) => void;
updateStatus: (status: 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED') => void;
updateStatus: (status: 'PENDING' | 'PROCESSING' | 'REQUIRES_ACTION' | 'SUCCEEDED' | 'FAILED') => void;
setCurrency: (currency: 'ETB' | 'DJF' | 'USD') => void;
clearPayment: () => void;
}

View File

@@ -108,3 +108,17 @@ export interface FaydaVerificationResponse {
nationality: string;
};
}
export interface PaymentMethod {
id: string;
type: string;
displayName: string;
region: string;
currency: string;
providerId: string | null;
isDefault: boolean;
enabled: boolean;
sortOrder: number;
createdAt: string;
updatedAt: string;
}

View File

@@ -210,7 +210,7 @@ export class TelebirrProvider implements PaymentProvider {
merch_code: this.merchantCode,
merch_order_id: input.merchantOrderId,
trade_type: "Checkout" as const,
title: `EDR ${input.orderRef}`,
title: `EDR booking payment`,
total_amount: totalAmount,
trans_currency: input.currency,
timeout_express: this.timeoutExpress,

View File

@@ -2,3 +2,5 @@ export * from "./common/index";
export * from "./freight/index";
export * as Freight from "./freight/index";
export * as Passenger from "./passenger/index";
export type { PaymentEvent, PaymentEventType, PaymentFailedEvent, PaymentSucceededEvent } from "./common/payments";
export { PaymentIntentSnapshot, InitiatePaymentRequest, PaymentReferenceType, PaymentService } from "./common/payments";

21
pnpm-lock.yaml generated
View File

@@ -434,6 +434,9 @@ importers:
'@nestjs/jwt':
specifier: ^10.2.0
version: 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/microservices':
specifier: ^11.1.24
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/passport':
specifier: ^10.0.3
version: 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
@@ -491,6 +494,9 @@ importers:
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
uuid:
specifier: ^10.0.0
version: 10.0.0
devDependencies:
'@edr/eslint-config':
specifier: workspace:*
@@ -528,6 +534,9 @@ importers:
'@types/supertest':
specifier: ^6.0.2
version: 6.0.3
'@types/uuid':
specifier: ^9.0.0
version: 9.0.8
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
@@ -4426,6 +4435,9 @@ packages:
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/uuid@9.0.8':
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
'@types/validate-npm-package-name@4.0.2':
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
@@ -12213,6 +12225,11 @@ packages:
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
uuid@11.1.1:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
@@ -17662,6 +17679,8 @@ snapshots:
'@types/use-sync-external-store@0.0.6': {}
'@types/uuid@9.0.8': {}
'@types/validate-npm-package-name@4.0.2': {}
'@types/validator@13.15.10': {}
@@ -27193,6 +27212,8 @@ snapshots:
dependencies:
base64-arraybuffer: 1.0.2
uuid@10.0.0: {}
uuid@11.1.1: {}
uuid@3.4.0: {}