diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 482c34268..6bfc042d8 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -37,6 +37,7 @@ import { import { JwtGuard } from "../../common/jwt.guard"; import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; +import { SeatsService } from "../seats/seats.service"; @ApiTags("Booking") @Controller("bookings") @@ -45,6 +46,7 @@ export class BookingsController { constructor( private service: BookingsService, private guestService: GuestBookingService, + private seatsService: SeatsService, ) {} @Get("my") @@ -359,6 +361,37 @@ export class BookingsController { return this.guestService.createGuestBooking(dto, req); } + @Post("group") + @PassengerStaff([PASSENGER_PERMS.bookings.manage]) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ + summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group", + description: `Staff-only entry point for bulk/group bookings (e.g. tour groups booked via an uploaded passenger list and auto-assigned seats from POST /seats/auto-assign-hold). + +Same body shape as POST /bookings/guest (CreateGuestBookingDto) and the same underlying pipeline — fare engine, ADULT/CHILD age pricing — just gated to staff and always ONE_WAY. + +Skips Verifayda national-ID verification: the roster comes from a staff-uploaded spreadsheet, not a live Fayda identity flow, so there is nothing to verify an ID number against. Passenger fields (name, DOB, nationality) are trusted exactly as uploaded. + +Deliberately does NOT forward the staff caller's identity into booking creation: the acting staff member is not a Passenger, so the underlying guest-booking flow (which tries to resolve an authenticated caller as an existing Passenger profile) would reject the request. The booking is created exactly like a guest booking — a fresh passenger record, contact info from the first passenger in the list — with staff authorization enforced only at this route. + +If booking creation fails after the seats were already held, the hold is released immediately so the seats don't sit locked for the rest of the hold TTL.`, + }) + @ApiResponse({ status: 201, description: "Group booking created successfully with fareBreakdown" }) + @ApiResponse({ status: 400, description: "Missing required seat IDs" }) + async createGroup(@Body() dto: CreateGuestBookingDto) { + try { + return await this.guestService.createGuestBooking({ ...dto, bookingType: "ONE_WAY", skipIdentityVerification: true }); + } catch (err) { + try { + await this.seatsService.releaseHold(dto.holdId); + } catch (releaseErr) { + // Best-effort — the hold may already be gone (e.g. it expired mid-request). The + // original booking-creation error is what the caller actually needs to see. + } + throw err; + } + } + @Post("reservations/:seatId/issue") @PassengerStaffStrict(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth("IAM-auth") diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts index 7065b6183..b59b65eb6 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts @@ -168,6 +168,15 @@ export class CreateGuestBookingDto { @ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' }) @IsOptional() @IsNumber() reviewedTotalMinor?: number; + + @ApiPropertyOptional({ + description: + 'Skip Verifayda national-ID verification and trust passenger fields as given (name, DOB, nationality). ' + + 'For staff-entered/bulk-uploaded rosters (e.g. group bookings) where there is no live Fayda identity ' + + 'flow to verify against — calling Verifayda for typed-in ID numbers either returns dev-mode mock data ' + + '(overwriting the real name) or, once configured, would reject the whole booking on a non-match.', + }) + @IsOptional() @IsBoolean() skipIdentityVerification?: boolean; } export class SavedPassengerProfileDto { diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 5484aa658..a887df62a 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -196,7 +196,7 @@ export class GuestBookingService { passenger.idDocumentType === IdDocumentType.NATIONAL_ID; if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { - if (passenger.idDocumentNumber) { + if (passenger.idDocumentNumber && !dto.skipIdentityVerification) { const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); if (!verification.verified) { throw new BadRequestException( 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 d19b6f024..2f95b22e6 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -21,7 +21,7 @@ import { ApiBody, } from "@nestjs/swagger"; import { SeatsService } from "./seats.service"; -import { BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaintenanceDto } from "./seats.dto"; +import { AutoAssignHoldDto, BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaintenanceDto } from "./seats.dto"; import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user"; import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto"; import { JwtGuard } from "../../common/jwt.guard"; @@ -186,6 +186,30 @@ This makes it clear which segment of the route each seat is held for, enabling s return this.service.holdSeats(dto); } + @Post("auto-assign-hold") + @PassengerStaff([PASSENGER_PERMS.bookings.manage]) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ + summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only", + description: `Picks the requested number of available seats of the given class (preferring a contiguous row) and holds them in one step, so the caller never shows an assignment it could lose to a race before the passenger data is submitted. + +No manual seat selection — this is for bulk/group booking flows where staff upload a passenger list rather than picking seats on a seat map. Returns the same hold shape as POST /seats/hold. + +Throws 409 with no partial hold created if fewer than the requested seats are available in that class.`, + }) + @ApiResponse({ status: 201, description: "Seats auto-assigned and held" }) + @ApiResponse({ status: 409, description: "Not enough seats available in the requested class" }) + autoAssignHold(@Body() dto: AutoAssignHoldDto) { + const passengerCount = dto.adultCount + (dto.childCount ?? 0); + return this.service.autoAssignAndHold( + dto.scheduleId, + dto.originStationId, + dto.destinationStationId, + dto.seatClassName, + passengerCount, + ); + } + @Delete("hold/:holdId") @UseGuards(JwtGuard) @ApiBearerAuth("JWT-auth") diff --git a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts index 1d177e719..6e135b853 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsArray, ValidateNested, IsOptional, IsEnum } from 'class-validator'; +import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsInt, Min } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; @@ -49,6 +49,26 @@ export class HoldSeatsDto { passengers: PassengerSeatDto[]; } +export class AutoAssignHoldDto { + @ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) + @IsString() scheduleId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID for this leg' }) + @IsString() originStationId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg' }) + @IsString() destinationStationId: string; + + @ApiProperty({ example: 'Economy Regular', description: 'Seat class name to auto-assign from — must match a class returned by POST /search for this schedule.' }) + @IsString() seatClassName: string; + + @ApiProperty({ example: 4, minimum: 1, description: 'Number of adult passengers to assign seats for.' }) + @IsInt() @Min(0) adultCount: number; + + @ApiPropertyOptional({ example: 1, minimum: 0, description: 'Number of child passengers to assign seats for.' }) + @IsOptional() @IsInt() @Min(0) childCount?: number; +} + export class ReleaseHoldDto { @ApiProperty({ example: 'hold-uuid', description: 'SeatHold UUID to release' }) @IsString() holdId: string; diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts index e80966d6a..98a019577 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts @@ -18,6 +18,10 @@ describe('SeatsService - Auto Assign', () => { findMany: jest.fn(), updateMany: jest.fn(), }, + seatClass: { + findFirst: jest.fn(), + findMany: jest.fn(), + }, tripStopTime: { findMany: jest.fn(), }, @@ -72,6 +76,14 @@ describe('SeatsService - Auto Assign', () => { ]); mockPrisma.seatBlock.findMany.mockResolvedValue([]); mockSegmentsService.getSeatAvailabilityMap.mockResolvedValue(new Map()); + mockPrisma.seatClass.findFirst.mockResolvedValue({ + coachTypeId: 'coach-type-1', + nationalityType: 'INTERNATIONAL', + coachType: { name: 'Hard Seat Coach' }, + }); + mockPrisma.seatClass.findMany.mockResolvedValue([ + { bedPosition: null }, + ]); }); describe('assertNoRouteSeatConflict', () => { @@ -114,25 +126,27 @@ describe('SeatsService - Auto Assign', () => { }); describe('autoAssignSeats', () => { - it('should assign contiguous seats in same row', async () => { + it('should assign seats in ascending seat-number order (not row/insertion order)', async () => { + // Deliberately out of order and non-contiguous-by-row to prove the sort is driven by + // seatNumber, not by the order seats came back from the query or their row grouping. const mockSeats = [ - { id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' }, - { id: 'seat-2', coachId: 'coach-1', row: 1, col: 'B' }, - { id: 'seat-3', coachId: 'coach-1', row: 1, col: 'C' }, - { id: 'seat-4', coachId: 'coach-1', row: 2, col: 'A' }, + { id: 'seat-3', seatNumber: '3', coachId: 'coach-1', row: 2, col: 'A' }, + { id: 'seat-1', seatNumber: '1', coachId: 'coach-1', row: 1, col: 'A' }, + { id: 'seat-2', seatNumber: '2', coachId: 'coach-1', row: 1, col: 'B' }, + { id: 'seat-10', seatNumber: '10', coachId: 'coach-1', row: 3, col: 'A' }, ]; mockPrisma.seat.findMany.mockResolvedValue(mockSeats); const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR'); - expect(result).toHaveLength(2); + // Numeric order (1, 2) — a lexicographic sort would have put '10' before '2'. expect(result).toEqual(['seat-1', 'seat-2']); }); it('should throw error if not enough seats available', async () => { mockPrisma.seat.findMany.mockResolvedValue([ - { id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' }, + { id: 'seat-1', seatNumber: '1', coachId: 'coach-1', row: 1, col: 'A' }, ]); await expect( @@ -140,22 +154,9 @@ describe('SeatsService - Auto Assign', () => { ).rejects.toThrow(ConflictException); }); - it('should respect eligibility filter', async () => { - const mockSeats = [ - { id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A', eligibility: 'ACCESSIBLE' }, - { id: 'seat-2', coachId: 'coach-1', row: 1, col: 'B', eligibility: 'ACCESSIBLE' }, - ]; - - mockPrisma.seat.findMany.mockResolvedValue(mockSeats); - - const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR'); - - expect(result).toHaveLength(2); - }); - it('should assign single seat', async () => { const mockSeats = [ - { id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' }, + { id: 'seat-1', seatNumber: '1', coachId: 'coach-1', row: 1, col: 'A' }, ]; mockPrisma.seat.findMany.mockResolvedValue(mockSeats); @@ -164,5 +165,75 @@ describe('SeatsService - Auto Assign', () => { expect(result).toEqual(['seat-1']); }); + + it('should fill Lower, then Middle, then Upper — a fixed physical order, not fare order', async () => { + mockPrisma.seatClass.findFirst.mockResolvedValue({ + coachTypeId: 'coach-type-hbc', + nationalityType: 'INTERNATIONAL', + coachType: { name: 'Hard Berth Coach' }, + }); + mockPrisma.seatClass.findMany.mockResolvedValue([ + { bedPosition: 'UPPER' }, + { bedPosition: 'MIDDLE' }, + { bedPosition: 'LOWER' }, + ]); + // Upper is the cheapest tier in the seed data (4000 vs 5500 Middle vs 6000 Lower) — this + // deliberately picks seats so a fare-order algorithm and a lower-first algorithm disagree. + const mockSeats = [ + { id: 'upper-1', seatNumber: '16', coachId: 'coach-1', row: 4, col: 'A', bedPosition: 'UPPER' }, + { id: 'upper-2', seatNumber: '17', coachId: 'coach-1', row: 4, col: 'B', bedPosition: 'UPPER' }, + { id: 'middle-1', seatNumber: '11', coachId: 'coach-1', row: 3, col: 'A', bedPosition: 'MIDDLE' }, + { id: 'lower-1', seatNumber: '6', coachId: 'coach-1', row: 2, col: 'A', bedPosition: 'LOWER' }, + { id: 'lower-2', seatNumber: '7', coachId: 'coach-1', row: 2, col: 'B', bedPosition: 'LOWER' }, + ]; + mockPrisma.seat.findMany.mockResolvedValue(mockSeats); + + const result = await service.autoAssignSeats('trip-1', 3, 'Economy Bed Upper (Intl)'); + + // Both Lower seats first, then spill into Middle — Upper is untouched even though it's cheaper. + expect(result).toEqual(['lower-1', 'lower-2', 'middle-1']); + }); + + it('should count all fare tiers toward availability, not just one tier', async () => { + mockPrisma.seatClass.findFirst.mockResolvedValue({ + coachTypeId: 'coach-type-hbc', + nationalityType: 'INTERNATIONAL', + coachType: { name: 'Hard Berth Coach' }, + }); + mockPrisma.seatClass.findMany.mockResolvedValue([ + { bedPosition: 'UPPER' }, + { bedPosition: 'LOWER' }, + ]); + mockPrisma.seat.findMany.mockResolvedValue([ + { id: 'upper-1', seatNumber: '16', coachId: 'coach-1', row: 4, col: 'A', bedPosition: 'UPPER' }, + { id: 'lower-1', seatNumber: '6', coachId: 'coach-1', row: 2, col: 'A', bedPosition: 'LOWER' }, + ]); + + const result = await service.autoAssignSeats('trip-1', 2, 'VIP Bed Upper (Intl)'); + + expect(result).toHaveLength(2); + }); + + it('should match bed-tier seats regardless of case (SeatClass.bedPosition is seeded uppercase, Seat.bedPosition is stored lowercase in production data)', async () => { + mockPrisma.seatClass.findFirst.mockResolvedValue({ + coachTypeId: 'coach-type-sbc', + nationalityType: 'INTERNATIONAL', + coachType: { name: 'Soft Berth Coach' }, + }); + mockPrisma.seatClass.findMany.mockResolvedValue([ + { bedPosition: 'UPPER' }, + { bedPosition: 'LOWER' }, + ]); + mockPrisma.seat.findMany.mockResolvedValue([ + { id: 'upper-1', seatNumber: '16', coachId: 'coach-1', row: 4, col: 'A', bedPosition: 'upper' }, + { id: 'lower-1', seatNumber: '6', coachId: 'coach-1', row: 3, col: 'A', bedPosition: 'lower' }, + ]); + + const result = await service.autoAssignSeats('trip-1', 2, 'VIP Bed Upper (Intl)'); + + expect(result).toHaveLength(2); + // Lower fills before Upper regardless of case. + expect(result).toEqual(['lower-1', 'upper-1']); + }); }); }); 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 00aff331b..e6256c28a 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -1,4 +1,5 @@ import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; import { PrismaService } from '../../common/prisma.service'; import { BlockSeatDto, HoldSeatsDto, JourneyDirection, SeatBlockReasonCategory } from './seats.dto'; import { ActingUser } from '../../common/acting-user'; @@ -824,14 +825,56 @@ export class SeatsService { }); if (!schedule) throw new NotFoundException('Schedule not found'); - const seats = await this.prisma.seat.findMany({ - where: { - coach: { assignments: { some: { scheduleId } } }, - seatNumber: { not: '' }, - NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }, { status: 'UNDER_MAINTENANCE' as any }], - }, - orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], + // Resolve the requested class to its actual SeatClass row, then pool seats across every + // fare tier (bed position) that shares its coachTypeId + nationalityType. "Economy"/"VIP" + // are coach categories, not one physical seat pool — Upper/Middle/Lower berths are + // genuinely different seats priced differently — but the whole group is billed one uniform + // rate (the cheapest tier, which is what callers pass as seatClassName; see + // bookings.controller's group endpoint). A coach type with no bed split (e.g. Economy + // Regular) has exactly one tier, so this collapses to plain seat-number order for it. + const seatClass = await this.prisma.seatClass.findFirst({ + where: { name: seatClassName }, + select: { coachTypeId: true, nationalityType: true, coachType: { select: { name: true } } }, }); + if (!seatClass) throw new NotFoundException(`Seat class "${seatClassName}" not found`); + + const siblingClasses = await this.prisma.seatClass.findMany({ + where: { coachTypeId: seatClass.coachTypeId, nationalityType: seatClass.nationalityType }, + select: { bedPosition: true }, + }); + // SeatClass.bedPosition is seeded uppercase ('UPPER'), but Seat.bedPosition is stored + // lowercase ('upper') — normalize both sides or every bed-tier seat silently fails to match. + const validBedPositions = new Set(siblingClasses.map((sc) => (sc.bedPosition ?? '').toLowerCase())); + // Fixed physical fill order — lower berths first, then middle, then upper — not fare-driven. + const BED_POSITION_ORDER: Record = { lower: 0, middle: 1, upper: 2 }; + + const allSeatsOnSchedule = await this.prisma.seat.findMany({ + where: { + coach: { + coachTypeId: seatClass.coachTypeId, + assignments: { some: { scheduleId } }, + }, + seatNumber: { not: '' }, + // Only 'BLOCKED' is a real SeatStatus value (AVAILABLE|HELD|BOOKED|BLOCKED) — this + // method never wrote 'UNDER_MAINTENANCE' before, and Prisma validates enum values at + // the query level regardless of an `as any` cast, so that clause would throw at + // runtime the moment this method was ever actually called. Maintenance-blocked seats + // are still excluded below via the schedule-scoped SeatBlock check. + NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }], + }, + orderBy: [{ coach: { number: 'asc' } }], + }); + + // Lower → Middle → Upper, then ascending seat number within a tier (seatNumber is a string + // column, so DB/lexicographic ordering would sort "10" before "2" — compare numerically here). + const seats = allSeatsOnSchedule + .filter((s) => validBedPositions.has((s.bedPosition ?? '').toLowerCase())) + .sort((a, b) => { + const tierDiff = (BED_POSITION_ORDER[(a.bedPosition ?? '').toLowerCase()] ?? 0) + - (BED_POSITION_ORDER[(b.bedPosition ?? '').toLowerCase()] ?? 0); + if (tierDiff !== 0) return tierDiff; + return parseInt(a.seatNumber, 10) - parseInt(b.seatNumber, 10); + }); const allSeatIds = seats.map(s => s.id); const stopTimes = await this.prisma.tripStopTime.findMany({ @@ -856,30 +899,42 @@ export class SeatsService { const availableSeats = seats.filter(s => !unavailable.has(s.id) && !scheduleBlockedIds.has(s.id)); if (availableSeats.length < count) { - throw new ConflictException(`Only ${availableSeats.length} seats available, requested ${count}`); + throw new ConflictException(`Only ${availableSeats.length} seats available in ${seatClass.coachType.name} (across all fare tiers), requested ${count}`); } - const assigned = this.findContiguousSeats(availableSeats, count); - return assigned.map((s) => s.id); + // availableSeats is already ordered lower→middle→upper, ascending seat number within a + // tier — take the first `count` in that order, spilling into the next tier once one runs out. + return availableSeats.slice(0, count).map((s) => s.id); } - private findContiguousSeats(seats: any[], count: number): any[] { - if (count === 1) return [seats[0]]; - - const grouped = new Map(); - for (const seat of seats) { - const key = `${seat.coachId}-${seat.row}`; - if (!grouped.has(key)) grouped.set(key, []); - grouped.get(key)!.push(seat); - } - - for (const rowSeats of grouped.values()) { - if (rowSeats.length >= count) { - return rowSeats.slice(0, count); - } - } - - return seats.slice(0, count); + /** + * Auto-assigns `count` seats of `seatClassName` and immediately holds them in one request, + * for callers (like bulk/group booking) that must never show an assignment the caller could + * lose to a race before confirming it. Reuses `holdSeats` as-is — a single hold already + * supports many seats/passengers in one row (see `SeatHold.seatIds: String[]`), so this is + * pure orchestration, not a new hold mechanism. + */ + async autoAssignAndHold( + scheduleId: string, + originStationId: string, + destinationStationId: string, + seatClassName: string, + passengerCount: number, + ) { + const seatIds = await this.autoAssignSeats(scheduleId, passengerCount, seatClassName); + // Scope the synthetic passengerId to this attempt (not just its row index) — a fixed + // "group-1", "group-2"... would collide with any other still-active group-booking hold on + // the same schedule (e.g. an abandoned/retried attempt, or two staff members booking the + // same train within the hold TTL), tripping holdSeats' "passenger already holds a seat on + // this journey leg" conflict check for two entirely unrelated bookings. + const attemptId = randomUUID(); + const passengers = seatIds.map((seatId, i) => ({ passengerId: `group-${attemptId}-${i + 1}`, seatId })); + return this.holdSeats({ + scheduleId, + originStationId, + destinationStationId, + passengers, + } as HoldSeatsDto); } async exportSeatsCSV(scheduleId: string): Promise { diff --git a/apps/edr-passenger-web/backoffice/src/app/group-booking/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/group-booking/layout.tsx new file mode 100644 index 000000000..aadccdf54 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/group-booking/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function GroupBookingLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx b/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx new file mode 100644 index 000000000..de50c6ff9 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx @@ -0,0 +1,1133 @@ +'use client'; + +import { useMemo, useRef, useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import Link from 'next/link'; +import { + AlertTriangle, ArmchairIcon, ArrowLeft, ArrowRight, BedDouble, Check, CheckCircle2, + Copy, CreditCard, Download, FileSpreadsheet, Landmark, Loader2, MapPin, Search, Smartphone, + Sparkles, Star, Train, Upload, Users, Wallet, X, +} from 'lucide-react'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; +import { apiClient } from '@/lib/api-client'; +import { + groupBookingApi, + type AutoAssignHoldResponse, + type CreateGroupBookingResponse, + type InitiatePaymentResponse, + type ScheduleClassOption, + type ScheduleResult, + type SearchEmptyReason, + type SeatClassOption, + type SupportedPaymentMethod, +} from '@/lib/api/group-booking'; +import { buildPassengerTemplate } from '@/lib/export/passenger-template'; +import { countByType, parsePassengerExcel, type ParsedPassengerRow } from '@/lib/import/passenger-excel'; +import ActionButton from '@/components/ui/ActionButton'; +import DatePicker from '@/components/ui/DatePicker'; +import Skeleton from '@/components/ui/Skeleton'; +import { cn, formatCurrency, formatDate, formatDateTime } from '@/lib/utils'; + +interface StationOption { + id: string; + name: string; + code: string; +} + +type Step = 'search' | 'results' | 'passengers' | 'confirm' | 'success'; + +interface SelectedClass { + scheduleId: string; + /** Raw SeatClass.name (e.g. "Economy Bed Upper (Local)") — used for API lookups, never shown to staff. */ + className: string; + /** Coach category shown to staff: Regular / Economy / VIP. */ + category: string; + fareMinor: number; + displayCurrency: string; + available: number; +} + +const STEPS: { key: Step; label: string }[] = [ + { key: 'search', label: 'Search' }, + { key: 'results', label: 'Trip & Class' }, + { key: 'passengers', label: 'Passengers' }, + { key: 'confirm', label: 'Seats & Confirm' }, + { key: 'success', label: 'Done' }, +]; + +/** outboundReason is a structured object, not a string — never render it directly. */ +function emptySearchMessage(reason: SearchEmptyReason | undefined): string { + if (!reason) return 'Try a different date or station pair.'; + const { originStationName: o, destinationStationName: d } = reason; + switch (reason.code) { + case 'NO_ROUTE': + return `No route connects ${o} to ${d} in this direction.`; + case 'NO_SCHEDULE_ON_DATE': + return `No scheduled departures from ${o} to ${d} on this date — try another date.`; + case 'CANCELLED': + return `Every departure from ${o} to ${d} on this date was cancelled.`; + case 'PACKAGE_ONLY': + return `Departures on this date are reserved for travel packages, not regular ticketing.`; + case 'CHECKIN_CLOSED': + return `Check-in has already closed for every departure on this date.`; + case 'FULLY_BOOKED': + return `This departure is fully booked for the requested party size — try fewer passengers or another date.`; + default: + return 'Try a different date or station pair.'; + } +} + +/** + * The API exposes one SeatClass row per bed position × nationality tier (e.g. "Economy Bed + * Upper (Local)"), but staff should only ever pick a coach category — the cheapest class + * within that category is used to price and assign the whole group automatically. + */ +const COACH_CATEGORY: Record = { HSC: 'Regular', HBC: 'Economy', SBC: 'VIP' }; + +/** Per-seat berth tier (Lower/Middle/Upper) — coach types with no bed split (Regular) report null. */ +function seatTypeLabel(bedPosition: string | null | undefined): string { + if (!bedPosition) return 'Standard'; + return bedPosition.charAt(0).toUpperCase() + bedPosition.slice(1).toLowerCase(); +} + +function categoryLabel(coachTypeCode: string, coachTypeName: string): string { + return COACH_CATEGORY[coachTypeCode] ?? coachTypeName; +} + +function categoryIcon(coachTypeCode: string) { + if (coachTypeCode === 'SBC') return Star; + if (coachTypeCode === 'HBC') return BedDouble; + return ArmchairIcon; +} + +interface CategoryOption { + coachTypeId: string; + coachTypeCode: string; + label: string; + cheapestClass: ScheduleClassOption; + /** Sum of available seats across every fare tier in this category (e.g. Upper+Middle+Lower) — this + * is what auto-assignment can actually draw from, since it fills the cheapest tier first and spills + * into pricier ones, all still billed at cheapestClass's rate. */ + totalAvailable: number; +} + +function ScheduleCard({ + schedule, + totalPassengers, + onChoose, +}: { + schedule: ScheduleResult; + totalPassengers: number; + onChoose: (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => void; +}) { + const categories: CategoryOption[] = schedule.coachTypes.map((ct) => ({ + coachTypeId: ct.coachTypeId, + coachTypeCode: ct.coachTypeCode, + label: categoryLabel(ct.coachTypeCode, ct.coachTypeName), + cheapestClass: ct.classes.reduce((min, c) => (c.baseFareMinor < min.baseFareMinor ? c : min), ct.classes[0]), + totalAvailable: ct.classes.reduce((sum, c) => sum + c.available, 0), + })); + const cheapestFare = Math.min(...categories.map((c) => c.cheapestClass.baseFareMinor)); + + return ( +
+
+
+
+ +
+
+

{schedule.trainNumber} · {schedule.trainName}

+

+ {schedule.origin.name} → {schedule.destination.name} +

+
+
+
+

{formatDateTime(schedule.departureAt)} → {formatDateTime(schedule.arrivalAt)}

+

{Math.floor(schedule.durationMinutes / 60)}h {schedule.durationMinutes % 60}m journey

+
+
+
+ {categories.map((cat) => { + const Icon = categoryIcon(cat.coachTypeCode); + const cls = cat.cheapestClass; + const enough = cat.totalAvailable >= totalPassengers; + const isBestValue = cls.baseFareMinor === cheapestFare; + return ( + + ); + })} +
+
+ ); +} + +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +function Stepper({ current }: { current: Step }) { + const idx = STEPS.findIndex((s) => s.key === current); + return ( +
+
+ {STEPS.map((s, i) => { + const done = i < idx; + const active = i === idx; + return ( +
+
+
+ {done ? : i + 1} +
+ + {s.label} + +
+ {i < STEPS.length - 1 && ( +
+ )} +
+ ); + })} +
+
+ ); +} + +function GroupBookingPageContent() { + const [step, setStep] = useState('search'); + + // ── Step 1: search ────────────────────────────────────────────────────── + const [originStationId, setOriginStationId] = useState(''); + const [destinationStationId, setDestinationStationId] = useState(''); + const [travelDate, setTravelDate] = useState(''); + const [adultCount, setAdultCount] = useState(1); + const [childCount, setChildCount] = useState(0); + const [searchTouched, setSearchTouched] = useState(false); + /** Local (Ethiopian/Djiboutian) vs International fare rates — the whole group is quoted and + * priced at one uniform tier, chosen up front since actual passenger nationalities aren't + * known until the Excel upload two steps later. */ + const [fareTier, setFareTier] = useState<'LOCAL' | 'INTERNATIONAL'>('LOCAL'); + + const { data: stations = [] } = useQuery({ + queryKey: ['stations'], + queryFn: () => apiClient.get('/stations'), + }); + const { data: seatClassOptions = [] } = useQuery({ + queryKey: ['seat-classes'], + queryFn: () => groupBookingApi.getSeatClasses(), + }); + + const totalPassengers = (adultCount || 0) + (childCount || 0); + const searchValid = !!originStationId && !!destinationStationId && originStationId !== destinationStationId + && !!travelDate && totalPassengers > 0; + + const searchMutation = useMutation({ + mutationFn: () => + groupBookingApi.searchTrips({ + originStationId, + destinationStationId, + date: travelDate, + adultCount: adultCount || 0, + childCount: childCount || 0, + journeyType: 'ONE_WAY', + nationality: fareTier === 'LOCAL' ? 'Ethiopian' : 'Other', + }), + }); + + const runSearch = () => { + setSearchTouched(true); + if (!searchValid) return; + setStep('results'); + searchMutation.mutate(); + }; + + // ── Step 2: results / class selection ─────────────────────────────────── + const [selectedSchedule, setSelectedSchedule] = useState(null); + const [selectedClass, setSelectedClass] = useState(null); + + const seatClassId = useMemo(() => { + if (!selectedClass) return null; + return seatClassOptions.find((sc) => sc.name === selectedClass.className)?.id ?? null; + }, [selectedClass, seatClassOptions]); + + const chooseClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => { + setSelectedSchedule(schedule); + setSelectedClass({ + scheduleId: schedule.scheduleId, + className: cls.name, + category, + fareMinor: cls.baseFareMinor, + displayCurrency: cls.displayCurrency, + available: totalAvailable, + }); + setStep('passengers'); + }; + + // ── Step 3: passenger Excel ────────────────────────────────────────────── + const fileInputRef = useRef(null); + const [dragOver, setDragOver] = useState(false); + const [passengerRows, setPassengerRows] = useState([]); + const [fileErrors, setFileErrors] = useState([]); + const [fileName, setFileName] = useState(null); + const [parsing, setParsing] = useState(false); + + const { adults: uploadedAdults, children: uploadedChildren } = countByType(passengerRows); + const rowsHaveErrors = passengerRows.some((r) => r.errors.length > 0); + const countMismatch = passengerRows.length > 0 && (uploadedAdults !== adultCount || uploadedChildren !== childCount); + const passengersValid = passengerRows.length > 0 && fileErrors.length === 0 && !rowsHaveErrors && !countMismatch; + + const downloadTemplate = async () => { + if (!selectedSchedule || !selectedClass) return; + const blob = await buildPassengerTemplate({ + trainNumber: selectedSchedule.trainNumber, + origin: selectedSchedule.origin.name, + destination: selectedSchedule.destination.name, + travelDate, + seatClassName: selectedClass.category, + adultCount, + childCount, + }); + downloadBlob(blob, `group-booking-passengers-${selectedSchedule.trainNumber}-${travelDate}.xlsx`); + }; + + const processFile = async (file: File) => { + setParsing(true); + setFileName(file.name); + try { + const result = await parsePassengerExcel(file, fareTier); + setPassengerRows(result.rows); + setFileErrors(result.fileErrors); + } finally { + setParsing(false); + } + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (file) await processFile(file); + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files?.[0]; + if (file) await processFile(file); + }; + + const clearUpload = () => { + setPassengerRows([]); + setFileErrors([]); + setFileName(null); + }; + + // ── Step 4: auto-assign + hold ─────────────────────────────────────────── + const [assignError, setAssignError] = useState(null); + const [hold, setHold] = useState(null); + + const autoAssignMutation = useMutation({ + mutationFn: () => { + if (!selectedSchedule || !selectedClass) throw new Error('No schedule/class selected'); + return groupBookingApi.autoAssignHold({ + scheduleId: selectedSchedule.scheduleId, + originStationId: selectedSchedule.origin.id, + destinationStationId: selectedSchedule.destination.id, + seatClassName: selectedClass.className, + adultCount, + childCount, + }); + }, + onSuccess: (data) => { + setHold(data); + setAssignError(null); + setStep('confirm'); + }, + onError: (err: any) => { + setAssignError(err?.response?.data?.message ?? err?.message ?? 'Not enough seats are available for this class.'); + }, + }); + + const proceedToAssignment = () => { + if (!passengersValid) return; + setAssignError(null); + autoAssignMutation.mutate(); + }; + + // Pairs each validated passenger row (upload order) with its auto-assigned seat (same order). + const seatAssignments = useMemo(() => { + if (!hold) return []; + return passengerRows.map((row, i) => ({ + row, + seat: hold.passengers[i]?.seat ?? null, + })); + }, [hold, passengerRows]); + + // ── Step 5: create booking ─────────────────────────────────────────────── + const [bookingError, setBookingError] = useState(null); + const [booking, setBooking] = useState(null); + + const createBookingMutation = useMutation({ + mutationFn: () => { + if (!selectedSchedule || !selectedClass || !hold || !seatClassId) { + throw new Error('Missing schedule, class, or hold — go back and try again.'); + } + return groupBookingApi.createGroupBooking({ + scheduleId: selectedSchedule.scheduleId, + holdId: hold.holdId, + originStationId: selectedSchedule.origin.id, + destinationStationId: selectedSchedule.destination.id, + seatClassId, + bookingType: 'ONE_WAY', + passengers: seatAssignments.map(({ row, seat }) => ({ + seatId: seat!.id, + passengerName: row.fullName, + dateOfBirth: row.dateOfBirth, + idDocumentType: row.idDocumentType as any, + idDocumentNumber: row.idDocumentNumber || undefined, + passportNumber: row.passportNumber || undefined, + passportCountry: row.passportCountry || undefined, + nationality: row.nationality || undefined, + phone: row.phone || undefined, + email: row.email || undefined, + })), + }); + }, + onSuccess: (data) => { + setBooking(data); + setBookingError(null); + setStep('success'); + }, + onError: (err: any) => { + // The hold was released server-side on failure — a retry needs a fresh one. + setHold(null); + setBookingError(err?.response?.data?.message ?? err?.message ?? 'Could not create the booking.'); + }, + }); + + const retryAfterBookingFailure = () => { + setBookingError(null); + setStep('passengers'); + }; + + // ── Step 6: pay ─────────────────────────────────────────────────────────── + const { data: paymentMethods = [] } = useQuery({ + queryKey: ['payment-methods'], + queryFn: () => groupBookingApi.getPaymentMethods(), + enabled: step === 'success', + staleTime: 5 * 60 * 1000, + }); + const enabledPaymentMethods = paymentMethods.filter((m) => m.enabled); + + const [selectedPaymentType, setSelectedPaymentType] = useState(null); + if (selectedPaymentType === null && enabledPaymentMethods.length > 0) { + const cbeBill = enabledPaymentMethods.find((m) => m.type === 'CBE_BILL'); + setSelectedPaymentType((cbeBill ?? enabledPaymentMethods[0]).type); + } + + const [paymentResult, setPaymentResult] = useState(null); + const [paymentError, setPaymentError] = useState(null); + const [billCopied, setBillCopied] = useState(false); + + const initiatePaymentMutation = useMutation({ + mutationFn: () => { + const method = enabledPaymentMethods.find((m) => m.type === selectedPaymentType); + if (!booking || !method) throw new Error('Select a payment method first.'); + return groupBookingApi.initiatePayment({ + bookingId: booking.id, + method: method.type, + paymentMethodId: method.id, + platform: 'web', + }); + }, + onSuccess: (data) => { + setPaymentResult(data); + setPaymentError(null); + }, + onError: (err: any) => { + setPaymentResult(null); + setPaymentError(err?.response?.data?.message ?? err?.message ?? 'Could not initiate payment.'); + }, + }); + + const paymentMethodIcon = (type: string) => { + if (type === 'CARD') return CreditCard; + if (type === 'WALLET') return Wallet; + if (type === 'CBE_BIRR' || type === 'CBE_BILL' || type === 'CAC_BANK') return Landmark; + return Smartphone; + }; + + const startOver = () => { + setStep('search'); + setSelectedSchedule(null); + setSelectedClass(null); + clearUpload(); + setHold(null); + setAssignError(null); + setBooking(null); + setBookingError(null); + setSelectedPaymentType(null); + setPaymentResult(null); + setPaymentError(null); + setBillCopied(false); + searchMutation.reset(); + }; + + return ( +
+
+

Group Booking

+

+ Search availability, upload a passenger list, and create one PNR for the whole group — seats are assigned automatically. +

+
+ + + + {/* Selection summary bar — visible from Step 2 onward */} + {selectedSchedule && selectedClass && step !== 'search' && step !== 'results' && ( +
+
+
+ +
+ {selectedSchedule.trainNumber} + {selectedSchedule.origin.name} → {selectedSchedule.destination.name} + · {formatDateTime(selectedSchedule.departureAt)} + · {selectedClass.category} + · {fareTier === 'LOCAL' ? 'Local' : 'International'} rates + · {adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? ` + ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''} +
+ +
+ )} + + {/* ── Step 1: Search ─────────────────────────────────────────────── */} + {step === 'search' && ( +
+
+
+ +
+

Search Availability

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + setAdultCount(e.target.value === '' ? 0 : Math.max(0, parseInt(e.target.value, 10) || 0))} + /> +
+
+ + setChildCount(e.target.value === '' ? 0 : Math.max(0, parseInt(e.target.value, 10) || 0))} + /> +
+
+ +
+ +
+ {(['LOCAL', 'INTERNATIONAL'] as const).map((tier) => ( + + ))} +
+

+ The whole group is quoted and charged at one uniform rate — pick the tier that matches most of the roster. + A passenger whose ID document implies the other tier will be flagged when you upload the passenger list. +

+
+ + {originStationId && destinationStationId && travelDate && totalPassengers > 0 && ( +
+ + Searching for {totalPassengers} passenger{totalPassengers === 1 ? '' : 's'} ({adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? `, ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}) +
+ )} + + {searchTouched && !searchValid && ( +

+ {totalPassengers <= 0 + ? 'Enter at least one adult or child.' + : originStationId && originStationId === destinationStationId + ? 'Origin and destination must be different.' + : 'Fill in origin, destination, and travel date.'} +

+ )} + + + Search Availability + +
+ )} + + {/* ── Step 2: Results ────────────────────────────────────────────── */} + {step === 'results' && ( +
+ + + {searchMutation.isPending && ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+
+ + +
+ +
+
+ {Array.from({ length: 3 }).map((_, j) => ( + + ))} +
+
+ ))} +
+ )} + + {searchMutation.isError && ( +
+ + + {(searchMutation.error as any)?.response?.data?.message ?? 'Search failed. Please try again.'} + + searchMutation.mutate()}>Retry +
+ )} + + {searchMutation.isSuccess && searchMutation.data.outbound.length === 0 && ( +
+ +

No schedules found for this search.

+

{emptySearchMessage(searchMutation.data.outboundReason)}

+
+ )} + + {searchMutation.isSuccess && (searchMutation.data.alternativeOutbound?.length ?? 0) > 0 && ( +
+

+ Nearby schedules for the same route +

+ {searchMutation.data!.alternativeOutbound!.map((schedule) => ( + + ))} +
+ )} + + {(searchMutation.data?.outbound ?? []).map((schedule) => ( + + ))} +
+ )} + + {/* ── Step 3: Passenger Information ─────────────────────────────── */} + {step === 'passengers' && selectedSchedule && selectedClass && ( +
+ + +
+
+
+ +
+

Passenger Information

+
+

+ Download the template, fill in one row per passenger, then upload the completed file. + Need exactly {totalPassengers} passenger{totalPassengers === 1 ? '' : 's'} ({adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? `, ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}). +

+ +
+ + Download Passenger Template + +
+ +
fileInputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + > + {fileName ? ( + <> + +

{fileName}

+ + + ) : ( + <> + +

{parsing ? 'Reading file…' : 'Drag & drop the completed template here'}

+

or click to browse — .xlsx or .xls, using the downloaded template

+ + )} + +
+ + {fileErrors.length > 0 && ( +
+ {fileErrors.map((e, i) =>

{e}

)} +
+ )} + + {countMismatch && ( +
+ Expected {totalPassengers} passengers ({adultCount} Adult{adultCount === 1 ? '' : 's'}, {childCount} Child{childCount === 1 ? '' : 'ren'}) — + the uploaded file has {passengerRows.length} ({uploadedAdults} Adult{uploadedAdults === 1 ? '' : 's'}, {uploadedChildren} Child{uploadedChildren === 1 ? '' : 'ren'}). + Fix the file to match and re-upload. +
+ )} +
+ + {passengerRows.length > 0 && ( +
+
+

+ Passenger Preview ({passengerRows.length}) +

+
+
+ + + + {['Row', 'Name', 'DOB', 'Type', 'ID Document', 'Nationality', 'Status'].map((h) => ( + + ))} + + + + {passengerRows.map((row) => ( + 0 ? 'bg-red-50/60 dark:bg-red-950/20' : undefined}> + + + + + + + + + ))} + +
{h}
{row.rowNumber}{row.fullName || '—'}{row.dateOfBirth || '—'}{row.passengerType || '—'}{row.idDocumentType || '—'}{row.nationality || '—'} + {row.errors.length === 0 ? ( + + Valid + {row.warnings.length > 0 && ({row.warnings.length} warning{row.warnings.length === 1 ? '' : 's'})} + + ) : ( + + + {row.errors.join('; ')} + + )} + {row.errors.length === 0 && row.warnings.length > 0 && ( +

{row.warnings.join('; ')}

+ )} +
+
+
+ + {passengersValid && } + {passengersValid ? 'All passengers valid — ready to continue.' : 'Fix the issues above before continuing.'} + + + Continue to Seat Assignment + +
+
+ )} + + {assignError && ( +
+ {assignError} +
+ )} +
+ )} + + {/* ── Step 4: Seat Assignment Preview + Step 5: Confirm ─────────── */} + {step === 'confirm' && hold && selectedSchedule && selectedClass && ( +
+
+
+
+
+ +
+

+ Seats Assigned Automatically +

+
+ Held for {Math.floor(hold.ttlSeconds / 60)}m {hold.ttlSeconds % 60}s +
+

Read-only — seats are assigned by the system, not selected manually.

+
+ {seatAssignments.map(({ row, seat }) => ( +
+
+ {seat ? (seat.seatNumber ?? seat.label ?? '?') : '—'} +
+
+

{row.fullName}

+

{row.passengerType} · {seat?.coach ?? '—'}

+
+
+ ))} +
+
+ + {bookingError && ( +
+ {bookingError} + Reassign & Retry +
+ )} + +
+
+ {totalPassengers} passenger{totalPassengers === 1 ? '' : 's'} · {selectedClass.category} · {formatCurrency(selectedClass.fareMinor * totalPassengers, selectedClass.displayCurrency)} estimated total +
+ createBookingMutation.mutate()} + > + Create Booking + +
+
+ )} + + {/* ── Step 6: Success ────────────────────────────────────────────── */} + {step === 'success' && booking && ( +
+
+
+
+
+ +
+
+

Group Booking Created

+

{booking.bookingRef}

+

Status: {booking.status}

+
+
+
+
+
+

Train

+

{booking.schedule.train.number} · {booking.schedule.train.name}

+
+
+

Travel Date

+

{formatDate(booking.schedule.departureAt)}

+
+
+

Origin → Destination

+

{booking.schedule.originStation.name} → {booking.schedule.destinationStation.name}

+
+
+

Departure → Arrival

+

{formatDateTime(booking.schedule.departureAt)} → {formatDateTime(booking.schedule.arrivalAt)}

+
+
+

Coach / Class

+

{selectedClass?.category ?? '—'}

+
+
+

Adults / Children

+

{booking.adultCount} / {booking.childCount}

+
+
+

Total Passengers

+

{booking.seats.length}

+
+
+

Total Fare

+

{formatCurrency(booking.totalMinor, booking.currency)}

+
+
+
+ +
+
+

Passenger List

+
+
+ {booking.seats.map((s, i) => ( +
+
+ {s.seat?.seatNumber ?? '?'} +
+
+

{s.passengerName}

+

+ {s.passengerCategory} · {seatTypeLabel(s.seat?.bedPosition)} · Seat {s.seat?.seatNumber ?? '—'} · Coach {s.seat?.coach?.number} +

+
+
+ ))} +
+
+ + {/* ── Pay now ─────────────────────────────────────────────────── */} + {!paymentResult && ( +
+

+ Collect Payment +

+ {enabledPaymentMethods.length === 0 ? ( +

Loading payment options…

+ ) : ( + <> +
+ {enabledPaymentMethods.map((m) => { + const Icon = paymentMethodIcon(m.type); + const active = selectedPaymentType === m.type; + return ( + + ); + })} +
+ {paymentError && ( +
+ {paymentError} +
+ )} + initiatePaymentMutation.mutate()} + disabled={!selectedPaymentType || initiatePaymentMutation.isPending} + > + {initiatePaymentMutation.isPending ? ( + + Initiating… + + ) : ( + 'Initiate Payment' + )} + + + )} +
+ )} + + {paymentResult && ( +
+

+ Payment {paymentResult.status === 'SUCCEEDED' ? 'Complete' : 'Initiated'} +

+ + {paymentResult.status === 'SUCCEEDED' && ( +
+ Payment succeeded. +
+ )} + + {(paymentResult.status === 'FAILED' || paymentResult.status === 'CANCELLED') && ( +
+ + {paymentResult.failureMessage ?? 'Payment was not completed.'} +
+ )} + + {paymentResult.clientAction?.type === 'SHOW_BILL_REFERENCE' && ( +
+

+ {paymentResult.clientAction.instructions ?? + 'Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD.'} +

+
+ + {paymentResult.clientAction.billReference} + + { + if (paymentResult.clientAction?.billReference) { + await navigator.clipboard.writeText(paymentResult.clientAction.billReference); + setBillCopied(true); + setTimeout(() => setBillCopied(false), 2000); + } + }} + > + {billCopied ? 'Copied' : 'Copy'} + +
+
+

Amount: {formatCurrency(booking.totalMinor, booking.currency)}

+ {paymentResult.clientAction.expiresAt && ( +

Pay before: {formatDateTime(paymentResult.clientAction.expiresAt)}

+ )} +
+
+ )} + + {paymentResult.clientAction && !['SHOW_BILL_REFERENCE'].includes(paymentResult.clientAction.type) && ( +

+ {paymentResult.clientAction.message ?? `Next step: ${paymentResult.clientAction.type}.`} +

+ )} + +

Payment reference: {paymentResult.intentId}

+ + +
+ )} + +
+ + View Booking + + +
+
+ )} +
+ ); +} + +export default function GroupBookingPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index de8619577..86253a385 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -39,6 +39,7 @@ import { Activity, Smartphone, Layers, + UsersRound, } from 'lucide-react'; import { useAuthStore } from '@/lib/auth-store'; import { cn } from '@/lib/utils'; @@ -63,6 +64,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ title: 'Operations', items: [ { name: 'Bookings', href: '/bookings', icon: Ticket, permission: PERMS.bookings.view }, + { name: 'Group Booking', href: '/group-booking', icon: UsersRound, permission: PERMS.bookings.manage }, { name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view }, { name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view }, { name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.manage }, diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts b/apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts new file mode 100644 index 000000000..0effa7d5b --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts @@ -0,0 +1,219 @@ +import { apiClient } from '@/lib/api-client'; + +// ── Search (POST /search) ────────────────────────────────────────────────── + +export interface SearchTripsRequest { + originStationId: string; + destinationStationId: string; + date: string; + adultCount: number; + childCount?: number; + journeyType: 'ONE_WAY'; + /** Drives which fare tier (Local vs International) gets quoted — see fareTier in the page component. */ + nationality?: string; +} + +export interface ScheduleClassOption { + name: string; + baseFareMinor: number; + displayCurrency: string; + displayAmountMinor: number; + available: number; +} + +export interface ScheduleCoachType { + coachTypeId: string; + coachTypeName: string; + coachTypeCode: string; + coachId: string; + classes: ScheduleClassOption[]; +} + +export interface ScheduleResult { + type: 'DIRECT'; + scheduleId: string; + trainNumber: string; + trainName: string; + origin: { id: string; code: string; name: string; city: string; sequence: number }; + destination: { id: string; code: string; name: string; city: string; sequence: number }; + departureAt: string; + arrivalAt: string; + durationMinutes: number; + status: string; + hasAvailability: boolean; + displayCurrency: string; + coachTypes: ScheduleCoachType[]; +} + +export type SearchEmptyReasonCode = + | 'NO_ROUTE' + | 'NO_SCHEDULE_ON_DATE' + | 'CANCELLED' + | 'PACKAGE_ONLY' + | 'CHECKIN_CLOSED' + | 'FULLY_BOOKED'; + +/** Structured, not a string — always render via a code→message lookup, never directly. */ +export interface SearchEmptyReason { + code: SearchEmptyReasonCode; + originStationName: string; + destinationStationName: string; +} + +export interface SearchTripsResponse { + journeyType: string; + outbound: ScheduleResult[]; + requestedDate: string; + outboundReason?: SearchEmptyReason; + /** Nearby schedules for the same station pair on a different date, offered when `outbound` is empty. */ + alternativeOutbound?: ScheduleResult[]; +} + +// ── Seat classes (GET /seat-classes) ─────────────────────────────────────── + +export interface SeatClassOption { + id: string; + name: string; +} + +// ── Auto-assign + hold (POST /seats/auto-assign-hold) ───────────────────── + +export interface AutoAssignHoldRequest { + scheduleId: string; + originStationId: string; + destinationStationId: string; + seatClassName: string; + adultCount: number; + childCount?: number; +} + +export interface HeldPassengerSeat { + passengerId: string; + seat: { + id: string; + label?: string; + seatNumber?: string; + coach?: string; + row?: number; + col?: string; + }; +} + +export interface AutoAssignHoldResponse { + holdId: string; + expiresAt: string; + ttlSeconds: number; + schedule: { id: string; trainNumber: string; trainName: string; departureAt: string; arrivalAt: string } | null; + passengers: HeldPassengerSeat[]; +} + +// ── Group booking creation (POST /bookings/group) ────────────────────────── + +export interface GroupBookingPassengerInput { + seatId: string; + passengerName: string; + dateOfBirth: string; + idDocumentType: 'NATIONAL_ID' | 'PASSPORT' | 'DRIVING_LICENSE' | 'OTHER'; + idDocumentNumber?: string; + passportNumber?: string; + passportCountry?: string; + nationality?: string; + phone?: string; + email?: string; +} + +export interface CreateGroupBookingRequest { + scheduleId: string; + holdId: string; + originStationId: string; + destinationStationId: string; + seatClassId: string; + bookingType: 'ONE_WAY'; + passengers: GroupBookingPassengerInput[]; +} + +export interface GroupBookingSeat { + seatId: string; + passengerName: string; + passengerCategory: 'ADULT' | 'CHILD'; + seat: { seatNumber: string; bedPosition?: string | null; coach: { number: string } }; +} + +export interface CreateGroupBookingResponse { + id: string; + bookingRef: string; + status: string; + totalMinor: number; + currency: string; + adultCount: number; + childCount: number; + seats: GroupBookingSeat[]; + schedule: { + departureAt: string; + arrivalAt: string; + train: { number: string; name: string }; + originStation: { name: string }; + destinationStation: { name: string }; + }; +} + +// ── Payment (GET /payments/methods, POST /payments/initiate) ─────────────── + +export type PaymentMethodType = + | 'TELEBIRR' | 'CBE_BIRR' | 'EBIRR' | 'WAAFI' | 'DMONEY' | 'CAC_BANK' | 'CARD' | 'WALLET' | 'CBE_BILL'; + +export interface SupportedPaymentMethod { + id: string; + type: PaymentMethodType; + displayName: string; + region: string; + currency: string; + enabled: boolean; +} + +export interface InitiatePaymentRequest { + bookingId: string; + method: PaymentMethodType; + paymentMethodId?: string; + platform?: 'web' | 'mobile' | 'inapp'; +} + +export interface PaymentClientAction { + type: 'REDIRECT' | 'LAUNCH_APP' | 'INVOKE_BRIDGE' | 'COLLECT_OTP' | 'AWAIT_PUSH' | 'SHOW_BILL_REFERENCE'; + url?: string; + /** Set when type=SHOW_BILL_REFERENCE (CBE bill payment) — the number the payer enters at any CBE channel. */ + billReference?: string; + instructions?: string; + expiresAt?: string; + message?: string; + payerAccountMasked?: string; +} + +export interface InitiatePaymentResponse { + intentId: string; + status: string; + clientAction?: PaymentClientAction; + merchantOrderId?: string; + failureCode?: string; + failureMessage?: string; + sessionExpiresAt?: string; + paymentDeadline?: string; +} + +export const groupBookingApi = { + searchTrips: (dto: SearchTripsRequest) => + apiClient.post('/search', dto), + + getSeatClasses: () => apiClient.get('/seat-classes'), + + autoAssignHold: (dto: AutoAssignHoldRequest) => + apiClient.post('/seats/auto-assign-hold', dto), + + createGroupBooking: (dto: CreateGroupBookingRequest) => + apiClient.post('/bookings/group', dto), + + getPaymentMethods: () => apiClient.get('/payments/methods'), + + initiatePayment: (dto: InitiatePaymentRequest) => + apiClient.post('/payments/initiate', dto), +}; diff --git a/apps/edr-passenger-web/backoffice/src/lib/export/passenger-template.ts b/apps/edr-passenger-web/backoffice/src/lib/export/passenger-template.ts new file mode 100644 index 000000000..4e2f20964 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/export/passenger-template.ts @@ -0,0 +1,149 @@ +import ExcelJS from 'exceljs'; + +// Brand palette — matches finance-workbook.ts / ActionButton's primary variant, kept as a +// small local copy rather than a shared import since these are two unrelated export domains. +const BRAND = 'FF14714C'; +const BRAND_TINT = 'FFEAF5EF'; +const INK = 'FF1F2937'; +const MUTED = 'FF6B7280'; +const BORDER = 'FFE2E5E1'; +const WHITE = 'FFFFFFFF'; + +const THIN_BORDER: Partial = { + top: { style: 'thin', color: { argb: BORDER } }, + left: { style: 'thin', color: { argb: BORDER } }, + bottom: { style: 'thin', color: { argb: BORDER } }, + right: { style: 'thin', color: { argb: BORDER } }, +}; + +/** Column order is the contract — passenger-excel.ts reads by this same header order. */ +export const PASSENGER_TEMPLATE_COLUMNS = [ + 'Full Name', + 'Date of Birth (YYYY-MM-DD)', + 'Passenger Type', + 'ID Document Type', + 'ID Document Number', + 'Passport Number', + 'Passport Country', + 'Nationality', + 'Phone', + 'Email', +] as const; + +const REQUIRED_ROW = 200; + +export interface PassengerTemplateInput { + trainNumber: string; + origin: string; + destination: string; + travelDate: string; + seatClassName: string; + adultCount: number; + childCount: number; +} + +export async function buildPassengerTemplate(input: PassengerTemplateInput): Promise { + const wb = new ExcelJS.Workbook(); + wb.creator = 'EDR Passenger Backoffice'; + wb.created = new Date(); + + const ws = wb.addWorksheet('Passengers', { views: [{ state: 'frozen', ySplit: 5 }] }); + ws.columns = PASSENGER_TEMPLATE_COLUMNS.map((h) => ({ width: h.length < 14 ? 18 : h.length + 4 })); + + // ── Title + trip context banner ────────────────────────────────────────── + ws.mergeCells(1, 1, 1, PASSENGER_TEMPLATE_COLUMNS.length); + const title = ws.getCell(1, 1); + title.value = 'EDR Group Booking — Passenger Template'; + title.font = { bold: true, size: 16, color: { argb: WHITE } }; + title.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } }; + title.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 }; + ws.getRow(1).height = 30; + for (let c = 1; c <= PASSENGER_TEMPLATE_COLUMNS.length; c++) ws.getCell(1, c).fill = title.fill; + + ws.mergeCells(2, 1, 2, PASSENGER_TEMPLATE_COLUMNS.length); + const subtitle = ws.getCell(2, 1); + subtitle.value = `Train ${input.trainNumber} · ${input.origin} → ${input.destination} · ${input.travelDate} · ${input.seatClassName}`; + subtitle.font = { size: 11, color: { argb: INK } }; + subtitle.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 }; + ws.getRow(2).height = 20; + + ws.mergeCells(3, 1, 3, PASSENGER_TEMPLATE_COLUMNS.length); + const requirement = ws.getCell(3, 1); + const total = input.adultCount + input.childCount; + requirement.value = `Fill in exactly ${total} passenger row${total === 1 ? '' : 's'} below — ${input.adultCount} Adult${input.adultCount === 1 ? '' : 's'} + ${input.childCount} Child${input.childCount === 1 ? '' : 'ren'}. One row per passenger, in any order.`; + requirement.font = { italic: true, size: 10, color: { argb: MUTED } }; + requirement.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 }; + ws.getRow(3).height = 18; + + ws.mergeCells(4, 1, 4, PASSENGER_TEMPLATE_COLUMNS.length); + const instructions = ws.getCell(4, 1); + instructions.value = + 'Columns marked * are required. Date of Birth must be YYYY-MM-DD and not in the future — it determines Adult/Child pricing (under 5 = Child). ' + + 'ID Document Type must be one of: NATIONAL_ID, PASSPORT, DRIVING_LICENSE, OTHER. Do not rename or reorder columns.'; + instructions.font = { size: 9, color: { argb: MUTED } }; + instructions.alignment = { vertical: 'middle', horizontal: 'left', indent: 1, wrapText: true }; + ws.getRow(4).height = 28; + + // ── Header row ──────────────────────────────────────────────────────────── + const headerRow = ws.getRow(5); + const requiredCols = new Set([0, 1, 2, 3]); // Full Name, DOB, Passenger Type, ID Document Type + PASSENGER_TEMPLATE_COLUMNS.forEach((h, i) => { + const cell = headerRow.getCell(i + 1); + cell.value = requiredCols.has(i) ? `${h} *` : h; + cell.font = { bold: true, color: { argb: WHITE }, size: 11 }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } }; + cell.alignment = { vertical: 'middle', horizontal: 'left', wrapText: true }; + cell.border = THIN_BORDER; + }); + headerRow.height = 30; + + // ── One filled example row so the format is obvious at a glance ────────── + const example = ws.getRow(6); + const exampleValues = [ + 'Abebe Kebede', + '1990-05-15', + 'Adult', + 'NATIONAL_ID', + 'ET123456789', + '', + '', + 'Ethiopian', + '+251911234567', + 'abebe@example.com', + ]; + exampleValues.forEach((v, i) => { + const cell = example.getCell(i + 1); + cell.value = v; + cell.font = { italic: true, color: { argb: MUTED } }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND_TINT } }; + cell.border = THIN_BORDER; + }); + + // ── Blank rows with borders + dropdown validation for Passenger Type / ID Document Type ── + // exceljs's types only expose per-cell `cell.dataValidation`, not a worksheet-level range API. + for (let r = 7; r <= REQUIRED_ROW; r++) { + const row = ws.getRow(r); + for (let c = 1; c <= PASSENGER_TEMPLATE_COLUMNS.length; c++) { + row.getCell(c).border = THIN_BORDER; + } + row.getCell(3).dataValidation = { + type: 'list', + allowBlank: true, + formulae: ['"Adult,Child"'], + showErrorMessage: true, + errorTitle: 'Invalid Passenger Type', + error: 'Choose Adult or Child.', + }; + row.getCell(4).dataValidation = { + type: 'list', + allowBlank: true, + formulae: ['"NATIONAL_ID,PASSPORT,DRIVING_LICENSE,OTHER"'], + showErrorMessage: true, + errorTitle: 'Invalid ID Document Type', + error: 'Choose NATIONAL_ID, PASSPORT, DRIVING_LICENSE, or OTHER.', + }; + } + + const buffer = await wb.xlsx.writeBuffer(); + return new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); +} diff --git a/apps/edr-passenger-web/backoffice/src/lib/import/passenger-excel.ts b/apps/edr-passenger-web/backoffice/src/lib/import/passenger-excel.ts new file mode 100644 index 000000000..2a37d3a80 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/import/passenger-excel.ts @@ -0,0 +1,229 @@ +import ExcelJS from 'exceljs'; + +const VALID_ID_TYPES = ['NATIONAL_ID', 'PASSPORT', 'DRIVING_LICENSE', 'OTHER']; + +/** + * Mirrors guest-booking.service.ts's per-passenger nationality inference exactly (NATIONAL_ID + * always forces 'Ethiopian'; PASSPORT falls back to Djiboutian/Other by passport country), so a + * row that will actually be priced at a different fare tier than the one quoted at search time + * is caught here instead of silently mispricing the group later. + */ +function inferredFareTier(docType: string, nationality: string, passportCountry: string): 'LOCAL' | 'INTERNATIONAL' { + const natUpper = nationality.trim().toUpperCase(); + const isEthiopian = natUpper === 'ETHIOPIAN' || docType === 'NATIONAL_ID'; + let resolved = nationality; + if (isEthiopian && docType === 'NATIONAL_ID') resolved = 'Ethiopian'; + else if (!isEthiopian && docType === 'PASSPORT') resolved = nationality || (passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); + else if (isEthiopian && docType === 'PASSPORT') resolved = 'Ethiopian'; + const resolvedUpper = resolved.trim().toUpperCase(); + return resolvedUpper === 'ETHIOPIAN' || resolvedUpper === 'DJIBOUTIAN' ? 'LOCAL' : 'INTERNATIONAL'; +} + +export interface ParsedPassengerRow { + /** 1-based row number in the sheet, for error messages ("row 8"). */ + rowNumber: number; + fullName: string; + dateOfBirth: string; // normalized YYYY-MM-DD, empty if invalid/missing + passengerType: 'Adult' | 'Child' | ''; + idDocumentType: string; + idDocumentNumber: string; + passportNumber: string; + passportCountry: string; + nationality: string; + phone: string; + email: string; + errors: string[]; + warnings: string[]; +} + +export interface ParsePassengerExcelResult { + rows: ParsedPassengerRow[]; + /** Structural problems (wrong file, missing columns) — nothing in `rows` can be trusted if this is non-empty. */ + fileErrors: string[]; +} + +function cellText(row: ExcelJS.Row, colIndex: number): string { + if (colIndex < 1) return ''; + const v = row.getCell(colIndex).value; + if (v === null || v === undefined) return ''; + if (v instanceof Date) return v.toISOString().split('T')[0]; + if (typeof v === 'object') { + const anyV = v as any; + if (typeof anyV.text === 'string') return anyV.text.trim(); + if (anyV.result !== undefined) return String(anyV.result).trim(); + if (anyV.richText) return anyV.richText.map((t: any) => t.text).join('').trim(); + } + return String(v).trim(); +} + +/** Strips a trailing " *" (required-column marker) so header matching survives the template's own formatting. */ +function normalizeHeader(h: string): string { + return h.replace(/\s*\*\s*$/, '').trim(); +} + +export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' | 'INTERNATIONAL'): Promise { + const buffer = await file.arrayBuffer(); + const wb = new ExcelJS.Workbook(); + try { + await wb.xlsx.load(buffer); + } catch { + return { + rows: [], + fileErrors: ['Could not read this file. Make sure it is a valid .xlsx or .xls file exported from the downloaded template.'], + }; + } + + const ws = wb.worksheets[0]; + if (!ws) return { rows: [], fileErrors: ['The workbook has no sheets.'] }; + + // Locate the header row by scanning the first several rows for one starting with "Full Name" — + // the template puts it at row 5 (after the title/instruction banners), but scanning is more + // forgiving of an edited file than hardcoding a row number. + let headerRowIndex = -1; + let headers: string[] = []; + for (let r = 1; r <= 10; r++) { + const row = ws.getRow(r); + const values: string[] = []; + for (let c = 1; c <= 12; c++) values.push(normalizeHeader(cellText(row, c))); + if (values.some((v) => v.toLowerCase().startsWith('full name'))) { + headerRowIndex = r; + headers = values; + break; + } + } + if (headerRowIndex === -1) { + return { + rows: [], + fileErrors: ['Could not find the expected header row (starting with "Full Name"). Please use the downloaded template without changing its structure.'], + }; + } + + const colFor = (label: string) => headers.findIndex((h) => h.toLowerCase().startsWith(label.toLowerCase())) + 1; + const idx = { + fullName: colFor('Full Name'), + dob: colFor('Date of Birth'), + type: colFor('Passenger Type'), + docType: colFor('ID Document Type'), + docNumber: colFor('ID Document Number'), + passportNumber: colFor('Passport Number'), + passportCountry: colFor('Passport Country'), + nationality: colFor('Nationality'), + phone: colFor('Phone'), + email: colFor('Email'), + }; + if (idx.fullName < 1 || idx.dob < 1 || idx.type < 1 || idx.docType < 1) { + return { + rows: [], + fileErrors: ['One or more required columns (Full Name, Date of Birth, Passenger Type, ID Document Type) are missing. Please use the downloaded template.'], + }; + } + + const rows: ParsedPassengerRow[] = []; + const lastRow = ws.actualRowCount || ws.rowCount; + + for (let r = headerRowIndex + 1; r <= lastRow; r++) { + const row = ws.getRow(r); + const fullName = cellText(row, idx.fullName); + const dobRaw = cellText(row, idx.dob); + const typeRaw = cellText(row, idx.type); + const docTypeRaw = cellText(row, idx.docType).toUpperCase(); + const docNumber = cellText(row, idx.docNumber); + const passportNumber = cellText(row, idx.passportNumber); + const passportCountry = cellText(row, idx.passportCountry); + const nationality = cellText(row, idx.nationality); + const phone = cellText(row, idx.phone); + const email = cellText(row, idx.email); + + // Skip fully blank trailing rows (the template pre-formats borders down to row 200). + if (![fullName, dobRaw, typeRaw, docTypeRaw, docNumber, passportNumber, nationality, phone, email].some((v) => v)) { + continue; + } + + const errors: string[] = []; + const warnings: string[] = []; + + if (!fullName) errors.push('Full Name is required'); + + let dateOfBirth = ''; + let ageYears: number | null = null; + if (!dobRaw) { + errors.push('Date of Birth is required'); + } else { + const parsed = new Date(dobRaw); + if (isNaN(parsed.getTime())) { + errors.push(`Date of Birth "${dobRaw}" is not a valid date (use YYYY-MM-DD)`); + } else if (parsed.getTime() > Date.now()) { + errors.push('Date of Birth cannot be in the future'); + } else { + dateOfBirth = parsed.toISOString().split('T')[0]; + ageYears = (Date.now() - parsed.getTime()) / (365.25 * 24 * 60 * 60 * 1000); + } + } + + let passengerType: 'Adult' | 'Child' | '' = ''; + const normalizedType = typeRaw.trim().toLowerCase(); + if (normalizedType === 'adult') passengerType = 'Adult'; + else if (normalizedType === 'child') passengerType = 'Child'; + else errors.push(`Passenger Type "${typeRaw}" must be "Adult" or "Child"`); + + // The backend computes ADULT/CHILD from date of birth alone (under 5 = Child), regardless + // of this column — flag a mismatch so the uploader notices before it surprises them later. + if (passengerType && ageYears !== null) { + const impliedType = ageYears < 5 ? 'Child' : 'Adult'; + if (impliedType !== passengerType) { + warnings.push(`Date of Birth implies ${impliedType}, but Passenger Type is set to ${passengerType} — seats/fare are priced by age, not this column`); + } + } + + if (!docTypeRaw) { + errors.push('ID Document Type is required'); + } else if (!VALID_ID_TYPES.includes(docTypeRaw)) { + errors.push(`ID Document Type "${docTypeRaw}" must be one of NATIONAL_ID, PASSPORT, DRIVING_LICENSE, OTHER`); + } + + if (docTypeRaw === 'PASSPORT' && !passportNumber) { + warnings.push('Passport Number is empty for a PASSPORT document type'); + } + + // The whole group is priced at one uniform fare tier (the one quoted at search time) — a + // row whose document type/nationality would actually resolve to the other tier will be + // priced wrong (over- or under-charged) with no per-passenger fare split to fix it. + if (quotedFareTier && VALID_ID_TYPES.includes(docTypeRaw)) { + const rowTier = inferredFareTier(docTypeRaw, nationality, passportCountry); + if (rowTier !== quotedFareTier) { + warnings.push( + `This passenger's documents imply ${rowTier === 'LOCAL' ? 'Local (Ethiopian/Djiboutian)' : 'International'} pricing, but the group was quoted at ${quotedFareTier === 'LOCAL' ? 'Local' : 'International'} rates — this passenger's actual fare will differ from the group rate`, + ); + } + } + + rows.push({ + rowNumber: r, + fullName, + dateOfBirth, + passengerType, + idDocumentType: docTypeRaw, + idDocumentNumber: docNumber, + passportNumber, + passportCountry, + nationality, + phone, + email, + errors, + warnings, + }); + } + + if (rows.length === 0) { + return { rows: [], fileErrors: ['No passenger rows found below the header. Fill in at least one row and try again.'] }; + } + + return { rows, fileErrors: [] }; +} + +export function countByType(rows: ParsedPassengerRow[]): { adults: number; children: number } { + return { + adults: rows.filter((r) => r.passengerType === 'Adult').length, + children: rows.filter((r) => r.passengerType === 'Child').length, + }; +}