mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 16:35:23 +02:00
Optimize performance (persistent tabs, pagination) and add sortable columns + stock badges to ForecastView
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user