mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 14:05:24 +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 filteredAdsData = useMemo(() => filterAdsData(adsData, filters), [adsData, filters]);
|
||||||
const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]);
|
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(() => {
|
const top50Ranking2025 = useMemo(() => {
|
||||||
// Filter only 2025 data
|
// Filter only 2025 data
|
||||||
const data2025 = rawData.filter(r => r.year === 2025);
|
const data2025 = rawData.filter(r => r.year === 2025);
|
||||||
|
|
||||||
// Aggregate Sell Out by ASIN
|
const calculateTop50 = (entries: SalesRecord[]) => {
|
||||||
const asinTotals = new Map<string, number>();
|
const asinTotals = new Map<string, number>();
|
||||||
data2025.forEach(r => {
|
entries.forEach(r => {
|
||||||
const asin = r.asin.trim().toUpperCase();
|
const asin = r.asin.trim().toUpperCase();
|
||||||
asinTotals.set(asin, (asinTotals.get(asin) || 0) + r.sellOut);
|
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())
|
||||||
const sorted = Array.from(asinTotals.entries())
|
.sort((a, b) => b[1] - a[1])
|
||||||
.sort((a, b) => b[1] - a[1])
|
.slice(0, 50);
|
||||||
.slice(0, 50);
|
|
||||||
|
|
||||||
// Create a Map of ASIN -> rank (1-50)
|
const rankMap = new Map<string, number>();
|
||||||
const rankMap = new Map<string, number>();
|
sorted.forEach(([asin], index) => {
|
||||||
sorted.forEach(([asin], index) => {
|
rankMap.set(asin, index + 1);
|
||||||
rankMap.set(asin, index + 1);
|
});
|
||||||
});
|
return rankMap;
|
||||||
|
};
|
||||||
|
|
||||||
return rankMap;
|
if (filters.customer.length > 0) {
|
||||||
}, [rawData]);
|
// 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
|
// Combine Sales & Ads Data dynamically based on current filters
|
||||||
const combinedAdsData = useMemo(() => {
|
const combinedAdsData = useMemo(() => {
|
||||||
|
|||||||
+67
-16
@@ -5,7 +5,12 @@ import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor'
|
|||||||
|
|
||||||
interface WeeklyGridProps {
|
interface WeeklyGridProps {
|
||||||
data: CombinedKPIs[];
|
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 = {
|
type SortConfig = {
|
||||||
@@ -28,14 +33,24 @@ const useDebounce = (value: string, delay: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Top 50 Badge Component
|
// Top 50 Badge Component
|
||||||
const Top50Badge: React.FC<{ rank: number }> = ({ rank }) => (
|
const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'blue' | 'indigo' }> = ({ rank, label, theme = 'amber' }) => {
|
||||||
<span
|
const themeClasses = {
|
||||||
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"
|
amber: 'from-amber-500 to-orange-500 border-amber-400/50',
|
||||||
title={`Top ${rank} Best Seller 2025`}
|
blue: 'from-blue-500 to-cyan-500 border-blue-400/50',
|
||||||
>
|
indigo: 'from-indigo-500 to-purple-500 border-indigo-400/50',
|
||||||
🏆 {rank}
|
};
|
||||||
</span>
|
|
||||||
);
|
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 }) => {
|
const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking }) => {
|
||||||
// Pivot data - memoized
|
// Pivot data - memoized
|
||||||
@@ -96,7 +111,14 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking }) => {
|
|||||||
|
|
||||||
// Top 50 Filter
|
// Top 50 Filter
|
||||||
if (showOnlyTop50 && top50Ranking) {
|
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
|
// Search Filter
|
||||||
@@ -150,8 +172,21 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking }) => {
|
|||||||
|
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
if (sortConfig.metric === 'rank') {
|
if (sortConfig.metric === 'rank') {
|
||||||
const rankA = top50Ranking?.get(a.asin.trim().toUpperCase()) || 999;
|
const asinA = a.asin.trim().toUpperCase();
|
||||||
const rankB = top50Ranking?.get(b.asin.trim().toUpperCase()) || 999;
|
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;
|
return direction === 'asc' ? rankA - rankB : rankB - rankA;
|
||||||
}
|
}
|
||||||
const valA = a[metricKey][weekKey] || 0;
|
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
|
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-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-slate-950/50 text-slate-400 border-white/10 hover:border-amber-500/50 hover:text-amber-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm">🏆</span>
|
<span className="text-sm">🏆</span>
|
||||||
@@ -387,13 +422,29 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking }) => {
|
|||||||
<tbody className="divide-y divide-white/5">
|
<tbody className="divide-y divide-white/5">
|
||||||
{paginatedRows.length > 0 ? (
|
{paginatedRows.length > 0 ? (
|
||||||
paginatedRows.map((row) => {
|
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 (
|
return (
|
||||||
<tr key={row.id} className="hover:bg-white/[0.02] transition-colors group">
|
<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">
|
<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 flex-col">
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
<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-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>
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user