Implement Store analytics and api integrations

This commit is contained in:
2026-10-08 16:32:23 +05:30
parent 4e12b03e4b
commit e5f6aa15b9
9 changed files with 1367 additions and 80 deletions
@@ -2,7 +2,6 @@
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon, PencilEdit01Icon } from '@hugeicons/core-free-icons'
import { Button } from '@/components/ui/button'
@@ -11,36 +10,21 @@ import { toast } from '@/components/ui/toast'
import { stores as storesApi, routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { StoreResponse, RouteResponse } from '@/types/api'
const LocationPicker = dynamic(
() => import('../_components/location-picker').then((m) => m.LocationPicker),
{ ssr: false, loading: () => <div className="animate-pulse rounded-2xl bg-muted" style={{ height: 460 }} /> }
)
import { StoreAnalytics } from '../_components/store-analytics'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const fmtMoney = (v: number) =>
`Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2 })}`
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5 py-3 border-b last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
<span className="text-sm font-medium text-foreground">{children}</span>
</div>
)
}
function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="rounded-2xl border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-1 text-xl font-semibold text-foreground">{value}</p>
{sub && <p className="text-xs text-muted-foreground mt-0.5">{sub}</p>}
</div>
)
}
export default function StoreViewPage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
@@ -66,7 +50,6 @@ export default function StoreViewPage() {
const routeName = store ? routesList.find((r) => r.id === store.routeId)?.routeName : undefined
const availableCredit = store ? store.creditLimit - store.outstandingBalance : 0
return (
<div className="flex flex-col gap-6">
@@ -103,73 +86,26 @@ export default function StoreViewPage() {
</Button>
</div>
{/* Body skeleton */}
{loading && (
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-2 lg:col-span-1">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="h-12 animate-pulse rounded-xl bg-muted" />
))}
</div>
<div className="flex flex-col gap-4 lg:col-span-2">
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-20 animate-pulse rounded-2xl bg-muted" />
))}
</div>
<div className="animate-pulse rounded-2xl bg-muted" style={{ height: 460 }} />
</div>
</div>
)}
{loading && <div className="h-20 animate-pulse rounded-xl bg-muted" />}
{/* Body */}
{!loading && store && (
<div className="grid gap-6 lg:grid-cols-3">
{/* Left — store details */}
<div className="lg:col-span-1">
<div className="rounded-2xl border px-4">
<DetailRow label="Owner">{store.ownerName ?? "—"}</DetailRow>
<DetailRow label="Contact">{store.contactNumber ?? "—"}</DetailRow>
<DetailRow label="Address">{store.storeAddress}</DetailRow>
<DetailRow label="Route">
{routeName ?? <span className="font-mono text-xs">{store.routeId}</span>}
</DetailRow>
{store.segment && (
<DetailRow label="Segment">{store.segment}</DetailRow>
)}
<DetailRow label="Credit Limit">{fmtMoney(store.creditLimit)}</DetailRow>
<DetailRow label="Registered">{fmtDate(store.createdAt)}</DetailRow>
</div>
</div>
{/* Right — credit stats + map */}
<div className="flex flex-col gap-4 lg:col-span-2">
<div className="grid grid-cols-3 gap-4">
<StatCard
label="Credit Limit"
value={fmtMoney(store.creditLimit)}
/>
<StatCard
label="Outstanding"
value={fmtMoney(store.outstandingBalance)}
/>
<StatCard
label="Available Credit"
value={fmtMoney(availableCredit)}
sub={availableCredit < 0 ? 'Over limit' : undefined}
/>
</div>
<LocationPicker
lat={store.latitude}
lng={store.longitude}
height={460}
readOnly
/>
<div className="flex flex-col gap-6">
<div className="grid gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
<DetailRow label="Owner">{store.ownerName ?? '—'}</DetailRow>
<DetailRow label="Contact">{store.contactNumber ?? '—'}</DetailRow>
<DetailRow label="Route">
{routeName ?? <span className="font-mono text-xs">{store.routeId}</span>}
</DetailRow>
<DetailRow label="Address">{store.storeAddress}</DetailRow>
<DetailRow label="Segment">{store.segment ?? '—'}</DetailRow>
<DetailRow label="Registered">{fmtDate(store.createdAt)}</DetailRow>
</div>
</div>
)}
{/* Shop analytics */}
{!loading && store && <StoreAnalytics storeId={id} creditLimit={store.creditLimit} outstandingBalance={store.outstandingBalance} />}
</div>
)
}
@@ -0,0 +1,403 @@
'use client'
import { useEffect, useState } from 'react'
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Label,
Line,
LineChart,
PolarAngleAxis,
Pie,
PieChart,
RadialBar,
RadialBarChart,
XAxis,
YAxis,
} from 'recharts'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart'
import { toast } from '@/components/ui/toast'
import { stores as storesApi, getApiError } from '@/lib/api/index'
import type { StoreAnalyticsResponse } from '@/types/api'
const fmtMoney = (v: number) =>
`Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
const fmtCompact = (v: number) =>
v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 })
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const fmtMonth = (v: string) =>
new Date(`${v}-01`).toLocaleDateString('en-GB', { month: 'short', year: '2-digit' })
const daysAgo = (v: string) => {
const d = Math.floor((Date.now() - new Date(v).getTime()) / 86_400_000)
return d <= 0 ? 'Today' : d === 1 ? '1 day ago' : `${d} days ago`
}
const salesConfig = {
invoiced: { label: 'Invoiced', color: 'var(--chart-2)' },
collected: { label: 'Collected', color: 'var(--chart-4)' },
} satisfies ChartConfig
const trendConfig = {
invoiced: { label: "Invoiced", color: "var(--chart-2)" },
outstanding: { label: "Outstanding", color: "var(--destructive)" },
} satisfies ChartConfig
const creditConfig = {
used: { label: 'Credit used', color: 'var(--chart-2)' },
} satisfies ChartConfig
const AGEING_FILL: Record<string, { color: string; opacity: number }> = {
'Current': { color: 'var(--chart-2)', opacity: 1 },
'1-30': { color: 'var(--destructive)', opacity: 0.45 },
'31-60': { color: 'var(--destructive)', opacity: 0.65 },
'61-90': { color: 'var(--destructive)', opacity: 0.85 },
'90+': { color: 'var(--destructive)', opacity: 1 },
}
const PRODUCT_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)', 'var(--muted-foreground)']
function DonutCenter({ title, value }: { title: string; value: string }) {
return (
<Label
content={({ viewBox }) => {
if (!viewBox || !('cx' in viewBox)) return null
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={viewBox.cx} y={(viewBox.cy ?? 0) - 6} className="fill-foreground text-base font-semibold">{value}</tspan>
<tspan x={viewBox.cx} y={(viewBox.cy ?? 0) + 12} className="fill-muted-foreground text-[11px]">{title}</tspan>
</text>
)
}}
/>
)
}
function Metric({ label, value, sub, danger }: { label: string; value: string; sub?: string; danger?: boolean }) {
return (
<div className="flex flex-col gap-0.5 rounded-xl border px-3 py-2">
<span className="truncate text-[11px] uppercase tracking-wide text-muted-foreground">{label}</span>
<span className={danger ? "truncate text-base font-semibold leading-tight text-destructive" : "truncate text-base font-semibold leading-tight"}>{value}</span>
{sub && <span className="truncate text-[11px] text-muted-foreground">{sub}</span>}
</div>
)
}
export function StoreAnalytics({
storeId,
creditLimit,
outstandingBalance,
}: {
storeId: string
creditLimit: number
outstandingBalance: number
}) {
const [data, setData] = useState<StoreAnalyticsResponse | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
storesApi.analytics(storeId)
.then(setData)
.catch((err) => toast.add({ title: 'Failed to load analytics', description: getApiError(err), type: 'error' }))
.finally(() => setLoading(false))
}, [storeId])
if (loading) {
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-16 rounded-xl" />)}
</div>
<Skeleton className="h-72 rounded-2xl" />
</div>
)
}
if (!data) return null
const { summary: s } = data
const availableCredit = creditLimit - outstandingBalance
const utilisationPct = creditLimit > 0 ? Math.round((outstandingBalance / creditLimit) * 1000) / 10 : 0
const ageingData = data.ageing.map((b) => ({ ...b, fill: AGEING_FILL[b.label]?.color, opacity: AGEING_FILL[b.label]?.opacity ?? 1 }))
const ageingTotal = data.ageing.reduce((sum, b) => sum + b.amount, 0)
const ageingConfig = Object.fromEntries(
data.ageing.map((b) => [b.label, { label: b.label === 'Current' ? 'Current' : `${b.label} days`, color: AGEING_FILL[b.label]?.color }])
) as ChartConfig
const topFive = data.topProducts.slice(0, 5)
const othersRevenue = data.topProducts.slice(5).reduce((sum, p) => sum + p.revenue, 0)
const productData = [
...topFive.map((p, i) => ({ name: p.name, revenue: p.revenue, fill: PRODUCT_COLORS[i] })),
...(othersRevenue > 0 ? [{ name: 'Others', revenue: othersRevenue, fill: PRODUCT_COLORS[5] }] : []),
]
const productTotal = productData.reduce((sum, p) => sum + p.revenue, 0)
const productConfig = Object.fromEntries(
productData.map((p) => [p.name, { label: p.name, color: p.fill }])
) as ChartConfig
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<Metric label="Total Sales (12m)" value={fmtMoney(s.totalSales)} sub={`${s.invoiceCount} invoices`} />
<Metric label="Collected" value={fmtMoney(s.totalCollected)} />
<Metric
label="Overdue"
value={fmtMoney(s.overdueAmount)}
sub={`${s.overdueInvoiceCount} invoice${s.overdueInvoiceCount === 1 ? '' : 's'} > ${data.overdueDays} days`}
danger={s.overdueAmount > 0}
/>
<Metric
label="Last Sale"
value={s.lastSaleDate ? fmtDate(s.lastSaleDate) : '—'}
sub={s.lastSaleDate ? daysAgo(s.lastSaleDate) : 'No sales yet'}
/>
<Metric label="Avg. Order Value" value={fmtMoney(s.averageOrderValue)} />
</div>
<div className="grid gap-4 lg:grid-cols-3">
<Card size="sm">
<CardHeader>
<CardTitle>Credit</CardTitle>
<CardDescription>{utilisationPct}% of credit limit used</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-3">
<div className="relative size-36">
<ChartContainer config={creditConfig} className="size-full">
<RadialBarChart
data={[{ used: Math.min(100, utilisationPct), fill: utilisationPct > 100 ? 'var(--destructive)' : 'var(--color-used)' }]}
startAngle={90}
endAngle={-270}
innerRadius="75%"
outerRadius="100%"
>
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
<RadialBar dataKey="used" background cornerRadius={10} />
</RadialBarChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-lg font-semibold">{utilisationPct}%</span>
<span className="text-[11px] text-muted-foreground">used</span>
</div>
</div>
<div className="grid w-full grid-cols-3 gap-2 text-center">
<div>
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Limit</p>
<p className="truncate text-xs font-semibold">{fmtCompact(creditLimit)}</p>
</div>
<div>
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Outstanding</p>
<p className="truncate text-xs font-semibold">{fmtCompact(outstandingBalance)}</p>
</div>
<div>
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Available</p>
<p className={availableCredit < 0 ? 'truncate text-xs font-semibold text-destructive' : 'truncate text-xs font-semibold'}>
{fmtCompact(availableCredit)}
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Monthly sales</CardTitle>
<CardDescription>Invoiced vs collected, last 12 months</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={salesConfig} className="h-56 w-full">
<AreaChart data={data.monthlySales}>
<defs>
<linearGradient id="fillInvoiced" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-invoiced)" stopOpacity={0.5} />
<stop offset="95%" stopColor="var(--color-invoiced)" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="fillCollected" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-collected)" stopOpacity={0.5} />
<stop offset="95%" stopColor="var(--color-collected)" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis dataKey="month" tickFormatter={fmtMonth} tickLine={false} axisLine={false} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={(v) => fmtMonth(String(v))} />} />
<Area dataKey="invoiced" type="monotone" stroke="var(--color-invoiced)" fill="url(#fillInvoiced)" />
<Area dataKey="collected" type="monotone" stroke="var(--color-collected)" fill="url(#fillCollected)" />
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Sales vs outstanding</CardTitle>
<CardDescription>Monthly invoiced vs balance owed at month end</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={trendConfig} className="h-56 w-full">
<LineChart data={data.monthlySales}>
<CartesianGrid vertical={false} />
<XAxis dataKey="month" tickFormatter={fmtMonth} tickLine={false} axisLine={false} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={(v) => fmtMonth(String(v))} />} />
<Line dataKey="invoiced" type="monotone" stroke="var(--color-invoiced)" strokeWidth={2} dot={false} />
<Line dataKey="outstanding" type="monotone" stroke="var(--color-outstanding)" strokeWidth={2} dot={false} />
<ChartLegend content={<ChartLegendContent />} />
</LineChart>
</ChartContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Receivables ageing</CardTitle>
<CardDescription>Unpaid balance by days overdue</CardDescription>
</CardHeader>
<CardContent>
{ageingTotal === 0 ? (
<p className="text-sm text-muted-foreground">No unpaid invoices.</p>
) : (
<ChartContainer config={ageingConfig} className="h-56 w-full">
<BarChart data={ageingData}>
<CartesianGrid vertical={false} />
<XAxis dataKey="label" tickLine={false} axisLine={false} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="label" />} />
<Bar dataKey="amount" radius={4} maxBarSize={36}>
{ageingData.map((b) => (
<Cell key={b.label} fill={b.fill} fillOpacity={b.opacity} />
))}
</Bar>
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
</div>
<Tabs defaultValue="products">
<TabsList>
<TabsTrigger value="products">Top products</TabsTrigger>
<TabsTrigger value="invoices">Invoices</TabsTrigger>
</TabsList>
<TabsContent value="products">
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Top selling products</CardTitle>
<CardDescription>By revenue, last 12 months</CardDescription>
</CardHeader>
<CardContent>
{data.topProducts.length === 0 ? (
<p className="text-sm text-muted-foreground">No product sales yet.</p>
) : (
<ChartContainer config={productConfig} className="mx-auto h-64 w-full">
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="name" />} />
<Pie data={productData} dataKey="revenue" nameKey="name" innerRadius={56} strokeWidth={2}>
<DonutCenter title="Revenue" value={fmtCompact(productTotal)} />
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="name" />} />
</PieChart>
</ChartContainer>
)}
</CardContent>
</Card>
<Card>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Revenue</TableHead>
<TableHead>Last sold</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.topProducts.map((p) => (
<TableRow key={p.itemId}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell className="text-right">{p.quantity}</TableCell>
<TableCell className="text-right">{fmtMoney(p.revenue)}</TableCell>
<TableCell>{fmtDate(p.lastSoldAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="invoices">
<Card>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Date</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Balance</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.invoices.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">No invoices yet.</TableCell>
</TableRow>
)}
{data.invoices.map((i) => (
<TableRow key={i.invoiceId}>
<TableCell className="font-mono text-xs">{i.invoiceId.slice(0, 8).toUpperCase()}</TableCell>
<TableCell>{fmtDate(i.invoiceDate)}</TableCell>
<TableCell>{fmtDate(i.dueDate)}</TableCell>
<TableCell className="text-right">{fmtMoney(i.totalAmount)}</TableCell>
<TableCell className="text-right">{fmtMoney(i.paidAmount)}</TableCell>
<TableCell className="text-right">{fmtMoney(i.balance)}</TableCell>
<TableCell>
{i.daysOverdue > 0 ? (
<Badge variant="destructive">{i.daysOverdue}d overdue</Badge>
) : (
<Badge variant={i.paymentStatus === 'Paid' ? 'default' : 'secondary'}>{i.paymentStatus}</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}