From 99eb35ff75a99f2c804f14119fc52a7658b68e01 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 20 Jul 2026 21:50:34 +0300 Subject: [PATCH] Payment discrepancy report updates --- .../src/modules/reports/reports.controller.ts | 17 + .../src/modules/reports/reports.service.ts | 151 +++++ .../src/app/reports/passengers/page.tsx | 17 + .../app/reports/payment-discrepancy/page.tsx | 6 +- .../src/app/reports/payments/layout.tsx | 3 + .../src/app/reports/payments/page.tsx | 536 ++++++++++++++++++ .../src/components/layout/Sidebar.tsx | 10 +- 7 files changed, 733 insertions(+), 7 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/payments/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/payments/page.tsx diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 8dceea63a..7df394090 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -47,6 +47,23 @@ export class ReportsController { return this.service.getPaymentDiscrepancyReport({ from, to, sortBy, search }); } + @Get("payments") + @ApiOperation({ summary: "Payments collected for a schedule" }) + getPaymentsReport(@Query('scheduleId') scheduleId: string) { + return this.service.getPaymentsReport(scheduleId); + } + + @Get("payments/discrepancy") + @ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" }) + getPaymentDiscrepancyBySchedule( + @Query('scheduleId') scheduleId: string, + @Query('search') search?: string, + @Query('seatClass') seatClass?: string, + @Query('sort') sort?: string, + ) { + return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort }); + } + @Get(":reportId") @ApiOperation({ summary: "Get report by ID" }) getReport(@Param("reportId") reportId: string) { 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 6236422bb..9067ffe56 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -843,6 +843,157 @@ export class ReportsService { })); } + async getPaymentsReport(scheduleId: string) { + const bookings = await this.prisma.booking.findMany({ + where: { + scheduleId, + status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any }, + paymentIntent: { status: 'SUCCEEDED' }, + }, + include: { + paymentIntent: { select: { amountMinor: true, currency: true, method: true, paidAt: true } }, + seats: { + where: { leg: 1 }, + select: { + passengerName: true, + fareMinor: true, + passengerCategory: true, + seatLabelSnapshot: true, + seat: { select: { coach: { select: { number: true, coachType: { select: { name: true } } } } } }, + }, + }, + passenger: { select: { user: { select: { phone: true, fullName: true } } } }, + }, + }); + + const rows = bookings.map(b => { + const actualMinor = b.seats.reduce((s, seat) => s + (seat.fareMinor ?? 0), 0); + const paidMinor = Math.round(b.paymentIntent!.amountMinor); + return { + bookingRef: b.bookingRef, + passengerName: b.seats[0]?.passengerName ?? b.passenger?.user?.fullName ?? '—', + phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—', + method: b.paymentIntent!.method, + paidAt: b.paymentIntent!.paidAt, + actualMinor, + paidMinor, + currency: 'ETB', + passengerCount: b.seats.length, + }; + }); + + const totalActualMinor = rows.reduce((s, r) => s + r.actualMinor, 0); + const totalPaidMinor = rows.reduce((s, r) => s + r.paidMinor, 0); + + const byMethod = rows.reduce((acc, r) => { + acc[r.method] = (acc[r.method] ?? 0) + r.paidMinor; + return acc; + }, {} as Record); + + return { totalActualMinor, totalPaidMinor, byMethod, rows }; + } + + async getPaymentDiscrepancyBySchedule(scheduleId: string, params: { + search?: string; + seatClass?: string; + sort?: string; + }) { + const bookings = await this.prisma.booking.findMany({ + where: { + scheduleId, + status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any }, + paymentIntent: { status: 'SUCCEEDED' }, + }, + include: { + paymentIntent: { select: { amountMinor: true, currency: true } }, + schedule: { + include: { + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + }, + seats: { + where: { leg: 1 }, + orderBy: [ + { seat: { coach: { number: 'asc' as const } } }, + { seat: { seatNumber: 'asc' as const } }, + ], + select: { + passengerName: true, + passengerCategory: true, + seatLabelSnapshot: true, + fareMinor: true, + seat: { + select: { + seatNumber: true, + coach: { select: { number: true, coachType: { select: { name: true } } } }, + }, + }, + }, + }, + passenger: { select: { user: { select: { phone: true, fullName: true } } } }, + }, + }); + + let rows = bookings.map(b => { + const pi = b.paymentIntent!; + const actualMinor = b.seats.reduce((s, seat) => s + (seat.fareMinor ?? 0), 0); + const paidMinor = Math.round(pi.amountMinor); + const varianceMinor = actualMinor - paidMinor; + + // Per-seat-class breakdown + const byClass = new Map(); + for (const s of b.seats) { + const key = s.seatLabelSnapshot ?? s.seat?.coach?.coachType?.name ?? 'Unknown'; + if (!byClass.has(key)) byClass.set(key, []); + byClass.get(key)!.push({ + seatClass: key, + coachNumber: s.seat?.coach?.number ?? null, + seatNumber: s.seat?.seatNumber ?? null, + fareMinor: s.fareMinor ?? 0, + }); + } + + const breakdown = [...byClass.entries()].map(([seatClass, seats]) => ({ + seatClass, + seats: seats.map(s => ({ coachNumber: s.coachNumber, seatNumber: s.seatNumber })), + totalFareMinor: seats.reduce((s, x) => s + x.fareMinor, 0), + count: seats.length, + })); + + const firstSeat = b.seats[0]; + return { + bookingRef: b.bookingRef, + seatClass: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—', + coachNumber: firstSeat?.seat?.coach?.number ?? null, + seatNumber: firstSeat?.seat?.seatNumber ?? null, + origin: b.schedule.originStation.name, + destination: b.schedule.destinationStation.name, + phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—', + actualMinor, + paidMinor, + varianceMinor, + breakdown, + }; + }).filter(r => r.varianceMinor > 0); + + if (params.search?.trim()) { + const q = params.search.trim().toUpperCase(); + rows = rows.filter(r => r.bookingRef.toUpperCase().includes(q)); + } + if (params.seatClass?.trim()) { + const sc = params.seatClass.trim().toLowerCase(); + rows = rows.filter(r => r.breakdown.some(b => b.seatClass.toLowerCase().includes(sc))); + } + if (params.sort === 'asc') { + rows.sort((a, b) => a.varianceMinor - b.varianceMinor); + } else { + rows.sort((a, b) => b.varianceMinor - a.varianceMinor); + } + + return { total: rows.length, rows }; + } + async getReport(reportId: string) { return this.prisma.operationalReport.findUnique({ where: { id: reportId }, 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 93470daeb..a028faa8c 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 @@ -76,6 +76,7 @@ export default function PassengersReportPage() { const [filterCoach, setFilterCoach] = useState(""); const [filterOrigin, setFilterOrigin] = useState(""); const [filterSeatClass, setFilterSeatClass] = useState(""); + const [filterCoachNumber, setFilterCoachNumber] = useState(""); const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery< ScheduleOption[] @@ -110,10 +111,12 @@ export default function PassengersReportPage() { const originOptions = [ ...new Set(passengerList.map((p) => p.origin).filter(Boolean)), ].sort() as string[]; + const coachNumberOptions = coachOptions; const filteredList = passengerList .filter((p) => { if (filterCoach && p.coachNumber !== filterCoach) return false; + if (filterCoachNumber && p.coachNumber !== filterCoachNumber) return false; if (filterOrigin && p.origin !== filterOrigin) return false; if (filterSeatClass && p.seatClassName !== filterSeatClass) return false; if (listSearch.trim()) { @@ -209,7 +212,9 @@ export default function PassengersReportPage() { setTab("occupancy"); setListSearch(""); setFilterCoach(""); + setFilterCoachNumber(""); setFilterOrigin(""); + setFilterSeatClass(""); }} disabled={loadingSchedules} > @@ -477,6 +482,18 @@ export default function PassengersReportPage() { ))} + setSearch(e.target.value.toUpperCase())} + placeholder="Booking ref…" + className="input pl-8 pr-7 font-mono text-sm w-full" + /> + {search && ( + + )} + + + + {data && data.rows.length > 0 && ( + + )} + + + {isLoading && ( +
+ Loading… +
+ )} + {isError &&

Failed to load discrepancy data.

} + + {data && ( +
+
+

+ {data.total} discrepanc{data.total !== 1 ? 'ies' : 'y'} found +

+ {data.rows.length > 0 && ( + + )} +
+ + + + + + + + + + + + + + {pg.slice.map(r => { + const isExpanded = expandedRef === r.bookingRef; + return ( + <> + setExpandedRef(isExpanded ? null : r.bookingRef)} + > + + + + + + + + + + + {/* Breakdown row */} + {isExpanded && ( + + + + )} + + ); + })} + {data.rows.length === 0 && ( + + )} + +
+ Booking RefSeat Class · Coach · SeatRouteActualPaidVariancePhone
+ {isExpanded ? : } + {r.bookingRef} + {r.seatClass} + {r.coachNumber && · {r.coachNumber}} + {r.seatNumber && · #{r.seatNumber}} + + {r.origin} → {r.destination} + {fmt(r.actualMinor)}{fmt(r.paidMinor)} + + + {fmt(r.varianceMinor)} + + {r.phone}
+

+ Fare breakdown by seat class +

+ + + + + + + + + + + {r.breakdown.map((b, bi) => ( + + + + + + + ))} + + + + + +
Seat ClassSeatsCountTotal Fare
{b.seatClass} + {b.seats.map(s => [s.coachNumber, s.seatNumber ? `#${s.seatNumber}` : null].filter(Boolean).join(' ')).join(', ') || '—'} + {b.count}{fmt(b.totalFareMinor)}
Total actual vs paid + {fmt(r.actualMinor)} / {fmt(r.paidMinor)} + + (+{fmt(r.varianceMinor)}) + +
+
No discrepancies found.
+ +
+ )} + + ); +} + +// ── Main page ───────────────────────────────────────────────────────────────── + +type Tab = 'payments' | 'discrepancy'; + +export default function PaymentsReportPage() { + const [scheduleId, setScheduleId] = useState(''); + const [tab, setTab] = useState('payments'); + + const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery({ + queryKey: ['report-schedules'], + queryFn: () => apiClient.get('/reports/schedules'), + }); + const schedules = schedulesRaw ?? []; + + const tabs: { key: Tab; label: string }[] = [ + { key: 'payments', label: 'Payments Collected' }, + { key: 'discrepancy', label: 'Discrepancy' }, + ]; + + return ( +
+
+

Payments Report

+

Payments collected and discrepancies for a schedule

+
+ + {/* Schedule selector */} +
+
+
+ + +
+
+
+ + {scheduleId ? ( + <> + {/* Schedule info banner */} +
+
+ +
+
+

{schedules.find(s => s.id === scheduleId)?.label ?? scheduleId}

+
+
+ + {/* Tabs */} +
+ {tabs.map(t => ( + + ))} +
+ + {tab === 'payments' && } + {tab === 'discrepancy' && } + + ) : ( +
+ +

Select a schedule above to load the payments report

+
+ )} +
+ ); +} 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 14ff2f161..ca81b5514 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -121,9 +121,10 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Analytics & Reports', items: [ - { name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view }, + { name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view }, { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view }, { name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view }, + { name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view }, { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view }, // { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view }, ] @@ -221,11 +222,12 @@ export default function Sidebar() { // Special handling for Settings to avoid conflict with User Management let isActive; if (item.href === '/settings') { - // Settings is active only for exact match or non-users sub-routes - isActive = pathname === '/settings' || + isActive = pathname === '/settings' || (pathname?.startsWith('/settings/') && !pathname.startsWith('/settings/users')); + } else if (item.href === '/payments') { + // Exact match only — avoid colliding with /reports/payments + isActive = pathname === '/payments' || pathname?.startsWith('/payments/'); } else { - // Standard matching for other items isActive = pathname === item.href || pathname?.startsWith(item.href + '/'); } return (