258 lines
9.2 KiB
TypeScript
258 lines
9.2 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import {
|
|
ResponsiveContainer,
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
Tooltip,
|
|
CartesianGrid,
|
|
PieChart,
|
|
Pie,
|
|
Cell,
|
|
Legend,
|
|
} from 'recharts';
|
|
import config from '../../config';
|
|
import { AlertTriangle, ShieldCheck, DollarSign } from 'lucide-react';
|
|
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
|
import { formatCurrency } from '@/utils/formatCurrency';
|
|
|
|
interface CoverBucket {
|
|
bucket: string;
|
|
productCount: number;
|
|
stockCost: number;
|
|
}
|
|
|
|
interface DemandPattern {
|
|
pattern: string;
|
|
productCount: number;
|
|
revenue: number;
|
|
stockCost: number;
|
|
}
|
|
|
|
interface ServiceStats {
|
|
avgFillRate: number;
|
|
avgServiceLevel: number;
|
|
totalStockoutIncidents: number;
|
|
totalLostSalesIncidents: number;
|
|
totalLostUnits: number;
|
|
totalLostRevenue: number;
|
|
productsWithStockouts: number;
|
|
avgStockoutRate: number;
|
|
}
|
|
|
|
interface StockHealthData {
|
|
coverDistribution: CoverBucket[];
|
|
demandPatterns: DemandPattern[];
|
|
serviceStats: ServiceStats;
|
|
}
|
|
|
|
// Color palette for demand pattern donut chart
|
|
const DEMAND_COLORS = [
|
|
METRIC_COLORS.revenue, // emerald
|
|
METRIC_COLORS.orders, // blue
|
|
METRIC_COLORS.comparison, // amber
|
|
METRIC_COLORS.aov, // violet
|
|
METRIC_COLORS.secondary, // cyan
|
|
];
|
|
|
|
function getCoverColor(bucket: string): string {
|
|
if (bucket.includes('Stockout')) return '#ef4444'; // red
|
|
if (bucket.includes('1-7')) return METRIC_COLORS.expense; // orange — critical low
|
|
if (bucket.includes('8-14')) return METRIC_COLORS.comparison; // amber — low
|
|
if (bucket.includes('15-30')) return '#eab308'; // yellow — watch
|
|
if (bucket.includes('31-60')) return METRIC_COLORS.revenue; // emerald — healthy
|
|
if (bucket.includes('61-90')) return METRIC_COLORS.orders; // blue — comfortable
|
|
if (bucket.includes('91-180')) return METRIC_COLORS.aov; // violet — high
|
|
return METRIC_COLORS.secondary; // cyan — excess
|
|
}
|
|
|
|
export function StockHealth() {
|
|
const { data, isLoading, isError } = useQuery<StockHealthData>({
|
|
queryKey: ['stock-health'],
|
|
queryFn: async () => {
|
|
const response = await fetch(`${config.apiUrl}/analytics/stock-health`);
|
|
if (!response.ok) throw new Error('Failed to fetch stock health');
|
|
return response.json();
|
|
},
|
|
});
|
|
|
|
if (isError) {
|
|
return (
|
|
<Card>
|
|
<CardHeader><CardTitle>Demand & Stock Health</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<div className="h-[300px] flex items-center justify-center">
|
|
<p className="text-sm text-destructive">Failed to load stock health data</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (isLoading || !data) {
|
|
return (
|
|
<Card>
|
|
<CardHeader><CardTitle>Demand & Stock Health</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<div className="h-[300px] flex items-center justify-center">
|
|
<div className="animate-pulse text-muted-foreground">Loading stock health...</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const { serviceStats } = data;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Service Level Stats */}
|
|
<div className="grid gap-4 md:grid-cols-4">
|
|
<Card>
|
|
<CardContent className="flex items-center gap-3 py-4">
|
|
<div className="rounded-full p-2 bg-green-500/10">
|
|
<ShieldCheck className="h-4 w-4 text-green-500" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium">Fill Rate</p>
|
|
<p className="text-xl font-bold">{serviceStats.avgFillRate}%</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="flex items-center gap-3 py-4">
|
|
<div className="rounded-full p-2 bg-blue-500/10">
|
|
<ShieldCheck className="h-4 w-4 text-blue-500" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium">Service Level</p>
|
|
<p className="text-xl font-bold">{serviceStats.avgServiceLevel}%</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="flex items-center gap-3 py-4">
|
|
<div className="rounded-full p-2 bg-red-500/10">
|
|
<AlertTriangle className="h-4 w-4 text-red-500" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium">Stockout Incidents</p>
|
|
<p className="text-xl font-bold">{serviceStats.totalStockoutIncidents.toLocaleString()}</p>
|
|
<p className="text-xs text-muted-foreground">{serviceStats.productsWithStockouts} products affected</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="flex items-center gap-3 py-4">
|
|
<div className="rounded-full p-2 bg-amber-500/10">
|
|
<DollarSign className="h-4 w-4 text-amber-500" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium">Est. Lost Revenue</p>
|
|
<p className="text-xl font-bold">{formatCurrency(serviceStats.totalLostRevenue)}</p>
|
|
<p className="text-xs text-muted-foreground">{Math.round(serviceStats.totalLostUnits).toLocaleString()} units</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{/* Stock Cover Distribution */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Stock Cover Distribution</CardTitle>
|
|
<p className="text-xs text-muted-foreground">
|
|
Days of stock cover across active replenishable products
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<BarChart data={data.coverDistribution}>
|
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
<XAxis
|
|
dataKey="bucket"
|
|
tick={{ fontSize: 10 }}
|
|
angle={-30}
|
|
textAnchor="end"
|
|
height={60}
|
|
/>
|
|
<YAxis tick={{ fontSize: 11 }} />
|
|
<Tooltip
|
|
content={({ active, payload }) => {
|
|
if (!active || !payload?.length) return null;
|
|
const d = payload[0].payload as CoverBucket;
|
|
return (
|
|
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
|
<p className="font-medium mb-1">{d.bucket}</p>
|
|
<p>{d.productCount.toLocaleString()} products</p>
|
|
<p>Stock value: {formatCurrency(d.stockCost)}</p>
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
<Bar dataKey="productCount" name="Products" radius={[4, 4, 0, 0]}>
|
|
{data.coverDistribution.map((entry, i) => (
|
|
<Cell key={i} fill={getCoverColor(entry.bucket)} />
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Demand Pattern Distribution */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Demand Patterns</CardTitle>
|
|
<p className="text-xs text-muted-foreground">
|
|
Distribution of demand variability across selling products
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<PieChart>
|
|
<Pie
|
|
data={data.demandPatterns}
|
|
dataKey="productCount"
|
|
nameKey="pattern"
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={60}
|
|
outerRadius={100}
|
|
paddingAngle={2}
|
|
label={({ pattern, productCount }) =>
|
|
`${pattern} (${productCount.toLocaleString()})`
|
|
}
|
|
>
|
|
{data.demandPatterns.map((_, i) => (
|
|
<Cell key={i} fill={DEMAND_COLORS[i % DEMAND_COLORS.length]} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip
|
|
content={({ active, payload }) => {
|
|
if (!active || !payload?.length) return null;
|
|
const d = payload[0].payload as DemandPattern;
|
|
return (
|
|
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
|
|
<p className="font-medium mb-1 capitalize">{d.pattern}</p>
|
|
<p>{d.productCount.toLocaleString()} products</p>
|
|
<p>Revenue (30d): {formatCurrency(d.revenue)}</p>
|
|
<p>Stock value: {formatCurrency(d.stockCost)}</p>
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
<Legend
|
|
formatter={(value) => <span className="capitalize text-xs">{value}</span>}
|
|
/>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|