From 19fe977f405025a0084a59199cb5b0645db326cc Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 20 Jul 2026 22:15:32 +0300 Subject: [PATCH 1/3] 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

)} From eabc15eebf5aad6299df1e18f4e7b0d4bbccc943 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 20 Jul 2026 22:48:50 +0300 Subject: [PATCH 2/3] Payment discrepancy report updates --- .../src/modules/reports/reports.service.ts | 20 ++++++--- .../src/app/reports/payments/page.tsx | 41 +++++++++++-------- 2 files changed, 38 insertions(+), 23 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 5a2692f44..23f89124a 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -418,6 +418,7 @@ export class ReportsService { select: { id: true, departureAt: true, + isPackageOnly: true, train: { select: { number: true } }, originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, @@ -428,7 +429,10 @@ export class ReportsService { return schedules.map((s) => ({ id: s.id, departureAt: s.departureAt, - label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}`, + isPackage: s.isPackageOnly, + label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}${ + s.isPackageOnly ? ' (package)' : '' + }`, })); } @@ -915,6 +919,7 @@ export class ReportsService { destinationStation: { select: { name: true } }, }, }, + package: { select: { id: true } }, seats: { where: { leg: 1 }, orderBy: [ @@ -953,29 +958,34 @@ export class ReportsService { const paidMinor = Math.round(pi.amountMinor); const varianceMinor = actualMinor - paidMinor; + const isPackage = !!(b as any).package; + const effectiveActualMinor = isPackage ? actualMinor * 2 : actualMinor; + const effectiveVarianceMinor = effectiveActualMinor - paidMinor; + 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, + fareMinor: isPackage ? (s.fareMinor ?? 0) * 2 : (s.fareMinor ?? 0), })); const firstSeat = b.seats[0]; return { bookingRef: b.bookingRef, + isPackage, 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, + actualMinor: effectiveActualMinor, paidMinor, - varianceMinor, + varianceMinor: effectiveVarianceMinor, breakdown, }; - }).filter(r => r.varianceMinor > 0); + }).filter(r => r.varianceMinor > 0 && r.paidMinor > 0); if (params.search?.trim()) { const q = params.search.trim().toUpperCase(); 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 4fe1a17a0..7ff4e5247 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 @@ -10,7 +10,7 @@ import { apiClient } from '@/lib/api-client'; // ── Types ───────────────────────────────────────────────────────────────────── -interface ScheduleOption { id: string; label: string; departureAt: string; } +interface ScheduleOption { id: string; label: string; departureAt: string; isPackage: boolean; } interface PassengerBreakdown { passengerName: string; @@ -22,6 +22,7 @@ interface PassengerBreakdown { interface DiscrepancyRow { bookingRef: string; + isPackage: boolean; seatClass: string; coachNumber: string | null; seatNumber: string | null; @@ -81,10 +82,15 @@ function Pagination({ page, totalPages, setPage, total }: { // ── Helpers ─────────────────────────────────────────────────────────────────── -function fmt(minor: number) { +function fmtMinor(minor: number) { return `ETB ${(minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`; } +// paidMinor from PaymentIntent.amountMinor is a Float stored as full units (not cents) +function fmtPaid(amount: number) { + return `ETB ${amount.toLocaleString('en-US', { minimumFractionDigits: 2 })}`; +} + function downloadCsv(csv: string, filename: string) { const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); @@ -231,18 +237,10 @@ export default function PaymentsReportPage() { {data && (
-
+

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

- {data.rows.length > 0 && ( - - )}
@@ -270,7 +268,14 @@ export default function PaymentsReportPage() { - + - - + + @@ -314,14 +319,14 @@ export default function PaymentsReportPage() { - + ))} From 226f839ad0c4b046c9dbd7b9639116ab336569e2 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 20 Jul 2026 22:52:17 +0300 Subject: [PATCH 3/3] Hide previous payment discrepancy link --- .../backoffice/src/components/layout/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ca81b5514..98f76cc18 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -125,7 +125,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { 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: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view }, // { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view }, ] },
{isExpanded ? : } {r.bookingRef} + {r.bookingRef} + {r.isPackage && ( + + package + + )} + {r.seatClass} {r.coachNumber && · {r.coachNumber}} @@ -279,12 +284,12 @@ export default function PaymentsReportPage() { {r.origin} → {r.destination} {fmt(r.actualMinor)}{fmt(r.paidMinor)}{fmtMinor(r.actualMinor)}{fmtPaid(r.paidMinor)} - {fmt(r.varianceMinor)} + {fmtMinor(r.varianceMinor)} {r.phone}{b.seatClass} {b.coachNumber ?? '—'} {b.seatNumber ?? '—'}{fmt(b.fareMinor)}{fmtMinor(b.fareMinor)}
Total actual vs paid - {fmt(r.actualMinor)} / {fmt(r.paidMinor)} - (+{fmt(r.varianceMinor)}) + {fmtMinor(r.actualMinor)} / {fmtPaid(r.paidMinor)} + (+{fmtMinor(r.varianceMinor)})