mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 11:45:23 +02:00
New Features: - Experiments tab with full CRUD operations - Create experiments by ASIN or product line groups - Track pricing, advertising, content, and promotion experiments - Performance analytics with baseline vs experiment comparison - Visual experiment badges in Weekly Sales grid - Experiment detail view with metrics and learnings Technical Changes: - Add Supabase experiments table migration - New services/experiments.ts for CRUD + calculations - New components: ExperimentsView, ExperimentDetail, ExperimentForm, ExperimentBadge - Integrate experiment indicators in WeeklyGrid - Add navigation tab (desktop + mobile) - Type definitions in types.ts Usage: 1. Run SQL migration in Supabase 2. Navigate to Experiments tab 3. Create experiments with ASINs, dates, hypothesis 4. View performance lift after completion 5. See active experiments marked in Weekly Sales Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1066 lines
60 KiB
TypeScript
1066 lines
60 KiB
TypeScript
import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
|
import * as XLSX from 'xlsx';
|
|
import { CombinedKPIs, ColumnFilterCondition, SalesRecord } from '../types';
|
|
import { pivotWeeklySalesData, WeeklyPivotRow, PAN_EU_COUNTRIES, checkNumericConditions, filterData } from '../services/dataProcessor';
|
|
import { StockBadge } from './StockBadge';
|
|
import { InColumnStockFilter } from './InColumnStockFilter';
|
|
import { NumericColumnFilter, NumericFilterConfig, passesNumericFilter } from './NumericColumnFilter';
|
|
import { Top50Badge } from './Top50Badge';
|
|
import { VendorStockBadge } from './VendorStockBadge';
|
|
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
|
|
import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons';
|
|
import { ExcelFilter } from './ExcelFilter';
|
|
import ExperimentTracker from './ExperimentTracker';
|
|
import { ExperimentBadge } from './ExperimentBadge';
|
|
import { ActiveExperiment } from '../types';
|
|
|
|
interface WeeklyGridProps {
|
|
data: CombinedKPIs[];
|
|
top50Ranking?: {
|
|
eu: Map<string, number>;
|
|
uk: Map<string, number>;
|
|
};
|
|
onDrillDown?: (sku: string) => void;
|
|
stockMap?: Map<string, number>;
|
|
stockFilter: string[];
|
|
onStockFilterChange: (newFilters: string[]) => void;
|
|
customerFilters: string[];
|
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
|
vendorStockFilter: string[];
|
|
onVendorStockFilterChange: (newFilters: string[]) => void;
|
|
wocFilter: string[];
|
|
onWocFilterChange: (newFilters: string[]) => void;
|
|
velocityMap?: Map<string, number>;
|
|
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
|
|
top50Mode?: 'eu' | 'uk';
|
|
experimentMap?: Map<string, ActiveExperiment[]>;
|
|
onOpenExperiment?: (experimentId: string) => void;
|
|
}
|
|
|
|
type SortConfig = {
|
|
key: string; // weekKey or 'rank'
|
|
direction: 'asc' | 'desc';
|
|
metric: 'units' | 'spend' | 'revenue' | 'rank' | 'gv';
|
|
} | null;
|
|
|
|
const ROWS_PER_PAGE = 50;
|
|
const MAX_VISIBLE_WEEKS = 20; // Limit visible weeks for performance
|
|
|
|
// Debounce hook for search
|
|
const useDebounce = (value: string, delay: number) => {
|
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
useEffect(() => {
|
|
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
|
return () => clearTimeout(handler);
|
|
}, [value, delay]);
|
|
return debouncedValue;
|
|
};
|
|
|
|
// Tooltip component to show comparison details on hover (WoW and YoY)
|
|
const MetricDetailTooltip: React.FC<{
|
|
children: React.ReactNode;
|
|
currentValue: number;
|
|
previousValue: number;
|
|
yoyValue: number;
|
|
currentWeekLabel: string;
|
|
previousWeekLabel: string;
|
|
yoyWeekLabel: string;
|
|
metricName: string;
|
|
metricColor: string;
|
|
formatValue?: (val: number) => string;
|
|
}> = ({ children, currentValue, previousValue, yoyValue, currentWeekLabel, previousWeekLabel, yoyWeekLabel, metricName, metricColor, formatValue }) => {
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
|
|
const wowGrowth = previousValue > 0 ? ((currentValue - previousValue) / previousValue) * 100 : (currentValue > 0 ? 100 : 0);
|
|
const yoyGrowth = yoyValue > 0 ? ((currentValue - yoyValue) / yoyValue) * 100 : null;
|
|
const format = formatValue || ((v: number) => v.toLocaleString('de-DE'));
|
|
const hasYoyData = yoyValue > 0;
|
|
|
|
return (
|
|
<div
|
|
className="relative inline-flex items-center"
|
|
onMouseEnter={() => setIsVisible(true)}
|
|
onMouseLeave={() => setIsVisible(false)}
|
|
>
|
|
<div className="cursor-help">{children}</div>
|
|
{isVisible && (
|
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-72 bg-slate-950 border border-white/20 rounded-xl shadow-2xl z-[200] p-3 pointer-events-none animate-in fade-in zoom-in-95 duration-150">
|
|
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 pb-2 border-b border-white/10">
|
|
📊 {metricName} Comparison
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
{/* Current Week */}
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-[10px] text-slate-500 font-bold">{currentWeekLabel}</span>
|
|
<span className={`text-xs font-black ${metricColor}`}>{format(currentValue)}</span>
|
|
</div>
|
|
|
|
{/* Previous Week */}
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-[10px] text-slate-500 font-bold">{previousWeekLabel}</span>
|
|
<span className="text-xs font-bold text-slate-400">{previousValue > 0 ? format(previousValue) : 'N/A'}</span>
|
|
</div>
|
|
|
|
{/* Same Week Last Year - ALWAYS SHOWN */}
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-[10px] text-slate-500 font-bold">{yoyWeekLabel}</span>
|
|
<span className={`text-xs font-bold ${hasYoyData ? 'text-slate-400' : 'text-slate-600 italic'}`}>
|
|
{hasYoyData ? format(yoyValue) : 'No data'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* WoW Growth */}
|
|
<div className="border-t border-white/10 pt-2 mt-2">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-[10px] text-slate-500 font-bold">vs Previous Week</span>
|
|
{previousValue > 0 ? (
|
|
<span className={`text-xs font-black ${wowGrowth >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
|
{wowGrowth >= 0 ? '▲' : '▼'} {Math.abs(wowGrowth).toFixed(1)}%
|
|
</span>
|
|
) : (
|
|
<span className="text-xs font-bold text-slate-600 italic">N/A</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* YoY Growth - ALWAYS SHOWN */}
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-[10px] text-slate-500 font-bold">vs Same Week Last Year</span>
|
|
{yoyGrowth !== null ? (
|
|
<span className={`text-xs font-black ${yoyGrowth >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
|
{yoyGrowth >= 0 ? '▲' : '▼'} {Math.abs(yoyGrowth).toFixed(1)}%
|
|
</span>
|
|
) : (
|
|
<span className="text-xs font-bold text-slate-600 italic">No data</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Arrow */}
|
|
<div className="absolute bottom-[-6px] left-1/2 -translate-x-1/2 w-3 h-3 bg-slate-950 border-r border-b border-white/20 rotate-45"></div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const WeeklyRow: React.FC<{
|
|
row: WeeklyPivotRow;
|
|
weeks: string[];
|
|
onDrillDown?: (sku: string) => void;
|
|
stockMap?: Map<string, number>;
|
|
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
|
|
top50Mode: 'eu' | 'uk';
|
|
sortConfig: SortConfig;
|
|
renderGrowth: (current: number, previous: number) => React.ReactNode;
|
|
customerFilters: string[];
|
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
|
velocityMap?: Map<string, number>;
|
|
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
|
|
primaryMetric: 'units' | 'revenue';
|
|
}> = React.memo(({ row, weeks, onDrillDown, stockMap, top50Ranking, top50Mode, sortConfig, renderGrowth, customerFilters, vendorStockMap, velocityMap, buyBoxLostMap, primaryMetric }) => {
|
|
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
|
const asin = row.asin.trim().toUpperCase();
|
|
|
|
if (top50Ranking) {
|
|
if (top50Mode === 'eu') {
|
|
const rank = top50Ranking.eu.get(asin);
|
|
if (rank) ranks.push({ rank, label: 'EU', theme: 'indigo' });
|
|
} else {
|
|
const rank = top50Ranking.uk.get(asin);
|
|
if (rank) ranks.push({ rank, label: 'UK', theme: 'blue' });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<tr className="hover:bg-white/[0.02] transition-colors group relative hover:z-50">
|
|
<td className="p-3 py-2 sticky left-0 z-10 bg-slate-900 group-hover:bg-slate-800 border-r border-white/10">
|
|
<div className="flex flex-col">
|
|
<div className="flex items-center gap-2 mb-0.5">
|
|
{ranks.map((r, i) => (
|
|
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
|
|
))}
|
|
<span
|
|
onClick={() => onDrillDown?.(row.sku)}
|
|
className={`text-xs font-black uppercase tracking-tighter truncate max-w-[120px] transition-all
|
|
${onDrillDown ? 'text-indigo-400 cursor-pointer hover:text-indigo-300 hover:underline' : 'text-indigo-400/70'}`}
|
|
title={onDrillDown ? `Click to see Ads detail for ${row.sku}` : ''}
|
|
>
|
|
{row.sku || '-'}
|
|
</span>
|
|
<span className="text-[10px] font-bold text-slate-500 bg-slate-800 px-1.5 py-0.5 rounded border border-white/5">{row.asin}</span>
|
|
</div>
|
|
<div className="flex items-start gap-2 mb-1">
|
|
<span className="text-[11px] text-white/70 truncate w-[190px] leading-tight" title={row.title}>{row.title}</span>
|
|
{stockMap && (
|
|
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
|
|
)}
|
|
<VendorStockBadge
|
|
asin={asin}
|
|
vendorStockMap={vendorStockMap}
|
|
mode={top50Mode}
|
|
avgWeeklySales={velocityMap?.get(asin)}
|
|
/>
|
|
<BuyBoxWarningBadge asin={asin} buyBoxLostMap={buyBoxLostMap} />
|
|
{experimentMap && (
|
|
<ExperimentBadge
|
|
experiments={experimentMap.get(asin) || []}
|
|
onClick={onOpenExperiment}
|
|
/>
|
|
)}
|
|
</div>
|
|
<span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{row.line}</span>
|
|
</div>
|
|
</td>
|
|
{weeks.map((week, idx) => {
|
|
const val = row.unitsByWeek[week] || 0;
|
|
const prevVal = row.unitsByWeek[weeks[idx + 1]] || 0;
|
|
const spend = row.spendByWeek[week] || 0;
|
|
const prevSpend = row.spendByWeek[weeks[idx + 1]] || 0;
|
|
const revenue = row.revenueByWeek[week] || 0;
|
|
const prevRevenue = row.revenueByWeek[weeks[idx + 1]] || 0;
|
|
const gv = row.gvByWeek?.[week] || 0;
|
|
const prevGv = row.gvByWeek?.[weeks[idx + 1]] || 0;
|
|
|
|
// Parse week as YYYY-WW
|
|
const [year, weekNum] = week.split('-');
|
|
const prevWeekNum = weeks[idx + 1]?.split('-')[1] || '-';
|
|
|
|
// Calculate same week last year key (e.g., 2026-05 -> 2025-05)
|
|
const lastYearWeek = `${parseInt(year) - 1}-${weekNum}`;
|
|
const yoyUnits = row.unitsByWeek[lastYearWeek] || 0;
|
|
const yoySpend = row.spendByWeek[lastYearWeek] || 0;
|
|
const yoyRevenue = row.revenueByWeek[lastYearWeek] || 0;
|
|
const yoyGv = row.gvByWeek?.[lastYearWeek] || 0;
|
|
|
|
|
|
return (
|
|
<td key={week} className={`p-3 py-2 text-center border-r border-white/5 align-middle ${sortConfig?.key === week ? 'bg-white/[0.01]' : ''}`}>
|
|
{/* Primary Metric based on toggle */}
|
|
{primaryMetric === 'units' ? (
|
|
<MetricDetailTooltip
|
|
currentValue={val}
|
|
previousValue={prevVal}
|
|
yoyValue={yoyUnits}
|
|
currentWeekLabel={`Week ${weekNum} (${year})`}
|
|
previousWeekLabel={`Week ${prevWeekNum} (${year})`}
|
|
yoyWeekLabel={`Week ${weekNum} (${parseInt(year) - 1})`}
|
|
metricName="Units"
|
|
metricColor="text-white"
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
<span className={`text-sm font-bold ${val > 0 ? (sortConfig?.key === week && sortConfig.metric === 'units' ? 'text-indigo-400' : 'text-white') : 'text-slate-700'}`}>
|
|
{val > 0 ? val.toLocaleString('de-DE') : '-'}
|
|
</span>
|
|
{val > 0 && renderGrowth(val, prevVal)}
|
|
</div>
|
|
</MetricDetailTooltip>
|
|
) : (
|
|
<MetricDetailTooltip
|
|
currentValue={revenue}
|
|
previousValue={prevRevenue}
|
|
yoyValue={yoyRevenue}
|
|
currentWeekLabel={`Week ${weekNum} (${year})`}
|
|
previousWeekLabel={`Week ${prevWeekNum} (${year})`}
|
|
yoyWeekLabel={`Week ${weekNum} (${parseInt(year) - 1})`}
|
|
metricName="Revenue"
|
|
metricColor="text-white"
|
|
formatValue={(v) => `€${v.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
<span className={`text-sm font-bold ${revenue > 0 ? (sortConfig?.key === week && sortConfig.metric === 'spend' ? 'text-amber-400' : 'text-white') : 'text-slate-700'}`}>
|
|
{revenue > 0 ? `€${revenue.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}` : '-'}
|
|
</span>
|
|
{revenue > 0 && renderGrowth(revenue, prevRevenue)}
|
|
</div>
|
|
</MetricDetailTooltip>
|
|
)}
|
|
|
|
<div className="flex flex-col items-center gap-0.5">
|
|
{/* Ads Spend & GV */}
|
|
<div className="flex flex-col items-center gap-0.5">
|
|
{/* Always show Ads Spend (if > 0) */}
|
|
{spend > 0 && (
|
|
<MetricDetailTooltip
|
|
currentValue={spend}
|
|
previousValue={prevSpend}
|
|
yoyValue={yoySpend}
|
|
currentWeekLabel={`Week ${weekNum} (${year})`}
|
|
previousWeekLabel={`Week ${prevWeekNum} (${year})`}
|
|
yoyWeekLabel={`Week ${weekNum} (${parseInt(year) - 1})`}
|
|
metricName="Ads Spend"
|
|
metricColor="text-rose-400/80"
|
|
formatValue={(v) => `€${v.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`}
|
|
>
|
|
<div className="flex items-center gap-1 text-[10px]">
|
|
<span className="font-bold text-rose-400/70 italic">
|
|
Ads: €{spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
|
|
</span>
|
|
{renderGrowth(spend, prevSpend)}
|
|
</div>
|
|
</MetricDetailTooltip>
|
|
)}
|
|
</div>
|
|
|
|
{/* GV with tooltip - Always small/bottom */}
|
|
{gv > 0 && (
|
|
<MetricDetailTooltip
|
|
currentValue={gv}
|
|
previousValue={prevGv}
|
|
yoyValue={yoyGv}
|
|
currentWeekLabel={`Week ${weekNum} (${year})`}
|
|
previousWeekLabel={`Week ${prevWeekNum} (${year})`}
|
|
yoyWeekLabel={`Week ${weekNum} (${parseInt(year) - 1})`}
|
|
metricName="GV (Glance View)"
|
|
metricColor="text-teal-400"
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
<span className={`text-[9px] font-black tracking-tighter ${sortConfig?.key === week && sortConfig.metric === 'gv' ? 'text-teal-300' : 'text-teal-500/70'}`}>
|
|
GV: {gv.toLocaleString('de-DE')}
|
|
</span>
|
|
{renderGrowth(gv, prevGv)}
|
|
</div>
|
|
</MetricDetailTooltip>
|
|
)}
|
|
</div>
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
});
|
|
|
|
const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
|
data,
|
|
top50Ranking,
|
|
onDrillDown,
|
|
stockMap,
|
|
vendorStockMap,
|
|
stockFilter,
|
|
onStockFilterChange,
|
|
vendorStockFilter,
|
|
onVendorStockFilterChange,
|
|
wocFilter,
|
|
onWocFilterChange,
|
|
customerFilters,
|
|
top50Mode,
|
|
velocityMap,
|
|
buyBoxLostMap
|
|
}) => {
|
|
// Pivot data - memoized
|
|
const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
|
|
|
|
// Limit visible weeks for performance
|
|
const weeks = useMemo(() => allWeeks.slice(0, MAX_VISIBLE_WEEKS), [allWeeks]);
|
|
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const debouncedSearch = useDebounce(searchTerm, 300);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [growthFilterMode, setGrowthFilterMode] = useState<'all' | 'up' | 'down' | 'stable'>('all');
|
|
const [growthThreshold, setGrowthThreshold] = useState(10);
|
|
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
|
const [displayCount, setDisplayCount] = useState(50);
|
|
const [primaryMetric, setPrimaryMetric] = useState<'units' | 'revenue'>('units');
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// State for Column Filters (SKU, ASIN, Title, Line)
|
|
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);
|
|
};
|
|
|
|
// Numeric filters for Units, Spend, GV
|
|
const [numericFilters, setNumericFilters] = useState<NumericFilterConfig[]>([]);
|
|
|
|
// Helper to get/set filter for a specific week+metric
|
|
const getNumericFilter = useCallback((week: string, metric: 'units' | 'spend' | 'revenue' | 'gv') => {
|
|
return numericFilters.find(f => f.week === week && f.metric === metric) || null;
|
|
}, [numericFilters]);
|
|
|
|
const setNumericFilter = useCallback((filter: NumericFilterConfig | null, week: string, metric: 'units' | 'spend' | 'revenue' | 'gv') => {
|
|
setNumericFilters(prev => {
|
|
// Remove existing filter for this week+metric
|
|
const filtered = prev.filter(f => !(f.week === week && f.metric === metric));
|
|
// Add new filter if provided
|
|
if (filter) {
|
|
return [...filtered, filter];
|
|
}
|
|
return filtered;
|
|
});
|
|
}, []);
|
|
|
|
// Default sort: most recent week, descending, units
|
|
const [sortConfig, setSortConfig] = useState<SortConfig>(() => {
|
|
if (weeks.length > 0) {
|
|
return { key: weeks[0], direction: 'desc', metric: 'units' };
|
|
}
|
|
return null;
|
|
});
|
|
|
|
// Reset pagination when search changes
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
setDisplayCount(50);
|
|
}, [debouncedSearch, showOnlyTop50, top50Mode]);
|
|
|
|
const handleSort = useCallback((weekKey: string, metric: 'units' | 'spend' | 'revenue' | 'rank' | 'gv') => {
|
|
setSortConfig(prev => {
|
|
if (prev?.key === weekKey && prev.metric === metric) {
|
|
return { key: weekKey, direction: prev.direction === 'asc' ? 'desc' : 'asc', metric };
|
|
}
|
|
return { key: weekKey, direction: metric === 'rank' ? 'asc' : 'desc', metric };
|
|
});
|
|
}, []);
|
|
|
|
// Calculate totals in a SINGLE PASS
|
|
const weekTotals = useMemo(() => {
|
|
const totals: { [weekKey: string]: { units: number, spend: number, revenue: number, gv: number } } = {};
|
|
const visibleWeeks = weeks;
|
|
|
|
// Initialize visible weeks
|
|
for (let i = 0; i < visibleWeeks.length; i++) {
|
|
totals[visibleWeeks[i]] = { units: 0, spend: 0, revenue: 0, gv: 0 };
|
|
}
|
|
|
|
// Single pass through rows
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const row = rows[i];
|
|
for (let j = 0; j < visibleWeeks.length; j++) {
|
|
const w = visibleWeeks[j];
|
|
totals[w].units += (row.unitsByWeek[w] || 0);
|
|
totals[w].spend += (row.spendByWeek[w] || 0);
|
|
totals[w].revenue += (row.revenueByWeek[w] || 0);
|
|
totals[w].gv += (row.gvByWeek?.[w] || 0);
|
|
}
|
|
}
|
|
return totals;
|
|
}, [rows, weeks]);
|
|
|
|
// 1. Filter by search term, Top 50, and growth (using debounced value)
|
|
const filteredRows = useMemo(() => {
|
|
let result = rows;
|
|
|
|
// Top 50 Filter
|
|
if (showOnlyTop50 && top50Ranking) {
|
|
result = result.filter(r => {
|
|
const asin = r.asin.trim().toUpperCase();
|
|
const isUK = r.customer.toLowerCase().includes('uk');
|
|
|
|
if (top50Mode === 'eu') {
|
|
return !isUK && top50Ranking.eu.has(asin);
|
|
} else {
|
|
return isUK && top50Ranking.uk.has(asin);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Column Filters (Excel-style) - using logic adapted from filterData
|
|
if (Object.keys(columnFilters).length > 0) {
|
|
(Object.entries(columnFilters) as [string, ColumnFilterCondition][]).forEach(([key, condition]) => {
|
|
if (!condition) return;
|
|
|
|
if (condition.selectedValues && condition.selectedValues.length > 0) {
|
|
result = result.filter(r => {
|
|
const val = String((r as any)[key] || '');
|
|
return condition.selectedValues?.includes(val);
|
|
});
|
|
}
|
|
|
|
if (condition.textFilter) {
|
|
const { operator, value } = condition.textFilter;
|
|
const lowerValue = value.toLowerCase();
|
|
|
|
result = result.filter(r => {
|
|
const rowVal = String((r as any)[key] || '').toLowerCase();
|
|
switch (operator) {
|
|
case 'equals': return rowVal === lowerValue;
|
|
case 'notEquals': return rowVal !== lowerValue;
|
|
case 'contains': return rowVal.includes(lowerValue);
|
|
case 'notContains': return !rowVal.includes(lowerValue);
|
|
case 'startsWith': return rowVal.startsWith(lowerValue);
|
|
case 'notStartsWith': return !rowVal.startsWith(lowerValue);
|
|
case 'endsWith': return rowVal.endsWith(lowerValue);
|
|
case 'notEndsWith': return !rowVal.endsWith(lowerValue);
|
|
default: return true;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Search Filter
|
|
if (debouncedSearch) {
|
|
const searchTerms = debouncedSearch.toLowerCase().split(/[\s,]+/).filter(t => t.length > 0);
|
|
if (searchTerms.length > 0) {
|
|
result = result.filter(r => {
|
|
const rowValues = [
|
|
r.sku.toLowerCase(),
|
|
r.asin.toLowerCase(),
|
|
r.title.toLowerCase(),
|
|
r.line.toLowerCase()
|
|
];
|
|
return searchTerms.some(term =>
|
|
rowValues.some(val => val.includes(term))
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Growth Filter (if active)
|
|
if (growthFilterMode !== 'all' && sortConfig) {
|
|
const currentWeek = sortConfig.key;
|
|
const currentWeekIdx = weeks.indexOf(currentWeek);
|
|
const prevWeek = weeks[currentWeekIdx + 1];
|
|
|
|
if (prevWeek) {
|
|
result = result.filter(r => {
|
|
const currentUnits = r.unitsByWeek[currentWeek] || 0;
|
|
const prevUnits = r.unitsByWeek[prevWeek] || 0;
|
|
|
|
if (prevUnits === 0) {
|
|
if (currentUnits === 0) return growthFilterMode === 'stable';
|
|
return growthFilterMode === 'up';
|
|
}
|
|
|
|
const growth = ((currentUnits - prevUnits) / prevUnits) * 100;
|
|
|
|
if (growthFilterMode === 'up') return growth >= growthThreshold;
|
|
if (growthFilterMode === 'down') return growth <= -Math.abs(growthThreshold);
|
|
if (growthFilterMode === 'stable') return Math.abs(growth) < growthThreshold;
|
|
return true;
|
|
});
|
|
}
|
|
}
|
|
|
|
// WOC Filter
|
|
if (wocFilter && wocFilter.length > 0 && velocityMap && vendorStockMap) {
|
|
result = result.filter(r => {
|
|
const asin = r.asin.trim().toUpperCase();
|
|
const stockData = vendorStockMap.get(asin);
|
|
if (!stockData) return false;
|
|
|
|
const stock = top50Mode === 'uk' ? stockData.uk : stockData.eu;
|
|
const velocity = velocityMap.get(asin) || 0;
|
|
|
|
let woc: number = 0;
|
|
if (velocity > 0) {
|
|
woc = stock / velocity;
|
|
} else if (stock > 0) {
|
|
woc = 999;
|
|
}
|
|
|
|
return checkNumericConditions(woc, wocFilter);
|
|
});
|
|
}
|
|
|
|
// Numeric Filters (Units, Spend, GV)
|
|
if (numericFilters.length > 0) {
|
|
result = result.filter(row => {
|
|
return numericFilters.every(filter => {
|
|
let value = 0;
|
|
if (filter.metric === 'units') {
|
|
value = row.unitsByWeek[filter.week] || 0;
|
|
} else if (filter.metric === 'spend') {
|
|
value = row.spendByWeek[filter.week] || 0;
|
|
} else if (filter.metric === 'revenue') {
|
|
value = row.revenueByWeek?.[filter.week] || 0;
|
|
} else if (filter.metric === 'gv') {
|
|
value = row.gvByWeek?.[filter.week] || 0;
|
|
}
|
|
return passesNumericFilter(value, filter);
|
|
});
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}, [rows, debouncedSearch, showOnlyTop50, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks, top50Mode, wocFilter, velocityMap, vendorStockMap, numericFilters, columnFilters]);
|
|
|
|
// 2. Sort results
|
|
const sortedRows = useMemo(() => {
|
|
if (!sortConfig || filteredRows.length === 0) return filteredRows;
|
|
|
|
const result = [...filteredRows];
|
|
const { key: weekKey, direction, metric } = sortConfig;
|
|
|
|
if (metric === 'rank') {
|
|
if (!top50Ranking) return result;
|
|
const rankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
|
|
|
|
result.sort((a, b) => {
|
|
const asinA = a.asin.trim().toUpperCase();
|
|
const asinB = b.asin.trim().toUpperCase();
|
|
const rankA = rankMap.get(asinA) || 999;
|
|
const rankB = rankMap.get(asinB) || 999;
|
|
return direction === 'asc' ? rankA - rankB : rankB - rankA;
|
|
});
|
|
} else {
|
|
const metricKey = metric === 'units' ? 'unitsByWeek' :
|
|
metric === 'spend' ? 'spendByWeek' :
|
|
metric === 'revenue' ? 'revenueByWeek' : 'gvByWeek';
|
|
|
|
result.sort((a, b) => {
|
|
const valA = a[metricKey][weekKey] || 0;
|
|
const valB = b[metricKey][weekKey] || 0;
|
|
if (valA === valB) return 0;
|
|
return direction === 'asc' ? valA - valB : valB - valA;
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}, [filteredRows, sortConfig, top50Ranking, top50Mode]);
|
|
|
|
// 3. Paginate
|
|
const paginatedRows = useMemo(() => {
|
|
const start = (currentPage - 1) * ROWS_PER_PAGE;
|
|
return sortedRows.slice(start, start + ROWS_PER_PAGE);
|
|
}, [sortedRows, currentPage]);
|
|
|
|
const totalPages = Math.ceil(sortedRows.length / ROWS_PER_PAGE);
|
|
|
|
const renderGrowth = useCallback((current: number, previous: number) => {
|
|
if (!previous || previous === 0) return null;
|
|
const pct = ((current - previous) / previous) * 100;
|
|
const isPositive = pct >= 0;
|
|
|
|
return (
|
|
<span className={`text-[10px] font-bold ${isPositive ? 'text-emerald-400' : 'text-red-400'}`}>
|
|
{isPositive ? '▲' : '▼'}{Math.abs(pct).toFixed(0)}%
|
|
</span>
|
|
);
|
|
}, []);
|
|
|
|
// Export to Excel
|
|
const handleExportExcel = useCallback(() => {
|
|
const exportData = sortedRows.map(row => {
|
|
const rowData: { [key: string]: string | number } = {
|
|
SKU: row.sku,
|
|
ASIN: row.asin,
|
|
Title: row.title,
|
|
Line: row.line,
|
|
};
|
|
allWeeks.forEach(week => {
|
|
rowData[`${week} Units`] = row.unitsByWeek[week] || 0;
|
|
rowData[`${week} Spend`] = row.spendByWeek[week] || 0;
|
|
rowData[`${week} GV`] = row.gvByWeek[week] || 0;
|
|
});
|
|
return rowData;
|
|
});
|
|
|
|
const totalsRow: any = {
|
|
SKU: 'TOTALS',
|
|
ASIN: '',
|
|
Title: 'ALL FILTERED PRODUCTS',
|
|
Line: '',
|
|
};
|
|
allWeeks.forEach(week => {
|
|
totalsRow[`${week} Units`] = weekTotals[week]?.units || 0;
|
|
totalsRow[`${week} Spend`] = weekTotals[week]?.spend || 0;
|
|
totalsRow[`${week} GV`] = weekTotals[week]?.gv || 0;
|
|
});
|
|
exportData.push(totalsRow);
|
|
|
|
const ws = XLSX.utils.json_to_sheet(exportData);
|
|
const wb = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(wb, ws, 'Weekly Sales');
|
|
XLSX.writeFile(wb, `Weekly_Sales_Export_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
|
}, [sortedRows, allWeeks, weekTotals]);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3 md:gap-4 animate-fade-in px-1 md:px-0">
|
|
{/* Experiment Tracker */}
|
|
<ExperimentTracker rows={rows} weeks={weeks} primaryMetric={primaryMetric} customerFilters={customerFilters} />
|
|
|
|
{/* Toolbar: Search & Pagination */}
|
|
<div className="flex flex-col lg:flex-row justify-between items-center gap-3 md:gap-4 bg-slate-900 border border-white/10 p-2 md:p-4 rounded-xl shadow-lg">
|
|
<div className="flex flex-wrap items-center gap-4 w-full lg:w-auto">
|
|
{/* Search Input */}
|
|
<div className="relative w-full md:w-80">
|
|
<input
|
|
type="text"
|
|
placeholder="Search SKU, Title, or ASIN..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full bg-slate-950 border border-white/10 rounded-lg 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>
|
|
</div>
|
|
|
|
{/* Growth Filter */}
|
|
<div className="flex items-center gap-2 bg-slate-950/50 p-1.5 px-3 rounded-lg border border-white/5">
|
|
<span className="text-[11px] font-black text-slate-500 uppercase tracking-widest">Growth Filter</span>
|
|
<select
|
|
value={growthFilterMode}
|
|
onChange={(e) => setGrowthFilterMode(e.target.value as any)}
|
|
className="bg-slate-900 border border-white/10 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
|
>
|
|
<option value="all">All Products</option>
|
|
<option value="up">Gaining</option>
|
|
<option value="down">Dropping</option>
|
|
<option value="stable">Stable</option>
|
|
</select>
|
|
{growthFilterMode !== 'all' && (
|
|
<div className="flex items-center gap-1.5 ml-1 border-l border-white/10 pl-3">
|
|
<span className="text-[10px] text-slate-500 font-bold">Thresh:</span>
|
|
<input
|
|
type="number"
|
|
value={growthThreshold}
|
|
onChange={(e) => setGrowthThreshold(Number(e.target.value))}
|
|
className="w-12 bg-slate-900 border border-white/10 rounded px-1.5 py-1 text-xs text-white text-center focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
|
/>
|
|
<span className="text-[10px] text-slate-500">%</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Top 50 Filter Toggle */}
|
|
{top50Ranking && (
|
|
(top50Ranking.eu.size || 0) > 0 ||
|
|
(top50Ranking.uk.size || 0) > 0
|
|
) && (
|
|
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm">
|
|
<button
|
|
onClick={() => {
|
|
const newMode = !showOnlyTop50;
|
|
setShowOnlyTop50(newMode);
|
|
if (newMode) handleSort('rank', 'rank');
|
|
}}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${showOnlyTop50
|
|
? 'bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-lg shadow-amber-500/20'
|
|
: 'text-slate-400 hover:text-amber-400'
|
|
}`}
|
|
>
|
|
<span className="text-sm">🏆</span>
|
|
Top 50 ({top50Mode.toUpperCase()})
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Metric Toggle */}
|
|
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm ml-auto lg:ml-0">
|
|
<button
|
|
onClick={() => setPrimaryMetric('units')}
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${primaryMetric === 'units'
|
|
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/20'
|
|
: 'text-slate-400 hover:text-white'
|
|
}`}
|
|
>
|
|
Units
|
|
</button>
|
|
<button
|
|
onClick={() => setPrimaryMetric('revenue')}
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${primaryMetric === 'revenue'
|
|
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/20'
|
|
: 'text-slate-400 hover:text-white'
|
|
}`}
|
|
>
|
|
Revenue (€)
|
|
</button>
|
|
</div>
|
|
|
|
{/* Export Button */}
|
|
<button
|
|
onClick={handleExportExcel}
|
|
className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1.5 rounded-lg text-xs font-bold transition-colors border border-emerald-500/50"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
Export Excel
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm text-slate-500 whitespace-nowrap">
|
|
Showing {Math.min(filteredRows.length, (currentPage - 1) * ROWS_PER_PAGE + 1)}-{Math.min(filteredRows.length, currentPage * ROWS_PER_PAGE)} of {filteredRows.length}
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setCurrentPage(p => Math.max(1, p - 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>
|
|
<span className="bg-slate-950 border border-white/10 px-3 py-1.5 rounded-lg text-xs font-bold text-white min-w-[60px] text-center">
|
|
{currentPage} / {Math.max(1, totalPages)}
|
|
</span>
|
|
<button
|
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 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>
|
|
</div>
|
|
|
|
<div className="bg-slate-900 border border-white/10 rounded-xl overflow-hidden shadow-2xl flex-1 min-h-0">
|
|
<div className="overflow-x-auto overflow-y-auto h-full max-h-[calc(100vh-220px)] custom-scrollbar scroll-touch">
|
|
<table className="w-full text-left border-collapse min-w-[max-content] text-[10px] md:text-sm">
|
|
<thead className="sticky top-0 z-20 bg-slate-900">
|
|
<tr className="bg-slate-900 border-b border-white/10 relative z-30">
|
|
<th
|
|
className="p-3 text-[11px] font-black text-slate-400 uppercase tracking-widest sticky left-0 z-40 bg-slate-900 border-r border-white/10 min-w-[240px]"
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center justify-between pr-2">
|
|
<div
|
|
className="flex items-center gap-2 cursor-pointer hover:text-white group"
|
|
onClick={() => handleSort('rank', 'rank')}
|
|
>
|
|
<span>Product Details</span>
|
|
{sortConfig?.metric === 'rank' && (
|
|
<span className="text-amber-500 font-black text-sm animate-bounce-subtle">
|
|
{sortConfig.direction === 'asc' ? '↑' : '↓'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<ExcelFilter
|
|
columnKey="sku"
|
|
title="SKU"
|
|
uniqueValues={Array.from(new Set(rows.map(r => r.sku))).sort()}
|
|
currentFilter={columnFilters['sku']}
|
|
onFilterChange={handleColumnFilterChange}
|
|
icon={<span className="text-[10px]">SKU</span>}
|
|
/>
|
|
<ExcelFilter
|
|
columnKey="asin"
|
|
title="ASIN"
|
|
uniqueValues={Array.from(new Set(rows.map(r => r.asin))).sort()}
|
|
currentFilter={columnFilters['asin']}
|
|
onFilterChange={handleColumnFilterChange}
|
|
icon={<span className="text-[10px]">ASIN</span>}
|
|
/>
|
|
<ExcelFilter
|
|
columnKey="title"
|
|
title="Title"
|
|
uniqueValues={Array.from(new Set(rows.map(r => r.title))).sort()}
|
|
currentFilter={columnFilters['title']}
|
|
onFilterChange={handleColumnFilterChange}
|
|
icon={<span className="text-[10px]">Title</span>}
|
|
/>
|
|
<ExcelFilter
|
|
columnKey="line"
|
|
title="Line"
|
|
uniqueValues={Array.from(new Set(rows.map(r => r.line))).sort()}
|
|
currentFilter={columnFilters['line']}
|
|
onFilterChange={handleColumnFilterChange}
|
|
icon={<span className="text-[10px]">Line</span>}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<InColumnStockFilter
|
|
currentFilters={stockFilter}
|
|
onFilterChange={onStockFilterChange}
|
|
title="Warehouse Stock"
|
|
icon={<WarehouseIcon className="w-3.5 h-3.5 text-fuchsia-400" />}
|
|
/>
|
|
<InColumnStockFilter
|
|
currentFilters={vendorStockFilter}
|
|
onFilterChange={onVendorStockFilterChange}
|
|
title="Vendor Stock"
|
|
icon={<AmazonSmileIcon className="w-5 h-5 text-amber-500" />}
|
|
options={[
|
|
'Out of Stock (0)',
|
|
'In Stock (>0)',
|
|
'In Stock (>20)',
|
|
'Low Stock (<10)',
|
|
]}
|
|
/>
|
|
<InColumnStockFilter
|
|
currentFilters={wocFilter}
|
|
onFilterChange={onWocFilterChange}
|
|
title="Week Coverage"
|
|
icon={<CoverageIcon className="w-3.5 h-3.5 text-emerald-400" />}
|
|
options={[
|
|
'< 4 Weeks',
|
|
'> 4 Weeks',
|
|
'Out of Stock',
|
|
'Infinite Cover'
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
{weeks.map(week => (
|
|
<th
|
|
key={week}
|
|
className={`p-0 text-[10px] font-black uppercase tracking-widest text-center border-r border-white/10 min-w-[130px] transition-colors select-none ${sortConfig?.key === week ? 'bg-white/[0.02]' : ''}`}
|
|
>
|
|
<div className="flex flex-col h-full">
|
|
<div className="p-2 border-b border-white/5 bg-slate-800/30 text-xs text-white">
|
|
{week.split('-')[1]}/{week.split('-')[0].slice(-2)}
|
|
</div>
|
|
|
|
<div
|
|
className={`flex-1 p-1.5 transition-colors flex items-center justify-center gap-1 border-b border-white/5 ${sortConfig?.key === week && sortConfig.metric === (primaryMetric === 'units' ? 'units' : 'revenue') ? 'bg-indigo-500/5 text-indigo-400' : 'text-slate-500'}`}
|
|
>
|
|
<span
|
|
onClick={() => handleSort(week, primaryMetric === 'units' ? 'units' : 'revenue')}
|
|
className="text-[9px] cursor-pointer hover:text-indigo-300"
|
|
>
|
|
{primaryMetric === 'units' ? 'Units' : 'Revenue'}
|
|
</span>
|
|
{sortConfig?.key === week && sortConfig.metric === (primaryMetric === 'units' ? 'units' : 'revenue') && (
|
|
<span className="text-xs font-bold leading-none">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
|
)}
|
|
<NumericColumnFilter
|
|
week={week}
|
|
metric={primaryMetric === 'units' ? 'units' : 'revenue'}
|
|
currentFilter={getNumericFilter(week, primaryMetric === 'units' ? 'units' : 'revenue')}
|
|
onFilterChange={(f) => setNumericFilter(f, week, primaryMetric === 'units' ? 'units' : 'revenue')}
|
|
accentColor="indigo"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className={`flex-1 p-1.5 transition-colors flex items-center justify-center gap-1 border-b border-white/5 ${sortConfig?.key === week && sortConfig.metric === 'spend' ? 'bg-amber-500/5 text-amber-400' : 'text-slate-500'}`}
|
|
>
|
|
<span
|
|
onClick={() => handleSort(week, 'spend')}
|
|
className="text-[9px] cursor-pointer hover:text-amber-300"
|
|
>
|
|
Spend
|
|
</span>
|
|
{sortConfig?.key === week && sortConfig.metric === 'spend' && (
|
|
<span className="text-xs font-bold leading-none">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
|
)}
|
|
<NumericColumnFilter
|
|
week={week}
|
|
metric="spend"
|
|
currentFilter={getNumericFilter(week, 'spend')}
|
|
onFilterChange={(f) => setNumericFilter(f, week, 'spend')}
|
|
accentColor="amber"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className={`flex-1 p-1.5 transition-colors flex items-center justify-center gap-1 ${sortConfig?.key === week && sortConfig.metric === 'gv' ? 'bg-teal-500/5 text-teal-400' : 'text-slate-500'}`}
|
|
>
|
|
<span
|
|
onClick={() => handleSort(week, 'gv')}
|
|
className="text-[9px] cursor-pointer hover:text-teal-300"
|
|
>
|
|
GV
|
|
</span>
|
|
{sortConfig?.key === week && sortConfig.metric === 'gv' && (
|
|
<span className="text-xs font-bold leading-none">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
|
)}
|
|
<NumericColumnFilter
|
|
week={week}
|
|
metric="gv"
|
|
currentFilter={getNumericFilter(week, 'gv')}
|
|
onFilterChange={(f) => setNumericFilter(f, week, 'gv')}
|
|
accentColor="teal"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
<tr className="bg-indigo-950 border-b border-white/10 relative z-20">
|
|
<th className="p-3 text-sm font-black text-white sticky left-0 z-40 bg-indigo-950 border-r border-white/10">TOTALS</th>
|
|
{weeks.map((week, idx) => (
|
|
<th key={week} className="p-3 text-sm font-black text-white text-center border-r border-white/10">
|
|
<div className="flex flex-col items-center">
|
|
{primaryMetric === 'units' ? (
|
|
<>
|
|
<div className="flex items-center gap-1">
|
|
<span>{weekTotals[week]?.units.toLocaleString('de-DE') || 0}</span>
|
|
{renderGrowth(weekTotals[week]?.units || 0, weekTotals[weeks[idx + 1]]?.units || 0)}
|
|
</div>
|
|
<div className="flex items-center gap-0.5 text-rose-400/80 italic font-bold text-[10px]">
|
|
<span>Ads: €{(weekTotals[week]?.spend || 0).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</span>
|
|
{renderGrowth(weekTotals[week]?.spend || 0, weekTotals[weeks[idx + 1]]?.spend || 0)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-1">
|
|
<span>€{(weekTotals[week]?.revenue || 0).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</span>
|
|
{renderGrowth(weekTotals[week]?.revenue || 0, weekTotals[weeks[idx + 1]]?.revenue || 0)}
|
|
</div>
|
|
<div className="flex items-center gap-0.5 text-rose-400/80 italic font-bold text-[10px]">
|
|
<span>Ads: €{(weekTotals[week]?.spend || 0).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</span>
|
|
{renderGrowth(weekTotals[week]?.spend || 0, weekTotals[weeks[idx + 1]]?.spend || 0)}
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="flex items-center justify-center gap-1 text-[9px] text-teal-400 font-black tracking-tight mt-0.5">
|
|
<span>GV: {(weekTotals[week]?.gv || 0).toLocaleString('de-DE')}</span>
|
|
{renderGrowth(weekTotals[week]?.gv || 0, weekTotals[weeks[idx + 1]]?.gv || 0)}
|
|
</div>
|
|
</div>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-white/5">
|
|
{sortedRows.length > 0 ? (
|
|
(() => {
|
|
const displayRows = sortedRows.slice(0, displayCount);
|
|
return (
|
|
<>
|
|
{displayRows.map(row => (
|
|
<WeeklyRow
|
|
key={row.id}
|
|
row={row}
|
|
weeks={weeks}
|
|
onDrillDown={onDrillDown}
|
|
stockMap={stockMap}
|
|
vendorStockMap={vendorStockMap}
|
|
top50Ranking={top50Ranking}
|
|
top50Mode={top50Mode}
|
|
sortConfig={sortConfig}
|
|
renderGrowth={renderGrowth}
|
|
customerFilters={customerFilters}
|
|
velocityMap={velocityMap}
|
|
buyBoxLostMap={buyBoxLostMap}
|
|
primaryMetric={primaryMetric}
|
|
/>
|
|
))}
|
|
{displayCount < sortedRows.length && (
|
|
<tr>
|
|
<td colSpan={weeks.length + 1} className="p-6 text-center bg-slate-900/50 backdrop-blur-sm border-t border-white/5">
|
|
<button
|
|
onClick={() => setDisplayCount(prev => prev + 100)}
|
|
className="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-black uppercase tracking-widest shadow-xl transition-all active:scale-95 border border-indigo-400/30"
|
|
>
|
|
Load More SKUs ({sortedRows.length - displayCount} remaining)
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
);
|
|
})()
|
|
) : (
|
|
<tr>
|
|
<td colSpan={weeks.length + 1} className="p-10 text-center text-slate-500 italic text-base">
|
|
No SKUs found matching "{debouncedSearch}"
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default WeeklyGrid;
|