From 19fe977f405025a0084a59199cb5b0645db326cc Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 20 Jul 2026 22:15:32 +0300 Subject: [PATCH] Payment discrepancy report updates --- .../src/modules/reports/reports.service.ts | 42 +- .../src/app/reports/payments/page.tsx | 548 ++++++------------ 2 files changed, 203 insertions(+), 387 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 9067ffe56..5a2692f44 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -412,7 +412,9 @@ export class ReportsService { } async listSchedulesForPicker() { + const now = new Date(); const schedules = await this.prisma.trainSchedule.findMany({ + where: { departureAt: { gte: now } }, select: { id: true, departureAt: true, @@ -420,12 +422,13 @@ export class ReportsService { originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, }, - orderBy: { departureAt: "desc" }, + orderBy: { departureAt: 'asc' }, take: 200, }); return schedules.map((s) => ({ id: s.id, - label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" })}`, + departureAt: s.departureAt, + label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}`, })); } @@ -926,7 +929,8 @@ export class ReportsService { seat: { select: { seatNumber: true, - coach: { select: { number: true, coachType: { select: { name: true } } } }, + bedPosition: true, + coach: { select: { number: true, coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } } } }, }, }, }, @@ -935,30 +939,26 @@ export class ReportsService { }, }); + const resolveSeatClass = (seat: any): string => { + const classes = seat?.coach?.coachType?.seatClasses ?? []; + const matched = seat?.bedPosition + ? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition.toLowerCase()) + : null; + return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? 'Unknown'; + }; + 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 breakdown = b.seats.map(s => ({ + passengerName: s.passengerName ?? '—', + seatClass: resolveSeatClass(s.seat), + coachNumber: s.seat?.coach?.number ?? null, + seatNumber: s.seat?.seatNumber ?? null, + fareMinor: s.fareMinor ?? 0, })); const firstSeat = b.seats[0]; diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/payments/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/payments/page.tsx index cb19f5fec..4fe1a17a0 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/payments/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/payments/page.tsx @@ -3,40 +3,21 @@ import { useState, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { - CreditCard, AlertTriangle, Train, Download, Search, X, + AlertTriangle, Train, Download, Search, X, ChevronDown, ChevronUp, Loader2, ChevronLeft, ChevronRight, } from 'lucide-react'; import { apiClient } from '@/lib/api-client'; -import { formatDateTime } from '@/lib/utils'; // ── Types ───────────────────────────────────────────────────────────────────── -interface ScheduleOption { id: string; label: string; } +interface ScheduleOption { id: string; label: string; departureAt: string; } -interface PaymentRow { - bookingRef: string; +interface PassengerBreakdown { passengerName: string; - phone: string; - method: string; - paidAt: string | null; - actualMinor: number; - paidMinor: number; - currency: string; - passengerCount: number; -} - -interface PaymentsReport { - totalActualMinor: number; - totalPaidMinor: number; - byMethod: Record; - rows: PaymentRow[]; -} - -interface BreakdownEntry { seatClass: string; - seats: { coachNumber: string | null; seatNumber: string | null }[]; - totalFareMinor: number; - count: number; + coachNumber: string | null; + seatNumber: string | null; + fareMinor: number; } interface DiscrepancyRow { @@ -50,7 +31,7 @@ interface DiscrepancyRow { actualMinor: number; paidMinor: number; varianceMinor: number; - breakdown: BreakdownEntry[]; + breakdown: PassengerBreakdown[]; } interface DiscrepancyReport { total: number; rows: DiscrepancyRow[]; } @@ -61,7 +42,6 @@ const PAGE_SIZE = 20; function usePagination(items: T[], resetKey?: unknown) { const [page, setPage] = useState(1); - // reset to page 1 whenever resetKey changes (e.g. new data loaded) useMemo(() => { setPage(1); }, [resetKey]); // eslint-disable-line react-hooks/exhaustive-deps const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE)); const safePage = Math.min(page, totalPages); @@ -105,10 +85,6 @@ function fmt(minor: number) { return `ETB ${(minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`; } -function methodLabel(m: string) { - return m.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); -} - function downloadCsv(csv: string, filename: string) { const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); @@ -117,134 +93,25 @@ function downloadCsv(csv: string, filename: string) { URL.revokeObjectURL(url); } -// ── Payments tab ────────────────────────────────────────────────────────────── +// ── Discrepancy page ────────────────────────────────────────────────────────── -function PaymentsTab({ scheduleId }: { scheduleId: string }) { - const { data, isLoading, isError } = useQuery({ - queryKey: ['payments-report', scheduleId], - queryFn: () => apiClient.get(`/reports/payments?scheduleId=${scheduleId}`), - enabled: !!scheduleId, - }); - - const pg = usePagination(data?.rows ?? [], scheduleId); - - const doExport = () => { - if (!data) return; - const headers = ['Booking Ref', 'Passenger', 'Phone', 'Method', 'Paid At', 'Actual (ETB)', 'Paid (ETB)', 'Passengers']; - const rows = data.rows.map(r => [ - r.bookingRef, - r.passengerName, - r.phone, - methodLabel(r.method), - r.paidAt ? new Date(r.paidAt).toLocaleString('en-GB') : '—', - (r.actualMinor / 100).toFixed(2), - (r.paidMinor / 100).toFixed(2), - String(r.passengerCount), - ].map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')); - downloadCsv([headers.join(','), ...rows].join('\n'), `payments-${scheduleId}.csv`); - }; - - if (!scheduleId) return null; - if (isLoading) return
Loading…
; - if (isError) return

Failed to load payments data.

; - if (!data) return null; - - const totalVarianceMinor = data.totalActualMinor - data.totalPaidMinor; - - return ( -
- {/* Summary cards */} -
- {[ - { label: 'Total Fare (Actual)', value: fmt(data.totalActualMinor), color: 'blue' }, - { label: 'Total Collected', value: fmt(data.totalPaidMinor), color: 'emerald' }, - { - label: 'Total Variance', - value: fmt(Math.abs(totalVarianceMinor)), - color: totalVarianceMinor === 0 ? 'emerald' : 'red', - sub: totalVarianceMinor === 0 ? 'Fully collected' : totalVarianceMinor > 0 ? 'Under-collected' : 'Over-collected', - }, - ].map(({ label, value, color, sub }) => ( -
-

{label}

-

{value}

- {sub &&

{sub}

} -
- ))} -
- - {/* By method */} - {Object.keys(data.byMethod).length > 0 && ( -
-

By Payment Method

-
- {Object.entries(data.byMethod).sort(([, a], [, b]) => b - a).map(([method, minor]) => ( -
- {methodLabel(method)} - {fmt(minor)} -
- ))} -
-
- )} - - {/* Rows table */} -
-
-

- Transactions ({data.rows.length}) -

- {data.rows.length > 0 && ( - - )} -
- - - - {['Booking Ref', 'Passenger', 'Phone', 'Method', 'Paid At', 'Actual', 'Paid', 'Pax'].map(h => ( - - ))} - - - - {pg.slice.map(r => ( - - - - - - - - - - - ))} - {data.rows.length === 0 && ( - - )} - -
{h}
{r.bookingRef}{r.passengerName}{r.phone}{methodLabel(r.method)} - {r.paidAt ? formatDateTime(r.paidAt) : '—'} - {fmt(r.actualMinor)}{fmt(r.paidMinor)}{r.passengerCount}
No payments found for this schedule.
- -
-
- ); -} - -// ── Discrepancy tab ─────────────────────────────────────────────────────────── - -function DiscrepancyTab({ scheduleId }: { scheduleId: string }) { +export default function PaymentsReportPage() { + const [scheduleId, setScheduleId] = useState(''); const [search, setSearch] = useState(''); const [seatClass, setSeatClass] = useState(''); const [sort, setSort] = useState<'desc' | 'asc'>('desc'); const [expandedRef, setExpandedRef] = useState(null); + const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery({ + queryKey: ['report-schedules'], + queryFn: () => apiClient.get('/reports/schedules'), + }); + + const now = new Date(); + const schedules = (schedulesRaw ?? []).filter( + s => new Date(s.departureAt) >= now, + ); + const { data, isLoading, isError } = useQuery({ queryKey: ['payments-discrepancy', scheduleId, search, seatClass, sort], queryFn: () => apiClient.get('/reports/payments/discrepancy', { @@ -277,201 +144,11 @@ function DiscrepancyTab({ scheduleId }: { scheduleId: string }) { downloadCsv([headers.join(','), ...rows].join('\n'), `discrepancy-${scheduleId}.csv`); }; - if (!scheduleId) return null; - - return ( -
- {/* Filters */} -
-
- - 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

+

Payment discrepancies for upcoming schedules

{/* Schedule selector */} @@ -482,7 +159,7 @@ export default function PaymentsReportPage() { setSearch(e.target.value.toUpperCase())} + placeholder="Booking ref…" + className="input pl-8 pr-7 font-mono text-sm w-full" + /> + {search && ( + + )}
- - - {/* Tabs */} -
- {tabs.map(t => ( + + + {data && data.rows.length > 0 && ( - ))} + )}
- {tab === 'payments' && } - {tab === 'discrepancy' && } + {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)} + > + + + + + + + + + + + {/* Fare breakdown */} + {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 +

+ + + + + + + + + + + + {r.breakdown.map((b, bi) => ( + + + + + + + + ))} + + + + + +
PassengerSeat ClassCoachSeatActual Fare
{b.passengerName}{b.seatClass}{b.coachNumber ?? '—'}{b.seatNumber ?? '—'}{fmt(b.fareMinor)}
Total actual vs paid + {fmt(r.actualMinor)} / {fmt(r.paidMinor)} + (+{fmt(r.varianceMinor)}) +
+
No discrepancies found.
+ +
+ )} ) : (
- -

Select a schedule above to load the payments report

+ +

Select a schedule above to load the discrepancy report

)}