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'); CREATE TYPE "Currency" AS ENUM ('ETB', 'DJF', 'USD');
-- CreateEnum -- 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 -- CreateEnum
CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL'); CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL');
@@ -76,7 +76,9 @@ CREATE TABLE "SeatClass" (
"coachTypeId" TEXT NOT NULL, "coachTypeId" TEXT NOT NULL,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"description" TEXT, "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, "isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
@@ -94,6 +96,8 @@ CREATE TABLE "User" (
"role" "UserRole" NOT NULL DEFAULT 'PASSENGER', "role" "UserRole" NOT NULL DEFAULT 'PASSENGER',
"nationality" TEXT, "nationality" TEXT,
"nationalityCode" TEXT, "nationalityCode" TEXT,
"gender" TEXT,
"dateOfBirth" TIMESTAMP(3),
"passportNumber" TEXT, "passportNumber" TEXT,
"nationalId" TEXT, "nationalId" TEXT,
"failedLoginAttempts" INTEGER NOT NULL DEFAULT 0, "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0,
@@ -155,10 +159,11 @@ CREATE TABLE "Station" (
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"city" TEXT NOT NULL, "city" TEXT NOT NULL,
"countryCode" TEXT, "countryCode" TEXT,
"sequence" INTEGER NOT NULL DEFAULT 0,
"isOperational" BOOLEAN NOT NULL DEFAULT true, "isOperational" BOOLEAN NOT NULL DEFAULT true,
"timezone" TEXT NOT NULL DEFAULT 'Africa/Addis_Ababa', "timezone" TEXT NOT NULL DEFAULT 'Africa/Addis_Ababa',
"lat" DECIMAL(9,6) NOT NULL, "lat" DECIMAL(9,6),
"lng" DECIMAL(9,6) NOT NULL, "lng" DECIMAL(9,6),
CONSTRAINT "Station_pkey" PRIMARY KEY ("id") CONSTRAINT "Station_pkey" PRIMARY KEY ("id")
); );
@@ -234,6 +239,7 @@ CREATE TABLE "Coach" (
"number" TEXT NOT NULL, "number" TEXT NOT NULL,
"arrangement" TEXT NOT NULL DEFAULT '2+2', "arrangement" TEXT NOT NULL DEFAULT '2+2',
"capacity" INTEGER NOT NULL DEFAULT 0, "capacity" INTEGER NOT NULL DEFAULT 0,
"sequence" INTEGER NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'ACTIVE', "status" TEXT NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,

View File

@@ -140,11 +140,7 @@ ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
-- AlterTable -- AlterTable
ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
-- AlterTable -- gender column already TEXT from init migration
-- 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;
-- CreateIndex -- CreateIndex
CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType"); 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 CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "Station"("sequence");
ALTER TABLE "passenger"."Station" ADD COLUMN IF NOT EXISTS "sequence" INTEGER NOT NULL DEFAULT 0;
-- Add index on sequence for Station CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "Coach"("sequence");
CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "passenger"."Station"("sequence");
-- Add sequence column to Coach table if it doesn't exist
ALTER TABLE "passenger"."Coach" ADD COLUMN IF NOT EXISTS "sequence" INTEGER NOT NULL DEFAULT 0;
-- Add index on sequence for Coach
CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "passenger"."Coach"("sequence");
-- Add missing columns to SeatClass if they don't exist
ALTER TABLE "passenger"."SeatClass" ADD COLUMN IF NOT EXISTS "premiumMinor" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "passenger"."SeatClass" ADD COLUMN IF NOT EXISTS "insuranceFeeMinor" INTEGER NOT NULL DEFAULT 0;
-- Add missing columns to User if they don't exist
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "gender" VARCHAR(255);
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "dateOfBirth" TIMESTAMP(3);
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "passportNumber" VARCHAR(255);
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "nationalId" VARCHAR(255);
-- Ensure Ticket has all required columns
ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "validatedAt" TIMESTAMP(3);
ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3);
-- Add missing columns to Booking if they don't exist
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "bookingType" VARCHAR(255) NOT NULL DEFAULT 'ONE_WAY';
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "displayCurrency" VARCHAR(255);
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "displayTotalMinor" INTEGER;
-- Ensure all indexes exist -- Ensure all indexes exist
CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "passenger"."Station"("city", "countryCode"); CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "Station"("city", "countryCode");
CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "passenger"."Coach"("coachTypeId"); CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "Coach"("coachTypeId");
CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "passenger"."TrainSchedule"("departureAt", "originStationId"); CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "TrainSchedule"("departureAt", "originStationId");
CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "passenger"."Booking"("passengerId", "status"); 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 -- Add CASCADE delete to all foreign key constraints that are missing it
-- TrainSchedule relations -- TrainSchedule relations
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey"; ALTER TABLE "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" 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 "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" 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 "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" 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 "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" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE CASCADE;
-- Coach relation -- Coach relation
ALTER TABLE "passenger"."Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE CASCADE;
-- CoachAssignment relations -- CoachAssignment relations
ALTER TABLE "passenger"."CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey"; ALTER TABLE "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" 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 "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" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE CASCADE;
-- Booking relations -- Booking relations
ALTER TABLE "passenger"."Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey"; ALTER TABLE "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" 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 "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" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
-- BookingSeat relations -- BookingSeat relations
ALTER TABLE "passenger"."BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey"; ALTER TABLE "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" 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 "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" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
-- PaymentIntent -- PaymentIntent
ALTER TABLE "passenger"."PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- PaymentRefund -- PaymentRefund
ALTER TABLE "passenger"."PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE CASCADE;
-- Ticket -- Ticket
ALTER TABLE "passenger"."Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- TicketSeat -- TicketSeat
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
-- WalletLedgerEntry -- WalletLedgerEntry
ALTER TABLE "passenger"."WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE CASCADE;
-- Notification -- Notification
ALTER TABLE "passenger"."Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
-- MenuItem -- MenuItem
ALTER TABLE "passenger"."MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey"; ALTER TABLE "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" 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 "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" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE CASCADE;
-- FoodOrder -- FoodOrder
ALTER TABLE "passenger"."FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- FoodOrderItem -- FoodOrderItem
ALTER TABLE "passenger"."FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE CASCADE;
-- FaqArticle -- FaqArticle
ALTER TABLE "passenger"."FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE CASCADE;
-- SupportMessage -- SupportMessage
ALTER TABLE "passenger"."SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE CASCADE;
-- TripStopTime -- TripStopTime
ALTER TABLE "passenger"."TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
-- TripLiveStatus -- TripLiveStatus
ALTER TABLE "passenger"."TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
-- JourneySegment -- JourneySegment
ALTER TABLE "passenger"."JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey"; ALTER TABLE "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" 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 "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" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
-- AgentBooking -- AgentBooking
ALTER TABLE "passenger"."AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey"; ALTER TABLE "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" 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 "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" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- AgentShift -- AgentShift
ALTER TABLE "passenger"."AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
-- AgentCommission -- AgentCommission
ALTER TABLE "passenger"."AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
-- BookingModification -- BookingModification
ALTER TABLE "passenger"."BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- BookingCancellation -- BookingCancellation
ALTER TABLE "passenger"."BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- GateValidationLog -- GateValidationLog
ALTER TABLE "passenger"."GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE;
-- BaggageBooking -- BaggageBooking
ALTER TABLE "passenger"."BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
-- RouteFareRule -- RouteFareRule
ALTER TABLE "passenger"."RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;
-- SegmentFareRule -- SegmentFareRule
ALTER TABLE "passenger"."SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;
-- StationCrowdSignal -- StationCrowdSignal
ALTER TABLE "passenger"."StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE CASCADE;
-- SeatBlock -- SeatBlock
ALTER TABLE "passenger"."SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
-- SavedRoute -- SavedRoute
ALTER TABLE "passenger"."SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
-- LoyaltyLedgerEntry -- LoyaltyLedgerEntry
ALTER TABLE "passenger"."LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE;
-- LoyaltyReward -- LoyaltyReward
ALTER TABLE "passenger"."LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE;
-- FareRule -- FareRule
ALTER TABLE "passenger"."FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey"; ALTER TABLE "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" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;

View File

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

View File

@@ -1,7 +1,7 @@
-- Create passenger schema if it doesn't exist -- Create passenger schema if it doesn't exist
CREATE SCHEMA IF NOT EXISTS passenger; 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 $$ DO $$
DECLARE DECLARE
e text; e text;
@@ -13,9 +13,10 @@ BEGIN
LOOP LOOP
EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e); EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e);
END LOOP; END LOOP;
EXCEPTION WHEN others THEN NULL;
END $$; END $$;
-- Move all tables from public to passenger schema -- Move tables from public to passenger schema (only if they exist in public)
DO $$ DO $$
DECLARE DECLARE
t text; t text;
@@ -26,6 +27,7 @@ BEGIN
LOOP LOOP
EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t); EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t);
END LOOP; END LOOP;
EXCEPTION WHEN others THEN NULL;
END $$; END $$;
-- Add missing columns to Booking -- 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 PENDING_PAYMENT
CONFIRMED CONFIRMED
CANCELLED CANCELLED
COMPLETED BOARDED
NO_SHOW NO_SHOW
REFUNDED REFUNDED
@@ -319,8 +319,8 @@ model Station {
sequence Int @default(0) sequence Int @default(0)
isOperational Boolean @default(true) isOperational Boolean @default(true)
timezone String @default("Africa/Addis_Ababa") timezone String @default("Africa/Addis_Ababa")
lat Decimal @db.Decimal(9, 6) lat Decimal? @db.Decimal(9, 6)
lng Decimal @db.Decimal(9, 6) lng Decimal? @db.Decimal(9, 6)
originSchedules TrainSchedule[] @relation("OriginTrips") originSchedules TrainSchedule[] @relation("OriginTrips")
destinationSchedules TrainSchedule[] @relation("DestinationTrips") destinationSchedules TrainSchedule[] @relation("DestinationTrips")
stopTimes TripStopTime[] stopTimes TripStopTime[]
@@ -542,6 +542,7 @@ model Booking {
modifications BookingModification[] modifications BookingModification[]
cancellation BookingCancellation? cancellation BookingCancellation?
baggage BaggageBooking[] baggage BaggageBooking[]
journey Journey?
@@index([passengerId, status]) @@index([passengerId, status])
@@index([bookingType]) @@index([bookingType])
@@ -933,10 +934,12 @@ model SavedRoute {
model Journey { model Journey {
id String @id @default(uuid()) id String @id @default(uuid())
passengerId String passengerId String
bookingId String? @unique
status String status String
totalMinor Int totalMinor Int
currency String @default("ETB") currency String @default("ETB")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
booking Booking? @relation(fields: [bookingId], references: [id])
journeySegments JourneySegment[] journeySegments JourneySegment[]
@@schema("passenger") @@schema("passenger")
} }

View File

@@ -216,6 +216,7 @@ export class BookingsService {
...(matchedPassengers.length > 0 ...(matchedPassengers.length > 0
? [{ passengerId: { in: matchedPassengers.map(p => p.id) } }] ? [{ passengerId: { in: matchedPassengers.map(p => p.id) } }]
: []), : []),
{ seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
]; ];
} }
@@ -268,6 +269,7 @@ export class BookingsService {
passenger: iam passenger: iam
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
: null, : null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
schedule: { schedule: {
train: booking.schedule.train, train: booking.schedule.train,
originStation: booking.schedule.originStation, originStation: booking.schedule.originStation,
@@ -294,10 +296,23 @@ export class BookingsService {
return this.createOneWayBooking(dto); 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) { private async createOneWayBooking(dto: CreateBookingDto) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired'); 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({ const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId }, where: { id: dto.scheduleId },
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
@@ -367,6 +382,11 @@ export class BookingsService {
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired'); 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'); 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([ const [outboundSchedule, returnSchedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({ this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId }, 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 (!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'); 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([ const [leg1Schedule, leg2Schedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({ this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId }, 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 (!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'); 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 // Load all 4 schedules
const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([ 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' } } } }), 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'); 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; return processedPassengers;
} }
@@ -1042,7 +1086,7 @@ export class BookingsService {
await this.prisma.bookingModification.create({ 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 }, 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); await this.seatsService.confirmSeats(dto.newSeatIds);
return { modified: true, bookingRef: dto.bookingRef }; return { modified: true, bookingRef: dto.bookingRef };
} }
@@ -1053,7 +1097,7 @@ export class BookingsService {
if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled'); if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled');
const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0; 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.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' } }); await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
this.eventEmitter.emit('booking.cancelled', { booking, refundAmount }); this.eventEmitter.emit('booking.cancelled', { booking, refundAmount });
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' }; 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 } }); const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } });
if (!booking) throw new NotFoundException('Booking not found'); 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.bookingSeat.deleteMany({ where: { bookingId: id } });
await this.prisma.booking.delete({ where: { 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 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 } }); const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } });
for (const b of expired) { 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' } }); 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(''); 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 { function calculateAge(dateOfBirth: Date): number {
const today = new Date(); const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear(); let age = today.getFullYear() - dateOfBirth.getFullYear();

View File

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

View File

@@ -447,7 +447,7 @@ export class PaymentsService {
include: { seats: true }, include: { seats: true },
}); });
if (booking) { if (booking) {
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); await this.seatsService.releaseSeats(booking.id);
await this.prisma.booking.update({ await this.prisma.booking.update({
where: { id: dto.bookingId }, where: { id: dto.bookingId },
data: { status: "CANCELLED" }, data: { status: "CANCELLED" },
@@ -746,51 +746,125 @@ export class PaymentsService {
private async createJourneySegments( private async createJourneySegments(
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
) { ) {
const schedule = await this.prisma.trainSchedule.findUnique({ const b = booking as any;
where: { id: booking.scheduleId },
include: {
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
},
});
if (!schedule) return;
const stopTimes = schedule.stopTimes; // Build per-leg definitions: { scheduleId, originStationId, destinationStationId, seatIds[] }
if (stopTimes.length < 2) return; // 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( const seatsForLeg = (legNum: number) =>
(st) => st.stationId === schedule.originStationId, booking.seats.filter((s: any) => s.leg === legNum).map((s: any) => s.seatId);
);
const destSequence = stopTimes.findIndex(
(st) => st.stationId === schedule.destinationStationId,
);
if ( if (booking.bookingType === 'ONE_WAY') {
originSequence < 0 || legDefs.push({
destSequence < 0 || scheduleId: booking.scheduleId,
originSequence >= destSequence originStationId: b.originStationId,
) destinationStationId: b.destinationStationId,
return; 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({ const journey = await this.prisma.journey.create({
data: { data: {
passengerId: booking.passengerId, passengerId: booking.passengerId,
status: "CONFIRMED", bookingId: booking.id,
totalMinor: booking.totalMinor, status: 'CONFIRMED',
currency: booking.currency, totalMinor: booking.totalMinor,
currency: booking.currency,
}, },
}); });
const journeySegments = []; const journeySegments: any[] = [];
for (const bookingSeat of booking.seats) { let segmentOrder = 0;
for (let i = originSequence; i < destSequence; i++) {
journeySegments.push({ for (const leg of legDefs) {
journeyId: journey.id, if (leg.seatIds.length === 0) continue;
scheduleId: booking.scheduleId,
segmentOrder: i, const stopTimes = await this.prisma.tripStopTime.findMany({
seatId: bookingSeat.seatId, where: { scheduleId: leg.scheduleId },
departureStationId: stopTimes[i].stationId, orderBy: { sequence: 'asc' },
arrivalStationId: stopTimes[i + 1].stationId, 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]; 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') { if (dto.journeyType === 'ROUND_TRIP') {
const [returnDirect, returnTransit] = await Promise.all([ const [returnDirect, returnTransit] = await Promise.all([
this.searchSchedules( this.searchSchedules(
@@ -68,12 +85,85 @@ export class SearchService {
new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival 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: 'ROUND_TRIP', outbound, inbound };
} }
return { journeyType: 'ONE_WAY', outbound }; 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( private async searchSchedules(
originStationId: string, originStationId: string,
destinationStationId: string, destinationStationId: string,
@@ -85,12 +175,13 @@ export class SearchService {
const [y, m, d] = dateStr.split('-').map(Number); const [y, m, d] = dateStr.split('-').map(Number);
const date = new Date(y, m - 1, d, 0, 0, 0, 0); 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 nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
const now = new Date();
const totalPassengers = adultCount + (childCount ?? 0); const totalPassengers = adultCount + (childCount ?? 0);
const schedules = await this.prisma.trainSchedule.findMany({ const schedules = await this.prisma.trainSchedule.findMany({
where: { where: {
status: { in: ['SCHEDULED', 'BOARDING'] }, status: { in: ['SCHEDULED', 'BOARDING'] },
departureAt: { gte: date, lt: nextDay }, departureAt: { gte: date < now ? now : date, lt: nextDay },
stopTimes: { some: { stationId: originStationId } }, stopTimes: { some: { stationId: originStationId } },
}, },
include: { include: {

View File

@@ -11,7 +11,7 @@ export class SeatsService {
private segmentsService: SegmentsService, 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({ const assignments = await this.prisma.coachAssignment.findMany({
where: { scheduleId, ...(coachId ? { coachId } : {}) }, where: { scheduleId, ...(coachId ? { coachId } : {}) },
include: { include: {
@@ -25,16 +25,14 @@ export class SeatsService {
orderBy: { positionNumber: 'asc' }, 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 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) => { coaches: assignments.map((a) => {
const allSeats = a.coach.seats; const allSeats = a.coach.seats;
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name); const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
return { return {
id: a.coach.id, id: a.coach.id,
assignmentId: a.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( async resolveEffectiveStatuses(
scheduleId: string, scheduleId: string,
seatIds: string[], seatIds: string[],
originStationId?: string,
destinationStationId?: string,
): Promise<Map<string, string>> { ): Promise<Map<string, string>> {
const statusMap = new Map<string, string>(); const statusMap = new Map<string, string>();
if (seatIds.length === 0) return statusMap; 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({ const activeHolds = await this.prisma.seatHold.findMany({
where: { where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } },
scheduleId, select: { seatIds: true, createdBy: true },
expiresAt: { gt: new Date() },
seatIds: { hasSome: seatIds },
},
select: { seatIds: true },
}); });
for (const hold of activeHolds) { 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) { 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({ const bookedSegments = await this.prisma.journeySegment.findMany({
where: { where: {
scheduleId, scheduleId,
seatId: { in: seatIds }, seatId: { in: seatIds },
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, 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; return statusMap;
@@ -154,6 +204,7 @@ export class SeatsService {
if (reqFrom >= reqTo) if (reqFrom >= reqTo)
throw new BadRequestException('Origin must come before destination'); throw new BadRequestException('Origin must come before destination');
// ── Check existing holds for overlap ────────────────────────────────────
const activeHolds = await tx.seatHold.findMany({ const activeHolds = await tx.seatHold.findMany({
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } }, where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
select: { seatIds: true, createdBy: true }, 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 = { const holdMeta = {
originStationId: dto.originStationId, originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId, destinationStationId: dto.destinationStationId,
@@ -347,16 +421,12 @@ export class SeatsService {
return { released: true, holdId }; return { released: true, holdId };
} }
async confirmSeats(seatIds: string[]) { // Physical seat.status stays AVAILABLE — segment rows are the source of truth for occupancy.
// No-op async confirmSeats(_seatIds: string[]) {}
}
async releaseSeats(seatIds: string[]) { // Delete the Journey (and its JourneySegments) scoped to this booking.
if (seatIds.length > 0) { async releaseSeats(bookingId: string) {
await this.prisma.journeySegment.deleteMany({ await this.prisma.journey.deleteMany({ where: { bookingId } });
where: { seatId: { in: seatIds } },
});
}
} }
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> { async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
@@ -424,7 +494,7 @@ export class SeatsService {
invalid++; invalid++;
continue; continue;
} }
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts; const [coachId, , row, col, seatNumber] = parts;
if (!coachId || !row || !col || !seatNumber) { if (!coachId || !row || !col || !seatNumber) {
errors.push(`Line ${i + 2}: Missing required fields`); errors.push(`Line ${i + 2}: Missing required fields`);
invalid++; invalid++;
@@ -448,7 +518,7 @@ export class SeatsService {
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
try { try {
const parts = lines[i].split(','); 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({ await this.prisma.seat.upsert({
where: { coachId_row_col: { coachId, row: parseInt(row), col } }, 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 } }); const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found'); if (!seat) throw new NotFoundException('Seat not found');
await this.prisma.seat.update({ await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } });
where: { id: seatId }, await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } });
data: { status: 'BLOCKED' },
});
await this.prisma.seatBlock.create({
data: {
seatId,
reason,
blockedBy: 'system',
},
});
return { blocked: true, seatId, reason }; return { blocked: true, seatId, reason };
} }
@@ -501,14 +561,8 @@ export class SeatsService {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found'); if (!seat) throw new NotFoundException('Seat not found');
await this.prisma.seat.update({ await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } });
where: { id: seatId }, await this.prisma.seatBlock.deleteMany({ where: { seatId } });
data: { status: 'AVAILABLE' },
});
await this.prisma.seatBlock.deleteMany({
where: { seatId },
});
return { unblocked: true, seatId }; return { unblocked: true, seatId };
} }
@@ -518,11 +572,9 @@ export class SeatsService {
if (!seat) throw new NotFoundException('Seat not found'); if (!seat) throw new NotFoundException('Seat not found');
if (!seat.seatNumber) throw new BadRequestException('Seat already removed'); 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({ await this.prisma.seat.update({
where: { id: seatId }, where: { id: seatId },
data: { seatNumber: negatedNumber }, data: { seatNumber: `-${seat.seatNumber}` },
}); });
return { removed: true, seatId, originalSeatNumber: seat.seatNumber }; return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
@@ -535,26 +587,15 @@ export class SeatsService {
throw new BadRequestException('Seat is not removed'); throw new BadRequestException('Seat is not removed');
} }
// Restore original seatNumber by removing the negative sign
const originalNumber = seat.seatNumber.slice(1); const originalNumber = seat.seatNumber.slice(1);
await this.prisma.seat.update({ await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: originalNumber } });
where: { id: seatId },
data: { seatNumber: originalNumber },
});
return { restored: true, seatId, seatNumber: originalNumber }; return { restored: true, seatId, seatNumber: originalNumber };
} }
@Cron(CronExpression.EVERY_MINUTE) @Cron(CronExpression.EVERY_MINUTE)
async expireHolds() { async expireHolds() {
const now = new Date(); // Holds are temporary and don't create Journey rows — just delete expired ones.
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } }); await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
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 } } });
} }
} }

View File

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

View File

@@ -50,7 +50,10 @@ export class StationsService {
} }
async create(dto: CreateStationDto) { 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({ await this.auditService.log({
userId: this.request?.user?.id, userId: this.request?.user?.id,

View File

@@ -50,7 +50,8 @@ export class TicketsService {
booking: { booking: {
include: { include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } }, 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 } }, passenger: { select: { id: true, iamUserId: true } },
}, },
}, },
@@ -90,6 +91,16 @@ export class TicketsService {
schedule: t.booking.schedule, schedule: t.booking.schedule,
seat: t.booking.seats[0]?.seat, seat: t.booking.seats[0]?.seat,
status: t.booking.status, 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, validatedAt: t.validatedAt,
createdAt: t.issuedAt, createdAt: t.issuedAt,
}; };
@@ -130,7 +141,7 @@ export class TicketsService {
legs: legSummary, legs: legSummary,
}); });
const qrPayload = await QRCode.toDataURL(qrData); 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({ const ticket = await this.prisma.ticket.upsert({
where: { bookingId }, where: { bookingId },

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination'; import Pagination from '@/components/ui/Pagination';
@@ -13,13 +13,21 @@ import { bookingsApi, apiClient } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils'; import { formatCurrency, formatDateTime } from '@/lib/utils';
import { BookingFilters } from '@/types'; 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() { export default function BookingsPage() {
const [filters, setFilters] = useState<BookingFilters>({ const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' });
page: 1,
pageSize: 20,
search: '',
status: '',
});
const [selectedBooking, setSelectedBooking] = useState<any>(null); const [selectedBooking, setSelectedBooking] = useState<any>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [bookingToDelete, setBookingToDelete] = useState<any>(null); const [bookingToDelete, setBookingToDelete] = useState<any>(null);
@@ -28,14 +36,8 @@ export default function BookingsPage() {
const [exportDateFrom, setExportDateFrom] = useState(''); const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState(''); const [exportDateTo, setExportDateTo] = useState('');
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({ const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
bookingRef: true, bookingRef: true, bookingType: false, passengerNames: true, contactPhone: true,
passenger: true, contactEmail: true, passengerCount: false, paymentStatus: true, totalMinor: true, status: true, createdAt: true,
status: true,
bookingType: false,
passengerCount: false,
totalMinor: true,
paymentStatus: true,
createdAt: true,
}); });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -45,10 +47,6 @@ export default function BookingsPage() {
queryFn: () => bookingsApi.getAll(filters), queryFn: () => bookingsApi.getAll(filters),
}); });
if (error) {
console.error('Bookings API Error:', error);
}
const cancelMutation = useMutation({ const cancelMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason), mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason),
onSuccess: () => { onSuccess: () => {
@@ -56,9 +54,7 @@ export default function BookingsPage() {
setSuccessMessage('Booking cancelled successfully'); setSuccessMessage('Booking cancelled successfully');
setTimeout(() => setSuccessMessage(''), 3000); setTimeout(() => setSuccessMessage(''), 3000);
}, },
onError: (error: any) => { onError: (error: any) => alert(`Error: ${error.message || 'Failed to cancel booking'}`),
alert(`Error: ${error.message || 'Failed to cancel booking'}`);
},
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
@@ -77,26 +73,22 @@ export default function BookingsPage() {
}); });
const handleCancel = async (booking: any) => { 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' }); await cancelMutation.mutateAsync({ id: booking.id, reason: 'Cancelled by admin' });
} }
}; };
const handleDeleteClick = (booking: any) => { const BOOKING_COLS = [
setBookingToDelete(booking); { key: 'bookingRef', label: 'Booking Reference' }, { key: 'journeyType', label: 'Journey Type' },
setDeleteConfirmOpen(true); { 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' },
const handleConfirmDelete = async () => { { key: 'status', label: 'Status' }, { key: 'createdAt', label: 'Created At' },
if (bookingToDelete) { ];
await deleteMutation.mutateAsync(bookingToDelete.id);
}
};
const confirmExport = () => { const confirmExport = () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); 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) => { const exportItems = (data?.items || []).filter((b: any) => {
if (!exportDateFrom && !exportDateTo) return true; if (!exportDateFrom && !exportDateTo) return true;
const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null; 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; if (exportDateTo && (!d || d > exportDateTo)) return false;
return true; return true;
}); });
const csv = [ const csv = [
cols.join(','), BOOKING_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((booking: any) => { ...exportItems.map((booking: any) => {
const values = cols.map(col => { const values = BOOKING_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (col) { switch (key) {
case 'bookingRef': return booking.bookingRef; case 'bookingRef': return booking.bookingRef;
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest'; case 'journeyType': return booking.bookingType || 'N/A';
case 'status': return booking.status; case 'passengerNames': return booking.passengerNames?.join(', ') || 'N/A';
case 'bookingType': return booking.bookingType || 'N/A'; case 'contactPhone': return booking.contactPhone || 'N/A';
case 'passengerCount': return booking.adultCount + booking.childCount; case 'contactEmail': return booking.contactEmail || 'N/A';
case 'totalMinor': return booking.totalMinor; case 'passengerCount': return (booking.adultCount ?? 0) + (booking.childCount ?? 0);
case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING'; 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 ''; default: return '';
} }
}); });
return values.map(v => `"${v}"`).join(','); return values.map(v => `"${v}"`).join(',');
}), }),
].join('\n'); ].join('\n');
const blob = new Blob([csv], { type: 'text/csv' }); const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -136,91 +128,61 @@ export default function BookingsPage() {
const columns = [ const columns = [
{ {
key: 'bookingRef', key: 'bookingRef', label: 'Reference', sortable: true,
label: 'Reference',
sortable: true,
render: (booking: any) => (
<span className="font-mono font-semibold">{booking.bookingRef}</span>
),
},
{
key: 'passenger',
label: 'Passenger',
render: (booking: any) => ( render: (booking: any) => (
<div> <div>
<div className="font-medium">{booking.passenger?.fullName || booking.contactEmail || 'Guest'}</div> <div className="font-mono font-semibold">{booking.bookingRef}</div>
<div className="text-sm text-muted-foreground">{booking.contactPhone || booking.passenger?.phone}</div> <div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div>
</div> </div>
), ),
}, },
{ {
key: 'bookingType', key: 'passengerNames', label: 'Names',
label: 'Type',
sortable: true,
render: (booking: any) => booking.bookingType || 'ONE_WAY',
},
{
key: 'passengerCount',
label: 'Passengers',
render: (booking: any) => { render: (booking: any) => {
const adults = booking.adultCount || 0; const names: string[] = booking.passengerNames || [];
const children = booking.childCount || 0; if (!names.length) return <span className="text-muted-foreground"></span>;
if (adults === 0 && children === 0) return '—'; return <div className="flex flex-col gap-0.5">{names.map((n, i) => <span key={i} className="text-sm">{n}</span>)}</div>;
const parts = [`Adult: ${adults}`];
if (children > 0) parts.push(`Child: ${children}`);
return parts.join(' / ');
}, },
}, },
{ {
key: 'status', key: 'contact', label: 'Contact',
label: 'Status',
render: (booking: any) => ( 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', key: 'passengerCount', label: 'Passengers',
label: 'Amount', render: (booking: any) => {
sortable: true, const adults = booking.adultCount || 0, children = booking.childCount || 0;
render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency), if (!adults && !children) return '—';
return <><div>Adult: {adults}</div><div className="text-sm text-muted-foreground">Child: {children}</div></>;
},
}, },
{ {
key: 'paymentStatus', key: 'paymentStatus', label: 'Payment',
label: 'Payment',
render: (booking: any) => ( render: (booking: any) => (
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}> <div>
{booking.paymentIntent?.status || 'PENDING'} <Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>{booking.paymentIntent?.status || 'PENDING'}</Badge>
</Badge> <div className="text-sm text-muted-foreground">{formatCurrency(booking.totalMinor, booking.currency)}</div>
</div>
), ),
}, },
{ {
key: 'createdAt', key: 'status', label: 'Status',
label: 'Created', render: (booking: any) => <Badge variant="status" status={booking.status}>{booking.status}</Badge>,
sortable: true,
render: (booking: any) => formatDateTime(booking.createdAt),
}, },
]; ];
const actions = [ const actions = [
{ label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye },
{ {
label: 'View Details', label: 'Cancel Booking', onClick: handleCancel, variant: 'danger' as const, icon: XCircle,
onClick: (booking: any) => setSelectedBooking(booking), show: (b: any) => b.status !== 'CANCELLED' && b.status !== 'BOARDED',
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: 'Delete', onClick: (b: any) => { setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
]; ];
return ( return (
@@ -235,9 +197,7 @@ export default function BookingsPage() {
<div className="card"> <div className="card">
{successMessage && ( {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"> <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>
{successMessage}
</div>
)} )}
{error && ( {error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200"> <div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
@@ -246,222 +206,195 @@ export default function BookingsPage() {
)} )}
<div className="mb-4 flex flex-wrap gap-4"> <div className="mb-4 flex flex-wrap gap-4">
<div className="flex-1"> <div className="flex-1">
<input <input type="text" placeholder="Search by reference, email, or phone..." className="input"
type="text" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
placeholder="Search by reference, email, or phone..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
/>
</div> </div>
<select <select className="input w-48" value={filters.status}
className="input w-48" onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}>
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}
>
<option value="">All Status</option> <option value="">All Status</option>
<option value="PENDING_PAYMENT">Pending Payment</option> <option value="PENDING_PAYMENT">Pending Payment</option>
<option value="CONFIRMED">Confirmed</option> <option value="CONFIRMED">Confirmed</option>
<option value="CANCELLED">Cancelled</option> <option value="CANCELLED">Cancelled</option>
<option value="COMPLETED">Completed</option> <option value="BOARDED">Boarded</option>
</select> </select>
<ActionButton variant="secondary" icon={Filter}>More Filters</ActionButton>
</div> </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 && ( {data?.meta && (
<Pagination <Pagination currentPage={data.meta.page} totalPages={data.meta.totalPages}
currentPage={data.meta.page} onPageChange={(page) => setFilters({ ...filters, page })} />
totalPages={data.meta.totalPages}
onPageChange={(page) => setFilters({ ...filters, page })}
/>
)} )}
</div> </div>
{/* Booking Details Modal */} {/* Booking Details Modal */}
<Modal isOpen={!!selectedBooking} onClose={() => setSelectedBooking(null)} title="Booking Details" size="xl"> <Modal isOpen={!!selectedBooking} onClose={() => setSelectedBooking(null)} title="Booking Details" size="xl">
{selectedBooking && ( {selectedBooking && (() => {
<div className="space-y-6"> const b = selectedBooking;
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> const isRoundTrip = b.bookingType === 'ROUND_TRIP' || b.bookingType === 'ROUND_TRIP_TRANSIT';
<div> return (
<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" />
<div> <div>
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3> {/* Gradient header */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <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> <div className="flex items-start justify-between gap-4">
<label className="text-sm font-medium text-muted-foreground">Name</label> <div>
<p className="text-lg font-semibold">{selectedBooking.passenger?.fullName || selectedBooking.contactEmail || 'N/A'}</p> <p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Booking Reference</p>
</div> <p className="text-white text-3xl font-mono font-bold tracking-wider">{b.bookingRef}</p>
<div> </div>
<label className="text-sm font-medium text-muted-foreground">Email</label> <div className="text-right shrink-0">
<p className="text-lg font-semibold">{selectedBooking.contactEmail || selectedBooking.passenger?.email || 'N/A'}</p> <Badge variant="status" status={b.status}>{b.status}</Badge>
</div> <p className="text-emerald-200 text-xs mt-2">{formatDateTime(b.createdAt)}</p>
<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>
</div> </div>
</div> </div>
<div> <div className="mt-4 flex flex-wrap gap-2">
<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> (b.bookingType || 'ONE_WAY').replace(/_/g, ' '),
</div> `${b.adultCount ?? 0} Adult${(b.adultCount ?? 0) !== 1 ? 's' : ''}${(b.childCount ?? 0) > 0 ? ` · ${b.childCount} Child${b.childCount !== 1 ? 'ren' : ''}` : ''}`,
<div> b.displayCurrency || b.currency || 'ETB',
<label className="text-sm font-medium text-muted-foreground">Display Currency</label> ].map((tag) => (
<p className="text-lg font-semibold">{selectedBooking.displayCurrency || selectedBooking.currency}</p> <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> </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> {/* Journey */}
<h3 className="text-lg font-semibold mb-3">Additional Information</h3> <section>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <SectionHeader title="Journey" />
<div> <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<label className="text-sm font-medium text-muted-foreground">Source</label> <Field label="Origin" value={b.schedule?.originStation?.name} />
<p className="text-lg font-semibold">{selectedBooking.source || 'N/A'}</p> <Field label="Destination" value={b.schedule?.destinationStation?.name} />
</div> <Field label="Departure" value={b.schedule?.departureAt ? formatDateTime(b.schedule.departureAt) : ''} />
<div> <Field label="Arrival" value={b.schedule?.arrivalAt ? formatDateTime(b.schedule.arrivalAt) : ''} />
<label className="text-sm font-medium text-muted-foreground">Last Updated</label> <Field label="Adults" value={String(b.adultCount ?? 0)} />
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.updatedAt)}</p> <Field label="Children" value={String(b.childCount ?? 0)} />
</div> <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> </div>
);
<div className="flex justify-end gap-2 pt-4"> })()}
<ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
</div>
</div>
)}
</Modal> </Modal>
{/* Delete Confirmation Dialog */}
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirmOpen} isOpen={deleteConfirmOpen}
onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); }} onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); }}
onConfirm={handleConfirmDelete} onConfirm={async () => { if (bookingToDelete) await deleteMutation.mutateAsync(bookingToDelete.id); }}
title="Delete Booking" 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.`} message={`Permanently delete booking ${bookingToDelete?.bookingRef}? This cannot be undone and will release all associated seats.`}
confirmText="Delete" confirmText="Delete" cancelText="Cancel" isLoading={deleteMutation.isPending} isDanger
cancelText="Cancel"
isLoading={deleteMutation.isPending}
isDanger={true}
/> />
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md"> <Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div><label className="label">Date From (Created)</label><input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} /></div>
<label className="label">Date From (Created)</label> <div><label className="label">Date To (Created)</label><input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} /></div>
<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>
<div> <div>
<p className="text-sm font-medium mb-2">Select Columns</p> <p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2 max-h-56 overflow-y-auto"> <div className="space-y-2 max-h-56 overflow-y-auto">
{[ {BOOKING_COLS.map((col) => (
{ 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) => (
<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"> <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 <input type="checkbox" checked={exportColumns[col.key] || false}
type="checkbox"
checked={exportColumns[col.key] || false}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })} 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> <span className="text-sm font-medium">{col.label}</span>
</label> </label>
))} ))}
</div> </div>
</div> </div>
<div className="flex justify-end gap-2 pt-4 border-t"> <div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton> <ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExport}>Export CSV</ActionButton> <ActionButton onClick={confirmExport}>Export CSV</ActionButton>

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination'; import Pagination from '@/components/ui/Pagination';
@@ -13,13 +13,28 @@ import { passengersApi, apiClient } from '@/lib/api';
import { formatDate, formatDateTime } from '@/lib/utils'; import { formatDate, formatDateTime } from '@/lib/utils';
import { PassengerFilters } from '@/types'; 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() { export default function PassengersPage() {
const [filters, setFilters] = useState<PassengerFilters>({ const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' });
page: 1,
pageSize: 20,
search: '',
role: 'PASSENGER',
});
const [selectedPassenger, setSelectedPassenger] = useState<any>(null); const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
const [exportModalOpen, setExportModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false);
@@ -33,35 +48,23 @@ export default function PassengersPage() {
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`), mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`),
onSuccess: () => { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['passengers'] }),
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({ const { data, isLoading, error } = useQuery({
queryKey: ['passengers', filters], queryKey: ['passengers', filters],
queryFn: () => passengersApi.getAll(filters), queryFn: () => passengersApi.getAll(filters),
}); });
if (error) { const PASSENGER_COLS = [
console.error('Passengers API Error:', error); { 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 confirmExportPassengers = () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); 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) => { const exportItems = (data?.items || []).filter((p: any) => {
if (!exportDateFrom && !exportDateTo) return true; if (!exportDateFrom && !exportDateTo) return true;
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null; 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; if (exportDateTo && (!d || d > exportDateTo)) return false;
return true; return true;
}); });
const csv = [ const csv = [
cols.join(','), PASSENGER_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((passenger: any) => { ...exportItems.map((p: any) => {
const values = cols.map(col => { const values = PASSENGER_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (col) { switch (key) {
case 'fullName': return passenger.fullName; case 'fullName': return p.fullName;
case 'email': return passenger.email || ''; case 'email': return p.email || '';
case 'phone': return passenger.phone || ''; case 'phone': return p.phone || '';
case 'dateOfBirth': return passenger.dateOfBirth ? formatDate(passenger.dateOfBirth) : ''; case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : '';
case 'gender': return passenger.gender || ''; case 'gender': return p.gender || '';
case 'nationality': return passenger.nationality || ''; case 'nationality': return p.nationality || '';
case 'verified': return passenger.nationalId ? 'Yes' : 'No'; case 'verified': return p.nationalId ? 'Yes' : 'No';
default: return ''; default: return '';
} }
}); });
return values.map(v => `"${v}"`).join(','); return values.map(v => `"${v}"`).join(',');
}), }),
].join('\n'); ].join('\n');
const blob = new Blob([csv], { type: 'text/csv' }); const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -99,65 +100,32 @@ export default function PassengersPage() {
}; };
const columns = [ const columns = [
{ {
key: 'fullName', key: 'fullName', label: 'Name', sortable: true,
label: 'Name', render: (p: any) => (
sortable: true,
render: (passenger: any) => (
<div> <div>
<div className="font-medium">{passenger.fullName}</div> <div className="font-medium">{p.fullName}</div>
<div className="text-sm text-muted-foreground">{passenger.email}</div> <div className="text-sm text-muted-foreground">{p.email}</div>
</div> </div>
), ),
}, },
{ { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone },
key: 'phone', { key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' },
label: 'Phone', { key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' },
sortable: true, { key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' },
render: (passenger: any) => passenger.phone, {
}, key: 'verified', label: 'Status',
{ render: (p: any) => (
key: 'gender', <Badge variant="status" status={p.nationalId ? 'CONFIRMED' : 'PENDING'}>
label: 'Gender', {p.nationalId ? 'Verified' : 'Unverified'}
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'}
</Badge> </Badge>
), ),
}, },
]; ];
const actions = [ const actions = [
{ { label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye },
label: 'View Details', { label: 'Delete', onClick: (p: any) => setDeleteConfirm({ isOpen: true, passenger: p }), variant: 'danger' as const, icon: Trash2 },
onClick: (passenger: any) => setSelectedPassenger(passenger),
variant: 'secondary' as const,
icon: Eye,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
]; ];
return ( return (
@@ -167,9 +135,7 @@ export default function PassengersPage() {
<h1 className="text-2xl font-bold">Passengers</h1> <h1 className="text-2xl font-bold">Passengers</h1>
<p className="text-muted-foreground">Manage passenger profiles and verification</p> <p className="text-muted-foreground">Manage passenger profiles and verification</p>
</div> </div>
<div className="flex gap-2"> <ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
</div> </div>
<div className="card"> <div className="card">
@@ -180,254 +146,218 @@ export default function PassengersPage() {
)} )}
<div className="mb-4 flex flex-wrap gap-4"> <div className="mb-4 flex flex-wrap gap-4">
<div className="flex-1"> <div className="flex-1">
<input <input type="text" placeholder="Search by name, email, or phone..." className="input"
type="text" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
placeholder="Search by name, email, or phone..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
/>
</div> </div>
<select <select className="input w-48" value={filters.verified?.toString() || ''}
className="input w-48" onChange={(e) => setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}>
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="">All Passengers</option>
<option value="true">Verified</option> <option value="true">Verified</option>
<option value="false">Unverified</option> <option value="false">Unverified</option>
</select> </select>
</div> </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 && ( {data?.meta && (
<Pagination <Pagination currentPage={data.meta.page} totalPages={data.meta.totalPages}
currentPage={data.meta.page} onPageChange={(page) => setFilters({ ...filters, page })} />
totalPages={data.meta.totalPages}
onPageChange={(page) => setFilters({ ...filters, page })}
/>
)} )}
</div> </div>
{/* Delete Confirmation */}
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirm.isOpen} isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, passenger: null })} 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" title="Delete Passenger"
message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`} message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`}
confirmText="Delete" confirmText="Delete" isDanger
isDanger={true}
warning="This passenger may have active bookings, loyalty points, and wallet balance. Deleting will impact these systems and records." warning="This passenger may have active bookings, loyalty points, and wallet balance. Deleting will impact these systems and records."
/> />
{/* Passenger Details Modal */} {/* Passenger Details Modal */}
<Modal <Modal isOpen={!!selectedPassenger} onClose={() => setSelectedPassenger(null)} title="Passenger Details" size="xl">
isOpen={!!selectedPassenger} {selectedPassenger && (() => {
onClose={() => setSelectedPassenger(null)} const p = selectedPassenger;
title="Passenger Details" const isVerified = !!p.faydaVerified || !!p.nationalId;
size="xl" const tier = p.passenger?.loyalty?.tier || p.loyalty?.tier;
> const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE;
{selectedPassenger && (
<div className="space-y-6"> return (
{/* Personal Information */}
<div> <div>
<h3 className="text-lg font-semibold mb-3">Personal Information</h3> {/* Gradient header with avatar */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <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> <div className="flex items-center gap-4">
<label className="text-sm font-medium text-muted-foreground">Full Name</label> <div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
<p className="text-lg font-semibold">{selectedPassenger.fullName}</p> <span className="text-white text-2xl font-bold">{(p.fullName || p.email || '?')[0].toUpperCase()}</span>
</div> </div>
<div> <div className="flex-1 min-w-0">
<label className="text-sm font-medium text-muted-foreground">Date of Birth</label> <p className="text-white text-xl font-bold truncate">{p.fullName}</p>
<p className="text-lg font-semibold"> <p className="text-emerald-200 text-sm truncate">{p.email}</p>
{selectedPassenger.dateOfBirth ? formatDate(selectedPassenger.dateOfBirth) : 'N/A'} </div>
</p> <div className="text-right shrink-0 space-y-1">
</div> <div>
<div> <Badge variant="status" status={isVerified ? 'CONFIRMED' : 'PENDING'}>
<label className="text-sm font-medium text-muted-foreground">Gender</label> {isVerified ? '✓ Verified' : 'Unverified'}
<p className="text-lg font-semibold">{selectedPassenger.gender || 'N/A'}</p> </Badge>
</div> </div>
<div> {tier && (
<label className="text-sm font-medium text-muted-foreground">Nationality</label> <span className={`inline-flex items-center gap-1 text-xs font-bold px-2.5 py-0.5 rounded-full border ${tierColor}`}>
<p className="text-lg font-semibold">{selectedPassenger.nationality || 'N/A'}</p> <Star className="w-3 h-3" />{tier}
</div> </span>
</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>
</div> </div>
</div> </div>
</div>
</div>
<hr className="border-muted" /> {/* Quick stats */}
<div className="mt-4 grid grid-cols-3 gap-3">
{/* Account Information */} {[
<div> { label: 'Loyalty Points', value: (p.passenger?.loyalty?.pointsBalance ?? p.loyalty?.pointsBalance ?? 0).toLocaleString() },
<h3 className="text-lg font-semibold mb-3">Account Information</h3> { label: 'Wallet Balance', value: p.passenger?.wallet || p.wallet ? `ETB ${((p.passenger?.wallet?.balanceMinor ?? p.wallet?.balanceMinor ?? 0) / 100).toFixed(2)}` : '—' },
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> { label: 'Nationality', value: p.nationality || '—' },
<div> ].map(({ label, value }) => (
<label className="text-sm font-medium text-muted-foreground">Passenger ID</label> <div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-sm font-mono">{selectedPassenger.id}</p> <p className="text-emerald-200 text-xs">{label}</p>
</div> <p className="text-white text-sm font-bold truncate">{value}</p>
<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>
</div> </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>
</div>
<div className="flex justify-end gap-2 pt-4"> <div className="space-y-6">
<ActionButton {/* Personal */}
variant="secondary" <section>
onClick={() => setSelectedPassenger(null)} <SectionHeader title="Personal Information" />
> <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
Close <Field label="Full Name" value={p.fullName} />
</ActionButton> <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>
</div> );
)} })()}
</Modal> </Modal>
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Passengers" size="md"> <Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Passengers" size="md">
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div><label className="label">Date From (Registered)</label><input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} /></div>
<label className="label">Date From (Registered)</label> <div><label className="label">Date To (Registered)</label><input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} /></div>
<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>
<div> <div>
<p className="text-sm font-medium mb-2">Select Columns</p> <p className="text-sm font-medium mb-2">Select Columns</p>
<div className="space-y-2 max-h-56 overflow-y-auto"> <div className="space-y-2 max-h-56 overflow-y-auto">
{[ {PASSENGER_COLS.map((col) => (
{ 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) => (
<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"> <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 <input type="checkbox" checked={exportColumns[col.key] || false}
type="checkbox"
checked={exportColumns[col.key] || false}
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })} 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> <span className="text-sm font-medium">{col.label}</span>
</label> </label>
))} ))}
</div> </div>
</div> </div>
<div className="flex justify-end gap-2 pt-4 border-t"> <div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton> <ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
<ActionButton onClick={confirmExportPassengers}>Export CSV</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 confirmExport = () => {
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k); 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 === 0) { alert('Please select at least one column'); return; }
@@ -42,16 +51,16 @@ export default function PaymentsPage() {
}); });
const csv = [ const csv = [
cols.join(','), PAYMENT_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((payment: any) => { ...exportItems.map((payment: any) => {
const values = cols.map(col => { const values = PAYMENT_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (col) { switch (key) {
case 'reference': return payment.reference || payment.id?.substring(0, 8) || ''; case 'reference': return payment.reference || payment.id?.substring(0, 8) || '';
case 'booking': return payment.booking?.bookingRef || 'N/A'; case 'booking': return payment.booking?.bookingRef || 'N/A';
case 'amount': return formatCurrency(payment.amountMinor, payment.currency); case 'amount': return formatCurrency(payment.amountMinor, payment.currency);
case 'method': return payment.method || ''; case 'method': return payment.method || '';
case 'status': return payment.status || ''; case 'status': return payment.status || '';
case 'createdAt': return payment.createdAt || ''; case 'createdAt': return payment.createdAt ? new Date(payment.createdAt).toLocaleString() : '';
default: return ''; default: return '';
} }
}); });
@@ -84,7 +93,7 @@ export default function PaymentsPage() {
<h1 className="text-2xl font-bold text-foreground">Payments</h1> <h1 className="text-2xl font-bold text-foreground">Payments</h1>
<p className="text-muted-foreground">Manage payment transactions and refunds</p> <p className="text-muted-foreground">Manage payment transactions and refunds</p>
</div> </div>
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton> <ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div> </div>
<div className="card"> <div className="card">

View File

@@ -239,9 +239,9 @@ export default function ReportsPage() {
<Pie <Pie
data={[ data={[
{ name: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length }, { 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: '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)} ].filter(d => d.value > 0)}
cx="50%" cx="50%"
cy="50%" cy="50%"
@@ -306,7 +306,7 @@ export default function ReportsPage() {
</div> </div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"> <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-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>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"> <div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Cancelled Bookings</p> <p className="text-sm text-muted-foreground">Cancelled Bookings</p>

View File

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

View File

@@ -443,13 +443,12 @@ export default function SeatsPage() {
className="input" className="input"
> >
<option value="">Select a schedule...</option> <option value="">Select a schedule...</option>
{schedules.map((schedule: any) => { {schedules.map((schedule: any) => {
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
const routeName = schedule.route?.name || 'N/A'; const routeName = schedule.route?.name || 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A'; const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
return ( return (
<option key={schedule.id} value={schedule.id}> <option key={schedule.id} value={schedule.id}>
{trainNumber} - {routeName} - {date} {date} - {routeName}
</option> </option>
); );
})} })}

View File

@@ -72,8 +72,8 @@ export default function StationsPage() {
name: formData.get('name') as string, name: formData.get('name') as string,
city: formData.get('city') as string, city: formData.get('city') as string,
countryCode: formData.get('countryCode') as string, countryCode: formData.get('countryCode') as string,
lat: parseFloat(formData.get('lat') as string) || null, lat: parseFloat(formData.get('lat') as string) || undefined,
lng: parseFloat(formData.get('lng') as string) || null, lng: parseFloat(formData.get('lng') as string) || undefined,
timezone: formData.get('timezone') as string, timezone: formData.get('timezone') as string,
sequence, sequence,
isOperational: formData.get('isOperational') === 'true', isOperational: formData.get('isOperational') === 'true',
@@ -304,28 +304,6 @@ export default function StationsPage() {
<option value="DJ">Djibouti (DJ)</option> <option value="DJ">Djibouti (DJ)</option>
</select> </select>
</div> </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> <div>
<label className="label">Timezone *</label> <label className="label">Timezone *</label>
<select <select

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
@@ -10,7 +10,7 @@ import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import { ticketsApi, apiClient, stationsApi } from '@/lib/api'; import { ticketsApi, apiClient, stationsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
export default function TicketsPage() { export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' }); const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
@@ -18,6 +18,8 @@ export default function TicketsPage() {
const [ticketToDelete, setTicketToDelete] = useState<any>(null); const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false); const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
const [ticketToBoard, setTicketToBoard] = useState<any>(null); const [ticketToBoard, setTicketToBoard] = useState<any>(null);
const [printBpModalOpen, setPrintBpModalOpen] = useState(false);
const [ticketToPrint, setTicketToPrint] = useState<any>(null);
const [successMessage, setSuccessMessage] = useState(''); const [successMessage, setSuccessMessage] = useState('');
const [detailsModalOpen, setDetailsModalOpen] = useState(false); const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null); const [selectedTicket, setSelectedTicket] = useState<any>(null);
@@ -88,9 +90,75 @@ export default function TicketsPage() {
}; };
const handleConfirmBoard = async () => { const handleConfirmBoard = async () => {
if (ticketToBoard) { if (!ticketToBoard) return;
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id }); 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) => { 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 confirmExport = () => {
const cols = Object.entries(selectedColumns) const cols = Object.entries(selectedColumns)
.filter(([, selected]) => selected) .filter(([, selected]) => selected)
@@ -125,19 +206,20 @@ export default function TicketsPage() {
}); });
const csv = [ const csv = [
cols.join(','), TICKET_COLS.map(c => `"${c.label}"`).join(','),
...exportItems.map((ticket: any) => { ...exportItems.map((ticket: any) => {
const values = cols.map(col => { const values = TICKET_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
switch (col) { switch (key) {
case 'ticketNumber': return ticket.ticketNumber || ''; case 'ticketNumber': return ticket.ticketNumber || 'N/A';
case 'booking': return ticket.booking?.bookingRef || ''; case 'booking': return ticket.booking?.bookingRef || 'N/A';
case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`; case 'passenger': return ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A';
case 'coach': return ticket.seat?.coach?.number || ''; case 'trip': return `${ticket.schedule?.originStation?.name || 'N/A'} - ${ticket.schedule?.destinationStation?.name || 'N/A'}`;
case 'seat': return ticket.seat?.seatNumber || ''; case 'coach': return ticket.seat?.coach?.number || 'N/A';
case 'seatClass': return ticket.seat?.coach?.coachType?.name || ''; case 'seat': return ticket.seat?.seatNumber || 'N/A';
case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB'); case 'seatClass': return ticket.seat?.coach?.coachType?.type || 'N/A';
case 'status': return ticket.status || ''; case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB');
case 'boarded': return ticket.boardedAt ? 'Yes' : 'No'; case 'status': return ticket.status || 'N/A';
case 'boarded': return ticket.boardedAt ? 'Yes' : 'No';
default: return ''; default: return '';
} }
}); });
@@ -160,17 +242,22 @@ export default function TicketsPage() {
label: 'Ticket Number', label: 'Ticket Number',
sortable: true, sortable: true,
render: (ticket: any) => ( 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', key: 'contact',
label: 'Booking', label: 'Contact',
render: (ticket: any) => ( render: (ticket: any) => (
<div> <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"> <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>
</div> </div>
), ),
@@ -178,16 +265,23 @@ export default function TicketsPage() {
{ {
key: 'trip', key: 'trip',
label: 'Trip', label: 'Trip',
render: (ticket: any) => ( render: (ticket: any) => {
<div> const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
<div className="font-medium"> const returnArrivalAt = ticket.booking?.returnSchedule?.arrivalAt;
{ticket.schedule?.originStation?.name || 'N/A'} {ticket.schedule?.destinationStation?.name || 'N/A'} 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>
<div className="text-sm text-muted-foreground"> );
{ticket.schedule?.departureAt ? formatDateTime(ticket.schedule.departureAt) : 'N/A'} },
</div>
</div>
),
}, },
{ {
key: 'seat', key: 'seat',
@@ -195,54 +289,29 @@ export default function TicketsPage() {
sortable: true, sortable: true,
render: (ticket: any) => ( render: (ticket: any) => (
<div> <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 className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.type || 'N/A'}</div>
</div> </div>
), ),
}, },
{ {
key: 'amount', key: 'boardingTimes',
label: 'Amount', label: 'Boarding Times',
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',
render: (ticket: any) => { render: (ticket: any) => {
const status = ticket.booking?.returnLegStatus; const outbound = ticket.booking?.outboundBoardedAt;
if (!status || status === 'NOT_APPLICABLE') return <span className="text-xs text-muted-foreground"></span>; const inbound = ticket.booking?.returnBoardedAt;
const map: Record<string, { label: string; cls: string }> = { const hasAny = outbound || inbound;
NEITHER_USED: { label: 'Neither Used', cls: 'edr-badge-warning' }, if (!hasAny) return <span className="text-sm text-muted-foreground">Not boarded</span>;
OUTBOUND_ONLY: { label: 'Outbound Only', cls: 'edr-badge-info' }, return (
INBOUND_ONLY: { label: 'Inbound Only', cls: 'edr-badge-danger' }, <div className="flex flex-col gap-0.5 text-sm">
BOTH_USED: { label: 'Both Used', cls: 'edr-badge-success' }, <span className={outbound ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}>
}; {outbound ? formatDateTimeShort(outbound) : 'Not boarded'}
const entry = map[status] ?? { label: status, cls: 'edr-badge-info' }; </span>
return <span className={`edr-badge ${entry.cls}`}>{entry.label}</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, onClick: handleBoard,
variant: 'primary' as const, variant: 'primary' as const,
icon: LogIn, 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', label: 'Details',
@@ -262,6 +349,7 @@ export default function TicketsPage() {
setDetailsModalOpen(true); setDetailsModalOpen(true);
}, },
variant: 'secondary' as const, variant: 'secondary' as const,
icon: ListCollapse,
}, },
{ {
label: 'Delete', label: 'Delete',
@@ -280,7 +368,7 @@ export default function TicketsPage() {
<h1 className="text-2xl font-bold text-foreground">Tickets</h1> <h1 className="text-2xl font-bold text-foreground">Tickets</h1>
<p className="text-muted-foreground">Manage tickets and validations</p> <p className="text-muted-foreground">Manage tickets and validations</p>
</div> </div>
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton> <ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div> </div>
{/* Filters */} {/* Filters */}
@@ -366,17 +454,67 @@ export default function TicketsPage() {
emptyMessage="No tickets found" emptyMessage="No tickets found"
/> />
{/* Board Confirmation Dialog */} {/* Board Confirmation Modal */}
<ConfirmDialog <Modal
isOpen={boardConfirmOpen} isOpen={boardConfirmOpen}
onClose={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }} onClose={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}
onConfirm={handleConfirmBoard}
title="Board Ticket" title="Board Ticket"
message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`} size="sm"
confirmText="Board" >
cancelText="Cancel" <div className="space-y-4">
isLoading={boardMutation.isPending} <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 */} {/* Delete Confirmation Dialog */}
<ConfirmDialog <ConfirmDialog

View File

@@ -22,6 +22,13 @@ export const formatDateTime = (date?: string | Date | null): string => {
return format(d, 'MMM dd, yyyy HH:mm'); 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 => { export const formatDateTimeLocal = (date?: string | Date | null): string => {
if (!date) return 'N/A'; if (!date) return 'N/A';
const d = new Date(date); 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', 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', 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', 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', 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', 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', 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; passengerId: string;
passenger?: Passenger.IPassenger; passenger?: Passenger.IPassenger;
scheduleId: string; scheduleId: string;
status: 'DRAFT' | 'PENDING_PAYMENT' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW' | 'REFUNDED'; status: 'DRAFT' | 'PENDING_PAYMENT' | 'CONFIRMED' | 'CANCELLED' | 'BOARDED' | 'NO_SHOW' | 'REFUNDED';
currency: string; currency: string;
totalMinor: number; totalMinor: number;
adultCount: number; adultCount: number;
@@ -33,8 +33,8 @@ export interface Station {
countryCode?: string; countryCode?: string;
isOperational: boolean; isOperational: boolean;
timezone: string; timezone: string;
lat: number; lat?: number;
lng: number; lng?: number;
createdAt: string; createdAt: string;
} }

View File

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

View File

@@ -150,68 +150,40 @@ export default function ReviewPage() {
return apiClient.post(endpoint, data); return apiClient.post(endpoint, data);
}, },
onSuccess: (data: any) => { 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 bookingIdValue = data.bookingId || data.id;
const pnrValue = data.pnr || data.bookingReference || data.bookingRef; 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); setBookingId(bookingIdValue);
setPNR(pnrValue); setPNR(pnrValue);
const totalAmount = data.totalMinor || data.totalAmount || 0;
const totalAmount = isAuthenticated ? (data.totalMinor || data.totalAmount || 0) : (data.totalMinor || data.totalAmount || 0);
console.log('Total amount:', totalAmount);
setTimeout(() => { 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) { if (totalAmount > 0) {
console.log('Redirecting to payment page');
router.push('/booking/payment'); router.push('/booking/payment');
} else { } else {
console.log('Redirecting to confirmation page');
router.push('/booking/confirmation'); router.push('/booking/confirmation');
} }
}, 100); }, 100);
}, },
onError: (error: any) => { 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.'; const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create booking. Please try again.';
alert(errorMessage); alert(errorMessage);
}, },
}); });
const handleConfirm = async () => { const handleConfirm = async () => {
console.log('handleConfirm called');
try { try {
const { searchCriteria } = useBookingStore.getState(); const { searchCriteria } = useBookingStore.getState();
console.log('Search criteria:', searchCriteria); if (!seatHold?.holdId) {
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');
alert('Please select seats before continuing.'); alert('Please select seats before continuing.');
router.push('/booking/seats'); router.push('/booking/seats');
return; return;
} }
if (isRoundTrip && !seatHold.returnHoldId) {
alert('Please select return seats before continuing.');
router.push('/booking/seats');
return;
}
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) { if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) {
console.error('Missing search criteria'); console.error('Missing search criteria');
@@ -247,37 +219,24 @@ export default function ReviewPage() {
console.log('Token found, length:', token.length); console.log('Token found, length:', token.length);
let passengerId = getPassengerIdFromToken(token); let passengerId = getPassengerIdFromToken(token);
console.log('Extracted passenger ID from JWT token:', passengerId);
// Fallback 1: Use passengerId from booking store // Fallback 1: Use passengerId from booking store
if (!passengerId && storedPassengerId) { if (!passengerId && storedPassengerId) {
passengerId = storedPassengerId; passengerId = storedPassengerId;
console.log('Fallback 1: Using passengerId from booking store:', passengerId);
} }
// Fallback 2: Use passengerId from localStorage // Fallback 2: Use passengerId from localStorage
if (!passengerId && typeof window !== 'undefined') { if (!passengerId && typeof window !== 'undefined') {
const localStoragePassengerId = localStorage.getItem('booking_passengerId'); const localStoragePassengerId = localStorage.getItem('booking_passengerId');
if (localStoragePassengerId) { if (localStoragePassengerId) passengerId = localStoragePassengerId;
passengerId = localStoragePassengerId;
console.log('Fallback 2: Using passengerId from localStorage:', passengerId);
}
} }
// Fallback 3: Use passengerId from user object // Fallback 3: Use passengerId from user object
if (!passengerId && user) { if (!passengerId && user) {
passengerId = (user as any).passengerId; passengerId = (user as any).passengerId;
console.log('Fallback 3: Using passengerId from user object:', passengerId);
} }
if (!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.'); 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.returnScheduleId = inboundSchedule.id;
bookingData.returnOriginStationId = searchCriteria.destinationStationId; bookingData.returnOriginStationId = searchCriteria.destinationStationId;
bookingData.returnDestinationStationId = searchCriteria.originStationId; bookingData.returnDestinationStationId = searchCriteria.originStationId;
bookingData.returnHoldId = seatHold?.holdId || ''; // Assuming same hold ID, adjust if needed bookingData.returnHoldId = seatHold.returnHoldId || seatHold.holdId;
bookingData.returnSeatClassId = returnSeatClassId; bookingData.returnSeatClassId = returnSeatClassId;
} }
@@ -356,7 +315,7 @@ export default function ReviewPage() {
bookingData.returnScheduleId = inboundSchedule.id; bookingData.returnScheduleId = inboundSchedule.id;
bookingData.returnOriginStationId = searchCriteria.destinationStationId; bookingData.returnOriginStationId = searchCriteria.destinationStationId;
bookingData.returnDestinationStationId = searchCriteria.originStationId; bookingData.returnDestinationStationId = searchCriteria.originStationId;
bookingData.returnHoldId = seatHold?.holdId || ''; // Assuming same hold ID, adjust if needed bookingData.returnHoldId = seatHold.returnHoldId || seatHold.holdId;
bookingData.returnSeatClassId = returnSeatClassId; bookingData.returnSeatClassId = returnSeatClassId;
} }
@@ -407,31 +366,12 @@ export default function ReviewPage() {
const displaySchedule = isRoundTrip ? outboundSchedule : selectedSchedule; const displaySchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
console.log('Selected schedule:', displaySchedule); const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum) => sum + (outboundSchedule.baseFareAdult || 0), 0) : 0;
console.log('Base fare adult:', displaySchedule?.baseFareAdult); const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum) => sum + (inboundSchedule.baseFareAdult || 0), 0) : 0;
console.log('Passengers:', passengers); const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => {
const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
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}`);
return sum + farePerPassenger; return sum + farePerPassenger;
}, 0); }, 0);
console.log('Calculated base fare:', baseFare);
const total = baseFare; const total = baseFare;
return ( return (

View File

@@ -111,10 +111,22 @@ export default function SeatsPage() {
}); });
}, },
onSuccess: (data: any) => { onSuccess: (data: any) => {
setSeatHold({ const isInbound = isRoundTrip && currentJourneyType === 'inbound';
holdId: data.holdId || data.id, if (isInbound) {
expiresAt: data.expiresAt, // 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) { if (!coachId) {
return ( return (

View File

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

View File

@@ -45,7 +45,7 @@ export class CacBankProvider implements PaymentProvider {
api_key: this.apiKey, api_key: this.apiKey,
customer_mobile: input.payerAccount, customer_mobile: input.payerAccount,
currency: input.currency || this.defaultCurrency, currency: input.currency || this.defaultCurrency,
desc: `EDR ${input.orderRef}`.slice(0, 500), desc: `${input.orderRef}`.slice(0, 500),
vender_ref: input.merchantOrderId, vender_ref: input.merchantOrderId,
amount: this.toMajorAmount(input.amountMinor, input.currency), amount: this.toMajorAmount(input.amountMinor, input.currency),
company_services_id: this.companyServicesId, company_services_id: this.companyServicesId,

View File

@@ -62,7 +62,7 @@ export class CardProvider implements PaymentProvider {
const requestBody: CardInitiateRequest = { const requestBody: CardInitiateRequest = {
amount, amount,
currency: input.currency, currency: input.currency,
description: `EDR ${input.orderRef}`, description: `${input.orderRef}`,
metadata: { metadata: {
merchantOrderId: input.merchantOrderId, merchantOrderId: input.merchantOrderId,
orderRef: input.orderRef, orderRef: input.orderRef,

View File

@@ -62,7 +62,7 @@ export class CbeBirrProvider implements PaymentProvider {
merchantOrderId: input.merchantOrderId, merchantOrderId: input.merchantOrderId,
amount, amount,
currency: input.currency, currency: input.currency,
description: `EDR ${input.orderRef}`, description: `${input.orderRef}`,
// Per-transaction browser return target (each calling app has its own UI); config is fallback. // Per-transaction browser return target (each calling app has its own UI); config is fallback.
returnUrl: input.returnUrl ?? this.returnUrl, returnUrl: input.returnUrl ?? this.returnUrl,
notifyUrl: this.notifyUrl, notifyUrl: this.notifyUrl,

View File

@@ -207,7 +207,7 @@ export class DMoneyProvider implements PaymentProvider {
merch_code: this.merchantCode, merch_code: this.merchantCode,
merch_order_id: input.merchantOrderId, merch_order_id: input.merchantOrderId,
trade_type: "Checkout" as const, trade_type: "Checkout" as const,
title: `EDR ${input.orderRef}`, title: `${input.orderRef}`,
total_amount: totalAmount, total_amount: totalAmount,
trans_currency: 1 == 1 ? "DJF": this.currency, trans_currency: 1 == 1 ? "DJF": this.currency,
timeout_express: this.timeoutExpress, timeout_express: this.timeoutExpress,

View File

@@ -210,7 +210,7 @@ export class WaafiProvider implements PaymentProvider {
amount: this.toAmount(input.amountMinor), amount: this.toAmount(input.amountMinor),
// Waafi has no ETB; `waafi.currency` overrides the booking currency when set. // Waafi has no ETB; `waafi.currency` overrides the booking currency when set.
currency: this.currency || input.currency, currency: this.currency || input.currency,
description: `EDR ${input.orderRef}`, description: `${input.orderRef}`,
}, },
}, },
}; };