mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 15:15:24 +02:00
- 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
496 lines
28 KiB
TypeScript
496 lines
28 KiB
TypeScript
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?: {
|
||
type: 'filtered' | 'dual';
|
||
overall?: Map<string, number>;
|
||
eu?: Map<string, number>;
|
||
uk?: Map<string, number>;
|
||
};
|
||
}
|
||
|
||
type SortConfig = {
|
||
key: string; // weekKey or 'rank'
|
||
direction: 'asc' | 'desc';
|
||
metric: 'units' | 'spend' | 'rank';
|
||
} | 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; 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
|
||
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);
|
||
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
||
|
||
// 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, showOnlyTop50]);
|
||
|
||
const handleSort = useCallback((weekKey: string, metric: 'units' | 'spend' | 'rank') => {
|
||
setSortConfig(prev => {
|
||
if (prev?.key === weekKey && prev.metric === metric) {
|
||
return { key: weekKey, direction: prev.direction === 'asc' ? 'desc' : 'asc', metric };
|
||
}
|
||
return { key: weekKey, direction: metric === 'rank' ? 'asc' : '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, Top 50, and growth (using debounced value)
|
||
const filteredRows = useMemo(() => {
|
||
let result = rows;
|
||
|
||
// Top 50 Filter
|
||
if (showOnlyTop50 && top50Ranking) {
|
||
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
|
||
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, showOnlyTop50, top50Ranking, 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) => {
|
||
if (sortConfig.metric === 'rank') {
|
||
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;
|
||
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>
|
||
|
||
{/* Top 50 Filter Toggle */}
|
||
{top50Ranking && top50Ranking.size > 0 && (
|
||
<button
|
||
onClick={() => {
|
||
const newMode = !showOnlyTop50;
|
||
setShowOnlyTop50(newMode);
|
||
if (newMode) {
|
||
handleSort('rank', 'rank');
|
||
}
|
||
}}
|
||
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'
|
||
}`}
|
||
>
|
||
<span className="text-sm">🏆</span>
|
||
Top 50 Only
|
||
{showOnlyTop50 && (
|
||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
)}
|
||
|
||
{/* 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
|
||
onClick={() => handleSort('rank', 'rank')}
|
||
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] cursor-pointer hover:bg-white/5 transition-colors group"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<span>Product Details</span>
|
||
{sortConfig?.metric === 'rank' && (
|
||
<span className="text-amber-500 font-black text-sm animate-bounce-subtle">
|
||
{sortConfig.direction === 'asc' ? '↑' : '↓'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</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 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">
|
||
{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>
|
||
<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;
|