mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 15:05:22 +02:00
feat: implement country-aware Top 50 ranking
- App.tsx: Update top50Ranking2025 to calculate ranks based on active country filters - App.tsx: Add split Pan-EU vs UK ranking when no filters are active - WeeklyGrid.tsx: Add support for multiple ranking badges (EU/UK) - WeeklyGrid.tsx: Enhance Top50Badge with labels and color themes - WeeklyGrid.tsx: Update sorting and filtering logic for new ranking structure
This commit is contained in:
@@ -254,31 +254,48 @@ const App: React.FC = () => {
|
||||
const filteredAdsData = useMemo(() => filterAdsData(adsData, filters), [adsData, filters]);
|
||||
const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]);
|
||||
|
||||
// Calculate static Top 50 Best Sellers for 2025 (independent of current filters)
|
||||
// Calculate country-aware Top 50 Best Sellers for 2025
|
||||
const top50Ranking2025 = useMemo(() => {
|
||||
// Filter only 2025 data
|
||||
const data2025 = rawData.filter(r => r.year === 2025);
|
||||
|
||||
// Aggregate Sell Out by ASIN
|
||||
const asinTotals = new Map<string, number>();
|
||||
data2025.forEach(r => {
|
||||
const asin = r.asin.trim().toUpperCase();
|
||||
asinTotals.set(asin, (asinTotals.get(asin) || 0) + r.sellOut);
|
||||
});
|
||||
const calculateTop50 = (entries: SalesRecord[]) => {
|
||||
const asinTotals = new Map<string, number>();
|
||||
entries.forEach(r => {
|
||||
const asin = r.asin.trim().toUpperCase();
|
||||
asinTotals.set(asin, (asinTotals.get(asin) || 0) + r.sellOut);
|
||||
});
|
||||
|
||||
// Sort by total Sell Out descending and take top 50
|
||||
const sorted = Array.from(asinTotals.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 50);
|
||||
const sorted = Array.from(asinTotals.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 50);
|
||||
|
||||
// Create a Map of ASIN -> rank (1-50)
|
||||
const rankMap = new Map<string, number>();
|
||||
sorted.forEach(([asin], index) => {
|
||||
rankMap.set(asin, index + 1);
|
||||
});
|
||||
const rankMap = new Map<string, number>();
|
||||
sorted.forEach(([asin], index) => {
|
||||
rankMap.set(asin, index + 1);
|
||||
});
|
||||
return rankMap;
|
||||
};
|
||||
|
||||
return rankMap;
|
||||
}, [rawData]);
|
||||
if (filters.customer.length > 0) {
|
||||
// Filtered mode: Rank products based on currently selected countries
|
||||
const filtered2025 = data2025.filter(r => filters.customer.includes(r.customer));
|
||||
return {
|
||||
type: 'filtered' as const,
|
||||
overall: calculateTop50(filtered2025)
|
||||
};
|
||||
} else {
|
||||
// Dual mode: Separate Pan-EU and UK rankings when no countries are selected
|
||||
const euData = data2025.filter(r => !r.customer.toLowerCase().includes('uk'));
|
||||
const ukData = data2025.filter(r => r.customer.toLowerCase().includes('uk'));
|
||||
|
||||
return {
|
||||
type: 'dual' as const,
|
||||
eu: calculateTop50(euData),
|
||||
uk: calculateTop50(ukData)
|
||||
};
|
||||
}
|
||||
}, [rawData, filters.customer]);
|
||||
|
||||
// Combine Sales & Ads Data dynamically based on current filters
|
||||
const combinedAdsData = useMemo(() => {
|
||||
|
||||
+67
-16
@@ -5,7 +5,12 @@ import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor'
|
||||
|
||||
interface WeeklyGridProps {
|
||||
data: CombinedKPIs[];
|
||||
top50Ranking?: Map<string, number>; // Static ranking of Top 50 ASINs by 2025 Sell Out
|
||||
top50Ranking?: {
|
||||
type: 'filtered' | 'dual';
|
||||
overall?: Map<string, number>;
|
||||
eu?: Map<string, number>;
|
||||
uk?: Map<string, number>;
|
||||
};
|
||||
}
|
||||
|
||||
type SortConfig = {
|
||||
@@ -28,14 +33,24 @@ const useDebounce = (value: string, delay: number) => {
|
||||
};
|
||||
|
||||
// Top 50 Badge Component
|
||||
const Top50Badge: React.FC<{ rank: number }> = ({ rank }) => (
|
||||
<span
|
||||
className="inline-flex items-center justify-center px-1.5 py-0.5 rounded text-[9px] font-black bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-sm border border-amber-400/50"
|
||||
title={`Top ${rank} Best Seller 2025`}
|
||||
>
|
||||
🏆 {rank}
|
||||
</span>
|
||||
);
|
||||
const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'blue' | 'indigo' }> = ({ rank, label, theme = 'amber' }) => {
|
||||
const themeClasses = {
|
||||
amber: 'from-amber-500 to-orange-500 border-amber-400/50',
|
||||
blue: 'from-blue-500 to-cyan-500 border-blue-400/50',
|
||||
indigo: 'from-indigo-500 to-purple-500 border-indigo-400/50',
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center px-1.5 py-0.5 rounded text-[9px] font-black bg-gradient-to-r ${themeClasses[theme]} text-white shadow-sm border`}
|
||||
title={`Top ${rank} Best Seller 2025 ${label ? `(${label})` : ''}`}
|
||||
>
|
||||
<span className="mr-0.5">🏆</span>
|
||||
{label && <span className="mr-0.5 opacity-90">{label}</span>}
|
||||
{rank}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking }) => {
|
||||
// Pivot data - memoized
|
||||
@@ -96,7 +111,14 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking }) => {
|
||||
|
||||
// Top 50 Filter
|
||||
if (showOnlyTop50 && top50Ranking) {
|
||||
result = result.filter(r => top50Ranking.has(r.asin.trim().toUpperCase()));
|
||||
result = result.filter(r => {
|
||||
const asin = r.asin.trim().toUpperCase();
|
||||
if (top50Ranking.type === 'filtered') {
|
||||
return top50Ranking.overall?.has(asin);
|
||||
} else {
|
||||
return top50Ranking.eu?.has(asin) || top50Ranking.uk?.has(asin);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Search Filter
|
||||
@@ -150,8 +172,21 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking }) => {
|
||||
|
||||
result.sort((a, b) => {
|
||||
if (sortConfig.metric === 'rank') {
|
||||
const rankA = top50Ranking?.get(a.asin.trim().toUpperCase()) || 999;
|
||||
const rankB = top50Ranking?.get(b.asin.trim().toUpperCase()) || 999;
|
||||
const asinA = a.asin.trim().toUpperCase();
|
||||
const asinB = b.asin.trim().toUpperCase();
|
||||
|
||||
let rankA = 999;
|
||||
let rankB = 999;
|
||||
|
||||
if (top50Ranking?.type === 'filtered') {
|
||||
rankA = top50Ranking.overall?.get(asinA) || 999;
|
||||
rankB = top50Ranking.overall?.get(asinB) || 999;
|
||||
} else if (top50Ranking?.type === 'dual') {
|
||||
// In dual mode, prioritize EU rank, then UK rank
|
||||
rankA = top50Ranking.eu?.get(asinA) || top50Ranking.uk?.get(asinA) || 999;
|
||||
rankB = top50Ranking.eu?.get(asinB) || top50Ranking.uk?.get(asinB) || 999;
|
||||
}
|
||||
|
||||
return direction === 'asc' ? rankA - rankB : rankB - rankA;
|
||||
}
|
||||
const valA = a[metricKey][weekKey] || 0;
|
||||
@@ -261,8 +296,8 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking }) => {
|
||||
}
|
||||
}}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all border ${showOnlyTop50
|
||||
? 'bg-gradient-to-r from-amber-500 to-orange-500 text-white border-amber-400 shadow-lg shadow-amber-500/20'
|
||||
: 'bg-slate-950/50 text-slate-400 border-white/10 hover:border-amber-500/50 hover:text-amber-400'
|
||||
? 'bg-gradient-to-r from-amber-500 to-orange-500 text-white border-amber-400 shadow-lg shadow-amber-500/20'
|
||||
: 'bg-slate-950/50 text-slate-400 border-white/10 hover:border-amber-500/50 hover:text-amber-400'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm">🏆</span>
|
||||
@@ -387,13 +422,29 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking }) => {
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{paginatedRows.length > 0 ? (
|
||||
paginatedRows.map((row) => {
|
||||
const top50Rank = top50Ranking?.get(row.asin.trim().toUpperCase());
|
||||
const asin = row.asin.trim().toUpperCase();
|
||||
const ranks = [];
|
||||
|
||||
if (top50Ranking) {
|
||||
if (top50Ranking.type === 'filtered') {
|
||||
const rank = top50Ranking.overall?.get(asin);
|
||||
if (rank) ranks.push({ rank, label: '', theme: 'amber' as const });
|
||||
} else {
|
||||
const euRank = top50Ranking.eu?.get(asin);
|
||||
const ukRank = top50Ranking.uk?.get(asin);
|
||||
if (euRank) ranks.push({ rank: euRank, label: 'EU', theme: 'indigo' as const });
|
||||
if (ukRank) ranks.push({ rank: ukRank, label: 'UK', theme: 'blue' as const });
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
{top50Rank && <Top50Badge rank={top50Rank} />}
|
||||
{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 uppercase tracking-tighter truncate max-w-[120px]">{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>
|
||||
|
||||
Reference in New Issue
Block a user