mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/develop
This commit is contained in:
128
.github/workflows/deploy.yml
vendored
128
.github/workflows/deploy.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -84,19 +84,20 @@ model CoachType {
|
||||
}
|
||||
|
||||
model SeatClass {
|
||||
id String @id @default(uuid())
|
||||
coachTypeId String
|
||||
name String
|
||||
description String?
|
||||
baseFareMinor Int
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
||||
fareRules FareRule[]
|
||||
routeFareRules RouteFareRule[]
|
||||
segmentFares SegmentFareRule[]
|
||||
|
||||
id String @id @default(uuid())
|
||||
coachTypeId String
|
||||
name String
|
||||
description String?
|
||||
baseFareMinor Int @default(0) // per-km rate
|
||||
premiumMinor Int @default(0) // flat fee per passenger
|
||||
insuranceFeeMinor Int @default(0) // flat fee per passenger
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
||||
fareRules FareRule[]
|
||||
routeFareRules RouteFareRule[]
|
||||
segmentFares SegmentFareRule[]
|
||||
@@unique([coachTypeId, name])
|
||||
@@index([coachTypeId])
|
||||
@@schema("passenger")
|
||||
@@ -226,22 +227,24 @@ enum DevicePlatform {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
phone String @unique
|
||||
fullName String
|
||||
passwordHash String
|
||||
role UserRole @default(PASSENGER)
|
||||
nationality String?
|
||||
nationalityCode String?
|
||||
passportNumber String?
|
||||
nationalId String?
|
||||
failedLoginAttempts Int @default(0)
|
||||
lockedUntil DateTime?
|
||||
blockedUntil DateTime?
|
||||
lastLoginAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
phone String @unique
|
||||
fullName String
|
||||
passwordHash String
|
||||
role UserRole @default(PASSENGER)
|
||||
nationality String?
|
||||
nationalityCode String?
|
||||
gender String? // Male, Female, Other
|
||||
dateOfBirth DateTime?
|
||||
passportNumber String?
|
||||
nationalId String?
|
||||
failedLoginAttempts Int @default(0)
|
||||
lockedUntil DateTime?
|
||||
blockedUntil DateTime?
|
||||
lastLoginAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
faydaVerified Boolean @default(false)
|
||||
faydaVerifiedAt DateTime?
|
||||
@@ -307,21 +310,22 @@ model TravelerProfile {
|
||||
}
|
||||
|
||||
model Station {
|
||||
id String @id @default(uuid())
|
||||
code String @unique
|
||||
name String
|
||||
city String
|
||||
countryCode String?
|
||||
isOperational Boolean @default(true)
|
||||
timezone String @default("Africa/Addis_Ababa")
|
||||
lat Decimal @db.Decimal(9, 6)
|
||||
lng Decimal @db.Decimal(9, 6)
|
||||
originSchedules TrainSchedule[] @relation("OriginTrips")
|
||||
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
|
||||
id String @id @default(uuid())
|
||||
code String @unique
|
||||
name String
|
||||
city String
|
||||
countryCode String?
|
||||
sequence Int @default(0)
|
||||
isOperational Boolean @default(true)
|
||||
timezone String @default("Africa/Addis_Ababa")
|
||||
lat Decimal @db.Decimal(9, 6)
|
||||
lng Decimal @db.Decimal(9, 6)
|
||||
originSchedules TrainSchedule[] @relation("OriginTrips")
|
||||
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
|
||||
stopTimes TripStopTime[]
|
||||
crowdSignals StationCrowdSignal[]
|
||||
|
||||
@@index([city, countryCode])
|
||||
@@index([sequence])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -402,19 +406,20 @@ model TripLiveStatus {
|
||||
}
|
||||
|
||||
model Coach {
|
||||
id String @id @default(uuid())
|
||||
coachTypeId String
|
||||
number String @unique
|
||||
arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2'
|
||||
capacity Int @default(0) // Total seats/beds
|
||||
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
coachTypeId String
|
||||
number String @unique
|
||||
arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2'
|
||||
capacity Int @default(0) // Total seats/beds
|
||||
sequence Int @default(0)
|
||||
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
||||
seats Seat[]
|
||||
assignments CoachAssignment[]
|
||||
|
||||
@@index([coachTypeId])
|
||||
@@index([sequence])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -628,20 +633,21 @@ model PaymentRefund {
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
id String @id @default(uuid())
|
||||
bookingId String @unique
|
||||
bookingRef String
|
||||
status String @default("CONFIRMED")
|
||||
qrPayload String
|
||||
barcodePayload String?
|
||||
pdfUrl String?
|
||||
deliveryChannel String @default("EMAIL")
|
||||
issuedAt DateTime @default(now())
|
||||
validatedAt DateTime?
|
||||
validatorId String?
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
validationLogs GateValidationLog[]
|
||||
seats TicketSeat[]
|
||||
id String @id @default(uuid())
|
||||
bookingId String @unique
|
||||
bookingRef String
|
||||
status String @default("ACTIVE")
|
||||
qrPayload String
|
||||
barcodePayload String?
|
||||
pdfUrl String?
|
||||
deliveryChannel String @default("EMAIL")
|
||||
issuedAt DateTime @default(now())
|
||||
validatedAt DateTime?
|
||||
validatorId String?
|
||||
boardedAt DateTime?
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
validationLogs GateValidationLog[]
|
||||
seats TicketSeat[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { randomUUID as uuidv4 } from 'crypto';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const EDR_ROUTE_ID = uuidv4();
|
||||
const TRAIN_ID = uuidv4();
|
||||
|
||||
async function seedSystemUsers() {
|
||||
@@ -24,6 +23,10 @@ async function seedSystemUsers() {
|
||||
phone: '+251900000000',
|
||||
passwordHash: adminHash,
|
||||
role: 'ADMIN',
|
||||
gender: 'Male',
|
||||
dateOfBirth: new Date('1980-05-20'),
|
||||
nationality: 'Ethiopian',
|
||||
nationalId: 'ET123456789',
|
||||
},
|
||||
});
|
||||
console.log(' ✅ Admin: admin@edr-platform.com / admin123');
|
||||
@@ -39,6 +42,9 @@ async function seedSystemUsers() {
|
||||
role: 'PASSENGER',
|
||||
nationality: 'Ethiopian',
|
||||
faydaVerified: true,
|
||||
gender: 'Male',
|
||||
dateOfBirth: new Date('1990-03-15'),
|
||||
nationalId: 'ET987654321',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,7 +55,7 @@ async function seedSystemUsers() {
|
||||
data: { passengerId: passengerRecord.id, pointsBalance: 1500, lifetimePoints: 3000, tier: 'SILVER' },
|
||||
});
|
||||
await prisma.walletAccount.create({
|
||||
data: { passengerId: passengerRecord.id, balanceMinor: 50000 },
|
||||
data: { passengerId: passengerRecord.id, balanceMinor: 500 },
|
||||
});
|
||||
}
|
||||
await prisma.userPreferences.upsert({
|
||||
@@ -68,6 +74,8 @@ async function seedSystemUsers() {
|
||||
phone: '+251911111111',
|
||||
passwordHash: agentHash,
|
||||
role: 'AGENT',
|
||||
gender: 'Female',
|
||||
dateOfBirth: new Date('1992-07-22'),
|
||||
},
|
||||
});
|
||||
await prisma.agent.upsert({
|
||||
@@ -86,6 +94,8 @@ async function seedSystemUsers() {
|
||||
phone: '+251922222222',
|
||||
passwordHash: supervisorHash,
|
||||
role: 'SUPERVISOR',
|
||||
gender: 'Male',
|
||||
dateOfBirth: new Date('1985-11-10'),
|
||||
},
|
||||
});
|
||||
console.log(' ✅ Supervisor: supervisor@edr-platform.com / supervisor123');
|
||||
@@ -99,6 +109,8 @@ async function seedSystemUsers() {
|
||||
phone: '+251933333333',
|
||||
passwordHash: staffHash,
|
||||
role: 'STAFF',
|
||||
gender: 'Female',
|
||||
dateOfBirth: new Date('1995-09-08'),
|
||||
},
|
||||
});
|
||||
console.log(' ✅ Staff: staff@edr-platform.com / staff123');
|
||||
@@ -107,21 +119,21 @@ async function seedSystemUsers() {
|
||||
async function seedStations() {
|
||||
console.log('\n📍 Seeding 15 stations (Ethio-Djibouti Railway)...');
|
||||
const stations = [
|
||||
{ code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9520, lng: 38.6150 },
|
||||
{ code: 'LEB', name: 'Lebu', city: 'Lebu', countryCode: 'ET', lat: 8.8890, lng: 38.5320 },
|
||||
{ code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7650, lng: 39.0240 },
|
||||
{ code: 'MOJ', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6780, lng: 39.2130 },
|
||||
{ code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5420, lng: 39.2780 },
|
||||
{ code: 'MTE', name: 'Metehara', city: 'Metehara', countryCode: 'ET', lat: 8.7890, lng: 39.8920 },
|
||||
{ code: 'MIS', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 8.9120, lng: 40.3450 },
|
||||
{ code: 'BIK', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.1230, lng: 40.8670 },
|
||||
{ code: 'DRE', name: 'Dire Dawa', city: 'Dire Dawa', countryCode: 'ET', lat: 9.5915, lng: 41.8578 },
|
||||
{ code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 9.7340, lng: 42.2150 },
|
||||
{ code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 10.0120, lng: 42.5670 },
|
||||
{ code: 'DAW', name: 'Dawanle', city: 'Dawanle', countryCode: 'ET', lat: 10.2340, lng: 42.8340 },
|
||||
{ code: 'ALS', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 10.8950, lng: 42.9560 },
|
||||
{ code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.1230, lng: 43.0450 },
|
||||
{ code: 'NAG', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', lat: 11.3780, lng: 43.1200 },
|
||||
{ code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9520, lng: 38.6150, sequence: 1 },
|
||||
{ code: 'LEB', name: 'Lebu', city: 'Lebu', countryCode: 'ET', lat: 8.8890, lng: 38.5320, sequence: 2 },
|
||||
{ code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7650, lng: 39.0240, sequence: 3 },
|
||||
{ code: 'MOJ', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6780, lng: 39.2130, sequence: 4 },
|
||||
{ code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5420, lng: 39.2780, sequence: 5 },
|
||||
{ code: 'MTE', name: 'Metehara', city: 'Metehara', countryCode: 'ET', lat: 8.7890, lng: 39.8920, sequence: 6 },
|
||||
{ code: 'MIS', name: 'Mieso', city: 'Mieso', countryCode: 'ET', lat: 8.9120, lng: 40.3450, sequence: 7 },
|
||||
{ code: 'BIK', name: 'Bike', city: 'Bike', countryCode: 'ET', lat: 9.1230, lng: 40.8670, sequence: 8 },
|
||||
{ code: 'DRE', name: 'Dire Dawa', city: 'Dire Dawa', countryCode: 'ET', lat: 9.5915, lng: 41.8578, sequence: 9 },
|
||||
{ code: 'ADG', name: 'Adigala', city: 'Adigala', countryCode: 'ET', lat: 9.7340, lng: 42.2150, sequence: 10 },
|
||||
{ code: 'AYS', name: 'Aysha', city: 'Aysha', countryCode: 'ET', lat: 10.0120, lng: 42.5670, sequence: 11 },
|
||||
{ code: 'DAW', name: 'Dawanle', city: 'Dawanle', countryCode: 'ET', lat: 10.2340, lng: 42.8340, sequence: 12 },
|
||||
{ code: 'ALS', name: 'Alisabieh', city: 'Alisabieh', countryCode: 'DJ', lat: 10.8950, lng: 42.9560, sequence: 13 },
|
||||
{ code: 'HOL', name: 'Holhol', city: 'Holhol', countryCode: 'DJ', lat: 11.1230, lng: 43.0450, sequence: 14 },
|
||||
{ code: 'NAG', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', lat: 11.3780, lng: 43.1200, sequence: 15 },
|
||||
];
|
||||
|
||||
for (const station of stations) {
|
||||
@@ -142,8 +154,8 @@ async function seedCoachTypesAndClasses() {
|
||||
console.log('\n🚂 Seeding coach types and seat classes...');
|
||||
const coachTypes = [
|
||||
{ code: 'HSC', name: 'Hard Seat Coach', type: 'Economy Regular' },
|
||||
{ code: 'HBC', name: 'Hard Bed Coach', type: 'Economy Bed' },
|
||||
{ code: 'SBC', name: 'Soft Bed Coach', type: 'VIP Bed' },
|
||||
{ code: 'HBC', name: 'Hard Berth Coach', type: 'Economy Bed' },
|
||||
{ code: 'SBC', name: 'Soft Berth Coach', type: 'VIP Bed' },
|
||||
];
|
||||
|
||||
for (const ct of coachTypes) {
|
||||
@@ -155,12 +167,12 @@ async function seedCoachTypesAndClasses() {
|
||||
}
|
||||
|
||||
const seatClasses = [
|
||||
{ name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900 },
|
||||
{ name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800 },
|
||||
{ name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600 },
|
||||
{ name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550 },
|
||||
{ name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500 },
|
||||
{ name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250 },
|
||||
{ name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900, premiumMinor: 50, insuranceFeeMinor: 25 },
|
||||
{ name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800, premiumMinor: 45, insuranceFeeMinor: 20 },
|
||||
{ name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600, premiumMinor: 30, insuranceFeeMinor: 15 },
|
||||
{ name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550, premiumMinor: 28, insuranceFeeMinor: 14 },
|
||||
{ name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500, premiumMinor: 25, insuranceFeeMinor: 12 },
|
||||
{ name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250, premiumMinor: 12, insuranceFeeMinor: 6 },
|
||||
];
|
||||
|
||||
for (const sc of seatClasses) {
|
||||
@@ -168,7 +180,7 @@ async function seedCoachTypesAndClasses() {
|
||||
await prisma.seatClass.upsert({
|
||||
where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } },
|
||||
update: {},
|
||||
create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor },
|
||||
create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor, premiumMinor: sc.premiumMinor, insuranceFeeMinor: sc.insuranceFeeMinor },
|
||||
});
|
||||
}
|
||||
console.log(` ✅ ${coachTypes.length} coach types, ${seatClasses.length} seat classes created`);
|
||||
@@ -176,14 +188,12 @@ async function seedCoachTypesAndClasses() {
|
||||
|
||||
async function seedRoute() {
|
||||
console.log('\n🛣️ Seeding route and stops...');
|
||||
const firstStation = await prisma.station.findUnique({ where: { code: 'SBT' } });
|
||||
const lastStation = await prisma.station.findUnique({ where: { code: 'NAG' } });
|
||||
|
||||
const route = await prisma.route.upsert({
|
||||
where: { code: 'EDR-101' },
|
||||
where: { code: 'Route-101' },
|
||||
update: {},
|
||||
create: {
|
||||
code: 'EDR-101',
|
||||
code: 'Route-101',
|
||||
name: 'Sebeta - Dire Dawa',
|
||||
description: 'Outbound local route from Sebeta to Dire Dawa',
|
||||
effectiveFrom: new Date('2026-01-01'),
|
||||
@@ -193,15 +203,40 @@ async function seedRoute() {
|
||||
});
|
||||
|
||||
const stationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE'];
|
||||
const routeDistancesKm = [0, 11.5, 67.2, 89.9, 106.7, 180.2, 231.6, 293.6, 413.0];
|
||||
for (let i = 0; i < stationCodes.length; i++) {
|
||||
const station = await prisma.station.findUnique({ where: { code: stationCodes[i] } });
|
||||
await prisma.routeStop.upsert({
|
||||
where: { routeId_sequence: { routeId: route.id, sequence: i + 1 } },
|
||||
update: {},
|
||||
create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: i * 85 },
|
||||
create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: routeDistancesKm[i] },
|
||||
});
|
||||
}
|
||||
console.log(` ✅ Route with ${stationCodes.length} stops created`);
|
||||
|
||||
const returnRoute = await prisma.route.upsert({
|
||||
where: { code: 'Route-102' },
|
||||
update: {},
|
||||
create: {
|
||||
code: 'Route-102',
|
||||
name: 'Dire Dawa - Sebeta',
|
||||
description: 'Inbound local route from Dire Dawa to Sebeta',
|
||||
effectiveFrom: new Date('2026-01-01'),
|
||||
effectiveUntil: new Date('2034-12-31'),
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const returnStationCodes = ['DRE', 'BIK', 'MIS', 'MTE', 'ADM', 'MOJ', 'BSH', 'LEB', 'SBT'];
|
||||
const returnRouteDistancesKm = [0, 119.4, 181.4, 232.8, 306.3, 323.1, 345.8, 401.5, 413.0];
|
||||
for (let i = 0; i < returnStationCodes.length; i++) {
|
||||
const station = await prisma.station.findUnique({ where: { code: returnStationCodes[i] } });
|
||||
await prisma.routeStop.upsert({
|
||||
where: { routeId_sequence: { routeId: returnRoute!.id, sequence: i + 1 } },
|
||||
update: {},
|
||||
create: { routeId: returnRoute!.id, stationId: station!.id, sequence: i + 1, distanceKm: returnRouteDistancesKm[i] },
|
||||
});
|
||||
}
|
||||
console.log(` ✅ Route with ${returnStationCodes.length} stops created`);
|
||||
}
|
||||
|
||||
async function seedCoaches() {
|
||||
@@ -211,9 +246,9 @@ async function seedCoaches() {
|
||||
const vipBedCoachType = await prisma.coachType.findUnique({ where: { id: 'SBC' } });
|
||||
|
||||
const coaches = [
|
||||
{ number: 'HSC-0001', coachTypeId: ecoCoachType!.id, arrangement: '3+2', capacity: 40 },
|
||||
{ number: 'HBC-0001', coachTypeId: ecoBedCoachType!.id, arrangement: '3+0', capacity: 66 },
|
||||
{ number: 'SBC-0001', coachTypeId: vipBedCoachType!.id, arrangement: '2+0', capacity: 120 },
|
||||
{ number: 'HSC-0001', coachTypeId: ecoCoachType!.id, arrangement: '3+2', capacity: 128, sequence: 1 },
|
||||
{ number: 'HBC-0001', coachTypeId: ecoBedCoachType!.id, arrangement: '3+0', capacity: 66, sequence: 2 },
|
||||
{ number: 'SBC-0001', coachTypeId: vipBedCoachType!.id, arrangement: '2+0', capacity: 40, sequence: 3 },
|
||||
];
|
||||
|
||||
let totalSeats = 0;
|
||||
@@ -230,19 +265,23 @@ async function seedCoaches() {
|
||||
// FK violation once BookingSeat/SeatBlock/TicketSeat rows reference them.
|
||||
let seatIndex = 1;
|
||||
for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) {
|
||||
for (const col of ['A', 'B', 'C', 'D']) {
|
||||
for (const col of ['A', 'B', 'C', 'D', 'E']) {
|
||||
if (seatIndex > coach.capacity) break;
|
||||
let bedPosition: string | null = null;
|
||||
if (c.coachTypeId === ecoBedCoachType!.id || c.coachTypeId === vipBedCoachType!.id) {
|
||||
if (c.coachTypeId === ecoBedCoachType!.id) {
|
||||
// Economy Bed: 3-row cycle (upper, middle, lower)
|
||||
if (row % 3 === 1) bedPosition = 'upper';
|
||||
else if (row % 3 === 2) bedPosition = 'middle';
|
||||
else bedPosition = 'lower';
|
||||
} else if (c.coachTypeId === vipBedCoachType!.id) {
|
||||
// VIP Bed: 2-row cycle (upper, lower)
|
||||
bedPosition = row % 2 === 1 ? 'upper' : 'lower';
|
||||
}
|
||||
|
||||
const seatData = {
|
||||
seatNumber: seatIndex.toString(),
|
||||
isWindow: col === 'A' || col === 'D',
|
||||
isAisle: col === 'B' || col === 'C',
|
||||
isWindow: col === 'A' || col === 'E',
|
||||
isAisle: col === 'B' || col === 'C' || col === 'D',
|
||||
bedPosition,
|
||||
};
|
||||
|
||||
@@ -264,67 +303,102 @@ async function seedTrips() {
|
||||
const train = await prisma.train.upsert({
|
||||
where: { number: 'EDR-001' },
|
||||
update: {},
|
||||
create: { id: TRAIN_ID, number: 'EDR-001', name: 'Djibouti Express' },
|
||||
create: { id: TRAIN_ID, number: 'EDR-001', name: 'Express Service' },
|
||||
});
|
||||
|
||||
const route = await prisma.route.findUnique({ where: { code: 'EDR-101' } });
|
||||
const route = await prisma.route.findUnique({ where: { code: 'Route-101' } });
|
||||
const returnRoute = await prisma.route.findUnique({ where: { code: 'Route-102' } });
|
||||
const firstStation = await prisma.station.findUnique({ where: { code: 'SBT' } });
|
||||
const lastStation = await prisma.station.findUnique({ where: { code: 'DRE' } });
|
||||
const firstReturnStation = await prisma.station.findUnique({ where: { code: 'DRE' } });
|
||||
const lastReturnStation = await prisma.station.findUnique({ where: { code: 'SBT' } });
|
||||
const coaches = await prisma.coach.findMany();
|
||||
|
||||
const now = new Date();
|
||||
const schedules = [];
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(now.getDate() + 1);
|
||||
|
||||
for (let d = 0; d < 30; d++) {
|
||||
const schedules = [];
|
||||
|
||||
for (let d = 0; d < 5; d++) {
|
||||
const tripDate = new Date(now);
|
||||
tripDate.setDate(tripDate.getDate() + d);
|
||||
tripDate.setHours(8, 0, 0, 0);
|
||||
|
||||
const departureAt = new Date(tripDate);
|
||||
const arrivalAt = new Date(departureAt.getTime() + 4 * 24 * 60 * 60 * 1000);
|
||||
|
||||
tripDate.setHours(20, 30, 0, 0);
|
||||
schedules.push({
|
||||
trainId: train.id,
|
||||
routeId: route!.id,
|
||||
originStationId: firstStation!.id,
|
||||
destinationStationId: lastStation!.id,
|
||||
departureAt,
|
||||
arrivalAt,
|
||||
durationMinutes: 4 * 24 * 60,
|
||||
stopsCount: 15,
|
||||
departureAt: new Date(tripDate),
|
||||
arrivalAt: new Date(tripDate), // patched below
|
||||
durationMinutes: 0, // patched below
|
||||
stopsCount: 9,
|
||||
});
|
||||
}
|
||||
|
||||
const createdSchedules = await Promise.all(
|
||||
schedules.map(s => prisma.trainSchedule.create({ data: s }))
|
||||
);
|
||||
|
||||
// Create TripStopTimes for each schedule
|
||||
const routeStops = await prisma.routeStop.findMany({
|
||||
where: { routeId: route!.id },
|
||||
orderBy: { sequence: 'asc' },
|
||||
include: { route: true },
|
||||
for (let d = 0; d < 5; d++) {
|
||||
const returnTripDate = new Date(tomorrow);
|
||||
returnTripDate.setDate(returnTripDate.getDate() + d);
|
||||
returnTripDate.setHours(20, 0, 0, 0);
|
||||
schedules.push({
|
||||
trainId: train.id,
|
||||
routeId: returnRoute!.id,
|
||||
originStationId: firstReturnStation!.id,
|
||||
destinationStationId: lastReturnStation!.id,
|
||||
departureAt: new Date(returnTripDate),
|
||||
arrivalAt: new Date(returnTripDate), // patched below
|
||||
durationMinutes: 0, // patched below
|
||||
stopsCount: 9,
|
||||
});
|
||||
}
|
||||
|
||||
// Load route stops for both routes upfront
|
||||
const routeStopsMap = new Map<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(
|
||||
patchedSchedules.map(s => prisma.trainSchedule.create({ data: s }))
|
||||
);
|
||||
|
||||
// Create TripStopTimes using cumulative distanceKm at 60 km/h
|
||||
for (const schedule of createdSchedules) {
|
||||
const stopTimes = [];
|
||||
for (const routeStop of routeStops) {
|
||||
const minutesFromStart = (routeStop.sequence - 1) * 480; // 8 hours per stop
|
||||
const stops = routeStopsMap.get(schedule.routeId!)!;
|
||||
const originKm = stops[0].distanceKm;
|
||||
const stopTimes = stops.map(stop => {
|
||||
const minutesFromStart = Math.ceil((stop.distanceKm - originKm) / 60 * 60);
|
||||
const plannedDepartureAt = new Date(schedule.departureAt.getTime() + minutesFromStart * 60_000);
|
||||
const plannedArrivalAt = new Date(plannedDepartureAt.getTime() + 30 * 60_000); // 30 min stop
|
||||
|
||||
stopTimes.push({
|
||||
const plannedArrivalAt = new Date(plannedDepartureAt.getTime() - 5 * 60_000); // 5 min dwell
|
||||
return {
|
||||
scheduleId: schedule.id,
|
||||
stationId: routeStop.stationId,
|
||||
sequence: routeStop.sequence,
|
||||
stationId: stop.stationId,
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt,
|
||||
plannedDepartureAt,
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
stopTimes.map(st => prisma.tripStopTime.create({ data: st }))
|
||||
);
|
||||
};
|
||||
});
|
||||
// First stop: arrival = departure (no dwell at origin)
|
||||
stopTimes[0].plannedArrivalAt = stopTimes[0].plannedDepartureAt;
|
||||
|
||||
await Promise.all(stopTimes.map(st => prisma.tripStopTime.create({ data: st })));
|
||||
}
|
||||
|
||||
const coachAssignments = [];
|
||||
@@ -355,7 +429,7 @@ async function seedTrips() {
|
||||
|
||||
async function seedFareRules() {
|
||||
console.log('\n💰 Seeding fare rules...');
|
||||
const route = await prisma.route.findUnique({ where: { code: 'EDR-101' } });
|
||||
const route = await prisma.route.findUnique({ where: { code: 'Route-101' } });
|
||||
const seatClasses = await prisma.seatClass.findMany();
|
||||
const validFrom = new Date('2024-01-01');
|
||||
|
||||
@@ -374,7 +448,7 @@ async function seedFareRules() {
|
||||
seatClassId: sc.id,
|
||||
passengerCategory: 'CHILD' as const,
|
||||
baseFareMinor: Math.floor(sc.baseFareMinor * 0.5),
|
||||
discountPercent: 50,
|
||||
discountPercent: 10,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
});
|
||||
@@ -422,6 +496,7 @@ async function seedPaymentMethods() {
|
||||
{ type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA' },
|
||||
{ type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA' },
|
||||
{ type: 'EBIRR', displayName: 'eBirr', region: 'ETHIOPIA' },
|
||||
{ type: 'WAAFI', displayName: 'Waffi', region: 'DJIBOUTI' },
|
||||
{ type: 'CARD', displayName: 'Credit/Debit Card', region: 'GLOBAL' },
|
||||
{ type: 'WALLET', displayName: 'Wallet', region: 'GLOBAL' },
|
||||
];
|
||||
@@ -436,13 +511,50 @@ async function seedPaymentMethods() {
|
||||
console.log(` ✅ ${methods.length} payment methods created`);
|
||||
}
|
||||
|
||||
async function seedSegmentFares() {
|
||||
console.log('\n📍 Seeding segment fare rules...');
|
||||
const route = await prisma.route.findUnique({
|
||||
where: { code: 'Route-101' },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
const seatClasses = await prisma.seatClass.findMany();
|
||||
const validFrom = new Date('2024-01-01');
|
||||
|
||||
if (route && route.stops.length > 2) {
|
||||
for (const sc of seatClasses) {
|
||||
await prisma.segmentFareRule.create({
|
||||
data: {
|
||||
routeId: route.id,
|
||||
seatClassId: sc.id,
|
||||
originStopSequence: 1,
|
||||
destinationStopSequence: 3,
|
||||
baseFareMinor: Math.floor(sc.baseFareMinor * 0.4),
|
||||
validFrom,
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
await prisma.segmentFareRule.create({
|
||||
data: {
|
||||
routeId: route.id,
|
||||
seatClassId: sc.id,
|
||||
originStopSequence: 5,
|
||||
destinationStopSequence: 9,
|
||||
baseFareMinor: Math.floor(sc.baseFareMinor * 0.6),
|
||||
validFrom,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
console.log(` ✅ ${seatClasses.length * 2} segment fare rules created`);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedNotificationTemplates() {
|
||||
console.log('\n🔔 Seeding notification templates...');
|
||||
const templates = [
|
||||
{ id: uuidv4(), code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed' },
|
||||
{ id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment received for {{bookingRef}}' },
|
||||
{ id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip departs in {{minutes}} minutes' },
|
||||
{ id: uuidv4(), code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip is delayed by {{delayMinutes}} minutes' },
|
||||
{ id: uuidv4(), code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{date}}' },
|
||||
{ id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment ETB {{amount}} received for {{bookingRef}}' },
|
||||
{ id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' },
|
||||
{ id: uuidv4(), code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' },
|
||||
{ id: uuidv4(), code: 'PROMOTION', channel: 'PUSH', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' },
|
||||
];
|
||||
|
||||
@@ -476,13 +588,13 @@ async function seedMenuAndFood() {
|
||||
const sandwichId = uuidv4();
|
||||
|
||||
await prisma.menuItem.create({
|
||||
data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 5000 },
|
||||
data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 50 },
|
||||
}).catch(() => {}); // ignore if exists
|
||||
await prisma.menuItem.create({
|
||||
data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 3500 },
|
||||
data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 35 },
|
||||
}).catch(() => {}); // ignore if exists
|
||||
await prisma.menuItem.create({
|
||||
data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 8000 },
|
||||
data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 80 },
|
||||
}).catch(() => {}); // ignore if exists
|
||||
}
|
||||
console.log(` ✅ Menu categories and items created`);
|
||||
@@ -493,7 +605,7 @@ async function seedPromotions() {
|
||||
const promos = [
|
||||
{ id: uuidv4(), title: 'Early Bird Discount', code: 'EARLY20', percentOff: 20, validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) },
|
||||
{ id: uuidv4(), title: 'Student Discount', code: 'STUDENT15', percentOff: 15, validUntil: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000) },
|
||||
{ id: uuidv4(), title: 'Group Booking', code: 'GROUP10', amountOffMinor: 10000, validUntil: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) },
|
||||
{ id: uuidv4(), title: 'Group Booking', code: 'GROUP10', amountOffMinor: 100, validUntil: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) },
|
||||
];
|
||||
|
||||
for (const p of promos) {
|
||||
@@ -583,6 +695,7 @@ async function main() {
|
||||
['promotions', seedPromotions],
|
||||
['FAQ', seedFAQ],
|
||||
['fraud rules', seedFraudRules],
|
||||
['segment fares', seedSegmentFares],
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
10
apps/edr-passenger-api/src/common/audit.module.ts
Normal file
10
apps/edr-passenger-api/src/common/audit.module.ts
Normal 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 {}
|
||||
92
apps/edr-passenger-api/src/common/audit.service.ts
Normal file
92
apps/edr-passenger-api/src/common/audit.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
41
apps/edr-passenger-api/src/modules/audit/audit.controller.ts
Normal file
41
apps/edr-passenger-api/src/modules/audit/audit.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/edr-passenger-api/src/modules/audit/audit.module.ts
Normal file
10
apps/edr-passenger-api/src/modules/audit/audit.module.ts
Normal 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 {}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export class SendEmail {
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
html?: string;
|
||||
templateKey?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export class SendMessage {
|
||||
to: string;
|
||||
message: string;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export class BulkMessagesDto {
|
||||
messages: SendMessage[];
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -47,17 +47,9 @@ export class PassengersService {
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
fullName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
nationalId: true,
|
||||
nationality: true,
|
||||
},
|
||||
},
|
||||
user: true,
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
_count: {
|
||||
select: {
|
||||
bookings: true,
|
||||
@@ -69,19 +61,30 @@ export class PassengersService {
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map(passenger => ({
|
||||
id: passenger.id,
|
||||
fullName: passenger.user.fullName,
|
||||
email: passenger.user.email,
|
||||
phone: passenger.user.phone,
|
||||
nationalId: passenger.user.nationalId,
|
||||
nationality: passenger.user.nationality,
|
||||
verified: !!passenger.user.nationalId,
|
||||
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
|
||||
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
|
||||
totalBookings: passenger._count.bookings,
|
||||
createdAt: passenger.createdAt,
|
||||
})),
|
||||
items: items.map(passenger => {
|
||||
const user = passenger.user as any;
|
||||
return {
|
||||
id: passenger.id,
|
||||
userId: passenger.userId,
|
||||
fullName: user.fullName,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
nationalId: user.nationalId,
|
||||
nationality: user.nationality,
|
||||
dateOfBirth: user.dateOfBirth ?? null,
|
||||
gender: user.gender ?? null,
|
||||
passportNumber: user.passportNumber,
|
||||
passportCountry: user.passportCountry ?? null,
|
||||
verified: !!user.nationalId,
|
||||
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
|
||||
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
|
||||
totalBookings: passenger._count.bookings,
|
||||
createdAt: passenger.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
loyalty: passenger.loyalty,
|
||||
wallet: passenger.wallet,
|
||||
};
|
||||
}),
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
@@ -95,9 +98,19 @@ export class PassengersService {
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { id: passengerId },
|
||||
include: {
|
||||
user: { select: { fullName: true, email: true, phone: true } },
|
||||
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } } },
|
||||
loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true,
|
||||
user: true,
|
||||
bookings: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } }
|
||||
}
|
||||
},
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
travelerProfiles: true,
|
||||
savedRoutes: true,
|
||||
},
|
||||
});
|
||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||
@@ -108,14 +121,35 @@ export class PassengersService {
|
||||
phone: passenger.user.phone,
|
||||
createdAt: passenger.createdAt,
|
||||
bookings: passenger.bookings.map((b) => ({
|
||||
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,
|
||||
id: b.id,
|
||||
bookingRef: b.bookingRef,
|
||||
status: b.status,
|
||||
totalFare: b.totalMinor / 100,
|
||||
createdAt: b.createdAt,
|
||||
trip: {
|
||||
number: b.schedule.train.number,
|
||||
origin: { id: b.schedule.originStation.id, name: b.schedule.originStation.name, code: b.schedule.originStation.code, city: b.schedule.originStation.city },
|
||||
destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city },
|
||||
origin: {
|
||||
id: b.schedule.originStation.id,
|
||||
name: b.schedule.originStation.name,
|
||||
code: b.schedule.originStation.code,
|
||||
city: b.schedule.originStation.city
|
||||
},
|
||||
destination: {
|
||||
id: b.schedule.destinationStation.id,
|
||||
name: b.schedule.destinationStation.name,
|
||||
code: b.schedule.destinationStation.code,
|
||||
city: b.schedule.destinationStation.city
|
||||
},
|
||||
departureAt: b.schedule.departureAt,
|
||||
},
|
||||
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' } })),
|
||||
passengers: b.seats.map((bs) => ({
|
||||
fullName: bs.passengerName,
|
||||
seat: {
|
||||
number: bs.seat.seatNumber,
|
||||
coach: bs.seat.coach.number,
|
||||
class: 'N/A'
|
||||
}
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -171,14 +205,25 @@ export class PassengersService {
|
||||
}
|
||||
|
||||
createTravelerProfile(dto: CreateTravelerProfileDto) {
|
||||
return this.prisma.travelerProfile.create({ data: { ...dto, dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null } });
|
||||
return this.prisma.travelerProfile.create({
|
||||
data: {
|
||||
...dto,
|
||||
dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getTravelerProfiles(passengerId: string) { return this.prisma.travelerProfile.findMany({ where: { passengerId } }); }
|
||||
getTravelerProfiles(passengerId: string) {
|
||||
return this.prisma.travelerProfile.findMany({ where: { passengerId } });
|
||||
}
|
||||
|
||||
createSavedRoute(dto: CreateSavedRouteDto) { return this.prisma.savedRoute.create({ data: dto }); }
|
||||
createSavedRoute(dto: CreateSavedRouteDto) {
|
||||
return this.prisma.savedRoute.create({ data: dto });
|
||||
}
|
||||
|
||||
getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); }
|
||||
getSavedRoutes(passengerId: string) {
|
||||
return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } });
|
||||
}
|
||||
|
||||
async updatePassenger(id: string, dto: any) {
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
||||
@@ -196,7 +241,7 @@ export class PassengersService {
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: { select: { fullName: true, email: true, phone: true, nationality: true } },
|
||||
user: true,
|
||||
loyalty: true,
|
||||
},
|
||||
});
|
||||
@@ -290,9 +335,7 @@ export class PassengersService {
|
||||
async deletePassenger(id: string) {
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||
|
||||
await this.prisma.passenger.delete({ where: { id } });
|
||||
return { deleted: true, passengerId: id };
|
||||
return this.prisma.passenger.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async checkPassengerUsage(id: string) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[] }> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -18,15 +18,57 @@ export class SearchService {
|
||||
) {}
|
||||
|
||||
async searchTrips(dto: SearchTripsDto) {
|
||||
const date = new Date(dto.date);
|
||||
const nextDay = new Date(date.getTime() + 86_400_000);
|
||||
const totalPassengers = dto.adultCount + (dto.childCount ?? 0);
|
||||
const outbound = await this.searchSchedules(
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
);
|
||||
|
||||
if (dto.journeyType === 'ROUND_TRIP') {
|
||||
const allInbound = await this.searchSchedules(
|
||||
dto.destinationStationId,
|
||||
dto.originStationId,
|
||||
dto.returnDate ?? dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
);
|
||||
|
||||
const latestOutboundArrival = outbound.length > 0
|
||||
? Math.max(...outbound.map((s) => new Date(s.arrivalAt).getTime()))
|
||||
: Date.now();
|
||||
|
||||
const inbound = allInbound.filter((schedule) =>
|
||||
new Date(schedule.departureAt).getTime() > latestOutboundArrival
|
||||
);
|
||||
|
||||
return { journeyType: 'ROUND_TRIP', outbound, inbound };
|
||||
}
|
||||
|
||||
return { journeyType: 'ONE_WAY', outbound };
|
||||
}
|
||||
|
||||
private async searchSchedules(
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
dateStr: string,
|
||||
adultCount: number,
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
) {
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
const date = new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||
const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||
departureAt: { gte: date, lt: nextDay },
|
||||
stopTimes: { some: { stationId: dto.originStationId } },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
},
|
||||
include: {
|
||||
train: true,
|
||||
@@ -42,8 +84,8 @@ export class SearchService {
|
||||
const results = [];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
|
||||
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
|
||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
|
||||
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
|
||||
|
||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
|
||||
|
||||
@@ -61,14 +103,14 @@ export class SearchService {
|
||||
if (seat.bedPosition !== bedPosition) continue;
|
||||
if (seat.status === 'BLOCKED') continue;
|
||||
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
|
||||
|
||||
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||
schedule.id, seat.id,
|
||||
originStop.sequence, destStop.sequence,
|
||||
);
|
||||
if (free) count++;
|
||||
}
|
||||
|
||||
|
||||
if (count > 0) {
|
||||
const matchingClass = seatClassNames.find((className: string) => {
|
||||
const classNameLower = className.toLowerCase();
|
||||
@@ -89,14 +131,14 @@ export class SearchService {
|
||||
for (const seat of assignment.coach.seats) {
|
||||
if (seat.status === 'BLOCKED') continue;
|
||||
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
|
||||
|
||||
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||
schedule.id, seat.id,
|
||||
originStop.sequence, destStop.sequence,
|
||||
);
|
||||
if (free) availableSeatsInCoach++;
|
||||
}
|
||||
|
||||
|
||||
for (const seatClassName of seatClassNames) {
|
||||
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
|
||||
availabilityByClass[seatClassName] += availableSeatsInCoach;
|
||||
@@ -109,11 +151,13 @@ export class SearchService {
|
||||
|
||||
const faresByClass = await this.calculateFaresForSegment(
|
||||
schedule,
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.nationality,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
nationality,
|
||||
);
|
||||
|
||||
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
|
||||
|
||||
results.push({
|
||||
scheduleId: schedule.id,
|
||||
trainNumber: schedule.train.number,
|
||||
@@ -150,6 +194,7 @@ export class SearchService {
|
||||
availabilityByClass,
|
||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||
faresByClass,
|
||||
coachTypes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,14 +303,14 @@ export class SearchService {
|
||||
.filter((id: any) => id)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
if (seatClassIds.length === 0) {
|
||||
console.log(`No seat classes assigned to schedule ${schedule.id}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const seatClasses = await this.prisma.seatClass.findMany({
|
||||
where: {
|
||||
where: {
|
||||
isActive: true,
|
||||
id: { in: seatClassIds }
|
||||
},
|
||||
@@ -307,7 +352,7 @@ export class SearchService {
|
||||
|
||||
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
|
||||
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
|
||||
|
||||
|
||||
if (originStation && destStation) {
|
||||
const segmentRoute = `${originStation.code}-${destStation.code}`;
|
||||
const now = new Date();
|
||||
@@ -341,6 +386,62 @@ export class SearchService {
|
||||
}));
|
||||
}
|
||||
|
||||
private async buildCoachTypeDetails(
|
||||
schedule: any,
|
||||
faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>,
|
||||
): Promise<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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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({
|
||||
where: { id },
|
||||
data: dto
|
||||
const oldStation = await this.findOne(id);
|
||||
const updatedStation = await this.prisma.station.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
});
|
||||
|
||||
await this.auditService.log({
|
||||
userId: this.request?.user?.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Station',
|
||||
entityId: id,
|
||||
oldData: oldStation,
|
||||
newData: updatedStation,
|
||||
});
|
||||
|
||||
return updatedStation;
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.findOne(id); // Check if exists
|
||||
return this.prisma.station.delete({ where: { id } });
|
||||
const station = await this.findOne(id);
|
||||
const deleted = await this.prisma.station.delete({ where: { id } });
|
||||
|
||||
await this.auditService.log({
|
||||
userId: this.request?.user?.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Station',
|
||||
entityId: id,
|
||||
oldData: station,
|
||||
});
|
||||
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
"extends": ["next/core-web-vitals"],
|
||||
"rules": {
|
||||
"react/no-unescaped-entities": "off"
|
||||
}
|
||||
}
|
||||
|
||||
2897
apps/edr-passenger-web/backoffice/public/docs.md
Normal file
2897
apps/edr-passenger-web/backoffice/public/docs.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
<div>
|
||||
<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>
|
||||
<option value="Booking">Booking</option>
|
||||
<option value="User">User</option>
|
||||
<option value="Payment">Payment</option>
|
||||
<option value="Ticket">Ticket</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="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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
<label className="label">Base Fare (ETB cents) *</label>
|
||||
<input
|
||||
type="number"
|
||||
name="baseFareMinor"
|
||||
className="input"
|
||||
defaultValue={editingClass?.baseFareMinor || ''}
|
||||
required
|
||||
min="0"
|
||||
placeholder="e.g., 45000 (450 ETB)"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Enter amount in cents (100 cents = 1 ETB)</p>
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
|
||||
|
||||
<div>
|
||||
<label className="label">Base Fare (ETB) *</label>
|
||||
<input
|
||||
type="number"
|
||||
name="baseFareMinor"
|
||||
className="input"
|
||||
defaultValue={editingClass?.baseFareMinor ? (editingClass.baseFareMinor / 100).toFixed(2) : ''}
|
||||
required
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="e.g., 350.00"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
475
apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx
Normal file
475
apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Ticket, Users, DollarSign, TrendingUp } from 'lucide-react';
|
||||
import { Ticket, Users, DollarSign, Percent } from 'lucide-react';
|
||||
import StatCard from '@/components/dashboard/StatCard';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import { dashboardApi } from '@/lib/api/dashboard';
|
||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
|
||||
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
@@ -20,22 +22,55 @@ export default function DashboardPage() {
|
||||
queryFn: () => dashboardApi.getRevenueChart(30),
|
||||
});
|
||||
|
||||
const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery<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: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') },
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
key: 'passenger',
|
||||
label: 'Passenger',
|
||||
render: (item: any) => {
|
||||
if (item.passenger?.fullName) {
|
||||
return item.passenger.fullName;
|
||||
}
|
||||
if (item.contactEmail) {
|
||||
return item.contactEmail;
|
||||
}
|
||||
if (item.contactPhone) {
|
||||
return item.contactPhone;
|
||||
}
|
||||
return 'N/A';
|
||||
}
|
||||
},
|
||||
{ key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') },
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (item: any) => (
|
||||
<Badge variant="status" status={item.status}>
|
||||
{item.status}
|
||||
@@ -45,19 +80,43 @@ export default function DashboardPage() {
|
||||
{ key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) },
|
||||
];
|
||||
|
||||
const agentColumns = [
|
||||
{ key: 'name', label: 'Agent Name', render: (item: any) => item.name || item.fullName },
|
||||
{ key: 'bookings', label: 'Bookings', render: (item: any) => item.bookingsCount || item.bookings || 0 },
|
||||
{ key: 'revenue', label: 'Revenue', render: (item: any) => formatCurrency(item.totalRevenue || item.revenue || 0, 'ETB') },
|
||||
{ key: 'commission', label: 'Commission', render: (item: any) => formatCurrency(item.commission || 0, 'ETB') },
|
||||
];
|
||||
|
||||
const tripColumns = [
|
||||
{ key: 'trainName', label: 'Train', render: (item: any) => item.trainName || item.train?.name },
|
||||
{ key: 'route', label: 'Route', render: (item: any) => `${item.originStation?.name || item.origin?.name} → ${item.destinationStation?.name || item.destination?.name}` },
|
||||
{ key: 'departure', label: 'Departure', render: (item: any) => formatDateTime(item.departureAt) },
|
||||
{ key: 'seats', label: 'Seats', render: (item: any) => `${item.availableSeats || 0}/${item.totalSeats || 0}` },
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (item: any) => (
|
||||
<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's what's happening today.</p>
|
||||
<p className="text-muted-foreground mt-1">Welcome back! Here'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"
|
||||
value={statsLoading ? '...' : (stats?.totalBookings || 0).toLocaleString()}
|
||||
icon={Ticket}
|
||||
color="blue"
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Revenue"
|
||||
@@ -74,35 +133,107 @@ export default function DashboardPage() {
|
||||
<StatCard
|
||||
title="Occupancy Rate"
|
||||
value={statsLoading ? '...' : `${stats?.occupancyRate || 0}%`}
|
||||
icon={TrendingUp}
|
||||
color="green"
|
||||
icon={Percent}
|
||||
color="orange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!revenueLoading && revenueData && revenueData.length > 0 && (
|
||||
{/* 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>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={revenueData}>
|
||||
<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) => formatCurrency(value, 'ETB')} />
|
||||
<Line type="monotone" dataKey="revenue" stroke="#2563eb" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</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">Revenue Trend (Last 30 Days)</h2>
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Payment Methods Distribution</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={revenueData}>
|
||||
<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) => formatCurrency(value, 'ETB')} />
|
||||
<Line type="monotone" dataKey="revenue" stroke="#2563eb" strokeWidth={2} />
|
||||
</LineChart>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
949
apps/edr-passenger-web/backoffice/src/app/docs/page.tsx
Normal file
949
apps/edr-passenger-web/backoffice/src/app/docs/page.tsx
Normal 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;
|
||||
537
apps/edr-passenger-web/backoffice/src/app/how-to/page.tsx
Normal file
537
apps/edr-passenger-web/backoffice/src/app/how-to/page.tsx
Normal 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'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>"CANCELLED"</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;
|
||||
@@ -1,17 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { Train } from 'lucide-react';
|
||||
import { useTheme } from '@/lib/theme-store';
|
||||
import { Train, Eye, EyeOff, Sun, Moon } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const { login } = useAuthStore();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -29,77 +37,109 @@ export default function LoginPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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 border border-red-200 dark:border-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<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="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="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<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="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>
|
||||
<h1 className="text-5xl font-bold text-white mb-4">EDR</h1>
|
||||
<p className="text-lg text-white/80">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" />
|
||||
</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>
|
||||
</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">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Report Type</label>
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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 })}
|
||||
>
|
||||
<option value="">All Types</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (<5) — first child travels free, subsequent children pay full fare
|
||||
</li>
|
||||
<li>
|
||||
• <strong>Age-Based Pricing:</strong> ADULT (5+ years) pays 100%, CHILD (<5) first child FREE, subsequent children 100%
|
||||
• <strong>Nationality-based:</strong> Override fares for specific nationalities (Ethiopian, Djiboutian, Other)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -732,28 +765,44 @@ 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>
|
||||
<label className="label">Nationality (Optional)</label>
|
||||
<select
|
||||
value={fareForm.nationality}
|
||||
onChange={(e) => setFareForm({ ...fareForm, nationality: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="">All Nationalities</option>
|
||||
<option value="Ethiopian">Ethiopian</option>
|
||||
<option value="Djiboutian">Djiboutian</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
<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
|
||||
value={fareForm.nationality}
|
||||
onChange={(e) => setFareForm({ ...fareForm, nationality: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="">All Nationalities</option>
|
||||
<option value="Ethiopian">Ethiopian</option>
|
||||
<option value="Djiboutian">Djiboutian</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -846,28 +895,44 @@ 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>
|
||||
<label className="label">Nationality (Optional)</label>
|
||||
<select
|
||||
value={segmentForm.nationality}
|
||||
onChange={(e) => setSegmentForm({ ...segmentForm, nationality: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="">All Nationalities</option>
|
||||
<option value="Ethiopian">Ethiopian</option>
|
||||
<option value="Djiboutian">Djiboutian</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
<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
|
||||
value={segmentForm.nationality}
|
||||
onChange={(e) => setSegmentForm({ ...segmentForm, nationality: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="">All Nationalities</option>
|
||||
<option value="Ethiopian">Ethiopian</option>
|
||||
<option value="Djiboutian">Djiboutian</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={revenueByRoute}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="route" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip formatter={(value: number) => formatCurrency(value, 'ETB')} />
|
||||
<Bar dataKey="revenue" fill="#2563eb" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">Bookings by Class</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={bookingsByClass}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, value }) => `${name}: ${value}%`}
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{bookingsByClass.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{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 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>
|
||||
|
||||
<div className="card">
|
||||
<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 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 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}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<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="text-lg font-semibold mb-4">Booking Status</h3>
|
||||
{bookings.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
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}`}
|
||||
outerRadius={100}
|
||||
dataKey="value"
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* Top Payment Methods */}
|
||||
<div className="card">
|
||||
<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>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -220,28 +220,15 @@ export default function RoutesPage() {
|
||||
setOriginStationId(routeStops[0].stationId);
|
||||
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
|
||||
|
||||
// Calculate cumulative distance for destination
|
||||
let cumulativeDistance = 0;
|
||||
routeStops.forEach((stop: any, idx: number) => {
|
||||
if (idx > 0) {
|
||||
cumulativeDistance += stop.distanceKm || 0;
|
||||
}
|
||||
});
|
||||
setDestinationDistance(cumulativeDistance);
|
||||
|
||||
// Calculate distance from origin for middle stops
|
||||
const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => {
|
||||
let distFromOrigin = 0;
|
||||
for (let i = 1; i <= idx + 1; i++) {
|
||||
distFromOrigin += routeStops[i].distanceKm || 0;
|
||||
}
|
||||
return {
|
||||
stationId: stop.stationId,
|
||||
sequence: stop.sequence,
|
||||
distanceKm: stop.distanceKm,
|
||||
distanceFromOrigin: distFromOrigin,
|
||||
};
|
||||
});
|
||||
// Last stop's distanceKm is already cumulative from origin
|
||||
setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0);
|
||||
|
||||
const middleStops = routeStops.slice(1, -1).map((stop: any) => ({
|
||||
stationId: stop.stationId,
|
||||
sequence: stop.sequence,
|
||||
distanceKm: stop.distanceKm,
|
||||
distanceFromOrigin: stop.distanceKm || 0,
|
||||
}));
|
||||
setStops(middleStops);
|
||||
}
|
||||
setShowModal(true);
|
||||
|
||||
@@ -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)}` : ''}
|
||||
</div>
|
||||
))}
|
||||
{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,22 @@ export default function SeatsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const coachesWithSeats = coaches.filter((coach: any) => {
|
||||
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||
return seats.length > 0;
|
||||
});
|
||||
const coachesWithSeats = coaches
|
||||
.filter((coach: any) => {
|
||||
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||
return seats.length > 0;
|
||||
})
|
||||
.sort((a: any, b: any) => (a.sequence || 0) - (b.sequence || 0));
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Seat Management</h1>
|
||||
<p className="text-muted-foreground mt-1">View and manage seats by coach</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="mb-6">
|
||||
{!selectedSchedule ? (
|
||||
<div className="card">
|
||||
<label className="label">Select Schedule</label>
|
||||
<select
|
||||
value={selectedSchedule}
|
||||
@@ -363,69 +443,122 @@ export default function SeatsPage() {
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!selectedSchedule ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<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>
|
||||
) : 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>
|
||||
) : coachesWithSeats.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No coaches with seats found for this schedule</p>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
) : 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}
|
||||
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>
|
||||
|
||||
<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'));
|
||||
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||
<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>
|
||||
<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>
|
||||
<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="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-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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-900/30 rounded-lg w-64 border border-gray-200 dark:border-gray-700 p-2">
|
||||
{renderCoachSeats(coach, isBedCoach)}
|
||||
</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="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>
|
||||
|
||||
{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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
@@ -78,16 +80,15 @@ const navigationSections = [
|
||||
items: [
|
||||
{ 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 },
|
||||
{ name: 'Notifications', href: '/notifications', icon: Bell },
|
||||
]
|
||||
},
|
||||
{
|
||||
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 },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
render?: (item: T) => ReactNode;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
250
apps/edr-passenger-web/portal/PAYMENT_FLOW.md
Normal file
250
apps/edr-passenger-web/portal/PAYMENT_FLOW.md
Normal 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
|
||||
¤cy=DJF
|
||||
&orderId=1209631
|
||||
&referenceId=17815888579838ddc23b3
|
||||
&responseCode=0
|
||||
&responseMsg=Approved+(sandbox+mode)
|
||||
&state=APPROVED
|
||||
&transactionId=1318559
|
||||
&txAmount=367.50
|
||||
&paymentMethod=MWALLET_ACCOUNT
|
||||
×tamp=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
|
||||
148
apps/edr-passenger-web/portal/TELEBIRR_PAYMENT_FLOW.md
Normal file
148
apps/edr-passenger-web/portal/TELEBIRR_PAYMENT_FLOW.md
Normal 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
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
// Simulate payment processing
|
||||
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');
|
||||
// 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));
|
||||
|
||||
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,12 +141,14 @@ 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);
|
||||
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [bookingId, pnr, router]);
|
||||
|
||||
@@ -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,42 +257,73 @@ 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>
|
||||
<div className="space-y-3">
|
||||
{paymentMethods.map((method) => {
|
||||
const Icon = method.icon;
|
||||
const isSelected = selectedMethod === method.id;
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
onClick={() => setSelectedMethod(method.id)}
|
||||
disabled={isProcessing}
|
||||
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' : ''}`}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="w-6 h-6 bg-primary rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="w-5 h-5 text-white" />
|
||||
<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 = getIconForMethod(method.type);
|
||||
const isSelected = selectedMethod === method.type;
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
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 || !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>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<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">
|
||||
<CheckCircle className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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 ? (
|
||||
@@ -279,7 +346,7 @@ export default function PaymentPage() {
|
||||
`Pay ETB ${(totalAmount / 100).toFixed(2)}`
|
||||
)}
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
disabled={isProcessing}
|
||||
@@ -288,12 +355,13 @@ export default function PaymentPage() {
|
||||
Back to review
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Error Message */}
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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,299 +578,219 @@ 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" />
|
||||
{/* ── 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/60 text-sm md:text-base mt-3">Book your train journey across East Africa</p>
|
||||
</div>
|
||||
<div className="container mx-auto px-4 pt-8 pb-20 md:pt-10 md:pb-24 relative z-10">
|
||||
|
||||
{/* ── 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">
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white leading-tight">
|
||||
Where are you headed?
|
||||
</h1>
|
||||
<p className="text-white/70 text-sm md:text-base mt-2">Search and book train tickets fast & easy</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-white/20 overflow-visible">
|
||||
|
||||
{/* Search Card — pulled up over the hero */}
|
||||
<div className="container mx-auto px-4 -mt-14 relative z-20 pb-8">
|
||||
<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">
|
||||
|
||||
{/* 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">
|
||||
<span>⚠️</span>
|
||||
<span>Unable to load stations. Please check your connection.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-5 md:p-6 space-y-3">
|
||||
|
||||
{/* ── Row 1 (mobile stacked): Stations + Date ── */}
|
||||
|
||||
{/* Mobile station fields */}
|
||||
<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'
|
||||
}`}>
|
||||
<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'}`}>
|
||||
{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">
|
||||
<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'
|
||||
}`}>
|
||||
<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'}`}>
|
||||
{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>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
{error && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ── Row 2: Passengers | Nationality | Search Button ── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 items-end">
|
||||
<div className="p-4 md:p-5">
|
||||
|
||||
{/* 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">
|
||||
{/* 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 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' : 'text-gray-400'}`}>
|
||||
{originStation?.name ?? 'Select departure'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<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={() => { 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' : 'text-gray-400'}`}>
|
||||
{destStation?.name ?? 'Select destination'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<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>
|
||||
</div>
|
||||
{/* 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"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Promo Code (collapsed by default) ── */}
|
||||
<div>
|
||||
{!promoVisible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPromoVisible(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-primary font-medium hover:underline transition-colors"
|
||||
>
|
||||
<Gift className="w-3.5 h-3.5" />
|
||||
Apply Promo Code
|
||||
<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 className="flex flex-col gap-1.5 animate-bounce-in">
|
||||
<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}
|
||||
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
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
{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"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{promoValidation && (
|
||||
<div className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{promoValidation.valid && <Check className="w-3.5 h-3.5" />}
|
||||
{promoValidation.message}
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
<Gift className="w-3.5 h-3.5" />
|
||||
Apply Promo Code
|
||||
</button>
|
||||
) : (
|
||||
<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}
|
||||
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 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 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 rounded-xl hover:bg-gray-100">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{promoValidation && (
|
||||
<div className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{promoValidation.valid && <Check className="w-3.5 h-3.5" />}
|
||||
{promoValidation.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</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 & 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);
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
.animate-slide-up { animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1); }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -85,4 +85,4 @@ export default {
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
21
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user