Files
CrazeAnalytix/components/ForecastView.tsx
T

669 lines
36 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 { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons';
import { Top50Badge } from './Top50Badge';
import { VendorStockBadge } from './VendorStockBadge';
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
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';
import { ExcelFilter } from './ExcelFilter';
import { ColumnFilterCondition } from '../types';
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;
wocFilter: string[];
onWocFilterChange: (newFilters: string[]) => void;
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
}
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 }>;
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
}> = React.memo(({ item, activeMonths, top50Ranking, top50Mode, stockMap, vendorStockMap, buyBoxLostMap }) => {
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
2026-01-28 12:49:41 +01:00
// Forecast Period: Sum of forecast units for active months (YTD)
const forecastPeriod = useMemo(() => {
return activeMonths.reduce((sum, month) => {
return sum + (item.monthlyData?.[month]?.forecastUnits || 0);
}, 0);
}, [item, activeMonths]);
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 relative hover:z-50">
{/* 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>
{/* Own Stock Indicator */}
{stockMap && (
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
)}
{/* WOC Indicator preserved */}
<VendorStockBadge asin={item.asin} vendorStockMap={vendorStockMap} mode={top50Mode} avgWeeklySales={item.avgWeeklySales} />
<BuyBoxWarningBadge asin={asin} buyBoxLostMap={buyBoxLostMap} />
</div>
</div>
</td>
{/* Annual Forecast */}
<td className="px-6 py-4 text-center">
<span className="text-sm font-bold text-slate-400">
{(item.annualForecast || 0).toLocaleString('de-DE')}
</span>
</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,
wocFilter,
onWocFilterChange,
top50Mode,
buyBoxLostMap
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchTerm.toLowerCase());
}, 300);
return () => clearTimeout(timer);
}, [searchTerm]);
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
const [displayCount, setDisplayCount] = useState(50);
const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' }>({ key: 'actualUnits', direction: 'desc' });
// State for Column Filters (SKU, ASIN, Title, Line)
const [columnFilters, setColumnFilters] = useState<Record<string, ColumnFilterCondition>>({});
const handleColumnFilterChange = (columnKey: string, condition: ColumnFilterCondition | undefined) => {
setColumnFilters(prev => {
const next = { ...prev };
if (condition) next[columnKey] = condition;
else delete next[columnKey];
return next;
});
};
const activeMonths = useMemo(() => {
if (filters.month && filters.month.length > 0) {
return filters.month.map(m => m.split('-')[0]);
}
const currentMonthIdx = new Date().getMonth();
return MONTH_ORDER.slice(0, currentMonthIdx + 1);
}, [filters.month]);
const handleSort = (key: string) => {
setSortConfig(prev => ({
key,
direction: prev.key === key && prev.direction === 'desc' ? 'asc' : 'desc'
}));
};
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]);
// [MOVED] chartData moved below processedData to depend on it
// [MOVED] globalSummary moved below processedData to depend on it
const calculatedData = useMemo(() => {
return baseFilteredData.map(item => {
const forecastPeriod = activeMonths.reduce((sum, month) => {
return sum + (item.monthlyData?.[month]?.forecastUnits || 0);
}, 0);
const ytdAchievement = forecastPeriod > 0 ? ((item.actualUnits || 0) / forecastPeriod) * 100 : 0;
const fcAchievement = (item.annualForecast || 0) > 0 ? ((item.actualUnits || 0) / item.annualForecast) * 100 : 0;
return {
...item,
forecastPeriod,
ytdAchievement,
fcAchievement
};
});
}, [baseFilteredData, activeMonths]);
const processedData = useMemo(() => {
let result = [...calculatedData];
if (showOnlyTop50 && top50Ranking) {
const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
result = result.filter(p => currentRankMap.has(p.asin.toUpperCase()));
}
if (debouncedSearch) {
const searchTerms = debouncedSearch.toLowerCase().split(/[\s,]+/).filter(term => term.length > 0);
if (searchTerms.length > 0) {
result = result.filter(p => {
const rowValues = [
p.sku.toLowerCase(),
p.asin.toLowerCase(),
p.title.toLowerCase()
];
return searchTerms.some(term =>
rowValues.some(val => val.includes(term))
);
});
}
}
// Column Filters (Excel-style)
if (Object.keys(columnFilters).length > 0) {
(Object.entries(columnFilters) as [string, ColumnFilterCondition][]).forEach(([key, condition]) => {
if (!condition) return;
if (condition.selectedValues && condition.selectedValues.length > 0) {
result = result.filter(p => {
const val = String((p as any)[key] || '');
return condition.selectedValues?.includes(val);
});
}
if (condition.textFilter) {
const { operator, value } = condition.textFilter;
const lowerValue = value.toLowerCase();
result = result.filter(p => {
const rowVal = String((p as any)[key] || '').toLowerCase();
switch (operator) {
case 'equals': return rowVal === lowerValue;
case 'notEquals': return rowVal !== lowerValue;
case 'contains': return rowVal.includes(lowerValue);
case 'notContains': return !rowVal.includes(lowerValue);
case 'startsWith': return rowVal.startsWith(lowerValue);
case 'notStartsWith': return !rowVal.startsWith(lowerValue);
case 'endsWith': return rowVal.endsWith(lowerValue);
case 'notEndsWith': return !rowVal.endsWith(lowerValue);
default: return true;
}
});
}
});
}
// Apply WOC Filter
if (wocFilter && wocFilter.length > 0 && vendorStockMap) {
result = result.filter(p => {
const asin = p.asin.trim().toUpperCase();
const stockData = vendorStockMap.get(asin);
if (!stockData) return false;
const stock = top50Mode === 'uk' ? stockData.uk : stockData.eu;
const velocity = p.avgWeeklySales || 0;
let woc: number = 0;
if (velocity > 0) {
woc = stock / velocity;
} else if (stock > 0) {
woc = 999;
}
return wocFilter.some(f => {
if (f === '< 4 Weeks') return woc < 4;
if (f === '> 4 Weeks') return woc >= 4;
if (f === 'Out of Stock') return woc === 0;
if (f === 'Infinite Cover') return woc === 999;
return true;
});
});
}
// Apply Sorting
const { key, direction } = sortConfig;
result.sort((a: any, b: any) => {
let valA = a[key];
let valB = b[key];
if (typeof valA === 'string') valA = valA.toLowerCase();
if (typeof valB === 'string') valB = valB.toLowerCase();
if (valA < valB) return direction === 'asc' ? -1 : 1;
if (valA > valB) return direction === 'asc' ? 1 : -1;
return 0;
});
return result;
}, [calculatedData, debouncedSearch, showOnlyTop50, top50Ranking, top50Mode, sortConfig, wocFilter, vendorStockMap, columnFilters]);
// [MOVED HERE] Global Summary - Now respects all filters including Search/WOC
const globalSummary = useMemo(() => {
return processedData.reduce((acc, curr) => {
const itemPeriodForecast = activeMonths.reduce((sum, month) => {
return sum + (curr.monthlyData?.[month]?.forecastUnits || 0);
}, 0);
return {
actualUnits: acc.actualUnits + (curr.actualUnits || 0),
forecastUnits: acc.forecastUnits + itemPeriodForecast,
annualForecast: acc.annualForecast + (curr.annualForecast || 0),
};
}, { actualUnits: 0, forecastUnits: 0, annualForecast: 0 });
}, [processedData, activeMonths]);
// [MOVED HERE] Chart Data - Now respects all filters including Search/WOC
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 }));
processedData.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());
}, [processedData]);
const paginatedData = useMemo(() => processedData.slice(0, displayCount), [processedData, displayCount]);
const handleExportExcel = useCallback(() => {
const exportData = processedData.map(p => ({
SKU: p.sku,
ASIN: p.asin,
Title: p.title,
Line: p.line,
'Annual Forecast': p.annualForecast,
'Forecast (Period)': p.forecastPeriod,
'Actual Units (2026)': p.actualUnits,
'FC 26 Achievement (%)': p.fcAchievement,
'YTD Achievement (%)': p.ytdAchievement
}));
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`);
}, [processedData]);
const SortIndicator = ({ column }: { column: string }) => {
if (sortConfig.key !== column) return <span className="ml-1 opacity-20"></span>;
return <span className="ml-1 text-indigo-400">{sortConfig.direction === 'desc' ? '↓' : '↑'}</span>;
};
return (
<div className="flex flex-col gap-4 md:gap-6 animate-fade-in p-3 md:p-6 h-full overflow-hidden">
<div className="flex flex-col xl:flex-row gap-4 md:gap-6 h-auto xl:h-[400px] shrink-0">
<div className="flex flex-col gap-4 min-w-[300px] xl:w-[25%]">
<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>
<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>
<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>
<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>
<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>
<div className="bg-slate-900 border border-white/10 rounded-2xl overflow-hidden shadow-2xl flex-1 flex flex-col min-h-0 relative">
<div className="p-2 md:p-4 border-b border-white/5 flex flex-col md:flex-row justify-between gap-2 md:gap-4 bg-slate-800/20 shrink-0">
<h3 className="text-sm md:text-lg font-bold text-white uppercase tracking-tight">
Product Performance Comparison <span className="text-xs text-slate-500 font-normal normal-case ml-2">(v2.5 optimized)</span>
</h3>
<div className="flex items-center gap-4">
<div className="text-[10px] font-bold text-slate-500 uppercase">Showing {paginatedData.length} of {processedData.length}</div>
{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>
<InColumnStockFilter
currentFilters={stockFilter}
onFilterChange={onStockFilterChange}
title="Warehouse Stock"
icon={<WarehouseIcon className="w-3.5 h-3.5 text-fuchsia-400" />}
/>
<InColumnStockFilter
currentFilters={vendorStockFilter}
onFilterChange={onVendorStockFilterChange}
title="Vendor Stock"
icon={<AmazonSmileIcon className="w-5 h-5 text-amber-500" />}
options={[
'Out of Stock (0)',
'In Stock (>0)',
'In Stock (>20)',
'Low Stock (<10)',
]}
/>
<InColumnStockFilter
currentFilters={wocFilter}
onFilterChange={onWocFilterChange}
title="Week Coverage"
icon={<CoverageIcon className="w-3.5 h-3.5 text-emerald-400" />}
options={[
'< 4 Weeks',
'> 4 Weeks',
'Out of Stock',
'Infinite Cover'
]}
/>
</div>
</div>
<div className="flex-1 overflow-auto min-h-0 custom-scrollbar scroll-touch relative">
<table className="w-full text-left border-collapse relative text-[10px] md:text-sm">
<thead className="sticky top-0 z-20 bg-slate-950 shadow-sm text-[10px] font-black text-slate-500 uppercase tracking-wider">
<tr>
<th className="px-6 py-4 text-left w-[30%]">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span
className="text-[10px] font-black text-slate-400 uppercase tracking-widest cursor-pointer hover:text-white"
onClick={() => handleSort('sku')}
>
Product Info <SortIndicator column="sku" />
</span>
<div className="flex items-center gap-1">
<ExcelFilter
columnKey="sku"
title="SKU"
uniqueValues={Array.from(new Set(data.map(d => d.sku))).sort()}
currentFilter={columnFilters['sku']}
onFilterChange={handleColumnFilterChange}
icon={<span className="text-[10px]">SKU</span>}
/>
<ExcelFilter
columnKey="asin"
title="ASIN"
uniqueValues={Array.from(new Set(data.map(d => d.asin))).sort()}
currentFilter={columnFilters['asin']}
onFilterChange={handleColumnFilterChange}
icon={<span className="text-[10px]">ASIN</span>}
/>
<ExcelFilter
columnKey="title"
title="Title"
uniqueValues={Array.from(new Set(data.map(d => d.title))).sort()}
currentFilter={columnFilters['title']}
onFilterChange={handleColumnFilterChange}
icon={<span className="text-[10px]">Title</span>}
/>
</div>
</div>
<div className="flex items-center gap-1">
<InColumnStockFilter
currentFilters={stockFilter}
onFilterChange={onStockFilterChange}
title="Warehouse Stock"
icon={<WarehouseIcon className="w-3" />}
/>
<InColumnStockFilter
currentFilters={vendorStockFilter}
onFilterChange={onVendorStockFilterChange}
title="Vendor Stock"
icon={<AmazonSmileIcon className="w-max" />}
options={[
'Out of Stock (0)',
'In Stock (>0)',
'In Stock (>20)',
'Low Stock (<10)',
]}
/>
<InColumnStockFilter
currentFilters={wocFilter}
onFilterChange={onWocFilterChange}
title="Week Coverage"
icon={<CoverageIcon className="w-3" />}
options={[
'< 4 Weeks',
'> 4 Weeks',
'Out of Stock',
'Infinite Cover'
]}
/>
</div>
</div>
</th>
<th className="px-6 py-4 text-center w-[12%] cursor-pointer hover:bg-white/5 transition-colors" onClick={() => handleSort('annualForecast')}>
Annual Forecast <SortIndicator column="annualForecast" />
</th>
<th className="px-6 py-4 text-center w-[12%] cursor-pointer hover:bg-white/5 transition-colors" onClick={() => handleSort('forecastPeriod')}>
Forecast (Period) <SortIndicator column="forecastPeriod" />
</th>
<th className="px-6 py-4 text-center w-[12%] cursor-pointer hover:bg-white/5 transition-colors" onClick={() => handleSort('actualUnits')}>
Actual Units 2026 <SortIndicator column="actualUnits" />
</th>
<th className="px-6 py-4 text-center w-[12%] cursor-pointer hover:bg-white/5 transition-colors" onClick={() => handleSort('fcAchievement')}>
FC 26 Achievement <SortIndicator column="fcAchievement" />
</th>
<th className="px-6 py-4 text-center w-[22%] cursor-pointer hover:bg-white/5 transition-colors" onClick={() => handleSort('ytdAchievement')}>
YTD Achievement <SortIndicator column="ytdAchievement" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{paginatedData.map(item => (
<ForecastRow
key={item.asin}
item={item}
activeMonths={activeMonths}
top50Ranking={top50Ranking}
top50Mode={top50Mode}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
buyBoxLostMap={buyBoxLostMap}
/>
))}
</tbody>
</table>
{displayCount < processedData.length && (
<div className="p-8 flex justify-center">
<button
onClick={() => setDisplayCount(prev => prev + 50)}
className="px-8 py-3 bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-black uppercase tracking-widest rounded-xl shadow-lg transition-all active:scale-95"
>
Load More Products
</button>
</div>
)}
{processedData.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;