mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 14:25:23 +02:00
The badge was rendered outside the stockMap conditional, causing it to appear independently of the Amazon stock badge. Now it's grouped inside the block, always appearing immediately next to VendorStockBadge. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1544 lines
94 KiB
TypeScript
1544 lines
94 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())} />
|
|
<BuyBoxWarningBadge asin={row.asin} buyBoxLostMap={buyBoxLostMap} />
|
|
</>
|
|
)}
|
|
</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>
|
|
: (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; |