Add in forecasting, lifecycle phases, associated component and script changes

This commit is contained in:
2026-02-13 22:45:18 -05:00
parent f41b5ab0f6
commit 45ded53530
29 changed files with 3643 additions and 376 deletions
@@ -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)} />
+3 -3
View File
@@ -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>
+15
View File
@@ -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