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 000000000..0fc5c6d2c Binary files /dev/null and b/apps/edr-passenger-web/portal/public/bed.png differ 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 */}