Files
inventory/inventory/src/components/overview/SalesMetrics.tsx
T

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>
</>
)
}