Fix dashoard issues #85
@@ -7,6 +7,8 @@ import { toast } from '@/components/ui/toast'
|
||||
import { dashboardReports, productReports, stockReports, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { DashboardInventorySummary, DashboardSalesSummary, ProductSalesRow, SalesKpis, StockOverview } from '@/lib/api/reports'
|
||||
import { DonutChart, MultiAreaChart, seriesColor } from './dashboard-charts'
|
||||
import { RegionSalesPie } from './region-sales-pie'
|
||||
import { SalesTrendInteractive } from './sales-trend-interactive'
|
||||
|
||||
export interface DateRange {
|
||||
from: string // yyyy-MM-dd
|
||||
@@ -60,8 +62,8 @@ function Kpi({ title, value, footer }: { title: string; value: string; footer?:
|
||||
function LoadingGrid() {
|
||||
return (
|
||||
<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">
|
||||
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
|
||||
{Array.from({ length: 7 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
@@ -175,6 +177,7 @@ export function SalesSection({ range }: { range: DateRange }) {
|
||||
if (loading || productsLoading || !sales) return <LoadingGrid />
|
||||
|
||||
const { current: c, previous: p } = sales.kpis
|
||||
const { current: cash, previous: cashPrev } = sales.cash
|
||||
const kpi = (title: string, key: keyof SalesKpis, money = true) => (
|
||||
<Kpi
|
||||
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 regionTotal = sales.byRegion.reduce((t, r) => t + r.amount, 0)
|
||||
const repTotal = sales.byRep.reduce((t, r) => t + r.amount, 0)
|
||||
|
||||
return (
|
||||
<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('Gross Sales', 'grossSales')}
|
||||
{kpi('Total Orders', 'orders', false)}
|
||||
@@ -204,16 +206,40 @@ export function SalesSection({ range }: { range: DateRange }) {
|
||||
}
|
||||
/>
|
||||
{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>
|
||||
<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.
|
||||
Gross profit uses item cost prices and covers only items that have one. Discounts and sales targets are not tracked per order yet.
|
||||
Sales are recognised when an invoice is finalized (billable invoice lines on non-cancelled orders), regardless of payment status or method.
|
||||
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>
|
||||
|
||||
<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">
|
||||
<MultiAreaChart data={trend} series={[{ key: 'sales', label: 'Sales', color: seriesColor(0) }]} emptyText="No sales in this period." />
|
||||
</ChartCard>
|
||||
<SalesTrendInteractive className="md:col-span-2" />
|
||||
<ChartCard title="Sales by Category" note="Share of gross sales">
|
||||
<DonutChart data={topWithOther(named(sales.byCategory))} centerLabel="Rs." emptyText="No sales in this period." />
|
||||
</ChartCard>
|
||||
@@ -232,9 +258,7 @@ export function SalesSection({ range }: { range: DateRange }) {
|
||||
])}
|
||||
/>
|
||||
</ChartCard>
|
||||
<ChartCard title="Sales by Region">
|
||||
<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>
|
||||
<RegionSalesPie rows={sales.byRegion} />
|
||||
|
||||
<ChartCard title="Sales vs Returns" note={`Per ${sales.granularity}`} className="md:col-span-2">
|
||||
<MultiAreaChart
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
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 { ColumnBarChart, DonutChart, HorizontalBarChart, RadialGauge, SalesTrendChart } 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: '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: '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: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md', permission: 'stock.view' },
|
||||
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm', permission: 'orders.view' },
|
||||
@@ -63,7 +62,7 @@ const WIDGET_META: WidgetMeta[] = [
|
||||
|
||||
const DEFAULT_ACTIVE = [
|
||||
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
|
||||
'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus',
|
||||
'deliveryTasks', 'outstandingPay', 'invoiceStatus',
|
||||
'salesTrendChart', 'poStatusChart', 'paymentStatusChart', 'warehouseChart',
|
||||
]
|
||||
|
||||
@@ -153,7 +152,6 @@ interface DashboardData {
|
||||
confirmedSheetsCount: number
|
||||
pendingTasksCount: number
|
||||
partialTasksCount: number
|
||||
lowStock: LowStockItemResponse[]
|
||||
movements: StockMovementResponse[]
|
||||
warehouseBalances: WarehouseBalanceRow[]
|
||||
outstandingAmount: number
|
||||
@@ -173,7 +171,7 @@ function emptyDashboardData(): DashboardData {
|
||||
openRides: [],
|
||||
plannedSheetsCount: 0, confirmedSheetsCount: 0,
|
||||
pendingTasksCount: 0, partialTasksCount: 0,
|
||||
lowStock: [], movements: [], warehouseBalances: [],
|
||||
movements: [], warehouseBalances: [],
|
||||
outstandingAmount: 0, outstandingInvoiceCount: 0,
|
||||
draftInvoiceCount: 0, printedInvoiceCount: 0, finalizedInvoiceCount: 0,
|
||||
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')) {
|
||||
fetches.push(
|
||||
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>
|
||||
)
|
||||
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':
|
||||
return data.movements.length === 0 ? (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user