From ef2fb2e05ccf638c580865fc7071fb8d89474027 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Mon, 20 Jul 2026 16:10:28 +0300 Subject: [PATCH] Expand to show multiple passengers in payment discrepancy --- .../src/modules/reports/reports.service.ts | 68 ++++-- .../app/reports/payment-discrepancy/page.tsx | 204 ++++++++++++------ 2 files changed, 191 insertions(+), 81 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 d44100fc8..6236422bb 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -629,6 +629,28 @@ export class ReportsService { if (params.from) dateFilter.gte = new Date(params.from + 'T00:00:00.000Z'); if (params.to) dateFilter.lte = new Date(params.to + 'T23:59:59.999Z'); + const seatSelect = { + where: { leg: 1 }, + orderBy: [ + { seat: { coach: { number: 'asc' as const } } }, + { seat: { seatNumber: 'asc' as const } }, + ], + select: { + passengerName: true, + passengerCategory: true, + seatLabelSnapshot: true, + fareMinor: true, + displayFareMinor: true, + displayCurrency: true, + seat: { + select: { + seatNumber: true, + coach: { select: { number: true, coachType: { select: { name: true } } } }, + }, + }, + }, + }; + const bookings = await this.prisma.booking.findMany({ where: { status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any }, @@ -643,21 +665,7 @@ export class ReportsService { destinationStation: { select: { name: true, code: true, city: true } }, }, }, - seats: { - take: 1, - orderBy: { leg: 'asc' }, - select: { - passengerName: true, - seatLabelSnapshot: true, - seat: { - select: { - seatNumber: true, - bedPosition: true, - coach: { select: { number: true, coachType: { select: { name: true } } } }, - }, - }, - }, - }, + seats: seatSelect, passenger: { select: { user: { select: { phone: true, fullName: true } } }, }, @@ -684,6 +692,7 @@ export class ReportsService { const balanceCurrency = 'ETB'; const firstSeat = b.seats[0]; + const passengers = this.buildSeatPassengers(b.seats, actualCurrency); return { pnr: b.bookingRef, passengerName: firstSeat?.passengerName ?? b.passenger?.user?.fullName ?? '—', @@ -700,6 +709,8 @@ export class ReportsService { paidCurrency, balanceMinor, balanceCurrency, + passengerCount: passengers.length, + passengers, }; }) .filter(r => r.balanceMinor > 0); @@ -749,15 +760,21 @@ export class ReportsService { }, }, seats: { - take: 1, - orderBy: { leg: 'asc' }, + where: { leg: 1 }, + orderBy: [ + { seat: { coach: { number: 'asc' } } }, + { seat: { seatNumber: 'asc' } }, + ], select: { passengerName: true, + passengerCategory: true, seatLabelSnapshot: true, + fareMinor: true, + displayFareMinor: true, + displayCurrency: true, seat: { select: { seatNumber: true, - bedPosition: true, coach: { select: { number: true, coachType: { select: { name: true } } } }, }, }, @@ -783,6 +800,7 @@ export class ReportsService { const balanceCurrency = 'ETB'; const firstSeat = b.seats[0]; + const passengers = this.buildSeatPassengers(b.seats, actualCurrency); const row = { pnr: b.bookingRef, passengerName: firstSeat?.passengerName ?? b.passenger?.user?.fullName ?? '—', @@ -801,6 +819,8 @@ export class ReportsService { balanceCurrency, bookingStatus: b.status, paymentStatus: pi?.status ?? null, + passengerCount: passengers.length, + passengers, }; return { @@ -811,6 +831,18 @@ export class ReportsService { }; } + private buildSeatPassengers(seats: any[], fallbackCurrency: string) { + return seats.map(s => ({ + name: s.passengerName as string, + category: s.passengerCategory as string, + seatNumber: (s.seat?.seatNumber ?? null) as string | null, + coachNumber: (s.seat?.coach?.number ?? null) as string | null, + seatType: (s.seatLabelSnapshot ?? s.seat?.coach?.coachType?.name ?? null) as string | null, + fareMinor: (s.displayFareMinor ?? s.fareMinor ?? null) as number | null, + fareCurrency: ((s.displayCurrency as string | null) ?? fallbackCurrency), + })); + } + async getReport(reportId: string) { return this.prisma.operationalReport.findUnique({ where: { id: reportId }, diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx index 168003887..80c4481ce 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { AlertTriangle, CheckCircle2, Download, Search, - Loader2, PhoneCall, RefreshCw, X, + Loader2, PhoneCall, RefreshCw, X, Users, ChevronDown, ChevronUp, } from 'lucide-react'; import { apiClient } from '@/lib/api-client'; import DatePicker from '@/components/ui/DatePicker'; @@ -18,6 +18,16 @@ interface Station { city: string; } +interface PassengerDetail { + name: string; + category: string; + seatNumber: string | null; + coachNumber: string | null; + seatType: string | null; + fareMinor: number | null; + fareCurrency: string; +} + interface DiscrepancyRow { pnr: string; passengerName: string; @@ -34,6 +44,8 @@ interface DiscrepancyRow { paidCurrency: string; balanceMinor: number; balanceCurrency: string; + passengerCount: number; + passengers: PassengerDetail[]; bookingStatus?: string; paymentStatus?: string | null; } @@ -109,6 +121,7 @@ export default function PaymentDiscrepancyPage() { const [sortBy, setSortBy] = useState<'balance' | 'departure'>('balance'); const [search, setSearch] = useState(''); const [applied, setApplied] = useState(null); + const [expandedPnr, setExpandedPnr] = useState(null); const isSearchMode = !!(applied?.search); @@ -305,7 +318,7 @@ export default function PaymentDiscrepancyPage() { {[ 'Passenger', 'PNR', 'Booking Date', 'Route', 'Departure', - 'Seat Type', 'Actual Price', 'Paid', 'Balance', 'Phone', + 'Seat Type', 'Actual Price', 'Paid', 'Balance', 'Passengers', 'Phone', ].map(h => ( - {rows.map((row, i) => ( - - - {row.passengerName} - - - - {row.pnr} - - - - {new Date(row.bookingDate).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })} - - - - {row.origin.city || row.origin.name} - - - - {row.destination.city || row.destination.name} - - - - {new Date(row.departureAt).toLocaleString('en-US', { - month: 'short', day: 'numeric', - hour: 'numeric', minute: '2-digit', hour12: true, - timeZone: 'Africa/Addis_Ababa', - })} - - -
{row.seatType}
- {row.coachNumber && ( -
Coach {row.coachNumber}
+ {rows.map((row, i) => { + const isExpanded = expandedPnr === row.pnr; + const hasMultiple = row.passengerCount > 1; + return ( + <> + + + {row.passengerName} + + + + {row.pnr} + + + + {new Date(row.bookingDate).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })} + + + + {row.origin.city || row.origin.name} + + + + {row.destination.city || row.destination.name} + + + + {new Date(row.departureAt).toLocaleString('en-US', { + month: 'short', day: 'numeric', + hour: 'numeric', minute: '2-digit', hour12: true, + timeZone: 'Africa/Addis_Ababa', + })} + + +
{row.seatType}
+ {row.coachNumber && ( +
Coach {row.coachNumber}
+ )} + + + {fmtMoney(row.actualMinor, row.actualCurrency)} + + + {fmtMoney(row.paidMinor, row.paidCurrency)} + + + + + {/* Passengers column */} + + {hasMultiple ? ( + + ) : ( + + 1 + + )} + + + {row.phone && row.phone !== '—' ? ( + + + {row.phone} + + ) : ( + + )} + + + + {/* Expandable passenger breakdown */} + {isExpanded && ( + + +
+ + Passengers under {row.pnr} +
+ + + + + + + + + + + + + + {row.passengers.map((p, pi) => ( + + + + + + + + + + ))} + +
#NameCategoryCoachSeatClassFare
{pi + 1}{p.name}{p.category.toLowerCase()}{p.coachNumber ?? '—'}{p.seatNumber ?? '—'}{p.seatType ?? '—'} + {p.fareMinor != null + ? fmtMoney(p.fareMinor, p.fareCurrency) + : '—'} +
+ + )} - - - {fmtMoney(row.actualMinor, row.actualCurrency)} - - - {fmtMoney(row.paidMinor, row.paidCurrency)} - - - - - - {row.phone && row.phone !== '—' ? ( - - - {row.phone} - - ) : ( - - )} - - - ))} + + ); + })}