feat: add Top 50 Best Sellers badge in Weekly Sales

- Calculate static Top 50 ranking based on 2025 Sell Out (independent of filters)
- Display trophy badge with position number for Top 50 ASINs
- Badge shows 🏆 followed by rank (1-50)
- Ranking stays static regardless of date/country filters applied
- Pre-computed ranking based on cumulative 2025 data
This commit is contained in:
Christian Vidal Wolf
2026-01-22 12:02:21 +01:00
parent 15f5b08c71
commit 1c8ec7b835
2 changed files with 78 additions and 37 deletions
+27 -1
View File
@@ -254,6 +254,32 @@ 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)
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);
});
// 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);
// Create a Map of ASIN -> rank (1-50)
const rankMap = new Map<string, number>();
sorted.forEach(([asin], index) => {
rankMap.set(asin, index + 1);
});
return rankMap;
}, [rawData]);
// Combine Sales & Ads Data dynamically based on current filters // Combine Sales & Ads Data dynamically based on current filters
const combinedAdsData = useMemo(() => { const combinedAdsData = useMemo(() => {
return mergeSalesAndAdsData(filteredData, filteredAdsData); return mergeSalesAndAdsData(filteredData, filteredAdsData);
@@ -427,7 +453,7 @@ const App: React.FC = () => {
)} )}
<Suspense fallback={<LoadingSpinner />}> <Suspense fallback={<LoadingSpinner />}>
{view === 'table' && <DataGrid data={combinedAdsData} hasCustomerFilter={filters.customer.length > 0} adsData={filteredAdsData} />} {view === 'table' && <DataGrid data={combinedAdsData} hasCustomerFilter={filters.customer.length > 0} adsData={filteredAdsData} />}
{view === 'weekly' && <WeeklyGrid data={combinedAdsData} />} {view === 'weekly' && <WeeklyGrid data={combinedAdsData} top50Ranking={top50Ranking2025} />}
{view === 'movers' && <TopMovers data={filteredData} />} {view === 'movers' && <TopMovers data={filteredData} />}
</Suspense> </Suspense>
</div> </div>
+51 -36
View File
@@ -5,6 +5,7 @@ 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
} }
type SortConfig = { type SortConfig = {
@@ -26,7 +27,17 @@ const useDebounce = (value: string, delay: number) => {
return debouncedValue; return debouncedValue;
}; };
const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => { // 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 WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking }) => {
// Pivot data - memoized // Pivot data - memoized
const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]); const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
@@ -327,42 +338,46 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
</thead> </thead>
<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) => {
<tr key={row.id} className="hover:bg-white/[0.02] transition-colors group"> const top50Rank = top50Ranking?.get(row.asin.trim().toUpperCase());
<td className="p-3 py-2 sticky left-0 z-10 bg-slate-900 group-hover:bg-slate-800 border-r border-white/10"> return (
<div className="flex flex-col"> <tr key={row.id} className="hover:bg-white/[0.02] transition-colors group">
<div className="flex items-center gap-2 mb-0.5"> <td className="p-3 py-2 sticky left-0 z-10 bg-slate-900 group-hover:bg-slate-800 border-r border-white/10">
<span className="text-xs font-black text-indigo-400 uppercase tracking-tighter truncate max-w-[120px]">{row.sku || '-'}</span> <div className="flex flex-col">
<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 className="flex items-center gap-2 mb-0.5">
</div> {top50Rank && <Top50Badge rank={top50Rank} />}
<span className="text-[11px] text-white/70 truncate w-[210px] leading-tight mb-1" title={row.title}>{row.title}</span> <span className="text-xs font-black text-indigo-400 uppercase tracking-tighter truncate max-w-[120px]">{row.sku || '-'}</span>
<span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{row.line}</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>
</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;
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>
{spend > 0 && (
<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>
)}
</div> </div>
</td> <span className="text-[11px] text-white/70 truncate w-[210px] leading-tight mb-1" title={row.title}>{row.title}</span>
); <span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{row.line}</span>
})} </div>
</tr> </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;
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>
{spend > 0 && (
<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>
)}
</div>
</td>
);
})}
</tr>
);
})
) : ( ) : (
<tr> <tr>
<td colSpan={weeks.length + 1} className="p-10 text-center text-slate-500 italic text-base"> <td colSpan={weeks.length + 1} className="p-10 text-center text-slate-500 italic text-base">