mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:25:22 +02:00
745 lines
39 KiB
TypeScript
745 lines
39 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;
|
|
}
|
|
}
|
|
|
|
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 (>)</option>
|
|
<option value="lt">Less Than (<)</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>
|
|
)}
|
|
|
|
{/* 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>
|
|
);
|
|
})}
|
|
|
|
{/* Total Columns per Year */}
|
|
{years.map(year => (
|
|
<React.Fragment key={year}>
|
|
<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-[100px]"
|
|
onClick={() => requestSort(`total_units_${year}`)}
|
|
>
|
|
<div className="flex items-center justify-end">
|
|
Units {year} {getSortIcon(`total_units_${year}`)}
|
|
</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 => {
|
|
const data = row.totalsByYear[year];
|
|
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>
|
|
</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; |