feat: add interactive sales trend and region sales pie components to dashboard

This commit is contained in:
2026-10-22 10:43:39 +05:30
parent 2be9d9b396
commit 99832df134
4 changed files with 295 additions and 43 deletions
@@ -7,6 +7,8 @@ import { toast } from '@/components/ui/toast'
import { dashboardReports, productReports, stockReports, getApiError, getPaginatedItems } from '@/lib/api/index' import { dashboardReports, productReports, stockReports, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports' import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports'
import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts' import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts'
import { RegionSalesPie } from './region-sales-pie'
import { SalesTrendInteractive } from './sales-trend-interactive'
export interface DateRange { export interface DateRange {
from: string // yyyy-MM-dd from: string // yyyy-MM-dd
@@ -60,8 +62,8 @@ function Kpi({ title, value, footer }: { title: string; value: string; footer?:
function LoadingGrid() { function LoadingGrid() {
return ( return (
<div className="flex flex-col gap-3" aria-busy="true"> <div className="flex flex-col gap-3" aria-busy="true">
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6"> <div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)} {Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
</div> </div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
@@ -175,6 +177,7 @@ export function SalesSection({ range }: { range: DateRange }) {
if (loading || productsLoading || !sales) return <LoadingGrid /> if (loading || productsLoading || !sales) return <LoadingGrid />
const { current: c, previous: p } = sales.kpis const { current: c, previous: p } = sales.kpis
const { current: cash, previous: cashPrev } = sales.cash
const kpi = (title: string, key: keyof SalesKpis, money = true) => ( const kpi = (title: string, key: keyof SalesKpis, money = true) => (
<Kpi <Kpi
title={title} title={title}
@@ -184,12 +187,11 @@ export function SalesSection({ range }: { range: DateRange }) {
) )
const trend = sales.trend.map(t => ({ period: t.period, sales: t.sales, returns: t.returns })) const trend = sales.trend.map(t => ({ period: t.period, sales: t.sales, returns: t.returns }))
const regionTotal = sales.byRegion.reduce((t, r) => t + r.amount, 0)
const repTotal = sales.byRep.reduce((t, r) => t + r.amount, 0) const repTotal = sales.byRep.reduce((t, r) => t + r.amount, 0)
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6"> <div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
{kpi('Net Sales', 'netSales')} {kpi('Net Sales', 'netSales')}
{kpi('Gross Sales', 'grossSales')} {kpi('Gross Sales', 'grossSales')}
{kpi('Total Orders', 'orders', false)} {kpi('Total Orders', 'orders', false)}
@@ -204,16 +206,40 @@ export function SalesSection({ range }: { range: DateRange }) {
} }
/> />
{kpi('Sales Returns', 'returnValue')} {kpi('Sales Returns', 'returnValue')}
{kpi('Discounts', 'discounts')}
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<Kpi
title="Collected"
value={fmtMoney(cash.collected)}
footer={<Delta current={cash.collected} previous={cashPrev.collected} />}
/>
<Kpi
title="Outstanding"
value={fmtMoney(cash.outstanding)}
footer={<span className="text-muted-foreground">Unpaid on finalized invoices</span>}
/>
<Kpi
title="Overdue"
value={fmtMoney(cash.overdue)}
footer={<span className="text-muted-foreground">Unpaid, invoiced over 30 days ago</span>}
/>
<Kpi
title="Collection Rate"
value={cash.collectionRatePercent === null ? '—' : `${cash.collectionRatePercent}%`}
footer={<span className="text-muted-foreground">Collected ÷ billed (gross − discounts)</span>}
/>
</div> </div>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
Sales = fulfilled lines on non-cancelled orders with a finalized invoice, dated by finalization. Net = gross − customer returns. Sales are recognised when an invoice is finalized (billable invoice lines on non-cancelled orders), regardless of payment status or method.
Gross profit uses item cost prices and covers only items that have one. Discounts and sales targets are not tracked per order yet. Net = gross − customer returns that deduct credit (item exchanges are net zero) − scheme discounts on those invoices
(not split by category, so Discounts is 0 when a category is selected). Collected = cash and cheque payments received;
credit-method payments stay outstanding. Outstanding and overdue are balances as of the end of the range, and Collected ignores the category filter.
Gross profit uses item cost prices and covers only items that have one. Sales targets are not tracked per order yet.
</p> </p>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
<ChartCard title="Sales Trend" note={`Gross sales per ${sales.granularity}`} className="md:col-span-2"> <SalesTrendInteractive className="md:col-span-2" />
<MultiAreaChart data={trend} series={[{ key: 'sales', label: 'Sales', color: seriesColor(0) }]} emptyText="No sales in this period." />
</ChartCard>
<ChartCard title="Sales by Category" note="Share of gross sales"> <ChartCard title="Sales by Category" note="Share of gross sales">
<DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." /> <DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." />
</ChartCard> </ChartCard>
@@ -232,9 +258,7 @@ export function SalesSection({ range }: { range: DateRange }) {
])} ])}
/> />
</ChartCard> </ChartCard>
<ChartCard title="Sales by Region"> <RegionSalesPie rows={sales.byRegion} />
<RankTable rows={sales.byRegion.map(r => ({ name: r.name, value: r.amount, extra: sharePct(r.amount, regionTotal) }))} fmt={fmtMoney} empty="No sales in this period." />
</ChartCard>
<ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2"> <ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2">
<MultiAreaChart <MultiAreaChart
@@ -18,7 +18,7 @@ import {
getPaginatedItems, getPaginatedItems,
} from '@/lib/api/index' } from '@/lib/api/index'
import { useAuth } from '@/contexts/auth-context' import { useAuth } from '@/contexts/auth-context'
import type { LowStockItemResponse, StockMovementResponse } from '@/types/api' import type { StockMovementResponse } from '@/types/api'
import { InventorySection, SalesSection, type DateRange } from './analytics-sections' import { InventorySection, SalesSection, type DateRange } from './analytics-sections'
import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts' import { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } from './dashboard-charts'
import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts' import type { ChartDatum, MonthlySalesPoint } from './dashboard-charts'
@@ -54,7 +54,6 @@ const WIDGET_META: WidgetMeta[] = [
{ id: 'openRides', cat: 'ops', title: 'Open Rides', size: 'md', permission: 'rides.view' }, { id: 'openRides', cat: 'ops', title: 'Open Rides', size: 'md', permission: 'rides.view' },
{ id: 'loadingSheetsByStatus',cat: 'ops', title: 'Loading Sheets by Status', size: 'md', permission: 'loading-sheet.view' }, { id: 'loadingSheetsByStatus',cat: 'ops', title: 'Loading Sheets by Status', size: 'md', permission: 'loading-sheet.view' },
{ id: 'deliveryTasks', cat: 'ops', title: 'Pending Delivery Tasks', size: 'sm', permission: 'delivery-task.view' }, { id: 'deliveryTasks', cat: 'ops', title: 'Pending Delivery Tasks', size: 'sm', permission: 'delivery-task.view' },
{ id: 'lowStock', cat: 'stock', title: 'Low Stock Alerts', size: 'lg', permission: 'stock.view' },
{ id: 'stockMovements', cat: 'stock', title: 'Recent Stock Movements', size: 'lg', permission: 'stock.view' }, { id: 'stockMovements', cat: 'stock', title: 'Recent Stock Movements', size: 'lg', permission: 'stock.view' },
{ id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md', permission: 'stock.view' }, { id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md', permission: 'stock.view' },
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm', permission: 'orders.view' }, { id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm', permission: 'orders.view' },
@@ -63,7 +62,7 @@ const WIDGET_META: WidgetMeta[] = [
const DEFAULT_ACTIVE = [ const DEFAULT_ACTIVE = [
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus', 'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus', 'deliveryTasks', 'outstandingPay', 'invoiceStatus',
'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart', 'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart',
] ]
@@ -153,7 +152,6 @@ interface DashboardData {
confirmedSheetsCount: number confirmedSheetsCount: number
pendingTasksCount: number pendingTasksCount: number
partialTasksCount: number partialTasksCount: number
lowStock: LowStockItemResponse[]
movements: StockMovementResponse[] movements: StockMovementResponse[]
warehouseBalances: WarehouseBalanceRow[] warehouseBalances: WarehouseBalanceRow[]
outstandingAmount: number outstandingAmount: number
@@ -173,7 +171,7 @@ function emptyDashboardData(): DashboardData {
openRides: [], openRides: [],
plannedSheetsCount: 0, confirmedSheetsCount: 0, plannedSheetsCount: 0, confirmedSheetsCount: 0,
pendingTasksCount: 0, partialTasksCount: 0, pendingTasksCount: 0, partialTasksCount: 0,
lowStock: [], movements: [], warehouseBalances: [], movements: [], warehouseBalances: [],
outstandingAmount: 0, outstandingInvoiceCount: 0, outstandingAmount: 0, outstandingInvoiceCount: 0,
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0, draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
poStatusChart: [], paymentStatusChart: [], movementTypeChart: [], monthlySales: [], poStatusChart: [], paymentStatusChart: [], movementTypeChart: [], monthlySales: [],
@@ -236,14 +234,6 @@ async function loadDashboardData(activeIds: Set<string>): Promise<DashboardData>
) )
} }
if (activeIds.has('lowStock')) {
fetches.push(
stock.lowStock()
.then(res => { data.lowStock = getPaginatedItems(res).slice(0, 5) })
.catch(() => {})
)
}
if (activeIds.has('stockMovements')) { if (activeIds.has('stockMovements')) {
fetches.push( fetches.push(
stock.movements({ pageSize: 5 }) stock.movements({ pageSize: 5 })
@@ -400,24 +390,6 @@ function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
<div className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div> <div className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div>
</div> </div>
) )
case 'lowStock':
return data.lowStock.length === 0 ? (
<p className="text-xs text-muted-foreground">No items below their reorder level.</p>
) : (
<div>
<div className="grid grid-cols-[2fr_1.4fr_0.8fr_0.8fr] gap-2 border-b pb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
<span>Item</span><span>Warehouse</span><span>On hand</span><span>Reorder</span>
</div>
{data.lowStock.map(row => (
<div key={`${row.itemId}-${row.locationId}`} className="grid grid-cols-[2fr_1.4fr_0.8fr_0.8fr] gap-2 border-b border-muted py-2.5 text-sm last:border-0">
<span className="text-foreground">{row.itemName}</span>
<span className="text-muted-foreground">{row.locationName}</span>
<span className="font-semibold text-destructive">{row.quantity}</span>
<span className="text-muted-foreground">{row.reorderLevel}</span>
</div>
))}
</div>
)
case 'stockMovements': case 'stockMovements':
return data.movements.length === 0 ? ( return data.movements.length === 0 ? (
<p className="text-xs text-muted-foreground">No recent stock movements.</p> <p className="text-xs text-muted-foreground">No recent stock movements.</p>
@@ -0,0 +1,123 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { Label, Pie, PieChart, Sector } from 'recharts'
import type { PieSectorShapeProps } from 'recharts/types/polar/Pie'
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
const SLICE_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']
const MAX_SLICES = 5
const fmtCompact = (v: number) => v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 })
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { maximumFractionDigits: 0 })}`
/** Top (n-1) regions plus an "Other" slice, so the pie and the picker stay readable. */
function topWithOther(rows: Array<{ name: string; amount: number }>) {
const sorted = [...rows].filter(r => r.amount > 0).sort((a, b) => b.amount - a.amount)
if (sorted.length <= MAX_SLICES) return sorted
const head = sorted.slice(0, MAX_SLICES - 1)
return [...head, { name: 'Other', amount: sorted.slice(MAX_SLICES - 1).reduce((t, r) => t + r.amount, 0) }]
}
/**
* Interactive pie of gross sales by region: pick a region (or it defaults to the largest) to pull its slice
* out and show its amount and share in the centre.
*/
export function RegionSalesPie({ rows, className }: { rows: Array<{ name: string; amount: number }>; className?: string }) {
const slices = useMemo(
() => topWithOther(rows).map((r, i) => ({ key: `r${i}`, name: r.name, amount: r.amount, fill: `var(--color-r${i})` })),
[rows],
)
const config = useMemo(
() => Object.fromEntries(slices.map((s, i) => [s.key, { label: s.name, color: SLICE_COLORS[i % SLICE_COLORS.length] }])) as ChartConfig,
[slices],
)
const total = slices.reduce((t, s) => t + s.amount, 0)
const [selected, setSelected] = useState<string | null>(null)
// Fall back to the largest region when nothing is picked or the picked region left the data (range changed).
const activeKey = slices.some(s => s.key === selected) ? selected : slices[0]?.key
const activeIndex = slices.findIndex(s => s.key === activeKey)
const active = slices[activeIndex]
const renderShape = useCallback(
({ index, outerRadius = 0, ...props }: PieSectorShapeProps) =>
index === activeIndex ? (
<g>
<Sector {...props} outerRadius={outerRadius + 10} />
<Sector {...props} outerRadius={outerRadius + 25} innerRadius={outerRadius + 12} />
</g>
) : (
<Sector {...props} outerRadius={outerRadius} />
),
[activeIndex],
)
return (
<Card className={className}>
<CardHeader>
<CardTitle>Sales by Region</CardTitle>
<CardDescription>Share of gross sales</CardDescription>
{active && (
<CardAction>
<Select value={activeKey} onValueChange={v => v && setSelected(v)}>
<SelectTrigger size="sm" className="w-36" aria-label="Select a region">
<SelectValue placeholder="Select region" />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{slices.map((s, i) => (
<SelectItem key={s.key} value={s.key} label={s.name}>
<span className="flex items-center gap-2 text-xs">
<span className="size-3 shrink-0 rounded-xs" style={{ backgroundColor: SLICE_COLORS[i % SLICE_COLORS.length] }} />
{s.name}
</span>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</CardAction>
)}
</CardHeader>
<CardContent className="flex flex-1 justify-center">
{!active ? (
<p className="flex h-52 items-center justify-center text-xs text-muted-foreground">No sales in this period.</p>
) : (
<ChartContainer config={config} className="mx-auto aspect-square w-full max-w-[260px]">
<PieChart>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel nameKey="key" formatter={(value, _name, item) => (
<div className="flex w-full items-center justify-between gap-4">
<span className="text-muted-foreground">{(item.payload as { name: string }).name}</span>
<span className="font-mono font-medium text-foreground tabular-nums">{fmtMoney(Number(value))}</span>
</div>
)} />}
/>
<Pie data={slices} dataKey="amount" nameKey="key" innerRadius={60} strokeWidth={5} shape={renderShape}>
<Label
content={({ viewBox }) => {
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) return null
const cy = viewBox.cy ?? 0
return (
<text x={viewBox.cx} y={cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={viewBox.cx} y={cy - 6} className="fill-foreground text-xl font-bold">{fmtCompact(active.amount)}</tspan>
<tspan x={viewBox.cx} y={cy + 12} className="fill-muted-foreground text-[11px]">
{total > 0 ? `${((active.amount / total) * 100).toFixed(1)}%` : ''}
</tspan>
</text>
)
}}
/>
</Pie>
</PieChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,133 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from '@/components/ui/toast'
import { dashboardReports, getApiError } from '@/lib/api/index'
import type { DashboardSalesSummary } from '@/lib/api/reports'
const MAX_DAYS = 90
const RANGES = [
{ value: '90', label: 'Last 3 months' },
{ value: '30', label: 'Last 30 days' },
{ value: '7', label: 'Last 7 days' },
]
const chartConfig = {
sales: { label: 'Gross sales', color: 'var(--chart-2)' },
} satisfies ChartConfig
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
const parseIso = (v: string) => new Date(`${v}T00:00:00`)
const fmtCompact = (v: number) => v.toLocaleString('en-LK', { notation: 'compact', maximumFractionDigits: 1 })
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { maximumFractionDigits: 0 })}`
const fmtTick = (v: string) => parseIso(v).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
const fmtLabel = (v: string) => parseIso(v).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
interface Loaded {
end: string // yyyy-MM-dd of the last day covered
trend: DashboardSalesSummary['trend']
}
/**
* Interactive area chart of daily gross sales. The last 90 days are loaded once and the
* time-range selector narrows them client-side. Days without activity are plotted as 0 so every day shows.
*/
export function SalesTrendInteractive({ className }: { className?: string }) {
const [range, setRange] = useState('90')
const [loaded, setLoaded] = useState<Loaded | null | undefined>(undefined)
useEffect(() => {
let cancelled = false
const today = new Date()
dashboardReports
.sales({ from: toIso(addDays(today, -(MAX_DAYS - 1))), to: toIso(today) })
.then(res => { if (!cancelled) setLoaded({ end: toIso(today), trend: res.trend }) })
.catch(err => {
if (cancelled) return
toast.add({ title: 'Failed to load sales trend', description: getApiError(err), type: 'error' })
setLoaded(null)
})
return () => { cancelled = true }
}, [])
const days = Number(range)
const data = useMemo(() => {
if (!loaded) return []
const byDay = new Map(loaded.trend.map(t => [t.period, t]))
const end = parseIso(loaded.end)
return Array.from({ length: days }, (_, i) => {
const date = toIso(addDays(end, i - (days - 1)))
const point = byDay.get(date)
return { date, sales: point?.sales ?? 0 }
})
}, [loaded, days])
const total = data.reduce((sum, d) => sum + d.sales, 0)
const rangeLabel = RANGES.find(r => r.value === range)?.label.toLowerCase() ?? ''
return (
<Card className={className}>
<CardHeader>
<CardTitle>Sales Trend</CardTitle>
<CardDescription>
{loaded ? `${fmtMoney(total)} gross sales in the ${rangeLabel}` : 'Gross sales per day'}
</CardDescription>
<CardAction>
<Select value={range} onValueChange={v => v && setRange(v)}>
<SelectTrigger size="sm" className="w-40" aria-label="Select time range">
<SelectValue placeholder="Last 3 months" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{RANGES.map(r => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent>
{loaded === undefined ? (
<Skeleton className="h-[250px] w-full rounded-xl" />
) : loaded === null || data.every(d => d.sales === 0) ? (
<p className="flex h-[250px] items-center justify-center text-xs text-muted-foreground">
{loaded === null ? 'Sales trend is unavailable.' : `No sales in the ${rangeLabel}.`}
</p>
) : (
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
<AreaChart data={data}>
<defs>
<linearGradient id="fillSales" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-sales)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--color-sales)" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} tickFormatter={fmtTick} />
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={44} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" labelFormatter={v => fmtLabel(String(v))} />}
/>
<Area dataKey="sales" type="natural" fill="url(#fillSales)" stroke="var(--color-sales)" />
</AreaChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
}