Files
inventory/inventory/src/components/analytics/StockHealth.tsx

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