From 610580e15e1445cbe67b3525cd88fabb4c5d8100 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Fri, 17 Jul 2026 10:20:35 +0300 Subject: [PATCH] Added discrepancy management for duplicate seats --- .../src/modules/seats/duplicate-seats.dto.ts | 33 ++ .../src/modules/seats/seats.controller.ts | 88 +++ .../src/modules/seats/seats.module.ts | 3 +- .../src/modules/seats/seats.service.ts | 414 ++++++++++++++ .../backoffice/src/app/discrepancy/layout.tsx | 5 + .../backoffice/src/app/discrepancy/page.tsx | 519 ++++++++++++++++++ .../src/components/layout/Sidebar.tsx | 4 +- .../backoffice/src/lib/api/index.ts | 4 + 8 files changed, 1068 insertions(+), 2 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts create mode 100644 apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx diff --git a/apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts b/apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts new file mode 100644 index 000000000..9018d77fb --- /dev/null +++ b/apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsDateString, IsOptional, IsUUID } from 'class-validator'; + +export class GetDuplicateSeatsQuery { + @ApiProperty({ example: '2026-07-17', description: 'Schedule date (YYYY-MM-DD)' }) + @IsDateString() + date: string; + + @ApiPropertyOptional({ description: 'Filter to a specific schedule ID' }) + @IsOptional() + @IsUUID() + scheduleId?: string; +} + +export class ResolveDuplicatesDto { + @ApiProperty({ + description: 'BookingSeat IDs of the duplicate bookings to reassign', + type: [String], + example: ['uuid-booking-seat-1', 'uuid-booking-seat-2'], + }) + @IsArray() + @IsUUID(undefined, { each: true }) + bookingSeatIds: string[]; + + @ApiProperty({ + description: 'Coach IDs to source replacement seats from (searched in order; first available seat per coach is used)', + type: [String], + example: ['uuid-coach-1', 'uuid-coach-2'], + }) + @IsArray() + @IsUUID(undefined, { each: true }) + coachIds: string[]; +} 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 596c88bb3..834973c71 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -17,9 +17,11 @@ import { ApiParam, ApiQuery, ApiResponse, + ApiBody, } from "@nestjs/swagger"; import { SeatsService } from "./seats.service"; import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto"; +import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto"; import { JwtGuard } from "../../common/jwt.guard"; import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @@ -306,4 +308,90 @@ This makes it clear which segment of the route each seat is held for, enabling s ) { return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit); } + + // ── Duplicate seat management (backoffice) ──────────────────────────────── + + @Get("duplicates") + @UseGuards(JwtGuard) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ + summary: "List duplicate seat assignments by schedule date", + description: + "Returns all schedules on the given date that have bookings sharing " + + "the same seat, grouped by coach. Each coach entry includes the duplicate " + + "groups (with full booking info) and the list of currently available seats " + + "that can be used for reassignment.", + }) + @ApiQuery({ name: "date", example: "2026-07-17", description: "Schedule date (YYYY-MM-DD)" }) + @ApiQuery({ name: "scheduleId", required: false, description: "Filter to a specific schedule" }) + @ApiResponse({ + status: 200, + description: "Duplicate seat report grouped by schedule → coach", + schema: { + example: { + date: "2026-07-17", + totalDuplicates: 1, + schedules: [{ + scheduleId: "uuid", + departureAt: "2026-07-17T06:00:00.000Z", + origin: "Addis Ababa", + destination: "Dire Dawa", + coaches: [{ + coachId: "uuid", + coachNumber: "C1", + coachTypeName: "SBC", + duplicates: [{ + seatId: "uuid", + seatNumber: "12A", + leg: 1, + bookings: [ + { bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "ATPC9F", passengerName: "Abebe", contactPhone: "+251911000000", createdAt: "2026-07-16T10:00:00.000Z" }, + { bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "XYZ123", passengerName: "Kebede", contactPhone: "+251922000000", createdAt: "2026-07-16T11:00:00.000Z" }, + ], + }], + availableSeats: [ + { seatId: "uuid", seatNumber: "14B" }, + { seatId: "uuid", seatNumber: "15A" }, + ], + }], + }], + }, + }, + }) + getDuplicateSeats(@Query() query: GetDuplicateSeatsQuery) { + return this.service.getDuplicateSeats(query.date, query.scheduleId); + } + + @Post("duplicates/resolve") + @UseGuards(JwtGuard) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ + summary: "Auto-assign duplicate bookings to seats in selected coaches", + description: + "Staff selects which duplicate BookingSeat IDs to fix and which coaches to pull replacement seats from. " + + "The system automatically picks the first available (non-blocked, non-occupied) seat in the given coaches " + + "for each booking, updates BookingSeat + Ticket + JourneySegment atomically so the seatmap reflects the " + + "change immediately, then sends an SMS notification to the passenger. " + + "Coaches are searched in the order provided; seats within each coach are assigned by row then column.", + }) + @ApiBody({ type: ResolveDuplicatesDto }) + @ApiResponse({ + status: 200, + description: "Resolution summary — resolved count, unresolved count, per-booking results", + schema: { + example: { + resolved: 2, + unresolved: 0, + results: [ + { bookingRef: "XYZ123", oldSeatNumber: "1A", newSeatNumber: "14B", contactPhone: "+251922000000" }, + { bookingRef: "ABC456", oldSeatNumber: "1A", newSeatNumber: "15A", contactPhone: "+251933000000" }, + ], + }, + }, + }) + @ApiResponse({ status: 400, description: "Booking not in CONFIRMED/BOARDED status" }) + @ApiResponse({ status: 404, description: "BookingSeat ID not found" }) + resolveDuplicateSeats(@Body() dto: ResolveDuplicatesDto) { + return this.service.resolveDuplicateSeats(dto.bookingSeatIds, dto.coachIds); + } } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.module.ts b/apps/edr-passenger-api/src/modules/seats/seats.module.ts index 0725f4393..2a3c4ee26 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.module.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.module.ts @@ -5,9 +5,10 @@ import { SeatsService } from './seats.service'; import { SegmentsModule } from '../segments/segments.module'; import { SystemConfigModule } from '../system-config/system-config.module'; import { AuditModule } from '../../common/audit.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule], + imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule, NotificationsModule], controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService], 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 10368f380..29d03206c 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -5,6 +5,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { SegmentsService } from '../segments/segments.service'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import { AuditService } from '../../common/audit.service'; +import { SmsClientService } from '../notifications/sms-client.service'; import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; import { checkDirectionConflict } from '../../common/utils/journey-direction.utils'; @@ -17,6 +18,7 @@ export class SeatsService { private segmentsService: SegmentsService, private systemConfig: SystemConfigService, private auditService: AuditService, + private sms: SmsClientService, ) {} async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) { @@ -960,4 +962,416 @@ export class SeatsService { skippedSeatIds: Array.from(skippedSeatIds), // kept for logging/API compat; no DB writes needed }; } + + // ───────────────────────────────────────────────────────────────────────── + // Duplicate-seat management (backoffice) + // ───────────────────────────────────────────────────────────────────────── + + async getDuplicateSeats(date: string, scheduleId?: string) { + const dayStart = new Date(`${date}T00:00:00.000Z`); + const dayEnd = new Date(`${date}T23:59:59.999Z`); + + const schedules = await this.prisma.trainSchedule.findMany({ + where: { + departureAt: { gte: dayStart, lte: dayEnd }, + ...(scheduleId ? { id: scheduleId } : {}), + }, + orderBy: { departureAt: 'asc' }, + include: { + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + coachAssignments: { + orderBy: { positionNumber: 'asc' }, + include: { + coach: { + include: { + coachType: { select: { name: true } }, + seats: { + orderBy: [{ row: 'asc' }, { col: 'asc' }], + select: { id: true, seatNumber: true, status: true, coachId: true }, + }, + }, + }, + }, + }, + }, + }); + + const result = []; + + for (const schedule of schedules) { + // All confirmed BookingSeat rows for this schedule + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { + OR: [ + { scheduleId: schedule.id }, + { scheduleId: null, booking: { scheduleId: schedule.id } }, + ], + booking: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + }, + select: { + id: true, seatId: true, scheduleId: true, leg: true, passengerName: true, + seat: { select: { coachId: true } }, + booking: { + select: { + id: true, bookingRef: true, scheduleId: true, + createdAt: true, contactPhone: true, + }, + }, + }, + }); + + // Seats occupied by any confirmed journey on this schedule (source of truth) + const journeySegments = await this.prisma.journeySegment.findMany({ + where: { + scheduleId: schedule.id, + seatId: { not: null }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, + }, + select: { seatId: true }, + }); + const occupiedIds = new Set(journeySegments.map(js => js.seatId!)); + + // Group BookingSeat rows by (seatId::leg) to detect duplicates + type BS = (typeof bookingSeats)[number]; + const groups = new Map(); + for (const bs of bookingSeats) { + const key = `${bs.seatId}::${bs.leg}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(bs); + } + + // All seats held by any confirmed BookingSeat — union of JourneySegment-based + // occupancy AND BookingSeat-based occupancy so that seats whose JourneySegments + // are missing (e.g. created via enhanced-seats path without bookingId) are still + // excluded from the available list. + const bookedSeatIds = new Set([ + ...occupiedIds, + ...bookingSeats.map(bs => bs.seatId).filter((id): id is string => id !== null && id !== undefined), + ]); + + const coachReports = []; + + for (const assignment of schedule.coachAssignments) { + const coach = assignment.coach; + + // Duplicate groups whose seat belongs to this coach + const duplicates = []; + for (const [key, group] of groups) { + if (group.length <= 1) continue; + if (group[0].seat.coachId !== coach.id) continue; + const [seatId] = key.split('::'); + const seat = coach.seats.find(s => s.id === seatId); + duplicates.push({ + seatId, + seatNumber: seat?.seatNumber ?? seatId, + leg: group[0].leg, + bookings: group.map(bs => ({ + bookingSeatId: bs.id, + bookingId: bs.booking.id, + bookingRef: bs.booking.bookingRef, + passengerName: bs.passengerName, + contactPhone: bs.booking.contactPhone, + createdAt: bs.booking.createdAt, + })), + }); + } + + // Free seats in this coach — excludes BLOCKED, all confirmed BookingSeat + // assignments, and all confirmed JourneySegment occupancies. + const availableSeats = coach.seats + .filter(s => + (s.status as string) !== 'BLOCKED' && + !s.seatNumber.startsWith('-') && + !bookedSeatIds.has(s.id), + ) + .map(s => ({ seatId: s.id, seatNumber: s.seatNumber })); + + coachReports.push({ + coachId: coach.id, + coachNumber: coach.number, + coachTypeName: coach.coachType.name, + duplicates, + availableSeats, + }); + } + + if (coachReports.some(c => c.duplicates.length > 0)) { + result.push({ + scheduleId: schedule.id, + departureAt: schedule.departureAt, + origin: schedule.originStation.name, + destination: schedule.destinationStation.name, + coaches: coachReports, + }); + } + } + + const totalDuplicates = result.reduce( + (sum, s) => sum + s.coaches.reduce((cs, c) => cs + c.duplicates.length, 0), + 0, + ); + + return { date, schedules: result, totalDuplicates }; + } + + async resolveDuplicateSeats(bookingSeatIds: string[], coachIds: string[]) { + if (bookingSeatIds.length === 0) return { resolved: 0, unresolved: 0, results: [] }; + + // Load BookingSeat rows with full booking + schedule context + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { id: { in: bookingSeatIds } }, + select: { + id: true, seatId: true, leg: true, scheduleId: true, + seat: { select: { seatNumber: true } }, + booking: { + select: { + id: true, bookingRef: true, scheduleId: true, + status: true, contactPhone: true, passengerId: true, + totalMinor: true, currency: true, + originStationId: true, destinationStationId: true, + schedule: { + select: { + originStationId: true, + destinationStationId: true, + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + departureAt: true, + }, + }, + }, + }, + }, + }); + + if (bookingSeats.length !== bookingSeatIds.length) { + const found = new Set(bookingSeats.map(bs => bs.id)); + const missing = bookingSeatIds.filter(id => !found.has(id)); + throw new NotFoundException(`BookingSeat(s) not found: ${missing.join(', ')}`); + } + + const invalid = bookingSeats.filter(bs => !['CONFIRMED', 'BOARDED'].includes(bs.booking.status)); + if (invalid.length > 0) { + throw new BadRequestException( + `Bookings must be CONFIRMED or BOARDED: ${invalid.map(bs => bs.booking.bookingRef).join(', ')}`, + ); + } + + // Load all non-blocked, non-removed seats from the selected coaches (ordered for deterministic pick) + const coachSeats = await this.prisma.seat.findMany({ + where: { + coachId: { in: coachIds }, + status: { not: 'BLOCKED' }, + NOT: { seatNumber: { startsWith: '-' } }, + }, + select: { id: true, seatNumber: true, coachId: true, row: true, col: true }, + orderBy: [{ coachId: 'asc' }, { row: 'asc' }, { col: 'asc' }], + }); + + // Build occupied-seat sets per schedule from confirmed JourneySegments + const scheduleIds = [ + ...new Set( + bookingSeats + .map(bs => bs.scheduleId ?? bs.booking.scheduleId) + .filter((id): id is string => id !== null && id !== undefined), + ), + ]; + + const occupiedBySchedule = new Map>(); + await Promise.all( + scheduleIds.map(async scheduleId => { + const segments = await this.prisma.journeySegment.findMany({ + where: { + scheduleId, + seatId: { not: null }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT', 'BOARDED'] } }, + }, + select: { seatId: true }, + }); + occupiedBySchedule.set(scheduleId, new Set(segments.map(s => s.seatId!))); + }), + ); + + // Track seats assigned within this batch to prevent double-assignment + const assignedInBatch = new Set(); + + const results: { bookingRef: string; oldSeatNumber: string; newSeatNumber: string; contactPhone: string | null }[] = []; + const unresolved: { bookingRef: string; reason: string }[] = []; + + for (const bs of bookingSeats) { + const scheduleId = (bs.scheduleId ?? bs.booking.scheduleId)!; + const occupied = occupiedBySchedule.get(scheduleId) ?? new Set(); + + // Pick the first available seat across the selected coaches + const newSeat = coachSeats.find( + seat => + !occupied.has(seat.id) && + !assignedInBatch.has(seat.id) && + seat.id !== bs.seatId, + ); + + if (!newSeat) { + unresolved.push({ + bookingRef: bs.booking.bookingRef, + reason: 'No available seat found in selected coaches', + }); + this.logger.warn( + `Duplicate resolve: no seat available for ${bs.booking.bookingRef} (schedule ${scheduleId})`, + ); + continue; + } + + await this.prisma.$transaction(async tx => { + // 1. Change the seat on the booking and ticket. + await tx.bookingSeat.update({ + where: { id: bs.id }, + data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber }, + }); + await tx.ticket.updateMany({ + where: { bookingId: bs.booking.id, seatId: bs.seatId, leg: bs.leg }, + data: { seatId: newSeat.id }, + }); + + // 2. Point the existing JourneySegments to the new seat. + // The Journey is already linked to this booking via bookingId; + // just update the seatId in its hop rows for this schedule. + const journey = await tx.journey.findFirst({ + where: { bookingId: bs.booking.id }, + select: { id: true }, + }); + + if (!journey) { + // No Journey/JourneySegment for this booking (e.g. duplicate that was never + // processed by finalizePaymentSuccess). Create them now using the same logic, + // scoped to the booking's origin→destination leg so the seatmap shows BOOKED + // only for the correct range of stops. + const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId; + const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId; + + const stopTimes = await tx.tripStopTime.findMany({ + where: { scheduleId }, + orderBy: { sequence: 'asc' }, + select: { stationId: true }, + }); + + const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0; + const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1; + const fromIdx = originIdx >= 0 ? originIdx : 0; + const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1; + + const newJourney = await tx.journey.create({ + data: { + passengerId: bs.booking.passengerId, + bookingId: bs.booking.id, + status: 'CONFIRMED', + totalMinor: bs.booking.totalMinor, + currency: bs.booking.currency, + } as any, + }); + + const segments = []; + for (let i = fromIdx; i < toIdx; i++) { + segments.push({ + journeyId: newJourney.id, + scheduleId, + segmentOrder: i - fromIdx, + seatId: newSeat.id, + coachId: newSeat.coachId, + departureStationId: stopTimes[i].stationId, + arrivalStationId: stopTimes[i + 1].stationId, + }); + } + if (segments.length > 0) { + await tx.journeySegment.createMany({ data: segments, skipDuplicates: true }); + } + this.logger.log( + `No Journey for ${bs.booking.bookingRef} — created Journey + ${segments.length} segment(s) for seat ${newSeat.seatNumber}`, + ); + return; + } + + const { count } = await tx.journeySegment.updateMany({ + where: { journeyId: journey.id, scheduleId, seatId: bs.seatId }, + data: { seatId: newSeat.id }, + }); + + // Journey exists but had no segments (e.g. booking confirmed via a path + // that skipped JourneySegment creation). Create them now for the new seat + // so the seatmap reflects BOOKED. + if (count === 0) { + const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId; + const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId; + const stopTimes = await tx.tripStopTime.findMany({ + where: { scheduleId }, + orderBy: { sequence: 'asc' }, + select: { stationId: true }, + }); + const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0; + const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1; + const fromIdx = originIdx >= 0 ? originIdx : 0; + const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1; + const segments = []; + for (let i = fromIdx; i < toIdx; i++) { + segments.push({ + journeyId: journey.id, + scheduleId, + segmentOrder: i - fromIdx, + seatId: newSeat.id, + coachId: newSeat.coachId, + departureStationId: stopTimes[i].stationId, + arrivalStationId: stopTimes[i + 1].stationId, + }); + } + if (segments.length > 0) { + await tx.journeySegment.createMany({ data: segments, skipDuplicates: true }); + } + this.logger.log( + `Seat reassigned: ${bs.booking.bookingRef} ` + + `${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` + + `(0 existing segments — created ${segments.length} new hop(s))`, + ); + } else { + this.logger.log( + `Seat reassigned: ${bs.booking.bookingRef} ` + + `${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` + + `(${count} segment hop(s) updated)`, + ); + } + }); + + // Mark as taken so the next booking in this batch doesn't get the same seat + assignedInBatch.add(newSeat.id); + occupied.add(newSeat.id); + + const oldSeatNumber = bs.seat?.seatNumber ?? '?'; + const origin = bs.booking.schedule?.originStation?.name ?? ''; + const dest = bs.booking.schedule?.destinationStation?.name ?? ''; + + if (bs.booking.contactPhone) { + const message = + `EDR: Your booking ${bs.booking.bookingRef} (${origin} → ${dest}): ` + + `your seat has been changed from seat ${oldSeatNumber} to seat ${newSeat.seatNumber}. ` + + `We apologize for any inconvenience.`; + await this.sms.sendSms({ to: bs.booking.contactPhone, message }).catch(() => null); + } + + this.logger.log( + `Duplicate resolved: ${bs.booking.bookingRef} seat ${oldSeatNumber} → ${newSeat.seatNumber}`, + ); + + results.push({ + bookingRef: bs.booking.bookingRef, + oldSeatNumber, + newSeatNumber: newSeat.seatNumber, + contactPhone: bs.booking.contactPhone, + }); + } + + return { + resolved: results.length, + unresolved: unresolved.length, + results, + ...(unresolved.length > 0 ? { unresolvedDetails: unresolved } : {}), + }; + } } diff --git a/apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx new file mode 100644 index 000000000..4b98bd933 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function DiscrepancyLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx b/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx new file mode 100644 index 000000000..9545c1d2a --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx @@ -0,0 +1,519 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react'; +import { seatsApi } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; + +// ── Types ───────────────────────────────────────────────────────────────── + +interface DuplicateBooking { + bookingSeatId: string; + bookingId: string; + bookingRef: string; + passengerName: string; + contactPhone: string | null; + createdAt: string; +} + +interface DuplicateSeatGroup { + seatId: string; + seatNumber: string; + leg: number; + bookings: DuplicateBooking[]; +} + +interface AvailableSeat { + seatId: string; + seatNumber: string; +} + +interface CoachReport { + coachId: string; + coachNumber: string; + coachTypeName: string; + duplicates: DuplicateSeatGroup[]; + availableSeats: AvailableSeat[]; +} + +interface ScheduleReport { + scheduleId: string; + origin: string; + destination: string; + departureAt: string; + coaches: CoachReport[]; +} + +interface DuplicatesResponse { + date: string; + schedules: ScheduleReport[]; + totalDuplicates: number; +} + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function today() { + return new Date().toISOString().slice(0, 10); +} + +function scheduleDuplicateCount(s: ScheduleReport) { + return s.coaches.reduce((sum, c) => sum + c.duplicates.length, 0); +} + +// ── Resolve modal ───────────────────────────────────────────────────────── + +interface ResolveModalProps { + schedule: ScheduleReport; + coach: CoachReport; + onClose: () => void; + onSuccess: () => void; +} + +function ResolveModal({ schedule, coach, onClose, onSuccess }: ResolveModalProps) { + // Default: pre-select all-but-first passenger in every duplicate group + const defaultSelected = new Set( + coach.duplicates.flatMap(g => g.bookings.slice(1).map(b => b.bookingSeatId)), + ); + const [selectedSeats, setSelectedSeats] = useState>(defaultSelected); + + // Coaches that have at least one available seat (pre-select all) + const coachesWithSeats = schedule.coaches.filter(c => c.availableSeats.length > 0); + const [selectedCoachIds, setSelectedCoachIds] = useState>( + new Set(coachesWithSeats.map(c => c.coachId)), + ); + + const [successMsg, setSuccessMsg] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + const mutation = useMutation({ + mutationFn: (data: { bookingSeatIds: string[]; coachIds: string[] }) => + seatsApi.resolveDuplicates(data), + onSuccess: (res) => { + setSuccessMsg( + `${res.resolved ?? 0} passenger(s) successfully reassigned.` + + (res.unresolved > 0 ? ` ${res.unresolved} could not be resolved (no available seat).` : ''), + ); + setErrorMsg(null); + onSuccess(); + }, + onError: (err: any) => { + setErrorMsg(err?.response?.data?.message ?? 'Failed to resolve duplicates.'); + }, + }); + + function toggleBookingSeat(id: string) { + setSelectedSeats(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + function toggleCoach(id: string) { + setSelectedCoachIds(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + function handleAssign() { + setErrorMsg(null); + if (selectedSeats.size === 0) { + setErrorMsg('Select at least one passenger to reassign.'); + return; + } + if (selectedCoachIds.size === 0) { + setErrorMsg('Select at least one coach to source the replacement seat from.'); + return; + } + mutation.mutate({ + bookingSeatIds: [...selectedSeats], + coachIds: [...selectedCoachIds], + }); + } + + return ( +
+
+ + {/* Header */} +
+
+

+ Resolve Duplicates — {coach.coachNumber} +

+

+ {schedule.origin} → {schedule.destination} · {formatDateTime(schedule.departureAt)} +

+
+ +
+ +
+ + {/* Duplicate seat groups */} +
+

+ Duplicate seat assignments +

+

+ Check the passengers you want to reassign to a new seat. Unchecked passengers keep their current seat. +

+ + {coach.duplicates.map(group => ( +
+
+ + + Seat {group.seatNumber} — {group.bookings.length} passengers assigned + +
+
+ {group.bookings.map((b, idx) => { + const checked = selectedSeats.has(b.bookingSeatId); + return ( + + ); + })} +
+
+ ))} +
+ + {/* Coach selection */} +
+

+ Reassign to seats in +

+

+ The system picks the first available seat in the selected coaches. +

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

No coaches have available seats on this schedule.

+ ) : ( + coachesWithSeats.map(c => ( + + )) + )} +
+
+ + {/* Feedback */} + {errorMsg && ( +
+ +

{errorMsg}

+
+ )} + {successMsg && ( +
+ +

{successMsg}

+
+ )} +
+ + {/* Footer */} +
+ + {!successMsg && ( + + )} +
+
+
+ ); +} + +// ── Coach card ──────────────────────────────────────────────────────────── + +interface CoachCardProps { + coach: CoachReport; + schedule: ScheduleReport; + onResolve: () => void; +} + +function CoachCard({ coach, schedule, onResolve }: CoachCardProps) { + const [expanded, setExpanded] = useState(false); + const hasDuplicates = coach.duplicates.length > 0; + + return ( +
+ {/* Card header */} +
+
+
+ {coach.coachNumber} + {coach.coachTypeName} +
+
+ {coach.availableSeats.length} available seats + {hasDuplicates && ( + + + {coach.duplicates.length} duplicate{coach.duplicates.length > 1 ? 's' : ''} + + )} +
+
+ +
+ {hasDuplicates && ( + + )} + {hasDuplicates && ( + + )} +
+
+ + {/* Expanded passenger list */} + {expanded && hasDuplicates && ( +
+ {coach.duplicates.map(group => ( +
+

+ Seat {group.seatNumber} — {group.bookings.length} passengers +

+
+ {group.bookings.map((b, idx) => ( +
+ + {idx + 1} + +
+ {b.passengerName || '—'} + {b.bookingRef} +
+ {b.contactPhone ?? '—'} +
+ ))} +
+
+ ))} +
+ )} +
+ ); +} + +// ── Main page ───────────────────────────────────────────────────────────── + +export default function DiscrepancyPage() { + const [date, setDate] = useState(today()); + const [searchDate, setSearchDate] = useState(''); + const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['seat-duplicates', searchDate], + queryFn: () => seatsApi.getDuplicates(searchDate), + enabled: !!searchDate, + }); + + function handleSearch() { + if (date) setSearchDate(date); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') handleSearch(); + } + + return ( +
+ + {/* Page header */} +
+
+ +
+
+

Seat Discrepancy

+

+ Detect and resolve duplicate seat assignments by schedule date +

+
+
+ + {/* Date picker */} +
+ setDate(e.target.value)} + onKeyDown={handleKeyDown} + className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+ + {/* Error */} + {isError && ( +
+ + Failed to load duplicate seat data. Please try again. +
+ )} + + {/* Summary banner */} + {data && ( +
0 + ? 'bg-orange-50 dark:bg-orange-950/20 border-orange-200 dark:border-orange-800' + : 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800' + }`}> + {data.totalDuplicates > 0 ? ( + + ) : ( + + )} + 0 + ? 'text-orange-800 dark:text-orange-300' + : 'text-green-800 dark:text-green-300' + }`}> + {data.totalDuplicates > 0 + ? `${data.totalDuplicates} duplicate seat assignment${data.totalDuplicates > 1 ? 's' : ''} found across ${data.schedules.length} schedule${data.schedules.length > 1 ? 's' : ''} on ${data.date}` + : `No duplicate seat assignments found on ${data.date}`} + +
+ )} + + {/* Results per schedule */} + {data?.schedules.map(schedule => ( +
+ {/* Schedule header */} +
+
+

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

+

+ {formatDateTime(schedule.departureAt)} · {scheduleDuplicateCount(schedule)} duplicate{scheduleDuplicateCount(schedule) !== 1 ? 's' : ''} +

+
+
+ + {/* Coach cards grid */} +
+ {schedule.coaches.map(coach => ( + setResolveTarget({ schedule, coach })} + /> + ))} +
+
+ ))} + + {/* Empty state when searched but no results */} + {data && data.schedules.length === 0 && data.totalDuplicates === 0 && searchDate && ( +
+ +

All seats are correctly assigned for {data.date}

+
+ )} + + {/* Resolve modal */} + {resolveTarget && ( + setResolveTarget(null)} + onSuccess={() => { + refetch(); + // Keep modal open to show success message; user closes manually + }} + /> + )} +
+ ); +} 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 45da6d8ca..62fa655c7 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -37,6 +37,7 @@ import { Banknote, Activity, Smartphone, + Layers, } from 'lucide-react'; import { useAuthStore } from '@/lib/auth-store'; import { cn } from '@/lib/utils'; @@ -64,7 +65,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { 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.view }, - { name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view }, + { name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view }, + { name: 'Discrepancy', href: '/discrepancy', icon: Layers, permission: PERMS.seats.manage }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index e8ed56195..2453eafa2 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -159,6 +159,10 @@ export const seatsApi = { undoRemove: (seatId: string) => apiClient.patch(`/seats/${seatId}/undo-remove`, {}), setMaintenance: (seatId: string, reason: string) => apiClient.post(`/seats/${seatId}/maintenance`, { reason }), clearMaintenance: (seatId: string) => apiClient.delete(`/seats/${seatId}/maintenance`), + getDuplicates: (date: string, scheduleId?: string) => + apiClient.get(`/seats/duplicates?date=${date}${scheduleId ? `&scheduleId=${scheduleId}` : ''}`), + resolveDuplicates: (data: { bookingSeatIds: string[]; coachIds: string[] }) => + apiClient.post('/seats/duplicates/resolve', data), }; // Payments API