Add multi-series charts and enhance dashboard functionality

This commit is contained in:
2026-10-19 15:51:39 +05:30
parent 2d6723a040
commit 3c75f9786e
5 changed files with 621 additions and 215 deletions
@@ -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 (
<div className={`flex h-full flex-col rounded-xl border bg-card p-3 shadow-xs transition-shadow hover:shadow-md ${className}`}>
<div className="mb-1.5">
<div className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
<span className="h-3.5 w-1 rounded-full bg-primary" />
{title}
</div>
{note && <div className="mt-0.5 text-[11px] text-muted-foreground">{note}</div>}
</div>
<div className="h-52 min-h-0">{children}</div>
</div>
)
}
function Delta({ current, previous }: { current: number; previous: number }) {
if (previous === 0) return <span className="text-[10px] text-muted-foreground">No previous-period data</span>
const pct = ((current - previous) / Math.abs(previous)) * 100
const up = pct >= 0
return (
<span className="inline-flex items-center gap-1">
<span className={`rounded-full px-1.5 py-px text-[10px] font-semibold ${up ? 'bg-foreground/10 text-foreground' : 'bg-destructive/10 text-destructive'}`}>
{up ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
<span className="text-muted-foreground">vs prev.</span>
</span>
)
}
function Kpi({ title, value, footer }: { title: string; value: string; footer?: ReactNode }) {
return (
<div className="relative flex h-[76px] flex-col justify-between overflow-hidden rounded-xl border bg-card px-3 py-2 shadow-xs transition-shadow hover:shadow-md">
<div>
<div className="truncate text-[11px] font-medium text-muted-foreground">{title}</div>
<div className="truncate text-lg font-bold leading-tight text-foreground">{value}</div>
</div>
{footer && <div className="truncate text-[10px] leading-tight">{footer}</div>}
</div>
)
}
function LoadingGrid() {
return (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
</div>
)
}
function useLoad<T>(load: () => Promise<T>, 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 <p className="py-6 text-center text-xs text-muted-foreground">{text}</p>
}
/** 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 <Empty text={empty} />
const max = Math.max(...rows.map(r => r.value), 1)
return (
<div className="h-full overflow-auto pr-1">
<table className="w-full text-xs">
<tbody>
{rows.map((r, i) => (
<tr key={`${r.name}-${i}`} className="border-b border-muted last:border-0">
<td className="w-5 py-1.5 text-[10px] font-semibold text-muted-foreground">{i + 1}</td>
<td className="max-w-0 truncate py-1.5 pr-2 font-medium text-foreground" title={r.name}>
<div className="truncate">{r.name}</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-muted">
<div className={`h-full rounded-full ${barClass}`} style={{ width: `${(r.value / max) * 100}%` }} />
</div>
</td>
<td className="whitespace-nowrap py-1.5 pl-1 text-right font-semibold tabular-nums text-foreground">
{fmt(r.value)}
{r.extra && <div className="text-[10px] font-normal text-muted-foreground">{r.extra}</div>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function DataTable({ cols, rows, empty }: { cols: string[]; rows: ReactNode[][]; empty: string }) {
if (rows.length === 0) return <Empty text={empty} />
return (
<div className="h-full overflow-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card">
<tr className="border-b text-[10px] uppercase tracking-wide text-muted-foreground">
{cols.map((c, i) => <th key={c} className={`py-1.5 font-semibold ${i === 0 ? 'text-left' : 'text-right'}`}>{c}</th>)}
</tr>
</thead>
<tbody>
{rows.map((r, ri) => (
<tr key={ri} className="border-b border-muted last:border-0 hover:bg-muted/40">
{r.map((cell, ci) => (
<td key={ci} className={`py-1.5 tabular-nums ${ci === 0 ? 'max-w-0 truncate pr-2 text-left font-medium text-foreground' : 'text-right text-muted-foreground'}`}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
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<DashboardSalesSummary>(
() => dashboardReports.sales(range), [range.from, range.to], 'Failed to load sales analytics')
const { data: products } = useLoad<ProductSalesRow[]>(
() => productReports.sales({ ...range, pageSize: 10 }).then(r => getPaginatedItems(r)),
[range.from, range.to], 'Failed to load product sales')
if (loading || !sales) return <LoadingGrid />
const { current: c, previous: p } = sales.kpis
const kpi = (title: string, key: keyof SalesKpis, money = true) => (
<Kpi
title={title}
value={money ? fmtMoney(c[key] as number) : fmtNum(c[key] as number)}
footer={<Delta current={c[key] as number} previous={p[key] as number} />}
/>
)
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 (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
{kpi('Net Sales', 'netSales')}
{kpi('Gross Sales', 'grossSales')}
{kpi('Total Orders', 'orders', false)}
{kpi('Average Order Value', 'averageOrderValue')}
<Kpi
title="Gross Profit"
value={fmtMoney(c.grossProfit)}
footer={
<span className="text-[10px] text-muted-foreground">
{c.grossProfitMarginPercent === null ? 'Margin n/a (no cost prices)' : `${c.grossProfitMarginPercent}% margin`}
</span>
}
/>
{kpi('Sales Returns', 'returnValue')}
</div>
<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.
Gross profit uses item cost prices and covers only items that have one. Discounts and sales targets are not tracked per order yet.
</p>
<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">
<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">
<DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." />
</ChartCard>
<ChartCard title="Top-Selling Products" note="Top 10 by sales value" className="md:col-span-2">
<DataTable
cols={['Product', 'Qty', 'Sales', 'Margin']}
empty="No product sales in this period."
rows={(products ?? []).map(r => [
r.name,
fmtNum(r.qtyFulfilled),
fmtMoney(r.salesValue),
r.marginPercent === null ? '—' : (
<span className={r.marginPercent >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive'}>{r.marginPercent}%</span>
),
])}
/>
</ChartCard>
<ChartCard title="Sales by Region">
<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">
<MultiAreaChart
data={trend}
series={[{ key: 'sales', label: 'Sales', color: seriesColor(0) }, { key: 'returns', label: 'Returns', color: '#f87171' }]}
emptyText="No sales or returns in this period."
/>
</ChartCard>
<ChartCard title="Sales by Representative" note="Top 20; rep = user who raised the order">
<RankTable rows={sales.byRep.map(r => ({ name: r.name, value: r.amount, extra: sharePct(r.amount, repTotal) }))} fmt={fmtMoney} empty="No sales in this period." />
</ChartCard>
</div>
</div>
)
}
export function InventorySection({ range }: { range: DateRange }) {
const { data: inv, loading } = useLoad<DashboardInventorySummary>(
() => dashboardReports.inventory(range), [range.from, range.to], 'Failed to load inventory analytics')
const { data: overview } = useLoad<StockOverview>(
() => 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<ProductSalesRow[]>(
() => productReports.sales({ ...range, pageSize: 50 }).then(r => getPaginatedItems(r)),
[range.from, range.to], 'Failed to load product sales')
if (loading || !inv || !overview) return <LoadingGrid />
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 (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
<Kpi title="Active Products" value={fmtNum(overview.totalSkus)} />
<Kpi title="Stock on Hand" value={fmtNum(overview.totalQty)} />
<Kpi title="Inventory Value" value={fmtMoney(overview.totalValue)} footer={<span className="text-[10px] text-muted-foreground">At cost price</span>} />
<Kpi title="Low-Stock Products" value={fmtNum(overview.lowStockCount)} />
<Kpi title="Out-of-Stock Products" value={fmtNum(overview.outOfStockCount)} />
<Kpi title="Expiring Soon (30 days)" value={fmtNum(overview.expiringSoonCount)} footer={<span className="text-[10px] text-muted-foreground">Batches · {fmtNum(overview.expiredCount)} already expired</span>} />
</div>
<p className="text-[11px] text-muted-foreground">
Reserved stock, excess stock, ageing, discrepancies and pending transfers are not tracked yet, so they are not shown.
</p>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
<ChartCard title="Stock Movement Trend" note={`Quantity per ${inv.granularity}. Received = purchases + customer returns; issued = sales + purchase returns + damages`} className="md:col-span-2">
<MultiAreaChart
data={inv.movementTrend}
series={[{ key: 'received', label: 'Received', color: seriesColor(0) }, { key: 'issued', label: 'Issued', color: seriesColor(1) }]}
emptyText="No stock movements in this period."
/>
</ChartCard>
<ChartCard title="Stock Health" note="Active products">
<DonutChart
data={[
{ name: 'Healthy', value: healthy },
{ name: 'Low stock', value: overview.lowStockCount },
{ name: 'Out of stock', value: overview.outOfStockCount },
]}
centerLabel="products"
emptyText="No active products."
/>
</ChartCard>
<ChartCard title="Warehouse Stock" note="Quantity and value at cost price" className="md:col-span-2">
<DataTable
cols={['Warehouse', 'Quantity', 'Value']}
empty="No warehouse stock recorded."
rows={inv.byWarehouse.map(r => [r.name, fmtNum(r.quantity), fmtMoney(r.value)])}
/>
</ChartCard>
<ChartCard title="Inventory Value by Category" note="At cost price">
<RankTable
rows={inv.valueByCategory.map(r => ({ name: r.category, value: r.value, extra: sharePct(r.value, catTotal) }))}
fmt={fmtMoney} empty="No stock recorded."
/>
</ChartCard>
<ChartCard title="Low-Stock Products" note="Lowest coverage of reorder level first" className="md:col-span-2">
<DataTable
cols={['Product', 'On hand', 'Reorder at', 'Coverage']}
empty="No products below their reorder level."
rows={lowRows.map(r => {
const cov = Math.round((r.warehouseQty / (r.reorderLevel ?? 1)) * 100)
return [
r.name,
fmtNum(r.warehouseQty),
fmtNum(r.reorderLevel ?? 0),
<span key="c" className={`rounded-full px-1.5 py-px text-[10px] font-semibold ${cov < 50 ? 'bg-destructive/10 text-destructive' : 'bg-muted text-muted-foreground'}`}>{cov}%</span>,
]
})}
/>
</ChartCard>
<ChartCard title="Fast & Slow Movers" note="Top and bottom 3 by quantity sold (among top 50 sellers)">
<div className="flex h-full flex-col gap-1 overflow-auto">
<div className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Fast-moving</div>
<RankTable rows={fast} fmt={fmtNum} empty="No sales in this period." />
<div className="mt-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Slow-moving</div>
<RankTable rows={slow} fmt={fmtNum} empty="No sales in this period." />
</div>
</ChartCard>
</div>
</div>
)
}
@@ -1,6 +1,6 @@
'use client' '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 { import {
ChartContainer, 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 <Empty text={emptyText} /> if (data.length === 0 || data.every(d => d.value === 0)) return <Empty text={emptyText} />
const config = { value: { label: 'Quantity', color } } satisfies ChartConfig const config = { value: { label, color } } satisfies ChartConfig
return ( return (
<ChartContainer config={config} className="h-full min-h-40 w-full"> <ChartContainer config={config} className="h-full min-h-40 w-full">
@@ -140,3 +140,83 @@ export function RadialGauge({ pct, label, sub }: { pct: number; label: string; s
</div> </div>
) )
} }
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<Record<string, string | number>>; series: SeriesDef[]; emptyText: string }) {
if (data.length === 0) return <Empty text={emptyText} />
const config = Object.fromEntries(series.map(s => [s.key, { label: s.label, color: s.color }])) as ChartConfig
return (
<ChartContainer config={config} className="h-full min-h-48 w-full">
<LineChart data={data}>
<CartesianGrid vertical={false} />
<XAxis dataKey="period" tickLine={false} axisLine={false} minTickGap={24} tick={{ fontSize: 10 }} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
<ChartTooltip content={<ChartTooltipContent />} />
{series.map(s => (
<Line key={s.key} dataKey={s.key} type="monotone" stroke={`var(--color-${s.key})`} strokeWidth={2} dot={false} />
))}
<ChartLegend content={<ChartLegendContent />} />
</LineChart>
</ChartContainer>
)
}
/** Grouped (side-by-side) column chart comparing several series per category. */
export function GroupedColumnChart({ data, series, emptyText }: { data: Array<Record<string, string | number>>; series: SeriesDef[]; emptyText: string }) {
if (data.length === 0) return <Empty text={emptyText} />
const config = Object.fromEntries(series.map(s => [s.key, { label: s.label, color: s.color }])) as ChartConfig
return (
<ChartContainer config={config} className="h-full min-h-48 w-full">
<BarChart data={data}>
<CartesianGrid vertical={false} />
<XAxis dataKey="period" tickLine={false} axisLine={false} minTickGap={16} tick={{ fontSize: 10 }} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
<ChartTooltip content={<ChartTooltipContent />} />
{series.map(s => (
<Bar key={s.key} dataKey={s.key} fill={`var(--color-${s.key})`} radius={3} maxBarSize={24} />
))}
<ChartLegend content={<ChartLegendContent />} />
</BarChart>
</ChartContainer>
)
}
/** Multi-series smooth area chart with a soft gradient under each line. */
export function MultiAreaChart({ data, series, emptyText }: { data: Array<Record<string, string | number>>; series: SeriesDef[]; emptyText: string }) {
if (data.length === 0) return <Empty text={emptyText} />
const config = Object.fromEntries(series.map(s => [s.key, { label: s.label, color: s.color }])) as ChartConfig
return (
<ChartContainer config={config} className="h-full min-h-40 w-full">
<AreaChart data={data}>
<defs>
{series.map(s => (
<linearGradient key={s.key} id={`area-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={`var(--color-${s.key})`} stopOpacity={0.45} />
<stop offset="95%" stopColor={`var(--color-${s.key})`} stopOpacity={0.03} />
</linearGradient>
))}
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="period" tickLine={false} axisLine={false} minTickGap={24} tick={{ fontSize: 10 }} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={44} tick={{ fontSize: 10 }} />
<ChartTooltip content={<ChartTooltipContent />} />
{series.map(s => (
<Area key={s.key} dataKey={s.key} type="monotone" stroke={`var(--color-${s.key})`} strokeWidth={2} fill={`url(#area-${s.key})`} />
))}
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
)
}
@@ -1,14 +1,10 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useState } from 'react' 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 { 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 '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 { Skeleton } from '@/components/ui/skeleton'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { import {
@@ -23,12 +19,12 @@ import {
} 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 { LowStockItemResponse, StockMovementResponse } from '@/types/api'
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'
const STORAGE_KEY = 'distribution-dashboard-widgets-v3' const ROW_HEIGHT = 26
const ROW_HEIGHT = 36 const GRID_MARGIN: [number, number] = [10, 10]
const GRID_MARGIN: [number, number] = [16, 16]
type Breakpoint = keyof typeof DEFAULT_BREAKPOINTS type Breakpoint = keyof typeof DEFAULT_BREAKPOINTS
const BREAKPOINT_ORDER = Object.keys(DEFAULT_BREAKPOINTS) as Breakpoint[] const BREAKPOINT_ORDER = Object.keys(DEFAULT_BREAKPOINTS) as Breakpoint[]
@@ -83,14 +79,14 @@ const SIZE_CONFIG: Record<Breakpoint, Record<Size, { w: number; h: number; minW:
}, },
md: { md: {
sm: { w: 5, h: 4, minW: 3, minH: 3 }, sm: { w: 5, h: 4, minW: 3, minH: 3 },
md: { w: 10, h: 6, minW: 4, minH: 3 }, md: { w: 5, h: 6, minW: 3, minH: 3 },
lg: { w: 10, h: 7, minW: 4, minH: 4 }, lg: { w: 10, h: 7, minW: 4, minH: 4 },
chart: { w: 5, h: 7, minW: 3, minH: 5 }, chart: { w: 5, h: 7, minW: 3, minH: 5 },
chartWide: { w: 10, h: 7, minW: 4, minH: 5 }, chartWide: { w: 10, h: 7, minW: 4, minH: 5 },
}, },
sm: { sm: {
sm: { w: 3, h: 4, minW: 2, minH: 3 }, sm: { w: 3, h: 4, minW: 2, minH: 3 },
md: { w: 6, h: 6, minW: 3, minH: 3 }, md: { w: 3, h: 6, minW: 2, minH: 3 },
lg: { w: 6, h: 7, minW: 3, minH: 4 }, lg: { w: 6, h: 7, minW: 3, minH: 4 },
chart: { w: 6, h: 7, minW: 3, minH: 5 }, chart: { w: 6, h: 7, minW: 3, minH: 5 },
chartWide: { w: 6, h: 7, minW: 3, minH: 5 }, chartWide: { w: 6, h: 7, minW: 3, minH: 5 },
@@ -133,54 +129,6 @@ function generateFlowLayout(ids: string[], bp: Breakpoint): LayoutItem[] {
return items return items
} }
function generateFlowLayouts(ids: string[]): Record<Breakpoint, LayoutItem[]> {
return Object.fromEntries(BREAKPOINT_ORDER.map(bp => [bp, generateFlowLayout(ids, bp)])) as Record<Breakpoint, LayoutItem[]>
}
/** 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<Breakpoint, LayoutItem[]>
}
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<Breakpoint, LayoutItem[]>
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 })}` const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2 })}`
interface OpenRide { interface OpenRide {
@@ -351,12 +299,51 @@ async function loadDashboardData(activeIds: Set<string>): Promise<DashboardData>
return data return data
} }
function CategoryPill({ cat }: { cat: Category }) { const STAT_IDS = ['pendingPO', 'awaitingConfirm', 'fulfillRate', 'deliveryTasks', 'outstandingPay', 'invoiceStatus']
const meta = CATEGORY_META[cat]
function StatTile({ title, value, sub, children }: { title: string; value: string; sub?: string; children?: React.ReactNode }) {
return ( return (
<span className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${meta.text} ${meta.bg}`}> <div className="relative flex h-[76px] flex-col justify-between overflow-hidden rounded-xl border bg-card px-3 py-2 shadow-xs transition-shadow hover:shadow-md">
{meta.label} <div>
</span> <div className="truncate text-[11px] font-medium text-muted-foreground">{title}</div>
<div className="truncate text-lg font-bold leading-tight text-foreground">{value}</div>
</div>
<div>
{sub && <div className="truncate text-[10px] text-muted-foreground">{sub}</div>}
{children}
</div>
</div>
)
}
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<string, React.ReactNode> = {
pendingPO: <StatTile title="Pending Shop POs" value={String(data.pendingPOCount)} sub={`${data.totalPOCount} total orders`} />,
awaitingConfirm: <StatTile title="Awaiting Confirmation" value={String(data.awaitingConfirmCount)} sub="Confirmed, not yet loaded" />,
fulfillRate: (
<StatTile title="Fulfillment Rate" value={`${pct}%`} sub={`${data.fulfilledPOCount} of ${data.totalPOCount} fulfilled`}>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} /></div>
</StatTile>
),
deliveryTasks: <StatTile title="Pending Delivery Tasks" value={String(data.pendingTasksCount)} sub={`${data.partialTasksCount} partially delivered`} />,
outstandingPay: <StatTile title="Outstanding Payments" value={fmtMoney(data.outstandingAmount)} sub={`${data.outstandingInvoiceCount} invoices`} />,
invoiceStatus: (
<StatTile title="Invoices by Status" value={String(invTotal)} sub={`${data.draftInvoiceCount} draft · ${data.printedInvoiceCount} printed · ${data.finalizedInvoiceCount} final`}>
<div className="mt-1 flex h-1 overflow-hidden rounded-full bg-muted">
<div className="bg-primary/30" style={{ width: seg(data.draftInvoiceCount) }} />
<div className="bg-primary/60" style={{ width: seg(data.printedInvoiceCount) }} />
<div className="bg-primary" style={{ width: seg(data.finalizedInvoiceCount) }} />
</div>
</StatTile>
),
}
return (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
{STAT_IDS.filter(id => ids.includes(id)).map(id => <div key={id}>{tiles[id]}</div>)}
</div>
) )
} }
@@ -365,14 +352,14 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
case 'pendingPO': case 'pendingPO':
return ( return (
<div> <div>
<div className="text-3xl font-bold text-foreground">{data.pendingPOCount}</div> <div className="text-2xl font-bold text-foreground">{data.pendingPOCount}</div>
<div className="mt-1.5 text-xs text-muted-foreground">{data.totalPOCount} total orders</div> <div className="mt-1.5 text-xs text-muted-foreground">{data.totalPOCount} total orders</div>
</div> </div>
) )
case 'awaitingConfirm': case 'awaitingConfirm':
return ( return (
<div> <div>
<div className="text-3xl font-bold text-foreground">{data.awaitingConfirmCount}</div> <div className="text-2xl font-bold text-foreground">{data.awaitingConfirmCount}</div>
<div className="mt-1.5 text-xs text-muted-foreground">Confirmed, not yet loaded</div> <div className="mt-1.5 text-xs text-muted-foreground">Confirmed, not yet loaded</div>
</div> </div>
) )
@@ -391,7 +378,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
<div className="text-sm font-medium text-foreground">{r.vehicleNumberPlate}</div> <div className="text-sm font-medium text-foreground">{r.vehicleNumberPlate}</div>
<div className="text-xs text-muted-foreground">{r.driverFullName}</div> <div className="text-xs text-muted-foreground">{r.driverFullName}</div>
</div> </div>
<span className="rounded-full bg-violet-600 px-2.5 py-1 text-[11px] font-semibold text-white">Open</span> <span className="rounded-full bg-emerald-100 px-2.5 py-1 text-[11px] font-semibold text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300">Open</span>
</div> </div>
))} ))}
</div> </div>
@@ -409,7 +396,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
case 'deliveryTasks': case 'deliveryTasks':
return ( return (
<div> <div>
<div className="text-3xl font-bold text-foreground">{data.pendingTasksCount}</div> <div className="text-2xl font-bold text-foreground">{data.pendingTasksCount}</div>
<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>
) )
@@ -454,7 +441,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
case 'outstandingPay': case 'outstandingPay':
return ( return (
<div> <div>
<div className="text-3xl font-bold text-foreground">{fmtMoney(data.outstandingAmount)}</div> <div className="text-2xl font-bold text-foreground">{fmtMoney(data.outstandingAmount)}</div>
<div className="mt-1.5 text-xs text-muted-foreground">{data.outstandingInvoiceCount} invoices</div> <div className="mt-1.5 text-xs text-muted-foreground">{data.outstandingInvoiceCount} invoices</div>
</div> </div>
) )
@@ -496,30 +483,43 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
} }
} }
function mergeLayoutsChange( function toIsoDate(d: Date) {
full: Record<Breakpoint, LayoutItem[]>, const pad = (n: number) => String(n).padStart(2, '0')
changed: Partial<Record<Breakpoint, Layout>>, return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
): Record<Breakpoint, LayoutItem[]> { }
const next = { ...full }
for (const bp of BREAKPOINT_ORDER) { type RangePreset = '7d' | '1m' | '3m' | '1y' | 'custom'
const changedArr = changed[bp]
if (!changedArr) continue const RANGE_PRESETS: Array<{ key: RangePreset; label: string }> = [
next[bp] = full[bp].map(item => { { key: '7d', label: 'Last 7 days' },
const updated = changedArr.find(c => c.i === item.i) { key: '1m', label: '1 month' },
return updated ? { ...item, x: updated.x, y: updated.y, w: updated.w, h: updated.h } : item { key: '3m', label: '3 months' },
}) { key: '1y', label: '1 year' },
} { key: 'custom', label: 'Custom' },
return next ]
function presetRange(preset: Exclude<RangePreset, 'custom'>): 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() { export function DashboardClient() {
const { hasPermission } = useAuth() const { hasPermission } = useAuth()
const [data, setData] = useState<DashboardData | null>(null) const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [state, setState] = useState<DashboardLayoutState>(defaultLayoutState) const [range, setRange] = useState<DateRange>(() => presetRange('1m'))
const [editMode, setEditMode] = useState(false) const [preset, setPreset] = useState<RangePreset>('1m')
const [showPicker, setShowPicker] = useState(false)
const [category, setCategory] = useState<'all' | Category>('all')
const { width, containerRef, mounted } = useContainerWidth({ initialWidth: 1200 }) const { width, containerRef, mounted } = useContainerWidth({ initialWidth: 1200 })
const allowedIds = useMemo( const allowedIds = useMemo(
@@ -528,23 +528,10 @@ export function DashboardClient() {
) )
const permittedActiveIds = useMemo( const permittedActiveIds = useMemo(
() => state.activeIds.filter(id => allowedIds.has(id)), () => DEFAULT_ACTIVE.filter(id => allowedIds.has(id)),
[state.activeIds, allowedIds], [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 () => { const fetchData = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
@@ -560,118 +547,82 @@ export function DashboardClient() {
fetchData() fetchData()
}, [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<Record<Breakpoint, Layout>>) {
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( const visibleWidgets = useMemo(
() => permittedActiveIds () => permittedActiveIds
.map(id => WIDGET_META.find(w => w.id === id)) .map(id => WIDGET_META.find(w => w.id === id))
.filter((w): w is WidgetMeta => Boolean(w)) .filter((w): w is WidgetMeta => Boolean(w))
.filter(w => category === 'all' || w.cat === category), .filter(w => !STAT_IDS.includes(w.id)),
[permittedActiveIds, category], [permittedActiveIds],
) )
const visibleLayouts = useMemo(() => { const visibleLayouts = useMemo(() => {
const visibleIds = new Set(visibleWidgets.map(w => w.id)) const visibleIds = new Set(visibleWidgets.map(w => w.id))
return Object.fromEntries( 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<Breakpoint, LayoutItem[]> ) as Record<Breakpoint, LayoutItem[]>
}, [state.layouts, visibleWidgets]) }, [visibleWidgets])
const filters: Array<{ key: 'all' | Category; label: string }> = [ const canSales = hasPermission('orders.view')
{ key: 'all', label: 'All widgets' }, const canInventory = hasPermission('stock.view')
{ 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 },
]
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-3">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1> <h1 className="text-xl font-semibold text-foreground">Dashboard</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{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
</p> </p>
</div> </div>
<div className="relative flex items-center gap-2">
{editMode && (
<Button variant="outline" size="sm" onClick={() => setShowPicker(v => !v)}>
<HugeiconsIcon icon={Add01Icon} size={14} />
Widgets
</Button>
)}
<Button variant={editMode ? 'default' : 'outline'} size="sm" onClick={() => { setEditMode(v => !v); setShowPicker(false) }}>
{editMode ? 'Done' : 'Customize Dashboard'}
</Button>
{showPicker && (
<div className="absolute right-0 top-full z-20 mt-2 w-72 rounded-2xl border bg-popover p-3 shadow-lg">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-foreground">Widgets</span>
<button onClick={() => setShowPicker(false)} aria-label="Close" className="rounded-full bg-muted p-1 text-muted-foreground">
<HugeiconsIcon icon={Cancel01Icon} size={12} />
</button>
</div>
<div className="flex flex-col gap-1">
{WIDGET_META.filter(w => allowedIds.has(w.id)).map(w => {
const checked = state.activeIds.includes(w.id)
return (
<label
key={w.id}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-muted"
>
<Checkbox checked={checked} onCheckedChange={(v) => toggleWidget(w.id, v === true)} />
<span className="text-sm text-foreground">{w.title}</span>
</label>
)
})}
</div>
</div>
)}
</div>
</div> </div>
<div className="flex flex-wrap gap-2"> {(canSales || canInventory) && (
{filters.map(f => ( <div className="flex flex-wrap items-center justify-between gap-3 pt-2">
<span />
<div className="flex flex-wrap items-center gap-2 text-sm">
<div className="inline-flex rounded-lg border bg-muted/50 p-0.5">
{RANGE_PRESETS.map(p => (
<button <button
key={f.key} key={p.key}
onClick={() => setCategory(f.key)} onClick={() => { setPreset(p.key); if (p.key !== 'custom') setRange(presetRange(p.key)) }}
className={`rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors ${ className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
category === f.key preset === p.key ? 'bg-primary text-primary-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'
? 'border-foreground bg-foreground text-background'
: 'border-border bg-background text-muted-foreground hover:bg-muted'
}`} }`}
> >
{f.label} {p.label}
</button> </button>
))} ))}
</div> </div>
{preset === 'custom' && (
<div className="flex items-center gap-1.5">
<input
type="date" value={range.from} min={minFromDate(range.to)} max={range.to} aria-label="From date"
onChange={e => e.target.value && setRange(r => ({ ...r, from: e.target.value }))}
className="rounded-lg border bg-background px-2 py-1 text-xs"
/>
<span className="text-xs text-muted-foreground">to</span>
<input
type="date" value={range.to} min={range.from} max={toIsoDate(new Date())} aria-label="To date"
onChange={e => e.target.value && setRange(r => ({ ...r, to: e.target.value }))}
className="rounded-lg border bg-background px-2 py-1 text-xs"
/>
</div>
)}
</div>
</div>
)}
{canSales && (
<section className="flex flex-col gap-3">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground"><span className="h-2 w-2 rounded-full bg-primary" />Sales</h3>
<SalesSection range={range} />
</section>
)}
{canInventory && (
<section className="flex flex-col gap-3">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground"><span className="h-2 w-2 rounded-full bg-primary" />Inventory</h3>
<InventorySection range={range} />
</section>
)}
{loading || !data ? ( {loading || !data ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
@@ -679,11 +630,9 @@ export function DashboardClient() {
<Skeleton key={i} className="h-40 rounded-2xl" /> <Skeleton key={i} className="h-40 rounded-2xl" />
))} ))}
</div> </div>
) : visibleWidgets.length === 0 ? (
<div className="rounded-2xl border border-dashed p-12 text-center text-sm text-muted-foreground">
No widgets on this dashboard yet. Click &ldquo;Widgets&rdquo; to add some.
</div>
) : ( ) : (
<>
<StatStrip ids={permittedActiveIds} data={data} />
<div ref={containerRef} className="w-full"> <div ref={containerRef} className="w-full">
{mounted && ( {mounted && (
<ResponsiveGridLayout <ResponsiveGridLayout
@@ -695,25 +644,18 @@ export function DashboardClient() {
margin={GRID_MARGIN} margin={GRID_MARGIN}
containerPadding={[0, 0]} containerPadding={[0, 0]}
maxRows={Infinity} maxRows={Infinity}
dragConfig={{ enabled: editMode }} dragConfig={{ enabled: false }}
resizeConfig={{ enabled: editMode, handles: ['se'] }} resizeConfig={{ enabled: false }}
autoSize autoSize
onLayoutChange={handleLayoutChange}
> >
{visibleWidgets.map(w => ( {visibleWidgets.map(w => (
<div key={w.id} className="relative flex flex-col overflow-auto rounded-2xl border bg-card p-5"> <div key={w.id} className="relative flex flex-col overflow-auto rounded-xl border bg-card p-3 shadow-xs transition-shadow hover:shadow-md">
{editMode && ( <div className="mb-2 shrink-0">
<button <div className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
onClick={() => toggleWidget(w.id, false)} <span className="h-3.5 w-1 rounded-full bg-primary" />
aria-label="Remove widget" {w.title}
className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full border bg-background text-muted-foreground hover:text-foreground" </div>
>
<HugeiconsIcon icon={Cancel01Icon} size={12} />
</button>
)}
<div className="mb-3.5 shrink-0">
<div className="text-sm font-semibold text-foreground">{w.title}</div>
<div className="mt-1"><CategoryPill cat={w.cat} /></div>
</div> </div>
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
<WidgetBody id={w.id} data={data} /> <WidgetBody id={w.id} data={data} />
@@ -723,6 +665,7 @@ export function DashboardClient() {
</ResponsiveGridLayout> </ResponsiveGridLayout>
)} )}
</div> </div>
</>
)} )}
</div> </div>
) )
+1 -1
View File
@@ -8,7 +8,7 @@ export { drivers, compliance } from './drivers'
export { purchaseOrders, grn, purchaseReturns } from './procurement' export { purchaseOrders, grn, purchaseReturns } from './procurement'
export { rides, loadingSheets, deliveryTasks } from './operations' export { rides, loadingSheets, deliveryTasks } from './operations'
export { stock } from './stock' export { stock } from './stock'
export { productReports } from './reports' export { productReports, stockReports, dashboardReports } from './reports'
export { shopPos, invoices, payments } from './sales' export { shopPos, invoices, payments } from './sales'
export { saleReturns, claims, supplierClaims } from './returns-claims' export { saleReturns, claims, supplierClaims } from './returns-claims'
export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS } from './sys-config' export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS } from './sys-config'
+38
View File
@@ -159,3 +159,41 @@ export const productReports = {
summary: (params?: Pick<ProductReportParams, 'brandId' | 'categoryId' | 'warehouseId' | 'from' | 'to'>) => summary: (params?: Pick<ProductReportParams, 'brandId' | 'categoryId' | 'warehouseId' | 'from' | 'to'>) =>
api.get<ProductReportSummary>('/reports/products/summary', { params }).then(r => r.data), api.get<ProductReportSummary>('/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<DashboardSalesSummary>('/reports/dashboard/sales', { params }).then(r => r.data),
inventory: (params?: { from?: string; to?: string; warehouseId?: string; categoryId?: string }) =>
api.get<DashboardInventorySummary>('/reports/dashboard/inventory', { params }).then(r => r.data),
}