From 8e84fcebd504cbc5f1c867e4b7a338dd301e4e72 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sun, 19 Jul 2026 22:52:23 +0300 Subject: [PATCH 1/3] Passenger list report filter updates --- .../src/modules/reports/reports.service.ts | 16 ++++++- .../src/app/reports/passengers/page.tsx | 43 ++++++++++++------- 2 files changed, 42 insertions(+), 17 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 55f3e21bf..b10d6963f 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -328,7 +328,7 @@ export class ReportsService { _count: { select: { seats: true } }, }, }, - seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } } } } }, + seat: { select: { seatNumber: true, bedPosition: true, coach: { select: { number: true, coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } } } } } }, }, orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }], }); @@ -356,13 +356,25 @@ export class ReportsService { passportNumber: bs.passportNumber, passportCountry: bs.passportCountry, seatLabel: bs.seatLabelSnapshot, + seatNumber: bs.seat?.seatNumber ?? null, + seatClassName: (() => { + const classes = bs.seat?.coach?.coachType?.seatClasses ?? []; + const matched = bs.seat?.bedPosition + ? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === bs.seat!.bedPosition!.toLowerCase()) + : null; + return (matched ?? classes[0])?.name ?? bs.seat?.coach?.coachType?.name ?? null; + })(), coachNumber: bs.seat?.coach?.number ?? null, - coachType: (bs.seat?.coach as any)?.coachType?.name ?? null, + coachType: bs.seat?.coach?.coachType?.name ?? null, + nationality: bs.passportCountry + ? (bs.passportCountry === 'Djibouti' ? 'Djiboutian' : bs.passportCountry) + : bs.idDocumentType === 'NATIONAL_ID' ? 'Ethiopian' : null, origin: bs.booking.originStationId ? (stationName.get(bs.booking.originStationId) ?? null) : null, destination: bs.booking.destinationStationId ? (stationName.get(bs.booking.destinationStationId) ?? null) : null, amountPaidMinor: bs.booking.totalMinor, currency: bs.booking.currency ?? 'ETB', isGroupBooking: (bs.booking._count?.seats ?? 0) > 1, + bookingStatus: bs.booking.status, })); } 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 4e2647877..baa561728 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 @@ -27,13 +27,17 @@ interface PassengerRow { passportNumber: string | null; passportCountry: string | null; seatLabel: string | null; + seatNumber: string | null; + seatClassName: string | null; coachNumber: string | null; coachType: string | null; + nationality: string | null; origin: string | null; destination: string | null; amountPaidMinor: number; currency: string; isGroupBooking: boolean; + bookingStatus: string; } type Tab = 'occupancy' | 'list'; @@ -44,6 +48,7 @@ export default function PassengersReportPage() { const [listSearch, setListSearch] = useState(''); const [filterCoach, setFilterCoach] = useState(''); const [filterOrigin, setFilterOrigin] = useState(''); + const [filterSeatClass, setFilterSeatClass] = useState(''); const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery({ queryKey: ['report-schedules'], @@ -65,11 +70,13 @@ export default function PassengersReportPage() { 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 seatClassOptions = [...new Set(passengerList.map(p => p.seatClassName).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 (filterSeatClass && p.seatClassName !== filterSeatClass) return false; if (listSearch.trim()) { const q = listSearch.toLowerCase(); return ( @@ -126,7 +133,7 @@ export default function PassengersReportPage() { setFilterSeatClass(e.target.value)}> + + {seatClassOptions.map(c => )} + + {passengerList.length > 0 && ( Export CSV )} @@ -314,20 +329,11 @@ export default function PassengersReportPage() { {filteredList.map((p, i) => ( {p.passengerName} - - {p.passportNumber ? ( - <> - {p.passportCountry ?? 'Intl'} - {p.passportNumber} - - ) : ( - {p.idDocumentNumber ?? '—'} - )} + + {[p.nationality, p.passportNumber].filter(Boolean).join(' · ') || '—'} - - {p.coachNumber && p.seatLabel - ? <>{p.coachNumber} · {p.seatLabel}{p.coachType && ({p.coachType})} - : (p.coachNumber ?? p.seatLabel ?? '—')} + + {[p.coachNumber, p.seatNumber ?? p.seatLabel, p.seatClassName].filter(Boolean).join(' · ') || '—'} {p.origin && p.destination ? `${p.origin} → ${p.destination}` : (p.origin ?? p.destination ?? '—')} @@ -342,7 +348,14 @@ export default function PassengersReportPage() { )} - {p.bookingRef} + +
+ {p.bookingRef} + {p.bookingStatus !== 'CONFIRMED' && p.bookingStatus !== 'BOARDED' && ( + {p.bookingStatus.replace('_', ' ')} + )} +
+ ))} {filteredList.length === 0 && ( From 57390647d877b1b63381c4eeb688b036cd54a229 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sun, 19 Jul 2026 23:57:16 +0300 Subject: [PATCH 2/3] Search schedule update --- .../src/common/utils/timezone.utils.ts | 11 +++++------ .../src/modules/search/search.service.ts | 19 ++++++++++--------- .../backoffice/src/app/schedules/page.tsx | 8 ++++---- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/edr-passenger-api/src/common/utils/timezone.utils.ts b/apps/edr-passenger-api/src/common/utils/timezone.utils.ts index d21a8a7dc..325a14d1f 100644 --- a/apps/edr-passenger-api/src/common/utils/timezone.utils.ts +++ b/apps/edr-passenger-api/src/common/utils/timezone.utils.ts @@ -18,12 +18,11 @@ * parseEthiopianTime('2026-06-15') // Treats as midnight EAT */ export function parseEthiopianTime(dateInput: string | Date): Date { - if (dateInput instanceof Date) { - return dateInput; - } - - // Parse as local time (EAT) since TZ is set to Africa/Addis_Ababa - return new Date(dateInput); + if (dateInput instanceof Date) return dateInput; + // Already has timezone info (Z or +HH:MM) — parse directly as UTC + if (/Z$|[+-]\d{2}:\d{2}$/.test(dateInput)) return new Date(dateInput); + // Bare local string (e.g. "2026-07-23T21:00") — treat explicitly as EAT (UTC+3) + return new Date(dateInput + '+03:00'); } /** 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 340d9fac9..91988ba55 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -153,8 +153,8 @@ export class SearchService { nationality?: string, ) { const [y, m, d] = dateStr.split('-').map(Number); - const requestedDate = new Date(y, m - 1, d, 0, 0, 0, 0); - const requestedNextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const requestedDate = new Date(`${String(y)}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}T00:00:00+03:00`); + const requestedNextDay = new Date(`${String(y)}-${String(m).padStart(2,'0')}-${String(d + 1).padStart(2,'0')}T00:00:00+03:00`); const now = new Date(); const totalPassengers = adultCount + (childCount ?? 0); const NEEDED = 3; @@ -213,14 +213,15 @@ export class SearchService { nationality?: string, ) { const [y, m, d] = dateStr.split('-').map(Number); - const date = new Date(y, m - 1, d, 0, 0, 0, 0); - const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const date = new Date(`${String(y)}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}T00:00:00+03:00`); + const nextDay = new Date(`${String(y)}-${String(m).padStart(2,'0')}-${String(d + 1).padStart(2,'0')}T00:00:00+03:00`); const now = new Date(); const totalPassengers = adultCount + (childCount ?? 0); const cutoffHours = await this.getCutoffHours(); const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000); - const isToday = now.getFullYear() === y && now.getMonth() === m - 1 && now.getDate() === d; + const nowInEAT = new Date(now.toLocaleString('en-US', { timeZone: 'Africa/Addis_Ababa' })); + const isToday = nowInEAT.getFullYear() === y && nowInEAT.getMonth() === m - 1 && nowInEAT.getDate() === d; const earliest = isToday ? cutoffThreshold : date; const schedules = await this.prisma.trainSchedule.findMany({ @@ -255,8 +256,8 @@ export class SearchService { nationality?: string, ) { const [y, m, d] = dateStr.split('-').map(Number); - const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0); - const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const dayStart = new Date(`${String(y)}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}T00:00:00+03:00`); + const dayEnd = new Date(`${String(y)}-${String(m).padStart(2,'0')}-${String(d + 1).padStart(2,'0')}T00:00:00+03:00`); const leg2WindowEnd = new Date(dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000); const totalPassengers = adultCount + (childCount ?? 0); @@ -442,8 +443,8 @@ export class SearchService { } const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass, nationality, availabilityByClass); - const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; - const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; + const legDepartureAt = schedule.departureAt; + const legArrivalAt = schedule.arrivalAt; const displayCurrency = faresByClass[0]?.displayCurrency ?? resolveCurrencyFromNationality(nationality); diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index d9a0e7ba8..b02b7ec76 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -300,7 +300,7 @@ export default function SchedulesPage() { const payload: any = { trainId: bulkForm.trainId, routeId: bulkForm.routeId, - startDateTime: bulkForm.startDateTime, + startDateTime: new Date(bulkForm.startDateTime).toISOString(), durationHours: parseInt(bulkForm.durationHours), repeatEveryDays: parseInt(bulkForm.repeatEveryDays), forNextDays: parseInt(bulkForm.forNextDays), @@ -389,11 +389,11 @@ export default function SchedulesPage() { const handleEditClick = (schedule: Schedule) => { setEditingSchedule(schedule); - // Format UTC ISO string as local YYYY-MM-DDTHH:mm for the picker + // Format UTC ISO string as EAT (Africa/Addis_Ababa) YYYY-MM-DDTHH:mm for the picker const toLocalDT = (iso: string) => { - const d = new Date(iso); + const eat = new Date(new Date(iso).toLocaleString('en-US', { timeZone: 'Africa/Addis_Ababa' })); const pad = (n: number) => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + return `${eat.getFullYear()}-${pad(eat.getMonth() + 1)}-${pad(eat.getDate())}T${pad(eat.getHours())}:${pad(eat.getMinutes())}`; }; const depStr = toLocalDT(schedule.departureAt); From c31a337a989eab9182fc253b05c5585467d2363c Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Sun, 19 Jul 2026 23:58:14 +0300 Subject: [PATCH 3/3] Include coach number to voucher --- .../src/app/booking/confirmation/page.tsx | 27 +++++++----- .../portal/src/lib/generate-voucher.ts | 44 ++++++++++++------- 2 files changed, 43 insertions(+), 28 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 7d9cd691a..1039b1465 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -263,21 +263,24 @@ export default function ConfirmationPage() { const ticketNumber = matchedTicket?.barcodePayload || "Not yet issued"; await generatePassengerVoucherPDF({ - bookingRef: pnr, + bookingRef: pnr, ticketNumber, - passengerName: p.name || `Passenger ${i + 1}`, - dateOfBirth: p.dateOfBirth, - nationality: p.nationality, - seatNumber: p.seatNumber, - outboundSeatNumber: (p as any).outboundSeatNumber, - inboundSeatNumber: (p as any).inboundSeatNumber, + passengerName: p.name || `Passenger ${i + 1}`, + dateOfBirth: p.dateOfBirth, + nationality: p.nationality, + seatNumber: p.seatNumber, + coachNumber: p.coachNumber, + outboundSeatNumber: p.outboundSeatNumber, + outboundCoachNumber: p.outboundCoachNumber, + inboundSeatNumber: p.inboundSeatNumber, + inboundCoachNumber: p.inboundCoachNumber, status, - outboundSchedule: outbound, - inboundSchedule: inbound, + outboundSchedule: outbound, + inboundSchedule: inbound, isRoundTrip, - fareMinor: hasSettledAmount ? settledAmountMinor! : getEtbFare(i), - currency: voucherCurrency, - fareIsMajorUnits: hasSettledAmount, + fareMinor: hasSettledAmount ? settledAmountMinor! : getEtbFare(i), + currency: voucherCurrency, + fareIsMajorUnits: hasSettledAmount, createdAt, }); } diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index 894795c52..439c26c29 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -24,8 +24,11 @@ interface PassengerVoucherData { dateOfBirth?: string; nationality?: string; seatNumber?: string; + coachNumber?: string; outboundSeatNumber?: string; + outboundCoachNumber?: string; inboundSeatNumber?: string; + inboundCoachNumber?: string; status: string; outboundSchedule: ScheduleInfo; inboundSchedule?: ScheduleInfo; @@ -314,16 +317,21 @@ function drawPassengerDetails(doc: jsPDF, data: PassengerVoucherData, y: number, label(doc, 'Passenger details', margin, y); y += 7; + const formatSeat = (coach: string | undefined, seat: string | undefined): string => { + if (!seat) return '—'; + return coach ? `Coach ${coach} · Seat ${seat}` : `Seat ${seat}`; + }; + const rows: [string, string][] = [ ['Full name', data.passengerName || '—'], ['Date of birth', data.dateOfBirth ? new Date(data.dateOfBirth).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'], ['Nationality', data.nationality || '—'], ]; if (data.isRoundTrip) { - rows.push(['Outbound seat', data.outboundSeatNumber || '—']); - rows.push(['Return seat', data.inboundSeatNumber || '—']); + rows.push(['Outbound seat', formatSeat(data.outboundCoachNumber, data.outboundSeatNumber)]); + rows.push(['Return seat', formatSeat(data.inboundCoachNumber, data.inboundSeatNumber)]); } else { - rows.push(['Seat', data.seatNumber || '—']); + rows.push(['Seat', formatSeat(data.coachNumber, data.seatNumber)]); } const rowH = 7; @@ -485,9 +493,10 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => // Group leg rows back into one entry per real passenger — without this, a round trip // produced two half-passenger vouchers (one per leg, each showing only its own leg's // seat) instead of one voucher per passenger covering both legs. + type SeatInfo = VoucherData['passengers'][number]['seat']; const grouped = new Map< string, - { fullName: string; category: string; outboundSeat?: VoucherData['passengers'][number]['seat']; returnSeat?: VoucherData['passengers'][number]['seat'] } + { fullName: string; category: string; outboundSeat?: SeatInfo; returnSeat?: SeatInfo } >(); booking.passengers.forEach((p) => { const key = `${p.fullName}|${p.dateOfBirth}|${p.category}`; @@ -514,20 +523,23 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued'; await generatePassengerVoucherPDF({ - bookingRef: booking.bookingRef, + bookingRef: booking.bookingRef, ticketNumber, - passengerName: p.fullName, - status: booking.status, - outboundSchedule: { ...booking.schedule, seatClass: p.outboundSeat?.seatClass }, - inboundSchedule: isRoundTrip ? { ...booking.returnSchedule!, seatClass: p.returnSeat?.seatClass } : undefined, + passengerName: p.fullName, + status: booking.status, + outboundSchedule: { ...booking.schedule, seatClass: p.outboundSeat?.seatClass }, + inboundSchedule: isRoundTrip ? { ...booking.returnSchedule!, seatClass: p.returnSeat?.seatClass } : undefined, isRoundTrip, - seatNumber: isRoundTrip ? undefined : p.outboundSeat?.number, - outboundSeatNumber: isRoundTrip ? p.outboundSeat?.number : undefined, - inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined, - fareMinor: voucherFareMinor, - currency: voucherCurrency, - fareIsMajorUnits: useSettledAmount, - createdAt: booking.createdAt, + seatNumber: isRoundTrip ? undefined : p.outboundSeat?.number, + coachNumber: isRoundTrip ? undefined : p.outboundSeat?.coach, + outboundSeatNumber: isRoundTrip ? p.outboundSeat?.number : undefined, + outboundCoachNumber: isRoundTrip ? p.outboundSeat?.coach : undefined, + inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined, + inboundCoachNumber: isRoundTrip ? p.returnSeat?.coach : undefined, + fareMinor: voucherFareMinor, + currency: voucherCurrency, + fareIsMajorUnits: useSettledAmount, + createdAt: booking.createdAt, }); } };