mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 13:25:23 +02:00
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:
@@ -254,6 +254,32 @@ 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)
|
||||
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
|
||||
const combinedAdsData = useMemo(() => {
|
||||
return mergeSalesAndAdsData(filteredData, filteredAdsData);
|
||||
@@ -427,7 +453,7 @@ const App: React.FC = () => {
|
||||
)}
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
{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} />}
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
+51
-36
@@ -5,6 +5,7 @@ import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor'
|
||||
|
||||
interface WeeklyGridProps {
|
||||
data: CombinedKPIs[];
|
||||
top50Ranking?: Map<string, number>; // Static ranking of Top 50 ASINs by 2025 Sell Out
|
||||
}
|
||||
|
||||
type SortConfig = {
|
||||
@@ -26,7 +27,17 @@ const useDebounce = (value: string, delay: number) => {
|
||||
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
|
||||
const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
|
||||
|
||||
@@ -327,42 +338,46 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{paginatedRows.length > 0 ? (
|
||||
paginatedRows.map((row) => (
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
paginatedRows.map((row) => {
|
||||
const top50Rank = top50Ranking?.get(row.asin.trim().toUpperCase());
|
||||
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} />}
|
||||
<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>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))
|
||||
<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>
|
||||
</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>
|
||||
<td colSpan={weeks.length + 1} className="p-10 text-center text-slate-500 italic text-base">
|
||||
|
||||
Reference in New Issue
Block a user