Files
CrazeAnalytix/components/WeeklyGrid.tsx
T

585 lines
34 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';
import { StockBadge } from './StockBadge';
interface WeeklyGridProps {
data: CombinedKPIs[];
top50Ranking?: {
eu: Map<string, number>;
uk: Map<string, number>;
};
onDrillDown?: (sku: string) => void;
stockMap?: Map<string, number>;
}
type SortConfig = {
key: string; // weekKey or 'rank'
direction: 'asc' | 'desc';
metric: 'units' | 'spend' | 'rank' | 'gv';
} | null;
const ROWS_PER_PAGE = 50;
const MAX_VISIBLE_WEEKS = 20; // 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, onDrillDown, stockMap }) => {
// 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);
const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu');
// 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, top50Mode]);
// Auto-detect best Top 50 mode based on currently selected data
useEffect(() => {
const hasUK = rows.some(r => r.customer.toLowerCase().includes('uk'));
const hasEU = rows.some(r => !r.customer.toLowerCase().includes('uk'));
if (hasUK && !hasEU) {
setTop50Mode('uk');
} else if (hasEU && !hasUK) {
setTop50Mode('eu');
}
}, [rows]);
const handleSort = useCallback((weekKey: string, metric: 'units' | 'spend' | 'rank' | 'gv') => {
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, gv: number } } = {};
// Initialize all weeks
weeks.forEach(week => {
totals[week] = { units: 0, spend: 0, gv: 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);
totals[week].gv += (row.gvByWeek?.[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();
const isUK = r.customer.toLowerCase().includes('uk');
if (top50Mode === 'eu') {
return !isUK && top50Ranking.eu.has(asin);
} else {
return isUK && 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' :
sortConfig.metric === 'spend' ? 'spendByWeek' : 'gvByWeek';
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) {
if (top50Mode === 'eu') {
rankA = top50Ranking.eu.get(asinA) || 999;
rankB = top50Ranking.eu.get(asinB) || 999;
} else {
rankA = top50Ranking.uk.get(asinA) || 999;
rankB = 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;
rowData[`${week} GV`] = row.gvByWeek[week] || 0;
});
return rowData;
});
// Add Totals row
const totalsRow: any = {
SKU: 'TOTALS',
ASIN: '',
Title: 'ALL FILTERED PRODUCTS',
Line: '',
};
allWeeks.forEach(week => {
totalsRow[`${week} Units`] = weekTotals[week]?.units || 0;
totalsRow[`${week} Spend`] = weekTotals[week]?.spend || 0;
totalsRow[`${week} GV`] = weekTotals[week]?.gv || 0;
});
exportData.push(totalsRow);
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, weekTotals]);
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.eu.size || 0) > 0 ||
(top50Ranking.uk.size || 0) > 0
) && (
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm">
<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 ${showOnlyTop50
? 'bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-lg shadow-amber-500/20'
: 'text-slate-400 hover:text-amber-400'
}`}
>
<span className="text-sm">🏆</span>
Top 50
</button>
{showOnlyTop50 && (
<div className="flex ml-1 p-0.5 bg-slate-900 rounded-lg border border-white/5">
<button
onClick={() => setTop50Mode('eu')}
className={`px-2 py-1 rounded-md text-[10px] font-black uppercase tracking-widest transition-all ${top50Mode === 'eu' ? 'bg-indigo-500 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'}`}
>
EU
</button>
<button
onClick={() => setTop50Mode('uk')}
className={`px-2 py-1 rounded-md text-[10px] font-black uppercase tracking-widest transition-all ${top50Mode === 'uk' ? 'bg-blue-500 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'}`}
>
UK
</button>
</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
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 border-b border-white/5 ${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>
{/* GV Sort Trigger */}
<div
onClick={() => handleSort(week, 'gv')}
className={`flex-1 p-1.5 cursor-pointer hover:bg-teal-500/10 transition-colors flex items-center justify-center gap-1 ${sortConfig?.key === week && sortConfig.metric === 'gv' ? 'bg-teal-500/5 text-teal-400' : 'text-slate-500 hover:text-slate-300'}`}
>
<span className="text-[9px]">GV</span>
{sortConfig?.key === week && sortConfig.metric === 'gv' && (
<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="flex items-center justify-center gap-1 text-[10px] text-indigo-400 font-bold">
<span>{(weekTotals[week]?.spend || 0).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</span>
{renderGrowth(weekTotals[week]?.spend || 0, weekTotals[weeks[idx + 1]]?.spend || 0)}
</div>
<div className="flex items-center justify-center gap-1 text-[9px] text-teal-400 font-black tracking-tight mt-0.5">
<span>GV: {(weekTotals[week]?.gv || 0).toLocaleString('de-DE')}</span>
{renderGrowth(weekTotals[week]?.gv || 0, weekTotals[weeks[idx + 1]]?.gv || 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 (top50Mode === 'eu') {
const rank = top50Ranking.eu.get(asin);
if (rank) ranks.push({ rank, label: 'EU', theme: 'indigo' as const });
} else {
const rank = top50Ranking.uk.get(asin);
if (rank) ranks.push({ rank, 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
onClick={() => onDrillDown?.(row.sku)}
className={`text-xs font-black uppercase tracking-tighter truncate max-w-[120px] transition-all
${onDrillDown ? 'text-indigo-400 cursor-pointer hover:text-indigo-300 hover:underline' : 'text-indigo-400/70'}`}
title={onDrillDown ? `Click to see Ads detail for ${row.sku}` : ''}
>
{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>
<div className="flex items-start gap-2 mb-1">
<span className="text-[11px] text-white/70 truncate w-[190px] leading-tight" title={row.title}>{row.title}</span>
{stockMap && (
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
)}
</div>
<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;
const prevSpend = row.spendByWeek[weeks[idx + 1]] || 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>
<div className="flex flex-col items-center">
{spend > 0 && (
<div className="flex items-center gap-1">
<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>
{renderGrowth(spend, prevSpend)}
</div>
)}
{row.gvByWeek?.[week] > 0 && (
<div className="flex items-center gap-1">
<span className={`text-[9px] font-black tracking-tighter ${sortConfig?.key === week && sortConfig.metric === 'gv' ? 'text-teal-300' : 'text-teal-500/70'}`}>
GV: {row.gvByWeek[week].toLocaleString('de-DE')}
</span>
{renderGrowth(row.gvByWeek[week], row.gvByWeek?.[weeks[idx + 1]] || 0)}
</div>
)}
</div>
</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;