mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Backoffice updates, boarding pass, alternative schedule search and more
This commit is contained in:
@@ -20,7 +20,7 @@ CREATE TYPE "IdDocumentType" AS ENUM ('NATIONAL_ID', 'PASSPORT', 'DRIVING_LICENS
|
||||
CREATE TYPE "Currency" AS ENUM ('ETB', 'DJF', 'USD');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW', 'REFUNDED');
|
||||
CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'BOARDED', 'NO_SHOW', 'REFUNDED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL');
|
||||
@@ -76,7 +76,9 @@ CREATE TABLE "SeatClass" (
|
||||
"coachTypeId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"baseFareMinor" INTEGER NOT NULL,
|
||||
"baseFareMinor" INTEGER NOT NULL DEFAULT 0,
|
||||
"premiumMinor" INTEGER NOT NULL DEFAULT 0,
|
||||
"insuranceFeeMinor" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
@@ -94,6 +96,8 @@ CREATE TABLE "User" (
|
||||
"role" "UserRole" NOT NULL DEFAULT 'PASSENGER',
|
||||
"nationality" TEXT,
|
||||
"nationalityCode" TEXT,
|
||||
"gender" TEXT,
|
||||
"dateOfBirth" TIMESTAMP(3),
|
||||
"passportNumber" TEXT,
|
||||
"nationalId" TEXT,
|
||||
"failedLoginAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -155,10 +159,11 @@ CREATE TABLE "Station" (
|
||||
"name" TEXT NOT NULL,
|
||||
"city" TEXT NOT NULL,
|
||||
"countryCode" TEXT,
|
||||
"sequence" INTEGER NOT NULL DEFAULT 0,
|
||||
"isOperational" BOOLEAN NOT NULL DEFAULT true,
|
||||
"timezone" TEXT NOT NULL DEFAULT 'Africa/Addis_Ababa',
|
||||
"lat" DECIMAL(9,6) NOT NULL,
|
||||
"lng" DECIMAL(9,6) NOT NULL,
|
||||
"lat" DECIMAL(9,6),
|
||||
"lng" DECIMAL(9,6),
|
||||
|
||||
CONSTRAINT "Station_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -234,6 +239,7 @@ CREATE TABLE "Coach" (
|
||||
"number" TEXT NOT NULL,
|
||||
"arrangement" TEXT NOT NULL DEFAULT '2+2',
|
||||
"capacity" INTEGER NOT NULL DEFAULT 0,
|
||||
"sequence" INTEGER NOT NULL DEFAULT 0,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
@@ -140,8 +140,7 @@ ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
|
||||
-- AlterTable
|
||||
ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "gender" SET DATA TYPE TEXT;
|
||||
-- gender column already TEXT from init migration
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Booking_bookingType_idx" ON "Booking"("bookingType");
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Empty placeholder migration
|
||||
SELECT 1;
|
||||
@@ -1,36 +1,9 @@
|
||||
-- Add sequence column to Station table if it doesn't exist
|
||||
ALTER TABLE "passenger"."Station" ADD COLUMN IF NOT EXISTS "sequence" INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "Station"("sequence");
|
||||
|
||||
-- Add index on sequence for Station
|
||||
CREATE INDEX IF NOT EXISTS "Station_sequence_idx" ON "passenger"."Station"("sequence");
|
||||
|
||||
-- Add sequence column to Coach table if it doesn't exist
|
||||
ALTER TABLE "passenger"."Coach" ADD COLUMN IF NOT EXISTS "sequence" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Add index on sequence for Coach
|
||||
CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "passenger"."Coach"("sequence");
|
||||
|
||||
-- Add missing columns to SeatClass if they don't exist
|
||||
ALTER TABLE "passenger"."SeatClass" ADD COLUMN IF NOT EXISTS "premiumMinor" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "passenger"."SeatClass" ADD COLUMN IF NOT EXISTS "insuranceFeeMinor" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Add missing columns to User if they don't exist
|
||||
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "gender" VARCHAR(255);
|
||||
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "dateOfBirth" TIMESTAMP(3);
|
||||
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "passportNumber" VARCHAR(255);
|
||||
ALTER TABLE "passenger"."User" ADD COLUMN IF NOT EXISTS "nationalId" VARCHAR(255);
|
||||
|
||||
-- Ensure Ticket has all required columns
|
||||
ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "validatedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3);
|
||||
|
||||
-- Add missing columns to Booking if they don't exist
|
||||
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "bookingType" VARCHAR(255) NOT NULL DEFAULT 'ONE_WAY';
|
||||
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "displayCurrency" VARCHAR(255);
|
||||
ALTER TABLE "passenger"."Booking" ADD COLUMN IF NOT EXISTS "displayTotalMinor" INTEGER;
|
||||
CREATE INDEX IF NOT EXISTS "Coach_sequence_idx" ON "Coach"("sequence");
|
||||
|
||||
-- Ensure all indexes exist
|
||||
CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "passenger"."Station"("city", "countryCode");
|
||||
CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "passenger"."Coach"("coachTypeId");
|
||||
CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "passenger"."TrainSchedule"("departureAt", "originStationId");
|
||||
CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "passenger"."Booking"("passengerId", "status");
|
||||
CREATE INDEX IF NOT EXISTS "Station_city_countryCode_idx" ON "Station"("city", "countryCode");
|
||||
CREATE INDEX IF NOT EXISTS "Coach_coachTypeId_idx" ON "Coach"("coachTypeId");
|
||||
CREATE INDEX IF NOT EXISTS "TrainSchedule_departureAt_originStationId_idx" ON "TrainSchedule"("departureAt", "originStationId");
|
||||
CREATE INDEX IF NOT EXISTS "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status");
|
||||
|
||||
@@ -1,164 +1,164 @@
|
||||
-- Add CASCADE delete to all foreign key constraints that are missing it
|
||||
|
||||
-- TrainSchedule relations
|
||||
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey";
|
||||
ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "passenger"."Train"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_trainId_fkey";
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey";
|
||||
ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "passenger"."Route"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_routeId_fkey";
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey";
|
||||
ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_originStationId_fkey";
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "passenger"."TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey";
|
||||
ALTER TABLE "passenger"."TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT IF EXISTS "TrainSchedule_destinationStationId_fkey";
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE CASCADE;
|
||||
|
||||
-- Coach relation
|
||||
ALTER TABLE "passenger"."Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey";
|
||||
ALTER TABLE "passenger"."Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "passenger"."CoachType"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "Coach" DROP CONSTRAINT IF EXISTS "Coach_coachTypeId_fkey";
|
||||
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE CASCADE;
|
||||
|
||||
-- CoachAssignment relations
|
||||
ALTER TABLE "passenger"."CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey";
|
||||
ALTER TABLE "passenger"."CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_scheduleId_fkey";
|
||||
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "passenger"."CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey";
|
||||
ALTER TABLE "passenger"."CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "passenger"."Coach"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "CoachAssignment" DROP CONSTRAINT IF EXISTS "CoachAssignment_coachId_fkey";
|
||||
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE CASCADE;
|
||||
|
||||
-- Booking relations
|
||||
ALTER TABLE "passenger"."Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey";
|
||||
ALTER TABLE "passenger"."Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_passengerId_fkey";
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "passenger"."Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey";
|
||||
ALTER TABLE "passenger"."Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "Booking" DROP CONSTRAINT IF EXISTS "Booking_scheduleId_fkey";
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
|
||||
|
||||
-- BookingSeat relations
|
||||
ALTER TABLE "passenger"."BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey";
|
||||
ALTER TABLE "passenger"."BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_bookingId_fkey";
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "passenger"."BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey";
|
||||
ALTER TABLE "passenger"."BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "BookingSeat" DROP CONSTRAINT IF EXISTS "BookingSeat_seatId_fkey";
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
|
||||
|
||||
-- PaymentIntent
|
||||
ALTER TABLE "passenger"."PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey";
|
||||
ALTER TABLE "passenger"."PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "PaymentIntent" DROP CONSTRAINT IF EXISTS "PaymentIntent_bookingId_fkey";
|
||||
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
|
||||
|
||||
-- PaymentRefund
|
||||
ALTER TABLE "passenger"."PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey";
|
||||
ALTER TABLE "passenger"."PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "passenger"."PaymentIntent"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "PaymentRefund" DROP CONSTRAINT IF EXISTS "PaymentRefund_paymentIntentId_fkey";
|
||||
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE CASCADE;
|
||||
|
||||
-- Ticket
|
||||
ALTER TABLE "passenger"."Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey";
|
||||
ALTER TABLE "passenger"."Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "Ticket" DROP CONSTRAINT IF EXISTS "Ticket_bookingId_fkey";
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
|
||||
|
||||
-- TicketSeat
|
||||
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
|
||||
ALTER TABLE "passenger"."TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
|
||||
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
|
||||
|
||||
-- WalletLedgerEntry
|
||||
ALTER TABLE "passenger"."WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey";
|
||||
ALTER TABLE "passenger"."WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "passenger"."WalletAccount"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT IF EXISTS "WalletLedgerEntry_walletId_fkey";
|
||||
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE CASCADE;
|
||||
|
||||
-- Notification
|
||||
ALTER TABLE "passenger"."Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey";
|
||||
ALTER TABLE "passenger"."Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "Notification" DROP CONSTRAINT IF EXISTS "Notification_passengerId_fkey";
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
|
||||
|
||||
-- MenuItem
|
||||
ALTER TABLE "passenger"."MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey";
|
||||
ALTER TABLE "passenger"."MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_scheduleId_fkey";
|
||||
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "passenger"."MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey";
|
||||
ALTER TABLE "passenger"."MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "passenger"."MenuCategory"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "MenuItem" DROP CONSTRAINT IF EXISTS "MenuItem_categoryId_fkey";
|
||||
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE CASCADE;
|
||||
|
||||
-- FoodOrder
|
||||
ALTER TABLE "passenger"."FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey";
|
||||
ALTER TABLE "passenger"."FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "FoodOrder" DROP CONSTRAINT IF EXISTS "FoodOrder_bookingId_fkey";
|
||||
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
|
||||
|
||||
-- FoodOrderItem
|
||||
ALTER TABLE "passenger"."FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey";
|
||||
ALTER TABLE "passenger"."FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "passenger"."FoodOrder"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT IF EXISTS "FoodOrderItem_orderId_fkey";
|
||||
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE CASCADE;
|
||||
|
||||
-- FaqArticle
|
||||
ALTER TABLE "passenger"."FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey";
|
||||
ALTER TABLE "passenger"."FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "passenger"."FaqCategory"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "FaqArticle" DROP CONSTRAINT IF EXISTS "FaqArticle_categoryId_fkey";
|
||||
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE CASCADE;
|
||||
|
||||
-- SupportMessage
|
||||
ALTER TABLE "passenger"."SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey";
|
||||
ALTER TABLE "passenger"."SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "passenger"."SupportConversation"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "SupportMessage" DROP CONSTRAINT IF EXISTS "SupportMessage_conversationId_fkey";
|
||||
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE CASCADE;
|
||||
|
||||
-- TripStopTime
|
||||
ALTER TABLE "passenger"."TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey";
|
||||
ALTER TABLE "passenger"."TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "TripStopTime" DROP CONSTRAINT IF EXISTS "TripStopTime_scheduleId_fkey";
|
||||
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
|
||||
|
||||
-- TripLiveStatus
|
||||
ALTER TABLE "passenger"."TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey";
|
||||
ALTER TABLE "passenger"."TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT IF EXISTS "TripLiveStatus_scheduleId_fkey";
|
||||
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
|
||||
|
||||
-- JourneySegment
|
||||
ALTER TABLE "passenger"."JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey";
|
||||
ALTER TABLE "passenger"."JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "passenger"."Journey"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_journeyId_fkey";
|
||||
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "passenger"."JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey";
|
||||
ALTER TABLE "passenger"."JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "passenger"."TrainSchedule"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "JourneySegment" DROP CONSTRAINT IF EXISTS "JourneySegment_scheduleId_fkey";
|
||||
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE CASCADE;
|
||||
|
||||
-- AgentBooking
|
||||
ALTER TABLE "passenger"."AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey";
|
||||
ALTER TABLE "passenger"."AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_agentId_fkey";
|
||||
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "passenger"."AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey";
|
||||
ALTER TABLE "passenger"."AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "AgentBooking" DROP CONSTRAINT IF EXISTS "AgentBooking_bookingId_fkey";
|
||||
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
|
||||
|
||||
-- AgentShift
|
||||
ALTER TABLE "passenger"."AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey";
|
||||
ALTER TABLE "passenger"."AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "AgentShift" DROP CONSTRAINT IF EXISTS "AgentShift_agentId_fkey";
|
||||
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
|
||||
|
||||
-- AgentCommission
|
||||
ALTER TABLE "passenger"."AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey";
|
||||
ALTER TABLE "passenger"."AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "passenger"."Agent"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "AgentCommission" DROP CONSTRAINT IF EXISTS "AgentCommission_agentId_fkey";
|
||||
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE;
|
||||
|
||||
-- BookingModification
|
||||
ALTER TABLE "passenger"."BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey";
|
||||
ALTER TABLE "passenger"."BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "BookingModification" DROP CONSTRAINT IF EXISTS "BookingModification_bookingId_fkey";
|
||||
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
|
||||
|
||||
-- BookingCancellation
|
||||
ALTER TABLE "passenger"."BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey";
|
||||
ALTER TABLE "passenger"."BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "BookingCancellation" DROP CONSTRAINT IF EXISTS "BookingCancellation_bookingId_fkey";
|
||||
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
|
||||
|
||||
-- GateValidationLog
|
||||
ALTER TABLE "passenger"."GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey";
|
||||
ALTER TABLE "passenger"."GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "passenger"."Ticket"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "GateValidationLog" DROP CONSTRAINT IF EXISTS "GateValidationLog_ticketId_fkey";
|
||||
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE;
|
||||
|
||||
-- BaggageBooking
|
||||
ALTER TABLE "passenger"."BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey";
|
||||
ALTER TABLE "passenger"."BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "passenger"."Booking"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "BaggageBooking" DROP CONSTRAINT IF EXISTS "BaggageBooking_bookingId_fkey";
|
||||
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE;
|
||||
|
||||
-- RouteFareRule
|
||||
ALTER TABLE "passenger"."RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey";
|
||||
ALTER TABLE "passenger"."RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "RouteFareRule" DROP CONSTRAINT IF EXISTS "RouteFareRule_seatClassId_fkey";
|
||||
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;
|
||||
|
||||
-- SegmentFareRule
|
||||
ALTER TABLE "passenger"."SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey";
|
||||
ALTER TABLE "passenger"."SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT IF EXISTS "SegmentFareRule_seatClassId_fkey";
|
||||
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;
|
||||
|
||||
-- StationCrowdSignal
|
||||
ALTER TABLE "passenger"."StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey";
|
||||
ALTER TABLE "passenger"."StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "passenger"."Station"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT IF EXISTS "StationCrowdSignal_stationId_fkey";
|
||||
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE CASCADE;
|
||||
|
||||
-- SeatBlock
|
||||
ALTER TABLE "passenger"."SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey";
|
||||
ALTER TABLE "passenger"."SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "SeatBlock" DROP CONSTRAINT IF EXISTS "SeatBlock_seatId_fkey";
|
||||
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE CASCADE;
|
||||
|
||||
-- SavedRoute
|
||||
ALTER TABLE "passenger"."SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey";
|
||||
ALTER TABLE "passenger"."SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "passenger"."Passenger"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "SavedRoute" DROP CONSTRAINT IF EXISTS "SavedRoute_passengerId_fkey";
|
||||
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE CASCADE;
|
||||
|
||||
-- LoyaltyLedgerEntry
|
||||
ALTER TABLE "passenger"."LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey";
|
||||
ALTER TABLE "passenger"."LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "passenger"."LoyaltyAccount"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT IF EXISTS "LoyaltyLedgerEntry_accountId_fkey";
|
||||
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE;
|
||||
|
||||
-- LoyaltyReward
|
||||
ALTER TABLE "passenger"."LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey";
|
||||
ALTER TABLE "passenger"."LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "passenger"."LoyaltyAccount"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT IF EXISTS "LoyaltyReward_accountId_fkey";
|
||||
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE;
|
||||
|
||||
-- FareRule
|
||||
ALTER TABLE "passenger"."FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey";
|
||||
ALTER TABLE "passenger"."FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "passenger"."SeatClass"("id") ON DELETE CASCADE;
|
||||
ALTER TABLE "FareRule" DROP CONSTRAINT IF EXISTS "FareRule_seatClassId_fkey";
|
||||
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE CASCADE;
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
|
||||
CREATE TYPE "ReturnLegStatus" AS ENUM ('NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED');
|
||||
|
||||
-- AlterTable: add return leg tracking columns to Booking
|
||||
ALTER TABLE "passenger"."Booking"
|
||||
ADD COLUMN "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
|
||||
ALTER TABLE "Booking"
|
||||
ADD COLUMN "returnLegStatus" "ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
|
||||
ADD COLUMN "outboundBoardedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "returnBoardedAt" TIMESTAMP(3);
|
||||
|
||||
-- Set NEITHER_USED for existing confirmed round-trip bookings
|
||||
UPDATE "passenger"."Booking"
|
||||
UPDATE "Booking"
|
||||
SET "returnLegStatus" = 'NEITHER_USED'
|
||||
WHERE "bookingType" = 'ROUND_TRIP'
|
||||
AND "status" IN ('CONFIRMED', 'COMPLETED');
|
||||
AND "status" IN ('CONFIRMED', 'BOARDED');
|
||||
|
||||
-- AlterTable: add leg column to GateValidationLog
|
||||
ALTER TABLE "passenger"."GateValidationLog"
|
||||
ALTER TABLE "GateValidationLog"
|
||||
ADD COLUMN "leg" TEXT;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- Create passenger schema if it doesn't exist
|
||||
CREATE SCHEMA IF NOT EXISTS passenger;
|
||||
|
||||
-- Move all enums from public to passenger schema
|
||||
-- Move enums from public to passenger schema (only if they exist in public)
|
||||
DO $$
|
||||
DECLARE
|
||||
e text;
|
||||
@@ -13,9 +13,10 @@ BEGIN
|
||||
LOOP
|
||||
EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e);
|
||||
END LOOP;
|
||||
EXCEPTION WHEN others THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- Move all tables from public to passenger schema
|
||||
-- Move tables from public to passenger schema (only if they exist in public)
|
||||
DO $$
|
||||
DECLARE
|
||||
t text;
|
||||
@@ -26,6 +27,7 @@ BEGIN
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t);
|
||||
END LOOP;
|
||||
EXCEPTION WHEN others THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- Add missing columns to Booking
|
||||
|
||||
@@ -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;
|
||||
@@ -108,7 +108,7 @@ enum BookingStatus {
|
||||
PENDING_PAYMENT
|
||||
CONFIRMED
|
||||
CANCELLED
|
||||
COMPLETED
|
||||
BOARDED
|
||||
NO_SHOW
|
||||
REFUNDED
|
||||
|
||||
@@ -325,8 +325,8 @@ model Station {
|
||||
sequence Int @default(0)
|
||||
isOperational Boolean @default(true)
|
||||
timezone String @default("Africa/Addis_Ababa")
|
||||
lat Decimal @db.Decimal(9, 6)
|
||||
lng Decimal @db.Decimal(9, 6)
|
||||
lat Decimal? @db.Decimal(9, 6)
|
||||
lng Decimal? @db.Decimal(9, 6)
|
||||
originSchedules TrainSchedule[] @relation("OriginTrips")
|
||||
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
|
||||
stopTimes TripStopTime[]
|
||||
@@ -548,6 +548,7 @@ model Booking {
|
||||
modifications BookingModification[]
|
||||
cancellation BookingCancellation?
|
||||
baggage BaggageBooking[]
|
||||
journey Journey?
|
||||
|
||||
@@index([passengerId, status])
|
||||
@@index([bookingType])
|
||||
@@ -939,10 +940,12 @@ model SavedRoute {
|
||||
model Journey {
|
||||
id String @id @default(uuid())
|
||||
passengerId String
|
||||
bookingId String? @unique
|
||||
status String
|
||||
totalMinor Int
|
||||
currency String @default("ETB")
|
||||
createdAt DateTime @default(now())
|
||||
booking Booking? @relation(fields: [bookingId], references: [id])
|
||||
journeySegments JourneySegment[]
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -198,6 +198,9 @@ export class BookingsService {
|
||||
{ contactEmail: { contains: search, mode: 'insensitive' } },
|
||||
{ contactPhone: { contains: search, mode: 'insensitive' } },
|
||||
{ passenger: { user: { fullName: { contains: search, mode: 'insensitive' } } } },
|
||||
{ passenger: { user: { email: { contains: search, mode: 'insensitive' } } } },
|
||||
{ passenger: { user: { phone: { contains: search, mode: 'insensitive' } } } },
|
||||
{ seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -237,6 +240,7 @@ export class BookingsService {
|
||||
childCount: booking.childCount,
|
||||
createdAt: booking.createdAt,
|
||||
passenger: booking.passenger?.user,
|
||||
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
originStation: booking.schedule.originStation,
|
||||
@@ -262,10 +266,23 @@ export class BookingsService {
|
||||
return this.createOneWayBooking(dto);
|
||||
}
|
||||
|
||||
private validateSeatIdsAgainstHold(holdId: string, holdSeatIds: string[], requestedSeatIds: string[]) {
|
||||
for (const seatId of requestedSeatIds) {
|
||||
if (!holdSeatIds.includes(seatId)) {
|
||||
throw new BadRequestException(
|
||||
`Seat ${seatId} is not part of hold ${holdId}. Use seat IDs returned from POST /seats/hold.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async createOneWayBooking(dto: CreateBookingDto) {
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
|
||||
|
||||
|
||||
const requestedSeatIds = (dto.passengers as any[]).map(p => p.seatId);
|
||||
this.validateSeatIdsAgainstHold(dto.holdId, hold.seatIds, requestedSeatIds);
|
||||
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }
|
||||
@@ -335,6 +352,11 @@ export class BookingsService {
|
||||
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired');
|
||||
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired');
|
||||
|
||||
const holdObSeatIds = (dto.passengers as any[]).map((p: any) => p.seatId ?? p.outboundSeatId).filter(Boolean);
|
||||
const holdRetSeatIds = (dto.passengers as any[]).map((p: any) => p.returnSeatId).filter(Boolean);
|
||||
if (holdObSeatIds.length) this.validateSeatIdsAgainstHold(dto.holdId, outboundHold.seatIds, holdObSeatIds);
|
||||
if (holdRetSeatIds.length) this.validateSeatIdsAgainstHold(dto.returnHoldId!, returnHold.seatIds, holdRetSeatIds);
|
||||
|
||||
const [outboundSchedule, returnSchedule] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
@@ -478,6 +500,11 @@ export class BookingsService {
|
||||
if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired');
|
||||
if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired');
|
||||
|
||||
const leg1SeatIds = (dto.passengers as any[]).map(p => p.seatId);
|
||||
const leg2SeatIds = (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId);
|
||||
this.validateSeatIdsAgainstHold(dto.holdId, leg1Hold.seatIds, leg1SeatIds);
|
||||
this.validateSeatIdsAgainstHold(dto.leg2HoldId!, leg2Hold.seatIds, leg2SeatIds);
|
||||
|
||||
const [leg1Schedule, leg2Schedule] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
@@ -624,6 +651,11 @@ export class BookingsService {
|
||||
if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 seat hold expired');
|
||||
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 seat hold expired');
|
||||
|
||||
this.validateSeatIdsAgainstHold(dto.holdId, obL1Hold.seatIds, (dto.passengers as any[]).map(p => p.seatId));
|
||||
this.validateSeatIdsAgainstHold(dto.leg2HoldId!, obL2Hold.seatIds, (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId));
|
||||
this.validateSeatIdsAgainstHold(dto.returnHoldId!, retL1Hold.seatIds, (dto.passengers as any[]).map(p => p.returnSeatId));
|
||||
this.validateSeatIdsAgainstHold(dto.returnLeg2HoldId!, retL2Hold.seatIds, (dto.passengers as any[]).map(p => p.returnLeg2SeatId ?? p.returnSeatId));
|
||||
|
||||
// Load all 4 schedules
|
||||
const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
|
||||
@@ -815,7 +847,21 @@ export class BookingsService {
|
||||
nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
|
||||
}
|
||||
|
||||
processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
|
||||
processedPassengers.push({
|
||||
...passenger,
|
||||
passengerName,
|
||||
dateOfBirth,
|
||||
category,
|
||||
verifaydaVerified,
|
||||
verifaydaData,
|
||||
nationality,
|
||||
// Normalise: PassengerInputDto uses seatId/returnSeatId; RoundTripPassengerDto uses
|
||||
// outboundSeatId/returnSeatId. Accept either form so both DTOs work.
|
||||
outboundSeatId: passenger.outboundSeatId ?? passenger.seatId,
|
||||
outboundLeg2SeatId: passenger.outboundLeg2SeatId ?? passenger.leg2SeatId,
|
||||
returnSeatId: passenger.returnSeatId,
|
||||
returnLeg2SeatId: passenger.returnLeg2SeatId,
|
||||
});
|
||||
}
|
||||
return processedPassengers;
|
||||
}
|
||||
@@ -994,7 +1040,7 @@ export class BookingsService {
|
||||
await this.prisma.bookingModification.create({
|
||||
data: { bookingId: booking.id, modifiedBy: booking.passengerId, modificationType: 'SEAT_CHANGE', oldData: { scheduleId: booking.scheduleId, seatIds: oldSeats }, newData: { scheduleId: dto.newScheduleId, seatIds: dto.newSeatIds }, fareAdjustment: 0, reason: dto.reason },
|
||||
});
|
||||
await this.seatsService.releaseSeats(oldSeats);
|
||||
await this.seatsService.releaseSeats(booking.id);
|
||||
await this.seatsService.confirmSeats(dto.newSeatIds);
|
||||
return { modified: true, bookingRef: dto.bookingRef };
|
||||
}
|
||||
@@ -1005,7 +1051,7 @@ export class BookingsService {
|
||||
if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled');
|
||||
const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0;
|
||||
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
|
||||
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
|
||||
await this.seatsService.releaseSeats(booking.id);
|
||||
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
|
||||
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
|
||||
}
|
||||
@@ -1033,7 +1079,7 @@ export class BookingsService {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
|
||||
await this.seatsService.releaseSeats(booking.id);
|
||||
|
||||
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } });
|
||||
await this.prisma.booking.delete({ where: { id } });
|
||||
@@ -1069,7 +1115,7 @@ export class BookingsService {
|
||||
const cutoff = new Date(Date.now() - 20 * 60 * 1000);
|
||||
const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } });
|
||||
for (const b of expired) {
|
||||
await this.seatsService.releaseSeats(b.seats.map((s) => s.seatId));
|
||||
await this.seatsService.releaseSeats(b.id);
|
||||
await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,21 @@ function generateRef(): string {
|
||||
return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
}
|
||||
|
||||
// Ethiopian mobile prefixes: Ethio Telecom (09xx) and Safaricom ET (07xx)
|
||||
const ETH_MOBILE_PREFIXES = ['911','912','913','914','915','916','917','921','922','923','924','930','931','932','933','934','935','936','937','938','939','961','962','963','964'];
|
||||
|
||||
function generateEthiopianPhone(): string {
|
||||
const prefix = ETH_MOBILE_PREFIXES[Math.floor(Math.random() * ETH_MOBILE_PREFIXES.length)];
|
||||
const suffix = String(Math.floor(Math.random() * 1_000_000)).padStart(6, '0');
|
||||
return `+251${prefix}${suffix}`;
|
||||
}
|
||||
|
||||
function generateGuestEmail(uniqueId: string): string {
|
||||
const domains = ['gmail.com', 'yahoo.com', 'ethionet.et', 'telecom.et'];
|
||||
const domain = domains[Math.floor(Math.random() * domains.length)];
|
||||
return `guest.edr.${uniqueId}@${domain}`;
|
||||
}
|
||||
|
||||
function calculateAge(dateOfBirth: Date): number {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
||||
@@ -840,7 +855,7 @@ export class GuestBookingService {
|
||||
const existingPhone = await this.prisma.user.findUnique({ where: { phone: accountPhone } });
|
||||
if (existingPhone) throw new BadRequestException('Phone number already registered. Please login instead.');
|
||||
}
|
||||
if (!accountPhone) accountPhone = `+guest-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
if (!accountPhone) accountPhone = generateEthiopianPhone();
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
@@ -860,17 +875,17 @@ export class GuestBookingService {
|
||||
}
|
||||
|
||||
const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`;
|
||||
let guestEmail = firstPassenger.email || generateGuestEmail(uniqueId);
|
||||
if (firstPassenger.email) {
|
||||
const existing = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } });
|
||||
if (existing) guestEmail = `guest-${uniqueId}@edr-platform.com`;
|
||||
if (existing) guestEmail = generateGuestEmail(uniqueId);
|
||||
}
|
||||
let guestPhone = firstPassenger.phone || null;
|
||||
if (guestPhone) {
|
||||
const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } });
|
||||
if (existing) guestPhone = null;
|
||||
}
|
||||
if (!guestPhone) guestPhone = `+guest-${uniqueId}`;
|
||||
if (!guestPhone) guestPhone = generateEthiopianPhone();
|
||||
|
||||
const tempUser = await this.prisma.user.create({
|
||||
data: {
|
||||
|
||||
@@ -157,11 +157,11 @@ export class PassengersService {
|
||||
|
||||
async getStats(passengerId: string) {
|
||||
const [totalTrips, totalSpendResult, loyalty] = await Promise.all([
|
||||
this.prisma.booking.count({ where: { passengerId, status: 'COMPLETED' } }),
|
||||
this.prisma.booking.aggregate({ where: { passengerId, status: 'COMPLETED' }, _sum: { totalMinor: true } }),
|
||||
this.prisma.booking.count({ where: { passengerId, status: 'BOARDED' as any } }),
|
||||
this.prisma.booking.aggregate({ where: { passengerId, status: 'BOARDED' as any }, _sum: { totalMinor: true } }),
|
||||
this.prisma.loyaltyAccount.findUnique({ where: { passengerId } }),
|
||||
]);
|
||||
const totalSpend = (totalSpendResult._sum.totalMinor ?? 0) / 100;
|
||||
const totalSpend = ((totalSpendResult._sum?.totalMinor ?? 0) as number) / 100;
|
||||
return { totalTrips, totalSpend, loyaltyPoints: loyalty?.pointsBalance ?? 0, co2Saved: totalTrips * 6 };
|
||||
}
|
||||
|
||||
|
||||
@@ -445,7 +445,7 @@ export class PaymentsService {
|
||||
include: { seats: true },
|
||||
});
|
||||
if (booking) {
|
||||
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
|
||||
await this.seatsService.releaseSeats(booking.id);
|
||||
await this.prisma.booking.update({
|
||||
where: { id: dto.bookingId },
|
||||
data: { status: "CANCELLED" },
|
||||
@@ -734,51 +734,125 @@ export class PaymentsService {
|
||||
private async createJourneySegments(
|
||||
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
||||
) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: booking.scheduleId },
|
||||
include: {
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!schedule) return;
|
||||
const b = booking as any;
|
||||
|
||||
const stopTimes = schedule.stopTimes;
|
||||
if (stopTimes.length < 2) return;
|
||||
// Build per-leg definitions: { scheduleId, originStationId, destinationStationId, seatIds[] }
|
||||
// BookingSeat.leg: 1=outbound/leg-1, 2=return/leg-2, 3=return leg-1 (transit), 4=return leg-2
|
||||
type LegDef = { scheduleId: string; originStationId: string; destinationStationId: string; seatIds: string[] };
|
||||
const legDefs: LegDef[] = [];
|
||||
|
||||
const originSequence = stopTimes.findIndex(
|
||||
(st) => st.stationId === schedule.originStationId,
|
||||
);
|
||||
const destSequence = stopTimes.findIndex(
|
||||
(st) => st.stationId === schedule.destinationStationId,
|
||||
);
|
||||
const seatsForLeg = (legNum: number) =>
|
||||
booking.seats.filter((s: any) => s.leg === legNum).map((s: any) => s.seatId);
|
||||
|
||||
if (
|
||||
originSequence < 0 ||
|
||||
destSequence < 0 ||
|
||||
originSequence >= destSequence
|
||||
)
|
||||
return;
|
||||
if (booking.bookingType === 'ONE_WAY') {
|
||||
legDefs.push({
|
||||
scheduleId: booking.scheduleId,
|
||||
originStationId: b.originStationId,
|
||||
destinationStationId: b.destinationStationId,
|
||||
seatIds: booking.seats.map((s: any) => s.seatId),
|
||||
});
|
||||
} else if (booking.bookingType === 'ROUND_TRIP') {
|
||||
legDefs.push({
|
||||
scheduleId: booking.scheduleId,
|
||||
originStationId: b.originStationId,
|
||||
destinationStationId: b.destinationStationId,
|
||||
seatIds: seatsForLeg(1),
|
||||
});
|
||||
if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) {
|
||||
legDefs.push({
|
||||
scheduleId: b.returnScheduleId,
|
||||
originStationId: b.returnOriginStationId,
|
||||
destinationStationId: b.returnDestinationStationId,
|
||||
seatIds: seatsForLeg(2),
|
||||
});
|
||||
}
|
||||
} else if (booking.bookingType === 'TRANSIT') {
|
||||
legDefs.push({
|
||||
scheduleId: booking.scheduleId,
|
||||
originStationId: b.originStationId,
|
||||
destinationStationId: b.leg2OriginStationId, // transit station
|
||||
seatIds: seatsForLeg(1),
|
||||
});
|
||||
if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) {
|
||||
legDefs.push({
|
||||
scheduleId: b.leg2ScheduleId,
|
||||
originStationId: b.leg2OriginStationId,
|
||||
destinationStationId: b.leg2DestinationStationId,
|
||||
seatIds: seatsForLeg(2),
|
||||
});
|
||||
}
|
||||
} else if (booking.bookingType === 'ROUND_TRIP_TRANSIT') {
|
||||
legDefs.push({
|
||||
scheduleId: booking.scheduleId,
|
||||
originStationId: b.originStationId,
|
||||
destinationStationId: b.leg2OriginStationId,
|
||||
seatIds: seatsForLeg(1),
|
||||
});
|
||||
if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) {
|
||||
legDefs.push({
|
||||
scheduleId: b.leg2ScheduleId,
|
||||
originStationId: b.leg2OriginStationId,
|
||||
destinationStationId: b.leg2DestinationStationId,
|
||||
seatIds: seatsForLeg(2),
|
||||
});
|
||||
}
|
||||
if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) {
|
||||
legDefs.push({
|
||||
scheduleId: b.returnScheduleId,
|
||||
originStationId: b.returnOriginStationId,
|
||||
destinationStationId: b.returnLeg2OriginStationId ?? b.returnDestinationStationId,
|
||||
seatIds: seatsForLeg(3),
|
||||
});
|
||||
}
|
||||
if (b.returnLeg2ScheduleId && b.returnLeg2OriginStationId && b.returnLeg2DestStationId) {
|
||||
legDefs.push({
|
||||
scheduleId: b.returnLeg2ScheduleId,
|
||||
originStationId: b.returnLeg2OriginStationId,
|
||||
destinationStationId: b.returnLeg2DestStationId,
|
||||
seatIds: seatsForLeg(4),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (legDefs.length === 0) return;
|
||||
|
||||
const journey = await this.prisma.journey.create({
|
||||
data: {
|
||||
passengerId: booking.passengerId,
|
||||
status: "CONFIRMED",
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
bookingId: booking.id,
|
||||
status: 'CONFIRMED',
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
},
|
||||
});
|
||||
|
||||
const journeySegments = [];
|
||||
for (const bookingSeat of booking.seats) {
|
||||
for (let i = originSequence; i < destSequence; i++) {
|
||||
journeySegments.push({
|
||||
journeyId: journey.id,
|
||||
scheduleId: booking.scheduleId,
|
||||
segmentOrder: i,
|
||||
seatId: bookingSeat.seatId,
|
||||
departureStationId: stopTimes[i].stationId,
|
||||
arrivalStationId: stopTimes[i + 1].stationId,
|
||||
});
|
||||
const journeySegments: any[] = [];
|
||||
let segmentOrder = 0;
|
||||
|
||||
for (const leg of legDefs) {
|
||||
if (leg.seatIds.length === 0) continue;
|
||||
|
||||
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId: leg.scheduleId },
|
||||
orderBy: { sequence: 'asc' },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
|
||||
const originIdx = stopTimes.findIndex(st => st.stationId === leg.originStationId);
|
||||
const destIdx = stopTimes.findIndex(st => st.stationId === leg.destinationStationId);
|
||||
if (originIdx < 0 || destIdx < 0 || originIdx >= destIdx) continue;
|
||||
|
||||
for (const seatId of leg.seatIds) {
|
||||
for (let i = originIdx; i < destIdx; i++) {
|
||||
journeySegments.push({
|
||||
journeyId: journey.id,
|
||||
scheduleId: leg.scheduleId,
|
||||
segmentOrder: segmentOrder++,
|
||||
seatId,
|
||||
departureStationId: stopTimes[i].stationId,
|
||||
arrivalStationId: stopTimes[i + 1].stationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,23 @@ export class SearchService {
|
||||
|
||||
const outbound = [...direct, ...transit];
|
||||
|
||||
if (outbound.length === 0) {
|
||||
const alternativesOutbound = await this.searchAlternatives(
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
);
|
||||
return {
|
||||
journeyType: dto.journeyType === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY',
|
||||
outbound: [],
|
||||
alternativeOutbound: alternativesOutbound,
|
||||
requestedDate: dto.date,
|
||||
};
|
||||
}
|
||||
|
||||
if (dto.journeyType === 'ROUND_TRIP') {
|
||||
const [returnDirect, returnTransit] = await Promise.all([
|
||||
this.searchSchedules(
|
||||
@@ -68,12 +85,85 @@ export class SearchService {
|
||||
new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival
|
||||
);
|
||||
|
||||
if (inbound.length === 0) {
|
||||
const alternativeInbound = await this.searchAlternatives(
|
||||
dto.destinationStationId,
|
||||
dto.originStationId,
|
||||
dto.returnDate ?? dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
);
|
||||
return { journeyType: 'ROUND_TRIP', outbound, inbound: [], alternativeInbound };
|
||||
}
|
||||
|
||||
return { journeyType: 'ROUND_TRIP', outbound, inbound };
|
||||
}
|
||||
|
||||
return { journeyType: 'ONE_WAY', outbound };
|
||||
}
|
||||
|
||||
private async searchAlternatives(
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
dateStr: string,
|
||||
adultCount: number,
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
) {
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
const requestedDate = new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||
|
||||
const now = new Date();
|
||||
const daysBefore = Math.min(7, Math.floor(requestedDate.getTime() / 86_400_000));
|
||||
const daysAfter = 14 - daysBefore;
|
||||
|
||||
const windowStart = new Date(requestedDate);
|
||||
windowStart.setDate(windowStart.getDate() - daysBefore);
|
||||
if (windowStart < now) windowStart.setTime(now.getTime());
|
||||
|
||||
const windowEnd = new Date(requestedDate);
|
||||
windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); // exclusive upper bound
|
||||
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
const requestedNextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
|
||||
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||
OR: [
|
||||
{ departureAt: { gte: windowStart, lt: requestedDate } },
|
||||
{ departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } },
|
||||
],
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
},
|
||||
include: {
|
||||
train: true,
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
coachAssignments: {
|
||||
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
|
||||
},
|
||||
},
|
||||
orderBy: { departureAt: 'asc' },
|
||||
});
|
||||
|
||||
const results: any[] = [];
|
||||
for (const schedule of schedules) {
|
||||
const result = await this.buildScheduleResult(
|
||||
schedule,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
totalPassengers,
|
||||
nationality,
|
||||
);
|
||||
if (result) results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async searchSchedules(
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
@@ -85,12 +175,13 @@ export class SearchService {
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
const date = new Date(y, m - 1, d, 0, 0, 0, 0);
|
||||
const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
|
||||
const now = new Date();
|
||||
const totalPassengers = adultCount + (childCount ?? 0);
|
||||
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||
departureAt: { gte: date, lt: nextDay },
|
||||
departureAt: { gte: date < now ? now : date, lt: nextDay },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
},
|
||||
include: {
|
||||
|
||||
@@ -11,7 +11,7 @@ export class SeatsService {
|
||||
private segmentsService: SegmentsService,
|
||||
) {}
|
||||
|
||||
async getSeatMap(scheduleId: string, coachId?: string) {
|
||||
async getSeatMap(scheduleId: string, coachId?: string, originStationId?: string, destinationStationId?: string) {
|
||||
const assignments = await this.prisma.coachAssignment.findMany({
|
||||
where: { scheduleId, ...(coachId ? { coachId } : {}) },
|
||||
include: {
|
||||
@@ -25,16 +25,14 @@ export class SeatsService {
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
});
|
||||
|
||||
console.log(`[getSeatMap] scheduleId=${scheduleId}, coachId=${coachId}, found ${assignments.length} coach assignments`);
|
||||
|
||||
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds);
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, originStationId, destinationStationId);
|
||||
|
||||
const response = {
|
||||
return {
|
||||
coaches: assignments.map((a) => {
|
||||
const allSeats = a.coach.seats;
|
||||
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
|
||||
|
||||
|
||||
return {
|
||||
id: a.coach.id,
|
||||
assignmentId: a.id,
|
||||
@@ -68,43 +66,95 @@ export class SeatsService {
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
console.log(`[getSeatMap] returning ${response.coaches.length} coaches with seats`);
|
||||
return response;
|
||||
}
|
||||
|
||||
async resolveEffectiveStatuses(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
originStationId?: string,
|
||||
destinationStationId?: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const statusMap = new Map<string, string>();
|
||||
|
||||
if (seatIds.length === 0) return statusMap;
|
||||
|
||||
// Resolve the requested leg's sequence range once
|
||||
let reqFrom: number | undefined;
|
||||
let reqTo: number | undefined;
|
||||
let allStopTimes: { stationId: string; sequence: number }[] | null = null;
|
||||
|
||||
const getStopTimes = async () => {
|
||||
if (!allStopTimes) {
|
||||
allStopTimes = await this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
}
|
||||
return allStopTimes;
|
||||
};
|
||||
|
||||
if (originStationId && destinationStationId) {
|
||||
const stops = await getStopTimes();
|
||||
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
||||
reqFrom = seqOf(originStationId);
|
||||
reqTo = seqOf(destinationStationId);
|
||||
}
|
||||
|
||||
// ── Active holds ──────────────────────────────────────────────────────────
|
||||
const activeHolds = await this.prisma.seatHold.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
expiresAt: { gt: new Date() },
|
||||
seatIds: { hasSome: seatIds },
|
||||
},
|
||||
select: { seatIds: true },
|
||||
where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } },
|
||||
select: { seatIds: true, createdBy: true },
|
||||
});
|
||||
|
||||
for (const hold of activeHolds) {
|
||||
let holdFrom: number | undefined;
|
||||
let holdTo: number | undefined;
|
||||
try {
|
||||
if (hold.createdBy?.trimStart().startsWith('{')) {
|
||||
const meta = JSON.parse(hold.createdBy);
|
||||
const stops = await getStopTimes();
|
||||
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
||||
holdFrom = seqOf(meta.originStationId);
|
||||
holdTo = seqOf(meta.destinationStationId);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
for (const seatId of hold.seatIds) {
|
||||
if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD');
|
||||
if (!seatIds.includes(seatId)) continue;
|
||||
if (reqFrom !== undefined && reqTo !== undefined && holdFrom !== undefined && holdTo !== undefined) {
|
||||
if (holdFrom < reqTo && reqFrom < holdTo) statusMap.set(seatId, 'HELD');
|
||||
} else {
|
||||
statusMap.set(seatId, 'HELD');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Confirmed bookings via JourneySegment ─────────────────────────────────
|
||||
const bookedSegments = await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
seatId: { in: seatIds },
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
select: { seatId: true },
|
||||
select: { seatId: true, departureStationId: true, arrivalStationId: true },
|
||||
});
|
||||
for (const seg of bookedSegments) {
|
||||
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
|
||||
|
||||
if (reqFrom !== undefined && reqTo !== undefined) {
|
||||
const stops = await getStopTimes();
|
||||
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
||||
for (const seg of bookedSegments) {
|
||||
if (!seg.seatId) continue;
|
||||
const segFrom = seqOf(seg.departureStationId);
|
||||
const segTo = seqOf(seg.arrivalStationId);
|
||||
if (segFrom !== undefined && segTo !== undefined) {
|
||||
if (segFrom < reqTo && reqFrom < segTo) statusMap.set(seg.seatId, 'BOOKED');
|
||||
} else {
|
||||
statusMap.set(seg.seatId, 'BOOKED');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const seg of bookedSegments) {
|
||||
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
|
||||
}
|
||||
}
|
||||
|
||||
return statusMap;
|
||||
@@ -154,6 +204,7 @@ export class SeatsService {
|
||||
if (reqFrom >= reqTo)
|
||||
throw new BadRequestException('Origin must come before destination');
|
||||
|
||||
// ── Check existing holds for overlap ────────────────────────────────────
|
||||
const activeHolds = await tx.seatHold.findMany({
|
||||
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
|
||||
select: { seatIds: true, createdBy: true },
|
||||
@@ -197,6 +248,29 @@ export class SeatsService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check confirmed JourneySegments for overlap ──────────────────────────
|
||||
const bookedSegments = await tx.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId: dto.scheduleId,
|
||||
seatId: { in: seatIds },
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
select: { seatId: true, departureStationId: true, arrivalStationId: true },
|
||||
});
|
||||
|
||||
for (const seg of bookedSegments) {
|
||||
if (!seg.seatId) continue;
|
||||
const segFrom = seqOf(seg.departureStationId);
|
||||
const segTo = seqOf(seg.arrivalStationId);
|
||||
if (segFrom !== undefined && segTo !== undefined) {
|
||||
if (segFrom < reqTo && reqFrom < segTo) {
|
||||
throw new ConflictException(
|
||||
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const holdMeta = {
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
@@ -347,16 +421,12 @@ export class SeatsService {
|
||||
return { released: true, holdId };
|
||||
}
|
||||
|
||||
async confirmSeats(seatIds: string[]) {
|
||||
// No-op
|
||||
}
|
||||
// Physical seat.status stays AVAILABLE — segment rows are the source of truth for occupancy.
|
||||
async confirmSeats(_seatIds: string[]) {}
|
||||
|
||||
async releaseSeats(seatIds: string[]) {
|
||||
if (seatIds.length > 0) {
|
||||
await this.prisma.journeySegment.deleteMany({
|
||||
where: { seatId: { in: seatIds } },
|
||||
});
|
||||
}
|
||||
// Delete the Journey (and its JourneySegments) scoped to this booking.
|
||||
async releaseSeats(bookingId: string) {
|
||||
await this.prisma.journey.deleteMany({ where: { bookingId } });
|
||||
}
|
||||
|
||||
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
|
||||
@@ -424,7 +494,7 @@ export class SeatsService {
|
||||
invalid++;
|
||||
continue;
|
||||
}
|
||||
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
|
||||
const [coachId, , row, col, seatNumber] = parts;
|
||||
if (!coachId || !row || !col || !seatNumber) {
|
||||
errors.push(`Line ${i + 2}: Missing required fields`);
|
||||
invalid++;
|
||||
@@ -448,7 +518,7 @@ export class SeatsService {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
try {
|
||||
const parts = lines[i].split(',');
|
||||
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
|
||||
const [coachId, , row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
|
||||
|
||||
await this.prisma.seat.upsert({
|
||||
where: { coachId_row_col: { coachId, row: parseInt(row), col } },
|
||||
@@ -481,18 +551,8 @@ export class SeatsService {
|
||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
|
||||
await this.prisma.seat.update({
|
||||
where: { id: seatId },
|
||||
data: { status: 'BLOCKED' },
|
||||
});
|
||||
|
||||
await this.prisma.seatBlock.create({
|
||||
data: {
|
||||
seatId,
|
||||
reason,
|
||||
blockedBy: 'system',
|
||||
},
|
||||
});
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } });
|
||||
await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } });
|
||||
|
||||
return { blocked: true, seatId, reason };
|
||||
}
|
||||
@@ -501,14 +561,8 @@ export class SeatsService {
|
||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
|
||||
await this.prisma.seat.update({
|
||||
where: { id: seatId },
|
||||
data: { status: 'AVAILABLE' },
|
||||
});
|
||||
|
||||
await this.prisma.seatBlock.deleteMany({
|
||||
where: { seatId },
|
||||
});
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } });
|
||||
await this.prisma.seatBlock.deleteMany({ where: { seatId } });
|
||||
|
||||
return { unblocked: true, seatId };
|
||||
}
|
||||
@@ -518,11 +572,9 @@ export class SeatsService {
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
if (!seat.seatNumber) throw new BadRequestException('Seat already removed');
|
||||
|
||||
// Mark removed seat with negative seatNumber (e.g., '1' → '-1') to show empty space
|
||||
const negatedNumber = `-${seat.seatNumber}`;
|
||||
await this.prisma.seat.update({
|
||||
where: { id: seatId },
|
||||
data: { seatNumber: negatedNumber },
|
||||
data: { seatNumber: `-${seat.seatNumber}` },
|
||||
});
|
||||
|
||||
return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
|
||||
@@ -535,26 +587,15 @@ export class SeatsService {
|
||||
throw new BadRequestException('Seat is not removed');
|
||||
}
|
||||
|
||||
// Restore original seatNumber by removing the negative sign
|
||||
const originalNumber = seat.seatNumber.slice(1);
|
||||
await this.prisma.seat.update({
|
||||
where: { id: seatId },
|
||||
data: { seatNumber: originalNumber },
|
||||
});
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: originalNumber } });
|
||||
|
||||
return { restored: true, seatId, seatNumber: originalNumber };
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async expireHolds() {
|
||||
const now = new Date();
|
||||
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } });
|
||||
if (expired.length === 0) return;
|
||||
|
||||
const expiredIds = expired.map(h => h.id);
|
||||
for (const hold of expired) {
|
||||
await this.releaseSeats(hold.seatIds);
|
||||
}
|
||||
await this.prisma.seatHold.deleteMany({ where: { id: { in: expiredIds } } });
|
||||
// Holds are temporary and don't create Journey rows — just delete expired ones.
|
||||
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ export class CreateStationDto {
|
||||
@ApiProperty({ example: 'Addis Ababa' }) @IsString() city: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() timezone?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() countryCode?: string;
|
||||
@ApiProperty({ example: 9.0054 }) @IsNumber() lat: number;
|
||||
@ApiProperty({ example: 38.7636 }) @IsNumber() lng: number;
|
||||
@ApiPropertyOptional({ example: 9.0054 }) @IsOptional() @IsNumber() lat?: number;
|
||||
@ApiPropertyOptional({ example: 38.7636 }) @IsOptional() @IsNumber() lng?: number;
|
||||
@ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() sequence?: number;
|
||||
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isOperational?: boolean;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,10 @@ export class StationsService {
|
||||
}
|
||||
|
||||
async create(dto: CreateStationDto) {
|
||||
const station = await this.prisma.station.create({ data: dto });
|
||||
const { lat, lng, ...rest } = dto;
|
||||
const station = await this.prisma.station.create({
|
||||
data: { ...rest, ...(lat !== undefined && { lat }), ...(lng !== undefined && { lng }) } as any,
|
||||
});
|
||||
|
||||
await this.auditService.log({
|
||||
userId: this.request?.user?.id,
|
||||
|
||||
@@ -44,6 +44,7 @@ export class TicketsService {
|
||||
booking: {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
passenger: { include: { user: true } },
|
||||
},
|
||||
@@ -72,6 +73,8 @@ export class TicketsService {
|
||||
displayTotalMinor: t.booking.displayTotalMinor,
|
||||
passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail },
|
||||
contactEmail: t.booking.contactEmail,
|
||||
contactPhone: t.booking.contactPhone,
|
||||
returnSchedule: (t.booking as any).returnSchedule ?? null,
|
||||
},
|
||||
schedule: t.booking.schedule,
|
||||
seat: t.booking.seats[0]?.seat,
|
||||
@@ -105,7 +108,7 @@ export class TicketsService {
|
||||
legs: legSummary,
|
||||
});
|
||||
const qrPayload = await QRCode.toDataURL(qrData);
|
||||
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
|
||||
const barcodePayload = `${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
|
||||
|
||||
const ticket = await this.prisma.ticket.upsert({
|
||||
where: { bookingId },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Filter, Download, Eye, XCircle, Trash2 } from 'lucide-react';
|
||||
import { Download, Eye, XCircle, Trash2 } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
@@ -13,13 +13,21 @@ import { bookingsApi, apiClient } from '@/lib/api';
|
||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||
import { BookingFilters } from '@/types';
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export default function BookingsPage() {
|
||||
const [filters, setFilters] = useState<BookingFilters>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
search: '',
|
||||
status: '',
|
||||
});
|
||||
const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' });
|
||||
const [selectedBooking, setSelectedBooking] = useState<any>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
|
||||
@@ -28,14 +36,8 @@ export default function BookingsPage() {
|
||||
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||
const [exportDateTo, setExportDateTo] = useState('');
|
||||
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
|
||||
bookingRef: true,
|
||||
passenger: true,
|
||||
status: true,
|
||||
bookingType: false,
|
||||
passengerCount: false,
|
||||
totalMinor: true,
|
||||
paymentStatus: true,
|
||||
createdAt: true,
|
||||
bookingRef: true, bookingType: false, passengerNames: true, contactPhone: true,
|
||||
contactEmail: true, passengerCount: false, paymentStatus: true, totalMinor: true, status: true, createdAt: true,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
@@ -45,10 +47,6 @@ export default function BookingsPage() {
|
||||
queryFn: () => bookingsApi.getAll(filters),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Bookings API Error:', error);
|
||||
}
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason),
|
||||
onSuccess: () => {
|
||||
@@ -56,9 +54,7 @@ export default function BookingsPage() {
|
||||
setSuccessMessage('Booking cancelled successfully');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(`Error: ${error.message || 'Failed to cancel booking'}`);
|
||||
},
|
||||
onError: (error: any) => alert(`Error: ${error.message || 'Failed to cancel booking'}`),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -77,26 +73,22 @@ export default function BookingsPage() {
|
||||
});
|
||||
|
||||
const handleCancel = async (booking: any) => {
|
||||
if (window.confirm(`Are you sure you want to cancel booking ${booking.bookingRef}? This will process a refund.`)) {
|
||||
if (window.confirm(`Cancel booking ${booking.bookingRef}? This will process a refund.`)) {
|
||||
await cancelMutation.mutateAsync({ id: booking.id, reason: 'Cancelled by admin' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (booking: any) => {
|
||||
setBookingToDelete(booking);
|
||||
setDeleteConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (bookingToDelete) {
|
||||
await deleteMutation.mutateAsync(bookingToDelete.id);
|
||||
}
|
||||
};
|
||||
const BOOKING_COLS = [
|
||||
{ key: 'bookingRef', label: 'Booking Reference' }, { key: 'journeyType', label: 'Journey Type' },
|
||||
{ key: 'passengerNames', label: 'Passenger Names' }, { key: 'contactPhone', label: 'Contact Phone' },
|
||||
{ key: 'contactEmail', label: 'Contact Email' }, { key: 'passengerCount', label: 'Passenger Count' },
|
||||
{ key: 'paymentStatus', label: 'Payment Status' }, { key: 'totalMinor', label: 'Amount' },
|
||||
{ key: 'status', label: 'Status' }, { key: 'createdAt', label: 'Created At' },
|
||||
];
|
||||
|
||||
const confirmExport = () => {
|
||||
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
|
||||
if (cols.length === 0) { alert('Please select at least one column'); return; }
|
||||
|
||||
if (!cols.length) { alert('Please select at least one column'); return; }
|
||||
const exportItems = (data?.items || []).filter((b: any) => {
|
||||
if (!exportDateFrom && !exportDateTo) return true;
|
||||
const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null;
|
||||
@@ -104,27 +96,27 @@ export default function BookingsPage() {
|
||||
if (exportDateTo && (!d || d > exportDateTo)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const csv = [
|
||||
cols.join(','),
|
||||
BOOKING_COLS.map(c => `"${c.label}"`).join(','),
|
||||
...exportItems.map((booking: any) => {
|
||||
const values = cols.map(col => {
|
||||
switch (col) {
|
||||
const values = BOOKING_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
|
||||
switch (key) {
|
||||
case 'bookingRef': return booking.bookingRef;
|
||||
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest';
|
||||
case 'status': return booking.status;
|
||||
case 'bookingType': return booking.bookingType || 'N/A';
|
||||
case 'passengerCount': return booking.adultCount + booking.childCount;
|
||||
case 'totalMinor': return booking.totalMinor;
|
||||
case 'journeyType': return booking.bookingType || 'N/A';
|
||||
case 'passengerNames': return booking.passengerNames?.join(', ') || 'N/A';
|
||||
case 'contactPhone': return booking.contactPhone || 'N/A';
|
||||
case 'contactEmail': return booking.contactEmail || 'N/A';
|
||||
case 'passengerCount': return (booking.adultCount ?? 0) + (booking.childCount ?? 0);
|
||||
case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING';
|
||||
case 'createdAt': return booking.createdAt;
|
||||
case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency);
|
||||
case 'status': return booking.status;
|
||||
case 'createdAt': return booking.createdAt ? formatDateTime(booking.createdAt) : '';
|
||||
default: return '';
|
||||
}
|
||||
});
|
||||
return values.map(v => `"${v}"`).join(',');
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -136,91 +128,61 @@ export default function BookingsPage() {
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'bookingRef',
|
||||
label: 'Reference',
|
||||
sortable: true,
|
||||
render: (booking: any) => (
|
||||
<span className="font-mono font-semibold">{booking.bookingRef}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'passenger',
|
||||
label: 'Passenger',
|
||||
key: 'bookingRef', label: 'Reference', sortable: true,
|
||||
render: (booking: any) => (
|
||||
<div>
|
||||
<div className="font-medium">{booking.passenger?.fullName || booking.contactEmail || 'Guest'}</div>
|
||||
<div className="text-sm text-muted-foreground">{booking.contactPhone || booking.passenger?.phone}</div>
|
||||
<div className="font-mono font-semibold">{booking.bookingRef}</div>
|
||||
<div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bookingType',
|
||||
label: 'Type',
|
||||
sortable: true,
|
||||
render: (booking: any) => booking.bookingType || 'ONE_WAY',
|
||||
},
|
||||
{
|
||||
key: 'passengerCount',
|
||||
label: 'Passengers',
|
||||
key: 'passengerNames', label: 'Names',
|
||||
render: (booking: any) => {
|
||||
const adults = booking.adultCount || 0;
|
||||
const children = booking.childCount || 0;
|
||||
if (adults === 0 && children === 0) return '—';
|
||||
const parts = [`Adult: ${adults}`];
|
||||
if (children > 0) parts.push(`Child: ${children}`);
|
||||
return parts.join(' / ');
|
||||
const names: string[] = booking.passengerNames || [];
|
||||
if (!names.length) return <span className="text-muted-foreground">—</span>;
|
||||
return <div className="flex flex-col gap-0.5">{names.map((n, i) => <span key={i} className="text-sm">{n}</span>)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
key: 'contact', label: 'Contact',
|
||||
render: (booking: any) => (
|
||||
<Badge variant="status" status={booking.status}>{booking.status}</Badge>
|
||||
<div>
|
||||
<div className="font-medium">{booking.contactPhone || booking.passenger?.phone}</div>
|
||||
<div className="text-sm text-muted-foreground">{booking.contactEmail || booking.passenger?.email}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'totalMinor',
|
||||
label: 'Amount',
|
||||
sortable: true,
|
||||
render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency),
|
||||
key: 'passengerCount', label: 'Passengers',
|
||||
render: (booking: any) => {
|
||||
const adults = booking.adultCount || 0, children = booking.childCount || 0;
|
||||
if (!adults && !children) return '—';
|
||||
return <><div>Adult: {adults}</div><div className="text-sm text-muted-foreground">Child: {children}</div></>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'paymentStatus',
|
||||
label: 'Payment',
|
||||
key: 'paymentStatus', label: 'Payment',
|
||||
render: (booking: any) => (
|
||||
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>
|
||||
{booking.paymentIntent?.status || 'PENDING'}
|
||||
</Badge>
|
||||
<div>
|
||||
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>{booking.paymentIntent?.status || 'PENDING'}</Badge>
|
||||
<div className="text-sm text-muted-foreground">{formatCurrency(booking.totalMinor, booking.currency)}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Created',
|
||||
sortable: true,
|
||||
render: (booking: any) => formatDateTime(booking.createdAt),
|
||||
key: 'status', label: 'Status',
|
||||
render: (booking: any) => <Badge variant="status" status={booking.status}>{booking.status}</Badge>,
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{ label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye },
|
||||
{
|
||||
label: 'View Details',
|
||||
onClick: (booking: any) => setSelectedBooking(booking),
|
||||
variant: 'secondary' as const,
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
label: 'Cancel Booking',
|
||||
onClick: handleCancel,
|
||||
variant: 'danger' as const,
|
||||
icon: XCircle,
|
||||
show: (booking: any) => booking.status !== 'CANCELLED' && booking.status !== 'COMPLETED',
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: handleDeleteClick,
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
label: 'Cancel Booking', onClick: handleCancel, variant: 'danger' as const, icon: XCircle,
|
||||
show: (b: any) => b.status !== 'CANCELLED' && b.status !== 'BOARDED',
|
||||
},
|
||||
{ label: 'Delete', onClick: (b: any) => { setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -235,9 +197,7 @@ export default function BookingsPage() {
|
||||
|
||||
<div className="card">
|
||||
{successMessage && (
|
||||
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
|
||||
✓ {successMessage}
|
||||
</div>
|
||||
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">✓ {successMessage}</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
|
||||
@@ -246,222 +206,195 @@ export default function BookingsPage() {
|
||||
)}
|
||||
<div className="mb-4 flex flex-wrap gap-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by reference, email, or phone..."
|
||||
className="input"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
|
||||
/>
|
||||
<input type="text" placeholder="Search by reference, email, or phone..." className="input"
|
||||
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
|
||||
</div>
|
||||
<select
|
||||
className="input w-48"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}
|
||||
>
|
||||
<select className="input w-48" value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}>
|
||||
<option value="">All Status</option>
|
||||
<option value="PENDING_PAYMENT">Pending Payment</option>
|
||||
<option value="CONFIRMED">Confirmed</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
<option value="BOARDED">Boarded</option>
|
||||
</select>
|
||||
<ActionButton variant="secondary" icon={Filter}>More Filters</ActionButton>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={data?.items || []}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No bookings found"
|
||||
/>
|
||||
|
||||
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No bookings found" />
|
||||
{data?.meta && (
|
||||
<Pagination
|
||||
currentPage={data.meta.page}
|
||||
totalPages={data.meta.totalPages}
|
||||
onPageChange={(page) => setFilters({ ...filters, page })}
|
||||
/>
|
||||
<Pagination currentPage={data.meta.page} totalPages={data.meta.totalPages}
|
||||
onPageChange={(page) => setFilters({ ...filters, page })} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Booking Details Modal */}
|
||||
<Modal isOpen={!!selectedBooking} onClose={() => setSelectedBooking(null)} title="Booking Details" size="xl">
|
||||
{selectedBooking && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Booking Reference</label>
|
||||
<p className="text-lg font-semibold font-mono">{selectedBooking.bookingRef}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Status</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="status" status={selectedBooking.status}>{selectedBooking.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Booking Type</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.bookingType || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Created</label>
|
||||
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{selectedBooking && (() => {
|
||||
const b = selectedBooking;
|
||||
const isRoundTrip = b.bookingType === 'ROUND_TRIP' || b.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Name</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.passenger?.fullName || selectedBooking.contactEmail || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Email</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.contactEmail || selectedBooking.passenger?.email || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Phone</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.contactPhone || selectedBooking.passenger?.phone || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Passenger ID</label>
|
||||
<p className="text-sm font-mono">{selectedBooking.passengerId || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Journey Details</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Adults</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.adultCount || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Children</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.childCount || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Schedule ID</label>
|
||||
<p className="text-sm font-mono">{selectedBooking.scheduleId || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Promo Code</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.promoCode || 'None'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Payment Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Amount</label>
|
||||
<p className="text-lg font-semibold">{formatCurrency(selectedBooking.totalMinor, selectedBooking.currency)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Payment Status</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="status" status={selectedBooking.paymentIntent?.status || 'PENDING'}>
|
||||
{selectedBooking.paymentIntent?.status || 'PENDING'}
|
||||
</Badge>
|
||||
{/* Gradient header */}
|
||||
<div className="-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r from-emerald-600 to-emerald-700 rounded-t-lg">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Booking Reference</p>
|
||||
<p className="text-white text-3xl font-mono font-bold tracking-wider">{b.bookingRef}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<Badge variant="status" status={b.status}>{b.status}</Badge>
|
||||
<p className="text-emerald-200 text-xs mt-2">{formatDateTime(b.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Paid At</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.paidAt ? formatDateTime(selectedBooking.paidAt) : 'Not paid'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Display Currency</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.displayCurrency || selectedBooking.currency}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{[
|
||||
(b.bookingType || 'ONE_WAY').replace(/_/g, ' '),
|
||||
`${b.adultCount ?? 0} Adult${(b.adultCount ?? 0) !== 1 ? 's' : ''}${(b.childCount ?? 0) > 0 ? ` · ${b.childCount} Child${b.childCount !== 1 ? 'ren' : ''}` : ''}`,
|
||||
b.displayCurrency || b.currency || 'ETB',
|
||||
].map((tag) => (
|
||||
<span key={tag} className="inline-flex items-center gap-1.5 bg-white/20 text-white text-xs font-medium px-3 py-1 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-200" />{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
<div className="space-y-6">
|
||||
{/* Passenger */}
|
||||
<section>
|
||||
<SectionHeader title="Passenger" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Full Name" value={b.passenger?.fullName || b.contactEmail} />
|
||||
<Field label="Email" value={b.contactEmail || b.passenger?.email} />
|
||||
<Field label="Phone" value={b.contactPhone || b.passenger?.phone} />
|
||||
<Field label="Passenger ID" value={b.passengerId} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Additional Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Source</label>
|
||||
<p className="text-lg font-semibold">{selectedBooking.source || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Last Updated</label>
|
||||
<p className="text-lg font-semibold">{formatDateTime(selectedBooking.updatedAt)}</p>
|
||||
</div>
|
||||
{/* Journey */}
|
||||
<section>
|
||||
<SectionHeader title="Journey" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Origin" value={b.schedule?.originStation?.name} />
|
||||
<Field label="Destination" value={b.schedule?.destinationStation?.name} />
|
||||
<Field label="Departure" value={b.schedule?.departureAt ? formatDateTime(b.schedule.departureAt) : ''} />
|
||||
<Field label="Arrival" value={b.schedule?.arrivalAt ? formatDateTime(b.schedule.arrivalAt) : ''} />
|
||||
<Field label="Adults" value={String(b.adultCount ?? 0)} />
|
||||
<Field label="Children" value={String(b.childCount ?? 0)} />
|
||||
<Field label="Promo Code" value={b.promoCode || 'None'} />
|
||||
<Field label="Schedule ID" value={b.scheduleId} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Return leg */}
|
||||
{isRoundTrip && (
|
||||
<section>
|
||||
<SectionHeader title="Return Leg" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Leg Status" value={(b.returnLegStatus || '—').replace(/_/g, ' ')} />
|
||||
<Field label="Outbound Boarded" value={b.outboundBoardedAt ? formatDateTime(b.outboundBoardedAt) : 'Not yet'} />
|
||||
<Field label="Return Boarded" value={b.returnBoardedAt ? formatDateTime(b.returnBoardedAt) : 'Not yet'} />
|
||||
<Field label="Return Schedule ID" value={b.returnScheduleId} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Payment */}
|
||||
<section>
|
||||
<SectionHeader title="Payment" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-100 dark:border-emerald-800 rounded-lg p-3 col-span-2">
|
||||
<p className="text-xs text-emerald-700 dark:text-emerald-400 mb-1">Total Amount</p>
|
||||
<p className="text-xl font-bold text-emerald-800 dark:text-emerald-300">{formatCurrency(b.totalMinor, b.currency || 'ETB')}</p>
|
||||
{b.displayCurrency && b.displayCurrency !== (b.currency || 'ETB') && (
|
||||
<p className="text-xs text-emerald-600 dark:text-emerald-500 mt-0.5">
|
||||
≈ {formatCurrency(b.displayTotalMinor ?? b.totalMinor, b.displayCurrency)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">Payment Status</p>
|
||||
<Badge variant="status" status={b.paymentIntent?.status || 'PENDING'}>{b.paymentIntent?.status || 'PENDING'}</Badge>
|
||||
</div>
|
||||
<Field label="Method" value={b.paymentIntent?.method || '—'} />
|
||||
<Field label="Paid At" value={b.paidAt ? formatDateTime(b.paidAt) : 'Not paid'} />
|
||||
<Field label="Display Currency" value={b.displayCurrency || b.currency || 'ETB'} />
|
||||
<Field label="Payment ID" value={b.paymentIntent?.id || '—'} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Seats */}
|
||||
{b.seats && b.seats.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader title={`Seats (${b.seats.length})`} />
|
||||
<div className="divide-y divide-muted rounded-lg border border-muted overflow-hidden">
|
||||
{b.seats.map((bs: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between px-4 py-3 bg-muted/20 hover:bg-muted/40 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 h-6 rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 text-xs font-bold flex items-center justify-center shrink-0">{i + 1}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{bs.passengerName || '—'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{bs.passengerCategory || '—'}{bs.leg ? ` · Leg ${bs.leg}` : ''}{bs.idDocumentType ? ` · ${bs.idDocumentType}` : ''}
|
||||
{bs.verifaydaVerified ? ' · ✓ Verified' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-mono font-semibold">{bs.seat?.seatNumber || bs.seatId || '—'}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatCurrency(bs.fareMinor ?? 0, b.currency || 'ETB')}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<section>
|
||||
<SectionHeader title="Timestamps & Meta" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Created" value={formatDateTime(b.createdAt)} />
|
||||
<Field label="Last Updated" value={formatDateTime(b.updatedAt)} />
|
||||
<Field label="Source / Device" value={b.source || b.userAgent || '—'} truncate />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirmOpen}
|
||||
onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); }}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onConfirm={async () => { if (bookingToDelete) await deleteMutation.mutateAsync(bookingToDelete.id); }}
|
||||
title="Delete Booking"
|
||||
message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`}
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
isLoading={deleteMutation.isPending}
|
||||
isDanger={true}
|
||||
message={`Permanently delete booking ${bookingToDelete?.bookingRef}? This cannot be undone and will release all associated seats.`}
|
||||
confirmText="Delete" cancelText="Cancel" isLoading={deleteMutation.isPending} isDanger
|
||||
/>
|
||||
|
||||
{/* Export Modal */}
|
||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Date From (Created)</label>
|
||||
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date To (Created)</label>
|
||||
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
|
||||
</div>
|
||||
<div><label className="label">Date From (Created)</label><input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} /></div>
|
||||
<div><label className="label">Date To (Created)</label><input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Select Columns</p>
|
||||
<div className="space-y-2 max-h-56 overflow-y-auto">
|
||||
{[
|
||||
{ key: 'bookingRef', label: 'Booking Reference' },
|
||||
{ key: 'passenger', label: 'Passenger' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'bookingType', label: 'Booking Type' },
|
||||
{ key: 'passengerCount', label: 'Passenger Count' },
|
||||
{ key: 'totalMinor', label: 'Amount' },
|
||||
{ key: 'paymentStatus', label: 'Payment Status' },
|
||||
{ key: 'createdAt', label: 'Created At' },
|
||||
].map((col) => (
|
||||
{BOOKING_COLS.map((col) => (
|
||||
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportColumns[col.key] || false}
|
||||
<input type="checkbox" checked={exportColumns[col.key] || false}
|
||||
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
className="w-4 h-4 rounded border-gray-300" />
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Download, Eye, Trash2 } from 'lucide-react';
|
||||
import { Download, Eye, Trash2, ShieldCheck, ShieldOff, Star, Wallet } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
@@ -13,13 +13,28 @@ import { passengersApi, apiClient } from '@/lib/api';
|
||||
import { formatDate, formatDateTime } from '@/lib/utils';
|
||||
import { PassengerFilters } from '@/types';
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||
</h3>
|
||||
);
|
||||
|
||||
const TIER_COLORS: Record<string, string> = {
|
||||
BRONZE: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 border-orange-200 dark:border-orange-800',
|
||||
SILVER: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600',
|
||||
GOLD: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
|
||||
PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800',
|
||||
};
|
||||
|
||||
export default function PassengersPage() {
|
||||
const [filters, setFilters] = useState<PassengerFilters>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
search: '',
|
||||
role: 'PASSENGER',
|
||||
});
|
||||
const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' });
|
||||
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
@@ -33,35 +48,23 @@ export default function PassengersPage() {
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['passengers'] });
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['passengers'] }),
|
||||
});
|
||||
|
||||
const handleDelete = (passenger: any) => {
|
||||
setDeleteConfirm({ isOpen: true, passenger });
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteConfirm.passenger) {
|
||||
await deleteMutation.mutateAsync(deleteConfirm.passenger.id);
|
||||
setDeleteConfirm({ isOpen: false, passenger: null });
|
||||
}
|
||||
};
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['passengers', filters],
|
||||
queryFn: () => passengersApi.getAll(filters),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Passengers API Error:', error);
|
||||
}
|
||||
const PASSENGER_COLS = [
|
||||
{ key: 'fullName', label: 'Full Name' }, { key: 'email', label: 'Email' }, { key: 'phone', label: 'Phone' },
|
||||
{ key: 'dateOfBirth', label: 'Date of Birth' }, { key: 'gender', label: 'Gender' },
|
||||
{ key: 'nationality', label: 'Nationality' }, { key: 'verified', label: 'Verified' },
|
||||
];
|
||||
|
||||
const confirmExportPassengers = () => {
|
||||
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
|
||||
if (cols.length === 0) { alert('Please select at least one column'); return; }
|
||||
|
||||
if (!cols.length) { alert('Please select at least one column'); return; }
|
||||
const exportItems = (data?.items || []).filter((p: any) => {
|
||||
if (!exportDateFrom && !exportDateTo) return true;
|
||||
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
|
||||
@@ -69,26 +72,24 @@ export default function PassengersPage() {
|
||||
if (exportDateTo && (!d || d > exportDateTo)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const csv = [
|
||||
cols.join(','),
|
||||
...exportItems.map((passenger: any) => {
|
||||
const values = cols.map(col => {
|
||||
switch (col) {
|
||||
case 'fullName': return passenger.fullName;
|
||||
case 'email': return passenger.email || '';
|
||||
case 'phone': return passenger.phone || '';
|
||||
case 'dateOfBirth': return passenger.dateOfBirth ? formatDate(passenger.dateOfBirth) : '';
|
||||
case 'gender': return passenger.gender || '';
|
||||
case 'nationality': return passenger.nationality || '';
|
||||
case 'verified': return passenger.nationalId ? 'Yes' : 'No';
|
||||
PASSENGER_COLS.map(c => `"${c.label}"`).join(','),
|
||||
...exportItems.map((p: any) => {
|
||||
const values = PASSENGER_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
|
||||
switch (key) {
|
||||
case 'fullName': return p.fullName;
|
||||
case 'email': return p.email || '';
|
||||
case 'phone': return p.phone || '';
|
||||
case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : '';
|
||||
case 'gender': return p.gender || '';
|
||||
case 'nationality': return p.nationality || '';
|
||||
case 'verified': return p.nationalId ? 'Yes' : 'No';
|
||||
default: return '';
|
||||
}
|
||||
});
|
||||
return values.map(v => `"${v}"`).join(',');
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -99,65 +100,32 @@ export default function PassengersPage() {
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'fullName',
|
||||
label: 'Name',
|
||||
sortable: true,
|
||||
render: (passenger: any) => (
|
||||
{
|
||||
key: 'fullName', label: 'Name', sortable: true,
|
||||
render: (p: any) => (
|
||||
<div>
|
||||
<div className="font-medium">{passenger.fullName}</div>
|
||||
<div className="text-sm text-muted-foreground">{passenger.email}</div>
|
||||
<div className="font-medium">{p.fullName}</div>
|
||||
<div className="text-sm text-muted-foreground">{p.email}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
label: 'Phone',
|
||||
sortable: true,
|
||||
render: (passenger: any) => passenger.phone,
|
||||
},
|
||||
{
|
||||
key: 'gender',
|
||||
label: 'Gender',
|
||||
sortable: true,
|
||||
render: (passenger: any) => passenger.gender || 'N/A',
|
||||
},
|
||||
{
|
||||
key: 'nationality',
|
||||
label: 'Nationality',
|
||||
sortable: true,
|
||||
render: (passenger: any) => passenger.nationality || 'N/A',
|
||||
},
|
||||
{
|
||||
key: 'dateOfBirth',
|
||||
label: 'Date of Birth',
|
||||
sortable: true,
|
||||
render: (passenger: any) => passenger.dateOfBirth ? formatDate(passenger.dateOfBirth) : 'N/A',
|
||||
},
|
||||
{
|
||||
key: 'verified',
|
||||
label: 'Status',
|
||||
render: (passenger: any) => (
|
||||
<Badge variant="status" status={passenger.nationalId ? 'CONFIRMED' : 'PENDING'}>
|
||||
{passenger.nationalId ? 'Verified' : 'Unverified'}
|
||||
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone },
|
||||
{ key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' },
|
||||
{ key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' },
|
||||
{ key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' },
|
||||
{
|
||||
key: 'verified', label: 'Status',
|
||||
render: (p: any) => (
|
||||
<Badge variant="status" status={p.nationalId ? 'CONFIRMED' : 'PENDING'}>
|
||||
{p.nationalId ? 'Verified' : 'Unverified'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'View Details',
|
||||
onClick: (passenger: any) => setSelectedPassenger(passenger),
|
||||
variant: 'secondary' as const,
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: handleDelete,
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
},
|
||||
{ label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye },
|
||||
{ label: 'Delete', onClick: (p: any) => setDeleteConfirm({ isOpen: true, passenger: p }), variant: 'danger' as const, icon: Trash2 },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -167,9 +135,7 @@ export default function PassengersPage() {
|
||||
<h1 className="text-2xl font-bold">Passengers</h1>
|
||||
<p className="text-muted-foreground">Manage passenger profiles and verification</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
@@ -180,254 +146,218 @@ export default function PassengersPage() {
|
||||
)}
|
||||
<div className="mb-4 flex flex-wrap gap-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name, email, or phone..."
|
||||
className="input"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
|
||||
/>
|
||||
<input type="text" placeholder="Search by name, email, or phone..." className="input"
|
||||
value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })} />
|
||||
</div>
|
||||
<select
|
||||
className="input w-48"
|
||||
value={filters.verified?.toString() || ''}
|
||||
onChange={(e) => setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}
|
||||
>
|
||||
<select className="input w-48" value={filters.verified?.toString() || ''}
|
||||
onChange={(e) => setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}>
|
||||
<option value="">All Passengers</option>
|
||||
<option value="true">Verified</option>
|
||||
<option value="false">Unverified</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={data?.items || []}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No passengers found"
|
||||
/>
|
||||
|
||||
<DataTable data={data?.items || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No passengers found" />
|
||||
{data?.meta && (
|
||||
<Pagination
|
||||
currentPage={data.meta.page}
|
||||
totalPages={data.meta.totalPages}
|
||||
onPageChange={(page) => setFilters({ ...filters, page })}
|
||||
/>
|
||||
<Pagination currentPage={data.meta.page} totalPages={data.meta.totalPages}
|
||||
onPageChange={(page) => setFilters({ ...filters, page })} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => setDeleteConfirm({ isOpen: false, passenger: null })}
|
||||
onConfirm={confirmDelete}
|
||||
onConfirm={async () => {
|
||||
if (deleteConfirm.passenger) {
|
||||
await deleteMutation.mutateAsync(deleteConfirm.passenger.id);
|
||||
setDeleteConfirm({ isOpen: false, passenger: null });
|
||||
}
|
||||
}}
|
||||
title="Delete Passenger"
|
||||
message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`}
|
||||
confirmText="Delete"
|
||||
isDanger={true}
|
||||
confirmText="Delete" isDanger
|
||||
warning="This passenger may have active bookings, loyalty points, and wallet balance. Deleting will impact these systems and records."
|
||||
/>
|
||||
|
||||
{/* Passenger Details Modal */}
|
||||
<Modal
|
||||
isOpen={!!selectedPassenger}
|
||||
onClose={() => setSelectedPassenger(null)}
|
||||
title="Passenger Details"
|
||||
size="xl"
|
||||
>
|
||||
{selectedPassenger && (
|
||||
<div className="space-y-6">
|
||||
{/* Personal Information */}
|
||||
<Modal isOpen={!!selectedPassenger} onClose={() => setSelectedPassenger(null)} title="Passenger Details" size="xl">
|
||||
{selectedPassenger && (() => {
|
||||
const p = selectedPassenger;
|
||||
const isVerified = !!p.faydaVerified || !!p.nationalId;
|
||||
const tier = p.passenger?.loyalty?.tier || p.loyalty?.tier;
|
||||
const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Personal Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Full Name</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.fullName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Date of Birth</label>
|
||||
<p className="text-lg font-semibold">
|
||||
{selectedPassenger.dateOfBirth ? formatDate(selectedPassenger.dateOfBirth) : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Gender</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.gender || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Nationality</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.nationality || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Contact Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Contact Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Email</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.email || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Phone</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.phone || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Identification */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Identification</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Passport Number</label>
|
||||
<p className="text-lg font-mono font-semibold">{selectedPassenger.passportNumber || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Passport Country</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.passportCountry || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Verification Status</label>
|
||||
<div className="mt-1">
|
||||
<Badge
|
||||
variant="status"
|
||||
status={selectedPassenger.nationalId ? 'CONFIRMED' : 'PENDING'}
|
||||
>
|
||||
{selectedPassenger.nationalId ? 'Verified' : 'Unverified'}
|
||||
</Badge>
|
||||
{/* Gradient header with avatar */}
|
||||
<div className="-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r from-emerald-600 to-emerald-700 rounded-t-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-white text-2xl font-bold">{(p.fullName || p.email || '?')[0].toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-xl font-bold truncate">{p.fullName}</p>
|
||||
<p className="text-emerald-200 text-sm truncate">{p.email}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0 space-y-1">
|
||||
<div>
|
||||
<Badge variant="status" status={isVerified ? 'CONFIRMED' : 'PENDING'}>
|
||||
{isVerified ? '✓ Verified' : 'Unverified'}
|
||||
</Badge>
|
||||
</div>
|
||||
{tier && (
|
||||
<span className={`inline-flex items-center gap-1 text-xs font-bold px-2.5 py-0.5 rounded-full border ${tierColor}`}>
|
||||
<Star className="w-3 h-3" />{tier}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Account Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Account Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Passenger ID</label>
|
||||
<p className="text-sm font-mono">{selectedPassenger.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">User ID</label>
|
||||
<p className="text-sm font-mono">{selectedPassenger.userId || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loyalty & Wallet (if available) */}
|
||||
{(selectedPassenger.loyalty || selectedPassenger.wallet) && (
|
||||
<>
|
||||
<hr className="border-muted" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{selectedPassenger.loyalty && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Loyalty Account</h3>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Tier</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.loyalty.tier || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Points Balance</label>
|
||||
<p className="text-lg font-semibold">{selectedPassenger.loyalty.pointsBalance || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Quick stats */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Loyalty Points', value: (p.passenger?.loyalty?.pointsBalance ?? p.loyalty?.pointsBalance ?? 0).toLocaleString() },
|
||||
{ label: 'Wallet Balance', value: p.passenger?.wallet || p.wallet ? `ETB ${((p.passenger?.wallet?.balanceMinor ?? p.wallet?.balanceMinor ?? 0) / 100).toFixed(2)}` : '—' },
|
||||
{ label: 'Nationality', value: p.nationality || '—' },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
||||
<p className="text-emerald-200 text-xs">{label}</p>
|
||||
<p className="text-white text-sm font-bold truncate">{value}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedPassenger.wallet && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Wallet</h3>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Balance</label>
|
||||
<p className="text-lg font-semibold">
|
||||
{(selectedPassenger.wallet.balanceMinor / 100).toFixed(2)} {selectedPassenger.wallet.currency}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Timestamps */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Timestamps</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Created</label>
|
||||
<p className="text-sm">{selectedPassenger.createdAt ? formatDateTime(selectedPassenger.createdAt) : 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Last Updated</label>
|
||||
<p className="text-sm">{selectedPassenger.updatedAt ? formatDateTime(selectedPassenger.updatedAt) : 'N/A'}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setSelectedPassenger(null)}
|
||||
>
|
||||
Close
|
||||
</ActionButton>
|
||||
<div className="space-y-6">
|
||||
{/* Personal */}
|
||||
<section>
|
||||
<SectionHeader title="Personal Information" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Full Name" value={p.fullName} />
|
||||
<Field label="Date of Birth" value={p.dateOfBirth ? formatDate(p.dateOfBirth) : ''} />
|
||||
<Field label="Gender" value={p.gender} />
|
||||
<Field label="Nationality" value={p.nationality} />
|
||||
<Field label="Nationality Code" value={p.nationalityCode} />
|
||||
<Field label="Preferred Language" value={p.passenger?.preferredLanguage || p.preferredLanguage} />
|
||||
<Field label="Last Login" value={p.lastLoginAt ? formatDateTime(p.lastLoginAt) : 'Never'} />
|
||||
<Field label="Role" value={p.role} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact */}
|
||||
<section>
|
||||
<SectionHeader title="Contact Information" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Email" value={p.email} />
|
||||
<Field label="Phone" value={p.phone} />
|
||||
<Field label="Address" value={p.address} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Identification */}
|
||||
<section>
|
||||
<SectionHeader title="Identification & Verification" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-muted/40 rounded-lg p-3 col-span-2 md:col-span-1">
|
||||
<p className="text-xs text-muted-foreground mb-2">Fayda (National ID)</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{isVerified
|
||||
? <ShieldCheck className="w-4 h-4 text-emerald-600 dark:text-emerald-400 shrink-0" />
|
||||
: <ShieldOff className="w-4 h-4 text-muted-foreground shrink-0" />}
|
||||
<span className={`text-sm font-semibold ${isVerified ? 'text-emerald-700 dark:text-emerald-400' : 'text-muted-foreground'}`}>
|
||||
{isVerified ? 'Verified' : 'Not verified'}
|
||||
</span>
|
||||
</div>
|
||||
{p.faydaVerifiedAt && <p className="text-xs text-muted-foreground mt-1">{formatDateTime(p.faydaVerifiedAt)}</p>}
|
||||
</div>
|
||||
<Field label="Passport Number" value={p.passportNumber} mono />
|
||||
<Field label="Passport Country" value={p.passportCountry} />
|
||||
<Field label="Passport Expiry" value={p.passportExpiryDate ? formatDate(p.passportExpiryDate) : ''} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Loyalty & Wallet */}
|
||||
{(p.passenger?.loyalty || p.loyalty || p.passenger?.wallet || p.wallet) && (
|
||||
<section>
|
||||
<SectionHeader title="Loyalty & Wallet" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{(p.passenger?.loyalty || p.loyalty) && (() => {
|
||||
const loyalty = p.passenger?.loyalty || p.loyalty;
|
||||
return (
|
||||
<>
|
||||
<div className={`rounded-lg p-3 border ${tierColor}`}>
|
||||
<p className="text-xs font-medium mb-1 opacity-70">Tier</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Star className="w-4 h-4" />
|
||||
<span className="text-sm font-bold">{loyalty.tier}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Points Balance" value={(loyalty.pointsBalance ?? 0).toLocaleString()} />
|
||||
<Field label="Lifetime Points" value={(loyalty.lifetimePoints ?? 0).toLocaleString()} />
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{(p.passenger?.wallet || p.wallet) && (() => {
|
||||
const wallet = p.passenger?.wallet || p.wallet;
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-3">
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 mb-1 flex items-center gap-1"><Wallet className="w-3 h-3" />Wallet Balance</p>
|
||||
<p className="text-base font-bold text-blue-800 dark:text-blue-300">
|
||||
ETB {((wallet.balanceMinor ?? 0) / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Account */}
|
||||
<section>
|
||||
<SectionHeader title="Account IDs" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Field label="User ID" value={p.id || p.userId} mono truncate />
|
||||
<Field label="Passenger ID" value={p.passenger?.id || p.passengerId} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Timestamps */}
|
||||
<section>
|
||||
<SectionHeader title="Timestamps" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Registered" value={p.createdAt ? formatDateTime(p.createdAt) : ''} />
|
||||
<Field label="Last Updated" value={p.updatedAt ? formatDateTime(p.updatedAt) : ''} />
|
||||
<Field label="Fayda Verified At" value={p.faydaVerifiedAt ? formatDateTime(p.faydaVerifiedAt) : 'N/A'} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={() => setSelectedPassenger(null)}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
{/* Export Modal */}
|
||||
|
||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Passengers" size="md">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Date From (Registered)</label>
|
||||
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date To (Registered)</label>
|
||||
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
|
||||
</div>
|
||||
<div><label className="label">Date From (Registered)</label><input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} /></div>
|
||||
<div><label className="label">Date To (Registered)</label><input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Select Columns</p>
|
||||
<div className="space-y-2 max-h-56 overflow-y-auto">
|
||||
{[
|
||||
{ key: 'fullName', label: 'Full Name' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'phone', label: 'Phone' },
|
||||
{ key: 'dateOfBirth', label: 'Date of Birth' },
|
||||
{ key: 'gender', label: 'Gender' },
|
||||
{ key: 'nationality', label: 'Nationality' },
|
||||
{ key: 'verified', label: 'Verified' },
|
||||
].map((col) => (
|
||||
{PASSENGER_COLS.map((col) => (
|
||||
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportColumns[col.key] || false}
|
||||
<input type="checkbox" checked={exportColumns[col.key] || false}
|
||||
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
className="w-4 h-4 rounded border-gray-300" />
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||
<ActionButton onClick={confirmExportPassengers}>Export CSV</ActionButton>
|
||||
|
||||
@@ -28,6 +28,15 @@ export default function PaymentsPage() {
|
||||
}),
|
||||
});
|
||||
|
||||
const PAYMENT_COLS = [
|
||||
{ key: 'reference', label: 'Reference' },
|
||||
{ key: 'booking', label: 'Booking Reference' },
|
||||
{ key: 'amount', label: 'Amount' },
|
||||
{ key: 'method', label: 'Payment Method' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'createdAt', label: 'Created At' },
|
||||
];
|
||||
|
||||
const confirmExport = () => {
|
||||
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
|
||||
if (cols.length === 0) { alert('Please select at least one column'); return; }
|
||||
@@ -42,16 +51,16 @@ export default function PaymentsPage() {
|
||||
});
|
||||
|
||||
const csv = [
|
||||
cols.join(','),
|
||||
PAYMENT_COLS.map(c => `"${c.label}"`).join(','),
|
||||
...exportItems.map((payment: any) => {
|
||||
const values = cols.map(col => {
|
||||
switch (col) {
|
||||
const values = PAYMENT_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
|
||||
switch (key) {
|
||||
case 'reference': return payment.reference || payment.id?.substring(0, 8) || '';
|
||||
case 'booking': return payment.booking?.bookingRef || 'N/A';
|
||||
case 'amount': return formatCurrency(payment.amountMinor, payment.currency);
|
||||
case 'method': return payment.method || '';
|
||||
case 'status': return payment.status || '';
|
||||
case 'createdAt': return payment.createdAt || '';
|
||||
case 'booking': return payment.booking?.bookingRef || 'N/A';
|
||||
case 'amount': return formatCurrency(payment.amountMinor, payment.currency);
|
||||
case 'method': return payment.method || '';
|
||||
case 'status': return payment.status || '';
|
||||
case 'createdAt': return payment.createdAt ? new Date(payment.createdAt).toLocaleString() : '';
|
||||
default: return '';
|
||||
}
|
||||
});
|
||||
@@ -84,7 +93,7 @@ export default function PaymentsPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Payments</h1>
|
||||
<p className="text-muted-foreground">Manage payment transactions and refunds</p>
|
||||
</div>
|
||||
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
|
||||
@@ -239,9 +239,9 @@ export default function ReportsPage() {
|
||||
<Pie
|
||||
data={[
|
||||
{ name: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length },
|
||||
{ name: 'Completed', value: bookings.filter((b: any) => b.status === 'COMPLETED').length },
|
||||
{ name: 'Completed', value: bookings.filter((b: any) => b.status === 'BOARDED').length },
|
||||
{ name: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length },
|
||||
{ name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'COMPLETED', 'CANCELLED'].includes(b.status)).length },
|
||||
{ name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'BOARDED', 'CANCELLED'].includes(b.status)).length },
|
||||
].filter(d => d.value > 0)}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
@@ -306,7 +306,7 @@ export default function ReportsPage() {
|
||||
</div>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Completed Bookings</p>
|
||||
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'COMPLETED').length}</p>
|
||||
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'BOARDED').length}</p>
|
||||
</div>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Cancelled Bookings</p>
|
||||
|
||||
@@ -563,7 +563,7 @@ export default function SchedulesPage() {
|
||||
<option value="">Select Train</option>
|
||||
{trains.map((train: Train) => (
|
||||
<option key={train.id} value={train.id}>
|
||||
{train.name} ({train.number})
|
||||
{train.number} ({train.name})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -580,7 +580,7 @@ export default function SchedulesPage() {
|
||||
<option value="">Select Route</option>
|
||||
{routes.map((route: Route) => (
|
||||
<option key={route.id} value={route.id}>
|
||||
{route.name} ({route.code})
|
||||
{route.code} ({route.name})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -443,13 +443,12 @@ export default function SeatsPage() {
|
||||
className="input"
|
||||
>
|
||||
<option value="">Select a schedule...</option>
|
||||
{schedules.map((schedule: any) => {
|
||||
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
||||
{schedules.map((schedule: any) => {
|
||||
const routeName = schedule.route?.name || 'N/A';
|
||||
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||
return (
|
||||
<option key={schedule.id} value={schedule.id}>
|
||||
{trainNumber} - {routeName} - {date}
|
||||
{date} - {routeName}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -72,8 +72,8 @@ export default function StationsPage() {
|
||||
name: formData.get('name') as string,
|
||||
city: formData.get('city') as string,
|
||||
countryCode: formData.get('countryCode') as string,
|
||||
lat: parseFloat(formData.get('lat') as string) || null,
|
||||
lng: parseFloat(formData.get('lng') as string) || null,
|
||||
lat: parseFloat(formData.get('lat') as string) || undefined,
|
||||
lng: parseFloat(formData.get('lng') as string) || undefined,
|
||||
timezone: formData.get('timezone') as string,
|
||||
sequence,
|
||||
isOperational: formData.get('isOperational') === 'true',
|
||||
@@ -304,28 +304,6 @@ export default function StationsPage() {
|
||||
<option value="DJ">Djibouti (DJ)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Latitude</label>
|
||||
<input
|
||||
type="number"
|
||||
name="lat"
|
||||
className="input"
|
||||
defaultValue={editingStation?.lat}
|
||||
step="0.0001"
|
||||
placeholder="e.g., 9.0320"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Longitude</label>
|
||||
<input
|
||||
type="number"
|
||||
name="lng"
|
||||
className="input"
|
||||
defaultValue={editingStation?.lng}
|
||||
step="0.0001"
|
||||
placeholder="e.g., 38.7469"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Timezone *</label>
|
||||
<select
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { LogIn, Trash2 } from 'lucide-react';
|
||||
import { LogIn, ListCollapse, Trash2, Printer } from 'lucide-react';
|
||||
import { Download } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
@@ -10,7 +10,7 @@ import ActionButton from '@/components/ui/ActionButton';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { ticketsApi, apiClient, stationsApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
|
||||
@@ -18,6 +18,8 @@ export default function TicketsPage() {
|
||||
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
|
||||
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
|
||||
const [ticketToBoard, setTicketToBoard] = useState<any>(null);
|
||||
const [printBpModalOpen, setPrintBpModalOpen] = useState(false);
|
||||
const [ticketToPrint, setTicketToPrint] = useState<any>(null);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<any>(null);
|
||||
@@ -88,9 +90,75 @@ export default function TicketsPage() {
|
||||
};
|
||||
|
||||
const handleConfirmBoard = async () => {
|
||||
if (ticketToBoard) {
|
||||
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id });
|
||||
}
|
||||
if (!ticketToBoard) return;
|
||||
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id });
|
||||
printBoardingPass(ticketToBoard, 'outbound');
|
||||
};
|
||||
|
||||
const printBoardingPass = (ticket: any, leg: 'outbound' | 'inbound' = 'outbound') => {
|
||||
const w = window.open('', '_blank', 'width=520,height=460');
|
||||
if (!w) return;
|
||||
const isInbound = leg === 'inbound';
|
||||
const origin = isInbound
|
||||
? (ticket.booking?.returnOriginStation?.name || ticket.schedule?.destinationStation?.name || 'N/A')
|
||||
: (ticket.schedule?.originStation?.name || 'N/A');
|
||||
const dest = isInbound
|
||||
? (ticket.booking?.returnDestinationStation?.name || ticket.schedule?.originStation?.name || 'N/A')
|
||||
: (ticket.schedule?.destinationStation?.name || 'N/A');
|
||||
const date = isInbound
|
||||
? (ticket.booking?.returnBoardedAt ? new Date(ticket.booking.returnBoardedAt).toLocaleString() : 'N/A')
|
||||
: (ticket.schedule?.departureAt ? new Date(ticket.schedule.departureAt).toLocaleString() : 'N/A');
|
||||
const seat = ticket.seat?.seatNumber || 'N/A';
|
||||
const coach = ticket.seat?.coach?.number || 'N/A';
|
||||
const bookingRef = ticket.booking?.bookingRef || 'N/A';
|
||||
const ticketNum = ticket.ticketNumber || 'N/A';
|
||||
const passenger = ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'Guest';
|
||||
w.document.write(
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"/><title>Boarding Pass</title><style>' +
|
||||
'*{box-sizing:border-box;margin:0;padding:0}' +
|
||||
'body{font-family:"Segoe UI",sans-serif;background:#f0fdf4;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}' +
|
||||
'.pass{background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,.12);width:460px}' +
|
||||
'.header{background:linear-gradient(135deg,#10b981,#059669);color:#fff;padding:24px 28px 20px}' +
|
||||
'.header-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}' +
|
||||
'.airline{font-size:12px;letter-spacing:2px;text-transform:uppercase;opacity:.85}' +
|
||||
'.badge{background:rgba(255,255,255,.2);border-radius:20px;padding:3px 12px;font-size:11px;letter-spacing:1px}' +
|
||||
'.route{display:flex;align-items:center;gap:8px;margin-top:14px}' +
|
||||
'.city{font-size:24px;font-weight:700}' +
|
||||
'.arrow{font-size:20px;opacity:.7;flex:1;text-align:center}' +
|
||||
'.body{padding:24px 28px}' +
|
||||
'.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}' +
|
||||
'.field label{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#6b7280;font-weight:600}' +
|
||||
'.field p{font-size:14px;font-weight:600;color:#111827;margin-top:3px}' +
|
||||
'.divider{border:none;border-top:2px dashed #d1fae5;margin:20px 0}' +
|
||||
'.footer{display:flex;justify-content:space-between;align-items:center}' +
|
||||
'.seat-box{background:#f0fdf4;border:2px solid #10b981;border-radius:10px;padding:8px 20px;text-align:center}' +
|
||||
'.seat-box label{font-size:10px;letter-spacing:1px;text-transform:uppercase;color:#059669;font-weight:700}' +
|
||||
'.seat-box p{font-size:28px;font-weight:800;color:#065f46}' +
|
||||
'@media print{body{background:#fff}.pass{box-shadow:none}}' +
|
||||
'</style></head><body>' +
|
||||
'<div class="pass">' +
|
||||
'<div class="header">' +
|
||||
'<div class="header-top"><span class="airline">EDR — Ethio-Djibouti Railway</span><span class="badge">BOARDING PASS</span></div>' +
|
||||
'<div class="route"><span class="city">' + origin + '</span><span class="arrow">🡢</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 & Time</label><p>' + date + '</p></div>' +
|
||||
'<div class="field"><label>Coach</label><p>' + coach + '</p></div>' +
|
||||
'</div>' +
|
||||
'<hr class="divider"/>' +
|
||||
'<div class="footer">' +
|
||||
'<div class="field"><label>Passenger</label><p>' + passenger + '</p></div>' +
|
||||
'<div class="seat-box"><label>Seat</label><p>' + seat + '</p></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<script>window.onload=function(){window.print();window.onafterprint=function(){window.close()};}<\/script>' +
|
||||
'</body></html>'
|
||||
);
|
||||
w.document.close();
|
||||
};
|
||||
|
||||
const handleDeleteClick = (ticket: any) => {
|
||||
@@ -104,6 +172,19 @@ export default function TicketsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const TICKET_COLS = [
|
||||
{ key: 'ticketNumber', label: 'Ticket Number' },
|
||||
{ key: 'booking', label: 'Booking Reference' },
|
||||
{ key: 'passenger', label: 'Passenger Name' },
|
||||
{ key: 'trip', label: 'Trip (Origin - Destination)' },
|
||||
{ key: 'coach', label: 'Coach Number' },
|
||||
{ key: 'seat', label: 'Seat Number' },
|
||||
{ key: 'seatClass', label: 'Seat Class' },
|
||||
{ key: 'amount', label: 'Amount' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'boarded', label: 'Boarded' },
|
||||
];
|
||||
|
||||
const confirmExport = () => {
|
||||
const cols = Object.entries(selectedColumns)
|
||||
.filter(([, selected]) => selected)
|
||||
@@ -125,19 +206,20 @@ export default function TicketsPage() {
|
||||
});
|
||||
|
||||
const csv = [
|
||||
cols.join(','),
|
||||
TICKET_COLS.map(c => `"${c.label}"`).join(','),
|
||||
...exportItems.map((ticket: any) => {
|
||||
const values = cols.map(col => {
|
||||
switch (col) {
|
||||
case 'ticketNumber': return ticket.ticketNumber || '';
|
||||
case 'booking': return ticket.booking?.bookingRef || '';
|
||||
case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`;
|
||||
case 'coach': return ticket.seat?.coach?.number || '';
|
||||
case 'seat': return ticket.seat?.seatNumber || '';
|
||||
case 'seatClass': return ticket.seat?.coach?.coachType?.name || '';
|
||||
case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB');
|
||||
case 'status': return ticket.status || '';
|
||||
case 'boarded': return ticket.boardedAt ? 'Yes' : 'No';
|
||||
const values = TICKET_COLS.filter(c => cols.includes(c.key)).map(({ key }) => {
|
||||
switch (key) {
|
||||
case 'ticketNumber': return ticket.ticketNumber || 'N/A';
|
||||
case 'booking': return ticket.booking?.bookingRef || 'N/A';
|
||||
case 'passenger': return ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A';
|
||||
case 'trip': return `${ticket.schedule?.originStation?.name || 'N/A'} - ${ticket.schedule?.destinationStation?.name || 'N/A'}`;
|
||||
case 'coach': return ticket.seat?.coach?.number || 'N/A';
|
||||
case 'seat': return ticket.seat?.seatNumber || 'N/A';
|
||||
case 'seatClass': return ticket.seat?.coach?.coachType?.type || 'N/A';
|
||||
case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB');
|
||||
case 'status': return ticket.status || 'N/A';
|
||||
case 'boarded': return ticket.boardedAt ? 'Yes' : 'No';
|
||||
default: return '';
|
||||
}
|
||||
});
|
||||
@@ -160,17 +242,22 @@ export default function TicketsPage() {
|
||||
label: 'Ticket Number',
|
||||
sortable: true,
|
||||
render: (ticket: any) => (
|
||||
<span className="font-mono font-semibold">{ticket.ticketNumber || 'N/A'}</span>
|
||||
<div>
|
||||
<div className="font-mono font-semibold">{ticket.ticketNumber || 'N/A'}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{ticket.booking?.passenger?.fullName || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'booking',
|
||||
label: 'Booking',
|
||||
key: 'contact',
|
||||
label: 'Contact',
|
||||
render: (ticket: any) => (
|
||||
<div>
|
||||
<div className="font-medium">{ticket.booking?.bookingRef || 'N/A'}</div>
|
||||
<div className="font-medium">{ticket.booking?.contactPhone || ticket.booking?.passenger?.phone || 'N/A'}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A'}
|
||||
<div className="text-xs text-muted-foreground">{ticket.booking?.contactEmail || ticket.booking?.passenger?.email || 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -178,16 +265,23 @@ export default function TicketsPage() {
|
||||
{
|
||||
key: 'trip',
|
||||
label: 'Trip',
|
||||
render: (ticket: any) => (
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{ticket.schedule?.originStation?.name || 'N/A'} → {ticket.schedule?.destinationStation?.name || 'N/A'}
|
||||
render: (ticket: any) => {
|
||||
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
const returnArrivalAt = ticket.booking?.returnSchedule?.arrivalAt;
|
||||
return (
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{ticket.schedule?.originStation?.name || 'N/A'} → {ticket.schedule?.destinationStation?.name || 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'}
|
||||
{isRoundTrip && (
|
||||
<span> → {returnArrivalAt ? formatDateTimeShort(returnArrivalAt) : 'N/A'}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{ticket.schedule?.departureAt ? formatDateTime(ticket.schedule.departureAt) : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'seat',
|
||||
@@ -195,54 +289,29 @@ export default function TicketsPage() {
|
||||
sortable: true,
|
||||
render: (ticket: any) => (
|
||||
<div>
|
||||
<div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'} - {ticket.seat?.seatNumber || 'N/A'}</div>
|
||||
<div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'}: {ticket.seat?.seatNumber || 'N/A'}</div>
|
||||
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.type || 'N/A'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: 'Amount',
|
||||
sortable: true,
|
||||
render: (ticket: any) => formatCurrency(ticket.booking?.totalMinor || 0, ticket.booking?.currency || 'ETB'),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
sortable: true,
|
||||
render: (ticket: any) => (
|
||||
<Badge variant="status" status={ticket.status || 'ACTIVE'}>
|
||||
{ticket.status || 'ACTIVE'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'boarded',
|
||||
label: 'Boarded',
|
||||
render: (ticket: any) => (
|
||||
ticket.validatedAt ? (
|
||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<span className="text-sm">{formatDateTime(ticket.validatedAt)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Not boarded</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'returnLegStatus',
|
||||
label: 'Return Leg',
|
||||
key: 'boardingTimes',
|
||||
label: 'Boarding Times',
|
||||
render: (ticket: any) => {
|
||||
const status = ticket.booking?.returnLegStatus;
|
||||
if (!status || status === 'NOT_APPLICABLE') return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const map: Record<string, { label: string; cls: string }> = {
|
||||
NEITHER_USED: { label: 'Neither Used', cls: 'edr-badge-warning' },
|
||||
OUTBOUND_ONLY: { label: 'Outbound Only', cls: 'edr-badge-info' },
|
||||
INBOUND_ONLY: { label: 'Inbound Only', cls: 'edr-badge-danger' },
|
||||
BOTH_USED: { label: 'Both Used', cls: 'edr-badge-success' },
|
||||
};
|
||||
const entry = map[status] ?? { label: status, cls: 'edr-badge-info' };
|
||||
return <span className={`edr-badge ${entry.cls}`}>{entry.label}</span>;
|
||||
const outbound = ticket.booking?.outboundBoardedAt;
|
||||
const inbound = ticket.booking?.returnBoardedAt;
|
||||
const hasAny = outbound || inbound;
|
||||
if (!hasAny) return <span className="text-sm text-muted-foreground">Not boarded</span>;
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 text-sm">
|
||||
<span className={outbound ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}>
|
||||
➡ {outbound ? formatDateTimeShort(outbound) : 'Not boarded'}
|
||||
</span>
|
||||
<span className={inbound ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}>
|
||||
⬅ {inbound ? formatDateTimeShort(inbound) : 'Not boarded'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -253,7 +322,25 @@ export default function TicketsPage() {
|
||||
onClick: handleBoard,
|
||||
variant: 'primary' as const,
|
||||
icon: LogIn,
|
||||
show: (ticket: any) => ticket.status !== 'USED' && !ticket.boardedAt,
|
||||
show: (ticket: any) => {
|
||||
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
if (isRoundTrip) {
|
||||
const inboundBoarded = !!ticket.booking?.returnBoardedAt;
|
||||
const returnLegStatus = ticket.booking?.returnLegStatus;
|
||||
return !ticket.validatedAt || (!inboundBoarded && returnLegStatus !== 'BOTH_USED');
|
||||
}
|
||||
return !ticket.validatedAt;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Print BP',
|
||||
onClick: (ticket: any) => {
|
||||
setTicketToPrint(ticket);
|
||||
setPrintBpModalOpen(true);
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
icon: Printer,
|
||||
show: (ticket: any) => !!ticket.validatedAt || !!ticket.booking?.outboundBoardedAt || !!ticket.booking?.returnBoardedAt,
|
||||
},
|
||||
{
|
||||
label: 'Details',
|
||||
@@ -262,6 +349,7 @@ export default function TicketsPage() {
|
||||
setDetailsModalOpen(true);
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
icon: ListCollapse,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
@@ -280,7 +368,7 @@ export default function TicketsPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Tickets</h1>
|
||||
<p className="text-muted-foreground">Manage tickets and validations</p>
|
||||
</div>
|
||||
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -366,17 +454,67 @@ export default function TicketsPage() {
|
||||
emptyMessage="No tickets found"
|
||||
/>
|
||||
|
||||
{/* Board Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
{/* Board Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={boardConfirmOpen}
|
||||
onClose={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}
|
||||
onConfirm={handleConfirmBoard}
|
||||
title="Board Ticket"
|
||||
message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`}
|
||||
confirmText="Board"
|
||||
cancelText="Cancel"
|
||||
isLoading={boardMutation.isPending}
|
||||
/>
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Are you sure you want to board ticket {ticketToBoard?.ticketNumber}?</p>
|
||||
<p className="text-sm text-muted-foreground">This will mark the ticket as USED.</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<ActionButton variant="secondary" onClick={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}>Cancel</ActionButton>
|
||||
<ActionButton icon={LogIn} loading={boardMutation.isPending} onClick={handleConfirmBoard}>Board and Print</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Print BP Modal */}
|
||||
<Modal
|
||||
isOpen={printBpModalOpen}
|
||||
onClose={() => { setPrintBpModalOpen(false); setTicketToPrint(null); }}
|
||||
title="Print Boarding Pass"
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const isRoundTrip = ticketToPrint?.booking?.bookingType === 'ROUND_TRIP' || ticketToPrint?.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
const outboundBoarded = !!ticketToPrint?.booking?.outboundBoardedAt || !!ticketToPrint?.validatedAt;
|
||||
const inboundBoarded = !!ticketToPrint?.booking?.returnBoardedAt;
|
||||
if (isRoundTrip && outboundBoarded && inboundBoarded) {
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">Select which leg to print:</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ActionButton icon={Printer} onClick={() => { printBoardingPass(ticketToPrint, 'outbound'); setPrintBpModalOpen(false); }}>
|
||||
Outbound
|
||||
</ActionButton>
|
||||
<ActionButton icon={Printer} variant="secondary" onClick={() => { printBoardingPass(ticketToPrint, 'inbound'); setPrintBpModalOpen(false); }}>
|
||||
Inbound (Return)
|
||||
</ActionButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const leg = isRoundTrip && inboundBoarded && !outboundBoarded ? 'inbound' : 'outbound';
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Print {leg === 'inbound' ? 'inbound (return)' : 'outbound'} boarding pass for ticket {ticketToPrint?.ticketNumber}?
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<ActionButton variant="secondary" onClick={() => { setPrintBpModalOpen(false); setTicketToPrint(null); }}>Cancel</ActionButton>
|
||||
<ActionButton icon={Printer} onClick={() => { printBoardingPass(ticketToPrint, leg); setPrintBpModalOpen(false); }}>Print</ActionButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -22,6 +22,13 @@ export const formatDateTime = (date?: string | Date | null): string => {
|
||||
return format(d, 'MMM dd, yyyy HH:mm');
|
||||
};
|
||||
|
||||
export const formatDateTimeShort = (date?: string | Date | null): string => {
|
||||
if (!date) return 'N/A';
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return 'N/A';
|
||||
return format(d, 'dd MMM yy HH:mm');
|
||||
};
|
||||
|
||||
export const formatDateTimeLocal = (date?: string | Date | null): string => {
|
||||
if (!date) return 'N/A';
|
||||
const d = new Date(date);
|
||||
@@ -34,7 +41,7 @@ export const getStatusColor = (status: string): string => {
|
||||
CONFIRMED: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
|
||||
PENDING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400',
|
||||
CANCELLED: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400',
|
||||
COMPLETED: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
|
||||
BOARDED: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
|
||||
PAID: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
|
||||
FAILED: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400',
|
||||
REFUNDED: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface Booking {
|
||||
passengerId: string;
|
||||
passenger?: Passenger.IPassenger;
|
||||
scheduleId: string;
|
||||
status: 'DRAFT' | 'PENDING_PAYMENT' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW' | 'REFUNDED';
|
||||
status: 'DRAFT' | 'PENDING_PAYMENT' | 'CONFIRMED' | 'CANCELLED' | 'BOARDED' | 'NO_SHOW' | 'REFUNDED';
|
||||
currency: string;
|
||||
totalMinor: number;
|
||||
adultCount: number;
|
||||
@@ -33,8 +33,8 @@ export interface Station {
|
||||
countryCode?: string;
|
||||
isOperational: boolean;
|
||||
timezone: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface Booking {
|
||||
passengerId: string;
|
||||
passenger?: Passenger.IPassenger;
|
||||
tripId: string;
|
||||
status: 'PENDING' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
|
||||
status: 'PENDING' | 'CONFIRMED' | 'CANCELLED' | 'BOARDED';
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
paymentStatus: Passenger.PaymentStatus;
|
||||
|
||||
@@ -147,61 +147,40 @@ export default function ReviewPage() {
|
||||
return apiClient.post(endpoint, data);
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
console.log('Booking created successfully:', data);
|
||||
const bookingIdValue = data.bookingId || data.id;
|
||||
const pnrValue = data.pnr || data.bookingReference || data.bookingRef;
|
||||
|
||||
console.log('Setting booking ID:', bookingIdValue);
|
||||
console.log('Setting PNR:', pnrValue);
|
||||
console.log('Booking via endpoint:', isAuthenticated ? '/bookings' : '/bookings/guest');
|
||||
|
||||
setBookingId(bookingIdValue);
|
||||
setPNR(pnrValue);
|
||||
|
||||
const totalAmount = isAuthenticated ? (data.totalMinor || data.totalAmount || 0) : (data.totalMinor || data.totalAmount || 0);
|
||||
|
||||
console.log('Total amount:', totalAmount);
|
||||
|
||||
const totalAmount = data.totalMinor || data.totalAmount || 0;
|
||||
setTimeout(() => {
|
||||
const currentState = useBookingStore.getState();
|
||||
console.log('Current booking store state:', currentState);
|
||||
console.log('bookingId:', currentState.bookingId);
|
||||
console.log('pnr:', currentState.pnr);
|
||||
|
||||
if (totalAmount > 0) {
|
||||
console.log('Redirecting to payment page');
|
||||
router.push('/booking/payment');
|
||||
} else {
|
||||
console.log('Redirecting to confirmation page');
|
||||
router.push('/booking/confirmation');
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Booking creation failed:', error);
|
||||
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create booking. Please try again.';
|
||||
alert(errorMessage);
|
||||
},
|
||||
});
|
||||
|
||||
const handleConfirm = async () => {
|
||||
console.log('handleConfirm called');
|
||||
try {
|
||||
const { searchCriteria } = useBookingStore.getState();
|
||||
|
||||
console.log('Search criteria:', searchCriteria);
|
||||
console.log('Seat hold:', seatHold);
|
||||
console.log('Selected schedule:', selectedSchedule);
|
||||
console.log('Outbound schedule:', outboundSchedule);
|
||||
console.log('Inbound schedule:', inboundSchedule);
|
||||
console.log('Passengers:', passengers);
|
||||
|
||||
if (!seatHold?.holdId) {
|
||||
console.error('No seat hold found');
|
||||
alert('Please select seats before continuing.');
|
||||
router.push('/booking/seats');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRoundTrip && !seatHold.returnHoldId) {
|
||||
alert('Please select return seats before continuing.');
|
||||
router.push('/booking/seats');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) {
|
||||
console.error('Missing search criteria');
|
||||
@@ -237,37 +216,24 @@ export default function ReviewPage() {
|
||||
console.log('Token found, length:', token.length);
|
||||
|
||||
let passengerId = getPassengerIdFromToken(token);
|
||||
console.log('Extracted passenger ID from JWT token:', passengerId);
|
||||
|
||||
// Fallback 1: Use passengerId from booking store
|
||||
if (!passengerId && storedPassengerId) {
|
||||
passengerId = storedPassengerId;
|
||||
console.log('Fallback 1: Using passengerId from booking store:', passengerId);
|
||||
}
|
||||
|
||||
// Fallback 2: Use passengerId from localStorage
|
||||
if (!passengerId && typeof window !== 'undefined') {
|
||||
const localStoragePassengerId = localStorage.getItem('booking_passengerId');
|
||||
if (localStoragePassengerId) {
|
||||
passengerId = localStoragePassengerId;
|
||||
console.log('Fallback 2: Using passengerId from localStorage:', passengerId);
|
||||
}
|
||||
if (localStoragePassengerId) passengerId = localStoragePassengerId;
|
||||
}
|
||||
|
||||
// Fallback 3: Use passengerId from user object
|
||||
if (!passengerId && user) {
|
||||
passengerId = (user as any).passengerId;
|
||||
console.log('Fallback 3: Using passengerId from user object:', passengerId);
|
||||
}
|
||||
|
||||
if (!passengerId) {
|
||||
console.error('Failed to extract passengerId');
|
||||
console.error('User object:', user);
|
||||
console.error('User object keys:', user ? Object.keys(user) : 'null');
|
||||
console.error('Stored passengerId from booking store:', storedPassengerId);
|
||||
if (typeof window !== 'undefined') {
|
||||
console.error('Stored passengerId from localStorage:', localStorage.getItem('booking_passengerId'));
|
||||
}
|
||||
throw new Error('Passenger ID not found in authentication token. Please log in again.');
|
||||
}
|
||||
|
||||
@@ -302,7 +268,7 @@ export default function ReviewPage() {
|
||||
bookingData.returnScheduleId = inboundSchedule.id;
|
||||
bookingData.returnOriginStationId = searchCriteria.destinationStationId;
|
||||
bookingData.returnDestinationStationId = searchCriteria.originStationId;
|
||||
bookingData.returnHoldId = seatHold.holdId; // Assuming same hold ID, adjust if needed
|
||||
bookingData.returnHoldId = seatHold.returnHoldId || seatHold.holdId;
|
||||
bookingData.returnSeatClassId = returnSeatClassId;
|
||||
}
|
||||
|
||||
@@ -346,7 +312,7 @@ export default function ReviewPage() {
|
||||
bookingData.returnScheduleId = inboundSchedule.id;
|
||||
bookingData.returnOriginStationId = searchCriteria.destinationStationId;
|
||||
bookingData.returnDestinationStationId = searchCriteria.originStationId;
|
||||
bookingData.returnHoldId = seatHold.holdId; // Assuming same hold ID, adjust if needed
|
||||
bookingData.returnHoldId = seatHold.returnHoldId || seatHold.holdId;
|
||||
bookingData.returnSeatClassId = returnSeatClassId;
|
||||
}
|
||||
|
||||
@@ -396,31 +362,12 @@ export default function ReviewPage() {
|
||||
|
||||
const displaySchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
||||
|
||||
console.log('Selected schedule:', displaySchedule);
|
||||
console.log('Base fare adult:', displaySchedule?.baseFareAdult);
|
||||
console.log('Passengers:', passengers);
|
||||
|
||||
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum) => {
|
||||
return sum + (outboundSchedule.baseFareAdult || 0);
|
||||
}, 0) : 0;
|
||||
|
||||
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum) => {
|
||||
return sum + (inboundSchedule.baseFareAdult || 0);
|
||||
}, 0) : 0;
|
||||
|
||||
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, p, i) => {
|
||||
const farePerPassenger = selectedSchedule?.baseFareAdult ||
|
||||
(selectedSchedule as any)?.fareAdult ||
|
||||
(selectedSchedule as any)?.price ||
|
||||
0;
|
||||
|
||||
console.log(`Passenger ${i}: ${p.name}, fare = ${farePerPassenger}`);
|
||||
|
||||
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum) => sum + (outboundSchedule.baseFareAdult || 0), 0) : 0;
|
||||
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum) => sum + (inboundSchedule.baseFareAdult || 0), 0) : 0;
|
||||
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => {
|
||||
const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
|
||||
return sum + farePerPassenger;
|
||||
}, 0);
|
||||
|
||||
console.log('Calculated base fare:', baseFare);
|
||||
|
||||
const total = baseFare;
|
||||
|
||||
return (
|
||||
|
||||
@@ -87,10 +87,22 @@ export default function SeatsPage() {
|
||||
});
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
setSeatHold({
|
||||
holdId: data.holdId || data.id,
|
||||
expiresAt: data.expiresAt,
|
||||
});
|
||||
const isInbound = isRoundTrip && currentJourneyType === 'inbound';
|
||||
if (isInbound) {
|
||||
// Merge the return hold into the existing outbound hold
|
||||
const current = useBookingStore.getState().seatHold;
|
||||
setSeatHold({
|
||||
holdId: current?.holdId || '',
|
||||
expiresAt: current?.expiresAt || '',
|
||||
returnHoldId: data.holdId || data.id,
|
||||
returnExpiresAt: data.expiresAt,
|
||||
});
|
||||
} else {
|
||||
setSeatHold({
|
||||
holdId: data.holdId || data.id,
|
||||
expiresAt: data.expiresAt,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -454,7 +466,7 @@ export default function SeatsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
if (!selectedSchedule || !passengers.length) return null;
|
||||
if (isRoundTrip ? (!outboundSchedule || !inboundSchedule || !passengers.length) : (!selectedSchedule || !passengers.length)) return null;
|
||||
|
||||
const allSelected = selectedSeats.length === passengers.length;
|
||||
const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed');
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface SelectedSchedule {
|
||||
export interface SeatHold {
|
||||
holdId: string;
|
||||
expiresAt: string;
|
||||
returnHoldId?: string;
|
||||
returnExpiresAt?: string;
|
||||
}
|
||||
|
||||
interface BookingState {
|
||||
|
||||
@@ -45,7 +45,7 @@ export class CacBankProvider implements PaymentProvider {
|
||||
api_key: this.apiKey,
|
||||
customer_mobile: input.payerAccount,
|
||||
currency: input.currency || this.defaultCurrency,
|
||||
desc: `EDR ${input.orderRef}`.slice(0, 500),
|
||||
desc: `${input.orderRef}`.slice(0, 500),
|
||||
vender_ref: input.merchantOrderId,
|
||||
amount: this.toMajorAmount(input.amountMinor, input.currency),
|
||||
company_services_id: this.companyServicesId,
|
||||
|
||||
@@ -62,7 +62,7 @@ export class CardProvider implements PaymentProvider {
|
||||
const requestBody: CardInitiateRequest = {
|
||||
amount,
|
||||
currency: input.currency,
|
||||
description: `EDR ${input.orderRef}`,
|
||||
description: `${input.orderRef}`,
|
||||
metadata: {
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
orderRef: input.orderRef,
|
||||
|
||||
@@ -62,7 +62,7 @@ export class CbeBirrProvider implements PaymentProvider {
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
amount,
|
||||
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.
|
||||
returnUrl: input.returnUrl ?? this.returnUrl,
|
||||
notifyUrl: this.notifyUrl,
|
||||
|
||||
@@ -207,7 +207,7 @@ export class DMoneyProvider implements PaymentProvider {
|
||||
merch_code: this.merchantCode,
|
||||
merch_order_id: input.merchantOrderId,
|
||||
trade_type: "Checkout" as const,
|
||||
title: `EDR ${input.orderRef}`,
|
||||
title: `${input.orderRef}`,
|
||||
total_amount: totalAmount,
|
||||
trans_currency: 1 == 1 ? "DJF": this.currency,
|
||||
timeout_express: this.timeoutExpress,
|
||||
|
||||
@@ -210,7 +210,7 @@ export class WaafiProvider implements PaymentProvider {
|
||||
amount: this.toAmount(input.amountMinor),
|
||||
// Waafi has no ETB; `waafi.currency` overrides the booking currency when set.
|
||||
currency: this.currency || input.currency,
|
||||
description: `EDR ${input.orderRef}`,
|
||||
description: `${input.orderRef}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user