feat: add interactive sales trend and region sales pie components to dashboard
This commit is contained in:
@@ -7,6 +7,8 @@ import { toast } from '@/components/ui/toast'
|
|||||||
import { dashboardReports, productReports, stockReports, getApiError, getPaginatedItems } from '@/lib/api/index'
|
import { dashboardReports, productReports, stockReports, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports'
|
import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports'
|
||||||
import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts'
|
import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts'
|
||||||
|
import { RegionSalesPie } from './region-sales-pie'
|
||||||
|
import { SalesTrendInteractive } from './sales-trend-interactive'
|
||||||
|
|
||||||
export interface DateRange {
|
export interface DateRange {
|
||||||
from: string // yyyy-MM-dd
|
from: string // yyyy-MM-dd
|
||||||
@@ -60,8 +62,8 @@ function Kpi({ title, value, footer }: { title: string; value: string; footer?:
|
|||||||
function LoadingGrid() {
|
function LoadingGrid() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3" aria-busy="true">
|
<div className="flex flex-col gap-3" aria-busy="true">
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
|
||||||
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
|
{Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
@@ -175,6 +177,7 @@ export function SalesSection({ range }: { range: DateRange }) {
|
|||||||
if (loading || productsLoading || !sales) return <LoadingGrid />
|
if (loading || productsLoading || !sales) return <LoadingGrid />
|
||||||
|
|
||||||
const { current: c, previous: p } = sales.kpis
|
const { current: c, previous: p } = sales.kpis
|
||||||
|
const { current: cash, previous: cashPrev } = sales.cash
|
||||||
const kpi = (title: string, key: keyof SalesKpis, money = true) => (
|
const kpi = (title: string, key: keyof SalesKpis, money = true) => (
|
||||||
<Kpi
|
<Kpi
|
||||||
title={title}
|
title={title}
|
||||||
@@ -184,12 +187,11 @@ export function SalesSection({ range }: { range: DateRange }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const trend = sales.trend.map(t => ({ period: t.period, sales: t.sales, returns: t.returns }))
|
const trend = sales.trend.map(t => ({ period: t.period, sales: t.sales, returns: t.returns }))
|
||||||
const regionTotal = sales.byRegion.reduce((t, r) => t + r.amount, 0)
|
|
||||||
const repTotal = sales.byRep.reduce((t, r) => t + r.amount, 0)
|
const repTotal = sales.byRep.reduce((t, r) => t + r.amount, 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
|
||||||
{kpi('Net Sales', 'netSales')}
|
{kpi('Net Sales', 'netSales')}
|
||||||
{kpi('Gross Sales', 'grossSales')}
|
{kpi('Gross Sales', 'grossSales')}
|
||||||
{kpi('Total Orders', 'orders', false)}
|
{kpi('Total Orders', 'orders', false)}
|
||||||
@@ -204,16 +206,40 @@ export function SalesSection({ range }: { range: DateRange }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{kpi('Sales Returns', 'returnValue')}
|
{kpi('Sales Returns', 'returnValue')}
|
||||||
|
{kpi('Discounts', 'discounts')}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
<Kpi
|
||||||
|
title="Collected"
|
||||||
|
value={fmtMoney(cash.collected)}
|
||||||
|
footer={<Delta current={cash.collected} previous={cashPrev.collected} />}
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
title="Outstanding"
|
||||||
|
value={fmtMoney(cash.outstanding)}
|
||||||
|
footer={<span className="text-muted-foreground">Unpaid on finalized invoices</span>}
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
title="Overdue"
|
||||||
|
value={fmtMoney(cash.overdue)}
|
||||||
|
footer={<span className="text-muted-foreground">Unpaid, invoiced over 30 days ago</span>}
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
title="Collection Rate"
|
||||||
|
value={cash.collectionRatePercent === null ? '—' : `${cash.collectionRatePercent}%`}
|
||||||
|
footer={<span className="text-muted-foreground">Collected ÷ billed (gross − discounts)</span>}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
Sales = fulfilled lines on non-cancelled orders with a finalized invoice, dated by finalization. Net = gross − customer returns.
|
Sales are recognised when an invoice is finalized (billable invoice lines on non-cancelled orders), regardless of payment status or method.
|
||||||
Gross profit uses item cost prices and covers only items that have one. Discounts and sales targets are not tracked per order yet.
|
Net = gross − customer returns that deduct credit (item exchanges are net zero) − scheme discounts on those invoices
|
||||||
|
(not split by category, so Discounts is 0 when a category is selected). Collected = cash and cheque payments received;
|
||||||
|
credit-method payments stay outstanding. Outstanding and overdue are balances as of the end of the range, and Collected ignores the category filter.
|
||||||
|
Gross profit uses item cost prices and covers only items that have one. Sales targets are not tracked per order yet.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<ChartCard title="Sales Trend" note={`Gross sales per ${sales.granularity}`} className="md:col-span-2">
|
<SalesTrendInteractive className="md:col-span-2" />
|
||||||
<MultiAreaChart data={trend} series={[{ key: 'sales', label: 'Sales', color: seriesColor(0) }]} emptyText="No sales in this period." />
|
|
||||||
</ChartCard>
|
|
||||||
<ChartCard title="Sales by Category" note="Share of gross sales">
|
<ChartCard title="Sales by Category" note="Share of gross sales">
|
||||||
<DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." />
|
<DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
@@ -232,9 +258,7 @@ export function SalesSection({ range }: { range: DateRange }) {
|
|||||||
])}
|
])}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard title="Sales by Region">
|
<RegionSalesPie rows={sales.byRegion} />
|
||||||
<RankTable rows={sales.byRegion.map(r => ({ name: r.name, value: r.amount, extra: sharePct(r.amount, regionTotal) }))} fmt={fmtMoney} empty="No sales in this period." />
|
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
<ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2">
|
<ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2">
|
||||||
<MultiAreaChart
|
<MultiAreaChart
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
getPaginatedItems,
|
getPaginatedItems,
|
||||||
} from '@/lib/api/index'
|
} from '@/lib/api/index'
|
||||||
import { useAuth } from '@/contexts/auth-context'
|
import { useAuth } from '@/contexts/auth-context'
|
||||||
import type { LowStockItemResponse, StockMovementResponse } from '@/types/api'
|
import type { StockMovementResponse } from '@/types/api'
|
||||||
import { InventorySection, SalesSection, type DateRange } from './analytics-sections'
|
import { InventorySection, SalesSection, type DateRange } from './analytics-sections'
|
||||||
import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts'
|
import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts'
|
||||||
import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts'
|
import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts'
|
||||||
@@ -54,7 +54,6 @@ const WIDGET_META: WidgetMeta[] = [
|
|||||||
{ id: 'openRides', cat: 'ops', title: 'Open Rides', size: 'md', permission: 'rides.view' },
|
{ id: 'openRides', cat: 'ops', title: 'Open Rides', size: 'md', permission: 'rides.view' },
|
||||||
{ id: 'loadingSheetsByStatus',cat: 'ops', title: 'Loading Sheets by Status', size: 'md', permission: 'loading-sheet.view' },
|
{ id: 'loadingSheetsByStatus',cat: 'ops', title: 'Loading Sheets by Status', size: 'md', permission: 'loading-sheet.view' },
|
||||||
{ id: 'deliveryTasks', cat: 'ops', title: 'Pending Delivery Tasks', size: 'sm', permission: 'delivery-task.view' },
|
{ id: 'deliveryTasks', cat: 'ops', title: 'Pending Delivery Tasks', size: 'sm', permission: 'delivery-task.view' },
|
||||||
{ id: 'lowStock', cat: 'stock', title: 'Low Stock Alerts', size: 'lg', permission: 'stock.view' },
|
|
||||||
{ id: 'stockMovements', cat: 'stock', title: 'Recent Stock Movements', size: 'lg', permission: 'stock.view' },
|
{ id: 'stockMovements', cat: 'stock', title: 'Recent Stock Movements', size: 'lg', permission: 'stock.view' },
|
||||||
{ id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md', permission: 'stock.view' },
|
{ id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md', permission: 'stock.view' },
|
||||||
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm', permission: 'orders.view' },
|
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm', permission: 'orders.view' },
|
||||||
@@ -63,7 +62,7 @@ const WIDGET_META: WidgetMeta[] = [
|
|||||||
|
|
||||||
const DEFAULT_ACTIVE = [
|
const DEFAULT_ACTIVE = [
|
||||||
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
|
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
|
||||||
'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus',
|
'deliveryTasks', 'outstandingPay', 'invoiceStatus',
|
||||||
'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart',
|
'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -153,7 +152,6 @@ interface DashboardData {
|
|||||||
confirmedSheetsCount: number
|
confirmedSheetsCount: number
|
||||||
pendingTasksCount: number
|
pendingTasksCount: number
|
||||||
partialTasksCount: number
|
partialTasksCount: number
|
||||||
lowStock: LowStockItemResponse[]
|
|
||||||
movements: StockMovementResponse[]
|
movements: StockMovementResponse[]
|
||||||
warehouseBalances: WarehouseBalanceRow[]
|
warehouseBalances: WarehouseBalanceRow[]
|
||||||
outstandingAmount: number
|
outstandingAmount: number
|
||||||
@@ -173,7 +171,7 @@ function emptyDashboardData(): DashboardData {
|
|||||||
openRides: [],
|
openRides: [],
|
||||||
plannedSheetsCount: 0, confirmedSheetsCount: 0,
|
plannedSheetsCount: 0, confirmedSheetsCount: 0,
|
||||||
pendingTasksCount: 0, partialTasksCount: 0,
|
pendingTasksCount: 0, partialTasksCount: 0,
|
||||||
lowStock: [], movements: [], warehouseBalances: [],
|
movements: [], warehouseBalances: [],
|
||||||
outstandingAmount: 0, outstandingInvoiceCount: 0,
|
outstandingAmount: 0, outstandingInvoiceCount: 0,
|
||||||
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
|
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
|
||||||
poStatusChart: [], paymentStatusChart: [], movementTypeChart: [], monthlySales: [],
|
poStatusChart: [], paymentStatusChart: [], movementTypeChart: [], monthlySales: [],
|
||||||
@@ -236,14 +234,6 @@ async function loadDashboardData(activeIds: Set<string>): Promise<DashboardData>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeIds.has('lowStock')) {
|
|
||||||
fetches.push(
|
|
||||||
stock.lowStock()
|
|
||||||
.then(res => { data.lowStock = getPaginatedItems(res).slice(0, 5) })
|
|
||||||
.catch(() => {})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeIds.has('stockMovements')) {
|
if (activeIds.has('stockMovements')) {
|
||||||
fetches.push(
|
fetches.push(
|
||||||
stock.movements({ pageSize: 5 })
|
stock.movements({ pageSize: 5 })
|
||||||
@@ -400,24 +390,6 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
|
|||||||
<div className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div>
|
<div className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
case 'lowStock':
|
|
||||||
return data.lowStock.length === 0 ? (
|
|
||||||
<p className="text-xs text-muted-foreground">No items below their reorder level.</p>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div className="grid grid-cols-[2fr_1.4fr_0.8fr_0.8fr] gap-2 border-b pb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
||||||
<span>Item</span><span>Warehouse</span><span>On hand</span><span>Reorder</span>
|
|
||||||
</div>
|
|
||||||
{data.lowStock.map(row => (
|
|
||||||
<div key={`${row.itemId}-${row.locationId}`} className="grid grid-cols-[2fr_1.4fr_0.8fr_0.8fr] gap-2 border-b border-muted py-2.5 text-sm last:border-0">
|
|
||||||
<span className="text-foreground">{row.itemName}</span>
|
|
||||||
<span className="text-muted-foreground">{row.locationName}</span>
|
|
||||||
<span className="font-semibold text-destructive">{row.quantity}</span>
|
|
||||||
<span className="text-muted-foreground">{row.reorderLevel}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
case 'stockMovements':
|
case 'stockMovements':
|
||||||
return data.movements.length === 0 ? (
|
return data.movements.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">No recent stock movements.</p>
|
<p className="text-xs text-muted-foreground">No recent stock movements.</p>
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { Label, Pie, PieChart, Sector } from 'recharts'
|
||||||
|
import type { PieSectorShapeProps } from 'recharts/types/polar/Pie'
|
||||||
|
|
||||||
|
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
|
||||||
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
|
||||||
|
const SLICE_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']
|
||||||
|
const MAX_SLICES = 5
|
||||||
|
|
||||||
|
const fmtCompact = (v: number) => v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 })
|
||||||
|
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { maximumFractionDigits: 0 })}`
|
||||||
|
|
||||||
|
/** Top (n-1) regions plus an "Other" slice, so the pie and the picker stay readable. */
|
||||||
|
function topWithOther(rows: Array<{ name: string; amount: number }>) {
|
||||||
|
const sorted = [...rows].filter(r => r.amount > 0).sort((a, b) => b.amount - a.amount)
|
||||||
|
if (sorted.length <= MAX_SLICES) return sorted
|
||||||
|
const head = sorted.slice(0, MAX_SLICES - 1)
|
||||||
|
return [...head, { name: 'Other', amount: sorted.slice(MAX_SLICES - 1).reduce((t, r) => t + r.amount, 0) }]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive pie of gross sales by region: pick a region (or it defaults to the largest) to pull its slice
|
||||||
|
* out and show its amount and share in the centre.
|
||||||
|
*/
|
||||||
|
export function RegionSalesPie({ rows, className }: { rows: Array<{ name: string; amount: number }>; className?: string }) {
|
||||||
|
const slices = useMemo(
|
||||||
|
() => topWithOther(rows).map((r, i) => ({ key: `r${i}`, name: r.name, amount: r.amount, fill: `var(--color-r${i})` })),
|
||||||
|
[rows],
|
||||||
|
)
|
||||||
|
const config = useMemo(
|
||||||
|
() => Object.fromEntries(slices.map((s, i) => [s.key, { label: s.name, color: SLICE_COLORS[i % SLICE_COLORS.length] }])) as ChartConfig,
|
||||||
|
[slices],
|
||||||
|
)
|
||||||
|
const total = slices.reduce((t, s) => t + s.amount, 0)
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
// Fall back to the largest region when nothing is picked or the picked region left the data (range changed).
|
||||||
|
const activeKey = slices.some(s => s.key === selected) ? selected : slices[0]?.key
|
||||||
|
const activeIndex = slices.findIndex(s => s.key === activeKey)
|
||||||
|
const active = slices[activeIndex]
|
||||||
|
|
||||||
|
const renderShape = useCallback(
|
||||||
|
({ index, outerRadius = 0, ...props }: PieSectorShapeProps) =>
|
||||||
|
index === activeIndex ? (
|
||||||
|
<g>
|
||||||
|
<Sector {...props} outerRadius={outerRadius + 10} />
|
||||||
|
<Sector {...props} outerRadius={outerRadius + 25} innerRadius={outerRadius + 12} />
|
||||||
|
</g>
|
||||||
|
) : (
|
||||||
|
<Sector {...props} outerRadius={outerRadius} />
|
||||||
|
),
|
||||||
|
[activeIndex],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sales by Region</CardTitle>
|
||||||
|
<CardDescription>Share of gross sales</CardDescription>
|
||||||
|
{active && (
|
||||||
|
<CardAction>
|
||||||
|
<Select value={activeKey} onValueChange={v => v && setSelected(v)}>
|
||||||
|
<SelectTrigger size="sm" className="w-36" aria-label="Select a region">
|
||||||
|
<SelectValue placeholder="Select region" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent align="end">
|
||||||
|
<SelectGroup>
|
||||||
|
{slices.map((s, i) => (
|
||||||
|
<SelectItem key={s.key} value={s.key} label={s.name}>
|
||||||
|
<span className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="size-3 shrink-0 rounded-xs" style={{ backgroundColor: SLICE_COLORS[i % SLICE_COLORS.length] }} />
|
||||||
|
{s.name}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardAction>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-1 justify-center">
|
||||||
|
{!active ? (
|
||||||
|
<p className="flex h-52 items-center justify-center text-xs text-muted-foreground">No sales in this period.</p>
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={config} className="mx-auto aspect-square w-full max-w-[260px]">
|
||||||
|
<PieChart>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent hideLabel nameKey="key" formatter={(value, _name, item) => (
|
||||||
|
<div className="flex w-full items-center justify-between gap-4">
|
||||||
|
<span className="text-muted-foreground">{(item.payload as { name: string }).name}</span>
|
||||||
|
<span className="font-mono font-medium text-foreground tabular-nums">{fmtMoney(Number(value))}</span>
|
||||||
|
</div>
|
||||||
|
)} />}
|
||||||
|
/>
|
||||||
|
<Pie data={slices} dataKey="amount" nameKey="key" innerRadius={60} strokeWidth={5} shape={renderShape}>
|
||||||
|
<Label
|
||||||
|
content={({ viewBox }) => {
|
||||||
|
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) return null
|
||||||
|
const cy = viewBox.cy ?? 0
|
||||||
|
return (
|
||||||
|
<text x={viewBox.cx} y={cy} textAnchor="middle" dominantBaseline="middle">
|
||||||
|
<tspan x={viewBox.cx} y={cy - 6} className="fill-foreground text-xl font-bold">{fmtCompact(active.amount)}</tspan>
|
||||||
|
<tspan x={viewBox.cx} y={cy + 12} className="fill-muted-foreground text-[11px]">
|
||||||
|
{total > 0 ? `${((active.amount / total) * 100).toFixed(1)}%` : ''}
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
|
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from '@/components/ui/chart'
|
||||||
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { dashboardReports, getApiError } from '@/lib/api/index'
|
||||||
|
import type { DashboardSalesSummary } from '@/lib/api/reports'
|
||||||
|
|
||||||
|
const MAX_DAYS = 90
|
||||||
|
|
||||||
|
const RANGES = [
|
||||||
|
{ value: '90', label: 'Last 3 months' },
|
||||||
|
{ value: '30', label: 'Last 30 days' },
|
||||||
|
{ value: '7', label: 'Last 7 days' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
sales: { label: 'Gross sales', color: 'var(--chart-2)' },
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
|
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
|
||||||
|
const parseIso = (v: string) => new Date(`${v}T00:00:00`)
|
||||||
|
|
||||||
|
const fmtCompact = (v: number) => v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 })
|
||||||
|
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { maximumFractionDigits: 0 })}`
|
||||||
|
const fmtTick = (v: string) => parseIso(v).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
|
const fmtLabel = (v: string) => parseIso(v).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
|
||||||
|
interface Loaded {
|
||||||
|
end: string // yyyy-MM-dd of the last day covered
|
||||||
|
trend: DashboardSalesSummary['trend']
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive area chart of daily gross sales. The last 90 days are loaded once and the
|
||||||
|
* time-range selector narrows them client-side. Days without activity are plotted as 0 so every day shows.
|
||||||
|
*/
|
||||||
|
export function SalesTrendInteractive({ className }: { className?: string }) {
|
||||||
|
const [range, setRange] = useState('90')
|
||||||
|
const [loaded, setLoaded] = useState<Loaded | null | undefined>(undefined)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const today = new Date()
|
||||||
|
dashboardReports
|
||||||
|
.sales({ from: toIso(addDays(today, -(MAX_DAYS - 1))), to: toIso(today) })
|
||||||
|
.then(res => { if (!cancelled) setLoaded({ end: toIso(today), trend: res.trend }) })
|
||||||
|
.catch(err => {
|
||||||
|
if (cancelled) return
|
||||||
|
toast.add({ title: 'Failed to load sales trend', description: getApiError(err), type: 'error' })
|
||||||
|
setLoaded(null)
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const days = Number(range)
|
||||||
|
const data = useMemo(() => {
|
||||||
|
if (!loaded) return []
|
||||||
|
const byDay = new Map(loaded.trend.map(t => [t.period, t]))
|
||||||
|
const end = parseIso(loaded.end)
|
||||||
|
return Array.from({ length: days }, (_, i) => {
|
||||||
|
const date = toIso(addDays(end, i - (days - 1)))
|
||||||
|
const point = byDay.get(date)
|
||||||
|
return { date, sales: point?.sales ?? 0 }
|
||||||
|
})
|
||||||
|
}, [loaded, days])
|
||||||
|
|
||||||
|
const total = data.reduce((sum, d) => sum + d.sales, 0)
|
||||||
|
const rangeLabel = RANGES.find(r => r.value === range)?.label.toLowerCase() ?? ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sales Trend</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{loaded ? `${fmtMoney(total)} gross sales in the ${rangeLabel}` : 'Gross sales per day'}
|
||||||
|
</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Select value={range} onValueChange={v => v && setRange(v)}>
|
||||||
|
<SelectTrigger size="sm" className="w-40" aria-label="Select time range">
|
||||||
|
<SelectValue placeholder="Last 3 months" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{RANGES.map(r => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loaded === undefined ? (
|
||||||
|
<Skeleton className="h-[250px] w-full rounded-xl" />
|
||||||
|
) : loaded === null || data.every(d => d.sales === 0) ? (
|
||||||
|
<p className="flex h-[250px] items-center justify-center text-xs text-muted-foreground">
|
||||||
|
{loaded === null ? 'Sales trend is unavailable.' : `No sales in the ${rangeLabel}.`}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
|
||||||
|
<AreaChart data={data}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fillSales" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-sales)" stopOpacity={0.8} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-sales)" stopOpacity={0.1} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} tickFormatter={fmtTick} />
|
||||||
|
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={44} />
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent indicator="dot" labelFormatter={v => fmtLabel(String(v))} />}
|
||||||
|
/>
|
||||||
|
<Area dataKey="sales" type="natural" fill="url(#fillSales)" stroke="var(--color-sales)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user