From 3c75f9786e58a2e9aa0aecf62ce04d05e5eb3b72 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Mon, 19 Oct 2026 15:51:39 +0530 Subject: [PATCH] Add multi-series charts and enhance dashboard functionality --- .../_components/analytics-sections.tsx | 345 +++++++++++++++++ .../_components/dashboard-charts.tsx | 86 ++++- .../_components/dashboard-client.tsx | 365 ++++++++---------- distribution_fe/lib/api/index.ts | 2 +- distribution_fe/lib/api/reports.ts | 38 ++ 5 files changed, 621 insertions(+), 215 deletions(-) create mode 100644 distribution_fe/app/(dashboard)/_components/analytics-sections.tsx diff --git a/distribution_fe/app/(dashboard)/_components/analytics-sections.tsx b/distribution_fe/app/(dashboard)/_components/analytics-sections.tsx new file mode 100644 index 0000000..e3ef94a --- /dev/null +++ b/distribution_fe/app/(dashboard)/_components/analytics-sections.tsx @@ -0,0 +1,345 @@ +'use client' + +import { useEffect, useState, type ReactNode } from 'react' + +import { Skeleton } from '@/components/ui/skeleton' +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' + +export interface DateRange { + from: string // yyyy-MM-dd + to: string +} + +const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { maximumFractionDigits: 0 })}` +const fmtNum = (v: number) => v.toLocaleString('en-LK', { maximumFractionDigits: 2 }) + +function ChartCard({ title, note, children, className = '' }: { title: string; note?: string; children: ReactNode; className?: string }) { + return ( +
+
+
+ + {title} +
+ {note &&
{note}
} +
+
{children}
+
+ ) +} + +function Delta({ current, previous }: { current: number; previous: number }) { + if (previous === 0) return No previous-period data + const pct = ((current - previous) / Math.abs(previous)) * 100 + const up = pct >= 0 + return ( + + + {up ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}% + + vs prev. + + ) +} + +function Kpi({ title, value, footer }: { title: string; value: string; footer?: ReactNode }) { + return ( +
+
+
{title}
+
{value}
+
+ {footer &&
{footer}
} +
+ ) +} + +function LoadingGrid() { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => )} +
+ ) +} + +function useLoad(load: () => Promise, deps: unknown[], failTitle: string) { + const key = JSON.stringify(deps) + const [result, setResult] = useState<{ key: string; data: T | null } | null>(null) + useEffect(() => { + let cancelled = false + load() + .then(data => { if (!cancelled) setResult({ key, data }) }) + .catch(err => { + if (cancelled) return + toast.add({ title: failTitle, description: getApiError(err), type: 'error' }) + setResult({ key, data: null }) + }) + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]) + // Loading until the response for the *current* deps has arrived, so a changed range shows the skeleton again. + return { data: result?.key === key ? result.data : null, loading: result?.key !== key } +} + +function Empty({ text }: { text: string }) { + return

{text}

+} + +/** Ranked list with an inline share bar — friendlier than a bar chart for "top N" comparisons. */ +function RankTable({ rows, fmt, empty, barClass = 'bg-primary' }: { + rows: Array<{ name: string; value: number; extra?: string }> + fmt: (v: number) => string + empty: string + barClass?: string +}) { + if (rows.length === 0 || rows.every(r => r.value === 0)) return + const max = Math.max(...rows.map(r => r.value), 1) + return ( +
+ + + {rows.map((r, i) => ( + + + + + + ))} + +
{i + 1} +
{r.name}
+
+
+
+
+ {fmt(r.value)} + {r.extra &&
{r.extra}
} +
+
+ ) +} + +function DataTable({ cols, rows, empty }: { cols: string[]; rows: ReactNode[][]; empty: string }) { + if (rows.length === 0) return + return ( +
+ + + + {cols.map((c, i) => )} + + + + {rows.map((r, ri) => ( + + {r.map((cell, ci) => ( + + ))} + + ))} + +
{c}
{cell}
+
+ ) +} + +const named = (rows: Array<{ name: string; amount: number }>) => rows.map(r => ({ name: r.name, value: r.amount })) + +/** Top (n-1) slices plus an "Other" slice, so donuts stay readable. */ +function topWithOther(rows: Array<{ name: string; value: number }>, n = 5) { + const sorted = [...rows].filter(r => r.value > 0).sort((a, b) => b.value - a.value) + if (sorted.length <= n) return sorted + const head = sorted.slice(0, n - 1) + return [...head, { name: 'Other', value: sorted.slice(n - 1).reduce((t, r) => t + r.value, 0) }] +} + +const sharePct = (v: number, total: number) => (total > 0 ? `${((v / total) * 100).toFixed(1)}%` : '—') + +export function SalesSection({ range }: { range: DateRange }) { + const { data: sales, loading } = useLoad( + () => dashboardReports.sales(range), [range.from, range.to], 'Failed to load sales analytics') + const { data: products } = useLoad( + () => productReports.sales({ ...range, pageSize: 10 }).then(r => getPaginatedItems(r)), + [range.from, range.to], 'Failed to load product sales') + + if (loading || !sales) return + + const { current: c, previous: p } = sales.kpis + const kpi = (title: string, key: keyof SalesKpis, money = true) => ( + } + /> + ) + + 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) + + return ( +
+
+ {kpi('Net Sales', 'netSales')} + {kpi('Gross Sales', 'grossSales')} + {kpi('Total Orders', 'orders', false)} + {kpi('Average Order Value', 'averageOrderValue')} + + {c.grossProfitMarginPercent === null ? 'Margin n/a (no cost prices)' : `${c.grossProfitMarginPercent}% margin`} + + } + /> + {kpi('Sales Returns', 'returnValue')} +
+

+ 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. +

+ +
+ + + + + + + + + [ + r.name, + fmtNum(r.qtyFulfilled), + fmtMoney(r.salesValue), + r.marginPercent === null ? '—' : ( + = 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive'}>{r.marginPercent}% + ), + ])} + /> + + + ({ name: r.name, value: r.amount, extra: sharePct(r.amount, regionTotal) }))} fmt={fmtMoney} empty="No sales in this period." /> + + + + + + + ({ name: r.name, value: r.amount, extra: sharePct(r.amount, repTotal) }))} fmt={fmtMoney} empty="No sales in this period." /> + +
+
+ ) +} + +export function InventorySection({ range }: { range: DateRange }) { + const { data: inv, loading } = useLoad( + () => dashboardReports.inventory(range), [range.from, range.to], 'Failed to load inventory analytics') + const { data: overview } = useLoad( + () => stockReports.overview(), [], 'Failed to load stock overview') + const { data: lowStock } = useLoad( + () => productReports.stock({ lowStockOnly: true, pageSize: 50 }).then(r => getPaginatedItems(r)), + [], 'Failed to load low-stock products') + const { data: movers } = useLoad( + () => productReports.sales({ ...range, pageSize: 50 }).then(r => getPaginatedItems(r)), + [range.from, range.to], 'Failed to load product sales') + + if (loading || !inv || !overview) return + + const healthy = Math.max(0, overview.totalSkus - overview.lowStockCount - overview.outOfStockCount) + const sold = (movers ?? []).map(r => ({ name: r.name, value: r.qtyFulfilled })) + const fast = sold.slice(0, 3) + const slow = sold.slice(-3).reverse() + const catTotal = inv.valueByCategory.reduce((t, r) => t + r.value, 0) + const lowRows = (lowStock ?? []) + .filter(r => r.reorderLevel) + .sort((a, b) => a.warehouseQty / (a.reorderLevel ?? 1) - b.warehouseQty / (b.reorderLevel ?? 1)) + .slice(0, 10) + + return ( +
+
+ + + At cost price} /> + + + Batches · {fmtNum(overview.expiredCount)} already expired} /> +
+

+ Reserved stock, excess stock, ageing, discrepancies and pending transfers are not tracked yet, so they are not shown. +

+ +
+ + + + + + + + + [r.name, fmtNum(r.quantity), fmtMoney(r.value)])} + /> + + + ({ name: r.category, value: r.value, extra: sharePct(r.value, catTotal) }))} + fmt={fmtMoney} empty="No stock recorded." + /> + + + + { + const cov = Math.round((r.warehouseQty / (r.reorderLevel ?? 1)) * 100) + return [ + r.name, + fmtNum(r.warehouseQty), + fmtNum(r.reorderLevel ?? 0), + {cov}%, + ] + })} + /> + + +
+
Fast-moving
+ +
Slow-moving
+ +
+
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/_components/dashboard-charts.tsx b/distribution_fe/app/(dashboard)/_components/dashboard-charts.tsx index 9b1b3ed..cdd72cf 100644 --- a/distribution_fe/app/(dashboard)/_components/dashboard-charts.tsx +++ b/distribution_fe/app/(dashboard)/_components/dashboard-charts.tsx @@ -1,6 +1,6 @@ 'use client' -import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Label, PolarAngleAxis, Pie, PieChart, RadialBar, RadialBarChart, XAxis, YAxis } from 'recharts' +import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Label, Line, LineChart, PolarAngleAxis, Pie, PieChart, RadialBar, RadialBarChart, XAxis, YAxis } from 'recharts' import { ChartContainer, @@ -60,10 +60,10 @@ export function DonutChart({ data, centerLabel, emptyText }: { data: ChartDatum[ ) } -export function HorizontalBarChart({ data, emptyText, color = 'var(--chart-2)' }: { data: ChartDatum[]; emptyText: string; color?: string }) { +export function HorizontalBarChart({ data, emptyText, color = 'var(--chart-2)', label = 'Quantity' }: { data: ChartDatum[]; emptyText: string; color?: string; label?: string }) { if (data.length === 0 || data.every(d => d.value === 0)) return - const config = { value: { label: 'Quantity', color } } satisfies ChartConfig + const config = { value: { label, color } } satisfies ChartConfig return ( @@ -140,3 +140,83 @@ export function RadialGauge({ pct, label, sub }: { pct: number; label: string; s ) } + +export interface SeriesDef { + key: string + label: string + color: string +} + +const SERIES_COLORS = ['var(--chart-2)', 'var(--chart-4)', 'var(--chart-1)'] +export const seriesColor = (i: number) => SERIES_COLORS[i % SERIES_COLORS.length] + +/** Multi-series line chart over a categorical x axis (day or month labels). */ +export function MultiLineChart({ data, series, emptyText }: { data: Array>; series: SeriesDef[]; emptyText: string }) { + if (data.length === 0) return + const config = Object.fromEntries(series.map(s => [s.key, { label: s.label, color: s.color }])) as ChartConfig + + return ( + + + + + + } /> + {series.map(s => ( + + ))} + } /> + + + ) +} + +/** Grouped (side-by-side) column chart comparing several series per category. */ +export function GroupedColumnChart({ data, series, emptyText }: { data: Array>; series: SeriesDef[]; emptyText: string }) { + if (data.length === 0) return + const config = Object.fromEntries(series.map(s => [s.key, { label: s.label, color: s.color }])) as ChartConfig + + return ( + + + + + + } /> + {series.map(s => ( + + ))} + } /> + + + ) +} + +/** Multi-series smooth area chart with a soft gradient under each line. */ +export function MultiAreaChart({ data, series, emptyText }: { data: Array>; series: SeriesDef[]; emptyText: string }) { + if (data.length === 0) return + const config = Object.fromEntries(series.map(s => [s.key, { label: s.label, color: s.color }])) as ChartConfig + + return ( + + + + {series.map(s => ( + + + + + ))} + + + + + } /> + {series.map(s => ( + + ))} + } /> + + + ) +} diff --git a/distribution_fe/app/(dashboard)/_components/dashboard-client.tsx b/distribution_fe/app/(dashboard)/_components/dashboard-client.tsx index db253d8..a8e2745 100644 --- a/distribution_fe/app/(dashboard)/_components/dashboard-client.tsx +++ b/distribution_fe/app/(dashboard)/_components/dashboard-client.tsx @@ -1,14 +1,10 @@ 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' -import { HugeiconsIcon } from '@hugeicons/react' -import { Add01Icon, Cancel01Icon } from '@hugeicons/core-free-icons' import { ResponsiveGridLayout, DEFAULT_BREAKPOINTS, DEFAULT_COLS, useContainerWidth } from 'react-grid-layout' -import type { Layout, LayoutItem } from 'react-grid-layout' +import type { LayoutItem } from 'react-grid-layout' import 'react-grid-layout/css/styles.css' -import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' import { Skeleton } from '@/components/ui/skeleton' import { toast } from '@/components/ui/toast' import { @@ -23,12 +19,12 @@ import { } from '@/lib/api/index' import { useAuth } from '@/contexts/auth-context' import type { LowStockItemResponse, StockMovementResponse } from '@/types/api' +import { InventorySection, SalesSection, type DateRange } from './analytics-sections' import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts' import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts' -const STORAGE_KEY = 'distribution-dashboard-widgets-v3' -const ROW_HEIGHT = 36 -const GRID_MARGIN: [number, number] = [16, 16] +const ROW_HEIGHT = 26 +const GRID_MARGIN: [number, number] = [10, 10] type Breakpoint = keyof typeof DEFAULT_BREAKPOINTS const BREAKPOINT_ORDER = Object.keys(DEFAULT_BREAKPOINTS) as Breakpoint[] @@ -83,14 +79,14 @@ const SIZE_CONFIG: Record { - return Object.fromEntries(BREAKPOINT_ORDER.map(bp => [bp, generateFlowLayout(ids, bp)])) as Record -} - -/** Places a newly-added widget below everything else on a given breakpoint, so it never overlaps what's already there. */ -function appendWidget(layout: LayoutItem[], meta: WidgetMeta, bp: Breakpoint): LayoutItem[] { - const cfg = SIZE_CONFIG[bp][meta.size] - const y = layout.reduce((max, item) => Math.max(max, item.y + item.h), 0) - return [...layout, { i: meta.id, x: 0, y, w: cfg.w, h: cfg.h, minW: cfg.minW, minH: cfg.minH }] -} - -interface DashboardLayoutState { - activeIds: string[] - layouts: Record -} - -function defaultLayoutState(): DashboardLayoutState { - return { activeIds: [...DEFAULT_ACTIVE], layouts: generateFlowLayouts(DEFAULT_ACTIVE) } -} - -function loadStoredLayoutState(): DashboardLayoutState { - try { - const raw = window.localStorage.getItem(STORAGE_KEY) - if (!raw) return defaultLayoutState() - - const parsed = JSON.parse(raw) - const validIds = new Set(WIDGET_META.map(w => w.id)) - const activeIds: string[] = Array.isArray(parsed?.activeIds) - ? parsed.activeIds.filter((id: unknown): id is string => typeof id === 'string' && validIds.has(id)) - : [] - if (activeIds.length === 0) return defaultLayoutState() - - const layouts = {} as Record - for (const bp of BREAKPOINT_ORDER) { - const storedLayout: LayoutItem[] = Array.isArray(parsed?.layouts?.[bp]) - ? parsed.layouts[bp].filter((l: unknown): l is LayoutItem => - !!l && typeof l === 'object' && typeof (l as LayoutItem).i === 'string' && validIds.has((l as LayoutItem).i) && activeIds.includes((l as LayoutItem).i)) - : [] - const missingIds = activeIds.filter(id => !storedLayout.some(l => l.i === id)) - layouts[bp] = missingIds.length > 0 ? [...storedLayout, ...generateFlowLayout(missingIds, bp)] : storedLayout - } - - return { activeIds, layouts } - } catch { - return defaultLayoutState() - } -} - const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2 })}` interface OpenRide { @@ -351,12 +299,51 @@ async function loadDashboardData(activeIds: Set): Promise return data } -function CategoryPill({ cat }: { cat: Category }) { - const meta = CATEGORY_META[cat] +const STAT_IDS = ['pendingPO', 'awaitingConfirm', 'fulfillRate', 'deliveryTasks', 'outstandingPay', 'invoiceStatus'] + +function StatTile({ title, value, sub, children }: { title: string; value: string; sub?: string; children?: React.ReactNode }) { return ( - - {meta.label} - +
+
+
{title}
+
{value}
+
+
+ {sub &&
{sub}
} + {children} +
+
+ ) +} + +function StatStrip({ ids, data }: { ids: string[]; data: DashboardData }) { + const pct = data.totalPOCount > 0 ? Math.round((data.fulfilledPOCount / data.totalPOCount) * 100) : 0 + const invTotal = data.draftInvoiceCount + data.printedInvoiceCount + data.finalizedInvoiceCount + const seg = (n: number) => (invTotal > 0 ? `${(n / invTotal) * 100}%` : '0%') + const tiles: Record = { + pendingPO: , + awaitingConfirm: , + fulfillRate: ( + +
+ + ), + deliveryTasks: , + outstandingPay: , + invoiceStatus: ( + +
+
+
+
+
+ + ), + } + return ( +
+ {STAT_IDS.filter(id => ids.includes(id)).map(id =>
{tiles[id]}
)} +
) } @@ -365,14 +352,14 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) { case 'pendingPO': return (
-
{data.pendingPOCount}
+
{data.pendingPOCount}
{data.totalPOCount} total orders
) case 'awaitingConfirm': return (
-
{data.awaitingConfirmCount}
+
{data.awaitingConfirmCount}
Confirmed, not yet loaded
) @@ -391,7 +378,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
{r.vehicleNumberPlate}
{r.driverFullName}
- Open + Open
))}
@@ -409,7 +396,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) { case 'deliveryTasks': return (
-
{data.pendingTasksCount}
+
{data.pendingTasksCount}
{data.partialTasksCount} partially delivered
) @@ -454,7 +441,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) { case 'outstandingPay': return (
-
{fmtMoney(data.outstandingAmount)}
+
{fmtMoney(data.outstandingAmount)}
{data.outstandingInvoiceCount} invoices
) @@ -496,30 +483,43 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) { } } -function mergeLayoutsChange( - full: Record, - changed: Partial>, -): Record { - const next = { ...full } - for (const bp of BREAKPOINT_ORDER) { - const changedArr = changed[bp] - if (!changedArr) continue - next[bp] = full[bp].map(item => { - const updated = changedArr.find(c => c.i === item.i) - return updated ? { ...item, x: updated.x, y: updated.y, w: updated.w, h: updated.h } : item - }) - } - return next +function toIsoDate(d: Date) { + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + +type RangePreset = '7d' | '1m' | '3m' | '1y' | 'custom' + +const RANGE_PRESETS: Array<{ key: RangePreset; label: string }> = [ + { key: '7d', label: 'Last 7 days' }, + { key: '1m', label: '1 month' }, + { key: '3m', label: '3 months' }, + { key: '1y', label: '1 year' }, + { key: 'custom', label: 'Custom' }, +] + +function presetRange(preset: Exclude): DateRange { + const to = new Date() + const from = new Date() + if (preset === '7d') from.setDate(from.getDate() - 6) + else if (preset === '1m') from.setMonth(from.getMonth() - 1) + else if (preset === '3m') from.setMonth(from.getMonth() - 3) + else { from.setFullYear(from.getFullYear() - 1); from.setDate(from.getDate() + 1) } + return { from: toIsoDate(from), to: toIsoDate(to) } +} + +function minFromDate(to: string) { + const d = new Date(`${to}T00:00:00`) + d.setDate(d.getDate() - 365) + return toIsoDate(d) } export function DashboardClient() { const { hasPermission } = useAuth() const [data, setData] = useState(null) const [loading, setLoading] = useState(true) - const [state, setState] = useState(defaultLayoutState) - const [editMode, setEditMode] = useState(false) - const [showPicker, setShowPicker] = useState(false) - const [category, setCategory] = useState<'all' | Category>('all') + const [range, setRange] = useState(() => presetRange('1m')) + const [preset, setPreset] = useState('1m') const { width, containerRef, mounted } = useContainerWidth({ initialWidth: 1200 }) const allowedIds = useMemo( @@ -528,23 +528,10 @@ export function DashboardClient() { ) const permittedActiveIds = useMemo( - () => state.activeIds.filter(id => allowedIds.has(id)), - [state.activeIds, allowedIds], + () => DEFAULT_ACTIVE.filter(id => allowedIds.has(id)), + [allowedIds], ) - useEffect(() => { - setState(loadStoredLayoutState()) - }, []) - - const persist = useCallback((next: DashboardLayoutState) => { - setState(next) - try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) - } catch { - // localStorage unavailable (private browsing, etc.) — layout just won't persist - } - }, []) - const fetchData = useCallback(async () => { setLoading(true) try { @@ -560,118 +547,82 @@ export function DashboardClient() { fetchData() }, [fetchData]) - function toggleWidget(id: string, checked: boolean) { - if (checked) { - const meta = WIDGET_META.find(w => w.id === id) - if (!meta || state.activeIds.includes(id)) return - const layouts = { ...state.layouts } - for (const bp of BREAKPOINT_ORDER) layouts[bp] = appendWidget(layouts[bp], meta, bp) - persist({ activeIds: [...state.activeIds, id], layouts }) - } else { - const layouts = { ...state.layouts } - for (const bp of BREAKPOINT_ORDER) layouts[bp] = layouts[bp].filter(l => l.i !== id) - persist({ activeIds: state.activeIds.filter(x => x !== id), layouts }) - } - } - - function handleLayoutChange(_layout: Layout, changedLayouts: Partial>) { - setState(prev => { - const next = { ...prev, layouts: mergeLayoutsChange(prev.layouts, changedLayouts) } - try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) - } catch { - // localStorage unavailable — position just won't persist - } - return next - }) - } - const visibleWidgets = useMemo( () => permittedActiveIds .map(id => WIDGET_META.find(w => w.id === id)) .filter((w): w is WidgetMeta => Boolean(w)) - .filter(w => category === 'all' || w.cat === category), - [permittedActiveIds, category], + .filter(w => !STAT_IDS.includes(w.id)), + [permittedActiveIds], ) const visibleLayouts = useMemo(() => { const visibleIds = new Set(visibleWidgets.map(w => w.id)) return Object.fromEntries( - BREAKPOINT_ORDER.map(bp => [bp, state.layouts[bp].filter(l => visibleIds.has(l.i))]), + BREAKPOINT_ORDER.map(bp => [bp, generateFlowLayout(Array.from(visibleIds), bp)]), ) as Record - }, [state.layouts, visibleWidgets]) + }, [visibleWidgets]) - const filters: Array<{ key: 'all' | Category; label: string }> = [ - { key: 'all', label: 'All widgets' }, - { key: 'sales', label: CATEGORY_META.sales.label }, - { key: 'ops', label: CATEGORY_META.ops.label }, - { key: 'stock', label: CATEGORY_META.stock.label }, - { key: 'finance', label: CATEGORY_META.finance.label }, - ] + const canSales = hasPermission('orders.view') + const canInventory = hasPermission('stock.view') return ( -
+
-

Dashboard

+

Dashboard

- {editMode ? 'Drag to move, drag a corner to resize, or tick which widgets show.' : 'Add, remove, drag and resize widgets to make this view yours.'} + Sales, inventory, shop orders, rides and loading sheets, deliveries, stock alerts, invoices and payments

+
-
- {editMode && ( - - )} - - - {showPicker && ( -
-
- Widgets - -
-
- {WIDGET_META.filter(w => allowedIds.has(w.id)).map(w => { - const checked = state.activeIds.includes(w.id) - return ( - - ) - })} -
+ ))}
- )} + {preset === 'custom' && ( +
+ e.target.value && setRange(r => ({ ...r, from: e.target.value }))} + className="rounded-lg border bg-background px-2 py-1 text-xs" + /> + to + e.target.value && setRange(r => ({ ...r, to: e.target.value }))} + className="rounded-lg border bg-background px-2 py-1 text-xs" + /> +
+ )} +
-
- -
- {filters.map(f => ( - - ))} -
+ )} + {canSales && ( +
+

Sales

+ +
+ )} + {canInventory && ( +
+

Inventory

+ +
+ )} {loading || !data ? (
@@ -679,11 +630,9 @@ export function DashboardClient() { ))}
- ) : visibleWidgets.length === 0 ? ( -
- No widgets on this dashboard yet. Click “Widgets” to add some. -
) : ( + <> +
{mounted && ( {visibleWidgets.map(w => ( -
- {editMode && ( - - )} -
-
{w.title}
-
+
+
+
+ + {w.title} +
+
@@ -723,6 +665,7 @@ export function DashboardClient() { )}
+ )}
) diff --git a/distribution_fe/lib/api/index.ts b/distribution_fe/lib/api/index.ts index 08ff4ca..e38e8ed 100644 --- a/distribution_fe/lib/api/index.ts +++ b/distribution_fe/lib/api/index.ts @@ -8,7 +8,7 @@ export { drivers, compliance } from './drivers' export { purchaseOrders, grn, purchaseReturns } from './procurement' export { rides, loadingSheets, deliveryTasks } from './operations' export { stock } from './stock' -export { productReports } from './reports' +export { productReports, stockReports, dashboardReports } from './reports' export { shopPos, invoices, payments } from './sales' export { saleReturns, claims, supplierClaims } from './returns-claims' export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS } from './sys-config' diff --git a/distribution_fe/lib/api/reports.ts b/distribution_fe/lib/api/reports.ts index 8d5122e..ce91694 100644 --- a/distribution_fe/lib/api/reports.ts +++ b/distribution_fe/lib/api/reports.ts @@ -159,3 +159,41 @@ export const productReports = { summary: (params?: Pick) => api.get('/reports/products/summary', { params }).then(r => r.data), } + +export interface SalesKpis { + grossSales: number + returnValue: number + netSales: number + orders: number + averageOrderValue: number + grossProfit: number + grossProfitMarginPercent: number | null +} + +export interface DashboardSalesSummary { + from: string + to: string + granularity: 'day' | 'month' + kpis: { current: SalesKpis; previous: SalesKpis; previousFrom: string; previousTo: string } + trend: Array<{ period: string; sales: number; returns: number }> + byCategory: Array<{ id: string | null; name: string; amount: number }> + byRegion: Array<{ id: string | null; name: string; amount: number }> + byRep: Array<{ id: string | null; name: string; amount: number }> +} + +export interface DashboardInventorySummary { + from: string + to: string + granularity: 'day' | 'month' + valueByCategory: Array<{ category: string; quantity: number; value: number }> + byWarehouse: Array<{ warehouseId: string; name: string; quantity: number; value: number }> + movementTrend: Array<{ period: string; received: number; issued: number; net: number }> +} + +export const dashboardReports = { + sales: (params?: { from?: string; to?: string; regionId?: string; categoryId?: string }) => + api.get('/reports/dashboard/sales', { params }).then(r => r.data), + + inventory: (params?: { from?: string; to?: string; warehouseId?: string; categoryId?: string }) => + api.get('/reports/dashboard/inventory', { params }).then(r => r.data), +}