Files
CrazeAnalytix/components/WeeklyGrid.tsx
T

944 lines
54 KiB
TypeScript
Raw Normal View History

import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import ReactDOM from 'react-dom';
import * as XLSX from 'xlsx';
import { CombinedKPIs, ColumnFilterCondition, SalesRecord } from '../types';
import { pivotWeeklySalesData, WeeklyPivotRow, PAN_EU_COUNTRIES, checkNumericConditions, filterData } from '../services/dataProcessor';
import { StockBadge } from './StockBadge';
import { InColumnStockFilter } from './InColumnStockFilter';
import { NumericColumnFilter, NumericFilterConfig, passesNumericFilter } from './NumericColumnFilter';
import { Top50Badge } from './Top50Badge';
import { VendorStockBadge } from './VendorStockBadge';
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons';
import { ExcelFilter } from './ExcelFilter';
import { ExperimentBadge } from './ExperimentBadge';
import { WeeklyRow } from './WeeklyRow';
2026-02-20 19:31:44 +01:00
import { ActiveExperiment } from '../types';
2026-01-21 13:19:13 +01:00
interface WeeklyGridProps {
data: CombinedKPIs[];
top50Ranking?: {
eu: Map<string, number>;
uk: Map<string, number>;
};
onDrillDown?: (sku: string) => void;
stockMap?: Map<string, number>;
stockFilter: string[];
onStockFilterChange: (newFilters: string[]) => void;
customerFilters: string[];
vendorStockMap?: Map<string, { eu: number; uk: number }>;
vendorStockFilter: string[];
onVendorStockFilterChange: (newFilters: string[]) => void;
wocFilter: string[];
onWocFilterChange: (newFilters: string[]) => void;
velocityMap?: Map<string, number>;
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
top50Mode?: 'eu' | 'uk';
2026-02-20 19:31:44 +01:00
experimentMap?: Map<string, ActiveExperiment[]>;
onOpenExperiment?: (experimentId: string) => void;
2026-01-21 13:19:13 +01:00
}
type SortConfig = {
key: string; // weekKey or 'rank'
direction: 'asc' | 'desc';
metric: 'units' | 'spend' | 'revenue' | '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;
};
// Sub-components (MetricDetailTooltip, WeeklyRow) have been extracted to separate files
// to prevent initialization errors and improve code maintainability.
const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
data,
top50Ranking,
onDrillDown,
stockMap,
vendorStockMap,
stockFilter,
onStockFilterChange,
vendorStockFilter,
onVendorStockFilterChange,
wocFilter,
onWocFilterChange,
customerFilters,
top50Mode,
velocityMap,
buyBoxLostMap,
experimentMap,
onOpenExperiment
}) => {
// 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]);
2026-01-21 13:19:13 +01:00
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);
2026-01-22 12:17:19 +01:00
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
const [showActiveExperiments, setShowActiveExperiments] = useState(false);
const [displayCount, setDisplayCount] = useState(50);
const [primaryMetric, setPrimaryMetric] = useState<'units' | 'revenue'>('units');
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [hoveredTotalWeek, setHoveredTotalWeek] = useState<string | null>(null);
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
// State for Column Filters (SKU, ASIN, Title, Line)
const [columnFilters, setColumnFilters] = useState<Record<string, ColumnFilterCondition>>({});
const handleColumnFilterChange = (columnKey: string, condition: ColumnFilterCondition | undefined) => {
setColumnFilters(prev => {
const next = { ...prev };
if (condition) next[columnKey] = condition;
else delete next[columnKey];
return next;
});
setCurrentPage(1);
};
// Numeric filters for Units, Spend, GV
const [numericFilters, setNumericFilters] = useState<NumericFilterConfig[]>([]);
// Helper to get/set filter for a specific week+metric
const getNumericFilter = useCallback((week: string, metric: 'units' | 'spend' | 'revenue' | 'gv') => {
return numericFilters.find(f => f.week === week && f.metric === metric) || null;
}, [numericFilters]);
const setNumericFilter = useCallback((filter: NumericFilterConfig | null, week: string, metric: 'units' | 'spend' | 'revenue' | 'gv') => {
setNumericFilters(prev => {
// Remove existing filter for this week+metric
const filtered = prev.filter(f => !(f.week === week && f.metric === metric));
// Add new filter if provided
if (filter) {
return filtered.concat([filter]);
}
return filtered;
});
}, []);
// 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);
setDisplayCount(50);
}, [debouncedSearch, showOnlyTop50, top50Mode]);
const handleSort = useCallback((weekKey: string, metric: 'units' | 'spend' | 'revenue' | '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
const weekTotals = useMemo(() => {
const totals: { [weekKey: string]: { units: number, spend: number, revenue: number, gv: number } } = {};
const visibleWeeks = weeks;
// Initialize visible weeks
for (let i = 0; i < visibleWeeks.length; i++) {
totals[visibleWeeks[i]] = { units: 0, spend: 0, revenue: 0, gv: 0 };
}
// Single pass through rows
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
for (let j = 0; j < visibleWeeks.length; j++) {
const w = visibleWeeks[j];
totals[w].units += (row.unitsByWeek[w] || 0);
totals[w].spend += (row.spendByWeek[w] || 0);
totals[w].revenue += (row.revenueByWeek[w] || 0);
totals[w].gv += (row.gvByWeek?.[w] || 0);
}
}
return totals;
}, [rows, weeks]);
// Totals for ALL weeks (for YoY tooltip comparison)
const allWeekTotals = useMemo(() => {
const totals: { [weekKey: string]: { units: number, spend: number, revenue: number, gv: number } } = {};
for (let i = 0; i < allWeeks.length; i++) {
totals[allWeeks[i]] = { units: 0, spend: 0, revenue: 0, gv: 0 };
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
for (let j = 0; j < allWeeks.length; j++) {
const w = allWeeks[j];
totals[w].units += (row.unitsByWeek[w] || 0);
totals[w].spend += (row.spendByWeek[w] || 0);
totals[w].revenue += (row.revenueByWeek[w] || 0);
totals[w].gv += (row.gvByWeek?.[w] || 0);
}
}
return totals;
}, [rows, allWeeks]);
2026-01-22 12:17:19 +01:00
// 1. Filter by search term, Top 50, and growth (using debounced value)
const filteredRows = useMemo(() => {
let result = rows.slice();
2026-01-22 12:17:19 +01:00
// 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);
}
});
2026-01-22 12:17:19 +01:00
}
// Active Experiments Filter
if (showActiveExperiments && experimentMap) {
result = result.filter(r => {
const asin = r.asin.trim().toUpperCase();
const experiments = experimentMap.get(asin);
return experiments && experiments.length > 0;
});
}
// Column Filters (Excel-style) - using logic adapted from filterData
if (Object.keys(columnFilters).length > 0) {
(Object.entries(columnFilters) as [string, ColumnFilterCondition][]).forEach(([key, condition]) => {
if (!condition) return;
if (condition.selectedValues && condition.selectedValues.length > 0) {
result = result.filter(r => {
const val = String((r as any)[key] || '');
return condition.selectedValues?.includes(val);
});
}
if (condition.textFilter) {
const { operator, value } = condition.textFilter;
const lowerValue = value.toLowerCase();
result = result.filter(r => {
const rowVal = String((r as any)[key] || '').toLowerCase();
switch (operator) {
case 'equals': return rowVal === lowerValue;
case 'notEquals': return rowVal !== lowerValue;
case 'contains': return rowVal.includes(lowerValue);
case 'notContains': return !rowVal.includes(lowerValue);
case 'startsWith': return rowVal.startsWith(lowerValue);
case 'notStartsWith': return !rowVal.startsWith(lowerValue);
case 'endsWith': return rowVal.endsWith(lowerValue);
case 'notEndsWith': return !rowVal.endsWith(lowerValue);
default: return true;
}
});
}
});
}
// Search Filter
if (debouncedSearch) {
const searchTerms = debouncedSearch.toLowerCase().split(/[\s,]+/).filter(t => t.length > 0);
if (searchTerms.length > 0) {
result = result.filter(r => {
const rowValues = [
r.sku.toLowerCase(),
r.asin.toLowerCase(),
r.title.toLowerCase(),
r.line.toLowerCase()
];
return searchTerms.some(term =>
rowValues.some(val => val.includes(term))
);
});
}
}
// 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;
});
}
}
// WOC Filter
if (wocFilter && wocFilter.length > 0 && velocityMap && vendorStockMap) {
result = result.filter(r => {
const asin = r.asin.trim().toUpperCase();
const stockData = vendorStockMap.get(asin);
if (!stockData) return false;
const stock = top50Mode === 'uk' ? stockData.uk : stockData.eu;
const velocity = velocityMap.get(asin) || 0;
let woc: number = 0;
if (velocity > 0) {
woc = stock / velocity;
} else if (stock > 0) {
woc = 999;
}
return checkNumericConditions(woc, wocFilter);
});
}
// Numeric Filters (Units, Spend, GV)
if (numericFilters.length > 0) {
result = result.filter(row => {
return numericFilters.every(filter => {
let value = 0;
if (filter.metric === 'units') {
value = row.unitsByWeek[filter.week] || 0;
} else if (filter.metric === 'spend') {
value = row.spendByWeek[filter.week] || 0;
} else if (filter.metric === 'revenue') {
value = row.revenueByWeek?.[filter.week] || 0;
} else if (filter.metric === 'gv') {
value = row.gvByWeek?.[filter.week] || 0;
}
return passesNumericFilter(value, filter);
});
});
}
return result;
}, [rows, debouncedSearch, showOnlyTop50, showActiveExperiments, experimentMap, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks, top50Mode, wocFilter, velocityMap, vendorStockMap, numericFilters, columnFilters]);
const aggregateWoc = useMemo(() => {
if (!vendorStockMap || !velocityMap || filteredRows.length === 0) return null;
let totalStock = 0;
let totalVelocity = 0;
filteredRows.forEach(row => {
const asin = row.asin.trim().toUpperCase();
const stockData = vendorStockMap.get(asin);
if (stockData) {
totalStock += (top50Mode === 'uk' ? stockData.uk : stockData.eu);
}
totalVelocity += (velocityMap.get(asin) || 0);
});
if (totalVelocity > 0) return totalStock / totalVelocity;
if (totalStock > 0) return 999;
return 0;
}, [filteredRows, vendorStockMap, velocityMap, top50Mode]);
// 2. Sort results
const sortedRows = useMemo(() => {
if (!sortConfig || filteredRows.length === 0) return filteredRows;
const result = filteredRows.slice();
const { key: weekKey, direction, metric } = sortConfig;
if (metric === 'rank') {
if (!top50Ranking) return result;
const rankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
result.sort((a, b) => {
const asinA = a.asin.trim().toUpperCase();
const asinB = b.asin.trim().toUpperCase();
const rankA = rankMap.get(asinA) || 999;
const rankB = rankMap.get(asinB) || 999;
return direction === 'asc' ? rankA - rankB : rankB - rankA;
});
} else {
const metricKey = metric === 'units' ? 'unitsByWeek' :
metric === 'spend' ? 'spendByWeek' :
metric === 'revenue' ? 'revenueByWeek' : 'gvByWeek';
result.sort((a, b) => {
const valA = a[metricKey][weekKey] || 0;
const valB = b[metricKey][weekKey] || 0;
if (valA === valB) return 0;
return direction === 'asc' ? valA - valB : valB - valA;
});
}
return result;
}, [filteredRows, sortConfig, top50Ranking, top50Mode]);
// 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) => {
2026-01-21 13:19:13 +01:00
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'}`}>
2026-01-21 13:19:13 +01:00
{isPositive ? '▲' : '▼'}{Math.abs(pct).toFixed(0)}%
</span>
2026-01-21 13:19:13 +01:00
);
}, []);
2026-01-21 13:19:13 +01:00
// 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;
});
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]);
const mainContent = (
<div className="flex flex-col gap-3 md:gap-4 animate-fade-in px-1 md:px-0">
{/* Toolbar: Search & Pagination */}
<div className="flex flex-col lg:flex-row justify-between items-center gap-3 md:gap-4 bg-slate-900 border border-white/10 p-2 md: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>
2026-01-22 12:17:19 +01:00
{/* 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 ({top50Mode.toUpperCase()})
</button>
</div>
)}
2026-01-22 12:17:19 +01:00
{/* Active Experiments Toggle */}
{experimentMap && experimentMap.size > 0 && (
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm ml-2">
<button
onClick={() => setShowActiveExperiments(!showActiveExperiments)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${showActiveExperiments
? 'bg-fuchsia-600 text-white shadow-lg shadow-fuchsia-500/20'
: 'text-slate-400 hover:text-fuchsia-400'
}`}
title="Filter grid to show only ASINs with logged experiments"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
Active Experiments
</button>
</div>
)}
{/* Metric Toggle */}
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm ml-auto lg:ml-0">
<button
onClick={() => setPrimaryMetric('units')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${primaryMetric === 'units'
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/20'
: 'text-slate-400 hover:text-white'
}`}
>
Units
</button>
<button
onClick={() => setPrimaryMetric('revenue')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${primaryMetric === 'revenue'
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/20'
: 'text-slate-400 hover:text-white'
}`}
>
Revenue ()
</button>
</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 scroll-touch">
<table className="w-full text-left border-collapse min-w-[max-content] text-[10px] md:text-sm">
<thead className="sticky top-0 z-20 bg-slate-900">
<tr className="bg-slate-900 border-b border-white/10 relative z-30">
<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]"
>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between pr-2">
<div
className="flex items-center gap-2 cursor-pointer hover:text-white group"
onClick={() => handleSort('rank', 'rank')}
>
<span>Product Details</span>
{sortConfig?.metric === 'rank' && (
<span className="text-amber-500 font-black text-sm animate-bounce-subtle">
{sortConfig.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
<div className="flex items-center gap-1">
<ExcelFilter
columnKey="sku"
title="SKU"
uniqueValues={Array.from(new Set(rows.map(r => r.sku))).sort()}
currentFilter={columnFilters['sku']}
onFilterChange={handleColumnFilterChange}
icon={<span className="text-[10px]">SKU</span>}
/>
<ExcelFilter
columnKey="asin"
title="ASIN"
uniqueValues={Array.from(new Set(rows.map(r => r.asin))).sort()}
currentFilter={columnFilters['asin']}
onFilterChange={handleColumnFilterChange}
icon={<span className="text-[10px]">ASIN</span>}
/>
<ExcelFilter
columnKey="title"
title="Title"
uniqueValues={Array.from(new Set(rows.map(r => r.title))).sort()}
currentFilter={columnFilters['title']}
onFilterChange={handleColumnFilterChange}
icon={<span className="text-[10px]">Title</span>}
/>
<ExcelFilter
columnKey="line"
title="Line"
uniqueValues={Array.from(new Set(rows.map(r => r.line))).sort()}
currentFilter={columnFilters['line']}
onFilterChange={handleColumnFilterChange}
icon={<span className="text-[10px]">Line</span>}
/>
</div>
</div>
<div className="flex items-center gap-1">
<InColumnStockFilter
currentFilters={stockFilter}
onFilterChange={onStockFilterChange}
title="Warehouse Stock"
icon={<WarehouseIcon className="w-3.5 h-3.5 text-fuchsia-400" />}
/>
<InColumnStockFilter
currentFilters={vendorStockFilter}
onFilterChange={onVendorStockFilterChange}
title="Vendor Stock"
icon={<AmazonSmileIcon className="w-5 h-5 text-amber-500" />}
options={[
'Out of Stock (0)',
'In Stock (>0)',
'In Stock (>20)',
'Low Stock (<10)',
]}
/>
<InColumnStockFilter
currentFilters={wocFilter}
onFilterChange={onWocFilterChange}
title="Week Coverage"
icon={<CoverageIcon className="w-3.5 h-3.5 text-emerald-400" />}
options={[
'< 4 Weeks',
'> 4 Weeks',
'Out of Stock',
'Infinite Cover'
]}
/>
</div>
</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">
<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>
<div
className={`flex-1 p-1.5 transition-colors flex items-center justify-center gap-1 border-b border-white/5 ${sortConfig?.key === week && sortConfig.metric === (primaryMetric === 'units' ? 'units' : 'revenue') ? 'bg-indigo-500/5 text-indigo-400' : 'text-slate-500'}`}
>
<span
onClick={() => handleSort(week, primaryMetric === 'units' ? 'units' : 'revenue')}
className="text-[9px] cursor-pointer hover:text-indigo-300"
>
{primaryMetric === 'units' ? 'Units' : 'Revenue'}
</span>
{sortConfig?.key === week && sortConfig.metric === (primaryMetric === 'units' ? 'units' : 'revenue') && (
<span className="text-xs font-bold leading-none">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
)}
<NumericColumnFilter
week={week}
metric={primaryMetric === 'units' ? 'units' : 'revenue'}
currentFilter={getNumericFilter(week, primaryMetric === 'units' ? 'units' : 'revenue')}
onFilterChange={(f) => setNumericFilter(f, week, primaryMetric === 'units' ? 'units' : 'revenue')}
accentColor="indigo"
/>
</div>
<div
className={`flex-1 p-1.5 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'}`}
>
<span
onClick={() => handleSort(week, 'spend')}
className="text-[9px] cursor-pointer hover:text-amber-300"
>
Spend
</span>
{sortConfig?.key === week && sortConfig.metric === 'spend' && (
<span className="text-xs font-bold leading-none">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
)}
<NumericColumnFilter
week={week}
metric="spend"
currentFilter={getNumericFilter(week, 'spend')}
onFilterChange={(f) => setNumericFilter(f, week, 'spend')}
accentColor="amber"
/>
</div>
<div
className={`flex-1 p-1.5 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'}`}
>
<span
onClick={() => handleSort(week, 'gv')}
className="text-[9px] cursor-pointer hover:text-teal-300"
>
GV
</span>
{sortConfig?.key === week && sortConfig.metric === 'gv' && (
<span className="text-xs font-bold leading-none">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
)}
<NumericColumnFilter
week={week}
metric="gv"
currentFilter={getNumericFilter(week, 'gv')}
onFilterChange={(f) => setNumericFilter(f, week, 'gv')}
accentColor="teal"
/>
</div>
</div>
</th>
))}
</tr>
<tr className="bg-indigo-950 border-b border-white/10 relative z-20">
<th className="p-3 text-sm font-black text-white sticky left-0 z-40 bg-indigo-950 border-r border-white/10">
<div className="flex items-center justify-between gap-2">
<span>TOTALS</span>
<div className="flex flex-col items-end">
<span className="text-[8px] opacity-60 uppercase tracking-tighter">Avg. Coverage</span>
<span className={`text-xs ${aggregateWoc && aggregateWoc < 4 ? 'text-rose-400' : 'text-emerald-400'}`}>
{aggregateWoc ? `${aggregateWoc.toFixed(1)}w` : '-'}
</span>
</div>
</div>
</th>
{weeks.map((week, idx) => {
const prevWeekTotals = allWeekTotals[weeks[idx + 1]];
const cur = weekTotals[week];
return (
<th key={week} className="p-3 text-sm font-black text-white text-center border-r border-white/10"
onMouseEnter={() => setHoveredTotalWeek(week)}
onMouseMove={(e) => setTooltipPos({ x: e.clientX, y: e.clientY })}
onMouseLeave={() => { setHoveredTotalWeek(null); setTooltipPos(null); }}
>
<div className="flex flex-col items-center">
{primaryMetric === 'units' ? (
<>
<div className="flex items-center gap-1">
<span>{cur?.units.toLocaleString('de-DE') || 0}</span>
{renderGrowth(cur?.units || 0, prevWeekTotals?.units || 0)}
</div>
<div className="flex items-center gap-0.5 text-rose-400/80 italic font-bold text-[10px]">
<span>Ads: {(cur?.spend || 0).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</span>
{renderGrowth(cur?.spend || 0, prevWeekTotals?.spend || 0)}
</div>
</>
) : (
<>
<div className="flex items-center gap-1">
<span>{(cur?.revenue || 0).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</span>
{renderGrowth(cur?.revenue || 0, prevWeekTotals?.revenue || 0)}
</div>
<div className="flex items-center gap-0.5 text-rose-400/80 italic font-bold text-[10px]">
<span>Ads: {(cur?.spend || 0).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</span>
{renderGrowth(cur?.spend || 0, prevWeekTotals?.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: {(cur?.gv || 0).toLocaleString('de-DE')}</span>
{renderGrowth(cur?.gv || 0, prevWeekTotals?.gv || 0)}
</div>
</div>
</th>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{sortedRows.length > 0 ? (
(() => {
const displayRows = sortedRows.slice(0, displayCount);
return (
<>
{displayRows.map(row => (
<WeeklyRow
key={row.id}
row={row}
weeks={weeks}
onDrillDown={onDrillDown}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
top50Ranking={top50Ranking}
top50Mode={top50Mode}
sortConfig={sortConfig}
renderGrowth={renderGrowth}
customerFilters={customerFilters}
velocityMap={velocityMap}
buyBoxLostMap={buyBoxLostMap}
primaryMetric={primaryMetric}
experimentMap={experimentMap}
onOpenExperiment={onOpenExperiment}
/>
))}
{displayCount < sortedRows.length && (
<tr>
<td colSpan={weeks.length + 1} className="p-6 text-center bg-slate-900/50 backdrop-blur-sm border-t border-white/5">
<button
onClick={() => setDisplayCount(prev => prev + 100)}
className="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-black uppercase tracking-widest shadow-xl transition-all active:scale-95 border border-indigo-400/30"
>
Load More SKUs ({sortedRows.length - displayCount} remaining)
</button>
</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>
2026-01-21 13:19:13 +01:00
</div>
</div>
);
const tooltipPortal = hoveredTotalWeek && tooltipPos ? (() => {
const week = hoveredTotalWeek;
const idx = weeks.indexOf(week);
const [yearStr, weekNum] = week.split('-');
const prevYearWeek = `${parseInt(yearStr) - 1}-${weekNum}`;
const cur = weekTotals[week];
const prevWeekTotals = allWeekTotals[weeks[idx + 1]];
const yoyTotals = allWeekTotals[prevYearWeek];
return ReactDOM.createPortal(
<div
className="pointer-events-none fixed w-64 bg-slate-900 border border-white/20 rounded-xl shadow-2xl p-3 text-left"
style={{ left: tooltipPos.x + 16, top: tooltipPos.y - 16, transform: 'translateY(-100%)', zIndex: 999999 }}
>
<div className="text-[10px] font-black text-white/50 uppercase tracking-widest mb-2">{week}</div>
<div className="mb-2">
<div className="text-[9px] font-black text-indigo-300 uppercase tracking-widest mb-1">Semana anterior ({weeks[idx + 1] || '—'})</div>
{prevWeekTotals ? (
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[11px]">
<span className="text-white/60">Units</span>
<span className="text-white font-bold text-right">{prevWeekTotals.units.toLocaleString('de-DE')} {renderGrowth(cur?.units || 0, prevWeekTotals.units)}</span>
<span className="text-white/60">Revenue</span>
<span className="text-white font-bold text-right">{prevWeekTotals.revenue.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} {renderGrowth(cur?.revenue || 0, prevWeekTotals.revenue)}</span>
<span className="text-white/60">Ads Spend</span>
<span className="text-rose-400 font-bold text-right">{prevWeekTotals.spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} {renderGrowth(cur?.spend || 0, prevWeekTotals.spend)}</span>
<span className="text-white/60">GV</span>
<span className="text-teal-400 font-bold text-right">{prevWeekTotals.gv.toLocaleString('de-DE')} {renderGrowth(cur?.gv || 0, prevWeekTotals.gv)}</span>
</div>
) : (
<span className="text-white/30 text-[11px]">Sin datos</span>
)}
</div>
<div className="border-t border-white/10 pt-2">
<div className="text-[9px] font-black text-amber-300 uppercase tracking-widest mb-1">Misma semana año anterior ({prevYearWeek})</div>
{yoyTotals && (yoyTotals.units > 0 || yoyTotals.revenue > 0) ? (
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[11px]">
<span className="text-white/60">Units</span>
<span className="text-white font-bold text-right">{yoyTotals.units.toLocaleString('de-DE')} {renderGrowth(cur?.units || 0, yoyTotals.units)}</span>
<span className="text-white/60">Revenue</span>
<span className="text-white font-bold text-right">{yoyTotals.revenue.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} {renderGrowth(cur?.revenue || 0, yoyTotals.revenue)}</span>
<span className="text-white/60">Ads Spend</span>
<span className="text-rose-400 font-bold text-right">{yoyTotals.spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} {renderGrowth(cur?.spend || 0, yoyTotals.spend)}</span>
<span className="text-white/60">GV</span>
<span className="text-teal-400 font-bold text-right">{yoyTotals.gv.toLocaleString('de-DE')} {renderGrowth(cur?.gv || 0, yoyTotals.gv)}</span>
</div>
) : (
<span className="text-white/30 text-[11px]">Sin datos del año anterior</span>
)}
</div>
</div>,
document.body
);
})() : null;
return <>{mainContent}{tooltipPortal}</>;
2026-01-21 13:19:13 +01:00
};
export default WeeklyGrid;