Files
CrazeAnalytix/components/DataGrid.tsx
T

913 lines
51 KiB
TypeScript

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 (
<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(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`
: `${Number(p.value).toLocaleString()} 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(undefined, { 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()} 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 px-6 pb-6 pt-16 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>
<div className="flex-1 overflow-auto bg-slate-900 rounded-xl p-6 border border-border custom-scrollbar">
{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 }) => {
const [currentPage, setCurrentPage] = useState(1);
const [sortConfig, setSortConfig] = useState<SortConfig>({ 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<string[]>(['line', 'customer', '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>('');
// 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;
}
}
// 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);
}
}
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 <span className="text-slate-600 ml-1"></span>;
return <span className="text-primary ml-1">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>;
};
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 (
<div className="space-y-6 max-w-[95vw] mx-auto animate-fade-in 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}
/>
)
])
) : (
[
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 overflow-hidden flex flex-col">
{/* Toolbar */}
<div className="p-4 border-b border-border bg-slate-900/50 flex flex-col lg:flex-row gap-4 justify-between items-start lg:items-center">
<div className="flex flex-wrap items-center gap-3 w-full lg:w-auto">
{/* Dimensions Selector */}
<div className="relative group z-30">
<button className="flex items-center gap-2 px-3 py-2 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg text-sm font-medium transition-colors">
<span className="text-slate-300">Group By:</span>
<span className="text-white font-bold">{effectiveDimensions.length} Columns</span>
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /></svg>
</button>
<div className="absolute top-full left-0 mt-2 w-48 bg-slate-900 border border-slate-700 rounded-xl shadow-xl p-2 hidden group-hover:block animate-fade-in">
{DIMENSION_OPTIONS.map(dim => (
<label key={dim.value} className="flex items-center gap-2 p-2 hover:bg-slate-800 rounded cursor-pointer">
<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="rounded border-slate-600 bg-slate-800 text-primary focus:ring-primary"
/>
<span className="text-sm text-slate-300">{dim.label}</span>
</label>
))}
</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>
</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>
)}
{/* Filter Totals Summary */}
<div className="bg-gradient-to-r from-indigo-900/20 to-purple-900/20 border-y border-indigo-500/30 px-4 py-3">
<div className="flex flex-wrap items-center gap-6">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 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-xs font-bold text-indigo-300 uppercase tracking-wide">Filtered Totals:</span>
</div>
{years.map((year, index) => {
const yearTotals = processedRows.reduce(
(acc, row) => {
const data = row.totalsByYear[year];
if (data) {
acc.sellOut += data.sellOut;
acc.units += data.units;
}
return acc;
},
{ sellOut: 0, units: 0 }
);
// Calculate YoY growth if previous year exists
const prevYear = years[index + 1];
let sellOutGrowth = null;
let unitsGrowth = null;
if (prevYear) {
const prevYearTotals = processedRows.reduce(
(acc, row) => {
const data = row.totalsByYear[prevYear];
if (data) {
acc.sellOut += data.sellOut;
acc.units += data.units;
}
return acc;
},
{ sellOut: 0, units: 0 }
);
if (prevYearTotals.sellOut > 0) {
sellOutGrowth = ((yearTotals.sellOut - prevYearTotals.sellOut) / prevYearTotals.sellOut) * 100;
} else if (yearTotals.sellOut > 0) {
sellOutGrowth = 100;
}
if (prevYearTotals.units > 0) {
unitsGrowth = ((yearTotals.units - prevYearTotals.units) / prevYearTotals.units) * 100;
} else if (yearTotals.units > 0) {
unitsGrowth = 100;
}
}
return (
<div key={year} className="flex items-center gap-4 px-4 py-2 bg-slate-900/50 border border-slate-700/50 rounded-lg">
<span className="text-xs font-bold text-slate-400">{year}</span>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">S.O:</span>
<span className="text-sm font-bold text-emerald-400">
{yearTotals.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</span>
{sellOutGrowth !== null && (
<span className={`text-xs font-bold ${sellOutGrowth >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{sellOutGrowth >= 0 ? '↑' : '↓'} {Math.abs(sellOutGrowth).toFixed(1)}%
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">Units:</span>
<span className="text-sm font-bold text-blue-400">
{yearTotals.units.toLocaleString()}
</span>
{unitsGrowth !== null && (
<span className={`text-xs font-bold ${unitsGrowth >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{unitsGrowth >= 0 ? '↑' : '↓'} {Math.abs(unitsGrowth).toFixed(1)}%
</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Data Table */}
<div className="overflow-x-auto min-h-[400px]">
<table className="w-full text-left 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">
<tr>
{/* Dynamic Dimension Headers */}
{effectiveDimensions.map(dim => {
const label = DIMENSION_OPTIONS.find(d => d.value === dim)?.label || dim;
return (
<th
key={dim}
className="px-4 py-3 border-b border-border cursor-pointer hover:text-white group bg-slate-950 min-w-[150px]"
onClick={() => requestSort(dim)}
>
<div className="flex items-center">
{label} {getSortIcon(dim)}
</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-4 py-3 border-b border-border text-right cursor-pointer hover:text-white bg-slate-950 min-w-[100px]"
onClick={() => requestSort(`total_sellOut_${year}`)}
>
<div className="flex items-center justify-end">
Sell Out {year} {getSortIcon(`total_sellOut_${year}`)}
</div>
</th>
<th
className="px-4 py-3 border-b border-border text-right cursor-pointer hover:text-white bg-slate-950 min-w-[80px]"
onClick={() => requestSort(`total_units_${year}`)}
>
<div className="flex items-center justify-end">
Units {year} {getSortIcon(`total_units_${year}`)}
</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>
</>
)}
</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">
{/* Dimension Values */}
{effectiveDimensions.map(dim => (
<td key={dim} className="px-4 py-3 font-medium text-slate-200 break-words max-w-xs">
{dim === 'title'
? <div className="line-clamp-2" title={row.title}>{row.title || '-'}</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-4 py-3 text-right font-medium text-white group-hover:text-emerald-400 transition-colors">
{data ? `€${data.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : '-'}
</td>
<td className="px-4 py-3 text-right text-slate-400">
{data ? data.units.toLocaleString() : '-'}
</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>
</>
)}
</React.Fragment>
);
})}
</tr>
))}
{paginatedRows.length === 0 && (
<tr>
<td colSpan={effectiveDimensions.length + (years.length * 2)} className="px-6 py-12 text-center text-slate-500 italic">
No data matches your filters.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="p-4 border-t border-border bg-slate-900/50 flex flex-col sm:flex-row justify-between items-center gap-4">
<div className="text-sm text-slate-500">
Page {currentPage} of {totalPages || 1}
</div>
<div className="flex gap-2">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-3 py-1 bg-slate-800 border border-slate-700 rounded text-sm text-slate-300 disabled:opacity-50 hover:bg-slate-700"
>
Previous
</button>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages || totalPages === 0}
className="px-3 py-1 bg-slate-800 border border-slate-700 rounded text-sm text-slate-300 disabled:opacity-50 hover:bg-slate-700"
>
Next
</button>
</div>
</div>
</div>
</div>
);
};
export default DataGrid;