Files
CrazeAnalytix/components/WeeklyGrid.tsx
T
Christian Vidal Wolf 1c8ec7b835 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
2026-01-22 12:02:21 +01:00

397 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import * as XLSX from 'xlsx';
import { CombinedKPIs } from '../types';
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 = {
key: string; // weekKey
direction: 'asc' | 'desc';
metric: 'units' | 'spend';
} | null;
const ROWS_PER_PAGE = 50;
const MAX_VISIBLE_WEEKS = 8; // Limit visible weeks for performance
// Debounce hook for search
const useDebounce = (value: string, delay: number) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
};
// 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]);
// Limit visible weeks for performance
const weeks = useMemo(() => allWeeks.slice(0, MAX_VISIBLE_WEEKS), [allWeeks]);
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useDebounce(searchTerm, 300);
const [currentPage, setCurrentPage] = useState(1);
const [growthFilterMode, setGrowthFilterMode] = useState<'all' | 'up' | 'down' | 'stable'>('all');
const [growthThreshold, setGrowthThreshold] = useState(10);
// Default sort: most recent week, descending, units
const [sortConfig, setSortConfig] = useState<SortConfig>(() => {
if (weeks.length > 0) {
return { key: weeks[0], direction: 'desc', metric: 'units' };
}
return null;
});
// Reset pagination when search changes
useEffect(() => {
setCurrentPage(1);
}, [debouncedSearch]);
const handleSort = useCallback((weekKey: string, metric: 'units' | 'spend') => {
setSortConfig(prev => {
if (prev?.key === weekKey && prev.metric === metric) {
return { key: weekKey, direction: prev.direction === 'asc' ? 'desc' : 'asc', metric };
}
return { key: weekKey, direction: 'desc', metric };
});
}, []);
// Calculate totals in a SINGLE PASS (O(rows) instead of O(weeks × rows))
const weekTotals = useMemo(() => {
const totals: { [weekKey: string]: { units: number, spend: number } } = {};
// Initialize all weeks
weeks.forEach(week => {
totals[week] = { units: 0, spend: 0 };
});
// Single pass through rows
rows.forEach(row => {
weeks.forEach(week => {
totals[week].units += (row.unitsByWeek[week] || 0);
totals[week].spend += (row.spendByWeek[week] || 0);
});
});
return totals;
}, [rows, weeks]);
// 1. Filter by search term (using debounced value)
const filteredRows = useMemo(() => {
let result = rows;
// Search Filter
if (debouncedSearch) {
const lowSearch = debouncedSearch.toLowerCase();
result = result.filter(r =>
r.sku.toLowerCase().includes(lowSearch) ||
r.asin.toLowerCase().includes(lowSearch) ||
r.title.toLowerCase().includes(lowSearch) ||
r.line.toLowerCase().includes(lowSearch)
);
}
// Growth Filter (if active)
if (growthFilterMode !== 'all' && sortConfig) {
const currentWeek = sortConfig.key;
const currentWeekIdx = weeks.indexOf(currentWeek);
const prevWeek = weeks[currentWeekIdx + 1];
if (prevWeek) {
result = result.filter(r => {
const currentUnits = r.unitsByWeek[currentWeek] || 0;
const prevUnits = r.unitsByWeek[prevWeek] || 0;
if (prevUnits === 0) {
if (currentUnits === 0) return growthFilterMode === 'stable';
return growthFilterMode === 'up';
}
const growth = ((currentUnits - prevUnits) / prevUnits) * 100;
if (growthFilterMode === 'up') return growth >= growthThreshold;
if (growthFilterMode === 'down') return growth <= -Math.abs(growthThreshold);
if (growthFilterMode === 'stable') return Math.abs(growth) < growthThreshold;
return true;
});
}
}
return result;
}, [rows, debouncedSearch, growthFilterMode, growthThreshold, sortConfig, weeks]);
// 2. Sort results
const sortedRows = useMemo(() => {
if (!sortConfig) return filteredRows;
const result = [...filteredRows];
const metricKey = sortConfig.metric === 'units' ? 'unitsByWeek' : 'spendByWeek';
const weekKey = sortConfig.key;
const direction = sortConfig.direction;
result.sort((a, b) => {
const valA = a[metricKey][weekKey] || 0;
const valB = b[metricKey][weekKey] || 0;
return direction === 'asc' ? valA - valB : valB - valA;
});
return result;
}, [filteredRows, sortConfig]);
// 3. Paginate
const paginatedRows = useMemo(() => {
const start = (currentPage - 1) * ROWS_PER_PAGE;
return sortedRows.slice(start, start + ROWS_PER_PAGE);
}, [sortedRows, currentPage]);
const totalPages = Math.ceil(sortedRows.length / ROWS_PER_PAGE);
const renderGrowth = useCallback((current: number, previous: number) => {
if (!previous || previous === 0) return null;
const pct = ((current - previous) / previous) * 100;
const isPositive = pct >= 0;
return (
<span className={`text-[10px] font-bold ${isPositive ? 'text-emerald-400' : 'text-red-400'}`}>
{isPositive ? '▲' : '▼'}{Math.abs(pct).toFixed(0)}%
</span>
);
}, []);
// Export to Excel
const handleExportExcel = useCallback(() => {
const exportData = sortedRows.map(row => {
const rowData: { [key: string]: string | number } = {
SKU: row.sku,
ASIN: row.asin,
Title: row.title,
Line: row.line,
};
allWeeks.forEach(week => {
rowData[`${week} Units`] = row.unitsByWeek[week] || 0;
rowData[`${week} Spend`] = row.spendByWeek[week] || 0;
});
return rowData;
});
const ws = XLSX.utils.json_to_sheet(exportData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Weekly Sales');
XLSX.writeFile(wb, `Weekly_Sales_Export_${new Date().toISOString().slice(0, 10)}.xlsx`);
}, [sortedRows, allWeeks]);
return (
<div className="flex flex-col gap-4 animate-fade-in">
{/* Toolbar: Search & Pagination */}
<div className="flex flex-col lg:flex-row justify-between items-center gap-4 bg-slate-900 border border-white/10 p-4 rounded-xl shadow-lg">
<div className="flex flex-wrap items-center gap-4 w-full lg:w-auto">
{/* Search Input */}
<div className="relative w-full md:w-80">
<input
type="text"
placeholder="Search SKU, Title, or ASIN..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-slate-950 border border-white/10 rounded-lg px-4 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all pl-10"
/>
<svg className="absolute left-3 top-2.5 w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* Growth Filter */}
<div className="flex items-center gap-2 bg-slate-950/50 p-1.5 px-3 rounded-lg border border-white/5">
<span className="text-[11px] font-black text-slate-500 uppercase tracking-widest">Growth Filter</span>
<select
value={growthFilterMode}
onChange={(e) => setGrowthFilterMode(e.target.value as any)}
className="bg-slate-900 border border-white/10 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
<option value="all">All Products</option>
<option value="up">Gaining</option>
<option value="down">Dropping</option>
<option value="stable">Stable</option>
</select>
{growthFilterMode !== 'all' && (
<div className="flex items-center gap-1.5 ml-1 border-l border-white/10 pl-3">
<span className="text-[10px] text-slate-500 font-bold">Thresh:</span>
<input
type="number"
value={growthThreshold}
onChange={(e) => setGrowthThreshold(Number(e.target.value))}
className="w-12 bg-slate-900 border border-white/10 rounded px-1.5 py-1 text-xs text-white text-center focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<span className="text-[10px] text-slate-500">%</span>
</div>
)}
</div>
{/* Export Button */}
<button
onClick={handleExportExcel}
className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1.5 rounded-lg text-xs font-bold transition-colors border border-emerald-500/50"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Export Excel
</button>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-slate-500 whitespace-nowrap">
Showing {Math.min(filteredRows.length, (currentPage - 1) * ROWS_PER_PAGE + 1)}-{Math.min(filteredRows.length, currentPage * ROWS_PER_PAGE)} of {filteredRows.length}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-2 bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:hover:bg-slate-800 rounded-lg transition-colors border border-white/5"
>
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
</button>
<span className="bg-slate-950 border border-white/10 px-3 py-1.5 rounded-lg text-xs font-bold text-white min-w-[60px] text-center">
{currentPage} / {Math.max(1, totalPages)}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage >= totalPages}
className="p-2 bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:hover:bg-slate-800 rounded-lg transition-colors border border-white/5"
>
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
</button>
</div>
</div>
</div>
<div className="bg-slate-900 border border-white/10 rounded-xl overflow-hidden shadow-2xl flex-1 min-h-0">
<div className="overflow-x-auto overflow-y-auto h-full max-h-[calc(100vh-220px)] custom-scrollbar">
<table className="w-full text-left border-collapse min-w-[max-content]">
<thead className="sticky top-0 z-20 bg-slate-900">
<tr className="bg-slate-900 border-b border-white/10">
<th className="p-3 text-[11px] font-black text-slate-400 uppercase tracking-widest sticky left-0 z-40 bg-slate-900 border-r border-white/10 min-w-[240px]">Product Details</th>
{weeks.map(week => (
<th
key={week}
className={`p-0 text-[10px] font-black uppercase tracking-widest text-center border-r border-white/10 min-w-[130px] transition-colors select-none ${sortConfig?.key === week ? 'bg-white/[0.02]' : ''}`}
>
<div className="flex flex-col h-full">
{/* Week Label */}
<div className="p-2 border-b border-white/5 bg-slate-800/30 text-xs text-white">
{week.split('-')[1]}/{week.split('-')[0].slice(-2)}
</div>
{/* Units Sort Trigger */}
<div
onClick={() => handleSort(week, 'units')}
className={`flex-1 p-1.5 cursor-pointer hover:bg-indigo-500/10 transition-colors flex items-center justify-center gap-1 border-b border-white/5 ${sortConfig?.key === week && sortConfig.metric === 'units' ? 'bg-indigo-500/5 text-indigo-400' : 'text-slate-500 hover:text-slate-300'}`}
>
<span className="text-[9px]">Units</span>
{sortConfig?.key === week && sortConfig.metric === 'units' && (
<span className="text-xs font-bold leading-none">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
)}
</div>
{/* Spend Sort Trigger */}
<div
onClick={() => handleSort(week, 'spend')}
className={`flex-1 p-1.5 cursor-pointer hover:bg-amber-500/10 transition-colors flex items-center justify-center gap-1 ${sortConfig?.key === week && sortConfig.metric === 'spend' ? 'bg-amber-500/5 text-amber-400' : 'text-slate-500 hover:text-slate-300'}`}
>
<span className="text-[9px]">Spend</span>
{sortConfig?.key === week && sortConfig.metric === 'spend' && (
<span className="text-xs font-bold leading-none">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
)}
</div>
</div>
</th>
))}
</tr>
<tr className="bg-indigo-950 border-b border-white/10">
<th className="p-3 text-sm font-black text-white sticky left-0 z-40 bg-indigo-950 border-r border-white/10">TOTALS</th>
{weeks.map((week, idx) => (
<th key={week} className="p-3 text-sm font-black text-white text-center border-r border-white/10">
<div className="flex flex-col items-center">
<div className="flex items-center gap-1">
<span>{weekTotals[week]?.units.toLocaleString('de-DE') || 0}</span>
{renderGrowth(weekTotals[week]?.units || 0, weekTotals[weeks[idx + 1]]?.units || 0)}
</div>
<div className="text-[10px] text-indigo-400 font-bold">
{(weekTotals[week]?.spend || 0).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</div>
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{paginatedRows.length > 0 ? (
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>
<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">
No SKUs found matching "{debouncedSearch}"
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default WeeklyGrid;