mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 11:05:23 +02:00
Sets up the project with Vite, React, Tailwind CSS, Gemini AI integration, and necessary dependencies for data analysis. Includes initial configuration for TypeScript, Tailwind, and project metadata.
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;
|