Optimize performance (persistent tabs, pagination) and add sortable columns + stock badges to ForecastView

This commit is contained in:
Christian Vidal Wolf
2026-01-28 15:11:47 +01:00
parent 1b362d0660
commit d40740fce7
2 changed files with 125 additions and 55 deletions
+97 -34
View File
@@ -96,6 +96,10 @@ const ForecastRow: React.FC<{
<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} />
</div>
@@ -161,30 +165,26 @@ const ForecastView: React.FC<ForecastViewProps> = ({
top50Mode
}) => {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useMemo(() => searchTerm.toLowerCase(), [searchTerm]);
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
const [displayCount, setDisplayCount] = useState(50);
const lastDataMonthIdx = useMemo(() => {
let maxDataMonth = 0;
for (let i = MONTH_ORDER.length - 1; i >= 0; i--) {
if (data.some(p => (p.monthlyData?.[MONTH_ORDER[i]]?.actualUnits || 0) > 0)) {
maxDataMonth = i;
break;
}
}
const currentMonth = new Date().getMonth(); // 0 for Jan
return Math.min(maxDataMonth, currentMonth);
}, [data]);
const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' }>({ key: 'actualUnits', direction: 'desc' });
const activeMonths = useMemo(() => {
if (filters.month && filters.month.length > 0) {
return filters.month.map(m => m.split('-')[0]);
}
// Default to YTD: Current month and all before it this year
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));
@@ -225,34 +225,75 @@ const ForecastView: React.FC<ForecastViewProps> = ({
return Array.from(dataMap.values());
}, [baseFilteredData]);
const finalFilteredData = useMemo(() => {
let result = baseFilteredData;
const processedData = useMemo(() => {
let result = 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
};
});
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));
if (debouncedSearch) {
result = result.filter(p =>
p.sku.toLowerCase().includes(debouncedSearch) ||
p.asin.toLowerCase().includes(debouncedSearch) ||
p.title.toLowerCase().includes(debouncedSearch)
);
}
// 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;
}, [baseFilteredData, searchTerm, showOnlyTop50, top50Ranking, top50Mode]);
}, [baseFilteredData, debouncedSearch, showOnlyTop50, top50Ranking, top50Mode, sortConfig, activeMonths]);
const paginatedData = useMemo(() => processedData.slice(0, displayCount), [processedData, displayCount]);
const handleExportExcel = useCallback(() => {
const exportData = finalFilteredData.map(p => ({
const exportData = processedData.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
'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`);
}, [finalFilteredData]);
}, [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-6 animate-fade-in p-6 h-full overflow-hidden">
@@ -318,12 +359,13 @@ const ForecastView: React.FC<ForecastViewProps> = ({
</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">
<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-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 <span className="text-xs text-slate-500 font-normal normal-case ml-2">(v2.4 Final)</span>
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
@@ -348,17 +390,27 @@ const ForecastView: React.FC<ForecastViewProps> = ({
<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">
<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 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>
<th className="px-6 py-4 text-white w-[35%] cursor-pointer hover:bg-white/5 transition-colors" onClick={() => handleSort('sku')}>
Product Info <SortIndicator column="sku" />
</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-[15%] 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-[26%] 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">
{finalFilteredData.map(item => (
{paginatedData.map(item => (
<ForecastRow
key={item.asin}
item={item}
@@ -372,7 +424,18 @@ const ForecastView: React.FC<ForecastViewProps> = ({
</tbody>
</table>
{finalFilteredData.length === 0 && (
{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>