Files
CrazeAnalytix/components/ForecastView.tsx
T

347 lines
20 KiB
TypeScript

import React, { useMemo, useState, useEffect } from 'react';
import { ProductForecastData, FilterState } from '../types';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
LineChart, Line, Legend, ComposedChart, Area
} from 'recharts';
interface ForecastViewProps {
data: ProductForecastData[];
filters: FilterState;
top50Ranking?: {
eu: Map<string, number>;
uk: Map<string, number>;
};
}
const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
// Top 50 Badge Component (reused logic)
// Top 50 Badge Component (matched to image)
const Top50Badge: React.FC<{ rank: number; label: string; theme?: 'amber' | 'indigo' | 'blue' }> = ({ rank, label }) => {
return (
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-[#8b5cf6] text-white border border-white/10 shadow-lg">
<span className="text-xs">🏆</span>
<span className="text-[10px] font-black uppercase tracking-tighter">{label} {rank}</span>
</div>
);
};
const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking }) => {
const [searchTerm, setSearchTerm] = useState('');
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu');
// Auto-detect best Top 50 mode based on data
useEffect(() => {
const hasUK = data.some(p => p.line.toLowerCase().includes('uk') || (p.monthlyData && p.actualUnits > 0 && filters.customer.some(c => c.toLowerCase().includes('uk'))));
const hasEU = data.some(p => !p.line.toLowerCase().includes('uk'));
// Simplified detection: if we have a lot of EU products, default EU, otherwise check UK
if (hasUK && !hasEU) setTop50Mode('uk');
}, [data, filters.customer]);
// Determine the "Current Month" based on available 2026 data
const lastDataMonthIdx = useMemo(() => {
let maxIdx = 0; // Default Jan
data.forEach(p => {
p.monthlyData.forEach((md, idx) => {
if (md.actualUnits > 0 && idx > maxIdx) {
maxIdx = idx;
}
});
});
return maxIdx;
}, [data]);
// Active months for "Monthly Forecast" calculation
const activeMonths = useMemo(() => {
if (filters.month && filters.month.length > 0) {
// Map e.g. "Apr-24" or just "Apr" to the short name
return filters.month.map(m => m.split('-')[0]);
}
// If no filter, use Jan to lastDataMonth
return MONTH_ORDER.slice(0, lastDataMonthIdx + 1);
}, [filters.month, lastDataMonthIdx]);
// Global Aggregate Data
const globalMonthlyData = useMemo(() => {
return MONTH_ORDER.map(m => {
let forecast = 0;
let actual = 0;
data.forEach(p => {
const monthPoint = p.monthlyData.find(md => md.month === m);
if (monthPoint) {
forecast += monthPoint.forecastUnits;
actual += monthPoint.actualUnits;
}
});
return { name: m, Forecast: forecast, Actual: actual };
});
}, [data]);
const globalSummary = useMemo(() => {
let totalForecast = 0;
let totalActual = 0;
data.forEach(p => {
totalForecast += p.annualForecast;
p.monthlyData.forEach(md => {
totalActual += md.actualUnits;
});
});
const fulfillment = totalForecast > 0 ? (totalActual / totalForecast) * 100 : 0;
return { totalForecast, totalActual, fulfillment };
}, [data]);
const filteredProducts = useMemo(() => {
let result = data;
if (searchTerm) {
const s = searchTerm.toLowerCase();
result = result.filter(p =>
p.asin.toLowerCase().includes(s) ||
p.sku.toLowerCase().includes(s) ||
p.title.toLowerCase().includes(s)
);
}
if (showOnlyTop50 && top50Ranking) {
const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
result = result.filter(p => currentRankMap.has(p.asin.toUpperCase()));
}
return result;
}, [data, searchTerm, showOnlyTop50, top50Mode, top50Ranking]);
const sortedProducts = useMemo(() => {
const result = [...filteredProducts];
if (showOnlyTop50 && top50Ranking) {
const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
result.sort((a, b) => {
const rankA = currentRankMap.get(a.asin.toUpperCase()) || 999;
const rankB = currentRankMap.get(b.asin.toUpperCase()) || 999;
return rankA - rankB;
});
} else {
result.sort((a, b) => b.annualForecast - a.annualForecast);
}
return result;
}, [filteredProducts, showOnlyTop50, top50Mode, top50Ranking]);
return (
<div className="p-6 space-y-6 max-w-7xl mx-auto animate-fade-in pb-24">
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-slate-900 border border-border rounded-2xl p-4 shadow-xl relative overflow-hidden group">
<div className="absolute top-0 right-0 w-32 h-32 bg-indigo-500/10 rounded-full -mr-16 -mt-16 blur-3xl group-hover:bg-indigo-500/20 transition-all"></div>
<h3 className="text-[10px] font-black text-slate-500 uppercase tracking-widest mb-1">Total Forecast 2026</h3>
<div className="text-2xl font-black text-white">
{globalSummary.totalForecast.toLocaleString('de-DE')} <span className="text-xs font-medium text-slate-500">Units</span>
</div>
</div>
<div className="bg-slate-900 border border-border rounded-2xl p-4 shadow-xl relative overflow-hidden group">
<div className="absolute top-0 right-0 w-32 h-32 bg-emerald-500/10 rounded-full -mr-16 -mt-16 blur-3xl group-hover:bg-emerald-500/20 transition-all"></div>
<h3 className="text-[10px] font-black text-slate-500 uppercase tracking-widest mb-1">Total Actual Sales 2026</h3>
<div className="text-2xl font-black text-emerald-400">
{globalSummary.totalActual.toLocaleString('de-DE')} <span className="text-xs font-medium text-slate-500">Units</span>
</div>
</div>
<div className="bg-slate-900 border border-indigo-500/30 rounded-2xl p-4 shadow-xl relative overflow-hidden group">
<div className="absolute top-0 right-0 w-32 h-32 bg-indigo-500/20 rounded-full -mr-16 -mt-16 blur-3xl"></div>
<h3 className="text-[10px] font-black text-indigo-400 uppercase tracking-widest mb-1">Global Fulfillment</h3>
<div className="flex items-baseline gap-2">
<div className={`text-2xl font-black ${globalSummary.fulfillment >= 100 ? 'text-emerald-400' : 'text-indigo-400'}`}>
{globalSummary.fulfillment.toFixed(1)}%
</div>
</div>
{/* Fulfillment Progress Bar */}
<div className="mt-2 w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-indigo-500 to-fuchsia-500 shadow-[0_0_8px_rgba(99,102,241,0.5)] transition-all duration-1000"
style={{ width: `${Math.min(100, globalSummary.fulfillment)}%` }}
></div>
</div>
</div>
</div>
{/* Main Trend Chart */}
<div className="bg-slate-900 border border-border rounded-2xl p-6 shadow-xl">
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg font-bold text-white flex items-center gap-2">
<svg className="w-5 h-5 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>
Monthly Evolution: Forecast vs Actual
</h3>
</div>
<div className="h-96">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={globalMonthlyData}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
<XAxis dataKey="name" stroke="#64748b" />
<YAxis stroke="#64748b" />
<Tooltip
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#1e293b', borderRadius: '12px', padding: '12px' }}
itemStyle={{ fontWeight: 'bold' }}
/>
<Legend verticalAlign="top" height={36} />
<Bar dataKey="Actual" fill="#10b981" radius={[4, 4, 0, 0]} name="Actual Sales" barSize={40} />
<Line type="monotone" dataKey="Forecast" stroke="#6366f1" strokeWidth={4} dot={{ r: 6, fill: '#6366f1' }} name="Forecast Target" />
<Area type="monotone" dataKey="Forecast" fill="#6366f1" fillOpacity={0.05} stroke="none" />
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
{/* Product Table */}
<div className="bg-slate-900 border border-border rounded-2xl shadow-xl overflow-hidden">
<div className="p-6 border-b border-border flex flex-col md:flex-row justify-between gap-4">
<h3 className="text-lg font-bold text-white uppercase tracking-tight">Product Performance Comparison</h3>
<div className="flex flex-col md:flex-row items-center gap-4">
{/* Top 50 Toggle */}
<div className="flex items-center bg-slate-950 p-1 rounded-lg border border-slate-700">
<button
onClick={() => setShowOnlyTop50(!showOnlyTop50)}
className={`px-3 py-1.5 rounded-md text-xs font-black transition-all flex items-center gap-2 ${showOnlyTop50 ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/20' : 'text-slate-500 hover:text-slate-300'}`}
>
<span className="text-sm">🏆</span>
Top 50
</button>
{showOnlyTop50 && (
<div className="flex items-center ml-1 border-l border-slate-700 pl-1">
<button
onClick={() => setTop50Mode('eu')}
className={`px-2 py-1 rounded text-[10px] font-bold transition-colors ${top50Mode === 'eu' ? 'text-indigo-400 bg-indigo-500/10' : 'text-slate-600 hover:text-slate-400'}`}
>
EU
</button>
<button
onClick={() => setTop50Mode('uk')}
className={`px-2 py-1 rounded text-[10px] font-bold transition-colors ${top50Mode === 'uk' ? 'text-indigo-400 bg-indigo-500/10' : 'text-slate-600 hover:text-slate-400'}`}
>
UK
</button>
</div>
)}
</div>
<div className="relative">
<input
type="text"
placeholder="Search by ASIN, SKU or Title..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="bg-slate-950 border border-slate-700 rounded-lg px-10 py-2 text-sm text-slate-200 focus:outline-none focus:border-indigo-500 w-full md:w-80"
/>
<svg className="absolute left-3 top-2.5 w-4 h-4 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm whitespace-nowrap">
<thead className="bg-slate-950 text-slate-500 uppercase text-[10px] font-black tracking-widest border-b border-border">
<tr>
<th className="px-6 py-4">Product Info</th>
<th className="px-6 py-4 text-right">Forecast (Period)</th>
<th className="px-6 py-4 text-right">Actual Units Sales 2026</th>
<th className="px-6 py-4 text-right">Fc 26 Achievement</th>
<th className="px-6 py-4 text-center">YTD Achievement</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{sortedProducts.map((p) => {
const asin = p.asin.toUpperCase();
// Top 50 Badges logic
const ranks = [];
if (top50Ranking) {
const rankEU = top50Ranking.eu.get(asin);
const rankUK = top50Ranking.uk.get(asin);
if (rankEU) ranks.push({ rank: rankEU, label: 'EU', theme: 'indigo' as const });
if (rankUK) ranks.push({ rank: rankUK, label: 'UK', theme: 'blue' as const });
}
// Monthly Forecast Calculation
const filteredForecast = p.monthlyData
.filter(md => activeMonths.includes(md.month))
.reduce((acc, md) => acc + md.forecastUnits, 0);
// Actual Sales for 2026 (Selected Period)
const actualPeriod = p.monthlyData
.filter(md => activeMonths.includes(md.month))
.reduce((acc, md) => acc + md.actualUnits, 0);
// Total annual actual for achievement percentage
const actualTotal = p.monthlyData.reduce((acc, md) => acc + md.actualUnits, 0);
const totalAchievement = p.annualForecast > 0 ? (actualTotal / p.annualForecast) * 100 : 0;
// YTD Achievement (Achievement of the period)
const periodAchievement = filteredForecast > 0 ? (actualPeriod / filteredForecast) * 100 : 0;
return (
<tr key={p.asin} className="hover:bg-indigo-500/5 transition-colors group">
<td className="px-6 py-4">
<div className="flex flex-col gap-1.5">
{/* ID Row */}
<div className="flex items-center gap-3">
{ranks.map((r, i) => (
<Top50Badge key={i} rank={r.rank} label={r.label} />
))}
<span className="font-black text-sm text-[#818cf8] uppercase tracking-tight">{p.sku}</span>
<div className="px-2 py-0.5 rounded bg-slate-800/80 border border-white/5 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{p.asin}
</div>
</div>
{/* Title Row */}
<div className="text-[13px] font-medium text-slate-100 leading-snug group-hover:text-white transition-colors">
{p.title}
</div>
{/* Line Row */}
<div className="text-[10px] font-black text-[#ec4899] uppercase tracking-[0.15em]">
{p.line}
</div>
</div>
</td>
<td className="px-6 py-4 text-right font-mono font-bold text-slate-400">
{filteredForecast.toLocaleString('de-DE')}
</td>
<td className="px-6 py-4 text-right font-mono font-bold text-emerald-400">
{actualPeriod.toLocaleString('de-DE')}
</td>
<td className="px-6 py-4 text-right">
<div className="flex flex-col items-end gap-1">
<span className={`px-2 py-1 rounded text-xs font-black ${totalAchievement >= 50 ? 'bg-emerald-500/10 text-emerald-400' : totalAchievement >= 10 ? 'bg-indigo-500/10 text-indigo-400' : 'bg-slate-800 text-slate-500'}`}>
{totalAchievement.toFixed(1)}%
</span>
<span className="text-[9px] text-slate-500 font-bold uppercase">of Annual {p.annualForecast.toLocaleString('de-DE')}</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-col items-center gap-2">
<div className="w-32 h-2 bg-slate-800 rounded-full overflow-hidden border border-white/5">
<div
className={`h-full transition-all duration-1000 ${periodAchievement >= 100 ? 'bg-emerald-500' : periodAchievement >= 50 ? 'bg-indigo-500' : 'bg-amber-500'}`}
style={{ width: `${Math.min(100, periodAchievement)}%` }}
></div>
</div>
<span className={`text-[11px] font-black ${periodAchievement >= 100 ? 'text-emerald-400' : periodAchievement >= 50 ? 'text-indigo-400' : 'text-amber-400'}`}>
{periodAchievement.toFixed(1)}%
</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default ForecastView;