mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 11:45:23 +02:00
982 lines
48 KiB
TypeScript
982 lines
48 KiB
TypeScript
|
|||
|
|
|
||
|
|
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;
|