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
+28 -21
View File
@@ -646,9 +646,12 @@ const App: React.FC = () => {
<> <>
<FilterBar filters={filters} onFilterChange={handleFilterChange} options={filterOptions} /> <FilterBar filters={filters} onFilterChange={handleFilterChange} options={filterOptions} />
<div className="mt-6"> <div className="mt-6">
{view === 'dashboard' && <Dashboard data={aggregatedData} filterState={filters} adsData={filteredAdsData} stockMap={stockMap} />} <div className={view === 'dashboard' ? '' : 'hidden'}>
{view === 'table' && ( <Dashboard data={aggregatedData} filterState={filters} adsData={filteredAdsData} stockMap={stockMap} />
<Suspense fallback={<LoadingSpinner />}> </div>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'table' ? '' : 'hidden'}>
<DataGrid <DataGrid
data={combinedAdsData} data={combinedAdsData}
hasCustomerFilter={filters.customer.length > 0} hasCustomerFilter={filters.customer.length > 0}
@@ -658,10 +661,11 @@ const App: React.FC = () => {
top50Ranking={top50Ranking2025} top50Ranking={top50Ranking2025}
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
/> />
</Suspense> </div>
)} </Suspense>
{view === 'weekly' && (
<Suspense fallback={<LoadingSpinner />}> <Suspense fallback={<LoadingSpinner />}>
<div className={view === 'weekly' ? '' : 'hidden'}>
<WeeklyGrid <WeeklyGrid
data={combinedAdsData} data={combinedAdsData}
onDrillDown={handleSkuDrillDown} onDrillDown={handleSkuDrillDown}
@@ -676,15 +680,17 @@ const App: React.FC = () => {
top50Ranking={top50Ranking2025} top50Ranking={top50Ranking2025}
velocityMap={velocityMap} velocityMap={velocityMap}
/> />
</Suspense> </div>
)} </Suspense>
{view === 'movers' && (
<Suspense fallback={<LoadingSpinner />}> <Suspense fallback={<LoadingSpinner />}>
<div className={view === 'movers' ? '' : 'hidden'}>
<TopMovers data={filteredData} stockMap={stockMap} /> <TopMovers data={filteredData} stockMap={stockMap} />
</Suspense> </div>
)} </Suspense>
{view === 'ads' && (
<Suspense fallback={<LoadingSpinner />}> <Suspense fallback={<LoadingSpinner />}>
<div className={view === 'ads' ? '' : 'hidden'}>
<AdsPerformance <AdsPerformance
data={combinedAdsData} data={combinedAdsData}
filters={filters} filters={filters}
@@ -697,10 +703,11 @@ const App: React.FC = () => {
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))} onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
/> />
</Suspense> </div>
)} </Suspense>
{view === 'forecast' && (
<Suspense fallback={<LoadingSpinner />}> <Suspense fallback={<LoadingSpinner />}>
<div className={view === 'forecast' ? '' : 'hidden'}>
<ForecastView <ForecastView
data={forecastData} data={forecastData}
filters={filters} filters={filters}
@@ -713,8 +720,8 @@ const App: React.FC = () => {
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))} onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
/> />
</Suspense> </div>
)} </Suspense>
</div> </div>
</> </>
)} )}
+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"> <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} {item.line}
</span> </span>
{/* Own Stock Indicator */}
{stockMap && (
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
)}
{/* WOC Indicator preserved */} {/* WOC Indicator preserved */}
<VendorStockBadge asin={item.asin} vendorStockMap={vendorStockMap} mode={top50Mode} avgWeeklySales={item.avgWeeklySales} /> <VendorStockBadge asin={item.asin} vendorStockMap={vendorStockMap} mode={top50Mode} avgWeeklySales={item.avgWeeklySales} />
</div> </div>
@@ -161,30 +165,26 @@ const ForecastView: React.FC<ForecastViewProps> = ({
top50Mode top50Mode
}) => { }) => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useMemo(() => searchTerm.toLowerCase(), [searchTerm]);
const [showOnlyTop50, setShowOnlyTop50] = useState(false); const [showOnlyTop50, setShowOnlyTop50] = useState(false);
const [displayCount, setDisplayCount] = useState(50); const [displayCount, setDisplayCount] = useState(50);
const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' }>({ key: 'actualUnits', direction: 'desc' });
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 activeMonths = useMemo(() => { const activeMonths = useMemo(() => {
if (filters.month && filters.month.length > 0) { if (filters.month && filters.month.length > 0) {
return filters.month.map(m => m.split('-')[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(); const currentMonthIdx = new Date().getMonth();
return MONTH_ORDER.slice(0, currentMonthIdx + 1); return MONTH_ORDER.slice(0, currentMonthIdx + 1);
}, [filters.month]); }, [filters.month]);
const handleSort = (key: string) => {
setSortConfig(prev => ({
key,
direction: prev.key === key && prev.direction === 'desc' ? 'asc' : 'desc'
}));
};
const baseFilteredData = useMemo(() => { const baseFilteredData = useMemo(() => {
let result = data; let result = data;
if (filters.line && filters.line.length > 0) result = result.filter(p => filters.line.includes(p.line)); 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()); return Array.from(dataMap.values());
}, [baseFilteredData]); }, [baseFilteredData]);
const finalFilteredData = useMemo(() => { const processedData = useMemo(() => {
let result = baseFilteredData; 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) { if (showOnlyTop50 && top50Ranking) {
const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk; const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
result = result.filter(p => currentRankMap.has(p.asin.toUpperCase())); result = result.filter(p => currentRankMap.has(p.asin.toUpperCase()));
} }
if (searchTerm) {
const s = searchTerm.toLowerCase(); if (debouncedSearch) {
result = result.filter(p => p.sku.toLowerCase().includes(s) || p.asin.toLowerCase().includes(s) || p.title.toLowerCase().includes(s)); 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; 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 handleExportExcel = useCallback(() => {
const exportData = finalFilteredData.map(p => ({ const exportData = processedData.map(p => ({
SKU: p.sku, SKU: p.sku,
ASIN: p.asin, ASIN: p.asin,
Title: p.title, Title: p.title,
Line: p.line, Line: p.line,
'Actual Units (2025)': p.actualUnits, 'Forecast (Period)': p.forecastPeriod,
'Forecast Units (2025)': p.forecastUnits, 'Actual Units (2026)': p.actualUnits,
'Accuracy (%)': p.accuracy 'FC 26 Achievement (%)': p.fcAchievement,
'YTD Achievement (%)': p.ytdAchievement
})); }));
const ws = XLSX.utils.json_to_sheet(exportData); const ws = XLSX.utils.json_to_sheet(exportData);
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Forecast'); XLSX.utils.book_append_sheet(wb, ws, 'Forecast');
XLSX.writeFile(wb, `Forecast_Export_${new Date().toISOString().slice(0, 10)}.xlsx`); 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 ( return (
<div className="flex flex-col gap-6 animate-fade-in p-6 h-full overflow-hidden"> <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> </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"> <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"> <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> </h3>
<div className="flex items-center gap-4"> <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) && ( {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"> <div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm">
<button <button
@@ -348,17 +390,27 @@ const ForecastView: React.FC<ForecastViewProps> = ({
<div className="flex-1 overflow-auto min-h-0 custom-scrollbar relative"> <div className="flex-1 overflow-auto min-h-0 custom-scrollbar relative">
<table className="w-full text-left border-collapse 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> <tr>
<th className="px-6 py-4 font-black text-white w-[35%]">Product Info</th> <th className="px-6 py-4 text-white w-[35%] cursor-pointer hover:bg-white/5 transition-colors" onClick={() => handleSort('sku')}>
<th className="px-6 py-4 text-center w-[12%]">Forecast (Period)</th> Product Info <SortIndicator column="sku" />
<th className="px-6 py-4 text-center w-[12%]">Actual Units Sales 2026</th> </th>
<th className="px-6 py-4 text-center w-[15%]">FC 26 Achievement</th> <th className="px-6 py-4 text-center w-[12%] cursor-pointer hover:bg-white/5 transition-colors" onClick={() => handleSort('forecastPeriod')}>
<th className="px-6 py-4 text-center w-[26%]">YTD Achievement</th> 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> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800/50"> <tbody className="divide-y divide-slate-800/50">
{finalFilteredData.map(item => ( {paginatedData.map(item => (
<ForecastRow <ForecastRow
key={item.asin} key={item.asin}
item={item} item={item}
@@ -372,7 +424,18 @@ const ForecastView: React.FC<ForecastViewProps> = ({
</tbody> </tbody>
</table> </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"> <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> <p className="text-lg">No forecast data found for current filters</p>
</div> </div>