import React, { useState, useMemo, useEffect, useRef } from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { SalesRecord, PivotRow, AdsRecord, CombinedKPIs } from '../types'; import { pivotSalesData, generateXLSX, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } 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'; interface DataGridProps { data: SalesRecord[] | CombinedKPIs[]; hasCustomerFilter: boolean; adsData?: AdsRecord[]; stockMap?: Map; top50Ranking?: { eu: Map; uk: Map; }; top50Mode: 'eu' | 'uk'; vendorStockMap?: Map; velocityMap?: Map; buyBoxLostMap?: Map }>; 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 (

{label}

{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 = ( = 0 ? 'text-emerald-400' : 'text-red-400'}`}> {pct >= 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}% ); } } return (
{p.name}:
{p.dataKey === 'sellOut' ? `€${Number(p.value).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}` : `${Number(p.value).toLocaleString('de-DE')} u`} {wowEl}
); })}
); } 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 (

{label}

{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 = ( = 0 ? 'text-emerald-400' : 'text-red-400'}`}> {pct >= 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}% ); } let unitsGrowthEl = null; if (prevYearData && prevYearData.units != null && prevYearData.units !== 0 && yearData.units != null) { const pct = ((yearData.units - prevYearData.units) / prevYearData.units) * 100; unitsGrowthEl = ( = 0 ? 'text-emerald-400' : 'text-red-400'}`}> {pct >= 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}% ); } return (

{year}

{yearData.sellOut != null && (
Sell Out:
€{Number(yearData.sellOut).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} {sellOutGrowthEl}
)} {yearData.units != null && (
Units:
{Number(yearData.units).toLocaleString('de-DE')} u {unitsGrowthEl}
)}
); })}
); } 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 (

{title}

{/* Use flex-1 and min-h-0 to allow children to fill available space */}
{children}
); } return (

{title}

{children}
); }; const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData, stockMap, vendorStockMap, top50Ranking, top50Mode, velocityMap, buyBoxLostMap, defaultSort }) => { const [currentPage, setCurrentPage] = useState(1); const [searchTerm, setSearchTerm] = useState(''); const [sortConfig, setSortConfig] = useState(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(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(['sku', 'title']); // State for Advanced Filtering const [showFilterBuilder, setShowFilterBuilder] = useState(false); const [rowFilters, setRowFilters] = useState([]); // Temp state for new filter inputs const [newFilterMetric, setNewFilterMetric] = useState(''); const [newFilterOperator, setNewFilterOperator] = useState<'gt' | 'lt'>('gt'); const [newFilterValue, setNewFilterValue] = useState(''); // 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); // pivotSalesData now handles ads aggregation correctly because it receives CombinedKPIs const { rows } = pivotSalesData(processedData, effectiveDimensions); return rows; }, [data, effectiveDimensions, hasCustomerFilter]); const { years } = useMemo(() => { // We still need unique years for columns const yearsSet = new Set(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(); 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 if (rowFilters.length > 0) { const latestYear = years[0]; const prevYear = years[1]; 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') { 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') { 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; }); }); } // 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 ; return {sortConfig.direction === 'asc' ? '↑' : '↓'}; }; 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 (
{/* 1. Time Series Chart Section */} {showChart && chartData.length > 0 && (
`€${(val / 1000).toFixed(0)}k`} /> isComparisonView ? : } /> {isComparisonView ? ( uniqueYears.flatMap((year, idx) => [ visibleMetrics.includes('sellOut') && ( ), visibleMetrics.includes('units') && ( ), // Ad Spend line (only when ads data exists and showAdsMetrics is on) showAdsMetrics && adsSummary && ( ), // Attributed Sales line showAttributedSales && adsSummary && ( ), // YTD Sell Out line (cumulative) showYTD && visibleMetrics.includes('sellOut') && ( ), // YTD Units line (cumulative) showYTD && visibleMetrics.includes('units') && ( ) ]) ) : ( [ visibleMetrics.includes('sellOut') && ( ), visibleMetrics.includes('units') && ( ) ] )}
)} {/* 2. Controls & Grid */}
{/* Toolbar */}
{/* Search Bar */}
{showBulkSearch ? (