Files
CrazeAnalytix/components/DataGrid.tsx
T

982 lines
48 KiB
TypeScript
Raw Normal View History

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 (
<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('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 (
<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 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 (
<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, 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<string, YearlyData> = {};
const accMonthsByYear: Record<number, Record<string, YearlyData>> = {};
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 (
<span className={`${textSize} font-bold ml-1.5 ${isPositive ? 'text-emerald-400' : 'text-rose-400'} bg-slate-800 border border-slate-700 px-1 rounded inline-block`}>
{isPositive ? '↑' : '↓'}{Math.abs(pct).toFixed(0)}%
</span>
);
};
if (data.length === 0) return null;
return (
<div className="max-w-[100vw] mx-auto px-4 pb-24 h-screen flex flex-col">
<div className="bg-surface border border-border rounded-xl shadow-lg flex flex-col flex-1">
{/* Header Bar */}
<div className="p-4 border-b border-border bg-slate-900 flex flex-col gap-4 shrink-0 rounded-t-xl z-50">
<div className="flex flex-wrap gap-4 justify-between items-center">
<div>
<h3 className="text-lg font-bold text-slate-100">Dynamic Pivot Table</h3>
<p className="text-xs text-slate-500">
Comparing {years.join(', ')} {processedRows.length} Rows
{rowFilters.length > 0 && <span className="text-indigo-400 ml-1">(Filtered)</span>}
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-400 font-medium">Group By:</span>
<MultiSelectDropdown
label="Variables"
selected={selectedDimensions}
options={DIMENSION_OPTIONS.map(d => d.value)}
onChange={setSelectedDimensions}
className="w-64"
/>
</div>
<button
onClick={() => setShowFilterBuilder(!showFilterBuilder)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm border
${showFilterBuilder || rowFilters.length > 0 ? 'bg-indigo-900/50 border-indigo-500 text-indigo-300' : 'bg-slate-800 border-border text-slate-300 hover:text-white hover:bg-slate-700'}`}
>
<FunnelIcon />
{rowFilters.length > 0 ? `${rowFilters.length} Active` : 'Filter Rows'}
</button>
<button
onClick={() => setShowChart(!showChart)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm border
${showChart ? 'bg-indigo-900/50 border-indigo-500 text-indigo-300' : 'bg-slate-800 border-border text-slate-300 hover:text-white hover:bg-slate-700'}`}
>
<ChartIcon />
{showChart ? 'Hide Trend' : 'Show Trend'}
</button>
<button
onClick={handleExport}
className="flex items-center gap-2 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors shadow-sm"
>
<DownloadIcon /> Export CSV
</button>
</div>
</div>
{/* Advanced Filter Builder Panel */}
{(showFilterBuilder || rowFilters.length > 0) && (
<div className="bg-slate-950/50 p-4 rounded-lg border border-border space-y-3 animate-fade-in">
{/* Active Filters List */}
{rowFilters.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{rowFilters.map(filter => {
const metricLabel = metricOptions.find(o => o.value === filter.metric)?.label || filter.metric;
const opLabel = filter.operator === 'gt' ? '>' : '<';
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} {opLabel} {filter.value}</span>
<button onClick={() => removeFilter(filter.id)} className="hover:text-white"><CloseIcon /></button>
</div>
);
})}
</div>
)}
{/* Filter Creator Inputs */}
{showFilterBuilder && (
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-slate-500 font-semibold uppercase">Metric</label>
<select
className="bg-slate-900 border border-slate-700 text-slate-200 text-sm rounded-md px-3 py-2 focus:ring-1 focus:ring-primary outline-none min-w-[220px]"
value={newFilterMetric}
onChange={(e) => setNewFilterMetric(e.target.value)}
>
<option value="">Select Metric...</option>
{metricOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-slate-500 font-semibold uppercase">Operator</label>
<select
className="bg-slate-900 border border-slate-700 text-slate-200 text-sm rounded-md px-3 py-2 focus:ring-1 focus:ring-primary outline-none"
value={newFilterOperator}
onChange={(e) => setNewFilterOperator(e.target.value as 'gt' | 'lt')}
>
<option value="gt">Greater Than ({'>'})</option>
<option value="lt">Less Than ({'<'})</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-slate-500 font-semibold uppercase">Value</label>
<input
type="number"
placeholder="0"
className="w-full bg-slate-900 border border-slate-700 text-slate-200 text-sm rounded-md px-3 py-2 focus:ring-1 focus:ring-primary outline-none w-32"
value={newFilterValue}
onChange={(e) => setNewFilterValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addFilter()}
/>
</div>
<button
onClick={addFilter}
disabled={!newFilterMetric || !newFilterValue}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:hover:bg-indigo-600 text-white rounded-md text-sm font-medium transition-colors"
>
Apply
</button>
</div>
)}
</div>
)}
</div>
{/* Trend Chart */}
{showChart && chartData.length > 1 && (
<ExpandableChartCard title={chartTitle} className="mb-6">
<div className="flex flex-col h-full min-h-[350px]">
<div className="flex justify-end mb-2 shrink-0">
<div className="bg-slate-900 p-1 rounded-lg border border-slate-800 flex text-xs font-medium">
<label className="flex items-center gap-2 px-3 py-1 rounded-md transition-colors cursor-pointer has-[:checked]:bg-indigo-600 has-[:checked]:text-white has-[:checked]:shadow-sm text-slate-400 hover:text-white">
<input type="checkbox" checked={visibleMetrics.includes('sellOut')} onChange={() => toggleMetric('sellOut')} className="hidden" />
Sell Out ()
</label>
<label className="flex items-center gap-2 px-3 py-1 rounded-md transition-colors cursor-pointer has-[:checked]:bg-teal-600 has-[:checked]:text-white has-[:checked]:shadow-sm text-slate-400 hover:text-white">
<input type="checkbox" checked={visibleMetrics.includes('units')} onChange={() => toggleMetric('units')} className="hidden" />
Units
</label>
</div>
</div>
<div className="flex-1 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="name" stroke="#64748b" tick={{ fontSize: 12 }} />
{visibleMetrics.includes('sellOut') && (
<YAxis yAxisId="left" stroke={CHART_COLORS[0]} tickFormatter={(val) => `€${(val / 1000).toFixed(0)}k`} />
)}
{visibleMetrics.includes('units') && (
<YAxis yAxisId="right" orientation="right" stroke={isComparisonView ? CHART_COLORS[1] : CHART_COLORS[2]} tickFormatter={(val) => `${(val / 1000).toFixed(0)}k`} />
)}
<Tooltip content={isComparisonView ? <ComparisonTooltip /> : <WoWTooltip data={chartData} />} />
<Legend />
{isComparisonView ? (
uniqueYears.map((year, index) => (
<React.Fragment key={year}>
{visibleMetrics.includes('sellOut') && (
<Line yAxisId="left" type="monotone" dataKey={`${year}_sellOut`} name={`SO ${year}`} stroke={CHART_COLORS[index % CHART_COLORS.length]} strokeWidth={2} dot={false} activeDot={{ r: 6 }} />
)}
{visibleMetrics.includes('units') && (
<Line yAxisId="right" type="monotone" dataKey={`${year}_units`} name={`Units ${year}`} stroke={CHART_COLORS[index % CHART_COLORS.length]} strokeWidth={2} strokeDasharray="5 5" dot={false} activeDot={{ r: 6 }} />
)}
</React.Fragment>
))
) : (
<>
{visibleMetrics.includes('sellOut') && (
<Line yAxisId="left" type="monotone" dataKey="sellOut" name="Sell Out" stroke={CHART_COLORS[0]} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 6 }} />
)}
{visibleMetrics.includes('units') && (
<Line yAxisId="right" type="monotone" dataKey="units" name="Units" stroke={CHART_COLORS[2]} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 6 }} />
)}
</>
)}
</LineChart>
</ResponsiveContainer>
</div>
</div>
</ExpandableChartCard>
)}
{(!showChart || chartData.length <=1) && !isComparisonView && (
<div className="p-4 border-b border-border text-center text-sm text-slate-500 italic bg-slate-900/30 mb-6">
{isComparisonView
? "Not enough weekly data to compare these years."
: "Not enough weekly data points to render a trend chart for the current selection."
}
</div>
)}
{/* Table Container */}
<div className="overflow-auto flex-1 relative custom-scrollbar bg-slate-950">
<table className="w-max text-left text-sm text-slate-300 border-collapse">
<thead className="bg-slate-950 text-sm uppercase font-semibold text-slate-400 z-30 shadow-md">
<tr className="border-b border-slate-800">
{/* Dynamic Dimension Headers - STICKY TOP */}
{effectiveDimensions.map((dim, index) => {
const label = getLabel(dim);
const isFirst = index === 0;
const isTitle = dim === 'title';
return (
<th
key={dim}
className={`p-3 border-r border-slate-800 cursor-pointer hover:text-white sticky top-0 bg-slate-950
${isTitle ? 'min-w-[300px]' : 'min-w-[150px]'}
${isFirst ? 'left-0 z-40 bg-slate-900' : 'z-30'}`}
onClick={() => requestSort(dim)}
>
{label} {sortConfig.key === dim && (sortConfig.direction === 'asc' ? '▲' : '▼')}
</th>
);
})}
{/* Dynamic Total Columns for each Year - STICKY TOP */}
{years.map(year => (
<th
key={`total_${year}`}
className="p-3 w-40 border-r border-indigo-500 text-right cursor-pointer text-white bg-indigo-700 hover:bg-indigo-600 transition-colors shadow-md sticky top-0 z-30"
onClick={() => requestSort(`total_${year}`)}
>
Total {year} {sortConfig.key === `total_${year}` && (sortConfig.direction === 'asc' ? '▲' : '▼')}
</th>
))}
{/* Monthly Headers - STICKY TOP */}
{MONTH_NAMES.map(m => (
<th key={m} className="p-2 min-w-[150px] text-center border-r border-slate-800 bg-slate-900 sticky top-0 z-30">
{m}
</th>
))}
</tr>
{/* Grand Total Row (Sticky Top BELOW Headers) */}
<tr className="border-b-2 border-indigo-400 bg-slate-950 text-white font-bold shadow-lg z-40 sticky top-[48px]">
{/*
Anchor TOTAL label to the first column (sticky left).
This ensures "TOTAL" stays visible on the left even when scrolling horizontally.
*/}
<td
className="p-3 border-r border-slate-800 text-left sticky left-0 z-50 bg-slate-950 whitespace-nowrap"
>
TOTAL ({processedRows.length} Rows)
</td>
{/* Spacer for remaining dimensions if any */}
{effectiveDimensions.length > 1 && (
<td
colSpan={effectiveDimensions.length - 1}
className="p-3 border-r border-slate-800 bg-slate-950"
/>
)}
{/* 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 (
<td key={`grand_${year}`} className="p-3 border-r border-indigo-500 text-right bg-indigo-800">
<div className="flex justify-end items-center mb-1">
<span className="text-base text-white">{currentData.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
{sellOutGrowth}
</div>
<div className="flex justify-end items-center">
<span className="text-sm text-violet-300 font-normal">{currentData.units.toLocaleString()} u</span>
{unitsGrowth}
</div>
</td>
);
})}
{/* Monthly Grand Totals */}
{MONTH_NAMES.map((_, idx) => (
<td key={`grand_m_${idx}`} className="p-2 border-r border-slate-800 text-right min-w-[150px] bg-slate-900">
{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 (
<div key={year} className="flex justify-between items-center text-xs mb-1 border-b border-white/5 last:border-0 pb-1 last:pb-0">
<div className="flex items-center text-slate-400 mr-2 min-w-[32px]">
<span>{year}</span>
</div>
<div className="text-right flex-1">
<div className="flex items-center justify-end">
<span>{data.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
{sellOutGrowth}
</div>
<div className="flex items-center justify-end text-violet-400 font-mono mt-0.5">
<span>{data.units.toLocaleString()}u</span>
{unitsGrowth}
</div>
</div>
</div>
);
})}
</td>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{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 (
<tr key={row.id} className={`${rowClass} hover:bg-slate-700 transition-colors group`}>
{/* 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 = (
<div className="overflow-x-auto whitespace-nowrap custom-scrollbar pb-1">
{val}
</div>
);
} else {
let displayVal = val;
if (typeof val === 'string' && val.length > 30) {
displayVal = val.substring(0, 30) + '...';
}
cellContent = displayVal;
}
return (
<td
key={dim}
className={`p-3 border-r border-slate-800 text-sm ${textColor}
${isTitle ? 'min-w-[300px] max-w-[300px]' : 'truncate max-w-[220px]'}
${isFirst ? `sticky left-0 z-20 ${rowClass} group-hover:bg-slate-700` : ''}
`}
title={val}
>
{cellContent}
</td>
);
})}
{/* 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 (
<td key={`row_tot_${year}`} className="p-3 border-r border-indigo-500/40 text-right font-bold text-white bg-indigo-900/60">
<div className="flex justify-end items-center mb-1">
<span className="text-base">{yData.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
{sellOutGrowth}
</div>
<div className="flex justify-end items-center text-sm text-violet-200 font-normal">
<span>{yData.units.toLocaleString()} u</span>
{unitsGrowth}
</div>
</td>
);
})}
{/* Monthly Data Columns (Listing all years) */}
{row.months.map((m, idx) => (
<td key={idx} className="p-2 border-r border-slate-800 text-right min-w-[150px]">
{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 (
<div key={year} className="mb-2 last:mb-0 border-b border-slate-800 last:border-0 pb-1 last:pb-0">
<div className="flex justify-between items-center text-xs text-slate-500 mb-0.5">
<span>{year}</span>
{sellOutGrowthEl}
</div>
<div className="flex justify-end items-center text-sm font-medium text-slate-300">
{sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</div>
<div className="flex justify-between items-center mt-0.5">
<div className="flex-1"></div>
<div className="flex items-center gap-1">
<span className="text-xs text-violet-400 font-mono">{units} u</span>
{unitsGrowthEl}
</div>
</div>
</div>
);
})}
</td>
))}
</tr>
)})}
</tbody>
</table>
</div>
{/* Footer */}
<div className="bg-slate-900 border-t border-border p-3 flex justify-between items-center text-sm shrink-0 rounded-b-xl z-40">
<div className="text-slate-500">
Showing {((currentPage - 1) * ROWS_PER_PAGE) + 1} - {Math.min(currentPage * ROWS_PER_PAGE, processedRows.length)} of {processedRows.length} Rows
</div>
<div className="flex gap-2">
<button
onClick={handlePrev}
disabled={currentPage === 1}
className="px-3 py-1 rounded bg-slate-800 border border-border hover:bg-slate-700 disabled:opacity-50 transition-colors"
>
Previous
</button>
<span className="flex items-center px-2 text-slate-300">
Page {currentPage} of {totalPages}
</span>
<button
onClick={handleNext}
disabled={currentPage === totalPages}
className="px-3 py-1 rounded bg-slate-800 border border-border hover:bg-slate-700 disabled:opacity-50 transition-colors"
>
Next
</button>
</div>
</div>
</div>
</div>
);
};
export default DataGrid;