@@ -0,0 +1,536 @@
'use client' ;
import { useState , useMemo } from 'react' ;
import { useQuery } from '@tanstack/react-query' ;
import {
CreditCard , 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 PaymentRow {
bookingRef : string ;
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 < string , number > ;
rows : PaymentRow [ ] ;
}
interface BreakdownEntry {
seatClass : string ;
seats : { coachNumber : string | null ; seatNumber : string | null } [ ] ;
totalFareMinor : number ;
count : number ;
}
interface DiscrepancyRow {
bookingRef : string ;
seatClass : string ;
coachNumber : string | null ;
seatNumber : string | null ;
origin : string ;
destination : string ;
phone : string ;
actualMinor : number ;
paidMinor : number ;
varianceMinor : number ;
breakdown : BreakdownEntry [ ] ;
}
interface DiscrepancyReport { total : number ; rows : DiscrepancyRow [ ] ; }
// ── Pagination ────────────────────────────────────────────────────────────────
const PAGE_SIZE = 20 ;
function usePagination < T > ( 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 ) ;
const slice = items . slice ( ( safePage - 1 ) * PAGE_SIZE , safePage * PAGE_SIZE ) ;
return { page : safePage , setPage , totalPages , slice } ;
}
function Pagination ( { page , totalPages , setPage , total } : {
page : number ; totalPages : number ; setPage : ( p : number ) = > void ; total : number ;
} ) {
if ( totalPages <= 1 ) return null ;
const from = ( page - 1 ) * PAGE_SIZE + 1 ;
const to = Math . min ( page * PAGE_SIZE , total ) ;
return (
< div className = "flex items-center justify-between px-1 pt-3 border-t border-border text-xs text-muted-foreground" >
< span > { from } – { to } of { total } < / span >
< div className = "flex items-center gap-1" >
< button
onClick = { ( ) = > setPage ( page - 1 ) }
disabled = { page === 1 }
className = "p-1 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
< ChevronLeft className = "w-4 h-4" / >
< / button >
< span className = "px-2" > Page { page } of { totalPages } < / span >
< button
onClick = { ( ) = > setPage ( page + 1 ) }
disabled = { page === totalPages }
className = "p-1 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
< ChevronRight className = "w-4 h-4" / >
< / button >
< / div >
< / div >
) ;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
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 ) ;
const a = document . createElement ( 'a' ) ;
a . href = url ; a . download = filename ; a . click ( ) ;
URL . revokeObjectURL ( url ) ;
}
// ── Payments tab ──────────────────────────────────────────────────────────────
function PaymentsTab ( { scheduleId } : { scheduleId : string } ) {
const { data , isLoading , isError } = useQuery < PaymentsReport > ( {
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 < div className = "flex items-center gap-2 text-sm text-muted-foreground py-8" > < Loader2 className = "w-4 h-4 animate-spin" / > Loading … < / div > ;
if ( isError ) return < p className = "text-sm text-red-500 py-4" > Failed to load payments data . < / p > ;
if ( ! data ) return null ;
const totalVarianceMinor = data . totalActualMinor - data . totalPaidMinor ;
return (
< div className = "space-y-6" >
{ /* Summary cards */ }
< div className = "grid grid-cols-1 sm:grid-cols-3 gap-4" >
{ [
{ 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 } ) = > (
< div key = { label } className = "card flex flex-col gap-1" >
< p className = "text-xs font-semibold uppercase tracking-wider text-muted-foreground" > { label } < / p >
< p className = { ` text-2xl font-bold tabular-nums mt-1 text- ${ color } -600 dark:text- ${ color } -400 ` } > { value } < / p >
{ sub && < p className = "text-xs text-muted-foreground" > { sub } < / p > }
< / div >
) ) }
< / div >
{ /* By method */ }
{ Object . keys ( data . byMethod ) . length > 0 && (
< div className = "card" >
< h3 className = "text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4" > By Payment Method < / h3 >
< div className = "space-y-2" >
{ Object . entries ( data . byMethod ) . sort ( ( [ , a ] , [ , b ] ) = > b - a ) . map ( ( [ method , minor ] ) = > (
< div key = { method } className = "flex items-center justify-between text-sm" >
< span className = "font-medium" > { methodLabel ( method ) } < / span >
< span className = "tabular-nums font-semibold" > { fmt ( minor ) } < / span >
< / div >
) ) }
< / div >
< / div >
) }
{ /* Rows table */ }
< div className = "card overflow-x-auto" >
< div className = "flex items-center justify-between mb-4" >
< h3 className = "text-sm font-semibold uppercase tracking-wider text-muted-foreground" >
Transactions ( { data . rows . length } )
< / h3 >
{ data . rows . length > 0 && (
< button
onClick = { doExport }
className = "flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border border-border rounded-lg px-3 py-1.5 transition-colors"
>
< Download className = "w-3.5 h-3.5" / > Export CSV
< / button >
) }
< / div >
< table className = "w-full text-sm" >
< thead >
< tr className = "border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider" >
{ [ 'Booking Ref' , 'Passenger' , 'Phone' , 'Method' , 'Paid At' , 'Actual' , 'Paid' , 'Pax' ] . map ( h = > (
< th key = { h } className = "pb-2 pr-4 whitespace-nowrap" > { h } < / th >
) ) }
< / tr >
< / thead >
< tbody className = "divide-y divide-border" >
{ pg . slice . map ( r = > (
< tr key = { r . bookingRef } className = "hover:bg-muted/30" >
< td className = "py-2 pr-4 font-mono text-xs" > { r . bookingRef } < / td >
< td className = "py-2 pr-4 font-medium whitespace-nowrap" > { r . passengerName } < / td >
< td className = "py-2 pr-4 text-muted-foreground text-xs" > { r . phone } < / td >
< td className = "py-2 pr-4 text-xs" > { methodLabel ( r . method ) } < / td >
< td className = "py-2 pr-4 text-xs text-muted-foreground whitespace-nowrap" >
{ r . paidAt ? formatDateTime ( r . paidAt ) : '—' }
< / td >
< td className = "py-2 pr-4 tabular-nums text-xs" > { fmt ( r . actualMinor ) } < / td >
< td className = "py-2 pr-4 tabular-nums text-xs font-semibold" > { fmt ( r . paidMinor ) } < / td >
< td className = "py-2 tabular-nums text-xs text-center" > { r . passengerCount } < / td >
< / tr >
) ) }
{ data . rows . length === 0 && (
< tr > < td colSpan = { 8 } className = "py-8 text-center text-sm text-muted-foreground" > No payments found for this schedule . < / td > < / tr >
) }
< / tbody >
< / table >
< Pagination page = { pg . page } totalPages = { pg . totalPages } setPage = { pg . setPage } total = { data . rows . length } / >
< / div >
< / div >
) ;
}
// ── Discrepancy tab ───────────────────────────────────────────────────────────
function DiscrepancyTab ( { scheduleId } : { scheduleId : string } ) {
const [ search , setSearch ] = useState ( '' ) ;
const [ seatClass , setSeatClass ] = useState ( '' ) ;
const [ sort , setSort ] = useState < 'desc' | 'asc' > ( 'desc' ) ;
const [ expandedRef , setExpandedRef ] = useState < string | null > ( null ) ;
const { data , isLoading , isError } = useQuery < DiscrepancyReport > ( {
queryKey : [ 'payments-discrepancy' , scheduleId , search , seatClass , sort ] ,
queryFn : ( ) = > apiClient . get ( '/reports/payments/discrepancy' , {
params : { scheduleId , search : search || undefined , seatClass : seatClass || undefined , sort } ,
} ) ,
enabled : ! ! scheduleId ,
} ) ;
const pg = usePagination ( data ? . rows ? ? [ ] , ` ${ scheduleId } - ${ search } - ${ seatClass } - ${ sort } ` ) ;
const seatClassOptions = useMemo ( ( ) = > {
if ( ! data ) return [ ] ;
return [ . . . new Set ( data . rows . flatMap ( r = > r . breakdown . map ( b = > b . seatClass ) ) ) ] . sort ( ) ;
} , [ data ] ) ;
const doExport = ( ) = > {
if ( ! data ) return ;
const headers = [ 'Booking Ref' , 'Route' , 'Seat Class' , 'Coach' , 'Seat' , 'Phone' , 'Actual (ETB)' , 'Paid (ETB)' , 'Variance (ETB)' ] ;
const rows = data . rows . map ( r = > [
r . bookingRef ,
` ${ r . origin } → ${ r . destination } ` ,
r . seatClass ,
r . coachNumber ? ? '—' ,
r . seatNumber ? ? '—' ,
r . phone ,
( r . actualMinor / 100 ) . toFixed ( 2 ) ,
( r . paidMinor / 100 ) . toFixed ( 2 ) ,
( r . varianceMinor / 100 ) . toFixed ( 2 ) ,
] . map ( v = > ` " ${ String ( v ) . replace ( /"/g , '""' ) } " ` ) . join ( ',' ) ) ;
downloadCsv ( [ headers . join ( ',' ) , . . . rows ] . join ( '\n' ) , ` discrepancy- ${ scheduleId } .csv ` ) ;
} ;
if ( ! scheduleId ) return null ;
return (
< div className = "space-y-4" >
{ /* Filters */ }
< div className = "flex items-center gap-2 flex-wrap" >
< div className = "relative flex-1 min-w-48" >
< Search className = "absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" / >
< input
type = "text"
value = { search }
onChange = { e = > setSearch ( e . target . value . toUpperCase ( ) ) }
placeholder = "Booking ref…"
className = "input pl-8 pr-7 font-mono text-sm w-full"
/ >
{ search && (
< button onClick = { ( ) = > setSearch ( '' ) } className = "absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" >
< X className = "w-3.5 h-3.5" / >
< / button >
) }
< / div >
< select
value = { seatClass }
onChange = { e = > setSeatClass ( e . target . value ) }
className = "input w-44 text-sm"
>
< option value = "" > All seat classes < / option >
{ seatClassOptions . map ( sc = > < option key = { sc } value = { sc } > { sc } < / option > ) }
< / select >
< select
value = { sort }
onChange = { e = > setSort ( e . target . value as 'desc' | 'asc' ) }
className = "input w-48 text-sm"
>
< option value = "desc" > Variance : High → Low < / option >
< option value = "asc" > Variance : Low → High < / option >
< / select >
{ data && data . rows . length > 0 && (
< button
onClick = { doExport }
className = "flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border border-border rounded-lg px-3 py-1.5 transition-colors"
>
< Download className = "w-3.5 h-3.5" / > Export CSV
< / button >
) }
< / div >
{ isLoading && (
< div className = "flex items-center gap-2 text-sm text-muted-foreground py-8" >
< Loader2 className = "w-4 h-4 animate-spin" / > Loading …
< / div >
) }
{ isError && < p className = "text-sm text-red-500 py-4" > Failed to load discrepancy data . < / p > }
{ data && (
< div className = "card overflow-x-auto" >
< div className = "flex items-center justify-between mb-4" >
< h3 className = "text-sm font-semibold uppercase tracking-wider text-muted-foreground" >
{ data . total } discrepanc { data . total !== 1 ? 'ies' : 'y' } found
< / h3 >
{ data . rows . length > 0 && (
< button
onClick = { doExport }
className = "flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border border-border rounded-lg px-3 py-1.5 transition-colors"
>
< Download className = "w-3.5 h-3.5" / > Export CSV
< / button >
) }
< / div >
< table className = "w-full text-sm" >
< thead >
< tr className = "border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider" >
< th className = "pb-2 pr-4 w-4" / >
< th className = "pb-2 pr-4 whitespace-nowrap" > Booking Ref < / th >
< th className = "pb-2 pr-4 whitespace-nowrap" > Seat Class · Coach · Seat < / th >
< th className = "pb-2 pr-4 whitespace-nowrap" > Route < / th >
< th className = "pb-2 pr-4 text-right whitespace-nowrap" > Actual < / th >
< th className = "pb-2 pr-4 text-right whitespace-nowrap" > Paid < / th >
< th className = "pb-2 pr-4 text-right whitespace-nowrap" > Variance < / th >
< th className = "pb-2 whitespace-nowrap" > Phone < / th >
< / tr >
< / thead >
< tbody className = "divide-y divide-border" >
{ pg . slice . map ( r = > {
const isExpanded = expandedRef === r . bookingRef ;
return (
< >
< tr
key = { r . bookingRef }
className = "hover:bg-muted/30 cursor-pointer"
onClick = { ( ) = > setExpandedRef ( isExpanded ? null : r . bookingRef ) }
>
< td className = "py-2 pr-2 text-muted-foreground" >
{ isExpanded ? < ChevronUp className = "w-3.5 h-3.5" / > : < ChevronDown className = "w-3.5 h-3.5" / > }
< / td >
< td className = "py-2 pr-4 font-mono text-xs font-semibold" > { r . bookingRef } < / td >
< td className = "py-2 pr-4 text-xs" >
< span className = "font-medium" > { r . seatClass } < / span >
{ r . coachNumber && < span className = "text-muted-foreground" > · { r . coachNumber } < / span > }
{ r . seatNumber && < span className = "text-muted-foreground" > · # { r . seatNumber } < / span > }
< / td >
< td className = "py-2 pr-4 text-xs text-muted-foreground whitespace-nowrap" >
{ r . origin } → { r . destination }
< / td >
< td className = "py-2 pr-4 text-right tabular-nums text-xs" > { fmt ( r . actualMinor ) } < / td >
< td className = "py-2 pr-4 text-right tabular-nums text-xs" > { fmt ( r . paidMinor ) } < / td >
< td className = "py-2 pr-4 text-right" >
< span className = "inline-flex items-center gap-1 text-xs font-bold px-2 py-0.5 rounded-md bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800" >
< AlertTriangle className = "w-3 h-3" / >
{ fmt ( r . varianceMinor ) }
< / span >
< / td >
< td className = "py-2 text-xs text-muted-foreground" > { r . phone } < / td >
< / tr >
{ /* Breakdown row */ }
{ isExpanded && (
< tr key = { ` ${ r . bookingRef } -breakdown ` } className = "bg-muted/20" >
< td colSpan = { 8 } className = "px-6 py-3" >
< p className = "text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2" >
Fare breakdown by seat class
< / p >
< table className = "w-full text-xs" >
< thead >
< tr className = "text-left text-muted-foreground" >
< th className = "pb-1.5 pr-4 font-semibold uppercase tracking-wide" > Seat Class < / th >
< th className = "pb-1.5 pr-4 font-semibold uppercase tracking-wide" > Seats < / th >
< th className = "pb-1.5 pr-4 font-semibold uppercase tracking-wide" > Count < / th >
< th className = "pb-1.5 font-semibold uppercase tracking-wide text-right" > Total Fare < / th >
< / tr >
< / thead >
< tbody className = "divide-y divide-border/50" >
{ r . breakdown . map ( ( b , bi ) = > (
< tr key = { bi } className = "text-foreground" >
< td className = "py-1.5 pr-4 font-medium" > { b . seatClass } < / td >
< td className = "py-1.5 pr-4 font-mono text-muted-foreground" >
{ b . seats . map ( s = > [ s . coachNumber , s . seatNumber ? ` # ${ s . seatNumber } ` : null ] . filter ( Boolean ) . join ( ' ' ) ) . join ( ', ' ) || '—' }
< / td >
< td className = "py-1.5 pr-4" > { b . count } < / td >
< td className = "py-1.5 text-right tabular-nums font-semibold" > { fmt ( b . totalFareMinor ) } < / td >
< / tr >
) ) }
< tr className = "border-t border-border font-semibold" >
< td colSpan = { 3 } className = "pt-2 text-muted-foreground" > Total actual vs paid < / td >
< td className = "pt-2 text-right tabular-nums" >
{ fmt ( r . actualMinor ) } / { fmt ( r . paidMinor ) }
< span className = "ml-2 text-red-500" >
( + { fmt ( r . varianceMinor ) } )
< / span >
< / td >
< / tr >
< / tbody >
< / table >
< / td >
< / tr >
) }
< / >
) ;
} ) }
{ data . rows . length === 0 && (
< tr > < td colSpan = { 8 } className = "py-8 text-center text-sm text-muted-foreground" > No discrepancies found . < / td > < / tr >
) }
< / tbody >
< / table >
< Pagination page = { pg . page } totalPages = { pg . totalPages } setPage = { pg . setPage } total = { data . rows . length } / >
< / div >
) }
< / div >
) ;
}
// ── Main page ─────────────────────────────────────────────────────────────────
type Tab = 'payments' | 'discrepancy' ;
export default function PaymentsReportPage() {
const [ scheduleId , setScheduleId ] = useState ( '' ) ;
const [ tab , setTab ] = useState < Tab > ( 'payments' ) ;
const { data : schedulesRaw , isLoading : loadingSchedules } = useQuery < ScheduleOption [ ] > ( {
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 (
< div className = "space-y-6" >
< div >
< h1 className = "text-3xl font-bold text-foreground" > Payments Report < / h1 >
< p className = "text-muted-foreground mt-1" > Payments collected and discrepancies for a schedule < / p >
< / div >
{ /* Schedule selector */ }
< div className = "card" >
< div className = "flex items-end gap-4 flex-wrap" >
< div className = "flex-1 min-w-72" >
< label className = "label" > Schedule < / label >
< select
className = "input"
value = { scheduleId }
onChange = { e = > { setScheduleId ( e . target . value ) ; setTab ( 'payments' ) ; } }
disabled = { loadingSchedules }
>
< option value = "" > { loadingSchedules ? 'Loading schedules…' : 'Select a schedule…' } < / option >
{ schedules . map ( s = > < option key = { s . id } value = { s . id } > { s . label } < / option > ) }
< / select >
< / div >
< / div >
< / div >
{ scheduleId ? (
< >
{ /* Schedule info banner */ }
< div className = "card flex items-center gap-4" >
< div className = "rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-2.5" >
< Train className = "h-5 w-5 text-emerald-600 dark:text-emerald-400" / >
< / div >
< div >
< p className = "font-semibold" > { schedules . find ( s = > s . id === scheduleId ) ? . label ? ? scheduleId } < / p >
< / div >
< / div >
{ /* Tabs */ }
< div className = "border-b border-border flex" >
{ tabs . map ( t = > (
< button
key = { t . key }
onClick = { ( ) = > setTab ( t . key ) }
className = { ` px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${
tab === t . key
? 'border-emerald-500 text-emerald-600 dark:text-emerald-400'
: 'border-transparent text-muted-foreground hover:text-foreground'
} ` }
>
{ t . key === 'discrepancy' && < CreditCard className = "w-3.5 h-3.5 inline mr-1.5 opacity-70" / > }
{ t . label }
< / button >
) ) }
< / div >
{ tab === 'payments' && < PaymentsTab scheduleId = { scheduleId } / > }
{ tab === 'discrepancy' && < DiscrepancyTab scheduleId = { scheduleId } / > }
< / >
) : (
< div className = "card py-16 text-center text-muted-foreground" >
< CreditCard className = "h-10 w-10 mx-auto mb-3 opacity-30" / >
< p > Select a schedule above to load the payments report < / p >
< / div >
) }
< / div >
) ;
}