Add in forecasting, lifecycle phases, associated component and script changes
This commit is contained in:
@@ -79,7 +79,7 @@ export function BestSellers() {
|
||||
) : (
|
||||
<>
|
||||
<TabsContent value="products">
|
||||
<ScrollArea className="h-[385px] w-full">
|
||||
<ScrollArea className="h-[420px] w-full">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { BarChart, Bar, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip, Cell, LineChart, Line } from "recharts"
|
||||
import config from "@/config"
|
||||
import { Target, TrendingDown, ArrowUpDown } from "lucide-react"
|
||||
import { Tooltip as UITooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { PHASE_CONFIG } from "@/utils/lifecyclePhases"
|
||||
|
||||
interface OverallMetrics {
|
||||
sampleSize: number
|
||||
totalActual: number
|
||||
totalForecast: number
|
||||
mae: number | null
|
||||
wmape: number | null
|
||||
bias: number | null
|
||||
rmse: number | null
|
||||
}
|
||||
|
||||
interface PhaseAccuracy {
|
||||
phase: string
|
||||
sampleSize: number
|
||||
totalActual: number
|
||||
totalForecast: number
|
||||
mae: number | null
|
||||
wmape: number | null
|
||||
bias: number | null
|
||||
rmse: number | null
|
||||
}
|
||||
|
||||
interface LeadTimeAccuracy {
|
||||
bucket: string
|
||||
sampleSize: number
|
||||
mae: number | null
|
||||
wmape: number | null
|
||||
bias: number | null
|
||||
rmse: number | null
|
||||
}
|
||||
|
||||
interface AccuracyTrendPoint {
|
||||
date: string
|
||||
mae: number | null
|
||||
wmape: number | null
|
||||
bias: number | null
|
||||
sampleSize: number
|
||||
}
|
||||
|
||||
interface AccuracyData {
|
||||
hasData: boolean
|
||||
message?: string
|
||||
computedAt?: string
|
||||
daysOfHistory?: number
|
||||
historyRange?: { from: string; to: string }
|
||||
overall?: OverallMetrics
|
||||
byPhase?: PhaseAccuracy[]
|
||||
byLeadTime?: LeadTimeAccuracy[]
|
||||
byMethod?: { method: string; sampleSize: number; mae: number | null; wmape: number | null; bias: number | null }[]
|
||||
dailyTrend?: { date: string; mae: number | null; wmape: number | null; bias: number | null }[]
|
||||
accuracyTrend?: AccuracyTrendPoint[]
|
||||
}
|
||||
|
||||
function MetricSkeleton() {
|
||||
return <div className="h-7 w-16 animate-pulse rounded bg-muted" />;
|
||||
}
|
||||
|
||||
function formatWmape(wmape: number | null): string {
|
||||
if (wmape === null) return "N/A"
|
||||
return `${wmape.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatBias(bias: number | null): string {
|
||||
if (bias === null) return "N/A"
|
||||
const sign = bias > 0 ? "+" : ""
|
||||
return `${sign}${bias.toFixed(3)}`
|
||||
}
|
||||
|
||||
function getAccuracyColor(wmape: number | null): string {
|
||||
if (wmape === null) return "text-muted-foreground"
|
||||
if (wmape <= 30) return "text-green-600"
|
||||
if (wmape <= 50) return "text-yellow-600"
|
||||
return "text-red-600"
|
||||
}
|
||||
|
||||
export function ForecastAccuracy() {
|
||||
const { data, error, isLoading } = useQuery<AccuracyData>({
|
||||
queryKey: ["forecast-accuracy"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/forecast/accuracy`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch forecast accuracy")
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
refetchInterval: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-3">Forecast Accuracy</h3>
|
||||
<p className="text-sm text-destructive">Failed to load accuracy data</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isLoading && data && !data.hasData) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-3">Forecast Accuracy</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Accuracy data will be available after the forecast engine has run for at least 2 days,
|
||||
building up historical comparisons between predictions and actual sales.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const phaseChartData = (data?.byPhase || [])
|
||||
.filter(p => p.wmape !== null && p.phase !== 'dormant')
|
||||
.map(p => ({
|
||||
phase: PHASE_CONFIG[p.phase]?.label || p.phase,
|
||||
rawPhase: p.phase,
|
||||
wmape: p.wmape,
|
||||
mae: p.mae,
|
||||
bias: p.bias,
|
||||
sampleSize: p.sampleSize,
|
||||
}))
|
||||
.sort((a, b) => (a.wmape ?? 100) - (b.wmape ?? 100))
|
||||
|
||||
const leadTimeData = (data?.byLeadTime || []).map(lt => ({
|
||||
bucket: lt.bucket,
|
||||
wmape: lt.wmape,
|
||||
mae: lt.mae,
|
||||
sampleSize: lt.sampleSize,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-3">Forecast Accuracy</h3>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<MetricSkeleton />
|
||||
<MetricSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Headline metrics */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">WMAPE</p>
|
||||
</div>
|
||||
<p className={`text-lg font-bold ${getAccuracyColor(data?.overall?.wmape ?? null)}`}>
|
||||
{formatWmape(data?.overall?.wmape ?? null)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingDown className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">MAE</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">
|
||||
{data?.overall?.mae !== null ? data?.overall?.mae?.toFixed(2) : "N/A"}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-1">units</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Bias</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">
|
||||
{formatBias(data?.overall?.bias ?? null)}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-1">
|
||||
{(data?.overall?.bias ?? 0) > 0 ? "over" : (data?.overall?.bias ?? 0) < 0 ? "under" : ""}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase accuracy bar */}
|
||||
{phaseChartData.length > 0 && (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">WMAPE by Lifecycle Phase</p>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="space-y-1">
|
||||
{phaseChartData.map((p) => {
|
||||
const cfg = PHASE_CONFIG[p.rawPhase] || { label: p.phase, color: "#94A3B8" }
|
||||
const maxWmape = Math.max(...phaseChartData.map(d => d.wmape ?? 0), 1)
|
||||
const barWidth = ((p.wmape ?? 0) / maxWmape) * 100
|
||||
return (
|
||||
<UITooltip key={p.rawPhase}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground w-16 text-right shrink-0">{cfg.label}</span>
|
||||
<div className="flex-1 h-3 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${barWidth}%`,
|
||||
backgroundColor: cfg.color,
|
||||
minWidth: barWidth > 0 ? 4 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-medium w-10 text-right shrink-0">
|
||||
{formatWmape(p.wmape)}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<div className="font-medium">{cfg.label}</div>
|
||||
<div>WMAPE: {formatWmape(p.wmape)}</div>
|
||||
<div>MAE: {p.mae?.toFixed(3) ?? "N/A"} units</div>
|
||||
<div>Bias: {formatBias(p.bias)}</div>
|
||||
<div className="text-muted-foreground">{p.sampleSize.toLocaleString()} samples</div>
|
||||
</TooltipContent>
|
||||
</UITooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lead time accuracy chart */}
|
||||
{leadTimeData.length > 0 && (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Accuracy by Lead Time</p>
|
||||
<div className="h-[120px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={leadTimeData} margin={{ top: 5, right: 0, left: -30, bottom: 0 }}>
|
||||
<XAxis
|
||||
dataKey="bucket"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 10 }}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
formatter={(value: number) => [`${value?.toFixed(1)}%`, "WMAPE"]}
|
||||
/>
|
||||
<Bar dataKey="wmape" radius={[4, 4, 0, 0]}>
|
||||
{leadTimeData.map((entry, index) => (
|
||||
<Cell
|
||||
key={index}
|
||||
fill={(entry.wmape ?? 0) <= 30 ? "#22C55E" : (entry.wmape ?? 0) <= 50 ? "#F59E0B" : "#EF4444"}
|
||||
fillOpacity={0.7}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accuracy trend sparkline */}
|
||||
{data?.accuracyTrend && data.accuracyTrend.length > 1 && (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Accuracy Trend (WMAPE)</p>
|
||||
<div className="h-[60px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data.accuracyTrend} margin={{ top: 5, right: 0, left: -60, bottom: 0 }}>
|
||||
<YAxis tickLine={false} axisLine={false} tick={false} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="wmape"
|
||||
stroke="#8884D8"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer info */}
|
||||
{data?.daysOfHistory !== undefined && (
|
||||
<p className="text-[10px] text-muted-foreground mt-3 mb-2">
|
||||
Based on {data.daysOfHistory} day{data.daysOfHistory !== 1 ? "s" : ""} of history
|
||||
{data.overall?.sampleSize ? ` (${data.overall.sampleSize.toLocaleString()} samples)` : ""}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,46 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts"
|
||||
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 { TrendingUp, DollarSign } from "lucide-react"
|
||||
import { DateRange } from "react-day-picker"
|
||||
import { TrendingUp, DollarSign, Target } from "lucide-react"
|
||||
import { Tooltip as UITooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ForecastAccuracy } from "@/components/overview/ForecastAccuracy"
|
||||
import { addDays, format } from "date-fns"
|
||||
import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
|
||||
import { PHASE_CONFIG, PHASE_KEYS } from "@/utils/lifecyclePhases"
|
||||
|
||||
function MetricSkeleton() {
|
||||
return <div className="h-7 w-20 animate-pulse rounded bg-muted" />;
|
||||
}
|
||||
|
||||
type Period = 30 | 90 | 'year';
|
||||
|
||||
function getEndDate(period: Period): Date {
|
||||
if (period === 'year') return new Date(new Date().getFullYear(), 11, 31);
|
||||
return addDays(new Date(), period);
|
||||
}
|
||||
|
||||
interface PhaseData {
|
||||
phase: string
|
||||
products: number
|
||||
units: number
|
||||
revenue: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
interface DailyPhaseData {
|
||||
date: string
|
||||
preorder: number
|
||||
launch: number
|
||||
decay: number
|
||||
mature: number
|
||||
slow_mover: number
|
||||
dormant: number
|
||||
}
|
||||
|
||||
interface ForecastData {
|
||||
forecastSales: number
|
||||
@@ -19,6 +52,8 @@ interface ForecastData {
|
||||
revenue: string
|
||||
confidence: number
|
||||
}[]
|
||||
dailyForecastsByPhase?: DailyPhaseData[]
|
||||
phaseBreakdown?: PhaseData[]
|
||||
categoryForecasts: {
|
||||
category: string
|
||||
units: number
|
||||
@@ -28,17 +63,14 @@ interface ForecastData {
|
||||
}
|
||||
|
||||
export function ForecastMetrics() {
|
||||
const [dateRange, setDateRange] = useState<DateRange>({
|
||||
from: new Date(),
|
||||
to: addDays(new Date(), 30),
|
||||
});
|
||||
const [period, setPeriod] = useState<Period>(30);
|
||||
|
||||
const { data, error, isLoading } = useQuery<ForecastData>({
|
||||
queryKey: ["forecast-metrics", dateRange],
|
||||
queryKey: ["forecast-metrics", period],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
startDate: dateRange.from?.toISOString() || "",
|
||||
endDate: dateRange.to?.toISOString() || "",
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: getEndDate(period).toISOString(),
|
||||
});
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics?${params}`)
|
||||
if (!response.ok) {
|
||||
@@ -50,25 +82,35 @@ export function ForecastMetrics() {
|
||||
},
|
||||
})
|
||||
|
||||
const hasPhaseData = data?.dailyForecastsByPhase && data.dailyForecastsByPhase.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="flex flex-row items-center justify-between pr-5">
|
||||
<CardTitle className="text-xl font-medium">Forecast</CardTitle>
|
||||
<div className="w-[230px]">
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={(range) => {
|
||||
if (range) setDateRange(range);
|
||||
}}
|
||||
future={true}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Target className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[400px]">
|
||||
<ForecastAccuracy />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Tabs value={String(period)} onValueChange={(v) => setPeriod(v === 'year' ? 'year' : Number(v) as Period)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="30">30D</TabsTrigger>
|
||||
<TabsTrigger value="90">90D</TabsTrigger>
|
||||
<TabsTrigger value="year">EOY</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="py-0 -mb-2">
|
||||
{error ? (
|
||||
<div className="text-sm text-red-500">Error: {error.message}</div>
|
||||
) : isLoading ? (
|
||||
<div className="text-sm">Loading forecast metrics...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -77,52 +119,125 @@ export function ForecastMetrics() {
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Forecast Sales</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{data?.forecastSales.toLocaleString() || 0}</p>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.forecastSales.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">Forecast Revenue</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">{formatCurrency(Number(data?.forecastRevenue) || 0)}</p>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(Number(data.forecastRevenue) || 0)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Forecast Revenue By Lifecycle Phase</p>
|
||||
<div className="h-2.5 w-full animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
) : data?.phaseBreakdown && data.phaseBreakdown.length > 0 && (
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Forecast 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 (
|
||||
<UITooltip key={p.phase}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: `${p.percentage}%`,
|
||||
backgroundColor: cfg.color,
|
||||
minWidth: p.percentage > 0 ? 4 : 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.products.toLocaleString()} products</div>
|
||||
</TooltipContent>
|
||||
</UITooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-[250px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={data?.dailyForecasts || []}
|
||||
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}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]}
|
||||
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#8884D8"
|
||||
fill="#8884D8"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
{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.dailyForecastsByPhase : (data?.dailyForecasts || [])}
|
||||
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 + 'T00:00:00'), '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="#8884D8"
|
||||
fill="#8884D8"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,18 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react"
|
||||
import { AlertTriangle, Layers, DollarSign, Tag } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { PHASE_CONFIG } from "@/utils/lifecyclePhases"
|
||||
|
||||
interface PhaseBreakdown {
|
||||
phase: string
|
||||
products: number
|
||||
units: number
|
||||
cost: number
|
||||
retail: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
interface OverstockMetricsData {
|
||||
overstockedProducts: number
|
||||
@@ -16,6 +27,7 @@ interface OverstockMetricsData {
|
||||
cost: number
|
||||
retail: number
|
||||
}[]
|
||||
phaseBreakdown?: PhaseBreakdown[]
|
||||
}
|
||||
|
||||
function MetricSkeleton() {
|
||||
@@ -44,7 +56,7 @@ export function OverstockMetrics() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Products</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
@@ -71,13 +83,48 @@ export function OverstockMetrics() {
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overstocked Retail</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.total_excess_retail)}</p>
|
||||
)}
|
||||
</div>
|
||||
{data?.phaseBreakdown && data.phaseBreakdown.length > 0 && (
|
||||
<div className="mt-1 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Overstocked Cost 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.cost)}</div>
|
||||
<div className="opacity-70">{p.products} products · {p.units} units</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react"
|
||||
import { ClipboardList, AlertCircle, Truck, DollarSign, Tag } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
interface PurchaseMetricsData {
|
||||
@@ -90,49 +90,49 @@ export function PurchaseMetrics() {
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load purchase metrics</p>
|
||||
) : (
|
||||
<div className="flex justify-between gap-8">
|
||||
<div className="flex-1">
|
||||
<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">Active Purchase Orders</p>
|
||||
<div className="flex gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">Active Purchase Orders</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.activePurchaseOrders.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Overdue Purchase Orders</p>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">Overdue Purchase Orders</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.overduePurchaseOrders.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">On Order Units</p>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Truck className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">On Order Units</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.onOrderUnits.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">On Order Cost</p>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">On Order Cost</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.onOrderCost)}</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">On Order Retail</p>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Tag className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">On Order Retail</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.onOrderRetail)}</p>
|
||||
@@ -140,9 +140,9 @@ export function PurchaseMetrics() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-md flex justify-center font-medium">Purchase Orders By Vendor</div>
|
||||
<div className="text-md flex justify-center font-medium">PO Costs By Vendor</div>
|
||||
<div className="h-[180px]">
|
||||
{isLoading || !data ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
||||
@@ -2,13 +2,24 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { Package, DollarSign, ShoppingCart } from "lucide-react"
|
||||
import { PackagePlus, DollarSign, Tag } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { PHASE_CONFIG } from "@/utils/lifecyclePhases"
|
||||
|
||||
interface PhaseBreakdown {
|
||||
phase: string
|
||||
products: number
|
||||
units: number
|
||||
cost: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
interface ReplenishmentMetricsData {
|
||||
productsToReplenish: number
|
||||
unitsToReplenish: number
|
||||
replenishmentCost: number
|
||||
replenishmentRetail: number
|
||||
phaseBreakdown?: PhaseBreakdown[]
|
||||
topVariants: {
|
||||
id: number
|
||||
title: string
|
||||
@@ -47,7 +58,7 @@ export function ReplenishmentMetrics() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<PackagePlus className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Units to Replenish</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
@@ -65,13 +76,48 @@ export function ReplenishmentMetrics() {
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Replenishment Retail</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.replenishmentRetail)}</p>
|
||||
)}
|
||||
</div>
|
||||
{data?.phaseBreakdown && data.phaseBreakdown.length > 0 && (
|
||||
<div className="mt-1 space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Replenishment Cost 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.cost)}</div>
|
||||
<div className="opacity-70">{p.products} products · {p.units} units</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts"
|
||||
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 { DateRange } from "react-day-picker"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { addDays, format } from "date-fns"
|
||||
import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
|
||||
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
|
||||
@@ -20,6 +43,8 @@ interface SalesData {
|
||||
revenue: number
|
||||
cogs: number
|
||||
}[]
|
||||
dailySalesByPhase?: DailyPhaseData[]
|
||||
phaseBreakdown?: PhaseBreakdown[]
|
||||
}
|
||||
|
||||
function MetricSkeleton() {
|
||||
@@ -27,17 +52,14 @@ function MetricSkeleton() {
|
||||
}
|
||||
|
||||
export function SalesMetrics() {
|
||||
const [dateRange, setDateRange] = useState<DateRange>({
|
||||
from: addDays(new Date(), -30),
|
||||
to: new Date(),
|
||||
});
|
||||
const [period, setPeriod] = useState<Period>(30);
|
||||
|
||||
const { data, isError, isLoading } = useQuery<SalesData>({
|
||||
queryKey: ["sales-metrics", dateRange],
|
||||
queryKey: ["sales-metrics", period],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
startDate: dateRange.from?.toISOString() || "",
|
||||
endDate: dateRange.to?.toISOString() || "",
|
||||
startDate: addDays(new Date(), -period).toISOString(),
|
||||
endDate: new Date().toISOString(),
|
||||
});
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`)
|
||||
if (!response.ok) throw new Error("Failed to fetch sales metrics");
|
||||
@@ -45,19 +67,19 @@ export function SalesMetrics() {
|
||||
},
|
||||
})
|
||||
|
||||
const hasPhaseData = data?.dailySalesByPhase && data.dailySalesByPhase.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="flex flex-row items-center justify-between pr-5">
|
||||
<CardTitle className="text-xl font-medium">Sales</CardTitle>
|
||||
<div className="w-[230px]">
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={(range) => {
|
||||
if (range) setDateRange(range);
|
||||
}}
|
||||
future={false}
|
||||
/>
|
||||
</div>
|
||||
<Tabs value={String(period)} onValueChange={(v) => setPeriod(Number(v) as Period)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="7">7D</TabsTrigger>
|
||||
<TabsTrigger value="30">30D</TabsTrigger>
|
||||
<TabsTrigger value="90">90D</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</CardHeader>
|
||||
<CardContent className="py-0 -mb-2">
|
||||
{isError ? (
|
||||
@@ -103,6 +125,42 @@ export function SalesMetrics() {
|
||||
</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">
|
||||
@@ -111,7 +169,7 @@ export function SalesMetrics() {
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={data?.dailySales || []}
|
||||
data={hasPhaseData ? data.dailySalesByPhase : (data?.dailySales || [])}
|
||||
margin={{ top: 30, right: 0, left: -60, bottom: 0 }}
|
||||
>
|
||||
<XAxis
|
||||
@@ -125,18 +183,40 @@ export function SalesMetrics() {
|
||||
axisLine={false}
|
||||
tick={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]}
|
||||
<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)}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#00C49F"
|
||||
fill="#00C49F"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -3,8 +3,18 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { Package, Layers, DollarSign, ShoppingCart } from "lucide-react"
|
||||
import { Package, PackageCheck, Layers, DollarSign, Tag } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { PHASE_CONFIG } from "@/utils/lifecyclePhases"
|
||||
|
||||
interface PhaseStock {
|
||||
phase: string
|
||||
products: number
|
||||
units: number
|
||||
cost: number
|
||||
retail: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
interface StockMetricsData {
|
||||
totalProducts: number
|
||||
@@ -19,6 +29,7 @@ interface StockMetricsData {
|
||||
cost: number
|
||||
retail: number
|
||||
}[]
|
||||
phaseStock?: PhaseStock[]
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
@@ -32,66 +43,54 @@ const COLORS = [
|
||||
"#FF7C43",
|
||||
]
|
||||
|
||||
const renderActiveShape = (props: any) => {
|
||||
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, brand, retail } = props;
|
||||
|
||||
// Split brand name into words and create lines of max 12 chars
|
||||
const words = brand.split(' ');
|
||||
function wrapLabel(text: string, maxLen = 12): string[] {
|
||||
const words = text.split(' ');
|
||||
const lines: string[] = [];
|
||||
let currentLine = '';
|
||||
|
||||
let cur = '';
|
||||
words.forEach((word: string) => {
|
||||
if ((currentLine + ' ' + word).length <= 12) {
|
||||
currentLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
if ((cur + ' ' + word).length <= maxLen) {
|
||||
cur = cur ? `${cur} ${word}` : word;
|
||||
} else {
|
||||
if (currentLine) lines.push(currentLine);
|
||||
currentLine = word;
|
||||
if (cur) lines.push(cur);
|
||||
cur = word;
|
||||
}
|
||||
});
|
||||
if (currentLine) lines.push(currentLine);
|
||||
if (cur) lines.push(cur);
|
||||
return lines;
|
||||
}
|
||||
|
||||
const renderActiveShape = (props: any) => {
|
||||
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, brand, cost } = props;
|
||||
const lines = wrapLabel(brand);
|
||||
|
||||
return (
|
||||
<g>
|
||||
<Sector
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
fill={fill}
|
||||
/>
|
||||
<Sector
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
innerRadius={outerRadius - 1}
|
||||
outerRadius={outerRadius + 4}
|
||||
fill={fill}
|
||||
/>
|
||||
<Sector cx={cx} cy={cy} innerRadius={innerRadius} outerRadius={outerRadius} startAngle={startAngle} endAngle={endAngle} fill={fill} />
|
||||
<Sector cx={cx} cy={cy} startAngle={startAngle} endAngle={endAngle} innerRadius={outerRadius - 1} outerRadius={outerRadius + 4} fill={fill} />
|
||||
{lines.map((line, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={cx}
|
||||
y={cy}
|
||||
dy={-20 + (i * 16)}
|
||||
textAnchor="middle"
|
||||
fill="#888888"
|
||||
className="text-xs"
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
<text key={i} x={cx} y={cy} dy={-20 + (i * 16)} textAnchor="middle" fill="#888888" className="text-xs">{line}</text>
|
||||
))}
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
dy={lines.length * 16 - 10}
|
||||
textAnchor="middle"
|
||||
fill="#000000"
|
||||
className="text-base font-medium"
|
||||
>
|
||||
{formatCurrency(retail)}
|
||||
<text x={cx} y={cy} dy={lines.length * 16 - 10} textAnchor="middle" fill="#000000" className="text-base font-medium">
|
||||
{formatCurrency(cost)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPhaseActiveShape = (props: any) => {
|
||||
const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, phase, cost } = props;
|
||||
const cfg = PHASE_CONFIG[phase] || { label: phase };
|
||||
const lines = wrapLabel(cfg.label);
|
||||
|
||||
return (
|
||||
<g>
|
||||
<Sector cx={cx} cy={cy} innerRadius={innerRadius} outerRadius={outerRadius} startAngle={startAngle} endAngle={endAngle} fill={fill} />
|
||||
<Sector cx={cx} cy={cy} startAngle={startAngle} endAngle={endAngle} innerRadius={outerRadius - 1} outerRadius={outerRadius + 4} fill={fill} />
|
||||
{lines.map((line, i) => (
|
||||
<text key={i} x={cx} y={cy} dy={-20 + (i * 16)} textAnchor="middle" fill="#888888" className="text-xs">{line}</text>
|
||||
))}
|
||||
<text x={cx} y={cy} dy={lines.length * 16 - 10} textAnchor="middle" fill="#000000" className="text-base font-medium">
|
||||
{formatCurrency(cost)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
@@ -103,6 +102,7 @@ function MetricSkeleton() {
|
||||
|
||||
export function StockMetrics() {
|
||||
const [activeIndex, setActiveIndex] = useState<number | undefined>();
|
||||
const [activePhaseIndex, setActivePhaseIndex] = useState<number | undefined>();
|
||||
|
||||
const { data, isError, isLoading } = useQuery<StockMetricsData>({
|
||||
queryKey: ["stock-metrics"],
|
||||
@@ -122,49 +122,49 @@ export function StockMetrics() {
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load stock metrics</p>
|
||||
) : (
|
||||
<div className="flex justify-between gap-8">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-4">
|
||||
<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">Products</p>
|
||||
<div className="flex gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Package className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">Products</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.totalProducts.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Products In Stock</p>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<PackageCheck className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">Products In Stock</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.productsInStock.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Stock Units</p>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Layers className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">Stock Units</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{data.totalStockUnits.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">Stock Cost</p>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">Stock Cost</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.totalStockCost)}</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">Stock Retail</p>
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Tag className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-nowrap text-sm font-medium text-muted-foreground">Stock Retail</p>
|
||||
</div>
|
||||
{isLoading || !data ? <MetricSkeleton /> : (
|
||||
<p className="text-lg font-bold">{formatCurrency(data.totalStockRetail)}</p>
|
||||
@@ -172,9 +172,9 @@ export function StockMetrics() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-md flex justify-center font-medium">Stock Retail By Brand</div>
|
||||
<div className="flex min-w-0 flex-1 gap-2">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="text-md flex justify-center font-medium">Stock Cost By Brand</div>
|
||||
<div className="h-[180px]">
|
||||
{isLoading || !data ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -185,7 +185,7 @@ export function StockMetrics() {
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.brandStock}
|
||||
dataKey="retail"
|
||||
dataKey="cost"
|
||||
nameKey="brand"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
@@ -209,6 +209,42 @@ export function StockMetrics() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="text-md flex justify-center font-medium">Stock Cost By Phase</div>
|
||||
<div className="h-[180px]">
|
||||
{isLoading || !data?.phaseStock ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-[160px] w-[160px] animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.phaseStock}
|
||||
dataKey="cost"
|
||||
nameKey="phase"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
activeIndex={activePhaseIndex}
|
||||
activeShape={renderPhaseActiveShape}
|
||||
onMouseEnter={(_, index) => setActivePhaseIndex(index)}
|
||||
onMouseLeave={() => setActivePhaseIndex(undefined)}
|
||||
>
|
||||
{data.phaseStock.map((entry) => {
|
||||
const cfg = PHASE_CONFIG[entry.phase] || { color: "#94A3B8" }
|
||||
return (
|
||||
<Cell key={entry.phase} fill={cfg.color} />
|
||||
)
|
||||
})}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function TopReplenishProducts() {
|
||||
) : isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<ScrollArea className="max-h-[530px] w-full overflow-y-auto">
|
||||
<ScrollArea className="max-h-[630px] w-full overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
||||
@@ -19,8 +19,9 @@ import { StatusBadge } from "@/components/products/StatusBadge";
|
||||
import { transformMetricsRow } from "@/utils/transformUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import config from "@/config";
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, Legend } from "recharts";
|
||||
import { ResponsiveContainer, LineChart, Line, AreaChart, Area, XAxis, YAxis, Tooltip, CartesianGrid, Legend } from "recharts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { format } from "date-fns";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
|
||||
// Interfaces for POs and time series data
|
||||
@@ -46,6 +47,26 @@ interface ProductTimeSeries {
|
||||
recentPurchases: ProductPurchaseOrder[];
|
||||
}
|
||||
|
||||
interface ProductForecast {
|
||||
phase: string | null;
|
||||
method: string | null;
|
||||
forecast: {
|
||||
date: string;
|
||||
units: number;
|
||||
revenue: number;
|
||||
confidenceLower: number;
|
||||
confidenceUpper: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
const PHASE_LABELS: Record<string, string> = {
|
||||
preorder: "Pre-order",
|
||||
launch: "Launch",
|
||||
decay: "Active Decay",
|
||||
mature: "Evergreen",
|
||||
dormant: "Dormant",
|
||||
};
|
||||
|
||||
interface ProductDetailProps {
|
||||
productId: number | null;
|
||||
onClose: () => void;
|
||||
@@ -109,6 +130,18 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
enabled: !!productId, // Only run query when productId is truthy
|
||||
});
|
||||
|
||||
// Fetch product forecast data
|
||||
const { data: forecastData, isLoading: isLoadingForecast } = useQuery<ProductForecast, Error>({
|
||||
queryKey: ["productForecast", productId],
|
||||
queryFn: async () => {
|
||||
if (!productId) throw new Error("Product ID is required");
|
||||
const response = await fetch(`${config.apiUrl}/products/${productId}/forecast`, {credentials: 'include'});
|
||||
if (!response.ok) throw new Error("Failed to fetch forecast");
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!productId,
|
||||
});
|
||||
|
||||
// Get PO status display names (DB stores text statuses)
|
||||
const getPOStatusName = (status: string): string => {
|
||||
const statusMap: Record<string, string> = {
|
||||
@@ -328,6 +361,72 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Forecast Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">90-Day Forecast</CardTitle>
|
||||
<CardDescription>
|
||||
{forecastData?.phase
|
||||
? `${PHASE_LABELS[forecastData.phase] || forecastData.phase} phase \u00b7 ${forecastData.method || 'unknown'} method`
|
||||
: 'Lifecycle-aware demand forecast'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px]">
|
||||
{isLoadingForecast ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Skeleton className="h-[250px] w-full" />
|
||||
</div>
|
||||
) : forecastData && forecastData.forecast.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={forecastData.forecast}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(d) => format(new Date(d + 'T00:00:00'), 'MMM d')}
|
||||
interval="preserveStartEnd"
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 11 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
labelFormatter={(d) => format(new Date(d + 'T00:00:00'), 'MMM d, yyyy')}
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'Revenue') return [formatCurrency(value), name];
|
||||
return [value.toFixed(1), name];
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Area
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="units"
|
||||
name="Units"
|
||||
stroke="#8884d8"
|
||||
fill="#8884d8"
|
||||
fillOpacity={0.15}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#82ca9d"
|
||||
fill="#82ca9d"
|
||||
fillOpacity={0.15}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||
<p>No forecast data available for this product.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Sales Performance (30 Days)</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
@@ -535,6 +634,8 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Forecasting</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Lifecycle Phase" value={forecastData?.phase ? (PHASE_LABELS[forecastData.phase] || forecastData.phase) : 'N/A'} />
|
||||
<InfoItem label="Forecast Method" value={forecastData?.method || 'N/A'} />
|
||||
<InfoItem label="Replenishment Units" value={formatNumber(product.replenishmentUnits)} />
|
||||
<InfoItem label="Replenishment Cost" value={formatCurrency(product.replenishmentCost)} />
|
||||
<InfoItem label="To Order Units" value={formatNumber(product.toOrderUnits)} />
|
||||
|
||||
@@ -18,11 +18,11 @@ export function Overview() {
|
||||
</div>
|
||||
|
||||
{/* First row - Stock and Purchase metrics */}
|
||||
<div className="grid gap-4 grid-cols-2">
|
||||
<Card className="col-span-1">
|
||||
<div className="grid gap-4 grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<StockMetrics />
|
||||
</Card>
|
||||
<Card className="col-span-1">
|
||||
<Card className="col-span-3">
|
||||
<PurchaseMetrics />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const PHASE_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
preorder: { label: "Pre-order", color: "#3B82F6" },
|
||||
launch: { label: "Launch", color: "#22C55E" },
|
||||
decay: { label: "Active", color: "#F59E0B" },
|
||||
mature: { label: "Evergreen", color: "#8B5CF6" },
|
||||
slow_mover: { label: "Slow Mover", color: "#14B8A6" },
|
||||
dormant: { label: "Dormant", color: "#6B7280" },
|
||||
unknown: { label: "Unclassified", color: "#94A3B8" },
|
||||
}
|
||||
|
||||
/** Stacking order for phase area/bar charts (bottom to top) */
|
||||
export const PHASE_KEYS = ["mature", "slow_mover", "decay", "launch", "preorder", "dormant"] as const
|
||||
|
||||
/** Same as PHASE_KEYS but includes the unknown bucket (for sales data where lifecycle_phase can be NULL) */
|
||||
export const PHASE_KEYS_WITH_UNKNOWN = ["mature", "slow_mover", "decay", "launch", "preorder", "dormant", "unknown"] as const
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user