Files
CrazeAnalytix/components/DataGrid.tsx
T
Christian Vidal WolfandClaude Sonnet 4.6 507af94e1a fix: move BuyBoxWarningBadge inside stockMap block to prevent duplication
The badge was rendered outside the stockMap conditional, causing it to
appear independently of the Amazon stock badge. Now it's grouped inside
the block, always appearing immediately next to VendorStockBadge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 10:23:28 +01:00

1544 lines
94 KiB
TypeScript

import React, { useState, useMemo, useEffect, useRef } from 'react';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
} from 'recharts';
import { SalesRecord, PivotRow, AdsRecord, CombinedKPIs, ColumnFilterCondition, FilterState } from '../types';
import { pivotSalesData, generateXLSX, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping, filterData } from '../services/dataProcessor';
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons';
import { StockBadge } from './StockBadge';
import { Top50Badge } from './Top50Badge';
import { VendorStockBadge } from './VendorStockBadge';
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
import { ExcelFilter } from './ExcelFilter';
interface DataGridProps {
data: SalesRecord[] | CombinedKPIs[];
filters: FilterState;
hasCustomerFilter: boolean;
adsData?: AdsRecord[];
stockMap?: Map<string, number>;
top50Ranking?: {
eu: Map<string, number>;
uk: Map<string, number>;
};
top50Mode: 'eu' | 'uk';
vendorStockMap?: Map<string, { eu: number; uk: number }>;
velocityMap?: Map<string, number>;
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
defaultSort?: SortConfig;
}
type SortConfig = {
key: string | null;
direction: 'asc' | 'desc';
};
type ConditionalFilter = {
id: string;
metric: string;
operator: 'gt' | 'lt';
value: number;
}
const ROWS_PER_PAGE = 50;
const CHART_COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#0ea5e9'];
// Available grouping dimensions
const DIMENSION_OPTIONS = [
{ label: 'Product Line', value: 'line' },
{ label: 'Customer', value: 'customer' },
{ label: 'SKU', value: 'sku' },
{ label: 'Title', value: 'title' },
{ label: 'ASIN', value: 'asin' },
];
// Tooltip for single-period view with Week-over-Week comparison
const WoWTooltip = ({ active, payload, label, data }: any) => {
if (active && payload && payload.length && data) {
const currentIndex = data.findIndex((d: any) => d.name === label);
const prevData = currentIndex > 0 ? data[currentIndex - 1] : null;
return (
<div className="bg-slate-900 border border-border p-3 rounded shadow-xl text-sm max-w-xs z-50">
<p className="font-bold text-slate-100 mb-2 border-b border-slate-700 pb-1">{label}</p>
{payload.map((p: any) => {
let wowEl = null;
if (prevData) {
const prevValue = prevData[p.dataKey];
const currentValue = p.value;
if (prevValue != null && prevValue > 0) {
const pct = ((currentValue - prevValue) / prevValue) * 100;
wowEl = (
<span className={`text-xs ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct >= 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
}
return (
<div key={p.name} className="flex justify-between items-center gap-2 mb-1">
<span style={{ color: p.color }}>{p.name}:</span>
<div className="flex items-center">
<span className="font-mono font-semibold text-slate-200">
{p.dataKey === 'sellOut'
? `€${Number(p.value).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`
: `${Number(p.value).toLocaleString('de-DE')} u`}
</span>
{wowEl}
</div>
</div>
);
})}
</div>
);
}
return null;
}
// Tooltip for multi-year comparison view
const ComparisonTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
interface YearData {
sellOut?: number;
units?: number;
color?: string;
}
const dataByYear: { [year: string]: YearData } = {};
payload.forEach((p: any) => {
const nameParts = p.name.split(' ');
if (nameParts.length < 2) return;
const year = nameParts[nameParts.length - 1];
const metric = nameParts.slice(0, nameParts.length - 1).join(' ');
if (!dataByYear[year]) {
dataByYear[year] = {};
}
// Use the color from the Sell Out line for consistency for that year block
if (metric.toLowerCase().includes('sell out')) {
dataByYear[year].sellOut = p.value;
dataByYear[year].color = p.stroke || p.color;
} else if (metric.toLowerCase().includes('units')) {
dataByYear[year].units = p.value;
if (!dataByYear[year].color) { // fallback color from units line
dataByYear[year].color = p.stroke || p.color;
}
}
});
const sortedYears = Object.keys(dataByYear).sort((a, b) => parseInt(b) - parseInt(a));
return (
<div className="bg-slate-900/90 backdrop-blur-sm border border-border p-3 rounded-lg shadow-xl text-sm w-56 z-50">
<p className="font-bold text-slate-100 mb-2 border-b border-slate-700 pb-1">{label}</p>
{sortedYears.map((year, index) => {
const yearData = dataByYear[year];
const prevYear = sortedYears[index + 1];
const prevYearData = prevYear ? dataByYear[prevYear] : null;
let sellOutGrowthEl = null;
if (prevYearData && prevYearData.sellOut != null && prevYearData.sellOut !== 0 && yearData.sellOut != null) {
const pct = ((yearData.sellOut - prevYearData.sellOut) / prevYearData.sellOut) * 100;
sellOutGrowthEl = (
<span className={`text-xs ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct >= 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
let unitsGrowthEl = null;
if (prevYearData && prevYearData.units != null && prevYearData.units !== 0 && yearData.units != null) {
const pct = ((yearData.units - prevYearData.units) / prevYearData.units) * 100;
unitsGrowthEl = (
<span className={`text-xs ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct >= 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
return (
<div key={year} className="mt-2">
<p className="font-bold text-slate-200 text-base">{year}</p>
{yearData.sellOut != null && (
<div className="flex justify-between items-center gap-2 pl-1 mt-1">
<span className="font-medium" style={{ color: yearData.color }}>Sell Out:</span>
<div className="flex items-center">
<span className="font-mono font-semibold" style={{ color: yearData.color }}>
{Number(yearData.sellOut).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</span>
{sellOutGrowthEl}
</div>
</div>
)}
{yearData.units != null && (
<div className="flex justify-between items-center gap-2 pl-1 mt-1">
<span className="font-medium" style={{ color: yearData.color }}>Units:</span>
<div className="flex items-center">
<span className="font-mono font-semibold" style={{ color: yearData.color }}>
{Number(yearData.units).toLocaleString('de-DE')} u
</span>
{unitsGrowthEl}
</div>
</div>
)}
</div>
);
})}
</div>
);
}
return null;
};
// Reusable Expandable Chart Card
const ExpandableChartCard: React.FC<{ title: string; children: React.ReactNode; className?: string }> = ({ title, children, className }) => {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpand = () => setIsExpanded(!isExpanded);
if (isExpanded) {
return (
<div className="fixed inset-0 z-[80] bg-slate-950 p-6 flex flex-col animate-fade-in overflow-hidden">
<div className="flex justify-between items-center mb-4 border-b border-slate-800 pb-4 shrink-0">
<h3 className="text-xl font-bold text-slate-100 uppercase tracking-wide">{title}</h3>
<button
onClick={toggleExpand}
className="p-2 bg-red-600/90 hover:bg-red-500 border border-red-400 rounded-full text-white transition-all shadow-2xl hover:scale-110 flex items-center gap-2 group"
title="Exit Fullscreen"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-5 h-5"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
</button>
</div>
{/* Use flex-1 and min-h-0 to allow children to fill available space */}
<div className="flex-1 min-h-0 bg-slate-900 rounded-xl p-6 border border-border [&>div]:h-full [&>div]:w-full">
{children}
</div>
</div>
);
}
return (
<div className={`bg-surface border-b border-border group relative transition-all duration-300 ${className}`}>
<div className="p-4 flex justify-between items-start">
<h3 className="text-sm font-semibold text-slate-400 uppercase tracking-wide">{title}</h3>
<button
onClick={toggleExpand}
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-primary transition-opacity"
title="Expand to Fullscreen"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5"><path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" /></svg>
</button>
</div>
<div className="cursor-pointer px-4 pb-4" onClick={toggleExpand}>
{children}
</div>
</div>
);
};
const DataGrid: React.FC<DataGridProps> = ({ data, filters, hasCustomerFilter, adsData, stockMap, vendorStockMap, top50Ranking, top50Mode, velocityMap, buyBoxLostMap, defaultSort }) => {
const [currentPage, setCurrentPage] = useState(1);
const [searchTerm, setSearchTerm] = useState('');
const [sortConfig, setSortConfig] = useState<SortConfig>(defaultSort || { key: null, direction: 'desc' });
const [showChart, setShowChart] = useState(true);
const [visibleMetrics, setVisibleMetrics] = useState<('sellOut' | 'units')[]>(['sellOut', 'units']);
const [showAdsMetrics, setShowAdsMetrics] = useState(true);
const [showAttributedSales, setShowAttributedSales] = useState(false);
const [showBulkSearch, setShowBulkSearch] = useState(false);
const [showYTD, setShowYTD] = useState(false);
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
const [showDimensionMenu, setShowDimensionMenu] = useState(false);
const dimensionRef = useRef<HTMLDivElement>(null);
// Close dimension menu on click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dimensionRef.current && !dimensionRef.current.contains(event.target as Node)) {
setShowDimensionMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Calculate Ads Summary for the Grid
const adsSummary = useMemo(() => {
if (!adsData || adsData.length === 0) return null;
const totals = adsData.reduce((acc, ad) => ({
cost: acc.cost + ad.cost,
attributedSales: acc.attributedSales + ad.attributedSales30d,
clicks: acc.clicks + ad.clicks,
impressions: acc.impressions + ad.impressions,
}), { cost: 0, attributedSales: 0, clicks: 0, impressions: 0 });
return {
totalSpend: totals.cost,
attributedSales: totals.attributedSales,
acos: totals.attributedSales > 0 ? (totals.cost / totals.attributedSales) * 100 : 0,
roas: totals.cost > 0 ? totals.attributedSales / totals.cost : 0,
recordCount: adsData.length,
};
}, [adsData]);
// State for dynamic grouping
const [selectedDimensions, setSelectedDimensions] = useState<string[]>(['sku', 'title']);
// State for Advanced Filtering
const [showFilterBuilder, setShowFilterBuilder] = useState(false);
const [rowFilters, setRowFilters] = useState<ConditionalFilter[]>([]);
// Temp state for new filter inputs
const [newFilterMetric, setNewFilterMetric] = useState<string>('');
const [newFilterOperator, setNewFilterOperator] = useState<'gt' | 'lt'>('gt');
const [newFilterValue, setNewFilterValue] = useState<string>('');
// State for Column Filters
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);
};
// Effective dimensions for rendering
const effectiveDimensions = useMemo(() =>
selectedDimensions.length > 0 ? selectedDimensions : ['customer'],
[selectedDimensions]);
// Transform flat data into Pivot structure
const pivotRows = useMemo(() => {
// Apply Pan-EU grouping when no customer filter is applied
const processedData = applyPanEUGrouping(data as SalesRecord[], hasCustomerFilter);
// Apply our comprehensive filters (includes Column Filters now)
const filterState: any = {
...filters,
bulkSearch: searchTerm, // searchTerm from the DataGrid's local search input
columnFilters
};
const filteredFlatData = filterData(processedData, filterState, stockMap, vendorStockMap, top50Mode);
// pivotSalesData now handles ads aggregation correctly because it receives CombinedKPIs
const { rows } = pivotSalesData(filteredFlatData, effectiveDimensions);
return rows;
}, [data, filters, effectiveDimensions, hasCustomerFilter, columnFilters, searchTerm, stockMap, vendorStockMap, top50Mode]);
const { years } = useMemo(() => {
// We still need unique years for columns
const yearsSet = new Set<string>(data.map(d => String(d.year)));
return { years: Array.from(yearsSet).sort((a, b) => parseInt(b) - parseInt(a)) };
}, [data]);
// Data for the time series chart, supporting single and multi-year comparison
const { chartData, uniqueYears, isComparisonView, chartTitle } = useMemo(() => {
const yearsInView = Array.from(new Set(data.map(d => d.year.toString()))).sort((a: string, b: string) => parseInt(b) - parseInt(a));
const isMultiYear = yearsInView.length > 1;
// Get base sales data
let salesChartData = isMultiYear
? aggregateForComparisonTimeSeries(data)
: aggregateForTimeSeries(data);
// Aggregate ads data by week/year and merge into chartData
if (adsData && adsData.length > 0) {
const adsMap = new Map<number, { [key: string]: number }>();
adsData.forEach(ad => {
if (ad.week >= 1 && ad.week <= 53) {
if (!adsMap.has(ad.week)) {
adsMap.set(ad.week, {});
}
const weekData = adsMap.get(ad.week)!;
const adSpendKey = `${ad.year}_adSpend`;
const attrSalesKey = `${ad.year}_attributedSales`;
weekData[adSpendKey] = (weekData[adSpendKey] || 0) + ad.cost;
weekData[attrSalesKey] = (weekData[attrSalesKey] || 0) + ad.attributedSales30d;
}
});
// Merge ads data into sales chart data
salesChartData = salesChartData.map(point => {
const adsWeekData = adsMap.get(point.week) || {};
return { ...point, ...adsWeekData };
});
}
// Calculate YTD (Year-To-Date) cumulative values for each year
const ytdAccumulators: { [year: string]: { sellOut: number; units: number } } = {};
yearsInView.forEach((year: string) => {
ytdAccumulators[year] = { sellOut: 0, units: 0 };
});
salesChartData.forEach((point: any) => {
yearsInView.forEach((year: string) => {
const sellOutKey = isMultiYear ? `${year}_sellOut` : 'sellOut';
const unitsKey = isMultiYear ? `${year}_units` : 'units';
// Accumulate values
ytdAccumulators[year].sellOut += (point[sellOutKey] as number) || 0;
ytdAccumulators[year].units += (point[unitsKey] as number) || 0;
// Add YTD values directly to point
point[`${year}_ytdSellOut`] = ytdAccumulators[year].sellOut;
point[`${year}_ytdUnits`] = ytdAccumulators[year].units;
});
});
return {
chartData: salesChartData,
uniqueYears: yearsInView,
isComparisonView: isMultiYear,
chartTitle: isMultiYear
? `Weekly Sales Comparison: ${yearsInView.join(' vs ')}`
: `Weekly Sales Evolution ${yearsInView[0] || ''}`
};
}, [data, adsData]);
// Filter Options based on available data
const metricOptions = useMemo(() => {
const options = [];
// Totals
years.forEach(y => {
options.push({ label: `Total Sell Out ${y} (€)`, value: `total_sellOut_${y}` });
options.push({ label: `Total Units ${y}`, value: `total_units_${y}` });
});
// Growth (Latest vs Previous)
if (years.length >= 2) {
options.push({ label: `Growth % Sell Out (${years[0]} vs ${years[1]})`, value: 'growth_sellOut' });
options.push({ label: `Growth % Units (${years[0]} vs ${years[1]})`, value: 'growth_units' });
}
return options;
}, [years]);
// Apply Advanced Row Filters THEN Sort
const processedRows = useMemo(() => {
let result = pivotRows;
// NEW: Filter for Top 50
if (showOnlyTop50 && top50Ranking) {
const rankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
result = result.filter(row => {
const asin = (row.asin || '').trim().toUpperCase();
return asin && rankMap.has(asin);
});
}
// 0. Global Search Filter
if (searchTerm) {
const rawTerm = searchTerm.trim();
// Check if it looks like a bulk search (contains newlines or commas)
const isBulk = rawTerm.includes('\n') || rawTerm.includes(',') || rawTerm.includes(' ');
if (isBulk) {
const searchTerms = rawTerm
.split(/[\s,\n]+/)
.map(t => t.trim().toLowerCase())
.filter(t => t.length > 0);
if (searchTerms.length > 0) {
result = result.filter(row => {
const rowSku = row.sku?.toLowerCase() || '';
const rowAsin = row.asin?.toLowerCase() || '';
const rowTitle = row.title?.toLowerCase() || '';
return searchTerms.some(term =>
rowSku.includes(term) ||
rowAsin.includes(term) ||
rowTitle.includes(term)
);
});
}
} else {
const term = rawTerm.toLowerCase();
result = result.filter(row => {
return (
(row.sku?.toLowerCase().includes(term)) ||
(row.asin?.toLowerCase().includes(term)) ||
(row.title?.toLowerCase().includes(term)) ||
(row.line?.toLowerCase().includes(term)) ||
(row.customer?.toLowerCase().includes(term))
);
});
}
}
// 1. Filter (Legacy Row Filters)
if (rowFilters.length > 0) {
result = result.filter(row => {
return rowFilters.every(filter => {
let rowValue = 0;
if (filter.metric.startsWith('total_sellOut_')) {
const y = filter.metric.split('_')[2];
rowValue = row.totalsByYear[y]?.sellOut || 0;
}
else if (filter.metric.startsWith('total_units_')) {
const y = filter.metric.split('_')[2];
rowValue = row.totalsByYear[y]?.units || 0;
}
else if (filter.metric === 'growth_sellOut') {
const latestYear = years[0];
const prevYear = years[1];
if (!prevYear) return true;
const curr = row.totalsByYear[latestYear]?.sellOut || 0;
const prev = row.totalsByYear[prevYear]?.sellOut || 0;
if (prev === 0) return curr > 0;
rowValue = ((curr - prev) / prev) * 100;
}
else if (filter.metric === 'growth_units') {
const latestYear = years[0];
const prevYear = years[1];
if (!prevYear) return true;
const curr = row.totalsByYear[latestYear]?.units || 0;
const prev = row.totalsByYear[prevYear]?.units || 0;
if (prev === 0) return curr > 0;
rowValue = ((curr - prev) / prev) * 100;
}
if (filter.operator === 'gt') return rowValue > filter.value;
if (filter.operator === 'lt') return rowValue < filter.value;
return true;
});
});
}
// 1b. Filter (New Excel Column Filters for Metrics)
(Object.entries(columnFilters) as [string, ColumnFilterCondition][]).forEach(([key, condition]) => {
if (!condition) return;
if (!key.startsWith('total_')) return; // Metric filters in this view start with 'total_'
const isSellOut = key.includes('_sellOut_');
const year = key.split('_')[2];
if (condition.selectedValues && condition.selectedValues.length > 0) {
result = result.filter(row => {
const val = isSellOut ? row.totalsByYear[year]?.sellOut : row.totalsByYear[year]?.units;
return condition.selectedValues?.includes(String(val || 0));
});
}
if (condition.textFilter) {
const { operator, value } = condition.textFilter;
const filterNum = parseFloat(value);
if (isNaN(filterNum)) return;
result = result.filter(row => {
const rowVal = (isSellOut ? row.totalsByYear[year]?.sellOut : row.totalsByYear[year]?.units) || 0;
switch (operator) {
case 'equals': return rowVal === filterNum;
case 'notEquals': return rowVal !== filterNum;
case 'gt': return rowVal > filterNum;
case 'lt': return rowVal < filterNum;
case 'gte': return rowVal >= filterNum;
case 'lte': return rowVal <= filterNum;
// Text-like operators on numeric values (convert to string)
case 'contains': return String(rowVal).includes(value);
case 'startsWith': return String(rowVal).startsWith(value);
case 'endsWith': return String(rowVal).endsWith(value);
default: return true;
}
});
}
});
// 2. Sort
if (sortConfig.key) {
// Create a copy to avoid mutating the original array and ensure React detects the change
result = [...result].sort((a, b) => {
let valA: number | string = '';
let valB: number | string = '';
// Handle sorting by dimensions
if (['customer', 'line', 'sku', 'title', 'articleName', 'asin'].includes(sortConfig.key as string)) {
valA = a[sortConfig.key as keyof PivotRow] as string || '';
valB = b[sortConfig.key as keyof PivotRow] as string || '';
}
// Handle sorting by Total Metrics (total_sellOut_2023)
else if ((sortConfig.key as string).startsWith('total_')) {
const parts = (sortConfig.key as string).split('_');
// parts[1] = metric (sellOut/units), parts[2] = year
if (parts.length === 3) {
const y = parts[2];
const m = parts[1] as 'sellOut' | 'units';
valA = a.totalsByYear[y]?.[m] || 0;
valB = b.totalsByYear[y]?.[m] || 0;
}
}
// Handle sorting by Growth Percentages (growth_sellOut_2024_2023)
else if ((sortConfig.key as string).startsWith('growth_')) {
const parts = (sortConfig.key as string).split('_');
// parts[1] = metric (sellOut/units), parts[2] = current year, parts[3] = previous year
if (parts.length === 4) {
const metric = parts[1] as 'sellOut' | 'units';
const currentYear = parts[2];
const prevYear = parts[3];
const currA = a.totalsByYear[currentYear]?.[metric] || 0;
const prevA = a.totalsByYear[prevYear]?.[metric] || 0;
valA = prevA !== 0 ? ((currA - prevA) / prevA) * 100 : (currA > 0 ? 100 : 0);
const currB = b.totalsByYear[currentYear]?.[metric] || 0;
const prevB = b.totalsByYear[prevYear]?.[metric] || 0;
valB = prevB !== 0 ? ((currB - prevB) / prevB) * 100 : (currB > 0 ? 100 : 0);
}
}
// Handle sorting by Ads Metrics (adSpend_2025, tacos_2025)
else if ((sortConfig.key as string).includes('_') && !((sortConfig.key as string).startsWith('total_') || (sortConfig.key as string).startsWith('growth_'))) {
const parts = (sortConfig.key as string).split('_');
if (parts.length === 2) {
const metric = parts[0] as 'adSpend' | 'tacos';
const year = parts[1];
valA = a.adsByYear?.[year]?.[metric] || 0;
valB = b.adsByYear?.[year]?.[metric] || 0;
}
}
if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1;
if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
} else if (showOnlyTop50 && top50Ranking) {
// Default sort by Rank when Top 50 is active
const rankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
result = [...result].sort((a, b) => {
const rankA = a.asin ? rankMap.get(a.asin.toUpperCase()) || 999 : 999;
const rankB = b.asin ? rankMap.get(b.asin.toUpperCase()) || 999 : 999;
return rankA - rankB;
});
}
return result;
}, [pivotRows, rowFilters, sortConfig, years, searchTerm, showOnlyTop50, top50Ranking, top50Mode]);
// Calculate aggregated totals for the header row
const filteredTotalsByYear = useMemo(() => {
const result: { [year: string]: { sellOut: number, units: number, sellOutGrowth?: number, unitsGrowth?: number } } = {};
years.forEach((year, index) => {
const totals = processedRows.reduce(
(acc, row) => {
const data = row.totalsByYear[year];
if (data) {
acc.sellOut += data.sellOut;
acc.units += data.units;
}
const ads = row.adsByYear?.[year];
if (ads) {
acc.adSpend += ads.adSpend;
acc.attributedSales += ads.attributedSales;
}
return acc;
},
{ sellOut: 0, units: 0, adSpend: 0, attributedSales: 0 }
);
result[year] = totals;
// Calculate YoY growth if previous year exists
const prevYear = years[index + 1];
if (prevYear) {
const prevYearTotals = processedRows.reduce(
(acc, row) => {
const data = row.totalsByYear[prevYear];
if (data) {
acc.sellOut += data.sellOut;
acc.units += data.units;
}
const ads = row.adsByYear?.[prevYear];
if (ads) {
acc.adSpend += ads.adSpend;
}
return acc;
},
{ sellOut: 0, units: 0, adSpend: 0 }
);
if (prevYearTotals.sellOut > 0) {
result[year].sellOutGrowth = ((totals.sellOut - prevYearTotals.sellOut) / prevYearTotals.sellOut) * 100;
} else if (totals.sellOut > 0) {
result[year].sellOutGrowth = 100;
}
if (prevYearTotals.units > 0) {
result[year].unitsGrowth = ((totals.units - prevYearTotals.units) / prevYearTotals.units) * 100;
} else if (totals.units > 0) {
result[year].unitsGrowth = 100;
}
if (prevYearTotals.adSpend > 0) {
(result[year] as any).adSpendGrowth = ((totals.adSpend - prevYearTotals.adSpend) / prevYearTotals.adSpend) * 100;
} else if (totals.adSpend > 0) {
(result[year] as any).adSpendGrowth = 100;
}
}
});
return result;
}, [processedRows, years]);
const paginatedRows = useMemo(() => {
const start = (currentPage - 1) * ROWS_PER_PAGE;
return processedRows.slice(start, start + ROWS_PER_PAGE);
}, [processedRows, currentPage]);
const totalPages = Math.ceil(processedRows.length / ROWS_PER_PAGE);
const requestSort = (key: string) => {
let direction: 'asc' | 'desc' = 'desc';
if (sortConfig.key === key && sortConfig.direction === 'desc') {
direction = 'asc';
}
setSortConfig({ key, direction });
};
const getSortIcon = (key: string) => {
if (sortConfig.key !== key) return <span className="text-slate-600 opacity-20 group-hover:opacity-100 transition-opacity ml-0.5"></span>;
return <span className="text-primary ml-0.5">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>;
};
const handleExport = () => {
generateXLSX(processedRows, effectiveDimensions, years);
};
const addFilter = () => {
if (newFilterMetric && newFilterValue) {
setRowFilters([
...rowFilters,
{
id: Date.now().toString(),
metric: newFilterMetric,
operator: newFilterOperator,
value: parseFloat(newFilterValue)
}
]);
setNewFilterMetric('');
setNewFilterValue('');
setShowFilterBuilder(false);
}
};
const removeFilter = (id: string) => {
setRowFilters(rowFilters.filter(f => f.id !== id));
};
// Reset pagination when filters change
useEffect(() => {
setCurrentPage(1);
}, [rowFilters, data, effectiveDimensions, searchTerm]);
return (
<div className="space-y-4 md:space-y-6 max-w-[98vw] md:max-w-[95vw] mx-auto animate-fade-in px-1 md:px-0 pb-24">
{/* 1. Time Series Chart Section */}
{showChart && chartData.length > 0 && (
<ExpandableChartCard title={chartTitle} className="rounded-xl overflow-hidden shadow-sm">
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis
dataKey="name"
stroke="#64748b"
tick={{ fontSize: 12 }}
interval={isComparisonView ? 2 : 'preserveStartEnd'}
/>
<YAxis
stroke="#64748b"
tickFormatter={(val) => `€${(val / 1000).toFixed(0)}k`}
/>
<Tooltip content={(props: any) => isComparisonView ? <ComparisonTooltip {...props} /> : <WoWTooltip {...props} data={chartData} />} />
<Legend />
{isComparisonView ? (
uniqueYears.flatMap((year, idx) => [
visibleMetrics.includes('sellOut') && (
<Line
key={`${year}_so`}
type="monotone"
dataKey={`${year}_sellOut`}
name={`Sell Out ${year}`}
stroke={CHART_COLORS[idx % CHART_COLORS.length]}
strokeWidth={2}
dot={false}
/>
),
visibleMetrics.includes('units') && (
<Line
key={`${year}_units`}
type="monotone"
dataKey={`${year}_units`}
name={`Units ${year}`}
stroke={CHART_COLORS[idx % CHART_COLORS.length]}
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
/>
),
// Ad Spend line (only when ads data exists and showAdsMetrics is on)
showAdsMetrics && adsSummary && (
<Line
key={`${year}_adSpend`}
type="monotone"
dataKey={`${year}_adSpend`}
name={`Ad Spend ${year}`}
stroke="#d946ef"
strokeWidth={2}
strokeDasharray="3 3"
dot={false}
/>
),
// Attributed Sales line
showAttributedSales && adsSummary && (
<Line
key={`${year}_attrSales`}
type="monotone"
dataKey={`${year}_attributedSales`}
name={`Attr. Sales ${year}`}
stroke="#fbbf24"
strokeWidth={2}
dot={false}
/>
),
// YTD Sell Out line (cumulative)
showYTD && visibleMetrics.includes('sellOut') && (
<Line
key={`${year}_ytdSellOut`}
type="monotone"
dataKey={`${year}_ytdSellOut`}
name={`YTD Sell Out ${year}`}
stroke={CHART_COLORS[idx % CHART_COLORS.length]}
strokeWidth={3}
strokeOpacity={0.6}
dot={false}
/>
),
// YTD Units line (cumulative)
showYTD && visibleMetrics.includes('units') && (
<Line
key={`${year}_ytdUnits`}
type="monotone"
dataKey={`${year}_ytdUnits`}
name={`YTD Units ${year}`}
stroke={CHART_COLORS[idx % CHART_COLORS.length]}
strokeWidth={3}
strokeDasharray="10 5"
strokeOpacity={0.6}
dot={false}
/>
)
])
) : (
[
visibleMetrics.includes('sellOut') && (
<Line
key="so"
type="monotone"
dataKey="sellOut"
name="Sell Out"
stroke="#6366f1"
strokeWidth={3}
dot={false}
/>
),
visibleMetrics.includes('units') && (
<Line
key="units"
type="monotone"
dataKey="units"
name="Units"
stroke="#10b981"
strokeWidth={3}
dot={false}
yAxisId={0} // Using same axis for simplicity, usually needs dual axis
/>
)
]
)}
</LineChart>
</ResponsiveContainer>
</div>
</ExpandableChartCard>
)}
{/* 2. Controls & Grid */}
<div className="bg-surface border border-border rounded-xl shadow-lg flex flex-col">
{/* Toolbar */}
<div className="p-4 border-b border-border bg-slate-900/50 flex flex-col xl:flex-row gap-4 justify-between items-start xl:items-center rounded-t-xl">
<div className="flex flex-wrap items-center gap-3 w-full xl:w-auto">
{/* Search Bar */}
<div className="flex items-center gap-2 mr-2">
<div className="relative w-full sm:w-64 lg:w-80">
{showBulkSearch ? (
<textarea
placeholder="Bulk Search (ASINs/SKUs)..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-slate-950 border border-white/10 rounded-xl px-4 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all min-h-[40px] max-h-[120px] font-mono leading-tight"
rows={1}
/>
) : (
<>
<input
type="text"
placeholder="Search SKU, ASIN..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-slate-950 border border-white/10 rounded-xl 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>
</>
)}
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className={`absolute right-3 ${showBulkSearch ? 'top-2' : 'top-2.5'} text-slate-500 hover:text-white`}
>
<CloseIcon />
</button>
)}
</div>
<button
onClick={() => setShowBulkSearch(!showBulkSearch)}
className={`p-2 rounded-lg border transition-all flex items-center justify-center ${showBulkSearch
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400 shadow-[0_0_10px_rgba(99,102,241,0.2)]'
: 'bg-slate-800/50 border-white/5 text-slate-400 hover:text-slate-200 hover:border-white/10'
}`}
title={showBulkSearch ? "Switch to Single Search" : "Enable Bulk Search"}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
</button>
</div>
<div className="h-6 w-px bg-white/10 hidden xl:block mx-1" />
{/* Dimensions Selector */}
<div className="relative z-30" ref={dimensionRef}>
<button
onClick={() => setShowDimensionMenu(!showDimensionMenu)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-semibold transition-all border shadow-sm ${showDimensionMenu ? 'bg-indigo-600 text-white border-indigo-500 shadow-indigo-500/20' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700 hover:border-slate-600'}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 4.5v15m6-15v15m-10.5-15h15a2.25 2.25 0 0 1 2.25 2.25v13.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75A2.25 2.25 0 0 1 4.5 4.5Z" />
</svg>
<span>Columns:</span>
<span className={showDimensionMenu ? 'text-indigo-100' : 'text-indigo-400'}>{effectiveDimensions.length} Columns</span>
<svg className={`w-3.5 h-3.5 transition-transform duration-200 ${showDimensionMenu ? 'rotate-180 text-white' : 'text-slate-500'}`} fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /></svg>
</button>
{showDimensionMenu && (
<div className="absolute top-full left-0 mt-2 w-64 bg-slate-900 border border-slate-700 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.5)] p-3 animate-fade-in z-[100] ring-1 ring-white/10">
<div className="flex justify-between items-center mb-3 px-1">
<span className="text-[11px] font-black text-slate-500 uppercase tracking-[0.2em]">Select Columns</span>
<button
onClick={() => setSelectedDimensions([])}
className="text-[10px] font-bold text-indigo-400 hover:text-indigo-300 uppercase underline decoration-indigo-500/30 underline-offset-4"
>
Clear All
</button>
</div>
<div className="space-y-1">
{DIMENSION_OPTIONS.map(dim => (
<label key={dim.value} className="flex items-center gap-3 p-2.5 hover:bg-white/[0.04] rounded-lg cursor-pointer group transition-all active:scale-[0.98]">
<div className="relative flex items-center">
<input
type="checkbox"
checked={selectedDimensions.includes(dim.value)}
onChange={() => {
if (selectedDimensions.includes(dim.value)) {
setSelectedDimensions(selectedDimensions.filter(d => d !== dim.value));
} else {
setSelectedDimensions([...selectedDimensions, dim.value]);
}
}}
className="w-4.5 h-4.5 rounded-md border-slate-600 bg-slate-800 text-indigo-500 focus:ring-indigo-500/50 focus:ring-offset-0 transition-all border-2 cursor-pointer"
/>
</div>
<span className={`text-sm font-semibold transition-colors ${selectedDimensions.includes(dim.value) ? 'text-white' : 'text-slate-400 group-hover:text-slate-300'}`}>
{dim.label}
</span>
{selectedDimensions.includes(dim.value) && (
<div className="ml-auto w-1 h-4 bg-indigo-500 rounded-full animate-pulse-slow"></div>
)}
</label>
))}
</div>
<div className="mt-3 pt-3 border-t border-slate-800 px-1">
<p className="text-[10px] text-slate-500 leading-relaxed italic">
The table will automatically refresh when you toggle these options.
</p>
</div>
</div>
)}
</div>
{/* Filter Builder Trigger */}
<button
onClick={() => setShowFilterBuilder(!showFilterBuilder)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${showFilterBuilder || rowFilters.length > 0 ? 'bg-indigo-600/20 text-indigo-400 border-indigo-500/50' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'}`}
>
<FunnelIcon />
<span>Filter Rows {rowFilters.length > 0 && `(${rowFilters.length})`}</span>
</button>
{/* Chart Toggle */}
<button
onClick={() => setShowChart(!showChart)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${showChart ? 'bg-indigo-600/20 text-indigo-400 border-indigo-500/50' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'}`}
>
<ChartIcon />
<span className="hidden sm:inline">{showChart ? 'Hide Chart' : 'Show Chart'}</span>
</button>
{/* Ads Toggle - Only show when ads data is loaded */}
{adsSummary && (
<button
onClick={() => setShowAdsMetrics(!showAdsMetrics)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${showAdsMetrics ? 'bg-fuchsia-600/20 text-fuchsia-400 border-fuchsia-500/50' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'}`}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 0h.008v.008H18V10.5Zm-12 0h.008v.008H6V10.5Z" />
</svg>
<span className="hidden sm:inline">{showAdsMetrics ? 'Hide Ads' : 'Show Ads'}</span>
</button>
)}
{/* Top 50 Toggle */}
{top50Ranking && (top50Ranking.eu.size > 0 || top50Ranking.uk.size > 0) && (
<button
onClick={() => setShowOnlyTop50(!showOnlyTop50)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-bold transition-all border shadow-sm ${showOnlyTop50 ? 'bg-gradient-to-r from-amber-600 to-orange-600 text-white border-amber-500 shadow-amber-500/20' : 'bg-slate-800 text-slate-400 border-slate-700 hover:text-amber-400 hover:bg-slate-700'}`}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4">
<path fillRule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z" clipRule="evenodd" />
</svg>
<span className="hidden sm:inline">Top 50 ({top50Mode.toUpperCase()})</span>
<span className="sm:hidden">Top 50</span>
</button>
)}
{/* Attributed Sales Toggle - Only show when ads data is loaded and ads metrics are shown */}
{adsSummary && (
<button
onClick={() => setShowAttributedSales(!showAttributedSales)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${showAttributedSales ? 'bg-amber-600/20 text-amber-400 border-amber-500/50' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'}`}
>
<TrendingIcon className="w-4 h-4" />
<span className="hidden sm:inline">{showAttributedSales ? 'Hide Attr. Sales' : 'Show Attr. Sales'}</span>
</button>
)}
{/* YTD (Year-To-Date) Toggle */}
<button
onClick={() => setShowYTD(!showYTD)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${showYTD ? 'bg-cyan-600/20 text-cyan-400 border-cyan-500/50' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
</svg>
<span className="hidden sm:inline">{showYTD ? 'Hide YTD' : 'Show YTD'}</span>
</button>
</div>
<div className="flex items-center gap-3 w-full lg:w-auto justify-between lg:justify-end">
<div className="text-xs text-slate-500 font-mono">
Showing {processedRows.length} rows
</div>
<button
onClick={handleExport}
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-bold shadow-lg shadow-emerald-900/20 transition-all"
>
<DownloadIcon />
Export CSV
</button>
</div>
</div>
{/* Filter Builder Panel */}
{showFilterBuilder && (
<div className="bg-slate-900 border-b border-border p-4 animate-fade-in">
<div className="flex flex-col sm:flex-row gap-3 items-end">
<div className="flex-1 w-full">
<label className="text-xs font-semibold text-slate-500 uppercase mb-1 block">Metric</label>
<select
value={newFilterMetric}
onChange={(e) => setNewFilterMetric(e.target.value)}
className="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-primary"
>
<option value="">Select Metric...</option>
{metricOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label className="text-xs font-semibold text-slate-500 uppercase mb-1 block">Operator</label>
<select
value={newFilterOperator}
onChange={(e) => setNewFilterOperator(e.target.value as 'gt' | 'lt')}
className="bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-primary"
>
<option value="gt">Greater Than (&gt;)</option>
<option value="lt">Less Than (&lt;)</option>
</select>
</div>
<div className="w-32">
<label className="text-xs font-semibold text-slate-500 uppercase mb-1 block">Value</label>
<input
type="number"
value={newFilterValue}
onChange={(e) => setNewFilterValue(e.target.value)}
placeholder="0"
className="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-primary"
/>
</div>
<button
onClick={addFilter}
disabled={!newFilterMetric || !newFilterValue}
className="px-4 py-2 bg-primary hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50 transition-colors"
>
Apply
</button>
</div>
{/* Active Filters Chips */}
{rowFilters.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4 pt-4 border-t border-slate-800">
{rowFilters.map(filter => {
const metricLabel = metricOptions.find(m => m.value === filter.metric)?.label || filter.metric;
return (
<div key={filter.id} className="flex items-center gap-2 bg-indigo-500/10 border border-indigo-500/30 text-indigo-300 px-3 py-1 rounded-full text-xs font-medium">
<span>{metricLabel} {filter.operator === 'gt' ? '>' : '<'} {filter.value}</span>
<button onClick={() => removeFilter(filter.id)} className="hover:text-white">
<CloseIcon />
</button>
</div>
);
})}
</div>
)}
</div>
)}
</div>
{/* Ads Performance Summary - Shows when ads data is loaded */}
{adsSummary && showAdsMetrics && (
<div className="bg-gradient-to-r from-fuchsia-900/20 to-indigo-900/20 border-y border-fuchsia-500/30 px-4 py-3">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-fuchsia-400 animate-pulse"></span>
<span className="text-xs font-bold text-fuchsia-300 uppercase tracking-wide">Advertising Data:</span>
<span className="text-xs text-slate-400">{adsSummary.recordCount.toLocaleString('de-DE')} records</span>
</div>
<div className="flex items-center gap-4 flex-wrap">
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
<span className="text-xs text-slate-400 mr-2">Ad Spend:</span>
<span className="text-sm font-bold text-fuchsia-400">{adsSummary.totalSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}</span>
</div>
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
<span className="text-xs text-slate-400 mr-2">Attr. Sales:</span>
<span className="text-sm font-bold text-emerald-400">{adsSummary.attributedSales.toLocaleString('de-DE', { maximumFractionDigits: 0 })}</span>
</div>
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
<span className="text-xs text-slate-400 mr-2">ACOS:</span>
<span className={`text-sm font-bold ${adsSummary.acos <= 30 ? 'text-emerald-400' : adsSummary.acos <= 50 ? 'text-amber-400' : 'text-red-400'}`}>
{adsSummary.acos.toFixed(1)}%
</span>
</div>
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
<span className="text-xs text-slate-400 mr-2">ROAS:</span>
<span className={`text-sm font-bold ${adsSummary.roas >= 3 ? 'text-emerald-400' : adsSummary.roas >= 2 ? 'text-amber-400' : 'text-red-400'}`}>
{adsSummary.roas.toFixed(2)}x
</span>
</div>
</div>
</div>
</div>
)}
{/* Data Table */}
<div className="overflow-x-auto min-h-[400px] scroll-touch">
<table className="w-full text-left text-xs md:text-sm border-collapse">
<thead className="bg-slate-950 text-slate-400 uppercase text-xs font-semibold tracking-wider sticky top-0 z-20 shadow-sm border-b border-white/10">
{/* NEW: Filtered Totals Header Row */}
<tr className="bg-indigo-950/20 border-b border-indigo-500/30">
<th colSpan={effectiveDimensions.length} className="px-4 py-3 bg-indigo-900/10">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-indigo-400" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
<span className="text-[10px] font-black text-indigo-300 tracking-widest uppercase">Filtered Totals</span>
</div>
</th>
{years.map((year, index) => {
const totals = filteredTotalsByYear[year];
const prevYear = years[index + 1];
return (
<React.Fragment key={`totals-${year}`}>
<th className="px-4 py-3 text-right border-x border-indigo-500/10 bg-indigo-900/5">
<div className="flex flex-col items-end">
<span className="text-emerald-400 font-bold text-sm">
{totals.sellOut.toLocaleString('de-DE', { maximumFractionDigits: 0 })}
</span>
{totals.sellOutGrowth !== undefined && (
<span className={`text-[9px] font-black ${totals.sellOutGrowth >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{totals.sellOutGrowth >= 0 ? '↑' : '↓'} {Math.abs(totals.sellOutGrowth).toFixed(1)}%
</span>
)}
</div>
</th>
<th className="px-4 py-3 text-right border-r border-indigo-500/10 bg-indigo-900/5">
<div className="flex flex-col items-end">
<span className="text-blue-400 font-bold text-sm">
{totals.units.toLocaleString('de-DE')}
</span>
{totals.unitsGrowth !== undefined && (
<span className={`text-[9px] font-black ${totals.unitsGrowth >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{totals.unitsGrowth >= 0 ? '↑' : '↓'} {Math.abs(totals.unitsGrowth).toFixed(1)}%
</span>
)}
</div>
</th>
{prevYear && <th colSpan={2} className="bg-indigo-900/5 border-r border-indigo-500/10"></th>}
{showAdsMetrics && adsSummary && (
<>
<th className="px-3 py-3 text-right bg-fuchsia-950/20 border-r border-fuchsia-500/10">
<div className="flex flex-col items-end">
<span className="text-fuchsia-400 font-bold text-[13px]">
{totals.adSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}
</span>
{(totals as any).adSpendGrowth !== undefined && (
<span className={`text-[9px] font-black ${(totals as any).adSpendGrowth <= 0 ? 'text-emerald-400' : 'text-amber-400'}`}>
{(totals as any).adSpendGrowth >= 0 ? '↑' : '↓'} {Math.abs((totals as any).adSpendGrowth).toFixed(1)}%
</span>
)}
</div>
</th>
<th className="px-3 py-3 text-right bg-fuchsia-950/20 border-r border-fuchsia-500/10">
<div className="flex flex-col items-end">
<span className="text-fuchsia-300 font-bold text-[13px]">
{totals.sellOut > 0 ? ((totals.adSpend / totals.sellOut) * 100).toFixed(2) : '0.00'}%
</span>
<span className="text-[9px] font-black text-slate-500 uppercase">TACOS</span>
</div>
</th>
</>
)}
</React.Fragment>
);
})}
</tr>
<tr>
{/* Dynamic Dimension Headers */}
{effectiveDimensions.map(dim => {
const label = DIMENSION_OPTIONS.find(d => d.value === dim)?.label || dim;
// Extract unique values for this dimension from ALL pivot rows (pre-filter)
const uniqueValues = Array.from(new Set(pivotRows.map(r => String((r as any)[dim] || ''))))
.filter(Boolean)
.sort();
return (
<th
key={dim}
className={`px-2.5 py-3 border-b border-border group bg-slate-950 ${dim === 'title' ? 'min-w-[400px]' : 'min-w-[140px]'}`}
>
<div className="flex items-center justify-between gap-1">
<div
className="flex items-center cursor-pointer hover:text-white text-[11px] font-bold uppercase tracking-wide truncate"
onClick={() => requestSort(dim)}
>
{label} {getSortIcon(dim)}
</div>
<ExcelFilter
columnKey={dim}
title={label}
buttonClassName={`p-1 rounded hover:bg-white/10 transition-all ${columnFilters[dim] ? 'text-indigo-400 bg-indigo-500/10' : 'text-slate-500'}`}
uniqueValues={uniqueValues}
currentFilter={columnFilters[dim]}
onFilterChange={handleColumnFilterChange}
/>
</div>
</th>
);
})}
{/* Data Columns: Year Groups */}
{years.map((year, index) => {
const prevYear = years[index + 1]; // Since years are sorted desc: 2025, 2024... next index is prev year
return (
<React.Fragment key={year}>
{/* Sell Out & Units */}
<th className="px-2 py-2.5 border-b border-border bg-slate-950 min-w-[105px]">
<div className="flex items-center justify-end gap-1 leading-none">
<ExcelFilter
columnKey={`total_sellOut_${year}`}
title={`Sell Out ${year}`}
buttonClassName={`w-4 h-4 flex items-center justify-center rounded hover:bg-white/10 transition-all ${columnFilters[`total_sellOut_${year}`] ? 'text-indigo-400 bg-indigo-500/10' : 'text-slate-600'}`}
uniqueValues={Array.from(new Set(pivotRows.map(r => String(r.totalsByYear[year]?.sellOut || 0)))).sort((a, b) => parseFloat(a as string) - parseFloat(b as string))}
currentFilter={columnFilters[`total_sellOut_${year}`]}
onFilterChange={handleColumnFilterChange}
icon={<FunnelIcon className="w-2.5 h-2.5" />}
/>
<div
className="flex items-center cursor-pointer hover:text-white text-[10px] font-black uppercase tracking-tight"
onClick={() => requestSort(`total_sellOut_${year}`)}
>
{year} SO {getSortIcon(`total_sellOut_${year}`)}
</div>
</div>
</th>
<th className="px-2 py-2.5 border-b border-border bg-slate-950 min-w-[85px]">
<div className="flex items-center justify-end gap-1 leading-none">
<ExcelFilter
columnKey={`total_units_${year}`}
title={`Units ${year}`}
buttonClassName={`w-4 h-4 flex items-center justify-center rounded hover:bg-white/10 transition-all ${columnFilters[`total_units_${year}`] ? 'text-indigo-400 bg-indigo-500/10' : 'text-slate-600'}`}
uniqueValues={Array.from(new Set(pivotRows.map(r => String(r.totalsByYear[year]?.units || 0)))).sort((a, b) => parseFloat(a as string) - parseFloat(b as string))}
currentFilter={columnFilters[`total_units_${year}`]}
onFilterChange={handleColumnFilterChange}
icon={<FunnelIcon className="w-2.5 h-2.5" />}
/>
<div
className="flex items-center cursor-pointer hover:text-white text-[10px] font-black uppercase tracking-tight"
onClick={() => requestSort(`total_units_${year}`)}
>
{year} U {getSortIcon(`total_units_${year}`)}
</div>
</div>
</th>
{/* Growth Columns (if prev year exists) */}
{prevYear && (
<>
<th
className="px-2 py-3 border-b border-border text-center bg-slate-950/50 min-w-[80px] cursor-pointer hover:text-white"
onClick={() => requestSort(`growth_sellOut_${year}_${prevYear}`)}
>
<div className="flex items-center justify-center text-[10px] text-slate-500 uppercase">
S.O. Δ% {getSortIcon(`growth_sellOut_${year}_${prevYear}`)}
</div>
</th>
<th
className="px-2 py-3 border-b border-border text-center bg-slate-950/50 min-w-[80px] cursor-pointer hover:text-white"
onClick={() => requestSort(`growth_units_${year}_${prevYear}`)}
>
<div className="flex items-center justify-center text-[10px] text-slate-500 uppercase">
Units Δ% {getSortIcon(`growth_units_${year}_${prevYear}`)}
</div>
</th>
</>
)}
{/* Ads Columns - Only show when ads data exists and toggle is on */}
{showAdsMetrics && adsSummary && (
<>
<th
className="px-3 py-3 border-b border-fuchsia-500/30 text-right bg-fuchsia-950/30 min-w-[90px] cursor-pointer hover:bg-fuchsia-900/40 transition-colors"
onClick={() => requestSort(`adSpend_${year}`)}
>
<div className="flex items-center justify-end text-fuchsia-400 text-[10px] uppercase">
Ad Spend {year} {getSortIcon(`adSpend_${year}`)}
</div>
</th>
<th
className="px-3 py-3 border-b border-fuchsia-500/30 text-right bg-fuchsia-950/30 min-w-[70px] cursor-pointer hover:bg-fuchsia-900/40 transition-colors"
onClick={() => requestSort(`tacos_${year}`)}
>
<div className="flex items-center justify-end text-fuchsia-400 text-[10px] uppercase">
TACOS {year} {getSortIcon(`tacos_${year}`)}
</div>
</th>
</>
)}
</React.Fragment>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-border text-slate-300">
{paginatedRows.map((row) => (
<tr key={row.id} className="hover:bg-slate-800/50 transition-colors group relative hover:z-50">
{effectiveDimensions.map(dim => (
<td key={dim} className="px-2.5 py-3 font-medium text-slate-200 break-words max-w-xs">
{dim === 'title'
? <div className="flex items-start gap-2 max-w-sm relative group/title">
<span className="line-clamp-2 cursor-help decoration-dotted underline underline-offset-2 decoration-slate-600">
{row.title || '-'}
</span>
<div className="absolute left-0 top-full mt-2 w-max max-w-md bg-slate-900 border border-slate-700 p-3 rounded-lg shadow-xl text-xs text-white z-[60] opacity-0 group-hover/title:opacity-100 pointer-events-none transition-opacity">
{row.title}
</div>
{stockMap && (
<>
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
<VendorStockBadge asin={row.asin} vendorStockMap={vendorStockMap} mode={top50Mode} avgWeeklySales={velocityMap?.get(row.asin.trim().toUpperCase())} />
<BuyBoxWarningBadge asin={row.asin} buyBoxLostMap={buyBoxLostMap} />
</>
)}
</div>
: dim === 'asin' || dim === 'sku'
? <div className="flex items-center gap-2 min-w-max">
{(() => {
const asin = (row.asin || '').trim().toUpperCase();
if (asin && top50Ranking) {
if (top50Mode === 'eu') {
const rank = top50Ranking.eu.get(asin);
if (rank) return <Top50Badge rank={rank} label="EU" theme="indigo" />;
} else {
const rank = top50Ranking.uk.get(asin);
if (rank) return <Top50Badge rank={rank} label="UK" theme="blue" />;
}
}
return null;
})()}
<span className="whitespace-nowrap">{(row[dim as keyof PivotRow] as string) || '-'}</span>
</div>
: (row[dim as keyof PivotRow] as string) || '-'
}
</td>
))}
{/* Metric Values */}
{years.map((year, index) => {
const data = row.totalsByYear[year];
const prevYear = years[index + 1];
const prevData = prevYear ? row.totalsByYear[prevYear] : null;
// Calculate Growth
let sellOutGrowth = null;
let unitsGrowth = null;
if (prevYear) {
const currSO = data?.sellOut || 0;
const prevSO = prevData?.sellOut || 0;
if (prevSO !== 0) sellOutGrowth = ((currSO - prevSO) / prevSO) * 100;
else if (currSO > 0) sellOutGrowth = 100; // New entry
const currUnits = data?.units || 0;
const prevUnits = prevData?.units || 0;
if (prevUnits !== 0) unitsGrowth = ((currUnits - prevUnits) / prevUnits) * 100;
else if (currUnits > 0) unitsGrowth = 100;
}
return (
<React.Fragment key={year}>
<td className="px-2 py-3 text-right font-medium text-white group-hover:text-emerald-400 transition-colors">
{data ? `€${data.sellOut.toLocaleString('de-DE', { maximumFractionDigits: 0 })}` : '-'}
</td>
<td className="px-2 py-3 text-right text-slate-400">
{data ? data.units.toLocaleString('de-DE') : '-'}
</td>
{/* Growth Cells */}
{prevYear && (
<>
<td className={`px-2 py-3 text-center font-bold text-xs ${sellOutGrowth !== null ? (sellOutGrowth >= 0 ? 'text-emerald-500' : 'text-red-500') : 'text-slate-600'}`}>
{sellOutGrowth !== null ? (
<span className="flex items-center justify-center gap-1">
{sellOutGrowth >= 0 ? '↑' : '↓'} {Math.abs(sellOutGrowth).toFixed(0)}%
</span>
) : '-'}
</td>
<td className={`px-2 py-3 text-center font-bold text-xs ${unitsGrowth !== null ? (unitsGrowth >= 0 ? 'text-emerald-500' : 'text-red-500') : 'text-slate-600'}`}>
{unitsGrowth !== null ? (
<span className="flex items-center justify-center gap-1">
{unitsGrowth >= 0 ? '↑' : '↓'} {Math.abs(unitsGrowth).toFixed(0)}%
</span>
) : '-'}
</td>
</>
)}
{/* Ads Data Cells */}
{showAdsMetrics && adsSummary && (() => {
const adsYearData = row.adsByYear?.[year];
return (
<>
<td className="px-3 py-3 text-right text-fuchsia-400 font-medium bg-fuchsia-950/10">
{adsYearData ? `€${adsYearData.adSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}` : '-'}
</td>
<td className={`px-3 py-3 text-right font-bold text-xs bg-fuchsia-950/10 ${adsYearData
? (adsYearData.tacos <= 10 ? 'text-emerald-400' : adsYearData.tacos <= 20 ? 'text-amber-400' : 'text-red-400')
: 'text-slate-600'
}`}>
{adsYearData ? `${adsYearData.tacos.toFixed(1)}%` : '-'}
</td>
</>
);
})()}
</React.Fragment>
);
})}
</tr>
))}
{paginatedRows.length === 0 && (
<tr>
<td colSpan={effectiveDimensions.length + (years.length * 2) + (showAdsMetrics && adsSummary ? years.length * 2 : 0)} className="px-6 py-12 text-center text-slate-500 italic">
No data matches your filters.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination and Info */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 bg-slate-900/50 p-4 rounded-xl border border-white/5 shadow-lg">
<div className="flex flex-wrap items-center gap-4 w-full md:w-auto">
<button
onClick={handleExport}
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl text-sm font-black uppercase tracking-widest transition-all shadow-lg active:scale-95 border border-emerald-500/50"
>
<DownloadIcon />
<span>Export Excel</span>
</button>
</div>
<div className="flex items-center gap-4 w-full md:w-auto justify-between md:justify-end">
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(prev => Math.max(1, prev - 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>
<div className="bg-slate-950 border border-white/10 px-4 py-2 rounded-xl">
<span className="text-xs font-black text-white">{currentPage} / {totalPages || 1}</span>
</div>
<button
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 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 className="hidden sm:block text-xs font-bold text-slate-500 uppercase tracking-widest">
{processedRows.length} Records found
</div>
</div>
</div>
</div>
);
};
export default DataGrid;