feat: Initialize Craze Analytix project structure

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.
This commit is contained in:
Christian
2025-12-11 11:25:26 +01:00
parent 563e6110ce
commit 9ba63ab8f8
23 changed files with 4481 additions and 8 deletions
+981
View File
@@ -0,0 +1,981 @@
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;