From 9320e0e9abecb85377f2ae14c25989694127bd75 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Wed, 24 Jun 2026 11:50:44 +0300 Subject: [PATCH] Update seatmap configuration --- .../src/modules/fleet/fleet.controller.ts | 35 +- .../src/modules/fleet/fleet.dto.ts | 26 +- .../src/modules/fleet/fleet.service.ts | 158 +- .../src/modules/payments/payments.service.ts | 2 +- .../src/modules/seats/seats.controller.ts | 22 +- .../src/modules/seats/seats.service.ts | 137 +- apps/edr-passenger-web/portal/public/bed.png | Bin 0 -> 14282 bytes .../portal/src/app/booking/results/page.tsx | 12 +- .../portal/src/app/booking/review/page.tsx | 3 +- .../portal/src/app/booking/seats/page.tsx | 1310 +++++++++++++---- 10 files changed, 1316 insertions(+), 389 deletions(-) create mode 100644 apps/edr-passenger-web/portal/public/bed.png diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index 60a6ef11d..45c600c67 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger'; import { FleetService } from './fleet.service'; -import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto'; +import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto'; import { JwtGuard } from '../../common/jwt.guard'; @ApiTags('Fleet') @@ -317,6 +317,39 @@ export class FleetController { return this.service.removeAssignment(id); } + @Post('seatmap/generate') + @ApiOperation({ + summary: 'Preview bed seat map — ECONOMY_BED or VIP_BED', + description: `Generates a structured seat map for bed coaches without persisting anything. + +**ECONOMY_BED**: 6 beds per room — Left(Lower/Middle/Upper) + Right(Lower/Middle/Upper) + +**VIP_BED**: 4 beds per room — Left(Lower/Upper) + Right(Lower/Upper) + +Use this to preview the full flat seat list before creating coaches.`, + }) + @ApiBody({ type: GenerateSeatMapDto }) + @ApiResponse({ + status: 201, + description: 'Generated seat map preview', + schema: { + example: { + coachCount: 1, roomsPerCoach: 2, roomType: 'ECONOMY_BED', bedsPerRoom: 6, totalBeds: 12, + seats: [ + { seat_id: 'C1-C1-R1-S1', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'LOWER', sequence_number: 1, status: 'AVAILABLE' }, + { seat_id: 'C1-C1-R1-S2', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'MIDDLE', sequence_number: 2, status: 'AVAILABLE' }, + { seat_id: 'C1-C1-R1-S3', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'UPPER', sequence_number: 3, status: 'AVAILABLE' }, + { seat_id: 'C1-C1-R1-S4', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'LOWER', sequence_number: 4, status: 'AVAILABLE' }, + { seat_id: 'C1-C1-R1-S5', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'MIDDLE', sequence_number: 5, status: 'AVAILABLE' }, + { seat_id: 'C1-C1-R1-S6', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'UPPER', sequence_number: 6, status: 'AVAILABLE' }, + ], + }, + }, + }) + generateSeatMap(@Body() dto: GenerateSeatMapDto) { + return this.service.generateSeatMapPreview(dto); + } + @Get('analytics') @ApiOperation({ summary: 'Fleet analytics and occupancy metrics' }) @ApiResponse({ status: 200, description: 'Occupancy statistics' }) diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts index 609afb4e5..34579c627 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts @@ -12,9 +12,20 @@ export class CreateTrainDto { export class CreateCoachDto { @ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string; @ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string; - @ApiProperty({ example: '2+2', description: 'Seat arrangement (e.g., "2+2", "3+2")' }) @IsString() arrangement: string; - @ApiProperty({ example: 60, description: 'Total seat capacity' }) @IsInt() capacity: number; + @ApiProperty({ example: '2+2', description: 'Seat arrangement for regular coaches (e.g., "2+2", "3+2"). Ignored for bed coaches.' }) @IsString() arrangement: string; + @ApiProperty({ example: 60, description: 'Total seat/bed capacity' }) @IsInt() capacity: number; @ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string; + @ApiPropertyOptional({ + enum: ['ECONOMY_BED', 'VIP_BED'], + description: 'Bed coach category. Set to generate bed/sleeper compartments instead of regular seats. Overrides name-based detection.', + example: 'VIP_BED', + }) + @IsOptional() @IsString() bedCategory?: 'ECONOMY_BED' | 'VIP_BED'; + @ApiPropertyOptional({ + example: 4, + description: 'Beds per compartment/room. Must be even (split equally left/right). Defaults: VIP_BED=4, ECONOMY_BED=6. Only applies when bedCategory is set.', + }) + @IsOptional() @IsInt() bedsPerRoom?: number; } export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) { @@ -67,3 +78,14 @@ export class UpdateClassDto { @IsBoolean() isActive?: boolean; } + +export class GenerateSeatMapDto { + @ApiProperty({ example: 2, description: 'Number of coaches' }) + @IsInt() coachCount: number; + + @ApiProperty({ example: 9, description: 'Number of rooms (compartments) per coach' }) + @IsInt() roomsPerCoach: number; + + @ApiProperty({ enum: ['ECONOMY_BED', 'VIP_BED'], example: 'ECONOMY_BED', description: 'ECONOMY_BED = 6 beds/room (L/M/U × Left/Right), VIP_BED = 4 beds/room (L/U × Left/Right)' }) + @IsString() roomType: 'ECONOMY_BED' | 'VIP_BED'; +} diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index b7f4695a1..09a1b4ed3 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; -import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto'; +import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto'; import { SeatKind } from '@prisma/client'; // Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2] @@ -33,41 +33,97 @@ function isAisleCol(colIndex: number, groups: number[]): boolean { return false; } -function buildSeats(coachId: string, coachNumber: string, arrangement: string, capacity: number, seatClass?: string): SeatRow[] { +type BedCategory = 'ECONOMY_BED' | 'VIP_BED' | null; + +// Default beds per room for each category when not explicitly configured +const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = { + VIP_BED: 4, + ECONOMY_BED: 6, +}; + +// Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type +function detectBedCategory(coachTypeName: string): BedCategory { + const name = coachTypeName.toLowerCase(); + const isBed = name.includes('bed') || name.includes('sleeper') || name.includes('couchette'); + if (!isBed) return null; + if (name.includes('vip')) return 'VIP_BED'; + return 'ECONOMY_BED'; +} + +// Resolves bed type names per side from beds-per-side count: +// 2/side → ['LOWER','UPPER'] (VIP style) +// 3/side → ['LOWER','MIDDLE','UPPER'] (Economy style) +function resolveBedTypes(bedsPerSide: number): string[] { + if (bedsPerSide === 1) return ['LOWER']; + if (bedsPerSide === 2) return ['LOWER', 'UPPER']; + if (bedsPerSide === 3) return ['LOWER', 'MIDDLE', 'UPPER']; + return Array.from({ length: bedsPerSide }, (_, i) => { + if (i === 0) return 'LOWER'; + if (i === bedsPerSide - 1) return 'UPPER'; + return 'MIDDLE'; + }); +} + +// Generates the flat seat/bed list for a bed coach. +// Row = room number; col = position-relative label (L1, L2 … R1, R2 …). +function buildBedSeats( + coachId: string, + capacity: number, + bedsPerRoom: number, +): SeatRow[] { + const bedsPerSide = bedsPerRoom / 2; + const bedTypeNames = resolveBedTypes(bedsPerSide); + const layout: Array<{ position: 'LEFT' | 'RIGHT'; bedType: string }> = [ + ...bedTypeNames.map(bt => ({ position: 'LEFT' as const, bedType: bt })), + ...bedTypeNames.map(bt => ({ position: 'RIGHT' as const, bedType: bt })), + ]; + + const roomCount = Math.ceil(capacity / bedsPerRoom); + const seats: SeatRow[] = []; + let seatNumber = 1; + + for (let room = 1; room <= roomCount; room++) { + const posCount: Record = {}; + for (let slot = 0; slot < bedsPerRoom && seats.length < capacity; slot++) { + const { position, bedType } = layout[slot]; + posCount[position] = (posCount[position] ?? 0) + 1; + const col = `${position[0]}${posCount[position]}`; + seats.push({ + coachId, + row: room, + col, + seatNumber: `${seatNumber}`, + kind: SeatKind.STANDARD, + bedPosition: bedType.toLowerCase(), + isWindow: false, + isAisle: false, + }); + seatNumber++; + } + } + return seats; +} + +function buildRegularSeats(coachId: string, arrangement: string, capacity: number): SeatRow[] { const cols = seatCols(arrangement); const groups = parseArrangement(arrangement); const seats: SeatRow[] = []; let row = 1; let seatNumber = 1; let seatIndex = 0; - const isBedCoach = seatClass?.toLowerCase().includes('bed'); - const totalCols = cols.length; while (seatIndex < capacity) { for (let ci = 0; ci < cols.length && seatIndex < capacity; ci++) { const col = cols[ci]; - let bedPosition = null; - - // Set bedPosition for bed coaches based on ROW cycling (not seat number) - if (isBedCoach) { - if (totalCols === 3) { - // Economy bed (3-row cycle): upper, middle, lower - if (row % 3 === 1) bedPosition = 'upper'; - else if (row % 3 === 2) bedPosition = 'middle'; - else bedPosition = 'lower'; - } else if (totalCols === 2) { - // VIP bed (2-row cycle): upper, lower - bedPosition = row % 2 === 1 ? 'upper' : 'lower'; - } - } - seats.push({ coachId, row, col, seatNumber: `${seatNumber}`, kind: SeatKind.STANDARD, - bedPosition, + bedPosition: null, + isWindow: isWindowCol(ci, groups), + isAisle: isAisleCol(ci, groups), }); seatNumber++; seatIndex++; @@ -84,6 +140,8 @@ type SeatRow = { seatNumber: string; kind: SeatKind; bedPosition?: string | null; + isWindow?: boolean; + isAisle?: boolean; }; @Injectable() @@ -332,8 +390,20 @@ export class FleetService { }); if (dto.capacity > 0) { - const seatClass = coach.coachType?.name || ''; - const seats = buildSeats(coach.id, coach.number, dto.arrangement, dto.capacity, seatClass); + // dto.bedCategory takes priority; fall back to name-based detection + const bedCategory: BedCategory = dto.bedCategory ?? detectBedCategory(coach.coachType?.name || ''); + let seats: SeatRow[]; + + if (bedCategory) { + const bedsPerRoom = dto.bedsPerRoom ?? DEFAULT_BEDS_PER_ROOM[bedCategory]; + if (bedsPerRoom < 2 || bedsPerRoom % 2 !== 0) { + throw new BadRequestException('bedsPerRoom must be an even number ≥ 2'); + } + seats = buildBedSeats(coach.id, dto.capacity, bedsPerRoom); + } else { + seats = buildRegularSeats(coach.id, dto.arrangement, dto.capacity); + } + await this.prisma.seat.createMany({ data: seats }); } @@ -425,6 +495,50 @@ export class FleetService { return this.prisma.coachAssignment.delete({ where: { id } }); } + async generateSeatMapPreview(dto: GenerateSeatMapDto) { + const { coachCount, roomsPerCoach, roomType } = dto; + const bedsPerRoom = DEFAULT_BEDS_PER_ROOM[roomType]; + const bedTypeNames = resolveBedTypes(bedsPerRoom / 2); + const layout: Array<{ position: 'LEFT' | 'RIGHT'; bedType: string }> = [ + ...bedTypeNames.map(bt => ({ position: 'LEFT' as const, bedType: bt })), + ...bedTypeNames.map(bt => ({ position: 'RIGHT' as const, bedType: bt })), + ]; + const seats: object[] = []; + let globalSeq = 1; + + for (let c = 1; c <= coachCount; c++) { + const coachLabel = `C${c}`; + for (let r = 1; r <= roomsPerCoach; r++) { + const roomLabel = `R${r}`; + const posCount: Record = {}; + for (let s = 0; s < bedsPerRoom; s++) { + const { position, bedType } = layout[s]; + posCount[position] = (posCount[position] ?? 0) + 1; + seats.push({ + seat_id: `${coachLabel}-${roomLabel}-S${globalSeq}`, + coach_id: coachLabel, + room_id: `${coachLabel}-${roomLabel}`, + category: roomType, + position, + col: `${position[0]}${posCount[position]}`, + bed_type: bedType, + sequence_number: globalSeq, + status: 'AVAILABLE', + }); + globalSeq++; + } + } + } + return { + coachCount, + roomsPerCoach, + roomType, + bedsPerRoom, + totalBeds: seats.length, + seats, + }; + } + async getAnalytics() { const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([ this.prisma.train.count(), diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index d9fa4efd7..7453e1c06 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -835,7 +835,7 @@ export class PaymentsService { status: 'CONFIRMED', totalMinor: booking.totalMinor, currency: booking.currency, - }, + } as any, }); const journeySegments: any[] = []; diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 8914ca465..4d0784f23 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -12,19 +12,19 @@ export class SeatsController { // ── Seat Map ────────────────────────────────────────────────────────────── @Get('seatmap/:scheduleId') - @ApiOperation({ - summary: 'Get seat map with real-time availability by class', - description: `Returns seat map for a schedule with availability by seat class: -- Economy Regular -- Economy Bed -- VIP Bed - -Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED` + @ApiOperation({ + summary: 'Get seat map filtered by coach type', + description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches.` }) @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) - @ApiQuery({ name: 'coachId', required: false, description: 'Filter by coach UUID' }) - @ApiResponse({ status: 200, description: 'Returns coaches with seats and seat class info' }) - getSeatMap(@Param('scheduleId') scheduleId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(scheduleId, coachId); } + @ApiQuery({ name: 'coachTypeId', required: false, description: 'Filter by CoachType UUID — returns all coaches of that type (e.g. all Economy coaches)' }) + @ApiResponse({ status: 200, description: 'List of coaches of the given type with their seats and availability' }) + getSeatMap( + @Param('scheduleId') scheduleId: string, + @Query('coachTypeId') coachTypeId?: string, + ) { + return this.service.getSeatMap(scheduleId, coachTypeId); + } // ── Hold / Release ──────────────────────────────────────────────────────── @Get('holds') diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 0dbc1c653..109adf178 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -11,9 +11,18 @@ export class SeatsService { private segmentsService: SegmentsService, ) {} - async getSeatMap(scheduleId: string, coachId?: string, originStationId?: string, destinationStationId?: string) { + async getSeatMap(scheduleId: string, coachTypeId?: string) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { originStationId: true, destinationStationId: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + const assignments = await this.prisma.coachAssignment.findMany({ - where: { scheduleId, ...(coachId ? { coachId } : {}) }, + where: { + scheduleId, + ...(coachTypeId ? { coach: { coachTypeId } } : {}), + }, include: { coach: { include: { @@ -26,48 +35,110 @@ export class SeatsService { }); const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id)); - const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, originStationId, destinationStationId); + const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, schedule.originStationId, schedule.destinationStationId); return { coaches: assignments.map((a) => { - const allSeats = a.coach.seats; + const allSeats = a.coach.seats; + const coachTypeName = a.coach.coachType?.name ?? ''; const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name); + const isBedCoach = this.isBedCoach(coachTypeName); + // Compute actual beds-per-room from first room to correctly identify VIP (4) vs Economy (6) + const bedsPerRoom = isBedCoach + ? allSeats.filter((s: any) => s.row === (allSeats[0] as any)?.row).length + : 0; + const bedCategory = isBedCoach ? this.getBedCategory(coachTypeName, bedsPerRoom) : null; - return { - id: a.coach.id, - assignmentId: a.id, - coachNumber: a.coach.number, - label: a.coach.number, - mode: a.coach.status, - name: `Coach ${a.coach.number}`, - seatClasses: seatClassNames, - seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard', + const mappedSeats = allSeats.map((s: any) => ({ + id: s.id, + seatNumber: 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, + // Bed-specific fields + ...(isBedCoach ? { + room_id: `${a.coach.id}-R${s.row}`, + category: bedCategory, + position: this.colToPosition(s.col), + bed_type: this.bedPositionToType(s.bedPosition), + bedPosition: s.bedPosition, + } : { + bedPosition: s.bedPosition, + }), + })); + + const base = { + id: a.coach.id, + assignmentId: a.id, + coachNumber: a.coach.number, + label: a.coach.number, + mode: a.coach.status, + name: `Coach ${a.coach.number}`, + coachTypeName, + isBedCoach, + bedCategory, + seatClasses: seatClassNames, + seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard', positionNumber: a.positionNumber, seatArrangement: a.coach.arrangement, - totalSeats: a.coach.capacity, - seats: allSeats.map((s) => ({ - id: s.id, - seatNumber: s.seatNumber, - number: s.seatNumber, - label: s.seatNumber, - status: effectiveStatuses.get(s.id) ?? s.status, - kind: s.kind, - row: s.row, - col: s.col, - isWindow: s.isWindow, - isAisle: s.isAisle, - bedPosition: s.bedPosition, - coach: { - id: a.coach.id, - coachNumber: a.coach.number, - label: a.coach.number, - }, - })), + totalSeats: a.coach.capacity, }; + + if (isBedCoach) { + // Group seats into rooms; row = room number + const roomMap = new Map(); + for (const seat of mappedSeats) { + if (!roomMap.has(seat.row)) roomMap.set(seat.row, []); + roomMap.get(seat.row)!.push(seat); + } + const rooms = Array.from(roomMap.entries()) + .sort(([a], [b]) => a - b) + .map(([roomNumber, beds]) => ({ + room_id: `${a.coach.id}-R${roomNumber}`, + roomNumber, + category: bedCategory, + totalBeds: beds.length, + beds, + })); + return { ...base, rooms, seats: mappedSeats }; + } + + return { ...base, seats: mappedSeats }; }), }; } + private isBedCoach(coachTypeName: string): boolean { + const n = coachTypeName.toLowerCase(); + return n.includes('bed') || n.includes('sleeper') || n.includes('couchette'); + } + + private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' { + const n = coachTypeName.toLowerCase(); + // Explicit VIP name check first + if (n.includes('vip')) return 'VIP_BED'; + // Fall back to actual beds-per-room count: 4 = VIP, 6 = Economy + if (bedsPerRoom === 4) return 'VIP_BED'; + return 'ECONOMY_BED'; + } + + // col format: L1, L2, L3, R1, R2, R3 + private colToPosition(col: string): 'LEFT' | 'RIGHT' { + return col?.startsWith('R') ? 'RIGHT' : 'LEFT'; + } + + private bedPositionToType(bedPosition: string | null): 'LOWER' | 'MIDDLE' | 'UPPER' | null { + if (!bedPosition) return null; + const map: Record = { + lower: 'LOWER', middle: 'MIDDLE', upper: 'UPPER', + }; + return map[bedPosition.toLowerCase()] ?? null; + } + async resolveEffectiveStatuses( scheduleId: string, seatIds: string[], @@ -426,7 +497,7 @@ export class SeatsService { // Delete the Journey (and its JourneySegments) scoped to this booking. async releaseSeats(bookingId: string) { - await this.prisma.journey.deleteMany({ where: { bookingId } }); + await this.prisma.journey.deleteMany({ where: { bookingId } as any }); } async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise { diff --git a/apps/edr-passenger-web/portal/public/bed.png b/apps/edr-passenger-web/portal/public/bed.png new file mode 100644 index 0000000000000000000000000000000000000000..0fc5c6d2cb0989fed2ed25f44bb31950615a5bc2 GIT binary patch literal 14282 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 literal 0 HcmV?d00001 diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 1e906be2c..5669240ad 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -142,8 +142,8 @@ export default function ResultsPage() { ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) : outboundSchedules.length > 0; - const handleSelectCoachType = (scheduleId: string, coachId: string, coachTypeCode: string, coachTypeName: string) => { - setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachId, code: coachTypeCode, name: coachTypeName } })); + const handleSelectCoachType = (scheduleId: string, coachTypeCode: string, coachTypeName: string) => { + setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeCode, code: coachTypeCode, name: coachTypeName } })); }; const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { @@ -156,7 +156,7 @@ export default function ResultsPage() { } // Find the coach type to get pricing info - const coachType = schedule.coachTypes?.find(ct => ct.coachId === selectedCoachType.id); + const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.id); const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0; const hours = Math.floor((schedule.durationMinutes || 0) / 60); @@ -175,7 +175,7 @@ export default function ResultsPage() { baseFareChild: minFare, selectedSeatClass: selectedCoachType.name, selectedSeatClassName: selectedCoachType.name, - selectedCoachId: selectedCoachType.id, + selectedCoachTypeId: selectedCoachType.id, selectedCoachTypeCode: selectedCoachType.code, selectedCoachTypeName: selectedCoachType.name, }; @@ -533,14 +533,14 @@ export default function ResultsPage() { {coachTypes.length > 0 ? (
{coachTypes.map((coachType: any, index: number) => { - const isSelected = selectedCoachType?.id === coachType.coachId; + const isSelected = selectedCoachType?.id === coachType.coachTypeCode; const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; const CoachIcon = getCoachIcon(coachType.coachTypeName); return ( -
+ ); }); -SeatButton.displayName = 'SeatButton'; +BedCard.displayName = "BedCard"; + +const SeatButton = memo( + ({ + seat, + isSelected, + onToggle, + isBedCoach, + bedLabel, + coachSeatClass, + }: any) => { + const seatLabel = seat.number || seat.label || seat.seatNumber || "?"; + const bedWidth = "w-24"; + const width = isBedCoach ? bedWidth : "w-10"; + + return ( +
+ +
+ ); + }, +); + +SeatButton.displayName = "SeatButton"; export default function SeatsPage() { const router = useRouter(); - const { selectedSchedule, outboundSchedule, inboundSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore(); + const { + selectedSchedule, + outboundSchedule, + inboundSchedule, + passengers, + setSeatHold, + setPassengers, + searchCriteria, + bookingId, + } = useBookingStore(); const [selectedSeats, setSelectedSeats] = useState([]); const [selectedCoach, setSelectedCoach] = useState(null); - const [currentJourneyType, setCurrentJourneyType] = useState<'outbound' | 'inbound'>('outbound'); + const [currentJourneyType, setCurrentJourneyType] = useState< + "outbound" | "inbound" + >("outbound"); const [modalState, setModalState] = useState({ isOpen: false, - title: '', - message: '', - type: 'info' as 'warning' | 'error' | 'success' | 'info', + title: "", + message: "", + type: "info" as "warning" | "error" | "success" | "info", }); - const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; - const currentSchedule = isRoundTrip && currentJourneyType === 'inbound' ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule); - const coachId = (currentSchedule as any)?.selectedCoachId; + const isRoundTrip = searchCriteria?.tripType === "ROUND_TRIP"; + const currentSchedule = + isRoundTrip && currentJourneyType === "inbound" + ? inboundSchedule + : isRoundTrip + ? outboundSchedule + : selectedSchedule; + const coachTypeId = (currentSchedule as any)?.selectedCoachTypeId; const coachTypeCode = (currentSchedule as any)?.selectedCoachTypeCode; - const { data: seatMapData, isLoading, error } = useQuery({ - queryKey: ['seatmap', currentSchedule?.id, coachId, currentJourneyType], + const { + data: seatMapData, + isLoading, + error, + } = useQuery({ + queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType], queryFn: async () => { - const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachId=${coachId}`; - console.log('🪑 Seatmap Request:', { + const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeCode}`; + console.log("🪑 Seatmap Request:", { endpoint, - scheduleId: currentSchedule?.id, - coachId, - coachTypeCode, - currentJourneyType, }); - + const response = await apiClient.get(endpoint); - - console.log('✅ Seatmap Response:', { + + console.log("✅ Seatmap Response:", { endpoint, fullResponse: response, dataCoaches: (response as any)?.data?.coaches?.length || 0, rootCoaches: (response as any)?.coaches?.length || 0, }); - + const finalData = (response as any)?.data || response; - console.log('🎯 Final data structure:', finalData); + console.log("🎯 Final data structure:", finalData); return finalData; }, - enabled: !!currentSchedule?.id && !!coachId, + enabled: !!currentSchedule?.id && !!coachTypeId, }); const holdMutation = useMutation({ mutationFn: async (seatIds: string[]) => { - const passengersForHold = passengers.slice(0, seatIds.length).map((_, i) => ({ - passengerId: `temp-${Date.now()}-${i}`, - seatId: seatIds[i], - })); - + const passengersForHold = passengers + .slice(0, seatIds.length) + .map((_, i) => ({ + passengerId: `temp-${Date.now()}-${i}`, + seatId: seatIds[i], + })); + // For round trip inbound, swap origin and destination - const isInbound = isRoundTrip && currentJourneyType === 'inbound'; - const originId = isInbound ? searchCriteria?.destinationStationId : searchCriteria?.originStationId; - const destinationId = isInbound ? searchCriteria?.originStationId : searchCriteria?.destinationStationId; - + const isInbound = isRoundTrip && currentJourneyType === "inbound"; + const originId = isInbound + ? searchCriteria?.destinationStationId + : searchCriteria?.originStationId; + const destinationId = isInbound + ? searchCriteria?.originStationId + : searchCriteria?.destinationStationId; + return apiClient.post(`/seats/hold`, { scheduleId: currentSchedule?.id, originStationId: originId, @@ -111,13 +189,13 @@ export default function SeatsPage() { }); }, onSuccess: (data: any) => { - const isInbound = isRoundTrip && currentJourneyType === 'inbound'; + const isInbound = isRoundTrip && currentJourneyType === "inbound"; if (isInbound) { // Merge the return hold into the existing outbound hold const current = useBookingStore.getState().seatHold; setSeatHold({ - holdId: current?.holdId || '', - expiresAt: current?.expiresAt || '', + holdId: current?.holdId || "", + expiresAt: current?.expiresAt || "", returnHoldId: data.holdId || data.id, returnExpiresAt: data.expiresAt, }); @@ -134,27 +212,33 @@ export default function SeatsPage() { mutationFn: async (seatIds: string[]) => { return Promise.all( seatIds.map((seatId) => - apiClient.patch(`/seats/${seatId}`, { - status: 'BOOKED', - }).catch(() => null) - ) + apiClient + .patch(`/seats/${seatId}`, { + status: "BOOKED", + }) + .catch(() => null), + ), ); }, }); const coaches = useMemo(() => { - const rawCoaches = (seatMapData as any)?.coaches || (seatMapData as any)?.data?.coaches || []; - console.log('📦 Raw coaches data:', { + const rawCoaches = + (seatMapData as any)?.coaches || + (seatMapData as any)?.data?.coaches || + []; + console.log("📦 Raw coaches data:", { fromRoot: (seatMapData as any)?.coaches?.length || 0, fromData: (seatMapData as any)?.data?.coaches?.length || 0, using: rawCoaches.length, - seatMapData + hasRooms: rawCoaches.some((c: any) => c.rooms?.length > 0), + sampleRooms: rawCoaches[0]?.rooms?.length || 0, }); return rawCoaches; }, [seatMapData]); - + const filteredCoaches = useMemo(() => { - console.log('🔍 Filtering coaches:', { + console.log("🔍 Filtering coaches:", { totalCoaches: coaches.length, selectedSeatClass: currentSchedule?.selectedSeatClass, coachesData: coaches.map((c: any) => ({ @@ -163,90 +247,145 @@ export default function SeatsPage() { label: c.label, seatClass: c.seatClass, seatClasses: c.seatClasses, - seatsCount: c.seats?.length || 0 - })) + seatsCount: c.seats?.length || 0, + })), }); - const coachesWithSeats = coaches.filter((c: any) => c.seats && c.seats.length > 0); - + const coachesWithSeats = coaches.filter( + (c: any) => c.seats && c.seats.length > 0, + ); + if (!currentSchedule?.selectedSeatClass) { - console.log('✅ No filter applied, returning all coaches:', coachesWithSeats.length); + console.log( + "✅ No filter applied, returning all coaches:", + coachesWithSeats.length, + ); return coachesWithSeats; } - - console.log('✅ No seat class filter - returning all coaches with seats:', coachesWithSeats.length); + + console.log( + "✅ No seat class filter - returning all coaches with seats:", + coachesWithSeats.length, + ); return coachesWithSeats; }, [coaches, currentSchedule?.selectedSeatClass]); - useEffect(() => { - if (filteredCoaches.length > 0 && !selectedCoach) { - setSelectedCoach(filteredCoaches[0].id); - } - }, [filteredCoaches, selectedCoach]); - const selectedCoachData = useMemo(() => filteredCoaches.find((c: any) => c.id === selectedCoach), [filteredCoaches, selectedCoach]); - const allSeats = useMemo(() => selectedCoachData?.seats || [], [selectedCoachData]); - + const selectedCoachData = useMemo( + () => filteredCoaches.find((c: any) => c.id === selectedCoach), + [filteredCoaches, selectedCoach], + ); + const allSeats = useMemo( + () => selectedCoachData?.seats || [], + [selectedCoachData], + ); + const getBedPosition = (selectedClass: string): string | null => { const lowerClass = selectedClass.toLowerCase(); - if (lowerClass.includes('upper')) return 'upper'; - if (lowerClass.includes('middle')) return 'middle'; - if (lowerClass.includes('lower')) return 'lower'; + if (lowerClass.includes("upper")) return "upper"; + if (lowerClass.includes("middle")) return "middle"; + if (lowerClass.includes("lower")) return "lower"; return null; }; const validSeats = useMemo(() => { + // If coach has rooms, extract all beds from rooms + if (selectedCoachData?.rooms?.length > 0) { + const allBeds: any[] = []; + selectedCoachData.rooms.forEach((room: any) => { + if (room.beds) { + allBeds.push(...room.beds); + } + }); + + let beds = allBeds.filter((s: any) => { + const seatLabel = s.label || s.number || s.seatNumber || ""; + return seatLabel && !seatLabel.startsWith("-"); + }); + + const isBedCoach = + selectedCoachData?.seatClass?.toLowerCase().includes("bed") || + selectedCoachData?.mode?.toLowerCase().includes("bed"); + + if (isBedCoach && currentSchedule?.selectedSeatClass) { + const selectedBedPosition = getBedPosition( + currentSchedule.selectedSeatClass, + ); + if (selectedBedPosition) { + beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition); + } + } + + return beds; + } + + // Fallback to old seat structure let seats = allSeats.filter((s: any) => { - const seatLabel = s.label || s.number || s.seatNumber || ''; - return seatLabel && !seatLabel.startsWith('-'); + const seatLabel = s.label || s.number || s.seatNumber || ""; + return seatLabel && !seatLabel.startsWith("-"); }); - const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed'); - + const isBedCoach = + selectedCoachData?.seatClass?.toLowerCase().includes("bed") || + selectedCoachData?.mode?.toLowerCase().includes("bed"); + if (isBedCoach && currentSchedule?.selectedSeatClass) { - const selectedBedPosition = getBedPosition(currentSchedule.selectedSeatClass); + const selectedBedPosition = getBedPosition( + currentSchedule.selectedSeatClass, + ); if (selectedBedPosition) { seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition); } } - + return seats; }, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]); - const handleSeatClick = useCallback((seatId: string) => { - setSelectedSeats(prev => { - if (prev.length < passengers.length) { - return [...prev, seatId]; - } else { - return [seatId]; - } - }); - }, [passengers.length]); + const handleSeatClick = useCallback( + (seatId: string) => { + setSelectedSeats((prev) => { + if (prev.length < passengers.length) { + return [...prev, seatId]; + } else { + return [seatId]; + } + }); + }, + [passengers.length], + ); const handleContinue = async () => { - if (isRoundTrip && currentJourneyType === 'outbound') { + if (isRoundTrip && currentJourneyType === "outbound") { if (selectedSeats.length > 0) { try { await holdMutation.mutateAsync(selectedSeats); const updatedPassengers = passengers.map((p, i) => { - const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]); + const seatData = validSeats?.find( + (s: any) => s.id === selectedSeats[i], + ); return { ...p, outboundSeatId: selectedSeats[i], - outboundSeatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '', + outboundSeatNumber: + seatData?.number || + seatData?.label || + seatData?.seatNumber || + "", }; }); setPassengers(updatedPassengers); } catch (error: any) { setModalState({ isOpen: true, - title: 'Seat Hold Failed', - message: error?.response?.data?.message || 'Failed to hold seats. Please try again.', - type: 'error', + title: "Seat Hold Failed", + message: + error?.response?.data?.message || + "Failed to hold seats. Please try again.", + type: "error", }); return; } } - setCurrentJourneyType('inbound'); + setCurrentJourneyType("inbound"); setSelectedSeats([]); setSelectedCoach(null); return; @@ -256,49 +395,61 @@ export default function SeatsPage() { try { await holdMutation.mutateAsync(selectedSeats); const updatedPassengers = passengers.map((p, i) => { - const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]); - if (isRoundTrip && currentJourneyType === 'inbound') { + const seatData = validSeats?.find( + (s: any) => s.id === selectedSeats[i], + ); + if (isRoundTrip && currentJourneyType === "inbound") { return { ...p, inboundSeatId: selectedSeats[i], - inboundSeatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '', + inboundSeatNumber: + seatData?.number || + seatData?.label || + seatData?.seatNumber || + "", }; } return { ...p, seatId: selectedSeats[i], - seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '', + seatNumber: + seatData?.number || seatData?.label || seatData?.seatNumber || "", }; }); setPassengers(updatedPassengers); } catch (error: any) { setModalState({ isOpen: true, - title: 'Seat Hold Failed', - message: error?.response?.data?.message || 'Failed to hold seats. Please try again.', - type: 'error', + title: "Seat Hold Failed", + message: + error?.response?.data?.message || + "Failed to hold seats. Please try again.", + type: "error", }); return; } } - router.push('/booking/review'); + router.push("/booking/review"); }; const handleAutoAssign = async () => { - const availableSeats = validSeats?.filter((s: any) => s.status === 'AVAILABLE') || []; + const availableSeats = + validSeats?.filter((s: any) => s.status === "AVAILABLE") || []; if (availableSeats.length < passengers.length) { setModalState({ isOpen: true, - title: 'Not Enough Seats', + title: "Not Enough Seats", message: `Only ${availableSeats.length} seat(s) available in this coach, but you need ${passengers.length} seat(s). Please select another coach.`, - type: 'warning', + type: "warning", }); return; } - - const autoSelectedSeats = availableSeats.slice(0, passengers.length).map((s: any) => s.id); + + const autoSelectedSeats = availableSeats + .slice(0, passengers.length) + .map((s: any) => s.id); setSelectedSeats(autoSelectedSeats); - + try { await holdMutation.mutateAsync(autoSelectedSeats); const updatedPassengers = passengers.map((p, i) => { @@ -306,134 +457,430 @@ export default function SeatsPage() { return { ...p, seatId: autoSelectedSeats[i], - seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '', + seatNumber: + seatData?.number || seatData?.label || seatData?.seatNumber || "", }; }); setPassengers(updatedPassengers); - router.push('/booking/review'); + router.push("/booking/review"); } catch (error: any) { - console.error('Failed to hold seats:', error); + console.error("Failed to hold seats:", error); setModalState({ isOpen: true, - title: 'Seat Hold Failed', - message: error?.response?.data?.message || 'Failed to hold seats. Please try again.', - type: 'error', + title: "Seat Hold Failed", + message: + error?.response?.data?.message || + "Failed to hold seats. Please try again.", + type: "error", }); } }; const handleBackToPassengers = () => { - router.push('/booking/passengers'); + router.push("/booking/passengers"); }; useEffect(() => { if (isRoundTrip) { if (!outboundSchedule || !inboundSchedule || !passengers.length) { - router.push('/booking/search'); + router.push("/booking/search"); } } else { if (!selectedSchedule || !passengers.length) { - router.push('/booking/search'); + router.push("/booking/search"); } } - }, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, router]); + }, [ + isRoundTrip, + selectedSchedule, + outboundSchedule, + inboundSchedule, + passengers.length, + router, + ]); useEffect(() => { if (bookingId && selectedSeats.length > 0) { bookSeatsMutation.mutate(selectedSeats); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [bookingId]); - const parseSeatArrangement = (arrangement: string | null, seatClasses?: string[]): number[] => { + const parseSeatArrangement = ( + arrangement: string | null, + seatClasses?: string[], + ): number[] => { if (!arrangement) return [2, 2]; - + // Check if this is a bed coach based on seat classes - const isBedCoach = seatClasses?.some(sc => sc?.toLowerCase().includes('bed')); - + const isBedCoach = seatClasses?.some((sc) => + sc?.toLowerCase().includes("bed"), + ); + if (isBedCoach) { // For bed coaches, arrangement like "3+0" means 3 beds stacked vertically // We want to render them as single column, so return [1] - const parts = arrangement.split('+').map(p => parseInt(p.trim())).filter(n => !isNaN(n) && n > 0); + const parts = arrangement + .split("+") + .map((p) => parseInt(p.trim())) + .filter((n) => !isNaN(n) && n > 0); return parts.length > 0 ? [Math.max(...parts)] : [3]; } - + // For regular seats, parse normally (e.g., "3+2" -> [3, 2]) - const parts = arrangement.split('+').map(p => parseInt(p.trim())).filter(n => !isNaN(n) && n > 0); + const parts = arrangement + .split("+") + .map((p) => parseInt(p.trim())) + .filter((n) => !isNaN(n) && n > 0); return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2]; }; const getBedLabel = (bedPosition: string | null): string => { - if (bedPosition === 'upper') return 'U'; - if (bedPosition === 'middle') return 'M'; - if (bedPosition === 'lower') return 'L'; - return ''; + 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, coach.seatClasses || [coach.seatClass]); + const arrangement = parseSeatArrangement( + coach.seatArrangement, + coach.seatClasses || [coach.seatClass], + ); if (validSeats.length === 0) { return
No seats
; } const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); - const seatClassStr = typeof selectedCoachData?.seatClass === 'string' ? selectedCoachData.seatClass : (selectedCoachData?.seatClass?.name || ''); + const seatClassStr = + typeof selectedCoachData?.seatClass === "string" + ? selectedCoachData.seatClass + : selectedCoachData?.seatClass?.name || ""; // Bed coach with bed positions (Upper, Middle, Lower) if (isBedCoach && hasBedPositionData) { - // Group by the base seat number (column), not by row - // For beds, seats with same number but different positions should be grouped together + // Check if this is VIP_BED or ECONOMY_BED based on room data + const rooms = (coach as any).rooms || []; + const hasRooms = rooms.length > 0; + + if (hasRooms) { + // Room-based layout (VIP_BED with 4 beds, ECONOMY_BED with 6 beds) + return ( +
+ {rooms.map((room: any) => { + const isVipBed = + room.category === "VIP_BED" || room.totalBeds === 4; + const isEconomyBed = + room.category === "ECONOMY_BED" || room.totalBeds === 6; + + // Sort beds by position and column + const sortedBeds = [...(room.beds || [])].sort((a, b) => { + const posOrder = { upper: 3, middle: 2, lower: 1 }; + const posA = + posOrder[a.bedPosition as keyof typeof posOrder] || 0; + const posB = + posOrder[b.bedPosition as keyof typeof posOrder] || 0; + if (posA !== posB) return posA - posB; + return (a.col || "").localeCompare(b.col || ""); + }); + + return ( +
+ {/* Room Header */} +
+
+

+ Room {room.roomNumber} +

+

+ {room.category === "VIP_BED" + ? "VIP BED" + : room.category === "ECONOMY_BED" + ? "ECONOMY BED" + : room.category} +

+
+
+ {room.totalBeds} beds +
+
+ + {/* Legend */} +
+
+
+ + Available + +
+
+
+ + Booked + +
+
+ + {/* VIP BED Layout (2x2 grid) */} + {isVipBed && ( +
+ {/* Upper Berths */} +
+
+ Upper +
+ Berth +
+
+ {sortedBeds + .filter((b: any) => b.bedPosition === "upper") + .map((bed: any) => ( +
+ +
+ ))} + {/* Vertical aisle indicator */} +
+
+
+ + {/* Lower Berths */} +
+
+ Lower +
+ Berth +
+
+ {sortedBeds + .filter((b: any) => b.bedPosition === "lower") + .map((bed: any) => ( +
+ +
+ ))} + {/* Vertical aisle indicator */} +
+
+
+
+ )} + + {/* ECONOMY BED Layout (3 rows: Lower, Middle, Upper on both sides) */} + {isEconomyBed && ( +
+ {/* Lower Berths */} +
+
+ Lower +
+ Berth +
+
+ {sortedBeds + .filter( + (b: any) => + b.bedPosition === "lower" && + (b.col === "A" || b.position === "LEFT"), + ) + .map((bed: any) => ( + + ))} + {/* Vertical aisle */} +
+ {sortedBeds + .filter( + (b: any) => + b.bedPosition === "lower" && + b.col !== "A" && + b.position !== "LEFT", + ) + .map((bed: any) => ( + + ))} +
+
+ + {/* Middle Berths */} +
+
+ Middle +
+ Berth +
+
+ {sortedBeds + .filter( + (b: any) => + b.bedPosition === "middle" && + (b.col === "B" || b.position === "LEFT"), + ) + .map((bed: any) => ( + + ))} + {/* Vertical aisle */} +
+ {sortedBeds + .filter( + (b: any) => + b.bedPosition === "middle" && + b.col !== "B" && + b.position !== "LEFT", + ) + .map((bed: any) => ( + + ))} +
+
+ + {/* Upper Berths */} +
+
+ Upper +
+ Berth +
+
+ {sortedBeds + .filter( + (b: any) => + b.bedPosition === "upper" && + (b.col === "C" || b.position === "LEFT"), + ) + .map((bed: any) => ( + + ))} + {/* Vertical aisle */} +
+ {sortedBeds + .filter( + (b: any) => + b.bedPosition === "upper" && + b.col !== "C" && + b.position !== "LEFT", + ) + .map((bed: any) => ( + + ))} +
+
+
+ )} +
+ ); + })} +
+ ); + } + + // Fallback: Old layout for beds without room data const seatGroups = new Map(); - + for (const seat of validSeats) { - const baseNumber = seat.seatNumber || seat.number || seat.label || ''; + const baseNumber = seat.seatNumber || seat.number || seat.label || ""; if (!seatGroups.has(baseNumber)) { seatGroups.set(baseNumber, []); } seatGroups.get(baseNumber)!.push(seat); } - // Sort groups by seat number - const sortedGroups = Array.from(seatGroups.entries()) - .sort(([a], [b]) => { - const numA = parseInt(a) || 0; - const numB = parseInt(b) || 0; - return numA - numB; - }); + const sortedGroups = Array.from(seatGroups.entries()).sort(([a], [b]) => { + const numA = parseInt(a) || 0; + const numB = parseInt(b) || 0; + return numA - numB; + }); return (
{sortedGroups.map(([seatNumber, beds], idx) => { const shouldFlipIcon = idx % 2 === 0; - - // Order: lower, middle, upper (bottom to top) - const orderedBeds = ['lower', 'middle', 'upper'] - .map(pos => beds.find(seat => seat.bedPosition === pos)) - .filter(seat => seat !== undefined); - + + const orderedBeds = ["lower", "middle", "upper"] + .map((pos) => beds.find((seat) => seat.bedPosition === pos)) + .filter((seat) => seat !== undefined); + if (orderedBeds.length === 0) return null; - + return ( -
+
{shouldFlipIcon && (
{orderedBeds.map((seat: any) => { - const seatLabel = seat.seatNumber || seat.number || seat.label || ''; - const bedLabelFull = seat.bedPosition ? ( - seat.bedPosition === 'upper' ? 'Upper' : - seat.bedPosition === 'middle' ? 'Middle' : 'Lower' - ) : ''; + const seatLabel = + seat.seatNumber || seat.number || seat.label || ""; + const bedLabelFull = seat.bedPosition + ? seat.bedPosition === "upper" + ? "Upper" + : seat.bedPosition === "middle" + ? "Middle" + : "Lower" + : ""; return ( -
- {seatLabel ? `${seatLabel} ${bedLabelFull}` : ''} +
+ {seatLabel ? `${seatLabel} ${bedLabelFull}` : ""}
); })}
)} - +
{orderedBeds.map((seat: any) => ( ))}
- + {!shouldFlipIcon && (
{orderedBeds.map((seat: any) => { - const seatLabel = seat.seatNumber || seat.number || seat.label || ''; - const bedLabelFull = seat.bedPosition ? ( - seat.bedPosition === 'upper' ? 'Upper' : - seat.bedPosition === 'middle' ? 'Middle' : 'Lower' - ) : ''; + const seatLabel = + seat.seatNumber || seat.number || seat.label || ""; + const bedLabelFull = seat.bedPosition + ? seat.bedPosition === "upper" + ? "Upper" + : seat.bedPosition === "middle" + ? "Middle" + : "Lower" + : ""; return ( -
- {seatLabel ? `${seatLabel} ${bedLabelFull}` : ''} +
+ {seatLabel ? `${seatLabel} ${bedLabelFull}` : ""}
); })} @@ -489,7 +943,7 @@ export default function SeatsPage() {
{rows.map((rowSeats: any[], rowIdx: number) => { const groups: any[][] = []; - + // Split seats into groups based on arrangement if (arrangement.length === 1) { // Single group (all seats together) @@ -497,8 +951,12 @@ export default function SeatsPage() { } else { // Multiple groups with aisle separation arrangement.forEach((_groupSize, groupIdx) => { - const startIdx = arrangement.slice(0, groupIdx).reduce((sum, size) => sum + size, 0); - const endIdx = arrangement.slice(0, groupIdx + 1).reduce((sum, size) => sum + size, 0); + const startIdx = arrangement + .slice(0, groupIdx) + .reduce((sum, size) => sum + size, 0); + const endIdx = arrangement + .slice(0, groupIdx + 1) + .reduce((sum, size) => sum + size, 0); const currentGroup = rowSeats.slice(startIdx, endIdx); if (currentGroup.length > 0) groups.push(currentGroup); }); @@ -513,11 +971,18 @@ export default function SeatsPage() { {shouldFlipArmchair && (
{groups.map((group, gIdx) => ( -
+
{group.map((seat: any) => { - const seatLabel = seat.label || seat.number || seat.seatNumber || ''; + const seatLabel = + seat.label || seat.number || seat.seatNumber || ""; return ( -
+
{seatLabel}
); @@ -547,11 +1012,18 @@ export default function SeatsPage() { {!shouldFlipArmchair && (
{groups.map((group, gIdx) => ( -
+
{group.map((seat: any) => { - const seatLabel = seat.label || seat.number || seat.seatNumber || ''; + const seatLabel = + seat.label || seat.number || seat.seatNumber || ""; return ( -
+
{seatLabel}
); @@ -561,7 +1033,9 @@ export default function SeatsPage() {
)} - {showSpacing &&
} + {showSpacing && ( +
+ )}
); })} @@ -569,16 +1043,25 @@ export default function SeatsPage() { ); }; - if (isRoundTrip ? (!outboundSchedule || !inboundSchedule || !passengers.length) : (!selectedSchedule || !passengers.length)) return null; + if ( + isRoundTrip + ? !outboundSchedule || !inboundSchedule || !passengers.length + : !selectedSchedule || !passengers.length + ) + return null; - if (!coachId) { + if (!coachTypeId) { return (
-

No coach selected

-

Please go back and select a coach type

+

+ No coach type selected +

+

+ Please go back and select a coach type +

- {isRoundTrip - ? (currentJourneyType === 'outbound' ? 'Select Outbound Seats' : 'Select Return Seats') - : 'Select Seats' - } + {isRoundTrip + ? currentJourneyType === "outbound" + ? "Select Outbound Seats" + : "Select Return Seats" + : "Select Seats"}

{selectedSeats.length}/{passengers.length} @@ -712,92 +1232,261 @@ export default function SeatsPage() {
- {/* ── Seat map panel ── */}
{isLoading ? (
-

Loading seat map...

+

+ Loading seat map... +

) : error ? (
-

Error loading seats

-

{(error as any)?.message || 'Please try again'}

+

+ Error loading seats +

+

+ {(error as any)?.message || "Please try again"} +

) : filteredCoaches.length === 0 ? (
-

No coaches available for {selectedSchedule?.selectedSeatClass}

+

+ No coaches available for{" "} + {selectedSchedule?.selectedSeatClass} +

) : ( - <> - {/* Coach selector */} -
-

Coach

-
- {filteredCoaches.map((coach: any) => { - const coachSeats = coach.seats?.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')) || []; - const isBed = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed'); - let fSeats = coachSeats; - if (isBed && currentSchedule?.selectedSeatClass) { - const bedPos = getBedPosition(currentSchedule.selectedSeatClass); - if (bedPos) fSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos); - } - const available = fSeats.filter((s: any) => s.status === 'AVAILABLE').length; - const isActive = selectedCoach === coach.id; - return ( - - ); - })} -
-
+
- {/* Seat map */} -
-
-

- {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber} -

- {selectedCoachData?.seatArrangement} -
- - {/* Legend */} -
- {[ - { color: 'bg-green-500', label: 'Available' }, - { color: 'bg-[rgb(20,113,76)]', label: 'Selected' }, - { color: 'bg-yellow-500', label: 'Held' }, - { color: 'bg-gray-400', label: 'Booked' }, - ].map(({ color, label }) => ( -
-
- {label} + {/* ── Locomotive head ── */} +
+
+ {/* Decorative top stripe */} +
+
+
+

+ EDR Express +

+

+ {filteredCoaches.length} Coach{filteredCoaches.length !== 1 ? "es" : ""} +

- ))} -
- -
-
- {validSeats.length === 0 ? ( -

No seats in this coach

- ) : ( - renderCoachSeats(selectedCoachData, isBedCoach) - )} + {/* Cabin windows */} +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+
+ {/* Headlight bar */} +
+
+
+
+ {/* Nose / front bumper */} +
- + + {/* ── Coach list ── */} + {filteredCoaches.map((coach: any, index: number) => { + let fSeats: any[] = []; + if (coach.rooms?.length > 0) { + coach.rooms.forEach((room: any) => { + if (room.beds) { + fSeats.push( + ...room.beds.filter( + (b: any) => b.label && !b.label.startsWith("-"), + ), + ); + } + }); + } else { + fSeats = + coach.seats?.filter( + (s: any) => s.seatNumber && !s.seatNumber.startsWith("-"), + ) || []; + } + + const isBed = + coach.seatClass?.toLowerCase().includes("bed") || + coach.mode?.toLowerCase().includes("bed"); + + if (isBed && currentSchedule?.selectedSeatClass) { + const bedPos = getBedPosition(currentSchedule.selectedSeatClass); + if (bedPos) + fSeats = fSeats.filter((s: any) => s.bedPosition === bedPos); + } + + const available = fSeats.filter((s: any) => s.status === "AVAILABLE").length; + const total = fSeats.length; + const isExpanded = selectedCoach === coach.id; + const coachLabel = + coach.label || coach.name || coach.coachNumber || `Coach ${index + 1}`; + + return ( +
+ {/* Coupling joint */} +
+
+
+
+
+
+
+ + {/* Coach car */} +
+ {/* Top colour stripe — brand rail */} +
+ + {/* Clickable header */} + + + {/* Expanded seat map */} + {isExpanded && ( +
+
+ {[ + { + color: "bg-green-50 border border-green-300", + label: "Available", + }, + { + color: "bg-blue-50 border-2 border-blue-500", + label: "Selected", + }, + { + color: "bg-red-50 border border-red-300", + label: "Booked", + }, + ].map(({ color, label }) => ( +
+
+ + {label} + +
+ ))} +
+ +
+
+ {validSeats.length === 0 ? ( +

+ No seats in this coach +

+ ) : ( + renderCoachSeats(selectedCoachData, isBedCoach) + )} +
+
+
+ )} + + {/* Bottom colour stripe */} +
+
+
+ ); + })} + + {/* ── Rear end cap ── */} +
+
+
+
+
+
+
+
+
+ +
)}
@@ -807,7 +1496,6 @@ export default function SeatsPage() {
-
{/* Mobile spacer so bottom-sheet doesn't cover last seat */}