This commit is contained in:
2026-10-22 11:42:23 +05:30
27 changed files with 14651 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);
@@ -0,0 +1,197 @@
"use client"
import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, XAxis, YAxis } from "recharts"
import { useId, useMemo } from "react"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { Skeleton } from "@/components/ui/skeleton"
import { DashboardChartsData, DashboardStats } from "@/types/dashboard"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useAuth } from "@/components/auth/AuthProvider"
import { buildChartSpecs, canSeeChart, CHART_SECTIONS, isChartEmpty, type BarChartProps, type ChartSpec, type LineChartProps, type Series } from "@/components/dashboard/dashboard-chart-data"
// The dashboard palette is the theme red (--primary) paired with a slate ash (--chart-ink in globals.css), plus
// lighter tints of each, so every chart reads as one set in the brand colours.
const tint = (base: string, pct: number) => `color-mix(in oklab, ${base} ${pct}%, var(--background))`
const RED = "var(--primary)"
const ASH = "var(--chart-ink)"
// Several series: ash and red side by side (clearly different), then a light ash for a third.
const SERIES_SHADES = [ASH, RED, tint(ASH, 35)]
// Donuts alternate red and ash, fading lighter, so neighbouring slices never look alike.
const SLICE_SHADES = [RED, ASH, tint(RED, 60), tint(ASH, 55), tint(ASH, 30)]
// A chart with one series is the brand red; with several it uses the ash / red pairing above.
const seriesColor = (i: number, count: number) => (count === 1 ? RED : SERIES_SHADES[i % SERIES_SHADES.length])
const sliceColor = (i: number) => SLICE_SHADES[i % SLICE_SHADES.length]
function seriesConfig(series: Series[]): ChartConfig {
return Object.fromEntries(series.map((s, i) => [s.key, { label: s.label, color: s.color ?? seriesColor(i, series.length) }]))
}
const compact = new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 })
const formatTick = (v: number | string) => (typeof v === "number" ? compact.format(v) : v)
export function BarChartView({ spec }: { spec: BarChartProps }) {
const config = seriesConfig(spec.series)
const horizontal = spec.horizontal === true
return (
<ChartContainer config={config} className="h-64 w-full">
<BarChart accessibilityLayer data={spec.data} layout={horizontal ? "vertical" : "horizontal"} margin={{ left: horizontal ? 8 : 0 }}>
<CartesianGrid horizontal={!horizontal} vertical={horizontal} />
{horizontal ? (
<>
<YAxis dataKey={spec.xKey} type="category" tickLine={false} axisLine={false} width={72} tickMargin={4} />
<XAxis type="number" hide />
</>
) : (
<>
<XAxis dataKey={spec.xKey} tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} width={44} tickFormatter={formatTick} />
</>
)}
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
{spec.series.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
{spec.series.map((s) => (
<Bar
key={s.key}
dataKey={s.key}
fill={`var(--color-${s.key})`}
stackId={spec.stacked ? "a" : undefined}
maxBarSize={56}
radius={spec.stacked ? 0 : horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]}
/>
))}
</BarChart>
</ChartContainer>
)
}
export function LineChartView({ spec }: { spec: LineChartProps }) {
// Gradient ids must be unique per chart on the page, or every chart would share the first one's fill.
const gid = useId().replace(/:/g, "")
const config = seriesConfig(spec.series)
const common = (
<>
<CartesianGrid vertical={false} />
<XAxis dataKey={spec.xKey} tickLine={false} axisLine={false} tickMargin={8} minTickGap={16} />
<YAxis tickLine={false} axisLine={false} width={44} tickFormatter={formatTick} />
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
{spec.series.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
</>
)
return (
<ChartContainer config={config} className="h-64 w-full">
{spec.area ? (
<AreaChart accessibilityLayer data={spec.data}>
<defs>
{spec.series.map((s) => (
<linearGradient key={s.key} id={`${gid}-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={`var(--color-${s.key})`} stopOpacity={0.35} />
<stop offset="95%" stopColor={`var(--color-${s.key})`} stopOpacity={0.02} />
</linearGradient>
))}
</defs>
{common}
{spec.series.map((s) => (
<Area key={s.key} dataKey={s.key} type="monotone" stroke={`var(--color-${s.key})`} fill={`url(#${gid}-${s.key})`} strokeWidth={2} dot={false} activeDot={{ r: 4 }} />
))}
</AreaChart>
) : (
<LineChart accessibilityLayer data={spec.data}>
{common}
{spec.series.map((s) => (
<Line key={s.key} dataKey={s.key} type="monotone" stroke={`var(--color-${s.key})`} strokeWidth={2} dot={false} />
))}
</LineChart>
)}
</ChartContainer>
)
}
function DonutSpec({ spec }: { spec: Extract<ChartSpec, { kind: "donut" }> }) {
const config: ChartConfig = Object.fromEntries(spec.data.map((d, i) => [d.key, { label: d.label, color: sliceColor(i) }]))
return (
<ChartContainer config={config} className="mx-auto h-64 w-full">
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="key" />} />
<Pie data={spec.data} dataKey="value" nameKey="key" innerRadius={52} outerRadius={84} strokeWidth={2}>
{spec.data.map((d) => (
<Cell key={d.key} fill={`var(--color-${d.key})`} />
))}
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
</PieChart>
</ChartContainer>
)
}
function ChartBody({ spec }: { spec: ChartSpec }) {
switch (spec.kind) {
case "bar":
return <BarChartView spec={spec} />
case "line":
return <LineChartView spec={spec} />
case "donut":
return <DonutSpec spec={spec} />
}
}
export function DashboardCharts({
stats,
charts,
warehouseCodes,
error,
}: {
stats: DashboardStats | null
charts: DashboardChartsData | null
warehouseCodes: Map<number, string>
error?: boolean
}) {
const { can } = useAuth()
// A role only sees the graphs for modules it can open; the API withholds the data too (DashboardAccess).
const specs = useMemo(
() => buildChartSpecs(stats, charts, warehouseCodes).filter((spec) => canSeeChart(spec, can)),
[stats, charts, warehouseCodes, can]
)
const loading = !charts && !error
return (
<div className="flex flex-col gap-6">
{CHART_SECTIONS.map((section) => {
const sectionSpecs = specs.filter((c) => c.section === section)
if (sectionSpecs.length === 0) return null
return (
<section key={section} className="flex flex-col gap-3">
<h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">{section}</h2>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 2xl:grid-cols-3">
{sectionSpecs.map((spec) => (
<Card key={spec.id} className="min-w-0">
<CardHeader>
<CardTitle className="text-sm sm:text-base">{spec.title}</CardTitle>
<CardDescription>{spec.description}</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-64 rounded-lg" />
) : isChartEmpty(spec) ? (
<p className="flex h-64 items-center justify-center text-sm text-muted-foreground">No data for this period.</p>
) : (
<ChartBody spec={spec} />
)}
</CardContent>
</Card>
))}
</div>
</section>
)
})}
</div>
)
}
@@ -0,0 +1,116 @@
"use client"
import { CalendarDays, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { DateRangePicker } from "@/components/ui/date-picker"
import type { CustomDateRange } from "@/components/ui/date-range-filter"
export type DashboardPeriod = "today" | "7d" | "14d" | "1m" | "2m" | "3m" | "6m" | "1y" | "custom"
export interface DashboardPeriodValue {
period: DashboardPeriod
custom?: CustomDateRange
}
const PERIODS: { value: DashboardPeriod; label: string }[] = [
{ value: "today", label: "Today" },
{ value: "7d", label: "7 Days" },
{ value: "14d", label: "14 Days" },
{ value: "1m", label: "1 Month" },
{ value: "2m", label: "2 Months" },
{ value: "3m", label: "3 Months" },
{ value: "6m", label: "6 Months" },
{ value: "1y", label: "1 Year" },
{ value: "custom", label: "Custom" },
]
const isoDate = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
/** Inclusive `YYYY-MM-DD` bounds for the chosen period; empty while a custom range has no start date yet. */
export function periodBounds(value: DashboardPeriodValue): { from?: string; to?: string } {
const today = new Date()
const from = new Date(today)
switch (value.period) {
case "custom":
return value.custom?.from ? { from: isoDate(value.custom.from), to: isoDate(value.custom.to ?? value.custom.from) } : {}
case "today":
break
case "7d":
from.setDate(today.getDate() - 6)
break
case "14d":
from.setDate(today.getDate() - 13)
break
case "1m":
from.setMonth(today.getMonth() - 1)
break
case "2m":
from.setMonth(today.getMonth() - 2)
break
case "3m":
from.setMonth(today.getMonth() - 3)
break
case "6m":
from.setMonth(today.getMonth() - 6)
break
case "1y":
from.setFullYear(today.getFullYear() - 1)
break
}
return { from: isoDate(from), to: isoDate(today) }
}
/** Appends the dashboard period to a link as ?from=&to= so the page it opens can apply the same dates. */
export function withPeriod(href: string, from?: string, to?: string): string {
if (!from) return href
return `${href}${href.includes("?") ? "&" : "?"}from=${from}&to=${to ?? from}`
}
/** One-line row of period buttons for the dashboard; "Custom" reveals a single-date / from–to picker. */
export function DashboardDateFilter({
value,
onChange,
}: {
value: DashboardPeriodValue
onChange: (next: DashboardPeriodValue) => void
}) {
// In custom mode the preset buttons step aside so only Custom, its picker and a way back remain.
const isCustom = value.period === "custom"
return (
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center lg:justify-end">
{/* A single line that scrolls sideways on narrow screens instead of wrapping. */}
<div role="group" aria-label="Dashboard period" className="-mx-1 flex gap-1.5 overflow-x-auto px-1 py-1">
{(isCustom ? PERIODS.filter((p) => p.value === "custom") : PERIODS).map((p) => (
<Button
key={p.value}
type="button"
size="sm"
variant={value.period === p.value ? "default" : "outline"}
aria-pressed={value.period === p.value}
className="shrink-0 rounded-full"
onClick={() => onChange({ ...value, period: p.value })}
>
{p.value === "custom" && <CalendarDays className="size-4" />}
{p.label}
</Button>
))}
</div>
{isCustom && (
<>
<DateRangePicker
value={value.custom}
onChange={(custom) => onChange({ ...value, custom })}
placeholder="Pick a date or from – to"
className="sm:w-72"
/>
<Button type="button" size="sm" variant="ghost" className="shrink-0 rounded-full" onClick={() => onChange({ ...value, period: "today" })}>
<X className="size-4" />
Back
</Button>
</>
)}
</div>
)
}
@@ -0,0 +1,187 @@
import {
AlertTriangle,
Boxes,
ListChecks,
PackageCheck,
Send,
ArrowLeftRight,
BadgeDollarSign,
Banknote,
CalendarClock,
CalendarX,
ClipboardCheck,
ClipboardList,
CreditCard,
FileText,
FileWarning,
Gift,
Hammer,
HandCoins,
LifeBuoy,
PackageX,
PiggyBank,
Receipt,
ReceiptText,
RotateCcw,
ShieldCheck,
ShieldAlert,
ShoppingBag,
Tag,
Timer,
Trash2,
TrendingUp,
UserCheck,
Users,
Wallet,
Warehouse,
type LucideIcon,
} from "lucide-react"
import Link from "next/link"
import { useAuth } from "@/components/auth/AuthProvider"
import { withPeriod } from "@/components/dashboard/DashboardDateFilter"
import { DashboardStats } from "@/types/dashboard"
import { StatCard } from "@/components/ui/stat-card"
// UI only: no data is connected yet, so every value shows a dash. Nothing here calls the API.
type NumericStat = Exclude<keyof DashboardStats, "stockValuationByWarehouse" | "monthlyPurchasesVsSales">
interface Card {
label: string
/** The Dashboard area (Settings -> Roles -> Dashboard) that shows this card; a role without it never sees the card. */
code?: string
/** Page the card opens; the dashboard period is appended as ?from=&to=. */
href: string
value: number | string
icon: LucideIcon
hint?: string
/** Live number from /dashboard/stats; replaces `value` once the stats have loaded. */
stat?: NumericStat
/** Show the value and icon in the destructive colour. */
alert?: boolean
}
interface Group {
title?: string
cards: Card[]
}
const GROUPS: Group[] = [
{
// The original six cards: no heading, live numbers from /dashboard/stats.
cards: [
{ label: "Low Stock Alerts", code: "dashboard.stock", href: "/dashboard/stock/reorder-alerts", value: "—", stat: "lowStockAlerts", icon: AlertTriangle },
{ label: "Stock On-Hand", code: "dashboard.stock", href: "/dashboard/stock/enquiry", value: "—", stat: "onHandTotal", icon: Boxes },
{ label: "Pending GRNs", code: "dashboard.procurement", href: "/dashboard/receiving/grn", value: "—", stat: "pendingGrns", icon: PackageCheck },
{ label: "Open Requisitions", code: "dashboard.procurement", href: "/dashboard/procurement/requisitions", value: "—", stat: "openRequisitions", icon: ClipboardList },
{ label: "Open Counts", code: "dashboard.stock", href: "/dashboard/stock/counts", value: "—", stat: "pendingCounts", icon: ListChecks },
{ label: "Active RFQs", code: "dashboard.procurement", href: "/dashboard/procurement/rfqs", value: "—", stat: "openRfqs", icon: Send },
],
},
{
title: "Overview",
cards: [
{ label: "Stock Value", code: "dashboard.stock", stat: "stockValuationTotal", href: "/dashboard/stock/valuation", value: "—", icon: BadgeDollarSign, hint: "LKR" },
{ label: "Overdue Sales Invoices", code: "dashboard.sales", stat: "overdueSalesInvoices", href: "/dashboard/sales/invoices", value: "—", icon: FileWarning },
{ label: "POs Awaiting Approval", code: "dashboard.approvals", stat: "pendingApprovalPurchaseOrders", href: "/dashboard/procurement/purchase-orders/approvals", value: "—", icon: ClipboardCheck },
{ label: "Pending Leave Requests", code: "dashboard.hrm", stat: "pendingLeaveRequests", href: "/dashboard/hrm/leave", value: "—", icon: CalendarClock },
{ label: "Warehouses Holding Stock", code: "dashboard.stock", stat: "onHandWarehouses", href: "/dashboard/stock/enquiry", value: "—", icon: Warehouse },
],
},
{
title: "Stock & Warehouse",
cards: [
{ label: "Batches Expiring Soon", code: "dashboard.stock", stat: "batchesExpiringSoon", href: "/dashboard/stock/batches", value: "—", icon: CalendarX, hint: "Next 30 days" },
{ label: "Transfers In Transit", code: "dashboard.stock", stat: "transfersInTransit", href: "/dashboard/stock/transfers", value: "—", icon: ArrowLeftRight },
{ label: "Draft Stock Adjustments", code: "dashboard.stock", stat: "draftStockAdjustments", href: "/dashboard/stock/adjustments", value: "—", icon: ClipboardList },
{ label: "Wastage Pending Approval", code: "dashboard.approvals", stat: "wastagePendingApproval", href: "/dashboard/stock/wastage", value: "—", icon: Trash2 },
],
},
{
title: "Approvals",
cards: [
{ label: "Pending Approvals", code: "dashboard.approvals", stat: "pendingApprovals", href: "/dashboard/approvals", value: "—", icon: ClipboardCheck },
{ label: "Price Adjustments Pending", code: "dashboard.approvals", stat: "priceAdjustmentsPending", href: "/dashboard/products/price-adjustments", value: "—", icon: Tag },
],
},
{
title: "Sales",
cards: [
{ label: "Draft Sales Invoices", code: "dashboard.sales", stat: "draftSalesInvoices", href: "/dashboard/sales/invoices", value: "—", icon: FileText },
{ label: "Draft Sales Slips", code: "dashboard.sales", stat: "draftSalesSlips", href: "/dashboard/sales/invoices", value: "—", icon: Receipt },
{ label: "Sales Returns", code: "dashboard.sales", stat: "salesReturns", href: "/dashboard/sales/sales-returns", value: "—", icon: RotateCcw },
{ label: "Bundle Sales", code: "dashboard.sales", stat: "bundleSales", href: "/dashboard/sales/bundles", value: "—", icon: Gift },
],
},
{
title: "Purchasing",
cards: [
{ label: "Purchase Returns Pending", code: "dashboard.procurement", stat: "purchaseReturnsPending", href: "/dashboard/procurement/purchase-returns", value: "—", icon: PackageX },
{ label: "Unpaid Purchase Invoices", code: "dashboard.procurement", stat: "unpaidPurchaseInvoices", href: "/dashboard/procurement/purchase-invoices", value: "—", icon: FileWarning },
],
},
{
title: "Money",
cards: [
{ label: "Cash Not Yet Deposited", code: "dashboard.accounts", stat: "cashInHand", href: "/dashboard/accounts/bank-deposits", value: "—", icon: Wallet, hint: "LKR" },
{ label: "Bank Deposits", code: "dashboard.accounts", stat: "bankDeposits", href: "/dashboard/accounts/bank-deposits", value: "—", icon: PiggyBank, hint: "LKR" },
],
},
{
title: "Service & Warranty",
cards: [
{ label: "Open Support Tickets", code: "dashboard.service", stat: "openSupportTickets", href: "/dashboard/help", value: "—", icon: LifeBuoy },
{ label: "Warranties Expiring Soon", code: "dashboard.service", stat: "warrantiesExpiringSoon", href: "/dashboard/sales/warranties", value: "—", icon: ShieldAlert, hint: "Next 30 days" },
{ label: "Warranty Claims Open", code: "dashboard.service", stat: "warrantyClaimsOpen", href: "/dashboard/sales/warranties", value: "—", icon: ShieldCheck },
],
},
{
title: "Production",
cards: [{ label: "Production Runs In Progress", code: "dashboard.production", stat: "productionRunsInProgress", href: "/dashboard/production/runs", value: "—", icon: Hammer }],
},
{
title: "HR",
cards: [
{ label: "Payroll Run Pending", code: "dashboard.hrm", stat: "payrollRunsPending", href: "/dashboard/hrm/payroll", value: "—", icon: Timer },
{ label: "Attendance Batches Pending", code: "dashboard.hrm", stat: "attendanceBatchesPending", href: "/dashboard/hrm/attendance", value: "—", icon: AlertTriangle },
{ label: "Employees Present", code: "dashboard.hrm", stat: "employeesPresent", href: "/dashboard/hrm/attendance", value: "—", icon: UserCheck },
{ label: "Total Active Employees", code: "dashboard.hrm", stat: "totalActiveEmployees", href: "/dashboard/hrm/employees", value: "—", icon: Users },
],
},
]
export function DashboardStatGroups({ from, to, stats }: { from?: string; to?: string; stats: DashboardStats | null }) {
const { can } = useAuth()
// A role only sees the cards of the dashboard areas it was given; the API withholds the figures too (DashboardAccess).
const groups = GROUPS.map((group) => ({ ...group, cards: group.cards.filter((card) => !card.code || can(card.code)) })).filter(
(group) => group.cards.length > 0
)
return (
<div className="flex flex-col gap-6">
{groups.map((group) => (
<section key={group.title ?? "main"} className="flex flex-col gap-3">
{group.title && <h2 className="text-sm font-bold tracking-tight text-foreground sm:text-base">{group.title}</h2>}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 2xl:grid-cols-6">
{group.cards.map((card) => (
<Link key={card.label} href={withPeriod(card.href, from, to)} className="block h-full rounded-2xl">
<StatCard
label={card.label}
value={card.stat && stats ? (stats[card.stat] ?? 0) : card.value}
hint={card.hint}
icon={card.icon}
compact
className="h-full"
valueClassName={card.alert ? "text-destructive" : undefined}
iconClassName={card.alert ? "bg-destructive/10 text-destructive" : undefined}
/>
</Link>
))}
</div>
</section>
))}
</div>
)
}
@@ -0,0 +1,341 @@
// Turns the dashboard's real data (GET /dashboard/charts, plus the monthly and per-warehouse figures that already
// come with GET /dashboard/stats) into the chart specs DashboardCharts draws. No numbers are made up here: an empty
// response draws an empty chart.
import { DashboardChartsData, DashboardStats } from "@/types/dashboard"
export interface Series {
key: string
label: string
/** CSS colour; defaults to the dashboard palette (ash and red), so leave it unset to keep every chart matching. */
color?: string
}
/** Which Dashboard areas (Settings -> Roles -> Dashboard) a role needs to see a graph. No rule = open to everyone. */
export interface ChartAccess {
/** Every one of these is required. */
all?: string[]
/** At least one of these is required. */
any?: string[]
}
interface BaseChart {
id: string
access?: ChartAccess
title: string
description: string
section: ChartSection
}
type Row = Record<string, string | number>
/** What the bar and line views need, so live charts on the page can reuse them with their own data. */
export interface BarChartProps {
xKey: string
series: Series[]
data: Row[]
stacked?: boolean
horizontal?: boolean
}
export interface LineChartProps {
xKey: string
series: Series[]
data: Row[]
area?: boolean
}
export type ChartSection = "Sales" | "Money" | "Purchasing" | "Stock" | "HR" | "Service & Production"
export type ChartSpec = BaseChart &
(
| ({ kind: "bar" } & BarChartProps)
| ({ kind: "line" } & LineChartProps)
| { kind: "donut"; data: { key: string; label: string; value: number }[] }
)
export const CHART_SECTIONS: ChartSection[] = ["Sales", "Money", "Purchasing", "Stock", "HR", "Service & Production"]
export function canSeeChart(spec: ChartSpec, can: (navCode: string) => boolean): boolean {
const { all, any } = spec.access ?? {}
return (all ?? []).every(can) && (!any || any.some(can))
}
/** True when a chart has nothing to draw: no rows, or every value is zero. */
export function isChartEmpty(spec: ChartSpec): boolean {
if (spec.kind === "donut") return spec.data.every((d) => d.value === 0)
return spec.data.every((row) => spec.series.every((s) => Number(row[s.key] ?? 0) === 0))
}
const slug = (label: string) => label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "x"
/** "2026-03" -> "Mar 26" (a mid-month day is added so the date parses the same in every time zone). */
function monthLabel(yearMonth: string): string {
const d = new Date(`${yearMonth}-15T00:00:00`)
return Number.isNaN(d.getTime()) ? yearMonth : d.toLocaleDateString(undefined, { month: "short", year: "2-digit" })
}
export function buildChartSpecs(
stats: DashboardStats | null,
charts: DashboardChartsData | null,
warehouseCodes: Map<number, string>
): ChartSpec[] {
const slices = (rows: { label: string; value: number }[]) => rows.map((r) => ({ key: slug(r.label), label: r.label, value: r.value }))
const c = charts
return [
// ---------------------------------------------------------------- Sales
{
id: "purchases-vs-sales",
access: { all: ["dashboard.sales", "dashboard.procurement"] },
section: "Sales",
title: "Purchases vs Sales by Month",
description: "Posted invoice totals, last 12 months (LKR)",
kind: "bar",
xKey: "month",
series: [
{ key: "purchases", label: "Purchases" },
{ key: "sales", label: "Sales" },
],
data: (stats?.monthlyPurchasesVsSales ?? []).map((m) => ({ month: monthLabel(m.month), purchases: m.purchases, sales: m.sales })),
},
{
id: "daily-sales",
access: { all: ["dashboard.sales"] },
section: "Sales",
title: "Daily Sales, Gross Profit and Expenses",
description: "Day by day over the selected period (LKR)",
kind: "line",
xKey: "day",
series: [
{ key: "sales", label: "Sales" },
{ key: "profit", label: "Gross profit" },
{ key: "expenses", label: "Expenses" },
],
data: (c?.dailySales ?? []).map((d) => ({ day: d.label, sales: d.sales, profit: d.grossProfit, expenses: d.expenses })),
},
{
id: "payment-split",
access: { all: ["dashboard.sales"] },
section: "Sales",
title: "Payment Method Split",
description: "How customers paid in the period (LKR)",
kind: "donut",
data: slices(c?.paymentSplit ?? []),
},
{
id: "top-items",
access: { all: ["dashboard.sales"] },
section: "Sales",
title: "Top 10 Selling Items",
description: "By sales value in the period (LKR)",
kind: "bar",
horizontal: true,
xKey: "item",
series: [{ key: "value", label: "Sales" }],
data: (c?.topItems ?? []).map((p) => ({ item: p.label, value: p.value })),
},
{
id: "sales-by-category",
access: { all: ["dashboard.sales"] },
section: "Sales",
title: "Sales by Category",
description: "Share of sales value in the period",
kind: "donut",
data: slices(c?.salesByCategory ?? []),
},
{
id: "sales-by-customer",
access: { all: ["dashboard.sales"] },
section: "Sales",
title: "Sales by Customer (Top 5)",
description: "By invoice value in the period (LKR)",
kind: "bar",
horizontal: true,
xKey: "customer",
series: [{ key: "value", label: "Sales" }],
data: (c?.salesByCustomer ?? []).map((p) => ({ customer: p.label, value: p.value })),
},
{
id: "document-status",
access: { any: ["dashboard.sales", "dashboard.procurement", "dashboard.accounts"] },
section: "Sales",
title: "Document Status Mix",
description: "Documents in the period by status",
kind: "bar",
stacked: true,
xKey: "doc",
series: [
{ key: "draft", label: "Draft" },
{ key: "posted", label: "Posted" },
{ key: "cancelled", label: "Cancelled" },
],
data: (c?.documentStatus ?? []).map((d) => ({ doc: d.doc, draft: d.draft, posted: d.posted, cancelled: d.cancelled })),
},
// ---------------------------------------------------------------- Money
{
id: "cash-in-hand",
access: { all: ["dashboard.accounts"] },
section: "Money",
title: "Cash in Hand by Day",
description: "Closing till cash each day (LKR)",
kind: "line",
area: true,
xKey: "day",
series: [{ key: "cash", label: "Closing cash" }],
data: (c?.cashInHandByDay ?? []).map((p) => ({ day: p.label, cash: p.value })),
},
{
id: "receivables-ageing",
access: { all: ["dashboard.sales"] },
section: "Money",
title: "Receivables Ageing",
description: "Unpaid sales invoices by age (LKR)",
kind: "bar",
xKey: "bucket",
series: [{ key: "amount", label: "Outstanding" }],
data: (c?.receivablesAgeing ?? []).map((p) => ({ bucket: p.label, amount: p.value })),
},
{
id: "payables-ageing",
access: { all: ["dashboard.procurement"] },
section: "Money",
title: "Payables Ageing",
description: "Unpaid purchase invoices by age (LKR)",
kind: "bar",
xKey: "bucket",
series: [{ key: "amount", label: "Outstanding" }],
data: (c?.payablesAgeing ?? []).map((p) => ({ bucket: p.label, amount: p.value })),
},
{
id: "expense-breakdown",
access: { all: ["dashboard.accounts"] },
section: "Money",
title: "Expense Breakdown by Category",
description: "Share of posted expenses in the period",
kind: "donut",
data: slices(c?.expenseBreakdown ?? []),
},
// ---------------------------------------------------------------- Purchasing
{
id: "top-vendors",
access: { all: ["dashboard.procurement"] },
section: "Purchasing",
title: "Top Vendors by Purchase Value",
description: "Posted purchase invoices in the period (LKR)",
kind: "bar",
horizontal: true,
xKey: "vendor",
series: [{ key: "value", label: "Purchases" }],
data: (c?.topVendors ?? []).map((p) => ({ vendor: p.label, value: p.value })),
},
// ---------------------------------------------------------------- Stock
{
id: "stock-value-share",
access: { all: ["dashboard.stock"] },
section: "Stock",
title: "Stock Value Share by Warehouse",
description: "Share of total stock value",
kind: "donut",
data: slices(
(stats?.stockValuationByWarehouse ?? []).map((w) => ({
label: warehouseCodes.get(w.warehouseId) ?? `#${w.warehouseId}`,
value: w.total,
}))
),
},
{
id: "in-vs-out",
access: { all: ["dashboard.stock"] },
section: "Stock",
title: "Stock In vs Out by Warehouse",
description: "Quantity moved in the period",
kind: "bar",
xKey: "warehouse",
series: [
{ key: "in", label: "In" },
{ key: "out", label: "Out" },
],
data: (c?.stockInVsOut ?? []).map((p) => ({ warehouse: p.label, in: p.first, out: p.second })),
},
{
id: "batches-expiring",
access: { all: ["dashboard.stock"] },
section: "Stock",
title: "Batches Expiring Soon",
description: "Batches by days until expiry",
kind: "bar",
xKey: "window",
series: [{ key: "batches", label: "Batches" }],
data: (c?.batchesExpiring ?? []).map((p) => ({ window: p.label, batches: p.value })),
},
// ---------------------------------------------------------------- HR
{
id: "attendance-mix",
access: { all: ["dashboard.hrm"] },
section: "HR",
title: "Attendance Status Mix",
description: "Attendance records in the period by status",
kind: "donut",
data: slices(c?.attendanceMix ?? []),
},
{
id: "payroll-cost",
access: { all: ["dashboard.hrm"] },
section: "HR",
title: "Payroll Cost by Month",
description: "Net salary of approved payroll runs, last 12 months (LKR)",
kind: "bar",
xKey: "month",
series: [{ key: "cost", label: "Payroll" }],
data: (c?.payrollCostByMonth ?? []).map((p) => ({ month: p.label, cost: p.value })),
},
{
id: "leave-status",
access: { all: ["dashboard.hrm"] },
section: "HR",
title: "Leave Requests by Status",
description: "Requests raised in the period",
kind: "donut",
data: slices(c?.leaveStatus ?? []),
},
// ---------------------------------------------------------------- Service & Production
{
id: "tickets-over-time",
access: { all: ["dashboard.service"] },
section: "Service & Production",
title: "Support Tickets: Opened vs Resolved",
description: "Per day over the selected period",
kind: "line",
xKey: "day",
series: [
{ key: "opened", label: "Opened" },
{ key: "resolved", label: "Resolved" },
],
data: (c?.ticketsOverTime ?? []).map((p) => ({ day: p.label, opened: p.first, resolved: p.second })),
},
{
id: "production-status",
access: { all: ["dashboard.production"] },
section: "Service & Production",
title: "Production Runs by Status",
description: "Runs started in the period",
kind: "donut",
data: slices(c?.productionStatus ?? []),
},
{
id: "warranty-expiry",
access: { all: ["dashboard.service"] },
section: "Service & Production",
title: "Warranties Expiring by Month",
description: "Active warranty registrations by end month, next 6 months",
kind: "bar",
xKey: "month",
series: [{ key: "warranties", label: "Warranties" }],
data: (c?.warrantyExpiryByMonth ?? []).map((p) => ({ month: p.label, warranties: p.value })),
},
]
}
+372
View File
@@ -0,0 +1,372 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
initialDimension?: {
width: number
height: number
}
}) {
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color ?? item.payload?.fill ?? item.color
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+18 -16
View File
@@ -58,10 +58,8 @@ export interface StatCardProps {
valueClassName?: string
/** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */
iconClassName?: string
/** Makes the card a toggle button that filters the list below it (click again to clear). */
onClick?: () => void
/** The card's filter is the one currently applied — highlighted and announced as pressed. */
active?: boolean
/** Tighter padding and a smaller icon / text, for dense rows such as the dashboard. */
compact?: boolean
}
/**
@@ -80,8 +78,7 @@ export function StatCard({
className,
valueClassName,
iconClassName,
onClick,
active = false,
compact = false,
}: StatCardProps) {
const hasDelta = typeof delta === "number" && delta !== 0
const isPositive = hasDelta && delta > 0
@@ -98,31 +95,36 @@ export function StatCard({
? { type: "button" as const, onClick, "aria-pressed": active, title: active ? "Click to clear this filter" : "Click to filter the list" }
: {})}
className={cn(
"@container min-w-0 rounded-2xl bg-card px-4 py-5 text-left shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
onClick && "w-full cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
className,
// After className so the "selected" look wins over a card's own tint.
active && "bg-primary/5 ring-2 ring-primary"
"@container min-w-0 rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
compact ? "px-3 py-2.5" : "px-4 py-5",
className
)}
>
{/* Wide enough: icon beside the text. Narrow (e.g. two per row on a phone): icon above, so the
value keeps the full width instead of being cut off. */}
<div className="flex flex-col gap-2.5 @[12.5rem]:flex-row @[12.5rem]:items-center @[12.5rem]:gap-3.5">
<div
className={cn(
"flex",
// Compact cards keep the icon beside the text at every width; the regular card stacks it above when narrow.
compact ? "flex-row items-center gap-2.5" : "flex-col gap-2.5 @[12.5rem]:flex-row @[12.5rem]:items-center @[12.5rem]:gap-3.5"
)}
>
{Icon && (
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary @[12.5rem]:size-11",
"flex shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary",
compact ? "size-9" : "size-9 @[12.5rem]:size-11",
iconClassName
)}
>
<Icon className="size-4.5 @[12.5rem]:size-5" aria-hidden />
<Icon className={compact ? "size-4" : "size-4.5 @[12.5rem]:size-5"} aria-hidden />
</div>
)}
<div className="flex min-w-0 flex-1 flex-col">
<p className="truncate text-sm text-muted-foreground">{label}</p>
<p className={cn("text-muted-foreground", compact ? "text-xs leading-tight" : "truncate text-sm")}>{label}</p>
<div className="flex items-end justify-between gap-2">
<p className={cn("text-base leading-tight font-semibold tracking-tight wrap-break-word text-foreground @[12.5rem]:text-lg", valueClassName)}>
<p className={cn("leading-tight font-semibold tracking-tight wrap-break-word text-foreground", compact ? "text-base" : "text-base @[12.5rem]:text-lg", valueClassName)}>
{formatValue(value)}
</p>
{trend && trend.length > 1 && <Sparkline points={trend} />}
+8 -2
View File
@@ -1,9 +1,15 @@
// Dashboard overview endpoint (Backend/ERPCore/Controllers/DashboardController.cs).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { DashboardStats } from "@/types/dashboard"
import { DashboardChartsData, DashboardStats } from "@/types/dashboard"
export const dashboardApi = {
stats(params: { branchId?: number } = {}): Promise<DashboardStats> {
/** `from` / `to` (YYYY-MM-DD) set the period for the activity counts; omitted means today. */
stats(params: { branchId?: number; from?: string; to?: string } = {}): Promise<DashboardStats> {
return apiRequest<DashboardStats>(`/dashboard/stats${buildQuery(params)}`)
},
/** Datasets for the dashboard graphs; the time-based ones follow `from` / `to` (YYYY-MM-DD, default today). */
charts(params: { branchId?: number; from?: string; to?: string } = {}): Promise<DashboardChartsData> {
return apiRequest<DashboardChartsData>(`/dashboard/charts${buildQuery(params)}`)
},
}
+331 -1
View File
@@ -16,6 +16,7 @@
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cn": "^0.5.0",
"date-fns": "^4.4.0",
"html-to-image": "^1.11.13",
"jspdf": "^4.2.1",
@@ -28,6 +29,7 @@
"react-dom": "19.2.4",
"react-hook-form": "^7.81.0",
"react-loading-skeleton": "^3.5.0",
"recharts": "^3.8.0",
"shadcn": "^4.13.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
@@ -1909,6 +1911,42 @@
"react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz",
"integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.2.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.2.3.tgz",
"integrity": "sha512-FbR9W6jf7+tvOCuDmOLJy3yStLCvoRDODRARzfeaWmn75+1fW4hFbMv5X5cWgEkxgWcC/eC5Nva00ZV1iv+0EA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1934,6 +1972,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@@ -2398,6 +2442,12 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
@@ -2413,6 +2463,12 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -2422,12 +2478,48 @@
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
@@ -2518,6 +2610,12 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/validate-npm-package-name": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
@@ -3916,6 +4014,18 @@
"node": ">=6"
}
},
"node_modules/cn": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cn/-/cn-0.5.0.tgz",
"integrity": "sha512-JGEixZEpfFXm1P5uXwvEobOFLlHwIWi5v4tjd1ZwfiYmcEilkuD2l+054rvJcYCR1B+uVThHeUbgmNz1FwuxuA==",
"license": "MIT",
"bin": {
"cn": "bin/cn.mjs"
},
"engines": {
"node": ">=22"
}
},
"node_modules/code-block-writer": {
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
@@ -4186,6 +4296,18 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
@@ -4226,6 +4348,15 @@
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
@@ -4238,6 +4369,31 @@
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
@@ -4247,6 +4403,42 @@
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
@@ -4394,6 +4586,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/dedent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -4861,6 +5059,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types",
"tests/browser-compat"
]
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -5317,6 +5527,12 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@@ -6093,6 +6309,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -6140,6 +6366,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
@@ -8484,7 +8719,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-loading-skeleton": {
@@ -8496,6 +8730,29 @@
"react": ">=16.8.0"
}
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/recast": {
"version": "0.23.12",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.12.tgz",
@@ -8512,6 +8769,57 @@
"node": ">= 4"
}
},
"node_modules/recharts": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts/node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -10090,6 +10398,28 @@
"node": ">= 0.8"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+2
View File
@@ -17,6 +17,7 @@
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cn": "^0.5.0",
"date-fns": "^4.4.0",
"html-to-image": "^1.11.13",
"jspdf": "^4.2.1",
@@ -29,6 +30,7 @@
"react-dom": "19.2.4",
"react-hook-form": "^7.81.0",
"react-loading-skeleton": "^3.5.0",
"recharts": "^3.8.0",
"shadcn": "^4.13.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
+80
View File
@@ -20,6 +20,36 @@ export interface DashboardStats {
pendingLeaveRequests: number
overdueSalesInvoices: number
monthlyPurchasesVsSales: MonthlyPurchasesVsSales[]
// Stock & warehouse
batchesExpiringSoon: number
transfersInTransit: number
draftStockAdjustments: number
wastagePendingApproval: number
// Approvals
pendingApprovals: number
priceAdjustmentsPending: number
// Sales (salesReturns and bundleSales are counted inside the requested from–to period)
draftSalesInvoices: number
draftSalesSlips: number
salesReturns: number
bundleSales: number
// Purchasing
purchaseReturnsPending: number
unpaidPurchaseInvoices: number
// Money (cashInHand is the closing figure at the end of the period; bankDeposits is summed inside it)
cashInHand: number
bankDeposits: number
// Service & warranty
openSupportTickets: number
warrantiesExpiringSoon: number
warrantyClaimsOpen: number
// Production
productionRunsInProgress: number
// HR (employeesPresent is counted inside the period)
payrollRunsPending: number
attendanceBatchesPending: number
employeesPresent: number
totalActiveEmployees: number
}
// One month of Posted purchase vs sales invoice GrandTotals; `month` is "yyyy-MM".
@@ -29,3 +59,53 @@ export interface MonthlyPurchasesVsSales {
purchases: number
sales: number
}
// GET /dashboard/charts — every dataset behind the dashboard graphs (Backend/ERPCore/Dtos/Dashboard/DashboardChartsDtos.cs).
// Time-based sets follow the requested from–to period; ageing, batches expiring, low stock and warranties are snapshots.
export interface ChartPoint {
label: string
value: number
}
/** Two values under one label: stock in / out by warehouse, tickets opened / resolved by day. */
export interface ChartPair {
label: string
first: number
second: number
}
export interface DailySalesPoint {
label: string
sales: number
grossProfit: number
expenses: number
}
export interface DocumentStatusPoint {
doc: string
draft: number
posted: number
cancelled: number
}
export interface DashboardChartsData {
dailySales: DailySalesPoint[]
paymentSplit: ChartPoint[]
topItems: ChartPoint[]
salesByCategory: ChartPoint[]
salesByCustomer: ChartPoint[]
documentStatus: DocumentStatusPoint[]
cashInHandByDay: ChartPoint[]
receivablesAgeing: ChartPoint[]
payablesAgeing: ChartPoint[]
expenseBreakdown: ChartPoint[]
topVendors: ChartPoint[]
stockInVsOut: ChartPair[]
batchesExpiring: ChartPoint[]
attendanceMix: ChartPoint[]
payrollCostByMonth: ChartPoint[]
leaveStatus: ChartPoint[]
ticketsOverTime: ChartPair[]
productionStatus: ChartPoint[]
warrantyExpiryByMonth: ChartPoint[]
}