mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Coaches, seats, schedules, and pricing related updates
This commit is contained in:
@@ -255,7 +255,7 @@ The API uses two authentication schemes:
|
||||
| **Routes** | `/routes` | JWT/IAM | Reusable route templates with ordered stops |
|
||||
| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates |
|
||||
| **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 |
|
||||
| **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation |
|
||||
| **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking |
|
||||
|
||||
@@ -13,14 +13,12 @@
|
||||
"type-check": "tsc --noEmit",
|
||||
"prisma:generate": "prisma generate",
|
||||
"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:backfill": "ts-node prisma/backfill-fields.ts",
|
||||
"prisma:verify": "ts-node prisma/verify-backfill.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "PaymentMethodType" ADD VALUE 'WAAFI';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "FareRule" ADD COLUMN "nationality" TEXT;
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
@@ -1,3 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "FaydaVerificationSession" ADD COLUMN "authCode" TEXT,
|
||||
ADD COLUMN "platform" TEXT NOT NULL DEFAULT 'WEB';
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
-- 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
|
||||
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
|
||||
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
|
||||
CREATE TABLE "SeatClass" (
|
||||
"id" TEXT NOT NULL,
|
||||
"coachTypeId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"basePrice" INTEGER NOT NULL,
|
||||
"baseFareMinor" INTEGER NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
@@ -86,6 +102,9 @@ CREATE TABLE "User" (
|
||||
"lastLoginAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"faydaVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"faydaVerifiedAt" TIMESTAMP(3),
|
||||
"faydaSub" TEXT,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -211,16 +230,11 @@ CREATE TABLE "TripLiveStatus" (
|
||||
-- CreateTable
|
||||
CREATE TABLE "Coach" (
|
||||
"id" TEXT NOT NULL,
|
||||
"coachNumber" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"seatClassId" TEXT NOT NULL,
|
||||
"coachType" TEXT,
|
||||
"mode" TEXT NOT NULL DEFAULT 'seat',
|
||||
"seatArrangement" TEXT,
|
||||
"bedArrangement" TEXT,
|
||||
"amenities" JSONB,
|
||||
"totalUnits" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"coachTypeId" TEXT NOT NULL,
|
||||
"number" TEXT NOT NULL,
|
||||
"arrangement" TEXT NOT NULL DEFAULT '2+2',
|
||||
"capacity" INTEGER NOT NULL DEFAULT 0,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
@@ -243,10 +257,9 @@ CREATE TABLE "CoachAssignment" (
|
||||
CREATE TABLE "Seat" (
|
||||
"id" TEXT NOT NULL,
|
||||
"coachId" TEXT NOT NULL,
|
||||
"seatNumber" TEXT NOT NULL,
|
||||
"row" INTEGER NOT NULL,
|
||||
"col" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"seatNumber" TEXT,
|
||||
"kind" "SeatKind" NOT NULL DEFAULT 'STANDARD',
|
||||
"status" "SeatStatus" NOT NULL DEFAULT 'AVAILABLE',
|
||||
"heldUntil" TIMESTAMP(3),
|
||||
@@ -254,7 +267,6 @@ CREATE TABLE "Seat" (
|
||||
"isAisle" BOOLEAN NOT NULL DEFAULT false,
|
||||
"bedPosition" TEXT,
|
||||
"premiumFeeMinor" INTEGER NOT NULL DEFAULT 0,
|
||||
"eligibility" TEXT,
|
||||
|
||||
CONSTRAINT "Seat_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -278,6 +290,7 @@ CREATE TABLE "FareRule" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tripId" TEXT,
|
||||
"route" TEXT,
|
||||
"nationality" TEXT,
|
||||
"seatClassId" TEXT NOT NULL,
|
||||
"baseFareMinor" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
@@ -303,6 +316,8 @@ CREATE TABLE "Booking" (
|
||||
"displayCurrency" "Currency",
|
||||
"displayTotalMinor" INTEGER,
|
||||
"bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY',
|
||||
"contactEmail" TEXT,
|
||||
"contactPhone" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"source" TEXT NOT NULL DEFAULT 'WEB',
|
||||
"promoCode" TEXT,
|
||||
@@ -327,6 +342,9 @@ CREATE TABLE "BookingSeat" (
|
||||
"passportCountry" TEXT,
|
||||
"verifaydaVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"verifaydaData" JSONB,
|
||||
"faydaVerifiedAt" TIMESTAMP(3),
|
||||
"faydaSub" TEXT,
|
||||
"faydaVerifiedName" TEXT,
|
||||
"seatLabelSnapshot" TEXT,
|
||||
"fareMinor" INTEGER,
|
||||
"displayCurrency" "Currency",
|
||||
@@ -338,13 +356,16 @@ CREATE TABLE "BookingSeat" (
|
||||
-- CreateTable
|
||||
CREATE TABLE "PaymentMethod" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" "PaymentMethodType" NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"maskedHint" TEXT,
|
||||
"region" "PaymentRegion" NOT NULL DEFAULT 'GLOBAL',
|
||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||
"providerId" TEXT,
|
||||
"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,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PaymentMethod_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -423,6 +444,16 @@ CREATE TABLE "Ticket" (
|
||||
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
|
||||
CREATE TABLE "LoyaltyAccount" (
|
||||
"id" TEXT NOT NULL,
|
||||
@@ -1016,8 +1047,51 @@ CREATE TABLE "VerifaydaVerification" (
|
||||
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
|
||||
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
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
@@ -1025,6 +1099,9 @@ CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_faydaSub_key" ON "User"("faydaSub");
|
||||
|
||||
-- CreateIndex
|
||||
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");
|
||||
|
||||
-- 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
|
||||
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");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Seat_coachId_row_col_key" ON "Seat"("coachId", "row", "col");
|
||||
CREATE INDEX "Seat_coachId_idx" ON "Seat"("coachId");
|
||||
|
||||
-- CreateIndex
|
||||
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
|
||||
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");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PaymentMethod_userId_isDefault_idx" ON "PaymentMethod"("userId", "isDefault");
|
||||
CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PaymentIntent_bookingId_key" ON "PaymentIntent"("bookingId");
|
||||
@@ -1100,6 +1183,12 @@ CREATE UNIQUE INDEX "PaymentWebhookEvent_provider_externalEventId_key" ON "Payme
|
||||
-- CreateIndex
|
||||
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
|
||||
CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passengerId");
|
||||
|
||||
@@ -1202,6 +1291,30 @@ CREATE INDEX "VerifaydaVerification_nationalId_idx" ON "VerifaydaVerification"("
|
||||
-- CreateIndex
|
||||
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
|
||||
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
|
||||
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
|
||||
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;
|
||||
|
||||
-- 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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)';
|
||||
@@ -70,17 +70,34 @@ enum Currency {
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model SeatClass {
|
||||
model CoachType {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
description String?
|
||||
basePrice Int
|
||||
isActive Boolean @default(true)
|
||||
code String
|
||||
name String
|
||||
type String @default("passenger") // 'passenger', 'sleeper', 'dining', 'baggage'
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
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[]
|
||||
routeFareRules RouteFareRule[]
|
||||
segmentFares SegmentFareRule[]
|
||||
@@unique([coachTypeId, name])
|
||||
@@index([coachTypeId])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
@@ -385,21 +402,17 @@ model TripLiveStatus {
|
||||
|
||||
model Coach {
|
||||
id String @id @default(uuid())
|
||||
coachNumber String @unique
|
||||
label String
|
||||
seatClassId String
|
||||
coachType String?
|
||||
mode String @default("seat") // 'seat', 'bed', 'convertible'
|
||||
seatArrangement String?
|
||||
bedArrangement String?
|
||||
amenities Json?
|
||||
totalUnits Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
coachTypeId String
|
||||
number String @unique
|
||||
arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2'
|
||||
capacity Int @default(0) // Total seats/beds
|
||||
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
seatClass SeatClass @relation(fields: [seatClassId], references: [id])
|
||||
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
||||
seats Seat[]
|
||||
assignments CoachAssignment[]
|
||||
@@index([coachTypeId])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
@@ -422,10 +435,9 @@ model CoachAssignment {
|
||||
model Seat {
|
||||
id String @id @default(uuid())
|
||||
coachId String
|
||||
seatNumber String // Auto-generated: e.g., '1', '2', '3' (unique per coach)
|
||||
row Int
|
||||
col String
|
||||
label String
|
||||
seatNumber String?
|
||||
kind SeatKind @default(STANDARD)
|
||||
status SeatStatus @default(AVAILABLE)
|
||||
heldUntil DateTime?
|
||||
@@ -433,13 +445,13 @@ model Seat {
|
||||
isAisle Boolean @default(false)
|
||||
bedPosition String? // 'lower', 'middle', 'upper'
|
||||
premiumFeeMinor Int @default(0)
|
||||
eligibility String?
|
||||
coach Coach @relation(fields: [coachId], references: [id])
|
||||
bookingSeats BookingSeat[]
|
||||
blocks SeatBlock[]
|
||||
ticketSeats TicketSeat[]
|
||||
@@unique([coachId, row, col])
|
||||
@@unique([coachId, seatNumber])
|
||||
@@unique([coachId, row, col])
|
||||
@@index([coachId])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
@@ -978,6 +990,7 @@ model Route {
|
||||
createdAt DateTime @default(now())
|
||||
stops RouteStop[]
|
||||
fareRules RouteFareRule[]
|
||||
segmentFares SegmentFareRule[]
|
||||
schedules TrainSchedule[]
|
||||
|
||||
@@schema("passenger")
|
||||
@@ -1017,6 +1030,26 @@ model RouteFareRule {
|
||||
@@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 {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique
|
||||
|
||||
@@ -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
@@ -392,7 +392,7 @@ export class BookingsService {
|
||||
where: { bookingRef },
|
||||
include: {
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
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,
|
||||
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass.name },
|
||||
})),
|
||||
|
||||
@@ -35,7 +35,7 @@ export class DashboardService {
|
||||
upcomingTicket: upcomingBooking ? {
|
||||
ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef,
|
||||
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,
|
||||
punctualityLabel: (upcomingBooking.schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
|
||||
} : null,
|
||||
|
||||
@@ -44,7 +44,7 @@ export class FareEngineService {
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
|
||||
|
||||
const ratePerKmMinor = seatClass.basePrice;
|
||||
const ratePerKmMinor = seatClass.baseFareMinor;
|
||||
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
||||
|
||||
const adultCount = dto.adultCount ?? 1;
|
||||
@@ -129,7 +129,7 @@ export class FareEngineService {
|
||||
) {
|
||||
const seatClasses = await this.prisma.seatClass.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { basePrice: 'asc' },
|
||||
orderBy: { baseFareMinor: 'asc' },
|
||||
});
|
||||
|
||||
const results = await Promise.all(
|
||||
@@ -172,7 +172,7 @@ export class FareEngineService {
|
||||
if (schedule.routeId) {
|
||||
const seatClasses = await this.prisma.seatClass.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { basePrice: 'asc' },
|
||||
orderBy: { baseFareMinor: 'asc' },
|
||||
});
|
||||
|
||||
const results = await Promise.all(
|
||||
@@ -198,23 +198,25 @@ export class FareEngineService {
|
||||
validFrom: { lte: now },
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
include: { seatClass: true },
|
||||
orderBy: { seatClass: { basePrice: 'asc' } },
|
||||
orderBy: [{ seatClass: { baseFareMinor: 'asc' } }],
|
||||
});
|
||||
|
||||
if (fareRules.length > 0) {
|
||||
const billingCurrency = resolveCurrencyFromNationality(nationality);
|
||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
|
||||
return fareRules.map(rule => ({
|
||||
seatClassId: rule.seatClassId,
|
||||
seatClassName: rule.seatClass.name,
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
totalMinor: rule.baseFareMinor,
|
||||
billingCurrency,
|
||||
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
|
||||
exchangeRate,
|
||||
source: 'FARE_RULE',
|
||||
}));
|
||||
return fareRules.map(rule => {
|
||||
const seatClassId = rule.seatClassId;
|
||||
return {
|
||||
seatClassId,
|
||||
seatClassName: 'Unknown',
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
totalMinor: rule.baseFareMinor,
|
||||
billingCurrency,
|
||||
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
|
||||
exchangeRate,
|
||||
source: 'FARE_RULE',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
throw new BadRequestException(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { 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';
|
||||
|
||||
@ApiTags('Fleet')
|
||||
@@ -11,16 +11,128 @@ import { JwtGuard } from '../../common/jwt.guard';
|
||||
export class FleetController {
|
||||
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')
|
||||
@ApiOperation({ summary: 'List all trains with their recent schedules' })
|
||||
@ApiResponse({ status: 200, description: 'Array of trains each with up to 5 most recent schedules' })
|
||||
getTrains() { return this.service.getTrains(); }
|
||||
@ApiResponse({ status: 200, description: 'Array of trains' })
|
||||
getTrains() {
|
||||
return this.service.getTrains();
|
||||
}
|
||||
|
||||
@Post('trains')
|
||||
@ApiOperation({ summary: 'Create a train service' })
|
||||
@ApiBody({ type: CreateTrainDto })
|
||||
@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')
|
||||
@ApiOperation({ summary: 'Update a train service' })
|
||||
@@ -28,96 +140,95 @@ export class FleetController {
|
||||
@ApiBody({ type: CreateTrainDto })
|
||||
@ApiResponse({ status: 200, description: 'Train updated' })
|
||||
@ApiResponse({ status: 404, description: 'Train not found' })
|
||||
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);
|
||||
updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) {
|
||||
return this.service.updateTrain(id, 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')
|
||||
@ApiOperation({ summary: 'Delete a train service' })
|
||||
@ApiParam({ name: 'id', description: 'Train UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Train deleted' })
|
||||
@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')
|
||||
@ApiOperation({ summary: 'Delete a coach' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Coach deleted' })
|
||||
@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')
|
||||
@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 })
|
||||
@ApiResponse({ status: 201, description: 'CoachAssignment created' })
|
||||
@ApiResponse({ status: 201, description: 'Coach assigned' })
|
||||
@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')
|
||||
@ApiOperation({ summary: 'Remove a coach assignment from a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'CoachAssignment UUID' })
|
||||
@ApiOperation({ summary: 'Remove a coach assignment' })
|
||||
@ApiParam({ name: 'id', description: 'Assignment UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Assignment removed' })
|
||||
@ApiResponse({ status: 404, description: 'Assignment not found' })
|
||||
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); }
|
||||
removeAssignment(@Param('id') id: string) {
|
||||
return this.service.removeAssignment(id);
|
||||
}
|
||||
|
||||
@Get('analytics')
|
||||
@ApiOperation({ summary: 'Fleet analytics: train count, schedule count, seat occupancy rate' })
|
||||
@ApiResponse({ status: 200, description: 'Returns totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate' })
|
||||
getAnalytics() { return this.service.getAnalytics(); }
|
||||
@ApiOperation({ summary: 'Fleet analytics and occupancy metrics' })
|
||||
@ApiResponse({ status: 200, description: 'Occupancy statistics' })
|
||||
getAnalytics() {
|
||||
return this.service.getAnalytics();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,41 +10,48 @@ export class CreateTrainDto {
|
||||
}
|
||||
|
||||
export class CreateCoachDto {
|
||||
@ApiProperty({ example: 'C-A1', description: 'Unique physical coach identifier' }) @IsString() coachNumber: string;
|
||||
@ApiProperty({ example: 'A', description: 'Display label shown on tickets' }) @IsString() label: string;
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID this coach belongs to' }) @IsString() seatClassId: string;
|
||||
@ApiPropertyOptional({ example: 'sleeper', description: 'Coach type descriptor' }) @IsOptional() @IsString() coachType?: string;
|
||||
@ApiPropertyOptional({ example: 'seat', description: 'seat | bed | convertible. Determines which arrangement field is used for seat generation.' }) @IsOptional() @IsString() mode?: 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;
|
||||
@ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string;
|
||||
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string;
|
||||
@ApiProperty({ example: '2+2', description: 'Seat arrangement (e.g., "2+2", "3+2")' }) @IsString() arrangement: string;
|
||||
@ApiProperty({ example: 60, description: 'Total seat capacity' }) @IsInt() capacity: number;
|
||||
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string;
|
||||
}
|
||||
|
||||
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['coachNumber'] as const)) {}
|
||||
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {}
|
||||
|
||||
export class AssignCoachDto {
|
||||
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) @IsString() scheduleId: 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;
|
||||
@ApiPropertyOptional({ example: true, description: 'Whether this coach is operational for this schedule' }) @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[];
|
||||
@ApiProperty({ example: 1, description: 'Position in the train consist' }) @IsInt() positionNumber: number;
|
||||
@ApiPropertyOptional({ example: true, description: 'Whether this coach is operational' }) @IsOptional() @IsBoolean() isOperational?: boolean;
|
||||
}
|
||||
|
||||
export class ListCoachesDto {
|
||||
@ApiPropertyOptional({ example: true, description: 'Filter by active/inactive status. Omit to return all.' })
|
||||
@IsOptional() @IsBoolean() isActive?: boolean;
|
||||
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Filter by status: ACTIVE, INACTIVE' })
|
||||
@IsOptional() @IsString() status?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'seat', description: 'Filter by mode: seat | bed | convertible' })
|
||||
@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' })
|
||||
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Filter coaches assigned to this schedule' })
|
||||
@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)) {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
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';
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// Derives column labels from a seat-mode arrangement string.
|
||||
// '2+2' → ['A','B','C','D'] (A/D window, B/C aisle)
|
||||
// '1+2+1' → ['A','B','C','D']
|
||||
// Derives column labels from arrangement: '2+2' → ['A','B','C','D']
|
||||
function seatCols(arrangement: string): string[] {
|
||||
const groups = parseArrangement(arrangement);
|
||||
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 {
|
||||
const total = groups.reduce((s, n) => s + n, 0);
|
||||
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 {
|
||||
let cursor = 0;
|
||||
for (const g of groups) {
|
||||
@@ -35,82 +33,180 @@ function isAisleCol(colIndex: number, groups: number[]): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bed positions for a given tier count: 2 → lower/upper, 3 → lower/middle/upper
|
||||
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[] {
|
||||
function buildSeats(coachId: string, coachNumber: string, arrangement: string, capacity: number, seatClass?: string): SeatRow[] {
|
||||
const cols = seatCols(arrangement);
|
||||
const groups = parseArrangement(arrangement);
|
||||
const seats: SeatRow[] = [];
|
||||
let row = 1;
|
||||
while (seats.length < totalUnits) {
|
||||
for (let ci = 0; ci < cols.length && seats.length < totalUnits; ci++) {
|
||||
let seatNumber = 1;
|
||||
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];
|
||||
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({
|
||||
coachId, row, col,
|
||||
label: `${row}${col}`,
|
||||
seatNumber: `${coachLabel}${row}${col}`,
|
||||
coachId,
|
||||
row,
|
||||
col,
|
||||
seatNumber: `${seatNumber}`,
|
||||
kind: SeatKind.STANDARD,
|
||||
isWindow: isWindowCol(ci, groups),
|
||||
isAisle: isAisleCol(ci, groups),
|
||||
bedPosition,
|
||||
});
|
||||
seatNumber++;
|
||||
seatIndex++;
|
||||
}
|
||||
row++;
|
||||
}
|
||||
return seats;
|
||||
}
|
||||
|
||||
function buildBedSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] {
|
||||
// arrangement for beds describes tiers per berth, e.g. '2+2' = 2 lower+upper on each side
|
||||
// Each compartment number is the row; each tier is the col (L=lower, M=middle, U=upper)
|
||||
const groups = parseArrangement(arrangement);
|
||||
const tiersPerSide = groups[0]; // e.g. 2 → lower+upper
|
||||
const positions = BED_POSITIONS[tiersPerSide] ?? ['lower', 'upper'];
|
||||
const tierCols = positions.map((_, i) => String.fromCharCode(65 + i)); // A=lower, B=upper, C=middle
|
||||
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;
|
||||
}
|
||||
type SeatRow = {
|
||||
coachId: string;
|
||||
row: number;
|
||||
col: string;
|
||||
seatNumber: string;
|
||||
kind: SeatKind;
|
||||
bedPosition?: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FleetService {
|
||||
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() {
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
|
||||
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) {
|
||||
const train = await this.prisma.train.findUnique({ where: { id } });
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
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) {
|
||||
const coach = await this.prisma.coach.findUnique({ where: { id } });
|
||||
if (!coach) throw new NotFoundException('Coach not found');
|
||||
|
||||
// Get all seat IDs for this coach
|
||||
const seats = await this.prisma.seat.findMany({ where: { coachId: id }, select: { id: true } });
|
||||
const seatIds = seats.map(s => s.id);
|
||||
|
||||
// Delete in order of foreign key dependencies
|
||||
if (seatIds.length > 0) {
|
||||
// 1. Delete seat blocks (references seats)
|
||||
await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } });
|
||||
|
||||
// 2. Delete ticket seats (references seats)
|
||||
await this.prisma.ticketSeat.deleteMany({ where: { seatId: { in: seatIds } } });
|
||||
|
||||
// 3. Delete booking seats (references seats)
|
||||
await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } });
|
||||
|
||||
// 4. Delete journey segments with these seats
|
||||
await this.prisma.journeySegment.deleteMany({ where: { seatId: { in: seatIds } } });
|
||||
}
|
||||
|
||||
// 5. Delete all associated seats
|
||||
await this.prisma.seat.deleteMany({ where: { coachId: id } });
|
||||
|
||||
// 6. Delete coach assignments
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { coachId: id } });
|
||||
|
||||
// 7. Finally delete the coach
|
||||
return this.prisma.coach.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -251,19 +339,6 @@ export class FleetService {
|
||||
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() {
|
||||
const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([
|
||||
this.prisma.train.count(),
|
||||
@@ -271,6 +346,12 @@ export class FleetService {
|
||||
this.prisma.seat.count(),
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,22 +92,22 @@ export class PassengersService {
|
||||
}
|
||||
|
||||
async getProfile(passengerId: string) {
|
||||
const p = await this.prisma.passenger.findUnique({
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { id: passengerId },
|
||||
include: {
|
||||
user: { select: { fullName: true, email: true, phone: true } },
|
||||
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { 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,
|
||||
},
|
||||
});
|
||||
if (!p) throw new NotFoundException('Passenger not found');
|
||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||
return {
|
||||
id: p.id,
|
||||
fullName: p.user.fullName,
|
||||
email: p.user.email,
|
||||
phone: p.user.phone,
|
||||
createdAt: p.createdAt,
|
||||
bookings: p.bookings.map((b) => ({
|
||||
id: passenger.id,
|
||||
fullName: passenger.user.fullName,
|
||||
email: passenger.user.email,
|
||||
phone: passenger.user.phone,
|
||||
createdAt: passenger.createdAt,
|
||||
bookings: passenger.bookings.map((b) => ({
|
||||
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,
|
||||
trip: {
|
||||
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 },
|
||||
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;
|
||||
let verifiedData: any = null;
|
||||
|
||||
// Auto-verify Ethiopian passengers with national ID if Fayda is enabled
|
||||
if (isEthiopian && dto.verifyWithFayda !== false) {
|
||||
try {
|
||||
const verification = await this.verifaydaService.verifyNationalId(dto.nationalId!);
|
||||
@@ -215,12 +214,10 @@ export class PassengersService {
|
||||
verifiedData = verification.passengerData;
|
||||
}
|
||||
} catch (error) {
|
||||
// If verification fails, continue with manual data
|
||||
console.warn('Fayda verification failed, using manual data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Use verified data if available, otherwise use provided data
|
||||
const finalData = {
|
||||
passengerName: verifiedData?.fullName || dto.passengerName,
|
||||
dateOfBirth: verifiedData?.dateOfBirth || new Date(dto.dateOfBirth),
|
||||
@@ -230,7 +227,6 @@ export class PassengersService {
|
||||
email: dto.email,
|
||||
};
|
||||
|
||||
// If logged in, update user profile and link passenger
|
||||
if (isLoggedIn) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: dto.userId },
|
||||
@@ -241,7 +237,6 @@ export class PassengersService {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
// Update user record if not already verified
|
||||
if (!user.faydaVerified && verifiedData) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: dto.userId },
|
||||
@@ -267,7 +262,6 @@ export class PassengersService {
|
||||
};
|
||||
}
|
||||
|
||||
// Guest user - save to SavedPassengerProfile
|
||||
const profile = await this.prisma.savedPassengerProfile.create({
|
||||
data: {
|
||||
deviceId: dto.deviceId,
|
||||
@@ -318,4 +312,4 @@ export class PassengersService {
|
||||
affectedModules: usage,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
const seatClass = await prisma.seatClass.upsert({
|
||||
where: { name: 'Economy Regular' },
|
||||
update: {},
|
||||
create: { name: 'Economy Regular', description: 'Standard economy seating', basePrice: 45000, isActive: true },
|
||||
const coachType = await prisma.coachType.create({ data: { name: 'Standard', code: 'STD' } });
|
||||
|
||||
const seatClass = await prisma.seatClass.create({
|
||||
data: { name: 'Economy Regular', description: 'Standard economy seating', baseFareMinor: 45000, isActive: true, coachTypeId: coachType.id },
|
||||
});
|
||||
|
||||
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', label: '1A', status: 'AVAILABLE' } });
|
||||
const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', seatNumber: '1A', status: 'AVAILABLE' } });
|
||||
|
||||
const booking = await prisma.booking.create({
|
||||
data: { bookingRef: 'TEST-BOOK-001', passengerId: passenger.id, scheduleId: schedule.id, status: 'PENDING_PAYMENT', totalMinor: 50000, currency: 'ETB' },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { 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 { TripStatus } from '@prisma/client';
|
||||
|
||||
@@ -10,14 +10,23 @@ import { TripStatus } from '@prisma/client';
|
||||
export class SchedulesController {
|
||||
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()
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Create a train schedule from a route template',
|
||||
description: `Creates a schedule by referencing a Route (routeId).
|
||||
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.`,
|
||||
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.`,
|
||||
})
|
||||
@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' })
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
// Static routes before parameterised ones
|
||||
// ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
|
||||
|
||||
@Post('fares')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
|
||||
@ApiResponse({ status: 201, description: 'Fare rule created' })
|
||||
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')
|
||||
@ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' })
|
||||
@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')
|
||||
@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' })
|
||||
@ApiResponse({ status: 200, description: 'Schedule updated' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
updateSchedule(@Param('id') id: string, @Body() dto: CreateScheduleDto) {
|
||||
return this.service.updateSchedule(id, dto);
|
||||
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
|
||||
return this.service.updateSchedulePartial(id, dto);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
// ── Stop Times ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(':id/stops')
|
||||
@ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' })
|
||||
@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,
|
||||
) { return this.service.updateStop(id, sequence, dto); }
|
||||
|
||||
// ── Fares ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(':scheduleId/fares')
|
||||
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
|
||||
@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);
|
||||
}
|
||||
|
||||
// ── Coach Assignments ──────────────────────────────────────────────────────
|
||||
|
||||
@Post(':id/coaches')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
|
||||
@@ -34,6 +34,13 @@ export class CreateScheduleDto {
|
||||
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 {
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: 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;
|
||||
}
|
||||
|
||||
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 {
|
||||
@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;
|
||||
@@ -67,3 +85,21 @@ export class ListSchedulesDto {
|
||||
export class UpdateScheduleStatusDto {
|
||||
@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[];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { RoutesService } from './routes.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()
|
||||
export class SchedulesService {
|
||||
@@ -10,9 +10,55 @@ export class SchedulesService {
|
||||
private prisma: PrismaService,
|
||||
private routesService: RoutesService,
|
||||
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) {
|
||||
const where: any = {};
|
||||
@@ -58,28 +104,48 @@ export class SchedulesService {
|
||||
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');
|
||||
|
||||
// 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
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
|
||||
|
||||
if (index === 0) {
|
||||
// First stop - use departure time
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
// Last stop - use arrival time
|
||||
stopTime = arr;
|
||||
} else {
|
||||
// Intermediate stops - calculate based on distance proportion
|
||||
const stopDistance = stop.distanceKm || 0;
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
@@ -113,7 +179,6 @@ export class SchedulesService {
|
||||
include: { train: true, originStation: true, destinationStation: true },
|
||||
});
|
||||
|
||||
// Copy route stops into TripStopTime with the provided planned times
|
||||
const plannedTimesMap = Object.fromEntries(
|
||||
plannedTimes.map(t => [t.sequence, t]),
|
||||
);
|
||||
@@ -130,7 +195,7 @@ export class SchedulesService {
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
coachAssignments: {
|
||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
|
||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
},
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
@@ -138,18 +203,16 @@ export class SchedulesService {
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
// Compute effective seat statuses from SeatHold + JourneySegment
|
||||
// (seat.status DB column is no longer written during booking)
|
||||
const allSeatIds = schedule.coachAssignments.flatMap(a => a.coach.seats.map(s => s.id));
|
||||
const allSeatIds = schedule.coachAssignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(id, allSeatIds);
|
||||
|
||||
return {
|
||||
...schedule,
|
||||
coachAssignments: schedule.coachAssignments.map(a => ({
|
||||
coachAssignments: schedule.coachAssignments.map((a: any) => ({
|
||||
...a,
|
||||
coach: {
|
||||
...a.coach,
|
||||
seats: a.coach.seats.map(s => ({
|
||||
seats: a.coach.seats.map((s: any) => ({
|
||||
...s,
|
||||
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(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
@@ -204,7 +261,6 @@ export class SchedulesService {
|
||||
const arr = new Date(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
|
||||
// Validate route exists and has stops
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
@@ -213,7 +269,6 @@ export class SchedulesService {
|
||||
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');
|
||||
|
||||
// Derive origin and destination from first and last route stop
|
||||
const firstStop = route.stops[0];
|
||||
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 } });
|
||||
|
||||
// Auto-generate plannedTimes if not provided
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
|
||||
|
||||
if (index === 0) {
|
||||
stopTime = dep;
|
||||
} 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);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
@@ -276,19 +329,53 @@ export class SchedulesService {
|
||||
async deleteSchedule(id: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||
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.seatHold.deleteMany({ where: { scheduleId: id } });
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: { scheduleId: id },
|
||||
select: { id: true },
|
||||
});
|
||||
const bookingIds = bookings.map(b => b.id);
|
||||
|
||||
if (bookingIds.length > 0) {
|
||||
const paymentIntents = await this.prisma.paymentIntent.findMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const paymentIntentIds = paymentIntents.map(pi => pi.id);
|
||||
|
||||
if (paymentIntentIds.length > 0) {
|
||||
await this.prisma.paymentRefund.deleteMany({
|
||||
where: { paymentIntentId: { in: paymentIntentIds } },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.ticket.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
await this.prisma.bookingSeat.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
await this.prisma.bookingModification.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
await this.prisma.bookingCancellation.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
await this.prisma.paymentIntent.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.booking.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||
|
||||
|
||||
return this.prisma.trainSchedule.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// ── Stop Times (per-schedule overrides) ───────────────────────────────────
|
||||
|
||||
getStops(scheduleId: string) {
|
||||
return this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
@@ -314,8 +401,6 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Fare Rules ─────────────────────────────────────────────────────────────
|
||||
|
||||
createFareRule(dto: CreateFareRuleDto) {
|
||||
const { validFrom, validUntil, scheduleId, nationality, ...rest } = dto;
|
||||
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) {
|
||||
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
|
||||
}
|
||||
@@ -337,10 +459,6 @@ export class SchedulesService {
|
||||
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[] }> {
|
||||
const results = await this.fareEngine.calculateAllForSchedule(scheduleId);
|
||||
const errors: string[] = [];
|
||||
@@ -352,7 +470,6 @@ export class SchedulesService {
|
||||
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: fare.seatClassName } });
|
||||
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({
|
||||
where: { tripId: scheduleId, seatClassId: seatClass.id, validUntil: null },
|
||||
data: { validUntil: now },
|
||||
@@ -377,8 +494,6 @@ export class SchedulesService {
|
||||
return { synced, errors };
|
||||
}
|
||||
|
||||
// ── Coach Assignments ──────────────────────────────────────────────────────
|
||||
|
||||
async assignCoaches(
|
||||
scheduleId: string,
|
||||
coaches: Array<{ coachId: string; positionNumber: number }>,
|
||||
@@ -386,7 +501,6 @@ export class SchedulesService {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
// Validate all coaches exist
|
||||
const coachIds = coaches.map(c => c.coachId);
|
||||
const existingCoaches = await this.prisma.coach.findMany({
|
||||
where: { id: { in: coachIds } },
|
||||
@@ -395,18 +509,16 @@ export class SchedulesService {
|
||||
throw new NotFoundException('One or more coaches not found');
|
||||
}
|
||||
|
||||
// Remove existing assignments
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
|
||||
|
||||
// Create new assignments
|
||||
await this.prisma.coachAssignment.createMany({
|
||||
data: coaches.map(c => ({
|
||||
scheduleId,
|
||||
coachId: c.coachId,
|
||||
positionNumber: c.positionNumber,
|
||||
isOperational: true,
|
||||
})),
|
||||
});
|
||||
const data = coaches.map((c, idx) => ({
|
||||
scheduleId,
|
||||
coachId: c.coachId,
|
||||
positionNumber: idx + 1,
|
||||
isOperational: true,
|
||||
}));
|
||||
|
||||
await this.prisma.coachAssignment.createMany({ data });
|
||||
|
||||
return { message: 'Coaches assigned successfully', count: coaches.length };
|
||||
}
|
||||
@@ -417,7 +529,6 @@ export class SchedulesService {
|
||||
include: {
|
||||
coach: {
|
||||
include: {
|
||||
seatClass: true,
|
||||
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) {
|
||||
const assignment = await this.prisma.coachAssignment.findFirst({
|
||||
where: { scheduleId, coachId },
|
||||
@@ -435,4 +581,4 @@ export class SchedulesService {
|
||||
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
|
||||
return { message: 'Coach assignment removed' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,6 @@ export class SearchService {
|
||||
const nextDay = new Date(date.getTime() + 86_400_000);
|
||||
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({
|
||||
where: {
|
||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||
@@ -36,7 +34,7 @@ export class SearchService {
|
||||
destinationStation: true,
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
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 = [];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
|
||||
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;
|
||||
|
||||
// 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> = {};
|
||||
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
const className = assignment.coach.seatClass.name;
|
||||
if (!availabilityByClass[className]) availabilityByClass[className] = 0;
|
||||
// Get seat class names from coach type
|
||||
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) {
|
||||
// Skip blocked seats
|
||||
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(
|
||||
schedule.id, seat.id,
|
||||
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 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(
|
||||
schedule,
|
||||
dto.originStationId,
|
||||
@@ -106,14 +114,14 @@ export class SearchService {
|
||||
),
|
||||
status: schedule.status,
|
||||
stops: schedule.stopTimes
|
||||
.filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
|
||||
.map(st => ({
|
||||
stationId: st.stationId,
|
||||
stationName: st.station.name,
|
||||
sequence: st.sequence,
|
||||
plannedArrivalAt: st.plannedArrivalAt,
|
||||
plannedDepartureAt: st.plannedDepartureAt,
|
||||
})),
|
||||
.filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
|
||||
.map((st: any) => ({
|
||||
stationId: st.stationId,
|
||||
stationName: st.station.name,
|
||||
sequence: st.sequence,
|
||||
plannedArrivalAt: st.plannedArrivalAt,
|
||||
plannedDepartureAt: st.plannedDepartureAt,
|
||||
})),
|
||||
availabilityByClass,
|
||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||
faresByClass,
|
||||
@@ -134,51 +142,19 @@ export class SearchService {
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
|
||||
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
|
||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) {
|
||||
throw new NotFoundException('Origin or destination not found on this schedule');
|
||||
}
|
||||
|
||||
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 fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
||||
const now = new Date();
|
||||
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({
|
||||
where: {
|
||||
seatClassId: seatClass?.id,
|
||||
@@ -243,38 +219,40 @@ export class SearchService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate fares for a specific segment of a schedule
|
||||
*/
|
||||
private async calculateFaresForSegment(
|
||||
schedule: any,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
nationality?: string,
|
||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
|
||||
// Get seat classes that are actually assigned to this schedule via coaches
|
||||
const assignedSeatClassIds: string[] = Array.from(
|
||||
// Get unique seat classes from all coaches assigned to this schedule via their coach types
|
||||
const seatClassIds: string[] = Array.from(
|
||||
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
|
||||
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) {
|
||||
if (seatClassIds.length === 0) {
|
||||
console.log(`No seat classes assigned to schedule ${schedule.id}`);
|
||||
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) {
|
||||
const results = await Promise.all(
|
||||
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 destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
|
||||
|
||||
@@ -314,26 +291,25 @@ export class SearchService {
|
||||
const fareRules = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
route: segmentRoute,
|
||||
seatClassId: { in: assignedSeatClassIds },
|
||||
seatClassId: { in: seatClassIds },
|
||||
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}`);
|
||||
const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name]));
|
||||
return fareRules.map(rule => ({
|
||||
seatClassName: rule.seatClass.name,
|
||||
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: Return default fares only for assigned seat classes
|
||||
console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
|
||||
return seatClasses.map(sc => ({
|
||||
seatClassName: sc.name,
|
||||
@@ -359,60 +335,6 @@ export class SearchService {
|
||||
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(
|
||||
candidates: any[],
|
||||
scheduleId: string,
|
||||
@@ -421,19 +343,16 @@ export class SearchService {
|
||||
nationality?: string,
|
||||
): any | null {
|
||||
const priorities = [
|
||||
// Schedule-specific rules
|
||||
{ tripId: scheduleId, route: segmentRoute, nationality },
|
||||
{ tripId: scheduleId, route: segmentRoute, nationality: null },
|
||||
{ tripId: scheduleId, route: fullRoute, nationality },
|
||||
{ tripId: scheduleId, route: fullRoute, nationality: null },
|
||||
{ tripId: scheduleId, route: null, nationality },
|
||||
{ tripId: scheduleId, route: null, nationality: null },
|
||||
// Route-specific rules (no schedule)
|
||||
{ tripId: null, route: segmentRoute, nationality },
|
||||
{ tripId: null, route: segmentRoute, nationality: null },
|
||||
{ tripId: null, route: fullRoute, nationality },
|
||||
{ tripId: null, route: fullRoute, nationality: null },
|
||||
// Global rules
|
||||
{ tripId: null, route: null, nationality },
|
||||
{ tripId: null, route: null, nationality: null },
|
||||
];
|
||||
|
||||
@@ -1,41 +1,33 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SeatClassesService {
|
||||
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() {
|
||||
return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' }, include: this.coachInclude });
|
||||
return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' } });
|
||||
}
|
||||
|
||||
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');
|
||||
return sc;
|
||||
}
|
||||
|
||||
async createSeatClass(dto: CreateSeatClassDto) {
|
||||
async createSeatClass(dto: any) {
|
||||
try {
|
||||
return await this.prisma.seatClass.create({ data: dto, include: this.coachInclude });
|
||||
return await this.prisma.seatClass.create({ data: dto });
|
||||
} catch (e: any) {
|
||||
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async updateSeatClass(id: string, dto: UpdateSeatClassDto) {
|
||||
async updateSeatClass(id: string, dto: any) {
|
||||
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||
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) {
|
||||
|
||||
@@ -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 { SeatsService } from './seats.service';
|
||||
import { HoldSeatsDto } from './seats.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
|
||||
@ApiTags('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' })
|
||||
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' })
|
||||
async exportCSV(@Param('scheduleId') scheduleId: string) {
|
||||
const csv = await this.service.exportSeatsCSV(scheduleId);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { SeatsController } from './seats.controller';
|
||||
import { SeatsService } from './seats.service';
|
||||
import { SegmentsModule } from '../segments/segments.module';
|
||||
import { IamModule } from '../../common/iam.module';
|
||||
|
||||
@Module({
|
||||
imports: [SegmentsModule],
|
||||
imports: [SegmentsModule, HttpModule, IamModule],
|
||||
controllers: [SeatsController],
|
||||
providers: [SeatsService],
|
||||
exports: [SeatsService],
|
||||
|
||||
@@ -11,48 +11,69 @@ export class SeatsService {
|
||||
private segmentsService: SegmentsService,
|
||||
) {}
|
||||
|
||||
// ── Seat Map ──────────────────────────────────────────────────────────────
|
||||
async getSeatMap(scheduleId: string, coachId?: string) {
|
||||
const assignments = await this.prisma.coachAssignment.findMany({
|
||||
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' },
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
return {
|
||||
coaches: assignments.map((a) => ({
|
||||
id: a.coach.id,
|
||||
assignmentId: a.id,
|
||||
name: `Coach ${a.coach.label}`,
|
||||
seatClass: a.coach.seatClass.name,
|
||||
positionNumber: a.positionNumber,
|
||||
seats: a.coach.seats.map((s) => ({
|
||||
id: s.id,
|
||||
number: s.label,
|
||||
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,
|
||||
})),
|
||||
})),
|
||||
const response = {
|
||||
coaches: assignments.map((a) => {
|
||||
// Include all seats (both valid and removed with negative seatNumbers)
|
||||
const allSeats = a.coach.seats;
|
||||
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
|
||||
const seatClass = seatClassNames.length > 0 ? seatClassNames[0] : 'Standard';
|
||||
|
||||
return {
|
||||
id: a.coach.id,
|
||||
assignmentId: a.id,
|
||||
coachNumber: a.coach.number,
|
||||
label: a.coach.number,
|
||||
mode: a.coach.status,
|
||||
name: `Coach ${a.coach.number}`,
|
||||
seatClass,
|
||||
positionNumber: a.positionNumber,
|
||||
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(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
@@ -61,7 +82,6 @@ export class SeatsService {
|
||||
|
||||
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({
|
||||
where: {
|
||||
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({
|
||||
where: {
|
||||
scheduleId,
|
||||
@@ -93,24 +111,21 @@ export class SeatsService {
|
||||
return statusMap;
|
||||
}
|
||||
|
||||
// ── Hold / Release ────────────────────────────────────────────────────────
|
||||
async holdSeats(dto: HoldSeatsDto) {
|
||||
// ── Validate request integrity ───────────────────────────────────────────
|
||||
const passengerIds = dto.passengers.map(p => p.passengerId);
|
||||
const seatIds = dto.passengers.map(p => p.seatId);
|
||||
|
||||
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)
|
||||
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 hold = await this.prisma.$transaction(async (tx) => {
|
||||
// ── 1. Validate seats exist and none are BLOCKED ─────────────────────
|
||||
const seats = await tx.seat.findMany({
|
||||
where: { id: { in: seatIds } },
|
||||
select: { id: true, status: true, label: true },
|
||||
select: { id: true, status: true, seatNumber: true },
|
||||
});
|
||||
|
||||
if (seats.length !== seatIds.length) {
|
||||
@@ -121,11 +136,10 @@ export class SeatsService {
|
||||
|
||||
const blocked = seats.filter(s => s.status === 'BLOCKED');
|
||||
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({
|
||||
where: { scheduleId: dto.scheduleId },
|
||||
select: { stationId: true, sequence: true },
|
||||
@@ -137,17 +151,15 @@ export class SeatsService {
|
||||
const reqTo = seqOf(dto.destinationStationId);
|
||||
|
||||
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)
|
||||
throw new BadRequestException('Origin must come before destination');
|
||||
|
||||
// ── 3. Load active holds for this schedule ───────────────────────────
|
||||
const activeHolds = await tx.seatHold.findMany({
|
||||
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
|
||||
select: { seatIds: true, createdBy: true },
|
||||
});
|
||||
|
||||
// Parse each hold's leg range and passenger list
|
||||
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = [];
|
||||
for (const h of activeHolds) {
|
||||
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 hold of parsedHolds) {
|
||||
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)) {
|
||||
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)) {
|
||||
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 = {
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
@@ -228,12 +236,7 @@ export class SeatsService {
|
||||
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) {
|
||||
// Decode leg and passenger→seat mapping from createdBy JSON
|
||||
let originStationId: string | null = null;
|
||||
let destinationStationId: string | null = null;
|
||||
let passengerSeatMap: { passengerId: string; seatId: string }[] = [];
|
||||
@@ -241,7 +244,6 @@ export class SeatsService {
|
||||
try {
|
||||
if (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('{')) {
|
||||
const meta = JSON.parse(raw);
|
||||
originStationId = meta.originStationId ?? null;
|
||||
@@ -249,7 +251,7 @@ export class SeatsService {
|
||||
passengerSeatMap = Array.isArray(meta.passengers) ? meta.passengers : [];
|
||||
}
|
||||
}
|
||||
} catch { /* ignore malformed createdBy */ }
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const seatIds = hold.seatIds as string[];
|
||||
|
||||
@@ -262,7 +264,7 @@ export class SeatsService {
|
||||
destinationStationId ? this.prisma.station.findUnique({ where: { id: destinationStationId } }) : null,
|
||||
this.prisma.seat.findMany({
|
||||
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;
|
||||
}
|
||||
|
||||
// Build seat map keyed by seatId for quick lookup
|
||||
const seatById = Object.fromEntries(seats.map(s => [s.id, s]));
|
||||
|
||||
// Merge passenger→seat mapping with seat details
|
||||
const passengers = passengerSeatMap.length > 0
|
||||
? passengerSeatMap.map(({ passengerId, seatId }) => {
|
||||
const s = seatById[seatId];
|
||||
@@ -288,26 +288,25 @@ export class SeatsService {
|
||||
passengerId,
|
||||
seat: s ? {
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
label: s.seatNumber,
|
||||
seatNumber: s.seatNumber,
|
||||
coach: s.coach.label,
|
||||
seatClass: s.coach.seatClass.name,
|
||||
coach: s.coach.number,
|
||||
seatClass: 'Standard',
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
} : { id: seatId },
|
||||
};
|
||||
})
|
||||
// Fallback for holds created before this change
|
||||
: seatIds.map(seatId => {
|
||||
const s = seatById[seatId];
|
||||
return {
|
||||
passengerId: hold.passengerId,
|
||||
seat: s ? {
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
label: s.seatNumber,
|
||||
seatNumber: s.seatNumber,
|
||||
coach: s.coach.label,
|
||||
seatClass: s.coach.seatClass.name,
|
||||
coach: s.coach.number,
|
||||
seatClass: 'Standard',
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
} : { id: seatId },
|
||||
@@ -350,8 +349,7 @@ export class SeatsService {
|
||||
}
|
||||
|
||||
async confirmSeats(seatIds: string[]) {
|
||||
// No-op for status — availability is segment-scoped via JourneySegment
|
||||
// seat.status = BLOCKED is the only hard gate; BOOKED is not used as a booking flag
|
||||
// No-op
|
||||
}
|
||||
|
||||
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({
|
||||
where: {
|
||||
coach: { seatClass: { name: seatClassName }, assignments: { some: { scheduleId } } },
|
||||
coach: { assignments: { some: { scheduleId } } },
|
||||
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) {
|
||||
@@ -404,10 +403,10 @@ export class SeatsService {
|
||||
where: { scheduleId },
|
||||
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 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');
|
||||
@@ -426,8 +425,8 @@ export class SeatsService {
|
||||
invalid++;
|
||||
continue;
|
||||
}
|
||||
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor] = parts;
|
||||
if (!coachId || !row || !col || !label) {
|
||||
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
|
||||
if (!coachId || !row || !col || !seatNumber) {
|
||||
errors.push(`Line ${i + 2}: Missing required fields`);
|
||||
invalid++;
|
||||
continue;
|
||||
@@ -444,32 +443,30 @@ export class SeatsService {
|
||||
let imported = 0;
|
||||
|
||||
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++) {
|
||||
try {
|
||||
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({
|
||||
where: { coachId_row_col: { coachId, row: parseInt(row), col } },
|
||||
update: {
|
||||
label,
|
||||
seatNumber,
|
||||
kind: kind as any,
|
||||
status: status as any,
|
||||
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
|
||||
eligibility: eligibility || null,
|
||||
},
|
||||
create: {
|
||||
coachId,
|
||||
row: parseInt(row),
|
||||
col,
|
||||
label,
|
||||
seatNumber,
|
||||
kind: kind as any,
|
||||
status: status as any,
|
||||
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
|
||||
eligibility: eligibility || null,
|
||||
},
|
||||
});
|
||||
imported++;
|
||||
@@ -481,6 +478,74 @@ export class SeatsService {
|
||||
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)
|
||||
async expireHolds() {
|
||||
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
|
||||
@@ -489,7 +554,6 @@ export class SeatsService {
|
||||
try {
|
||||
await this.prisma.seatHold.delete({ where: { id: hold.id } });
|
||||
} catch (err) {
|
||||
// Ignore if already deleted (e.g., by another process)
|
||||
if (err instanceof Error && !err.message.includes('P2025')) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ async function holdSeatsTransaction(scheduleId: string, seatIds: string[], passe
|
||||
|
||||
for (const seat of seats) {
|
||||
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})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,14 +35,12 @@ export class EnhancedSeatsService {
|
||||
for (const seatId of request.seatIds) {
|
||||
const seat = await tx.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new BadRequestException(`Seat ${seatId} not found`);
|
||||
// Only BLOCKED seats are hard-rejected — BOOKED/HELD are fine if the
|
||||
// segment does not overlap (another passenger may occupy a different leg)
|
||||
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.label} is blocked`);
|
||||
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.seatNumber} is blocked`);
|
||||
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||
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);
|
||||
@@ -51,7 +49,6 @@ export class EnhancedSeatsService {
|
||||
scheduleId: request.scheduleId,
|
||||
seatIds: request.seatIds,
|
||||
passengerId: request.passengerId,
|
||||
// Store leg in createdBy JSON — no fareQuoteId needed
|
||||
createdBy: JSON.stringify({
|
||||
originStationId: request.originStationId,
|
||||
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 });
|
||||
return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds };
|
||||
});
|
||||
@@ -81,7 +77,6 @@ export class EnhancedSeatsService {
|
||||
});
|
||||
if (!schedule) throw new BadRequestException('Schedule not found');
|
||||
|
||||
// Resolve the passenger's leg from createdBy JSON
|
||||
let originStationId: string | undefined;
|
||||
let destinationStationId: string | undefined;
|
||||
try {
|
||||
@@ -92,15 +87,15 @@ export class EnhancedSeatsService {
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const originStop = originStationId ? schedule.stopTimes.find(s => s.stationId === originStationId) : undefined;
|
||||
const destStop = destinationStationId ? schedule.stopTimes.find(s => s.stationId === destinationStationId) : undefined;
|
||||
const originStop = originStationId ? schedule.stopTimes.find((s: any) => s.stationId === originStationId) : undefined;
|
||||
const destStop = destinationStationId ? schedule.stopTimes.find((s: any) => s.stationId === destinationStationId) : undefined;
|
||||
const fromSeq = originStop?.sequence ?? schedule.stopTimes[0].sequence;
|
||||
const toSeq = destStop?.sequence ?? schedule.stopTimes[schedule.stopTimes.length - 1].sequence;
|
||||
|
||||
const segments: Segment[] = [];
|
||||
for (let i = fromSeq; i < toSeq; i++) {
|
||||
const fromStop = schedule.stopTimes.find(s => s.sequence === i);
|
||||
const toStop = schedule.stopTimes.find(s => s.sequence === i + 1);
|
||||
const fromStop = schedule.stopTimes.find((s: any) => s.sequence === i);
|
||||
const toStop = schedule.stopTimes.find((s: any) => s.sequence === i + 1);
|
||||
if (fromStop && toStop) {
|
||||
segments.push({
|
||||
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 } });
|
||||
|
||||
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({
|
||||
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');
|
||||
|
||||
const availableSeats = [];
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
for (const seat of assignment.coach.seats) {
|
||||
// Hard-blocked seats are never available
|
||||
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);
|
||||
if (free) {
|
||||
availableSeats.push({
|
||||
id: seat.id, label: seat.label,
|
||||
coach: assignment.coach.label,
|
||||
seatClass: assignment.coach.seatClass.name,
|
||||
id: seat.id, label: seat.seatNumber,
|
||||
coach: assignment.coach.number,
|
||||
seatClass: 'Standard',
|
||||
row: seat.row, col: seat.col,
|
||||
kind: seat.kind,
|
||||
isWindow: seat.isWindow,
|
||||
|
||||
@@ -87,9 +87,15 @@ export class TicketsService {
|
||||
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);
|
||||
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({
|
||||
data: {
|
||||
seatId,
|
||||
@@ -162,7 +168,7 @@ export class TicketsService {
|
||||
id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||
fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name,
|
||||
departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name,
|
||||
coachLabel: seat?.seat.coach.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,
|
||||
barcodePayload: booking.ticket.barcodePayload
|
||||
};
|
||||
@@ -207,8 +213,8 @@ export class TicketsService {
|
||||
bookingRef: b.bookingRef,
|
||||
ticketId: b.ticket?.id,
|
||||
passengerName: b.seats[0]?.passengerName,
|
||||
seatLabel: b.seats[0]?.seat.label,
|
||||
coachLabel: b.seats[0]?.seat.coach.label,
|
||||
seatLabel: b.seats[0]?.seat.seatNumber,
|
||||
coachLabel: b.seats[0]?.seat.coach.number,
|
||||
qrPayload: b.ticket?.qrPayload,
|
||||
status: b.status,
|
||||
validatedAt: b.ticket?.validatedAt,
|
||||
|
||||
309
apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
Normal file
309
apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,141 +2,251 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
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 ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
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() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('coaches');
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingCoach, setEditingCoach] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; coach: any | null }>({ isOpen: false, coach: null });
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null }>({ isOpen: false, item: null });
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['coaches', search],
|
||||
queryFn: () => fleetApi.getCoaches({ search }),
|
||||
// Coach Types Queries
|
||||
const { data: coachTypesData, isLoading: typesLoading } = useQuery({
|
||||
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,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['coaches'] });
|
||||
setShowModal(false);
|
||||
setEditingCoach(null);
|
||||
setEditingItem(null);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
const updateCoachMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => fleetApi.updateCoach(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['coaches'] });
|
||||
setShowModal(false);
|
||||
setEditingCoach(null);
|
||||
setEditingItem(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
const deleteCoachMutation = useMutation({
|
||||
mutationFn: fleetApi.deleteCoach,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['coaches'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
const handleCoachTypeSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const coachData = {
|
||||
coachNumber: formData.get('coachNumber') as string,
|
||||
label: formData.get('label') as string,
|
||||
seatClassId: formData.get('seatClassId') 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',
|
||||
const data = {
|
||||
code: formData.get('code') as string,
|
||||
name: formData.get('name') as string,
|
||||
type: formData.get('type') as string,
|
||||
};
|
||||
|
||||
if (editingCoach) {
|
||||
await updateMutation.mutateAsync({ id: editingCoach.id, data: coachData });
|
||||
if (editingItem?.isCoachType) {
|
||||
await updateCoachTypeMutation.mutateAsync({ id: editingItem.id, data });
|
||||
} else {
|
||||
await createMutation.mutateAsync(coachData);
|
||||
await createCoachTypeMutation.mutateAsync(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (coach: any) => {
|
||||
setDeleteConfirm({ isOpen: true, coach });
|
||||
const handleCoachSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
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 () => {
|
||||
if (deleteConfirm.coach) {
|
||||
await deleteMutation.mutateAsync(deleteConfirm.coach.id);
|
||||
setDeleteConfirm({ isOpen: false, coach: null });
|
||||
if (deleteConfirm.item?.isCoachType) {
|
||||
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
|
||||
} 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',
|
||||
label: 'Coach Number',
|
||||
key: 'code',
|
||||
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,
|
||||
render: (coach: 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-medium">{coach.coachNumber}</span>
|
||||
<span className="font-medium">{coach.number || coach.coachNumber}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'seatClass',
|
||||
label: 'Seat Class',
|
||||
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',
|
||||
key: 'coachType',
|
||||
label: 'Coach Type',
|
||||
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',
|
||||
label: 'Layout',
|
||||
key: 'arrangement',
|
||||
label: 'Arrangement',
|
||||
render: (coach: any) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{coach.layout || coach.seatLayout || coach.seatArrangement || 'N/A'}
|
||||
</span>
|
||||
<span className="text-sm font-mono">{coach.arrangement || 'N/A'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'capacity',
|
||||
label: 'Capacity',
|
||||
render: (coach: any) => (
|
||||
<span className="font-mono text-sm font-medium">{coach.capacity || 0}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (coach: any) => {
|
||||
const status = coach.isActive ? 'ACTIVE' : 'INACTIVE';
|
||||
const statusMap: Record<string, string> = {
|
||||
ACTIVE: 'edr-badge-success',
|
||||
MAINTENANCE: 'edr-badge-warning',
|
||||
INACTIVE: 'edr-badge-danger',
|
||||
};
|
||||
const status = coach.status || 'ACTIVE';
|
||||
return (
|
||||
<span className={`edr-badge ${statusMap[status] || 'edr-badge-info'}`}>
|
||||
{status}
|
||||
@@ -146,11 +256,11 @@ export default function CoachesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
const coachTypeActions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
onClick: (coach: any) => {
|
||||
setEditingCoach(coach);
|
||||
onClick: (item: any) => {
|
||||
setEditingItem({ ...item, isCoachType: true });
|
||||
setShowModal(true);
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
@@ -158,7 +268,25 @@ export default function CoachesPage() {
|
||||
},
|
||||
{
|
||||
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,
|
||||
icon: Trash2,
|
||||
},
|
||||
@@ -169,51 +297,112 @@ export default function CoachesPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
onClick={() => {
|
||||
setEditingCoach(null);
|
||||
setEditingItem(null);
|
||||
setSearch('');
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
Add Coach
|
||||
{activeTab === 'types' ? 'Add Coach Type' : 'Add Coach'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search coaches..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex border-b border-border">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('types');
|
||||
setSearch('');
|
||||
}}
|
||||
className={`px-4 py-3 font-medium transition-colors ${
|
||||
activeTab === 'types'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
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>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={coaches}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
/>
|
||||
{/* Coach Types Tab */}
|
||||
{activeTab === 'types' && (
|
||||
<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 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>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => setDeleteConfirm({ isOpen: false, coach: null })}
|
||||
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Coach"
|
||||
message={`Are you sure you want to delete coach ${deleteConfirm.coach?.coachNumber}?`}
|
||||
title={`Delete ${deleteConfirm.item?.isCoachType ? 'Coach Type' : 'Coach'}`}
|
||||
message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`}
|
||||
confirmText="Delete"
|
||||
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 */}
|
||||
@@ -221,106 +410,170 @@ export default function CoachesPage() {
|
||||
isOpen={showModal}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingCoach(null);
|
||||
setEditingItem(null);
|
||||
}}
|
||||
title={`${editingCoach ? 'Edit' : 'Add'} Coach`}
|
||||
size="lg"
|
||||
title={
|
||||
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">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Coach Number *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="coachNumber"
|
||||
className="input"
|
||||
defaultValue={editingCoach?.coachNumber}
|
||||
required
|
||||
placeholder="e.g., C001"
|
||||
/>
|
||||
{/* Coach Type Form */}
|
||||
{activeTab === 'types' && (
|
||||
<form onSubmit={handleCoachTypeSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="label">Code</label>
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
className="input"
|
||||
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>
|
||||
<label className="label">Label *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="label"
|
||||
className="input"
|
||||
defaultValue={editingCoach?.label}
|
||||
required
|
||||
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'}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
}}
|
||||
>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
</select>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
type="submit"
|
||||
loading={createCoachTypeMutation.isPending || updateCoachTypeMutation.isPending}
|
||||
>
|
||||
{editingItem?.isCoachType ? 'Update' : 'Create'} Coach Type
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
setEditingCoach(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
type="submit"
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{editingCoach ? 'Update' : 'Create'} Coach
|
||||
</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Coach Form */}
|
||||
{activeTab === 'coaches' && (
|
||||
<form onSubmit={handleCoachSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Coach Type</label>
|
||||
<select
|
||||
name="coachTypeId"
|
||||
className="input"
|
||||
defaultValue={editingItem?.coachTypeId || ''}
|
||||
required
|
||||
>
|
||||
<option value="">Select Coach Type</option>
|
||||
{coachTypesArray.map((ct: any) => (
|
||||
<option key={ct.id} value={ct.id}>
|
||||
{ct.code} - {ct.name} - {ct.type}
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useAuthStore } from '@/lib/auth-store';
|
||||
import { Train } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('admin@edr-platform.com');
|
||||
const [password, setPassword] = useState('admin123');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function PassengersPage() {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
search: '',
|
||||
role: 'PASSENGER',
|
||||
});
|
||||
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
|
||||
|
||||
@@ -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 }) {
|
||||
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
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
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 Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
@@ -26,6 +26,7 @@ export default function RoutesPage() {
|
||||
const [destinationStationId, setDestinationStationId] = useState('');
|
||||
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null }>({ isOpen: false, route: null });
|
||||
const [search, setSearch] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
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 = [
|
||||
{
|
||||
label: 'Edit',
|
||||
@@ -238,6 +250,7 @@ export default function RoutesPage() {
|
||||
setDestinationStationId('');
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
setSearch('');
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
@@ -245,12 +258,23 @@ export default function RoutesPage() {
|
||||
</ActionButton>
|
||||
</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
|
||||
data={(routes as any)?.items || (Array.isArray(routes) ? routes : [])}
|
||||
data={displayedRoutes}
|
||||
columns={routeColumns}
|
||||
actions={routeActions}
|
||||
loading={routesLoading}
|
||||
emptyMessage="No routes found"
|
||||
emptyMessage={search ? "No routes match your search" : "No routes found"}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
@@ -275,6 +299,7 @@ export default function RoutesPage() {
|
||||
setDestinationStationId('');
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
setSearch('');
|
||||
}}
|
||||
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
|
||||
size="lg"
|
||||
@@ -516,6 +541,7 @@ export default function RoutesPage() {
|
||||
setDestinationStationId('');
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
setSearch('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -1,5 +1,54 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
'use client';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
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 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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,14 @@ import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { seatsApi, schedulesApi } from '@/lib/api';
|
||||
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 { Search, Armchair, Lock, Unlock, ChevronRight } from 'lucide-react';
|
||||
import { Armchair, Lock, Unlock, Bed, X, RotateCcw } from 'lucide-react';
|
||||
|
||||
export default function SeatsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedSchedule, setSelectedSchedule] = useState('');
|
||||
const [showBlockModal, setShowBlockModal] = useState(false);
|
||||
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||
const [selectedSeat, setSelectedSeat] = useState<any>(null);
|
||||
const [blockReason, setBlockReason] = useState('');
|
||||
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 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 () => {
|
||||
if (!blockReason.trim()) {
|
||||
alert('Please provide a reason for blocking');
|
||||
@@ -66,6 +93,10 @@ export default function SeatsPage() {
|
||||
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason });
|
||||
};
|
||||
|
||||
const submitRemoveSeat = async () => {
|
||||
await removeSeatMutation.mutateAsync(selectedSeat.id);
|
||||
};
|
||||
|
||||
const getSeatStatus = (seat: any) => {
|
||||
if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED';
|
||||
if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED';
|
||||
@@ -83,9 +114,221 @@ export default function SeatsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCoaches = coaches.filter((coach: any) =>
|
||||
search ? coach.coachNumber?.toLowerCase().includes(search.toLowerCase()) : true
|
||||
);
|
||||
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 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 (
|
||||
<div className="space-y-6">
|
||||
@@ -97,40 +340,25 @@ export default function SeatsPage() {
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<label className="label">Select Schedule</label>
|
||||
<select
|
||||
value={selectedSchedule}
|
||||
onChange={(e) => setSelectedSchedule(e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
<option value="">Select a schedule...</option>
|
||||
{schedules.map((schedule: any) => {
|
||||
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
||||
const routeName = schedule.route?.name || 'N/A';
|
||||
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||
return (
|
||||
<option key={schedule.id} value={schedule.id}>
|
||||
{trainNumber} - {routeName} - {date}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="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 className="mb-6">
|
||||
<label className="label">Select Schedule</label>
|
||||
<select
|
||||
value={selectedSchedule}
|
||||
onChange={(e) => setSelectedSchedule(e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
<option value="">Select a schedule...</option>
|
||||
{schedules.map((schedule: any) => {
|
||||
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
||||
const routeName = schedule.route?.name || 'N/A';
|
||||
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||
return (
|
||||
<option key={schedule.id} value={schedule.id}>
|
||||
{trainNumber} - {routeName} - {date}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!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>
|
||||
<p className="text-muted-foreground mt-3">Loading seats...</p>
|
||||
</div>
|
||||
) : filteredCoaches.length === 0 ? (
|
||||
) : coachesWithSeats.length === 0 ? (
|
||||
<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 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-2">
|
||||
<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>
|
||||
<span className="text-sm">Blocked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-dashed border-gray-400"></div>
|
||||
<span className="text-sm">Removed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coaches */}
|
||||
{filteredCoaches.map((coach: any) => {
|
||||
const seats = coach.seats || [];
|
||||
const seatClass = coach.seatClass?.name || 'N/A';
|
||||
const availableCount = seats.filter((s: any) => getSeatStatus(s) === 'AVAILABLE').length;
|
||||
const bookedCount = seats.filter((s: any) => getSeatStatus(s) === 'BOOKED').length;
|
||||
const blockedCount = seats.filter((s: any) => getSeatStatus(s) === 'BLOCKED').length;
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{coachesWithSeats.map((coach: any) => {
|
||||
const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) ||
|
||||
(coach.mode && coach.mode.toLowerCase().includes('bed'));
|
||||
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||
|
||||
return (
|
||||
<div key={coach.id} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
Coach {coach.coachNumber} - {coach.label}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{seatClass} • {seats.length} seats
|
||||
</p>
|
||||
return (
|
||||
<div key={coach.id} className="border rounded-lg p-3 bg-white dark:bg-card">
|
||||
<div className="mb-3">
|
||||
<h3 className="font-semibold text-sm">Coach {coach.coachNumber}</h3>
|
||||
</div>
|
||||
<div className="flex gap-3 text-sm">
|
||||
<span className="text-green-600">Available: {availableCount}</span>
|
||||
<span className="text-red-600">Booked: {bookedCount}</span>
|
||||
<span className="text-gray-600">Blocked: {blockedCount}</span>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-900/30 py-2 rounded-lg">
|
||||
{renderCoachSeats(coach, isBedCoach)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-8 gap-2">
|
||||
{seats.map((seat: any) => {
|
||||
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>
|
||||
|
||||
{/* Block Modal */}
|
||||
<Modal
|
||||
isOpen={showBlockModal}
|
||||
onClose={() => {
|
||||
@@ -293,6 +469,162 @@ export default function SeatsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
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 ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
@@ -15,6 +15,7 @@ import { formatDate } from '@/lib/utils';
|
||||
export default function TrainsPage() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
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 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 = [
|
||||
{
|
||||
key: 'number',
|
||||
@@ -168,13 +182,27 @@ export default function TrainsPage() {
|
||||
</ActionButton>
|
||||
</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 */}
|
||||
<DataTable
|
||||
data={trainsData?.items || []}
|
||||
data={filteredTrains}
|
||||
columns={trainColumns}
|
||||
actions={actions}
|
||||
loading={trainsLoading}
|
||||
emptyMessage="No trains found"
|
||||
emptyMessage={search ? "No trains match your search" : "No trains found"}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
|
||||
@@ -57,12 +57,12 @@ const navigationSections = [
|
||||
title: 'Master Data',
|
||||
items: [
|
||||
{ name: 'Stations', href: '/stations', icon: MapPin },
|
||||
{ name: 'Routes', href: '/routes', icon: Route },
|
||||
{ name: 'Trains', href: '/trains', icon: Train },
|
||||
{ name: 'Coaches', href: '/coaches', icon: Grid3x3 },
|
||||
{ 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: 'Classes', href: '/seat-classes', icon: Settings },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,8 +7,12 @@ interface PaginationProps {
|
||||
}
|
||||
|
||||
export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
||||
if (totalPages <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
@@ -27,7 +31,7 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
|
||||
</div>
|
||||
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
<p className="text-sm text-foreground">
|
||||
Page <span className="font-medium">{currentPage}</span> of{' '}
|
||||
<span className="font-medium">{totalPages}</span>
|
||||
</p>
|
||||
@@ -51,4 +55,4 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
43
apps/edr-passenger-web/backoffice/src/components/ui/Tabs.tsx
Normal file
43
apps/edr-passenger-web/backoffice/src/components/ui/Tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -147,6 +147,8 @@ export const seatsApi = {
|
||||
release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
|
||||
block: (seatId: string, data: any) => apiClient.post<any>(`/seats/${seatId}/block`, data),
|
||||
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
|
||||
@@ -332,13 +334,13 @@ export const liveApi = {
|
||||
getCrowdSignals: () => apiClient.get<any[]>('/live/crowd-signals'),
|
||||
};
|
||||
|
||||
// Seat Classes API
|
||||
// Classes API (formerly Seat Classes)
|
||||
export const seatClassesApi = {
|
||||
getAll: () => apiClient.get<any[]>('/seat-classes'),
|
||||
getById: (id: string) => apiClient.get<any>(`/seat-classes/${id}`),
|
||||
create: (data: any) => apiClient.post<any>('/seat-classes', data),
|
||||
update: (id: string, data: any) => apiClient.patch<any>(`/seat-classes/${id}`, data),
|
||||
delete: (id: string) => apiClient.delete(`/seat-classes/${id}`),
|
||||
getAll: () => apiClient.get<any[]>('/fleet/classes'),
|
||||
getById: (id: string) => apiClient.get<any>(`/fleet/classes/${id}`),
|
||||
create: (data: any) => apiClient.post<any>('/fleet/classes', data),
|
||||
update: (id: string, data: any) => apiClient.patch<any>(`/fleet/classes/${id}`, data),
|
||||
delete: (id: string) => apiClient.delete(`/fleet/classes/${id}`),
|
||||
};
|
||||
|
||||
// Food & Dining API
|
||||
|
||||
@@ -7,6 +7,7 @@ export const passengersApi = {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
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?.pageSize) params.append('pageSize', filters.pageSize.toString());
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface BookingFilters {
|
||||
export interface PassengerFilters {
|
||||
search?: string;
|
||||
verified?: boolean;
|
||||
role?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ export default function ResultsPage() {
|
||||
|
||||
{isExpanded && (
|
||||
<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">
|
||||
{schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 ? (
|
||||
schedule.faresByClass.map((fareClass: any) => {
|
||||
|
||||
@@ -276,7 +276,7 @@ export default function SearchPage() {
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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"><5 years • First free</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -7,30 +7,39 @@ import { useBookingStore } from '@/lib/booking-store';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useState, useEffect, useCallback, useMemo, memo } from 'react';
|
||||
import { Armchair, Bed, ChevronLeft } from 'lucide-react';
|
||||
|
||||
import CustomModal from '@/components/CustomModal';
|
||||
|
||||
// Separate component for seat button to prevent re-render issues
|
||||
const SeatButton = memo(({ seat, isSelected, onToggle }: any) => {
|
||||
const SeatButton = memo(({ seat, isSelected, onToggle, isBedCoach, bedLabel }: any) => {
|
||||
const seatLabel = seat.number || seat.label || seat.seatNumber || '?';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onToggle(seat.id)}
|
||||
disabled={seat.status !== 'AVAILABLE'}
|
||||
className={`w-12 h-12 rounded flex items-center justify-center text-xs font-semibold transition-all ${
|
||||
isSelected
|
||||
? 'bg-primary text-white shadow-md scale-105'
|
||||
: 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'
|
||||
: seat.status === 'HELD'
|
||||
? 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-200 cursor-not-allowed opacity-75'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed opacity-60'
|
||||
}`}
|
||||
title={`Seat ${seatLabel} - ${seat.status}`}
|
||||
>
|
||||
{seatLabel}
|
||||
</button>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xs font-bold mb-0.5 text-gray-900 dark:text-gray-100">
|
||||
{seatLabel}{bedLabel}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onToggle(seat.id)}
|
||||
disabled={seat.status !== 'AVAILABLE'}
|
||||
className={`w-11 h-11 rounded flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? 'bg-[rgb(20_113_76)] text-white shadow-md scale-105'
|
||||
: seat.status === 'AVAILABLE'
|
||||
? 'bg-green-500 hover:bg-green-600 text-white cursor-pointer hover:shadow-md'
|
||||
: seat.status === 'HELD'
|
||||
? 'bg-yellow-500 text-white cursor-not-allowed opacity-75'
|
||||
: 'bg-gray-500 text-white cursor-not-allowed opacity-60'
|
||||
}`}
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria } = useBookingStore();
|
||||
const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore();
|
||||
const [selectedSeats, setSelectedSeats] = useState<string[]>([]);
|
||||
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
|
||||
const [_timeLeft, _setTimeLeft] = useState<number | null>(null);
|
||||
const [modalState, setModalState] = useState({
|
||||
isOpen: false,
|
||||
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 filteredCoaches = useMemo(() => {
|
||||
return selectedSchedule?.selectedSeatClass
|
||||
let filtered = selectedSchedule?.selectedSeatClass
|
||||
? coaches.filter((c: any) => {
|
||||
const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || '');
|
||||
return seatClassName === selectedSchedule.selectedSeatClass ||
|
||||
@@ -88,10 +109,12 @@ export default function SeatsPage() {
|
||||
seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase();
|
||||
})
|
||||
: coaches;
|
||||
return filtered.filter((c: any) => c.seats && c.seats.length > 0);
|
||||
}, [coaches, selectedSchedule?.selectedSeatClass]);
|
||||
|
||||
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(() => {
|
||||
if (filteredCoaches && filteredCoaches.length > 0 && !selectedCoach) {
|
||||
@@ -114,7 +137,7 @@ export default function SeatsPage() {
|
||||
if (selectedSeats.length > 0) {
|
||||
await holdMutation.mutateAsync(selectedSeats);
|
||||
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 {
|
||||
...p,
|
||||
seatId: selectedSeats[i],
|
||||
@@ -127,7 +150,7 @@ export default function SeatsPage() {
|
||||
};
|
||||
|
||||
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) {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
@@ -164,12 +187,155 @@ export default function SeatsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToPassengers = () => {
|
||||
router.push('/booking/passengers');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSchedule || !passengers.length) {
|
||||
router.push('/booking/search');
|
||||
}
|
||||
}, [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;
|
||||
|
||||
return (
|
||||
@@ -182,143 +348,151 @@ export default function SeatsPage() {
|
||||
type={modalState.type}
|
||||
/>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select seats</h1>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="card mb-4">
|
||||
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select coach</h3>
|
||||
{selectedSchedule?.selectedSeatClassName && (
|
||||
<div className="mb-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
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 className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
onClick={handleBackToPassengers}
|
||||
className="flex items-center gap-2 text-[rgb(20_113_76)] hover:text-[rgb(10_80_50)] font-semibold transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
Back to passenger details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select seats</h1>
|
||||
|
||||
<div>
|
||||
<div className="card 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">
|
||||
{passengers.map((p, i) => {
|
||||
const assignedSeat = selectedSeats[i] ? seats?.find((s: any) => s.id === selectedSeats[i]) : null;
|
||||
const seatLabel = assignedSeat ? (assignedSeat.number || assignedSeat.label || assignedSeat.seatNumber || '-') : '-';
|
||||
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">
|
||||
{seatLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 mb-4">
|
||||
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select coach</h3>
|
||||
{selectedSchedule?.selectedSeatClassName && (
|
||||
<div className="mb-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing coaches for: <span className="font-semibold text-[rgb(20_113_76)]">{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 transition-all ${
|
||||
selectedCoach === coach.id
|
||||
? 'bg-[rgb(20_113_76)] text-white shadow-lg'
|
||||
: '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>
|
||||
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={selectedSeats.length === 0}
|
||||
className="btn-primary w-full mb-2"
|
||||
>
|
||||
Continue with selected seats
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAutoAssign}
|
||||
disabled={holdMutation.isPending}
|
||||
className="btn-secondary w-full"
|
||||
>
|
||||
{holdMutation.isPending ? 'Assigning...' : 'Auto-assign seats'}
|
||||
</button>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||
Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}
|
||||
</h3>
|
||||
{selectedCoachData && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
Arrangement: {selectedCoachData.seatArrangement} • Total: {selectedCoachData.totalSeats} seats
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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>
|
||||
) : 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user