+
{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.
- ) : (
-
-
- Item Warehouse On hand Reorder
-
- {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 && (
+
+ v && setSelected(v)}>
+
+
+
+
+
+ {slices.map((s, i) => (
+
+
+
+ {s.name}
+
+
+ ))}
+
+
+
+
+ )}
+
+
+ {!active ? (
+ No sales in this period.
+ ) : (
+
+
+ (
+
+ {(item.payload as { name: string }).name}
+ {fmtMoney(Number(value))}
+
+ )} />}
+ />
+
+ {
+ if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) return null
+ const cy = viewBox.cy ?? 0
+ return (
+
+ {fmtCompact(active.amount)}
+
+ {total > 0 ? `${((active.amount / total) * 100).toFixed(1)}%` : ''}
+
+
+ )
+ }}
+ />
+
+
+
+ )}
+
+
+ )
+}
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'}
+
+
+ v && setRange(v)}>
+
+
+
+
+
+ {RANGES.map(r => {r.label} )}
+
+
+
+
+
+
+ {loaded === undefined ? (
+
+ ) : loaded === null || data.every(d => d.sales === 0) ? (
+
+ {loaded === null ? 'Sales trend is unavailable.' : `No sales in the ${rangeLabel}.`}
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+ fmtLabel(String(v))} />}
+ />
+
+
+
+ )}
+
+
+ )
+}