feat(dashboard): add date filter and stat groups components

- Implemented DashboardDateFilter component for selecting date ranges.
- Created DashboardStatGroups component to display various dashboard statistics.
- Added dashboard chart data processing logic to support new chart specifications.
- Introduced reusable Chart components for rendering different types of charts.
This commit is contained in:
2026-10-21 14:57:54 +05:30
parent e6efc33ac0
commit 0a212ed1aa
16 changed files with 1129 additions and 350 deletions
+183 -292
View File
@@ -2,74 +2,53 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import {
AlertTriangle,
ArrowDownLeft,
ArrowUpRight,
BadgeDollarSign,
Boxes,
ClipboardList,
ListChecks,
PackageCheck,
ScrollText,
Send,
} from "lucide-react"
import { ArrowDownLeft, ArrowUpRight, ScrollText } from "lucide-react"
import { dashboardApi } from "@/lib/api/dashboard"
import { stockApi } from "@/lib/api/stock"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { DashboardStats } from "@/types/dashboard"
import { DashboardChartsData, DashboardStats } from "@/types/dashboard"
import { LedgerEntry } from "@/types/stock"
import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { useIsMobile } from "@/hooks/use-mobile"
import { StatCard } from "@/components/ui/stat-card"
import { DashboardDateFilter, DashboardPeriodValue, periodBounds } from "@/components/dashboard/DashboardDateFilter"
import { DashboardCharts } from "@/components/dashboard/DashboardCharts"
import { DashboardStatGroups } from "@/components/dashboard/DashboardStatGroups"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import BarChart from "@/components/ui/bar-chart"
import LineChart from "@/components/ui/line-chart"
const TREND_DAYS = 14
const MOBILE_TREND_DAYS = 5
/** Fewest bar slots the valuation chart's axis spans, so a couple of bars sit at the start. */
const CHART_MIN_SLOTS = 5
function isoDate(d: Date) {
return d.toISOString().slice(0, 10)
}
export default function DashboardPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const { activeBranchId, branchingEnabled, can } = useAuth()
// The recent-movements table belongs to the Stock area of the dashboard (Settings -> Roles -> Dashboard).
const canStock = can("dashboard.stock")
const [stats, setStats] = useState<DashboardStats | null>(null)
const [charts, setCharts] = useState<DashboardChartsData | null>(null)
const [chartsFailed, setChartsFailed] = useState(false)
const [movements, setMovements] = useState<LedgerEntry[] | null>(null)
const [trend, setTrend] = useState<LedgerEntry[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [period, setPeriod] = useState<DashboardPeriodValue>({ period: "today" })
const bounds = useMemo(() => periodBounds(period), [period])
useEffect(() => {
let cancelled = false
const from = new Date()
from.setDate(from.getDate() - (TREND_DAYS - 1))
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
Promise.all([
dashboardApi.stats({ branchId }),
stockApi.ledger({ page: 1, pageSize: 5, branchId }),
stockApi.ledger({ from: isoDate(from), page: 1, pageSize: 200, branchId }),
warehousesApi.list(),
canStock ? stockApi.ledger({ page: 1, pageSize: 5, branchId }) : Promise.resolve(null),
// Only used to label warehouses; a role without the Warehouses screen just sees ids instead of an error.
warehousesApi.list().catch(() => null),
])
.then(([statsRes, ledger, trendRes, warehousesRes]) => {
.then(([ledger, warehousesRes]) => {
if (cancelled) return
setStats(statsRes)
setMovements(ledger.items)
setTrend(trendRes.items)
setWarehouses(warehousesRes.items)
setMovements(ledger?.items ?? [])
setWarehouses(warehousesRes?.items ?? [])
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
@@ -78,61 +57,62 @@ export default function DashboardPage() {
return () => {
cancelled = true
}
}, [activeBranchId, branchingEnabled])
}, [activeBranchId, branchingEnabled, canStock])
// Stats follow the date filter: the activity counts use its from–to period, the rest are snapshots.
useEffect(() => {
let cancelled = false
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
dashboardApi
.stats({ branchId, from: bounds.from, to: bounds.to })
.then((res) => {
if (!cancelled) setStats(res)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [activeBranchId, branchingEnabled, bounds.from, bounds.to])
// Graph datasets follow the same date filter. A failure only blanks the graphs, not the whole dashboard.
useEffect(() => {
let cancelled = false
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
dashboardApi
.charts({ branchId, from: bounds.from, to: bounds.to })
.then((res) => {
if (cancelled) return
setCharts(res)
setChartsFailed(false)
})
.catch(() => {
if (!cancelled) setChartsFailed(true)
})
return () => {
cancelled = true
}
}, [activeBranchId, branchingEnabled, bounds.from, bounds.to])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
// Last TREND_DAYS days, oldest first, each bucket summing In/Out qty for that date.
const movementTrend = useMemo(() => {
const days: string[] = []
const cursor = new Date()
cursor.setDate(cursor.getDate() - (TREND_DAYS - 1))
for (let i = 0; i < TREND_DAYS; i++) {
days.push(isoDate(cursor))
cursor.setDate(cursor.getDate() + 1)
}
const warehouseCodes = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w.code])), [warehouses])
const labels = days.map((d) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "short" }))
const inByDay = new Map(days.map((d) => [d, 0]))
const outByDay = new Map(days.map((d) => [d, 0]))
for (const entry of trend ?? []) {
const day = entry.createdAt.slice(0, 10)
const bucket = entry.direction === "In" ? inByDay : outByDay
if (bucket.has(day)) bucket.set(day, (bucket.get(day) ?? 0) + entry.qtyBase)
}
return {
labels,
inData: days.map((d) => inByDay.get(d) ?? 0),
outData: days.map((d) => outByDay.get(d) ?? 0),
}
}, [trend])
// Same fixed-axis padding as the product page's chart: with fewer than CHART_MIN_SLOTS
// warehouses, Chart.js would center the bars across the full width. Trailing blank slots keep
// the bars at the start. Skipped on mobile, where the bars should stretch to fill the card.
const isMobile = useIsMobile()
// Phones show only the most recent MOBILE_TREND_DAYS days — 14 points are unreadable at that width.
const trendDays = isMobile ? MOBILE_TREND_DAYS : TREND_DAYS
const shownTrend = useMemo(
() => ({
labels: movementTrend.labels.slice(-trendDays),
inData: movementTrend.inData.slice(-trendDays),
outData: movementTrend.outData.slice(-trendDays),
}),
[movementTrend, trendDays]
)
const valuationRows = stats?.stockValuationByWarehouse ?? []
const valuationPad = isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - valuationRows.length)
const loaded = stats && movements && trend && warehouses
const loaded = stats && movements && warehouses
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-base font-bold text-foreground sm:text-lg">Dashboard</h1>
<p className="text-xs text-muted-foreground sm:text-sm">Overview of stock, receiving and procurement.</p>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="shrink-0">
<h1 className="text-base font-bold text-foreground sm:text-lg">Dashboard</h1>
<p className="text-xs text-muted-foreground sm:text-sm">Overview of stock, receiving and procurement.</p>
</div>
<DashboardDateFilter value={period} onChange={setPeriod} />
</div>
{error && (
@@ -141,232 +121,143 @@ export default function DashboardPage() {
</div>
)}
{/* Three across until there is room for six — at six-up the cards get too narrow and switch to the
compact stacked layout, so they would not match the stat cards on every other screen. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 2xl:grid-cols-6">
{loaded ? (
<>
<Link href="/dashboard/stock/reorder-alerts" className="block h-full rounded-2xl">
<StatCard label="Low Stock Alerts" value={stats.lowStockAlerts} icon={AlertTriangle} className="h-full" />
</Link>
<Link href="/dashboard/stock/enquiry" className="block h-full rounded-2xl">
<StatCard label="Stock On-Hand" value={stats.onHandTotal} icon={Boxes} className="h-full" />
</Link>
<Link href="/dashboard/receiving/grn" className="block h-full rounded-2xl">
<StatCard label="Pending GRNs" value={stats.pendingGrns} icon={PackageCheck} className="h-full" />
</Link>
<Link href="/dashboard/procurement/requisitions" className="block h-full rounded-2xl">
<StatCard label="Open Requisitions" value={stats.openRequisitions} icon={ClipboardList} className="h-full" />
</Link>
<Link href="/dashboard/stock/counts" className="block h-full rounded-2xl">
<StatCard label="Open Counts" value={stats.pendingCounts} icon={ListChecks} className="h-full" />
</Link>
<Link href="/dashboard/procurement/rfqs" className="block h-full rounded-2xl">
<StatCard label="Active RFQs" value={stats.openRfqs} icon={Send} className="h-full" />
</Link>
</>
) : (
!error && Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
)}
</div>
<DashboardStatGroups from={bounds.from} to={bounds.to} stats={stats} />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<DashboardCharts stats={stats} charts={charts} warehouseCodes={warehouseCodes} error={chartsFailed} />
{canStock && (
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
<BadgeDollarSign className="size-4 shrink-0 text-primary" />
Stock Valuation by Warehouse
<ScrollText className="size-4 shrink-0 text-primary" />
Recent Stock Movements
</h2>
<Link href="/dashboard/stock/valuation" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
View details
<Link href="/dashboard/stock/ledger" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
View all
</Link>
</div>
{loaded ? (
(stats.stockValuationByWarehouse ?? []).length > 0 ? (
<div className="h-64 sm:h-72">
<BarChart
labels={[
...valuationRows.map((w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`),
...Array<string>(valuationPad).fill(""),
]}
datasets={[
{
label: "Stock Value (LKR)",
data: [...valuationRows.map((w) => w.total), ...Array<null>(valuationPad).fill(null)],
backgroundColor: "#E11D48",
},
]}
barPercentage={0.6}
/>
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock on hand yet.</p>
)
) : (
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
)}
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex items-center gap-2">
<ScrollText className="size-4 shrink-0 text-primary" />
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">
Stock Movement Trend (last {trendDays} days)
</h2>
</div>
{loaded ? (
shownTrend.inData.some((v) => v > 0) || shownTrend.outData.some((v) => v > 0) ? (
<div className="h-64 sm:h-72">
<LineChart
labels={shownTrend.labels}
datasets={[
{ label: "In", data: shownTrend.inData, borderColor: "#22c55e", backgroundColor: "rgba(34, 197, 94, 0.12)" },
{ label: "Out", data: shownTrend.outData, borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.12)" },
]}
/>
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock movement in the last {trendDays} days.</p>
)
) : (
!error && <Skeleton className="h-64 rounded-lg sm:h-72" />
)}
</div>
</div>
<div className="rounded-2xl bg-card p-4 shadow-sm ring-1 ring-foreground/10 sm:p-5">
<div className="mb-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold tracking-tight text-foreground sm:text-base">
<ScrollText className="size-4 shrink-0 text-primary" />
Recent Stock Movements
</h2>
<Link href="/dashboard/stock/ledger" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
View all
</Link>
</div>
{loaded ? (
movements.length > 0 ? (
<>
{/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */}
<div className="flex flex-col gap-3 md:hidden">
{movements.map((entry) => (
<div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-3">
<div
movements.length > 0 ? (
<>
{/* Mobile: stacked cards, most-scannable field (item/direction/qty) up top. */}
<div className="flex flex-col gap-3 md:hidden">
{movements.map((entry) => (
<div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-3">
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
entry.direction === "In" ? "bg-success/10" : "bg-destructive/10"
)}
>
{entry.direction === "In" ? (
<ArrowDownLeft className="size-4 text-success" />
) : (
<ArrowUpRight className="size-4 text-destructive" />
)}
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">Item #{entry.itemId}</p>
<p className="text-sm text-muted-foreground">
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
</p>
</div>
</div>
<Badge
variant="outline"
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
entry.direction === "In" ? "bg-success/10" : "bg-destructive/10"
"h-6 shrink-0 justify-center border-transparent px-2.5 text-xs",
entry.direction === "In" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{entry.direction === "In" ? (
<ArrowDownLeft className="size-4 text-success" />
) : (
<ArrowUpRight className="size-4 text-destructive" />
)}
{entry.direction}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Qty</p>
<p className="font-medium text-foreground">{entry.qtyBase}</p>
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">Item #{entry.itemId}</p>
<p className="text-sm text-muted-foreground">
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
<div>
<p className="text-muted-foreground">Date</p>
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Source</p>
<p className="font-medium text-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
</p>
</div>
</div>
<Badge
variant="outline"
className={cn(
"h-6 shrink-0 justify-center border-transparent px-2.5 text-xs",
entry.direction === "In" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Qty</p>
<p className="font-medium text-foreground">{entry.qtyBase}</p>
</div>
<div>
<p className="text-muted-foreground">Date</p>
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Source</p>
<p className="font-medium text-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
</p>
</div>
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="min-w-160">
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Direction</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Source</TableHead>
<TableHead className="text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{movements.map((entry) => (
<TableRow key={entry.ledgerId}>
<TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
<TableCell>
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn(
"h-6 w-16 justify-center text-xs",
entry.direction === "In"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
</TableCell>
<TableCell>{entry.qtyBase}</TableCell>
<TableCell>
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
<TableCell className="text-right">
{new Date(entry.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p>
)
) : (
!error && (
<div className="flex flex-col gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 rounded-lg" />
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-hidden rounded-xl border md:block">
<div className="overflow-x-auto">
<Table className="min-w-160">
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Direction</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Source</TableHead>
<TableHead className="text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{movements.map((entry) => (
<TableRow key={entry.ledgerId}>
<TableCell className="font-medium text-foreground">{entry.itemId}</TableCell>
<TableCell>
{warehousesById.get(entry.warehouseId)?.code ?? entry.warehouseId}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn(
"h-6 w-16 justify-center text-xs",
entry.direction === "In"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
)}
>
{entry.direction}
</Badge>
</TableCell>
<TableCell>{entry.qtyBase}</TableCell>
<TableCell>
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
<TableCell className="text-right">
{new Date(entry.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">No stock movements yet.</p>
)
) : (
!error && (
<div className="flex flex-col gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 rounded-lg" />
))}
</div>
)
)}
</div>
)
)}
</div>
)}
</div>
)
}
+3
View File
@@ -81,6 +81,8 @@
--chart-3: oklch(0.582 0.183 142.495);
--chart-4: oklch(0.787 0.141 72.031);
--chart-5: oklch(0.577 0.245 27.325);
/* Dashboard charts: one slate ink, tinted lighter for extra series, so every graph matches. */
--chart-ink: #5b6b6e;
--radius: 0.5rem;
--sidebar: oklch(0.98 0 0);
--sidebar-foreground: oklch(0.15 0 0);
@@ -120,6 +122,7 @@
--chart-3: oklch(0.65 0.15 142.495);
--chart-4: oklch(0.85 0.12 72.031);
--chart-5: oklch(0.704 0.191 22.216);
--chart-ink: #a3b3b6;
--sidebar: oklch(0.125 0 0);
--sidebar-foreground: oklch(0.95 0 0);
--sidebar-primary: oklch(0.645 0.215 16.439);