mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 13:05:24 +02:00
Performance optimizations: lazy rendering, row memoization, and data processing loops
This commit is contained in:
@@ -610,7 +610,7 @@ const App: React.FC = () => {
|
||||
{view === 'weekly' && (
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<WeeklyGrid
|
||||
data={filteredAdsData.length > 0 ? mergeSalesAndAdsData(filteredData, filteredAdsData, undefined, trafficData) : filteredData}
|
||||
data={combinedAdsData}
|
||||
onDrillDown={handleSkuDrillDown}
|
||||
stockMap={stockMap}
|
||||
stockFilter={filters.stock}
|
||||
|
||||
+126
-81
@@ -34,10 +34,105 @@ const Top50Badge: React.FC<{ rank: number; label: string; theme?: 'amber' | 'ind
|
||||
);
|
||||
};
|
||||
|
||||
const ForecastRow: React.FC<{
|
||||
item: ForecastViewData;
|
||||
activeMonths: string[];
|
||||
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
|
||||
stockMap?: Map<string, number>;
|
||||
}> = React.memo(({ item, activeMonths, top50Ranking, stockMap }) => {
|
||||
const asin = item.asin.toUpperCase();
|
||||
|
||||
// Top 50 Badges logic
|
||||
const ranks: { rank: number; label: string; theme: 'blue' | 'indigo' }[] = [];
|
||||
if (top50Ranking) {
|
||||
const rankEU = top50Ranking.eu.get(asin);
|
||||
const rankUK = top50Ranking.uk.get(asin);
|
||||
if (rankEU) ranks.push({ rank: rankEU, label: 'EU', theme: 'indigo' });
|
||||
if (rankUK) ranks.push({ rank: rankUK, label: 'UK', theme: 'blue' });
|
||||
}
|
||||
|
||||
// Monthly Forecast Calculation
|
||||
const filteredForecast = item.monthlyData
|
||||
.filter(md => activeMonths.includes(md.month))
|
||||
.reduce((acc, md) => acc + md.forecastUnits, 0);
|
||||
|
||||
// Actual Sales for 2026 (Selected Period)
|
||||
const actualPeriod = item.monthlyData
|
||||
.filter(md => activeMonths.includes(md.month))
|
||||
.reduce((acc, md) => acc + md.actualUnits, 0);
|
||||
|
||||
// Total annual actual for achievement percentage
|
||||
const actualTotal = item.monthlyData.reduce((acc, md) => acc + md.actualUnits, 0);
|
||||
const totalAchievement = item.annualForecast > 0 ? (actualTotal / item.annualForecast) * 100 : 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-3">
|
||||
{ranks.map((r, i) => (
|
||||
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
|
||||
))}
|
||||
<span className="font-black text-sm text-[#818cf8] uppercase tracking-tight">{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>
|
||||
<div className="flex items-start gap-2 text-[13px] font-medium text-slate-100 leading-snug group-hover:text-white transition-colors">
|
||||
<span className="truncate max-w-[170px]">{item.title}</span>
|
||||
{stockMap && (
|
||||
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] font-black text-[#ec4899] uppercase tracking-[0.15em]">
|
||||
{item.line}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right font-mono font-bold text-slate-400">
|
||||
{filteredForecast.toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right font-mono font-bold text-emerald-400">
|
||||
{actualPeriod.toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className={`px-2 py-1 rounded text-xs font-black ${totalAchievement >= 50 ? 'bg-emerald-500/10 text-emerald-400' : totalAchievement >= 10 ? 'bg-indigo-500/10 text-indigo-400' : 'bg-slate-800 text-slate-500'}`}>
|
||||
{totalAchievement.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-500 font-bold uppercase">of Annual {item.annualForecast.toLocaleString('de-DE')}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-24 h-1.5 bg-slate-800 rounded-full overflow-hidden border border-white/5">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 rounded-full ${totalAchievement >= 100 ? 'bg-emerald-500' : totalAchievement >= 50 ? 'bg-indigo-500' : 'bg-amber-500/50'}`}
|
||||
style={{ width: `${Math.min(100, totalAchievement)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{totalAchievement >= 100 ? (
|
||||
<div className="px-1.5 py-0.5 rounded text-[8px] font-black bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 uppercase tracking-tighter animate-pulse">
|
||||
Target Hit
|
||||
</div>
|
||||
) : totalAchievement > 0 ? (
|
||||
<div className="text-[9px] font-bold text-slate-500 italic">
|
||||
{totalAchievement.toFixed(0)}% Done
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
||||
const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu');
|
||||
const [displayCount, setDisplayCount] = useState(50);
|
||||
|
||||
// Auto-detect best Top 50 mode based on data
|
||||
useEffect(() => {
|
||||
@@ -376,92 +471,42 @@ const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{sortedProducts.map((p) => {
|
||||
const asin = p.asin.toUpperCase();
|
||||
|
||||
// Top 50 Badges logic
|
||||
const ranks = [];
|
||||
if (top50Ranking) {
|
||||
const rankEU = top50Ranking.eu.get(asin);
|
||||
const rankUK = top50Ranking.uk.get(asin);
|
||||
if (rankEU) ranks.push({ rank: rankEU, label: 'EU', theme: 'indigo' as const });
|
||||
if (rankUK) ranks.push({ rank: rankUK, label: 'UK', theme: 'blue' as const });
|
||||
}
|
||||
|
||||
// Monthly Forecast Calculation
|
||||
const filteredForecast = p.monthlyData
|
||||
.filter(md => activeMonths.includes(md.month))
|
||||
.reduce((acc, md) => acc + md.forecastUnits, 0);
|
||||
|
||||
// Actual Sales for 2026 (Selected Period)
|
||||
const actualPeriod = p.monthlyData
|
||||
.filter(md => activeMonths.includes(md.month))
|
||||
.reduce((acc, md) => acc + md.actualUnits, 0);
|
||||
|
||||
// Total annual actual for achievement percentage
|
||||
const actualTotal = p.monthlyData.reduce((acc, md) => acc + md.actualUnits, 0);
|
||||
const totalAchievement = p.annualForecast > 0 ? (actualTotal / p.annualForecast) * 100 : 0;
|
||||
|
||||
// YTD Achievement (Achievement of the period)
|
||||
const periodAchievement = filteredForecast > 0 ? (actualPeriod / filteredForecast) * 100 : 0;
|
||||
|
||||
{sortedAndFiltered.length > 0 ? (
|
||||
(() => {
|
||||
const displayItems = sortedAndFiltered.slice(0, displayCount);
|
||||
return (
|
||||
<tr key={p.asin} className="hover:bg-indigo-500/5 transition-colors group">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* ID Row */}
|
||||
<div className="flex items-center gap-3">
|
||||
{ranks.map((r, i) => (
|
||||
<Top50Badge key={i} rank={r.rank} label={r.label} />
|
||||
<>
|
||||
{displayItems.map(p => (
|
||||
<ForecastRow
|
||||
key={p.asin}
|
||||
item={p}
|
||||
activeMonths={activeMonths}
|
||||
top50Ranking={top50Ranking}
|
||||
stockMap={stockMap}
|
||||
/>
|
||||
))}
|
||||
<span className="font-black text-sm text-[#818cf8] uppercase tracking-tight">{p.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">
|
||||
{p.asin}
|
||||
</div>
|
||||
</div>
|
||||
{/* Title Row */}
|
||||
<div className="flex items-start gap-2 text-[13px] font-medium text-slate-100 leading-snug group-hover:text-white transition-colors">
|
||||
<span className="truncate max-w-[170px]">{p.title}</span>
|
||||
{stockMap && (
|
||||
<StockBadge stock={stockMap.get(p.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||
)}
|
||||
</div>
|
||||
{/* Line Row */}
|
||||
<div className="text-[10px] font-black text-[#ec4899] uppercase tracking-[0.15em]">
|
||||
{p.line}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right font-mono font-bold text-slate-400">
|
||||
{filteredForecast.toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right font-mono font-bold text-emerald-400">
|
||||
{actualPeriod.toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className={`px-2 py-1 rounded text-xs font-black ${totalAchievement >= 50 ? 'bg-emerald-500/10 text-emerald-400' : totalAchievement >= 10 ? 'bg-indigo-500/10 text-indigo-400' : 'bg-slate-800 text-slate-500'}`}>
|
||||
{totalAchievement.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-500 font-bold uppercase">of Annual {p.annualForecast.toLocaleString('de-DE')}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-32 h-2 bg-slate-800 rounded-full overflow-hidden border border-white/5">
|
||||
<div
|
||||
className={`h-full transition-all duration-1000 ${periodAchievement >= 100 ? 'bg-emerald-500' : periodAchievement >= 50 ? 'bg-indigo-500' : 'bg-amber-500'}`}
|
||||
style={{ width: `${Math.min(100, periodAchievement)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className={`text-[11px] font-black ${periodAchievement >= 100 ? 'text-emerald-400' : periodAchievement >= 50 ? 'text-indigo-400' : 'text-amber-400'}`}>
|
||||
{periodAchievement.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
{displayCount < sortedAndFiltered.length && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-6 text-center bg-slate-900/40 backdrop-blur-sm border-t border-white/5">
|
||||
<button
|
||||
onClick={() => setDisplayCount(prev => prev + 100)}
|
||||
className="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-black uppercase tracking-widest shadow-xl transition-all active:scale-95 border border-indigo-400/30"
|
||||
>
|
||||
Load More SKUs ({sortedAndFiltered.length - displayCount} remaining)
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
})()
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-10 text-center text-slate-500 italic">
|
||||
No products found matching your filters...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
+118
-75
@@ -56,6 +56,95 @@ const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'bl
|
||||
);
|
||||
};
|
||||
|
||||
const WeeklyRow: React.FC<{
|
||||
row: WeeklyPivotRow;
|
||||
weeks: string[];
|
||||
onDrillDown?: (sku: string) => void;
|
||||
stockMap?: Map<string, number>;
|
||||
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
|
||||
top50Mode: 'eu' | 'uk';
|
||||
sortConfig: SortConfig;
|
||||
renderGrowth: (current: number, previous: number) => React.ReactNode;
|
||||
}> = React.memo(({ row, weeks, onDrillDown, stockMap, top50Ranking, top50Mode, sortConfig, renderGrowth }) => {
|
||||
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
||||
const asin = row.asin.trim().toUpperCase();
|
||||
if (top50Ranking) {
|
||||
if (top50Mode === 'eu') {
|
||||
const rank = top50Ranking.eu.get(asin);
|
||||
if (rank) ranks.push({ rank, label: 'EU', theme: 'indigo' });
|
||||
} else {
|
||||
const rank = top50Ranking.uk.get(asin);
|
||||
if (rank) ranks.push({ rank, label: 'UK', theme: 'blue' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-white/[0.02] transition-colors group">
|
||||
<td className="p-3 py-2 sticky left-0 z-10 bg-slate-900 group-hover:bg-slate-800 border-r border-white/10">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
{ranks.map((r, i) => (
|
||||
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
|
||||
))}
|
||||
<span
|
||||
onClick={() => onDrillDown?.(row.sku)}
|
||||
className={`text-xs font-black uppercase tracking-tighter truncate max-w-[120px] transition-all
|
||||
${onDrillDown ? 'text-indigo-400 cursor-pointer hover:text-indigo-300 hover:underline' : 'text-indigo-400/70'}`}
|
||||
title={onDrillDown ? `Click to see Ads detail for ${row.sku}` : ''}
|
||||
>
|
||||
{row.sku || '-'}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-slate-500 bg-slate-800 px-1.5 py-0.5 rounded border border-white/5">{row.asin}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<span className="text-[11px] text-white/70 truncate w-[190px] leading-tight" title={row.title}>{row.title}</span>
|
||||
{stockMap && (
|
||||
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{row.line}</span>
|
||||
</div>
|
||||
</td>
|
||||
{weeks.map((week, idx) => {
|
||||
const val = row.unitsByWeek[week] || 0;
|
||||
const prevVal = row.unitsByWeek[weeks[idx + 1]] || 0;
|
||||
const spend = row.spendByWeek[week] || 0;
|
||||
const prevSpend = row.spendByWeek[weeks[idx + 1]] || 0;
|
||||
return (
|
||||
<td key={week} className={`p-3 py-2 text-center border-r border-white/5 align-middle ${sortConfig?.key === week ? 'bg-white/[0.01]' : ''}`}>
|
||||
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-sm font-bold ${val > 0 ? (sortConfig?.key === week && sortConfig.metric === 'units' ? 'text-indigo-400' : 'text-white') : 'text-slate-700'}`}>
|
||||
{val > 0 ? val.toLocaleString('de-DE') : '-'}
|
||||
</span>
|
||||
{val > 0 && renderGrowth(val, prevVal)}
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
{spend > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-[11px] font-medium ${sortConfig?.key === week && sortConfig.metric === 'spend' ? 'text-amber-300' : 'text-indigo-400/80'}`}>
|
||||
€{spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
|
||||
</span>
|
||||
{renderGrowth(spend, prevSpend)}
|
||||
</div>
|
||||
)}
|
||||
{row.gvByWeek?.[week] > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-[9px] font-black tracking-tighter ${sortConfig?.key === week && sortConfig.metric === 'gv' ? 'text-teal-300' : 'text-teal-500/70'}`}>
|
||||
GV: {row.gvByWeek[week].toLocaleString('de-DE')}
|
||||
</span>
|
||||
{renderGrowth(row.gvByWeek[week], row.gvByWeek?.[weeks[idx + 1]] || 0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown, stockMap, stockFilter, onStockFilterChange }) => {
|
||||
// Pivot data - memoized
|
||||
const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
|
||||
@@ -70,6 +159,8 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown
|
||||
const [growthThreshold, setGrowthThreshold] = useState(10);
|
||||
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
||||
const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu');
|
||||
const [displayCount, setDisplayCount] = useState(50);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Default sort: most recent week, descending, units
|
||||
const [sortConfig, setSortConfig] = useState<SortConfig>(() => {
|
||||
@@ -492,87 +583,39 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{paginatedRows.length > 0 ? (
|
||||
paginatedRows.map((row) => {
|
||||
const asin = row.asin.trim().toUpperCase();
|
||||
const ranks = [];
|
||||
|
||||
if (top50Ranking) {
|
||||
if (top50Mode === 'eu') {
|
||||
const rank = top50Ranking.eu.get(asin);
|
||||
if (rank) ranks.push({ rank, label: 'EU', theme: 'indigo' as const });
|
||||
} else {
|
||||
const rank = top50Ranking.uk.get(asin);
|
||||
if (rank) ranks.push({ rank, label: 'UK', theme: 'blue' as const });
|
||||
}
|
||||
}
|
||||
|
||||
{sortedRows.length > 0 ? (
|
||||
(() => {
|
||||
const displayRows = sortedRows.slice(0, displayCount);
|
||||
return (
|
||||
<tr key={row.id} className="hover:bg-white/[0.02] transition-colors group">
|
||||
<td className="p-3 py-2 sticky left-0 z-10 bg-slate-900 group-hover:bg-slate-800 border-r border-white/10">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
{ranks.map((r, i) => (
|
||||
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
|
||||
<>
|
||||
{displayRows.map(row => (
|
||||
<WeeklyRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
weeks={weeks}
|
||||
onDrillDown={onDrillDown}
|
||||
stockMap={stockMap}
|
||||
top50Ranking={top50Ranking}
|
||||
top50Mode={top50Mode}
|
||||
sortConfig={sortConfig}
|
||||
renderGrowth={renderGrowth}
|
||||
/>
|
||||
))}
|
||||
<span
|
||||
onClick={() => onDrillDown?.(row.sku)}
|
||||
className={`text-xs font-black uppercase tracking-tighter truncate max-w-[120px] transition-all
|
||||
${onDrillDown ? 'text-indigo-400 cursor-pointer hover:text-indigo-300 hover:underline' : 'text-indigo-400/70'}`}
|
||||
title={onDrillDown ? `Click to see Ads detail for ${row.sku}` : ''}
|
||||
{displayCount < sortedRows.length && (
|
||||
<tr>
|
||||
<td colSpan={weeks.length + 1} className="p-6 text-center bg-slate-900/50 backdrop-blur-sm border-t border-white/5">
|
||||
<button
|
||||
onClick={() => setDisplayCount(prev => prev + 100)}
|
||||
className="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-black uppercase tracking-widest shadow-xl transition-all active:scale-95 border border-indigo-400/30"
|
||||
>
|
||||
{row.sku || '-'}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-slate-500 bg-slate-800 px-1.5 py-0.5 rounded border border-white/5">{row.asin}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<span className="text-[11px] text-white/70 truncate w-[190px] leading-tight" title={row.title}>{row.title}</span>
|
||||
{stockMap && (
|
||||
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{row.line}</span>
|
||||
</div>
|
||||
Load More SKUs ({sortedRows.length - displayCount} remaining)
|
||||
</button>
|
||||
</td>
|
||||
{weeks.map((week, idx) => {
|
||||
const val = row.unitsByWeek[week] || 0;
|
||||
const prevVal = row.unitsByWeek[weeks[idx + 1]] || 0;
|
||||
const spend = row.spendByWeek[week] || 0;
|
||||
const prevSpend = row.spendByWeek[weeks[idx + 1]] || 0;
|
||||
return (
|
||||
<td key={week} className={`p-3 py-2 text-center border-r border-white/5 align-middle ${sortConfig?.key === week ? 'bg-white/[0.01]' : ''}`}>
|
||||
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-sm font-bold ${val > 0 ? (sortConfig?.key === week && sortConfig.metric === 'units' ? 'text-indigo-400' : 'text-white') : 'text-slate-700'}`}>
|
||||
{val > 0 ? val.toLocaleString('de-DE') : '-'}
|
||||
</span>
|
||||
{val > 0 && renderGrowth(val, prevVal)}
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
{spend > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-[11px] font-medium ${sortConfig?.key === week && sortConfig.metric === 'spend' ? 'text-amber-300' : 'text-indigo-400/80'}`}>
|
||||
€{spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
|
||||
</span>
|
||||
{renderGrowth(spend, prevSpend)}
|
||||
</div>
|
||||
)}
|
||||
{row.gvByWeek?.[week] > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-[9px] font-black tracking-tighter ${sortConfig?.key === week && sortConfig.metric === 'gv' ? 'text-teal-300' : 'text-teal-500/70'}`}>
|
||||
GV: {row.gvByWeek[week].toLocaleString('de-DE')}
|
||||
</span>
|
||||
{renderGrowth(row.gvByWeek[week], row.gvByWeek?.[weeks[idx + 1]] || 0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})
|
||||
})()
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={weeks.length + 1} className="p-10 text-center text-slate-500 italic text-base">
|
||||
|
||||
+32
-30
@@ -511,13 +511,14 @@ export const mergeSalesAndAdsData = (
|
||||
const createKey = (asin: string, customer: string, year: number, week: number) =>
|
||||
`${asin.trim().toUpperCase()}|${customer.trim().toUpperCase()}|${year}|${week}`;
|
||||
|
||||
// Build traffic lookup map
|
||||
// Build traffic lookup map - Use a more efficient key
|
||||
const trafficMap = new Map<string, number>();
|
||||
if (trafficData) {
|
||||
trafficData.forEach(t => {
|
||||
const key = createKey(t.asin, t.country, t.year, t.week);
|
||||
trafficMap.set(key, (trafficMap.get(key) || 0) + t.glanceViews);
|
||||
});
|
||||
for (let i = 0; i < trafficData.length; i++) {
|
||||
const t = trafficData[i];
|
||||
const key = `${t.asin.trim().toUpperCase()}|${t.country.trim().toUpperCase()}|${t.year}|${t.week}`;
|
||||
trafficMap.set(key, (trafficMap.get(key) || 0) + (t.glanceViews || 0));
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Initialize metadata lookup map with provided global map if available, otherwise build from current sales
|
||||
@@ -537,23 +538,24 @@ export const mergeSalesAndAdsData = (
|
||||
month: string;
|
||||
}>();
|
||||
|
||||
salesData.forEach(sale => {
|
||||
for (let i = 0; i < salesData.length; i++) {
|
||||
const sale = salesData[i];
|
||||
const weekNum = sale.week || 0;
|
||||
if (weekNum === 0) return;
|
||||
if (weekNum === 0) continue;
|
||||
|
||||
const key = createKey(sale.asin, sale.customer, sale.year, weekNum);
|
||||
const asinUpper = sale.asin.trim().toUpperCase();
|
||||
const key = `${asinUpper}|${sale.customer.trim().toUpperCase()}|${sale.year}|${weekNum}`;
|
||||
|
||||
// If no global map provided, build it on the fly
|
||||
if (!asinMetadataMap) {
|
||||
const metaKey = sale.asin.trim().toUpperCase();
|
||||
const existingMeta = asinMetadata.get(metaKey);
|
||||
const existingMeta = asinMetadata.get(asinUpper);
|
||||
if (!existingMeta || (sale.title && sale.title.length > (existingMeta.title?.length || 0))) {
|
||||
asinMetadata.set(metaKey, { sku: sale.sku, title: sale.title, line: sale.line });
|
||||
asinMetadata.set(asinUpper, { sku: sale.sku, title: sale.title, line: sale.line });
|
||||
}
|
||||
}
|
||||
|
||||
if (salesMap.has(key)) {
|
||||
const existing = salesMap.get(key)!;
|
||||
const existing = salesMap.get(key);
|
||||
if (existing) {
|
||||
existing.sellOut += sale.sellOut;
|
||||
existing.units += sale.units;
|
||||
if (sale.title && sale.title.length > (existing.title?.length || 0)) {
|
||||
@@ -576,14 +578,15 @@ export const mergeSalesAndAdsData = (
|
||||
month: sale.month
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Aggregate Ads by ASIN|Customer|Year|Week
|
||||
const adsMap = new Map<string, AdsRecord>();
|
||||
adsData.forEach(ad => {
|
||||
const key = createKey(ad.asin, ad.country, ad.year, ad.week);
|
||||
if (adsMap.has(key)) {
|
||||
const existing = adsMap.get(key)!;
|
||||
for (let i = 0; i < adsData.length; i++) {
|
||||
const ad = adsData[i];
|
||||
const key = `${ad.asin.trim().toUpperCase()}|${ad.country.trim().toUpperCase()}|${ad.year}|${ad.week}`;
|
||||
const existing = adsMap.get(key);
|
||||
if (existing) {
|
||||
existing.cost += ad.cost;
|
||||
existing.clicks += ad.clicks;
|
||||
existing.impressions += ad.impressions;
|
||||
@@ -593,7 +596,7 @@ export const mergeSalesAndAdsData = (
|
||||
} else {
|
||||
adsMap.set(key, { ...ad });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const mergedData: CombinedKPIs[] = [];
|
||||
const processedKeys = new Set<string>();
|
||||
@@ -1492,13 +1495,14 @@ export const pivotWeeklySalesData = (data: CombinedKPIs[]): {
|
||||
|
||||
const map = new Map<string, WeeklyPivotRow>();
|
||||
|
||||
data.forEach(record => {
|
||||
// Use ASIN as primary key since mergeSalesAndAdsData outputs one record per ASIN/week
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const record = data[i];
|
||||
const key = record.asin || record.sku || `${record.title}-${record.line}`;
|
||||
if (!key) return;
|
||||
if (!key) continue;
|
||||
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
let row = map.get(key);
|
||||
if (!row) {
|
||||
row = {
|
||||
id: key,
|
||||
sku: record.sku || '',
|
||||
title: record.title || '',
|
||||
@@ -1508,19 +1512,17 @@ export const pivotWeeklySalesData = (data: CombinedKPIs[]): {
|
||||
unitsByWeek: {},
|
||||
spendByWeek: {},
|
||||
gvByWeek: {}
|
||||
});
|
||||
};
|
||||
map.set(key, row);
|
||||
}
|
||||
|
||||
const row = map.get(key)!;
|
||||
if (record.week) {
|
||||
const weekKey = `${record.year}-${String(record.week).padStart(2, '0')}`;
|
||||
row.unitsByWeek[weekKey] = (row.unitsByWeek[weekKey] || 0) + record.unitsTotal;
|
||||
// Only add cost if we haven't already added it for this ASIN/week
|
||||
// Since mergeSalesAndAdsData now outputs one record per ASIN/week, this should be clean
|
||||
row.unitsByWeek[weekKey] = (row.unitsByWeek[weekKey] || 0) + (record.unitsTotal || 0);
|
||||
row.spendByWeek[weekKey] = (row.spendByWeek[weekKey] || 0) + (record.cost || 0);
|
||||
row.gvByWeek[weekKey] = (row.gvByWeek[weekKey] || 0) + (record.glanceViews || 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rows: Array.from(map.values()),
|
||||
|
||||
Reference in New Issue
Block a user