Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/components/shared/StatusBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import { cn } from '@/lib/utils'

const statusStyles: Record<string, string> = {
Completed: 'bg-green-100 text-green-700',
COMPLETED: 'bg-green-100 text-green-700',
'Partially Authorized': 'bg-orange-100 text-orange-700',
Rejected: 'bg-red-100 text-red-700',
Pending: 'bg-yellow-100 text-yellow-700',
IN_PROGRESS: 'bg-blue-100 text-blue-700',
Failed: 'bg-red-100 text-red-700',
FAILED: 'bg-red-100 text-red-700',
Unknown: 'bg-gray-100 text-gray-600',
// Voucher statuses
Active: 'bg-green-100 text-green-700',
Inactive: 'bg-gray-100 text-gray-600',
Expand All @@ -20,9 +24,17 @@ export default function StatusBadge({
status,
label,
}: {
status: string
status: string | null | undefined
label?: string
}) {
if (!status) {
return (
<span className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-gray-100 text-gray-600">
-
</span>
)
}

return (
<span
className={cn(
Expand Down
9 changes: 6 additions & 3 deletions src/lib/api/paymentHub.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import apiClient from './client'

export const fetchMainBatches = async () => {
console.log('Fetching batches...')
const response = await apiClient.get('/batches')
console.log('Batches response:', response.data)
return response.data
return { data: response.data.data }
}

export const fetchTransfers = async () => {
Expand All @@ -13,3 +11,8 @@ export const fetchTransfers = async () => {
})
return response.data
Comment thread
Flashl3opard marked this conversation as resolved.
}

export const fetchSubBatches = async (batchId: string) => {
const response = await apiClient.get(`/batches/${batchId}/subBatches`)
return response.data
}
2 changes: 2 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import SplashScreen from '@/pages/SplashScreen'
import LoginPage from '@/pages/LoginPage'
import Dashboard from '@/pages/Dashboard'
import PaymentHub from '@/pages/PaymentHub'
import BatchDetail from '@/pages/BatchDetail'
import Vouchers from '@/pages/Vouchers'
import AccountMapper from '@/pages/AccountMapper'
import G2PConfig from '@/pages/G2PConfig'
Expand Down Expand Up @@ -47,6 +48,7 @@ const router = createBrowserRouter([
children: [
{ index: true, element: <Dashboard /> },
{ path: 'payment-hub', element: <PaymentHub /> },
{ path: 'payment-hub/batch/:batchId', element: <BatchDetail /> },
{ path: 'vouchers', element: <Vouchers /> },
{ path: 'account-mapper', element: <AccountMapper /> },
{ path: 'g2p-config', element: <G2PConfig /> },
Expand Down
176 changes: 176 additions & 0 deletions src/modules/payment-hub/BatchDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { ArrowLeft } from 'lucide-react'
import { fetchMainBatches, fetchSubBatches } from '@/lib/api/paymentHub'
import { mainBatches as mockBatches } from './mocks/mainBatches.mock'
import { subBatches as mockSubBatches } from './mocks/subBatches.mock'
import StatusBadge from '@/components/shared/StatusBadge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Button } from '@/components/ui/button'

const SKELETON_ROWS = 5

const formatAmount = (amount: number | null) => {
if (!amount) return '0'
return Math.abs(amount / 100).toLocaleString()
}

const formatDate = (ts: number | null) => (ts ? new Date(ts).toLocaleString() : '-')

const formatField = (value: string | null | undefined) =>
!value || value === 'null' ? '-' : value

function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-2 border-b border-gray-50 last:border-0">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-sm font-medium text-gray-800">{value}</span>
</div>
)
}

function StatCard({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Card>
<CardContent className="pt-5 pb-5">
<p className="text-xs text-gray-500 font-medium mb-1">{label}</p>
<span className="text-3xl font-bold text-gray-800">{value}</span>
</CardContent>
</Card>
)
}

export default function BatchDetail() {
const { batchId } = useParams()
const navigate = useNavigate()

const { data: batchData, isError: isBatchesError } = useQuery({
queryKey: ['mainBatches'],
queryFn: fetchMainBatches,
})

const batches = isBatchesError ? mockBatches : (batchData?.data ?? [])
const batch = batches.find((b) => b.batchId === batchId)

const { data: subBatchData, isLoading: isSubBatchesLoading, isError: isSubBatchesError } = useQuery({
queryKey: ['subBatches', batchId],
queryFn: () => fetchSubBatches(batchId!),
enabled: !!batchId,
})

const subBatches = isSubBatchesError
? mockSubBatches.filter((sb) => sb.batchId === batchId)
: (subBatchData?.content ?? [])

return (
<div className="space-y-6">
{/* Breadcrumb */}
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Link to="/" className="hover:text-foreground transition-colors">
Dashboard
</Link>
<span>/</span>
<Link to="/payment-hub" className="hover:text-foreground transition-colors">
Payment Hub
</Link>
<span>/</span>
<span className="text-foreground font-medium">Batch Detail</span>
</nav>

{/* Header */}
<div className="flex items-center gap-3">
<Button variant="outline" size="icon-sm" onClick={() => navigate('/payment-hub')}>
<ArrowLeft size={16} />
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{batchId}</h1>
<StatusBadge status={batch?.status ?? null} />
</div>

{/* Info cards */}
<div className="grid grid-cols-4 gap-4">
<StatCard label="Total Transactions" value={batch?.totalTransactions ?? 0} />
<StatCard label="Completed" value={batch?.completed ?? 0} />
<StatCard label="Failed" value={batch?.failed ?? 0} />
<StatCard label="Total Amount" value={formatAmount(batch?.totalAmount ?? null)} />
</div>

{/* Batch info + Sub batches */}
<div className="flex gap-4 items-start">
{/* Batch Info */}
<Card className="flex-1 min-w-0">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold text-gray-700">Batch Info</CardTitle>
</CardHeader>
<CardContent>
<InfoRow label="Batch ID" value={formatField(batch?.batchId)} />
<InfoRow label="Payer FSP" value={formatField(batch?.payerFsp)} />
<InfoRow label="Started At" value={formatDate(batch?.startedAt ?? null)} />
<InfoRow label="Completed At" value={formatDate(batch?.completedAt ?? null)} />
<InfoRow label="Correlation ID" value={formatField(batch?.correlationId)} />
<InfoRow label="Registering Institution ID" value={formatField(batch?.registeringInstitutionId)} />
</CardContent>
</Card>

{/* Sub Batches */}
<Card className="flex-2 min-w-0">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold text-gray-700">Sub Batches</CardTitle>
</CardHeader>
<CardContent className="p-0">
{isSubBatchesError && (
<div className="px-6 pb-3 text-xs text-orange-700">
Could not reach the API — showing cached data.
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>Sub Batch ID</TableHead>
<TableHead className="text-right">Transactions</TableHead>
<TableHead className="text-right">Completed</TableHead>
<TableHead className="text-right">Failed</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isSubBatchesLoading
? Array.from({ length: SKELETON_ROWS }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: 6 }).map((__, j) => (
<TableCell key={j}>
<div className="h-4 rounded bg-gray-100 animate-pulse w-full" />
</TableCell>
))}
</TableRow>
))
: subBatches.length > 0
? subBatches.map((sb) => (
<TableRow key={sb.id}>
<TableCell className="font-medium">{sb.subBatchId ?? '-'}</TableCell>
<TableCell className="text-right">{sb.totalTransactions}</TableCell>
<TableCell className="text-right">{sb.completed}</TableCell>
<TableCell className="text-right">{sb.failed}</TableCell>
<TableCell className="text-right">{formatAmount(sb.totalAmount)}</TableCell>
<TableCell><StatusBadge status={sb.status} /></TableCell>
</TableRow>
))
: (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No sub batches found.
</TableCell>
</TableRow>
)
}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</div>
)
}
69 changes: 47 additions & 22 deletions src/modules/payment-hub/MainBatchesTab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { fetchMainBatches } from '@/lib/api/paymentHub'
import { mainBatches as mockBatches } from './mocks/mainBatches.mock'
Expand All @@ -15,10 +16,23 @@ import { ChevronLeft, ChevronRight, Download, FileText, AlertCircle } from 'luci
import { exportCsv, csvDate } from '@/lib/exportCsv'
import { exportPdf } from '@/lib/exportPdf'

const STATUSES: MainBatch['status'][] = ['Completed', 'Partially Authorized', 'Rejected']
const STATUSES = [
{ label: 'Completed', value: 'COMPLETED' },
{ label: 'Partially Authorized', value: 'IN_PROGRESS' },
{ label: 'Rejected', value: 'FAILED' },
]
const SKELETON_ROWS = 5

const formatAmount = (amount: number | null) => {
if (!amount) return '0'
return Math.abs(amount / 100).toLocaleString()
}

const formatInstitutionId = (id: string | null) =>
!id || id === 'null' ? '-' : id

export default function MainBatchesTab() {
const navigate = useNavigate()
const [statusFilter, setStatusFilter] = useState<string>('all')
const [page, setPage] = useState(1)
const [perPage, setPerPage] = useState(10)
Expand All @@ -28,8 +42,8 @@ export default function MainBatchesTab() {
queryFn: fetchMainBatches,
})

// Fall back to mock data when API returns empty or errors
const rows: MainBatch[] = (apiData?.data?.length ? apiData.data : mockBatches)
// Fall back to mock data only when the API call fails
const rows: MainBatch[] = isError ? mockBatches : (apiData?.data ?? [])
const totalCount: number = apiData?.totalBatches ?? rows.length

const filtered = rows.filter(
Expand All @@ -38,7 +52,16 @@ export default function MainBatchesTab() {
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage))
const paginated = filtered.slice((page - 1) * perPage, page * perPage)

const exportRows = filtered as unknown as Record<string, unknown>[]
const exportRows: Record<string, unknown>[] = filtered.map((b) => ({
'Batch Reference': b.batchId,
'Start Time': b.startedAt ? new Date(b.startedAt).toLocaleString() : '-',
'Completed Time': b.completedAt ? new Date(b.completedAt).toLocaleString() : '-',
'Institution ID': formatInstitutionId(b.registeringInstitutionId),
Instructions: b.totalTransactions,
Amount: formatAmount(b.totalAmount),
'Payer FSP': b.payerFsp ?? '-',
Status: b.status ?? 'Unknown',
}))

return (
<div className="space-y-4">
Expand All @@ -57,15 +80,15 @@ export default function MainBatchesTab() {
</button>
{STATUSES.map((s) => (
<button
key={s}
onClick={() => { setStatusFilter(s); setPage(1) }}
key={s.value}
onClick={() => { setStatusFilter(s.value); setPage(1) }}
className={`rounded-full px-3 py-1 text-xs font-medium border transition-colors ${
statusFilter === s
statusFilter === s.value
? 'bg-[#1565C0] text-white border-[#1565C0]'
: 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50'
}`}
>
{s}
{s.label}
</button>
))}
</div>
Expand All @@ -80,8 +103,8 @@ export default function MainBatchesTab() {
variant="outline" size="sm" className="gap-1.5 text-xs"
onClick={() => exportPdf(
'Main Batches',
['Batch Reference', 'Start Time', 'Completed Time', 'Institution ID', 'Source Ministry', 'Instructions', 'Amount', 'Payer FSP', 'Status'],
filtered.map((b) => [b.batchReferenceNumber, b.startTime, b.completedTime, b.registeringInstitutionId, b.sourceMinistry, b.numberOfInstructions, b.amount.toLocaleString(), b.payerFSP, b.status]),
['Batch Reference', 'Start Time', 'Completed Time', 'Institution ID', 'Instructions', 'Amount', 'Payer FSP', 'Status'],
filtered.map((b) => [b.batchId, b.startedAt ? new Date(b.startedAt).toLocaleString() : '-', b.completedAt ? new Date(b.completedAt).toLocaleString() : '-', formatInstitutionId(b.registeringInstitutionId), b.totalTransactions, formatAmount(b.totalAmount), b.payerFsp ?? '-', b.status ?? 'Unknown']),
`main-batches-${csvDate()}.pdf`,
)}
>
Expand All @@ -107,7 +130,6 @@ export default function MainBatchesTab() {
<TableHead>Start Time</TableHead>
<TableHead>Completed Time</TableHead>
<TableHead>Institution ID</TableHead>
<TableHead>Source Ministry</TableHead>
<TableHead className="text-right">Instructions</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead>Payer FSP</TableHead>
Expand All @@ -118,7 +140,7 @@ export default function MainBatchesTab() {
{isLoading
? Array.from({ length: SKELETON_ROWS }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: 9 }).map((__, j) => (
{Array.from({ length: 8 }).map((__, j) => (
<TableCell key={j}>
<div className="h-4 rounded bg-gray-100 animate-pulse w-full" />
</TableCell>
Expand All @@ -127,21 +149,24 @@ export default function MainBatchesTab() {
))
: paginated.length > 0
? paginated.map((batch) => (
<TableRow key={batch.batchReferenceNumber}>
<TableCell className="font-medium">{batch.batchReferenceNumber}</TableCell>
<TableCell>{batch.startTime}</TableCell>
<TableCell>{batch.completedTime}</TableCell>
<TableCell>{batch.registeringInstitutionId}</TableCell>
<TableCell>{batch.sourceMinistry}</TableCell>
<TableCell className="text-right">{batch.numberOfInstructions}</TableCell>
<TableCell className="text-right">{batch.amount.toLocaleString()}</TableCell>
<TableCell>{batch.payerFSP}</TableCell>
<TableRow
key={batch.batchId}
className="cursor-pointer"
onClick={() => navigate(`/payment-hub/batch/${batch.batchId}`)}
>
<TableCell className="font-medium">{batch.batchId}</TableCell>
<TableCell>{batch.startedAt ? new Date(batch.startedAt).toLocaleString() : '-'}</TableCell>
<TableCell>{batch.completedAt ? new Date(batch.completedAt).toLocaleString() : '-'}</TableCell>
<TableCell>{formatInstitutionId(batch.registeringInstitutionId)}</TableCell>
<TableCell className="text-right">{batch.totalTransactions}</TableCell>
<TableCell className="text-right">{formatAmount(batch.totalAmount)}</TableCell>
<TableCell>{batch.payerFsp ?? '-'}</TableCell>
<TableCell><StatusBadge status={batch.status} /></TableCell>
</TableRow>
))
: (
<TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground py-8">
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
No records found.
</TableCell>
</TableRow>
Expand Down
Loading
Loading