import React, { useState, useMemo, useEffect } from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { SalesRecord, PivotRow } from '../types'; import { pivotSalesData, generateCSV, aggregateForTimeSeries, aggregateForComparisonTimeSeries } from '../services/dataProcessor'; import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon } from './Icons'; interface DataGridProps { data: SalesRecord[]; } 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(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('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(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })} {sellOutGrowthEl}
)} {yearData.units != null && (
Units:
{Number(yearData.units).toLocaleString()} 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}

{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: string, b: string) => 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]); // 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 (sortConfig.key) { 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; } } if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1; if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1; return 0; }); } return result; }, [pivotRows, rowFilters, sortConfig, 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 = () => { generateCSV(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]); 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') && ( ) ]) ) : ( [ visibleMetrics.includes('sellOut') && ( ), visibleMetrics.includes('units') && ( ) ] )}
)} {/* 2. Controls & Grid */}
{/* Toolbar */}
{/* Dimensions Selector */}
{DIMENSION_OPTIONS.map(dim => ( ))}
{/* Filter Builder Trigger */} {/* Chart Toggle */}
Showing {processedRows.length} rows
{/* Filter Builder Panel */} {showFilterBuilder && (
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" />
{/* Active Filters Chips */} {rowFilters.length > 0 && (
{rowFilters.map(filter => { const metricLabel = metricOptions.find(m => m.value === filter.metric)?.label || filter.metric; return (
{metricLabel} {filter.operator === 'gt' ? '>' : '<'} {filter.value}
); })}
)}
)} {/* Data Table */}
{/* Dynamic Dimension Headers */} {effectiveDimensions.map(dim => { const label = DIMENSION_OPTIONS.find(d => d.value === dim)?.label || dim; return ( ); })} {/* 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 ( {/* Sell Out & Units */} {/* Growth Columns (if prev year exists) */} {prevYear && ( <> )} ); })} {paginatedRows.map((row) => ( {/* Dimension Values */} {effectiveDimensions.map(dim => ( ))} {/* 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 ( {/* Growth Cells */} {prevYear && ( <> )} ); })} ))} {paginatedRows.length === 0 && ( )}
requestSort(dim)} >
{label} {getSortIcon(dim)}
requestSort(`total_sellOut_${year}`)} >
Sell Out {year} {getSortIcon(`total_sellOut_${year}`)}
requestSort(`total_units_${year}`)} >
Units {year} {getSortIcon(`total_units_${year}`)}
S.O. Δ%
Units Δ%
{dim === 'title' ?
{row.title || '-'}
: (row[dim as keyof PivotRow] as string) || '-' }
{data ? `€${data.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : '-'} {data ? data.units.toLocaleString() : '-'} = 0 ? 'text-emerald-500' : 'text-red-500') : 'text-slate-600'}`}> {sellOutGrowth !== null ? ( {sellOutGrowth >= 0 ? '↑' : '↓'} {Math.abs(sellOutGrowth).toFixed(0)}% ) : '-'} = 0 ? 'text-emerald-500' : 'text-red-500') : 'text-slate-600'}`}> {unitsGrowth !== null ? ( {unitsGrowth >= 0 ? '↑' : '↓'} {Math.abs(unitsGrowth).toFixed(0)}% ) : '-'}
No data matches your filters.
{/* Pagination */}
Page {currentPage} of {totalPages || 1}
); }; export default DataGrid;