Files
CrazeAnalytix/components/ForecastView.tsx
T

297 lines
16 KiB
TypeScript
Raw Normal View History

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' });
}
}
const monthlySales = activeMonths.map(m => (item.monthlyData && item.monthlyData[m]?.units) || 0);
const avgMonthly = monthlySales.length > 0 ? monthlySales.reduce((a, b) => a + b, 0) / activeMonths.length : 0;
const peakMonthly = monthlySales.length > 0 ? Math.max(...monthlySales) : 0;
return (
<tr key={item.asin} className="hover:bg-indigo-500/5 transition-colors group">
<td className="px-6 py-4">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1.5 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-2 py-0.5 rounded bg-slate-800/80 border border-white/5 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{item.asin}
</div>
</div>
<span className="text-xs text-white/70 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-indigo-500/10 text-[9px] font-black text-indigo-300 uppercase tracking-widest border border-indigo-500/20">{item.line}</span>
{stockMap && (
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
)}
<VendorStockBadge asin={item.asin} vendorStockMap={vendorStockMap} mode={top50Mode} avgWeeklySales={item.avgWeeklySales} />
</div>
</div>
</td>
<td className="px-6 py-4 text-center">
<span className="text-sm font-black text-white">{(item.actualUnits || 0).toLocaleString('de-DE')}</span>
</td>
<td className="px-6 py-4 text-center">
<span className="text-sm font-black text-emerald-400">{(item.forecastUnits || 0).toLocaleString('de-DE')}</span>
</td>
<td className="px-6 py-4 text-center">
<div className="flex flex-col items-center">
<span className="text-sm font-black text-indigo-400">{(Math.round(avgMonthly) || 0).toLocaleString('de-DE')}</span>
<span className="text-[9px] text-slate-500 font-bold uppercase tracking-tighter">Peak: {(Math.round(peakMonthly) || 0).toLocaleString('de-DE')}</span>
</div>
</td>
<td className="px-6 py-4">
<div className="h-10 w-full min-w-[120px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={activeMonths.map(m => ({ name: m, units: (item.monthlyData && item.monthlyData[m]?.units) || 0 }))}>
<Bar dataKey="units" fill="#6366f1" radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</td>
<td className="px-6 py-4 text-center">
<div className="flex flex-col items-center">
<span className={`text-sm font-black ${item.accuracy >= 80 ? 'text-emerald-400' : item.accuracy >= 50 ? 'text-amber-400' : 'text-rose-400'}`}>
{item.accuracy}%
</span>
<div className="w-16 h-1.5 bg-slate-800 rounded-full mt-1 overflow-hidden border border-white/5">
<div
className={`h-full transition-all duration-1000 ${item.accuracy >= 80 ? 'bg-emerald-500' : item.accuracy >= 50 ? 'bg-amber-500' : 'bg-rose-500'}`}
style={{ width: `${item.accuracy}%` }}
/>
</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]]?.units > 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,
forecastUnits: acc.forecastUnits + curr.forecastUnits,
}), { actualUnits: 0, forecastUnits: 0 });
}, [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">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-slate-900/50 backdrop-blur-xl border border-white/10 p-5 rounded-2xl shadow-xl">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-indigo-500/20 rounded-lg"><ChartIcon /></div>
<span className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Global Actuals</span>
</div>
<div className="text-3xl font-black text-white tabular-nums">{(globalSummary.actualUnits || 0).toLocaleString('de-DE')}</div>
</div>
<div className="bg-slate-900/50 backdrop-blur-xl border border-white/10 p-5 rounded-2xl shadow-xl">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-emerald-500/20 rounded-lg"><TrendingIcon /></div>
<span className="text-[10px] font-black text-emerald-400 uppercase tracking-widest">Global Forecast</span>
</div>
<div className="text-3xl font-black text-emerald-400 tabular-nums">{(globalSummary.forecastUnits || 0).toLocaleString('de-DE')}</div>
</div>
</div>
<div className="bg-slate-900 border border-white/10 rounded-2xl overflow-hidden shadow-2xl flex-1 flex flex-col">
<div className="p-6 border-b border-white/5 flex flex-col md:flex-row justify-between gap-4 bg-slate-800/20">
<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="overflow-x-auto custom-scrollbar">
<table className="w-full text-left border-collapse">
<thead className="bg-slate-800/40">
<tr>
<th className="px-6 py-4 font-black text-slate-400 uppercase tracking-widest text-[10px] min-w-[300px]">
<div className="flex items-center gap-2">
<span>Product Details</span>
<div className="flex items-center gap-1 scale-75 origin-left">
<InColumnStockFilter
currentFilters={stockFilter}
onFilterChange={onStockFilterChange}
title="Warehouse Stock"
/>
<InColumnStockFilter
currentFilters={vendorStockFilter}
onFilterChange={onVendorStockFilterChange}
title="Vendor Stock"
options={[
'Out of Stock (0)',
'In Stock (>0)',
'In Stock (>20)',
'Low Stock (<10)',
]}
/>
</div>
</div>
</th>
<th className="px-6 py-4 font-black text-slate-400 uppercase tracking-widest text-[10px] text-center">Actuals</th>
<th className="px-6 py-4 font-black text-slate-400 uppercase tracking-widest text-[10px] text-center">Forecast</th>
<th className="px-6 py-4 font-black text-slate-400 uppercase tracking-widest text-[10px] text-center">Avg/Mo</th>
<th className="px-6 py-4 font-black text-slate-400 uppercase tracking-widest text-[10px]">Trend</th>
<th className="px-6 py-4 font-black text-slate-400 uppercase tracking-widest text-[10px] text-center">Accuracy</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{finalFilteredData.slice(0, displayCount).map(p => (
<ForecastRow
key={p.asin}
item={p}
activeMonths={activeMonths}
top50Ranking={top50Ranking}
top50Mode={top50Mode}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
/>
))}
</tbody>
</table>
{displayCount < finalFilteredData.length && (
<div className="p-6 text-center bg-slate-800/20">
<button onClick={() => setDisplayCount(prev => prev + 50)} className="px-6 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-black uppercase tracking-widest">Load More</button>
</div>
)}
</div>
</div>
</div>
);
};
export default ForecastView;