Fe changes

This commit is contained in:
2026-10-16 15:47:23 +05:30
parent 09d86835a2
commit c5a0992b5d
23 changed files with 2715 additions and 89 deletions
@@ -0,0 +1,142 @@
'use client'
import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Label, PolarAngleAxis, Pie, PieChart, RadialBar, RadialBarChart, XAxis, YAxis } from 'recharts'
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart'
export interface ChartDatum {
name: string
value: number
}
export interface MonthlySalesPoint {
month: string // yyyy-MM
invoiced: number
}
const fmtCompact = (v: number) => v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 })
const fmtMonth = (v: string) => new Date(`${v}-01`).toLocaleDateString('en-GB', { month: 'short', year: '2-digit' })
const SLICE_COLORS = ['var(--chart-2)', 'var(--chart-4)', 'var(--chart-1)', 'var(--chart-3)', 'var(--destructive)']
function Empty({ text }: { text: string }) {
return <p className="text-xs text-muted-foreground">{text}</p>
}
export function DonutChart({ data, centerLabel, emptyText }: { data: ChartDatum[]; centerLabel: string; emptyText: string }) {
const total = data.reduce((sum, d) => sum + d.value, 0)
if (total === 0) return <Empty text={emptyText} />
const slices = data.map((d, i) => ({ ...d, fill: SLICE_COLORS[i % SLICE_COLORS.length] }))
const config = Object.fromEntries(slices.map(s => [s.name, { label: s.name, color: s.fill }])) as ChartConfig
return (
<ChartContainer config={config} className="h-full min-h-40 w-full">
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="name" />} />
<Pie data={slices} dataKey="value" nameKey="name" innerRadius="55%" strokeWidth={2}>
<Label
content={({ viewBox }) => {
if (!viewBox || !('cx' in viewBox)) return null
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={viewBox.cx} y={(viewBox.cy ?? 0) - 6} className="fill-foreground text-base font-semibold">{fmtCompact(total)}</tspan>
<tspan x={viewBox.cx} y={(viewBox.cy ?? 0) + 12} className="fill-muted-foreground text-[11px]">{centerLabel}</tspan>
</text>
)
}}
/>
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="name" />} />
</PieChart>
</ChartContainer>
)
}
export function HorizontalBarChart({ data, emptyText, color = 'var(--chart-2)' }: { data: ChartDatum[]; emptyText: string; color?: string }) {
if (data.length === 0 || data.every(d => d.value === 0)) return <Empty text={emptyText} />
const config = { value: { label: 'Quantity', color } } satisfies ChartConfig
return (
<ChartContainer config={config} className="h-full min-h-40 w-full">
<BarChart data={data} layout="vertical" margin={{ left: 0, right: 8 }}>
<CartesianGrid horizontal={false} />
<XAxis type="number" tickFormatter={fmtCompact} tickLine={false} axisLine={false} />
<YAxis type="category" dataKey="name" tickLine={false} axisLine={false} width={96} />
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Bar dataKey="value" fill="var(--color-value)" radius={4} maxBarSize={22}>
{data.map(d => <Cell key={d.name} fill={color} />)}
</Bar>
</BarChart>
</ChartContainer>
)
}
export function ColumnBarChart({ data, emptyText, label }: { data: ChartDatum[]; emptyText: string; label: string }) {
if (data.length === 0 || data.every(d => d.value === 0)) return <Empty text={emptyText} />
const config = { value: { label, color: 'var(--chart-2)' } } satisfies ChartConfig
return (
<ChartContainer config={config} className="h-full min-h-40 w-full">
<BarChart data={data}>
<CartesianGrid vertical={false} />
<XAxis dataKey="name" tickLine={false} axisLine={false} interval={0} tick={{ fontSize: 10 }} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={40} />
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Bar dataKey="value" fill="var(--color-value)" radius={4} maxBarSize={36} />
</BarChart>
</ChartContainer>
)
}
export function SalesTrendChart({ data }: { data: MonthlySalesPoint[] }) {
if (data.length === 0) return <Empty text="No invoices yet." />
const config = { invoiced: { label: 'Invoiced', color: 'var(--chart-2)' } } satisfies ChartConfig
return (
<ChartContainer config={config} className="h-full min-h-40 w-full">
<AreaChart data={data}>
<defs>
<linearGradient id="dashFillInvoiced" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-invoiced)" stopOpacity={0.5} />
<stop offset="95%" stopColor="var(--color-invoiced)" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis dataKey="month" tickFormatter={fmtMonth} tickLine={false} axisLine={false} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={(v) => fmtMonth(String(v))} />} />
<Area dataKey="invoiced" type="monotone" stroke="var(--color-invoiced)" fill="url(#dashFillInvoiced)" />
</AreaChart>
</ChartContainer>
)
}
export function RadialGauge({ pct, label, sub }: { pct: number; label: string; sub: string }) {
const config = { value: { label, color: 'var(--chart-2)' } } satisfies ChartConfig
return (
<div className="flex h-full min-h-32 items-center gap-4">
<div className="relative aspect-square h-full max-h-40 min-h-24">
<ChartContainer config={config} className="size-full">
<RadialBarChart data={[{ value: pct, fill: 'var(--color-value)' }]} startAngle={90} endAngle={-270} innerRadius="75%" outerRadius="100%">
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
<RadialBar dataKey="value" background cornerRadius={10} />
</RadialBarChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-lg font-semibold text-foreground">{pct}%</div>
</div>
<p className="text-xs text-muted-foreground">{sub}</p>
</div>
)
}
@@ -22,6 +22,8 @@ import {
getPaginatedItems,
} from '@/lib/api/index'
import type { LowStockItemResponse, StockMovementResponse } from '@/types/api'
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
@@ -31,7 +33,7 @@ type Breakpoint = keyof typeof DEFAULT_BREAKPOINTS
const BREAKPOINT_ORDER = Object.keys(DEFAULT_BREAKPOINTS) as Breakpoint[]
type Category = 'sales' | 'ops' | 'stock' | 'finance'
type Size = 'sm' | 'md' | 'lg'
type Size = 'sm' | 'md' | 'lg' | 'chart' | 'chartWide'
interface WidgetMeta {
id: string
@@ -59,11 +61,18 @@ const WIDGET_META: WidgetMeta[] = [
{ id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md' },
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm' },
{ id: 'invoiceStatus', cat: 'finance', title: 'Invoices by Status', size: 'md' },
{ id: 'salesTrendChart', cat: 'finance', title: 'Sales Trend (6 months)', size: 'chartWide' },
{ id: 'paymentStatusChart', cat: 'finance', title: 'Payment Status', size: 'chart' },
{ id: 'poStatusChart', cat: 'sales', title: 'Shop POs by Status', size: 'chart' },
{ id: 'loadingSheetsChart', cat: 'ops', title: 'Loading Sheets Split', size: 'chart' },
{ id: 'warehouseChart', cat: 'stock', title: 'Warehouse Stock Chart', size: 'chart' },
{ id: 'movementTypeChart', cat: 'stock', title: 'Movements by Type', size: 'chart' },
]
const DEFAULT_ACTIVE = [
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus',
'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart',
]
/** Width (in that breakpoint's own columns), height, and minimums per widget size, per breakpoint. Narrower breakpoints
@@ -71,28 +80,38 @@ const DEFAULT_ACTIVE = [
const SIZE_CONFIG: Record<Breakpoint, Record<Size, { w: number; h: number; minW: number; minH: number }>> = {
lg: {
sm: { w: 3, h: 4, minW: 2, minH: 3 },
md: { w: 6, h: 5, minW: 3, minH: 3 },
md: { w: 6, h: 6, minW: 3, minH: 3 },
lg: { w: 12, h: 7, minW: 4, minH: 4 },
chart: { w: 4, h: 7, minW: 3, minH: 5 },
chartWide: { w: 8, h: 7, minW: 4, minH: 5 },
},
md: {
sm: { w: 5, h: 4, minW: 3, minH: 3 },
md: { w: 10, h: 5, minW: 4, minH: 3 },
md: { w: 10, h: 6, minW: 4, 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: 5, minW: 3, minH: 3 },
md: { w: 6, h: 6, minW: 3, 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 },
},
xs: {
sm: { w: 4, h: 4, minW: 2, minH: 3 },
md: { w: 4, h: 5, minW: 2, minH: 3 },
md: { w: 4, h: 6, minW: 2, minH: 3 },
lg: { w: 4, h: 7, minW: 2, minH: 4 },
chart: { w: 4, h: 7, minW: 2, minH: 5 },
chartWide: { w: 4, h: 7, minW: 2, minH: 5 },
},
xxs: {
sm: { w: 2, h: 4, minW: 1, minH: 3 },
md: { w: 2, h: 5, minW: 1, minH: 3 },
md: { w: 2, h: 6, minW: 1, minH: 3 },
lg: { w: 2, h: 7, minW: 1, minH: 4 },
chart: { w: 2, h: 7, minW: 1, minH: 5 },
chartWide: { w: 2, h: 7, minW: 1, minH: 5 },
},
}
@@ -198,6 +217,32 @@ interface DashboardData {
draftInvoiceCount: number
printedInvoiceCount: number
finalizedInvoiceCount: number
poStatusChart: ChartDatum[]
paymentStatusChart: ChartDatum[]
movementTypeChart: ChartDatum[]
monthlySales: MonthlySalesPoint[]
}
const PO_STATUSES = ['Pending', 'PartiallyFulfilled', 'Fulfilled', 'Cancelled'] as const
const PAYMENT_STATUSES = ['Pending', 'PartiallyPaid', 'Paid'] as const
const SALES_MONTHS = 6
/** Recent-activity charts aggregate this many latest records client-side. */
const CHART_SAMPLE_SIZE = 200
const splitWords = (s: string) => s.replace(/([a-z])([A-Z])/g, '$1 $2')
function buildMonthlySales(rows: Array<{ createdAt: string; totalAmount: number }>): MonthlySalesPoint[] {
const now = new Date()
const months: MonthlySalesPoint[] = []
for (let i = SALES_MONTHS - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
months.push({ month: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`, invoiced: 0 })
}
for (const r of rows) {
const bucket = months.find(m => m.month === r.createdAt.slice(0, 7))
if (bucket) bucket.invoiced += r.totalAmount
}
return months
}
async function loadDashboardData(): Promise<DashboardData> {
@@ -211,6 +256,8 @@ async function loadDashboardData(): Promise<DashboardData> {
balances,
outstanding,
draftInv, printedInv, finalizedInv,
poStatusCounts, paymentStatusCounts,
recentInvoices, recentMovements,
] = await Promise.all([
shopPos.list({ status: 'Pending', pageSize: 1 }),
shopPos.list({ confirmed: true, status: 'Pending', pageSize: 1 }),
@@ -228,8 +275,17 @@ async function loadDashboardData(): Promise<DashboardData> {
invoices.list({ status: 'Draft', pageSize: 1 }),
invoices.list({ status: 'Printed', pageSize: 1 }),
invoices.list({ status: 'Finalized', pageSize: 1 }),
Promise.all(PO_STATUSES.map(status => shopPos.list({ status, pageSize: 1 }))),
Promise.all(PAYMENT_STATUSES.map(paymentStatus => invoices.list({ paymentStatus, pageSize: 1 }))),
invoices.list({ pageSize: CHART_SAMPLE_SIZE }),
stock.movements({ pageSize: CHART_SAMPLE_SIZE }),
])
const movementCounts = new Map<string, number>()
for (const m of getPaginatedItems(recentMovements)) {
movementCounts.set(m.type, (movementCounts.get(m.type) ?? 0) + 1)
}
const balanceRows = getPaginatedItems(balances)
const byWarehouse = new Map<string, WarehouseBalanceRow>()
for (const b of balanceRows) {
@@ -259,6 +315,10 @@ async function loadDashboardData(): Promise<DashboardData> {
draftInvoiceCount: draftInv.total,
printedInvoiceCount: printedInv.total,
finalizedInvoiceCount: finalizedInv.total,
poStatusChart: PO_STATUSES.map((s, i) => ({ name: splitWords(s), value: poStatusCounts[i].total })),
paymentStatusChart: PAYMENT_STATUSES.map((s, i) => ({ name: splitWords(s), value: paymentStatusCounts[i].total })),
movementTypeChart: Array.from(movementCounts, ([type, value]) => ({ name: splitWords(type), value })).sort((a, b) => b.value - a.value),
monthlySales: buildMonthlySales(getPaginatedItems(recentInvoices)),
}
}
@@ -271,14 +331,6 @@ function CategoryPill({ cat }: { cat: Category }) {
)
}
function Bar({ pct, colorClass }: { pct: number; colorClass: string }) {
return (
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full ${colorClass}`} style={{ width: `${Math.min(100, Math.max(0, pct))}%` }} />
</div>
)
}
function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
switch (id) {
case 'pendingPO':
@@ -297,13 +349,7 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
)
case 'fulfillRate': {
const pct = data.totalPOCount > 0 ? Math.round((data.fulfilledPOCount / data.totalPOCount) * 100) : 0
return (
<div>
<div className="mb-2.5 text-2xl font-bold text-foreground">{pct}%</div>
<Bar pct={pct} colorClass="bg-blue-600" />
<div className="mt-2.5 text-xs text-muted-foreground">{data.fulfilledPOCount} of {data.totalPOCount} orders fulfilled</div>
</div>
)
return <RadialGauge pct={pct} label="Fulfilled" sub={`${data.fulfilledPOCount} of ${data.totalPOCount} orders fulfilled`} />
}
case 'openRides':
return data.openRides.length === 0 ? (
@@ -321,29 +367,16 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
))}
</div>
)
case 'loadingSheetsByStatus': {
const total = data.plannedSheetsCount + data.confirmedSheetsCount
const plannedPct = total > 0 ? (data.plannedSheetsCount / total) * 100 : 0
const confirmedPct = total > 0 ? (data.confirmedSheetsCount / total) * 100 : 0
case 'loadingSheetsByStatus':
return (
<div className="flex flex-col gap-3">
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-muted-foreground">Planned</span>
<span className="font-semibold text-foreground">{data.plannedSheetsCount}</span>
</div>
<Bar pct={plannedPct} colorClass="bg-amber-600" />
</div>
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-muted-foreground">Confirmed</span>
<span className="font-semibold text-foreground">{data.confirmedSheetsCount}</span>
</div>
<Bar pct={confirmedPct} colorClass="bg-violet-600" />
</div>
</div>
<HorizontalBarChart
data={[
{ name: 'Planned', value: data.plannedSheetsCount },
{ name: 'Confirmed', value: data.confirmedSheetsCount },
]}
emptyText="No loading sheets yet."
/>
)
}
case 'deliveryTasks':
return (
<div>
@@ -387,24 +420,8 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
))}
</div>
)
case 'warehouseBalance': {
const max = Math.max(1, ...data.warehouseBalances.map(w => w.quantity))
return data.warehouseBalances.length === 0 ? (
<p className="text-xs text-muted-foreground">No warehouse stock recorded.</p>
) : (
<div className="flex flex-col gap-3">
{data.warehouseBalances.map(w => (
<div key={w.locationId}>
<div className="mb-1 flex justify-between text-xs">
<span className="text-muted-foreground">{w.name}</span>
<span className="font-semibold text-foreground">{w.quantity}</span>
</div>
<Bar pct={(w.quantity / max) * 100} colorClass="bg-amber-600" />
</div>
))}
</div>
)
}
case 'warehouseBalance':
return <HorizontalBarChart data={data.warehouseBalances.map(w => ({ name: w.name, value: w.quantity }))} emptyText="No warehouse stock recorded." />
case 'outstandingPay':
return (
<div>
@@ -412,34 +429,39 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
<div className="mt-1.5 text-xs text-muted-foreground">{data.outstandingInvoiceCount} invoices</div>
</div>
)
case 'invoiceStatus': {
const total = data.draftInvoiceCount + data.printedInvoiceCount + data.finalizedInvoiceCount
const segs = [
{ label: 'Draft', count: data.draftInvoiceCount, color: 'bg-slate-400' },
{ label: 'Printed', count: data.printedInvoiceCount, color: 'bg-amber-600' },
{ label: 'Finalized', count: data.finalizedInvoiceCount, color: 'bg-emerald-600' },
]
case 'invoiceStatus':
return (
<div>
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
{segs.map(s => (
<div key={s.label} className={s.color} style={{ width: total > 0 ? `${(s.count / total) * 100}%` : '0%' }} />
))}
</div>
<div className="mt-3.5 flex flex-col gap-1.5">
{segs.map(s => (
<div key={s.label} className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5 text-muted-foreground">
<span className={`inline-block h-2 w-2 rounded-full ${s.color}`} />
{s.label}
</span>
<span className="font-semibold text-foreground">{s.count}</span>
</div>
))}
</div>
</div>
<DonutChart
data={[
{ name: 'Draft', value: data.draftInvoiceCount },
{ name: 'Printed', value: data.printedInvoiceCount },
{ name: 'Finalized', value: data.finalizedInvoiceCount },
]}
centerLabel="invoices"
emptyText="No invoices yet."
/>
)
}
case 'salesTrendChart':
return <SalesTrendChart data={data.monthlySales} />
case 'paymentStatusChart':
return <DonutChart data={data.paymentStatusChart} centerLabel="invoices" emptyText="No invoices yet." />
case 'poStatusChart':
return <DonutChart data={data.poStatusChart} centerLabel="orders" emptyText="No shop POs yet." />
case 'loadingSheetsChart':
return (
<DonutChart
data={[
{ name: 'Planned', value: data.plannedSheetsCount },
{ name: 'Confirmed', value: data.confirmedSheetsCount },
]}
centerLabel="sheets"
emptyText="No loading sheets yet."
/>
)
case 'warehouseChart':
return <HorizontalBarChart data={data.warehouseBalances.map(w => ({ name: w.name, value: w.quantity }))} emptyText="No warehouse stock recorded." />
case 'movementTypeChart':
return <ColumnBarChart data={data.movementTypeChart} label="Movements" emptyText="No recent stock movements." />
default:
return null
}
@@ -653,7 +675,9 @@ export function DashboardClient() {
<div className="text-sm font-semibold text-foreground">{w.title}</div>
<div className="mt-1"><CategoryPill cat={w.cat} /></div>
</div>
<WidgetBody id={w.id} data={data} />
<div className="min-h-0 flex-1">
<WidgetBody id={w.id} data={data} />
</div>
</div>
))}
</ResponsiveGridLayout>