Merge pull request #111 from Tria-plc/alpha

Coaches, seats, schedules, and pricing related updates
This commit is contained in:
Stephanos A.
2026-06-07 17:53:19 +03:00
committed by GitHub
55 changed files with 5119 additions and 2930 deletions

View File

@@ -255,7 +255,7 @@ The API uses two authentication schemes:
| **Routes** | `/routes` | JWT/IAM | Reusable route templates with ordered stops | | **Routes** | `/routes` | JWT/IAM | Reusable route templates with ordered stops |
| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates | | **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates |
| **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations | | **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations |
| **Seat Classes** | `/seat-classes` | Public/JWT/IAM | Seat class management and configuration | | **Seat Classes** | `/seat-classes` or `/classes` | Public/JWT/IAM | Seat class management and configuration |
| **Segment Seats** | `/segments/seats` | Public/JWT | Segment-based seat availability and booking | | **Segment Seats** | `/segments/seats` | Public/JWT | Segment-based seat availability and booking |
| **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation | | **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation |
| **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking | | **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking |

View File

@@ -13,14 +13,12 @@
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed-complete.ts", "prisma:seed": "ts-node prisma/seed.ts",
"prisma:seed-full": "ts-node prisma/seed.ts", "prisma:seed-full": "ts-node prisma/seed.ts",
"prisma:backfill": "ts-node prisma/backfill-fields.ts", "prisma:backfill": "ts-node prisma/backfill-fields.ts",
"prisma:verify": "ts-node prisma/verify-backfill.ts" "prisma:verify": "ts-node prisma/verify-backfill.ts"
}, },
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.1", "@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",

View File

@@ -1,5 +0,0 @@
-- AlterEnum
ALTER TYPE "PaymentMethodType" ADD VALUE 'WAAFI';
-- AlterTable
ALTER TABLE "FareRule" ADD COLUMN "nationality" TEXT;

View File

@@ -1,28 +0,0 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "contactEmail" TEXT,
ADD COLUMN "contactPhone" TEXT;
-- CreateTable
CREATE TABLE "SavedPassengerProfile" (
"id" TEXT NOT NULL,
"userId" TEXT,
"deviceId" TEXT,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3) NOT NULL,
"idDocumentType" "IdDocumentType" NOT NULL,
"passportNumber" TEXT,
"passportCountry" TEXT,
"nationality" TEXT,
"phone" TEXT,
"email" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SavedPassengerProfile_userId_idx" ON "SavedPassengerProfile"("userId");
-- CreateIndex
CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "SavedPassengerProfile"("deviceId");

View File

@@ -1,55 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[faydaSub]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN "faydaSub" TEXT,
ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3),
ADD COLUMN "faydaVerifiedName" TEXT;
-- AlterTable
ALTER TABLE "passenger"."User" ADD COLUMN "faydaSub" TEXT,
ADD COLUMN "faydaVerified" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "faydaVerifiedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "passenger"."FaydaVerificationSession" (
"id" TEXT NOT NULL,
"state" TEXT NOT NULL,
"codeVerifier" TEXT NOT NULL,
"purpose" TEXT NOT NULL DEFAULT 'PURCHASE',
"saveToAccount" BOOLEAN NOT NULL DEFAULT false,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"errorCode" TEXT,
"errorDescription" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"completedAt" TIMESTAMP(3),
"userId" TEXT,
"bookingId" TEXT,
CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "passenger"."FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_userId_idx" ON "passenger"."FaydaVerificationSession"("userId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "passenger"."FaydaVerificationSession"("bookingId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_state_idx" ON "passenger"."FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "passenger"."FaydaVerificationSession"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "User_faydaSub_key" ON "passenger"."User"("faydaSub");
-- AddForeignKey
ALTER TABLE "passenger"."FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "passenger"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,26 +0,0 @@
/*
Warnings:
- You are about to drop the column `maskedHint` on the `PaymentMethod` table. All the data in the column will be lost.
- You are about to drop the column `userId` on the `PaymentMethod` table. All the data in the column will be lost.
- A unique constraint covering the columns `[type]` on the table `PaymentMethod` will be added. If there are existing duplicate values, this will fail.
- Added the required column `updatedAt` to the `PaymentMethod` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL');
-- DropIndex
DROP INDEX "PaymentMethod_userId_isDefault_idx";
-- AlterTable
ALTER TABLE "PaymentMethod" DROP COLUMN "maskedHint",
DROP COLUMN "userId",
ADD COLUMN "currency" TEXT NOT NULL DEFAULT 'ETB',
ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "region" "PaymentRegion" NOT NULL DEFAULT 'GLOBAL',
ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type");

View File

@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "FaydaVerificationSession" ADD COLUMN "authCode" TEXT,
ADD COLUMN "platform" TEXT NOT NULL DEFAULT 'WEB';

View File

@@ -1,2 +0,0 @@
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -23,7 +23,10 @@ CREATE TYPE "Currency" AS ENUM ('ETB', 'DJF', 'USD');
CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW', 'REFUNDED'); CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW', 'REFUNDED');
-- CreateEnum -- CreateEnum
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET'); CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL');
-- CreateEnum
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET', 'WAAFI');
-- CreateEnum -- CreateEnum
CREATE TYPE "PaymentIntentStatus" AS ENUM ('REQUIRES_ACTION', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'REFUNDED'); CREATE TYPE "PaymentIntentStatus" AS ENUM ('REQUIRES_ACTION', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'REFUNDED');
@@ -55,12 +58,25 @@ CREATE TYPE "FoodOrderStatus" AS ENUM ('PENDING', 'PREPARING', 'READY', 'DELIVER
-- CreateEnum -- CreateEnum
CREATE TYPE "DevicePlatform" AS ENUM ('IOS', 'ANDROID', 'WEB'); CREATE TYPE "DevicePlatform" AS ENUM ('IOS', 'ANDROID', 'WEB');
-- CreateTable
CREATE TABLE "CoachType" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'passenger',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CoachType_pkey" PRIMARY KEY ("id")
);
-- CreateTable -- CreateTable
CREATE TABLE "SeatClass" ( CREATE TABLE "SeatClass" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"coachTypeId" TEXT NOT NULL,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"description" TEXT, "description" TEXT,
"basePrice" INTEGER NOT NULL, "baseFareMinor" INTEGER NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true, "isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
@@ -86,6 +102,9 @@ CREATE TABLE "User" (
"lastLoginAt" TIMESTAMP(3), "lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
"faydaVerified" BOOLEAN NOT NULL DEFAULT false,
"faydaVerifiedAt" TIMESTAMP(3),
"faydaSub" TEXT,
CONSTRAINT "User_pkey" PRIMARY KEY ("id") CONSTRAINT "User_pkey" PRIMARY KEY ("id")
); );
@@ -211,16 +230,11 @@ CREATE TABLE "TripLiveStatus" (
-- CreateTable -- CreateTable
CREATE TABLE "Coach" ( CREATE TABLE "Coach" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"coachNumber" TEXT NOT NULL, "coachTypeId" TEXT NOT NULL,
"label" TEXT NOT NULL, "number" TEXT NOT NULL,
"seatClassId" TEXT NOT NULL, "arrangement" TEXT NOT NULL DEFAULT '2+2',
"coachType" TEXT, "capacity" INTEGER NOT NULL DEFAULT 0,
"mode" TEXT NOT NULL DEFAULT 'seat', "status" TEXT NOT NULL DEFAULT 'ACTIVE',
"seatArrangement" TEXT,
"bedArrangement" TEXT,
"amenities" JSONB,
"totalUnits" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
@@ -243,10 +257,9 @@ CREATE TABLE "CoachAssignment" (
CREATE TABLE "Seat" ( CREATE TABLE "Seat" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"coachId" TEXT NOT NULL, "coachId" TEXT NOT NULL,
"seatNumber" TEXT NOT NULL,
"row" INTEGER NOT NULL, "row" INTEGER NOT NULL,
"col" TEXT NOT NULL, "col" TEXT NOT NULL,
"label" TEXT NOT NULL,
"seatNumber" TEXT,
"kind" "SeatKind" NOT NULL DEFAULT 'STANDARD', "kind" "SeatKind" NOT NULL DEFAULT 'STANDARD',
"status" "SeatStatus" NOT NULL DEFAULT 'AVAILABLE', "status" "SeatStatus" NOT NULL DEFAULT 'AVAILABLE',
"heldUntil" TIMESTAMP(3), "heldUntil" TIMESTAMP(3),
@@ -254,7 +267,6 @@ CREATE TABLE "Seat" (
"isAisle" BOOLEAN NOT NULL DEFAULT false, "isAisle" BOOLEAN NOT NULL DEFAULT false,
"bedPosition" TEXT, "bedPosition" TEXT,
"premiumFeeMinor" INTEGER NOT NULL DEFAULT 0, "premiumFeeMinor" INTEGER NOT NULL DEFAULT 0,
"eligibility" TEXT,
CONSTRAINT "Seat_pkey" PRIMARY KEY ("id") CONSTRAINT "Seat_pkey" PRIMARY KEY ("id")
); );
@@ -278,6 +290,7 @@ CREATE TABLE "FareRule" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"tripId" TEXT, "tripId" TEXT,
"route" TEXT, "route" TEXT,
"nationality" TEXT,
"seatClassId" TEXT NOT NULL, "seatClassId" TEXT NOT NULL,
"baseFareMinor" INTEGER NOT NULL, "baseFareMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB', "currency" TEXT NOT NULL DEFAULT 'ETB',
@@ -303,6 +316,8 @@ CREATE TABLE "Booking" (
"displayCurrency" "Currency", "displayCurrency" "Currency",
"displayTotalMinor" INTEGER, "displayTotalMinor" INTEGER,
"bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY', "bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY',
"contactEmail" TEXT,
"contactPhone" TEXT,
"userAgent" TEXT, "userAgent" TEXT,
"source" TEXT NOT NULL DEFAULT 'WEB', "source" TEXT NOT NULL DEFAULT 'WEB',
"promoCode" TEXT, "promoCode" TEXT,
@@ -327,6 +342,9 @@ CREATE TABLE "BookingSeat" (
"passportCountry" TEXT, "passportCountry" TEXT,
"verifaydaVerified" BOOLEAN NOT NULL DEFAULT false, "verifaydaVerified" BOOLEAN NOT NULL DEFAULT false,
"verifaydaData" JSONB, "verifaydaData" JSONB,
"faydaVerifiedAt" TIMESTAMP(3),
"faydaSub" TEXT,
"faydaVerifiedName" TEXT,
"seatLabelSnapshot" TEXT, "seatLabelSnapshot" TEXT,
"fareMinor" INTEGER, "fareMinor" INTEGER,
"displayCurrency" "Currency", "displayCurrency" "Currency",
@@ -338,13 +356,16 @@ CREATE TABLE "BookingSeat" (
-- CreateTable -- CreateTable
CREATE TABLE "PaymentMethod" ( CREATE TABLE "PaymentMethod" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "PaymentMethodType" NOT NULL, "type" "PaymentMethodType" NOT NULL,
"displayName" TEXT NOT NULL, "displayName" TEXT NOT NULL,
"maskedHint" TEXT, "region" "PaymentRegion" NOT NULL DEFAULT 'GLOBAL',
"currency" TEXT NOT NULL DEFAULT 'ETB',
"providerId" TEXT, "providerId" TEXT,
"isDefault" BOOLEAN NOT NULL DEFAULT false, "isDefault" BOOLEAN NOT NULL DEFAULT false,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PaymentMethod_pkey" PRIMARY KEY ("id") CONSTRAINT "PaymentMethod_pkey" PRIMARY KEY ("id")
); );
@@ -423,6 +444,16 @@ CREATE TABLE "Ticket" (
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id") CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
); );
-- CreateTable
CREATE TABLE "TicketSeat" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"seatId" TEXT NOT NULL,
"seatIndex" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id")
);
-- CreateTable -- CreateTable
CREATE TABLE "LoyaltyAccount" ( CREATE TABLE "LoyaltyAccount" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
@@ -1016,8 +1047,51 @@ CREATE TABLE "VerifaydaVerification" (
CONSTRAINT "VerifaydaVerification_pkey" PRIMARY KEY ("id") CONSTRAINT "VerifaydaVerification_pkey" PRIMARY KEY ("id")
); );
-- CreateTable
CREATE TABLE "SavedPassengerProfile" (
"id" TEXT NOT NULL,
"userId" TEXT,
"deviceId" TEXT,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3) NOT NULL,
"idDocumentType" "IdDocumentType" NOT NULL,
"passportNumber" TEXT,
"passportCountry" TEXT,
"nationality" TEXT,
"phone" TEXT,
"email" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FaydaVerificationSession" (
"id" TEXT NOT NULL,
"state" TEXT NOT NULL,
"codeVerifier" TEXT NOT NULL,
"purpose" TEXT NOT NULL DEFAULT 'PURCHASE',
"platform" TEXT NOT NULL DEFAULT 'WEB',
"saveToAccount" BOOLEAN NOT NULL DEFAULT false,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"errorCode" TEXT,
"errorDescription" TEXT,
"authCode" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"completedAt" TIMESTAMP(3),
"userId" TEXT,
"bookingId" TEXT,
CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "SeatClass_name_key" ON "SeatClass"("name"); CREATE INDEX "SeatClass_coachTypeId_idx" ON "SeatClass"("coachTypeId");
-- CreateIndex
CREATE UNIQUE INDEX "SeatClass_coachTypeId_name_key" ON "SeatClass"("coachTypeId", "name");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
@@ -1025,6 +1099,9 @@ CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone"); CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "User_faydaSub_key" ON "User"("faydaSub");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token"); CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
@@ -1053,7 +1130,10 @@ CREATE UNIQUE INDEX "TripStopTime_scheduleId_sequence_key" ON "TripStopTime"("sc
CREATE UNIQUE INDEX "TripLiveStatus_scheduleId_key" ON "TripLiveStatus"("scheduleId"); CREATE UNIQUE INDEX "TripLiveStatus_scheduleId_key" ON "TripLiveStatus"("scheduleId");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "Coach_coachNumber_key" ON "Coach"("coachNumber"); CREATE UNIQUE INDEX "Coach_number_key" ON "Coach"("number");
-- CreateIndex
CREATE INDEX "Coach_coachTypeId_idx" ON "Coach"("coachTypeId");
-- CreateIndex -- CreateIndex
CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId"); CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId");
@@ -1062,11 +1142,14 @@ CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId")
CREATE UNIQUE INDEX "CoachAssignment_scheduleId_positionNumber_key" ON "CoachAssignment"("scheduleId", "positionNumber"); CREATE UNIQUE INDEX "CoachAssignment_scheduleId_positionNumber_key" ON "CoachAssignment"("scheduleId", "positionNumber");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "Seat_coachId_row_col_key" ON "Seat"("coachId", "row", "col"); CREATE INDEX "Seat_coachId_idx" ON "Seat"("coachId");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "Seat_coachId_seatNumber_key" ON "Seat"("coachId", "seatNumber"); CREATE UNIQUE INDEX "Seat_coachId_seatNumber_key" ON "Seat"("coachId", "seatNumber");
-- CreateIndex
CREATE UNIQUE INDEX "Seat_coachId_row_col_key" ON "Seat"("coachId", "row", "col");
-- CreateIndex -- CreateIndex
CREATE INDEX "SeatHold_expiresAt_idx" ON "SeatHold"("expiresAt"); CREATE INDEX "SeatHold_expiresAt_idx" ON "SeatHold"("expiresAt");
@@ -1077,7 +1160,7 @@ CREATE UNIQUE INDEX "Booking_bookingRef_key" ON "Booking"("bookingRef");
CREATE INDEX "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status"); CREATE INDEX "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status");
-- CreateIndex -- CreateIndex
CREATE INDEX "PaymentMethod_userId_isDefault_idx" ON "PaymentMethod"("userId", "isDefault"); CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "PaymentIntent_bookingId_key" ON "PaymentIntent"("bookingId"); CREATE UNIQUE INDEX "PaymentIntent_bookingId_key" ON "PaymentIntent"("bookingId");
@@ -1100,6 +1183,12 @@ CREATE UNIQUE INDEX "PaymentWebhookEvent_provider_externalEventId_key" ON "Payme
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "Ticket_bookingId_key" ON "Ticket"("bookingId"); CREATE UNIQUE INDEX "Ticket_bookingId_key" ON "Ticket"("bookingId");
-- CreateIndex
CREATE INDEX "TicketSeat_ticketId_idx" ON "TicketSeat"("ticketId");
-- CreateIndex
CREATE INDEX "TicketSeat_seatId_idx" ON "TicketSeat"("seatId");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passengerId"); CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passengerId");
@@ -1202,6 +1291,30 @@ CREATE INDEX "VerifaydaVerification_nationalId_idx" ON "VerifaydaVerification"("
-- CreateIndex -- CreateIndex
CREATE INDEX "VerifaydaVerification_bookingId_idx" ON "VerifaydaVerification"("bookingId"); CREATE INDEX "VerifaydaVerification_bookingId_idx" ON "VerifaydaVerification"("bookingId");
-- CreateIndex
CREATE INDEX "SavedPassengerProfile_userId_idx" ON "SavedPassengerProfile"("userId");
-- CreateIndex
CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "SavedPassengerProfile"("deviceId");
-- CreateIndex
CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_userId_idx" ON "FaydaVerificationSession"("userId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "FaydaVerificationSession"("bookingId");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_state_idx" ON "FaydaVerificationSession"("state");
-- CreateIndex
CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "FaydaVerificationSession"("expiresAt");
-- AddForeignKey
ALTER TABLE "SeatClass" ADD CONSTRAINT "SeatClass_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1214,6 +1327,9 @@ ALTER TABLE "TravelerProfile" ADD CONSTRAINT "TravelerProfile_passengerId_fkey"
-- AddForeignKey -- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1230,7 +1346,7 @@ ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_stationId_fkey" FOREIGN
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1265,6 +1381,12 @@ ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey"
-- AddForeignKey -- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1363,3 +1485,6 @@ ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("sea
-- AddForeignKey -- AddForeignKey
ALTER TABLE "FraudAlert" ADD CONSTRAINT "FraudAlert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "FraudAlert" ADD CONSTRAINT "FraudAlert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "SegmentFareRule" (
"id" TEXT NOT NULL,
"routeId" TEXT NOT NULL,
"originStopSequence" INTEGER NOT NULL,
"destinationStopSequence" INTEGER NOT NULL,
"seatClassId" TEXT NOT NULL,
"baseFareMinor" INTEGER NOT NULL,
"nationality" TEXT,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"validFrom" TIMESTAMP(3) NOT NULL,
"validUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SegmentFareRule_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SegmentFareRule_routeId_seatClassId_idx" ON "SegmentFareRule"("routeId", "seatClassId");
-- CreateIndex
CREATE UNIQUE INDEX "SegmentFareRule_routeId_originStopSequence_destinationStopS_key" ON "SegmentFareRule"("routeId", "originStopSequence", "destinationStopSequence", "seatClassId", "nationality");
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,30 +0,0 @@
-- Add contact fields to Booking table
ALTER TABLE "passenger"."Booking"
ADD COLUMN "contactEmail" TEXT,
ADD COLUMN "contactPhone" TEXT;
-- Create SavedPassengerProfile table
CREATE TABLE "passenger"."SavedPassengerProfile" (
"id" TEXT NOT NULL,
"userId" TEXT,
"deviceId" TEXT,
"passengerName" TEXT NOT NULL,
"dateOfBirth" TIMESTAMP(3) NOT NULL,
"idDocumentType" "passenger"."IdDocumentType" NOT NULL,
"passportNumber" TEXT,
"passportCountry" TEXT,
"nationality" TEXT,
"phone" TEXT,
"email" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id")
);
-- Create indexes
CREATE INDEX "SavedPassengerProfile_userId_idx" ON "passenger"."SavedPassengerProfile"("userId");
CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "passenger"."SavedPassengerProfile"("deviceId");
-- Add comment
COMMENT ON TABLE "passenger"."SavedPassengerProfile" IS 'Stores passenger details for quick rebooking (by userId or deviceId)';

View File

@@ -70,17 +70,34 @@ enum Currency {
@@schema("passenger") @@schema("passenger")
} }
model SeatClass { model CoachType {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique code String
description String? name String
basePrice Int type String @default("passenger") // 'passenger', 'sleeper', 'dining', 'baggage'
isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
coaches Coach[] coaches Coach[]
seatClasses SeatClass[]
@@schema("passenger")
}
model SeatClass {
id String @id @default(uuid())
coachTypeId String
name String
description String?
baseFareMinor Int
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
coachType CoachType @relation(fields: [coachTypeId], references: [id])
fareRules FareRule[] fareRules FareRule[]
routeFareRules RouteFareRule[] routeFareRules RouteFareRule[]
segmentFares SegmentFareRule[]
@@unique([coachTypeId, name])
@@index([coachTypeId])
@@schema("passenger") @@schema("passenger")
} }
@@ -385,21 +402,17 @@ model TripLiveStatus {
model Coach { model Coach {
id String @id @default(uuid()) id String @id @default(uuid())
coachNumber String @unique coachTypeId String
label String number String @unique
seatClassId String arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2'
coachType String? capacity Int @default(0) // Total seats/beds
mode String @default("seat") // 'seat', 'bed', 'convertible' status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
seatArrangement String?
bedArrangement String?
amenities Json?
totalUnits Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
seatClass SeatClass @relation(fields: [seatClassId], references: [id]) coachType CoachType @relation(fields: [coachTypeId], references: [id])
seats Seat[] seats Seat[]
assignments CoachAssignment[] assignments CoachAssignment[]
@@index([coachTypeId])
@@schema("passenger") @@schema("passenger")
} }
@@ -422,10 +435,9 @@ model CoachAssignment {
model Seat { model Seat {
id String @id @default(uuid()) id String @id @default(uuid())
coachId String coachId String
seatNumber String // Auto-generated: e.g., '1', '2', '3' (unique per coach)
row Int row Int
col String col String
label String
seatNumber String?
kind SeatKind @default(STANDARD) kind SeatKind @default(STANDARD)
status SeatStatus @default(AVAILABLE) status SeatStatus @default(AVAILABLE)
heldUntil DateTime? heldUntil DateTime?
@@ -433,13 +445,13 @@ model Seat {
isAisle Boolean @default(false) isAisle Boolean @default(false)
bedPosition String? // 'lower', 'middle', 'upper' bedPosition String? // 'lower', 'middle', 'upper'
premiumFeeMinor Int @default(0) premiumFeeMinor Int @default(0)
eligibility String?
coach Coach @relation(fields: [coachId], references: [id]) coach Coach @relation(fields: [coachId], references: [id])
bookingSeats BookingSeat[] bookingSeats BookingSeat[]
blocks SeatBlock[] blocks SeatBlock[]
ticketSeats TicketSeat[] ticketSeats TicketSeat[]
@@unique([coachId, row, col])
@@unique([coachId, seatNumber]) @@unique([coachId, seatNumber])
@@unique([coachId, row, col])
@@index([coachId])
@@schema("passenger") @@schema("passenger")
} }
@@ -978,6 +990,7 @@ model Route {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
stops RouteStop[] stops RouteStop[]
fareRules RouteFareRule[] fareRules RouteFareRule[]
segmentFares SegmentFareRule[]
schedules TrainSchedule[] schedules TrainSchedule[]
@@schema("passenger") @@schema("passenger")
@@ -1017,6 +1030,26 @@ model RouteFareRule {
@@schema("passenger") @@schema("passenger")
} }
model SegmentFareRule {
id String @id @default(uuid())
routeId String
originStopSequence Int
destinationStopSequence Int
seatClassId String
baseFareMinor Int
nationality String? // Optional: Ethiopian, Djiboutian, Other
currency String @default("ETB")
validFrom DateTime
validUntil DateTime?
createdAt DateTime @default(now())
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
seatClass SeatClass @relation(fields: [seatClassId], references: [id])
@@unique([routeId, originStopSequence, destinationStopSequence, seatClassId, nationality])
@@index([routeId, seatClassId])
@@schema("passenger")
}
model Agent { model Agent {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @unique userId String @unique

View File

@@ -1,231 +0,0 @@
import { PrismaClient, SeatKind } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Starting complete seed...\n');
// 1. STATIONS
console.log('📍 Seeding stations...');
const stationData = [
{ code: 'SBT', name: 'Sebeta', city: 'Sebeta', countryCode: 'ET', lat: 8.9167, lng: 38.6167 },
{ code: 'LBU', name: 'Labu', city: 'Labu', countryCode: 'ET', lat: 8.8500, lng: 38.7000 },
{ code: 'IND', name: 'Indode', city: 'Indode', countryCode: 'ET', lat: 8.7800, lng: 38.8200 },
{ code: 'BSH', name: 'Bishoftu', city: 'Bishoftu', countryCode: 'ET', lat: 8.7500, lng: 38.9833 },
{ code: 'MJO', name: 'Mojo', city: 'Mojo', countryCode: 'ET', lat: 8.6000, lng: 39.1200 },
{ code: 'ADM', name: 'Adama', city: 'Adama', countryCode: 'ET', lat: 8.5400, lng: 39.2675 },
{ code: 'DDW', name: 'Diredawa', city: 'Diredawa', countryCode: 'ET', lat: 9.5931, lng: 41.8661 },
{ code: 'NGD', name: 'Nagad', city: 'Nagad', countryCode: 'DJ', timezone: 'Africa/Djibouti', lat: 11.5720, lng: 43.1456 },
];
const stations = [];
for (const s of stationData) {
stations.push(await prisma.station.upsert({ where: { code: s.code }, update: {}, create: s }));
}
console.log(`${stations.length} stations\n`);
// 2. SEAT CLASSES
console.log('💺 Seeding seat classes...');
const scEconomy = await prisma.seatClass.upsert({
where: { name: 'Economy Regular' },
update: {},
create: { name: 'Economy Regular', description: 'Standard economy', basePrice: 25000, isActive: true },
});
const scBed = await prisma.seatClass.upsert({
where: { name: 'Economy Bed' },
update: {},
create: { name: 'Economy Bed', description: 'Economy bed', basePrice: 35000, isActive: true },
});
console.log(`✅ 2 seat classes\n`);
// 3. ROUTES
console.log('🛤️ Seeding routes...');
const route1 = await prisma.route.upsert({
where: { code: 'SBT-NGD' },
update: {},
create: { code: 'SBT-NGD', name: 'Sebeta-Nagad Express', effectiveFrom: new Date('2026-01-01'), active: true },
});
await prisma.routeStop.createMany({
data: [
{ routeId: route1.id, stationId: stations[0].id, sequence: 1, distanceKm: 0 },
{ routeId: route1.id, stationId: stations[1].id, sequence: 2, distanceKm: 15 },
{ routeId: route1.id, stationId: stations[2].id, sequence: 3, distanceKm: 28 },
{ routeId: route1.id, stationId: stations[3].id, sequence: 4, distanceKm: 45 },
{ routeId: route1.id, stationId: stations[4].id, sequence: 5, distanceKm: 73 },
{ routeId: route1.id, stationId: stations[5].id, sequence: 6, distanceKm: 99 },
{ routeId: route1.id, stationId: stations[6].id, sequence: 7, distanceKm: 378 },
{ routeId: route1.id, stationId: stations[7].id, sequence: 8, distanceKm: 756 },
],
skipDuplicates: true,
});
await prisma.routeFareRule.createMany({
data: [
{ routeId: route1.id, seatClassId: scEconomy.id, passengerCategory: 'ADULT', baseFareMinor: 65000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, seatClassId: scEconomy.id, passengerCategory: 'CHILD', baseFareMinor: 65000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, seatClassId: scBed.id, passengerCategory: 'ADULT', baseFareMinor: 91000, validFrom: new Date('2026-01-01') },
{ routeId: route1.id, seatClassId: scBed.id, passengerCategory: 'CHILD', baseFareMinor: 91000, validFrom: new Date('2026-01-01') },
],
skipDuplicates: true,
});
console.log(`✅ 1 route with stops and fares\n`);
// 4. TRAINS
console.log('🚂 Seeding trains...');
const train = await prisma.train.upsert({
where: { number: '301' },
update: {},
create: { number: '301', name: 'Express 301', description: 'Main Express' },
});
console.log(`✅ 1 train\n`);
// 5. COACHES & SEATS
console.log('🚃 Seeding coaches...');
const coach1 = await prisma.coach.upsert({
where: { coachNumber: 'C-A1' },
update: {},
create: { coachNumber: 'C-A1', label: 'A', seatClassId: scEconomy.id, mode: 'seat', totalUnits: 20 },
});
const existingSeats = await prisma.seat.count({ where: { coachId: coach1.id } });
if (existingSeats === 0) {
const seats = [];
for (let row = 1; row <= 5; row++) {
for (const col of ['A', 'B', 'C', 'D']) {
seats.push({
coachId: coach1.id,
row,
col,
label: `${row}${col}`,
seatNumber: `A${row}${col}`,
kind: 'STANDARD' as SeatKind,
});
}
}
await prisma.seat.createMany({ data: seats });
}
console.log(`✅ 1 coach with 20 seats\n`);
// 6. SCHEDULE
console.log('📅 Seeding schedule...');
const existingSchedules = await prisma.trainSchedule.findMany({ where: { trainId: train.id }, select: { id: true } });
if (existingSchedules.length > 0) {
const scheduleIds = existingSchedules.map(s => s.id);
const bookingIds = (
await prisma.booking.findMany({ where: { scheduleId: { in: scheduleIds } }, select: { id: true } })
).map(b => b.id);
// Delete booking children in FK-safe order before deleting the bookings themselves
await prisma.foodOrderItem.deleteMany({ where: { order: { bookingId: { in: bookingIds } } } });
await prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.booking.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await prisma.fareRule.deleteMany({ where: { tripId: { in: scheduleIds } } });
await prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
await prisma.trainSchedule.deleteMany({ where: { trainId: train.id } });
}
const schedule = await prisma.trainSchedule.create({
data: {
trainId: train.id,
routeId: route1.id,
originStationId: stations[0].id,
destinationStationId: stations[7].id,
departureAt: new Date('2026-06-15T06:00:00Z'),
arrivalAt: new Date('2026-06-15T22:00:00Z'),
durationMinutes: 960,
stopsCount: 8,
},
});
await prisma.coachAssignment.create({
data: { scheduleId: schedule.id, coachId: coach1.id, positionNumber: 1 },
});
await prisma.tripStopTime.createMany({
data: [
{ scheduleId: schedule.id, stationId: stations[0].id, sequence: 1, plannedDepartureAt: new Date('2026-06-15T06:00:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[1].id, sequence: 2, plannedArrivalAt: new Date('2026-06-15T07:00:00Z'), plannedDepartureAt: new Date('2026-06-15T07:05:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[2].id, sequence: 3, plannedArrivalAt: new Date('2026-06-15T08:00:00Z'), plannedDepartureAt: new Date('2026-06-15T08:05:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[3].id, sequence: 4, plannedArrivalAt: new Date('2026-06-15T09:00:00Z'), plannedDepartureAt: new Date('2026-06-15T09:10:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[4].id, sequence: 5, plannedArrivalAt: new Date('2026-06-15T10:00:00Z'), plannedDepartureAt: new Date('2026-06-15T10:10:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[5].id, sequence: 6, plannedArrivalAt: new Date('2026-06-15T11:00:00Z'), plannedDepartureAt: new Date('2026-06-15T11:15:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[6].id, sequence: 7, plannedArrivalAt: new Date('2026-06-15T15:00:00Z'), plannedDepartureAt: new Date('2026-06-15T15:20:00Z'), status: 'UPCOMING' },
{ scheduleId: schedule.id, stationId: stations[7].id, sequence: 8, plannedArrivalAt: new Date('2026-06-15T22:00:00Z'), status: 'UPCOMING' },
],
});
console.log(`✅ 1 schedule with stops\n`);
// 7. USERS
console.log('👥 Seeding users...');
const adminHash = await bcrypt.hash('admin123', 10);
const userHash = await bcrypt.hash('password123', 10);
await prisma.user.upsert({
where: { email: 'admin@edr-platform.com' },
update: {},
create: { fullName: 'Admin', email: 'admin@edr-platform.com', phone: '+251900000000', passwordHash: adminHash, role: 'ADMIN' },
});
const user = await prisma.user.upsert({
where: { email: 'abebe@email.com' },
update: {},
create: { fullName: 'Abebe Kebede', email: 'abebe@email.com', phone: '+251912345678', passwordHash: userHash, nationality: 'Ethiopian' },
});
let passenger = await prisma.passenger.findUnique({ where: { userId: user.id } });
if (!passenger) {
passenger = await prisma.passenger.create({ data: { userId: user.id } });
await prisma.loyaltyAccount.create({ data: { passengerId: passenger.id, pointsBalance: 1000, tier: 'BRONZE' } });
await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 100000 } });
}
console.log(`✅ 2 users\n`);
// 8. SUPPORTING DATA
console.log('📦 Seeding supporting data...');
await prisma.paymentMethod.upsert({
where: { type: 'TELEBIRR' },
update: {},
create: { type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA', currency: 'ETB', enabled: true, sortOrder: 1 },
});
await prisma.currencyExchangeRate.deleteMany({});
await prisma.currencyExchangeRate.createMany({
data: [
{ fromCurrency: 'ETB', toCurrency: 'ETB', rate: 1.0, effectiveDate: new Date() },
{ fromCurrency: 'ETB', toCurrency: 'USD', rate: 0.018, effectiveDate: new Date() },
{ fromCurrency: 'ETB', toCurrency: 'DJF', rate: 3.2, effectiveDate: new Date() },
],
});
console.log(`✅ Payment methods and currencies\n`);
console.log('✅ SEED COMPLETE!\n');
console.log('📋 Summary:');
console.log(' - 8 Stations');
console.log(' - 2 Seat Classes');
console.log(' - 1 Route with 8 stops');
console.log(' - 1 Train with 1 schedule');
console.log(' - 1 Coach with 20 seats');
console.log(' - 2 Users (Admin + Passenger)');
console.log('\n🔑 Credentials:');
console.log(' Admin: admin@edr-platform.com / admin123');
console.log(' User: abebe@email.com / password123');
}
main()
.catch((e) => {
console.error('❌ Error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

File diff suppressed because it is too large Load Diff

View File

@@ -392,7 +392,7 @@ export class BookingsService {
where: { bookingRef }, where: { bookingRef },
include: { include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } }, seats: { include: { seat: { include: { coach: true } } } },
paymentIntent: true, ticket: true, paymentIntent: true, ticket: true,
}, },
}); });
@@ -408,7 +408,7 @@ export class BookingsService {
destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city }, destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city },
departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt, departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt,
}, },
passengers: booking.seats.map((bs) => ({ passengers: booking.seats?.map((bs: any) => ({
fullName: bs.passengerName, category: bs.passengerCategory, verifaydaVerified: bs.verifaydaVerified, fullName: bs.passengerName, category: bs.passengerCategory, verifaydaVerified: bs.verifaydaVerified,
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass.name }, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass.name },
})), })),

View File

@@ -35,7 +35,7 @@ export class DashboardService {
upcomingTicket: upcomingBooking ? { upcomingTicket: upcomingBooking ? {
ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef, ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef,
from: upcomingBooking.schedule.originStation.name, to: upcomingBooking.schedule.destinationStation.name, from: upcomingBooking.schedule.originStation.name, to: upcomingBooking.schedule.destinationStation.name,
trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label, trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber,
departureAt: upcomingBooking.schedule.departureAt, departureAt: upcomingBooking.schedule.departureAt,
punctualityLabel: (upcomingBooking.schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME', punctualityLabel: (upcomingBooking.schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
} : null, } : null,

View File

@@ -44,7 +44,7 @@ export class FareEngineService {
if (!seatClass) throw new NotFoundException('Seat class not found'); if (!seatClass) throw new NotFoundException('Seat class not found');
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active'); if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
const ratePerKmMinor = seatClass.basePrice; const ratePerKmMinor = seatClass.baseFareMinor;
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor; const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
const adultCount = dto.adultCount ?? 1; const adultCount = dto.adultCount ?? 1;
@@ -129,7 +129,7 @@ export class FareEngineService {
) { ) {
const seatClasses = await this.prisma.seatClass.findMany({ const seatClasses = await this.prisma.seatClass.findMany({
where: { isActive: true }, where: { isActive: true },
orderBy: { basePrice: 'asc' }, orderBy: { baseFareMinor: 'asc' },
}); });
const results = await Promise.all( const results = await Promise.all(
@@ -172,7 +172,7 @@ export class FareEngineService {
if (schedule.routeId) { if (schedule.routeId) {
const seatClasses = await this.prisma.seatClass.findMany({ const seatClasses = await this.prisma.seatClass.findMany({
where: { isActive: true }, where: { isActive: true },
orderBy: { basePrice: 'asc' }, orderBy: { baseFareMinor: 'asc' },
}); });
const results = await Promise.all( const results = await Promise.all(
@@ -198,23 +198,25 @@ export class FareEngineService {
validFrom: { lte: now }, validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }], OR: [{ validUntil: null }, { validUntil: { gte: now } }],
}, },
include: { seatClass: true }, orderBy: [{ seatClass: { baseFareMinor: 'asc' } }],
orderBy: { seatClass: { basePrice: 'asc' } },
}); });
if (fareRules.length > 0) { if (fareRules.length > 0) {
const billingCurrency = resolveCurrencyFromNationality(nationality); const billingCurrency = resolveCurrencyFromNationality(nationality);
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
return fareRules.map(rule => ({ return fareRules.map(rule => {
seatClassId: rule.seatClassId, const seatClassId = rule.seatClassId;
seatClassName: rule.seatClass.name, return {
baseFareMinor: rule.baseFareMinor, seatClassId,
totalMinor: rule.baseFareMinor, seatClassName: 'Unknown',
billingCurrency, baseFareMinor: rule.baseFareMinor,
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), totalMinor: rule.baseFareMinor,
exchangeRate, billingCurrency,
source: 'FARE_RULE', totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
})); exchangeRate,
source: 'FARE_RULE',
};
});
} }
throw new BadRequestException( throw new BadRequestException(

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger';
import { FleetService } from './fleet.service'; import { FleetService } from './fleet.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto'; import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Fleet') @ApiTags('Fleet')
@@ -11,16 +11,128 @@ import { JwtGuard } from '../../common/jwt.guard';
export class FleetController { export class FleetController {
constructor(private service: FleetService) {} constructor(private service: FleetService) {}
// Coach Type Endpoints
@Get('coach-types')
@ApiOperation({ summary: 'List all coach types' })
@ApiResponse({ status: 200, description: 'Array of coach types' })
getCoachTypes() {
return this.service.getCoachTypes();
}
@Post('coach-types')
@ApiOperation({ summary: 'Create a coach type' })
@ApiBody({ type: CreateCoachTypeDto })
@ApiResponse({ status: 201, description: 'Coach type created' })
createCoachType(@Body() dto: CreateCoachTypeDto) {
return this.service.createCoachType(dto);
}
@Patch('coach-types/:id')
@ApiOperation({ summary: 'Update a coach type' })
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
@ApiBody({ type: UpdateCoachTypeDto })
@ApiResponse({ status: 200, description: 'Coach type updated' })
@ApiResponse({ status: 404, description: 'Coach type not found' })
updateCoachType(@Param('id') id: string, @Body() dto: UpdateCoachTypeDto) {
return this.service.updateCoachType(id, dto);
}
@Delete('coach-types/:id')
@ApiOperation({ summary: 'Delete a coach type' })
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
@ApiResponse({ status: 200, description: 'Coach type deleted' })
@ApiResponse({ status: 404, description: 'Coach type not found' })
deleteCoachType(@Param('id') id: string) {
return this.service.deleteCoachType(id);
}
// Class Endpoints
@Get('classes')
@ApiOperation({ summary: 'List all classes' })
@ApiQuery({ name: 'coachTypeId', required: false, description: 'Filter by coach type' })
@ApiResponse({ status: 200, description: 'Array of classes' })
getClasses(@Query('coachTypeId') coachTypeId?: string) {
return this.service.getClasses(coachTypeId);
}
@Post('classes')
@ApiOperation({ summary: 'Create a class' })
@ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' })
createClass(@Body() dto: CreateClassDto) {
return this.service.createClass(dto);
}
@Patch('classes/:id')
@ApiOperation({ summary: 'Update a class' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto })
@ApiResponse({ status: 200, description: 'Class updated' })
@ApiResponse({ status: 404, description: 'Class not found' })
updateClass(@Param('id') id: string, @Body() dto: UpdateClassDto) {
return this.service.updateClass(id, dto);
}
@Delete('classes/:id')
@ApiOperation({ summary: 'Delete a class' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiResponse({ status: 200, description: 'Class deleted' })
@ApiResponse({ status: 404, description: 'Class not found' })
deleteClass(@Param('id') id: string) {
return this.service.deleteClass(id);
}
// Seat Class Endpoints (DEPRECATED - use Classes endpoints instead)
@Get('seat-classes')
@ApiOperation({ summary: 'List all classes (DEPRECATED - use /fleet/classes)' })
@ApiQuery({ name: 'coachTypeId', required: false, description: 'Filter by coach type' })
@ApiResponse({ status: 200, description: 'Array of classes' })
getSeatClasses(@Query('coachTypeId') coachTypeId?: string) {
return this.service.getClasses(coachTypeId);
}
@Post('seat-classes')
@ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' })
@ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' })
createSeatClass(@Body() dto: CreateClassDto) {
return this.service.createClass(dto);
}
@Patch('seat-classes/:id')
@ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto })
@ApiResponse({ status: 200, description: 'Class updated' })
@ApiResponse({ status: 404, description: 'Class not found' })
updateSeatClass(@Param('id') id: string, @Body() dto: UpdateClassDto) {
return this.service.updateClass(id, dto);
}
@Delete('seat-classes/:id')
@ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiResponse({ status: 200, description: 'Class deleted' })
@ApiResponse({ status: 404, description: 'Class not found' })
deleteSeatClass(@Param('id') id: string) {
return this.service.deleteClass(id);
}
// Train Endpoints
@Get('trains') @Get('trains')
@ApiOperation({ summary: 'List all trains with their recent schedules' }) @ApiOperation({ summary: 'List all trains with their recent schedules' })
@ApiResponse({ status: 200, description: 'Array of trains each with up to 5 most recent schedules' }) @ApiResponse({ status: 200, description: 'Array of trains' })
getTrains() { return this.service.getTrains(); } getTrains() {
return this.service.getTrains();
}
@Post('trains') @Post('trains')
@ApiOperation({ summary: 'Create a train service' }) @ApiOperation({ summary: 'Create a train service' })
@ApiBody({ type: CreateTrainDto }) @ApiBody({ type: CreateTrainDto })
@ApiResponse({ status: 201, description: 'Train created' }) @ApiResponse({ status: 201, description: 'Train created' })
createTrain(@Body() dto: CreateTrainDto) { return this.service.createTrain(dto); } createTrain(@Body() dto: CreateTrainDto) {
return this.service.createTrain(dto);
}
@Patch('trains/:id') @Patch('trains/:id')
@ApiOperation({ summary: 'Update a train service' }) @ApiOperation({ summary: 'Update a train service' })
@@ -28,96 +140,95 @@ export class FleetController {
@ApiBody({ type: CreateTrainDto }) @ApiBody({ type: CreateTrainDto })
@ApiResponse({ status: 200, description: 'Train updated' }) @ApiResponse({ status: 200, description: 'Train updated' })
@ApiResponse({ status: 404, description: 'Train not found' }) @ApiResponse({ status: 404, description: 'Train not found' })
updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) { return this.service.updateTrain(id, dto); } updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) {
return this.service.updateTrain(id, dto);
@Get('coaches')
@ApiOperation({ summary: 'List coaches filtered by status, mode, seat class, or schedule assignment' })
@ApiQuery({ name: 'isActive', required: false, type: Boolean, description: 'true = active only, false = inactive only, omit = all' })
@ApiQuery({ name: 'mode', required: false, description: 'Filter by mode: seat | bed | convertible' })
@ApiQuery({ name: 'seatClassId', required: false, description: 'Filter by SeatClass UUID' })
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter to coaches assigned to this TrainSchedule UUID' })
@ApiResponse({ status: 200, description: 'Coaches with seat class info, assignment count, and seat status summary (total/available/held/booked/blocked)' })
listCoaches(
@Query('isActive') isActive?: string,
@Query('mode') mode?: string,
@Query('seatClassId') seatClassId?: string,
@Query('scheduleId') scheduleId?: string,
) {
const dto: ListCoachesDto = {
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
mode,
seatClassId,
scheduleId,
};
return this.service.listCoaches(dto);
} }
@Get('coaches/:id')
@ApiOperation({ summary: 'Get a single coach with full seat layout and arrangement' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiResponse({
status: 200,
description: `Coach detail including:
- seatClass: seat class info
- seatsByRow: seats grouped by row number, each seat includes label, seatNumber, col, kind (STANDARD/PREMIUM/ACCESSIBLE), status (AVAILABLE/HELD/BOOKED/BLOCKED), isWindow, isAisle, bedPosition (bed mode only), premiumFeeMinor
- seatStatusSummary: total/available/held/booked/blocked counts
- assignments: up to 5 most recent schedule assignments with origin/destination`,
})
@ApiResponse({ status: 404, description: 'Coach not found' })
getCoach(@Param('id') id: string) { return this.service.getCoach(id); }
@Post('coaches')
@ApiOperation({ summary: 'Register a new physical coach and auto-generate its seats from arrangement config' })
@ApiBody({ type: CreateCoachDto })
@ApiResponse({ status: 201, description: 'Coach created with seats auto-generated from mode + arrangement + totalUnits' })
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
createCoach(@Body() dto: CreateCoachDto) { return this.service.createCoach(dto); }
@Patch('coaches/:id')
@ApiOperation({ summary: 'Update coach properties (label, mode, arrangement, etc.)' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiBody({ type: UpdateCoachDto })
@ApiResponse({ status: 200, description: 'Coach updated' })
@ApiResponse({ status: 404, description: 'Coach not found' })
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) { return this.service.updateCoach(id, dto); }
@Delete('trains/:id') @Delete('trains/:id')
@ApiOperation({ summary: 'Delete a train service' }) @ApiOperation({ summary: 'Delete a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' }) @ApiParam({ name: 'id', description: 'Train UUID' })
@ApiResponse({ status: 200, description: 'Train deleted' }) @ApiResponse({ status: 200, description: 'Train deleted' })
@ApiResponse({ status: 404, description: 'Train not found' }) @ApiResponse({ status: 404, description: 'Train not found' })
deleteTrain(@Param('id') id: string) { return this.service.deleteTrain(id); } deleteTrain(@Param('id') id: string) {
return this.service.deleteTrain(id);
}
// Coach Endpoints
@Get('coaches')
@ApiOperation({ summary: 'List coaches with seat status summary' })
@ApiQuery({ name: 'status', required: false, description: 'Filter by status: ACTIVE, INACTIVE' })
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter coaches assigned to schedule' })
@ApiResponse({ status: 200, description: 'Array of coaches' })
listCoaches(
@Query('status') status?: string,
@Query('scheduleId') scheduleId?: string,
) {
const dto: ListCoachesDto = {
status,
scheduleId,
};
return this.service.listCoaches(dto);
}
@Get('coaches/:id')
@ApiOperation({ summary: 'Get single coach with seat layout' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiResponse({ status: 200, description: 'Coach detail with seats by row' })
@ApiResponse({ status: 404, description: 'Coach not found' })
getCoach(@Param('id') id: string) {
return this.service.getCoach(id);
}
@Post('coaches')
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
@ApiBody({ type: CreateCoachDto })
@ApiResponse({ status: 201, description: 'Coach created' })
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
createCoach(@Body() dto: CreateCoachDto) {
return this.service.createCoach(dto);
}
@Patch('coaches/:id')
@ApiOperation({ summary: 'Update coach properties' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiBody({ type: UpdateCoachDto })
@ApiResponse({ status: 200, description: 'Coach updated' })
@ApiResponse({ status: 404, description: 'Coach not found' })
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) {
return this.service.updateCoach(id, dto);
}
@Delete('coaches/:id') @Delete('coaches/:id')
@ApiOperation({ summary: 'Delete a coach' }) @ApiOperation({ summary: 'Delete a coach' })
@ApiParam({ name: 'id', description: 'Coach UUID' }) @ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiResponse({ status: 200, description: 'Coach deleted' }) @ApiResponse({ status: 200, description: 'Coach deleted' })
@ApiResponse({ status: 404, description: 'Coach not found' }) @ApiResponse({ status: 404, description: 'Coach not found' })
deleteCoach(@Param('id') id: string) { return this.service.deleteCoach(id); } deleteCoach(@Param('id') id: string) {
return this.service.deleteCoach(id);
}
@Post('assignments') @Post('assignments')
@ApiOperation({ summary: 'Assign a physical coach to a train schedule at a given position' }) @ApiOperation({ summary: 'Assign a coach to a schedule' })
@ApiBody({ type: AssignCoachDto }) @ApiBody({ type: AssignCoachDto })
@ApiResponse({ status: 201, description: 'CoachAssignment created' }) @ApiResponse({ status: 201, description: 'Coach assigned' })
@ApiResponse({ status: 404, description: 'Schedule or coach not found' }) @ApiResponse({ status: 404, description: 'Schedule or coach not found' })
assignCoach(@Body() dto: AssignCoachDto) { return this.service.assignCoach(dto); } assignCoach(@Body() dto: AssignCoachDto) {
return this.service.assignCoach(dto);
}
@Delete('assignments/:id') @Delete('assignments/:id')
@ApiOperation({ summary: 'Remove a coach assignment from a schedule' }) @ApiOperation({ summary: 'Remove a coach assignment' })
@ApiParam({ name: 'id', description: 'CoachAssignment UUID' }) @ApiParam({ name: 'id', description: 'Assignment UUID' })
@ApiResponse({ status: 200, description: 'Assignment removed' }) @ApiResponse({ status: 200, description: 'Assignment removed' })
@ApiResponse({ status: 404, description: 'Assignment not found' }) @ApiResponse({ status: 404, description: 'Assignment not found' })
removeAssignment(@Param('id') id: string) { return this.service.removeAssignment(id); } removeAssignment(@Param('id') id: string) {
return this.service.removeAssignment(id);
@Post('seats/batch') }
@ApiOperation({ summary: 'Batch-generate seats for a coach (rows × cols)' })
@ApiBody({ type: CreateSeatBatchDto })
@ApiResponse({ status: 201, description: 'Returns count of seats created' })
@ApiResponse({ status: 404, description: 'Coach not found' })
createSeatBatch(@Body() dto: CreateSeatBatchDto) { return this.service.createSeatBatch(dto); }
@Get('analytics') @Get('analytics')
@ApiOperation({ summary: 'Fleet analytics: train count, schedule count, seat occupancy rate' }) @ApiOperation({ summary: 'Fleet analytics and occupancy metrics' })
@ApiResponse({ status: 200, description: 'Returns totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate' }) @ApiResponse({ status: 200, description: 'Occupancy statistics' })
getAnalytics() { return this.service.getAnalytics(); } getAnalytics() {
return this.service.getAnalytics();
}
} }

View File

@@ -10,41 +10,48 @@ export class CreateTrainDto {
} }
export class CreateCoachDto { export class CreateCoachDto {
@ApiProperty({ example: 'C-A1', description: 'Unique physical coach identifier' }) @IsString() coachNumber: string; @ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string;
@ApiProperty({ example: 'A', description: 'Display label shown on tickets' }) @IsString() label: string; @ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string;
@ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID this coach belongs to' }) @IsString() seatClassId: string; @ApiProperty({ example: '2+2', description: 'Seat arrangement (e.g., "2+2", "3+2")' }) @IsString() arrangement: string;
@ApiPropertyOptional({ example: 'sleeper', description: 'Coach type descriptor' }) @IsOptional() @IsString() coachType?: string; @ApiProperty({ example: 60, description: 'Total seat capacity' }) @IsInt() capacity: number;
@ApiPropertyOptional({ example: 'seat', description: 'seat | bed | convertible. Determines which arrangement field is used for seat generation.' }) @IsOptional() @IsString() mode?: string; @ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string;
@ApiPropertyOptional({ example: '2+2', description: 'Seat arrangement for seat/convertible mode. Format: groups separated by +, e.g. "2+2" (4 cols: A/B aisle C/D) or "1+2+1". Used to derive columns, window and aisle flags. Required when mode=seat and totalUnits>0.' }) @IsOptional() @IsString() seatArrangement?: string;
@ApiPropertyOptional({ example: '2+2', description: 'Bed arrangement for bed mode. First number = tiers per berth: 2 → lower/upper, 3 → lower/middle/upper. E.g. "2+2" = 2-tier berths. Required when mode=bed and totalUnits>0.' }) @IsOptional() @IsString() bedArrangement?: string;
@ApiPropertyOptional({ example: 60, description: 'Total seat/bed units. When >0, seats are auto-generated from the arrangement on coach creation.' }) @IsOptional() @IsInt() totalUnits?: number;
} }
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['coachNumber'] as const)) {} export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {}
export class AssignCoachDto { export class AssignCoachDto {
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) @IsString() scheduleId: string; @ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) @IsString() scheduleId: string;
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string; @ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string;
@ApiProperty({ example: 1, description: 'Position in the train consist (1 = first coach)' }) @IsInt() positionNumber: number; @ApiProperty({ example: 1, description: 'Position in the train consist' }) @IsInt() positionNumber: number;
@ApiPropertyOptional({ example: true, description: 'Whether this coach is operational for this schedule' }) @IsOptional() @IsBoolean() isOperational?: boolean; @ApiPropertyOptional({ example: true, description: 'Whether this coach is operational' }) @IsOptional() @IsBoolean() isOperational?: boolean;
}
export class CreateSeatBatchDto {
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID to generate seats for' }) @IsString() coachId: string;
@ApiProperty({ example: 15, description: 'Number of rows to generate' }) @IsInt() rows: number;
@ApiProperty({ example: ['A', 'B', 'C', 'D'], type: [String], description: 'Column labels per row' }) @IsArray() @IsString({ each: true }) cols: string[];
} }
export class ListCoachesDto { export class ListCoachesDto {
@ApiPropertyOptional({ example: true, description: 'Filter by active/inactive status. Omit to return all.' }) @ApiPropertyOptional({ example: 'ACTIVE', description: 'Filter by status: ACTIVE, INACTIVE' })
@IsOptional() @IsBoolean() isActive?: boolean; @IsOptional() @IsString() status?: string;
@ApiPropertyOptional({ example: 'seat', description: 'Filter by mode: seat | bed | convertible' }) @ApiPropertyOptional({ example: 'schedule-uuid', description: 'Filter coaches assigned to this schedule' })
@IsOptional() @IsString() mode?: string;
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'Filter by SeatClass UUID' })
@IsOptional() @IsString() seatClassId?: string;
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Filter to coaches assigned to this TrainSchedule UUID' })
@IsOptional() @IsString() scheduleId?: string; @IsOptional() @IsString() scheduleId?: string;
} }
// Legacy DTO types for backward compatibility
export class CreateCoachTypeDto {
@ApiProperty({ example: 'sleeper' }) @IsString() code: string;
@ApiProperty({ example: 'Sleeper Coach' }) @IsString() name: string;
@IsOptional() @IsString() type?: string;
}
export class UpdateCoachTypeDto {
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() code?: string;
@ApiPropertyOptional({ example: 'Sleeper Coach' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() type?: string;
}
export class CreateClassDto {
@ApiProperty({ example: 'coach-type-uuid' }) @IsString() coachTypeId: string;
@ApiProperty({ example: 'Economy' }) @IsString() name: string;
@IsOptional() @IsString() description?: string;
@ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number;
}
export class UpdateClassDto extends PartialType(OmitType(CreateClassDto, ['coachTypeId'] as const)) {}

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto'; import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto';
import { SeatKind } from '@prisma/client'; import { SeatKind } from '@prisma/client';
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2] // Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
@@ -8,22 +8,20 @@ function parseArrangement(arrangement: string): number[] {
return arrangement.split('+').map((n) => parseInt(n, 10)); return arrangement.split('+').map((n) => parseInt(n, 10));
} }
// Derives column labels from a seat-mode arrangement string. // Derives column labels from arrangement: '2+2' → ['A','B','C','D']
// '2+2' → ['A','B','C','D'] (A/D window, B/C aisle)
// '1+2+1' → ['A','B','C','D']
function seatCols(arrangement: string): string[] { function seatCols(arrangement: string): string[] {
const groups = parseArrangement(arrangement); const groups = parseArrangement(arrangement);
const total = groups.reduce((s, n) => s + n, 0); const total = groups.reduce((s, n) => s + n, 0);
return Array.from({ length: total }, (_, i) => String.fromCharCode(65 + i)); // A, B, C … return Array.from({ length: total }, (_, i) => String.fromCharCode(65 + i));
} }
// Returns true if the column index is a window seat given the arrangement groups. // Returns true if column is a window seat
function isWindowCol(colIndex: number, groups: number[]): boolean { function isWindowCol(colIndex: number, groups: number[]): boolean {
const total = groups.reduce((s, n) => s + n, 0); const total = groups.reduce((s, n) => s + n, 0);
return colIndex === 0 || colIndex === total - 1; return colIndex === 0 || colIndex === total - 1;
} }
// Returns true if the column index is an aisle seat. // Returns true if column is an aisle seat
function isAisleCol(colIndex: number, groups: number[]): boolean { function isAisleCol(colIndex: number, groups: number[]): boolean {
let cursor = 0; let cursor = 0;
for (const g of groups) { for (const g of groups) {
@@ -35,82 +33,180 @@ function isAisleCol(colIndex: number, groups: number[]): boolean {
return false; return false;
} }
// Bed positions for a given tier count: 2 → lower/upper, 3 → lower/middle/upper function buildSeats(coachId: string, coachNumber: string, arrangement: string, capacity: number, seatClass?: string): SeatRow[] {
const BED_POSITIONS: Record<number, string[]> = {
2: ['lower', 'upper'],
3: ['lower', 'middle', 'upper'],
};
type SeatRow = {
coachId: string;
row: number;
col: string;
label: string;
seatNumber: string;
kind: SeatKind;
isWindow: boolean;
isAisle: boolean;
bedPosition?: string;
};
function buildSeatSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] {
const cols = seatCols(arrangement); const cols = seatCols(arrangement);
const groups = parseArrangement(arrangement); const groups = parseArrangement(arrangement);
const seats: SeatRow[] = []; const seats: SeatRow[] = [];
let row = 1; let row = 1;
while (seats.length < totalUnits) { let seatNumber = 1;
for (let ci = 0; ci < cols.length && seats.length < totalUnits; ci++) { let seatIndex = 0;
const isBedCoach = seatClass?.toLowerCase().includes('bed');
const totalCols = cols.length;
while (seatIndex < capacity) {
for (let ci = 0; ci < cols.length && seatIndex < capacity; ci++) {
const col = cols[ci]; const col = cols[ci];
let bedPosition = null;
// Set bedPosition for bed coaches based on seat number cycling
if (isBedCoach) {
if (totalCols === 3) {
// Economy bed (3 levels): 1L, 2M, 3U, 4L, 5M, 6U...
const posMod = ((seatNumber - 1) % 3);
if (posMod === 0) bedPosition = 'lower';
else if (posMod === 1) bedPosition = 'middle';
else if (posMod === 2) bedPosition = 'upper';
} else if (totalCols === 2) {
// VIP bed (2 levels): 1L, 2U, 3L, 4U...
const posMod = ((seatNumber - 1) % 2);
if (posMod === 0) bedPosition = 'lower';
else if (posMod === 1) bedPosition = 'upper';
}
}
seats.push({ seats.push({
coachId, row, col, coachId,
label: `${row}${col}`, row,
seatNumber: `${coachLabel}${row}${col}`, col,
seatNumber: `${seatNumber}`,
kind: SeatKind.STANDARD, kind: SeatKind.STANDARD,
isWindow: isWindowCol(ci, groups), bedPosition,
isAisle: isAisleCol(ci, groups),
}); });
seatNumber++;
seatIndex++;
} }
row++; row++;
} }
return seats; return seats;
} }
function buildBedSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] { type SeatRow = {
// arrangement for beds describes tiers per berth, e.g. '2+2' = 2 lower+upper on each side coachId: string;
// Each compartment number is the row; each tier is the col (L=lower, M=middle, U=upper) row: number;
const groups = parseArrangement(arrangement); col: string;
const tiersPerSide = groups[0]; // e.g. 2 → lower+upper seatNumber: string;
const positions = BED_POSITIONS[tiersPerSide] ?? ['lower', 'upper']; kind: SeatKind;
const tierCols = positions.map((_, i) => String.fromCharCode(65 + i)); // A=lower, B=upper, C=middle bedPosition?: string | null;
const seats: SeatRow[] = []; };
let compartment = 1;
while (seats.length < totalUnits) {
for (let ti = 0; ti < tierCols.length && seats.length < totalUnits; ti++) {
const col = tierCols[ti];
seats.push({
coachId, row: compartment, col,
label: `${compartment}${col}`,
seatNumber: `${coachLabel}${compartment}${col}`,
kind: SeatKind.STANDARD,
isWindow: false,
isAisle: false,
bedPosition: positions[ti],
});
}
compartment++;
}
return seats;
}
@Injectable() @Injectable()
export class FleetService { export class FleetService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async createCoachType(dto: CreateCoachTypeDto) {
return this.prisma.coachType.create({
data: {
code: dto.code,
name: dto.name,
type: dto.type || 'passenger',
},
include: {
seatClasses: true,
coaches: true,
},
});
}
async getCoachTypes() {
return this.prisma.coachType.findMany({
include: {
seatClasses: true,
coaches: true,
},
orderBy: { createdAt: 'desc' },
});
}
async updateCoachType(id: string, dto: UpdateCoachTypeDto) {
const coachType = await this.prisma.coachType.findUnique({ where: { id } });
if (!coachType) throw new NotFoundException('Coach type not found');
const data: any = {};
if (dto.code !== undefined) data.code = dto.code;
if (dto.name !== undefined) data.name = dto.name;
if (dto.type !== undefined) data.type = dto.type;
return this.prisma.coachType.update({
where: { id },
data,
include: {
seatClasses: true,
coaches: true,
},
});
}
async deleteCoachType(id: string) {
const coachType = await this.prisma.coachType.findUnique({ where: { id } });
if (!coachType) throw new NotFoundException('Coach type not found');
return this.prisma.coachType.delete({ where: { id } });
}
async createClass(dto: CreateClassDto) {
return this.prisma.seatClass.create({
data: {
coachTypeId: dto.coachTypeId,
name: dto.name,
description: dto.description,
baseFareMinor: dto.baseFareMinor,
},
});
}
async getClasses(coachTypeId?: string) {
const where = coachTypeId ? { coachTypeId } : {};
return this.prisma.seatClass.findMany({
where,
include: { coachType: true },
orderBy: { createdAt: 'desc' },
});
}
async updateClass(id: string, dto: UpdateClassDto) {
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
if (!seatClass) throw new NotFoundException('Seat class not found');
return this.prisma.seatClass.update({
where: { id },
data: {
name: dto.name,
description: dto.description,
baseFareMinor: dto.baseFareMinor,
},
});
}
async deleteClass(id: string) {
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
if (!seatClass) throw new NotFoundException('Seat class not found');
return this.prisma.seatClass.delete({ where: { id } });
}
createSeatClass(dto: CreateClassDto) {
return this.createClass(dto);
}
getSeatClasses(coachTypeId?: string) {
return this.getClasses(coachTypeId);
}
async updateSeatClass(id: string, dto: UpdateClassDto) {
return this.updateClass(id, dto);
}
async deleteSeatClass(id: string) {
return this.deleteClass(id);
}
getTrains() { getTrains() {
return this.prisma.train.findMany({ include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } } }); return this.prisma.train.findMany({ include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } } });
} }
createTrain(dto: CreateTrainDto) { return this.prisma.train.create({ data: dto }); } createTrain(dto: CreateTrainDto) {
return this.prisma.train.create({ data: dto });
}
async updateTrain(id: string, dto: CreateTrainDto) { async updateTrain(id: string, dto: CreateTrainDto) {
const train = await this.prisma.train.findUnique({ where: { id } }); const train = await this.prisma.train.findUnique({ where: { id } });
@@ -118,120 +214,112 @@ export class FleetService {
return this.prisma.train.update({ where: { id }, data: dto }); return this.prisma.train.update({ where: { id }, data: dto });
} }
async getCoach(id: string) {
const coach = await this.prisma.coach.findUnique({
where: { id },
include: {
seatClass: true,
seats: {
orderBy: [{ row: 'asc' }, { col: 'asc' }],
},
assignments: {
include: { schedule: { include: { originStation: true, destinationStation: true } } },
orderBy: { schedule: { departureAt: 'desc' } },
take: 5,
},
_count: { select: { seats: true, assignments: true } },
},
});
if (!coach) throw new NotFoundException('Coach not found');
// Group seats by row to reflect the physical arrangement layout
const rowMap = new Map<number, typeof coach.seats>();
for (const seat of coach.seats) {
if (!rowMap.has(seat.row)) rowMap.set(seat.row, []);
rowMap.get(seat.row)!.push(seat);
}
const seatsByRow = Array.from(rowMap.entries()).map(([row, seats]) => ({ row, seats }));
const seatStatusSummary = {
total: coach.seats.length,
available: coach.seats.filter(s => s.status === 'AVAILABLE').length,
held: coach.seats.filter(s => s.status === 'HELD').length,
booked: coach.seats.filter(s => s.status === 'BOOKED').length,
blocked: coach.seats.filter(s => s.status === 'BLOCKED').length,
};
const { seats, ...coachData } = coach;
return { ...coachData, seatsByRow, seatStatusSummary };
}
async listCoaches(dto: ListCoachesDto) {
const where: any = {};
if (dto.isActive !== undefined) where.isActive = dto.isActive;
if (dto.mode) where.mode = dto.mode;
if (dto.seatClassId) where.seatClassId = dto.seatClassId;
if (dto.scheduleId) where.assignments = { some: { scheduleId: dto.scheduleId } };
const coaches = await this.prisma.coach.findMany({
where,
include: {
seatClass: true,
seats: { select: { status: true } },
_count: { select: { seats: true, assignments: true } },
},
orderBy: [{ isActive: 'desc' }, { label: 'asc' }],
});
return coaches.map(({ seats, ...coach }) => ({
...coach,
seatStatusSummary: {
total: seats.length,
available: seats.filter(s => s.status === 'AVAILABLE').length,
held: seats.filter(s => s.status === 'HELD').length,
booked: seats.filter(s => s.status === 'BOOKED').length,
blocked: seats.filter(s => s.status === 'BLOCKED').length,
},
}));
}
async createCoach(dto: CreateCoachDto) {
const mode = dto.mode ?? 'seat';
const totalUnits = dto.totalUnits ?? 0;
const isBed = mode === 'bed';
const arrangement = isBed
? (dto.bedArrangement ?? dto.seatArrangement ?? '2+2')
: (dto.seatArrangement ?? '2+2');
if (totalUnits > 0) {
const groups = parseArrangement(arrangement);
if (groups.some(isNaN)) {
throw new BadRequestException(`Invalid arrangement format "${arrangement}". Use e.g. "2+2" or "2+2+2"`);
}
}
const coach = await this.prisma.coach.create({ data: dto });
if (totalUnits > 0) {
const seats = isBed
? buildBedSeats(coach.id, coach.label, arrangement, totalUnits)
: buildSeatSeats(coach.id, coach.label, arrangement, totalUnits);
await this.prisma.seat.createMany({ data: seats, skipDuplicates: true });
}
return this.prisma.coach.findUnique({
where: { id: coach.id },
include: { seatClass: true, _count: { select: { seats: true } } },
});
}
async updateCoach(id: string, dto: UpdateCoachDto) {
const coach = await this.prisma.coach.findUnique({ where: { id } });
if (!coach) throw new NotFoundException('Coach not found');
return this.prisma.coach.update({ where: { id }, data: dto });
}
async deleteTrain(id: string) { async deleteTrain(id: string) {
const train = await this.prisma.train.findUnique({ where: { id } }); const train = await this.prisma.train.findUnique({ where: { id } });
if (!train) throw new NotFoundException('Train not found'); if (!train) throw new NotFoundException('Train not found');
return this.prisma.train.delete({ where: { id } }); return this.prisma.train.delete({ where: { id } });
} }
async getCoach(id: string) {
const coach = await this.prisma.coach.findUnique({
where: { id },
include: {
coachType: true,
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
assignments: {
include: { schedule: { include: { originStation: true, destinationStation: true } } },
orderBy: { schedule: { departureAt: 'desc' } },
take: 5,
},
},
});
if (!coach) throw new NotFoundException('Coach not found');
return coach;
}
async listCoaches(dto: ListCoachesDto) {
const where: any = {};
if (dto.status) where.status = dto.status;
if (dto.scheduleId) where.assignments = { some: { scheduleId: dto.scheduleId } };
return this.prisma.coach.findMany({
where,
include: { coachType: true },
orderBy: { number: 'asc' },
});
}
async createCoach(dto: CreateCoachDto) {
const groups = parseArrangement(dto.arrangement);
if (groups.some(isNaN)) {
throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`);
}
const coach = await this.prisma.coach.create({
data: {
coachTypeId: dto.coachTypeId,
number: dto.number,
arrangement: dto.arrangement,
capacity: dto.capacity,
status: dto.status || 'ACTIVE',
},
include: { coachType: true },
});
if (dto.capacity > 0) {
const seatClass = coach.coachType?.name || '';
const seats = buildSeats(coach.id, coach.number, dto.arrangement, dto.capacity, seatClass);
await this.prisma.seat.createMany({ data: seats });
}
return coach;
}
async updateCoach(id: string, dto: UpdateCoachDto) {
const coach = await this.prisma.coach.findUnique({ where: { id } });
if (!coach) throw new NotFoundException('Coach not found');
return this.prisma.coach.update({
where: { id },
data: {
arrangement: dto.arrangement,
capacity: dto.capacity,
status: dto.status,
},
include: { coachType: true },
});
}
async deleteCoach(id: string) { async deleteCoach(id: string) {
const coach = await this.prisma.coach.findUnique({ where: { id } }); const coach = await this.prisma.coach.findUnique({ where: { id } });
if (!coach) throw new NotFoundException('Coach not found'); if (!coach) throw new NotFoundException('Coach not found');
// Get all seat IDs for this coach
const seats = await this.prisma.seat.findMany({ where: { coachId: id }, select: { id: true } });
const seatIds = seats.map(s => s.id);
// Delete in order of foreign key dependencies
if (seatIds.length > 0) {
// 1. Delete seat blocks (references seats)
await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } });
// 2. Delete ticket seats (references seats)
await this.prisma.ticketSeat.deleteMany({ where: { seatId: { in: seatIds } } });
// 3. Delete booking seats (references seats)
await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } });
// 4. Delete journey segments with these seats
await this.prisma.journeySegment.deleteMany({ where: { seatId: { in: seatIds } } });
}
// 5. Delete all associated seats
await this.prisma.seat.deleteMany({ where: { coachId: id } });
// 6. Delete coach assignments
await this.prisma.coachAssignment.deleteMany({ where: { coachId: id } });
// 7. Finally delete the coach
return this.prisma.coach.delete({ where: { id } }); return this.prisma.coach.delete({ where: { id } });
} }
@@ -251,19 +339,6 @@ export class FleetService {
return this.prisma.coachAssignment.delete({ where: { id } }); return this.prisma.coachAssignment.delete({ where: { id } });
} }
async createSeatBatch(dto: CreateSeatBatchDto) {
const coach = await this.prisma.coach.findUnique({ where: { id: dto.coachId } });
if (!coach) throw new NotFoundException('Coach not found');
const seats = [];
for (let row = 1; row <= dto.rows; row++) {
for (const col of dto.cols) {
seats.push({ coachId: dto.coachId, row, col, label: `${row}${col}`, seatNumber: `${coach.label}${row}${col}` });
}
}
await this.prisma.seat.createMany({ data: seats, skipDuplicates: true });
return { created: seats.length };
}
async getAnalytics() { async getAnalytics() {
const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([ const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([
this.prisma.train.count(), this.prisma.train.count(),
@@ -271,6 +346,12 @@ export class FleetService {
this.prisma.seat.count(), this.prisma.seat.count(),
this.prisma.seat.count({ where: { status: 'BOOKED' } }), this.prisma.seat.count({ where: { status: 'BOOKED' } }),
]); ]);
return { totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0 }; return {
totalTrains,
totalSchedules,
totalSeats,
bookedSeats,
occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0,
};
} }
} }

View File

@@ -92,22 +92,22 @@ export class PassengersService {
} }
async getProfile(passengerId: string) { async getProfile(passengerId: string) {
const p = await this.prisma.passenger.findUnique({ const passenger = await this.prisma.passenger.findUnique({
where: { id: passengerId }, where: { id: passengerId },
include: { include: {
user: { select: { fullName: true, email: true, phone: true } }, user: { select: { fullName: true, email: true, phone: true } },
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } } } }, bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } } },
loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true, loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true,
}, },
}); });
if (!p) throw new NotFoundException('Passenger not found'); if (!passenger) throw new NotFoundException('Passenger not found');
return { return {
id: p.id, id: passenger.id,
fullName: p.user.fullName, fullName: passenger.user.fullName,
email: p.user.email, email: passenger.user.email,
phone: p.user.phone, phone: passenger.user.phone,
createdAt: p.createdAt, createdAt: passenger.createdAt,
bookings: p.bookings.map((b) => ({ bookings: passenger.bookings.map((b) => ({
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt, id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,
trip: { trip: {
number: b.schedule.train.number, number: b.schedule.train.number,
@@ -115,7 +115,7 @@ export class PassengersService {
destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city }, destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city },
departureAt: b.schedule.departureAt, departureAt: b.schedule.departureAt,
}, },
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass?.name ?? 'N/A' } })), passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' } })),
})), })),
}; };
} }
@@ -207,7 +207,6 @@ export class PassengersService {
const isLoggedIn = !!dto.userId; const isLoggedIn = !!dto.userId;
let verifiedData: any = null; let verifiedData: any = null;
// Auto-verify Ethiopian passengers with national ID if Fayda is enabled
if (isEthiopian && dto.verifyWithFayda !== false) { if (isEthiopian && dto.verifyWithFayda !== false) {
try { try {
const verification = await this.verifaydaService.verifyNationalId(dto.nationalId!); const verification = await this.verifaydaService.verifyNationalId(dto.nationalId!);
@@ -215,12 +214,10 @@ export class PassengersService {
verifiedData = verification.passengerData; verifiedData = verification.passengerData;
} }
} catch (error) { } catch (error) {
// If verification fails, continue with manual data
console.warn('Fayda verification failed, using manual data:', error); console.warn('Fayda verification failed, using manual data:', error);
} }
} }
// Use verified data if available, otherwise use provided data
const finalData = { const finalData = {
passengerName: verifiedData?.fullName || dto.passengerName, passengerName: verifiedData?.fullName || dto.passengerName,
dateOfBirth: verifiedData?.dateOfBirth || new Date(dto.dateOfBirth), dateOfBirth: verifiedData?.dateOfBirth || new Date(dto.dateOfBirth),
@@ -230,7 +227,6 @@ export class PassengersService {
email: dto.email, email: dto.email,
}; };
// If logged in, update user profile and link passenger
if (isLoggedIn) { if (isLoggedIn) {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { id: dto.userId }, where: { id: dto.userId },
@@ -241,7 +237,6 @@ export class PassengersService {
throw new BadRequestException('User not found'); throw new BadRequestException('User not found');
} }
// Update user record if not already verified
if (!user.faydaVerified && verifiedData) { if (!user.faydaVerified && verifiedData) {
await this.prisma.user.update({ await this.prisma.user.update({
where: { id: dto.userId }, where: { id: dto.userId },
@@ -267,7 +262,6 @@ export class PassengersService {
}; };
} }
// Guest user - save to SavedPassengerProfile
const profile = await this.prisma.savedPassengerProfile.create({ const profile = await this.prisma.savedPassengerProfile.create({
data: { data: {
deviceId: dto.deviceId, deviceId: dto.deviceId,
@@ -318,4 +312,4 @@ export class PassengersService {
affectedModules: usage, affectedModules: usage,
}; };
} }
} }

View File

@@ -40,19 +40,17 @@ describe('Payments E2E', () => {
data: { trainId: train.id, originStationId: station1.id, destinationStationId: station2.id, departureAt: new Date(Date.now() + 86400000), arrivalAt: new Date(Date.now() + 90000000), durationMinutes: 60 }, data: { trainId: train.id, originStationId: station1.id, destinationStationId: station2.id, departureAt: new Date(Date.now() + 86400000), arrivalAt: new Date(Date.now() + 90000000), durationMinutes: 60 },
}); });
const seatClass = await prisma.seatClass.upsert({ const coachType = await prisma.coachType.create({ data: { name: 'Standard', code: 'STD' } });
where: { name: 'Economy Regular' },
update: {}, const seatClass = await prisma.seatClass.create({
create: { name: 'Economy Regular', description: 'Standard economy seating', basePrice: 45000, isActive: true }, data: { name: 'Economy Regular', description: 'Standard economy seating', baseFareMinor: 45000, isActive: true, coachTypeId: coachType.id },
}); });
const coach = await prisma.coach.create({ const coach = await prisma.coach.create({
data: { coachNumber: 'TEST-C1', label: 'A', seatClassId: seatClass.id, mode: 'seat', totalUnits: 10 }, data: { coachTypeId: coachType.id, number: 'TEST-C1', arrangement: '2+2', capacity: 10, status: 'ACTIVE' },
}); });
await prisma.coachAssignment.create({ data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1 } }); const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', seatNumber: '1A', status: 'AVAILABLE' } });
const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', label: '1A', status: 'AVAILABLE' } });
const booking = await prisma.booking.create({ const booking = await prisma.booking.create({
data: { bookingRef: 'TEST-BOOK-001', passengerId: passenger.id, scheduleId: schedule.id, status: 'PENDING_PAYMENT', totalMinor: 50000, currency: 'ETB' }, data: { bookingRef: 'TEST-BOOK-001', passengerId: passenger.id, scheduleId: schedule.id, status: 'PENDING_PAYMENT', totalMinor: 50000, currency: 'ETB' },

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { SchedulesService } from './schedules.service'; import { SchedulesService } from './schedules.service';
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto'; import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto } from './schedules.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { TripStatus } from '@prisma/client'; import { TripStatus } from '@prisma/client';
@@ -10,14 +10,23 @@ import { TripStatus } from '@prisma/client';
export class SchedulesController { export class SchedulesController {
constructor(private service: SchedulesService) {} constructor(private service: SchedulesService) {}
@Post('bulk-generate')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Bulk generate repetitive schedules',
description: 'Creates multiple schedules automatically by repeating every X days for the next Y days. Example: repeat every 2 days for 30 days = 15 schedules.',
})
@ApiResponse({ status: 201, description: 'Schedules generated successfully' })
@ApiResponse({ status: 400, description: 'Invalid parameters or route not found' })
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
return this.service.bulkGenerateSchedules(dto);
}
@Post() @Post()
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ @ApiOperation({
summary: 'Create a train schedule from a route template', summary: 'Create a train schedule from a route template',
description: `Creates a schedule by referencing a Route (routeId). description: `Creates a schedule by referencing a Route (routeId).\nStops are automatically copied from the route's RouteStop definitions.\nYou supply the actual planned arrival/departure times per stop sequence.\nOrigin and destination are derived from the first and last route stop — no need to specify them manually.`,
Stops are automatically copied from the route's RouteStop definitions.
You supply the actual planned arrival/departure times per stop sequence.
Origin and destination are derived from the first and last route stop — no need to specify them manually.`,
}) })
@ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' }) @ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' })
@ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' }) @ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' })
@@ -40,13 +49,42 @@ Origin and destination are derived from the first and last route stop — no nee
return this.service.listSchedules({ date, routeId, trainId, status }); return this.service.listSchedules({ date, routeId, trainId, status });
} }
// Static routes before parameterised ones // ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
@Post('fares') @Post('fares')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' }) @ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
@ApiResponse({ status: 201, description: 'Fare rule created' }) @ApiResponse({ status: 201, description: 'Fare rule created' })
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); } createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
@Post('segment-fares')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create a segment fare rule (stop-to-stop pricing on a route)' })
@ApiResponse({ status: 201, description: 'Segment fare rule created' })
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
@Get('routes/:routeId/segment-fares')
@ApiOperation({ summary: 'List all segment fare rules for a route' })
@ApiParam({ name: 'routeId', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'List of segment fare rules' })
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
@Patch('segment-fares/:id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
@ApiResponse({ status: 200, description: 'Segment fare rule updated' })
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
@Delete('segment-fares/:id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
@ApiResponse({ status: 200, description: 'Segment fare rule deleted' })
deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); }
// ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) =====
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' }) @ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@@ -56,12 +94,12 @@ Origin and destination are derived from the first and last route stop — no nee
@Patch(':id') @Patch(':id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update a schedule' }) @ApiOperation({ summary: 'Update a schedule (partial update - times, status, coaches)' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiResponse({ status: 200, description: 'Schedule updated' }) @ApiResponse({ status: 200, description: 'Schedule updated' })
@ApiResponse({ status: 404, description: 'Schedule not found' }) @ApiResponse({ status: 404, description: 'Schedule not found' })
updateSchedule(@Param('id') id: string, @Body() dto: CreateScheduleDto) { updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
return this.service.updateSchedule(id, dto); return this.service.updateSchedulePartial(id, dto);
} }
@Patch(':id/status') @Patch(':id/status')
@@ -84,8 +122,6 @@ Origin and destination are derived from the first and last route stop — no nee
return this.service.deleteSchedule(id); return this.service.deleteSchedule(id);
} }
// ── Stop Times ─────────────────────────────────────────────────────────────
@Get(':id/stops') @Get(':id/stops')
@ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' }) @ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@@ -106,8 +142,6 @@ Origin and destination are derived from the first and last route stop — no nee
@Body() dto: UpdateStopTimeDto, @Body() dto: UpdateStopTimeDto,
) { return this.service.updateStop(id, sequence, dto); } ) { return this.service.updateStop(id, sequence, dto); }
// ── Fares ──────────────────────────────────────────────────────────────────
@Get(':scheduleId/fares') @Get(':scheduleId/fares')
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' }) @ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@@ -151,8 +185,6 @@ Origin and destination are derived from the first and last route stop — no nee
return this.service.syncFaresFromEngine(id); return this.service.syncFaresFromEngine(id);
} }
// ── Coach Assignments ──────────────────────────────────────────────────────
@Post(':id/coaches') @Post(':id/coaches')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ @ApiOperation({

View File

@@ -34,6 +34,13 @@ export class CreateScheduleDto {
plannedTimes: PlannedStopTimeDto[]; plannedTimes: PlannedStopTimeDto[];
} }
export class UpdateScheduleDto {
@ApiPropertyOptional({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsOptional() @IsDateString() departureAt?: string;
@ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string;
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
}
export class UpdateStopTimeDto { export class UpdateStopTimeDto {
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string; @ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string; @ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
@@ -50,6 +57,17 @@ export class CreateFareRuleDto {
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string; @ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
} }
export class CreateSegmentFareRuleDto {
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
@ApiProperty({ example: 1, description: 'Origin stop sequence number' }) @IsInt() @Min(1) originStopSequence: number;
@ApiProperty({ example: 2, description: 'Destination stop sequence number' }) @IsInt() @Min(1) destinationStopSequence: number;
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality scope (Ethiopian, Djiboutian, Other)' }) @IsOptional() @IsString() nationality?: string;
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
}
export class ListSchedulesDto { export class ListSchedulesDto {
@ApiPropertyOptional({ example: '2026-06-15', description: 'Filter by departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' }) @ApiPropertyOptional({ example: '2026-06-15', description: 'Filter by departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' })
@IsOptional() @IsDateString() date?: string; @IsOptional() @IsDateString() date?: string;
@@ -67,3 +85,21 @@ export class ListSchedulesDto {
export class UpdateScheduleStatusDto { export class UpdateScheduleStatusDto {
@ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus; @ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus;
} }
export class BulkCreateSchedulesDto {
@ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string;
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
@ApiProperty({ example: '2026-06-15T08:00:00Z', description: 'Start date and time for first schedule' }) @IsDateString() startDateTime: string;
@ApiProperty({ example: 12, description: 'Hours duration per schedule' }) @IsInt() @Min(1) durationHours: number;
@ApiProperty({ example: 2, description: 'Repeat every X days' }) @IsInt() @Min(1) repeatEveryDays: number;
@ApiProperty({ example: 30, description: 'Generate schedules for the next Y days' }) @IsInt() @Min(1) forNextDays: number;
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' })
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
}
export class BulkSchedulesResponseDto {
@ApiProperty() schedulesCreated: number;
@ApiProperty() errors: string[];
@ApiProperty() scheduleIds: string[];
}

View File

@@ -2,7 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { RoutesService } from './routes.service'; import { RoutesService } from './routes.service';
import { FareEngineService } from '../fare-engine/fare-engine.service'; import { FareEngineService } from '../fare-engine/fare-engine.service';
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto'; import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
@Injectable() @Injectable()
export class SchedulesService { export class SchedulesService {
@@ -10,9 +10,55 @@ export class SchedulesService {
private prisma: PrismaService, private prisma: PrismaService,
private routesService: RoutesService, private routesService: RoutesService,
private fareEngine: FareEngineService, private fareEngine: FareEngineService,
) {} ) { }
// ── Schedule CRUD ────────────────────────────────────────────────────────── async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
const startDate = new Date(dto.startDateTime);
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
const errors: string[] = [];
const scheduleIds: string[] = [];
// Validate route and get stops for plannedTimes generation
const route = await this.prisma.route.findUnique({
where: { id: dto.routeId },
include: { stops: { orderBy: { sequence: 'asc' } } },
});
if (!route) throw new NotFoundException('Route not found');
if (!route.active) throw new BadRequestException('Route is not active');
let currentDate = new Date(startDate);
let scheduleCount = 0;
while (currentDate < endDate) {
try {
const departureAt = new Date(currentDate);
const arrivalAt = new Date(departureAt.getTime() + dto.durationHours * 60 * 60 * 1000);
const createDto: CreateScheduleDto = {
trainId: dto.trainId,
routeId: dto.routeId,
departureAt: departureAt.toISOString(),
arrivalAt: arrivalAt.toISOString(),
plannedTimes: dto.plannedTimes || [],
};
const schedule = await this.createSchedule(createDto);
scheduleIds.push(schedule.id);
scheduleCount++;
} catch (error) {
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
}
// Move to next repetition
currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000);
}
return {
schedulesCreated: scheduleCount,
errors,
scheduleIds,
};
}
async listSchedules(dto: ListSchedulesDto) { async listSchedules(dto: ListSchedulesDto) {
const where: any = {}; const where: any = {};
@@ -58,28 +104,48 @@ export class SchedulesService {
if (!route.active) throw new BadRequestException('Route is not active'); if (!route.active) throw new BadRequestException('Route is not active');
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops'); if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
// Check for duplicate schedule with same train, route, and date
const depDate = new Date(dep);
depDate.setHours(0, 0, 0, 0);
const nextDay = new Date(depDate);
nextDay.setDate(nextDay.getDate() + 1);
const existingSchedule = await this.prisma.trainSchedule.findFirst({
where: {
trainId: dto.trainId,
routeId: dto.routeId,
departureAt: {
gte: depDate,
lt: nextDay,
},
},
});
if (existingSchedule) {
throw new BadRequestException(
`A schedule for this train, route, and date already exists. Departure: ${new Date(existingSchedule.departureAt).toLocaleString()}`,
);
}
// Auto-generate plannedTimes if not provided or empty // Auto-generate plannedTimes if not provided or empty
let plannedTimes = dto.plannedTimes; let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) { if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime(); const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => { plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date; let stopTime: Date;
if (index === 0) { if (index === 0) {
// First stop - use departure time
stopTime = dep; stopTime = dep;
} else if (index === route.stops.length - 1) { } else if (index === route.stops.length - 1) {
// Last stop - use arrival time
stopTime = arr; stopTime = arr;
} else { } else {
// Intermediate stops - calculate based on distance proportion
const stopDistance = stop.distanceKm || 0; const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress); stopTime = new Date(dep.getTime() + totalDuration * progress);
} }
return { return {
sequence: stop.sequence, sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
@@ -113,7 +179,6 @@ export class SchedulesService {
include: { train: true, originStation: true, destinationStation: true }, include: { train: true, originStation: true, destinationStation: true },
}); });
// Copy route stops into TripStopTime with the provided planned times
const plannedTimesMap = Object.fromEntries( const plannedTimesMap = Object.fromEntries(
plannedTimes.map(t => [t.sequence, t]), plannedTimes.map(t => [t.sequence, t]),
); );
@@ -130,7 +195,7 @@ export class SchedulesService {
originStation: true, originStation: true,
destinationStation: true, destinationStation: true,
coachAssignments: { coachAssignments: {
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } }, include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
orderBy: { positionNumber: 'asc' }, orderBy: { positionNumber: 'asc' },
}, },
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
@@ -138,18 +203,16 @@ export class SchedulesService {
}); });
if (!schedule) throw new NotFoundException('Schedule not found'); if (!schedule) throw new NotFoundException('Schedule not found');
// Compute effective seat statuses from SeatHold + JourneySegment const allSeatIds = schedule.coachAssignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
// (seat.status DB column is no longer written during booking)
const allSeatIds = schedule.coachAssignments.flatMap(a => a.coach.seats.map(s => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(id, allSeatIds); const effectiveStatuses = await this.resolveEffectiveStatuses(id, allSeatIds);
return { return {
...schedule, ...schedule,
coachAssignments: schedule.coachAssignments.map(a => ({ coachAssignments: schedule.coachAssignments.map((a: any) => ({
...a, ...a,
coach: { coach: {
...a.coach, ...a.coach,
seats: a.coach.seats.map(s => ({ seats: a.coach.seats.map((s: any) => ({
...s, ...s,
status: effectiveStatuses.get(s.id) ?? s.status, status: effectiveStatuses.get(s.id) ?? s.status,
})), })),
@@ -158,12 +221,6 @@ export class SchedulesService {
}; };
} }
/**
* Computes effective seat status for a schedule by checking active SeatHolds
* and confirmed JourneySegments. The DB seat.status column is not written
* during segment-based booking, so this overlay is required.
* Priority: BLOCKED (physical) > BOOKED (confirmed) > HELD (active hold) > AVAILABLE
*/
private async resolveEffectiveStatuses( private async resolveEffectiveStatuses(
scheduleId: string, scheduleId: string,
seatIds: string[], seatIds: string[],
@@ -204,7 +261,6 @@ export class SchedulesService {
const arr = new Date(dto.arrivalAt); const arr = new Date(dto.arrivalAt);
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt'); if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
// Validate route exists and has stops
const route = await this.prisma.route.findUnique({ const route = await this.prisma.route.findUnique({
where: { id: dto.routeId }, where: { id: dto.routeId },
include: { stops: { orderBy: { sequence: 'asc' } } }, include: { stops: { orderBy: { sequence: 'asc' } } },
@@ -213,7 +269,6 @@ export class SchedulesService {
if (!route.active) throw new BadRequestException('Route is not active'); if (!route.active) throw new BadRequestException('Route is not active');
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops'); if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
// Derive origin and destination from first and last route stop
const firstStop = route.stops[0]; const firstStop = route.stops[0];
const lastStop = route.stops[route.stops.length - 1]; const lastStop = route.stops[route.stops.length - 1];
@@ -231,18 +286,16 @@ export class SchedulesService {
}, },
}); });
// Delete existing stop times and recreate
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } }); await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
// Auto-generate plannedTimes if not provided
let plannedTimes = dto.plannedTimes; let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) { if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime(); const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => { plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date; let stopTime: Date;
if (index === 0) { if (index === 0) {
stopTime = dep; stopTime = dep;
} else if (index === route.stops.length - 1) { } else if (index === route.stops.length - 1) {
@@ -252,7 +305,7 @@ export class SchedulesService {
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress); stopTime = new Date(dep.getTime() + totalDuration * progress);
} }
return { return {
sequence: stop.sequence, sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
@@ -276,19 +329,53 @@ export class SchedulesService {
async deleteSchedule(id: string) { async deleteSchedule(id: string) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } }); const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
if (!schedule) throw new NotFoundException('Schedule not found'); if (!schedule) throw new NotFoundException('Schedule not found');
// Delete related records first (in dependency order)
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } }); await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } }); await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } });
const bookings = await this.prisma.booking.findMany({
where: { scheduleId: id },
select: { id: true },
});
const bookingIds = bookings.map(b => b.id);
if (bookingIds.length > 0) {
const paymentIntents = await this.prisma.paymentIntent.findMany({
where: { bookingId: { in: bookingIds } },
select: { id: true },
});
const paymentIntentIds = paymentIntents.map(pi => pi.id);
if (paymentIntentIds.length > 0) {
await this.prisma.paymentRefund.deleteMany({
where: { paymentIntentId: { in: paymentIntentIds } },
});
}
await this.prisma.ticket.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.bookingSeat.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.bookingModification.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.bookingCancellation.deleteMany({
where: { bookingId: { in: bookingIds } },
});
await this.prisma.paymentIntent.deleteMany({
where: { bookingId: { in: bookingIds } },
});
}
await this.prisma.booking.deleteMany({ where: { scheduleId: id } }); await this.prisma.booking.deleteMany({ where: { scheduleId: id } });
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } }); await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } }); await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
return this.prisma.trainSchedule.delete({ where: { id } }); return this.prisma.trainSchedule.delete({ where: { id } });
} }
// ── Stop Times (per-schedule overrides) ───────────────────────────────────
getStops(scheduleId: string) { getStops(scheduleId: string) {
return this.prisma.tripStopTime.findMany({ return this.prisma.tripStopTime.findMany({
where: { scheduleId }, where: { scheduleId },
@@ -314,8 +401,6 @@ export class SchedulesService {
}); });
} }
// ── Fare Rules ─────────────────────────────────────────────────────────────
createFareRule(dto: CreateFareRuleDto) { createFareRule(dto: CreateFareRuleDto) {
const { validFrom, validUntil, scheduleId, nationality, ...rest } = dto; const { validFrom, validUntil, scheduleId, nationality, ...rest } = dto;
return this.prisma.fareRule.create({ return this.prisma.fareRule.create({
@@ -329,6 +414,43 @@ export class SchedulesService {
}); });
} }
createSegmentFareRule(dto: any) {
const { validFrom, validUntil, ...rest } = dto;
return this.prisma.segmentFareRule.create({
data: {
...rest,
validFrom: new Date(validFrom),
validUntil: validUntil ? new Date(validUntil) : null,
},
include: { seatClass: true, route: true },
});
}
getSegmentFares(routeId: string) {
return this.prisma.segmentFareRule.findMany({
where: { routeId },
include: { seatClass: true, route: true },
orderBy: [{ originStopSequence: 'asc' }, { destinationStopSequence: 'asc' }],
});
}
deleteSegmentFareRule(id: string) {
return this.prisma.segmentFareRule.delete({ where: { id } });
}
updateSegmentFareRule(id: string, dto: any) {
const { validFrom, validUntil, ...rest } = dto;
return this.prisma.segmentFareRule.update({
where: { id },
data: {
...rest,
validFrom: validFrom ? new Date(validFrom) : undefined,
validUntil: validUntil ? new Date(validUntil) : null,
},
include: { seatClass: true, route: true },
});
}
getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) { getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) {
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality); return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
} }
@@ -337,10 +459,6 @@ export class SchedulesService {
return this.fareEngine.calculateAllForSchedule(scheduleId, nationality); return this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
} }
/**
* Recalculate fares for all active seat classes on a schedule using the fare engine
* and upsert them as FareRule records scoped to this schedule.
*/
async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> { async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> {
const results = await this.fareEngine.calculateAllForSchedule(scheduleId); const results = await this.fareEngine.calculateAllForSchedule(scheduleId);
const errors: string[] = []; const errors: string[] = [];
@@ -352,7 +470,6 @@ export class SchedulesService {
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: fare.seatClassName } }); const seatClass = await this.prisma.seatClass.findFirst({ where: { name: fare.seatClassName } });
if (!seatClass) { errors.push(`Seat class not found: ${fare.seatClassName}`); continue; } if (!seatClass) { errors.push(`Seat class not found: ${fare.seatClassName}`); continue; }
// Expire any existing active rule for this schedule + seat class
await this.prisma.fareRule.updateMany({ await this.prisma.fareRule.updateMany({
where: { tripId: scheduleId, seatClassId: seatClass.id, validUntil: null }, where: { tripId: scheduleId, seatClassId: seatClass.id, validUntil: null },
data: { validUntil: now }, data: { validUntil: now },
@@ -377,8 +494,6 @@ export class SchedulesService {
return { synced, errors }; return { synced, errors };
} }
// ── Coach Assignments ──────────────────────────────────────────────────────
async assignCoaches( async assignCoaches(
scheduleId: string, scheduleId: string,
coaches: Array<{ coachId: string; positionNumber: number }>, coaches: Array<{ coachId: string; positionNumber: number }>,
@@ -386,7 +501,6 @@ export class SchedulesService {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }); const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
if (!schedule) throw new NotFoundException('Schedule not found'); if (!schedule) throw new NotFoundException('Schedule not found');
// Validate all coaches exist
const coachIds = coaches.map(c => c.coachId); const coachIds = coaches.map(c => c.coachId);
const existingCoaches = await this.prisma.coach.findMany({ const existingCoaches = await this.prisma.coach.findMany({
where: { id: { in: coachIds } }, where: { id: { in: coachIds } },
@@ -395,18 +509,16 @@ export class SchedulesService {
throw new NotFoundException('One or more coaches not found'); throw new NotFoundException('One or more coaches not found');
} }
// Remove existing assignments
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } }); await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
// Create new assignments const data = coaches.map((c, idx) => ({
await this.prisma.coachAssignment.createMany({ scheduleId,
data: coaches.map(c => ({ coachId: c.coachId,
scheduleId, positionNumber: idx + 1,
coachId: c.coachId, isOperational: true,
positionNumber: c.positionNumber, }));
isOperational: true,
})), await this.prisma.coachAssignment.createMany({ data });
});
return { message: 'Coaches assigned successfully', count: coaches.length }; return { message: 'Coaches assigned successfully', count: coaches.length };
} }
@@ -417,7 +529,6 @@ export class SchedulesService {
include: { include: {
coach: { coach: {
include: { include: {
seatClass: true,
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
}, },
}, },
@@ -426,6 +537,41 @@ export class SchedulesService {
}); });
} }
async updateSchedulePartial(id: string, dto: UpdateScheduleDto) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
if (!schedule) throw new NotFoundException('Schedule not found');
const updateData: any = {};
if (dto.departureAt || dto.arrivalAt) {
const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt);
const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt);
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
updateData.departureAt = dep;
updateData.arrivalAt = arr;
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
}
if (dto.status) {
updateData.status = dto.status;
}
if (Object.keys(updateData).length > 0) {
await this.prisma.trainSchedule.update({
where: { id },
data: updateData,
});
}
if (dto.coaches && dto.coaches.length > 0) {
await this.assignCoaches(id, dto.coaches);
}
return this.getSchedule(id);
}
async removeCoachAssignment(scheduleId: string, coachId: string) { async removeCoachAssignment(scheduleId: string, coachId: string) {
const assignment = await this.prisma.coachAssignment.findFirst({ const assignment = await this.prisma.coachAssignment.findFirst({
where: { scheduleId, coachId }, where: { scheduleId, coachId },
@@ -435,4 +581,4 @@ export class SchedulesService {
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } }); await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
return { message: 'Coach assignment removed' }; return { message: 'Coach assignment removed' };
} }
} }

View File

@@ -22,8 +22,6 @@ export class SearchService {
const nextDay = new Date(date.getTime() + 86_400_000); const nextDay = new Date(date.getTime() + 86_400_000);
const totalPassengers = dto.adultCount + (dto.childCount ?? 0); const totalPassengers = dto.adultCount + (dto.childCount ?? 0);
// Find all schedules that have BOTH origin and destination as stops
// (not just terminal-to-terminal) and depart on the requested date
const schedules = await this.prisma.trainSchedule.findMany({ const schedules = await this.prisma.trainSchedule.findMany({
where: { where: {
status: { in: ['SCHEDULED', 'BOARDING'] }, status: { in: ['SCHEDULED', 'BOARDING'] },
@@ -36,7 +34,7 @@ export class SearchService {
destinationStation: true, destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: { coachAssignments: {
include: { coach: { include: { seats: true, seatClass: true } } }, include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
}, },
}, },
}); });
@@ -44,36 +42,46 @@ export class SearchService {
const results = []; const results = [];
for (const schedule of schedules) { for (const schedule of schedules) {
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId); const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
// Both stops must exist and origin must come before destination
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue; if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
// Compute per-seat availability for the requested segment range
// A seat is available if no active booking/hold overlaps [originSeq, destSeq)
const availabilityByClass: Record<string, number> = {}; const availabilityByClass: Record<string, number> = {};
for (const assignment of schedule.coachAssignments) { for (const assignment of schedule.coachAssignments) {
const className = assignment.coach.seatClass.name; // Get seat class names from coach type
if (!availabilityByClass[className]) availabilityByClass[className] = 0; const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
for (const seatClassName of seatClassNames) {
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
}
// Count available seats (skip blocked and removed seats)
for (const seat of assignment.coach.seats) { for (const seat of assignment.coach.seats) {
// Skip blocked seats
if (seat.status === 'BLOCKED') continue; if (seat.status === 'BLOCKED') continue;
// Use segment-aware check — a seat booked A→B is still free for B→D
// Skip removed seats (empty seatNumber)
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg( const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id, schedule.id, seat.id,
originStop.sequence, destStop.sequence, originStop.sequence, destStop.sequence,
); );
if (free) availabilityByClass[className]++;
if (free) {
// Group by seat class - use the first seat class for now
// In a full implementation, seats would have a seatClassId
const className = seatClassNames[0] || 'Standard';
availabilityByClass[className]++;
}
} }
} }
// Departure/arrival times for the requested leg (not the full schedule)
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
// Fetch fares for all seat classes - need to pass the SEARCH origin/destination, not schedule terminals
const faresByClass = await this.calculateFaresForSegment( const faresByClass = await this.calculateFaresForSegment(
schedule, schedule,
dto.originStationId, dto.originStationId,
@@ -106,14 +114,14 @@ export class SearchService {
), ),
status: schedule.status, status: schedule.status,
stops: schedule.stopTimes stops: schedule.stopTimes
.filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
.map(st => ({ .map((st: any) => ({
stationId: st.stationId, stationId: st.stationId,
stationName: st.station.name, stationName: st.station.name,
sequence: st.sequence, sequence: st.sequence,
plannedArrivalAt: st.plannedArrivalAt, plannedArrivalAt: st.plannedArrivalAt,
plannedDepartureAt: st.plannedDepartureAt, plannedDepartureAt: st.plannedDepartureAt,
})), })),
availabilityByClass, availabilityByClass,
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
faresByClass, faresByClass,
@@ -134,51 +142,19 @@ export class SearchService {
}); });
if (!schedule) throw new NotFoundException('Schedule not found'); if (!schedule) throw new NotFoundException('Schedule not found');
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId); const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) { if (!originStop || !destStop || originStop.sequence >= destStop.sequence) {
throw new NotFoundException('Origin or destination not found on this schedule'); throw new NotFoundException('Origin or destination not found on this schedule');
} }
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } }); const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } });
// Compute route codes for fare lookup
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
const now = new Date(); const now = new Date();
const nationality = dto.nationality; const nationality = dto.nationality;
// Query fare rules with specificity ordering:
// 1. schedule+segment+nationality
// 2. schedule+segment
// 3. schedule+full-route+nationality
// 4. schedule+full-route
// 5. schedule+global
// 6. segment+nationality
// 7. segment
// 8. full-route+nationality
// 9. full-route
// 10. global
const fareRule = await this.prisma.fareRule.findFirst({
where: {
seatClassId: seatClass?.id,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
orderBy: [
// Prioritize schedule-specific rules
{ tripId: { sort: 'desc', nulls: 'last' } },
// Then prioritize nationality match
{ nationality: { sort: 'desc', nulls: 'last' } },
// Most recent validFrom
{ validFrom: 'desc' },
],
});
// Manual specificity filtering to find best match
const candidates = await this.prisma.fareRule.findMany({ const candidates = await this.prisma.fareRule.findMany({
where: { where: {
seatClassId: seatClass?.id, seatClassId: seatClass?.id,
@@ -243,38 +219,40 @@ export class SearchService {
}; };
} }
/**
* Calculate fares for a specific segment of a schedule
*/
private async calculateFaresForSegment( private async calculateFaresForSegment(
schedule: any, schedule: any,
originStationId: string, originStationId: string,
destinationStationId: string, destinationStationId: string,
nationality?: string, nationality?: string,
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> { ): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
// Get seat classes that are actually assigned to this schedule via coaches // Get unique seat classes from all coaches assigned to this schedule via their coach types
const assignedSeatClassIds: string[] = Array.from( const seatClassIds: string[] = Array.from(
new Set( new Set(
schedule.coachAssignments.map((a: any) => a.coach.seatClass.id as string) schedule.coachAssignments
.flatMap((a: any) => a.coach.coachType?.seatClasses || [])
.map((sc: any) => sc.id)
.filter((id: any) => id)
) )
); );
// Get only the seat classes that are assigned to this schedule if (seatClassIds.length === 0) {
const seatClasses = await this.prisma.seatClass.findMany({
where: {
isActive: true,
id: { in: assignedSeatClassIds }
},
orderBy: { basePrice: 'asc' },
});
// If no coaches assigned, return empty array
if (seatClasses.length === 0) {
console.log(`No seat classes assigned to schedule ${schedule.id}`); console.log(`No seat classes assigned to schedule ${schedule.id}`);
return []; return [];
} }
// If schedule has a route, use route-based calculation const seatClasses = await this.prisma.seatClass.findMany({
where: {
isActive: true,
id: { in: seatClassIds }
},
orderBy: { baseFareMinor: 'asc' },
});
if (seatClasses.length === 0) {
console.log(`No active seat classes for schedule ${schedule.id}`);
return [];
}
if (schedule.routeId) { if (schedule.routeId) {
const results = await Promise.all( const results = await Promise.all(
seatClasses.map(async (sc) => { seatClasses.map(async (sc) => {
@@ -303,7 +281,6 @@ export class SearchService {
} }
} }
// Fallback: Try to get fares from FareRule table
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } }); const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } }); const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
@@ -314,26 +291,25 @@ export class SearchService {
const fareRules = await this.prisma.fareRule.findMany({ const fareRules = await this.prisma.fareRule.findMany({
where: { where: {
route: segmentRoute, route: segmentRoute,
seatClassId: { in: assignedSeatClassIds }, seatClassId: { in: seatClassIds },
validFrom: { lte: now }, validFrom: { lte: now },
OR: [ OR: [
{ validUntil: null }, { validUntil: null },
{ validUntil: { gte: now } }, { validUntil: { gte: now } },
], ],
}, },
include: { seatClass: true },
}); });
if (fareRules.length > 0) { if (fareRules.length > 0) {
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`); console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name]));
return fareRules.map(rule => ({ return fareRules.map(rule => ({
seatClassName: rule.seatClass.name, seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
baseFareMinor: rule.baseFareMinor, baseFareMinor: rule.baseFareMinor,
})); }));
} }
} }
// Last resort: Return default fares only for assigned seat classes
console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`); console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
return seatClasses.map(sc => ({ return seatClasses.map(sc => ({
seatClassName: sc.name, seatClassName: sc.name,
@@ -359,60 +335,6 @@ export class SearchService {
return fares[seatClassName] ?? 45000; return fares[seatClassName] ?? 45000;
} }
/**
* Fallback method to get fares from FareRule table when fare engine fails
*/
private async getFallbackFares(
scheduleId: string,
originCode: string,
destCode: string,
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
const segmentRoute = `${originCode}-${destCode}`;
const now = new Date();
// Try to find fare rules for this segment
const fareRules = await this.prisma.fareRule.findMany({
where: {
route: segmentRoute,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
include: { seatClass: true },
});
if (fareRules.length > 0) {
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
return fareRules.map(rule => ({
seatClassName: rule.seatClass.name,
baseFareMinor: rule.baseFareMinor,
}));
}
// If no segment-specific rules, return default fares
console.log(`No fare rules found for ${segmentRoute}, using defaults`);
return [
{ seatClassName: 'Economy Regular', baseFareMinor: 35000 },
{ seatClassName: 'Economy Bed', baseFareMinor: 49000 },
{ seatClassName: 'VIP Bed', baseFareMinor: 63000 },
];
}
/**
* Select the best matching fare rule based on specificity:
* 1. schedule+segment+nationality
* 2. schedule+segment
* 3. schedule+full-route+nationality
* 4. schedule+full-route
* 5. schedule+global
* 6. segment+nationality
* 7. segment
* 8. full-route+nationality
* 9. full-route
* 10. global
*/
private selectBestFareRule( private selectBestFareRule(
candidates: any[], candidates: any[],
scheduleId: string, scheduleId: string,
@@ -421,19 +343,16 @@ export class SearchService {
nationality?: string, nationality?: string,
): any | null { ): any | null {
const priorities = [ const priorities = [
// Schedule-specific rules
{ tripId: scheduleId, route: segmentRoute, nationality }, { tripId: scheduleId, route: segmentRoute, nationality },
{ tripId: scheduleId, route: segmentRoute, nationality: null }, { tripId: scheduleId, route: segmentRoute, nationality: null },
{ tripId: scheduleId, route: fullRoute, nationality }, { tripId: scheduleId, route: fullRoute, nationality },
{ tripId: scheduleId, route: fullRoute, nationality: null }, { tripId: scheduleId, route: fullRoute, nationality: null },
{ tripId: scheduleId, route: null, nationality }, { tripId: scheduleId, route: null, nationality },
{ tripId: scheduleId, route: null, nationality: null }, { tripId: scheduleId, route: null, nationality: null },
// Route-specific rules (no schedule)
{ tripId: null, route: segmentRoute, nationality }, { tripId: null, route: segmentRoute, nationality },
{ tripId: null, route: segmentRoute, nationality: null }, { tripId: null, route: segmentRoute, nationality: null },
{ tripId: null, route: fullRoute, nationality }, { tripId: null, route: fullRoute, nationality },
{ tripId: null, route: fullRoute, nationality: null }, { tripId: null, route: fullRoute, nationality: null },
// Global rules
{ tripId: null, route: null, nationality }, { tripId: null, route: null, nationality },
{ tripId: null, route: null, nationality: null }, { tripId: null, route: null, nationality: null },
]; ];

View File

@@ -1,41 +1,33 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
@Injectable() @Injectable()
export class SeatClassesService { export class SeatClassesService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
private readonly coachInclude = {
coaches: {
select: { id: true, coachNumber: true, label: true, mode: true, totalUnits: true, _count: { select: { seats: true } } },
orderBy: { label: 'asc' as const },
},
};
listSeatClasses() { listSeatClasses() {
return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' }, include: this.coachInclude }); return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' } });
} }
async getSeatClass(id: string) { async getSeatClass(id: string) {
const sc = await this.prisma.seatClass.findUnique({ where: { id }, include: this.coachInclude }); const sc = await this.prisma.seatClass.findUnique({ where: { id } });
if (!sc) throw new NotFoundException('SeatClass not found'); if (!sc) throw new NotFoundException('SeatClass not found');
return sc; return sc;
} }
async createSeatClass(dto: CreateSeatClassDto) { async createSeatClass(dto: any) {
try { try {
return await this.prisma.seatClass.create({ data: dto, include: this.coachInclude }); return await this.prisma.seatClass.create({ data: dto });
} catch (e: any) { } catch (e: any) {
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`); if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
throw e; throw e;
} }
} }
async updateSeatClass(id: string, dto: UpdateSeatClassDto) { async updateSeatClass(id: string, dto: any) {
const sc = await this.prisma.seatClass.findUnique({ where: { id } }); const sc = await this.prisma.seatClass.findUnique({ where: { id } });
if (!sc) throw new NotFoundException('SeatClass not found'); if (!sc) throw new NotFoundException('SeatClass not found');
return this.prisma.seatClass.update({ where: { id }, data: dto, include: this.coachInclude }); return this.prisma.seatClass.update({ where: { id }, data: dto });
} }
async deleteSeatClass(id: string) { async deleteSeatClass(id: string) {

View File

@@ -1,8 +1,9 @@
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Patch, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { SeatsService } from './seats.service'; import { SeatsService } from './seats.service';
import { HoldSeatsDto } from './seats.dto'; import { HoldSeatsDto } from './seats.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { IamGuard } from '../../common/iam-adapter';
@ApiTags('Seats') @ApiTags('Seats')
@Controller('seats') @Controller('seats')
@@ -78,6 +79,47 @@ This makes it clear which segment of the route each seat is held for, enabling s
@ApiResponse({ status: 404, description: 'Hold not found' }) @ApiResponse({ status: 404, description: 'Hold not found' })
releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); } releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); }
// ── Seat Block / Unblock ───────────────────────────────────────────────────
@Post(':seatId/block')
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Block a seat (e.g., maintenance, damage)' })
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
@ApiResponse({ status: 200, description: 'Seat blocked' })
blockSeat(@Param('seatId') seatId: string, @Body() body: { reason: string }) {
return this.service.blockSeat(seatId, body.reason);
}
@Delete(':seatId/block')
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Unblock a seat' })
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
@ApiResponse({ status: 200, description: 'Seat unblocked' })
unblockSeat(@Param('seatId') seatId: string) {
return this.service.unblockSeat(seatId);
}
// ── Remove Seat ────────────────────────────────────────────────────────────
@Patch(':seatId/remove')
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a seat by marking with negative seatNumber' })
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
@ApiResponse({ status: 200, description: 'Seat removed (seatNumber negated), shows as empty space' })
@ApiResponse({ status: 404, description: 'Seat not found' })
removeSeat(@Param('seatId') seatId: string) {
return this.service.removeSeat(seatId);
}
@Patch(':seatId/undo-remove')
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Undo seat removal by restoring original seatNumber' })
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
@ApiResponse({ status: 200, description: 'Seat restored (negative seatNumber removed)' })
@ApiResponse({ status: 404, description: 'Seat not found' })
@ApiResponse({ status: 400, description: 'Seat is not removed' })
undoRemoveSeat(@Param('seatId') seatId: string) {
return this.service.undoRemoveSeat(seatId);
}
@Get('export/csv/:scheduleId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' }) @Get('export/csv/:scheduleId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' })
async exportCSV(@Param('scheduleId') scheduleId: string) { async exportCSV(@Param('scheduleId') scheduleId: string) {
const csv = await this.service.exportSeatsCSV(scheduleId); const csv = await this.service.exportSeatsCSV(scheduleId);

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { SeatsController } from './seats.controller'; import { SeatsController } from './seats.controller';
import { SeatsService } from './seats.service'; import { SeatsService } from './seats.service';
import { SegmentsModule } from '../segments/segments.module'; import { SegmentsModule } from '../segments/segments.module';
import { IamModule } from '../../common/iam.module';
@Module({ @Module({
imports: [SegmentsModule], imports: [SegmentsModule, HttpModule, IamModule],
controllers: [SeatsController], controllers: [SeatsController],
providers: [SeatsService], providers: [SeatsService],
exports: [SeatsService], exports: [SeatsService],

View File

@@ -11,48 +11,69 @@ export class SeatsService {
private segmentsService: SegmentsService, private segmentsService: SegmentsService,
) {} ) {}
// ── Seat Map ──────────────────────────────────────────────────────────────
async getSeatMap(scheduleId: string, coachId?: string) { async getSeatMap(scheduleId: string, coachId?: string) {
const assignments = await this.prisma.coachAssignment.findMany({ const assignments = await this.prisma.coachAssignment.findMany({
where: { scheduleId, ...(coachId ? { coachId } : {}) }, where: { scheduleId, ...(coachId ? { coachId } : {}) },
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } }, include: {
coach: {
include: {
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
coachType: { include: { seatClasses: true } },
},
},
},
orderBy: { positionNumber: 'asc' }, orderBy: { positionNumber: 'asc' },
}); });
const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id)); 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);
return { const response = {
coaches: assignments.map((a) => ({ coaches: assignments.map((a) => {
id: a.coach.id, // Include all seats (both valid and removed with negative seatNumbers)
assignmentId: a.id, const allSeats = a.coach.seats;
name: `Coach ${a.coach.label}`, const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
seatClass: a.coach.seatClass.name, const seatClass = seatClassNames.length > 0 ? seatClassNames[0] : 'Standard';
positionNumber: a.positionNumber,
seats: a.coach.seats.map((s) => ({ return {
id: s.id, id: a.coach.id,
number: s.label, assignmentId: a.id,
status: effectiveStatuses.get(s.id) ?? s.status, coachNumber: a.coach.number,
kind: s.kind, label: a.coach.number,
row: s.row, mode: a.coach.status,
col: s.col, name: `Coach ${a.coach.number}`,
isWindow: s.isWindow, seatClass,
isAisle: s.isAisle, positionNumber: a.positionNumber,
bedPosition: s.bedPosition, seatArrangement: a.coach.arrangement,
})), totalSeats: a.coach.capacity,
})), seats: allSeats.map((s) => ({
id: s.id,
seatNumber: s.seatNumber,
number: s.seatNumber,
label: s.seatNumber,
status: effectiveStatuses.get(s.id) ?? s.status,
kind: s.kind,
row: s.row,
col: s.col,
isWindow: s.isWindow,
isAisle: s.isAisle,
bedPosition: s.bedPosition,
coach: {
id: a.coach.id,
coachNumber: a.coach.number,
label: a.coach.number,
},
})),
};
}),
}; };
console.log(`[getSeatMap] returning ${response.coaches.length} coaches with seats`);
return response;
} }
/**
* Computes the effective seat status for a set of seats on a specific schedule
* by checking active SeatHolds and confirmed JourneySegments.
*
* Priority: BLOCKED (physical) > BOOKED (confirmed journey) > HELD (active hold) > AVAILABLE
*
* This is needed because seat.status is no longer written during booking —
* availability is segment-scoped, so the DB column stays AVAILABLE even when held.
*/
async resolveEffectiveStatuses( async resolveEffectiveStatuses(
scheduleId: string, scheduleId: string,
seatIds: string[], seatIds: string[],
@@ -61,7 +82,6 @@ export class SeatsService {
if (seatIds.length === 0) return statusMap; if (seatIds.length === 0) return statusMap;
// 1. Active holds — any seat in an unexpired SeatHold for this schedule is HELD
const activeHolds = await this.prisma.seatHold.findMany({ const activeHolds = await this.prisma.seatHold.findMany({
where: { where: {
scheduleId, scheduleId,
@@ -76,8 +96,6 @@ export class SeatsService {
} }
} }
// 2. Active bookings via JourneySegment — CONFIRMED or PENDING_PAYMENT → BOOKED
// (overwrites HELD if the same seat has a confirmed booking)
const bookedSegments = await this.prisma.journeySegment.findMany({ const bookedSegments = await this.prisma.journeySegment.findMany({
where: { where: {
scheduleId, scheduleId,
@@ -93,24 +111,21 @@ export class SeatsService {
return statusMap; return statusMap;
} }
// ── Hold / Release ────────────────────────────────────────────────────────
async holdSeats(dto: HoldSeatsDto) { async holdSeats(dto: HoldSeatsDto) {
// ── Validate request integrity ───────────────────────────────────────────
const passengerIds = dto.passengers.map(p => p.passengerId); const passengerIds = dto.passengers.map(p => p.passengerId);
const seatIds = dto.passengers.map(p => p.seatId); const seatIds = dto.passengers.map(p => p.seatId);
if (new Set(passengerIds).size !== passengerIds.length) if (new Set(passengerIds).size !== passengerIds.length)
throw new BadRequestException('Duplicate passengerId in passengers list — each passenger must appear once'); throw new BadRequestException('Duplicate passengerId in passengers list');
if (new Set(seatIds).size !== seatIds.length) if (new Set(seatIds).size !== seatIds.length)
throw new BadRequestException('Duplicate seatId in passengers list — each seat can only be assigned to one passenger'); throw new BadRequestException('Duplicate seatId in passengers list');
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
const hold = await this.prisma.$transaction(async (tx) => { const hold = await this.prisma.$transaction(async (tx) => {
// ── 1. Validate seats exist and none are BLOCKED ─────────────────────
const seats = await tx.seat.findMany({ const seats = await tx.seat.findMany({
where: { id: { in: seatIds } }, where: { id: { in: seatIds } },
select: { id: true, status: true, label: true }, select: { id: true, status: true, seatNumber: true },
}); });
if (seats.length !== seatIds.length) { if (seats.length !== seatIds.length) {
@@ -121,11 +136,10 @@ export class SeatsService {
const blocked = seats.filter(s => s.status === 'BLOCKED'); const blocked = seats.filter(s => s.status === 'BLOCKED');
if (blocked.length > 0) if (blocked.length > 0)
throw new ConflictException(`Seat(s) ${blocked.map(s => s.label).join(', ')} are blocked`); throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are blocked`);
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.label])); const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
// ── 2. Resolve requested leg sequences ──────────────────────────────
const stopTimes = await tx.tripStopTime.findMany({ const stopTimes = await tx.tripStopTime.findMany({
where: { scheduleId: dto.scheduleId }, where: { scheduleId: dto.scheduleId },
select: { stationId: true, sequence: true }, select: { stationId: true, sequence: true },
@@ -137,17 +151,15 @@ export class SeatsService {
const reqTo = seqOf(dto.destinationStationId); const reqTo = seqOf(dto.destinationStationId);
if (reqFrom === undefined || reqTo === undefined) if (reqFrom === undefined || reqTo === undefined)
throw new BadRequestException('Origin or destination station not found on this schedule'); throw new BadRequestException('Origin or destination station not found');
if (reqFrom >= reqTo) if (reqFrom >= reqTo)
throw new BadRequestException('Origin must come before destination'); throw new BadRequestException('Origin must come before destination');
// ── 3. Load active holds for this schedule ───────────────────────────
const activeHolds = await tx.seatHold.findMany({ const activeHolds = await tx.seatHold.findMany({
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } }, where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
select: { seatIds: true, createdBy: true }, select: { seatIds: true, createdBy: true },
}); });
// Parse each hold's leg range and passenger list
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = []; const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = [];
for (const h of activeHolds) { for (const h of activeHolds) {
try { try {
@@ -164,32 +176,28 @@ export class SeatsService {
}); });
} }
} }
} catch { /* ignore malformed */ } } catch { /* ignore */ }
} }
// ── 4. Per-passenger validation with overlap check ───────────────────
for (const { passengerId, seatId } of dto.passengers) { for (const { passengerId, seatId } of dto.passengers) {
for (const hold of parsedHolds) { for (const hold of parsedHolds) {
const legsOverlap = hold.from < reqTo && reqFrom < hold.to; const legsOverlap = hold.from < reqTo && reqFrom < hold.to;
if (!legsOverlap) continue; // non-overlapping leg — no conflict if (!legsOverlap) continue;
// Rule A: seat is held on an overlapping leg
if (hold.seatIds.includes(seatId)) { if (hold.seatIds.includes(seatId)) {
throw new ConflictException( throw new ConflictException(
`Seat ${seatLabelById[seatId]} is already held for this leg. Please choose a different seat.`, `Seat ${seatLabelById[seatId]} is already held for this leg`,
); );
} }
// Rule B: passenger already holds a seat on an overlapping leg
if (hold.passengerIds.includes(passengerId)) { if (hold.passengerIds.includes(passengerId)) {
throw new ConflictException( throw new ConflictException(
`Passenger already holds a seat on this journey leg. You can only hold one seat per journey.`, `Passenger already holds a seat on this journey leg`,
); );
} }
} }
} }
// Store passenger→seat mapping AND leg in createdBy as JSON
const holdMeta = { const holdMeta = {
originStationId: dto.originStationId, originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId, destinationStationId: dto.destinationStationId,
@@ -228,12 +236,7 @@ export class SeatsService {
return this.enrichHold(hold); return this.enrichHold(hold);
} }
/**
* Resolves the opaque fareQuoteId leg encoding into human-readable station
* names and enriches the hold with schedule, seat, and leg details.
*/
private async enrichHold(hold: any) { private async enrichHold(hold: any) {
// Decode leg and passenger→seat mapping from createdBy JSON
let originStationId: string | null = null; let originStationId: string | null = null;
let destinationStationId: string | null = null; let destinationStationId: string | null = null;
let passengerSeatMap: { passengerId: string; seatId: string }[] = []; let passengerSeatMap: { passengerId: string; seatId: string }[] = [];
@@ -241,7 +244,6 @@ export class SeatsService {
try { try {
if (hold.createdBy) { if (hold.createdBy) {
const raw = hold.createdBy; const raw = hold.createdBy;
// Guard: only parse if it looks like a JSON object, not a plain number/string
if (typeof raw === 'string' && raw.trimStart().startsWith('{')) { if (typeof raw === 'string' && raw.trimStart().startsWith('{')) {
const meta = JSON.parse(raw); const meta = JSON.parse(raw);
originStationId = meta.originStationId ?? null; originStationId = meta.originStationId ?? null;
@@ -249,7 +251,7 @@ export class SeatsService {
passengerSeatMap = Array.isArray(meta.passengers) ? meta.passengers : []; passengerSeatMap = Array.isArray(meta.passengers) ? meta.passengers : [];
} }
} }
} catch { /* ignore malformed createdBy */ } } catch { /* ignore */ }
const seatIds = hold.seatIds as string[]; const seatIds = hold.seatIds as string[];
@@ -262,7 +264,7 @@ export class SeatsService {
destinationStationId ? this.prisma.station.findUnique({ where: { id: destinationStationId } }) : null, destinationStationId ? this.prisma.station.findUnique({ where: { id: destinationStationId } }) : null,
this.prisma.seat.findMany({ this.prisma.seat.findMany({
where: { id: { in: seatIds } }, where: { id: { in: seatIds } },
include: { coach: { include: { seatClass: true } } }, include: { coach: true },
}), }),
]); ]);
@@ -277,10 +279,8 @@ export class SeatsService {
destinationSequence = stopTimes.find(s => s.stationId === destinationStationId)?.sequence ?? null; destinationSequence = stopTimes.find(s => s.stationId === destinationStationId)?.sequence ?? null;
} }
// Build seat map keyed by seatId for quick lookup
const seatById = Object.fromEntries(seats.map(s => [s.id, s])); const seatById = Object.fromEntries(seats.map(s => [s.id, s]));
// Merge passenger→seat mapping with seat details
const passengers = passengerSeatMap.length > 0 const passengers = passengerSeatMap.length > 0
? passengerSeatMap.map(({ passengerId, seatId }) => { ? passengerSeatMap.map(({ passengerId, seatId }) => {
const s = seatById[seatId]; const s = seatById[seatId];
@@ -288,26 +288,25 @@ export class SeatsService {
passengerId, passengerId,
seat: s ? { seat: s ? {
id: s.id, id: s.id,
label: s.label, label: s.seatNumber,
seatNumber: s.seatNumber, seatNumber: s.seatNumber,
coach: s.coach.label, coach: s.coach.number,
seatClass: s.coach.seatClass.name, seatClass: 'Standard',
row: s.row, row: s.row,
col: s.col, col: s.col,
} : { id: seatId }, } : { id: seatId },
}; };
}) })
// Fallback for holds created before this change
: seatIds.map(seatId => { : seatIds.map(seatId => {
const s = seatById[seatId]; const s = seatById[seatId];
return { return {
passengerId: hold.passengerId, passengerId: hold.passengerId,
seat: s ? { seat: s ? {
id: s.id, id: s.id,
label: s.label, label: s.seatNumber,
seatNumber: s.seatNumber, seatNumber: s.seatNumber,
coach: s.coach.label, coach: s.coach.number,
seatClass: s.coach.seatClass.name, seatClass: 'Standard',
row: s.row, row: s.row,
col: s.col, col: s.col,
} : { id: seatId }, } : { id: seatId },
@@ -350,8 +349,7 @@ export class SeatsService {
} }
async confirmSeats(seatIds: string[]) { async confirmSeats(seatIds: string[]) {
// No-op for status — availability is segment-scoped via JourneySegment // No-op
// seat.status = BLOCKED is the only hard gate; BOOKED is not used as a booking flag
} }
async releaseSeats(seatIds: string[]) { async releaseSeats(seatIds: string[]) {
@@ -362,14 +360,15 @@ export class SeatsService {
} }
} }
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise<string[]> { async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
const seats = await this.prisma.seat.findMany({ const seats = await this.prisma.seat.findMany({
where: { where: {
coach: { seatClass: { name: seatClassName }, assignments: { some: { scheduleId } } }, coach: { assignments: { some: { scheduleId } } },
status: 'AVAILABLE', status: 'AVAILABLE',
...(eligibility ? { eligibility } : {}), seatNumber: { not: '' },
NOT: { seatNumber: { startsWith: '-' } },
}, },
orderBy: [{ coach: { label: 'asc' } }, { row: 'asc' }, { col: 'asc' }], orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
}); });
if (seats.length < count) { if (seats.length < count) {
@@ -404,10 +403,10 @@ export class SeatsService {
where: { scheduleId }, where: { scheduleId },
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } }, include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
}); });
const rows = ['coachId,coachLabel,row,col,label,kind,status,premiumFeeMinor,eligibility']; const rows = ['coachId,coachLabel,row,col,seatNumber,kind,status,premiumFeeMinor'];
for (const a of assignments) { for (const a of assignments) {
for (const seat of a.coach.seats) { for (const seat of a.coach.seats) {
rows.push(`${a.coach.id},${a.coach.label},${seat.row},${seat.col},${seat.label},${seat.kind},${seat.status},${seat.premiumFeeMinor},${seat.eligibility || ''}`); rows.push(`${a.coach.id},${a.coach.number},${seat.row},${seat.col},${seat.seatNumber},${seat.kind},${seat.status},${seat.premiumFeeMinor}`);
} }
} }
return rows.join('\n'); return rows.join('\n');
@@ -426,8 +425,8 @@ export class SeatsService {
invalid++; invalid++;
continue; continue;
} }
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor] = parts; const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
if (!coachId || !row || !col || !label) { if (!coachId || !row || !col || !seatNumber) {
errors.push(`Line ${i + 2}: Missing required fields`); errors.push(`Line ${i + 2}: Missing required fields`);
invalid++; invalid++;
continue; continue;
@@ -444,32 +443,30 @@ export class SeatsService {
let imported = 0; let imported = 0;
if (!commit) { if (!commit) {
return { imported: 0, errors: ['Preview mode - use commit=true to apply changes'] }; return { imported: 0, errors: ['Preview mode'] };
} }
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
try { try {
const parts = lines[i].split(','); const parts = lines[i].split(',');
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor, eligibility] = parts; const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
await this.prisma.seat.upsert({ await this.prisma.seat.upsert({
where: { coachId_row_col: { coachId, row: parseInt(row), col } }, where: { coachId_row_col: { coachId, row: parseInt(row), col } },
update: { update: {
label, seatNumber,
kind: kind as any, kind: kind as any,
status: status as any, status: status as any,
premiumFeeMinor: parseInt(premiumFeeMinor) || 0, premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
eligibility: eligibility || null,
}, },
create: { create: {
coachId, coachId,
row: parseInt(row), row: parseInt(row),
col, col,
label, seatNumber,
kind: kind as any, kind: kind as any,
status: status as any, status: status as any,
premiumFeeMinor: parseInt(premiumFeeMinor) || 0, premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
eligibility: eligibility || null,
}, },
}); });
imported++; imported++;
@@ -481,6 +478,74 @@ export class SeatsService {
return { imported, errors: errors.slice(0, 10) }; return { imported, errors: errors.slice(0, 10) };
} }
async blockSeat(seatId: string, reason: string) {
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',
},
});
return { blocked: true, seatId, reason };
}
async unblockSeat(seatId: string) {
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 },
});
return { unblocked: true, seatId };
}
async removeSeat(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
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 },
});
return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
}
async undoRemoveSeat(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
if (!seat.seatNumber || !seat.seatNumber.startsWith('-')) {
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 },
});
return { restored: true, seatId, seatNumber: originalNumber };
}
@Cron(CronExpression.EVERY_MINUTE) @Cron(CronExpression.EVERY_MINUTE)
async expireHolds() { async expireHolds() {
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } }); const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
@@ -489,7 +554,6 @@ export class SeatsService {
try { try {
await this.prisma.seatHold.delete({ where: { id: hold.id } }); await this.prisma.seatHold.delete({ where: { id: hold.id } });
} catch (err) { } catch (err) {
// Ignore if already deleted (e.g., by another process)
if (err instanceof Error && !err.message.includes('P2025')) { if (err instanceof Error && !err.message.includes('P2025')) {
throw err; throw err;
} }

View File

@@ -87,7 +87,7 @@ async function holdSeatsTransaction(scheduleId: string, seatIds: string[], passe
for (const seat of seats) { for (const seat of seats) {
if (seat.status !== 'AVAILABLE') { if (seat.status !== 'AVAILABLE') {
throw new Error(`Seat ${seat.label} is not available (status: ${seat.status})`); throw new Error(`Seat ${seat.seatNumber} is not available (status: ${seat.status})`);
} }
} }

View File

@@ -35,14 +35,12 @@ export class EnhancedSeatsService {
for (const seatId of request.seatIds) { for (const seatId of request.seatIds) {
const seat = await tx.seat.findUnique({ where: { id: seatId } }); const seat = await tx.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new BadRequestException(`Seat ${seatId} not found`); if (!seat) throw new BadRequestException(`Seat ${seatId} not found`);
// Only BLOCKED seats are hard-rejected — BOOKED/HELD are fine if the if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.seatNumber} is blocked`);
// segment does not overlap (another passenger may occupy a different leg)
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.label} is blocked`);
const free = await this.segmentsService.isSeatFreeForLeg( const free = await this.segmentsService.isSeatFreeForLeg(
request.scheduleId, seatId, reqFrom, reqTo, request.scheduleId, seatId, reqFrom, reqTo,
); );
if (!free) throw new ConflictException(`Seat ${seat.label} is not available for the requested leg`); if (!free) throw new ConflictException(`Seat ${seat.seatNumber} is not available for the requested leg`);
} }
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
@@ -51,7 +49,6 @@ export class EnhancedSeatsService {
scheduleId: request.scheduleId, scheduleId: request.scheduleId,
seatIds: request.seatIds, seatIds: request.seatIds,
passengerId: request.passengerId, passengerId: request.passengerId,
// Store leg in createdBy JSON — no fareQuoteId needed
createdBy: JSON.stringify({ createdBy: JSON.stringify({
originStationId: request.originStationId, originStationId: request.originStationId,
destinationStationId: request.destinationStationId, destinationStationId: request.destinationStationId,
@@ -60,7 +57,6 @@ export class EnhancedSeatsService {
}, },
}); });
// Do NOT set seat.status = HELD globally — status is segment-scoped
this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments }); this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments });
return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds }; return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds };
}); });
@@ -81,7 +77,6 @@ export class EnhancedSeatsService {
}); });
if (!schedule) throw new BadRequestException('Schedule not found'); if (!schedule) throw new BadRequestException('Schedule not found');
// Resolve the passenger's leg from createdBy JSON
let originStationId: string | undefined; let originStationId: string | undefined;
let destinationStationId: string | undefined; let destinationStationId: string | undefined;
try { try {
@@ -92,15 +87,15 @@ export class EnhancedSeatsService {
} }
} catch { /* ignore */ } } catch { /* ignore */ }
const originStop = originStationId ? schedule.stopTimes.find(s => s.stationId === originStationId) : undefined; const originStop = originStationId ? schedule.stopTimes.find((s: any) => s.stationId === originStationId) : undefined;
const destStop = destinationStationId ? schedule.stopTimes.find(s => s.stationId === destinationStationId) : undefined; const destStop = destinationStationId ? schedule.stopTimes.find((s: any) => s.stationId === destinationStationId) : undefined;
const fromSeq = originStop?.sequence ?? schedule.stopTimes[0].sequence; const fromSeq = originStop?.sequence ?? schedule.stopTimes[0].sequence;
const toSeq = destStop?.sequence ?? schedule.stopTimes[schedule.stopTimes.length - 1].sequence; const toSeq = destStop?.sequence ?? schedule.stopTimes[schedule.stopTimes.length - 1].sequence;
const segments: Segment[] = []; const segments: Segment[] = [];
for (let i = fromSeq; i < toSeq; i++) { for (let i = fromSeq; i < toSeq; i++) {
const fromStop = schedule.stopTimes.find(s => s.sequence === i); const fromStop = schedule.stopTimes.find((s: any) => s.sequence === i);
const toStop = schedule.stopTimes.find(s => s.sequence === i + 1); const toStop = schedule.stopTimes.find((s: any) => s.sequence === i + 1);
if (fromStop && toStop) { if (fromStop && toStop) {
segments.push({ segments.push({
fromStationId: fromStop.stationId, fromStationId: fromStop.stationId,
@@ -132,7 +127,6 @@ export class EnhancedSeatsService {
} }
} }
// Do NOT set seat.status = BOOKED globally — availability is segment-scoped
await tx.seatHold.delete({ where: { id: request.holdId } }); await tx.seatHold.delete({ where: { id: request.holdId } });
this.eventEmitter.emit('booking.confirmed', { bookingId: request.bookingId, scheduleId: hold.scheduleId, seatIds: hold.seatIds, segments }); this.eventEmitter.emit('booking.confirmed', { bookingId: request.bookingId, scheduleId: hold.scheduleId, seatIds: hold.seatIds, segments });
@@ -185,22 +179,20 @@ export class EnhancedSeatsService {
const schedule = await this.prisma.trainSchedule.findUnique({ const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId }, where: { id: scheduleId },
include: { coachAssignments: { include: { coach: { include: { seats: true, seatClass: true } } } } }, include: { coachAssignments: { include: { coach: { include: { seats: true } } } } },
}); });
if (!schedule) throw new BadRequestException('Schedule not found'); if (!schedule) throw new BadRequestException('Schedule not found');
const availableSeats = []; const availableSeats = [];
for (const assignment of schedule.coachAssignments) { for (const assignment of schedule.coachAssignments) {
for (const seat of assignment.coach.seats) { for (const seat of assignment.coach.seats) {
// Hard-blocked seats are never available
if (seat.status === 'BLOCKED') continue; if (seat.status === 'BLOCKED') continue;
// Availability is determined purely by segment overlap — not global seat.status
const free = await this.segmentsService.isSeatFreeForLeg(scheduleId, seat.id, reqFrom, reqTo); const free = await this.segmentsService.isSeatFreeForLeg(scheduleId, seat.id, reqFrom, reqTo);
if (free) { if (free) {
availableSeats.push({ availableSeats.push({
id: seat.id, label: seat.label, id: seat.id, label: seat.seatNumber,
coach: assignment.coach.label, coach: assignment.coach.number,
seatClass: assignment.coach.seatClass.name, seatClass: 'Standard',
row: seat.row, col: seat.col, row: seat.row, col: seat.col,
kind: seat.kind, kind: seat.kind,
isWindow: seat.isWindow, isWindow: seat.isWindow,

View File

@@ -87,9 +87,15 @@ export class TicketsService {
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload }, create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
}); });
// Create permanent seat blocks for all booked seats // Update all booked seats from HELD to BOOKED and create permanent seat blocks
const seatIds = booking.seats.map(bs => bs.seatId); const seatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of seatIds) { for (const seatId of seatIds) {
// Update seat status to BOOKED
await this.prisma.seat.update({
where: { id: seatId },
data: { status: 'BOOKED' },
});
// Create permanent seat blocks for all booked seats
await this.prisma.seatBlock.create({ await this.prisma.seatBlock.create({
data: { data: {
seatId, seatId,
@@ -162,7 +168,7 @@ export class TicketsService {
id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status, id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status,
fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name, fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name,
departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name, departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name,
coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label, passengerName: seat?.passengerName, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName,
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload, priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
barcodePayload: booking.ticket.barcodePayload barcodePayload: booking.ticket.barcodePayload
}; };
@@ -207,8 +213,8 @@ export class TicketsService {
bookingRef: b.bookingRef, bookingRef: b.bookingRef,
ticketId: b.ticket?.id, ticketId: b.ticket?.id,
passengerName: b.seats[0]?.passengerName, passengerName: b.seats[0]?.passengerName,
seatLabel: b.seats[0]?.seat.label, seatLabel: b.seats[0]?.seat.seatNumber,
coachLabel: b.seats[0]?.seat.coach.label, coachLabel: b.seats[0]?.seat.coach.number,
qrPayload: b.ticket?.qrPayload, qrPayload: b.ticket?.qrPayload,
status: b.status, status: b.status,
validatedAt: b.ticket?.validatedAt, validatedAt: b.ticket?.validatedAt,

View File

@@ -0,0 +1,309 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, Search } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { seatClassesApi, apiClient } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
export default function ClassesPage() {
const [filters, setFilters] = useState({ search: '' });
const [showModal, setShowModal] = useState(false);
const [editingClass, setEditingClass] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null }>({ isOpen: false, class: null });
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['classes', filters],
queryFn: () => seatClassesApi.getAll(),
});
const { data: coachTypes } = useQuery<any>({
queryKey: ['coach-types'],
queryFn: () => apiClient.get('/fleet/coach-types'),
});
const createMutation = useMutation({
mutationFn: seatClassesApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['classes'] });
setShowModal(false);
setEditingClass(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => seatClassesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['classes'] });
setShowModal(false);
setEditingClass(null);
},
});
const deleteMutation = useMutation({
mutationFn: seatClassesApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['classes'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const classData = {
coachTypeId: formData.get('coachTypeId') as string,
name: formData.get('name') as string,
description: formData.get('description') as string,
baseFareMinor: parseInt(formData.get('baseFareMinor') as string) || 0,
};
if (editingClass) {
await updateMutation.mutateAsync({ id: editingClass.id, data: classData });
} else {
await createMutation.mutateAsync(classData);
}
};
const handleDelete = (cls: any) => {
setDeleteConfirm({ isOpen: true, class: cls });
};
const confirmDelete = async () => {
if (deleteConfirm.class) {
await deleteMutation.mutateAsync(deleteConfirm.class.id);
setDeleteConfirm({ isOpen: false, class: null });
}
};
const coachTypesArray = Array.isArray(coachTypes) ? coachTypes : ((coachTypes as any)?.data || (coachTypes as any)?.items || []);
const coachTypeMap = coachTypesArray.reduce((map: any, ct: any) => {
map[ct.id] = ct.name;
return map;
}, {});
const filteredClasses = (data as any)?.items || (Array.isArray(data) ? data : []);
const displayedClasses = filteredClasses.filter((cls: any) => {
if (!filters.search) return true;
const searchLower = filters.search.toLowerCase();
return (
cls.name?.toLowerCase().includes(searchLower) ||
cls.coachType?.name?.toLowerCase().includes(searchLower) ||
cls.description?.toLowerCase().includes(searchLower)
);
});
const columns = [
{
key: 'coachType',
label: 'Coach Type',
render: (cls: any) => (
<span className="text-sm">{cls.coachType?.name || coachTypeMap[cls.coachTypeId] || 'N/A'}</span>
),
},
{
key: 'name',
label: 'Class Name',
render: (cls: any) => <span className="font-medium">{cls.name}</span>,
},
{
key: 'description',
label: 'Description',
render: (cls: any) => (
<span className="text-sm text-muted-foreground">{cls.description || '-'}</span>
),
},
{
key: 'baseFareMinor',
label: 'Base Fare (ETB)',
render: (cls: any) => (
<span className="font-mono text-sm">{formatCurrency(cls.baseFareMinor, 'ETB')}</span>
),
},
{
key: 'isActive',
label: 'Status',
render: (cls: any) => (
<Badge variant="status" status={cls.isActive ? 'CONFIRMED' : 'CANCELLED'}>
{cls.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
];
const actions = [
{
label: 'Edit',
onClick: (cls: any) => {
setEditingClass(cls);
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Classes</h1>
<p className="text-muted-foreground">Manage class configurations by coach type</p>
</div>
<ActionButton
icon={Plus}
onClick={() => {
setEditingClass(null);
setShowModal(true);
}}
>
Add Class
</ActionButton>
</div>
<div className="card">
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by name, coach type, or description..."
className="input pl-10 w-full"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
</div>
<DataTable
data={displayedClasses}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage={filters.search ? "No classes match your search" : "No classes found"}
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, class: null })}
onConfirm={confirmDelete}
title="Delete Class"
message={`Are you sure you want to delete ${deleteConfirm.class?.name}?`}
confirmText="Delete"
isDanger={true}
warning="This class may be used by coaches and fare rules. Deleting it may impact seat assignments and pricing."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingClass(null);
}}
title={`${editingClass ? 'Edit' : 'Add'} Class`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="label">Coach Type *</label>
<select
name="coachTypeId"
className="input"
defaultValue={editingClass?.coachTypeId || ''}
required
>
<option value="">Select Coach Type</option>
{coachTypesArray.map((ct: any) => (
<option key={ct.id} value={ct.id}>
{ct.code} - {ct.name}
</option>
))}
</select>
</div>
<div>
<label className="label">Class Name *</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingClass?.name || ''}
required
placeholder="e.g., Economy Regular"
/>
</div>
<div>
<label className="label">Description</label>
<textarea
name="description"
className="input"
rows={3}
defaultValue={editingClass?.description || ''}
placeholder="Describe this class..."
/>
</div>
<div>
<label className="label">Base Fare (ETB cents) *</label>
<input
type="number"
name="baseFareMinor"
className="input"
defaultValue={editingClass?.baseFareMinor || ''}
required
min="0"
placeholder="e.g., 45000 (450 ETB)"
/>
<p className="text-xs text-muted-foreground mt-1">Enter amount in cents (100 cents = 1 ETB)</p>
</div>
<div>
<label className="label">Status</label>
<select
name="isActive"
className="input"
defaultValue={editingClass?.isActive?.toString() || 'true'}
>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingClass(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingClass ? 'Update' : 'Create'} Class
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -2,141 +2,251 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fleetApi } from '@/lib/api'; import { Plus, Search, Grid3x3, Edit, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { Plus, Search, Grid3x3, Train, Edit, Trash2 } from 'lucide-react'; import { fleetApi, apiClient } from '@/lib/api';
type Tab = 'types' | 'coaches';
export default function CoachesPage() { export default function CoachesPage() {
const [activeTab, setActiveTab] = useState<Tab>('coaches');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingCoach, setEditingCoach] = useState<any>(null); const [editingItem, setEditingItem] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; coach: any | null }>({ isOpen: false, coach: null }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null }>({ isOpen: false, item: null });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ // Coach Types Queries
queryKey: ['coaches', search], const { data: coachTypesData, isLoading: typesLoading } = useQuery({
queryFn: () => fleetApi.getCoaches({ search }), queryKey: ['coach-types'],
queryFn: () => apiClient.get('/fleet/coach-types'),
}); });
const createMutation = useMutation({ // Coaches Queries
const { data: coachesData, isLoading: coachesLoading } = useQuery({
queryKey: ['coaches'],
queryFn: () => fleetApi.getCoaches({}),
});
// Coach Type Mutations
const createCoachTypeMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coach-types'] });
setShowModal(false);
setEditingItem(null);
},
});
const updateCoachTypeMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/fleet/coach-types/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coach-types'] });
setShowModal(false);
setEditingItem(null);
},
});
const deleteCoachTypeMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/fleet/coach-types/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coach-types'] });
},
});
// Coach Mutations
const createCoachMutation = useMutation({
mutationFn: fleetApi.createCoach, mutationFn: fleetApi.createCoach,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coaches'] }); queryClient.invalidateQueries({ queryKey: ['coaches'] });
setShowModal(false); setShowModal(false);
setEditingCoach(null); setEditingItem(null);
}, },
}); });
const updateMutation = useMutation({ const updateCoachMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => fleetApi.updateCoach(id, data), mutationFn: ({ id, data }: { id: string; data: any }) => fleetApi.updateCoach(id, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coaches'] }); queryClient.invalidateQueries({ queryKey: ['coaches'] });
setShowModal(false); setShowModal(false);
setEditingCoach(null); setEditingItem(null);
}, },
}); });
const deleteMutation = useMutation({ const deleteCoachMutation = useMutation({
mutationFn: fleetApi.deleteCoach, mutationFn: fleetApi.deleteCoach,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coaches'] }); queryClient.invalidateQueries({ queryKey: ['coaches'] });
}, },
}); });
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleCoachTypeSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const coachData = { const data = {
coachNumber: formData.get('coachNumber') as string, code: formData.get('code') as string,
label: formData.get('label') as string, name: formData.get('name') as string,
seatClassId: formData.get('seatClassId') as string, type: formData.get('type') as string,
coachType: formData.get('coachType') as string,
mode: formData.get('mode') as string,
seatArrangement: formData.get('seatArrangement') as string,
totalUnits: parseInt(formData.get('totalUnits') as string),
isActive: formData.get('isActive') === 'true',
}; };
if (editingCoach) { if (editingItem?.isCoachType) {
await updateMutation.mutateAsync({ id: editingCoach.id, data: coachData }); await updateCoachTypeMutation.mutateAsync({ id: editingItem.id, data });
} else { } else {
await createMutation.mutateAsync(coachData); await createCoachTypeMutation.mutateAsync(data);
} }
}; };
const handleDelete = (coach: any) => { const handleCoachSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
setDeleteConfirm({ isOpen: true, coach }); e.preventDefault();
const formData = new FormData(e.currentTarget);
const data = {
number: formData.get('number') as string,
coachTypeId: formData.get('coachTypeId') as string,
arrangement: formData.get('arrangement') as string,
capacity: parseInt(formData.get('capacity') as string),
status: formData.get('status') as string,
};
if (editingItem?.isCoach) {
await updateCoachMutation.mutateAsync({ id: editingItem.id, data });
} else {
await createCoachMutation.mutateAsync(data);
}
};
const handleDelete = (item: any, isCoachType: boolean) => {
setDeleteConfirm({ isOpen: true, item: { ...item, isCoachType } });
}; };
const confirmDelete = async () => { const confirmDelete = async () => {
if (deleteConfirm.coach) { if (deleteConfirm.item?.isCoachType) {
await deleteMutation.mutateAsync(deleteConfirm.coach.id); await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
setDeleteConfirm({ isOpen: false, coach: null }); } else {
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
} }
setDeleteConfirm({ isOpen: false, item: null });
}; };
const coaches = data?.items || data?.data || []; const coachTypesArray = Array.isArray(coachTypesData) ? coachTypesData : (coachTypesData as any)?.items || (coachTypesData as any)?.data || [];
const coaches = coachesData?.items || coachesData?.data || [];
const columns = [ const filteredCoachTypes = coachTypesArray.filter((ct: any) => {
if (!search) return true;
const searchLower = search.toLowerCase();
return (
ct.code?.toLowerCase().includes(searchLower) ||
ct.name?.toLowerCase().includes(searchLower) ||
ct.type?.toLowerCase().includes(searchLower)
);
});
const filteredCoaches = coaches.filter((coach: any) => {
if (!search) return true;
const searchLower = search.toLowerCase();
return (
coach.number?.toLowerCase().includes(searchLower) ||
coach.coachNumber?.toLowerCase().includes(searchLower) ||
coach.coachType?.name?.toLowerCase().includes(searchLower) ||
coach.arrangement?.toLowerCase().includes(searchLower)
);
});
const typeColorMap: Record<string, string> = {
passenger: 'edr-badge-info',
sleeper: 'edr-badge-warning',
dining: 'edr-badge-success',
baggage: 'edr-badge-danger',
};
const statusMap: Record<string, string> = {
ACTIVE: 'edr-badge-success',
MAINTENANCE: 'edr-badge-warning',
INACTIVE: 'edr-badge-danger',
};
// Coach Types Columns
const coachTypeColumns = [
{ {
key: 'coachNumber', key: 'code',
label: 'Coach Number', label: 'Code',
sortable: true,
render: (ct: any) => (
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[rgb(20,113,76)]">
<Grid3x3 className="h-4 w-4 text-white" />
</div>
<span className="font-mono font-medium">{ct.code}</span>
</div>
),
},
{
key: 'name',
label: 'Name',
sortable: true,
render: (ct: any) => <span className="font-medium">{ct.name}</span>,
},
{
key: 'type',
label: 'Type',
render: (ct: any) => (
<span className={`edr-badge ${typeColorMap[ct.type] || 'edr-badge-info'}`}>
{ct.type}
</span>
),
},
{
key: 'coaches',
label: 'Coaches',
render: (ct: any) => (
<span className="text-sm font-medium">{ct.coaches?.length || 0}</span>
),
},
];
// Coaches Columns
const coachColumns = [
{
key: 'number',
label: 'Number',
sortable: true, sortable: true,
render: (coach: any) => ( render: (coach: any) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[rgb(20,113,76)]"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[rgb(20,113,76)]">
<Grid3x3 className="h-4 w-4 text-white" /> <Grid3x3 className="h-4 w-4 text-white" />
</div> </div>
<span className="font-medium">{coach.coachNumber}</span> <span className="font-medium">{coach.number || coach.coachNumber}</span>
</div> </div>
), ),
}, },
{ {
key: 'seatClass', key: 'coachType',
label: 'Seat Class', label: 'Coach Type',
render: (coach: any) => {
const seatClass = coach.seatClass?.name || coach.serviceClass || 'N/A';
const colorMap: Record<string, string> = {
'ECONOMY_REGULAR': 'edr-badge-info',
'ECONOMY_BED': 'edr-badge-warning',
'VIP_BED': 'edr-badge-success',
};
return (
<span className={`edr-badge ${colorMap[seatClass] || 'edr-badge-info'}`}>
{seatClass.replace(/_/g, ' ')}
</span>
);
},
},
{
key: 'totalSeats',
label: 'Total Seats',
render: (coach: any) => ( render: (coach: any) => (
<span className="font-mono text-sm">{coach.totalSeats || coach.totalUnits || 0}</span> <span className="text-sm">{coach.coachType?.name || 'N/A'}</span>
), ),
}, },
{ {
key: 'layout', key: 'arrangement',
label: 'Layout', label: 'Arrangement',
render: (coach: any) => ( render: (coach: any) => (
<span className="text-sm text-muted-foreground"> <span className="text-sm font-mono">{coach.arrangement || 'N/A'}</span>
{coach.layout || coach.seatLayout || coach.seatArrangement || 'N/A'} ),
</span> },
{
key: 'capacity',
label: 'Capacity',
render: (coach: any) => (
<span className="font-mono text-sm font-medium">{coach.capacity || 0}</span>
), ),
}, },
{ {
key: 'status', key: 'status',
label: 'Status', label: 'Status',
render: (coach: any) => { render: (coach: any) => {
const status = coach.isActive ? 'ACTIVE' : 'INACTIVE'; const status = coach.status || 'ACTIVE';
const statusMap: Record<string, string> = {
ACTIVE: 'edr-badge-success',
MAINTENANCE: 'edr-badge-warning',
INACTIVE: 'edr-badge-danger',
};
return ( return (
<span className={`edr-badge ${statusMap[status] || 'edr-badge-info'}`}> <span className={`edr-badge ${statusMap[status] || 'edr-badge-info'}`}>
{status} {status}
@@ -146,11 +256,11 @@ export default function CoachesPage() {
}, },
]; ];
const actions = [ const coachTypeActions = [
{ {
label: 'Edit', label: 'Edit',
onClick: (coach: any) => { onClick: (item: any) => {
setEditingCoach(coach); setEditingItem({ ...item, isCoachType: true });
setShowModal(true); setShowModal(true);
}, },
variant: 'secondary' as const, variant: 'secondary' as const,
@@ -158,7 +268,25 @@ export default function CoachesPage() {
}, },
{ {
label: 'Delete', label: 'Delete',
onClick: handleDelete, onClick: (item: any) => handleDelete(item, true),
variant: 'danger' as const,
icon: Trash2,
},
];
const coachActions = [
{
label: 'Edit',
onClick: (item: any) => {
setEditingItem({ ...item, isCoach: true });
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: (item: any) => handleDelete(item, false),
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
}, },
@@ -169,51 +297,112 @@ export default function CoachesPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Coach Management</h1> <h1 className="text-3xl font-bold text-foreground">Coach Management</h1>
<p className="text-muted-foreground mt-1">Manage train coaches and configurations</p> <p className="text-muted-foreground mt-1">Manage coach types and train coaches</p>
</div> </div>
<ActionButton <ActionButton
icon={Plus} icon={Plus}
onClick={() => { onClick={() => {
setEditingCoach(null); setEditingItem(null);
setSearch('');
setShowModal(true); setShowModal(true);
}} }}
> >
Add Coach {activeTab === 'types' ? 'Add Coach Type' : 'Add Coach'}
</ActionButton> </ActionButton>
</div> </div>
{/* Tabs */}
<div className="card"> <div className="card">
<div className="flex items-center gap-4 mb-6"> <div className="flex border-b border-border">
<div className="relative flex-1"> <button
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> onClick={() => {
<input setActiveTab('types');
type="text" setSearch('');
placeholder="Search coaches..." }}
value={search} className={`px-4 py-3 font-medium transition-colors ${
onChange={(e) => setSearch(e.target.value)} activeTab === 'types'
className="input pl-10" ? 'border-b-2 border-primary text-primary'
/> : 'text-muted-foreground hover:text-foreground'
</div> }`}
>
Coach Types
</button>
<button
onClick={() => {
setActiveTab('coaches');
setSearch('');
}}
className={`px-4 py-3 font-medium transition-colors ${
activeTab === 'coaches'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Coaches
</button>
</div> </div>
<DataTable {/* Coach Types Tab */}
columns={columns} {activeTab === 'types' && (
data={coaches} <div className="pt-6 space-y-4">
actions={actions} <div className="relative">
loading={isLoading} <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
/> <input
type="text"
placeholder="Search by code, name, or type..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10 w-full"
/>
</div>
<DataTable
columns={coachTypeColumns}
data={filteredCoachTypes}
actions={coachTypeActions}
loading={typesLoading}
emptyMessage={search ? "No coach types match your search" : "No coach types found. Create one to get started."}
/>
</div>
)}
{/* Coaches Tab */}
{activeTab === 'coaches' && (
<div className="pt-6 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by coach number, type, or arrangement..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10 w-full"
/>
</div>
<DataTable
columns={coachColumns}
data={filteredCoaches}
actions={coachActions}
loading={coachesLoading}
emptyMessage={search ? "No coaches match your search" : "No coaches found. Create one to get started."}
/>
</div>
)}
</div> </div>
{/* Delete Confirmation */} {/* Delete Confirmation */}
<ConfirmDialog <ConfirmDialog
isOpen={deleteConfirm.isOpen} isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, coach: null })} onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
onConfirm={confirmDelete} onConfirm={confirmDelete}
title="Delete Coach" title={`Delete ${deleteConfirm.item?.isCoachType ? 'Coach Type' : 'Coach'}`}
message={`Are you sure you want to delete coach ${deleteConfirm.coach?.coachNumber}?`} message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`}
confirmText="Delete" confirmText="Delete"
isDanger={true} isDanger={true}
warning="This coach may be assigned to schedules and trips. Deleting it may impact these systems." warning={
deleteConfirm.item?.isCoachType
? 'This coach type may have coaches assigned. Deleting it may impact these systems.'
: 'This coach may be assigned to schedules. Deleting it may impact these systems.'
}
/> />
{/* Add/Edit Modal */} {/* Add/Edit Modal */}
@@ -221,106 +410,170 @@ export default function CoachesPage() {
isOpen={showModal} isOpen={showModal}
onClose={() => { onClose={() => {
setShowModal(false); setShowModal(false);
setEditingCoach(null); setEditingItem(null);
}} }}
title={`${editingCoach ? 'Edit' : 'Add'} Coach`} title={
size="lg" activeTab === 'types'
? `${editingItem?.isCoachType ? 'Edit' : 'Add'} Coach Type`
: `${editingItem?.isCoach ? 'Edit' : 'Add'} Coach`
}
size={activeTab === 'types' ? 'md' : 'lg'}
> >
<form onSubmit={handleSubmit} className="space-y-4"> {/* Coach Type Form */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> {activeTab === 'types' && (
<div> <form onSubmit={handleCoachTypeSubmit} className="space-y-4">
<label className="label">Coach Number *</label> <div className="grid grid-cols-1 gap-4">
<input <div>
type="text" <label className="label">Code</label>
name="coachNumber" <input
className="input" type="text"
defaultValue={editingCoach?.coachNumber} name="code"
required className="input"
placeholder="e.g., C001" defaultValue={editingItem?.code || ''}
/> required
placeholder="e.g., HSC"
/>
</div>
<div>
<label className="label">Name</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingItem?.name || ''}
required
placeholder="e.g., Hard Seat Coach"
/>
</div>
<div>
<label className="label">Type</label>
<input
type="text"
name="type"
className="input"
defaultValue={editingItem?.type || ''}
required
placeholder="e.g., Regular Seat"
/>
</div>
</div> </div>
<div>
<label className="label">Label *</label> <div className="flex justify-end gap-2 pt-4">
<input <ActionButton
type="text" type="button"
name="label" variant="secondary"
className="input" onClick={() => {
defaultValue={editingCoach?.label} setShowModal(false);
required setEditingItem(null);
placeholder="e.g., Coach 1" }}
/>
</div>
<div>
<label className="label">Coach Type</label>
<select name="coachType" className="input" defaultValue={editingCoach?.coachType}>
<option value="passenger">Passenger</option>
<option value="sleeper">Sleeper</option>
<option value="dining">Dining</option>
<option value="baggage">Baggage</option>
</select>
</div>
<div>
<label className="label">Mode *</label>
<select name="mode" className="input" defaultValue={editingCoach?.mode || 'seat'}>
<option value="seat">Seat</option>
<option value="bed">Bed</option>
<option value="convertible">Convertible</option>
</select>
</div>
<div>
<label className="label">Seat Arrangement</label>
<input
type="text"
name="seatArrangement"
className="input"
defaultValue={editingCoach?.seatArrangement}
placeholder="e.g., 2+2"
/>
</div>
<div>
<label className="label">Total Units *</label>
<input
type="number"
name="totalUnits"
className="input"
defaultValue={editingCoach?.totalUnits}
required
min="1"
placeholder="60"
/>
</div>
<div>
<label className="label">Status</label>
<select
name="isActive"
className="input"
defaultValue={editingCoach?.isActive?.toString() || 'true'}
> >
<option value="true">Active</option> Cancel
<option value="false">Inactive</option> </ActionButton>
</select> <ActionButton
type="submit"
loading={createCoachTypeMutation.isPending || updateCoachTypeMutation.isPending}
>
{editingItem?.isCoachType ? 'Update' : 'Create'} Coach Type
</ActionButton>
</div> </div>
</div> </form>
)}
<div className="flex justify-end gap-2 pt-4">
<ActionButton {/* Coach Form */}
type="button" {activeTab === 'coaches' && (
variant="secondary" <form onSubmit={handleCoachSubmit} className="space-y-4">
onClick={() => { <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
setShowModal(false); <div>
setEditingCoach(null); <label className="label">Coach Type</label>
}} <select
> name="coachTypeId"
Cancel className="input"
</ActionButton> defaultValue={editingItem?.coachTypeId || ''}
<ActionButton required
type="submit" >
loading={createMutation.isPending || updateMutation.isPending} <option value="">Select Coach Type</option>
> {coachTypesArray.map((ct: any) => (
{editingCoach ? 'Update' : 'Create'} Coach <option key={ct.id} value={ct.id}>
</ActionButton> {ct.code} - {ct.name} - {ct.type}
</div> </option>
</form> ))}
</select>
</div>
<div>
<label className="label">Number</label>
<input
type="text"
name="number"
className="input"
defaultValue={editingItem?.number || editingItem?.coachNumber || ''}
required
placeholder="e.g., A-001"
/>
</div>
<div>
<label className="label">Arrangement</label>
<input
type="text"
name="arrangement"
className="input"
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement || '2+2'}
required
placeholder="e.g., 2+2, 3+2"
/>
<p className="text-xs text-muted-foreground mt-1">Format: separate columns with +</p>
</div>
<div>
<label className="label">Capacity</label>
<input
type="number"
name="capacity"
className="input"
defaultValue={editingItem?.capacity || editingItem?.totalUnits || ''}
required
min="1"
placeholder="60"
/>
</div>
<div className="md:col-span-2">
<label className="label">Status</label>
<select
name="status"
className="input"
defaultValue={editingItem?.status || 'ACTIVE'}
>
<option value="ACTIVE">Active</option>
<option value="MAINTENANCE">Maintenance</option>
<option value="INACTIVE">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingItem(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createCoachMutation.isPending || updateCoachMutation.isPending}
>
{editingItem?.isCoach ? 'Update' : 'Create'} Coach
</ActionButton>
</div>
</form>
)}
</Modal> </Modal>
</div> </div>
); );

View File

@@ -6,8 +6,8 @@ import { useAuthStore } from '@/lib/auth-store';
import { Train } from 'lucide-react'; import { Train } from 'lucide-react';
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState('admin@edr-platform.com'); const [email, setEmail] = useState('');
const [password, setPassword] = useState('admin123'); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const router = useRouter(); const router = useRouter();

View File

@@ -18,6 +18,7 @@ export default function PassengersPage() {
page: 1, page: 1,
pageSize: 20, pageSize: 20,
search: '', search: '',
role: 'PASSENGER',
}); });
const [selectedPassenger, setSelectedPassenger] = useState<any>(null); const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, X } from 'lucide-react'; import { Plus, Edit, Trash2, X, Search } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
@@ -26,6 +26,7 @@ export default function RoutesPage() {
const [destinationStationId, setDestinationStationId] = useState(''); const [destinationStationId, setDestinationStationId] = useState('');
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined); const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null }>({ isOpen: false, route: null }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null }>({ isOpen: false, route: null });
const [search, setSearch] = useState('');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: routes, isLoading: routesLoading } = useQuery({ const { data: routes, isLoading: routesLoading } = useQuery({
@@ -176,6 +177,17 @@ export default function RoutesPage() {
}, },
]; ];
const filteredRoutes = (routes as any)?.items || (Array.isArray(routes) ? routes : []);
const displayedRoutes = filteredRoutes.filter((route: any) => {
if (!search) return true;
const searchLower = search.toLowerCase();
return (
route.code?.toLowerCase().includes(searchLower) ||
route.name?.toLowerCase().includes(searchLower) ||
route.description?.toLowerCase().includes(searchLower)
);
});
const routeActions = [ const routeActions = [
{ {
label: 'Edit', label: 'Edit',
@@ -238,6 +250,7 @@ export default function RoutesPage() {
setDestinationStationId(''); setDestinationStationId('');
setDestinationDistance(undefined); setDestinationDistance(undefined);
setStops([]); setStops([]);
setSearch('');
setShowModal(true); setShowModal(true);
}} }}
> >
@@ -245,12 +258,23 @@ export default function RoutesPage() {
</ActionButton> </ActionButton>
</div> </div>
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by code, name, or description..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10 w-full"
/>
</div>
<DataTable <DataTable
data={(routes as any)?.items || (Array.isArray(routes) ? routes : [])} data={displayedRoutes}
columns={routeColumns} columns={routeColumns}
actions={routeActions} actions={routeActions}
loading={routesLoading} loading={routesLoading}
emptyMessage="No routes found" emptyMessage={search ? "No routes match your search" : "No routes found"}
/> />
{/* Delete Confirmation */} {/* Delete Confirmation */}
@@ -275,6 +299,7 @@ export default function RoutesPage() {
setDestinationStationId(''); setDestinationStationId('');
setDestinationDistance(undefined); setDestinationDistance(undefined);
setStops([]); setStops([]);
setSearch('');
}} }}
title={`${editingRoute ? 'Edit' : 'Add'} Route`} title={`${editingRoute ? 'Edit' : 'Add'} Route`}
size="lg" size="lg"
@@ -516,6 +541,7 @@ export default function RoutesPage() {
setDestinationStationId(''); setDestinationStationId('');
setDestinationDistance(undefined); setDestinationDistance(undefined);
setStops([]); setStops([]);
setSearch('');
}} }}
> >
Cancel Cancel

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,236 +0,0 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Plus, Edit, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { seatClassesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function SeatClassesPage() {
const [filters, setFilters] = useState({ search: '' });
const [showModal, setShowModal] = useState(false);
const [editingSeatClass, setEditingSeatClass] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; seatClass: any | null }>({ isOpen: false, seatClass: null });
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['seat-classes', filters],
queryFn: () => seatClassesApi.getAll(),
});
const createMutation = useMutation({
mutationFn: seatClassesApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seat-classes'] });
setShowModal(false);
setEditingSeatClass(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => seatClassesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seat-classes'] });
setShowModal(false);
setEditingSeatClass(null);
},
});
const deleteMutation = useMutation({
mutationFn: seatClassesApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seat-classes'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const seatClassData = {
name: formData.get('name') as string,
description: formData.get('description') as string,
basePrice: Math.round(parseFloat(formData.get('basePrice') as string) * 100), // Convert to minor units
isActive: formData.get('isActive') === 'true',
};
if (editingSeatClass) {
await updateMutation.mutateAsync({ id: editingSeatClass.id, data: seatClassData });
} else {
await createMutation.mutateAsync(seatClassData);
}
};
const handleDelete = (seatClass: any) => {
setDeleteConfirm({ isOpen: true, seatClass });
};
const confirmDelete = async () => {
if (deleteConfirm.seatClass) {
await deleteMutation.mutateAsync(deleteConfirm.seatClass.id);
setDeleteConfirm({ isOpen: false, seatClass: null });
}
};
const columns = [
{ key: 'name', label: 'Name', render: (cls: any) => <span className="font-medium">{cls.name}</span> },
{ key: 'description', label: 'Description', render: (cls: any) => cls.description || 'N/A' },
{ key: 'basePrice', label: 'Base Price', render: (cls: any) => formatCurrency(cls.basePrice, 'ETB') },
{ key: 'isActive', label: 'Status', render: (cls: any) => <Badge variant="status" status={cls.isActive ? 'CONFIRMED' : 'CANCELLED'}>{cls.isActive ? 'Active' : 'Inactive'}</Badge> },
];
const actions = [
{
label: 'Edit',
onClick: (seatClass: any) => {
setEditingSeatClass(seatClass);
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Classes</h1>
<p className="text-muted-foreground">Manage seat class configurations</p>
</div>
<div className="flex gap-2">
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
<ActionButton
icon={Plus}
onClick={() => {
setEditingSeatClass(null);
setShowModal(true);
}}
>
Add Seat Class
</ActionButton>
</div>
</div>
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
</div>
</div>
<DataTable
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No seat classes found"
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, seatClass: null })}
onConfirm={confirmDelete}
title="Delete Seat Class"
message={`Are you sure you want to delete ${deleteConfirm.seatClass?.name}?`}
confirmText="Delete"
isDanger={true}
warning="This seat class may be used by coaches and trips. Deleting it may impact fare calculations and seat assignments."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingSeatClass(null);
}}
title={`${editingSeatClass ? 'Edit' : 'Add'} Seat Class`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="label">Class Name *</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingSeatClass?.name}
required
placeholder="e.g., Economy Regular"
/>
</div>
<div>
<label className="label">Description</label>
<textarea
name="description"
className="input"
rows={3}
defaultValue={editingSeatClass?.description}
placeholder="Describe the seat class..."
/>
</div>
<div>
<label className="label">Base Price (ETB) *</label>
<input
type="number"
name="basePrice"
className="input"
defaultValue={editingSeatClass?.basePrice ? (editingSeatClass.basePrice / 100).toFixed(2) : ''}
required
min="0"
step="0.01"
placeholder="e.g., 450.00"
/>
<p className="text-xs text-gray-500 mt-1">Enter amount in ETB (e.g., 450.00)</p>
</div>
<div>
<label className="label">Status</label>
<select
name="isActive"
className="input"
defaultValue={editingSeatClass?.isActive?.toString() || 'true'}
>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingSeatClass(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingSeatClass ? 'Update' : 'Create'} Seat Class
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -4,14 +4,14 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { seatsApi, schedulesApi } from '@/lib/api'; import { seatsApi, schedulesApi } from '@/lib/api';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton'
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import { Search, Armchair, Lock, Unlock, ChevronRight } from 'lucide-react'; import { Armchair, Lock, Unlock, Bed, X, RotateCcw } from 'lucide-react';
export default function SeatsPage() { export default function SeatsPage() {
const [search, setSearch] = useState('');
const [selectedSchedule, setSelectedSchedule] = useState(''); const [selectedSchedule, setSelectedSchedule] = useState('');
const [showBlockModal, setShowBlockModal] = useState(false); const [showBlockModal, setShowBlockModal] = useState(false);
const [showRemoveModal, setShowRemoveModal] = useState(false);
const [selectedSeat, setSelectedSeat] = useState<any>(null); const [selectedSeat, setSelectedSeat] = useState<any>(null);
const [blockReason, setBlockReason] = useState(''); const [blockReason, setBlockReason] = useState('');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -44,6 +44,22 @@ export default function SeatsPage() {
}, },
}); });
const removeSeatMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.removeSeat(seatId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
setShowRemoveModal(false);
setSelectedSeat(null);
},
});
const undoRemoveMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.undoRemove(seatId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
},
});
const schedules = schedulesData?.items || schedulesData?.data || []; const schedules = schedulesData?.items || schedulesData?.data || [];
const coaches = seatMapData?.coaches || []; const coaches = seatMapData?.coaches || [];
@@ -58,6 +74,17 @@ export default function SeatsPage() {
} }
}; };
const handleRemoveSeat = (seat: any) => {
setSelectedSeat(seat);
setShowRemoveModal(true);
};
const handleUndoRemove = async (seat: any) => {
if (confirm('Restore this removed seat?')) {
await undoRemoveMutation.mutateAsync(seat.id);
}
};
const submitBlock = async () => { const submitBlock = async () => {
if (!blockReason.trim()) { if (!blockReason.trim()) {
alert('Please provide a reason for blocking'); alert('Please provide a reason for blocking');
@@ -66,6 +93,10 @@ export default function SeatsPage() {
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason }); await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason });
}; };
const submitRemoveSeat = async () => {
await removeSeatMutation.mutateAsync(selectedSeat.id);
};
const getSeatStatus = (seat: any) => { const getSeatStatus = (seat: any) => {
if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED'; if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED';
if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED'; if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED';
@@ -83,9 +114,221 @@ export default function SeatsPage() {
} }
}; };
const filteredCoaches = coaches.filter((coach: any) => const parseSeatArrangement = (arrangement: string | null): number[] => {
search ? coach.coachNumber?.toLowerCase().includes(search.toLowerCase()) : true if (!arrangement) return [2, 2];
); const parts = arrangement.split('+').map(p => parseInt(p.trim()));
return parts.length === 2 ? parts : [2, 2];
};
const getBedLabel = (bedPosition: string | null): string => {
if (bedPosition === 'upper') return 'U';
if (bedPosition === 'middle') return 'M';
if (bedPosition === 'lower') return 'L';
return '';
};
const renderCoachSeats = (coach: any, isBedCoach: boolean) => {
const allSeats = coach.seats || [];
const validSeats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
const removedSeats = allSeats.filter((s: any) => s.seatNumber && s.seatNumber.startsWith('-'));
if (validSeats.length === 0 && removedSeats.length === 0) {
return <div className="text-xs text-muted-foreground">No seats</div>;
}
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
if (isBedCoach && hasBedPositionData) {
// Render bed coach with flipping effect and bed position labels
const arrangement = parseSeatArrangement(coach.seatArrangement);
const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
const allSeatsForLayout = [...validSeats, ...removedSeats];
const rows = [];
for (let i = 0; i < allSeatsForLayout.length; i += seatsPerRow) {
rows.push(allSeatsForLayout.slice(i, i + seatsPerRow));
}
return (
<div className="space-y-0">
{rows.map((rowSeats: any[], idx: number) => {
const rowNumber = rowSeats[0]?.row || (idx + 1);
const shouldFlipIcon = rowNumber % 2 === 0;
const showSpacing = idx % 2 === 1;
return (
<div key={`bed-row-${idx}`}>
{shouldFlipIcon && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
{rowSeats.map((seat: any) => (
<div key={`num-before-${seat.id}`} className="w-12 h-4 flex items-center justify-center">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
))}
</div>
)}
<div className="flex gap-0.5 justify-start">
{rowSeats.map((seat: any) => (
<SeatIcon
key={seat.id}
seat={seat}
coach={coach}
isBedCoach={true}
shouldFlipIcon={shouldFlipIcon}
getSeatStatus={getSeatStatus}
getSeatColor={getSeatColor}
handleBlock={handleBlock}
handleRemoveSeat={handleRemoveSeat}
handleUnblock={handleUnblock}
handleUndoRemove={handleUndoRemove}
hideNumber={true}
/>
))}
</div>
{!shouldFlipIcon && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
{rowSeats.map((seat: any) => (
<div key={`num-after-${seat.id}`} className="w-12 h-4 flex items-center justify-center">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
))}
</div>
)}
{showSpacing && <div className="h-2" />}
</div>
);
})}
</div>
);
}
// Regular armchair layout
const arrangement = parseSeatArrangement(coach.seatArrangement);
const leftCount = arrangement[0];
const rightCount = arrangement[1] || 0;
const rows = [];
const processedRows = new Set();
const allSeatsForLayout = [...validSeats, ...removedSeats];
for (const seat of allSeatsForLayout) {
if (!processedRows.has(seat.row)) {
rows.push(allSeatsForLayout.filter((s: any) => s.row === seat.row).sort((a: any, b: any) => {
const colA = a.col.charCodeAt(0);
const colB = b.col.charCodeAt(0);
return colA - colB;
}));
processedRows.add(seat.row);
}
}
return (
<div className="space-y-0">
{rows.map((rowSeats: any[], rowIdx: number) => {
const leftSeats = rowSeats.slice(0, leftCount);
const rightSeats = rowSeats.slice(leftCount);
const rowNumber = rowSeats[0]?.row || 1;
const shouldFlipArmchair = rowNumber % 2 === 0;
const showSpacing = rowIdx % 2 === 1;
return (
<div key={`row-${rowSeats[0]?.id}`}>
{shouldFlipArmchair && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
)}
</div>
)}
<div className="flex gap-0.5 justify-start">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<SeatIcon
key={seat.id}
seat={seat}
coach={coach}
isBedCoach={false}
shouldFlipIcon={shouldFlipArmchair}
getSeatStatus={getSeatStatus}
getSeatColor={getSeatColor}
handleBlock={handleBlock}
handleRemoveSeat={handleRemoveSeat}
handleUnblock={handleUnblock}
handleUndoRemove={handleUndoRemove}
hideNumber={true}
/>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<SeatIcon
key={seat.id}
seat={seat}
coach={coach}
isBedCoach={false}
shouldFlipIcon={shouldFlipArmchair}
getSeatStatus={getSeatStatus}
getSeatColor={getSeatColor}
handleBlock={handleBlock}
handleRemoveSeat={handleRemoveSeat}
handleUnblock={handleUnblock}
handleUndoRemove={handleUndoRemove}
hideNumber={true}
/>
))}
</div>
)}
</div>
{!shouldFlipArmchair && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
)}
</div>
)}
{showSpacing && <div className="h-2" />}
</div>
);
})}
</div>
);
};
const coachesWithSeats = coaches.filter((coach: any) => {
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
return seats.length > 0;
});
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -97,40 +340,25 @@ export default function SeatsPage() {
</div> </div>
<div className="card"> <div className="card">
<div className="flex items-center gap-4 mb-6"> <div className="mb-6">
<div className="flex-1"> <label className="label">Select Schedule</label>
<label className="label">Select Schedule</label> <select
<select value={selectedSchedule}
value={selectedSchedule} onChange={(e) => setSelectedSchedule(e.target.value)}
onChange={(e) => setSelectedSchedule(e.target.value)} className="input"
className="input" >
> <option value="">Select a schedule...</option>
<option value="">Select a schedule...</option> {schedules.map((schedule: any) => {
{schedules.map((schedule: any) => { const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A'; const routeName = schedule.route?.name || 'N/A';
const routeName = schedule.route?.name || 'N/A'; const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A'; return (
return ( <option key={schedule.id} value={schedule.id}>
<option key={schedule.id} value={schedule.id}> {trainNumber} - {routeName} - {date}
{trainNumber} - {routeName} - {date} </option>
</option> );
); })}
})} </select>
</select>
</div>
<div className="relative flex-1">
<label className="label">Search Coaches</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by coach number..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10"
/>
</div>
</div>
</div> </div>
{!selectedSchedule ? ( {!selectedSchedule ? (
@@ -143,13 +371,12 @@ export default function SeatsPage() {
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-3">Loading seats...</p> <p className="text-muted-foreground mt-3">Loading seats...</p>
</div> </div>
) : filteredCoaches.length === 0 ? ( ) : coachesWithSeats.length === 0 ? (
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 text-muted-foreground">
<p>No coaches found for this schedule</p> <p>No coaches with seats found for this schedule</p>
</div> </div>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
{/* Legend */}
<div className="flex items-center gap-6 p-4 bg-muted/50 rounded-lg"> <div className="flex items-center gap-6 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-green-500"></div> <div className="w-4 h-4 rounded bg-green-500"></div>
@@ -167,86 +394,35 @@ export default function SeatsPage() {
<div className="w-4 h-4 rounded bg-gray-500"></div> <div className="w-4 h-4 rounded bg-gray-500"></div>
<span className="text-sm">Blocked</span> <span className="text-sm">Blocked</span>
</div> </div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-dashed border-gray-400"></div>
<span className="text-sm">Removed</span>
</div>
</div> </div>
{/* Coaches */} <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{filteredCoaches.map((coach: any) => { {coachesWithSeats.map((coach: any) => {
const seats = coach.seats || []; const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) ||
const seatClass = coach.seatClass?.name || 'N/A'; (coach.mode && coach.mode.toLowerCase().includes('bed'));
const availableCount = seats.filter((s: any) => getSeatStatus(s) === 'AVAILABLE').length; const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
const bookedCount = seats.filter((s: any) => getSeatStatus(s) === 'BOOKED').length;
const blockedCount = seats.filter((s: any) => getSeatStatus(s) === 'BLOCKED').length;
return ( return (
<div key={coach.id} className="border rounded-lg p-4"> <div key={coach.id} className="border rounded-lg p-3 bg-white dark:bg-card">
<div className="flex items-center justify-between mb-4"> <div className="mb-3">
<div> <h3 className="font-semibold text-sm">Coach {coach.coachNumber}</h3>
<h3 className="text-lg font-semibold">
Coach {coach.coachNumber} - {coach.label}
</h3>
<p className="text-sm text-muted-foreground">
{seatClass} {seats.length} seats
</p>
</div> </div>
<div className="flex gap-3 text-sm">
<span className="text-green-600">Available: {availableCount}</span> <div className="bg-gray-50 dark:bg-gray-900/30 py-2 rounded-lg">
<span className="text-red-600">Booked: {bookedCount}</span> {renderCoachSeats(coach, isBedCoach)}
<span className="text-gray-600">Blocked: {blockedCount}</span>
</div> </div>
</div> </div>
);
<div className="grid grid-cols-8 gap-2"> })}
{seats.map((seat: any) => { </div>
const status = getSeatStatus(seat);
const color = getSeatColor(status);
const canBlock = status === 'AVAILABLE';
const canUnblock = status === 'BLOCKED';
return (
<div
key={seat.id}
className="relative group"
>
<div
className={`${color} text-white rounded-lg p-2 text-center text-sm font-medium cursor-pointer hover:opacity-80 transition-opacity`}
title={`${seat.seatNumber} - ${status}`}
>
{seat.seatNumber}
</div>
{(canBlock || canUnblock) && (
<div className="absolute inset-0 bg-black/60 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
{canBlock && (
<button
onClick={() => handleBlock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100"
title="Block seat"
>
<Lock className="h-3 w-3 text-gray-700" />
</button>
)}
{canUnblock && (
<button
onClick={() => handleUnblock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100"
title="Unblock seat"
>
<Unlock className="h-3 w-3 text-gray-700" />
</button>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</div> </div>
)} )}
</div> </div>
{/* Block Modal */}
<Modal <Modal
isOpen={showBlockModal} isOpen={showBlockModal}
onClose={() => { onClose={() => {
@@ -293,6 +469,162 @@ export default function SeatsPage() {
</div> </div>
</div> </div>
</Modal> </Modal>
<Modal
isOpen={showRemoveModal}
onClose={() => {
setShowRemoveModal(false);
setSelectedSeat(null);
}}
title="Remove Seat"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Remove seat <strong>{selectedSeat?.seatNumber}</strong> from Coach{' '}
<strong>{selectedSeat?.coach?.coachNumber}</strong>
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p className="text-sm text-yellow-800">
This will mark the seat as removed. The seat will show as an empty space on the seat map.
You can undo this action anytime by clicking the undo button on the removed seat.
</p>
</div>
<div className="flex justify-end gap-2">
<ActionButton
variant="secondary"
onClick={() => {
setShowRemoveModal(false);
setSelectedSeat(null);
}}
>
Cancel
</ActionButton>
<ActionButton
variant="danger"
onClick={submitRemoveSeat}
loading={removeSeatMutation.isPending}
>
Remove Seat
</ActionButton>
</div>
</div>
</Modal>
</div>
);
}
interface SeatIconProps {
seat: any;
coach: any;
isBedCoach: boolean;
shouldFlipIcon?: boolean;
hideNumber?: boolean;
getSeatStatus: (seat: any) => string;
getSeatColor: (status: string) => string;
handleBlock: (seat: any) => void;
handleRemoveSeat: (seat: any) => void;
handleUnblock: (seat: any) => void;
handleUndoRemove: (seat: any) => void;
}
function SeatIcon({
seat,
coach,
isBedCoach,
shouldFlipIcon = false,
hideNumber = false,
getSeatStatus,
getSeatColor,
handleBlock,
handleRemoveSeat,
handleUnblock,
handleUndoRemove,
}: SeatIconProps) {
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
if (!seat.seatNumber) {
return <div className="w-7 h-7" />;
}
if (isRemoved) {
return (
<div className="relative group flex flex-col items-center">
<div className="w-11 h-11 rounded border-2 border-dashed border-gray-400 flex items-center justify-center hover:opacity-80 transition-opacity" title="Removed seat">
</div>
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
<button
onClick={() => handleUndoRemove(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Undo remove"
>
<RotateCcw className="h-3 w-3 text-gray-700" />
</button>
</div>
</div>
);
}
const status = getSeatStatus(seat);
const color = getSeatColor(status);
const canBlock = status === 'AVAILABLE';
const canUnblock = status === 'BLOCKED';
return (
<div className="relative group flex flex-col items-center">
{!hideNumber && (
<span className="text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
{seat.seatNumber}
</span>
)}
{isBedCoach ? (
<div
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}`}
>
<Bed className="w-7 h-7 text-white" style={shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined} />
</div>
) : (
<div
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
title={`${seat.seatNumber} - ${status}`}
>
<Armchair className="w-7 h-7 text-white" style={shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined} />
</div>
)}
{(canBlock || canUnblock) && (
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
{canBlock && (
<>
<button
onClick={() => handleBlock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Block seat"
>
<Lock className="h-3 w-3 text-gray-700" />
</button>
<button
onClick={() => handleRemoveSeat(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Remove seat"
>
<X className="h-3 w-3 text-gray-700" />
</button>
</>
)}
{canUnblock && (
<button
onClick={() => handleUnblock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Unblock seat"
>
<Unlock className="h-3 w-3 text-gray-700" />
</button>
)}
</div>
)}
</div> </div>
); );
} }

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, Train } from 'lucide-react'; import { Plus, Edit, Trash2, Train, Search } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
@@ -15,6 +15,7 @@ import { formatDate } from '@/lib/utils';
export default function TrainsPage() { export default function TrainsPage() {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingTrain, setEditingTrain] = useState<TrainType | null>(null); const [editingTrain, setEditingTrain] = useState<TrainType | null>(null);
const [search, setSearch] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null }>({ isOpen: false, train: null }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null }>({ isOpen: false, train: null });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -86,6 +87,19 @@ export default function TrainsPage() {
} }
}; };
const trains = trainsData?.items || [];
const filteredTrains = trains.filter((train: any) => {
if (!search) return true;
const searchLower = search.toLowerCase();
return (
train.number.toLowerCase().includes(searchLower) ||
train.name.toLowerCase().includes(searchLower) ||
train.operatorName?.toLowerCase().includes(searchLower) ||
train.description?.toLowerCase().includes(searchLower)
);
});
const trainColumns = [ const trainColumns = [
{ {
key: 'number', key: 'number',
@@ -168,13 +182,27 @@ export default function TrainsPage() {
</ActionButton> </ActionButton>
</div> </div>
{/* Search Filter */}
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by number, name, or operator..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10 w-full"
/>
</div>
</div>
{/* Trains Table */} {/* Trains Table */}
<DataTable <DataTable
data={trainsData?.items || []} data={filteredTrains}
columns={trainColumns} columns={trainColumns}
actions={actions} actions={actions}
loading={trainsLoading} loading={trainsLoading}
emptyMessage="No trains found" emptyMessage={search ? "No trains match your search" : "No trains found"}
/> />
{/* Delete Confirmation */} {/* Delete Confirmation */}

View File

@@ -57,12 +57,12 @@ const navigationSections = [
title: 'Master Data', title: 'Master Data',
items: [ items: [
{ name: 'Stations', href: '/stations', icon: MapPin }, { name: 'Stations', href: '/stations', icon: MapPin },
{ name: 'Routes', href: '/routes', icon: Route },
{ name: 'Trains', href: '/trains', icon: Train }, { name: 'Trains', href: '/trains', icon: Train },
{ name: 'Coaches', href: '/coaches', icon: Grid3x3 }, { name: 'Coaches', href: '/coaches', icon: Grid3x3 },
{ name: 'Seats', href: '/seats', icon: Armchair }, { name: 'Seats', href: '/seats', icon: Armchair },
{ name: 'Classes', href: '/classes', icon: Settings },
{ name: 'Routes', href: '/routes', icon: Route },
{ name: 'Schedules', href: '/schedules', icon: Calendar }, { name: 'Schedules', href: '/schedules', icon: Calendar },
{ name: 'Classes', href: '/seat-classes', icon: Settings },
] ]
}, },
{ {

View File

@@ -7,8 +7,12 @@ interface PaginationProps {
} }
export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) { export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
if (totalPages <= 1) {
return null;
}
return ( return (
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6"> <div className="flex items-center justify-between border-t border-border bg-card px-4 py-3 sm:px-6">
<div className="flex flex-1 justify-between sm:hidden"> <div className="flex flex-1 justify-between sm:hidden">
<button <button
onClick={() => onPageChange(currentPage - 1)} onClick={() => onPageChange(currentPage - 1)}
@@ -27,7 +31,7 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
</div> </div>
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between"> <div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div> <div>
<p className="text-sm text-gray-700"> <p className="text-sm text-foreground">
Page <span className="font-medium">{currentPage}</span> of{' '} Page <span className="font-medium">{currentPage}</span> of{' '}
<span className="font-medium">{totalPages}</span> <span className="font-medium">{totalPages}</span>
</p> </p>
@@ -51,4 +55,4 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
</div> </div>
</div> </div>
); );
} }

View File

@@ -0,0 +1,43 @@
import React, { ReactNode } from 'react';
interface TabItem {
id: string;
label: string;
icon?: ReactNode;
content: ReactNode;
}
interface TabsProps {
tabs: TabItem[];
defaultTab?: string;
}
export default function Tabs({ tabs, defaultTab }: TabsProps) {
const [activeTab, setActiveTab] = React.useState(defaultTab || tabs[0]?.id);
return (
<div>
<div className="border-b border-gray-200">
<div className="flex gap-1 -mb-px">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 font-medium text-sm border-b-2 transition-colors ${
activeTab === tab.id
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
} flex items-center gap-2`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
</div>
<div className="mt-6">
{tabs.find((tab) => tab.id === activeTab)?.content}
</div>
</div>
);
}

View File

@@ -147,6 +147,8 @@ export const seatsApi = {
release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`), release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
block: (seatId: string, data: any) => apiClient.post<any>(`/seats/${seatId}/block`, data), block: (seatId: string, data: any) => apiClient.post<any>(`/seats/${seatId}/block`, data),
unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`), unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`),
removeSeat: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/remove`, {}),
undoRemove: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/undo-remove`, {}),
}; };
// Payments API // Payments API
@@ -332,13 +334,13 @@ export const liveApi = {
getCrowdSignals: () => apiClient.get<any[]>('/live/crowd-signals'), getCrowdSignals: () => apiClient.get<any[]>('/live/crowd-signals'),
}; };
// Seat Classes API // Classes API (formerly Seat Classes)
export const seatClassesApi = { export const seatClassesApi = {
getAll: () => apiClient.get<any[]>('/seat-classes'), getAll: () => apiClient.get<any[]>('/fleet/classes'),
getById: (id: string) => apiClient.get<any>(`/seat-classes/${id}`), getById: (id: string) => apiClient.get<any>(`/fleet/classes/${id}`),
create: (data: any) => apiClient.post<any>('/seat-classes', data), create: (data: any) => apiClient.post<any>('/fleet/classes', data),
update: (id: string, data: any) => apiClient.patch<any>(`/seat-classes/${id}`, data), update: (id: string, data: any) => apiClient.patch<any>(`/fleet/classes/${id}`, data),
delete: (id: string) => apiClient.delete(`/seat-classes/${id}`), delete: (id: string) => apiClient.delete(`/fleet/classes/${id}`),
}; };
// Food & Dining API // Food & Dining API

View File

@@ -7,6 +7,7 @@ export const passengersApi = {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filters?.search) params.append('search', filters.search); if (filters?.search) params.append('search', filters.search);
if (filters?.verified !== undefined) params.append('verified', filters.verified.toString()); if (filters?.verified !== undefined) params.append('verified', filters.verified.toString());
if (filters?.role) params.append('role', filters.role);
if (filters?.page) params.append('page', filters.page.toString()); if (filters?.page) params.append('page', filters.page.toString());
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString()); if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());

View File

@@ -56,6 +56,7 @@ export interface BookingFilters {
export interface PassengerFilters { export interface PassengerFilters {
search?: string; search?: string;
verified?: boolean; verified?: boolean;
role?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
} }

View File

@@ -280,7 +280,7 @@ export default function ResultsPage() {
{isExpanded && ( {isExpanded && (
<div className="mt-6 pt-6 border-t dark:border-gray-700"> <div className="mt-6 pt-6 border-t dark:border-gray-700">
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-3">Select Seat Class</h4> <h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-3">Select Class</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 ? ( {schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 ? (
schedule.faresByClass.map((fareClass: any) => { schedule.faresByClass.map((fareClass: any) => {

View File

@@ -276,7 +276,7 @@ export default function SearchPage() {
<div className="border-t border-gray-200 dark:border-gray-700 pt-4"> <div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Children</div> <div className="text-sm font-medium text-gray-900 dark:text-gray-100 text-left">Children</div>
<div className="text-xs text-gray-500 dark:text-gray-400">&lt;5 years First free</div> <div className="text-xs text-gray-500 dark:text-gray-400">&lt;5 years First free</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -7,30 +7,39 @@ import { useBookingStore } from '@/lib/booking-store';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useState, useEffect, useCallback, useMemo, memo } from 'react'; import { useState, useEffect, useCallback, useMemo, memo } from 'react';
import { Armchair, Bed, ChevronLeft } from 'lucide-react';
import CustomModal from '@/components/CustomModal'; import CustomModal from '@/components/CustomModal';
// Separate component for seat button to prevent re-render issues const SeatButton = memo(({ seat, isSelected, onToggle, isBedCoach, bedLabel }: any) => {
const SeatButton = memo(({ seat, isSelected, onToggle }: any) => {
const seatLabel = seat.number || seat.label || seat.seatNumber || '?'; const seatLabel = seat.number || seat.label || seat.seatNumber || '?';
return ( return (
<button <div className="flex flex-col items-center">
onClick={() => onToggle(seat.id)} <span className="text-xs font-bold mb-0.5 text-gray-900 dark:text-gray-100">
disabled={seat.status !== 'AVAILABLE'} {seatLabel}{bedLabel}
className={`w-12 h-12 rounded flex items-center justify-center text-xs font-semibold transition-all ${ </span>
isSelected <button
? 'bg-primary text-white shadow-md scale-105' onClick={() => onToggle(seat.id)}
: seat.status === 'AVAILABLE' disabled={seat.status !== 'AVAILABLE'}
? 'bg-green-100 dark:bg-green-900/40 hover:bg-green-200 dark:hover:bg-green-800/50 text-green-800 dark:text-green-200 hover:shadow-md cursor-pointer' className={`w-11 h-11 rounded flex items-center justify-center transition-all ${
: seat.status === 'HELD' isSelected
? 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-200 cursor-not-allowed opacity-75' ? 'bg-[rgb(20_113_76)] text-white shadow-md scale-105'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed opacity-60' : seat.status === 'AVAILABLE'
}`} ? 'bg-green-500 hover:bg-green-600 text-white cursor-pointer hover:shadow-md'
title={`Seat ${seatLabel} - ${seat.status}`} : seat.status === 'HELD'
> ? 'bg-yellow-500 text-white cursor-not-allowed opacity-75'
{seatLabel} : 'bg-gray-500 text-white cursor-not-allowed opacity-60'
</button> }`}
title={`Seat ${seatLabel}${bedLabel} - ${seat.status}`}
>
{isBedCoach ? (
<Bed className="w-7 h-7" style={seat.bedFlip ? { transform: 'scaleY(-1)' } : undefined} />
) : (
<Armchair className="w-7 h-7" style={seat.armchairFlip ? { transform: 'scaleY(-1)' } : undefined} />
)}
</button>
</div>
); );
}); });
@@ -38,10 +47,9 @@ SeatButton.displayName = 'SeatButton';
export default function SeatsPage() { export default function SeatsPage() {
const router = useRouter(); const router = useRouter();
const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria } = useBookingStore(); const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore();
const [selectedSeats, setSelectedSeats] = useState<string[]>([]); const [selectedSeats, setSelectedSeats] = useState<string[]>([]);
const [selectedCoach, setSelectedCoach] = useState<string | null>(null); const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
const [_timeLeft, _setTimeLeft] = useState<number | null>(null);
const [modalState, setModalState] = useState({ const [modalState, setModalState] = useState({
isOpen: false, isOpen: false,
title: '', title: '',
@@ -77,10 +85,23 @@ export default function SeatsPage() {
}, },
}); });
// Mutation to book seats permanently (called after payment)
const bookSeatsMutation = useMutation({
mutationFn: async (seatIds: string[]) => {
return Promise.all(
seatIds.map((seatId) =>
apiClient.patch(`/seats/${seatId}`, {
status: 'BOOKED',
}).catch(() => null) // Ignore errors, seats are already booked via booking system
)
);
},
});
const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]); const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]);
const filteredCoaches = useMemo(() => { const filteredCoaches = useMemo(() => {
return selectedSchedule?.selectedSeatClass let filtered = selectedSchedule?.selectedSeatClass
? coaches.filter((c: any) => { ? coaches.filter((c: any) => {
const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || ''); const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || '');
return seatClassName === selectedSchedule.selectedSeatClass || return seatClassName === selectedSchedule.selectedSeatClass ||
@@ -88,10 +109,12 @@ export default function SeatsPage() {
seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase(); seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase();
}) })
: coaches; : coaches;
return filtered.filter((c: any) => c.seats && c.seats.length > 0);
}, [coaches, selectedSchedule?.selectedSeatClass]); }, [coaches, selectedSchedule?.selectedSeatClass]);
const selectedCoachData = useMemo(() => filteredCoaches.find((c: any) => c.id === selectedCoach), [filteredCoaches, selectedCoach]); const selectedCoachData = useMemo(() => filteredCoaches.find((c: any) => c.id === selectedCoach), [filteredCoaches, selectedCoach]);
const seats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]); const allSeats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]);
const validSeats = useMemo(() => allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')), [allSeats]);
useEffect(() => { useEffect(() => {
if (filteredCoaches && filteredCoaches.length > 0 && !selectedCoach) { if (filteredCoaches && filteredCoaches.length > 0 && !selectedCoach) {
@@ -114,7 +137,7 @@ export default function SeatsPage() {
if (selectedSeats.length > 0) { if (selectedSeats.length > 0) {
await holdMutation.mutateAsync(selectedSeats); await holdMutation.mutateAsync(selectedSeats);
const updatedPassengers = passengers.map((p, i) => { const updatedPassengers = passengers.map((p, i) => {
const seatData = seats?.find((s: any) => s.id === selectedSeats[i]); const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
return { return {
...p, ...p,
seatId: selectedSeats[i], seatId: selectedSeats[i],
@@ -127,7 +150,7 @@ export default function SeatsPage() {
}; };
const handleAutoAssign = async () => { const handleAutoAssign = async () => {
const availableSeats = seats?.filter((s: any) => s.status === 'AVAILABLE') || []; const availableSeats = validSeats?.filter((s: any) => s.status === 'AVAILABLE') || [];
if (availableSeats.length < passengers.length) { if (availableSeats.length < passengers.length) {
setModalState({ setModalState({
isOpen: true, isOpen: true,
@@ -164,12 +187,155 @@ export default function SeatsPage() {
} }
}; };
const handleBackToPassengers = () => {
router.push('/booking/passengers');
};
useEffect(() => { useEffect(() => {
if (!selectedSchedule || !passengers.length) { if (!selectedSchedule || !passengers.length) {
router.push('/booking/search'); router.push('/booking/search');
} }
}, [selectedSchedule, passengers.length, router]); }, [selectedSchedule, passengers.length, router]);
// Auto-book seats when booking is confirmed (after payment)
useEffect(() => {
if (bookingId && selectedSeats.length > 0) {
bookSeatsMutation.mutate(selectedSeats);
}
}, [bookingId]);
const parseSeatArrangement = (arrangement: string | null): number[] => {
if (!arrangement) return [2, 2];
const parts = arrangement.split('+').map(p => parseInt(p.trim()));
return parts.length === 2 ? parts : [2, 2];
};
const getBedLabel = (bedPosition: string | null): string => {
if (bedPosition === 'upper') return 'U';
if (bedPosition === 'middle') return 'M';
if (bedPosition === 'lower') return 'L';
return '';
};
const renderCoachSeats = (coach: any, isBedCoach: boolean) => {
const arrangement = parseSeatArrangement(coach.seatArrangement);
const leftCount = arrangement[0];
const rows = [];
const processedRows = new Set();
for (const seat of allSeats) {
if (!processedRows.has(seat.row)) {
rows.push(allSeats.filter((s: any) => s.row === seat.row).sort((a: any, b: any) => {
const colA = a.col.charCodeAt(0);
const colB = b.col.charCodeAt(0);
return colA - colB;
}));
processedRows.add(seat.row);
}
}
return (
<div className="space-y-0">
{rows.map((rowSeats: any[], rowIdx: number) => {
const leftSeats = rowSeats.slice(0, leftCount);
const rightSeats = rowSeats.slice(leftCount);
const rowNumber = rowSeats[0]?.row || 1;
const shouldFlipIcon = rowNumber % 2 === 0;
const showSpacing = rowIdx % 2 === 1;
return (
<div key={`row-${rowSeats[0]?.id}`}>
{shouldFlipIcon && (
<div className="flex gap-0.5 justify-center text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
))}
</div>
)}
</div>
)}
<div className="flex gap-0.5 justify-center">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
seat.seatNumber && !seat.seatNumber.startsWith('-') ? (
<SeatButton
key={seat.id}
seat={seat}
isSelected={selectedSeats.includes(seat.id)}
onToggle={toggleSeat}
isBedCoach={isBedCoach}
bedLabel={getBedLabel(seat.bedPosition)}
/>
) : (
<div key={seat.id} className="w-11 h-11" />
)
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
seat.seatNumber && !seat.seatNumber.startsWith('-') ? (
<SeatButton
key={seat.id}
seat={seat}
isSelected={selectedSeats.includes(seat.id)}
onToggle={toggleSeat}
isBedCoach={isBedCoach}
bedLabel={getBedLabel(seat.bedPosition)}
/>
) : (
<div key={seat.id} className="w-11 h-11" />
)
))}
</div>
)}
</div>
{!shouldFlipIcon && (
<div className="flex gap-0.5 justify-center text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
))}
</div>
)}
</div>
)}
{showSpacing && <div className="h-2" />}
</div>
);
})}
</div>
);
};
const isBedCoach = selectedCoachData && (selectedCoachData.seatClass?.toLowerCase().includes('bed') || selectedCoachData.mode?.toLowerCase().includes('bed'));
if (!selectedSchedule || !passengers.length) return null; if (!selectedSchedule || !passengers.length) return null;
return ( return (
@@ -182,143 +348,151 @@ export default function SeatsPage() {
type={modalState.type} type={modalState.type}
/> />
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select seats</h1> <div className="flex items-center gap-3 mb-6">
<button
<div className="grid lg:grid-cols-3 gap-6"> onClick={handleBackToPassengers}
<div className="lg:col-span-2"> className="flex items-center gap-2 text-[rgb(20_113_76)] hover:text-[rgb(10_80_50)] font-semibold transition-colors"
<div className="card mb-4"> >
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select coach</h3> <ChevronLeft className="w-5 h-5" />
{selectedSchedule?.selectedSeatClassName && ( Back to passenger details
<div className="mb-3 text-sm text-gray-600 dark:text-gray-400"> </button>
Showing coaches for: <span className="font-semibold text-primary">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span>
</div>
)}
<div className="flex gap-2 overflow-x-auto pb-2">
{filteredCoaches?.map((coach: any) => {
const availableCount = coach.seats?.filter((s: any) => s.status === 'AVAILABLE').length || 0;
const seatClassName = typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || '');
return (
<button
key={coach.id}
onClick={() => setSelectedCoach(coach.id)}
className={`px-4 py-2 rounded whitespace-nowrap ${
selectedCoach === coach.id
? 'bg-primary text-white'
: 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
<div>{coach.label || coach.name || coach.coachNumber}</div>
<div className="text-xs opacity-75">{seatClassName}</div>
<div className="text-xs opacity-75">{availableCount} available</div>
</button>
);
})}
</div>
</div>
<div className="card">
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}</h3>
{isLoading ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>Loading seats...</p>
</div>
) : error ? (
<div className="text-center py-8 text-red-500 dark:text-red-400">
<p>Error loading seats</p>
<p className="text-sm mt-2">{error?.message || 'Please try again'}</p>
</div>
) : seats.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No seats available in this coach</p>
<p className="text-sm mt-2\">Please select a different coach</p>
</div>
) : (
<>
{/* Seat Grid */}
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg mb-4 overflow-x-auto">
<div className="inline-grid gap-2" style={{ gridTemplateColumns: `repeat(4, minmax(0, 1fr))` }}>
{seats?.map((seat: any) => (
<SeatButton
key={seat.id}
seat={seat}
isSelected={selectedSeats.includes(seat.id)}
onToggle={toggleSeat}
/>
))}
</div>
</div>
{/* Legend */}
<div className="flex flex-wrap gap-4 text-sm text-gray-700 dark:text-gray-300">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-green-100 dark:bg-green-900/40 rounded"></div>
<span>Available</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-primary rounded"></div>
<span>Selected</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-yellow-100 dark:bg-yellow-900/40 rounded"></div>
<span>Held</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-gray-200 dark:bg-gray-700 rounded"></div>
<span>Booked</span>
</div>
</div>
</>
)}
</div>
</div> </div>
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select seats</h1>
<div> <div className="grid lg:grid-cols-3 gap-6">
<div className="card sticky top-4"> <div className="lg:col-span-2">
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection summary</h3> <div className="bg-white dark:bg-gray-800 rounded-lg p-4 mb-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4"> <h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select coach</h3>
Select {passengers.length} seat(s) for your passengers {selectedSchedule?.selectedSeatClassName && (
</p> <div className="mb-3 text-sm text-gray-600 dark:text-gray-400">
<p className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100"> Showing coaches for: <span className="font-semibold text-[rgb(20_113_76)]">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span>
{selectedSeats.length} / {passengers.length} selected </div>
</p> )}
<div className="flex gap-2 overflow-x-auto pb-2">
<div className="space-y-2 mb-6"> {filteredCoaches?.map((coach: any) => {
{passengers.map((p, i) => { const availableCount = coach.seats?.filter((s: any) => s.status === 'AVAILABLE').length || 0;
const assignedSeat = selectedSeats[i] ? seats?.find((s: any) => s.id === selectedSeats[i]) : null; const seatClassName = typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || '');
const seatLabel = assignedSeat ? (assignedSeat.number || assignedSeat.label || assignedSeat.seatNumber || '-') : '-'; return (
return ( <button
<div key={i} className="flex justify-between text-sm text-gray-700 dark:text-gray-300"> key={coach.id}
<span>{p.name}</span> onClick={() => setSelectedCoach(coach.id)}
<span className="font-medium"> className={`px-4 py-2 rounded whitespace-nowrap transition-all ${
{seatLabel} selectedCoach === coach.id
</span> ? 'bg-[rgb(20_113_76)] text-white shadow-lg'
</div> : 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100'
); }`}
})} >
<div className="font-semibold">{coach.label || coach.name || coach.coachNumber}</div>
<div className="text-xs opacity-75">{seatClassName}</div>
<div className="text-xs opacity-75">{availableCount} available</div>
</button>
);
})}
</div>
</div> </div>
<button <div className="bg-white dark:bg-gray-800 rounded-lg p-4">
onClick={handleContinue} <h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">
disabled={selectedSeats.length === 0} Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}
className="btn-primary w-full mb-2" </h3>
> {selectedCoachData && (
Continue with selected seats <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
</button> Arrangement: {selectedCoachData.seatArrangement} Total: {selectedCoachData.totalSeats} seats
<button </p>
onClick={handleAutoAssign} )}
disabled={holdMutation.isPending}
className="btn-secondary w-full" {isLoading ? (
> <div className="text-center py-8 text-gray-500 dark:text-gray-400">
{holdMutation.isPending ? 'Assigning...' : 'Auto-assign seats'} <p>Loading seats...</p>
</button> </div>
) : error ? (
<div className="text-center py-8 text-red-500 dark:text-red-400">
<p>Error loading seats</p>
<p className="text-sm mt-2">{error?.message || 'Please try again'}</p>
</div>
) : validSeats.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No seats available in this coach</p>
<p className="text-sm mt-2">Please select a different coach</p>
</div>
) : (
<>
<div className="flex flex-wrap gap-4 mb-6 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded"></div>
<span className="text-gray-700 dark:text-gray-300">Available</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-[rgb(20_113_76)] rounded"></div>
<span className="text-gray-700 dark:text-gray-300">Selected</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-yellow-500 rounded"></div>
<span className="text-gray-700 dark:text-gray-300">Held</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gray-500 rounded"></div>
<span className="text-gray-700 dark:text-gray-300">Booked</span>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-700/30 p-6 rounded-lg overflow-x-auto">
{renderCoachSeats(selectedCoachData, isBedCoach)}
</div>
</>
)}
</div>
</div>
<div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 sticky top-4">
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection summary</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Select {passengers.length} seat(s) for your passengers
</p>
<p className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
{selectedSeats.length} / {passengers.length} selected
</p>
<div className="space-y-2 mb-6 max-h-48 overflow-y-auto">
{passengers.map((p, i) => {
const assignedSeat = selectedSeats[i] ? validSeats?.find((s: any) => s.id === selectedSeats[i]) : null;
const seatLabel = assignedSeat ? (assignedSeat.number || assignedSeat.label || assignedSeat.seatNumber || '-') : '-';
const bedLabel = assignedSeat ? getBedLabel(assignedSeat.bedPosition) : '';
return (
<div key={i} className="flex justify-between text-sm text-gray-700 dark:text-gray-300">
<span>{p.name}</span>
<span className="font-medium text-[rgb(20_113_76)]">
{seatLabel}{bedLabel}
</span>
</div>
);
})}
</div>
<button
onClick={handleContinue}
disabled={selectedSeats.length === 0}
className="w-full bg-[rgb(20_113_76)] hover:bg-[rgb(10_80_50)] disabled:bg-gray-400 text-white font-semibold py-2 rounded mb-2 transition-colors"
>
Continue with selected seats
</button>
<button
onClick={handleAutoAssign}
disabled={holdMutation.isPending}
className="w-full bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white font-semibold py-2 rounded transition-colors"
>
{holdMutation.isPending ? 'Assigning...' : 'Auto-assign seats'}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</> </>
); );
} }