Add multi-series charts and enhance dashboard functionality
This commit is contained in:
@@ -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'
|
||||
|
||||
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 <Empty text={emptyText} />
|
||||
|
||||
const config = { value: { label: 'Quantity', color } } satisfies ChartConfig
|
||||
const config = { value: { label, color } } satisfies ChartConfig
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
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<Breakpoint, Record<Size, { w: number; h: number; minW:
|
||||
},
|
||||
md: {
|
||||
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 },
|
||||
chart: { w: 5, h: 7, minW: 3, minH: 5 },
|
||||
chartWide: { w: 10, h: 7, minW: 4, minH: 5 },
|
||||
},
|
||||
sm: {
|
||||
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 },
|
||||
chart: { 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
|
||||
}
|
||||
|
||||
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 })}`
|
||||
|
||||
interface OpenRide {
|
||||
@@ -351,12 +299,51 @@ async function loadDashboardData(activeIds: Set<string>): Promise<DashboardData>
|
||||
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 (
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${meta.text} ${meta.bg}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<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>
|
||||
<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':
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
case 'awaitingConfirm':
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
@@ -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-xs text-muted-foreground">{r.driverFullName}</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>
|
||||
@@ -409,7 +396,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
|
||||
case 'deliveryTasks':
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
@@ -454,7 +441,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
|
||||
case 'outstandingPay':
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
@@ -496,30 +483,43 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
|
||||
}
|
||||
}
|
||||
|
||||
function mergeLayoutsChange(
|
||||
full: Record<Breakpoint, LayoutItem[]>,
|
||||
changed: Partial<Record<Breakpoint, Layout>>,
|
||||
): Record<Breakpoint, LayoutItem[]> {
|
||||
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<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() {
|
||||
const { hasPermission } = useAuth()
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [state, setState] = useState<DashboardLayoutState>(defaultLayoutState)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [category, setCategory] = useState<'all' | Category>('all')
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange('1m'))
|
||||
const [preset, setPreset] = useState<RangePreset>('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<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(
|
||||
() => 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<Breakpoint, LayoutItem[]>
|
||||
}, [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 (
|
||||
<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>
|
||||
<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">
|
||||
{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>
|
||||
</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 className="flex flex-wrap gap-2">
|
||||
{filters.map(f => (
|
||||
{(canSales || canInventory) && (
|
||||
<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
|
||||
key={f.key}
|
||||
onClick={() => setCategory(f.key)}
|
||||
className={`rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors ${
|
||||
category === f.key
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'
|
||||
key={p.key}
|
||||
onClick={() => { setPreset(p.key); if (p.key !== 'custom') setRange(presetRange(p.key)) }}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
preset === p.key ? 'bg-primary text-primary-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</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 ? (
|
||||
<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" />
|
||||
))}
|
||||
</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 “Widgets” to add some.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<StatStrip ids={permittedActiveIds} data={data} />
|
||||
<div ref={containerRef} className="w-full">
|
||||
{mounted && (
|
||||
<ResponsiveGridLayout
|
||||
@@ -695,25 +644,18 @@ export function DashboardClient() {
|
||||
margin={GRID_MARGIN}
|
||||
containerPadding={[0, 0]}
|
||||
maxRows={Infinity}
|
||||
dragConfig={{ enabled: editMode }}
|
||||
resizeConfig={{ enabled: editMode, handles: ['se'] }}
|
||||
dragConfig={{ enabled: false }}
|
||||
resizeConfig={{ enabled: false }}
|
||||
autoSize
|
||||
onLayoutChange={handleLayoutChange}
|
||||
>
|
||||
{visibleWidgets.map(w => (
|
||||
<div key={w.id} className="relative flex flex-col overflow-auto rounded-2xl border bg-card p-5">
|
||||
{editMode && (
|
||||
<button
|
||||
onClick={() => toggleWidget(w.id, false)}
|
||||
aria-label="Remove widget"
|
||||
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"
|
||||
>
|
||||
<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 key={w.id} className="relative flex flex-col overflow-auto rounded-xl border bg-card p-3 shadow-xs transition-shadow hover:shadow-md">
|
||||
<div className="mb-2 shrink-0">
|
||||
<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" />
|
||||
{w.title}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<WidgetBody id={w.id} data={data} />
|
||||
@@ -723,6 +665,7 @@ export function DashboardClient() {
|
||||
</ResponsiveGridLayout>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -159,3 +159,41 @@ export const productReports = {
|
||||
summary: (params?: Pick<ProductReportParams, 'brandId' | 'categoryId' | 'warehouseId' | 'from' | 'to'>) =>
|
||||
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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user