feat: Add Payment Analytics page and integrate payment reports API

This commit is contained in:
2026-10-22 17:33:06 +05:30
parent 455ce699ad
commit 932d09c135
4 changed files with 293 additions and 2 deletions
@@ -0,0 +1,266 @@
'use client'
import { useEffect, useState } from 'react'
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from 'recharts'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { paymentReports, getApiError } from '@/lib/api/index'
import type { PaymentAnalyticsResponse } from '@/lib/api/reports'
import { daysFromToday, fmtCompact, fmtMoney, fmtNum, fmtShortDay } from '@/app/(dashboard)/network/warehouses/_components/dashboard/format'
import { EmptyNote, Metric, Section } from '@/app/(dashboard)/network/warehouses/_components/dashboard/shared'
const PRESETS = [
{ label: '7 days', days: -6 },
{ label: '30 days', days: -29 },
{ label: '90 days', days: -89 },
{ label: '1 year', days: -364 },
]
const flowConfig = {
cash: { label: 'Cash', color: 'var(--chart-1)' },
cheque: { label: 'Cheque', color: 'var(--chart-2)' },
credit: { label: 'Credit', color: 'var(--chart-3)' },
} satisfies ChartConfig
const METHOD_COLOR: Record<string, string> = {
Cash: 'var(--chart-1)',
Cheque: 'var(--chart-2)',
Credit: 'var(--chart-3)',
}
const methodConfig = {
Cash: { label: 'Cash', color: METHOD_COLOR.Cash },
Cheque: { label: 'Cheque', color: METHOD_COLOR.Cheque },
Credit: { label: 'Credit', color: METHOD_COLOR.Credit },
} satisfies ChartConfig
const balanceConfig = { balance: { label: 'Outstanding', color: 'var(--chart-4)' } } satisfies ChartConfig
const periodLabel = (p: string) => (p.length === 10 ? fmtShortDay(p) : p)
export default function PaymentAnalyticsPage() {
const [from, setFrom] = useState(() => daysFromToday(-29))
const [to, setTo] = useState(() => daysFromToday(0))
const [data, setData] = useState<PaymentAnalyticsResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!from || !to) return
let cancelled = false
setLoading(true)
setError(null)
paymentReports.analytics({ from, to })
.then(res => { if (!cancelled) setData(res) })
.catch(err => { if (!cancelled) setError(getApiError(err)) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [from, to])
const s = data?.summary
const hasFlow = !!data && data.byMethod.some(m => m.amount > 0)
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Payment Analytics</h1>
<p className="text-sm text-muted-foreground mt-0.5">Money flow by method, collections and outstanding receivables.</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{PRESETS.map(p => (
<Button
key={p.label}
variant={from === daysFromToday(p.days) && to === daysFromToday(0) ? 'default' : 'outline'}
size="sm"
onClick={() => { setFrom(daysFromToday(p.days)); setTo(daysFromToday(0)) }}
>
{p.label}
</Button>
))}
<Input type="date" value={from} max={to} onChange={e => setFrom(e.target.value)} className="w-40" aria-label="From date" />
<Input type="date" value={to} min={from} onChange={e => setTo(e.target.value)} className="w-40" aria-label="To date" />
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div>
)}
{!error && !data && loading && (
<div className="flex flex-col gap-3">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)}
{data && s && (
<div className={loading ? 'flex flex-col gap-6 opacity-60 transition-opacity' : 'flex flex-col gap-6'}>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
<Metric label="Invoiced" value={fmtMoney(s.invoiced)} sub="Finalized in period" />
<Metric label="Collected" value={fmtMoney(s.collected)} sub={`${fmtNum(s.paymentCount)} payments · cash + cheque`} />
<Metric label="Credit extended" value={fmtMoney(s.creditExtended)} />
<Metric label="Outstanding" value={fmtMoney(s.outstanding)} sub="As of end date" tone={s.outstanding > 0 ? 'warning' : undefined} />
<Metric label="Overdue" value={fmtMoney(s.overdue)} sub="Unpaid over 30 days" tone={s.overdue > 0 ? 'danger' : undefined} />
<Metric label="Collection rate" value={s.collectionRatePct == null ? '—' : `${s.collectionRatePct}%`} sub="Collected ÷ invoiced" />
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-2">
<Section title="Money flow" description="Amount received per period, by method">
{!hasFlow ? (
<EmptyNote>No payments in this period.</EmptyNote>
) : (
<ChartContainer config={flowConfig} className="w-full" style={{ height: 260 }}>
<BarChart data={data.trend} margin={{ left: 8 }}>
<CartesianGrid vertical={false} />
<XAxis dataKey="period" tickFormatter={periodLabel} tickLine={false} axisLine={false} minTickGap={16} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={(l) => periodLabel(String(l))} formatter={(v) => fmtMoney(Number(v))} />} />
<Bar dataKey="cash" stackId="a" fill="var(--color-cash)" />
<Bar dataKey="cheque" stackId="a" fill="var(--color-cheque)" />
<Bar dataKey="credit" stackId="a" fill="var(--color-credit)" radius={[6, 6, 0, 0]} />
</BarChart>
</ChartContainer>
)}
</Section>
</div>
<Section title="By payment method" description="Share of total amount">
{!hasFlow ? (
<EmptyNote>No payments in this period.</EmptyNote>
) : (
<div className="flex flex-col gap-3">
<ChartContainer config={methodConfig} className="mx-auto w-full" style={{ height: 160 }}>
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="method" formatter={(v) => fmtMoney(Number(v))} />} />
<Pie data={data.byMethod} dataKey="amount" nameKey="method" innerRadius={44} outerRadius={70} strokeWidth={2}>
{data.byMethod.map(m => <Cell key={m.method} fill={METHOD_COLOR[m.method]} />)}
</Pie>
</PieChart>
</ChartContainer>
<ul className="flex flex-col gap-1.5 text-sm">
{data.byMethod.map(m => (
<li key={m.method} className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2">
<span className="size-2.5 rounded-full" style={{ background: METHOD_COLOR[m.method] }} />
{m.method} <span className="text-xs text-muted-foreground">· {fmtNum(m.count)}</span>
</span>
<span className="tabular-nums">{fmtMoney(m.amount)} <span className="text-xs text-muted-foreground">({m.sharePct}%)</span></span>
</li>
))}
</ul>
</div>
)}
</Section>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Section title="Outstanding aging" description="Unpaid balance by days since invoice finalized">
{s.outstanding === 0 ? (
<EmptyNote>Nothing outstanding.</EmptyNote>
) : (
<ChartContainer config={balanceConfig} className="w-full" style={{ height: 220 }}>
<BarChart data={data.aging} margin={{ left: 8 }}>
<CartesianGrid vertical={false} />
<XAxis dataKey="bucket" tickLine={false} axisLine={false} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
<ChartTooltip content={<ChartTooltipContent hideLabel formatter={(v) => fmtMoney(Number(v))} />} />
<Bar dataKey="balance" fill="var(--color-balance)" radius={6} />
</BarChart>
</ChartContainer>
)}
</Section>
<Section title="Top outstanding stores" description="Largest unpaid balances as of end date">
{data.topOutstanding.length === 0 ? (
<EmptyNote>No outstanding balances.</EmptyNote>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Store</TableHead>
<TableHead className="text-right">Invoices</TableHead>
<TableHead className="text-right">Outstanding</TableHead>
<TableHead className="text-right">Overdue</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.topOutstanding.map(r => (
<TableRow key={r.storeId}>
<TableCell className="font-medium">{r.storeName}</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(r.invoices)}</TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(r.balance)}</TableCell>
<TableCell className="text-right tabular-nums">
{r.overdue > 0 ? <span className="text-destructive">{fmtMoney(r.overdue)}</span> : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Section title="Cash by account" description="Cash payments received into each account">
{data.byAccount.length === 0 ? (
<EmptyNote>No cash payments in this period.</EmptyNote>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Account</TableHead>
<TableHead className="text-right">Payments</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.byAccount.map(a => (
<TableRow key={a.accountId ?? 'none'}>
<TableCell className="font-medium">{a.accountName}</TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(a.count)}</TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(a.amount)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
<Section title="Cheque pipeline" description="Cheque payments in period by cheque status" href="/payments/received-cheques" hrefLabel="Received cheques">
{data.cheques.length === 0 ? (
<EmptyNote>No cheque payments in this period.</EmptyNote>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Status</TableHead>
<TableHead className="text-right">Cheques</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.cheques.map(c => (
<TableRow key={c.status}>
<TableCell><Badge variant={c.status === 'Returned' ? 'destructive' : 'secondary'}>{c.status}</Badge></TableCell>
<TableCell className="text-right tabular-nums">{fmtNum(c.count)}</TableCell>
<TableCell className="text-right tabular-nums">{fmtMoney(c.amount)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Section>
</div>
</div>
)}
</div>
)
}
+1 -1
View File
@@ -65,7 +65,6 @@ export const NAV_GROUPS: NavGroup[] = [
items: [
{ title: 'Shop POs', href: '/sales', icon: ClipboardListIcon, description: 'Purchase orders raised by sales agents on behalf of stores', permission: 'orders.view' },
{ title: 'Invoices', href: '/sales/orders', icon: DollarSquareIcon, description: 'Billing documents generated from confirmed Shop POs', permission: 'orders.view' },
{ title: 'Payments', href: '/sales/payments', icon: BankIcon, description: 'Cash and credit payments collected against invoices', permission: 'orders.view' },
],
},
{
@@ -131,6 +130,7 @@ export const NAV_GROUPS: NavGroup[] = [
title: 'Accounts',
icon: BankIcon,
items: [
{ title: 'Analytics', href: '/payments/analytics', icon: BankIcon, description: 'Overview of bank accounts, cheque books and received cheques' },
{ title: 'Bank Accounts', href: '/payments/bank-accounts', icon: BankIcon, description: 'Cash and bank accounts used for payments and deposits' },
{ title: 'Cheque Books', href: '/payments/cheque-books', icon: ClipboardListIcon, description: 'Cheque books issued from the company’s own supply' },
{ title: 'Received Cheques', href: '/payments/received-cheques', icon: DollarSquareIcon, description: 'Cheques received from customers, suppliers, or others' },
+1 -1
View File
@@ -8,7 +8,7 @@ export { drivers, compliance } from './drivers'
export { purchaseOrders, grn, purchaseReturns } from './procurement'
export { rides, loadingSheets, deliveryTasks } from './operations'
export { stock } from './stock'
export { productReports, stockReports, dashboardReports } from './reports'
export { productReports, stockReports, dashboardReports, paymentReports } from './reports'
export { shopPos, invoices, payments } from './sales'
export { saleReturns, claims, supplierClaims } from './returns-claims'
export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS, invoiceCreationMode, INVOICE_CREATION_MODE_KEY, DEFAULT_INVOICE_CREATION_MODE } from './sys-config'
+25
View File
@@ -206,3 +206,28 @@ export const dashboardReports = {
inventory: (params?: { from?: string; to?: string; warehouseId?: string; categoryId?: string }) =>
api.get<DashboardInventorySummary>('/reports/dashboard/inventory', { params }).then(r => r.data),
}
export interface PaymentAnalyticsResponse {
from: string
to: string
summary: {
invoiced: number
collected: number
creditExtended: number
outstanding: number
overdue: number
collectionRatePct: number | null
paymentCount: number
}
byMethod: Array<{ method: 'Cash' | 'Cheque' | 'Credit'; count: number; amount: number; sharePct: number }>
trend: Array<{ period: string; cash: number; cheque: number; credit: number }>
aging: Array<{ bucket: string; invoices: number; balance: number }>
topOutstanding: Array<{ storeId: string; storeName: string; invoices: number; balance: number; overdue: number }>
byAccount: Array<{ accountId: string | null; accountName: string; count: number; amount: number }>
cheques: Array<{ status: string; count: number; amount: number }>
}
export const paymentReports = {
analytics: (params?: { from?: string; to?: string; regionId?: string }) =>
api.get<PaymentAnalyticsResponse>('/reports/payments/analytics', { params }).then(r => r.data),
}