feat(dashboard): add date filter and stat groups components
- Implemented DashboardDateFilter component for selecting date ranges including custom options. - Created DashboardStatGroups component to display various statistical cards grouped by categories. - Added dashboard-chart-data module to handle chart specifications and data processing for dashboard charts. - Introduced Chart component with tooltip and legend functionalities for better data visualization.
This commit is contained in:
@@ -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 })),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user