feat: Add Payment Analytics page and integrate payment reports API
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user