229 lines
9.0 KiB
TypeScript
229 lines
9.0 KiB
TypeScript
import { useQuery } from "@tanstack/react-query"
|
|
import { apiFetch } from '@/utils/api';
|
|
import { CardContent } from "@/components/ui/card"
|
|
import { SectionHeader } from "@/components/shared"
|
|
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip } from "recharts"
|
|
import { useState } from "react"
|
|
import config from "@/config"
|
|
import { formatCurrency } from "@/utils/formatCurrency"
|
|
import { ClipboardList, Package, DollarSign, ShoppingCart } from "lucide-react"
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
|
import { HorizonTabs } from "@/components/dashboard/shared/HorizonTabs"
|
|
import { addDays, format } from "date-fns"
|
|
import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases"
|
|
|
|
type Period = 7 | 30 | 90;
|
|
|
|
interface PhaseBreakdown {
|
|
phase: string
|
|
orders: number
|
|
units: number
|
|
revenue: number
|
|
cogs: number
|
|
percentage: number
|
|
}
|
|
|
|
interface DailyPhaseData {
|
|
date: string
|
|
preorder: number
|
|
launch: number
|
|
decay: number
|
|
mature: number
|
|
slow_mover: number
|
|
dormant: number
|
|
unknown: number
|
|
}
|
|
|
|
interface SalesData {
|
|
totalOrders: number
|
|
totalUnitsSold: number
|
|
totalCogs: number
|
|
totalRevenue: number
|
|
dailySales: {
|
|
date: string
|
|
units: number
|
|
revenue: number
|
|
cogs: number
|
|
}[]
|
|
dailySalesByPhase?: DailyPhaseData[]
|
|
phaseBreakdown?: PhaseBreakdown[]
|
|
}
|
|
|
|
function MetricSkeleton() {
|
|
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
|
|
}
|
|
|
|
export function SalesMetrics() {
|
|
const [period, setPeriod] = useState<Period>(30);
|
|
|
|
const { data, isError, isLoading } = useQuery<SalesData>({
|
|
queryKey: ["sales-metrics", period],
|
|
queryFn: async () => {
|
|
const params = new URLSearchParams({
|
|
startDate: addDays(new Date(), -period).toISOString(),
|
|
endDate: new Date().toISOString(),
|
|
});
|
|
const response = await apiFetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`)
|
|
if (!response.ok) throw new Error("Failed to fetch sales metrics");
|
|
return response.json()
|
|
},
|
|
})
|
|
|
|
const hasPhaseData = data?.dailySalesByPhase && data.dailySalesByPhase.length > 0
|
|
|
|
return (
|
|
<>
|
|
<SectionHeader
|
|
title="Sales"
|
|
size="large"
|
|
timeSelector={
|
|
<HorizonTabs value={period} onChange={(d) => setPeriod(d as Period)} options={[7, 30, 90]} />
|
|
}
|
|
/>
|
|
<CardContent className="pt-3 pb-0 -mb-2">
|
|
{isError ? (
|
|
<p className="text-sm text-destructive">Failed to load sales metrics</p>
|
|
) : (
|
|
<>
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex items-baseline justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
|
<p className="text-sm font-medium text-muted-foreground">Total Orders</p>
|
|
</div>
|
|
{isLoading || !data ? <MetricSkeleton /> : (
|
|
<p className="text-lg font-bold">{data.totalOrders.toLocaleString()}</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-baseline justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Package className="h-4 w-4 text-muted-foreground" />
|
|
<p className="text-sm font-medium text-muted-foreground">Units Sold</p>
|
|
</div>
|
|
{isLoading || !data ? <MetricSkeleton /> : (
|
|
<p className="text-lg font-bold">{data.totalUnitsSold.toLocaleString()}</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-baseline justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
|
<p className="text-sm font-medium text-muted-foreground">Cost of Goods</p>
|
|
</div>
|
|
{isLoading || !data ? <MetricSkeleton /> : (
|
|
<p className="text-lg font-bold">{formatCurrency(Number(data.totalCogs))}</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-baseline justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
|
<p className="text-sm font-medium text-muted-foreground">Revenue</p>
|
|
</div>
|
|
{isLoading || !data ? <MetricSkeleton /> : (
|
|
<p className="text-lg font-bold">{formatCurrency(Number(data.totalRevenue))}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{data?.phaseBreakdown && data.phaseBreakdown.length > 0 && (
|
|
<div className="mt-4 space-y-1.5">
|
|
<p className="text-xs font-medium text-muted-foreground">Revenue By Lifecycle Phase</p>
|
|
<TooltipProvider delayDuration={0}>
|
|
<div className="flex h-2.5 w-full overflow-hidden rounded-full">
|
|
{data.phaseBreakdown.map((p) => {
|
|
const cfg = PHASE_CONFIG[p.phase] || { label: p.phase, color: "#94A3B8" }
|
|
return (
|
|
<Tooltip key={p.phase}>
|
|
<TooltipTrigger asChild>
|
|
<div
|
|
className="h-full transition-all"
|
|
style={{
|
|
width: `${p.percentage}%`,
|
|
backgroundColor: cfg.color,
|
|
minWidth: p.percentage > 0 ? 3 : 0,
|
|
}}
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
<div className="flex items-center gap-1.5 font-medium">
|
|
<div className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: cfg.color }} />
|
|
{cfg.label}
|
|
<span className="font-normal opacity-70">{p.percentage}%</span>
|
|
</div>
|
|
<div className="mt-0.5 font-semibold">{formatCurrency(p.revenue)}</div>
|
|
<div className="opacity-70">{p.units.toLocaleString()} units · {p.orders.toLocaleString()} orders</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)
|
|
})}
|
|
</div>
|
|
</TooltipProvider>
|
|
</div>
|
|
)}
|
|
|
|
<div className="h-[250px] w-full">
|
|
{isLoading ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<div className="h-[200px] w-full animate-pulse rounded bg-muted" />
|
|
</div>
|
|
) : (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart
|
|
data={hasPhaseData ? data.dailySalesByPhase : (data?.dailySales || [])}
|
|
margin={{ top: 30, right: 0, left: -60, bottom: 0 }}
|
|
>
|
|
<XAxis
|
|
dataKey="date"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tick={false}
|
|
/>
|
|
<YAxis
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tick={false}
|
|
/>
|
|
<RechartsTooltip
|
|
formatter={(value: number, name: string) => {
|
|
const cfg = PHASE_CONFIG[name]
|
|
return [formatCurrency(value), cfg?.label || name]
|
|
}}
|
|
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
|
|
itemSorter={(item) => -(item.value as number || 0)}
|
|
/>
|
|
{hasPhaseData ? (
|
|
PHASE_KEYS.map((phase) => {
|
|
const cfg = PHASE_CONFIG[phase]
|
|
return (
|
|
<Area
|
|
key={phase}
|
|
type="monotone"
|
|
dataKey={phase}
|
|
name={phase}
|
|
stackId="a"
|
|
stroke={cfg.color}
|
|
fill={cfg.color}
|
|
fillOpacity={0.6}
|
|
/>
|
|
)
|
|
})
|
|
) : (
|
|
<Area
|
|
type="monotone"
|
|
dataKey="revenue"
|
|
name="Revenue"
|
|
stroke="#00C49F"
|
|
fill="#00C49F"
|
|
fillOpacity={0.2}
|
|
/>
|
|
)}
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</>
|
|
)
|
|
}
|