import React, { useState, useMemo, useEffect } from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { SalesRecord, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types'; import { pivotSalesData, generateCSV, aggregateForTimeSeries, aggregateForComparisonTimeSeries } from '../services/dataProcessor'; import MultiSelectDropdown from './MultiSelectDropdown'; import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon } from './Icons'; interface DataGridProps { data: SalesRecord[]; } type SortConfig = { key: keyof PivotRow | string | null; // string for dynamic year sorting direction: 'asc' | 'desc'; }; type ConditionalFilter = { id: string; metric: string; operator: 'gt' | 'lt'; value: number; } const ROWS_PER_PAGE = 50; const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 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(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}` : `${Number(p.value).toLocaleString()} 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('so')) { 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(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})} {sellOutGrowthEl}
)} {yearData.units != null && (
Units:
{Number(yearData.units).toLocaleString()} u {unitsGrowthEl}
)}
); })}
); } return null; }; // Reusable Expandable Card for the Chart 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}

{children}
); } return (

{title}

{children}
); }; const DataGrid: React.FC = ({ data }) => { const [currentPage, setCurrentPage] = useState(1); const [sortConfig, setSortConfig] = useState({ key: null, direction: 'desc' }); const [showChart, setShowChart] = useState(true); const [visibleMetrics, setVisibleMetrics] = useState<('sellOut' | 'units')[]>(['sellOut', 'units']); // State for dynamic grouping const [selectedDimensions, setSelectedDimensions] = useState(['line', 'customer', '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 { rows: pivotRows, years } = useMemo(() => { return pivotSalesData(data, effectiveDimensions); }, [data, effectiveDimensions]); // 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, b) => parseInt(b) - parseInt(a)); const isMultiYear = yearsInView.length > 1; if (isMultiYear) { return { chartData: aggregateForComparisonTimeSeries(data), uniqueYears: yearsInView, isComparisonView: true, chartTitle: `Weekly Sales Comparison: ${yearsInView.join(' vs ')}` }; } else { return { chartData: aggregateForTimeSeries(data), uniqueYears: yearsInView, isComparisonView: false, chartTitle: `Weekly Sales Evolution ${yearsInView[0] || ''}` }; } }, [data]); // 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]); // Default sorting const effectiveSortKey = sortConfig.key || (years.length > 0 ? `total_${years[0]}` : null); // Apply Advanced Row Filters THEN Sort const processedRows = useMemo(() => { let result = pivotRows; // 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 (effectiveSortKey) { result = [...result].sort((a, b) => { let aVal: string | number = 0; let bVal: string | number = 0; const sortKeyStr = String(effectiveSortKey); if (effectiveDimensions.includes(sortKeyStr)) { const key = sortKeyStr as keyof PivotRow; const valA = a[key]; const valB = b[key]; if (typeof valA === 'string' || typeof valA === 'number') { aVal = valA; } if (typeof valB === 'string' || typeof valB === 'number') { bVal = valB; } } else if (sortKeyStr.startsWith('total_')) { const year = sortKeyStr.split('_')[1]; aVal = a.totalsByYear[year]?.sellOut || 0; bVal = b.totalsByYear[year]?.sellOut || 0; } if (typeof aVal === 'string' && typeof bVal === 'string') { return sortConfig.direction === 'asc' ? String(aVal).localeCompare(String(bVal)) : String(bVal).localeCompare(String(aVal)); } if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1; if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1; return 0; }); } return result; }, [pivotRows, rowFilters, effectiveSortKey, sortConfig, effectiveDimensions, years]); // Grand Totals (Calculated on Filtered Rows for context) const grandTotals = useMemo(() => { const accTotalsByYear: Record = {}; const accMonthsByYear: Record> = {}; years.forEach(y => { accTotalsByYear[y] = { sellOut: 0, units: 0 }; }); for(let i=0; i<12; i++) { accMonthsByYear[i] = {}; years.forEach(y => { accMonthsByYear[i][y] = { sellOut: 0, units: 0 }; }); } processedRows.forEach(row => { // Totals Object.entries(row.totalsByYear).forEach(([y, val]) => { const v = val as YearlyData; if (accTotalsByYear[y]) { accTotalsByYear[y].sellOut += v.sellOut; accTotalsByYear[y].units += v.units; } }); // Months row.months.forEach((m, idx) => { Object.entries(m.byYear).forEach(([y, val]) => { const v = val as YearlyData; if (accMonthsByYear[idx][y]) { accMonthsByYear[idx][y].sellOut += v.sellOut; accMonthsByYear[idx][y].units += v.units; } }); }); }); return { totalsByYear: accTotalsByYear, months: accMonthsByYear }; }, [processedRows, years]); // Pagination const totalPages = Math.ceil(processedRows.length / ROWS_PER_PAGE); const currentRows = useMemo(() => { const start = (currentPage - 1) * ROWS_PER_PAGE; return processedRows.slice(start, start + ROWS_PER_PAGE); }, [processedRows, currentPage]); // Handlers const requestSort = (key: string) => { let direction: 'asc' | 'desc' = 'desc'; if (sortConfig.key === key && sortConfig.direction === 'desc') { direction = 'asc'; } setSortConfig({ key, direction }); setCurrentPage(1); }; const handlePrev = () => setCurrentPage(p => Math.max(1, p - 1)); const handleNext = () => setCurrentPage(p => Math.min(totalPages, p + 1)); const handleExport = () => generateCSV(processedRows, effectiveDimensions, years); const getLabel = (val: string) => DIMENSION_OPTIONS.find(d => d.value === val)?.label || val; const toggleMetric = (metric: 'sellOut' | 'units') => { setVisibleMetrics(prev => prev.includes(metric) ? prev.filter(m => m !== metric) : [...prev, metric] ); }; // Filter Handlers const addFilter = () => { if (!newFilterMetric || !newFilterValue) return; setRowFilters(prev => [ ...prev, { id: Date.now().toString(), metric: newFilterMetric, operator: newFilterOperator, value: parseFloat(newFilterValue) } ]); setNewFilterValue(''); // Don't close builder to allow adding more }; const removeFilter = (id: string) => { setRowFilters(prev => prev.filter(f => f.id !== id)); }; // Render Helpers const renderGrowth = (current: number, previous: number, size: 'sm' | 'xs' = 'xs') => { if (previous === 0) return null; const pct = ((current - previous) / previous) * 100; const isPositive = pct >= 0; const textSize = size === 'sm' ? 'text-xs' : 'text-[10px]'; return ( {isPositive ? '↑' : '↓'}{Math.abs(pct).toFixed(0)}% ); }; if (data.length === 0) return null; return (
{/* Header Bar */}

Dynamic Pivot Table

Comparing {years.join(', ')} • {processedRows.length} Rows {rowFilters.length > 0 && (Filtered)}

Group By: d.value)} onChange={setSelectedDimensions} className="w-64" />
{/* Advanced Filter Builder Panel */} {(showFilterBuilder || rowFilters.length > 0) && (
{/* Active Filters List */} {rowFilters.length > 0 && (
{rowFilters.map(filter => { const metricLabel = metricOptions.find(o => o.value === filter.metric)?.label || filter.metric; const opLabel = filter.operator === 'gt' ? '>' : '<'; return (
{metricLabel} {opLabel} {filter.value}
); })}
)} {/* Filter Creator Inputs */} {showFilterBuilder && (
setNewFilterValue(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addFilter()} />
)}
)}
{/* Trend Chart */} {showChart && chartData.length > 1 && (
{visibleMetrics.includes('sellOut') && ( `€${(val / 1000).toFixed(0)}k`} /> )} {visibleMetrics.includes('units') && ( `${(val / 1000).toFixed(0)}k`} /> )} : } /> {isComparisonView ? ( uniqueYears.map((year, index) => ( {visibleMetrics.includes('sellOut') && ( )} {visibleMetrics.includes('units') && ( )} )) ) : ( <> {visibleMetrics.includes('sellOut') && ( )} {visibleMetrics.includes('units') && ( )} )}
)} {(!showChart || chartData.length <=1) && !isComparisonView && (
{isComparisonView ? "Not enough weekly data to compare these years." : "Not enough weekly data points to render a trend chart for the current selection." }
)} {/* Table Container */}
{/* Dynamic Dimension Headers - STICKY TOP */} {effectiveDimensions.map((dim, index) => { const label = getLabel(dim); const isFirst = index === 0; const isTitle = dim === 'title'; return ( ); })} {/* Dynamic Total Columns for each Year - STICKY TOP */} {years.map(year => ( ))} {/* Monthly Headers - STICKY TOP */} {MONTH_NAMES.map(m => ( ))} {/* Grand Total Row (Sticky Top BELOW Headers) */} {/* Anchor TOTAL label to the first column (sticky left). This ensures "TOTAL" stays visible on the left even when scrolling horizontally. */} {/* Spacer for remaining dimensions if any */} {effectiveDimensions.length > 1 && ( ); })} {/* Monthly Grand Totals */} {MONTH_NAMES.map((_, idx) => ( ))} {currentRows.map((row, index) => { const isAlternate = index % 2 === 1; // Alternate row color: Default dark (slate-950) vs Alternate lighter (slate-800) for high contrast const rowClass = isAlternate ? 'bg-slate-800' : 'bg-slate-950'; return ( {/* Dimensions */} {effectiveDimensions.map((dim, index) => { const isFirst = index === 0; // @ts-ignore const val = row[dim]; const isTitle = dim === 'title'; const textColor = isTitle ? 'text-white font-semibold' : (isFirst ? 'text-slate-200 font-medium' : 'text-slate-400'); let cellContent: React.ReactNode = val; if (isTitle && typeof val === 'string') { cellContent = (
{val}
); } else { let displayVal = val; if (typeof val === 'string' && val.length > 30) { displayVal = val.substring(0, 30) + '...'; } cellContent = displayVal; } return ( ); })} {/* Dynamic Total Columns per Year - HIGH CONTRAST BODY (Indigo 900/60) */} {years.map((year, yIdx) => { const yData = row.totalsByYear[year] || { sellOut: 0, units: 0 }; let sellOutGrowth = null; let unitsGrowth = null; if (yIdx < years.length - 1) { const prevYear = years[yIdx + 1]; const prevData = row.totalsByYear[prevYear]; if (prevData) { sellOutGrowth = renderGrowth(yData.sellOut, prevData.sellOut); unitsGrowth = renderGrowth(yData.units, prevData.units); } } return ( ); })} {/* Monthly Data Columns (Listing all years) */} {row.months.map((m, idx) => ( ))} )})}
requestSort(dim)} > {label} {sortConfig.key === dim && (sortConfig.direction === 'asc' ? '▲' : '▼')} requestSort(`total_${year}`)} > Total {year} {sortConfig.key === `total_${year}` && (sortConfig.direction === 'asc' ? '▲' : '▼')} {m}
TOTAL ({processedRows.length} Rows) )} {/* Totals for each year - HIGH CONTRAST (Indigo 800) */} {years.map((year, yIdx) => { const currentData = grandTotals.totalsByYear[year] || { sellOut: 0, units: 0 }; let sellOutGrowth = null; let unitsGrowth = null; // Compare with next year in the list (chronologically previous) if (yIdx < years.length - 1) { const prevYear = years[yIdx + 1]; const prevData = grandTotals.totalsByYear[prevYear]; if (prevData) { sellOutGrowth = renderGrowth(currentData.sellOut, prevData.sellOut, 'sm'); unitsGrowth = renderGrowth(currentData.units, prevData.units, 'sm'); } } return (
€{currentData.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })} {sellOutGrowth}
{currentData.units.toLocaleString()} u {unitsGrowth}
{years.map((year, yIdx) => { const data = grandTotals.months[idx][year]; if(!data || (data.sellOut === 0 && data.units === 0)) return null; let sellOutGrowth = null; let unitsGrowth = null; if (yIdx < years.length - 1) { const prevYear = years[yIdx + 1]; const prevData = grandTotals.months[idx][prevYear]; if (prevData) { sellOutGrowth = renderGrowth(data.sellOut, prevData.sellOut); unitsGrowth = renderGrowth(data.units, prevData.units); } } return (
{year}
€{data.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })} {sellOutGrowth}
{data.units.toLocaleString()}u {unitsGrowth}
); })}
{cellContent}
€{yData.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })} {sellOutGrowth}
{yData.units.toLocaleString()} u {unitsGrowth}
{years.map((year, yIdx) => { const yData = m.byYear[year]; // Skip if year has no data, unless it's the only year selected if (!yData && years.length > 1) return null; const sellOut = yData?.sellOut || 0; const units = yData?.units || 0; let sellOutGrowthEl = null; let unitsGrowthEl = null; if (yIdx < years.length - 1) { const nextYear = years[yIdx + 1]; const nextData = m.byYear[nextYear]; if (nextData) { if (nextData.sellOut > 0) { sellOutGrowthEl = renderGrowth(sellOut, nextData.sellOut); } if (nextData.units > 0) { unitsGrowthEl = renderGrowth(units, nextData.units); } } } return (
{year} {sellOutGrowthEl}
€{sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}
{units} u {unitsGrowthEl}
); })}
{/* Footer */}
Showing {((currentPage - 1) * ROWS_PER_PAGE) + 1} - {Math.min(currentPage * ROWS_PER_PAGE, processedRows.length)} of {processedRows.length} Rows
Page {currentPage} of {totalPages}
); }; export default DataGrid;