Implement Store analytics and api integrations
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user