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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user