mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
Merge pull request #111 from Tria-plc/alpha
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 |
|
| **Routes** | `/routes` | JWT/IAM | Reusable route templates with ordered stops |
|
||||||
| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates |
|
| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates |
|
||||||
| **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations |
|
| **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations |
|
||||||
| **Seat Classes** | `/seat-classes` | Public/JWT/IAM | Seat class management and configuration |
|
| **Seat Classes** | `/seat-classes` or `/classes` | Public/JWT/IAM | Seat class management and configuration |
|
||||||
| **Segment Seats** | `/segments/seats` | Public/JWT | Segment-based seat availability and booking |
|
| **Segment Seats** | `/segments/seats` | Public/JWT | Segment-based seat availability and booking |
|
||||||
| **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation |
|
| **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation |
|
||||||
| **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking |
|
| **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking |
|
||||||
|
|||||||
@@ -13,14 +13,12 @@
|
|||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:seed": "ts-node prisma/seed-complete.ts",
|
"prisma:seed": "ts-node prisma/seed.ts",
|
||||||
"prisma:seed-full": "ts-node prisma/seed.ts",
|
"prisma:seed-full": "ts-node prisma/seed.ts",
|
||||||
"prisma:backfill": "ts-node prisma/backfill-fields.ts",
|
"prisma:backfill": "ts-node prisma/backfill-fields.ts",
|
||||||
"prisma:verify": "ts-node prisma/verify-backfill.ts"
|
"prisma:verify": "ts-node prisma/verify-backfill.ts"
|
||||||
},
|
},
|
||||||
"prisma": {
|
|
||||||
"seed": "ts-node prisma/seed.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
|
|||||||
@@ -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');
|
CREATE TYPE "BookingStatus" AS ENUM ('DRAFT', 'PENDING_PAYMENT', 'CONFIRMED', 'CANCELLED', 'COMPLETED', 'NO_SHOW', 'REFUNDED');
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET');
|
CREATE TYPE "PaymentRegion" AS ENUM ('ETHIOPIA', 'DJIBOUTI', 'INTERNATIONAL', 'GLOBAL');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PaymentMethodType" AS ENUM ('TELEBIRR', 'CBE_BIRR', 'EBIRR', 'CARD', 'WALLET', 'WAAFI');
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
CREATE TYPE "PaymentIntentStatus" AS ENUM ('REQUIRES_ACTION', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'REFUNDED');
|
CREATE TYPE "PaymentIntentStatus" AS ENUM ('REQUIRES_ACTION', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'REFUNDED');
|
||||||
@@ -55,12 +58,25 @@ CREATE TYPE "FoodOrderStatus" AS ENUM ('PENDING', 'PREPARING', 'READY', 'DELIVER
|
|||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
CREATE TYPE "DevicePlatform" AS ENUM ('IOS', 'ANDROID', 'WEB');
|
CREATE TYPE "DevicePlatform" AS ENUM ('IOS', 'ANDROID', 'WEB');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CoachType" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL DEFAULT 'passenger',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "CoachType_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "SeatClass" (
|
CREATE TABLE "SeatClass" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
|
"coachTypeId" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
"description" TEXT,
|
"description" TEXT,
|
||||||
"basePrice" INTEGER NOT NULL,
|
"baseFareMinor" INTEGER NOT NULL,
|
||||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
@@ -86,6 +102,9 @@ CREATE TABLE "User" (
|
|||||||
"lastLoginAt" TIMESTAMP(3),
|
"lastLoginAt" TIMESTAMP(3),
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"faydaVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"faydaVerifiedAt" TIMESTAMP(3),
|
||||||
|
"faydaSub" TEXT,
|
||||||
|
|
||||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
@@ -211,16 +230,11 @@ CREATE TABLE "TripLiveStatus" (
|
|||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "Coach" (
|
CREATE TABLE "Coach" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"coachNumber" TEXT NOT NULL,
|
"coachTypeId" TEXT NOT NULL,
|
||||||
"label" TEXT NOT NULL,
|
"number" TEXT NOT NULL,
|
||||||
"seatClassId" TEXT NOT NULL,
|
"arrangement" TEXT NOT NULL DEFAULT '2+2',
|
||||||
"coachType" TEXT,
|
"capacity" INTEGER NOT NULL DEFAULT 0,
|
||||||
"mode" TEXT NOT NULL DEFAULT 'seat',
|
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||||
"seatArrangement" TEXT,
|
|
||||||
"bedArrangement" TEXT,
|
|
||||||
"amenities" JSONB,
|
|
||||||
"totalUnits" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
@@ -243,10 +257,9 @@ CREATE TABLE "CoachAssignment" (
|
|||||||
CREATE TABLE "Seat" (
|
CREATE TABLE "Seat" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"coachId" TEXT NOT NULL,
|
"coachId" TEXT NOT NULL,
|
||||||
|
"seatNumber" TEXT NOT NULL,
|
||||||
"row" INTEGER NOT NULL,
|
"row" INTEGER NOT NULL,
|
||||||
"col" TEXT NOT NULL,
|
"col" TEXT NOT NULL,
|
||||||
"label" TEXT NOT NULL,
|
|
||||||
"seatNumber" TEXT,
|
|
||||||
"kind" "SeatKind" NOT NULL DEFAULT 'STANDARD',
|
"kind" "SeatKind" NOT NULL DEFAULT 'STANDARD',
|
||||||
"status" "SeatStatus" NOT NULL DEFAULT 'AVAILABLE',
|
"status" "SeatStatus" NOT NULL DEFAULT 'AVAILABLE',
|
||||||
"heldUntil" TIMESTAMP(3),
|
"heldUntil" TIMESTAMP(3),
|
||||||
@@ -254,7 +267,6 @@ CREATE TABLE "Seat" (
|
|||||||
"isAisle" BOOLEAN NOT NULL DEFAULT false,
|
"isAisle" BOOLEAN NOT NULL DEFAULT false,
|
||||||
"bedPosition" TEXT,
|
"bedPosition" TEXT,
|
||||||
"premiumFeeMinor" INTEGER NOT NULL DEFAULT 0,
|
"premiumFeeMinor" INTEGER NOT NULL DEFAULT 0,
|
||||||
"eligibility" TEXT,
|
|
||||||
|
|
||||||
CONSTRAINT "Seat_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "Seat_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
@@ -278,6 +290,7 @@ CREATE TABLE "FareRule" (
|
|||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"tripId" TEXT,
|
"tripId" TEXT,
|
||||||
"route" TEXT,
|
"route" TEXT,
|
||||||
|
"nationality" TEXT,
|
||||||
"seatClassId" TEXT NOT NULL,
|
"seatClassId" TEXT NOT NULL,
|
||||||
"baseFareMinor" INTEGER NOT NULL,
|
"baseFareMinor" INTEGER NOT NULL,
|
||||||
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||||
@@ -303,6 +316,8 @@ CREATE TABLE "Booking" (
|
|||||||
"displayCurrency" "Currency",
|
"displayCurrency" "Currency",
|
||||||
"displayTotalMinor" INTEGER,
|
"displayTotalMinor" INTEGER,
|
||||||
"bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY',
|
"bookingType" TEXT NOT NULL DEFAULT 'ONE_WAY',
|
||||||
|
"contactEmail" TEXT,
|
||||||
|
"contactPhone" TEXT,
|
||||||
"userAgent" TEXT,
|
"userAgent" TEXT,
|
||||||
"source" TEXT NOT NULL DEFAULT 'WEB',
|
"source" TEXT NOT NULL DEFAULT 'WEB',
|
||||||
"promoCode" TEXT,
|
"promoCode" TEXT,
|
||||||
@@ -327,6 +342,9 @@ CREATE TABLE "BookingSeat" (
|
|||||||
"passportCountry" TEXT,
|
"passportCountry" TEXT,
|
||||||
"verifaydaVerified" BOOLEAN NOT NULL DEFAULT false,
|
"verifaydaVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||||
"verifaydaData" JSONB,
|
"verifaydaData" JSONB,
|
||||||
|
"faydaVerifiedAt" TIMESTAMP(3),
|
||||||
|
"faydaSub" TEXT,
|
||||||
|
"faydaVerifiedName" TEXT,
|
||||||
"seatLabelSnapshot" TEXT,
|
"seatLabelSnapshot" TEXT,
|
||||||
"fareMinor" INTEGER,
|
"fareMinor" INTEGER,
|
||||||
"displayCurrency" "Currency",
|
"displayCurrency" "Currency",
|
||||||
@@ -338,13 +356,16 @@ CREATE TABLE "BookingSeat" (
|
|||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "PaymentMethod" (
|
CREATE TABLE "PaymentMethod" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"type" "PaymentMethodType" NOT NULL,
|
"type" "PaymentMethodType" NOT NULL,
|
||||||
"displayName" TEXT NOT NULL,
|
"displayName" TEXT NOT NULL,
|
||||||
"maskedHint" TEXT,
|
"region" "PaymentRegion" NOT NULL DEFAULT 'GLOBAL',
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'ETB',
|
||||||
"providerId" TEXT,
|
"providerId" TEXT,
|
||||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
CONSTRAINT "PaymentMethod_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "PaymentMethod_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
@@ -423,6 +444,16 @@ CREATE TABLE "Ticket" (
|
|||||||
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TicketSeat" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"ticketId" TEXT NOT NULL,
|
||||||
|
"seatId" TEXT NOT NULL,
|
||||||
|
"seatIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "TicketSeat_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "LoyaltyAccount" (
|
CREATE TABLE "LoyaltyAccount" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
@@ -1016,8 +1047,51 @@ CREATE TABLE "VerifaydaVerification" (
|
|||||||
CONSTRAINT "VerifaydaVerification_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "VerifaydaVerification_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SavedPassengerProfile" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
"deviceId" TEXT,
|
||||||
|
"passengerName" TEXT NOT NULL,
|
||||||
|
"dateOfBirth" TIMESTAMP(3) NOT NULL,
|
||||||
|
"idDocumentType" "IdDocumentType" NOT NULL,
|
||||||
|
"passportNumber" TEXT,
|
||||||
|
"passportCountry" TEXT,
|
||||||
|
"nationality" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"email" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "SavedPassengerProfile_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FaydaVerificationSession" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"state" TEXT NOT NULL,
|
||||||
|
"codeVerifier" TEXT NOT NULL,
|
||||||
|
"purpose" TEXT NOT NULL DEFAULT 'PURCHASE',
|
||||||
|
"platform" TEXT NOT NULL DEFAULT 'WEB',
|
||||||
|
"saveToAccount" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||||
|
"errorCode" TEXT,
|
||||||
|
"errorDescription" TEXT,
|
||||||
|
"authCode" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"userId" TEXT,
|
||||||
|
"bookingId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "FaydaVerificationSession_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "SeatClass_name_key" ON "SeatClass"("name");
|
CREATE INDEX "SeatClass_coachTypeId_idx" ON "SeatClass"("coachTypeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SeatClass_coachTypeId_name_key" ON "SeatClass"("coachTypeId", "name");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
@@ -1025,6 +1099,9 @@ CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
|||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
|
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_faydaSub_key" ON "User"("faydaSub");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
|
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
|
||||||
|
|
||||||
@@ -1053,7 +1130,10 @@ CREATE UNIQUE INDEX "TripStopTime_scheduleId_sequence_key" ON "TripStopTime"("sc
|
|||||||
CREATE UNIQUE INDEX "TripLiveStatus_scheduleId_key" ON "TripLiveStatus"("scheduleId");
|
CREATE UNIQUE INDEX "TripLiveStatus_scheduleId_key" ON "TripLiveStatus"("scheduleId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "Coach_coachNumber_key" ON "Coach"("coachNumber");
|
CREATE UNIQUE INDEX "Coach_number_key" ON "Coach"("number");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Coach_coachTypeId_idx" ON "Coach"("coachTypeId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId");
|
CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId");
|
||||||
@@ -1062,11 +1142,14 @@ CREATE INDEX "CoachAssignment_scheduleId_idx" ON "CoachAssignment"("scheduleId")
|
|||||||
CREATE UNIQUE INDEX "CoachAssignment_scheduleId_positionNumber_key" ON "CoachAssignment"("scheduleId", "positionNumber");
|
CREATE UNIQUE INDEX "CoachAssignment_scheduleId_positionNumber_key" ON "CoachAssignment"("scheduleId", "positionNumber");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "Seat_coachId_row_col_key" ON "Seat"("coachId", "row", "col");
|
CREATE INDEX "Seat_coachId_idx" ON "Seat"("coachId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "Seat_coachId_seatNumber_key" ON "Seat"("coachId", "seatNumber");
|
CREATE UNIQUE INDEX "Seat_coachId_seatNumber_key" ON "Seat"("coachId", "seatNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Seat_coachId_row_col_key" ON "Seat"("coachId", "row", "col");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "SeatHold_expiresAt_idx" ON "SeatHold"("expiresAt");
|
CREATE INDEX "SeatHold_expiresAt_idx" ON "SeatHold"("expiresAt");
|
||||||
|
|
||||||
@@ -1077,7 +1160,7 @@ CREATE UNIQUE INDEX "Booking_bookingRef_key" ON "Booking"("bookingRef");
|
|||||||
CREATE INDEX "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status");
|
CREATE INDEX "Booking_passengerId_status_idx" ON "Booking"("passengerId", "status");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "PaymentMethod_userId_isDefault_idx" ON "PaymentMethod"("userId", "isDefault");
|
CREATE UNIQUE INDEX "PaymentMethod_type_key" ON "PaymentMethod"("type");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "PaymentIntent_bookingId_key" ON "PaymentIntent"("bookingId");
|
CREATE UNIQUE INDEX "PaymentIntent_bookingId_key" ON "PaymentIntent"("bookingId");
|
||||||
@@ -1100,6 +1183,12 @@ CREATE UNIQUE INDEX "PaymentWebhookEvent_provider_externalEventId_key" ON "Payme
|
|||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "Ticket_bookingId_key" ON "Ticket"("bookingId");
|
CREATE UNIQUE INDEX "Ticket_bookingId_key" ON "Ticket"("bookingId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TicketSeat_ticketId_idx" ON "TicketSeat"("ticketId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TicketSeat_seatId_idx" ON "TicketSeat"("seatId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passengerId");
|
CREATE UNIQUE INDEX "LoyaltyAccount_passengerId_key" ON "LoyaltyAccount"("passengerId");
|
||||||
|
|
||||||
@@ -1202,6 +1291,30 @@ CREATE INDEX "VerifaydaVerification_nationalId_idx" ON "VerifaydaVerification"("
|
|||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "VerifaydaVerification_bookingId_idx" ON "VerifaydaVerification"("bookingId");
|
CREATE INDEX "VerifaydaVerification_bookingId_idx" ON "VerifaydaVerification"("bookingId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SavedPassengerProfile_userId_idx" ON "SavedPassengerProfile"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SavedPassengerProfile_deviceId_idx" ON "SavedPassengerProfile"("deviceId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "FaydaVerificationSession_state_key" ON "FaydaVerificationSession"("state");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FaydaVerificationSession_userId_idx" ON "FaydaVerificationSession"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FaydaVerificationSession_bookingId_idx" ON "FaydaVerificationSession"("bookingId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FaydaVerificationSession_state_idx" ON "FaydaVerificationSession"("state");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FaydaVerificationSession_expiresAt_idx" ON "FaydaVerificationSession"("expiresAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SeatClass" ADD CONSTRAINT "SeatClass_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -1214,6 +1327,9 @@ ALTER TABLE "TravelerProfile" ADD CONSTRAINT "TravelerProfile_passengerId_fkey"
|
|||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -1230,7 +1346,7 @@ ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_stationId_fkey" FOREIGN
|
|||||||
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -1265,6 +1381,12 @@ ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey"
|
|||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -1363,3 +1485,6 @@ ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("sea
|
|||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "FraudAlert" ADD CONSTRAINT "FraudAlert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "FraudAlert" ADD CONSTRAINT "FraudAlert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "FaydaVerificationSession" ADD CONSTRAINT "FaydaVerificationSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -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")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
model SeatClass {
|
model CoachType {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String @unique
|
code String
|
||||||
description String?
|
name String
|
||||||
basePrice Int
|
type String @default("passenger") // 'passenger', 'sleeper', 'dining', 'baggage'
|
||||||
isActive Boolean @default(true)
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
coaches Coach[]
|
coaches Coach[]
|
||||||
|
seatClasses SeatClass[]
|
||||||
|
|
||||||
|
@@schema("passenger")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SeatClass {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
coachTypeId String
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
baseFareMinor Int
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
||||||
fareRules FareRule[]
|
fareRules FareRule[]
|
||||||
routeFareRules RouteFareRule[]
|
routeFareRules RouteFareRule[]
|
||||||
|
segmentFares SegmentFareRule[]
|
||||||
|
@@unique([coachTypeId, name])
|
||||||
|
@@index([coachTypeId])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
@@ -385,21 +402,17 @@ model TripLiveStatus {
|
|||||||
|
|
||||||
model Coach {
|
model Coach {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
coachNumber String @unique
|
coachTypeId String
|
||||||
label String
|
number String @unique
|
||||||
seatClassId String
|
arrangement String @default("2+2") // e.g., '2+2', '3+2', '2+2+2'
|
||||||
coachType String?
|
capacity Int @default(0) // Total seats/beds
|
||||||
mode String @default("seat") // 'seat', 'bed', 'convertible'
|
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
|
||||||
seatArrangement String?
|
|
||||||
bedArrangement String?
|
|
||||||
amenities Json?
|
|
||||||
totalUnits Int @default(0)
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
seatClass SeatClass @relation(fields: [seatClassId], references: [id])
|
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
||||||
seats Seat[]
|
seats Seat[]
|
||||||
assignments CoachAssignment[]
|
assignments CoachAssignment[]
|
||||||
|
@@index([coachTypeId])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
@@ -422,10 +435,9 @@ model CoachAssignment {
|
|||||||
model Seat {
|
model Seat {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
coachId String
|
coachId String
|
||||||
|
seatNumber String // Auto-generated: e.g., '1', '2', '3' (unique per coach)
|
||||||
row Int
|
row Int
|
||||||
col String
|
col String
|
||||||
label String
|
|
||||||
seatNumber String?
|
|
||||||
kind SeatKind @default(STANDARD)
|
kind SeatKind @default(STANDARD)
|
||||||
status SeatStatus @default(AVAILABLE)
|
status SeatStatus @default(AVAILABLE)
|
||||||
heldUntil DateTime?
|
heldUntil DateTime?
|
||||||
@@ -433,13 +445,13 @@ model Seat {
|
|||||||
isAisle Boolean @default(false)
|
isAisle Boolean @default(false)
|
||||||
bedPosition String? // 'lower', 'middle', 'upper'
|
bedPosition String? // 'lower', 'middle', 'upper'
|
||||||
premiumFeeMinor Int @default(0)
|
premiumFeeMinor Int @default(0)
|
||||||
eligibility String?
|
|
||||||
coach Coach @relation(fields: [coachId], references: [id])
|
coach Coach @relation(fields: [coachId], references: [id])
|
||||||
bookingSeats BookingSeat[]
|
bookingSeats BookingSeat[]
|
||||||
blocks SeatBlock[]
|
blocks SeatBlock[]
|
||||||
ticketSeats TicketSeat[]
|
ticketSeats TicketSeat[]
|
||||||
@@unique([coachId, row, col])
|
|
||||||
@@unique([coachId, seatNumber])
|
@@unique([coachId, seatNumber])
|
||||||
|
@@unique([coachId, row, col])
|
||||||
|
@@index([coachId])
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
@@ -978,6 +990,7 @@ model Route {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
stops RouteStop[]
|
stops RouteStop[]
|
||||||
fareRules RouteFareRule[]
|
fareRules RouteFareRule[]
|
||||||
|
segmentFares SegmentFareRule[]
|
||||||
schedules TrainSchedule[]
|
schedules TrainSchedule[]
|
||||||
|
|
||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
@@ -1017,6 +1030,26 @@ model RouteFareRule {
|
|||||||
@@schema("passenger")
|
@@schema("passenger")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SegmentFareRule {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
routeId String
|
||||||
|
originStopSequence Int
|
||||||
|
destinationStopSequence Int
|
||||||
|
seatClassId String
|
||||||
|
baseFareMinor Int
|
||||||
|
nationality String? // Optional: Ethiopian, Djiboutian, Other
|
||||||
|
currency String @default("ETB")
|
||||||
|
validFrom DateTime
|
||||||
|
validUntil DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
||||||
|
seatClass SeatClass @relation(fields: [seatClassId], references: [id])
|
||||||
|
@@unique([routeId, originStopSequence, destinationStopSequence, seatClassId, nationality])
|
||||||
|
@@index([routeId, seatClassId])
|
||||||
|
|
||||||
|
@@schema("passenger")
|
||||||
|
}
|
||||||
|
|
||||||
model Agent {
|
model Agent {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @unique
|
userId String @unique
|
||||||
|
|||||||
@@ -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 },
|
where: { bookingRef },
|
||||||
include: {
|
include: {
|
||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } },
|
seats: { include: { seat: { include: { coach: true } } } },
|
||||||
paymentIntent: true, ticket: true,
|
paymentIntent: true, ticket: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -408,7 +408,7 @@ export class BookingsService {
|
|||||||
destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city },
|
destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city },
|
||||||
departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt,
|
departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt,
|
||||||
},
|
},
|
||||||
passengers: booking.seats.map((bs) => ({
|
passengers: booking.seats?.map((bs: any) => ({
|
||||||
fullName: bs.passengerName, category: bs.passengerCategory, verifaydaVerified: bs.verifaydaVerified,
|
fullName: bs.passengerName, category: bs.passengerCategory, verifaydaVerified: bs.verifaydaVerified,
|
||||||
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass.name },
|
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass.name },
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export class DashboardService {
|
|||||||
upcomingTicket: upcomingBooking ? {
|
upcomingTicket: upcomingBooking ? {
|
||||||
ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef,
|
ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef,
|
||||||
from: upcomingBooking.schedule.originStation.name, to: upcomingBooking.schedule.destinationStation.name,
|
from: upcomingBooking.schedule.originStation.name, to: upcomingBooking.schedule.destinationStation.name,
|
||||||
trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label,
|
trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber,
|
||||||
departureAt: upcomingBooking.schedule.departureAt,
|
departureAt: upcomingBooking.schedule.departureAt,
|
||||||
punctualityLabel: (upcomingBooking.schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
|
punctualityLabel: (upcomingBooking.schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
|
||||||
} : null,
|
} : null,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export class FareEngineService {
|
|||||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||||
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
|
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
|
||||||
|
|
||||||
const ratePerKmMinor = seatClass.basePrice;
|
const ratePerKmMinor = seatClass.baseFareMinor;
|
||||||
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
||||||
|
|
||||||
const adultCount = dto.adultCount ?? 1;
|
const adultCount = dto.adultCount ?? 1;
|
||||||
@@ -129,7 +129,7 @@ export class FareEngineService {
|
|||||||
) {
|
) {
|
||||||
const seatClasses = await this.prisma.seatClass.findMany({
|
const seatClasses = await this.prisma.seatClass.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
orderBy: { basePrice: 'asc' },
|
orderBy: { baseFareMinor: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
@@ -172,7 +172,7 @@ export class FareEngineService {
|
|||||||
if (schedule.routeId) {
|
if (schedule.routeId) {
|
||||||
const seatClasses = await this.prisma.seatClass.findMany({
|
const seatClasses = await this.prisma.seatClass.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
orderBy: { basePrice: 'asc' },
|
orderBy: { baseFareMinor: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
@@ -198,23 +198,25 @@ export class FareEngineService {
|
|||||||
validFrom: { lte: now },
|
validFrom: { lte: now },
|
||||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||||
},
|
},
|
||||||
include: { seatClass: true },
|
orderBy: [{ seatClass: { baseFareMinor: 'asc' } }],
|
||||||
orderBy: { seatClass: { basePrice: 'asc' } },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fareRules.length > 0) {
|
if (fareRules.length > 0) {
|
||||||
const billingCurrency = resolveCurrencyFromNationality(nationality);
|
const billingCurrency = resolveCurrencyFromNationality(nationality);
|
||||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
|
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
|
||||||
return fareRules.map(rule => ({
|
return fareRules.map(rule => {
|
||||||
seatClassId: rule.seatClassId,
|
const seatClassId = rule.seatClassId;
|
||||||
seatClassName: rule.seatClass.name,
|
return {
|
||||||
baseFareMinor: rule.baseFareMinor,
|
seatClassId,
|
||||||
totalMinor: rule.baseFareMinor,
|
seatClassName: 'Unknown',
|
||||||
billingCurrency,
|
baseFareMinor: rule.baseFareMinor,
|
||||||
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
|
totalMinor: rule.baseFareMinor,
|
||||||
exchangeRate,
|
billingCurrency,
|
||||||
source: 'FARE_RULE',
|
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
|
||||||
}));
|
exchangeRate,
|
||||||
|
source: 'FARE_RULE',
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger';
|
||||||
import { FleetService } from './fleet.service';
|
import { FleetService } from './fleet.service';
|
||||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto';
|
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
|
|
||||||
@ApiTags('Fleet')
|
@ApiTags('Fleet')
|
||||||
@@ -11,16 +11,128 @@ import { JwtGuard } from '../../common/jwt.guard';
|
|||||||
export class FleetController {
|
export class FleetController {
|
||||||
constructor(private service: FleetService) {}
|
constructor(private service: FleetService) {}
|
||||||
|
|
||||||
|
// Coach Type Endpoints
|
||||||
|
@Get('coach-types')
|
||||||
|
@ApiOperation({ summary: 'List all coach types' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Array of coach types' })
|
||||||
|
getCoachTypes() {
|
||||||
|
return this.service.getCoachTypes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('coach-types')
|
||||||
|
@ApiOperation({ summary: 'Create a coach type' })
|
||||||
|
@ApiBody({ type: CreateCoachTypeDto })
|
||||||
|
@ApiResponse({ status: 201, description: 'Coach type created' })
|
||||||
|
createCoachType(@Body() dto: CreateCoachTypeDto) {
|
||||||
|
return this.service.createCoachType(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('coach-types/:id')
|
||||||
|
@ApiOperation({ summary: 'Update a coach type' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
|
||||||
|
@ApiBody({ type: UpdateCoachTypeDto })
|
||||||
|
@ApiResponse({ status: 200, description: 'Coach type updated' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Coach type not found' })
|
||||||
|
updateCoachType(@Param('id') id: string, @Body() dto: UpdateCoachTypeDto) {
|
||||||
|
return this.service.updateCoachType(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('coach-types/:id')
|
||||||
|
@ApiOperation({ summary: 'Delete a coach type' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Coach type deleted' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Coach type not found' })
|
||||||
|
deleteCoachType(@Param('id') id: string) {
|
||||||
|
return this.service.deleteCoachType(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Class Endpoints
|
||||||
|
@Get('classes')
|
||||||
|
@ApiOperation({ summary: 'List all classes' })
|
||||||
|
@ApiQuery({ name: 'coachTypeId', required: false, description: 'Filter by coach type' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Array of classes' })
|
||||||
|
getClasses(@Query('coachTypeId') coachTypeId?: string) {
|
||||||
|
return this.service.getClasses(coachTypeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('classes')
|
||||||
|
@ApiOperation({ summary: 'Create a class' })
|
||||||
|
@ApiBody({ type: CreateClassDto })
|
||||||
|
@ApiResponse({ status: 201, description: 'Class created' })
|
||||||
|
createClass(@Body() dto: CreateClassDto) {
|
||||||
|
return this.service.createClass(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('classes/:id')
|
||||||
|
@ApiOperation({ summary: 'Update a class' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||||
|
@ApiBody({ type: UpdateClassDto })
|
||||||
|
@ApiResponse({ status: 200, description: 'Class updated' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Class not found' })
|
||||||
|
updateClass(@Param('id') id: string, @Body() dto: UpdateClassDto) {
|
||||||
|
return this.service.updateClass(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('classes/:id')
|
||||||
|
@ApiOperation({ summary: 'Delete a class' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Class deleted' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Class not found' })
|
||||||
|
deleteClass(@Param('id') id: string) {
|
||||||
|
return this.service.deleteClass(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seat Class Endpoints (DEPRECATED - use Classes endpoints instead)
|
||||||
|
@Get('seat-classes')
|
||||||
|
@ApiOperation({ summary: 'List all classes (DEPRECATED - use /fleet/classes)' })
|
||||||
|
@ApiQuery({ name: 'coachTypeId', required: false, description: 'Filter by coach type' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Array of classes' })
|
||||||
|
getSeatClasses(@Query('coachTypeId') coachTypeId?: string) {
|
||||||
|
return this.service.getClasses(coachTypeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('seat-classes')
|
||||||
|
@ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' })
|
||||||
|
@ApiBody({ type: CreateClassDto })
|
||||||
|
@ApiResponse({ status: 201, description: 'Class created' })
|
||||||
|
createSeatClass(@Body() dto: CreateClassDto) {
|
||||||
|
return this.service.createClass(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('seat-classes/:id')
|
||||||
|
@ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||||
|
@ApiBody({ type: UpdateClassDto })
|
||||||
|
@ApiResponse({ status: 200, description: 'Class updated' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Class not found' })
|
||||||
|
updateSeatClass(@Param('id') id: string, @Body() dto: UpdateClassDto) {
|
||||||
|
return this.service.updateClass(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('seat-classes/:id')
|
||||||
|
@ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Class deleted' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Class not found' })
|
||||||
|
deleteSeatClass(@Param('id') id: string) {
|
||||||
|
return this.service.deleteClass(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Train Endpoints
|
||||||
@Get('trains')
|
@Get('trains')
|
||||||
@ApiOperation({ summary: 'List all trains with their recent schedules' })
|
@ApiOperation({ summary: 'List all trains with their recent schedules' })
|
||||||
@ApiResponse({ status: 200, description: 'Array of trains each with up to 5 most recent schedules' })
|
@ApiResponse({ status: 200, description: 'Array of trains' })
|
||||||
getTrains() { return this.service.getTrains(); }
|
getTrains() {
|
||||||
|
return this.service.getTrains();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('trains')
|
@Post('trains')
|
||||||
@ApiOperation({ summary: 'Create a train service' })
|
@ApiOperation({ summary: 'Create a train service' })
|
||||||
@ApiBody({ type: CreateTrainDto })
|
@ApiBody({ type: CreateTrainDto })
|
||||||
@ApiResponse({ status: 201, description: 'Train created' })
|
@ApiResponse({ status: 201, description: 'Train created' })
|
||||||
createTrain(@Body() dto: CreateTrainDto) { return this.service.createTrain(dto); }
|
createTrain(@Body() dto: CreateTrainDto) {
|
||||||
|
return this.service.createTrain(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch('trains/:id')
|
@Patch('trains/:id')
|
||||||
@ApiOperation({ summary: 'Update a train service' })
|
@ApiOperation({ summary: 'Update a train service' })
|
||||||
@@ -28,96 +140,95 @@ export class FleetController {
|
|||||||
@ApiBody({ type: CreateTrainDto })
|
@ApiBody({ type: CreateTrainDto })
|
||||||
@ApiResponse({ status: 200, description: 'Train updated' })
|
@ApiResponse({ status: 200, description: 'Train updated' })
|
||||||
@ApiResponse({ status: 404, description: 'Train not found' })
|
@ApiResponse({ status: 404, description: 'Train not found' })
|
||||||
updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) { return this.service.updateTrain(id, dto); }
|
updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) {
|
||||||
|
return this.service.updateTrain(id, dto);
|
||||||
@Get('coaches')
|
|
||||||
@ApiOperation({ summary: 'List coaches filtered by status, mode, seat class, or schedule assignment' })
|
|
||||||
@ApiQuery({ name: 'isActive', required: false, type: Boolean, description: 'true = active only, false = inactive only, omit = all' })
|
|
||||||
@ApiQuery({ name: 'mode', required: false, description: 'Filter by mode: seat | bed | convertible' })
|
|
||||||
@ApiQuery({ name: 'seatClassId', required: false, description: 'Filter by SeatClass UUID' })
|
|
||||||
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter to coaches assigned to this TrainSchedule UUID' })
|
|
||||||
@ApiResponse({ status: 200, description: 'Coaches with seat class info, assignment count, and seat status summary (total/available/held/booked/blocked)' })
|
|
||||||
listCoaches(
|
|
||||||
@Query('isActive') isActive?: string,
|
|
||||||
@Query('mode') mode?: string,
|
|
||||||
@Query('seatClassId') seatClassId?: string,
|
|
||||||
@Query('scheduleId') scheduleId?: string,
|
|
||||||
) {
|
|
||||||
const dto: ListCoachesDto = {
|
|
||||||
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
|
|
||||||
mode,
|
|
||||||
seatClassId,
|
|
||||||
scheduleId,
|
|
||||||
};
|
|
||||||
return this.service.listCoaches(dto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('coaches/:id')
|
|
||||||
@ApiOperation({ summary: 'Get a single coach with full seat layout and arrangement' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: `Coach detail including:
|
|
||||||
- seatClass: seat class info
|
|
||||||
- seatsByRow: seats grouped by row number, each seat includes label, seatNumber, col, kind (STANDARD/PREMIUM/ACCESSIBLE), status (AVAILABLE/HELD/BOOKED/BLOCKED), isWindow, isAisle, bedPosition (bed mode only), premiumFeeMinor
|
|
||||||
- seatStatusSummary: total/available/held/booked/blocked counts
|
|
||||||
- assignments: up to 5 most recent schedule assignments with origin/destination`,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
|
||||||
getCoach(@Param('id') id: string) { return this.service.getCoach(id); }
|
|
||||||
|
|
||||||
@Post('coaches')
|
|
||||||
@ApiOperation({ summary: 'Register a new physical coach and auto-generate its seats from arrangement config' })
|
|
||||||
@ApiBody({ type: CreateCoachDto })
|
|
||||||
@ApiResponse({ status: 201, description: 'Coach created with seats auto-generated from mode + arrangement + totalUnits' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
|
|
||||||
createCoach(@Body() dto: CreateCoachDto) { return this.service.createCoach(dto); }
|
|
||||||
|
|
||||||
@Patch('coaches/:id')
|
|
||||||
@ApiOperation({ summary: 'Update coach properties (label, mode, arrangement, etc.)' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
|
||||||
@ApiBody({ type: UpdateCoachDto })
|
|
||||||
@ApiResponse({ status: 200, description: 'Coach updated' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
|
||||||
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) { return this.service.updateCoach(id, dto); }
|
|
||||||
|
|
||||||
@Delete('trains/:id')
|
@Delete('trains/:id')
|
||||||
@ApiOperation({ summary: 'Delete a train service' })
|
@ApiOperation({ summary: 'Delete a train service' })
|
||||||
@ApiParam({ name: 'id', description: 'Train UUID' })
|
@ApiParam({ name: 'id', description: 'Train UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Train deleted' })
|
@ApiResponse({ status: 200, description: 'Train deleted' })
|
||||||
@ApiResponse({ status: 404, description: 'Train not found' })
|
@ApiResponse({ status: 404, description: 'Train not found' })
|
||||||
deleteTrain(@Param('id') id: string) { return this.service.deleteTrain(id); }
|
deleteTrain(@Param('id') id: string) {
|
||||||
|
return this.service.deleteTrain(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coach Endpoints
|
||||||
|
@Get('coaches')
|
||||||
|
@ApiOperation({ summary: 'List coaches with seat status summary' })
|
||||||
|
@ApiQuery({ name: 'status', required: false, description: 'Filter by status: ACTIVE, INACTIVE' })
|
||||||
|
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter coaches assigned to schedule' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Array of coaches' })
|
||||||
|
listCoaches(
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('scheduleId') scheduleId?: string,
|
||||||
|
) {
|
||||||
|
const dto: ListCoachesDto = {
|
||||||
|
status,
|
||||||
|
scheduleId,
|
||||||
|
};
|
||||||
|
return this.service.listCoaches(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('coaches/:id')
|
||||||
|
@ApiOperation({ summary: 'Get single coach with seat layout' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Coach detail with seats by row' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||||
|
getCoach(@Param('id') id: string) {
|
||||||
|
return this.service.getCoach(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('coaches')
|
||||||
|
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
|
||||||
|
@ApiBody({ type: CreateCoachDto })
|
||||||
|
@ApiResponse({ status: 201, description: 'Coach created' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
|
||||||
|
createCoach(@Body() dto: CreateCoachDto) {
|
||||||
|
return this.service.createCoach(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('coaches/:id')
|
||||||
|
@ApiOperation({ summary: 'Update coach properties' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||||
|
@ApiBody({ type: UpdateCoachDto })
|
||||||
|
@ApiResponse({ status: 200, description: 'Coach updated' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||||
|
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) {
|
||||||
|
return this.service.updateCoach(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('coaches/:id')
|
@Delete('coaches/:id')
|
||||||
@ApiOperation({ summary: 'Delete a coach' })
|
@ApiOperation({ summary: 'Delete a coach' })
|
||||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Coach deleted' })
|
@ApiResponse({ status: 200, description: 'Coach deleted' })
|
||||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||||
deleteCoach(@Param('id') id: string) { return this.service.deleteCoach(id); }
|
deleteCoach(@Param('id') id: string) {
|
||||||
|
return this.service.deleteCoach(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('assignments')
|
@Post('assignments')
|
||||||
@ApiOperation({ summary: 'Assign a physical coach to a train schedule at a given position' })
|
@ApiOperation({ summary: 'Assign a coach to a schedule' })
|
||||||
@ApiBody({ type: AssignCoachDto })
|
@ApiBody({ type: AssignCoachDto })
|
||||||
@ApiResponse({ status: 201, description: 'CoachAssignment created' })
|
@ApiResponse({ status: 201, description: 'Coach assigned' })
|
||||||
@ApiResponse({ status: 404, description: 'Schedule or coach not found' })
|
@ApiResponse({ status: 404, description: 'Schedule or coach not found' })
|
||||||
assignCoach(@Body() dto: AssignCoachDto) { return this.service.assignCoach(dto); }
|
assignCoach(@Body() dto: AssignCoachDto) {
|
||||||
|
return this.service.assignCoach(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('assignments/:id')
|
@Delete('assignments/:id')
|
||||||
@ApiOperation({ summary: 'Remove a coach assignment from a schedule' })
|
@ApiOperation({ summary: 'Remove a coach assignment' })
|
||||||
@ApiParam({ name: 'id', description: 'CoachAssignment UUID' })
|
@ApiParam({ name: 'id', description: 'Assignment UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Assignment removed' })
|
@ApiResponse({ status: 200, description: 'Assignment removed' })
|
||||||
@ApiResponse({ status: 404, description: 'Assignment not found' })
|
@ApiResponse({ status: 404, description: 'Assignment not found' })
|
||||||
removeAssignment(@Param('id') id: string) { return this.service.removeAssignment(id); }
|
removeAssignment(@Param('id') id: string) {
|
||||||
|
return this.service.removeAssignment(id);
|
||||||
@Post('seats/batch')
|
}
|
||||||
@ApiOperation({ summary: 'Batch-generate seats for a coach (rows × cols)' })
|
|
||||||
@ApiBody({ type: CreateSeatBatchDto })
|
|
||||||
@ApiResponse({ status: 201, description: 'Returns count of seats created' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
|
||||||
createSeatBatch(@Body() dto: CreateSeatBatchDto) { return this.service.createSeatBatch(dto); }
|
|
||||||
|
|
||||||
@Get('analytics')
|
@Get('analytics')
|
||||||
@ApiOperation({ summary: 'Fleet analytics: train count, schedule count, seat occupancy rate' })
|
@ApiOperation({ summary: 'Fleet analytics and occupancy metrics' })
|
||||||
@ApiResponse({ status: 200, description: 'Returns totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate' })
|
@ApiResponse({ status: 200, description: 'Occupancy statistics' })
|
||||||
getAnalytics() { return this.service.getAnalytics(); }
|
getAnalytics() {
|
||||||
|
return this.service.getAnalytics();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,41 +10,48 @@ export class CreateTrainDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CreateCoachDto {
|
export class CreateCoachDto {
|
||||||
@ApiProperty({ example: 'C-A1', description: 'Unique physical coach identifier' }) @IsString() coachNumber: string;
|
@ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string;
|
||||||
@ApiProperty({ example: 'A', description: 'Display label shown on tickets' }) @IsString() label: string;
|
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string;
|
||||||
@ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID this coach belongs to' }) @IsString() seatClassId: string;
|
@ApiProperty({ example: '2+2', description: 'Seat arrangement (e.g., "2+2", "3+2")' }) @IsString() arrangement: string;
|
||||||
@ApiPropertyOptional({ example: 'sleeper', description: 'Coach type descriptor' }) @IsOptional() @IsString() coachType?: string;
|
@ApiProperty({ example: 60, description: 'Total seat capacity' }) @IsInt() capacity: number;
|
||||||
@ApiPropertyOptional({ example: 'seat', description: 'seat | bed | convertible. Determines which arrangement field is used for seat generation.' }) @IsOptional() @IsString() mode?: string;
|
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string;
|
||||||
@ApiPropertyOptional({ example: '2+2', description: 'Seat arrangement for seat/convertible mode. Format: groups separated by +, e.g. "2+2" (4 cols: A/B aisle C/D) or "1+2+1". Used to derive columns, window and aisle flags. Required when mode=seat and totalUnits>0.' }) @IsOptional() @IsString() seatArrangement?: string;
|
|
||||||
@ApiPropertyOptional({ example: '2+2', description: 'Bed arrangement for bed mode. First number = tiers per berth: 2 → lower/upper, 3 → lower/middle/upper. E.g. "2+2" = 2-tier berths. Required when mode=bed and totalUnits>0.' }) @IsOptional() @IsString() bedArrangement?: string;
|
|
||||||
@ApiPropertyOptional({ example: 60, description: 'Total seat/bed units. When >0, seats are auto-generated from the arrangement on coach creation.' }) @IsOptional() @IsInt() totalUnits?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['coachNumber'] as const)) {}
|
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {}
|
||||||
|
|
||||||
export class AssignCoachDto {
|
export class AssignCoachDto {
|
||||||
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) @IsString() scheduleId: string;
|
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) @IsString() scheduleId: string;
|
||||||
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string;
|
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string;
|
||||||
@ApiProperty({ example: 1, description: 'Position in the train consist (1 = first coach)' }) @IsInt() positionNumber: number;
|
@ApiProperty({ example: 1, description: 'Position in the train consist' }) @IsInt() positionNumber: number;
|
||||||
@ApiPropertyOptional({ example: true, description: 'Whether this coach is operational for this schedule' }) @IsOptional() @IsBoolean() isOperational?: boolean;
|
@ApiPropertyOptional({ example: true, description: 'Whether this coach is operational' }) @IsOptional() @IsBoolean() isOperational?: boolean;
|
||||||
}
|
|
||||||
|
|
||||||
export class CreateSeatBatchDto {
|
|
||||||
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID to generate seats for' }) @IsString() coachId: string;
|
|
||||||
@ApiProperty({ example: 15, description: 'Number of rows to generate' }) @IsInt() rows: number;
|
|
||||||
@ApiProperty({ example: ['A', 'B', 'C', 'D'], type: [String], description: 'Column labels per row' }) @IsArray() @IsString({ each: true }) cols: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ListCoachesDto {
|
export class ListCoachesDto {
|
||||||
@ApiPropertyOptional({ example: true, description: 'Filter by active/inactive status. Omit to return all.' })
|
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Filter by status: ACTIVE, INACTIVE' })
|
||||||
@IsOptional() @IsBoolean() isActive?: boolean;
|
@IsOptional() @IsString() status?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'seat', description: 'Filter by mode: seat | bed | convertible' })
|
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Filter coaches assigned to this schedule' })
|
||||||
@IsOptional() @IsString() mode?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'Filter by SeatClass UUID' })
|
|
||||||
@IsOptional() @IsString() seatClassId?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Filter to coaches assigned to this TrainSchedule UUID' })
|
|
||||||
@IsOptional() @IsString() scheduleId?: string;
|
@IsOptional() @IsString() scheduleId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy DTO types for backward compatibility
|
||||||
|
export class CreateCoachTypeDto {
|
||||||
|
@ApiProperty({ example: 'sleeper' }) @IsString() code: string;
|
||||||
|
@ApiProperty({ example: 'Sleeper Coach' }) @IsString() name: string;
|
||||||
|
@IsOptional() @IsString() type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateCoachTypeDto {
|
||||||
|
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() code?: string;
|
||||||
|
@ApiPropertyOptional({ example: 'Sleeper Coach' }) @IsOptional() @IsString() name?: string;
|
||||||
|
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateClassDto {
|
||||||
|
@ApiProperty({ example: 'coach-type-uuid' }) @IsString() coachTypeId: string;
|
||||||
|
@ApiProperty({ example: 'Economy' }) @IsString() name: string;
|
||||||
|
@IsOptional() @IsString() description?: string;
|
||||||
|
@ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateClassDto extends PartialType(OmitType(CreateClassDto, ['coachTypeId'] as const)) {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto';
|
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto';
|
||||||
import { SeatKind } from '@prisma/client';
|
import { SeatKind } from '@prisma/client';
|
||||||
|
|
||||||
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
|
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
|
||||||
@@ -8,22 +8,20 @@ function parseArrangement(arrangement: string): number[] {
|
|||||||
return arrangement.split('+').map((n) => parseInt(n, 10));
|
return arrangement.split('+').map((n) => parseInt(n, 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derives column labels from a seat-mode arrangement string.
|
// Derives column labels from arrangement: '2+2' → ['A','B','C','D']
|
||||||
// '2+2' → ['A','B','C','D'] (A/D window, B/C aisle)
|
|
||||||
// '1+2+1' → ['A','B','C','D']
|
|
||||||
function seatCols(arrangement: string): string[] {
|
function seatCols(arrangement: string): string[] {
|
||||||
const groups = parseArrangement(arrangement);
|
const groups = parseArrangement(arrangement);
|
||||||
const total = groups.reduce((s, n) => s + n, 0);
|
const total = groups.reduce((s, n) => s + n, 0);
|
||||||
return Array.from({ length: total }, (_, i) => String.fromCharCode(65 + i)); // A, B, C …
|
return Array.from({ length: total }, (_, i) => String.fromCharCode(65 + i));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns true if the column index is a window seat given the arrangement groups.
|
// Returns true if column is a window seat
|
||||||
function isWindowCol(colIndex: number, groups: number[]): boolean {
|
function isWindowCol(colIndex: number, groups: number[]): boolean {
|
||||||
const total = groups.reduce((s, n) => s + n, 0);
|
const total = groups.reduce((s, n) => s + n, 0);
|
||||||
return colIndex === 0 || colIndex === total - 1;
|
return colIndex === 0 || colIndex === total - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns true if the column index is an aisle seat.
|
// Returns true if column is an aisle seat
|
||||||
function isAisleCol(colIndex: number, groups: number[]): boolean {
|
function isAisleCol(colIndex: number, groups: number[]): boolean {
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
@@ -35,82 +33,180 @@ function isAisleCol(colIndex: number, groups: number[]): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bed positions for a given tier count: 2 → lower/upper, 3 → lower/middle/upper
|
function buildSeats(coachId: string, coachNumber: string, arrangement: string, capacity: number, seatClass?: string): SeatRow[] {
|
||||||
const BED_POSITIONS: Record<number, string[]> = {
|
|
||||||
2: ['lower', 'upper'],
|
|
||||||
3: ['lower', 'middle', 'upper'],
|
|
||||||
};
|
|
||||||
|
|
||||||
type SeatRow = {
|
|
||||||
coachId: string;
|
|
||||||
row: number;
|
|
||||||
col: string;
|
|
||||||
label: string;
|
|
||||||
seatNumber: string;
|
|
||||||
kind: SeatKind;
|
|
||||||
isWindow: boolean;
|
|
||||||
isAisle: boolean;
|
|
||||||
bedPosition?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildSeatSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] {
|
|
||||||
const cols = seatCols(arrangement);
|
const cols = seatCols(arrangement);
|
||||||
const groups = parseArrangement(arrangement);
|
const groups = parseArrangement(arrangement);
|
||||||
const seats: SeatRow[] = [];
|
const seats: SeatRow[] = [];
|
||||||
let row = 1;
|
let row = 1;
|
||||||
while (seats.length < totalUnits) {
|
let seatNumber = 1;
|
||||||
for (let ci = 0; ci < cols.length && seats.length < totalUnits; ci++) {
|
let seatIndex = 0;
|
||||||
|
const isBedCoach = seatClass?.toLowerCase().includes('bed');
|
||||||
|
const totalCols = cols.length;
|
||||||
|
|
||||||
|
while (seatIndex < capacity) {
|
||||||
|
for (let ci = 0; ci < cols.length && seatIndex < capacity; ci++) {
|
||||||
const col = cols[ci];
|
const col = cols[ci];
|
||||||
|
let bedPosition = null;
|
||||||
|
|
||||||
|
// Set bedPosition for bed coaches based on seat number cycling
|
||||||
|
if (isBedCoach) {
|
||||||
|
if (totalCols === 3) {
|
||||||
|
// Economy bed (3 levels): 1L, 2M, 3U, 4L, 5M, 6U...
|
||||||
|
const posMod = ((seatNumber - 1) % 3);
|
||||||
|
if (posMod === 0) bedPosition = 'lower';
|
||||||
|
else if (posMod === 1) bedPosition = 'middle';
|
||||||
|
else if (posMod === 2) bedPosition = 'upper';
|
||||||
|
} else if (totalCols === 2) {
|
||||||
|
// VIP bed (2 levels): 1L, 2U, 3L, 4U...
|
||||||
|
const posMod = ((seatNumber - 1) % 2);
|
||||||
|
if (posMod === 0) bedPosition = 'lower';
|
||||||
|
else if (posMod === 1) bedPosition = 'upper';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
seats.push({
|
seats.push({
|
||||||
coachId, row, col,
|
coachId,
|
||||||
label: `${row}${col}`,
|
row,
|
||||||
seatNumber: `${coachLabel}${row}${col}`,
|
col,
|
||||||
|
seatNumber: `${seatNumber}`,
|
||||||
kind: SeatKind.STANDARD,
|
kind: SeatKind.STANDARD,
|
||||||
isWindow: isWindowCol(ci, groups),
|
bedPosition,
|
||||||
isAisle: isAisleCol(ci, groups),
|
|
||||||
});
|
});
|
||||||
|
seatNumber++;
|
||||||
|
seatIndex++;
|
||||||
}
|
}
|
||||||
row++;
|
row++;
|
||||||
}
|
}
|
||||||
return seats;
|
return seats;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBedSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] {
|
type SeatRow = {
|
||||||
// arrangement for beds describes tiers per berth, e.g. '2+2' = 2 lower+upper on each side
|
coachId: string;
|
||||||
// Each compartment number is the row; each tier is the col (L=lower, M=middle, U=upper)
|
row: number;
|
||||||
const groups = parseArrangement(arrangement);
|
col: string;
|
||||||
const tiersPerSide = groups[0]; // e.g. 2 → lower+upper
|
seatNumber: string;
|
||||||
const positions = BED_POSITIONS[tiersPerSide] ?? ['lower', 'upper'];
|
kind: SeatKind;
|
||||||
const tierCols = positions.map((_, i) => String.fromCharCode(65 + i)); // A=lower, B=upper, C=middle
|
bedPosition?: string | null;
|
||||||
const seats: SeatRow[] = [];
|
};
|
||||||
let compartment = 1;
|
|
||||||
while (seats.length < totalUnits) {
|
|
||||||
for (let ti = 0; ti < tierCols.length && seats.length < totalUnits; ti++) {
|
|
||||||
const col = tierCols[ti];
|
|
||||||
seats.push({
|
|
||||||
coachId, row: compartment, col,
|
|
||||||
label: `${compartment}${col}`,
|
|
||||||
seatNumber: `${coachLabel}${compartment}${col}`,
|
|
||||||
kind: SeatKind.STANDARD,
|
|
||||||
isWindow: false,
|
|
||||||
isAisle: false,
|
|
||||||
bedPosition: positions[ti],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
compartment++;
|
|
||||||
}
|
|
||||||
return seats;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FleetService {
|
export class FleetService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async createCoachType(dto: CreateCoachTypeDto) {
|
||||||
|
return this.prisma.coachType.create({
|
||||||
|
data: {
|
||||||
|
code: dto.code,
|
||||||
|
name: dto.name,
|
||||||
|
type: dto.type || 'passenger',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
seatClasses: true,
|
||||||
|
coaches: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCoachTypes() {
|
||||||
|
return this.prisma.coachType.findMany({
|
||||||
|
include: {
|
||||||
|
seatClasses: true,
|
||||||
|
coaches: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCoachType(id: string, dto: UpdateCoachTypeDto) {
|
||||||
|
const coachType = await this.prisma.coachType.findUnique({ where: { id } });
|
||||||
|
if (!coachType) throw new NotFoundException('Coach type not found');
|
||||||
|
|
||||||
|
const data: any = {};
|
||||||
|
if (dto.code !== undefined) data.code = dto.code;
|
||||||
|
if (dto.name !== undefined) data.name = dto.name;
|
||||||
|
if (dto.type !== undefined) data.type = dto.type;
|
||||||
|
|
||||||
|
return this.prisma.coachType.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
seatClasses: true,
|
||||||
|
coaches: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCoachType(id: string) {
|
||||||
|
const coachType = await this.prisma.coachType.findUnique({ where: { id } });
|
||||||
|
if (!coachType) throw new NotFoundException('Coach type not found');
|
||||||
|
|
||||||
|
return this.prisma.coachType.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createClass(dto: CreateClassDto) {
|
||||||
|
return this.prisma.seatClass.create({
|
||||||
|
data: {
|
||||||
|
coachTypeId: dto.coachTypeId,
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
baseFareMinor: dto.baseFareMinor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClasses(coachTypeId?: string) {
|
||||||
|
const where = coachTypeId ? { coachTypeId } : {};
|
||||||
|
return this.prisma.seatClass.findMany({
|
||||||
|
where,
|
||||||
|
include: { coachType: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateClass(id: string, dto: UpdateClassDto) {
|
||||||
|
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||||
|
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||||
|
|
||||||
|
return this.prisma.seatClass.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
baseFareMinor: dto.baseFareMinor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteClass(id: string) {
|
||||||
|
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||||
|
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||||
|
|
||||||
|
return this.prisma.seatClass.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
createSeatClass(dto: CreateClassDto) {
|
||||||
|
return this.createClass(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSeatClasses(coachTypeId?: string) {
|
||||||
|
return this.getClasses(coachTypeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSeatClass(id: string, dto: UpdateClassDto) {
|
||||||
|
return this.updateClass(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSeatClass(id: string) {
|
||||||
|
return this.deleteClass(id);
|
||||||
|
}
|
||||||
|
|
||||||
getTrains() {
|
getTrains() {
|
||||||
return this.prisma.train.findMany({ include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } } });
|
return this.prisma.train.findMany({ include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } } });
|
||||||
}
|
}
|
||||||
|
|
||||||
createTrain(dto: CreateTrainDto) { return this.prisma.train.create({ data: dto }); }
|
createTrain(dto: CreateTrainDto) {
|
||||||
|
return this.prisma.train.create({ data: dto });
|
||||||
|
}
|
||||||
|
|
||||||
async updateTrain(id: string, dto: CreateTrainDto) {
|
async updateTrain(id: string, dto: CreateTrainDto) {
|
||||||
const train = await this.prisma.train.findUnique({ where: { id } });
|
const train = await this.prisma.train.findUnique({ where: { id } });
|
||||||
@@ -118,120 +214,112 @@ export class FleetService {
|
|||||||
return this.prisma.train.update({ where: { id }, data: dto });
|
return this.prisma.train.update({ where: { id }, data: dto });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCoach(id: string) {
|
|
||||||
const coach = await this.prisma.coach.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
seatClass: true,
|
|
||||||
seats: {
|
|
||||||
orderBy: [{ row: 'asc' }, { col: 'asc' }],
|
|
||||||
},
|
|
||||||
assignments: {
|
|
||||||
include: { schedule: { include: { originStation: true, destinationStation: true } } },
|
|
||||||
orderBy: { schedule: { departureAt: 'desc' } },
|
|
||||||
take: 5,
|
|
||||||
},
|
|
||||||
_count: { select: { seats: true, assignments: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!coach) throw new NotFoundException('Coach not found');
|
|
||||||
|
|
||||||
// Group seats by row to reflect the physical arrangement layout
|
|
||||||
const rowMap = new Map<number, typeof coach.seats>();
|
|
||||||
for (const seat of coach.seats) {
|
|
||||||
if (!rowMap.has(seat.row)) rowMap.set(seat.row, []);
|
|
||||||
rowMap.get(seat.row)!.push(seat);
|
|
||||||
}
|
|
||||||
|
|
||||||
const seatsByRow = Array.from(rowMap.entries()).map(([row, seats]) => ({ row, seats }));
|
|
||||||
|
|
||||||
const seatStatusSummary = {
|
|
||||||
total: coach.seats.length,
|
|
||||||
available: coach.seats.filter(s => s.status === 'AVAILABLE').length,
|
|
||||||
held: coach.seats.filter(s => s.status === 'HELD').length,
|
|
||||||
booked: coach.seats.filter(s => s.status === 'BOOKED').length,
|
|
||||||
blocked: coach.seats.filter(s => s.status === 'BLOCKED').length,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { seats, ...coachData } = coach;
|
|
||||||
return { ...coachData, seatsByRow, seatStatusSummary };
|
|
||||||
}
|
|
||||||
|
|
||||||
async listCoaches(dto: ListCoachesDto) {
|
|
||||||
const where: any = {};
|
|
||||||
if (dto.isActive !== undefined) where.isActive = dto.isActive;
|
|
||||||
if (dto.mode) where.mode = dto.mode;
|
|
||||||
if (dto.seatClassId) where.seatClassId = dto.seatClassId;
|
|
||||||
if (dto.scheduleId) where.assignments = { some: { scheduleId: dto.scheduleId } };
|
|
||||||
|
|
||||||
const coaches = await this.prisma.coach.findMany({
|
|
||||||
where,
|
|
||||||
include: {
|
|
||||||
seatClass: true,
|
|
||||||
seats: { select: { status: true } },
|
|
||||||
_count: { select: { seats: true, assignments: true } },
|
|
||||||
},
|
|
||||||
orderBy: [{ isActive: 'desc' }, { label: 'asc' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
return coaches.map(({ seats, ...coach }) => ({
|
|
||||||
...coach,
|
|
||||||
seatStatusSummary: {
|
|
||||||
total: seats.length,
|
|
||||||
available: seats.filter(s => s.status === 'AVAILABLE').length,
|
|
||||||
held: seats.filter(s => s.status === 'HELD').length,
|
|
||||||
booked: seats.filter(s => s.status === 'BOOKED').length,
|
|
||||||
blocked: seats.filter(s => s.status === 'BLOCKED').length,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async createCoach(dto: CreateCoachDto) {
|
|
||||||
const mode = dto.mode ?? 'seat';
|
|
||||||
const totalUnits = dto.totalUnits ?? 0;
|
|
||||||
|
|
||||||
const isBed = mode === 'bed';
|
|
||||||
const arrangement = isBed
|
|
||||||
? (dto.bedArrangement ?? dto.seatArrangement ?? '2+2')
|
|
||||||
: (dto.seatArrangement ?? '2+2');
|
|
||||||
|
|
||||||
if (totalUnits > 0) {
|
|
||||||
const groups = parseArrangement(arrangement);
|
|
||||||
if (groups.some(isNaN)) {
|
|
||||||
throw new BadRequestException(`Invalid arrangement format "${arrangement}". Use e.g. "2+2" or "2+2+2"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const coach = await this.prisma.coach.create({ data: dto });
|
|
||||||
|
|
||||||
if (totalUnits > 0) {
|
|
||||||
const seats = isBed
|
|
||||||
? buildBedSeats(coach.id, coach.label, arrangement, totalUnits)
|
|
||||||
: buildSeatSeats(coach.id, coach.label, arrangement, totalUnits);
|
|
||||||
await this.prisma.seat.createMany({ data: seats, skipDuplicates: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.prisma.coach.findUnique({
|
|
||||||
where: { id: coach.id },
|
|
||||||
include: { seatClass: true, _count: { select: { seats: true } } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateCoach(id: string, dto: UpdateCoachDto) {
|
|
||||||
const coach = await this.prisma.coach.findUnique({ where: { id } });
|
|
||||||
if (!coach) throw new NotFoundException('Coach not found');
|
|
||||||
return this.prisma.coach.update({ where: { id }, data: dto });
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteTrain(id: string) {
|
async deleteTrain(id: string) {
|
||||||
const train = await this.prisma.train.findUnique({ where: { id } });
|
const train = await this.prisma.train.findUnique({ where: { id } });
|
||||||
if (!train) throw new NotFoundException('Train not found');
|
if (!train) throw new NotFoundException('Train not found');
|
||||||
return this.prisma.train.delete({ where: { id } });
|
return this.prisma.train.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCoach(id: string) {
|
||||||
|
const coach = await this.prisma.coach.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
coachType: true,
|
||||||
|
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
|
||||||
|
assignments: {
|
||||||
|
include: { schedule: { include: { originStation: true, destinationStation: true } } },
|
||||||
|
orderBy: { schedule: { departureAt: 'desc' } },
|
||||||
|
take: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!coach) throw new NotFoundException('Coach not found');
|
||||||
|
return coach;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCoaches(dto: ListCoachesDto) {
|
||||||
|
const where: any = {};
|
||||||
|
if (dto.status) where.status = dto.status;
|
||||||
|
if (dto.scheduleId) where.assignments = { some: { scheduleId: dto.scheduleId } };
|
||||||
|
|
||||||
|
return this.prisma.coach.findMany({
|
||||||
|
where,
|
||||||
|
include: { coachType: true },
|
||||||
|
orderBy: { number: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCoach(dto: CreateCoachDto) {
|
||||||
|
const groups = parseArrangement(dto.arrangement);
|
||||||
|
if (groups.some(isNaN)) {
|
||||||
|
throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const coach = await this.prisma.coach.create({
|
||||||
|
data: {
|
||||||
|
coachTypeId: dto.coachTypeId,
|
||||||
|
number: dto.number,
|
||||||
|
arrangement: dto.arrangement,
|
||||||
|
capacity: dto.capacity,
|
||||||
|
status: dto.status || 'ACTIVE',
|
||||||
|
},
|
||||||
|
include: { coachType: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dto.capacity > 0) {
|
||||||
|
const seatClass = coach.coachType?.name || '';
|
||||||
|
const seats = buildSeats(coach.id, coach.number, dto.arrangement, dto.capacity, seatClass);
|
||||||
|
await this.prisma.seat.createMany({ data: seats });
|
||||||
|
}
|
||||||
|
|
||||||
|
return coach;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCoach(id: string, dto: UpdateCoachDto) {
|
||||||
|
const coach = await this.prisma.coach.findUnique({ where: { id } });
|
||||||
|
if (!coach) throw new NotFoundException('Coach not found');
|
||||||
|
|
||||||
|
return this.prisma.coach.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
arrangement: dto.arrangement,
|
||||||
|
capacity: dto.capacity,
|
||||||
|
status: dto.status,
|
||||||
|
},
|
||||||
|
include: { coachType: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async deleteCoach(id: string) {
|
async deleteCoach(id: string) {
|
||||||
const coach = await this.prisma.coach.findUnique({ where: { id } });
|
const coach = await this.prisma.coach.findUnique({ where: { id } });
|
||||||
if (!coach) throw new NotFoundException('Coach not found');
|
if (!coach) throw new NotFoundException('Coach not found');
|
||||||
|
|
||||||
|
// Get all seat IDs for this coach
|
||||||
|
const seats = await this.prisma.seat.findMany({ where: { coachId: id }, select: { id: true } });
|
||||||
|
const seatIds = seats.map(s => s.id);
|
||||||
|
|
||||||
|
// Delete in order of foreign key dependencies
|
||||||
|
if (seatIds.length > 0) {
|
||||||
|
// 1. Delete seat blocks (references seats)
|
||||||
|
await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } });
|
||||||
|
|
||||||
|
// 2. Delete ticket seats (references seats)
|
||||||
|
await this.prisma.ticketSeat.deleteMany({ where: { seatId: { in: seatIds } } });
|
||||||
|
|
||||||
|
// 3. Delete booking seats (references seats)
|
||||||
|
await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } });
|
||||||
|
|
||||||
|
// 4. Delete journey segments with these seats
|
||||||
|
await this.prisma.journeySegment.deleteMany({ where: { seatId: { in: seatIds } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Delete all associated seats
|
||||||
|
await this.prisma.seat.deleteMany({ where: { coachId: id } });
|
||||||
|
|
||||||
|
// 6. Delete coach assignments
|
||||||
|
await this.prisma.coachAssignment.deleteMany({ where: { coachId: id } });
|
||||||
|
|
||||||
|
// 7. Finally delete the coach
|
||||||
return this.prisma.coach.delete({ where: { id } });
|
return this.prisma.coach.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,19 +339,6 @@ export class FleetService {
|
|||||||
return this.prisma.coachAssignment.delete({ where: { id } });
|
return this.prisma.coachAssignment.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSeatBatch(dto: CreateSeatBatchDto) {
|
|
||||||
const coach = await this.prisma.coach.findUnique({ where: { id: dto.coachId } });
|
|
||||||
if (!coach) throw new NotFoundException('Coach not found');
|
|
||||||
const seats = [];
|
|
||||||
for (let row = 1; row <= dto.rows; row++) {
|
|
||||||
for (const col of dto.cols) {
|
|
||||||
seats.push({ coachId: dto.coachId, row, col, label: `${row}${col}`, seatNumber: `${coach.label}${row}${col}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await this.prisma.seat.createMany({ data: seats, skipDuplicates: true });
|
|
||||||
return { created: seats.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAnalytics() {
|
async getAnalytics() {
|
||||||
const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([
|
const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([
|
||||||
this.prisma.train.count(),
|
this.prisma.train.count(),
|
||||||
@@ -271,6 +346,12 @@ export class FleetService {
|
|||||||
this.prisma.seat.count(),
|
this.prisma.seat.count(),
|
||||||
this.prisma.seat.count({ where: { status: 'BOOKED' } }),
|
this.prisma.seat.count({ where: { status: 'BOOKED' } }),
|
||||||
]);
|
]);
|
||||||
return { totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0 };
|
return {
|
||||||
|
totalTrains,
|
||||||
|
totalSchedules,
|
||||||
|
totalSeats,
|
||||||
|
bookedSeats,
|
||||||
|
occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,22 +92,22 @@ export class PassengersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getProfile(passengerId: string) {
|
async getProfile(passengerId: string) {
|
||||||
const p = await this.prisma.passenger.findUnique({
|
const passenger = await this.prisma.passenger.findUnique({
|
||||||
where: { id: passengerId },
|
where: { id: passengerId },
|
||||||
include: {
|
include: {
|
||||||
user: { select: { fullName: true, email: true, phone: true } },
|
user: { select: { fullName: true, email: true, phone: true } },
|
||||||
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } } } },
|
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } } },
|
||||||
loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true,
|
loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!p) throw new NotFoundException('Passenger not found');
|
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: passenger.id,
|
||||||
fullName: p.user.fullName,
|
fullName: passenger.user.fullName,
|
||||||
email: p.user.email,
|
email: passenger.user.email,
|
||||||
phone: p.user.phone,
|
phone: passenger.user.phone,
|
||||||
createdAt: p.createdAt,
|
createdAt: passenger.createdAt,
|
||||||
bookings: p.bookings.map((b) => ({
|
bookings: passenger.bookings.map((b) => ({
|
||||||
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,
|
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,
|
||||||
trip: {
|
trip: {
|
||||||
number: b.schedule.train.number,
|
number: b.schedule.train.number,
|
||||||
@@ -115,7 +115,7 @@ export class PassengersService {
|
|||||||
destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city },
|
destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city },
|
||||||
departureAt: b.schedule.departureAt,
|
departureAt: b.schedule.departureAt,
|
||||||
},
|
},
|
||||||
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass?.name ?? 'N/A' } })),
|
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' } })),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,6 @@ export class PassengersService {
|
|||||||
const isLoggedIn = !!dto.userId;
|
const isLoggedIn = !!dto.userId;
|
||||||
let verifiedData: any = null;
|
let verifiedData: any = null;
|
||||||
|
|
||||||
// Auto-verify Ethiopian passengers with national ID if Fayda is enabled
|
|
||||||
if (isEthiopian && dto.verifyWithFayda !== false) {
|
if (isEthiopian && dto.verifyWithFayda !== false) {
|
||||||
try {
|
try {
|
||||||
const verification = await this.verifaydaService.verifyNationalId(dto.nationalId!);
|
const verification = await this.verifaydaService.verifyNationalId(dto.nationalId!);
|
||||||
@@ -215,12 +214,10 @@ export class PassengersService {
|
|||||||
verifiedData = verification.passengerData;
|
verifiedData = verification.passengerData;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If verification fails, continue with manual data
|
|
||||||
console.warn('Fayda verification failed, using manual data:', error);
|
console.warn('Fayda verification failed, using manual data:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use verified data if available, otherwise use provided data
|
|
||||||
const finalData = {
|
const finalData = {
|
||||||
passengerName: verifiedData?.fullName || dto.passengerName,
|
passengerName: verifiedData?.fullName || dto.passengerName,
|
||||||
dateOfBirth: verifiedData?.dateOfBirth || new Date(dto.dateOfBirth),
|
dateOfBirth: verifiedData?.dateOfBirth || new Date(dto.dateOfBirth),
|
||||||
@@ -230,7 +227,6 @@ export class PassengersService {
|
|||||||
email: dto.email,
|
email: dto.email,
|
||||||
};
|
};
|
||||||
|
|
||||||
// If logged in, update user profile and link passenger
|
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: dto.userId },
|
where: { id: dto.userId },
|
||||||
@@ -241,7 +237,6 @@ export class PassengersService {
|
|||||||
throw new BadRequestException('User not found');
|
throw new BadRequestException('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user record if not already verified
|
|
||||||
if (!user.faydaVerified && verifiedData) {
|
if (!user.faydaVerified && verifiedData) {
|
||||||
await this.prisma.user.update({
|
await this.prisma.user.update({
|
||||||
where: { id: dto.userId },
|
where: { id: dto.userId },
|
||||||
@@ -267,7 +262,6 @@ export class PassengersService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guest user - save to SavedPassengerProfile
|
|
||||||
const profile = await this.prisma.savedPassengerProfile.create({
|
const profile = await this.prisma.savedPassengerProfile.create({
|
||||||
data: {
|
data: {
|
||||||
deviceId: dto.deviceId,
|
deviceId: dto.deviceId,
|
||||||
@@ -318,4 +312,4 @@ export class PassengersService {
|
|||||||
affectedModules: usage,
|
affectedModules: usage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,19 +40,17 @@ describe('Payments E2E', () => {
|
|||||||
data: { trainId: train.id, originStationId: station1.id, destinationStationId: station2.id, departureAt: new Date(Date.now() + 86400000), arrivalAt: new Date(Date.now() + 90000000), durationMinutes: 60 },
|
data: { trainId: train.id, originStationId: station1.id, destinationStationId: station2.id, departureAt: new Date(Date.now() + 86400000), arrivalAt: new Date(Date.now() + 90000000), durationMinutes: 60 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const seatClass = await prisma.seatClass.upsert({
|
const coachType = await prisma.coachType.create({ data: { name: 'Standard', code: 'STD' } });
|
||||||
where: { name: 'Economy Regular' },
|
|
||||||
update: {},
|
const seatClass = await prisma.seatClass.create({
|
||||||
create: { name: 'Economy Regular', description: 'Standard economy seating', basePrice: 45000, isActive: true },
|
data: { name: 'Economy Regular', description: 'Standard economy seating', baseFareMinor: 45000, isActive: true, coachTypeId: coachType.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
const coach = await prisma.coach.create({
|
const coach = await prisma.coach.create({
|
||||||
data: { coachNumber: 'TEST-C1', label: 'A', seatClassId: seatClass.id, mode: 'seat', totalUnits: 10 },
|
data: { coachTypeId: coachType.id, number: 'TEST-C1', arrangement: '2+2', capacity: 10, status: 'ACTIVE' },
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.coachAssignment.create({ data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1 } });
|
const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', seatNumber: '1A', status: 'AVAILABLE' } });
|
||||||
|
|
||||||
const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', label: '1A', status: 'AVAILABLE' } });
|
|
||||||
|
|
||||||
const booking = await prisma.booking.create({
|
const booking = await prisma.booking.create({
|
||||||
data: { bookingRef: 'TEST-BOOK-001', passengerId: passenger.id, scheduleId: schedule.id, status: 'PENDING_PAYMENT', totalMinor: 50000, currency: 'ETB' },
|
data: { bookingRef: 'TEST-BOOK-001', passengerId: passenger.id, scheduleId: schedule.id, status: 'PENDING_PAYMENT', totalMinor: 50000, currency: 'ETB' },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||||
import { SchedulesService } from './schedules.service';
|
import { SchedulesService } from './schedules.service';
|
||||||
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
|
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto } from './schedules.dto';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
import { TripStatus } from '@prisma/client';
|
import { TripStatus } from '@prisma/client';
|
||||||
|
|
||||||
@@ -10,14 +10,23 @@ import { TripStatus } from '@prisma/client';
|
|||||||
export class SchedulesController {
|
export class SchedulesController {
|
||||||
constructor(private service: SchedulesService) {}
|
constructor(private service: SchedulesService) {}
|
||||||
|
|
||||||
|
@Post('bulk-generate')
|
||||||
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Bulk generate repetitive schedules',
|
||||||
|
description: 'Creates multiple schedules automatically by repeating every X days for the next Y days. Example: repeat every 2 days for 30 days = 15 schedules.',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 201, description: 'Schedules generated successfully' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid parameters or route not found' })
|
||||||
|
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
|
||||||
|
return this.service.bulkGenerateSchedules(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Create a train schedule from a route template',
|
summary: 'Create a train schedule from a route template',
|
||||||
description: `Creates a schedule by referencing a Route (routeId).
|
description: `Creates a schedule by referencing a Route (routeId).\nStops are automatically copied from the route's RouteStop definitions.\nYou supply the actual planned arrival/departure times per stop sequence.\nOrigin and destination are derived from the first and last route stop — no need to specify them manually.`,
|
||||||
Stops are automatically copied from the route's RouteStop definitions.
|
|
||||||
You supply the actual planned arrival/departure times per stop sequence.
|
|
||||||
Origin and destination are derived from the first and last route stop — no need to specify them manually.`,
|
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' })
|
@ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' })
|
||||||
@ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' })
|
@ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' })
|
||||||
@@ -40,13 +49,42 @@ Origin and destination are derived from the first and last route stop — no nee
|
|||||||
return this.service.listSchedules({ date, routeId, trainId, status });
|
return this.service.listSchedules({ date, routeId, trainId, status });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static routes before parameterised ones
|
// ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
|
||||||
|
|
||||||
@Post('fares')
|
@Post('fares')
|
||||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
|
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
|
||||||
@ApiResponse({ status: 201, description: 'Fare rule created' })
|
@ApiResponse({ status: 201, description: 'Fare rule created' })
|
||||||
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
|
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
|
||||||
|
|
||||||
|
@Post('segment-fares')
|
||||||
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Create a segment fare rule (stop-to-stop pricing on a route)' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Segment fare rule created' })
|
||||||
|
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
|
||||||
|
|
||||||
|
@Get('routes/:routeId/segment-fares')
|
||||||
|
@ApiOperation({ summary: 'List all segment fare rules for a route' })
|
||||||
|
@ApiParam({ name: 'routeId', description: 'Route UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'List of segment fare rules' })
|
||||||
|
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
|
||||||
|
|
||||||
|
@Patch('segment-fares/:id')
|
||||||
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Update a segment fare rule' })
|
||||||
|
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Segment fare rule updated' })
|
||||||
|
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
|
||||||
|
|
||||||
|
@Delete('segment-fares/:id')
|
||||||
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Delete a segment fare rule' })
|
||||||
|
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Segment fare rule deleted' })
|
||||||
|
deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); }
|
||||||
|
|
||||||
|
// ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) =====
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' })
|
@ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
@@ -56,12 +94,12 @@ Origin and destination are derived from the first and last route stop — no nee
|
|||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Update a schedule' })
|
@ApiOperation({ summary: 'Update a schedule (partial update - times, status, coaches)' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Schedule updated' })
|
@ApiResponse({ status: 200, description: 'Schedule updated' })
|
||||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||||
updateSchedule(@Param('id') id: string, @Body() dto: CreateScheduleDto) {
|
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
|
||||||
return this.service.updateSchedule(id, dto);
|
return this.service.updateSchedulePartial(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
@@ -84,8 +122,6 @@ Origin and destination are derived from the first and last route stop — no nee
|
|||||||
return this.service.deleteSchedule(id);
|
return this.service.deleteSchedule(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stop Times ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Get(':id/stops')
|
@Get(':id/stops')
|
||||||
@ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' })
|
@ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
@@ -106,8 +142,6 @@ Origin and destination are derived from the first and last route stop — no nee
|
|||||||
@Body() dto: UpdateStopTimeDto,
|
@Body() dto: UpdateStopTimeDto,
|
||||||
) { return this.service.updateStop(id, sequence, dto); }
|
) { return this.service.updateStop(id, sequence, dto); }
|
||||||
|
|
||||||
// ── Fares ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Get(':scheduleId/fares')
|
@Get(':scheduleId/fares')
|
||||||
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
|
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
|
||||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||||
@@ -151,8 +185,6 @@ Origin and destination are derived from the first and last route stop — no nee
|
|||||||
return this.service.syncFaresFromEngine(id);
|
return this.service.syncFaresFromEngine(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Coach Assignments ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Post(':id/coaches')
|
@Post(':id/coaches')
|
||||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ export class CreateScheduleDto {
|
|||||||
plannedTimes: PlannedStopTimeDto[];
|
plannedTimes: PlannedStopTimeDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpdateScheduleDto {
|
||||||
|
@ApiPropertyOptional({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsOptional() @IsDateString() departureAt?: string;
|
||||||
|
@ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string;
|
||||||
|
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
|
||||||
|
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateStopTimeDto {
|
export class UpdateStopTimeDto {
|
||||||
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
|
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
|
||||||
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
|
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
|
||||||
@@ -50,6 +57,17 @@ export class CreateFareRuleDto {
|
|||||||
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
|
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CreateSegmentFareRuleDto {
|
||||||
|
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
|
||||||
|
@ApiProperty({ example: 1, description: 'Origin stop sequence number' }) @IsInt() @Min(1) originStopSequence: number;
|
||||||
|
@ApiProperty({ example: 2, description: 'Destination stop sequence number' }) @IsInt() @Min(1) destinationStopSequence: number;
|
||||||
|
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
|
||||||
|
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
|
||||||
|
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality scope (Ethiopian, Djiboutian, Other)' }) @IsOptional() @IsString() nationality?: string;
|
||||||
|
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
|
||||||
|
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ListSchedulesDto {
|
export class ListSchedulesDto {
|
||||||
@ApiPropertyOptional({ example: '2026-06-15', description: 'Filter by departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' })
|
@ApiPropertyOptional({ example: '2026-06-15', description: 'Filter by departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' })
|
||||||
@IsOptional() @IsDateString() date?: string;
|
@IsOptional() @IsDateString() date?: string;
|
||||||
@@ -67,3 +85,21 @@ export class ListSchedulesDto {
|
|||||||
export class UpdateScheduleStatusDto {
|
export class UpdateScheduleStatusDto {
|
||||||
@ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus;
|
@ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BulkCreateSchedulesDto {
|
||||||
|
@ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string;
|
||||||
|
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
|
||||||
|
@ApiProperty({ example: '2026-06-15T08:00:00Z', description: 'Start date and time for first schedule' }) @IsDateString() startDateTime: string;
|
||||||
|
@ApiProperty({ example: 12, description: 'Hours duration per schedule' }) @IsInt() @Min(1) durationHours: number;
|
||||||
|
@ApiProperty({ example: 2, description: 'Repeat every X days' }) @IsInt() @Min(1) repeatEveryDays: number;
|
||||||
|
@ApiProperty({ example: 30, description: 'Generate schedules for the next Y days' }) @IsInt() @Min(1) forNextDays: number;
|
||||||
|
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' })
|
||||||
|
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||||
|
plannedTimes?: PlannedStopTimeDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BulkSchedulesResponseDto {
|
||||||
|
@ApiProperty() schedulesCreated: number;
|
||||||
|
@ApiProperty() errors: string[];
|
||||||
|
@ApiProperty() scheduleIds: string[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
|
|||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { RoutesService } from './routes.service';
|
import { RoutesService } from './routes.service';
|
||||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||||
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
|
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SchedulesService {
|
export class SchedulesService {
|
||||||
@@ -10,9 +10,55 @@ export class SchedulesService {
|
|||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private routesService: RoutesService,
|
private routesService: RoutesService,
|
||||||
private fareEngine: FareEngineService,
|
private fareEngine: FareEngineService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
// ── Schedule CRUD ──────────────────────────────────────────────────────────
|
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
|
||||||
|
const startDate = new Date(dto.startDateTime);
|
||||||
|
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
|
||||||
|
const errors: string[] = [];
|
||||||
|
const scheduleIds: string[] = [];
|
||||||
|
|
||||||
|
// Validate route and get stops for plannedTimes generation
|
||||||
|
const route = await this.prisma.route.findUnique({
|
||||||
|
where: { id: dto.routeId },
|
||||||
|
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||||
|
});
|
||||||
|
if (!route) throw new NotFoundException('Route not found');
|
||||||
|
if (!route.active) throw new BadRequestException('Route is not active');
|
||||||
|
|
||||||
|
let currentDate = new Date(startDate);
|
||||||
|
let scheduleCount = 0;
|
||||||
|
|
||||||
|
while (currentDate < endDate) {
|
||||||
|
try {
|
||||||
|
const departureAt = new Date(currentDate);
|
||||||
|
const arrivalAt = new Date(departureAt.getTime() + dto.durationHours * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const createDto: CreateScheduleDto = {
|
||||||
|
trainId: dto.trainId,
|
||||||
|
routeId: dto.routeId,
|
||||||
|
departureAt: departureAt.toISOString(),
|
||||||
|
arrivalAt: arrivalAt.toISOString(),
|
||||||
|
plannedTimes: dto.plannedTimes || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule = await this.createSchedule(createDto);
|
||||||
|
scheduleIds.push(schedule.id);
|
||||||
|
scheduleCount++;
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next repetition
|
||||||
|
currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
schedulesCreated: scheduleCount,
|
||||||
|
errors,
|
||||||
|
scheduleIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async listSchedules(dto: ListSchedulesDto) {
|
async listSchedules(dto: ListSchedulesDto) {
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
@@ -58,28 +104,48 @@ export class SchedulesService {
|
|||||||
if (!route.active) throw new BadRequestException('Route is not active');
|
if (!route.active) throw new BadRequestException('Route is not active');
|
||||||
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
|
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
|
||||||
|
|
||||||
|
// Check for duplicate schedule with same train, route, and date
|
||||||
|
const depDate = new Date(dep);
|
||||||
|
depDate.setHours(0, 0, 0, 0);
|
||||||
|
const nextDay = new Date(depDate);
|
||||||
|
nextDay.setDate(nextDay.getDate() + 1);
|
||||||
|
|
||||||
|
const existingSchedule = await this.prisma.trainSchedule.findFirst({
|
||||||
|
where: {
|
||||||
|
trainId: dto.trainId,
|
||||||
|
routeId: dto.routeId,
|
||||||
|
departureAt: {
|
||||||
|
gte: depDate,
|
||||||
|
lt: nextDay,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingSchedule) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`A schedule for this train, route, and date already exists. Departure: ${new Date(existingSchedule.departureAt).toLocaleString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-generate plannedTimes if not provided or empty
|
// Auto-generate plannedTimes if not provided or empty
|
||||||
let plannedTimes = dto.plannedTimes;
|
let plannedTimes = dto.plannedTimes;
|
||||||
if (!plannedTimes || plannedTimes.length === 0) {
|
if (!plannedTimes || plannedTimes.length === 0) {
|
||||||
const totalDuration = arr.getTime() - dep.getTime();
|
const totalDuration = arr.getTime() - dep.getTime();
|
||||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||||
|
|
||||||
plannedTimes = route.stops.map((stop, index) => {
|
plannedTimes = route.stops.map((stop, index) => {
|
||||||
let stopTime: Date;
|
let stopTime: Date;
|
||||||
|
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
// First stop - use departure time
|
|
||||||
stopTime = dep;
|
stopTime = dep;
|
||||||
} else if (index === route.stops.length - 1) {
|
} else if (index === route.stops.length - 1) {
|
||||||
// Last stop - use arrival time
|
|
||||||
stopTime = arr;
|
stopTime = arr;
|
||||||
} else {
|
} else {
|
||||||
// Intermediate stops - calculate based on distance proportion
|
|
||||||
const stopDistance = stop.distanceKm || 0;
|
const stopDistance = stop.distanceKm || 0;
|
||||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sequence: stop.sequence,
|
sequence: stop.sequence,
|
||||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||||
@@ -113,7 +179,6 @@ export class SchedulesService {
|
|||||||
include: { train: true, originStation: true, destinationStation: true },
|
include: { train: true, originStation: true, destinationStation: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Copy route stops into TripStopTime with the provided planned times
|
|
||||||
const plannedTimesMap = Object.fromEntries(
|
const plannedTimesMap = Object.fromEntries(
|
||||||
plannedTimes.map(t => [t.sequence, t]),
|
plannedTimes.map(t => [t.sequence, t]),
|
||||||
);
|
);
|
||||||
@@ -130,7 +195,7 @@ export class SchedulesService {
|
|||||||
originStation: true,
|
originStation: true,
|
||||||
destinationStation: true,
|
destinationStation: true,
|
||||||
coachAssignments: {
|
coachAssignments: {
|
||||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
|
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
|
||||||
orderBy: { positionNumber: 'asc' },
|
orderBy: { positionNumber: 'asc' },
|
||||||
},
|
},
|
||||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||||
@@ -138,18 +203,16 @@ export class SchedulesService {
|
|||||||
});
|
});
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
// Compute effective seat statuses from SeatHold + JourneySegment
|
const allSeatIds = schedule.coachAssignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||||
// (seat.status DB column is no longer written during booking)
|
|
||||||
const allSeatIds = schedule.coachAssignments.flatMap(a => a.coach.seats.map(s => s.id));
|
|
||||||
const effectiveStatuses = await this.resolveEffectiveStatuses(id, allSeatIds);
|
const effectiveStatuses = await this.resolveEffectiveStatuses(id, allSeatIds);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...schedule,
|
...schedule,
|
||||||
coachAssignments: schedule.coachAssignments.map(a => ({
|
coachAssignments: schedule.coachAssignments.map((a: any) => ({
|
||||||
...a,
|
...a,
|
||||||
coach: {
|
coach: {
|
||||||
...a.coach,
|
...a.coach,
|
||||||
seats: a.coach.seats.map(s => ({
|
seats: a.coach.seats.map((s: any) => ({
|
||||||
...s,
|
...s,
|
||||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||||
})),
|
})),
|
||||||
@@ -158,12 +221,6 @@ export class SchedulesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes effective seat status for a schedule by checking active SeatHolds
|
|
||||||
* and confirmed JourneySegments. The DB seat.status column is not written
|
|
||||||
* during segment-based booking, so this overlay is required.
|
|
||||||
* Priority: BLOCKED (physical) > BOOKED (confirmed) > HELD (active hold) > AVAILABLE
|
|
||||||
*/
|
|
||||||
private async resolveEffectiveStatuses(
|
private async resolveEffectiveStatuses(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
seatIds: string[],
|
seatIds: string[],
|
||||||
@@ -204,7 +261,6 @@ export class SchedulesService {
|
|||||||
const arr = new Date(dto.arrivalAt);
|
const arr = new Date(dto.arrivalAt);
|
||||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||||
|
|
||||||
// Validate route exists and has stops
|
|
||||||
const route = await this.prisma.route.findUnique({
|
const route = await this.prisma.route.findUnique({
|
||||||
where: { id: dto.routeId },
|
where: { id: dto.routeId },
|
||||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||||
@@ -213,7 +269,6 @@ export class SchedulesService {
|
|||||||
if (!route.active) throw new BadRequestException('Route is not active');
|
if (!route.active) throw new BadRequestException('Route is not active');
|
||||||
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
|
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
|
||||||
|
|
||||||
// Derive origin and destination from first and last route stop
|
|
||||||
const firstStop = route.stops[0];
|
const firstStop = route.stops[0];
|
||||||
const lastStop = route.stops[route.stops.length - 1];
|
const lastStop = route.stops[route.stops.length - 1];
|
||||||
|
|
||||||
@@ -231,18 +286,16 @@ export class SchedulesService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete existing stop times and recreate
|
|
||||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
||||||
|
|
||||||
// Auto-generate plannedTimes if not provided
|
|
||||||
let plannedTimes = dto.plannedTimes;
|
let plannedTimes = dto.plannedTimes;
|
||||||
if (!plannedTimes || plannedTimes.length === 0) {
|
if (!plannedTimes || plannedTimes.length === 0) {
|
||||||
const totalDuration = arr.getTime() - dep.getTime();
|
const totalDuration = arr.getTime() - dep.getTime();
|
||||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||||
|
|
||||||
plannedTimes = route.stops.map((stop, index) => {
|
plannedTimes = route.stops.map((stop, index) => {
|
||||||
let stopTime: Date;
|
let stopTime: Date;
|
||||||
|
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
stopTime = dep;
|
stopTime = dep;
|
||||||
} else if (index === route.stops.length - 1) {
|
} else if (index === route.stops.length - 1) {
|
||||||
@@ -252,7 +305,7 @@ export class SchedulesService {
|
|||||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sequence: stop.sequence,
|
sequence: stop.sequence,
|
||||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||||
@@ -276,19 +329,53 @@ export class SchedulesService {
|
|||||||
async deleteSchedule(id: string) {
|
async deleteSchedule(id: string) {
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
// Delete related records first (in dependency order)
|
|
||||||
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
|
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
|
||||||
await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } });
|
await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } });
|
||||||
|
|
||||||
|
const bookings = await this.prisma.booking.findMany({
|
||||||
|
where: { scheduleId: id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const bookingIds = bookings.map(b => b.id);
|
||||||
|
|
||||||
|
if (bookingIds.length > 0) {
|
||||||
|
const paymentIntents = await this.prisma.paymentIntent.findMany({
|
||||||
|
where: { bookingId: { in: bookingIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const paymentIntentIds = paymentIntents.map(pi => pi.id);
|
||||||
|
|
||||||
|
if (paymentIntentIds.length > 0) {
|
||||||
|
await this.prisma.paymentRefund.deleteMany({
|
||||||
|
where: { paymentIntentId: { in: paymentIntentIds } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.ticket.deleteMany({
|
||||||
|
where: { bookingId: { in: bookingIds } },
|
||||||
|
});
|
||||||
|
await this.prisma.bookingSeat.deleteMany({
|
||||||
|
where: { bookingId: { in: bookingIds } },
|
||||||
|
});
|
||||||
|
await this.prisma.bookingModification.deleteMany({
|
||||||
|
where: { bookingId: { in: bookingIds } },
|
||||||
|
});
|
||||||
|
await this.prisma.bookingCancellation.deleteMany({
|
||||||
|
where: { bookingId: { in: bookingIds } },
|
||||||
|
});
|
||||||
|
await this.prisma.paymentIntent.deleteMany({
|
||||||
|
where: { bookingId: { in: bookingIds } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await this.prisma.booking.deleteMany({ where: { scheduleId: id } });
|
await this.prisma.booking.deleteMany({ where: { scheduleId: id } });
|
||||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
||||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||||
|
|
||||||
return this.prisma.trainSchedule.delete({ where: { id } });
|
return this.prisma.trainSchedule.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stop Times (per-schedule overrides) ───────────────────────────────────
|
|
||||||
|
|
||||||
getStops(scheduleId: string) {
|
getStops(scheduleId: string) {
|
||||||
return this.prisma.tripStopTime.findMany({
|
return this.prisma.tripStopTime.findMany({
|
||||||
where: { scheduleId },
|
where: { scheduleId },
|
||||||
@@ -314,8 +401,6 @@ export class SchedulesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fare Rules ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
createFareRule(dto: CreateFareRuleDto) {
|
createFareRule(dto: CreateFareRuleDto) {
|
||||||
const { validFrom, validUntil, scheduleId, nationality, ...rest } = dto;
|
const { validFrom, validUntil, scheduleId, nationality, ...rest } = dto;
|
||||||
return this.prisma.fareRule.create({
|
return this.prisma.fareRule.create({
|
||||||
@@ -329,6 +414,43 @@ export class SchedulesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createSegmentFareRule(dto: any) {
|
||||||
|
const { validFrom, validUntil, ...rest } = dto;
|
||||||
|
return this.prisma.segmentFareRule.create({
|
||||||
|
data: {
|
||||||
|
...rest,
|
||||||
|
validFrom: new Date(validFrom),
|
||||||
|
validUntil: validUntil ? new Date(validUntil) : null,
|
||||||
|
},
|
||||||
|
include: { seatClass: true, route: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getSegmentFares(routeId: string) {
|
||||||
|
return this.prisma.segmentFareRule.findMany({
|
||||||
|
where: { routeId },
|
||||||
|
include: { seatClass: true, route: true },
|
||||||
|
orderBy: [{ originStopSequence: 'asc' }, { destinationStopSequence: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSegmentFareRule(id: string) {
|
||||||
|
return this.prisma.segmentFareRule.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSegmentFareRule(id: string, dto: any) {
|
||||||
|
const { validFrom, validUntil, ...rest } = dto;
|
||||||
|
return this.prisma.segmentFareRule.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...rest,
|
||||||
|
validFrom: validFrom ? new Date(validFrom) : undefined,
|
||||||
|
validUntil: validUntil ? new Date(validUntil) : null,
|
||||||
|
},
|
||||||
|
include: { seatClass: true, route: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) {
|
getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) {
|
||||||
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
|
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
|
||||||
}
|
}
|
||||||
@@ -337,10 +459,6 @@ export class SchedulesService {
|
|||||||
return this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
|
return this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Recalculate fares for all active seat classes on a schedule using the fare engine
|
|
||||||
* and upsert them as FareRule records scoped to this schedule.
|
|
||||||
*/
|
|
||||||
async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> {
|
async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> {
|
||||||
const results = await this.fareEngine.calculateAllForSchedule(scheduleId);
|
const results = await this.fareEngine.calculateAllForSchedule(scheduleId);
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
@@ -352,7 +470,6 @@ export class SchedulesService {
|
|||||||
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: fare.seatClassName } });
|
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: fare.seatClassName } });
|
||||||
if (!seatClass) { errors.push(`Seat class not found: ${fare.seatClassName}`); continue; }
|
if (!seatClass) { errors.push(`Seat class not found: ${fare.seatClassName}`); continue; }
|
||||||
|
|
||||||
// Expire any existing active rule for this schedule + seat class
|
|
||||||
await this.prisma.fareRule.updateMany({
|
await this.prisma.fareRule.updateMany({
|
||||||
where: { tripId: scheduleId, seatClassId: seatClass.id, validUntil: null },
|
where: { tripId: scheduleId, seatClassId: seatClass.id, validUntil: null },
|
||||||
data: { validUntil: now },
|
data: { validUntil: now },
|
||||||
@@ -377,8 +494,6 @@ export class SchedulesService {
|
|||||||
return { synced, errors };
|
return { synced, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Coach Assignments ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async assignCoaches(
|
async assignCoaches(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
coaches: Array<{ coachId: string; positionNumber: number }>,
|
coaches: Array<{ coachId: string; positionNumber: number }>,
|
||||||
@@ -386,7 +501,6 @@ export class SchedulesService {
|
|||||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
// Validate all coaches exist
|
|
||||||
const coachIds = coaches.map(c => c.coachId);
|
const coachIds = coaches.map(c => c.coachId);
|
||||||
const existingCoaches = await this.prisma.coach.findMany({
|
const existingCoaches = await this.prisma.coach.findMany({
|
||||||
where: { id: { in: coachIds } },
|
where: { id: { in: coachIds } },
|
||||||
@@ -395,18 +509,16 @@ export class SchedulesService {
|
|||||||
throw new NotFoundException('One or more coaches not found');
|
throw new NotFoundException('One or more coaches not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove existing assignments
|
|
||||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
|
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
|
||||||
|
|
||||||
// Create new assignments
|
const data = coaches.map((c, idx) => ({
|
||||||
await this.prisma.coachAssignment.createMany({
|
scheduleId,
|
||||||
data: coaches.map(c => ({
|
coachId: c.coachId,
|
||||||
scheduleId,
|
positionNumber: idx + 1,
|
||||||
coachId: c.coachId,
|
isOperational: true,
|
||||||
positionNumber: c.positionNumber,
|
}));
|
||||||
isOperational: true,
|
|
||||||
})),
|
await this.prisma.coachAssignment.createMany({ data });
|
||||||
});
|
|
||||||
|
|
||||||
return { message: 'Coaches assigned successfully', count: coaches.length };
|
return { message: 'Coaches assigned successfully', count: coaches.length };
|
||||||
}
|
}
|
||||||
@@ -417,7 +529,6 @@ export class SchedulesService {
|
|||||||
include: {
|
include: {
|
||||||
coach: {
|
coach: {
|
||||||
include: {
|
include: {
|
||||||
seatClass: true,
|
|
||||||
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
|
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -426,6 +537,41 @@ export class SchedulesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateSchedulePartial(id: string, dto: UpdateScheduleDto) {
|
||||||
|
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||||
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
|
||||||
|
if (dto.departureAt || dto.arrivalAt) {
|
||||||
|
const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt);
|
||||||
|
const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt);
|
||||||
|
|
||||||
|
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
|
||||||
|
|
||||||
|
updateData.departureAt = dep;
|
||||||
|
updateData.arrivalAt = arr;
|
||||||
|
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.status) {
|
||||||
|
updateData.status = dto.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length > 0) {
|
||||||
|
await this.prisma.trainSchedule.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.coaches && dto.coaches.length > 0) {
|
||||||
|
await this.assignCoaches(id, dto.coaches);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getSchedule(id);
|
||||||
|
}
|
||||||
|
|
||||||
async removeCoachAssignment(scheduleId: string, coachId: string) {
|
async removeCoachAssignment(scheduleId: string, coachId: string) {
|
||||||
const assignment = await this.prisma.coachAssignment.findFirst({
|
const assignment = await this.prisma.coachAssignment.findFirst({
|
||||||
where: { scheduleId, coachId },
|
where: { scheduleId, coachId },
|
||||||
@@ -435,4 +581,4 @@ export class SchedulesService {
|
|||||||
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
|
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
|
||||||
return { message: 'Coach assignment removed' };
|
return { message: 'Coach assignment removed' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,8 +22,6 @@ export class SearchService {
|
|||||||
const nextDay = new Date(date.getTime() + 86_400_000);
|
const nextDay = new Date(date.getTime() + 86_400_000);
|
||||||
const totalPassengers = dto.adultCount + (dto.childCount ?? 0);
|
const totalPassengers = dto.adultCount + (dto.childCount ?? 0);
|
||||||
|
|
||||||
// Find all schedules that have BOTH origin and destination as stops
|
|
||||||
// (not just terminal-to-terminal) and depart on the requested date
|
|
||||||
const schedules = await this.prisma.trainSchedule.findMany({
|
const schedules = await this.prisma.trainSchedule.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||||
@@ -36,7 +34,7 @@ export class SearchService {
|
|||||||
destinationStation: true,
|
destinationStation: true,
|
||||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||||
coachAssignments: {
|
coachAssignments: {
|
||||||
include: { coach: { include: { seats: true, seatClass: true } } },
|
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -44,36 +42,46 @@ export class SearchService {
|
|||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
for (const schedule of schedules) {
|
for (const schedule of schedules) {
|
||||||
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
|
||||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
|
||||||
|
|
||||||
// Both stops must exist and origin must come before destination
|
|
||||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
|
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
|
||||||
|
|
||||||
// Compute per-seat availability for the requested segment range
|
|
||||||
// A seat is available if no active booking/hold overlaps [originSeq, destSeq)
|
|
||||||
const availabilityByClass: Record<string, number> = {};
|
const availabilityByClass: Record<string, number> = {};
|
||||||
|
|
||||||
for (const assignment of schedule.coachAssignments) {
|
for (const assignment of schedule.coachAssignments) {
|
||||||
const className = assignment.coach.seatClass.name;
|
// Get seat class names from coach type
|
||||||
if (!availabilityByClass[className]) availabilityByClass[className] = 0;
|
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
|
||||||
|
|
||||||
|
for (const seatClassName of seatClassNames) {
|
||||||
|
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count available seats (skip blocked and removed seats)
|
||||||
for (const seat of assignment.coach.seats) {
|
for (const seat of assignment.coach.seats) {
|
||||||
|
// Skip blocked seats
|
||||||
if (seat.status === 'BLOCKED') continue;
|
if (seat.status === 'BLOCKED') continue;
|
||||||
// Use segment-aware check — a seat booked A→B is still free for B→D
|
|
||||||
|
// Skip removed seats (empty seatNumber)
|
||||||
|
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
|
||||||
|
|
||||||
const free = await this.segmentsService.isSeatFreeForLeg(
|
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||||
schedule.id, seat.id,
|
schedule.id, seat.id,
|
||||||
originStop.sequence, destStop.sequence,
|
originStop.sequence, destStop.sequence,
|
||||||
);
|
);
|
||||||
if (free) availabilityByClass[className]++;
|
|
||||||
|
if (free) {
|
||||||
|
// Group by seat class - use the first seat class for now
|
||||||
|
// In a full implementation, seats would have a seatClassId
|
||||||
|
const className = seatClassNames[0] || 'Standard';
|
||||||
|
availabilityByClass[className]++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Departure/arrival times for the requested leg (not the full schedule)
|
|
||||||
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||||
|
|
||||||
// Fetch fares for all seat classes - need to pass the SEARCH origin/destination, not schedule terminals
|
|
||||||
const faresByClass = await this.calculateFaresForSegment(
|
const faresByClass = await this.calculateFaresForSegment(
|
||||||
schedule,
|
schedule,
|
||||||
dto.originStationId,
|
dto.originStationId,
|
||||||
@@ -106,14 +114,14 @@ export class SearchService {
|
|||||||
),
|
),
|
||||||
status: schedule.status,
|
status: schedule.status,
|
||||||
stops: schedule.stopTimes
|
stops: schedule.stopTimes
|
||||||
.filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
|
.filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
|
||||||
.map(st => ({
|
.map((st: any) => ({
|
||||||
stationId: st.stationId,
|
stationId: st.stationId,
|
||||||
stationName: st.station.name,
|
stationName: st.station.name,
|
||||||
sequence: st.sequence,
|
sequence: st.sequence,
|
||||||
plannedArrivalAt: st.plannedArrivalAt,
|
plannedArrivalAt: st.plannedArrivalAt,
|
||||||
plannedDepartureAt: st.plannedDepartureAt,
|
plannedDepartureAt: st.plannedDepartureAt,
|
||||||
})),
|
})),
|
||||||
availabilityByClass,
|
availabilityByClass,
|
||||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||||
faresByClass,
|
faresByClass,
|
||||||
@@ -134,51 +142,19 @@ export class SearchService {
|
|||||||
});
|
});
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
|
||||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
|
||||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) {
|
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) {
|
||||||
throw new NotFoundException('Origin or destination not found on this schedule');
|
throw new NotFoundException('Origin or destination not found on this schedule');
|
||||||
}
|
}
|
||||||
|
|
||||||
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } });
|
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } });
|
||||||
|
|
||||||
// Compute route codes for fare lookup
|
|
||||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||||
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const nationality = dto.nationality;
|
const nationality = dto.nationality;
|
||||||
|
|
||||||
// Query fare rules with specificity ordering:
|
|
||||||
// 1. schedule+segment+nationality
|
|
||||||
// 2. schedule+segment
|
|
||||||
// 3. schedule+full-route+nationality
|
|
||||||
// 4. schedule+full-route
|
|
||||||
// 5. schedule+global
|
|
||||||
// 6. segment+nationality
|
|
||||||
// 7. segment
|
|
||||||
// 8. full-route+nationality
|
|
||||||
// 9. full-route
|
|
||||||
// 10. global
|
|
||||||
const fareRule = await this.prisma.fareRule.findFirst({
|
|
||||||
where: {
|
|
||||||
seatClassId: seatClass?.id,
|
|
||||||
validFrom: { lte: now },
|
|
||||||
OR: [
|
|
||||||
{ validUntil: null },
|
|
||||||
{ validUntil: { gte: now } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
orderBy: [
|
|
||||||
// Prioritize schedule-specific rules
|
|
||||||
{ tripId: { sort: 'desc', nulls: 'last' } },
|
|
||||||
// Then prioritize nationality match
|
|
||||||
{ nationality: { sort: 'desc', nulls: 'last' } },
|
|
||||||
// Most recent validFrom
|
|
||||||
{ validFrom: 'desc' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Manual specificity filtering to find best match
|
|
||||||
const candidates = await this.prisma.fareRule.findMany({
|
const candidates = await this.prisma.fareRule.findMany({
|
||||||
where: {
|
where: {
|
||||||
seatClassId: seatClass?.id,
|
seatClassId: seatClass?.id,
|
||||||
@@ -243,38 +219,40 @@ export class SearchService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate fares for a specific segment of a schedule
|
|
||||||
*/
|
|
||||||
private async calculateFaresForSegment(
|
private async calculateFaresForSegment(
|
||||||
schedule: any,
|
schedule: any,
|
||||||
originStationId: string,
|
originStationId: string,
|
||||||
destinationStationId: string,
|
destinationStationId: string,
|
||||||
nationality?: string,
|
nationality?: string,
|
||||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
|
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
|
||||||
// Get seat classes that are actually assigned to this schedule via coaches
|
// Get unique seat classes from all coaches assigned to this schedule via their coach types
|
||||||
const assignedSeatClassIds: string[] = Array.from(
|
const seatClassIds: string[] = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
schedule.coachAssignments.map((a: any) => a.coach.seatClass.id as string)
|
schedule.coachAssignments
|
||||||
|
.flatMap((a: any) => a.coach.coachType?.seatClasses || [])
|
||||||
|
.map((sc: any) => sc.id)
|
||||||
|
.filter((id: any) => id)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get only the seat classes that are assigned to this schedule
|
if (seatClassIds.length === 0) {
|
||||||
const seatClasses = await this.prisma.seatClass.findMany({
|
|
||||||
where: {
|
|
||||||
isActive: true,
|
|
||||||
id: { in: assignedSeatClassIds }
|
|
||||||
},
|
|
||||||
orderBy: { basePrice: 'asc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// If no coaches assigned, return empty array
|
|
||||||
if (seatClasses.length === 0) {
|
|
||||||
console.log(`No seat classes assigned to schedule ${schedule.id}`);
|
console.log(`No seat classes assigned to schedule ${schedule.id}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// If schedule has a route, use route-based calculation
|
const seatClasses = await this.prisma.seatClass.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
id: { in: seatClassIds }
|
||||||
|
},
|
||||||
|
orderBy: { baseFareMinor: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (seatClasses.length === 0) {
|
||||||
|
console.log(`No active seat classes for schedule ${schedule.id}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
if (schedule.routeId) {
|
if (schedule.routeId) {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
seatClasses.map(async (sc) => {
|
seatClasses.map(async (sc) => {
|
||||||
@@ -303,7 +281,6 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Try to get fares from FareRule table
|
|
||||||
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
|
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
|
||||||
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
|
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
|
||||||
|
|
||||||
@@ -314,26 +291,25 @@ export class SearchService {
|
|||||||
const fareRules = await this.prisma.fareRule.findMany({
|
const fareRules = await this.prisma.fareRule.findMany({
|
||||||
where: {
|
where: {
|
||||||
route: segmentRoute,
|
route: segmentRoute,
|
||||||
seatClassId: { in: assignedSeatClassIds },
|
seatClassId: { in: seatClassIds },
|
||||||
validFrom: { lte: now },
|
validFrom: { lte: now },
|
||||||
OR: [
|
OR: [
|
||||||
{ validUntil: null },
|
{ validUntil: null },
|
||||||
{ validUntil: { gte: now } },
|
{ validUntil: { gte: now } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
include: { seatClass: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fareRules.length > 0) {
|
if (fareRules.length > 0) {
|
||||||
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
|
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
|
||||||
|
const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name]));
|
||||||
return fareRules.map(rule => ({
|
return fareRules.map(rule => ({
|
||||||
seatClassName: rule.seatClass.name,
|
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
|
||||||
baseFareMinor: rule.baseFareMinor,
|
baseFareMinor: rule.baseFareMinor,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last resort: Return default fares only for assigned seat classes
|
|
||||||
console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
|
console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
|
||||||
return seatClasses.map(sc => ({
|
return seatClasses.map(sc => ({
|
||||||
seatClassName: sc.name,
|
seatClassName: sc.name,
|
||||||
@@ -359,60 +335,6 @@ export class SearchService {
|
|||||||
return fares[seatClassName] ?? 45000;
|
return fares[seatClassName] ?? 45000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fallback method to get fares from FareRule table when fare engine fails
|
|
||||||
*/
|
|
||||||
private async getFallbackFares(
|
|
||||||
scheduleId: string,
|
|
||||||
originCode: string,
|
|
||||||
destCode: string,
|
|
||||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
|
|
||||||
const segmentRoute = `${originCode}-${destCode}`;
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
// Try to find fare rules for this segment
|
|
||||||
const fareRules = await this.prisma.fareRule.findMany({
|
|
||||||
where: {
|
|
||||||
route: segmentRoute,
|
|
||||||
validFrom: { lte: now },
|
|
||||||
OR: [
|
|
||||||
{ validUntil: null },
|
|
||||||
{ validUntil: { gte: now } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
include: { seatClass: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fareRules.length > 0) {
|
|
||||||
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
|
|
||||||
return fareRules.map(rule => ({
|
|
||||||
seatClassName: rule.seatClass.name,
|
|
||||||
baseFareMinor: rule.baseFareMinor,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no segment-specific rules, return default fares
|
|
||||||
console.log(`No fare rules found for ${segmentRoute}, using defaults`);
|
|
||||||
return [
|
|
||||||
{ seatClassName: 'Economy Regular', baseFareMinor: 35000 },
|
|
||||||
{ seatClassName: 'Economy Bed', baseFareMinor: 49000 },
|
|
||||||
{ seatClassName: 'VIP Bed', baseFareMinor: 63000 },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select the best matching fare rule based on specificity:
|
|
||||||
* 1. schedule+segment+nationality
|
|
||||||
* 2. schedule+segment
|
|
||||||
* 3. schedule+full-route+nationality
|
|
||||||
* 4. schedule+full-route
|
|
||||||
* 5. schedule+global
|
|
||||||
* 6. segment+nationality
|
|
||||||
* 7. segment
|
|
||||||
* 8. full-route+nationality
|
|
||||||
* 9. full-route
|
|
||||||
* 10. global
|
|
||||||
*/
|
|
||||||
private selectBestFareRule(
|
private selectBestFareRule(
|
||||||
candidates: any[],
|
candidates: any[],
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
@@ -421,19 +343,16 @@ export class SearchService {
|
|||||||
nationality?: string,
|
nationality?: string,
|
||||||
): any | null {
|
): any | null {
|
||||||
const priorities = [
|
const priorities = [
|
||||||
// Schedule-specific rules
|
|
||||||
{ tripId: scheduleId, route: segmentRoute, nationality },
|
{ tripId: scheduleId, route: segmentRoute, nationality },
|
||||||
{ tripId: scheduleId, route: segmentRoute, nationality: null },
|
{ tripId: scheduleId, route: segmentRoute, nationality: null },
|
||||||
{ tripId: scheduleId, route: fullRoute, nationality },
|
{ tripId: scheduleId, route: fullRoute, nationality },
|
||||||
{ tripId: scheduleId, route: fullRoute, nationality: null },
|
{ tripId: scheduleId, route: fullRoute, nationality: null },
|
||||||
{ tripId: scheduleId, route: null, nationality },
|
{ tripId: scheduleId, route: null, nationality },
|
||||||
{ tripId: scheduleId, route: null, nationality: null },
|
{ tripId: scheduleId, route: null, nationality: null },
|
||||||
// Route-specific rules (no schedule)
|
|
||||||
{ tripId: null, route: segmentRoute, nationality },
|
{ tripId: null, route: segmentRoute, nationality },
|
||||||
{ tripId: null, route: segmentRoute, nationality: null },
|
{ tripId: null, route: segmentRoute, nationality: null },
|
||||||
{ tripId: null, route: fullRoute, nationality },
|
{ tripId: null, route: fullRoute, nationality },
|
||||||
{ tripId: null, route: fullRoute, nationality: null },
|
{ tripId: null, route: fullRoute, nationality: null },
|
||||||
// Global rules
|
|
||||||
{ tripId: null, route: null, nationality },
|
{ tripId: null, route: null, nationality },
|
||||||
{ tripId: null, route: null, nationality: null },
|
{ tripId: null, route: null, nationality: null },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,41 +1,33 @@
|
|||||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SeatClassesService {
|
export class SeatClassesService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
private readonly coachInclude = {
|
|
||||||
coaches: {
|
|
||||||
select: { id: true, coachNumber: true, label: true, mode: true, totalUnits: true, _count: { select: { seats: true } } },
|
|
||||||
orderBy: { label: 'asc' as const },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
listSeatClasses() {
|
listSeatClasses() {
|
||||||
return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' }, include: this.coachInclude });
|
return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSeatClass(id: string) {
|
async getSeatClass(id: string) {
|
||||||
const sc = await this.prisma.seatClass.findUnique({ where: { id }, include: this.coachInclude });
|
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||||
return sc;
|
return sc;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSeatClass(dto: CreateSeatClassDto) {
|
async createSeatClass(dto: any) {
|
||||||
try {
|
try {
|
||||||
return await this.prisma.seatClass.create({ data: dto, include: this.coachInclude });
|
return await this.prisma.seatClass.create({ data: dto });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
|
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSeatClass(id: string, dto: UpdateSeatClassDto) {
|
async updateSeatClass(id: string, dto: any) {
|
||||||
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||||
return this.prisma.seatClass.update({ where: { id }, data: dto, include: this.coachInclude });
|
return this.prisma.seatClass.update({ where: { id }, data: dto });
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSeatClass(id: string) {
|
async deleteSeatClass(id: string) {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Post, Patch, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||||
import { SeatsService } from './seats.service';
|
import { SeatsService } from './seats.service';
|
||||||
import { HoldSeatsDto } from './seats.dto';
|
import { HoldSeatsDto } from './seats.dto';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
|
import { IamGuard } from '../../common/iam-adapter';
|
||||||
|
|
||||||
@ApiTags('Seats')
|
@ApiTags('Seats')
|
||||||
@Controller('seats')
|
@Controller('seats')
|
||||||
@@ -78,6 +79,47 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
|||||||
@ApiResponse({ status: 404, description: 'Hold not found' })
|
@ApiResponse({ status: 404, description: 'Hold not found' })
|
||||||
releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); }
|
releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); }
|
||||||
|
|
||||||
|
// ── Seat Block / Unblock ───────────────────────────────────────────────────
|
||||||
|
@Post(':seatId/block')
|
||||||
|
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
|
||||||
|
@ApiOperation({ summary: 'Block a seat (e.g., maintenance, damage)' })
|
||||||
|
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Seat blocked' })
|
||||||
|
blockSeat(@Param('seatId') seatId: string, @Body() body: { reason: string }) {
|
||||||
|
return this.service.blockSeat(seatId, body.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':seatId/block')
|
||||||
|
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
|
||||||
|
@ApiOperation({ summary: 'Unblock a seat' })
|
||||||
|
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Seat unblocked' })
|
||||||
|
unblockSeat(@Param('seatId') seatId: string) {
|
||||||
|
return this.service.unblockSeat(seatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Remove Seat ────────────────────────────────────────────────────────────
|
||||||
|
@Patch(':seatId/remove')
|
||||||
|
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
|
||||||
|
@ApiOperation({ summary: 'Remove a seat by marking with negative seatNumber' })
|
||||||
|
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Seat removed (seatNumber negated), shows as empty space' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Seat not found' })
|
||||||
|
removeSeat(@Param('seatId') seatId: string) {
|
||||||
|
return this.service.removeSeat(seatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':seatId/undo-remove')
|
||||||
|
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
|
||||||
|
@ApiOperation({ summary: 'Undo seat removal by restoring original seatNumber' })
|
||||||
|
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Seat restored (negative seatNumber removed)' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Seat not found' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Seat is not removed' })
|
||||||
|
undoRemoveSeat(@Param('seatId') seatId: string) {
|
||||||
|
return this.service.undoRemoveSeat(seatId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('export/csv/:scheduleId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' })
|
@Get('export/csv/:scheduleId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' })
|
||||||
async exportCSV(@Param('scheduleId') scheduleId: string) {
|
async exportCSV(@Param('scheduleId') scheduleId: string) {
|
||||||
const csv = await this.service.exportSeatsCSV(scheduleId);
|
const csv = await this.service.exportSeatsCSV(scheduleId);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
import { SeatsController } from './seats.controller';
|
import { SeatsController } from './seats.controller';
|
||||||
import { SeatsService } from './seats.service';
|
import { SeatsService } from './seats.service';
|
||||||
import { SegmentsModule } from '../segments/segments.module';
|
import { SegmentsModule } from '../segments/segments.module';
|
||||||
|
import { IamModule } from '../../common/iam.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SegmentsModule],
|
imports: [SegmentsModule, HttpModule, IamModule],
|
||||||
controllers: [SeatsController],
|
controllers: [SeatsController],
|
||||||
providers: [SeatsService],
|
providers: [SeatsService],
|
||||||
exports: [SeatsService],
|
exports: [SeatsService],
|
||||||
|
|||||||
@@ -11,48 +11,69 @@ export class SeatsService {
|
|||||||
private segmentsService: SegmentsService,
|
private segmentsService: SegmentsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── Seat Map ──────────────────────────────────────────────────────────────
|
|
||||||
async getSeatMap(scheduleId: string, coachId?: string) {
|
async getSeatMap(scheduleId: string, coachId?: string) {
|
||||||
const assignments = await this.prisma.coachAssignment.findMany({
|
const assignments = await this.prisma.coachAssignment.findMany({
|
||||||
where: { scheduleId, ...(coachId ? { coachId } : {}) },
|
where: { scheduleId, ...(coachId ? { coachId } : {}) },
|
||||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
|
include: {
|
||||||
|
coach: {
|
||||||
|
include: {
|
||||||
|
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
|
||||||
|
coachType: { include: { seatClasses: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: { positionNumber: 'asc' },
|
orderBy: { positionNumber: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id));
|
console.log(`[getSeatMap] scheduleId=${scheduleId}, coachId=${coachId}, found ${assignments.length} coach assignments`);
|
||||||
|
|
||||||
|
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||||
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds);
|
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds);
|
||||||
|
|
||||||
return {
|
const response = {
|
||||||
coaches: assignments.map((a) => ({
|
coaches: assignments.map((a) => {
|
||||||
id: a.coach.id,
|
// Include all seats (both valid and removed with negative seatNumbers)
|
||||||
assignmentId: a.id,
|
const allSeats = a.coach.seats;
|
||||||
name: `Coach ${a.coach.label}`,
|
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
|
||||||
seatClass: a.coach.seatClass.name,
|
const seatClass = seatClassNames.length > 0 ? seatClassNames[0] : 'Standard';
|
||||||
positionNumber: a.positionNumber,
|
|
||||||
seats: a.coach.seats.map((s) => ({
|
return {
|
||||||
id: s.id,
|
id: a.coach.id,
|
||||||
number: s.label,
|
assignmentId: a.id,
|
||||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
coachNumber: a.coach.number,
|
||||||
kind: s.kind,
|
label: a.coach.number,
|
||||||
row: s.row,
|
mode: a.coach.status,
|
||||||
col: s.col,
|
name: `Coach ${a.coach.number}`,
|
||||||
isWindow: s.isWindow,
|
seatClass,
|
||||||
isAisle: s.isAisle,
|
positionNumber: a.positionNumber,
|
||||||
bedPosition: s.bedPosition,
|
seatArrangement: a.coach.arrangement,
|
||||||
})),
|
totalSeats: a.coach.capacity,
|
||||||
})),
|
seats: allSeats.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
seatNumber: s.seatNumber,
|
||||||
|
number: s.seatNumber,
|
||||||
|
label: s.seatNumber,
|
||||||
|
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||||
|
kind: s.kind,
|
||||||
|
row: s.row,
|
||||||
|
col: s.col,
|
||||||
|
isWindow: s.isWindow,
|
||||||
|
isAisle: s.isAisle,
|
||||||
|
bedPosition: s.bedPosition,
|
||||||
|
coach: {
|
||||||
|
id: a.coach.id,
|
||||||
|
coachNumber: a.coach.number,
|
||||||
|
label: a.coach.number,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log(`[getSeatMap] returning ${response.coaches.length} coaches with seats`);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes the effective seat status for a set of seats on a specific schedule
|
|
||||||
* by checking active SeatHolds and confirmed JourneySegments.
|
|
||||||
*
|
|
||||||
* Priority: BLOCKED (physical) > BOOKED (confirmed journey) > HELD (active hold) > AVAILABLE
|
|
||||||
*
|
|
||||||
* This is needed because seat.status is no longer written during booking —
|
|
||||||
* availability is segment-scoped, so the DB column stays AVAILABLE even when held.
|
|
||||||
*/
|
|
||||||
async resolveEffectiveStatuses(
|
async resolveEffectiveStatuses(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
seatIds: string[],
|
seatIds: string[],
|
||||||
@@ -61,7 +82,6 @@ export class SeatsService {
|
|||||||
|
|
||||||
if (seatIds.length === 0) return statusMap;
|
if (seatIds.length === 0) return statusMap;
|
||||||
|
|
||||||
// 1. Active holds — any seat in an unexpired SeatHold for this schedule is HELD
|
|
||||||
const activeHolds = await this.prisma.seatHold.findMany({
|
const activeHolds = await this.prisma.seatHold.findMany({
|
||||||
where: {
|
where: {
|
||||||
scheduleId,
|
scheduleId,
|
||||||
@@ -76,8 +96,6 @@ export class SeatsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Active bookings via JourneySegment — CONFIRMED or PENDING_PAYMENT → BOOKED
|
|
||||||
// (overwrites HELD if the same seat has a confirmed booking)
|
|
||||||
const bookedSegments = await this.prisma.journeySegment.findMany({
|
const bookedSegments = await this.prisma.journeySegment.findMany({
|
||||||
where: {
|
where: {
|
||||||
scheduleId,
|
scheduleId,
|
||||||
@@ -93,24 +111,21 @@ export class SeatsService {
|
|||||||
return statusMap;
|
return statusMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Hold / Release ────────────────────────────────────────────────────────
|
|
||||||
async holdSeats(dto: HoldSeatsDto) {
|
async holdSeats(dto: HoldSeatsDto) {
|
||||||
// ── Validate request integrity ───────────────────────────────────────────
|
|
||||||
const passengerIds = dto.passengers.map(p => p.passengerId);
|
const passengerIds = dto.passengers.map(p => p.passengerId);
|
||||||
const seatIds = dto.passengers.map(p => p.seatId);
|
const seatIds = dto.passengers.map(p => p.seatId);
|
||||||
|
|
||||||
if (new Set(passengerIds).size !== passengerIds.length)
|
if (new Set(passengerIds).size !== passengerIds.length)
|
||||||
throw new BadRequestException('Duplicate passengerId in passengers list — each passenger must appear once');
|
throw new BadRequestException('Duplicate passengerId in passengers list');
|
||||||
if (new Set(seatIds).size !== seatIds.length)
|
if (new Set(seatIds).size !== seatIds.length)
|
||||||
throw new BadRequestException('Duplicate seatId in passengers list — each seat can only be assigned to one passenger');
|
throw new BadRequestException('Duplicate seatId in passengers list');
|
||||||
|
|
||||||
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
|
||||||
|
|
||||||
const hold = await this.prisma.$transaction(async (tx) => {
|
const hold = await this.prisma.$transaction(async (tx) => {
|
||||||
// ── 1. Validate seats exist and none are BLOCKED ─────────────────────
|
|
||||||
const seats = await tx.seat.findMany({
|
const seats = await tx.seat.findMany({
|
||||||
where: { id: { in: seatIds } },
|
where: { id: { in: seatIds } },
|
||||||
select: { id: true, status: true, label: true },
|
select: { id: true, status: true, seatNumber: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (seats.length !== seatIds.length) {
|
if (seats.length !== seatIds.length) {
|
||||||
@@ -121,11 +136,10 @@ export class SeatsService {
|
|||||||
|
|
||||||
const blocked = seats.filter(s => s.status === 'BLOCKED');
|
const blocked = seats.filter(s => s.status === 'BLOCKED');
|
||||||
if (blocked.length > 0)
|
if (blocked.length > 0)
|
||||||
throw new ConflictException(`Seat(s) ${blocked.map(s => s.label).join(', ')} are blocked`);
|
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are blocked`);
|
||||||
|
|
||||||
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.label]));
|
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
|
||||||
|
|
||||||
// ── 2. Resolve requested leg sequences ──────────────────────────────
|
|
||||||
const stopTimes = await tx.tripStopTime.findMany({
|
const stopTimes = await tx.tripStopTime.findMany({
|
||||||
where: { scheduleId: dto.scheduleId },
|
where: { scheduleId: dto.scheduleId },
|
||||||
select: { stationId: true, sequence: true },
|
select: { stationId: true, sequence: true },
|
||||||
@@ -137,17 +151,15 @@ export class SeatsService {
|
|||||||
const reqTo = seqOf(dto.destinationStationId);
|
const reqTo = seqOf(dto.destinationStationId);
|
||||||
|
|
||||||
if (reqFrom === undefined || reqTo === undefined)
|
if (reqFrom === undefined || reqTo === undefined)
|
||||||
throw new BadRequestException('Origin or destination station not found on this schedule');
|
throw new BadRequestException('Origin or destination station not found');
|
||||||
if (reqFrom >= reqTo)
|
if (reqFrom >= reqTo)
|
||||||
throw new BadRequestException('Origin must come before destination');
|
throw new BadRequestException('Origin must come before destination');
|
||||||
|
|
||||||
// ── 3. Load active holds for this schedule ───────────────────────────
|
|
||||||
const activeHolds = await tx.seatHold.findMany({
|
const activeHolds = await tx.seatHold.findMany({
|
||||||
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
|
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
|
||||||
select: { seatIds: true, createdBy: true },
|
select: { seatIds: true, createdBy: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse each hold's leg range and passenger list
|
|
||||||
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = [];
|
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = [];
|
||||||
for (const h of activeHolds) {
|
for (const h of activeHolds) {
|
||||||
try {
|
try {
|
||||||
@@ -164,32 +176,28 @@ export class SeatsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* ignore malformed */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 4. Per-passenger validation with overlap check ───────────────────
|
|
||||||
for (const { passengerId, seatId } of dto.passengers) {
|
for (const { passengerId, seatId } of dto.passengers) {
|
||||||
for (const hold of parsedHolds) {
|
for (const hold of parsedHolds) {
|
||||||
const legsOverlap = hold.from < reqTo && reqFrom < hold.to;
|
const legsOverlap = hold.from < reqTo && reqFrom < hold.to;
|
||||||
if (!legsOverlap) continue; // non-overlapping leg — no conflict
|
if (!legsOverlap) continue;
|
||||||
|
|
||||||
// Rule A: seat is held on an overlapping leg
|
|
||||||
if (hold.seatIds.includes(seatId)) {
|
if (hold.seatIds.includes(seatId)) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Seat ${seatLabelById[seatId]} is already held for this leg. Please choose a different seat.`,
|
`Seat ${seatLabelById[seatId]} is already held for this leg`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule B: passenger already holds a seat on an overlapping leg
|
|
||||||
if (hold.passengerIds.includes(passengerId)) {
|
if (hold.passengerIds.includes(passengerId)) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Passenger already holds a seat on this journey leg. You can only hold one seat per journey.`,
|
`Passenger already holds a seat on this journey leg`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store passenger→seat mapping AND leg in createdBy as JSON
|
|
||||||
const holdMeta = {
|
const holdMeta = {
|
||||||
originStationId: dto.originStationId,
|
originStationId: dto.originStationId,
|
||||||
destinationStationId: dto.destinationStationId,
|
destinationStationId: dto.destinationStationId,
|
||||||
@@ -228,12 +236,7 @@ export class SeatsService {
|
|||||||
return this.enrichHold(hold);
|
return this.enrichHold(hold);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the opaque fareQuoteId leg encoding into human-readable station
|
|
||||||
* names and enriches the hold with schedule, seat, and leg details.
|
|
||||||
*/
|
|
||||||
private async enrichHold(hold: any) {
|
private async enrichHold(hold: any) {
|
||||||
// Decode leg and passenger→seat mapping from createdBy JSON
|
|
||||||
let originStationId: string | null = null;
|
let originStationId: string | null = null;
|
||||||
let destinationStationId: string | null = null;
|
let destinationStationId: string | null = null;
|
||||||
let passengerSeatMap: { passengerId: string; seatId: string }[] = [];
|
let passengerSeatMap: { passengerId: string; seatId: string }[] = [];
|
||||||
@@ -241,7 +244,6 @@ export class SeatsService {
|
|||||||
try {
|
try {
|
||||||
if (hold.createdBy) {
|
if (hold.createdBy) {
|
||||||
const raw = hold.createdBy;
|
const raw = hold.createdBy;
|
||||||
// Guard: only parse if it looks like a JSON object, not a plain number/string
|
|
||||||
if (typeof raw === 'string' && raw.trimStart().startsWith('{')) {
|
if (typeof raw === 'string' && raw.trimStart().startsWith('{')) {
|
||||||
const meta = JSON.parse(raw);
|
const meta = JSON.parse(raw);
|
||||||
originStationId = meta.originStationId ?? null;
|
originStationId = meta.originStationId ?? null;
|
||||||
@@ -249,7 +251,7 @@ export class SeatsService {
|
|||||||
passengerSeatMap = Array.isArray(meta.passengers) ? meta.passengers : [];
|
passengerSeatMap = Array.isArray(meta.passengers) ? meta.passengers : [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* ignore malformed createdBy */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
const seatIds = hold.seatIds as string[];
|
const seatIds = hold.seatIds as string[];
|
||||||
|
|
||||||
@@ -262,7 +264,7 @@ export class SeatsService {
|
|||||||
destinationStationId ? this.prisma.station.findUnique({ where: { id: destinationStationId } }) : null,
|
destinationStationId ? this.prisma.station.findUnique({ where: { id: destinationStationId } }) : null,
|
||||||
this.prisma.seat.findMany({
|
this.prisma.seat.findMany({
|
||||||
where: { id: { in: seatIds } },
|
where: { id: { in: seatIds } },
|
||||||
include: { coach: { include: { seatClass: true } } },
|
include: { coach: true },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -277,10 +279,8 @@ export class SeatsService {
|
|||||||
destinationSequence = stopTimes.find(s => s.stationId === destinationStationId)?.sequence ?? null;
|
destinationSequence = stopTimes.find(s => s.stationId === destinationStationId)?.sequence ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build seat map keyed by seatId for quick lookup
|
|
||||||
const seatById = Object.fromEntries(seats.map(s => [s.id, s]));
|
const seatById = Object.fromEntries(seats.map(s => [s.id, s]));
|
||||||
|
|
||||||
// Merge passenger→seat mapping with seat details
|
|
||||||
const passengers = passengerSeatMap.length > 0
|
const passengers = passengerSeatMap.length > 0
|
||||||
? passengerSeatMap.map(({ passengerId, seatId }) => {
|
? passengerSeatMap.map(({ passengerId, seatId }) => {
|
||||||
const s = seatById[seatId];
|
const s = seatById[seatId];
|
||||||
@@ -288,26 +288,25 @@ export class SeatsService {
|
|||||||
passengerId,
|
passengerId,
|
||||||
seat: s ? {
|
seat: s ? {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
label: s.label,
|
label: s.seatNumber,
|
||||||
seatNumber: s.seatNumber,
|
seatNumber: s.seatNumber,
|
||||||
coach: s.coach.label,
|
coach: s.coach.number,
|
||||||
seatClass: s.coach.seatClass.name,
|
seatClass: 'Standard',
|
||||||
row: s.row,
|
row: s.row,
|
||||||
col: s.col,
|
col: s.col,
|
||||||
} : { id: seatId },
|
} : { id: seatId },
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
// Fallback for holds created before this change
|
|
||||||
: seatIds.map(seatId => {
|
: seatIds.map(seatId => {
|
||||||
const s = seatById[seatId];
|
const s = seatById[seatId];
|
||||||
return {
|
return {
|
||||||
passengerId: hold.passengerId,
|
passengerId: hold.passengerId,
|
||||||
seat: s ? {
|
seat: s ? {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
label: s.label,
|
label: s.seatNumber,
|
||||||
seatNumber: s.seatNumber,
|
seatNumber: s.seatNumber,
|
||||||
coach: s.coach.label,
|
coach: s.coach.number,
|
||||||
seatClass: s.coach.seatClass.name,
|
seatClass: 'Standard',
|
||||||
row: s.row,
|
row: s.row,
|
||||||
col: s.col,
|
col: s.col,
|
||||||
} : { id: seatId },
|
} : { id: seatId },
|
||||||
@@ -350,8 +349,7 @@ export class SeatsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async confirmSeats(seatIds: string[]) {
|
async confirmSeats(seatIds: string[]) {
|
||||||
// No-op for status — availability is segment-scoped via JourneySegment
|
// No-op
|
||||||
// seat.status = BLOCKED is the only hard gate; BOOKED is not used as a booking flag
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async releaseSeats(seatIds: string[]) {
|
async releaseSeats(seatIds: string[]) {
|
||||||
@@ -362,14 +360,15 @@ export class SeatsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise<string[]> {
|
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
|
||||||
const seats = await this.prisma.seat.findMany({
|
const seats = await this.prisma.seat.findMany({
|
||||||
where: {
|
where: {
|
||||||
coach: { seatClass: { name: seatClassName }, assignments: { some: { scheduleId } } },
|
coach: { assignments: { some: { scheduleId } } },
|
||||||
status: 'AVAILABLE',
|
status: 'AVAILABLE',
|
||||||
...(eligibility ? { eligibility } : {}),
|
seatNumber: { not: '' },
|
||||||
|
NOT: { seatNumber: { startsWith: '-' } },
|
||||||
},
|
},
|
||||||
orderBy: [{ coach: { label: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
|
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (seats.length < count) {
|
if (seats.length < count) {
|
||||||
@@ -404,10 +403,10 @@ export class SeatsService {
|
|||||||
where: { scheduleId },
|
where: { scheduleId },
|
||||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
|
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
|
||||||
});
|
});
|
||||||
const rows = ['coachId,coachLabel,row,col,label,kind,status,premiumFeeMinor,eligibility'];
|
const rows = ['coachId,coachLabel,row,col,seatNumber,kind,status,premiumFeeMinor'];
|
||||||
for (const a of assignments) {
|
for (const a of assignments) {
|
||||||
for (const seat of a.coach.seats) {
|
for (const seat of a.coach.seats) {
|
||||||
rows.push(`${a.coach.id},${a.coach.label},${seat.row},${seat.col},${seat.label},${seat.kind},${seat.status},${seat.premiumFeeMinor},${seat.eligibility || ''}`);
|
rows.push(`${a.coach.id},${a.coach.number},${seat.row},${seat.col},${seat.seatNumber},${seat.kind},${seat.status},${seat.premiumFeeMinor}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows.join('\n');
|
return rows.join('\n');
|
||||||
@@ -426,8 +425,8 @@ export class SeatsService {
|
|||||||
invalid++;
|
invalid++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor] = parts;
|
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
|
||||||
if (!coachId || !row || !col || !label) {
|
if (!coachId || !row || !col || !seatNumber) {
|
||||||
errors.push(`Line ${i + 2}: Missing required fields`);
|
errors.push(`Line ${i + 2}: Missing required fields`);
|
||||||
invalid++;
|
invalid++;
|
||||||
continue;
|
continue;
|
||||||
@@ -444,32 +443,30 @@ export class SeatsService {
|
|||||||
let imported = 0;
|
let imported = 0;
|
||||||
|
|
||||||
if (!commit) {
|
if (!commit) {
|
||||||
return { imported: 0, errors: ['Preview mode - use commit=true to apply changes'] };
|
return { imported: 0, errors: ['Preview mode'] };
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
try {
|
try {
|
||||||
const parts = lines[i].split(',');
|
const parts = lines[i].split(',');
|
||||||
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor, eligibility] = parts;
|
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
|
||||||
|
|
||||||
await this.prisma.seat.upsert({
|
await this.prisma.seat.upsert({
|
||||||
where: { coachId_row_col: { coachId, row: parseInt(row), col } },
|
where: { coachId_row_col: { coachId, row: parseInt(row), col } },
|
||||||
update: {
|
update: {
|
||||||
label,
|
seatNumber,
|
||||||
kind: kind as any,
|
kind: kind as any,
|
||||||
status: status as any,
|
status: status as any,
|
||||||
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
|
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
|
||||||
eligibility: eligibility || null,
|
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
coachId,
|
coachId,
|
||||||
row: parseInt(row),
|
row: parseInt(row),
|
||||||
col,
|
col,
|
||||||
label,
|
seatNumber,
|
||||||
kind: kind as any,
|
kind: kind as any,
|
||||||
status: status as any,
|
status: status as any,
|
||||||
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
|
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
|
||||||
eligibility: eligibility || null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
imported++;
|
imported++;
|
||||||
@@ -481,6 +478,74 @@ export class SeatsService {
|
|||||||
return { imported, errors: errors.slice(0, 10) };
|
return { imported, errors: errors.slice(0, 10) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async blockSeat(seatId: string, reason: string) {
|
||||||
|
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||||
|
if (!seat) throw new NotFoundException('Seat not found');
|
||||||
|
|
||||||
|
await this.prisma.seat.update({
|
||||||
|
where: { id: seatId },
|
||||||
|
data: { status: 'BLOCKED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.seatBlock.create({
|
||||||
|
data: {
|
||||||
|
seatId,
|
||||||
|
reason,
|
||||||
|
blockedBy: 'system',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { blocked: true, seatId, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
async unblockSeat(seatId: string) {
|
||||||
|
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||||
|
if (!seat) throw new NotFoundException('Seat not found');
|
||||||
|
|
||||||
|
await this.prisma.seat.update({
|
||||||
|
where: { id: seatId },
|
||||||
|
data: { status: 'AVAILABLE' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.seatBlock.deleteMany({
|
||||||
|
where: { seatId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { unblocked: true, seatId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSeat(seatId: string) {
|
||||||
|
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||||
|
if (!seat) throw new NotFoundException('Seat not found');
|
||||||
|
if (!seat.seatNumber) throw new BadRequestException('Seat already removed');
|
||||||
|
|
||||||
|
// Mark removed seat with negative seatNumber (e.g., '1' → '-1') to show empty space
|
||||||
|
const negatedNumber = `-${seat.seatNumber}`;
|
||||||
|
await this.prisma.seat.update({
|
||||||
|
where: { id: seatId },
|
||||||
|
data: { seatNumber: negatedNumber },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
async undoRemoveSeat(seatId: string) {
|
||||||
|
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||||
|
if (!seat) throw new NotFoundException('Seat not found');
|
||||||
|
if (!seat.seatNumber || !seat.seatNumber.startsWith('-')) {
|
||||||
|
throw new BadRequestException('Seat is not removed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore original seatNumber by removing the negative sign
|
||||||
|
const originalNumber = seat.seatNumber.slice(1);
|
||||||
|
await this.prisma.seat.update({
|
||||||
|
where: { id: seatId },
|
||||||
|
data: { seatNumber: originalNumber },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { restored: true, seatId, seatNumber: originalNumber };
|
||||||
|
}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_MINUTE)
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
async expireHolds() {
|
async expireHolds() {
|
||||||
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
|
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
|
||||||
@@ -489,7 +554,6 @@ export class SeatsService {
|
|||||||
try {
|
try {
|
||||||
await this.prisma.seatHold.delete({ where: { id: hold.id } });
|
await this.prisma.seatHold.delete({ where: { id: hold.id } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Ignore if already deleted (e.g., by another process)
|
|
||||||
if (err instanceof Error && !err.message.includes('P2025')) {
|
if (err instanceof Error && !err.message.includes('P2025')) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ async function holdSeatsTransaction(scheduleId: string, seatIds: string[], passe
|
|||||||
|
|
||||||
for (const seat of seats) {
|
for (const seat of seats) {
|
||||||
if (seat.status !== 'AVAILABLE') {
|
if (seat.status !== 'AVAILABLE') {
|
||||||
throw new Error(`Seat ${seat.label} is not available (status: ${seat.status})`);
|
throw new Error(`Seat ${seat.seatNumber} is not available (status: ${seat.status})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,14 +35,12 @@ export class EnhancedSeatsService {
|
|||||||
for (const seatId of request.seatIds) {
|
for (const seatId of request.seatIds) {
|
||||||
const seat = await tx.seat.findUnique({ where: { id: seatId } });
|
const seat = await tx.seat.findUnique({ where: { id: seatId } });
|
||||||
if (!seat) throw new BadRequestException(`Seat ${seatId} not found`);
|
if (!seat) throw new BadRequestException(`Seat ${seatId} not found`);
|
||||||
// Only BLOCKED seats are hard-rejected — BOOKED/HELD are fine if the
|
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.seatNumber} is blocked`);
|
||||||
// segment does not overlap (another passenger may occupy a different leg)
|
|
||||||
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.label} is blocked`);
|
|
||||||
|
|
||||||
const free = await this.segmentsService.isSeatFreeForLeg(
|
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||||
request.scheduleId, seatId, reqFrom, reqTo,
|
request.scheduleId, seatId, reqFrom, reqTo,
|
||||||
);
|
);
|
||||||
if (!free) throw new ConflictException(`Seat ${seat.label} is not available for the requested leg`);
|
if (!free) throw new ConflictException(`Seat ${seat.seatNumber} is not available for the requested leg`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
||||||
@@ -51,7 +49,6 @@ export class EnhancedSeatsService {
|
|||||||
scheduleId: request.scheduleId,
|
scheduleId: request.scheduleId,
|
||||||
seatIds: request.seatIds,
|
seatIds: request.seatIds,
|
||||||
passengerId: request.passengerId,
|
passengerId: request.passengerId,
|
||||||
// Store leg in createdBy JSON — no fareQuoteId needed
|
|
||||||
createdBy: JSON.stringify({
|
createdBy: JSON.stringify({
|
||||||
originStationId: request.originStationId,
|
originStationId: request.originStationId,
|
||||||
destinationStationId: request.destinationStationId,
|
destinationStationId: request.destinationStationId,
|
||||||
@@ -60,7 +57,6 @@ export class EnhancedSeatsService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Do NOT set seat.status = HELD globally — status is segment-scoped
|
|
||||||
this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments });
|
this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments });
|
||||||
return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds };
|
return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds };
|
||||||
});
|
});
|
||||||
@@ -81,7 +77,6 @@ export class EnhancedSeatsService {
|
|||||||
});
|
});
|
||||||
if (!schedule) throw new BadRequestException('Schedule not found');
|
if (!schedule) throw new BadRequestException('Schedule not found');
|
||||||
|
|
||||||
// Resolve the passenger's leg from createdBy JSON
|
|
||||||
let originStationId: string | undefined;
|
let originStationId: string | undefined;
|
||||||
let destinationStationId: string | undefined;
|
let destinationStationId: string | undefined;
|
||||||
try {
|
try {
|
||||||
@@ -92,15 +87,15 @@ export class EnhancedSeatsService {
|
|||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
const originStop = originStationId ? schedule.stopTimes.find(s => s.stationId === originStationId) : undefined;
|
const originStop = originStationId ? schedule.stopTimes.find((s: any) => s.stationId === originStationId) : undefined;
|
||||||
const destStop = destinationStationId ? schedule.stopTimes.find(s => s.stationId === destinationStationId) : undefined;
|
const destStop = destinationStationId ? schedule.stopTimes.find((s: any) => s.stationId === destinationStationId) : undefined;
|
||||||
const fromSeq = originStop?.sequence ?? schedule.stopTimes[0].sequence;
|
const fromSeq = originStop?.sequence ?? schedule.stopTimes[0].sequence;
|
||||||
const toSeq = destStop?.sequence ?? schedule.stopTimes[schedule.stopTimes.length - 1].sequence;
|
const toSeq = destStop?.sequence ?? schedule.stopTimes[schedule.stopTimes.length - 1].sequence;
|
||||||
|
|
||||||
const segments: Segment[] = [];
|
const segments: Segment[] = [];
|
||||||
for (let i = fromSeq; i < toSeq; i++) {
|
for (let i = fromSeq; i < toSeq; i++) {
|
||||||
const fromStop = schedule.stopTimes.find(s => s.sequence === i);
|
const fromStop = schedule.stopTimes.find((s: any) => s.sequence === i);
|
||||||
const toStop = schedule.stopTimes.find(s => s.sequence === i + 1);
|
const toStop = schedule.stopTimes.find((s: any) => s.sequence === i + 1);
|
||||||
if (fromStop && toStop) {
|
if (fromStop && toStop) {
|
||||||
segments.push({
|
segments.push({
|
||||||
fromStationId: fromStop.stationId,
|
fromStationId: fromStop.stationId,
|
||||||
@@ -132,7 +127,6 @@ export class EnhancedSeatsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do NOT set seat.status = BOOKED globally — availability is segment-scoped
|
|
||||||
await tx.seatHold.delete({ where: { id: request.holdId } });
|
await tx.seatHold.delete({ where: { id: request.holdId } });
|
||||||
|
|
||||||
this.eventEmitter.emit('booking.confirmed', { bookingId: request.bookingId, scheduleId: hold.scheduleId, seatIds: hold.seatIds, segments });
|
this.eventEmitter.emit('booking.confirmed', { bookingId: request.bookingId, scheduleId: hold.scheduleId, seatIds: hold.seatIds, segments });
|
||||||
@@ -185,22 +179,20 @@ export class EnhancedSeatsService {
|
|||||||
|
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
where: { id: scheduleId },
|
where: { id: scheduleId },
|
||||||
include: { coachAssignments: { include: { coach: { include: { seats: true, seatClass: true } } } } },
|
include: { coachAssignments: { include: { coach: { include: { seats: true } } } } },
|
||||||
});
|
});
|
||||||
if (!schedule) throw new BadRequestException('Schedule not found');
|
if (!schedule) throw new BadRequestException('Schedule not found');
|
||||||
|
|
||||||
const availableSeats = [];
|
const availableSeats = [];
|
||||||
for (const assignment of schedule.coachAssignments) {
|
for (const assignment of schedule.coachAssignments) {
|
||||||
for (const seat of assignment.coach.seats) {
|
for (const seat of assignment.coach.seats) {
|
||||||
// Hard-blocked seats are never available
|
|
||||||
if (seat.status === 'BLOCKED') continue;
|
if (seat.status === 'BLOCKED') continue;
|
||||||
// Availability is determined purely by segment overlap — not global seat.status
|
|
||||||
const free = await this.segmentsService.isSeatFreeForLeg(scheduleId, seat.id, reqFrom, reqTo);
|
const free = await this.segmentsService.isSeatFreeForLeg(scheduleId, seat.id, reqFrom, reqTo);
|
||||||
if (free) {
|
if (free) {
|
||||||
availableSeats.push({
|
availableSeats.push({
|
||||||
id: seat.id, label: seat.label,
|
id: seat.id, label: seat.seatNumber,
|
||||||
coach: assignment.coach.label,
|
coach: assignment.coach.number,
|
||||||
seatClass: assignment.coach.seatClass.name,
|
seatClass: 'Standard',
|
||||||
row: seat.row, col: seat.col,
|
row: seat.row, col: seat.col,
|
||||||
kind: seat.kind,
|
kind: seat.kind,
|
||||||
isWindow: seat.isWindow,
|
isWindow: seat.isWindow,
|
||||||
|
|||||||
@@ -87,9 +87,15 @@ export class TicketsService {
|
|||||||
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
|
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create permanent seat blocks for all booked seats
|
// Update all booked seats from HELD to BOOKED and create permanent seat blocks
|
||||||
const seatIds = booking.seats.map(bs => bs.seatId);
|
const seatIds = booking.seats.map(bs => bs.seatId);
|
||||||
for (const seatId of seatIds) {
|
for (const seatId of seatIds) {
|
||||||
|
// Update seat status to BOOKED
|
||||||
|
await this.prisma.seat.update({
|
||||||
|
where: { id: seatId },
|
||||||
|
data: { status: 'BOOKED' },
|
||||||
|
});
|
||||||
|
// Create permanent seat blocks for all booked seats
|
||||||
await this.prisma.seatBlock.create({
|
await this.prisma.seatBlock.create({
|
||||||
data: {
|
data: {
|
||||||
seatId,
|
seatId,
|
||||||
@@ -162,7 +168,7 @@ export class TicketsService {
|
|||||||
id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||||
fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name,
|
fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name,
|
||||||
departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name,
|
departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name,
|
||||||
coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label, passengerName: seat?.passengerName,
|
coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName,
|
||||||
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
|
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
|
||||||
barcodePayload: booking.ticket.barcodePayload
|
barcodePayload: booking.ticket.barcodePayload
|
||||||
};
|
};
|
||||||
@@ -207,8 +213,8 @@ export class TicketsService {
|
|||||||
bookingRef: b.bookingRef,
|
bookingRef: b.bookingRef,
|
||||||
ticketId: b.ticket?.id,
|
ticketId: b.ticket?.id,
|
||||||
passengerName: b.seats[0]?.passengerName,
|
passengerName: b.seats[0]?.passengerName,
|
||||||
seatLabel: b.seats[0]?.seat.label,
|
seatLabel: b.seats[0]?.seat.seatNumber,
|
||||||
coachLabel: b.seats[0]?.seat.coach.label,
|
coachLabel: b.seats[0]?.seat.coach.number,
|
||||||
qrPayload: b.ticket?.qrPayload,
|
qrPayload: b.ticket?.qrPayload,
|
||||||
status: b.status,
|
status: b.status,
|
||||||
validatedAt: b.ticket?.validatedAt,
|
validatedAt: b.ticket?.validatedAt,
|
||||||
|
|||||||
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 { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { fleetApi } from '@/lib/api';
|
import { Plus, Search, Grid3x3, Edit, Trash2 } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { Plus, Search, Grid3x3, Train, Edit, Trash2 } from 'lucide-react';
|
import { fleetApi, apiClient } from '@/lib/api';
|
||||||
|
|
||||||
|
type Tab = 'types' | 'coaches';
|
||||||
|
|
||||||
export default function CoachesPage() {
|
export default function CoachesPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('coaches');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingCoach, setEditingCoach] = useState<any>(null);
|
const [editingItem, setEditingItem] = useState<any>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; coach: any | null }>({ isOpen: false, coach: null });
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null }>({ isOpen: false, item: null });
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
// Coach Types Queries
|
||||||
queryKey: ['coaches', search],
|
const { data: coachTypesData, isLoading: typesLoading } = useQuery({
|
||||||
queryFn: () => fleetApi.getCoaches({ search }),
|
queryKey: ['coach-types'],
|
||||||
|
queryFn: () => apiClient.get('/fleet/coach-types'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
// Coaches Queries
|
||||||
|
const { data: coachesData, isLoading: coachesLoading } = useQuery({
|
||||||
|
queryKey: ['coaches'],
|
||||||
|
queryFn: () => fleetApi.getCoaches({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Coach Type Mutations
|
||||||
|
const createCoachTypeMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['coach-types'] });
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCoachTypeMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/fleet/coach-types/${id}`, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['coach-types'] });
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteCoachTypeMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => apiClient.delete(`/fleet/coach-types/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['coach-types'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Coach Mutations
|
||||||
|
const createCoachMutation = useMutation({
|
||||||
mutationFn: fleetApi.createCoach,
|
mutationFn: fleetApi.createCoach,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['coaches'] });
|
queryClient.invalidateQueries({ queryKey: ['coaches'] });
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setEditingCoach(null);
|
setEditingItem(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateCoachMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: string; data: any }) => fleetApi.updateCoach(id, data),
|
mutationFn: ({ id, data }: { id: string; data: any }) => fleetApi.updateCoach(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['coaches'] });
|
queryClient.invalidateQueries({ queryKey: ['coaches'] });
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setEditingCoach(null);
|
setEditingItem(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteCoachMutation = useMutation({
|
||||||
mutationFn: fleetApi.deleteCoach,
|
mutationFn: fleetApi.deleteCoach,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['coaches'] });
|
queryClient.invalidateQueries({ queryKey: ['coaches'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleCoachTypeSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const coachData = {
|
const data = {
|
||||||
coachNumber: formData.get('coachNumber') as string,
|
code: formData.get('code') as string,
|
||||||
label: formData.get('label') as string,
|
name: formData.get('name') as string,
|
||||||
seatClassId: formData.get('seatClassId') as string,
|
type: formData.get('type') as string,
|
||||||
coachType: formData.get('coachType') as string,
|
|
||||||
mode: formData.get('mode') as string,
|
|
||||||
seatArrangement: formData.get('seatArrangement') as string,
|
|
||||||
totalUnits: parseInt(formData.get('totalUnits') as string),
|
|
||||||
isActive: formData.get('isActive') === 'true',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingCoach) {
|
if (editingItem?.isCoachType) {
|
||||||
await updateMutation.mutateAsync({ id: editingCoach.id, data: coachData });
|
await updateCoachTypeMutation.mutateAsync({ id: editingItem.id, data });
|
||||||
} else {
|
} else {
|
||||||
await createMutation.mutateAsync(coachData);
|
await createCoachTypeMutation.mutateAsync(data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (coach: any) => {
|
const handleCoachSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
setDeleteConfirm({ isOpen: true, coach });
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const data = {
|
||||||
|
number: formData.get('number') as string,
|
||||||
|
coachTypeId: formData.get('coachTypeId') as string,
|
||||||
|
arrangement: formData.get('arrangement') as string,
|
||||||
|
capacity: parseInt(formData.get('capacity') as string),
|
||||||
|
status: formData.get('status') as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingItem?.isCoach) {
|
||||||
|
await updateCoachMutation.mutateAsync({ id: editingItem.id, data });
|
||||||
|
} else {
|
||||||
|
await createCoachMutation.mutateAsync(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (item: any, isCoachType: boolean) => {
|
||||||
|
setDeleteConfirm({ isOpen: true, item: { ...item, isCoachType } });
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
if (deleteConfirm.coach) {
|
if (deleteConfirm.item?.isCoachType) {
|
||||||
await deleteMutation.mutateAsync(deleteConfirm.coach.id);
|
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
|
||||||
setDeleteConfirm({ isOpen: false, coach: null });
|
} else {
|
||||||
|
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
|
||||||
}
|
}
|
||||||
|
setDeleteConfirm({ isOpen: false, item: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
const coaches = data?.items || data?.data || [];
|
const coachTypesArray = Array.isArray(coachTypesData) ? coachTypesData : (coachTypesData as any)?.items || (coachTypesData as any)?.data || [];
|
||||||
|
const coaches = coachesData?.items || coachesData?.data || [];
|
||||||
|
|
||||||
const columns = [
|
const filteredCoachTypes = coachTypesArray.filter((ct: any) => {
|
||||||
|
if (!search) return true;
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
ct.code?.toLowerCase().includes(searchLower) ||
|
||||||
|
ct.name?.toLowerCase().includes(searchLower) ||
|
||||||
|
ct.type?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredCoaches = coaches.filter((coach: any) => {
|
||||||
|
if (!search) return true;
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
coach.number?.toLowerCase().includes(searchLower) ||
|
||||||
|
coach.coachNumber?.toLowerCase().includes(searchLower) ||
|
||||||
|
coach.coachType?.name?.toLowerCase().includes(searchLower) ||
|
||||||
|
coach.arrangement?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeColorMap: Record<string, string> = {
|
||||||
|
passenger: 'edr-badge-info',
|
||||||
|
sleeper: 'edr-badge-warning',
|
||||||
|
dining: 'edr-badge-success',
|
||||||
|
baggage: 'edr-badge-danger',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
ACTIVE: 'edr-badge-success',
|
||||||
|
MAINTENANCE: 'edr-badge-warning',
|
||||||
|
INACTIVE: 'edr-badge-danger',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Coach Types Columns
|
||||||
|
const coachTypeColumns = [
|
||||||
{
|
{
|
||||||
key: 'coachNumber',
|
key: 'code',
|
||||||
label: 'Coach Number',
|
label: 'Code',
|
||||||
|
sortable: true,
|
||||||
|
render: (ct: any) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[rgb(20,113,76)]">
|
||||||
|
<Grid3x3 className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-mono font-medium">{ct.code}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
sortable: true,
|
||||||
|
render: (ct: any) => <span className="font-medium">{ct.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
label: 'Type',
|
||||||
|
render: (ct: any) => (
|
||||||
|
<span className={`edr-badge ${typeColorMap[ct.type] || 'edr-badge-info'}`}>
|
||||||
|
{ct.type}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'coaches',
|
||||||
|
label: 'Coaches',
|
||||||
|
render: (ct: any) => (
|
||||||
|
<span className="text-sm font-medium">{ct.coaches?.length || 0}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Coaches Columns
|
||||||
|
const coachColumns = [
|
||||||
|
{
|
||||||
|
key: 'number',
|
||||||
|
label: 'Number',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (coach: any) => (
|
render: (coach: any) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[rgb(20,113,76)]">
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[rgb(20,113,76)]">
|
||||||
<Grid3x3 className="h-4 w-4 text-white" />
|
<Grid3x3 className="h-4 w-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium">{coach.coachNumber}</span>
|
<span className="font-medium">{coach.number || coach.coachNumber}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'seatClass',
|
key: 'coachType',
|
||||||
label: 'Seat Class',
|
label: 'Coach Type',
|
||||||
render: (coach: any) => {
|
|
||||||
const seatClass = coach.seatClass?.name || coach.serviceClass || 'N/A';
|
|
||||||
const colorMap: Record<string, string> = {
|
|
||||||
'ECONOMY_REGULAR': 'edr-badge-info',
|
|
||||||
'ECONOMY_BED': 'edr-badge-warning',
|
|
||||||
'VIP_BED': 'edr-badge-success',
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<span className={`edr-badge ${colorMap[seatClass] || 'edr-badge-info'}`}>
|
|
||||||
{seatClass.replace(/_/g, ' ')}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'totalSeats',
|
|
||||||
label: 'Total Seats',
|
|
||||||
render: (coach: any) => (
|
render: (coach: any) => (
|
||||||
<span className="font-mono text-sm">{coach.totalSeats || coach.totalUnits || 0}</span>
|
<span className="text-sm">{coach.coachType?.name || 'N/A'}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'layout',
|
key: 'arrangement',
|
||||||
label: 'Layout',
|
label: 'Arrangement',
|
||||||
render: (coach: any) => (
|
render: (coach: any) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm font-mono">{coach.arrangement || 'N/A'}</span>
|
||||||
{coach.layout || coach.seatLayout || coach.seatArrangement || 'N/A'}
|
),
|
||||||
</span>
|
},
|
||||||
|
{
|
||||||
|
key: 'capacity',
|
||||||
|
label: 'Capacity',
|
||||||
|
render: (coach: any) => (
|
||||||
|
<span className="font-mono text-sm font-medium">{coach.capacity || 0}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
render: (coach: any) => {
|
render: (coach: any) => {
|
||||||
const status = coach.isActive ? 'ACTIVE' : 'INACTIVE';
|
const status = coach.status || 'ACTIVE';
|
||||||
const statusMap: Record<string, string> = {
|
|
||||||
ACTIVE: 'edr-badge-success',
|
|
||||||
MAINTENANCE: 'edr-badge-warning',
|
|
||||||
INACTIVE: 'edr-badge-danger',
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<span className={`edr-badge ${statusMap[status] || 'edr-badge-info'}`}>
|
<span className={`edr-badge ${statusMap[status] || 'edr-badge-info'}`}>
|
||||||
{status}
|
{status}
|
||||||
@@ -146,11 +256,11 @@ export default function CoachesPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const actions = [
|
const coachTypeActions = [
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
onClick: (coach: any) => {
|
onClick: (item: any) => {
|
||||||
setEditingCoach(coach);
|
setEditingItem({ ...item, isCoachType: true });
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
},
|
},
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
@@ -158,7 +268,25 @@ export default function CoachesPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
onClick: handleDelete,
|
onClick: (item: any) => handleDelete(item, true),
|
||||||
|
variant: 'danger' as const,
|
||||||
|
icon: Trash2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const coachActions = [
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
onClick: (item: any) => {
|
||||||
|
setEditingItem({ ...item, isCoach: true });
|
||||||
|
setShowModal(true);
|
||||||
|
},
|
||||||
|
variant: 'secondary' as const,
|
||||||
|
icon: Edit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
onClick: (item: any) => handleDelete(item, false),
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
},
|
},
|
||||||
@@ -169,51 +297,112 @@ export default function CoachesPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">Coach Management</h1>
|
<h1 className="text-3xl font-bold text-foreground">Coach Management</h1>
|
||||||
<p className="text-muted-foreground mt-1">Manage train coaches and configurations</p>
|
<p className="text-muted-foreground mt-1">Manage coach types and train coaches</p>
|
||||||
</div>
|
</div>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={Plus}
|
icon={Plus}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingCoach(null);
|
setEditingItem(null);
|
||||||
|
setSearch('');
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add Coach
|
{activeTab === 'types' ? 'Add Coach Type' : 'Add Coach'}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex border-b border-border">
|
||||||
<div className="relative flex-1">
|
<button
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
onClick={() => {
|
||||||
<input
|
setActiveTab('types');
|
||||||
type="text"
|
setSearch('');
|
||||||
placeholder="Search coaches..."
|
}}
|
||||||
value={search}
|
className={`px-4 py-3 font-medium transition-colors ${
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
activeTab === 'types'
|
||||||
className="input pl-10"
|
? 'border-b-2 border-primary text-primary'
|
||||||
/>
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
</div>
|
}`}
|
||||||
|
>
|
||||||
|
Coach Types
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab('coaches');
|
||||||
|
setSearch('');
|
||||||
|
}}
|
||||||
|
className={`px-4 py-3 font-medium transition-colors ${
|
||||||
|
activeTab === 'coaches'
|
||||||
|
? 'border-b-2 border-primary text-primary'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Coaches
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
{/* Coach Types Tab */}
|
||||||
columns={columns}
|
{activeTab === 'types' && (
|
||||||
data={coaches}
|
<div className="pt-6 space-y-4">
|
||||||
actions={actions}
|
<div className="relative">
|
||||||
loading={isLoading}
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
/>
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by code, name, or type..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="input pl-10 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
columns={coachTypeColumns}
|
||||||
|
data={filteredCoachTypes}
|
||||||
|
actions={coachTypeActions}
|
||||||
|
loading={typesLoading}
|
||||||
|
emptyMessage={search ? "No coach types match your search" : "No coach types found. Create one to get started."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coaches Tab */}
|
||||||
|
{activeTab === 'coaches' && (
|
||||||
|
<div className="pt-6 space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by coach number, type, or arrangement..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="input pl-10 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
columns={coachColumns}
|
||||||
|
data={filteredCoaches}
|
||||||
|
actions={coachActions}
|
||||||
|
loading={coachesLoading}
|
||||||
|
emptyMessage={search ? "No coaches match your search" : "No coaches found. Create one to get started."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Delete Confirmation */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.isOpen}
|
isOpen={deleteConfirm.isOpen}
|
||||||
onClose={() => setDeleteConfirm({ isOpen: false, coach: null })}
|
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
|
||||||
onConfirm={confirmDelete}
|
onConfirm={confirmDelete}
|
||||||
title="Delete Coach"
|
title={`Delete ${deleteConfirm.item?.isCoachType ? 'Coach Type' : 'Coach'}`}
|
||||||
message={`Are you sure you want to delete coach ${deleteConfirm.coach?.coachNumber}?`}
|
message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`}
|
||||||
confirmText="Delete"
|
confirmText="Delete"
|
||||||
isDanger={true}
|
isDanger={true}
|
||||||
warning="This coach may be assigned to schedules and trips. Deleting it may impact these systems."
|
warning={
|
||||||
|
deleteConfirm.item?.isCoachType
|
||||||
|
? 'This coach type may have coaches assigned. Deleting it may impact these systems.'
|
||||||
|
: 'This coach may be assigned to schedules. Deleting it may impact these systems.'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Add/Edit Modal */}
|
{/* Add/Edit Modal */}
|
||||||
@@ -221,106 +410,170 @@ export default function CoachesPage() {
|
|||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setEditingCoach(null);
|
setEditingItem(null);
|
||||||
}}
|
}}
|
||||||
title={`${editingCoach ? 'Edit' : 'Add'} Coach`}
|
title={
|
||||||
size="lg"
|
activeTab === 'types'
|
||||||
|
? `${editingItem?.isCoachType ? 'Edit' : 'Add'} Coach Type`
|
||||||
|
: `${editingItem?.isCoach ? 'Edit' : 'Add'} Coach`
|
||||||
|
}
|
||||||
|
size={activeTab === 'types' ? 'md' : 'lg'}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
{/* Coach Type Form */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{activeTab === 'types' && (
|
||||||
<div>
|
<form onSubmit={handleCoachTypeSubmit} className="space-y-4">
|
||||||
<label className="label">Coach Number *</label>
|
<div className="grid grid-cols-1 gap-4">
|
||||||
<input
|
<div>
|
||||||
type="text"
|
<label className="label">Code</label>
|
||||||
name="coachNumber"
|
<input
|
||||||
className="input"
|
type="text"
|
||||||
defaultValue={editingCoach?.coachNumber}
|
name="code"
|
||||||
required
|
className="input"
|
||||||
placeholder="e.g., C001"
|
defaultValue={editingItem?.code || ''}
|
||||||
/>
|
required
|
||||||
|
placeholder="e.g., HSC"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingItem?.name || ''}
|
||||||
|
required
|
||||||
|
placeholder="e.g., Hard Seat Coach"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Type</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="type"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingItem?.type || ''}
|
||||||
|
required
|
||||||
|
placeholder="e.g., Regular Seat"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="label">Label *</label>
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
<input
|
<ActionButton
|
||||||
type="text"
|
type="button"
|
||||||
name="label"
|
variant="secondary"
|
||||||
className="input"
|
onClick={() => {
|
||||||
defaultValue={editingCoach?.label}
|
setShowModal(false);
|
||||||
required
|
setEditingItem(null);
|
||||||
placeholder="e.g., Coach 1"
|
}}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Coach Type</label>
|
|
||||||
<select name="coachType" className="input" defaultValue={editingCoach?.coachType}>
|
|
||||||
<option value="passenger">Passenger</option>
|
|
||||||
<option value="sleeper">Sleeper</option>
|
|
||||||
<option value="dining">Dining</option>
|
|
||||||
<option value="baggage">Baggage</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Mode *</label>
|
|
||||||
<select name="mode" className="input" defaultValue={editingCoach?.mode || 'seat'}>
|
|
||||||
<option value="seat">Seat</option>
|
|
||||||
<option value="bed">Bed</option>
|
|
||||||
<option value="convertible">Convertible</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Seat Arrangement</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="seatArrangement"
|
|
||||||
className="input"
|
|
||||||
defaultValue={editingCoach?.seatArrangement}
|
|
||||||
placeholder="e.g., 2+2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Total Units *</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="totalUnits"
|
|
||||||
className="input"
|
|
||||||
defaultValue={editingCoach?.totalUnits}
|
|
||||||
required
|
|
||||||
min="1"
|
|
||||||
placeholder="60"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Status</label>
|
|
||||||
<select
|
|
||||||
name="isActive"
|
|
||||||
className="input"
|
|
||||||
defaultValue={editingCoach?.isActive?.toString() || 'true'}
|
|
||||||
>
|
>
|
||||||
<option value="true">Active</option>
|
Cancel
|
||||||
<option value="false">Inactive</option>
|
</ActionButton>
|
||||||
</select>
|
<ActionButton
|
||||||
|
type="submit"
|
||||||
|
loading={createCoachTypeMutation.isPending || updateCoachTypeMutation.isPending}
|
||||||
|
>
|
||||||
|
{editingItem?.isCoachType ? 'Update' : 'Create'} Coach Type
|
||||||
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
|
)}
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
|
||||||
<ActionButton
|
{/* Coach Form */}
|
||||||
type="button"
|
{activeTab === 'coaches' && (
|
||||||
variant="secondary"
|
<form onSubmit={handleCoachSubmit} className="space-y-4">
|
||||||
onClick={() => {
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
setShowModal(false);
|
<div>
|
||||||
setEditingCoach(null);
|
<label className="label">Coach Type</label>
|
||||||
}}
|
<select
|
||||||
>
|
name="coachTypeId"
|
||||||
Cancel
|
className="input"
|
||||||
</ActionButton>
|
defaultValue={editingItem?.coachTypeId || ''}
|
||||||
<ActionButton
|
required
|
||||||
type="submit"
|
>
|
||||||
loading={createMutation.isPending || updateMutation.isPending}
|
<option value="">Select Coach Type</option>
|
||||||
>
|
{coachTypesArray.map((ct: any) => (
|
||||||
{editingCoach ? 'Update' : 'Create'} Coach
|
<option key={ct.id} value={ct.id}>
|
||||||
</ActionButton>
|
{ct.code} - {ct.name} - {ct.type}
|
||||||
</div>
|
</option>
|
||||||
</form>
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Number</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="number"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingItem?.number || editingItem?.coachNumber || ''}
|
||||||
|
required
|
||||||
|
placeholder="e.g., A-001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Arrangement</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="arrangement"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement || '2+2'}
|
||||||
|
required
|
||||||
|
placeholder="e.g., 2+2, 3+2"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Format: separate columns with +</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Capacity</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="capacity"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingItem?.capacity || editingItem?.totalUnits || ''}
|
||||||
|
required
|
||||||
|
min="1"
|
||||||
|
placeholder="60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="label">Status</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingItem?.status || 'ACTIVE'}
|
||||||
|
>
|
||||||
|
<option value="ACTIVE">Active</option>
|
||||||
|
<option value="MAINTENANCE">Maintenance</option>
|
||||||
|
<option value="INACTIVE">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<ActionButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
type="submit"
|
||||||
|
loading={createCoachMutation.isPending || updateCoachMutation.isPending}
|
||||||
|
>
|
||||||
|
{editingItem?.isCoach ? 'Update' : 'Create'} Coach
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { useAuthStore } from '@/lib/auth-store';
|
|||||||
import { Train } from 'lucide-react';
|
import { Train } from 'lucide-react';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [email, setEmail] = useState('admin@edr-platform.com');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('admin123');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default function PassengersPage() {
|
|||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
search: '',
|
search: '',
|
||||||
|
role: 'PASSENGER',
|
||||||
});
|
});
|
||||||
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
|
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
|
||||||
|
|||||||
@@ -1,5 +1,54 @@
|
|||||||
import DashboardLayout from '../dashboard/layout';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Sidebar from '@/components/layout/Sidebar';
|
||||||
|
import Header from '@/components/layout/Header';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
|
||||||
export default function PricingLayout({ children }: { children: React.ReactNode }) {
|
export default function PricingLayout({ children }: { children: React.ReactNode }) {
|
||||||
return <DashboardLayout>{children}</DashboardLayout>;
|
const router = useRouter();
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !isAuthenticated) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, router, isLoading]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600 mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950 p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Edit, Trash2, X } from 'lucide-react';
|
import { Plus, Edit, Trash2, X, Search } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
@@ -26,6 +26,7 @@ export default function RoutesPage() {
|
|||||||
const [destinationStationId, setDestinationStationId] = useState('');
|
const [destinationStationId, setDestinationStationId] = useState('');
|
||||||
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
|
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null }>({ isOpen: false, route: null });
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null }>({ isOpen: false, route: null });
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: routes, isLoading: routesLoading } = useQuery({
|
const { data: routes, isLoading: routesLoading } = useQuery({
|
||||||
@@ -176,6 +177,17 @@ export default function RoutesPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const filteredRoutes = (routes as any)?.items || (Array.isArray(routes) ? routes : []);
|
||||||
|
const displayedRoutes = filteredRoutes.filter((route: any) => {
|
||||||
|
if (!search) return true;
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
route.code?.toLowerCase().includes(searchLower) ||
|
||||||
|
route.name?.toLowerCase().includes(searchLower) ||
|
||||||
|
route.description?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const routeActions = [
|
const routeActions = [
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
@@ -238,6 +250,7 @@ export default function RoutesPage() {
|
|||||||
setDestinationStationId('');
|
setDestinationStationId('');
|
||||||
setDestinationDistance(undefined);
|
setDestinationDistance(undefined);
|
||||||
setStops([]);
|
setStops([]);
|
||||||
|
setSearch('');
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -245,12 +258,23 @@ export default function RoutesPage() {
|
|||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-6">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by code, name, or description..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="input pl-10 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
data={(routes as any)?.items || (Array.isArray(routes) ? routes : [])}
|
data={displayedRoutes}
|
||||||
columns={routeColumns}
|
columns={routeColumns}
|
||||||
actions={routeActions}
|
actions={routeActions}
|
||||||
loading={routesLoading}
|
loading={routesLoading}
|
||||||
emptyMessage="No routes found"
|
emptyMessage={search ? "No routes match your search" : "No routes found"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Delete Confirmation */}
|
||||||
@@ -275,6 +299,7 @@ export default function RoutesPage() {
|
|||||||
setDestinationStationId('');
|
setDestinationStationId('');
|
||||||
setDestinationDistance(undefined);
|
setDestinationDistance(undefined);
|
||||||
setStops([]);
|
setStops([]);
|
||||||
|
setSearch('');
|
||||||
}}
|
}}
|
||||||
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
|
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -516,6 +541,7 @@ export default function RoutesPage() {
|
|||||||
setDestinationStationId('');
|
setDestinationStationId('');
|
||||||
setDestinationDistance(undefined);
|
setDestinationDistance(undefined);
|
||||||
setStops([]);
|
setStops([]);
|
||||||
|
setSearch('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -1,5 +1,54 @@
|
|||||||
import DashboardLayout from '../dashboard/layout';
|
'use client';
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
import { useEffect, useState } from 'react';
|
||||||
return <DashboardLayout>{children}</DashboardLayout>;
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Sidebar from '@/components/layout/Sidebar';
|
||||||
|
import Header from '@/components/layout/Header';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
|
||||||
|
export default function SchedulesLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !isAuthenticated) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, router, isLoading]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600 mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950 p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { seatsApi, schedulesApi } from '@/lib/api';
|
import { seatsApi, schedulesApi } from '@/lib/api';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton'
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import { Search, Armchair, Lock, Unlock, ChevronRight } from 'lucide-react';
|
import { Armchair, Lock, Unlock, Bed, X, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
export default function SeatsPage() {
|
export default function SeatsPage() {
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [selectedSchedule, setSelectedSchedule] = useState('');
|
const [selectedSchedule, setSelectedSchedule] = useState('');
|
||||||
const [showBlockModal, setShowBlockModal] = useState(false);
|
const [showBlockModal, setShowBlockModal] = useState(false);
|
||||||
|
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||||
const [selectedSeat, setSelectedSeat] = useState<any>(null);
|
const [selectedSeat, setSelectedSeat] = useState<any>(null);
|
||||||
const [blockReason, setBlockReason] = useState('');
|
const [blockReason, setBlockReason] = useState('');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -44,6 +44,22 @@ export default function SeatsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const removeSeatMutation = useMutation({
|
||||||
|
mutationFn: (seatId: string) => seatsApi.removeSeat(seatId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
|
||||||
|
setShowRemoveModal(false);
|
||||||
|
setSelectedSeat(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const undoRemoveMutation = useMutation({
|
||||||
|
mutationFn: (seatId: string) => seatsApi.undoRemove(seatId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const schedules = schedulesData?.items || schedulesData?.data || [];
|
const schedules = schedulesData?.items || schedulesData?.data || [];
|
||||||
const coaches = seatMapData?.coaches || [];
|
const coaches = seatMapData?.coaches || [];
|
||||||
|
|
||||||
@@ -58,6 +74,17 @@ export default function SeatsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRemoveSeat = (seat: any) => {
|
||||||
|
setSelectedSeat(seat);
|
||||||
|
setShowRemoveModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUndoRemove = async (seat: any) => {
|
||||||
|
if (confirm('Restore this removed seat?')) {
|
||||||
|
await undoRemoveMutation.mutateAsync(seat.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const submitBlock = async () => {
|
const submitBlock = async () => {
|
||||||
if (!blockReason.trim()) {
|
if (!blockReason.trim()) {
|
||||||
alert('Please provide a reason for blocking');
|
alert('Please provide a reason for blocking');
|
||||||
@@ -66,6 +93,10 @@ export default function SeatsPage() {
|
|||||||
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason });
|
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitRemoveSeat = async () => {
|
||||||
|
await removeSeatMutation.mutateAsync(selectedSeat.id);
|
||||||
|
};
|
||||||
|
|
||||||
const getSeatStatus = (seat: any) => {
|
const getSeatStatus = (seat: any) => {
|
||||||
if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED';
|
if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED';
|
||||||
if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED';
|
if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED';
|
||||||
@@ -83,9 +114,221 @@ export default function SeatsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredCoaches = coaches.filter((coach: any) =>
|
const parseSeatArrangement = (arrangement: string | null): number[] => {
|
||||||
search ? coach.coachNumber?.toLowerCase().includes(search.toLowerCase()) : true
|
if (!arrangement) return [2, 2];
|
||||||
);
|
const parts = arrangement.split('+').map(p => parseInt(p.trim()));
|
||||||
|
return parts.length === 2 ? parts : [2, 2];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBedLabel = (bedPosition: string | null): string => {
|
||||||
|
if (bedPosition === 'upper') return 'U';
|
||||||
|
if (bedPosition === 'middle') return 'M';
|
||||||
|
if (bedPosition === 'lower') return 'L';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCoachSeats = (coach: any, isBedCoach: boolean) => {
|
||||||
|
const allSeats = coach.seats || [];
|
||||||
|
const validSeats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
|
||||||
|
const removedSeats = allSeats.filter((s: any) => s.seatNumber && s.seatNumber.startsWith('-'));
|
||||||
|
|
||||||
|
if (validSeats.length === 0 && removedSeats.length === 0) {
|
||||||
|
return <div className="text-xs text-muted-foreground">No seats</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
|
||||||
|
|
||||||
|
if (isBedCoach && hasBedPositionData) {
|
||||||
|
// Render bed coach with flipping effect and bed position labels
|
||||||
|
const arrangement = parseSeatArrangement(coach.seatArrangement);
|
||||||
|
const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
|
||||||
|
const allSeatsForLayout = [...validSeats, ...removedSeats];
|
||||||
|
const rows = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < allSeatsForLayout.length; i += seatsPerRow) {
|
||||||
|
rows.push(allSeatsForLayout.slice(i, i + seatsPerRow));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0">
|
||||||
|
{rows.map((rowSeats: any[], idx: number) => {
|
||||||
|
const rowNumber = rowSeats[0]?.row || (idx + 1);
|
||||||
|
const shouldFlipIcon = rowNumber % 2 === 0;
|
||||||
|
const showSpacing = idx % 2 === 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`bed-row-${idx}`}>
|
||||||
|
{shouldFlipIcon && (
|
||||||
|
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
||||||
|
{rowSeats.map((seat: any) => (
|
||||||
|
<div key={`num-before-${seat.id}`} className="w-12 h-4 flex items-center justify-center">
|
||||||
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-0.5 justify-start">
|
||||||
|
{rowSeats.map((seat: any) => (
|
||||||
|
<SeatIcon
|
||||||
|
key={seat.id}
|
||||||
|
seat={seat}
|
||||||
|
coach={coach}
|
||||||
|
isBedCoach={true}
|
||||||
|
shouldFlipIcon={shouldFlipIcon}
|
||||||
|
getSeatStatus={getSeatStatus}
|
||||||
|
getSeatColor={getSeatColor}
|
||||||
|
handleBlock={handleBlock}
|
||||||
|
handleRemoveSeat={handleRemoveSeat}
|
||||||
|
handleUnblock={handleUnblock}
|
||||||
|
handleUndoRemove={handleUndoRemove}
|
||||||
|
hideNumber={true}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!shouldFlipIcon && (
|
||||||
|
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
||||||
|
{rowSeats.map((seat: any) => (
|
||||||
|
<div key={`num-after-${seat.id}`} className="w-12 h-4 flex items-center justify-center">
|
||||||
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showSpacing && <div className="h-2" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular armchair layout
|
||||||
|
const arrangement = parseSeatArrangement(coach.seatArrangement);
|
||||||
|
const leftCount = arrangement[0];
|
||||||
|
const rightCount = arrangement[1] || 0;
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
const processedRows = new Set();
|
||||||
|
const allSeatsForLayout = [...validSeats, ...removedSeats];
|
||||||
|
for (const seat of allSeatsForLayout) {
|
||||||
|
if (!processedRows.has(seat.row)) {
|
||||||
|
rows.push(allSeatsForLayout.filter((s: any) => s.row === seat.row).sort((a: any, b: any) => {
|
||||||
|
const colA = a.col.charCodeAt(0);
|
||||||
|
const colB = b.col.charCodeAt(0);
|
||||||
|
return colA - colB;
|
||||||
|
}));
|
||||||
|
processedRows.add(seat.row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0">
|
||||||
|
{rows.map((rowSeats: any[], rowIdx: number) => {
|
||||||
|
const leftSeats = rowSeats.slice(0, leftCount);
|
||||||
|
const rightSeats = rowSeats.slice(leftCount);
|
||||||
|
const rowNumber = rowSeats[0]?.row || 1;
|
||||||
|
const shouldFlipArmchair = rowNumber % 2 === 0;
|
||||||
|
const showSpacing = rowIdx % 2 === 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`row-${rowSeats[0]?.id}`}>
|
||||||
|
{shouldFlipArmchair && (
|
||||||
|
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{leftSeats.map((seat: any) => (
|
||||||
|
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
|
||||||
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{rightSeats.length > 0 && <div className="w-3" />}
|
||||||
|
{rightSeats.length > 0 && (
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{rightSeats.map((seat: any) => (
|
||||||
|
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
|
||||||
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-0.5 justify-start">
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{leftSeats.map((seat: any) => (
|
||||||
|
<SeatIcon
|
||||||
|
key={seat.id}
|
||||||
|
seat={seat}
|
||||||
|
coach={coach}
|
||||||
|
isBedCoach={false}
|
||||||
|
shouldFlipIcon={shouldFlipArmchair}
|
||||||
|
getSeatStatus={getSeatStatus}
|
||||||
|
getSeatColor={getSeatColor}
|
||||||
|
handleBlock={handleBlock}
|
||||||
|
handleRemoveSeat={handleRemoveSeat}
|
||||||
|
handleUnblock={handleUnblock}
|
||||||
|
handleUndoRemove={handleUndoRemove}
|
||||||
|
hideNumber={true}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{rightSeats.length > 0 && <div className="w-3" />}
|
||||||
|
{rightSeats.length > 0 && (
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{rightSeats.map((seat: any) => (
|
||||||
|
<SeatIcon
|
||||||
|
key={seat.id}
|
||||||
|
seat={seat}
|
||||||
|
coach={coach}
|
||||||
|
isBedCoach={false}
|
||||||
|
shouldFlipIcon={shouldFlipArmchair}
|
||||||
|
getSeatStatus={getSeatStatus}
|
||||||
|
getSeatColor={getSeatColor}
|
||||||
|
handleBlock={handleBlock}
|
||||||
|
handleRemoveSeat={handleRemoveSeat}
|
||||||
|
handleUnblock={handleUnblock}
|
||||||
|
handleUndoRemove={handleUndoRemove}
|
||||||
|
hideNumber={true}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!shouldFlipArmchair && (
|
||||||
|
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{leftSeats.map((seat: any) => (
|
||||||
|
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
|
||||||
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{rightSeats.length > 0 && <div className="w-3" />}
|
||||||
|
{rightSeats.length > 0 && (
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{rightSeats.map((seat: any) => (
|
||||||
|
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
|
||||||
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSpacing && <div className="h-2" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const coachesWithSeats = coaches.filter((coach: any) => {
|
||||||
|
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||||
|
return seats.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -97,40 +340,25 @@ export default function SeatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex-1">
|
<label className="label">Select Schedule</label>
|
||||||
<label className="label">Select Schedule</label>
|
<select
|
||||||
<select
|
value={selectedSchedule}
|
||||||
value={selectedSchedule}
|
onChange={(e) => setSelectedSchedule(e.target.value)}
|
||||||
onChange={(e) => setSelectedSchedule(e.target.value)}
|
className="input"
|
||||||
className="input"
|
>
|
||||||
>
|
<option value="">Select a schedule...</option>
|
||||||
<option value="">Select a schedule...</option>
|
{schedules.map((schedule: any) => {
|
||||||
{schedules.map((schedule: any) => {
|
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
||||||
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
const routeName = schedule.route?.name || 'N/A';
|
||||||
const routeName = schedule.route?.name || 'N/A';
|
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||||
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
return (
|
||||||
return (
|
<option key={schedule.id} value={schedule.id}>
|
||||||
<option key={schedule.id} value={schedule.id}>
|
{trainNumber} - {routeName} - {date}
|
||||||
{trainNumber} - {routeName} - {date}
|
</option>
|
||||||
</option>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</select>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<label className="label">Search Coaches</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by coach number..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="input pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!selectedSchedule ? (
|
{!selectedSchedule ? (
|
||||||
@@ -143,13 +371,12 @@ export default function SeatsPage() {
|
|||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||||
<p className="text-muted-foreground mt-3">Loading seats...</p>
|
<p className="text-muted-foreground mt-3">Loading seats...</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredCoaches.length === 0 ? (
|
) : coachesWithSeats.length === 0 ? (
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
<p>No coaches found for this schedule</p>
|
<p>No coaches with seats found for this schedule</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Legend */}
|
|
||||||
<div className="flex items-center gap-6 p-4 bg-muted/50 rounded-lg">
|
<div className="flex items-center gap-6 p-4 bg-muted/50 rounded-lg">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-4 h-4 rounded bg-green-500"></div>
|
<div className="w-4 h-4 rounded bg-green-500"></div>
|
||||||
@@ -167,86 +394,35 @@ export default function SeatsPage() {
|
|||||||
<div className="w-4 h-4 rounded bg-gray-500"></div>
|
<div className="w-4 h-4 rounded bg-gray-500"></div>
|
||||||
<span className="text-sm">Blocked</span>
|
<span className="text-sm">Blocked</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded border-2 border-dashed border-gray-400"></div>
|
||||||
|
<span className="text-sm">Removed</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Coaches */}
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{filteredCoaches.map((coach: any) => {
|
{coachesWithSeats.map((coach: any) => {
|
||||||
const seats = coach.seats || [];
|
const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) ||
|
||||||
const seatClass = coach.seatClass?.name || 'N/A';
|
(coach.mode && coach.mode.toLowerCase().includes('bed'));
|
||||||
const availableCount = seats.filter((s: any) => getSeatStatus(s) === 'AVAILABLE').length;
|
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
||||||
const bookedCount = seats.filter((s: any) => getSeatStatus(s) === 'BOOKED').length;
|
|
||||||
const blockedCount = seats.filter((s: any) => getSeatStatus(s) === 'BLOCKED').length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={coach.id} className="border rounded-lg p-4">
|
<div key={coach.id} className="border rounded-lg p-3 bg-white dark:bg-card">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="mb-3">
|
||||||
<div>
|
<h3 className="font-semibold text-sm">Coach {coach.coachNumber}</h3>
|
||||||
<h3 className="text-lg font-semibold">
|
|
||||||
Coach {coach.coachNumber} - {coach.label}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{seatClass} • {seats.length} seats
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 text-sm">
|
|
||||||
<span className="text-green-600">Available: {availableCount}</span>
|
<div className="bg-gray-50 dark:bg-gray-900/30 py-2 rounded-lg">
|
||||||
<span className="text-red-600">Booked: {bookedCount}</span>
|
{renderCoachSeats(coach, isBedCoach)}
|
||||||
<span className="text-gray-600">Blocked: {blockedCount}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
<div className="grid grid-cols-8 gap-2">
|
})}
|
||||||
{seats.map((seat: any) => {
|
</div>
|
||||||
const status = getSeatStatus(seat);
|
|
||||||
const color = getSeatColor(status);
|
|
||||||
const canBlock = status === 'AVAILABLE';
|
|
||||||
const canUnblock = status === 'BLOCKED';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={seat.id}
|
|
||||||
className="relative group"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`${color} text-white rounded-lg p-2 text-center text-sm font-medium cursor-pointer hover:opacity-80 transition-opacity`}
|
|
||||||
title={`${seat.seatNumber} - ${status}`}
|
|
||||||
>
|
|
||||||
{seat.seatNumber}
|
|
||||||
</div>
|
|
||||||
{(canBlock || canUnblock) && (
|
|
||||||
<div className="absolute inset-0 bg-black/60 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
|
|
||||||
{canBlock && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleBlock(seat)}
|
|
||||||
className="p-1 bg-white rounded hover:bg-gray-100"
|
|
||||||
title="Block seat"
|
|
||||||
>
|
|
||||||
<Lock className="h-3 w-3 text-gray-700" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canUnblock && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleUnblock(seat)}
|
|
||||||
className="p-1 bg-white rounded hover:bg-gray-100"
|
|
||||||
title="Unblock seat"
|
|
||||||
>
|
|
||||||
<Unlock className="h-3 w-3 text-gray-700" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Block Modal */}
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showBlockModal}
|
isOpen={showBlockModal}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -293,6 +469,162 @@ export default function SeatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showRemoveModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowRemoveModal(false);
|
||||||
|
setSelectedSeat(null);
|
||||||
|
}}
|
||||||
|
title="Remove Seat"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Remove seat <strong>{selectedSeat?.seatNumber}</strong> from Coach{' '}
|
||||||
|
<strong>{selectedSeat?.coach?.coachNumber}</strong>
|
||||||
|
</p>
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-yellow-800">
|
||||||
|
This will mark the seat as removed. The seat will show as an empty space on the seat map.
|
||||||
|
You can undo this action anytime by clicking the undo button on the removed seat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<ActionButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowRemoveModal(false);
|
||||||
|
setSelectedSeat(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
variant="danger"
|
||||||
|
onClick={submitRemoveSeat}
|
||||||
|
loading={removeSeatMutation.isPending}
|
||||||
|
>
|
||||||
|
Remove Seat
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeatIconProps {
|
||||||
|
seat: any;
|
||||||
|
coach: any;
|
||||||
|
isBedCoach: boolean;
|
||||||
|
shouldFlipIcon?: boolean;
|
||||||
|
hideNumber?: boolean;
|
||||||
|
getSeatStatus: (seat: any) => string;
|
||||||
|
getSeatColor: (status: string) => string;
|
||||||
|
handleBlock: (seat: any) => void;
|
||||||
|
handleRemoveSeat: (seat: any) => void;
|
||||||
|
handleUnblock: (seat: any) => void;
|
||||||
|
handleUndoRemove: (seat: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeatIcon({
|
||||||
|
seat,
|
||||||
|
coach,
|
||||||
|
isBedCoach,
|
||||||
|
shouldFlipIcon = false,
|
||||||
|
hideNumber = false,
|
||||||
|
getSeatStatus,
|
||||||
|
getSeatColor,
|
||||||
|
handleBlock,
|
||||||
|
handleRemoveSeat,
|
||||||
|
handleUnblock,
|
||||||
|
handleUndoRemove,
|
||||||
|
}: SeatIconProps) {
|
||||||
|
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
|
||||||
|
|
||||||
|
if (!seat.seatNumber) {
|
||||||
|
return <div className="w-7 h-7" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRemoved) {
|
||||||
|
return (
|
||||||
|
<div className="relative group flex flex-col items-center">
|
||||||
|
<div className="w-11 h-11 rounded border-2 border-dashed border-gray-400 flex items-center justify-center hover:opacity-80 transition-opacity" title="Removed seat">
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => handleUndoRemove(seat)}
|
||||||
|
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||||
|
title="Undo remove"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3 text-gray-700" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = getSeatStatus(seat);
|
||||||
|
const color = getSeatColor(status);
|
||||||
|
const canBlock = status === 'AVAILABLE';
|
||||||
|
const canUnblock = status === 'BLOCKED';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative group flex flex-col items-center">
|
||||||
|
{!hideNumber && (
|
||||||
|
<span className="text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
|
||||||
|
{seat.seatNumber}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isBedCoach ? (
|
||||||
|
<div
|
||||||
|
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
||||||
|
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}`}
|
||||||
|
>
|
||||||
|
<Bed className="w-7 h-7 text-white" style={shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
||||||
|
title={`${seat.seatNumber} - ${status}`}
|
||||||
|
>
|
||||||
|
<Armchair className="w-7 h-7 text-white" style={shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(canBlock || canUnblock) && (
|
||||||
|
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
|
||||||
|
{canBlock && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleBlock(seat)}
|
||||||
|
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||||
|
title="Block seat"
|
||||||
|
>
|
||||||
|
<Lock className="h-3 w-3 text-gray-700" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveSeat(seat)}
|
||||||
|
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||||
|
title="Remove seat"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 text-gray-700" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{canUnblock && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnblock(seat)}
|
||||||
|
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||||
|
title="Unblock seat"
|
||||||
|
>
|
||||||
|
<Unlock className="h-3 w-3 text-gray-700" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Edit, Trash2, Train } from 'lucide-react';
|
import { Plus, Edit, Trash2, Train, Search } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
@@ -15,6 +15,7 @@ import { formatDate } from '@/lib/utils';
|
|||||||
export default function TrainsPage() {
|
export default function TrainsPage() {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingTrain, setEditingTrain] = useState<TrainType | null>(null);
|
const [editingTrain, setEditingTrain] = useState<TrainType | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null }>({ isOpen: false, train: null });
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null }>({ isOpen: false, train: null });
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -86,6 +87,19 @@ export default function TrainsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const trains = trainsData?.items || [];
|
||||||
|
|
||||||
|
const filteredTrains = trains.filter((train: any) => {
|
||||||
|
if (!search) return true;
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
train.number.toLowerCase().includes(searchLower) ||
|
||||||
|
train.name.toLowerCase().includes(searchLower) ||
|
||||||
|
train.operatorName?.toLowerCase().includes(searchLower) ||
|
||||||
|
train.description?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const trainColumns = [
|
const trainColumns = [
|
||||||
{
|
{
|
||||||
key: 'number',
|
key: 'number',
|
||||||
@@ -168,13 +182,27 @@ export default function TrainsPage() {
|
|||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search Filter */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by number, name, or operator..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="input pl-10 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Trains Table */}
|
{/* Trains Table */}
|
||||||
<DataTable
|
<DataTable
|
||||||
data={trainsData?.items || []}
|
data={filteredTrains}
|
||||||
columns={trainColumns}
|
columns={trainColumns}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
loading={trainsLoading}
|
loading={trainsLoading}
|
||||||
emptyMessage="No trains found"
|
emptyMessage={search ? "No trains match your search" : "No trains found"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Delete Confirmation */}
|
||||||
|
|||||||
@@ -57,12 +57,12 @@ const navigationSections = [
|
|||||||
title: 'Master Data',
|
title: 'Master Data',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Stations', href: '/stations', icon: MapPin },
|
{ name: 'Stations', href: '/stations', icon: MapPin },
|
||||||
{ name: 'Routes', href: '/routes', icon: Route },
|
|
||||||
{ name: 'Trains', href: '/trains', icon: Train },
|
{ name: 'Trains', href: '/trains', icon: Train },
|
||||||
{ name: 'Coaches', href: '/coaches', icon: Grid3x3 },
|
{ name: 'Coaches', href: '/coaches', icon: Grid3x3 },
|
||||||
{ name: 'Seats', href: '/seats', icon: Armchair },
|
{ name: 'Seats', href: '/seats', icon: Armchair },
|
||||||
|
{ name: 'Classes', href: '/classes', icon: Settings },
|
||||||
|
{ name: 'Routes', href: '/routes', icon: Route },
|
||||||
{ name: 'Schedules', href: '/schedules', icon: Calendar },
|
{ name: 'Schedules', href: '/schedules', icon: Calendar },
|
||||||
{ name: 'Classes', href: '/seat-classes', icon: Settings },
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ interface PaginationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6">
|
<div className="flex items-center justify-between border-t border-border bg-card px-4 py-3 sm:px-6">
|
||||||
<div className="flex flex-1 justify-between sm:hidden">
|
<div className="flex flex-1 justify-between sm:hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => onPageChange(currentPage - 1)}
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
@@ -27,7 +31,7 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
|
|||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-foreground">
|
||||||
Page <span className="font-medium">{currentPage}</span> of{' '}
|
Page <span className="font-medium">{currentPage}</span> of{' '}
|
||||||
<span className="font-medium">{totalPages}</span>
|
<span className="font-medium">{totalPages}</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -51,4 +55,4 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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}`),
|
release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
|
||||||
block: (seatId: string, data: any) => apiClient.post<any>(`/seats/${seatId}/block`, data),
|
block: (seatId: string, data: any) => apiClient.post<any>(`/seats/${seatId}/block`, data),
|
||||||
unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`),
|
unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`),
|
||||||
|
removeSeat: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/remove`, {}),
|
||||||
|
undoRemove: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/undo-remove`, {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Payments API
|
// Payments API
|
||||||
@@ -332,13 +334,13 @@ export const liveApi = {
|
|||||||
getCrowdSignals: () => apiClient.get<any[]>('/live/crowd-signals'),
|
getCrowdSignals: () => apiClient.get<any[]>('/live/crowd-signals'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Seat Classes API
|
// Classes API (formerly Seat Classes)
|
||||||
export const seatClassesApi = {
|
export const seatClassesApi = {
|
||||||
getAll: () => apiClient.get<any[]>('/seat-classes'),
|
getAll: () => apiClient.get<any[]>('/fleet/classes'),
|
||||||
getById: (id: string) => apiClient.get<any>(`/seat-classes/${id}`),
|
getById: (id: string) => apiClient.get<any>(`/fleet/classes/${id}`),
|
||||||
create: (data: any) => apiClient.post<any>('/seat-classes', data),
|
create: (data: any) => apiClient.post<any>('/fleet/classes', data),
|
||||||
update: (id: string, data: any) => apiClient.patch<any>(`/seat-classes/${id}`, data),
|
update: (id: string, data: any) => apiClient.patch<any>(`/fleet/classes/${id}`, data),
|
||||||
delete: (id: string) => apiClient.delete(`/seat-classes/${id}`),
|
delete: (id: string) => apiClient.delete(`/fleet/classes/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Food & Dining API
|
// Food & Dining API
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const passengersApi = {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters?.search) params.append('search', filters.search);
|
if (filters?.search) params.append('search', filters.search);
|
||||||
if (filters?.verified !== undefined) params.append('verified', filters.verified.toString());
|
if (filters?.verified !== undefined) params.append('verified', filters.verified.toString());
|
||||||
|
if (filters?.role) params.append('role', filters.role);
|
||||||
if (filters?.page) params.append('page', filters.page.toString());
|
if (filters?.page) params.append('page', filters.page.toString());
|
||||||
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());
|
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export interface BookingFilters {
|
|||||||
export interface PassengerFilters {
|
export interface PassengerFilters {
|
||||||
search?: string;
|
search?: string;
|
||||||
verified?: boolean;
|
verified?: boolean;
|
||||||
|
role?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export default function ResultsPage() {
|
|||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="mt-6 pt-6 border-t dark:border-gray-700">
|
<div className="mt-6 pt-6 border-t dark:border-gray-700">
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-3">Select Seat Class</h4>
|
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-3">Select Class</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
{schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 ? (
|
{schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 ? (
|
||||||
schedule.faresByClass.map((fareClass: any) => {
|
schedule.faresByClass.map((fareClass: any) => {
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ export default function SearchPage() {
|
|||||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Children</div>
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 text-left">Children</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400"><5 years • First free</div>
|
<div className="text-xs text-gray-500 dark:text-gray-400"><5 years • First free</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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 { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useState, useEffect, useCallback, useMemo, memo } from 'react';
|
import { useState, useEffect, useCallback, useMemo, memo } from 'react';
|
||||||
|
import { Armchair, Bed, ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
import CustomModal from '@/components/CustomModal';
|
import CustomModal from '@/components/CustomModal';
|
||||||
|
|
||||||
// Separate component for seat button to prevent re-render issues
|
const SeatButton = memo(({ seat, isSelected, onToggle, isBedCoach, bedLabel }: any) => {
|
||||||
const SeatButton = memo(({ seat, isSelected, onToggle }: any) => {
|
|
||||||
const seatLabel = seat.number || seat.label || seat.seatNumber || '?';
|
const seatLabel = seat.number || seat.label || seat.seatNumber || '?';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="flex flex-col items-center">
|
||||||
onClick={() => onToggle(seat.id)}
|
<span className="text-xs font-bold mb-0.5 text-gray-900 dark:text-gray-100">
|
||||||
disabled={seat.status !== 'AVAILABLE'}
|
{seatLabel}{bedLabel}
|
||||||
className={`w-12 h-12 rounded flex items-center justify-center text-xs font-semibold transition-all ${
|
</span>
|
||||||
isSelected
|
<button
|
||||||
? 'bg-primary text-white shadow-md scale-105'
|
onClick={() => onToggle(seat.id)}
|
||||||
: seat.status === 'AVAILABLE'
|
disabled={seat.status !== 'AVAILABLE'}
|
||||||
? 'bg-green-100 dark:bg-green-900/40 hover:bg-green-200 dark:hover:bg-green-800/50 text-green-800 dark:text-green-200 hover:shadow-md cursor-pointer'
|
className={`w-11 h-11 rounded flex items-center justify-center transition-all ${
|
||||||
: seat.status === 'HELD'
|
isSelected
|
||||||
? 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-200 cursor-not-allowed opacity-75'
|
? 'bg-[rgb(20_113_76)] text-white shadow-md scale-105'
|
||||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed opacity-60'
|
: seat.status === 'AVAILABLE'
|
||||||
}`}
|
? 'bg-green-500 hover:bg-green-600 text-white cursor-pointer hover:shadow-md'
|
||||||
title={`Seat ${seatLabel} - ${seat.status}`}
|
: seat.status === 'HELD'
|
||||||
>
|
? 'bg-yellow-500 text-white cursor-not-allowed opacity-75'
|
||||||
{seatLabel}
|
: 'bg-gray-500 text-white cursor-not-allowed opacity-60'
|
||||||
</button>
|
}`}
|
||||||
|
title={`Seat ${seatLabel}${bedLabel} - ${seat.status}`}
|
||||||
|
>
|
||||||
|
{isBedCoach ? (
|
||||||
|
<Bed className="w-7 h-7" style={seat.bedFlip ? { transform: 'scaleY(-1)' } : undefined} />
|
||||||
|
) : (
|
||||||
|
<Armchair className="w-7 h-7" style={seat.armchairFlip ? { transform: 'scaleY(-1)' } : undefined} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,10 +47,9 @@ SeatButton.displayName = 'SeatButton';
|
|||||||
|
|
||||||
export default function SeatsPage() {
|
export default function SeatsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria } = useBookingStore();
|
const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore();
|
||||||
const [selectedSeats, setSelectedSeats] = useState<string[]>([]);
|
const [selectedSeats, setSelectedSeats] = useState<string[]>([]);
|
||||||
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
|
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
|
||||||
const [_timeLeft, _setTimeLeft] = useState<number | null>(null);
|
|
||||||
const [modalState, setModalState] = useState({
|
const [modalState, setModalState] = useState({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
title: '',
|
title: '',
|
||||||
@@ -77,10 +85,23 @@ export default function SeatsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mutation to book seats permanently (called after payment)
|
||||||
|
const bookSeatsMutation = useMutation({
|
||||||
|
mutationFn: async (seatIds: string[]) => {
|
||||||
|
return Promise.all(
|
||||||
|
seatIds.map((seatId) =>
|
||||||
|
apiClient.patch(`/seats/${seatId}`, {
|
||||||
|
status: 'BOOKED',
|
||||||
|
}).catch(() => null) // Ignore errors, seats are already booked via booking system
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]);
|
const coaches = useMemo(() => (seatMapData as any)?.coaches || [], [seatMapData]);
|
||||||
|
|
||||||
const filteredCoaches = useMemo(() => {
|
const filteredCoaches = useMemo(() => {
|
||||||
return selectedSchedule?.selectedSeatClass
|
let filtered = selectedSchedule?.selectedSeatClass
|
||||||
? coaches.filter((c: any) => {
|
? coaches.filter((c: any) => {
|
||||||
const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || '');
|
const seatClassName = typeof c.seatClass === 'string' ? c.seatClass : (c.seatClass?.name || c.coachClass || '');
|
||||||
return seatClassName === selectedSchedule.selectedSeatClass ||
|
return seatClassName === selectedSchedule.selectedSeatClass ||
|
||||||
@@ -88,10 +109,12 @@ export default function SeatsPage() {
|
|||||||
seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase();
|
seatClassName.toLowerCase() === selectedSchedule.selectedSeatClass?.toLowerCase();
|
||||||
})
|
})
|
||||||
: coaches;
|
: coaches;
|
||||||
|
return filtered.filter((c: any) => c.seats && c.seats.length > 0);
|
||||||
}, [coaches, selectedSchedule?.selectedSeatClass]);
|
}, [coaches, selectedSchedule?.selectedSeatClass]);
|
||||||
|
|
||||||
const selectedCoachData = useMemo(() => filteredCoaches.find((c: any) => c.id === selectedCoach), [filteredCoaches, selectedCoach]);
|
const selectedCoachData = useMemo(() => filteredCoaches.find((c: any) => c.id === selectedCoach), [filteredCoaches, selectedCoach]);
|
||||||
const seats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]);
|
const allSeats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]);
|
||||||
|
const validSeats = useMemo(() => allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')), [allSeats]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filteredCoaches && filteredCoaches.length > 0 && !selectedCoach) {
|
if (filteredCoaches && filteredCoaches.length > 0 && !selectedCoach) {
|
||||||
@@ -114,7 +137,7 @@ export default function SeatsPage() {
|
|||||||
if (selectedSeats.length > 0) {
|
if (selectedSeats.length > 0) {
|
||||||
await holdMutation.mutateAsync(selectedSeats);
|
await holdMutation.mutateAsync(selectedSeats);
|
||||||
const updatedPassengers = passengers.map((p, i) => {
|
const updatedPassengers = passengers.map((p, i) => {
|
||||||
const seatData = seats?.find((s: any) => s.id === selectedSeats[i]);
|
const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]);
|
||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
seatId: selectedSeats[i],
|
seatId: selectedSeats[i],
|
||||||
@@ -127,7 +150,7 @@ export default function SeatsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAutoAssign = async () => {
|
const handleAutoAssign = async () => {
|
||||||
const availableSeats = seats?.filter((s: any) => s.status === 'AVAILABLE') || [];
|
const availableSeats = validSeats?.filter((s: any) => s.status === 'AVAILABLE') || [];
|
||||||
if (availableSeats.length < passengers.length) {
|
if (availableSeats.length < passengers.length) {
|
||||||
setModalState({
|
setModalState({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
@@ -164,12 +187,155 @@ export default function SeatsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBackToPassengers = () => {
|
||||||
|
router.push('/booking/passengers');
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSchedule || !passengers.length) {
|
if (!selectedSchedule || !passengers.length) {
|
||||||
router.push('/booking/search');
|
router.push('/booking/search');
|
||||||
}
|
}
|
||||||
}, [selectedSchedule, passengers.length, router]);
|
}, [selectedSchedule, passengers.length, router]);
|
||||||
|
|
||||||
|
// Auto-book seats when booking is confirmed (after payment)
|
||||||
|
useEffect(() => {
|
||||||
|
if (bookingId && selectedSeats.length > 0) {
|
||||||
|
bookSeatsMutation.mutate(selectedSeats);
|
||||||
|
}
|
||||||
|
}, [bookingId]);
|
||||||
|
|
||||||
|
const parseSeatArrangement = (arrangement: string | null): number[] => {
|
||||||
|
if (!arrangement) return [2, 2];
|
||||||
|
const parts = arrangement.split('+').map(p => parseInt(p.trim()));
|
||||||
|
return parts.length === 2 ? parts : [2, 2];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBedLabel = (bedPosition: string | null): string => {
|
||||||
|
if (bedPosition === 'upper') return 'U';
|
||||||
|
if (bedPosition === 'middle') return 'M';
|
||||||
|
if (bedPosition === 'lower') return 'L';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCoachSeats = (coach: any, isBedCoach: boolean) => {
|
||||||
|
const arrangement = parseSeatArrangement(coach.seatArrangement);
|
||||||
|
const leftCount = arrangement[0];
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
const processedRows = new Set();
|
||||||
|
for (const seat of allSeats) {
|
||||||
|
if (!processedRows.has(seat.row)) {
|
||||||
|
rows.push(allSeats.filter((s: any) => s.row === seat.row).sort((a: any, b: any) => {
|
||||||
|
const colA = a.col.charCodeAt(0);
|
||||||
|
const colB = b.col.charCodeAt(0);
|
||||||
|
return colA - colB;
|
||||||
|
}));
|
||||||
|
processedRows.add(seat.row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0">
|
||||||
|
{rows.map((rowSeats: any[], rowIdx: number) => {
|
||||||
|
const leftSeats = rowSeats.slice(0, leftCount);
|
||||||
|
const rightSeats = rowSeats.slice(leftCount);
|
||||||
|
const rowNumber = rowSeats[0]?.row || 1;
|
||||||
|
const shouldFlipIcon = rowNumber % 2 === 0;
|
||||||
|
const showSpacing = rowIdx % 2 === 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`row-${rowSeats[0]?.id}`}>
|
||||||
|
{shouldFlipIcon && (
|
||||||
|
<div className="flex gap-0.5 justify-center text-xs text-muted-foreground mb-1">
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{leftSeats.map((seat: any) => (
|
||||||
|
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
|
||||||
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{rightSeats.length > 0 && <div className="w-3" />}
|
||||||
|
{rightSeats.length > 0 && (
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{rightSeats.map((seat: any) => (
|
||||||
|
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
|
||||||
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-0.5 justify-center">
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{leftSeats.map((seat: any) => (
|
||||||
|
seat.seatNumber && !seat.seatNumber.startsWith('-') ? (
|
||||||
|
<SeatButton
|
||||||
|
key={seat.id}
|
||||||
|
seat={seat}
|
||||||
|
isSelected={selectedSeats.includes(seat.id)}
|
||||||
|
onToggle={toggleSeat}
|
||||||
|
isBedCoach={isBedCoach}
|
||||||
|
bedLabel={getBedLabel(seat.bedPosition)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div key={seat.id} className="w-11 h-11" />
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{rightSeats.length > 0 && <div className="w-3" />}
|
||||||
|
{rightSeats.length > 0 && (
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{rightSeats.map((seat: any) => (
|
||||||
|
seat.seatNumber && !seat.seatNumber.startsWith('-') ? (
|
||||||
|
<SeatButton
|
||||||
|
key={seat.id}
|
||||||
|
seat={seat}
|
||||||
|
isSelected={selectedSeats.includes(seat.id)}
|
||||||
|
onToggle={toggleSeat}
|
||||||
|
isBedCoach={isBedCoach}
|
||||||
|
bedLabel={getBedLabel(seat.bedPosition)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div key={seat.id} className="w-11 h-11" />
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!shouldFlipIcon && (
|
||||||
|
<div className="flex gap-0.5 justify-center text-xs text-muted-foreground mb-1">
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{leftSeats.map((seat: any) => (
|
||||||
|
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
|
||||||
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{rightSeats.length > 0 && <div className="w-3" />}
|
||||||
|
{rightSeats.length > 0 && (
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{rightSeats.map((seat: any) => (
|
||||||
|
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center">
|
||||||
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSpacing && <div className="h-2" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBedCoach = selectedCoachData && (selectedCoachData.seatClass?.toLowerCase().includes('bed') || selectedCoachData.mode?.toLowerCase().includes('bed'));
|
||||||
|
|
||||||
if (!selectedSchedule || !passengers.length) return null;
|
if (!selectedSchedule || !passengers.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -182,143 +348,151 @@ export default function SeatsPage() {
|
|||||||
type={modalState.type}
|
type={modalState.type}
|
||||||
/>
|
/>
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select seats</h1>
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<button
|
||||||
<div className="grid lg:grid-cols-3 gap-6">
|
onClick={handleBackToPassengers}
|
||||||
<div className="lg:col-span-2">
|
className="flex items-center gap-2 text-[rgb(20_113_76)] hover:text-[rgb(10_80_50)] font-semibold transition-colors"
|
||||||
<div className="card mb-4">
|
>
|
||||||
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select coach</h3>
|
<ChevronLeft className="w-5 h-5" />
|
||||||
{selectedSchedule?.selectedSeatClassName && (
|
Back to passenger details
|
||||||
<div className="mb-3 text-sm text-gray-600 dark:text-gray-400">
|
</button>
|
||||||
Showing coaches for: <span className="font-semibold text-primary">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
|
||||||
{filteredCoaches?.map((coach: any) => {
|
|
||||||
const availableCount = coach.seats?.filter((s: any) => s.status === 'AVAILABLE').length || 0;
|
|
||||||
const seatClassName = typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || '');
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={coach.id}
|
|
||||||
onClick={() => setSelectedCoach(coach.id)}
|
|
||||||
className={`px-4 py-2 rounded whitespace-nowrap ${
|
|
||||||
selectedCoach === coach.id
|
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div>{coach.label || coach.name || coach.coachNumber}</div>
|
|
||||||
<div className="text-xs opacity-75">{seatClassName}</div>
|
|
||||||
<div className="text-xs opacity-75">{availableCount} available</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}</h3>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
||||||
<p>Loading seats...</p>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="text-center py-8 text-red-500 dark:text-red-400">
|
|
||||||
<p>Error loading seats</p>
|
|
||||||
<p className="text-sm mt-2">{error?.message || 'Please try again'}</p>
|
|
||||||
</div>
|
|
||||||
) : seats.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
||||||
<p>No seats available in this coach</p>
|
|
||||||
<p className="text-sm mt-2\">Please select a different coach</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Seat Grid */}
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg mb-4 overflow-x-auto">
|
|
||||||
<div className="inline-grid gap-2" style={{ gridTemplateColumns: `repeat(4, minmax(0, 1fr))` }}>
|
|
||||||
{seats?.map((seat: any) => (
|
|
||||||
<SeatButton
|
|
||||||
key={seat.id}
|
|
||||||
seat={seat}
|
|
||||||
isSelected={selectedSeats.includes(seat.id)}
|
|
||||||
onToggle={toggleSeat}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<div className="flex flex-wrap gap-4 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-6 h-6 bg-green-100 dark:bg-green-900/40 rounded"></div>
|
|
||||||
<span>Available</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-6 h-6 bg-primary rounded"></div>
|
|
||||||
<span>Selected</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-6 h-6 bg-yellow-100 dark:bg-yellow-900/40 rounded"></div>
|
|
||||||
<span>Held</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-6 h-6 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
||||||
<span>Booked</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select seats</h1>
|
||||||
|
|
||||||
<div>
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
<div className="card sticky top-4">
|
<div className="lg:col-span-2">
|
||||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection summary</h3>
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 mb-4">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select coach</h3>
|
||||||
Select {passengers.length} seat(s) for your passengers
|
{selectedSchedule?.selectedSeatClassName && (
|
||||||
</p>
|
<div className="mb-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<p className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
Showing coaches for: <span className="font-semibold text-[rgb(20_113_76)]">{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}</span>
|
||||||
{selectedSeats.length} / {passengers.length} selected
|
</div>
|
||||||
</p>
|
)}
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||||
<div className="space-y-2 mb-6">
|
{filteredCoaches?.map((coach: any) => {
|
||||||
{passengers.map((p, i) => {
|
const availableCount = coach.seats?.filter((s: any) => s.status === 'AVAILABLE').length || 0;
|
||||||
const assignedSeat = selectedSeats[i] ? seats?.find((s: any) => s.id === selectedSeats[i]) : null;
|
const seatClassName = typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || '');
|
||||||
const seatLabel = assignedSeat ? (assignedSeat.number || assignedSeat.label || assignedSeat.seatNumber || '-') : '-';
|
return (
|
||||||
return (
|
<button
|
||||||
<div key={i} className="flex justify-between text-sm text-gray-700 dark:text-gray-300">
|
key={coach.id}
|
||||||
<span>{p.name}</span>
|
onClick={() => setSelectedCoach(coach.id)}
|
||||||
<span className="font-medium">
|
className={`px-4 py-2 rounded whitespace-nowrap transition-all ${
|
||||||
{seatLabel}
|
selectedCoach === coach.id
|
||||||
</span>
|
? 'bg-[rgb(20_113_76)] text-white shadow-lg'
|
||||||
</div>
|
: 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100'
|
||||||
);
|
}`}
|
||||||
})}
|
>
|
||||||
|
<div className="font-semibold">{coach.label || coach.name || coach.coachNumber}</div>
|
||||||
|
<div className="text-xs opacity-75">{seatClassName}</div>
|
||||||
|
<div className="text-xs opacity-75">{availableCount} available</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||||
onClick={handleContinue}
|
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||||
disabled={selectedSeats.length === 0}
|
Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}
|
||||||
className="btn-primary w-full mb-2"
|
</h3>
|
||||||
>
|
{selectedCoachData && (
|
||||||
Continue with selected seats
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||||
</button>
|
Arrangement: {selectedCoachData.seatArrangement} • Total: {selectedCoachData.totalSeats} seats
|
||||||
<button
|
</p>
|
||||||
onClick={handleAutoAssign}
|
)}
|
||||||
disabled={holdMutation.isPending}
|
|
||||||
className="btn-secondary w-full"
|
{isLoading ? (
|
||||||
>
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
{holdMutation.isPending ? 'Assigning...' : 'Auto-assign seats'}
|
<p>Loading seats...</p>
|
||||||
</button>
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-8 text-red-500 dark:text-red-400">
|
||||||
|
<p>Error loading seats</p>
|
||||||
|
<p className="text-sm mt-2">{error?.message || 'Please try again'}</p>
|
||||||
|
</div>
|
||||||
|
) : validSeats.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<p>No seats available in this coach</p>
|
||||||
|
<p className="text-sm mt-2">Please select a different coach</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-4 mb-6 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">Available</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-[rgb(20_113_76)] rounded"></div>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">Selected</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-yellow-500 rounded"></div>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">Held</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-gray-500 rounded"></div>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">Booked</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700/30 p-6 rounded-lg overflow-x-auto">
|
||||||
|
{renderCoachSeats(selectedCoachData, isBedCoach)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 sticky top-4">
|
||||||
|
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection summary</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Select {passengers.length} seat(s) for your passengers
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||||
|
{selectedSeats.length} / {passengers.length} selected
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-6 max-h-48 overflow-y-auto">
|
||||||
|
{passengers.map((p, i) => {
|
||||||
|
const assignedSeat = selectedSeats[i] ? validSeats?.find((s: any) => s.id === selectedSeats[i]) : null;
|
||||||
|
const seatLabel = assignedSeat ? (assignedSeat.number || assignedSeat.label || assignedSeat.seatNumber || '-') : '-';
|
||||||
|
const bedLabel = assignedSeat ? getBedLabel(assignedSeat.bedPosition) : '';
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex justify-between text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<span>{p.name}</span>
|
||||||
|
<span className="font-medium text-[rgb(20_113_76)]">
|
||||||
|
{seatLabel}{bedLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleContinue}
|
||||||
|
disabled={selectedSeats.length === 0}
|
||||||
|
className="w-full bg-[rgb(20_113_76)] hover:bg-[rgb(10_80_50)] disabled:bg-gray-400 text-white font-semibold py-2 rounded mb-2 transition-colors"
|
||||||
|
>
|
||||||
|
Continue with selected seats
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAutoAssign}
|
||||||
|
disabled={holdMutation.isPending}
|
||||||
|
className="w-full bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white font-semibold py-2 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{holdMutation.isPending ? 'Assigning...' : 'Auto-assign seats'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user