From 99832df134c76490ee198c18976992e49a7791f2 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Thu, 22 Oct 2026 10:43:39 +0530 Subject: [PATCH] feat: add interactive sales trend and region sales pie components to dashboard --- .../_components/analytics-sections.tsx | 48 +++++-- .../_components/dashboard-client.tsx | 34 +---- .../_components/region-sales-pie.tsx | 123 ++++++++++++++++ .../_components/sales-trend-interactive.tsx | 133 ++++++++++++++++++ 4 files changed, 295 insertions(+), 43 deletions(-) create mode 100644 distribution_fe/app/(dashboard)/_components/region-sales-pie.tsx create mode 100644 distribution_fe/app/(dashboard)/_components/sales-trend-interactive.tsx diff --git a/distribution_fe/app/(dashboard)/_components/analytics-sections.tsx b/distribution_fe/app/(dashboard)/_components/analytics-sections.tsx index 34e55d5..8e39954 100644 --- a/distribution_fe/app/(dashboard)/_components/analytics-sections.tsx +++ b/distribution_fe/app/(dashboard)/_components/analytics-sections.tsx @@ -7,6 +7,8 @@ import { toast } from '@/components/ui/toast' import { dashboardReports, productReports, stockReports, getApiError, getPaginatedItems } from '@/lib/api/index' import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports' import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts' +import { RegionSalesPie } from './region-sales-pie' +import { SalesTrendInteractive } from './sales-trend-interactive' export interface DateRange { from: string // yyyy-MM-dd @@ -60,8 +62,8 @@ function Kpi({ title, value, footer }: { title: string; value: string; footer?: function LoadingGrid() { return (
-
- {Array.from({ length: 6 }).map((_, i) => )} +
+ {Array.from({ length: 7 }).map((_, i) => )}
{Array.from({ length: 6 }).map((_, i) => ( @@ -175,6 +177,7 @@ export function SalesSection({ range }: { range: DateRange }) { if (loading || productsLoading || !sales) return const { current: c, previous: p } = sales.kpis + const { current: cash, previous: cashPrev } = sales.cash const kpi = (title: string, key: keyof SalesKpis, money = true) => ( ({ 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) return (
-
+
{kpi('Net Sales', 'netSales')} {kpi('Gross Sales', 'grossSales')} {kpi('Total Orders', 'orders', false)} @@ -204,16 +206,40 @@ export function SalesSection({ range }: { range: DateRange }) { } /> {kpi('Sales Returns', 'returnValue')} + {kpi('Discounts', 'discounts')} +
+
+ } + /> + Unpaid on finalized invoices} + /> + Unpaid, invoiced over 30 days ago} + /> + Collected ÷ billed (gross − discounts)} + />

- Sales = fulfilled lines on non-cancelled orders with a finalized invoice, dated by finalization. Net = gross − customer returns. - Gross profit uses item cost prices and covers only items that have one. Discounts and sales targets are not tracked per order yet. + Sales are recognised when an invoice is finalized (billable invoice lines on non-cancelled orders), regardless of payment status or method. + 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.

- - - + @@ -232,9 +258,7 @@ export function SalesSection({ range }: { range: DateRange }) { ])} /> - - ({ name: r.name, value: r.amount, extra: sharePct(r.amount, regionTotal) }))} fmt={fmtMoney} empty="No sales in this period." /> - + ): Promise ) } - if (activeIds.has('lowStock')) { - fetches.push( - stock.lowStock() - .then(res => { data.lowStock = getPaginatedItems(res).slice(0, 5) }) - .catch(() => {}) - ) - } - if (activeIds.has('stockMovements')) { fetches.push( stock.movements({ pageSize: 5 }) @@ -400,24 +390,6 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
{data.partialTasksCount} partially delivered
) - case 'lowStock': - return data.lowStock.length === 0 ? ( -

No items below their reorder level.

- ) : ( -
-
- ItemWarehouseOn handReorder -
- {data.lowStock.map(row => ( -
- {row.itemName} - {row.locationName} - {row.quantity} - {row.reorderLevel} -
- ))} -
- ) case 'stockMovements': return data.movements.length === 0 ? (

No recent stock movements.

diff --git a/distribution_fe/app/(dashboard)/_components/region-sales-pie.tsx b/distribution_fe/app/(dashboard)/_components/region-sales-pie.tsx new file mode 100644 index 0000000..8ca88a5 --- /dev/null +++ b/distribution_fe/app/(dashboard)/_components/region-sales-pie.tsx @@ -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(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 ? ( + + + + + ) : ( + + ), + [activeIndex], + ) + + return ( + + + Sales by Region + Share of gross sales + {active && ( + + + + )} + + + {!active ? ( +

No sales in this period.

+ ) : ( + + + ( +
+ {(item.payload as { name: string }).name} + {fmtMoney(Number(value))} +
+ )} />} + /> + + +
+
+ )} +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/_components/sales-trend-interactive.tsx b/distribution_fe/app/(dashboard)/_components/sales-trend-interactive.tsx new file mode 100644 index 0000000..7ca8e53 --- /dev/null +++ b/distribution_fe/app/(dashboard)/_components/sales-trend-interactive.tsx @@ -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(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 ( + + + Sales Trend + + {loaded ? `${fmtMoney(total)} gross sales in the ${rangeLabel}` : 'Gross sales per day'} + + + + + + + {loaded === undefined ? ( + + ) : loaded === null || data.every(d => d.sales === 0) ? ( +

+ {loaded === null ? 'Sales trend is unavailable.' : `No sales in the ${rangeLabel}.`} +

+ ) : ( + + + + + + + + + + + + fmtLabel(String(v))} />} + /> + + + + )} +
+
+ ) +}