mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 16:05:23 +02:00
373 lines
20 KiB
TypeScript
373 lines
20 KiB
TypeScript
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
|
import * as XLSX from 'xlsx';
|
|
import { ProductForecastData, FilterState, CombinedKPIs } from '../types';
|
|
import { DownloadIcon, FunnelIcon, TrendingIcon, ChartIcon } from './Icons';
|
|
import { StockBadge } from './StockBadge';
|
|
import { Top50Badge } from './Top50Badge';
|
|
import { VendorStockBadge } from './VendorStockBadge';
|
|
import { InColumnStockFilter } from './InColumnStockFilter';
|
|
import { PAN_EU_COUNTRIES } from '../services/dataProcessor';
|
|
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> };
|
|
stockMap: Map<string, number>;
|
|
top50Mode: 'eu' | 'uk';
|
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
|
stockFilter: string[];
|
|
onStockFilterChange: (newFilters: string[]) => void;
|
|
vendorStockFilter: string[];
|
|
onVendorStockFilterChange: (newFilters: string[]) => void;
|
|
}
|
|
|
|
const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
|
|
const ForecastRow: React.FC<{
|
|
item: ProductForecastData;
|
|
activeMonths: string[];
|
|
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
|
|
top50Mode: 'eu' | 'uk';
|
|
stockMap?: Map<string, number>;
|
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
|
}> = React.memo(({ item, activeMonths, top50Ranking, top50Mode, stockMap, vendorStockMap }) => {
|
|
const asin = item.asin.trim().toUpperCase();
|
|
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
|
|
|
if (top50Ranking) {
|
|
if (top50Mode === 'eu') {
|
|
const rankEU = top50Ranking.eu.get(asin);
|
|
if (rankEU) ranks.push({ rank: rankEU, label: 'EU', theme: 'indigo' });
|
|
} else {
|
|
const rankUK = top50Ranking.uk.get(asin);
|
|
if (rankUK) ranks.push({ rank: rankUK, label: 'UK', theme: 'blue' });
|
|
}
|
|
}
|
|
|
|
// Calculations for new columns
|
|
const forecastPeriod = item.forecastUnits || 0;
|
|
const actualUnits = item.actualUnits || 0;
|
|
const annualForecast = item.annualForecast || 0;
|
|
|
|
// FC 26 Achievement: Actual / Annual Forecast
|
|
const fcAchievement = annualForecast > 0 ? (actualUnits / annualForecast) * 100 : 0;
|
|
|
|
// YTD Achievement: Actual / Period Forecast
|
|
// If activeMonths is basically "Year To Date" (which it usually is in this view unless filtered), this logic holds.
|
|
// If user selects specific months, it becomes "Period Achievement".
|
|
const ytdAchievement = forecastPeriod > 0 ? (actualUnits / forecastPeriod) * 100 : 0;
|
|
|
|
let achievementColor = 'bg-slate-700';
|
|
if (ytdAchievement >= 100) achievementColor = 'bg-emerald-500';
|
|
else if (ytdAchievement >= 80) achievementColor = 'bg-emerald-400';
|
|
else if (ytdAchievement >= 50) achievementColor = 'bg-amber-400';
|
|
else achievementColor = 'bg-rose-500';
|
|
|
|
return (
|
|
<tr key={item.asin} className="hover:bg-indigo-500/5 transition-colors group border-b border-slate-800/50 last:border-0">
|
|
{/* Product Info */}
|
|
<td className="px-6 py-4">
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
{ranks.map((r, i) => (
|
|
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
|
|
))}
|
|
<span className="text-xs font-black text-indigo-400 tracking-tighter uppercase">
|
|
{item.sku}
|
|
</span>
|
|
<div className="px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-[9px] font-bold text-slate-400 uppercase tracking-wider">
|
|
{item.asin}
|
|
</div>
|
|
</div>
|
|
<span className="text-sm font-medium text-slate-200 line-clamp-1 group-hover:line-clamp-none transition-all" title={item.title}>
|
|
{item.title}
|
|
</span>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<span className="px-2 py-0.5 rounded bg-purple-500/10 text-[9px] font-black text-purple-300 uppercase tracking-widest border border-purple-500/20">
|
|
{item.line}
|
|
</span>
|
|
{/* WOC Indicator preserved */}
|
|
<VendorStockBadge asin={item.asin} vendorStockMap={vendorStockMap} mode={top50Mode} avgWeeklySales={item.avgWeeklySales} />
|
|
</div>
|
|
</div>
|
|
</td>
|
|
{/* Forecast (Period) */}
|
|
<td className="px-6 py-4 text-center">
|
|
<span className="text-sm font-bold text-slate-400">
|
|
{forecastPeriod.toLocaleString('de-DE')}
|
|
</span>
|
|
</td>
|
|
|
|
{/* Actual Units Sales 2026 */}
|
|
<td className="px-6 py-4 text-center">
|
|
<span className="text-base font-black text-emerald-400">
|
|
{actualUnits.toLocaleString('de-DE')}
|
|
</span>
|
|
</td>
|
|
|
|
{/* FC 26 Achievement (Annual %) */}
|
|
<td className="px-6 py-4 text-center">
|
|
<div className="flex flex-col items-center justify-center gap-1">
|
|
<div className="px-3 py-1 rounded bg-slate-800 border border-slate-700 text-xs font-bold text-white shadow-sm">
|
|
{fcAchievement.toFixed(1)}%
|
|
</div>
|
|
<span className="text-[9px] font-bold text-slate-500 uppercase tracking-tight">
|
|
OF ANNUAL {Math.round(annualForecast / 1000)}K
|
|
</span>
|
|
</div>
|
|
</td>
|
|
|
|
{/* YTD Achievement (Period %) */}
|
|
<td className="px-6 py-4 align-middle">
|
|
<div className="flex flex-col gap-1 w-full max-w-[200px] mx-auto">
|
|
<div className="flex justify-between items-end mb-1">
|
|
<span className={`text-xs font-black ${ytdAchievement >= 100 ? 'text-emerald-400' : 'text-slate-300'}`}>
|
|
{ytdAchievement.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
<div className="h-2 w-full bg-slate-800 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all duration-500 ${achievementColor}`}
|
|
style={{ width: `${Math.min(100, ytdAchievement)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
});
|
|
|
|
const ForecastView: React.FC<ForecastViewProps> = ({
|
|
data,
|
|
filters,
|
|
top50Ranking,
|
|
stockMap,
|
|
vendorStockMap,
|
|
stockFilter,
|
|
onStockFilterChange,
|
|
vendorStockFilter,
|
|
onVendorStockFilterChange,
|
|
top50Mode
|
|
}) => {
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
|
const [displayCount, setDisplayCount] = useState(50);
|
|
|
|
const lastDataMonthIdx = useMemo(() => {
|
|
for (let i = MONTH_ORDER.length - 1; i >= 0; i--) {
|
|
if (data.some(p => (p.monthlyData?.[MONTH_ORDER[i]]?.actualUnits || 0) > 0)) return i;
|
|
}
|
|
return 0;
|
|
}, [data]);
|
|
|
|
const activeMonths = useMemo(() => {
|
|
if (filters.month && filters.month.length > 0) {
|
|
return filters.month.map(m => m.split('-')[0]);
|
|
}
|
|
return MONTH_ORDER.slice(0, lastDataMonthIdx + 1);
|
|
}, [filters.month, lastDataMonthIdx]);
|
|
|
|
const baseFilteredData = useMemo(() => {
|
|
let result = data;
|
|
if (filters.line && filters.line.length > 0) result = result.filter(p => filters.line.includes(p.line));
|
|
if (filters.asin && filters.asin.length > 0) result = result.filter(p => filters.asin.includes(p.asin));
|
|
if (filters.sku && filters.sku.length > 0) result = result.filter(p => filters.sku.includes(p.sku));
|
|
return result;
|
|
}, [data, filters.line, filters.asin, filters.sku]);
|
|
|
|
const globalSummary = useMemo(() => {
|
|
return baseFilteredData.reduce((acc, curr) => ({
|
|
actualUnits: acc.actualUnits + (curr.actualUnits || 0),
|
|
forecastUnits: acc.forecastUnits + (curr.forecastUnits || 0),
|
|
annualForecast: acc.annualForecast + (curr.annualForecast || 0),
|
|
}), { actualUnits: 0, forecastUnits: 0, annualForecast: 0 });
|
|
}, [baseFilteredData]);
|
|
|
|
const chartData = useMemo(() => {
|
|
const dataMap = new Map<string, { name: string; actual: number; forecast: number }>();
|
|
MONTH_ORDER.forEach(m => dataMap.set(m, { name: m, actual: 0, forecast: 0 }));
|
|
|
|
baseFilteredData.forEach(item => {
|
|
if (item.monthlyData) {
|
|
Object.values(item.monthlyData).forEach((m: any) => {
|
|
const entry = dataMap.get(m.month);
|
|
if (entry) {
|
|
entry.actual += m.actualUnits || 0;
|
|
entry.forecast += m.forecastUnits || 0;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
return Array.from(dataMap.values());
|
|
}, [baseFilteredData]);
|
|
|
|
const finalFilteredData = useMemo(() => {
|
|
let result = baseFilteredData;
|
|
if (showOnlyTop50 && top50Ranking) {
|
|
const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
|
|
result = result.filter(p => currentRankMap.has(p.asin.toUpperCase()));
|
|
}
|
|
if (searchTerm) {
|
|
const s = searchTerm.toLowerCase();
|
|
result = result.filter(p => p.sku.toLowerCase().includes(s) || p.asin.toLowerCase().includes(s) || p.title.toLowerCase().includes(s));
|
|
}
|
|
return result;
|
|
}, [baseFilteredData, searchTerm, showOnlyTop50, top50Ranking, top50Mode]);
|
|
|
|
const handleExportExcel = useCallback(() => {
|
|
const exportData = finalFilteredData.map(p => ({
|
|
SKU: p.sku,
|
|
ASIN: p.asin,
|
|
Title: p.title,
|
|
Line: p.line,
|
|
'Actual Units (2025)': p.actualUnits,
|
|
'Forecast Units (2025)': p.forecastUnits,
|
|
'Accuracy (%)': p.accuracy
|
|
}));
|
|
const ws = XLSX.utils.json_to_sheet(exportData);
|
|
const wb = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(wb, ws, 'Forecast');
|
|
XLSX.writeFile(wb, `Forecast_Export_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
|
}, [finalFilteredData]);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6 animate-fade-in p-6 h-full overflow-hidden">
|
|
{/* Top Section: Metrics & Graph */}
|
|
<div className="flex flex-col xl:flex-row gap-6 h-[400px] shrink-0">
|
|
{/* Metrics Cards */}
|
|
<div className="flex flex-col gap-4 min-w-[300px] xl:w-[25%]">
|
|
{/* Annual Forecast */}
|
|
<div className="bg-slate-900 border border-white/5 p-4 rounded-xl shadow-lg flex-1 flex flex-col justify-center">
|
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">Annual Forecast Total</span>
|
|
<div className="text-2xl font-black text-white">{(globalSummary.annualForecast || 0).toLocaleString('de-DE')} <span className="text-sm font-bold text-slate-500">Units</span></div>
|
|
</div>
|
|
{/* Period Forecast */}
|
|
<div className="bg-slate-900 border border-white/5 p-4 rounded-xl shadow-lg flex-1 flex flex-col justify-center">
|
|
<span className="text-[10px] font-black text-indigo-400 uppercase tracking-widest mb-1">Total Forecast (Period)</span>
|
|
<div className="text-2xl font-black text-white">{(globalSummary.forecastUnits || 0).toLocaleString('de-DE')} <span className="text-sm font-bold text-slate-500">Units</span></div>
|
|
</div>
|
|
{/* Actual Sales */}
|
|
<div className="bg-slate-900 border border-white/5 p-4 rounded-xl shadow-lg flex-1 flex flex-col justify-center">
|
|
<span className="text-[10px] font-black text-emerald-400 uppercase tracking-widest mb-1">Total Actual Sales (Period)</span>
|
|
<div className="text-2xl font-black text-emerald-400">{(globalSummary.actualUnits || 0).toLocaleString('de-DE')} <span className="text-sm font-bold text-emerald-600/70">Units</span></div>
|
|
</div>
|
|
{/* Fulfillment */}
|
|
<div className="flex gap-4 flex-1">
|
|
<div className="bg-slate-900 border border-white/5 p-4 rounded-xl shadow-lg flex-1 flex flex-col justify-center">
|
|
<span className="text-[10px] font-black text-blue-400 uppercase tracking-widest mb-1">Fulfillment (Period)</span>
|
|
<div className="text-xl font-black text-white">{globalSummary.forecastUnits > 0 ? ((globalSummary.actualUnits / globalSummary.forecastUnits) * 100).toFixed(1) : '0.0'}%</div>
|
|
<div className="mt-2 h-1 bg-slate-800 rounded-full overflow-hidden">
|
|
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${Math.min(100, globalSummary.forecastUnits > 0 ? ((globalSummary.actualUnits / globalSummary.forecastUnits) * 100) : 0)}%` }} />
|
|
</div>
|
|
</div>
|
|
<div className="bg-slate-900 border border-white/5 p-4 rounded-xl shadow-lg flex-1 flex flex-col justify-center">
|
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">Annual Fulfillment %</span>
|
|
<div className="text-xl font-black text-white">{globalSummary.annualForecast > 0 ? ((globalSummary.actualUnits / globalSummary.annualForecast) * 100).toFixed(1) : '0.0'}%</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Graph */}
|
|
<div className="flex-1 bg-slate-900 border border-white/5 rounded-xl shadow-lg p-6 flex flex-col">
|
|
<h3 className="flex items-center gap-2 text-sm font-bold text-white mb-6">
|
|
<TrendingIcon /> Monthly Evolution: Forecast vs Actual
|
|
</h3>
|
|
<div className="flex-1 w-full min-h-0">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<ComposedChart data={chartData}>
|
|
<defs>
|
|
<linearGradient id="colorActual" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="colorForecast" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.3} vertical={false} />
|
|
<XAxis dataKey="name" stroke="#94a3b8" tick={{ fontSize: 12 }} axisLine={false} tickLine={false} />
|
|
<YAxis stroke="#94a3b8" tick={{ fontSize: 12 }} axisLine={false} tickLine={false} />
|
|
<Tooltip
|
|
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '8px', color: '#f8fafc' }}
|
|
itemStyle={{ fontSize: '12px' }}
|
|
/>
|
|
<Legend />
|
|
<Bar dataKey="actual" name="Actual Sales" fill="#10b981" radius={[4, 4, 0, 0]} barSize={20} />
|
|
<Line type="monotone" dataKey="forecast" name="Forecast" stroke="#6366f1" strokeWidth={3} dot={{ r: 4, fill: "#6366f1" }} activeDot={{ r: 6 }} />
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom Section: Table */}
|
|
<div className="bg-slate-900 border border-white/10 rounded-2xl overflow-hidden shadow-2xl flex-1 flex flex-col min-h-0">
|
|
<div className="p-4 border-b border-white/5 flex flex-col md:flex-row justify-between gap-4 bg-slate-800/20 shrink-0">
|
|
<h3 className="text-lg font-bold text-white uppercase tracking-tight">Product Performance Comparison</h3>
|
|
<div className="flex items-center gap-4">
|
|
{top50Ranking && (top50Ranking.eu.size > 0 || top50Ranking.uk.size > 0) && (
|
|
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm">
|
|
<button
|
|
onClick={() => setShowOnlyTop50(!showOnlyTop50)}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${showOnlyTop50 ? 'bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-lg' : 'text-slate-400 hover:text-amber-400'}`}
|
|
>
|
|
<span className="text-sm">🏆</span>
|
|
Top 50 ({top50Mode.toUpperCase()})
|
|
</button>
|
|
</div>
|
|
)}
|
|
<input
|
|
type="text"
|
|
placeholder="Search..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="bg-slate-950 border border-white/10 rounded-xl px-4 py-2 text-sm text-white focus:outline-none w-64"
|
|
/>
|
|
<button onClick={handleExportExcel} className="p-2 bg-slate-800 hover:bg-slate-700 text-white rounded-xl border border-white/5 transition-all"><DownloadIcon /></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto min-h-0 custom-scrollbar relative">
|
|
<table className="w-full text-left border-collapse relative">
|
|
<thead className="sticky top-0 z-20 bg-slate-950 shadow-sm text-xs font-bold text-slate-400 uppercase tracking-wider">
|
|
<tr>
|
|
<th className="px-6 py-4 font-black text-white w-[35%]">Product Info</th>
|
|
<th className="px-6 py-4 text-center w-[12%]">Forecast (Period)</th>
|
|
<th className="px-6 py-4 text-center w-[12%]">Actual Units Sales 2026</th>
|
|
<th className="px-6 py-4 text-center w-[15%]">FC 26 Achievement</th>
|
|
<th className="px-6 py-4 text-center w-[26%]">YTD Achievement</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-800/50">
|
|
{finalFilteredData.map(item => (
|
|
<ForecastRow
|
|
key={item.asin}
|
|
item={item}
|
|
activeMonths={filters.month.length > 0 ? filters.month : MONTH_ORDER}
|
|
top50Ranking={top50Ranking}
|
|
top50Mode={top50Mode}
|
|
stockMap={stockMap}
|
|
vendorStockMap={vendorStockMap}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
{finalFilteredData.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center py-20 text-slate-500">
|
|
<p className="text-lg">No forecast data found for current filters</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ForecastView;
|