mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 13:45:23 +02:00
Fix: Corrected 2026 revenue discrepancy, resolved WeeklyGrid initialization crash, and restored Week Coverage data
This commit is contained in:
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
interface MetricDetailTooltipProps {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tooltip component to show comparison details on hover (WoW and YoY).
|
||||||
|
* Extracted from WeeklyGrid to prevent circular dependencies and initialization errors.
|
||||||
|
*/
|
||||||
|
export const MetricDetailTooltip: React.FC<MetricDetailTooltipProps> = ({
|
||||||
|
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 */}
|
||||||
|
<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 */}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,16 +11,17 @@ interface VendorStockBadgeProps {
|
|||||||
export const VendorStockBadge: React.FC<VendorStockBadgeProps> = ({ asin, vendorStockMap, mode, avgWeeklySales }) => {
|
export const VendorStockBadge: React.FC<VendorStockBadgeProps> = ({ asin, vendorStockMap, mode, avgWeeklySales }) => {
|
||||||
if (!asin) return null;
|
if (!asin) return null;
|
||||||
|
|
||||||
const stockData = vendorStockMap?.get(asin.toUpperCase());
|
const normalizedAsin = asin.trim().toUpperCase();
|
||||||
|
const stockData = vendorStockMap?.get(normalizedAsin);
|
||||||
|
|
||||||
if (!stockData) {
|
if (!stockData) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-0.5 justify-center min-w-[70px] opacity-40 group-hover:opacity-100 transition-opacity" title={`Inventory data missing for ASIN: ${asin}`}>
|
<div className="flex flex-col items-center gap-0.5 justify-center min-w-[70px] opacity-40 group-hover:opacity-100 transition-opacity" title={`Inventory data missing for ASIN: ${normalizedAsin}`}>
|
||||||
<div className="text-[10px] font-bold text-slate-500 bg-slate-800/50 px-2 py-0.5 rounded border border-white/5">
|
<div className="text-[10px] font-bold text-slate-500 bg-slate-800/50 px-2 py-0.5 rounded border border-white/5">
|
||||||
No Data
|
No Data
|
||||||
</div>
|
</div>
|
||||||
<div className="px-1.5 py-0.5 rounded-sm bg-slate-900/20 border border-white/5">
|
<div className="px-1.5 py-0.5 rounded-sm bg-slate-900/20 border border-white/5">
|
||||||
<span className="text-[10px] font-black text-slate-600">-</span>
|
<span className="text-[10px] font-black text-slate-700">-</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+10
-406
@@ -1,16 +1,4 @@
|
|||||||
import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
import { WeeklyRow } from './WeeklyRow';
|
||||||
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';
|
import { ActiveExperiment } from '../types';
|
||||||
|
|
||||||
interface WeeklyGridProps {
|
interface WeeklyGridProps {
|
||||||
@@ -55,398 +43,8 @@ const useDebounce = (value: string, delay: number) => {
|
|||||||
return debouncedValue;
|
return debouncedValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tooltip component to show comparison details on hover (WoW and YoY)
|
// Sub-components (MetricDetailTooltip, WeeklyRow) have been extracted to separate files
|
||||||
const MetricDetailTooltip: React.FC<{
|
// to prevent initialization errors and improve code maintainability.
|
||||||
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 min-w-[240px]">
|
|
||||||
<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, ''))} />
|
|
||||||
)}
|
|
||||||
<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>
|
|
||||||
{/* Coverage Column (Dedicated) */}
|
|
||||||
<td className="p-3 py-2 sticky left-[240px] z-10 bg-slate-900 group-hover:bg-slate-800 border-r border-white/10 min-w-[100px]">
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<VendorStockBadge
|
|
||||||
asin={asin}
|
|
||||||
vendorStockMap={vendorStockMap}
|
|
||||||
mode={top50Mode}
|
|
||||||
avgWeeklySales={velocityMap?.get(asin)}
|
|
||||||
/>
|
|
||||||
</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' }> = ({
|
const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
||||||
data,
|
data,
|
||||||
@@ -1151,7 +749,13 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
|||||||
<span>TOTALS</span>
|
<span>TOTALS</span>
|
||||||
</th>
|
</th>
|
||||||
{/* COVERAGE Totals Column */}
|
{/* COVERAGE Totals Column */}
|
||||||
<th className="p-3 text-sm font-black text-white sticky left-[240px] z-40 bg-indigo-950 border-r border-white/10">
|
<th className="p-3 text-sm font-black text-white sticky left-[240px] z-40 bg-indigo-950 border-r border-white/10 uppercase tracking-tighter">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-[8px] opacity-60">Avg. Coverage</span>
|
||||||
|
<span className={`text-xs ${aggregateWoc && aggregateWoc < 4 ? 'text-rose-400' : 'text-emerald-400'}`}>
|
||||||
|
{aggregateWoc ? `${aggregateWoc.toFixed(1)}w` : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
{weeks.map((week, idx) => (
|
{weeks.map((week, idx) => (
|
||||||
<th key={week} className="p-3 text-sm font-black text-white text-center border-r border-white/10">
|
<th key={week} className="p-3 text-sm font-black text-white text-center border-r border-white/10">
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { WeeklyPivotRow } from '../services/dataProcessor';
|
||||||
|
import { ActiveExperiment } from '../types';
|
||||||
|
import { Top50Badge } from './Top50Badge';
|
||||||
|
import { StockBadge } from './StockBadge';
|
||||||
|
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
|
||||||
|
import { ExperimentBadge } from './ExperimentBadge';
|
||||||
|
import { VendorStockBadge } from './VendorStockBadge';
|
||||||
|
import { MetricDetailTooltip } from './MetricDetailTooltip';
|
||||||
|
|
||||||
|
interface WeeklyRowProps {
|
||||||
|
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: {
|
||||||
|
key: string;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
metric: 'units' | 'spend' | 'revenue' | 'rank' | 'gv';
|
||||||
|
} | null;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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')}`;
|
||||||
|
|
||||||
|
const startIdx = weeks.indexOf(startWeekStr);
|
||||||
|
if (startIdx === -1) return null;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const weekStart = new Date(year, 0, 1 + (week - 1) * 7);
|
||||||
|
const weekEnd = new Date(weekStart.getTime() + 6 * 86400000);
|
||||||
|
|
||||||
|
const expStart = new Date(exp.start_date);
|
||||||
|
const expEnd = exp.end_date ? new Date(exp.end_date) : new Date();
|
||||||
|
|
||||||
|
return weekStart <= expEnd && weekEnd >= expStart;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WeeklyRow: React.FC<WeeklyRowProps> = 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 min-w-[240px]">
|
||||||
|
<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, ''))} />
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
{/* Coverage Column */}
|
||||||
|
<td className="p-3 py-2 sticky left-[240px] z-10 bg-slate-900 group-hover:bg-slate-800 border-r border-white/10 min-w-[100px]">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<VendorStockBadge
|
||||||
|
asin={asin}
|
||||||
|
vendorStockMap={vendorStockMap}
|
||||||
|
mode={top50Mode}
|
||||||
|
avgWeeklySales={velocityMap?.get(asin)}
|
||||||
|
/>
|
||||||
|
</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;
|
||||||
|
|
||||||
|
const [year, weekNum] = week.split('-');
|
||||||
|
const prevWeekNum = weeks[idx + 1]?.split('-')[1] || '-';
|
||||||
|
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 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}`}>
|
||||||
|
{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 === 'revenue' ? '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">
|
||||||
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
|
{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 > 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -307,11 +307,10 @@ const isAllowedCustomer = (customer: string): boolean => {
|
|||||||
if (!customer) return false;
|
if (!customer) return false;
|
||||||
const normCustomer = customer.trim().toLowerCase();
|
const normCustomer = customer.trim().toLowerCase();
|
||||||
|
|
||||||
// Check if the customer string includes any of our allowed market names
|
// Use strict equality to prevent loose matches (e.g., 'UK' matching 'Amazon UK')
|
||||||
|
// which incorrectly included ad spend records in sell-out totals.
|
||||||
return ALLOWED_CUSTOMERS.some(allowed =>
|
return ALLOWED_CUSTOMERS.some(allowed =>
|
||||||
normCustomer === allowed.toLowerCase() ||
|
normCustomer === allowed.toLowerCase()
|
||||||
normCustomer.includes(allowed.toLowerCase()) ||
|
|
||||||
allowed.toLowerCase().includes(normCustomer)
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -412,8 +411,9 @@ export const processCSV = (fileOrContent: File | string): Promise<SalesRecord[]>
|
|||||||
const data: SalesRecord[] = results.data.map((row: any, index: number) => {
|
const data: SalesRecord[] = results.data.map((row: any, index: number) => {
|
||||||
return mapRowToRecord(row, index);
|
return mapRowToRecord(row, index);
|
||||||
})
|
})
|
||||||
// Filter: Valid Year >= 2023 (include full historical data) AND Allowed Customer
|
// Filter: Valid Year >= 2023 AND Allowed Customer AND non-zero units
|
||||||
.filter((r: SalesRecord) => r.year >= 2023 && isAllowedCustomer(r.customer));
|
// Excluding zero-unit records prevents ad spend or financial adjustments from inflating revenue.
|
||||||
|
.filter((r: SalesRecord) => r.year >= 2023 && isAllowedCustomer(r.customer) && r.units !== 0);
|
||||||
|
|
||||||
resolve(data);
|
resolve(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -440,8 +440,8 @@ export const processExcel = async (file: File): Promise<SalesRecord[]> => {
|
|||||||
const data: SalesRecord[] = jsonData.map((row: any, index: number) => {
|
const data: SalesRecord[] = jsonData.map((row: any, index: number) => {
|
||||||
return mapRowToRecord(row, index);
|
return mapRowToRecord(row, index);
|
||||||
})
|
})
|
||||||
// Filter: Valid Year AND Allowed Customer
|
// Filter: Valid Year AND Allowed Customer AND non-zero units
|
||||||
.filter((r: SalesRecord) => r.year > 0 && isAllowedCustomer(r.customer));
|
.filter((r: SalesRecord) => r.year > 0 && isAllowedCustomer(r.customer) && r.units !== 0);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user