mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 17:45:22 +02:00
1208 lines
68 KiB
TypeScript
1208 lines
68 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 { 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;
|
|
experimentDelta?: number | null;
|
|
baselineValue?: number | null;
|
|
}> = ({ children, currentValue, previousValue, yoyValue, currentWeekLabel, previousWeekLabel, yoyWeekLabel, metricName, metricColor, formatValue, experimentDelta, baselineValue }) => {
|
|
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>
|
|
|
|
{/* Experiment Baseline Delta (If active) */}
|
|
{typeof experimentDelta === 'number' && typeof baselineValue === 'number' && (
|
|
<div className="border-t border-fuchsia-500/20 pt-2 mt-2 bg-fuchsia-500/5 -mx-3 px-3 pb-1">
|
|
<div className="flex justify-between items-center mb-1">
|
|
<span className="text-[10px] text-fuchsia-400/80 font-bold">vs Pre-Experiment Baseline</span>
|
|
<span className={`text-xs font-black ${experimentDelta >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
|
{experimentDelta >= 0 ? '▲' : '▼'} {Math.abs(experimentDelta).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-[9px] text-slate-500">Baseline Avg (4w)</span>
|
|
<span className="text-[10px] font-bold text-slate-400">{format(baselineValue)}</span>
|
|
</div>
|
|
</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>
|
|
);
|
|
};
|
|
|
|
// Helper to get 4-week baseline prior to the start date
|
|
const getBaselineMetrics = (row: WeeklyPivotRow, weeks: string[], expStartDate: string) => {
|
|
const startDate = new Date(expStartDate);
|
|
const jan1 = new Date(startDate.getFullYear(), 0, 1);
|
|
const dayOfYear = Math.floor((startDate.getTime() - jan1.getTime()) / 86400000) + 1;
|
|
const weekNum = Math.ceil((dayOfYear + jan1.getDay()) / 7);
|
|
const startWeekStr = `${startDate.getFullYear()}-${String(weekNum).padStart(2, '0')}`;
|
|
|
|
// Find index of startWeekStr in weeks array
|
|
const startIdx = weeks.indexOf(startWeekStr);
|
|
|
|
if (startIdx === -1) return null; // Can't compute baseline if start week is not in our data view
|
|
|
|
// Weeks before the start date are at startIdx + 1 to startIdx + 4
|
|
let u = 0, r = 0, g = 0, weeksCount = 0;
|
|
for (let i = 1; i <= 4; i++) {
|
|
const w = weeks[startIdx + i];
|
|
if (w) {
|
|
u += (row.unitsByWeek[w] || 0);
|
|
r += (row.revenueByWeek[w] || 0);
|
|
g += (row.gvByWeek?.[w] || 0);
|
|
weeksCount++;
|
|
}
|
|
}
|
|
|
|
if (weeksCount === 0) return null;
|
|
return {
|
|
units: u / weeksCount,
|
|
revenue: r / weeksCount,
|
|
gv: g / weeksCount,
|
|
cvr: g > 0 ? (u / g) * 100 : 0
|
|
};
|
|
};
|
|
|
|
// Helper to check if a week overlaps with an experiment's date range
|
|
const isWeekInExperiment = (weekKey: string, exp: ActiveExperiment): boolean => {
|
|
if (!weekKey || !weekKey.includes('-')) return false;
|
|
const [yearStr, weekStr] = weekKey.split('-');
|
|
const year = parseInt(yearStr);
|
|
const week = parseInt(weekStr);
|
|
|
|
// Basic approximation of week start (Jan 1 + (week-1)*7 days)
|
|
const weekStart = new Date(year, 0, 1 + (week - 1) * 7);
|
|
const weekEnd = new Date(weekStart.getTime() + 6 * 86400000); // add 6 days
|
|
|
|
const expStart = new Date(exp.start_date);
|
|
const expEnd = exp.end_date ? new Date(exp.end_date) : new Date(); // If no end date, it's ongoing
|
|
|
|
// Overlap logic: weekStart <= expEnd && weekEnd >= expStart
|
|
return weekStart <= expEnd && weekEnd >= expStart;
|
|
};
|
|
|
|
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';
|
|
experimentMap?: Map<string, ActiveExperiment[]>;
|
|
onOpenExperiment?: (experimentId: string) => void;
|
|
}> = React.memo(({ row, weeks, onDrillDown, stockMap, top50Ranking, top50Mode, sortConfig, renderGrowth, customerFilters, vendorStockMap, velocityMap, buyBoxLostMap, primaryMetric, experimentMap, onOpenExperiment }) => {
|
|
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;
|
|
|
|
const cvr = gv > 0 ? (val / gv) * 100 : 0;
|
|
const prevCvr = prevGv > 0 ? (prevVal / prevGv) * 100 : 0;
|
|
const yoyCvr = yoyGv > 0 ? (yoyUnits / yoyGv) * 100 : 0;
|
|
|
|
// Determine if this cell should be highlighted due to an active experiment
|
|
const experimentsInWeek = experimentMap?.get(asin)?.filter(exp => isWeekInExperiment(week, exp)) || [];
|
|
const hasActiveExperiment = experimentsInWeek.length > 0;
|
|
|
|
let highlightClass = '';
|
|
let experimentDelta: number | null = null;
|
|
let baselineValue: number | null = null;
|
|
|
|
if (hasActiveExperiment) {
|
|
const primaryExp = experimentsInWeek[0];
|
|
if (primaryExp.type === 'pricing') highlightClass = 'bg-amber-500/10 border-t border-amber-500/20';
|
|
else if (primaryExp.type === 'advertising') highlightClass = 'bg-rose-500/10 border-t border-rose-500/20';
|
|
else if (primaryExp.type === 'content') highlightClass = 'bg-emerald-500/10 border-t border-emerald-500/20';
|
|
else highlightClass = 'bg-indigo-500/10 border-t border-indigo-500/20';
|
|
|
|
const baseline = getBaselineMetrics(row, weeks, primaryExp.start_date);
|
|
if (baseline) {
|
|
const myMetric = primaryMetric === 'units' ? val : revenue;
|
|
const baseMetric = primaryMetric === 'units' ? baseline.units : baseline.revenue;
|
|
if (baseMetric > 0) {
|
|
experimentDelta = ((myMetric - baseMetric) / baseMetric) * 100;
|
|
baselineValue = baseMetric;
|
|
}
|
|
}
|
|
}
|
|
|
|
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]' : ''} ${highlightClass}`}>
|
|
{/* 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"
|
|
experimentDelta={experimentDelta}
|
|
baselineValue={baselineValue}
|
|
>
|
|
<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 })}`}
|
|
experimentDelta={experimentDelta}
|
|
baselineValue={baselineValue}
|
|
>
|
|
<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 and CVR 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 & CVR"
|
|
metricColor="text-teal-400"
|
|
>
|
|
<div className="flex flex-col items-center gap-0.5 mt-0.5">
|
|
<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>
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[9px] font-bold tracking-tighter text-indigo-400/70">
|
|
CVR: {cvr.toFixed(1)}%
|
|
</span>
|
|
{renderGrowth(cvr, prevCvr)}
|
|
</div>
|
|
</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,
|
|
experimentMap,
|
|
onOpenExperiment
|
|
}) => {
|
|
// 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 [showActiveExperiments, setShowActiveExperiments] = 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);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Active Experiments Filter
|
|
if (showActiveExperiments && experimentMap) {
|
|
result = result.filter(r => {
|
|
const asin = r.asin.trim().toUpperCase();
|
|
const experiments = experimentMap.get(asin);
|
|
return experiments && experiments.length > 0;
|
|
});
|
|
}
|
|
|
|
// 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, showActiveExperiments, experimentMap, 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">
|
|
{/* 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>
|
|
)}
|
|
|
|
{/* Active Experiments Toggle */}
|
|
{experimentMap && experimentMap.size > 0 && (
|
|
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm ml-2">
|
|
<button
|
|
onClick={() => setShowActiveExperiments(!showActiveExperiments)}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${showActiveExperiments
|
|
? 'bg-fuchsia-600 text-white shadow-lg shadow-fuchsia-500/20'
|
|
: 'text-slate-400 hover:text-fuchsia-400'
|
|
}`}
|
|
title="Filter grid to show only ASINs with logged experiments"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
|
</svg>
|
|
Active Experiments
|
|
</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}
|
|
experimentMap={experimentMap}
|
|
onOpenExperiment={onOpenExperiment}
|
|
/>
|
|
))}
|
|
{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;
|