mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 17:05:23 +02:00
- Add bottom navigation bar (6 tabs) for mobile, hidden on desktop - Compact header with smaller logo and reduced padding on mobile - Collapsible filter bar with active filter count badge on mobile - Bottom-sheet style dropdowns with overlay and larger touch targets - Fullscreen AI chat on mobile, floating window on desktop - Responsive dashboard cards with always-visible expand button - Smaller table text and touch-friendly scroll on all data grids - iPhone safe-area support and thinner scrollbars on mobile Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1546 lines
95 KiB
TypeScript
1546 lines
95 KiB
TypeScript
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
|
import {
|
|
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
|
|
} from 'recharts';
|
|
import { SalesRecord, PivotRow, AdsRecord, CombinedKPIs, ColumnFilterCondition, FilterState } from '../types';
|
|
import { pivotSalesData, generateXLSX, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping, filterData } from '../services/dataProcessor';
|
|
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons';
|
|
import { StockBadge } from './StockBadge';
|
|
import { Top50Badge } from './Top50Badge';
|
|
import { VendorStockBadge } from './VendorStockBadge';
|
|
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
|
|
import { ExcelFilter } from './ExcelFilter';
|
|
|
|
interface DataGridProps {
|
|
data: SalesRecord[] | CombinedKPIs[];
|
|
filters: FilterState;
|
|
hasCustomerFilter: boolean;
|
|
adsData?: AdsRecord[];
|
|
stockMap?: Map<string, number>;
|
|
top50Ranking?: {
|
|
eu: Map<string, number>;
|
|
uk: Map<string, number>;
|
|
};
|
|
top50Mode: 'eu' | 'uk';
|
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
|
velocityMap?: Map<string, number>;
|
|
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
|
|
defaultSort?: SortConfig;
|
|
}
|
|
|
|
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('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`
|
|
: `${Number(p.value).toLocaleString('de-DE')} 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('de-DE', { 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('de-DE')} 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 p-6 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>
|
|
{/* Use flex-1 and min-h-0 to allow children to fill available space */}
|
|
<div className="flex-1 min-h-0 bg-slate-900 rounded-xl p-6 border border-border [&>div]:h-full [&>div]:w-full">
|
|
{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, filters, hasCustomerFilter, adsData, stockMap, vendorStockMap, top50Ranking, top50Mode, velocityMap, buyBoxLostMap, defaultSort }) => {
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [sortConfig, setSortConfig] = useState<SortConfig>(defaultSort || { key: null, direction: 'desc' });
|
|
const [showChart, setShowChart] = useState(true);
|
|
const [visibleMetrics, setVisibleMetrics] = useState<('sellOut' | 'units')[]>(['sellOut', 'units']);
|
|
const [showAdsMetrics, setShowAdsMetrics] = useState(true);
|
|
const [showAttributedSales, setShowAttributedSales] = useState(false);
|
|
const [showBulkSearch, setShowBulkSearch] = useState(false);
|
|
const [showYTD, setShowYTD] = useState(false);
|
|
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
|
const [showDimensionMenu, setShowDimensionMenu] = useState(false);
|
|
const dimensionRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Close dimension menu on click outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (dimensionRef.current && !dimensionRef.current.contains(event.target as Node)) {
|
|
setShowDimensionMenu(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
// Calculate Ads Summary for the Grid
|
|
const adsSummary = useMemo(() => {
|
|
if (!adsData || adsData.length === 0) return null;
|
|
|
|
const totals = adsData.reduce((acc, ad) => ({
|
|
cost: acc.cost + ad.cost,
|
|
attributedSales: acc.attributedSales + ad.attributedSales30d,
|
|
clicks: acc.clicks + ad.clicks,
|
|
impressions: acc.impressions + ad.impressions,
|
|
}), { cost: 0, attributedSales: 0, clicks: 0, impressions: 0 });
|
|
|
|
return {
|
|
totalSpend: totals.cost,
|
|
attributedSales: totals.attributedSales,
|
|
acos: totals.attributedSales > 0 ? (totals.cost / totals.attributedSales) * 100 : 0,
|
|
roas: totals.cost > 0 ? totals.attributedSales / totals.cost : 0,
|
|
recordCount: adsData.length,
|
|
};
|
|
}, [adsData]);
|
|
|
|
// State for dynamic grouping
|
|
const [selectedDimensions, setSelectedDimensions] = useState<string[]>(['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>('');
|
|
|
|
// State for Column Filters
|
|
const [columnFilters, setColumnFilters] = useState<Record<string, ColumnFilterCondition>>({});
|
|
|
|
const handleColumnFilterChange = (columnKey: string, condition: ColumnFilterCondition | undefined) => {
|
|
setColumnFilters(prev => {
|
|
const next = { ...prev };
|
|
if (condition) next[columnKey] = condition;
|
|
else delete next[columnKey];
|
|
return next;
|
|
});
|
|
setCurrentPage(1);
|
|
};
|
|
|
|
// Effective dimensions for rendering
|
|
const effectiveDimensions = useMemo(() =>
|
|
selectedDimensions.length > 0 ? selectedDimensions : ['customer'],
|
|
[selectedDimensions]);
|
|
|
|
// Transform flat data into Pivot structure
|
|
const pivotRows = useMemo(() => {
|
|
// Apply Pan-EU grouping when no customer filter is applied
|
|
const processedData = applyPanEUGrouping(data as SalesRecord[], hasCustomerFilter);
|
|
|
|
// Apply our comprehensive filters (includes Column Filters now)
|
|
const filterState: any = {
|
|
...filters,
|
|
bulkSearch: searchTerm, // searchTerm from the DataGrid's local search input
|
|
columnFilters
|
|
};
|
|
const filteredFlatData = filterData(processedData, filterState, stockMap, vendorStockMap, top50Mode);
|
|
|
|
// pivotSalesData now handles ads aggregation correctly because it receives CombinedKPIs
|
|
const { rows } = pivotSalesData(filteredFlatData, effectiveDimensions);
|
|
return rows;
|
|
}, [data, filters, effectiveDimensions, hasCustomerFilter, columnFilters, searchTerm, stockMap, vendorStockMap, top50Mode]);
|
|
|
|
const { years } = useMemo(() => {
|
|
// We still need unique years for columns
|
|
const yearsSet = new Set<string>(data.map(d => String(d.year)));
|
|
return { years: Array.from(yearsSet).sort((a, b) => parseInt(b) - parseInt(a)) };
|
|
}, [data]);
|
|
|
|
// 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;
|
|
|
|
// Get base sales data
|
|
let salesChartData = isMultiYear
|
|
? aggregateForComparisonTimeSeries(data)
|
|
: aggregateForTimeSeries(data);
|
|
|
|
// Aggregate ads data by week/year and merge into chartData
|
|
if (adsData && adsData.length > 0) {
|
|
const adsMap = new Map<number, { [key: string]: number }>();
|
|
|
|
adsData.forEach(ad => {
|
|
if (ad.week >= 1 && ad.week <= 53) {
|
|
if (!adsMap.has(ad.week)) {
|
|
adsMap.set(ad.week, {});
|
|
}
|
|
const weekData = adsMap.get(ad.week)!;
|
|
const adSpendKey = `${ad.year}_adSpend`;
|
|
const attrSalesKey = `${ad.year}_attributedSales`;
|
|
weekData[adSpendKey] = (weekData[adSpendKey] || 0) + ad.cost;
|
|
weekData[attrSalesKey] = (weekData[attrSalesKey] || 0) + ad.attributedSales30d;
|
|
}
|
|
});
|
|
|
|
// Merge ads data into sales chart data
|
|
salesChartData = salesChartData.map(point => {
|
|
const adsWeekData = adsMap.get(point.week) || {};
|
|
return { ...point, ...adsWeekData };
|
|
});
|
|
}
|
|
|
|
// Calculate YTD (Year-To-Date) cumulative values for each year
|
|
const ytdAccumulators: { [year: string]: { sellOut: number; units: number } } = {};
|
|
yearsInView.forEach((year: string) => {
|
|
ytdAccumulators[year] = { sellOut: 0, units: 0 };
|
|
});
|
|
|
|
salesChartData.forEach((point: any) => {
|
|
yearsInView.forEach((year: string) => {
|
|
const sellOutKey = isMultiYear ? `${year}_sellOut` : 'sellOut';
|
|
const unitsKey = isMultiYear ? `${year}_units` : 'units';
|
|
|
|
// Accumulate values
|
|
ytdAccumulators[year].sellOut += (point[sellOutKey] as number) || 0;
|
|
ytdAccumulators[year].units += (point[unitsKey] as number) || 0;
|
|
|
|
// Add YTD values directly to point
|
|
point[`${year}_ytdSellOut`] = ytdAccumulators[year].sellOut;
|
|
point[`${year}_ytdUnits`] = ytdAccumulators[year].units;
|
|
});
|
|
});
|
|
|
|
return {
|
|
chartData: salesChartData,
|
|
uniqueYears: yearsInView,
|
|
isComparisonView: isMultiYear,
|
|
chartTitle: isMultiYear
|
|
? `Weekly Sales Comparison: ${yearsInView.join(' vs ')}`
|
|
: `Weekly Sales Evolution ${yearsInView[0] || ''}`
|
|
};
|
|
}, [data, adsData]);
|
|
|
|
// 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;
|
|
|
|
// NEW: Filter for Top 50
|
|
if (showOnlyTop50 && top50Ranking) {
|
|
const rankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
|
|
result = result.filter(row => {
|
|
const asin = (row.asin || '').trim().toUpperCase();
|
|
return asin && rankMap.has(asin);
|
|
});
|
|
}
|
|
|
|
// 0. Global Search Filter
|
|
if (searchTerm) {
|
|
const rawTerm = searchTerm.trim();
|
|
// Check if it looks like a bulk search (contains newlines or commas)
|
|
const isBulk = rawTerm.includes('\n') || rawTerm.includes(',') || rawTerm.includes(' ');
|
|
|
|
if (isBulk) {
|
|
const searchTerms = rawTerm
|
|
.split(/[\s,\n]+/)
|
|
.map(t => t.trim().toLowerCase())
|
|
.filter(t => t.length > 0);
|
|
|
|
if (searchTerms.length > 0) {
|
|
result = result.filter(row => {
|
|
const rowSku = row.sku?.toLowerCase() || '';
|
|
const rowAsin = row.asin?.toLowerCase() || '';
|
|
const rowTitle = row.title?.toLowerCase() || '';
|
|
return searchTerms.some(term =>
|
|
rowSku.includes(term) ||
|
|
rowAsin.includes(term) ||
|
|
rowTitle.includes(term)
|
|
);
|
|
});
|
|
}
|
|
} else {
|
|
const term = rawTerm.toLowerCase();
|
|
result = result.filter(row => {
|
|
return (
|
|
(row.sku?.toLowerCase().includes(term)) ||
|
|
(row.asin?.toLowerCase().includes(term)) ||
|
|
(row.title?.toLowerCase().includes(term)) ||
|
|
(row.line?.toLowerCase().includes(term)) ||
|
|
(row.customer?.toLowerCase().includes(term))
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
// 1. Filter (Legacy Row Filters)
|
|
if (rowFilters.length > 0) {
|
|
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') {
|
|
const latestYear = years[0];
|
|
const prevYear = years[1];
|
|
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') {
|
|
const latestYear = years[0];
|
|
const prevYear = years[1];
|
|
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;
|
|
});
|
|
});
|
|
}
|
|
|
|
// 1b. Filter (New Excel Column Filters for Metrics)
|
|
(Object.entries(columnFilters) as [string, ColumnFilterCondition][]).forEach(([key, condition]) => {
|
|
if (!condition) return;
|
|
if (!key.startsWith('total_')) return; // Metric filters in this view start with 'total_'
|
|
|
|
const isSellOut = key.includes('_sellOut_');
|
|
const year = key.split('_')[2];
|
|
|
|
if (condition.selectedValues && condition.selectedValues.length > 0) {
|
|
result = result.filter(row => {
|
|
const val = isSellOut ? row.totalsByYear[year]?.sellOut : row.totalsByYear[year]?.units;
|
|
return condition.selectedValues?.includes(String(val || 0));
|
|
});
|
|
}
|
|
|
|
if (condition.textFilter) {
|
|
const { operator, value } = condition.textFilter;
|
|
const filterNum = parseFloat(value);
|
|
if (isNaN(filterNum)) return;
|
|
|
|
result = result.filter(row => {
|
|
const rowVal = (isSellOut ? row.totalsByYear[year]?.sellOut : row.totalsByYear[year]?.units) || 0;
|
|
switch (operator) {
|
|
case 'equals': return rowVal === filterNum;
|
|
case 'notEquals': return rowVal !== filterNum;
|
|
case 'gt': return rowVal > filterNum;
|
|
case 'lt': return rowVal < filterNum;
|
|
case 'gte': return rowVal >= filterNum;
|
|
case 'lte': return rowVal <= filterNum;
|
|
// Text-like operators on numeric values (convert to string)
|
|
case 'contains': return String(rowVal).includes(value);
|
|
case 'startsWith': return String(rowVal).startsWith(value);
|
|
case 'endsWith': return String(rowVal).endsWith(value);
|
|
default: return true;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// 2. Sort
|
|
if (sortConfig.key) {
|
|
// Create a copy to avoid mutating the original array and ensure React detects the change
|
|
result = [...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;
|
|
}
|
|
}
|
|
// Handle sorting by Growth Percentages (growth_sellOut_2024_2023)
|
|
else if ((sortConfig.key as string).startsWith('growth_')) {
|
|
const parts = (sortConfig.key as string).split('_');
|
|
// parts[1] = metric (sellOut/units), parts[2] = current year, parts[3] = previous year
|
|
if (parts.length === 4) {
|
|
const metric = parts[1] as 'sellOut' | 'units';
|
|
const currentYear = parts[2];
|
|
const prevYear = parts[3];
|
|
|
|
const currA = a.totalsByYear[currentYear]?.[metric] || 0;
|
|
const prevA = a.totalsByYear[prevYear]?.[metric] || 0;
|
|
valA = prevA !== 0 ? ((currA - prevA) / prevA) * 100 : (currA > 0 ? 100 : 0);
|
|
|
|
const currB = b.totalsByYear[currentYear]?.[metric] || 0;
|
|
const prevB = b.totalsByYear[prevYear]?.[metric] || 0;
|
|
valB = prevB !== 0 ? ((currB - prevB) / prevB) * 100 : (currB > 0 ? 100 : 0);
|
|
}
|
|
}
|
|
|
|
// Handle sorting by Ads Metrics (adSpend_2025, tacos_2025)
|
|
else if ((sortConfig.key as string).includes('_') && !((sortConfig.key as string).startsWith('total_') || (sortConfig.key as string).startsWith('growth_'))) {
|
|
const parts = (sortConfig.key as string).split('_');
|
|
if (parts.length === 2) {
|
|
const metric = parts[0] as 'adSpend' | 'tacos';
|
|
const year = parts[1];
|
|
valA = a.adsByYear?.[year]?.[metric] || 0;
|
|
valB = b.adsByYear?.[year]?.[metric] || 0;
|
|
}
|
|
}
|
|
|
|
if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1;
|
|
if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
} else if (showOnlyTop50 && top50Ranking) {
|
|
// Default sort by Rank when Top 50 is active
|
|
const rankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
|
|
result = [...result].sort((a, b) => {
|
|
const rankA = a.asin ? rankMap.get(a.asin.toUpperCase()) || 999 : 999;
|
|
const rankB = b.asin ? rankMap.get(b.asin.toUpperCase()) || 999 : 999;
|
|
return rankA - rankB;
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}, [pivotRows, rowFilters, sortConfig, years, searchTerm, showOnlyTop50, top50Ranking, top50Mode]);
|
|
|
|
// Calculate aggregated totals for the header row
|
|
const filteredTotalsByYear = useMemo(() => {
|
|
const result: { [year: string]: { sellOut: number, units: number, sellOutGrowth?: number, unitsGrowth?: number } } = {};
|
|
|
|
years.forEach((year, index) => {
|
|
const totals = processedRows.reduce(
|
|
(acc, row) => {
|
|
const data = row.totalsByYear[year];
|
|
if (data) {
|
|
acc.sellOut += data.sellOut;
|
|
acc.units += data.units;
|
|
}
|
|
const ads = row.adsByYear?.[year];
|
|
if (ads) {
|
|
acc.adSpend += ads.adSpend;
|
|
acc.attributedSales += ads.attributedSales;
|
|
}
|
|
return acc;
|
|
},
|
|
{ sellOut: 0, units: 0, adSpend: 0, attributedSales: 0 }
|
|
);
|
|
|
|
result[year] = totals;
|
|
|
|
// Calculate YoY growth if previous year exists
|
|
const prevYear = years[index + 1];
|
|
if (prevYear) {
|
|
const prevYearTotals = processedRows.reduce(
|
|
(acc, row) => {
|
|
const data = row.totalsByYear[prevYear];
|
|
if (data) {
|
|
acc.sellOut += data.sellOut;
|
|
acc.units += data.units;
|
|
}
|
|
const ads = row.adsByYear?.[prevYear];
|
|
if (ads) {
|
|
acc.adSpend += ads.adSpend;
|
|
}
|
|
return acc;
|
|
},
|
|
{ sellOut: 0, units: 0, adSpend: 0 }
|
|
);
|
|
|
|
if (prevYearTotals.sellOut > 0) {
|
|
result[year].sellOutGrowth = ((totals.sellOut - prevYearTotals.sellOut) / prevYearTotals.sellOut) * 100;
|
|
} else if (totals.sellOut > 0) {
|
|
result[year].sellOutGrowth = 100;
|
|
}
|
|
|
|
if (prevYearTotals.units > 0) {
|
|
result[year].unitsGrowth = ((totals.units - prevYearTotals.units) / prevYearTotals.units) * 100;
|
|
} else if (totals.units > 0) {
|
|
result[year].unitsGrowth = 100;
|
|
}
|
|
|
|
if (prevYearTotals.adSpend > 0) {
|
|
(result[year] as any).adSpendGrowth = ((totals.adSpend - prevYearTotals.adSpend) / prevYearTotals.adSpend) * 100;
|
|
} else if (totals.adSpend > 0) {
|
|
(result[year] as any).adSpendGrowth = 100;
|
|
}
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}, [processedRows, 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 opacity-20 group-hover:opacity-100 transition-opacity ml-0.5">↕</span>;
|
|
return <span className="text-primary ml-0.5">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>;
|
|
};
|
|
|
|
const handleExport = () => {
|
|
generateXLSX(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, searchTerm]);
|
|
|
|
return (
|
|
<div className="space-y-4 md:space-y-6 max-w-[98vw] md:max-w-[95vw] mx-auto animate-fade-in px-1 md:px-0 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}
|
|
/>
|
|
),
|
|
// Ad Spend line (only when ads data exists and showAdsMetrics is on)
|
|
showAdsMetrics && adsSummary && (
|
|
<Line
|
|
key={`${year}_adSpend`}
|
|
type="monotone"
|
|
dataKey={`${year}_adSpend`}
|
|
name={`Ad Spend ${year}`}
|
|
stroke="#d946ef"
|
|
strokeWidth={2}
|
|
strokeDasharray="3 3"
|
|
dot={false}
|
|
/>
|
|
),
|
|
// Attributed Sales line
|
|
showAttributedSales && adsSummary && (
|
|
<Line
|
|
key={`${year}_attrSales`}
|
|
type="monotone"
|
|
dataKey={`${year}_attributedSales`}
|
|
name={`Attr. Sales ${year}`}
|
|
stroke="#fbbf24"
|
|
strokeWidth={2}
|
|
dot={false}
|
|
/>
|
|
),
|
|
// YTD Sell Out line (cumulative)
|
|
showYTD && visibleMetrics.includes('sellOut') && (
|
|
<Line
|
|
key={`${year}_ytdSellOut`}
|
|
type="monotone"
|
|
dataKey={`${year}_ytdSellOut`}
|
|
name={`YTD Sell Out ${year}`}
|
|
stroke={CHART_COLORS[idx % CHART_COLORS.length]}
|
|
strokeWidth={3}
|
|
strokeOpacity={0.6}
|
|
dot={false}
|
|
/>
|
|
),
|
|
// YTD Units line (cumulative)
|
|
showYTD && visibleMetrics.includes('units') && (
|
|
<Line
|
|
key={`${year}_ytdUnits`}
|
|
type="monotone"
|
|
dataKey={`${year}_ytdUnits`}
|
|
name={`YTD Units ${year}`}
|
|
stroke={CHART_COLORS[idx % CHART_COLORS.length]}
|
|
strokeWidth={3}
|
|
strokeDasharray="10 5"
|
|
strokeOpacity={0.6}
|
|
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 flex flex-col">
|
|
|
|
{/* Toolbar */}
|
|
<div className="p-4 border-b border-border bg-slate-900/50 flex flex-col xl:flex-row gap-4 justify-between items-start xl:items-center rounded-t-xl">
|
|
|
|
<div className="flex flex-wrap items-center gap-3 w-full xl:w-auto">
|
|
{/* Search Bar */}
|
|
<div className="flex items-center gap-2 mr-2">
|
|
<div className="relative w-full sm:w-64 lg:w-80">
|
|
{showBulkSearch ? (
|
|
<textarea
|
|
placeholder="Bulk Search (ASINs/SKUs)..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full bg-slate-950 border border-white/10 rounded-xl px-4 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all min-h-[40px] max-h-[120px] font-mono leading-tight"
|
|
rows={1}
|
|
/>
|
|
) : (
|
|
<>
|
|
<input
|
|
type="text"
|
|
placeholder="Search SKU, ASIN..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full bg-slate-950 border border-white/10 rounded-xl px-4 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all pl-10"
|
|
/>
|
|
<svg className="absolute left-3 top-2.5 w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
</>
|
|
)}
|
|
{searchTerm && (
|
|
<button
|
|
onClick={() => setSearchTerm('')}
|
|
className={`absolute right-3 ${showBulkSearch ? 'top-2' : 'top-2.5'} text-slate-500 hover:text-white`}
|
|
>
|
|
<CloseIcon />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => setShowBulkSearch(!showBulkSearch)}
|
|
className={`p-2 rounded-lg border transition-all flex items-center justify-center ${showBulkSearch
|
|
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400 shadow-[0_0_10px_rgba(99,102,241,0.2)]'
|
|
: 'bg-slate-800/50 border-white/5 text-slate-400 hover:text-slate-200 hover:border-white/10'
|
|
}`}
|
|
title={showBulkSearch ? "Switch to Single Search" : "Enable Bulk Search"}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="h-6 w-px bg-white/10 hidden xl:block mx-1" />
|
|
|
|
{/* Dimensions Selector */}
|
|
<div className="relative z-30" ref={dimensionRef}>
|
|
<button
|
|
onClick={() => setShowDimensionMenu(!showDimensionMenu)}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-semibold transition-all border shadow-sm ${showDimensionMenu ? 'bg-indigo-600 text-white border-indigo-500 shadow-indigo-500/20' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700 hover:border-slate-600'}`}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 4.5v15m6-15v15m-10.5-15h15a2.25 2.25 0 0 1 2.25 2.25v13.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75A2.25 2.25 0 0 1 4.5 4.5Z" />
|
|
</svg>
|
|
<span>Columns:</span>
|
|
<span className={showDimensionMenu ? 'text-indigo-100' : 'text-indigo-400'}>{effectiveDimensions.length} Columns</span>
|
|
<svg className={`w-3.5 h-3.5 transition-transform duration-200 ${showDimensionMenu ? 'rotate-180 text-white' : 'text-slate-500'}`} fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /></svg>
|
|
</button>
|
|
{showDimensionMenu && (
|
|
<div className="absolute top-full left-0 mt-2 w-64 bg-slate-900 border border-slate-700 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.5)] p-3 animate-fade-in z-[100] ring-1 ring-white/10">
|
|
<div className="flex justify-between items-center mb-3 px-1">
|
|
<span className="text-[11px] font-black text-slate-500 uppercase tracking-[0.2em]">Select Columns</span>
|
|
<button
|
|
onClick={() => setSelectedDimensions([])}
|
|
className="text-[10px] font-bold text-indigo-400 hover:text-indigo-300 uppercase underline decoration-indigo-500/30 underline-offset-4"
|
|
>
|
|
Clear All
|
|
</button>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{DIMENSION_OPTIONS.map(dim => (
|
|
<label key={dim.value} className="flex items-center gap-3 p-2.5 hover:bg-white/[0.04] rounded-lg cursor-pointer group transition-all active:scale-[0.98]">
|
|
<div className="relative flex items-center">
|
|
<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="w-4.5 h-4.5 rounded-md border-slate-600 bg-slate-800 text-indigo-500 focus:ring-indigo-500/50 focus:ring-offset-0 transition-all border-2 cursor-pointer"
|
|
/>
|
|
</div>
|
|
<span className={`text-sm font-semibold transition-colors ${selectedDimensions.includes(dim.value) ? 'text-white' : 'text-slate-400 group-hover:text-slate-300'}`}>
|
|
{dim.label}
|
|
</span>
|
|
{selectedDimensions.includes(dim.value) && (
|
|
<div className="ml-auto w-1 h-4 bg-indigo-500 rounded-full animate-pulse-slow"></div>
|
|
)}
|
|
</label>
|
|
))}
|
|
</div>
|
|
<div className="mt-3 pt-3 border-t border-slate-800 px-1">
|
|
<p className="text-[10px] text-slate-500 leading-relaxed italic">
|
|
The table will automatically refresh when you toggle these options.
|
|
</p>
|
|
</div>
|
|
</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>
|
|
|
|
{/* Ads Toggle - Only show when ads data is loaded */}
|
|
{adsSummary && (
|
|
<button
|
|
onClick={() => setShowAdsMetrics(!showAdsMetrics)}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${showAdsMetrics ? 'bg-fuchsia-600/20 text-fuchsia-400 border-fuchsia-500/50' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'}`}
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 0h.008v.008H18V10.5Zm-12 0h.008v.008H6V10.5Z" />
|
|
</svg>
|
|
<span className="hidden sm:inline">{showAdsMetrics ? 'Hide Ads' : 'Show Ads'}</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Top 50 Toggle */}
|
|
{top50Ranking && (top50Ranking.eu.size > 0 || top50Ranking.uk.size > 0) && (
|
|
<button
|
|
onClick={() => setShowOnlyTop50(!showOnlyTop50)}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-bold transition-all border shadow-sm ${showOnlyTop50 ? 'bg-gradient-to-r from-amber-600 to-orange-600 text-white border-amber-500 shadow-amber-500/20' : 'bg-slate-800 text-slate-400 border-slate-700 hover:text-amber-400 hover:bg-slate-700'}`}
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4">
|
|
<path fillRule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z" clipRule="evenodd" />
|
|
</svg>
|
|
<span className="hidden sm:inline">Top 50 ({top50Mode.toUpperCase()})</span>
|
|
<span className="sm:hidden">Top 50</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Attributed Sales Toggle - Only show when ads data is loaded and ads metrics are shown */}
|
|
{adsSummary && (
|
|
<button
|
|
onClick={() => setShowAttributedSales(!showAttributedSales)}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${showAttributedSales ? 'bg-amber-600/20 text-amber-400 border-amber-500/50' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'}`}
|
|
>
|
|
<TrendingIcon className="w-4 h-4" />
|
|
<span className="hidden sm:inline">{showAttributedSales ? 'Hide Attr. Sales' : 'Show Attr. Sales'}</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* YTD (Year-To-Date) Toggle */}
|
|
<button
|
|
onClick={() => setShowYTD(!showYTD)}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${showYTD ? 'bg-cyan-600/20 text-cyan-400 border-cyan-500/50' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'}`}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
|
|
</svg>
|
|
<span className="hidden sm:inline">{showYTD ? 'Hide YTD' : 'Show YTD'}</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>
|
|
)}
|
|
</div>
|
|
|
|
|
|
{/* Ads Performance Summary - Shows when ads data is loaded */}
|
|
{adsSummary && showAdsMetrics && (
|
|
<div className="bg-gradient-to-r from-fuchsia-900/20 to-indigo-900/20 border-y border-fuchsia-500/30 px-4 py-3">
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-2 h-2 rounded-full bg-fuchsia-400 animate-pulse"></span>
|
|
<span className="text-xs font-bold text-fuchsia-300 uppercase tracking-wide">Advertising Data:</span>
|
|
<span className="text-xs text-slate-400">{adsSummary.recordCount.toLocaleString('de-DE')} records</span>
|
|
</div>
|
|
<div className="flex items-center gap-4 flex-wrap">
|
|
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
|
|
<span className="text-xs text-slate-400 mr-2">Ad Spend:</span>
|
|
<span className="text-sm font-bold text-fuchsia-400">€{adsSummary.totalSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}</span>
|
|
</div>
|
|
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
|
|
<span className="text-xs text-slate-400 mr-2">Attr. Sales:</span>
|
|
<span className="text-sm font-bold text-emerald-400">€{adsSummary.attributedSales.toLocaleString('de-DE', { maximumFractionDigits: 0 })}</span>
|
|
</div>
|
|
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
|
|
<span className="text-xs text-slate-400 mr-2">ACOS:</span>
|
|
<span className={`text-sm font-bold ${adsSummary.acos <= 30 ? 'text-emerald-400' : adsSummary.acos <= 50 ? 'text-amber-400' : 'text-red-400'}`}>
|
|
{adsSummary.acos.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
|
|
<span className="text-xs text-slate-400 mr-2">ROAS:</span>
|
|
<span className={`text-sm font-bold ${adsSummary.roas >= 3 ? 'text-emerald-400' : adsSummary.roas >= 2 ? 'text-amber-400' : 'text-red-400'}`}>
|
|
{adsSummary.roas.toFixed(2)}x
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Data Table */}
|
|
<div className="overflow-x-auto min-h-[400px] scroll-touch">
|
|
<table className="w-full text-left text-xs md: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 border-b border-white/10">
|
|
{/* NEW: Filtered Totals Header Row */}
|
|
<tr className="bg-indigo-950/20 border-b border-indigo-500/30">
|
|
<th colSpan={effectiveDimensions.length} className="px-4 py-3 bg-indigo-900/10">
|
|
<div className="flex items-center gap-2">
|
|
<svg className="w-4 h-4 text-indigo-400" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
|
</svg>
|
|
<span className="text-[10px] font-black text-indigo-300 tracking-widest uppercase">Filtered Totals</span>
|
|
</div>
|
|
</th>
|
|
{years.map((year, index) => {
|
|
const totals = filteredTotalsByYear[year];
|
|
const prevYear = years[index + 1];
|
|
return (
|
|
<React.Fragment key={`totals-${year}`}>
|
|
<th className="px-4 py-3 text-right border-x border-indigo-500/10 bg-indigo-900/5">
|
|
<div className="flex flex-col items-end">
|
|
<span className="text-emerald-400 font-bold text-sm">
|
|
€{totals.sellOut.toLocaleString('de-DE', { maximumFractionDigits: 0 })}
|
|
</span>
|
|
{totals.sellOutGrowth !== undefined && (
|
|
<span className={`text-[9px] font-black ${totals.sellOutGrowth >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
|
{totals.sellOutGrowth >= 0 ? '↑' : '↓'} {Math.abs(totals.sellOutGrowth).toFixed(1)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
</th>
|
|
<th className="px-4 py-3 text-right border-r border-indigo-500/10 bg-indigo-900/5">
|
|
<div className="flex flex-col items-end">
|
|
<span className="text-blue-400 font-bold text-sm">
|
|
{totals.units.toLocaleString('de-DE')}
|
|
</span>
|
|
{totals.unitsGrowth !== undefined && (
|
|
<span className={`text-[9px] font-black ${totals.unitsGrowth >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
|
{totals.unitsGrowth >= 0 ? '↑' : '↓'} {Math.abs(totals.unitsGrowth).toFixed(1)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
</th>
|
|
{prevYear && <th colSpan={2} className="bg-indigo-900/5 border-r border-indigo-500/10"></th>}
|
|
{showAdsMetrics && adsSummary && (
|
|
<>
|
|
<th className="px-3 py-3 text-right bg-fuchsia-950/20 border-r border-fuchsia-500/10">
|
|
<div className="flex flex-col items-end">
|
|
<span className="text-fuchsia-400 font-bold text-[13px]">
|
|
€{totals.adSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}
|
|
</span>
|
|
{(totals as any).adSpendGrowth !== undefined && (
|
|
<span className={`text-[9px] font-black ${(totals as any).adSpendGrowth <= 0 ? 'text-emerald-400' : 'text-amber-400'}`}>
|
|
{(totals as any).adSpendGrowth >= 0 ? '↑' : '↓'} {Math.abs((totals as any).adSpendGrowth).toFixed(1)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
</th>
|
|
<th className="px-3 py-3 text-right bg-fuchsia-950/20 border-r border-fuchsia-500/10">
|
|
<div className="flex flex-col items-end">
|
|
<span className="text-fuchsia-300 font-bold text-[13px]">
|
|
{totals.sellOut > 0 ? ((totals.adSpend / totals.sellOut) * 100).toFixed(2) : '0.00'}%
|
|
</span>
|
|
<span className="text-[9px] font-black text-slate-500 uppercase">TACOS</span>
|
|
</div>
|
|
</th>
|
|
</>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</tr>
|
|
<tr>
|
|
{/* Dynamic Dimension Headers */}
|
|
{effectiveDimensions.map(dim => {
|
|
const label = DIMENSION_OPTIONS.find(d => d.value === dim)?.label || dim;
|
|
// Extract unique values for this dimension from ALL pivot rows (pre-filter)
|
|
const uniqueValues = Array.from(new Set(pivotRows.map(r => String((r as any)[dim] || ''))))
|
|
.filter(Boolean)
|
|
.sort();
|
|
|
|
return (
|
|
<th
|
|
key={dim}
|
|
className={`px-2.5 py-3 border-b border-border group bg-slate-950 ${dim === 'title' ? 'min-w-[400px]' : 'min-w-[140px]'}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-1">
|
|
<div
|
|
className="flex items-center cursor-pointer hover:text-white text-[11px] font-bold uppercase tracking-wide truncate"
|
|
onClick={() => requestSort(dim)}
|
|
>
|
|
{label} {getSortIcon(dim)}
|
|
</div>
|
|
<ExcelFilter
|
|
columnKey={dim}
|
|
title={label}
|
|
buttonClassName={`p-1 rounded hover:bg-white/10 transition-all ${columnFilters[dim] ? 'text-indigo-400 bg-indigo-500/10' : 'text-slate-500'}`}
|
|
uniqueValues={uniqueValues}
|
|
currentFilter={columnFilters[dim]}
|
|
onFilterChange={handleColumnFilterChange}
|
|
/>
|
|
</div>
|
|
</th>
|
|
);
|
|
})}
|
|
|
|
{/* Data Columns: Year Groups */}
|
|
{years.map((year, index) => {
|
|
const prevYear = years[index + 1]; // Since years are sorted desc: 2025, 2024... next index is prev year
|
|
return (
|
|
<React.Fragment key={year}>
|
|
{/* Sell Out & Units */}
|
|
<th className="px-2 py-2.5 border-b border-border bg-slate-950 min-w-[105px]">
|
|
<div className="flex items-center justify-end gap-1 leading-none">
|
|
<ExcelFilter
|
|
columnKey={`total_sellOut_${year}`}
|
|
title={`Sell Out ${year}`}
|
|
buttonClassName={`w-4 h-4 flex items-center justify-center rounded hover:bg-white/10 transition-all ${columnFilters[`total_sellOut_${year}`] ? 'text-indigo-400 bg-indigo-500/10' : 'text-slate-600'}`}
|
|
uniqueValues={Array.from(new Set(pivotRows.map(r => String(r.totalsByYear[year]?.sellOut || 0)))).sort((a, b) => parseFloat(a as string) - parseFloat(b as string))}
|
|
currentFilter={columnFilters[`total_sellOut_${year}`]}
|
|
onFilterChange={handleColumnFilterChange}
|
|
icon={<FunnelIcon className="w-2.5 h-2.5" />}
|
|
/>
|
|
<div
|
|
className="flex items-center cursor-pointer hover:text-white text-[10px] font-black uppercase tracking-tight"
|
|
onClick={() => requestSort(`total_sellOut_${year}`)}
|
|
>
|
|
{year} SO {getSortIcon(`total_sellOut_${year}`)}
|
|
</div>
|
|
</div>
|
|
</th>
|
|
<th className="px-2 py-2.5 border-b border-border bg-slate-950 min-w-[85px]">
|
|
<div className="flex items-center justify-end gap-1 leading-none">
|
|
<ExcelFilter
|
|
columnKey={`total_units_${year}`}
|
|
title={`Units ${year}`}
|
|
buttonClassName={`w-4 h-4 flex items-center justify-center rounded hover:bg-white/10 transition-all ${columnFilters[`total_units_${year}`] ? 'text-indigo-400 bg-indigo-500/10' : 'text-slate-600'}`}
|
|
uniqueValues={Array.from(new Set(pivotRows.map(r => String(r.totalsByYear[year]?.units || 0)))).sort((a, b) => parseFloat(a as string) - parseFloat(b as string))}
|
|
currentFilter={columnFilters[`total_units_${year}`]}
|
|
onFilterChange={handleColumnFilterChange}
|
|
icon={<FunnelIcon className="w-2.5 h-2.5" />}
|
|
/>
|
|
<div
|
|
className="flex items-center cursor-pointer hover:text-white text-[10px] font-black uppercase tracking-tight"
|
|
onClick={() => requestSort(`total_units_${year}`)}
|
|
>
|
|
{year} U {getSortIcon(`total_units_${year}`)}
|
|
</div>
|
|
</div>
|
|
</th>
|
|
|
|
{/* Growth Columns (if prev year exists) */}
|
|
{prevYear && (
|
|
<>
|
|
<th
|
|
className="px-2 py-3 border-b border-border text-center bg-slate-950/50 min-w-[80px] cursor-pointer hover:text-white"
|
|
onClick={() => requestSort(`growth_sellOut_${year}_${prevYear}`)}
|
|
>
|
|
<div className="flex items-center justify-center text-[10px] text-slate-500 uppercase">
|
|
S.O. Δ% {getSortIcon(`growth_sellOut_${year}_${prevYear}`)}
|
|
</div>
|
|
</th>
|
|
<th
|
|
className="px-2 py-3 border-b border-border text-center bg-slate-950/50 min-w-[80px] cursor-pointer hover:text-white"
|
|
onClick={() => requestSort(`growth_units_${year}_${prevYear}`)}
|
|
>
|
|
<div className="flex items-center justify-center text-[10px] text-slate-500 uppercase">
|
|
Units Δ% {getSortIcon(`growth_units_${year}_${prevYear}`)}
|
|
</div>
|
|
</th>
|
|
</>
|
|
)}
|
|
|
|
{/* Ads Columns - Only show when ads data exists and toggle is on */}
|
|
{showAdsMetrics && adsSummary && (
|
|
<>
|
|
<th
|
|
className="px-3 py-3 border-b border-fuchsia-500/30 text-right bg-fuchsia-950/30 min-w-[90px] cursor-pointer hover:bg-fuchsia-900/40 transition-colors"
|
|
onClick={() => requestSort(`adSpend_${year}`)}
|
|
>
|
|
<div className="flex items-center justify-end text-fuchsia-400 text-[10px] uppercase">
|
|
Ad Spend {year} {getSortIcon(`adSpend_${year}`)}
|
|
</div>
|
|
</th>
|
|
<th
|
|
className="px-3 py-3 border-b border-fuchsia-500/30 text-right bg-fuchsia-950/30 min-w-[70px] cursor-pointer hover:bg-fuchsia-900/40 transition-colors"
|
|
onClick={() => requestSort(`tacos_${year}`)}
|
|
>
|
|
<div className="flex items-center justify-end text-fuchsia-400 text-[10px] uppercase">
|
|
TACOS {year} {getSortIcon(`tacos_${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 relative hover:z-50">
|
|
{effectiveDimensions.map(dim => (
|
|
<td key={dim} className="px-2.5 py-3 font-medium text-slate-200 break-words max-w-xs">
|
|
{dim === 'title'
|
|
? <div className="flex items-start gap-2 max-w-sm relative group/title">
|
|
<span className="line-clamp-2 cursor-help decoration-dotted underline underline-offset-2 decoration-slate-600">
|
|
{row.title || '-'}
|
|
</span>
|
|
<div className="absolute left-0 top-full mt-2 w-max max-w-md bg-slate-900 border border-slate-700 p-3 rounded-lg shadow-xl text-xs text-white z-[60] opacity-0 group-hover/title:opacity-100 pointer-events-none transition-opacity">
|
|
{row.title}
|
|
</div>
|
|
{stockMap && (
|
|
<>
|
|
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
|
|
<VendorStockBadge asin={row.asin} vendorStockMap={vendorStockMap} mode={top50Mode} avgWeeklySales={velocityMap?.get(row.asin.trim().toUpperCase())} />
|
|
</>
|
|
)}
|
|
</div>
|
|
: dim === 'asin' || dim === 'sku'
|
|
? <div className="flex items-center gap-2 min-w-max">
|
|
{(() => {
|
|
const asin = (row.asin || '').trim().toUpperCase();
|
|
if (asin && top50Ranking) {
|
|
if (top50Mode === 'eu') {
|
|
const rank = top50Ranking.eu.get(asin);
|
|
if (rank) return <Top50Badge rank={rank} label="EU" theme="indigo" />;
|
|
} else {
|
|
const rank = top50Ranking.uk.get(asin);
|
|
if (rank) return <Top50Badge rank={rank} label="UK" theme="blue" />;
|
|
}
|
|
}
|
|
return null;
|
|
})()}
|
|
<span className="whitespace-nowrap">{(row[dim as keyof PivotRow] as string) || '-'}</span>
|
|
<div className="shrink-0">
|
|
<BuyBoxWarningBadge asin={row.asin} buyBoxLostMap={buyBoxLostMap} />
|
|
</div>
|
|
</div>
|
|
: (row[dim as keyof PivotRow] as string) || '-'
|
|
}
|
|
</td>
|
|
))}
|
|
|
|
{/* Metric Values */}
|
|
{years.map((year, index) => {
|
|
const data = row.totalsByYear[year];
|
|
const prevYear = years[index + 1];
|
|
const prevData = prevYear ? row.totalsByYear[prevYear] : null;
|
|
|
|
// Calculate Growth
|
|
let sellOutGrowth = null;
|
|
let unitsGrowth = null;
|
|
|
|
if (prevYear) {
|
|
const currSO = data?.sellOut || 0;
|
|
const prevSO = prevData?.sellOut || 0;
|
|
if (prevSO !== 0) sellOutGrowth = ((currSO - prevSO) / prevSO) * 100;
|
|
else if (currSO > 0) sellOutGrowth = 100; // New entry
|
|
|
|
const currUnits = data?.units || 0;
|
|
const prevUnits = prevData?.units || 0;
|
|
if (prevUnits !== 0) unitsGrowth = ((currUnits - prevUnits) / prevUnits) * 100;
|
|
else if (currUnits > 0) unitsGrowth = 100;
|
|
}
|
|
|
|
return (
|
|
<React.Fragment key={year}>
|
|
<td className="px-2 py-3 text-right font-medium text-white group-hover:text-emerald-400 transition-colors">
|
|
{data ? `€${data.sellOut.toLocaleString('de-DE', { maximumFractionDigits: 0 })}` : '-'}
|
|
</td>
|
|
<td className="px-2 py-3 text-right text-slate-400">
|
|
{data ? data.units.toLocaleString('de-DE') : '-'}
|
|
</td>
|
|
|
|
{/* Growth Cells */}
|
|
{prevYear && (
|
|
<>
|
|
<td className={`px-2 py-3 text-center font-bold text-xs ${sellOutGrowth !== null ? (sellOutGrowth >= 0 ? 'text-emerald-500' : 'text-red-500') : 'text-slate-600'}`}>
|
|
{sellOutGrowth !== null ? (
|
|
<span className="flex items-center justify-center gap-1">
|
|
{sellOutGrowth >= 0 ? '↑' : '↓'} {Math.abs(sellOutGrowth).toFixed(0)}%
|
|
</span>
|
|
) : '-'}
|
|
</td>
|
|
<td className={`px-2 py-3 text-center font-bold text-xs ${unitsGrowth !== null ? (unitsGrowth >= 0 ? 'text-emerald-500' : 'text-red-500') : 'text-slate-600'}`}>
|
|
{unitsGrowth !== null ? (
|
|
<span className="flex items-center justify-center gap-1">
|
|
{unitsGrowth >= 0 ? '↑' : '↓'} {Math.abs(unitsGrowth).toFixed(0)}%
|
|
</span>
|
|
) : '-'}
|
|
</td>
|
|
</>
|
|
)}
|
|
|
|
{/* Ads Data Cells */}
|
|
{showAdsMetrics && adsSummary && (() => {
|
|
const adsYearData = row.adsByYear?.[year];
|
|
return (
|
|
<>
|
|
<td className="px-3 py-3 text-right text-fuchsia-400 font-medium bg-fuchsia-950/10">
|
|
{adsYearData ? `€${adsYearData.adSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}` : '-'}
|
|
</td>
|
|
<td className={`px-3 py-3 text-right font-bold text-xs bg-fuchsia-950/10 ${adsYearData
|
|
? (adsYearData.tacos <= 10 ? 'text-emerald-400' : adsYearData.tacos <= 20 ? 'text-amber-400' : 'text-red-400')
|
|
: 'text-slate-600'
|
|
}`}>
|
|
{adsYearData ? `${adsYearData.tacos.toFixed(1)}%` : '-'}
|
|
</td>
|
|
</>
|
|
);
|
|
})()}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
|
|
{paginatedRows.length === 0 && (
|
|
<tr>
|
|
<td colSpan={effectiveDimensions.length + (years.length * 2) + (showAdsMetrics && adsSummary ? years.length * 2 : 0)} className="px-6 py-12 text-center text-slate-500 italic">
|
|
No data matches your filters.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination and Info */}
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 bg-slate-900/50 p-4 rounded-xl border border-white/5 shadow-lg">
|
|
<div className="flex flex-wrap items-center gap-4 w-full md:w-auto">
|
|
<button
|
|
onClick={handleExport}
|
|
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl text-sm font-black uppercase tracking-widest transition-all shadow-lg active:scale-95 border border-emerald-500/50"
|
|
>
|
|
<DownloadIcon />
|
|
<span>Export Excel</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4 w-full md:w-auto justify-between md:justify-end">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
|
disabled={currentPage === 1}
|
|
className="p-2 bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:hover:bg-slate-800 rounded-lg transition-colors border border-white/5"
|
|
>
|
|
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
|
</button>
|
|
<div className="bg-slate-950 border border-white/10 px-4 py-2 rounded-xl">
|
|
<span className="text-xs font-black text-white">{currentPage} / {totalPages || 1}</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
|
disabled={currentPage >= totalPages}
|
|
className="p-2 bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:hover:bg-slate-800 rounded-lg transition-colors border border-white/5"
|
|
>
|
|
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="hidden sm:block text-xs font-bold text-slate-500 uppercase tracking-widest">
|
|
{processedRows.length} Records found
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DataGrid; |