From 80915f94ec3e0edbff382808753339beeabc4667 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sun, 19 Jul 2026 17:21:44 +0300 Subject: [PATCH 1/2] Passenger reports filter updates --- .../src/modules/reports/reports.service.ts | 13 ++- .../src/app/reports/passengers/page.tsx | 105 ++++++++++++------ 2 files changed, 84 insertions(+), 34 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 785bf3c17..5fa590620 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -311,11 +311,20 @@ export class ReportsService { booking: { status: { in: ['CONFIRMED', 'BOARDED'] } }, }, include: { - booking: { select: { bookingRef: true, status: true } }, + booking: { select: { bookingRef: true, status: true, originStationId: true, destinationStationId: true } }, seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } } } } }, }, orderBy: [{ seat: { coach: { number: 'asc' } } }], }); + + const stationIds = [...new Set( + seats.flatMap(bs => [bs.booking.originStationId, bs.booking.destinationStationId]).filter(Boolean) as string[] + )]; + const stations = stationIds.length + ? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } }) + : []; + const stationMap = new Map(stations.map(s => [s.id, s.name])); + return seats.map(bs => ({ bookingRef: bs.booking.bookingRef, bookingStatus: bs.booking.status, @@ -328,6 +337,8 @@ export class ReportsService { seatLabel: bs.seatLabelSnapshot, coachNumber: bs.seat?.coach?.number ?? null, coachType: (bs.seat?.coach as any)?.coachType?.name ?? null, + origin: bs.booking.originStationId ? (stationMap.get(bs.booking.originStationId) ?? null) : null, + destination: bs.booking.destinationStationId ? (stationMap.get(bs.booking.destinationStationId) ?? null) : null, })); } diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index d80110aa7..60d5cea09 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -9,10 +9,7 @@ import { formatDateTime } from '@/lib/utils'; interface ScheduleOption { id: string; label: string; } -interface ScheduleOption { id: string; label: string; } - interface PassengersReport { - schedule: { id: string; trainName: string; origin: string; destination: string; departureAt: string; arrivalAt: string; }; schedule: { id: string; trainName: string; origin: string; destination: string; departureAt: string; arrivalAt: string; }; summary: { totalSeats: number; totalPassengers: number; occupancyRate: number }; byCoach: { coachNumber: string; coachType: string; totalSeats: number; booked: number; occupancyRate: number }[]; @@ -30,9 +27,12 @@ interface PassengerRow { idDocumentNumber: string | null; passportNumber: string | null; passportCountry: string | null; + nationality: string | null; seatLabel: string | null; coachNumber: string | null; coachType: string | null; + origin: string | null; + destination: string | null; } type Tab = 'occupancy' | 'list'; @@ -41,6 +41,8 @@ export default function PassengersReportPage() { const [scheduleId, setScheduleId] = useState(''); const [tab, setTab] = useState('occupancy'); const [listSearch, setListSearch] = useState(''); + const [filterCoach, setFilterCoach] = useState(''); + const [filterOrigin, setFilterOrigin] = useState(''); const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery({ queryKey: ['report-schedules'], @@ -60,14 +62,23 @@ export default function PassengersReportPage() { enabled: !!scheduleId, }); - const filteredList = listSearch.trim() - ? passengerList.filter(p => - p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) || - p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()) || - (p.idDocumentNumber ?? '').toLowerCase().includes(listSearch.toLowerCase()) || - (p.passportNumber ?? '').toLowerCase().includes(listSearch.toLowerCase()), - ) - : passengerList; + const coachOptions = [...new Set(passengerList.map(p => p.coachNumber).filter(Boolean))].sort() as string[]; + const originOptions = [...new Set(passengerList.map(p => p.origin).filter(Boolean))].sort() as string[]; + + const filteredList = passengerList.filter(p => { + if (filterCoach && p.coachNumber !== filterCoach) return false; + if (filterOrigin && p.origin !== filterOrigin) return false; + if (listSearch.trim()) { + const q = listSearch.toLowerCase(); + return ( + p.passengerName.toLowerCase().includes(q) || + p.bookingRef.toLowerCase().includes(q) || + (p.idDocumentNumber ?? '').toLowerCase().includes(q) || + (p.passportNumber ?? '').toLowerCase().includes(q) + ); + } + return true; + }); const downloadCsv = (csv: string, filename: string) => { const blob = new Blob([csv], { type: 'text/csv' }); @@ -85,11 +96,12 @@ export default function PassengersReportPage() { const doExportList = () => { if (!passengerList.length) return; - const headers = ['Booking Ref', 'Status', 'Name', 'Category', 'ID Type', 'ID Number', 'Passport', 'Country', 'Seat', 'Coach', 'Class']; + const headers = ['Booking Ref', 'Status', 'Name', 'Category', 'Nationality / Passport', 'Coach · Seat', 'Origin', 'Destination']; const rows = passengerList.map(p => [ p.bookingRef, p.bookingStatus, p.passengerName, p.passengerCategory, - p.idDocumentType ?? '', p.idDocumentNumber ?? '', p.passportNumber ?? '', - p.passportCountry ?? '', p.seatLabel ?? '', p.coachNumber ?? '', p.coachType ?? '', + p.passportNumber ? `${p.passportCountry ?? ''} · ${p.passportNumber}` : (p.idDocumentNumber ?? ''), + p.coachNumber && p.seatLabel ? `${p.coachNumber} · ${p.seatLabel}` : (p.coachNumber ?? p.seatLabel ?? ''), + p.origin ?? '', p.destination ?? '', ].map(v => `"${String(v).replace(/"/g, '""')}"`)); downloadCsv([headers.join(','), ...rows.map(r => r.join(','))].join('\n'), `passengers-${scheduleId}.csv`); }; @@ -109,7 +121,7 @@ export default function PassengersReportPage() { setListSearch(e.target.value)} - /> +
+ setListSearch(e.target.value)} + /> + + +
@@ -287,9 +317,10 @@ export default function PassengersReportPage() { - - - + + + + @@ -304,15 +335,23 @@ export default function PassengersReportPage() { {p.passengerCategory} - - - + + ))} {filteredList.length === 0 && ( - + )}
# Name CategoryID / PassportSeatCoachNationality / PassportCoach · SeatOriginDestination Booking Ref Status
- {p.idDocumentNumber ?? p.passportNumber ?? '—'} - {p.passportCountry && ({p.passportCountry})} + + {p.passportNumber ? ( + <> + {p.passportCountry ?? 'Intl'} + {p.passportNumber} + + ) : ( + {p.idDocumentNumber ?? '—'} + )} {p.seatLabel ?? '—'} - {p.coachNumber ?? '—'} - {p.coachType && ({p.coachType})} + + {p.coachNumber && p.seatLabel + ? `${p.coachNumber} · ${p.seatLabel}` + : (p.coachNumber ?? p.seatLabel ?? '—')} {p.origin ?? '—'}{p.destination ?? '—'} {p.bookingRef} @@ -322,7 +361,7 @@ export default function PassengersReportPage() {
No passengers found
No passengers found
From f57f8d4550ae60b34eb43a7f6d9faff61e495e4f Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Sun, 19 Jul 2026 18:50:37 +0300 Subject: [PATCH 2/2] Fix blocked seat count on booking result --- .../src/modules/search/search.service.ts | 14 ++++++++-- .../src/modules/seats/seats.service.ts | 27 ++++++++++++++++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 29be84adc..89dfe0ca0 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -390,8 +390,8 @@ export class SearchService { // Exclude schedules with no seats at all if (allValidSeatIds.length === 0) return null; - // Run availability batch and fare calculation in parallel - const [freeSeats, faresByClass] = await Promise.all([ + // Run availability batch, fare calculation, and schedule-scoped seat blocks in parallel + const [freeSeatsRaw, faresByClass, scheduleBlocks] = await Promise.all([ this.segmentsService.getFreeSeatIds( schedule.id, allValidSeatIds, @@ -400,7 +400,17 @@ export class SearchService { destStop.sequence, ), this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality), + this.prisma.seatBlock.findMany({ + where: { + scheduleId: schedule.id, + seatId: { in: allValidSeatIds }, + }, + select: { seatId: true }, + }), ]); + const scheduleBlockedIds = new Set(scheduleBlocks.map((b: any) => b.seatId)); + // Seats that are free from holds/bookings AND not schedule-blocked + const freeSeats = new Set([...freeSeatsRaw].filter(id => !scheduleBlockedIds.has(id))); // Compute per-class availability using the pre-computed free seat set. A coach type // has separate seat classes per nationality tier (e.g. "VIP Bed Upper (Local)" AND 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 91af5e11d..abb747710 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -325,6 +325,18 @@ export class SeatsService { throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`); const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber])); + + // Schedule-scoped blocks prevent holding a seat on this specific schedule + // even if its global Seat.status is AVAILABLE. + const scheduleBlockRecords = await tx.seatBlock.findMany({ + where: { scheduleId: dto.scheduleId, seatId: { in: seatIds } }, + select: { seatId: true }, + }); + if (scheduleBlockRecords.length > 0) { + const blockedNums = scheduleBlockRecords.map(b => seatLabelById[b.seatId]).join(', '); + throw new ConflictException(`Seat(s) ${blockedNums} are blocked for this schedule`); + } + const stopTimes = await tx.tripStopTime.findMany({ where: { scheduleId: dto.scheduleId }, select: { stationId: true, sequence: true }, @@ -711,11 +723,18 @@ export class SeatsService { const reqFrom = seqOf(schedule.originStationId) ?? 0; const reqTo = seqOf(schedule.destinationStationId) ?? stopTimes.length; - const unavailable = await this.segmentsService.getSeatAvailabilityMap( - scheduleId, allSeatIds, stopTimes, reqFrom, reqTo, - ); + const [unavailable, scheduleBlocks] = await Promise.all([ + this.segmentsService.getSeatAvailabilityMap( + scheduleId, allSeatIds, stopTimes, reqFrom, reqTo, + ), + this.prisma.seatBlock.findMany({ + where: { scheduleId, seatId: { in: allSeatIds } }, + select: { seatId: true }, + }), + ]); + const scheduleBlockedIds = new Set(scheduleBlocks.map(b => b.seatId)); - const availableSeats = seats.filter(s => !unavailable.has(s.id)); + 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}`);