Merge branch 'alpha' into passenger/feat/iam

This commit is contained in:
Abubeker Yasin
2026-06-23 14:47:24 +03:00
37 changed files with 1271 additions and 1048 deletions

View File

@@ -20,7 +20,7 @@ CREATE TYPE "IdDocumentType" AS ENUM ('NATIONAL_ID', 'PASSPORT', 'DRIVING_LICENS
CREATE TYPE "Currency" AS ENUM ('ETB', 'DJF', 'USD');
-- CreateEnum
CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW', 'REFUNDED');
CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'BOARDED', 'NO_SHOW', 'REFUNDED');
-- CreateEnum
CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL');
@@ -76,7 +76,9 @@ CREATE TABLE "SeatClass" (
"coachTypeId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"baseFareMinor" INTEGER NOT NULL,
"baseFareMinor" INTEGER NOT NULL DEFAULT 0,
"premiumMinor" INTEGER NOT NULL DEFAULT 0,
"insuranceFeeMinor" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
@@ -94,6 +96,8 @@ CREATE TABLE "User" (
"role" "UserRole" NOT NULL DEFAULT 'PASSENGER',
"nationality" TEXT,
"nationalityCode" TEXT,
"gender" TEXT,
"dateOfBirth" TIMESTAMP(3),
"passportNumber" TEXT,
"nationalId" TEXT,
"failedLoginAttempts" INTEGER NOT NULL DEFAULT 0,
@@ -155,10 +159,11 @@ CREATE TABLE "Station" (
"name" TEXT NOT NULL,
"city" TEXT NOT NULL,
"countryCode" TEXT,
"sequence" INTEGER NOT NULL DEFAULT 0,
"isOperational" BOOLEAN NOT NULL DEFAULT true,
"timezone" TEXT NOT NULL DEFAULT 'Africa/Addis_Ababa',
"lat" DECIMAL(9,6) NOT NULL,
"lng" DECIMAL(9,6) NOT NULL,
"lat" DECIMAL(9,6),
"lng" DECIMAL(9,6),
CONSTRAINT "Station_pkey" PRIMARY KEY ("id")
);
@@ -234,6 +239,7 @@ CREATE TABLE "Coach" (
"number" TEXT NOT NULL,
"arrangement" TEXT NOT NULL DEFAULT '2+2',
"capacity" INTEGER NOT NULL DEFAULT 0,
"sequence" INTEGER NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

View File

@@ -140,11 +140,7 @@ ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
-- AlterTable
ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
-- AlterTable
-- gender is created here on a clean migration history (no prior migration adds it);
-- on an already-drifted DB where it exists as varchar, normalize it to TEXT.
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "gender" TEXT;
ALTER TABLE "User" ALTER COLUMN "gender" SET DATA TYPE TEXT;
-- gender column already TEXT from init migration
-- CreateIndex
CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType");

View File

@@ -0,0 +1,2 @@
-- Empty placeholder migration
SELECT 1;

View File

@@ -1,36 +1,9 @@
-- 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;
CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "Station"("sequence");
-- 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;
CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "Coach"("sequence");
-- 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");
CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "Station"("city", "countryCode");
CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "Coach"("coachTypeId");
CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "TrainSchedule"("departureAt", "originStationId");
CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status");

View File

@@ -1,164 +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 "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey";
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "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 "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey";
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "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 "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey";
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "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;
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey";
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "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;
ALTER TABLE "Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey";
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "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 "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey";
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "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;
ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey";
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "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 "Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey";
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "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;
ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey";
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "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 "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey";
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "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;
ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey";
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "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;
ALTER TABLE "PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey";
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "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;
ALTER TABLE "PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey";
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "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;
ALTER TABLE "Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey";
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "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;
ALTER TABLE "TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "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;
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey";
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "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;
ALTER TABLE "Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey";
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "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 "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey";
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "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;
ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey";
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "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;
ALTER TABLE "FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey";
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "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;
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey";
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "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;
ALTER TABLE "FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey";
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "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;
ALTER TABLE "SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey";
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "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;
ALTER TABLE "TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey";
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "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;
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey";
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "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 "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey";
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "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;
ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey";
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "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 "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey";
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "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;
ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey";
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "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;
ALTER TABLE "AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey";
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "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;
ALTER TABLE "AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey";
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "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;
ALTER TABLE "BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey";
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "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;
ALTER TABLE "BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey";
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "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;
ALTER TABLE "GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey";
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "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;
ALTER TABLE "BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey";
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "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;
ALTER TABLE "RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey";
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "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;
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey";
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "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;
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey";
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "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;
ALTER TABLE "SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey";
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "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;
ALTER TABLE "SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey";
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "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;
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey";
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "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;
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey";
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "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;
ALTER TABLE "FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey";
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;

View File

@@ -1,18 +1,18 @@
-- CreateEnum
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
CREATE TYPE "ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
-- AlterTable: add return leg tracking columns to Booking
ALTER TABLE "passenger"."Booking"
ADD COLUMN "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
ALTER TABLE "Booking"
ADD COLUMN "returnLegStatus" "ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
ADD COLUMN "outboundBoardedAt" TIMESTAMP(3),
ADD COLUMN "returnBoardedAt" TIMESTAMP(3);
-- Set NEITHER_USED for existing confirmed round-trip bookings
UPDATE "passenger"."Booking"
UPDATE "Booking"
SET "returnLegStatus" = 'NEITHER_USED'
WHERE "bookingType" = 'ROUND_TRIP'
AND "status" IN ('CONFIRMED', 'COMPLETED');
AND "status" IN ('CONFIRMED', 'BOARDED');
-- AlterTable: add leg column to GateValidationLog
ALTER TABLE "passenger"."GateValidationLog"
ALTER TABLE "GateValidationLog"
ADD COLUMN "leg" TEXT;

View File

@@ -1,7 +1,7 @@
-- Create passenger schema if it doesn't exist
CREATE SCHEMA IF NOT EXISTS passenger;
-- Move all enums from public to passenger schema
-- Move enums from public to passenger schema (only if they exist in public)
DO $$
DECLARE
e text;
@@ -13,9 +13,10 @@ BEGIN
LOOP
EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e);
END LOOP;
EXCEPTION WHEN others THEN NULL;
END $$;
-- Move all tables from public to passenger schema
-- Move tables from public to passenger schema (only if they exist in public)
DO $$
DECLARE
t text;
@@ -26,6 +27,7 @@ BEGIN
LOOP
EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t);
END LOOP;
EXCEPTION WHEN others THEN NULL;
END $$;
-- Add missing columns to Booking

View File

@@ -0,0 +1,14 @@
-- Add bookingId to Journey for per-booking segment release
ALTER TABLE "passenger"."Journey"
ADD COLUMN IF NOT EXISTS "bookingId" TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS "Journey_bookingId_key" ON "passenger"."Journey"("bookingId");
CREATE INDEX IF NOT EXISTS "Journey_bookingId_idx" ON "passenger"."Journey"("bookingId");
-- Ensure JourneySegment cascades on Journey delete
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;

View File

@@ -108,7 +108,7 @@ enum BookingStatus {
PENDING_PAYMENT
CONFIRMED
CANCELLED
COMPLETED
BOARDED
NO_SHOW
REFUNDED
@@ -319,8 +319,8 @@ model Station {
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)
lat Decimal? @db.Decimal(9, 6)
lng Decimal? @db.Decimal(9, 6)
originSchedules TrainSchedule[] @relation("OriginTrips")
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
stopTimes TripStopTime[]
@@ -542,6 +542,7 @@ model Booking {
modifications BookingModification[]
cancellation BookingCancellation?
baggage BaggageBooking[]
journey Journey?
@@index([passengerId, status])
@@index([bookingType])
@@ -933,10 +934,12 @@ model SavedRoute {
model Journey {
id String @id @default(uuid())
passengerId String
bookingId String? @unique
status String
totalMinor Int
currency String @default("ETB")
createdAt DateTime @default(now())
booking Booking? @relation(fields: [bookingId], references: [id])
journeySegments JourneySegment[]
@@schema("passenger")
}

View File

@@ -216,6 +216,7 @@ export class BookingsService {
...(matchedPassengers.length > 0
? [{ passengerId: { in: matchedPassengers.map(p => p.id) } }]
: []),
{ seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
];
}
@@ -268,6 +269,7 @@ export class BookingsService {
passenger: iam
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
: null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
@@ -294,10 +296,23 @@ export class BookingsService {
return this.createOneWayBooking(dto);
}
private validateSeatIdsAgainstHold(holdId: string, holdSeatIds: string[], requestedSeatIds: string[]) {
for (const seatId of requestedSeatIds) {
if (!holdSeatIds.includes(seatId)) {
throw new BadRequestException(
`Seat ${seatId} is not part of hold ${holdId}. Use seat IDs returned from POST /seats/hold.`,
);
}
}
}
private async createOneWayBooking(dto: CreateBookingDto) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
const requestedSeatIds = (dto.passengers as any[]).map(p => p.seatId);
this.validateSeatIdsAgainstHold(dto.holdId, hold.seatIds, requestedSeatIds);
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
@@ -367,6 +382,11 @@ export class BookingsService {
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired');
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired');
const holdObSeatIds = (dto.passengers as any[]).map((p: any) => p.seatId ?? p.outboundSeatId).filter(Boolean);
const holdRetSeatIds = (dto.passengers as any[]).map((p: any) => p.returnSeatId).filter(Boolean);
if (holdObSeatIds.length) this.validateSeatIdsAgainstHold(dto.holdId, outboundHold.seatIds, holdObSeatIds);
if (holdRetSeatIds.length) this.validateSeatIdsAgainstHold(dto.returnHoldId!, returnHold.seatIds, holdRetSeatIds);
const [outboundSchedule, returnSchedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
@@ -510,6 +530,11 @@ export class BookingsService {
if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired');
if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired');
const leg1SeatIds = (dto.passengers as any[]).map(p => p.seatId);
const leg2SeatIds = (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId);
this.validateSeatIdsAgainstHold(dto.holdId, leg1Hold.seatIds, leg1SeatIds);
this.validateSeatIdsAgainstHold(dto.leg2HoldId!, leg2Hold.seatIds, leg2SeatIds);
const [leg1Schedule, leg2Schedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
@@ -656,6 +681,11 @@ export class BookingsService {
if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 seat hold expired');
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 seat hold expired');
this.validateSeatIdsAgainstHold(dto.holdId, obL1Hold.seatIds, (dto.passengers as any[]).map(p => p.seatId));
this.validateSeatIdsAgainstHold(dto.leg2HoldId!, obL2Hold.seatIds, (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId));
this.validateSeatIdsAgainstHold(dto.returnHoldId!, retL1Hold.seatIds, (dto.passengers as any[]).map(p => p.returnSeatId));
this.validateSeatIdsAgainstHold(dto.returnLeg2HoldId!, retL2Hold.seatIds, (dto.passengers as any[]).map(p => p.returnLeg2SeatId ?? p.returnSeatId));
// Load all 4 schedules
const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
@@ -847,7 +877,21 @@ export class BookingsService {
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
}
processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
processedPassengers.push({
...passenger,
passengerName,
dateOfBirth,
category,
verifaydaVerified,
verifaydaData,
nationality,
// Normalise: PassengerInputDto uses seatId/returnSeatId; RoundTripPassengerDto uses
// outboundSeatId/returnSeatId. Accept either form so both DTOs work.
outboundSeatId: passenger.outboundSeatId ?? passenger.seatId,
outboundLeg2SeatId: passenger.outboundLeg2SeatId ?? passenger.leg2SeatId,
returnSeatId: passenger.returnSeatId,
returnLeg2SeatId: passenger.returnLeg2SeatId,
});
}
return processedPassengers;
}
@@ -1042,7 +1086,7 @@ export class BookingsService {
await this.prisma.bookingModification.create({
data: { bookingId: booking.id, modifiedBy: booking.passengerId, modificationType: 'SEAT_CHANGE', oldData: { scheduleId: booking.scheduleId, seatIds: oldSeats }, newData: { scheduleId: dto.newScheduleId, seatIds: dto.newSeatIds }, fareAdjustment: 0, reason: dto.reason },
});
await this.seatsService.releaseSeats(oldSeats);
await this.seatsService.releaseSeats(booking.id);
await this.seatsService.confirmSeats(dto.newSeatIds);
return { modified: true, bookingRef: dto.bookingRef };
}
@@ -1053,7 +1097,7 @@ export class BookingsService {
if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled');
const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0;
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
await this.seatsService.releaseSeats(booking.id);
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
this.eventEmitter.emit('booking.cancelled', { booking, refundAmount });
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
@@ -1082,7 +1126,7 @@ export class BookingsService {
const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } });
if (!booking) throw new NotFoundException('Booking not found');
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
await this.seatsService.releaseSeats(booking.id);
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } });
await this.prisma.booking.delete({ where: { id } });
@@ -1118,7 +1162,7 @@ export class BookingsService {
const cutoff = new Date(Date.now() - 20 * 60 * 1000);
const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } });
for (const b of expired) {
await this.seatsService.releaseSeats(b.seats.map((s) => s.seatId));
await this.seatsService.releaseSeats(b.id);
await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } });
}
}

View File

@@ -14,6 +14,21 @@ function generateRef(): string {
return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
// Ethiopian mobile prefixes: Ethio Telecom (09xx) and Safaricom ET (07xx)
const ETH_MOBILE_PREFIXES = ['911','912','913','914','915','916','917','921','922','923','924','930','931','932','933','934','935','936','937','938','939','961','962','963','964'];
function generateEthiopianPhone(): string {
const prefix = ETH_MOBILE_PREFIXES[Math.floor(Math.random() * ETH_MOBILE_PREFIXES.length)];
const suffix = String(Math.floor(Math.random() * 1_000_000)).padStart(6, '0');
return `+251${prefix}${suffix}`;
}
function generateGuestEmail(uniqueId: string): string {
const domains = ['gmail.com', 'yahoo.com', 'ethionet.et', 'telecom.et'];
const domain = domains[Math.floor(Math.random() * domains.length)];
return `guest.edr.${uniqueId}@${domain}`;
}
function calculateAge(dateOfBirth: Date): number {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();

View File

@@ -189,11 +189,11 @@ export class PassengersService {
async getStats(passengerId: string) {
const [totalTrips, totalSpendResult, loyalty] = await Promise.all([
this.prisma.booking.count({ where: { passengerId, status: 'COMPLETED' } }),
this.prisma.booking.aggregate({ where: { passengerId, status: 'COMPLETED' }, _sum: { totalMinor: true } }),
this.prisma.booking.count({ where: { passengerId, status: 'BOARDED' as any } }),
this.prisma.booking.aggregate({ where: { passengerId, status: 'BOARDED' as any }, _sum: { totalMinor: true } }),
this.prisma.loyaltyAccount.findUnique({ where: { passengerId } }),
]);
const totalSpend = (totalSpendResult._sum.totalMinor ?? 0) / 100;
const totalSpend = ((totalSpendResult._sum?.totalMinor ?? 0) as number) / 100;
return { totalTrips, totalSpend, loyaltyPoints: loyalty?.pointsBalance ?? 0, co2Saved: totalTrips * 6 };
}

View File

@@ -447,7 +447,7 @@ export class PaymentsService {
include: { seats: true },
});
if (booking) {
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
await this.seatsService.releaseSeats(booking.id);
await this.prisma.booking.update({
where: { id: dto.bookingId },
data: { status: "CANCELLED" },
@@ -746,51 +746,125 @@ export class PaymentsService {
private async createJourneySegments(
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: booking.scheduleId },
include: {
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
},
});
if (!schedule) return;
const b = booking as any;
const stopTimes = schedule.stopTimes;
if (stopTimes.length < 2) return;
// Build per-leg definitions: { scheduleId, originStationId, destinationStationId, seatIds[] }
// BookingSeat.leg: 1=outbound/leg-1, 2=return/leg-2, 3=return leg-1 (transit), 4=return leg-2
type LegDef = { scheduleId: string; originStationId: string; destinationStationId: string; seatIds: string[] };
const legDefs: LegDef[] = [];
const originSequence = stopTimes.findIndex(
(st) => st.stationId === schedule.originStationId,
);
const destSequence = stopTimes.findIndex(
(st) => st.stationId === schedule.destinationStationId,
);
const seatsForLeg = (legNum: number) =>
booking.seats.filter((s: any) => s.leg === legNum).map((s: any) => s.seatId);
if (
originSequence < 0 ||
destSequence < 0 ||
originSequence >= destSequence
)
return;
if (booking.bookingType === 'ONE_WAY') {
legDefs.push({
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
destinationStationId: b.destinationStationId,
seatIds: booking.seats.map((s: any) => s.seatId),
});
} else if (booking.bookingType === 'ROUND_TRIP') {
legDefs.push({
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
destinationStationId: b.destinationStationId,
seatIds: seatsForLeg(1),
});
if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) {
legDefs.push({
scheduleId: b.returnScheduleId,
originStationId: b.returnOriginStationId,
destinationStationId: b.returnDestinationStationId,
seatIds: seatsForLeg(2),
});
}
} else if (booking.bookingType === 'TRANSIT') {
legDefs.push({
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
destinationStationId: b.leg2OriginStationId, // transit station
seatIds: seatsForLeg(1),
});
if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) {
legDefs.push({
scheduleId: b.leg2ScheduleId,
originStationId: b.leg2OriginStationId,
destinationStationId: b.leg2DestinationStationId,
seatIds: seatsForLeg(2),
});
}
} else if (booking.bookingType === 'ROUND_TRIP_TRANSIT') {
legDefs.push({
scheduleId: booking.scheduleId,
originStationId: b.originStationId,
destinationStationId: b.leg2OriginStationId,
seatIds: seatsForLeg(1),
});
if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) {
legDefs.push({
scheduleId: b.leg2ScheduleId,
originStationId: b.leg2OriginStationId,
destinationStationId: b.leg2DestinationStationId,
seatIds: seatsForLeg(2),
});
}
if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) {
legDefs.push({
scheduleId: b.returnScheduleId,
originStationId: b.returnOriginStationId,
destinationStationId: b.returnLeg2OriginStationId ?? b.returnDestinationStationId,
seatIds: seatsForLeg(3),
});
}
if (b.returnLeg2ScheduleId && b.returnLeg2OriginStationId && b.returnLeg2DestStationId) {
legDefs.push({
scheduleId: b.returnLeg2ScheduleId,
originStationId: b.returnLeg2OriginStationId,
destinationStationId: b.returnLeg2DestStationId,
seatIds: seatsForLeg(4),
});
}
}
if (legDefs.length === 0) return;
const journey = await this.prisma.journey.create({
data: {
passengerId: booking.passengerId,
status: "CONFIRMED",
totalMinor: booking.totalMinor,
currency: booking.currency,
bookingId: booking.id,
status: 'CONFIRMED',
totalMinor: booking.totalMinor,
currency: booking.currency,
},
});
const journeySegments = [];
for (const bookingSeat of booking.seats) {
for (let i = originSequence; i < destSequence; i++) {
journeySegments.push({
journeyId: journey.id,
scheduleId: booking.scheduleId,
segmentOrder: i,
seatId: bookingSeat.seatId,
departureStationId: stopTimes[i].stationId,
arrivalStationId: stopTimes[i + 1].stationId,
});
const journeySegments: any[] = [];
let segmentOrder = 0;
for (const leg of legDefs) {
if (leg.seatIds.length === 0) continue;
const stopTimes = await this.prisma.tripStopTime.findMany({
where: { scheduleId: leg.scheduleId },
orderBy: { sequence: 'asc' },
select: { stationId: true, sequence: true },
});
const originIdx = stopTimes.findIndex(st => st.stationId === leg.originStationId);
const destIdx = stopTimes.findIndex(st => st.stationId === leg.destinationStationId);
if (originIdx < 0 || destIdx < 0 || originIdx >= destIdx) continue;
for (const seatId of leg.seatIds) {
for (let i = originIdx; i < destIdx; i++) {
journeySegments.push({
journeyId: journey.id,
scheduleId: leg.scheduleId,
segmentOrder: segmentOrder++,
seatId,
departureStationId: stopTimes[i].stationId,
arrivalStationId: stopTimes[i + 1].stationId,
});
}
}
}

View File

@@ -39,6 +39,23 @@ export class SearchService {
const outbound = [...direct, ...transit];
if (outbound.length === 0) {
const alternativesOutbound = await this.searchAlternatives(
dto.originStationId,
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
);
return {
journeyType: dto.journeyType === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY',
outbound: [],
alternativeOutbound: alternativesOutbound,
requestedDate: dto.date,
};
}
if (dto.journeyType === 'ROUND_TRIP') {
const [returnDirect, returnTransit] = await Promise.all([
this.searchSchedules(
@@ -68,12 +85,85 @@ export class SearchService {
new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival
);
if (inbound.length === 0) {
const alternativeInbound = await this.searchAlternatives(
dto.destinationStationId,
dto.originStationId,
dto.returnDate ?? dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
);
return { journeyType: 'ROUND_TRIP', outbound, inbound: [], alternativeInbound };
}
return { journeyType: 'ROUND_TRIP', outbound, inbound };
}
return { journeyType: 'ONE_WAY', outbound };
}
private async searchAlternatives(
originStationId: string,
destinationStationId: string,
dateStr: string,
adultCount: number,
childCount?: number,
nationality?: string,
) {
const [y, m, d] = dateStr.split('-').map(Number);
const requestedDate = new Date(y, m - 1, d, 0, 0, 0, 0);
const now = new Date();
const daysBefore = Math.min(7, Math.floor(requestedDate.getTime() / 86_400_000));
const daysAfter = 14 - daysBefore;
const windowStart = new Date(requestedDate);
windowStart.setDate(windowStart.getDate() - daysBefore);
if (windowStart < now) windowStart.setTime(now.getTime());
const windowEnd = new Date(requestedDate);
windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); // exclusive upper bound
const totalPassengers = adultCount + (childCount ?? 0);
const requestedNextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
OR: [
{ departureAt: { gte: windowStart, lt: requestedDate } },
{ departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } },
],
stopTimes: { some: { stationId: originStationId } },
},
include: {
train: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
},
},
orderBy: { departureAt: 'asc' },
});
const results: any[] = [];
for (const schedule of schedules) {
const result = await this.buildScheduleResult(
schedule,
originStationId,
destinationStationId,
totalPassengers,
nationality,
);
if (result) results.push(result);
}
return results;
}
private async searchSchedules(
originStationId: string,
destinationStationId: string,
@@ -85,12 +175,13 @@ export class SearchService {
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 now = new Date();
const totalPassengers = adultCount + (childCount ?? 0);
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
departureAt: { gte: date, lt: nextDay },
departureAt: { gte: date < now ? now : date, lt: nextDay },
stopTimes: { some: { stationId: originStationId } },
},
include: {

View File

@@ -11,7 +11,7 @@ export class SeatsService {
private segmentsService: SegmentsService,
) {}
async getSeatMap(scheduleId: string, coachId?: string) {
async getSeatMap(scheduleId: string, coachId?: string, originStationId?: string, destinationStationId?: string) {
const assignments = await this.prisma.coachAssignment.findMany({
where: { scheduleId, ...(coachId ? { coachId } : {}) },
include: {
@@ -25,16 +25,14 @@ export class SeatsService {
orderBy: { positionNumber: 'asc' },
});
console.log(`[getSeatMap] scheduleId=${scheduleId}, coachId=${coachId}, found ${assignments.length} coach assignments`);
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds);
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, originStationId, destinationStationId);
const response = {
return {
coaches: assignments.map((a) => {
const allSeats = a.coach.seats;
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
return {
id: a.coach.id,
assignmentId: a.id,
@@ -68,43 +66,95 @@ export class SeatsService {
};
}),
};
console.log(`[getSeatMap] returning ${response.coaches.length} coaches with seats`);
return response;
}
async resolveEffectiveStatuses(
scheduleId: string,
seatIds: string[],
originStationId?: string,
destinationStationId?: string,
): Promise<Map<string, string>> {
const statusMap = new Map<string, string>();
if (seatIds.length === 0) return statusMap;
// Resolve the requested leg's sequence range once
let reqFrom: number | undefined;
let reqTo: number | undefined;
let allStopTimes: { stationId: string; sequence: number }[] | null = null;
const getStopTimes = async () => {
if (!allStopTimes) {
allStopTimes = await this.prisma.tripStopTime.findMany({
where: { scheduleId },
select: { stationId: true, sequence: true },
});
}
return allStopTimes;
};
if (originStationId && destinationStationId) {
const stops = await getStopTimes();
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
reqFrom = seqOf(originStationId);
reqTo = seqOf(destinationStationId);
}
// ── Active holds ──────────────────────────────────────────────────────────
const activeHolds = await this.prisma.seatHold.findMany({
where: {
scheduleId,
expiresAt: { gt: new Date() },
seatIds: { hasSome: seatIds },
},
select: { seatIds: true },
where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } },
select: { seatIds: true, createdBy: true },
});
for (const hold of activeHolds) {
let holdFrom: number | undefined;
let holdTo: number | undefined;
try {
if (hold.createdBy?.trimStart().startsWith('{')) {
const meta = JSON.parse(hold.createdBy);
const stops = await getStopTimes();
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
holdFrom = seqOf(meta.originStationId);
holdTo = seqOf(meta.destinationStationId);
}
} catch { /* ignore */ }
for (const seatId of hold.seatIds) {
if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD');
if (!seatIds.includes(seatId)) continue;
if (reqFrom !== undefined && reqTo !== undefined && holdFrom !== undefined && holdTo !== undefined) {
if (holdFrom < reqTo && reqFrom < holdTo) statusMap.set(seatId, 'HELD');
} else {
statusMap.set(seatId, 'HELD');
}
}
}
// ── Confirmed bookings via JourneySegment ─────────────────────────────────
const bookedSegments = await this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId: { in: seatIds },
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
select: { seatId: true },
select: { seatId: true, departureStationId: true, arrivalStationId: true },
});
for (const seg of bookedSegments) {
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
if (reqFrom !== undefined && reqTo !== undefined) {
const stops = await getStopTimes();
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
for (const seg of bookedSegments) {
if (!seg.seatId) continue;
const segFrom = seqOf(seg.departureStationId);
const segTo = seqOf(seg.arrivalStationId);
if (segFrom !== undefined && segTo !== undefined) {
if (segFrom < reqTo && reqFrom < segTo) statusMap.set(seg.seatId, 'BOOKED');
} else {
statusMap.set(seg.seatId, 'BOOKED');
}
}
} else {
for (const seg of bookedSegments) {
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
}
}
return statusMap;
@@ -154,6 +204,7 @@ export class SeatsService {
if (reqFrom >= reqTo)
throw new BadRequestException('Origin must come before destination');
// ── Check existing holds for overlap ────────────────────────────────────
const activeHolds = await tx.seatHold.findMany({
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
select: { seatIds: true, createdBy: true },
@@ -197,6 +248,29 @@ export class SeatsService {
}
}
// ── Check confirmed JourneySegments for overlap ──────────────────────────
const bookedSegments = await tx.journeySegment.findMany({
where: {
scheduleId: dto.scheduleId,
seatId: { in: seatIds },
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
select: { seatId: true, departureStationId: true, arrivalStationId: true },
});
for (const seg of bookedSegments) {
if (!seg.seatId) continue;
const segFrom = seqOf(seg.departureStationId);
const segTo = seqOf(seg.arrivalStationId);
if (segFrom !== undefined && segTo !== undefined) {
if (segFrom < reqTo && reqFrom < segTo) {
throw new ConflictException(
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`,
);
}
}
}
const holdMeta = {
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
@@ -347,16 +421,12 @@ export class SeatsService {
return { released: true, holdId };
}
async confirmSeats(seatIds: string[]) {
// No-op
}
// Physical seat.status stays AVAILABLE — segment rows are the source of truth for occupancy.
async confirmSeats(_seatIds: string[]) {}
async releaseSeats(seatIds: string[]) {
if (seatIds.length > 0) {
await this.prisma.journeySegment.deleteMany({
where: { seatId: { in: seatIds } },
});
}
// Delete the Journey (and its JourneySegments) scoped to this booking.
async releaseSeats(bookingId: string) {
await this.prisma.journey.deleteMany({ where: { bookingId } });
}
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
@@ -424,7 +494,7 @@ export class SeatsService {
invalid++;
continue;
}
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
const [coachId, , row, col, seatNumber] = parts;
if (!coachId || !row || !col || !seatNumber) {
errors.push(`Line ${i + 2}: Missing required fields`);
invalid++;
@@ -448,7 +518,7 @@ export class SeatsService {
for (let i = 0; i < lines.length; i++) {
try {
const parts = lines[i].split(',');
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
const [coachId, , row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
await this.prisma.seat.upsert({
where: { coachId_row_col: { coachId, row: parseInt(row), col } },
@@ -481,18 +551,8 @@ export class SeatsService {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
await this.prisma.seat.update({
where: { id: seatId },
data: { status: 'BLOCKED' },
});
await this.prisma.seatBlock.create({
data: {
seatId,
reason,
blockedBy: 'system',
},
});
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } });
await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } });
return { blocked: true, seatId, reason };
}
@@ -501,14 +561,8 @@ export class SeatsService {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
await this.prisma.seat.update({
where: { id: seatId },
data: { status: 'AVAILABLE' },
});
await this.prisma.seatBlock.deleteMany({
where: { seatId },
});
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } });
await this.prisma.seatBlock.deleteMany({ where: { seatId } });
return { unblocked: true, seatId };
}
@@ -518,11 +572,9 @@ export class SeatsService {
if (!seat) throw new NotFoundException('Seat not found');
if (!seat.seatNumber) throw new BadRequestException('Seat already removed');
// Mark removed seat with negative seatNumber (e.g., '1' → '-1') to show empty space
const negatedNumber = `-${seat.seatNumber}`;
await this.prisma.seat.update({
where: { id: seatId },
data: { seatNumber: negatedNumber },
data: { seatNumber: `-${seat.seatNumber}` },
});
return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
@@ -535,26 +587,15 @@ export class SeatsService {
throw new BadRequestException('Seat is not removed');
}
// Restore original seatNumber by removing the negative sign
const originalNumber = seat.seatNumber.slice(1);
await this.prisma.seat.update({
where: { id: seatId },
data: { seatNumber: originalNumber },
});
await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: originalNumber } });
return { restored: true, seatId, seatNumber: originalNumber };
}
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
const now = new Date();
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } });
if (expired.length === 0) return;
const expiredIds = expired.map(h => h.id);
for (const hold of expired) {
await this.releaseSeats(hold.seatIds);
}
await this.prisma.seatHold.deleteMany({ where: { id: { in: expiredIds } } });
// Holds are temporary and don't create Journey rows — just delete expired ones.
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
}
}

View File

@@ -7,8 +7,8 @@ export class CreateStationDto {
@ApiProperty({ example: 'Addis Ababa' }) @IsString() city: string;
@ApiPropertyOptional() @IsOptional() @IsString() timezone?: string;
@ApiPropertyOptional() @IsOptional() @IsString() countryCode?: string;
@ApiProperty({ example: 9.0054 }) @IsNumber() lat: number;
@ApiProperty({ example: 38.7636 }) @IsNumber() lng: number;
@ApiPropertyOptional({ example: 9.0054 }) @IsOptional() @IsNumber() lat?: number;
@ApiPropertyOptional({ example: 38.7636 }) @IsOptional() @IsNumber() lng?: number;
@ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() sequence?: number;
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isOperational?: boolean;
}

View File

@@ -50,7 +50,10 @@ export class StationsService {
}
async create(dto: CreateStationDto) {
const station = await this.prisma.station.create({ data: dto });
const { lat, lng, ...rest } = dto;
const station = await this.prisma.station.create({
data: { ...rest, ...(lat !== undefined && { lat }), ...(lng !== undefined && { lng }) } as any,
});
await this.auditService.log({
userId: this.request?.user?.id,

View File

@@ -50,7 +50,8 @@ export class TicketsService {
booking: {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } },
returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { select: { id: true, iamUserId: true } },
},
},
@@ -90,6 +91,16 @@ export class TicketsService {
schedule: t.booking.schedule,
seat: t.booking.seats[0]?.seat,
status: t.booking.status,
bookingType: t.booking.bookingType,
returnLegStatus: (t.booking as any).returnLegStatus ?? null,
outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
totalMinor: t.booking.totalMinor,
displayCurrency: t.booking.displayCurrency,
displayTotalMinor: t.booking.displayTotalMinor,
contactEmail: t.booking.contactEmail,
contactPhone: t.booking.contactPhone,
returnSchedule: (t.booking as any).returnSchedule ?? null,
validatedAt: t.validatedAt,
createdAt: t.issuedAt,
};
@@ -130,7 +141,7 @@ export class TicketsService {
legs: legSummary,
});
const qrPayload = await QRCode.toDataURL(qrData);
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
const barcodePayload = `${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
const ticket = await this.prisma.ticket.upsert({
where: { bookingId },

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Filter, Download, Eye, XCircle, Trash2 } from 'lucide-react';
import { Download, Eye, XCircle, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination';
@@ -13,13 +13,21 @@ import { bookingsApi, apiClient } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { BookingFilters } from '@/types';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
export default function BookingsPage() {
const [filters, setFilters] = useState<BookingFilters>({
page: 1,
pageSize: 20,
search: '',
status: '',
});
const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' });
const [selectedBooking, setSelectedBooking] = useState<any>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
@@ -28,14 +36,8 @@ export default function BookingsPage() {
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
bookingRef: true,
passenger: true,
status: true,
bookingType: false,
passengerCount: false,
totalMinor: true,
paymentStatus: true,
createdAt: true,
bookingRef: true, bookingType: false, passengerNames: true, contactPhone: true,
contactEmail: true, passengerCount: false, paymentStatus: true, totalMinor: true, status: true, createdAt: true,
});
const queryClient = useQueryClient();
@@ -45,10 +47,6 @@ export default function BookingsPage() {
queryFn: () => bookingsApi.getAll(filters),
});
if (error) {
console.error('Bookings API Error:', error);
}
const cancelMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason),
onSuccess: () => {
@@ -56,9 +54,7 @@ export default function BookingsPage() {
setSuccessMessage('Booking cancelled successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
alert(`Error: ${error.message || 'Failed to cancel booking'}`);
},
onError: (error: any) => alert(`Error: ${error.message || 'Failed to cancel booking'}`),
});
const deleteMutation = useMutation({
@@ -77,26 +73,22 @@ export default function BookingsPage() {
});
const handleCancel = async (booking: any) => {
if (window.confirm(`Are you sure you want to cancel booking ${booking.bookingRef}? This will process a refund.`)) {
if (window.confirm(`Cancel booking ${booking.bookingRef}? This will process a refund.`)) {
await cancelMutation.mutateAsync({ id: booking.id, reason: 'Cancelled by admin' });
}
};
const handleDeleteClick = (booking: any) => {
setBookingToDelete(booking);
setDeleteConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (bookingToDelete) {
await deleteMutation.mutateAsync(bookingToDelete.id);
}
};
const BOOKING_COLS = [
{ key: 'bookingRef', label: 'Booking Reference' }, { key: 'journeyType', label: 'Journey Type' },
{ key: 'passengerNames', label: 'Passenger Names' }, { key: 'contactPhone', label: 'Contact Phone' },
{ key: 'contactEmail', label: 'Contact Email' }, { key: 'passengerCount', label: 'Passenger Count' },
{ key: 'paymentStatus', label: 'Payment Status' }, { key: 'totalMinor', label: 'Amount' },
{ key: 'status', label: 'Status' }, { key: 'createdAt', label: 'Created At' },
];
const confirmExport = () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (cols.length === 0) { alert('Please select at least one column'); return; }
if (!cols.length) { alert('Please select at least one column'); return; }
const exportItems = (data?.items || []).filter((b: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null;
@@ -104,27 +96,27 @@ export default function BookingsPage() {
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
cols.join(','),
BOOKING_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((booking: any) => {
const values = cols.map(col => {
switch (col) {
const values = BOOKING_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
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 'journeyType': return booking.bookingType || 'N/A';
case 'passengerNames': return booking.passengerNames?.join(', ') || 'N/A';
case 'contactPhone': return booking.contactPhone || 'N/A';
case 'contactEmail': return booking.contactEmail || 'N/A';
case 'passengerCount': return (booking.adultCount ?? 0) + (booking.childCount ?? 0);
case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING';
case 'createdAt': return booking.createdAt;
case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency);
case 'status': return booking.status;
case 'createdAt': return booking.createdAt ? formatDateTime(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');
@@ -136,91 +128,61 @@ export default function BookingsPage() {
const columns = [
{
key: 'bookingRef',
label: 'Reference',
sortable: true,
render: (booking: any) => (
<span className="font-mono font-semibold">{booking.bookingRef}</span>
),
},
{
key: 'passenger',
label: 'Passenger',
key: 'bookingRef', label: 'Reference', sortable: true,
render: (booking: any) => (
<div>
<div className="font-medium">{booking.passenger?.fullName || booking.contactEmail || 'Guest'}</div>
<div className="text-sm text-muted-foreground">{booking.contactPhone || booking.passenger?.phone}</div>
<div className="font-mono font-semibold">{booking.bookingRef}</div>
<div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div>
</div>
),
},
{
key: 'bookingType',
label: 'Type',
sortable: true,
render: (booking: any) => booking.bookingType || 'ONE_WAY',
},
{
key: 'passengerCount',
label: 'Passengers',
key: 'passengerNames', label: 'Names',
render: (booking: any) => {
const adults = booking.adultCount || 0;
const children = booking.childCount || 0;
if (adults === 0 && children === 0) return '—';
const parts = [`Adult: ${adults}`];
if (children > 0) parts.push(`Child: ${children}`);
return parts.join(' / ');
const names: string[] = booking.passengerNames || [];
if (!names.length) return <span className="text-muted-foreground"></span>;
return <div className="flex flex-col gap-0.5">{names.map((n, i) => <span key={i} className="text-sm">{n}</span>)}</div>;
},
},
{
key: 'status',
label: 'Status',
key: 'contact', label: 'Contact',
render: (booking: any) => (
<Badge variant="status" status={booking.status}>{booking.status}</Badge>
<div>
<div className="font-medium">{booking.contactPhone || booking.passenger?.phone}</div>
<div className="text-sm text-muted-foreground">{booking.contactEmail || booking.passenger?.email}</div>
</div>
),
},
{
key: 'totalMinor',
label: 'Amount',
sortable: true,
render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency),
key: 'passengerCount', label: 'Passengers',
render: (booking: any) => {
const adults = booking.adultCount || 0, children = booking.childCount || 0;
if (!adults && !children) return '—';
return <><div>Adult: {adults}</div><div className="text-sm text-muted-foreground">Child: {children}</div></>;
},
},
{
key: 'paymentStatus',
label: 'Payment',
key: 'paymentStatus', label: 'Payment',
render: (booking: any) => (
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>
{booking.paymentIntent?.status || 'PENDING'}
</Badge>
<div>
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>{booking.paymentIntent?.status || 'PENDING'}</Badge>
<div className="text-sm text-muted-foreground">{formatCurrency(booking.totalMinor, booking.currency)}</div>
</div>
),
},
{
key: 'createdAt',
label: 'Created',
sortable: true,
render: (booking: any) => formatDateTime(booking.createdAt),
key: 'status', label: 'Status',
render: (booking: any) => <Badge variant="status" status={booking.status}>{booking.status}</Badge>,
},
];
const actions = [
{ label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye },
{
label: 'View Details',
onClick: (booking: any) => setSelectedBooking(booking),
variant: 'secondary' as const,
icon: Eye,
},
{
label: 'Cancel Booking',
onClick: handleCancel,
variant: 'danger' as const,
icon: XCircle,
show: (booking: any) => booking.status !== 'CANCELLED' && booking.status !== 'COMPLETED',
},
{
label: 'Delete',
onClick: handleDeleteClick,
variant: 'danger' as const,
icon: Trash2,
label: 'Cancel Booking', onClick: handleCancel, variant: 'danger' as const, icon: XCircle,
show: (b: any) => b.status !== 'CANCELLED' && b.status !== 'BOARDED',
},
{ label: 'Delete', onClick: (b: any) => { setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
];
return (
@@ -235,9 +197,7 @@ export default function BookingsPage() {
<div className="card">
{successMessage && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
{successMessage}
</div>
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200"> {successMessage}</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">
@@ -246,222 +206,195 @@ export default function BookingsPage() {
)}
<div className="mb-4 flex flex-wrap gap-4">
<div className="flex-1">
<input
type="text"
placeholder="Search by reference, email, or phone..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
/>
<input type="text" placeholder="Search by reference, email, or phone..." className="input"
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
</div>
<select
className="input w-48"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}
>
<select className="input w-48" value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}>
<option value="">All Status</option>
<option value="PENDING_PAYMENT">Pending Payment</option>
<option value="CONFIRMED">Confirmed</option>
<option value="CANCELLED">Cancelled</option>
<option value="COMPLETED">Completed</option>
<option value="BOARDED">Boarded</option>
</select>
<ActionButton variant="secondary" icon={Filter}>More Filters</ActionButton>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No bookings found"
/>
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No bookings found" />
{data?.meta && (
<Pagination
currentPage={data.meta.page}
totalPages={data.meta.totalPages}
onPageChange={(page) => setFilters({ ...filters, page })}
/>
<Pagination currentPage={data.meta.page} totalPages={data.meta.totalPages}
onPageChange={(page) => setFilters({ ...filters, page })} />
)}
</div>
{/* Booking Details Modal */}
<Modal isOpen={!!selectedBooking} onClose={() => setSelectedBooking(null)} title="Booking Details" size="xl">
{selectedBooking && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Booking Reference</label>
<p className="text-lg font-semibold font-mono">{selectedBooking.bookingRef}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Status</label>
<div className="mt-1">
<Badge variant="status" status={selectedBooking.status}>{selectedBooking.status}</Badge>
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Booking Type</label>
<p className="text-lg font-semibold">{selectedBooking.bookingType || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Created</label>
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.createdAt)}</p>
</div>
</div>
<hr className="border-muted" />
{selectedBooking && (() => {
const b = selectedBooking;
const isRoundTrip = b.bookingType === 'ROUND_TRIP' || b.bookingType === 'ROUND_TRIP_TRANSIT';
return (
<div>
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Name</label>
<p className="text-lg font-semibold">{selectedBooking.passenger?.fullName || selectedBooking.contactEmail || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Email</label>
<p className="text-lg font-semibold">{selectedBooking.contactEmail || selectedBooking.passenger?.email || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Phone</label>
<p className="text-lg font-semibold">{selectedBooking.contactPhone || selectedBooking.passenger?.phone || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Passenger ID</label>
<p className="text-sm font-mono">{selectedBooking.passengerId || 'N/A'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
<div>
<h3 className="text-lg font-semibold mb-3">Journey Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Adults</label>
<p className="text-lg font-semibold">{selectedBooking.adultCount || 0}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Children</label>
<p className="text-lg font-semibold">{selectedBooking.childCount || 0}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Schedule ID</label>
<p className="text-sm font-mono">{selectedBooking.scheduleId || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Promo Code</label>
<p className="text-lg font-semibold">{selectedBooking.promoCode || 'None'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
<div>
<h3 className="text-lg font-semibold mb-3">Payment Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Amount</label>
<p className="text-lg font-semibold">{formatCurrency(selectedBooking.totalMinor, selectedBooking.currency)}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Payment Status</label>
<div className="mt-1">
<Badge variant="status" status={selectedBooking.paymentIntent?.status || 'PENDING'}>
{selectedBooking.paymentIntent?.status || 'PENDING'}
</Badge>
{/* Gradient header */}
<div className="-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r from-emerald-600 to-emerald-700 rounded-t-lg">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Booking Reference</p>
<p className="text-white text-3xl font-mono font-bold tracking-wider">{b.bookingRef}</p>
</div>
<div className="text-right shrink-0">
<Badge variant="status" status={b.status}>{b.status}</Badge>
<p className="text-emerald-200 text-xs mt-2">{formatDateTime(b.createdAt)}</p>
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Paid At</label>
<p className="text-lg font-semibold">{selectedBooking.paidAt ? formatDateTime(selectedBooking.paidAt) : 'Not paid'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Display Currency</label>
<p className="text-lg font-semibold">{selectedBooking.displayCurrency || selectedBooking.currency}</p>
<div className="mt-4 flex flex-wrap gap-2">
{[
(b.bookingType || 'ONE_WAY').replace(/_/g, ' '),
`${b.adultCount ?? 0} Adult${(b.adultCount ?? 0) !== 1 ? 's' : ''}${(b.childCount ?? 0) > 0 ? ` · ${b.childCount} Child${b.childCount !== 1 ? 'ren' : ''}` : ''}`,
b.displayCurrency || b.currency || 'ETB',
].map((tag) => (
<span key={tag} className="inline-flex items-center gap-1.5 bg-white/20 text-white text-xs font-medium px-3 py-1 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-200" />{tag}
</span>
))}
</div>
</div>
</div>
<hr className="border-muted" />
<div className="space-y-6">
{/* Passenger */}
<section>
<SectionHeader title="Passenger" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Full Name" value={b.passenger?.fullName || b.contactEmail} />
<Field label="Email" value={b.contactEmail || b.passenger?.email} />
<Field label="Phone" value={b.contactPhone || b.passenger?.phone} />
<Field label="Passenger ID" value={b.passengerId} mono truncate />
</div>
</section>
<div>
<h3 className="text-lg font-semibold mb-3">Additional Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Source</label>
<p className="text-lg font-semibold">{selectedBooking.source || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Last Updated</label>
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.updatedAt)}</p>
</div>
{/* Journey */}
<section>
<SectionHeader title="Journey" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Origin" value={b.schedule?.originStation?.name} />
<Field label="Destination" value={b.schedule?.destinationStation?.name} />
<Field label="Departure" value={b.schedule?.departureAt ? formatDateTime(b.schedule.departureAt) : ''} />
<Field label="Arrival" value={b.schedule?.arrivalAt ? formatDateTime(b.schedule.arrivalAt) : ''} />
<Field label="Adults" value={String(b.adultCount ?? 0)} />
<Field label="Children" value={String(b.childCount ?? 0)} />
<Field label="Promo Code" value={b.promoCode || 'None'} />
<Field label="Schedule ID" value={b.scheduleId} mono truncate />
</div>
</section>
{/* Return leg */}
{isRoundTrip && (
<section>
<SectionHeader title="Return Leg" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Leg Status" value={(b.returnLegStatus || '—').replace(/_/g, ' ')} />
<Field label="Outbound Boarded" value={b.outboundBoardedAt ? formatDateTime(b.outboundBoardedAt) : 'Not yet'} />
<Field label="Return Boarded" value={b.returnBoardedAt ? formatDateTime(b.returnBoardedAt) : 'Not yet'} />
<Field label="Return Schedule ID" value={b.returnScheduleId} mono truncate />
</div>
</section>
)}
{/* Payment */}
<section>
<SectionHeader title="Payment" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-100 dark:border-emerald-800 rounded-lg p-3 col-span-2">
<p className="text-xs text-emerald-700 dark:text-emerald-400 mb-1">Total Amount</p>
<p className="text-xl font-bold text-emerald-800 dark:text-emerald-300">{formatCurrency(b.totalMinor, b.currency || 'ETB')}</p>
{b.displayCurrency && b.displayCurrency !== (b.currency || 'ETB') && (
<p className="text-xs text-emerald-600 dark:text-emerald-500 mt-0.5">
{formatCurrency(b.displayTotalMinor ?? b.totalMinor, b.displayCurrency)}
</p>
)}
</div>
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-2">Payment Status</p>
<Badge variant="status" status={b.paymentIntent?.status || 'PENDING'}>{b.paymentIntent?.status || 'PENDING'}</Badge>
</div>
<Field label="Method" value={b.paymentIntent?.method || '—'} />
<Field label="Paid At" value={b.paidAt ? formatDateTime(b.paidAt) : 'Not paid'} />
<Field label="Display Currency" value={b.displayCurrency || b.currency || 'ETB'} />
<Field label="Payment ID" value={b.paymentIntent?.id || '—'} mono truncate />
</div>
</section>
{/* Seats */}
{b.seats && b.seats.length > 0 && (
<section>
<SectionHeader title={`Seats (${b.seats.length})`} />
<div className="divide-y divide-muted rounded-lg border border-muted overflow-hidden">
{b.seats.map((bs: any, i: number) => (
<div key={i} className="flex items-center justify-between px-4 py-3 bg-muted/20 hover:bg-muted/40 transition-colors">
<div className="flex items-center gap-3">
<span className="w-6 h-6 rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 text-xs font-bold flex items-center justify-center shrink-0">{i + 1}</span>
<div>
<p className="text-sm font-semibold">{bs.passengerName || '—'}</p>
<p className="text-xs text-muted-foreground">
{bs.passengerCategory || '—'}{bs.leg ? ` · Leg ${bs.leg}` : ''}{bs.idDocumentType ? ` · ${bs.idDocumentType}` : ''}
{bs.verifaydaVerified ? ' · ✓ Verified' : ''}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-mono font-semibold">{bs.seat?.seatNumber || bs.seatId || '—'}</p>
<p className="text-xs text-muted-foreground">{formatCurrency(bs.fareMinor ?? 0, b.currency || 'ETB')}</p>
</div>
</div>
))}
</div>
</section>
)}
{/* Timestamps */}
<section>
<SectionHeader title="Timestamps & Meta" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Created" value={formatDateTime(b.createdAt)} />
<Field label="Last Updated" value={formatDateTime(b.updatedAt)} />
<Field label="Source / Device" value={b.source || b.userAgent || '—'} truncate />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
</div>
</div>
)}
);
})()}
</Modal>
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); }}
onConfirm={handleConfirmDelete}
onConfirm={async () => { if (bookingToDelete) await deleteMutation.mutateAsync(bookingToDelete.id); }}
title="Delete Booking"
message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`}
confirmText="Delete"
cancelText="Cancel"
isLoading={deleteMutation.isPending}
isDanger={true}
message={`Permanently delete booking ${bookingToDelete?.bookingRef}? This cannot be undone and will release all associated seats.`}
confirmText="Delete" cancelText="Cancel" isLoading={deleteMutation.isPending} isDanger
/>
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From (Created)</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To (Created)</label>
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
</div>
<div><label className="label">Date From (Created)</label><input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} /></div>
<div><label className="label">Date To (Created)</label><input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} /></div>
</div>
<div>
<p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2 max-h-56 overflow-y-auto">
{[
{ key: 'bookingRef', label: 'Booking Reference' },
{ key: 'passenger', label: 'Passenger' },
{ key: 'status', label: 'Status' },
{ key: 'bookingType', label: 'Booking Type' },
{ key: 'passengerCount', label: 'Passenger Count' },
{ key: 'totalMinor', label: 'Amount' },
{ key: 'paymentStatus', label: 'Payment Status' },
{ key: 'createdAt', label: 'Created At' },
].map((col) => (
{BOOKING_COLS.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={exportColumns[col.key] || false}
<input type="checkbox" checked={exportColumns[col.key] || false}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300"
/>
className="w-4 h-4 rounded border-gray-300" />
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
</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>

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, Trash2 } from 'lucide-react';
import { Download, Eye, Trash2, ShieldCheck, ShieldOff, Star, Wallet } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination';
@@ -13,13 +13,28 @@ import { passengersApi, apiClient } from '@/lib/api';
import { formatDate, formatDateTime } from '@/lib/utils';
import { PassengerFilters } from '@/types';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
const TIER_COLORS: Record<string, string> = {
BRONZE: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 border-orange-200 dark:border-orange-800',
SILVER: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600',
GOLD: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800',
};
export default function PassengersPage() {
const [filters, setFilters] = useState<PassengerFilters>({
page: 1,
pageSize: 20,
search: '',
role: 'PASSENGER',
});
const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' });
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
const [exportModalOpen, setExportModalOpen] = useState(false);
@@ -33,35 +48,23 @@ export default function PassengersPage() {
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['passengers'] });
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['passengers'] }),
});
const handleDelete = (passenger: any) => {
setDeleteConfirm({ isOpen: true, passenger });
};
const confirmDelete = async () => {
if (deleteConfirm.passenger) {
await deleteMutation.mutateAsync(deleteConfirm.passenger.id);
setDeleteConfirm({ isOpen: false, passenger: null });
}
};
const { data, isLoading, error } = useQuery({
queryKey: ['passengers', filters],
queryFn: () => passengersApi.getAll(filters),
});
if (error) {
console.error('Passengers API Error:', error);
}
const PASSENGER_COLS = [
{ key: 'fullName', label: 'Full Name' }, { key: 'email', label: 'Email' }, { key: 'phone', label: 'Phone' },
{ key: 'dateOfBirth', label: 'Date of Birth' }, { key: 'gender', label: 'Gender' },
{ key: 'nationality', label: 'Nationality' }, { key: 'verified', label: 'Verified' },
];
const confirmExportPassengers = () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (cols.length === 0) { alert('Please select at least one column'); return; }
if (!cols.length) { alert('Please select at least one column'); return; }
const exportItems = (data?.items || []).filter((p: any) => {
if (!exportDateFrom && !exportDateTo) return true;
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
@@ -69,26 +72,24 @@ export default function PassengersPage() {
if (exportDateTo && (!d || d > exportDateTo)) return false;
return true;
});
const csv = [
cols.join(','),
...exportItems.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';
PASSENGER_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((p: any) => {
const values = PASSENGER_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'fullName': return p.fullName;
case 'email': return p.email || '';
case 'phone': return p.phone || '';
case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : '';
case 'gender': return p.gender || '';
case 'nationality': return p.nationality || '';
case 'verified': return p.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');
@@ -99,65 +100,32 @@ export default function PassengersPage() {
};
const columns = [
{
key: 'fullName',
label: 'Name',
sortable: true,
render: (passenger: any) => (
{
key: 'fullName', label: 'Name', sortable: true,
render: (p: any) => (
<div>
<div className="font-medium">{passenger.fullName}</div>
<div className="text-sm text-muted-foreground">{passenger.email}</div>
<div className="font-medium">{p.fullName}</div>
<div className="text-sm text-muted-foreground">{p.email}</div>
</div>
),
},
{
key: 'phone',
label: 'Phone',
sortable: true,
render: (passenger: any) => passenger.phone,
},
{
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',
},
{
key: 'verified',
label: 'Status',
render: (passenger: any) => (
<Badge variant="status" status={passenger.nationalId ? 'CONFIRMED' : 'PENDING'}>
{passenger.nationalId ? 'Verified' : 'Unverified'}
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone },
{ key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' },
{ key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' },
{ key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' },
{
key: 'verified', label: 'Status',
render: (p: any) => (
<Badge variant="status" status={p.nationalId ? 'CONFIRMED' : 'PENDING'}>
{p.nationalId ? 'Verified' : 'Unverified'}
</Badge>
),
},
];
const actions = [
{
label: 'View Details',
onClick: (passenger: any) => setSelectedPassenger(passenger),
variant: 'secondary' as const,
icon: Eye,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
{ label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye },
{ label: 'Delete', onClick: (p: any) => setDeleteConfirm({ isOpen: true, passenger: p }), variant: 'danger' as const, icon: Trash2 },
];
return (
@@ -167,9 +135,7 @@ export default function PassengersPage() {
<h1 className="text-2xl font-bold">Passengers</h1>
<p className="text-muted-foreground">Manage passenger profiles and verification</p>
</div>
<div className="flex gap-2">
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
<div className="card">
@@ -180,254 +146,218 @@ export default function PassengersPage() {
)}
<div className="mb-4 flex flex-wrap gap-4">
<div className="flex-1">
<input
type="text"
placeholder="Search by name, email, or phone..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
/>
<input type="text" placeholder="Search by name, email, or phone..." className="input"
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
</div>
<select
className="input w-48"
value={filters.verified?.toString() || ''}
onChange={(e) => setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}
>
<select className="input w-48" value={filters.verified?.toString() || ''}
onChange={(e) => setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}>
<option value="">All Passengers</option>
<option value="true">Verified</option>
<option value="false">Unverified</option>
</select>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No passengers found"
/>
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No passengers found" />
{data?.meta && (
<Pagination
currentPage={data.meta.page}
totalPages={data.meta.totalPages}
onPageChange={(page) => setFilters({ ...filters, page })}
/>
<Pagination currentPage={data.meta.page} totalPages={data.meta.totalPages}
onPageChange={(page) => setFilters({ ...filters, page })} />
)}
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, passenger: null })}
onConfirm={confirmDelete}
onConfirm={async () => {
if (deleteConfirm.passenger) {
await deleteMutation.mutateAsync(deleteConfirm.passenger.id);
setDeleteConfirm({ isOpen: false, passenger: null });
}
}}
title="Delete Passenger"
message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`}
confirmText="Delete"
isDanger={true}
confirmText="Delete" isDanger
warning="This passenger may have active bookings, loyalty points, and wallet balance. Deleting will impact these systems and records."
/>
{/* Passenger Details Modal */}
<Modal
isOpen={!!selectedPassenger}
onClose={() => setSelectedPassenger(null)}
title="Passenger Details"
size="xl"
>
{selectedPassenger && (
<div className="space-y-6">
{/* Personal Information */}
<Modal isOpen={!!selectedPassenger} onClose={() => setSelectedPassenger(null)} title="Passenger Details" size="xl">
{selectedPassenger && (() => {
const p = selectedPassenger;
const isVerified = !!p.faydaVerified || !!p.nationalId;
const tier = p.passenger?.loyalty?.tier || p.loyalty?.tier;
const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE;
return (
<div>
<h3 className="text-lg font-semibold mb-3">Personal Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Full Name</label>
<p className="text-lg font-semibold">{selectedPassenger.fullName}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Date of Birth</label>
<p className="text-lg font-semibold">
{selectedPassenger.dateOfBirth ? formatDate(selectedPassenger.dateOfBirth) : 'N/A'}
</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Gender</label>
<p className="text-lg font-semibold">{selectedPassenger.gender || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Nationality</label>
<p className="text-lg font-semibold">{selectedPassenger.nationality || 'N/A'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Contact Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Contact Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Email</label>
<p className="text-lg font-semibold">{selectedPassenger.email || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Phone</label>
<p className="text-lg font-semibold">{selectedPassenger.phone || 'N/A'}</p>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Identification */}
<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">Passport Number</label>
<p className="text-lg font-mono font-semibold">{selectedPassenger.passportNumber || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Passport Country</label>
<p className="text-lg font-semibold">{selectedPassenger.passportCountry || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Verification Status</label>
<div className="mt-1">
<Badge
variant="status"
status={selectedPassenger.nationalId ? 'CONFIRMED' : 'PENDING'}
>
{selectedPassenger.nationalId ? 'Verified' : 'Unverified'}
</Badge>
{/* Gradient header with avatar */}
<div className="-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r from-emerald-600 to-emerald-700 rounded-t-lg">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
<span className="text-white text-2xl font-bold">{(p.fullName || p.email || '?')[0].toUpperCase()}</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-xl font-bold truncate">{p.fullName}</p>
<p className="text-emerald-200 text-sm truncate">{p.email}</p>
</div>
<div className="text-right shrink-0 space-y-1">
<div>
<Badge variant="status" status={isVerified ? 'CONFIRMED' : 'PENDING'}>
{isVerified ? '✓ Verified' : 'Unverified'}
</Badge>
</div>
{tier && (
<span className={`inline-flex items-center gap-1 text-xs font-bold px-2.5 py-0.5 rounded-full border ${tierColor}`}>
<Star className="w-3 h-3" />{tier}
</span>
)}
</div>
</div>
</div>
</div>
<hr className="border-muted" />
{/* Account Information */}
<div>
<h3 className="text-lg font-semibold mb-3">Account Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Passenger ID</label>
<p className="text-sm font-mono">{selectedPassenger.id}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">User ID</label>
<p className="text-sm font-mono">{selectedPassenger.userId || 'N/A'}</p>
</div>
</div>
</div>
{/* Loyalty & Wallet (if available) */}
{(selectedPassenger.loyalty || selectedPassenger.wallet) && (
<>
<hr className="border-muted" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedPassenger.loyalty && (
<div>
<h3 className="text-lg font-semibold mb-2">Loyalty Account</h3>
<div className="space-y-2">
<div>
<label className="text-sm font-medium text-muted-foreground">Tier</label>
<p className="text-lg font-semibold">{selectedPassenger.loyalty.tier || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Points Balance</label>
<p className="text-lg font-semibold">{selectedPassenger.loyalty.pointsBalance || 0}</p>
</div>
</div>
{/* Quick stats */}
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Loyalty Points', value: (p.passenger?.loyalty?.pointsBalance ?? p.loyalty?.pointsBalance ?? 0).toLocaleString() },
{ label: 'Wallet Balance', value: p.passenger?.wallet || p.wallet ? `ETB ${((p.passenger?.wallet?.balanceMinor ?? p.wallet?.balanceMinor ?? 0) / 100).toFixed(2)}` : '—' },
{ label: 'Nationality', value: p.nationality || '—' },
].map(({ label, value }) => (
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-emerald-200 text-xs">{label}</p>
<p className="text-white text-sm font-bold truncate">{value}</p>
</div>
)}
{selectedPassenger.wallet && (
<div>
<h3 className="text-lg font-semibold mb-2">Wallet</h3>
<div className="space-y-2">
<div>
<label className="text-sm font-medium text-muted-foreground">Balance</label>
<p className="text-lg font-semibold">
{(selectedPassenger.wallet.balanceMinor / 100).toFixed(2)} {selectedPassenger.wallet.currency}
</p>
</div>
</div>
</div>
)}
</div>
</>
)}
<hr className="border-muted" />
{/* Timestamps */}
<div>
<h3 className="text-lg font-semibold mb-3">Timestamps</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Created</label>
<p className="text-sm">{selectedPassenger.createdAt ? formatDateTime(selectedPassenger.createdAt) : 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Last Updated</label>
<p className="text-sm">{selectedPassenger.updatedAt ? formatDateTime(selectedPassenger.updatedAt) : 'N/A'}</p>
))}
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
variant="secondary"
onClick={() => setSelectedPassenger(null)}
>
Close
</ActionButton>
<div className="space-y-6">
{/* Personal */}
<section>
<SectionHeader title="Personal Information" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Full Name" value={p.fullName} />
<Field label="Date of Birth" value={p.dateOfBirth ? formatDate(p.dateOfBirth) : ''} />
<Field label="Gender" value={p.gender} />
<Field label="Nationality" value={p.nationality} />
<Field label="Nationality Code" value={p.nationalityCode} />
<Field label="Preferred Language" value={p.passenger?.preferredLanguage || p.preferredLanguage} />
<Field label="Last Login" value={p.lastLoginAt ? formatDateTime(p.lastLoginAt) : 'Never'} />
<Field label="Role" value={p.role} />
</div>
</section>
{/* Contact */}
<section>
<SectionHeader title="Contact Information" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Email" value={p.email} />
<Field label="Phone" value={p.phone} />
<Field label="Address" value={p.address} />
</div>
</section>
{/* Identification */}
<section>
<SectionHeader title="Identification & Verification" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-muted/40 rounded-lg p-3 col-span-2 md:col-span-1">
<p className="text-xs text-muted-foreground mb-2">Fayda (National ID)</p>
<div className="flex items-center gap-2">
{isVerified
? <ShieldCheck className="w-4 h-4 text-emerald-600 dark:text-emerald-400 shrink-0" />
: <ShieldOff className="w-4 h-4 text-muted-foreground shrink-0" />}
<span className={`text-sm font-semibold ${isVerified ? 'text-emerald-700 dark:text-emerald-400' : 'text-muted-foreground'}`}>
{isVerified ? 'Verified' : 'Not verified'}
</span>
</div>
{p.faydaVerifiedAt && <p className="text-xs text-muted-foreground mt-1">{formatDateTime(p.faydaVerifiedAt)}</p>}
</div>
<Field label="Passport Number" value={p.passportNumber} mono />
<Field label="Passport Country" value={p.passportCountry} />
<Field label="Passport Expiry" value={p.passportExpiryDate ? formatDate(p.passportExpiryDate) : ''} />
</div>
</section>
{/* Loyalty & Wallet */}
{(p.passenger?.loyalty || p.loyalty || p.passenger?.wallet || p.wallet) && (
<section>
<SectionHeader title="Loyalty & Wallet" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{(p.passenger?.loyalty || p.loyalty) && (() => {
const loyalty = p.passenger?.loyalty || p.loyalty;
return (
<>
<div className={`rounded-lg p-3 border ${tierColor}`}>
<p className="text-xs font-medium mb-1 opacity-70">Tier</p>
<div className="flex items-center gap-1.5">
<Star className="w-4 h-4" />
<span className="text-sm font-bold">{loyalty.tier}</span>
</div>
</div>
<Field label="Points Balance" value={(loyalty.pointsBalance ?? 0).toLocaleString()} />
<Field label="Lifetime Points" value={(loyalty.lifetimePoints ?? 0).toLocaleString()} />
</>
);
})()}
{(p.passenger?.wallet || p.wallet) && (() => {
const wallet = p.passenger?.wallet || p.wallet;
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-3">
<p className="text-xs text-blue-600 dark:text-blue-400 mb-1 flex items-center gap-1"><Wallet className="w-3 h-3" />Wallet Balance</p>
<p className="text-base font-bold text-blue-800 dark:text-blue-300">
ETB {((wallet.balanceMinor ?? 0) / 100).toFixed(2)}
</p>
</div>
);
})()}
</div>
</section>
)}
{/* Account */}
<section>
<SectionHeader title="Account IDs" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="User ID" value={p.id || p.userId} mono truncate />
<Field label="Passenger ID" value={p.passenger?.id || p.passengerId} mono truncate />
</div>
</section>
{/* Timestamps */}
<section>
<SectionHeader title="Timestamps" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Registered" value={p.createdAt ? formatDateTime(p.createdAt) : ''} />
<Field label="Last Updated" value={p.updatedAt ? formatDateTime(p.updatedAt) : ''} />
<Field label="Fayda Verified At" value={p.faydaVerifiedAt ? formatDateTime(p.faydaVerifiedAt) : 'N/A'} />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelectedPassenger(null)}>Close</ActionButton>
</div>
</div>
</div>
)}
);
})()}
</Modal>
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Passengers" size="md">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Date From (Registered)</label>
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
</div>
<div>
<label className="label">Date To (Registered)</label>
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
</div>
<div><label className="label">Date From (Registered)</label><input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} /></div>
<div><label className="label">Date To (Registered)</label><input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} /></div>
</div>
<div>
<p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2 max-h-56 overflow-y-auto">
{[
{ key: 'fullName', label: 'Full Name' },
{ key: 'email', label: 'Email' },
{ key: 'phone', label: 'Phone' },
{ key: 'dateOfBirth', label: 'Date of Birth' },
{ key: 'gender', label: 'Gender' },
{ key: 'nationality', label: 'Nationality' },
{ key: 'verified', label: 'Verified' },
].map((col) => (
{PASSENGER_COLS.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={exportColumns[col.key] || false}
<input type="checkbox" checked={exportColumns[col.key] || false}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
className="w-4 h-4 rounded border-gray-300"
/>
className="w-4 h-4 rounded border-gray-300" />
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExportPassengers}>Export CSV</ActionButton>

View File

@@ -28,6 +28,15 @@ export default function PaymentsPage() {
}),
});
const PAYMENT_COLS = [
{ key: 'reference', label: 'Reference' },
{ key: 'booking', label: 'Booking Reference' },
{ key: 'amount', label: 'Amount' },
{ key: 'method', label: 'Payment Method' },
{ key: 'status', label: 'Status' },
{ key: 'createdAt', label: 'Created At' },
];
const confirmExport = () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
if (cols.length === 0) { alert('Please select at least one column'); return; }
@@ -42,16 +51,16 @@ export default function PaymentsPage() {
});
const csv = [
cols.join(','),
PAYMENT_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((payment: any) => {
const values = cols.map(col => {
switch (col) {
const values = PAYMENT_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'reference': return payment.reference || payment.id?.substring(0, 8) || '';
case 'booking': return payment.booking?.bookingRef || 'N/A';
case 'amount': return formatCurrency(payment.amountMinor, payment.currency);
case 'method': return payment.method || '';
case 'status': return payment.status || '';
case 'createdAt': return payment.createdAt || '';
case 'booking': return payment.booking?.bookingRef || 'N/A';
case 'amount': return formatCurrency(payment.amountMinor, payment.currency);
case 'method': return payment.method || '';
case 'status': return payment.status || '';
case 'createdAt': return payment.createdAt ? new Date(payment.createdAt).toLocaleString() : '';
default: return '';
}
});
@@ -84,7 +93,7 @@ export default function PaymentsPage() {
<h1 className="text-2xl font-bold text-foreground">Payments</h1>
<p className="text-muted-foreground">Manage payment transactions and refunds</p>
</div>
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
<div className="card">

View File

@@ -239,9 +239,9 @@ export default function ReportsPage() {
<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: 'Completed', value: bookings.filter((b: any) => b.status === 'BOARDED').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 },
{ name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'BOARDED', 'CANCELLED'].includes(b.status)).length },
].filter(d => d.value > 0)}
cx="50%"
cy="50%"
@@ -306,7 +306,7 @@ export default function ReportsPage() {
</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>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'BOARDED').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>

View File

@@ -563,7 +563,7 @@ export default function SchedulesPage() {
<option value="">Select Train</option>
{trains.map((train: Train) => (
<option key={train.id} value={train.id}>
{train.name} ({train.number})
{train.number} ({train.name})
</option>
))}
</select>
@@ -580,7 +580,7 @@ export default function SchedulesPage() {
<option value="">Select Route</option>
{routes.map((route: Route) => (
<option key={route.id} value={route.id}>
{route.name} ({route.code})
{route.code} ({route.name})
</option>
))}
</select>

View File

@@ -443,13 +443,12 @@ export default function SeatsPage() {
className="input"
>
<option value="">Select a schedule...</option>
{schedules.map((schedule: any) => {
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
{schedules.map((schedule: any) => {
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}
{date} - {routeName}
</option>
);
})}

View File

@@ -72,8 +72,8 @@ export default function StationsPage() {
name: formData.get('name') as string,
city: formData.get('city') as string,
countryCode: formData.get('countryCode') as string,
lat: parseFloat(formData.get('lat') as string) || null,
lng: parseFloat(formData.get('lng') as string) || null,
lat: parseFloat(formData.get('lat') as string) || undefined,
lng: parseFloat(formData.get('lng') as string) || undefined,
timezone: formData.get('timezone') as string,
sequence,
isOperational: formData.get('isOperational') === 'true',
@@ -304,28 +304,6 @@ export default function StationsPage() {
<option value="DJ">Djibouti (DJ)</option>
</select>
</div>
<div>
<label className="label">Latitude</label>
<input
type="number"
name="lat"
className="input"
defaultValue={editingStation?.lat}
step="0.0001"
placeholder="e.g., 9.0320"
/>
</div>
<div>
<label className="label">Longitude</label>
<input
type="number"
name="lng"
className="input"
defaultValue={editingStation?.lng}
step="0.0001"
placeholder="e.g., 38.7469"
/>
</div>
<div>
<label className="label">Timezone *</label>
<select

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { LogIn, Trash2 } from 'lucide-react';
import { LogIn, ListCollapse, Trash2, Printer } from 'lucide-react';
import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
@@ -10,7 +10,7 @@ import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal';
import { ticketsApi, apiClient, stationsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
@@ -18,6 +18,8 @@ export default function TicketsPage() {
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
const [ticketToBoard, setTicketToBoard] = useState<any>(null);
const [printBpModalOpen, setPrintBpModalOpen] = useState(false);
const [ticketToPrint, setTicketToPrint] = useState<any>(null);
const [successMessage, setSuccessMessage] = useState('');
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null);
@@ -88,9 +90,75 @@ export default function TicketsPage() {
};
const handleConfirmBoard = async () => {
if (ticketToBoard) {
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id });
}
if (!ticketToBoard) return;
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id });
printBoardingPass(ticketToBoard, 'outbound');
};
const printBoardingPass = (ticket: any, leg: 'outbound' | 'inbound' = 'outbound') => {
const w = window.open('', '_blank', 'width=520,height=460');
if (!w) return;
const isInbound = leg === 'inbound';
const origin = isInbound
? (ticket.booking?.returnOriginStation?.name || ticket.schedule?.destinationStation?.name || 'N/A')
: (ticket.schedule?.originStation?.name || 'N/A');
const dest = isInbound
? (ticket.booking?.returnDestinationStation?.name || ticket.schedule?.originStation?.name || 'N/A')
: (ticket.schedule?.destinationStation?.name || 'N/A');
const date = isInbound
? (ticket.booking?.returnBoardedAt ? new Date(ticket.booking.returnBoardedAt).toLocaleString() : 'N/A')
: (ticket.schedule?.departureAt ? new Date(ticket.schedule.departureAt).toLocaleString() : 'N/A');
const seat = ticket.seat?.seatNumber || 'N/A';
const coach = ticket.seat?.coach?.number || 'N/A';
const bookingRef = ticket.booking?.bookingRef || 'N/A';
const ticketNum = ticket.ticketNumber || 'N/A';
const passenger = ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'Guest';
w.document.write(
'<!DOCTYPE html><html><head><meta charset="utf-8"/><title>Boarding Pass</title><style>' +
'*{box-sizing:border-box;margin:0;padding:0}' +
'body{font-family:"Segoe UI",sans-serif;background:#f0fdf4;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}' +
'.pass{background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,.12);width:460px}' +
'.header{background:linear-gradient(135deg,#10b981,#059669);color:#fff;padding:24px 28px 20px}' +
'.header-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}' +
'.airline{font-size:12px;letter-spacing:2px;text-transform:uppercase;opacity:.85}' +
'.badge{background:rgba(255,255,255,.2);border-radius:20px;padding:3px 12px;font-size:11px;letter-spacing:1px}' +
'.route{display:flex;align-items:center;gap:8px;margin-top:14px}' +
'.city{font-size:24px;font-weight:700}' +
'.arrow{font-size:20px;opacity:.7;flex:1;text-align:center}' +
'.body{padding:24px 28px}' +
'.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}' +
'.field label{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#6b7280;font-weight:600}' +
'.field p{font-size:14px;font-weight:600;color:#111827;margin-top:3px}' +
'.divider{border:none;border-top:2px dashed #d1fae5;margin:20px 0}' +
'.footer{display:flex;justify-content:space-between;align-items:center}' +
'.seat-box{background:#f0fdf4;border:2px solid #10b981;border-radius:10px;padding:8px 20px;text-align:center}' +
'.seat-box label{font-size:10px;letter-spacing:1px;text-transform:uppercase;color:#059669;font-weight:700}' +
'.seat-box p{font-size:28px;font-weight:800;color:#065f46}' +
'@media print{body{background:#fff}.pass{box-shadow:none}}' +
'</style></head><body>' +
'<div class="pass">' +
'<div class="header">' +
'<div class="header-top"><span class="airline">EDR &mdash; Ethio-Djibouti Railway</span><span class="badge">BOARDING PASS</span></div>' +
'<div class="route"><span class="city">' + origin + '</span><span class="arrow">&#129122;</span><span class="city">' + dest + '</span></div>' +
'</div>' +
'<div class="body">' +
'<div class="grid">' +
'<div class="field"><label>Booking Ref</label><p>' + bookingRef + '</p></div>' +
'<div class="field"><label>Ticket No.</label><p>' + ticketNum + '</p></div>' +
'<div class="field"><label>Date &amp; Time</label><p>' + date + '</p></div>' +
'<div class="field"><label>Coach</label><p>' + coach + '</p></div>' +
'</div>' +
'<hr class="divider"/>' +
'<div class="footer">' +
'<div class="field"><label>Passenger</label><p>' + passenger + '</p></div>' +
'<div class="seat-box"><label>Seat</label><p>' + seat + '</p></div>' +
'</div>' +
'</div>' +
'</div>' +
'<script>window.onload=function(){window.print();window.onafterprint=function(){window.close()};}<\/script>' +
'</body></html>'
);
w.document.close();
};
const handleDeleteClick = (ticket: any) => {
@@ -104,6 +172,19 @@ export default function TicketsPage() {
}
};
const TICKET_COLS = [
{ key: 'ticketNumber', label: 'Ticket Number' },
{ key: 'booking', label: 'Booking Reference' },
{ key: 'passenger', label: 'Passenger Name' },
{ 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' },
];
const confirmExport = () => {
const cols = Object.entries(selectedColumns)
.filter(([, selected]) => selected)
@@ -125,19 +206,20 @@ export default function TicketsPage() {
});
const csv = [
cols.join(','),
TICKET_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.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';
const values = TICKET_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (key) {
case 'ticketNumber': return ticket.ticketNumber || 'N/A';
case 'booking': return ticket.booking?.bookingRef || 'N/A';
case 'passenger': return ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A';
case 'trip': return `${ticket.schedule?.originStation?.name || 'N/A'} - ${ticket.schedule?.destinationStation?.name || 'N/A'}`;
case 'coach': return ticket.seat?.coach?.number || 'N/A';
case 'seat': return ticket.seat?.seatNumber || 'N/A';
case 'seatClass': return ticket.seat?.coach?.coachType?.type || 'N/A';
case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB');
case 'status': return ticket.status || 'N/A';
case 'boarded': return ticket.boardedAt ? 'Yes' : 'No';
default: return '';
}
});
@@ -160,17 +242,22 @@ export default function TicketsPage() {
label: 'Ticket Number',
sortable: true,
render: (ticket: any) => (
<span className="font-mono font-semibold">{ticket.ticketNumber || 'N/A'}</span>
<div>
<div className="font-mono font-semibold">{ticket.ticketNumber || 'N/A'}</div>
<div className="text-sm text-muted-foreground">
{ticket.booking?.passenger?.fullName || 'N/A'}
</div>
</div>
),
},
{
key: 'booking',
label: 'Booking',
key: 'contact',
label: 'Contact',
render: (ticket: any) => (
<div>
<div className="font-medium">{ticket.booking?.bookingRef || 'N/A'}</div>
<div className="font-medium">{ticket.booking?.contactPhone || ticket.booking?.passenger?.phone || 'N/A'}</div>
<div className="text-sm text-muted-foreground">
{ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A'}
<div className="text-xs text-muted-foreground">{ticket.booking?.contactEmail || ticket.booking?.passenger?.email || 'N/A'}</div>
</div>
</div>
),
@@ -178,16 +265,23 @@ export default function TicketsPage() {
{
key: 'trip',
label: 'Trip',
render: (ticket: any) => (
<div>
<div className="font-medium">
{ticket.schedule?.originStation?.name || 'N/A'} {ticket.schedule?.destinationStation?.name || 'N/A'}
render: (ticket: any) => {
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
const returnArrivalAt = ticket.booking?.returnSchedule?.arrivalAt;
return (
<div>
<div className="font-medium">
{ticket.schedule?.originStation?.name || 'N/A'} {ticket.schedule?.destinationStation?.name || 'N/A'}
</div>
<div className="text-xs text-muted-foreground">
{ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'}
{isRoundTrip && (
<span> {returnArrivalAt ? formatDateTimeShort(returnArrivalAt) : 'N/A'}</span>
)}
</div>
</div>
<div className="text-sm text-muted-foreground">
{ticket.schedule?.departureAt ? formatDateTime(ticket.schedule.departureAt) : 'N/A'}
</div>
</div>
),
);
},
},
{
key: 'seat',
@@ -195,54 +289,29 @@ export default function TicketsPage() {
sortable: true,
render: (ticket: any) => (
<div>
<div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'} - {ticket.seat?.seatNumber || 'N/A'}</div>
<div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'}: {ticket.seat?.seatNumber || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.type || '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 || 'ACTIVE'}>
{ticket.status || 'ACTIVE'}
</Badge>
),
},
{
key: 'boarded',
label: 'Boarded',
render: (ticket: any) => (
ticket.validatedAt ? (
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
<span className="text-sm">{formatDateTime(ticket.validatedAt)}</span>
</div>
) : (
<span className="text-sm text-muted-foreground">Not boarded</span>
)
),
},
{
key: 'returnLegStatus',
label: 'Return Leg',
key: 'boardingTimes',
label: 'Boarding Times',
render: (ticket: any) => {
const status = ticket.booking?.returnLegStatus;
if (!status || status === 'NOT_APPLICABLE') return <span className="text-xs text-muted-foreground"></span>;
const map: Record<string, { label: string; cls: string }> = {
NEITHER_USED: { label: 'Neither Used', cls: 'edr-badge-warning' },
OUTBOUND_ONLY: { label: 'Outbound Only', cls: 'edr-badge-info' },
INBOUND_ONLY: { label: 'Inbound Only', cls: 'edr-badge-danger' },
BOTH_USED: { label: 'Both Used', cls: 'edr-badge-success' },
};
const entry = map[status] ?? { label: status, cls: 'edr-badge-info' };
return <span className={`edr-badge ${entry.cls}`}>{entry.label}</span>;
const outbound = ticket.booking?.outboundBoardedAt;
const inbound = ticket.booking?.returnBoardedAt;
const hasAny = outbound || inbound;
if (!hasAny) return <span className="text-sm text-muted-foreground">Not boarded</span>;
return (
<div className="flex flex-col gap-0.5 text-sm">
<span className={outbound ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}>
{outbound ? formatDateTimeShort(outbound) : 'Not boarded'}
</span>
<span className={inbound ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}>
{inbound ? formatDateTimeShort(inbound) : 'Not boarded'}
</span>
</div>
);
},
},
];
@@ -253,7 +322,25 @@ export default function TicketsPage() {
onClick: handleBoard,
variant: 'primary' as const,
icon: LogIn,
show: (ticket: any) => ticket.status !== 'USED' && !ticket.boardedAt,
show: (ticket: any) => {
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
if (isRoundTrip) {
const inboundBoarded = !!ticket.booking?.returnBoardedAt;
const returnLegStatus = ticket.booking?.returnLegStatus;
return !ticket.validatedAt || (!inboundBoarded && returnLegStatus !== 'BOTH_USED');
}
return !ticket.validatedAt;
},
},
{
label: 'Print BP',
onClick: (ticket: any) => {
setTicketToPrint(ticket);
setPrintBpModalOpen(true);
},
variant: 'secondary' as const,
icon: Printer,
show: (ticket: any) => !!ticket.validatedAt || !!ticket.booking?.outboundBoardedAt || !!ticket.booking?.returnBoardedAt,
},
{
label: 'Details',
@@ -262,6 +349,7 @@ export default function TicketsPage() {
setDetailsModalOpen(true);
},
variant: 'secondary' as const,
icon: ListCollapse,
},
{
label: 'Delete',
@@ -280,7 +368,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" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
{/* Filters */}
@@ -366,17 +454,67 @@ export default function TicketsPage() {
emptyMessage="No tickets found"
/>
{/* Board Confirmation Dialog */}
<ConfirmDialog
{/* Board Confirmation Modal */}
<Modal
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}
/>
size="sm"
>
<div className="space-y-4">
<div className="space-y-1">
<p className="font-medium">Are you sure you want to board ticket {ticketToBoard?.ticketNumber}?</p>
<p className="text-sm text-muted-foreground">This will mark the ticket as USED.</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}>Cancel</ActionButton>
<ActionButton icon={LogIn} loading={boardMutation.isPending} onClick={handleConfirmBoard}>Board and Print</ActionButton>
</div>
</div>
</Modal>
{/* Print BP Modal */}
<Modal
isOpen={printBpModalOpen}
onClose={() => { setPrintBpModalOpen(false); setTicketToPrint(null); }}
title="Print Boarding Pass"
size="sm"
>
<div className="space-y-4">
{(() => {
const isRoundTrip = ticketToPrint?.booking?.bookingType === 'ROUND_TRIP' || ticketToPrint?.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
const outboundBoarded = !!ticketToPrint?.booking?.outboundBoardedAt || !!ticketToPrint?.validatedAt;
const inboundBoarded = !!ticketToPrint?.booking?.returnBoardedAt;
if (isRoundTrip && outboundBoarded && inboundBoarded) {
return (
<>
<p className="text-sm text-muted-foreground">Select which leg to print:</p>
<div className="flex flex-col gap-2">
<ActionButton icon={Printer} onClick={() => { printBoardingPass(ticketToPrint, 'outbound'); setPrintBpModalOpen(false); }}>
Outbound
</ActionButton>
<ActionButton icon={Printer} variant="secondary" onClick={() => { printBoardingPass(ticketToPrint, 'inbound'); setPrintBpModalOpen(false); }}>
Inbound (Return)
</ActionButton>
</div>
</>
);
}
const leg = isRoundTrip && inboundBoarded && !outboundBoarded ? 'inbound' : 'outbound';
return (
<>
<p className="text-sm text-muted-foreground">
Print {leg === 'inbound' ? 'inbound (return)' : 'outbound'} boarding pass for ticket {ticketToPrint?.ticketNumber}?
</p>
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => { setPrintBpModalOpen(false); setTicketToPrint(null); }}>Cancel</ActionButton>
<ActionButton icon={Printer} onClick={() => { printBoardingPass(ticketToPrint, leg); setPrintBpModalOpen(false); }}>Print</ActionButton>
</div>
</>
);
})()}
</div>
</Modal>
{/* Delete Confirmation Dialog */}
<ConfirmDialog

View File

@@ -22,6 +22,13 @@ export const formatDateTime = (date?: string | Date | null): string => {
return format(d, 'MMM dd, yyyy HH:mm');
};
export const formatDateTimeShort = (date?: string | Date | null): string => {
if (!date) return 'N/A';
const d = new Date(date);
if (isNaN(d.getTime())) return 'N/A';
return format(d, 'dd MMM yy HH:mm');
};
export const formatDateTimeLocal = (date?: string | Date | null): string => {
if (!date) return 'N/A';
const d = new Date(date);
@@ -34,7 +41,7 @@ export const getStatusColor = (status: string): string => {
CONFIRMED: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
PENDING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400',
CANCELLED: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400',
COMPLETED: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
BOARDED: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
PAID: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
FAILED: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400',
REFUNDED: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',

View File

@@ -7,7 +7,7 @@ export interface Booking {
passengerId: string;
passenger?: Passenger.IPassenger;
scheduleId: string;
status: 'DRAFT' | 'PENDING_PAYMENT' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW' | 'REFUNDED';
status: 'DRAFT' | 'PENDING_PAYMENT' | 'CONFIRMED' | 'CANCELLED' | 'BOARDED' | 'NO_SHOW' | 'REFUNDED';
currency: string;
totalMinor: number;
adultCount: number;
@@ -33,8 +33,8 @@ export interface Station {
countryCode?: string;
isOperational: boolean;
timezone: string;
lat: number;
lng: number;
lat?: number;
lng?: number;
createdAt: string;
}

View File

@@ -6,7 +6,7 @@ export interface Booking {
passengerId: string;
passenger?: Passenger.IPassenger;
tripId: string;
status: 'PENDING' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
status: 'PENDING' | 'CONFIRMED' | 'CANCELLED' | 'BOARDED';
totalAmount: number;
currency: string;
paymentStatus: Passenger.PaymentStatus;

View File

@@ -150,68 +150,40 @@ export default function ReviewPage() {
return apiClient.post(endpoint, data);
},
onSuccess: (data: any) => {
console.log('=== API RESPONSE SUCCESS ===');
console.log('Response Data:', JSON.stringify(data, null, 2));
console.log('Booking created successfully:', data);
const bookingIdValue = data.bookingId || data.id;
const pnrValue = data.pnr || data.bookingReference || data.bookingRef;
console.log('Setting booking ID:', bookingIdValue);
console.log('Setting PNR:', pnrValue);
console.log('Booking via endpoint:', isAuthenticated ? '/bookings' : '/bookings/guest');
setBookingId(bookingIdValue);
setPNR(pnrValue);
const totalAmount = isAuthenticated ? (data.totalMinor || data.totalAmount || 0) : (data.totalMinor || data.totalAmount || 0);
console.log('Total amount:', totalAmount);
const totalAmount = data.totalMinor || data.totalAmount || 0;
setTimeout(() => {
const currentState = useBookingStore.getState();
console.log('Current booking store state:', currentState);
console.log('bookingId:', currentState.bookingId);
console.log('pnr:', currentState.pnr);
if (totalAmount > 0) {
console.log('Redirecting to payment page');
router.push('/booking/payment');
} else {
console.log('Redirecting to confirmation page');
router.push('/booking/confirmation');
}
}, 100);
},
onError: (error: any) => {
console.log('=== API RESPONSE ERROR ===');
console.error('Error Object:', error);
console.error('Error Response:', error?.response);
console.error('Error Response Data:', JSON.stringify(error?.response?.data, null, 2));
console.error('Error Status:', error?.response?.status);
console.error('Error Message:', error?.message);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create booking. Please try again.';
alert(errorMessage);
},
});
const handleConfirm = async () => {
console.log('handleConfirm called');
try {
const { searchCriteria } = useBookingStore.getState();
console.log('Search criteria:', searchCriteria);
console.log('Seat hold:', seatHold);
console.log('Selected schedule:', selectedSchedule);
console.log('Outbound schedule:', outboundSchedule);
console.log('Inbound schedule:', inboundSchedule);
console.log('Passengers:', passengers);
if (!seatHold?.holdId && (passengers.some(p => p.seatId) || passengers.some(p => (p as any).outboundSeatId || (p as any).inboundSeatId))) {
console.error('No seat hold found');
if (!seatHold?.holdId) {
alert('Please select seats before continuing.');
router.push('/booking/seats');
return;
}
if (isRoundTrip && !seatHold.returnHoldId) {
alert('Please select return seats before continuing.');
router.push('/booking/seats');
return;
}
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) {
console.error('Missing search criteria');
@@ -247,37 +219,24 @@ export default function ReviewPage() {
console.log('Token found, length:', token.length);
let passengerId = getPassengerIdFromToken(token);
console.log('Extracted passenger ID from JWT token:', passengerId);
// Fallback 1: Use passengerId from booking store
if (!passengerId && storedPassengerId) {
passengerId = storedPassengerId;
console.log('Fallback 1: Using passengerId from booking store:', passengerId);
}
// Fallback 2: Use passengerId from localStorage
if (!passengerId && typeof window !== 'undefined') {
const localStoragePassengerId = localStorage.getItem('booking_passengerId');
if (localStoragePassengerId) {
passengerId = localStoragePassengerId;
console.log('Fallback 2: Using passengerId from localStorage:', passengerId);
}
if (localStoragePassengerId) passengerId = localStoragePassengerId;
}
// Fallback 3: Use passengerId from user object
if (!passengerId && user) {
passengerId = (user as any).passengerId;
console.log('Fallback 3: Using passengerId from user object:', passengerId);
}
if (!passengerId) {
console.error('Failed to extract passengerId');
console.error('User object:', user);
console.error('User object keys:', user ? Object.keys(user) : 'null');
console.error('Stored passengerId from booking store:', storedPassengerId);
if (typeof window !== 'undefined') {
console.error('Stored passengerId from localStorage:', localStorage.getItem('booking_passengerId'));
}
throw new Error('Passenger ID not found in authentication token. Please log in again.');
}
@@ -312,7 +271,7 @@ export default function ReviewPage() {
bookingData.returnScheduleId = inboundSchedule.id;
bookingData.returnOriginStationId = searchCriteria.destinationStationId;
bookingData.returnDestinationStationId = searchCriteria.originStationId;
bookingData.returnHoldId = seatHold?.holdId || ''; // Assuming same hold ID, adjust if needed
bookingData.returnHoldId = seatHold.returnHoldId || seatHold.holdId;
bookingData.returnSeatClassId = returnSeatClassId;
}
@@ -356,7 +315,7 @@ export default function ReviewPage() {
bookingData.returnScheduleId = inboundSchedule.id;
bookingData.returnOriginStationId = searchCriteria.destinationStationId;
bookingData.returnDestinationStationId = searchCriteria.originStationId;
bookingData.returnHoldId = seatHold?.holdId || ''; // Assuming same hold ID, adjust if needed
bookingData.returnHoldId = seatHold.returnHoldId || seatHold.holdId;
bookingData.returnSeatClassId = returnSeatClassId;
}
@@ -407,31 +366,12 @@ export default function ReviewPage() {
const displaySchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
console.log('Selected schedule:', displaySchedule);
console.log('Base fare adult:', displaySchedule?.baseFareAdult);
console.log('Passengers:', passengers);
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum) => {
return sum + (outboundSchedule.baseFareAdult || 0);
}, 0) : 0;
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum) => {
return sum + (inboundSchedule.baseFareAdult || 0);
}, 0) : 0;
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, p, i) => {
const farePerPassenger = selectedSchedule?.baseFareAdult ||
(selectedSchedule as any)?.fareAdult ||
(selectedSchedule as any)?.price ||
0;
console.log(`Passenger ${i}: ${p.name}, fare = ${farePerPassenger}`);
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum) => sum + (outboundSchedule.baseFareAdult || 0), 0) : 0;
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum) => sum + (inboundSchedule.baseFareAdult || 0), 0) : 0;
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => {
const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
return sum + farePerPassenger;
}, 0);
console.log('Calculated base fare:', baseFare);
const total = baseFare;
return (

View File

@@ -111,10 +111,22 @@ export default function SeatsPage() {
});
},
onSuccess: (data: any) => {
setSeatHold({
holdId: data.holdId || data.id,
expiresAt: data.expiresAt,
});
const isInbound = isRoundTrip && currentJourneyType === 'inbound';
if (isInbound) {
// Merge the return hold into the existing outbound hold
const current = useBookingStore.getState().seatHold;
setSeatHold({
holdId: current?.holdId || '',
expiresAt: current?.expiresAt || '',
returnHoldId: data.holdId || data.id,
returnExpiresAt: data.expiresAt,
});
} else {
setSeatHold({
holdId: data.holdId || data.id,
expiresAt: data.expiresAt,
});
}
},
});
@@ -557,7 +569,7 @@ export default function SeatsPage() {
);
};
if (!selectedSchedule || !passengers.length) return null;
if (isRoundTrip ? (!outboundSchedule || !inboundSchedule || !passengers.length) : (!selectedSchedule || !passengers.length)) return null;
if (!coachId) {
return (

View File

@@ -51,6 +51,8 @@ export interface SelectedSchedule {
export interface SeatHold {
holdId: string;
expiresAt: string;
returnHoldId?: string;
returnExpiresAt?: string;
}
interface BookingState {