mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 11:35:24 +02:00
feat: remove MKT and Experiments tabs and all related code
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
9f9c0c2975
commit
76d901fcde
@@ -1,115 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ActiveExperiment, ExperimentStatus } from '../types';
|
||||
import { getExperimentStatusColor, getExperimentTypeColor, getExperimentIcon } from '../services/experiments';
|
||||
|
||||
interface ExperimentBadgeProps {
|
||||
experiments: ActiveExperiment[];
|
||||
onClick?: (experimentId: string) => void;
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: ExperimentStatus) => {
|
||||
switch (status) {
|
||||
case 'active': return '🟢';
|
||||
case 'planned': return '🟡';
|
||||
case 'completed': return '⚪';
|
||||
case 'paused': return '⏸️';
|
||||
}
|
||||
};
|
||||
|
||||
export const ExperimentBadge: React.FC<ExperimentBadgeProps> = ({ experiments, onClick }) => {
|
||||
if (!experiments || experiments.length === 0) return null;
|
||||
|
||||
const activeExp = experiments.find(e => e.status === 'active');
|
||||
const plannedExp = experiments.find(e => e.status === 'planned');
|
||||
|
||||
// Priority: active > planned > past
|
||||
const displayExp = activeExp || plannedExp || experiments[0];
|
||||
|
||||
const daysText = displayExp.days_remaining !== undefined
|
||||
? displayExp.days_remaining > 0
|
||||
? `${displayExp.days_remaining}d left`
|
||||
: 'Ending soon'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
{experiments.length > 1 && (
|
||||
<span className="text-[9px] text-slate-500 mr-1">+{experiments.length}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(displayExp.experiment_id);
|
||||
}}
|
||||
className={`
|
||||
inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-medium
|
||||
border ${getExperimentStatusColor(displayExp.status)}
|
||||
hover:opacity-80 transition-opacity cursor-pointer
|
||||
`}
|
||||
title={`${displayExp.experiment_name}
|
||||
Type: ${displayExp.type}
|
||||
Start: ${displayExp.start_date}
|
||||
End: ${displayExp.end_date || 'Ongoing'}
|
||||
${daysText}`}
|
||||
>
|
||||
<span>{getExperimentIcon(displayExp.type)}</span>
|
||||
<span className="hidden xl:inline truncate max-w-[80px]">{displayExp.experiment_name}</span>
|
||||
{daysText && <span className="opacity-60">{daysText}</span>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ExperimentStatusBadgeProps {
|
||||
status: ExperimentStatus;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const ExperimentStatusBadge: React.FC<ExperimentStatusBadgeProps> = ({ status, size = 'md' }) => {
|
||||
const sizeClasses = {
|
||||
sm: 'px-1.5 py-0.5 text-[9px]',
|
||||
md: 'px-2 py-1 text-xs',
|
||||
lg: 'px-3 py-1.5 text-sm',
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
active: 'Active',
|
||||
planned: 'Planned',
|
||||
completed: 'Completed',
|
||||
paused: 'Paused',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full font-medium border ${getExperimentStatusColor(status)} ${sizeClasses[size]}`}>
|
||||
{getStatusBadge(status)} {statusLabels[status]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface ExperimentTypeBadgeProps {
|
||||
type: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const ExperimentTypeBadge: React.FC<ExperimentTypeBadgeProps> = ({ type, size = 'md' }) => {
|
||||
const sizeClasses = {
|
||||
sm: 'px-1.5 py-0.5 text-[9px]',
|
||||
md: 'px-2 py-1 text-xs',
|
||||
lg: 'px-3 py-1.5 text-sm',
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
pricing: 'Pricing',
|
||||
advertising: 'Advertising',
|
||||
content: 'Content',
|
||||
promotion: 'Promotion',
|
||||
seo: 'SEO',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full font-medium border ${getExperimentTypeColor(type as any)} ${sizeClasses[size]}`}>
|
||||
<span>{getExperimentIcon(type as any)}</span>
|
||||
{typeLabels[type as keyof typeof typeLabels] || type}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -1,634 +0,0 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
ReferenceDot
|
||||
} from 'recharts';
|
||||
import { WeeklyPivotRow } from '../services/dataProcessor';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface Experiment {
|
||||
id: string;
|
||||
week: string; // "2026-08" matching YYYY-WW format used in pivot data
|
||||
product_line: string;
|
||||
marketplace: string; // e.g. "Amazon DE", "Amazon UK"
|
||||
action_type: 'SEO' | 'Price' | 'Advertising' | 'Images' | 'A+ Content' | 'Variants' | 'Stock' | 'Other';
|
||||
description: string;
|
||||
expected_impact: 'More Sales' | 'Better Visibility' | 'Lower Costs';
|
||||
status: 'in_progress' | 'success' | 'failed';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type ActionType = Experiment['action_type'];
|
||||
type ExpectedImpact = Experiment['expected_impact'];
|
||||
type ExperimentStatus = Experiment['status'];
|
||||
|
||||
const ACTION_TYPES: ActionType[] = ['SEO', 'Price', 'Advertising', 'Images', 'A+ Content', 'Variants', 'Stock', 'Other'];
|
||||
const EXPECTED_IMPACTS: ExpectedImpact[] = ['More Sales', 'Better Visibility', 'Lower Costs'];
|
||||
const STATUSES: ExperimentStatus[] = ['in_progress', 'success', 'failed'];
|
||||
|
||||
const ACTION_COLORS: Record<ActionType, string> = {
|
||||
'SEO': '#3b82f6',
|
||||
'Price': '#f59e0b',
|
||||
'Advertising': '#f43f5e',
|
||||
'Images': '#a855f7',
|
||||
'A+ Content': '#10b981',
|
||||
'Variants': '#14b8a6',
|
||||
'Stock': '#f97316',
|
||||
'Other': '#64748b',
|
||||
};
|
||||
|
||||
const ACTION_BG_CLASSES: Record<ActionType, string> = {
|
||||
'SEO': 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
'Price': 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
'Advertising': 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
'Images': 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
'A+ Content': 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
'Variants': 'bg-teal-500/20 text-teal-400 border-teal-500/30',
|
||||
'Stock': 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||
'Other': 'bg-slate-500/20 text-slate-400 border-slate-500/30',
|
||||
};
|
||||
|
||||
const STATUS_CLASSES: Record<ExperimentStatus, string> = {
|
||||
'in_progress': 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
'success': 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
'failed': 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<ExperimentStatus, string> = {
|
||||
'in_progress': 'In Progress',
|
||||
'success': 'Success',
|
||||
'failed': 'Failed',
|
||||
};
|
||||
|
||||
const ALL_MARKETPLACES = ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES', 'Amazon UK'];
|
||||
|
||||
const STORAGE_KEY = 'craze_experiments';
|
||||
|
||||
// --- Storage helpers ---
|
||||
|
||||
const loadExperiments = (): Experiment[] => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const saveExperiments = (experiments: Experiment[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(experiments));
|
||||
};
|
||||
|
||||
// --- Current ISO week helper ---
|
||||
|
||||
const getCurrentWeek = (): string => {
|
||||
const now = new Date();
|
||||
const jan1 = new Date(now.getFullYear(), 0, 1);
|
||||
const dayOfYear = Math.floor((now.getTime() - jan1.getTime()) / 86400000) + 1;
|
||||
const weekNum = Math.ceil((dayOfYear + jan1.getDay()) / 7);
|
||||
return `${now.getFullYear()}-${String(weekNum).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// --- Props ---
|
||||
|
||||
interface ExperimentTrackerProps {
|
||||
rows: WeeklyPivotRow[];
|
||||
weeks: string[]; // sorted descending (most recent first)
|
||||
primaryMetric: 'units' | 'revenue';
|
||||
customerFilters: string[]; // active marketplace filters from parent
|
||||
}
|
||||
|
||||
// --- Custom dot for experiment markers ---
|
||||
|
||||
const ExperimentDot: React.FC<any> = (props) => {
|
||||
const { cx, cy, experiments } = props;
|
||||
if (!experiments || experiments.length === 0 || !cx || !cy) return null;
|
||||
|
||||
const color = ACTION_COLORS[experiments[0].action_type as ActionType] || '#64748b';
|
||||
|
||||
return (
|
||||
<g>
|
||||
<circle cx={cx} cy={cy} r={10} fill={color} fillOpacity={0.25} stroke={color} strokeWidth={1.5} />
|
||||
<circle cx={cx} cy={cy} r={4} fill={color} />
|
||||
{experiments.length > 1 && (
|
||||
<text x={cx + 12} y={cy - 4} fill={color} fontSize={9} fontWeight="bold">
|
||||
+{experiments.length - 1}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Custom chart tooltip ---
|
||||
|
||||
const ChartTooltipContent: React.FC<any> = ({ active, payload, label, experiments }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
const weekExperiments = experiments?.filter((e: Experiment) => e.week === label) || [];
|
||||
|
||||
return (
|
||||
<div className="bg-slate-950 border border-white/20 rounded-xl shadow-2xl p-3 max-w-xs">
|
||||
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 pb-2 border-b border-white/10">
|
||||
Week {label?.split('-')[1]}/{label?.split('-')[0]?.slice(-2)}
|
||||
</div>
|
||||
{payload.map((entry: any, i: number) => (
|
||||
<div key={i} className="flex justify-between items-center gap-4 mb-1">
|
||||
<span className="text-[10px] text-slate-500 font-bold">{entry.name}</span>
|
||||
<span className="text-xs font-black" style={{ color: entry.color }}>
|
||||
{entry.name === 'Revenue' ? `€${entry.value?.toLocaleString('de-DE')}` : entry.value?.toLocaleString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{weekExperiments.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-white/10 space-y-1.5">
|
||||
<div className="text-[9px] font-black text-fuchsia-400 uppercase tracking-widest">Experiments</div>
|
||||
{weekExperiments.map((exp: Experiment) => (
|
||||
<div key={exp.id} className="flex items-start gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full mt-1 flex-shrink-0"
|
||||
style={{ backgroundColor: ACTION_COLORS[exp.action_type] }}
|
||||
/>
|
||||
<div>
|
||||
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded border ${ACTION_BG_CLASSES[exp.action_type]}`}>
|
||||
{exp.action_type}
|
||||
</span>
|
||||
<span className="text-[10px] text-white/70 ml-1.5">{exp.product_line}</span>
|
||||
{exp.marketplace && <span className="text-[9px] text-slate-500 ml-1">({exp.marketplace})</span>}
|
||||
<p className="text-[10px] text-slate-400 mt-0.5 leading-tight">{exp.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
const ExperimentTracker: React.FC<ExperimentTrackerProps> = ({ rows, weeks, primaryMetric, customerFilters }) => {
|
||||
const [experiments, setExperiments] = useState<Experiment[]>(loadExperiments);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [showPanel, setShowPanel] = useState(true);
|
||||
const [editingStatus, setEditingStatus] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [formWeek, setFormWeek] = useState(getCurrentWeek);
|
||||
const [formLine, setFormLine] = useState('');
|
||||
const [formMarketplace, setFormMarketplace] = useState('');
|
||||
const [formAction, setFormAction] = useState<ActionType>('SEO');
|
||||
const [formDesc, setFormDesc] = useState('');
|
||||
const [formImpact, setFormImpact] = useState<ExpectedImpact>('More Sales');
|
||||
|
||||
// Default marketplace to active filter when opening form
|
||||
const defaultMarketplace = useMemo(() => {
|
||||
if (customerFilters.length === 1) return customerFilters[0];
|
||||
if (customerFilters.length > 0) return customerFilters[0];
|
||||
return ALL_MARKETPLACES[0];
|
||||
}, [customerFilters]);
|
||||
|
||||
// Available product lines from data
|
||||
const productLines = useMemo(() => {
|
||||
const lines = new Set(rows.map(r => r.line).filter(Boolean));
|
||||
return Array.from(lines).sort();
|
||||
}, [rows]);
|
||||
|
||||
// Chart data: aggregate totals by week (ascending order for chart)
|
||||
const chartData = useMemo(() => {
|
||||
const reversedWeeks = [...weeks].reverse(); // ascending for chart
|
||||
return reversedWeeks.map(week => {
|
||||
let units = 0;
|
||||
let revenue = 0;
|
||||
let spend = 0;
|
||||
for (const row of rows) {
|
||||
units += row.unitsByWeek[week] || 0;
|
||||
revenue += row.revenueByWeek[week] || 0;
|
||||
spend += row.spendByWeek[week] || 0;
|
||||
}
|
||||
return { week, units, revenue, spend };
|
||||
});
|
||||
}, [rows, weeks]);
|
||||
|
||||
// Weeks with experiments (for ReferenceDots)
|
||||
const weekExperimentMap = useMemo(() => {
|
||||
const map = new Map<string, Experiment[]>();
|
||||
for (const exp of experiments) {
|
||||
const existing = map.get(exp.week) || [];
|
||||
existing.push(exp);
|
||||
map.set(exp.week, existing);
|
||||
}
|
||||
return map;
|
||||
}, [experiments]);
|
||||
|
||||
// Compute delta % for an experiment
|
||||
const computeDelta = useCallback((exp: Experiment): { baseline: number | null; post: number | null; delta: number | null } => {
|
||||
const ascWeeks = [...weeks].reverse();
|
||||
const weekIdx = ascWeeks.indexOf(exp.week);
|
||||
if (weekIdx === -1) return { baseline: null, post: null, delta: null };
|
||||
|
||||
const getWeekTotal = (w: string) => {
|
||||
let total = 0;
|
||||
for (const row of rows) {
|
||||
if (row.line !== exp.product_line) continue;
|
||||
if (exp.marketplace && row.customer !== exp.marketplace) continue;
|
||||
total += primaryMetric === 'units'
|
||||
? (row.unitsByWeek[w] || 0)
|
||||
: (row.revenueByWeek[w] || 0);
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
// 3 weeks before
|
||||
const beforeWeeks = ascWeeks.slice(Math.max(0, weekIdx - 3), weekIdx);
|
||||
// 3 weeks after
|
||||
const afterWeeks = ascWeeks.slice(weekIdx + 1, weekIdx + 4);
|
||||
|
||||
if (beforeWeeks.length === 0) return { baseline: null, post: null, delta: null };
|
||||
|
||||
const baselineSum = beforeWeeks.reduce((sum, w) => sum + getWeekTotal(w), 0);
|
||||
const baselineAvg = baselineSum / beforeWeeks.length;
|
||||
|
||||
if (afterWeeks.length === 0) return { baseline: baselineAvg, post: null, delta: null };
|
||||
|
||||
const postSum = afterWeeks.reduce((sum, w) => sum + getWeekTotal(w), 0);
|
||||
const postAvg = postSum / afterWeeks.length;
|
||||
|
||||
const delta = baselineAvg > 0 ? ((postAvg - baselineAvg) / baselineAvg) * 100 : null;
|
||||
|
||||
return { baseline: baselineAvg, post: postAvg, delta };
|
||||
}, [rows, weeks, primaryMetric]);
|
||||
|
||||
// Persist experiments
|
||||
const updateExperiments = useCallback((updated: Experiment[]) => {
|
||||
setExperiments(updated);
|
||||
saveExperiments(updated);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!formLine || !formMarketplace || !formDesc.trim()) return;
|
||||
|
||||
const newExp: Experiment = {
|
||||
id: `exp_${Date.now()}`,
|
||||
week: formWeek,
|
||||
product_line: formLine,
|
||||
marketplace: formMarketplace,
|
||||
action_type: formAction,
|
||||
description: formDesc.trim(),
|
||||
expected_impact: formImpact,
|
||||
status: 'in_progress',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
updateExperiments([...experiments, newExp]);
|
||||
setShowForm(false);
|
||||
setFormDesc('');
|
||||
setFormWeek(getCurrentWeek());
|
||||
}, [formWeek, formLine, formMarketplace, formAction, formDesc, formImpact, experiments, updateExperiments]);
|
||||
|
||||
const handleStatusChange = useCallback((id: string, newStatus: ExperimentStatus) => {
|
||||
updateExperiments(experiments.map(e => e.id === id ? { ...e, status: newStatus } : e));
|
||||
setEditingStatus(null);
|
||||
}, [experiments, updateExperiments]);
|
||||
|
||||
const handleDelete = useCallback((id: string) => {
|
||||
updateExperiments(experiments.filter(e => e.id !== id));
|
||||
}, [experiments, updateExperiments]);
|
||||
|
||||
// Available weeks for the form dropdown
|
||||
const availableWeeks = useMemo(() => [...weeks].slice(0, 20), [weeks]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header + Log Action Button */}
|
||||
<div className="flex items-center justify-between bg-slate-900 border border-white/10 p-3 px-4 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-fuchsia-500 to-indigo-600 flex items-center justify-center shadow-lg shadow-fuchsia-500/20">
|
||||
<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.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-white uppercase tracking-wider">Experiment Tracker</h3>
|
||||
<p className="text-[10px] text-slate-500 font-bold">{experiments.length} experiments logged</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setFormMarketplace(defaultMarketplace); setShowForm(true); }}
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-xs font-bold transition-colors border border-indigo-500/50 shadow-lg shadow-indigo-500/20"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Log Action
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
{chartData.length > 0 && (
|
||||
<div className="bg-slate-900 border border-white/10 rounded-xl p-4 shadow-lg">
|
||||
<div className="text-[10px] font-black text-slate-500 uppercase tracking-widest mb-3">
|
||||
Total {primaryMetric === 'units' ? 'Units' : 'Revenue'} by Week
|
||||
{experiments.length > 0 && (
|
||||
<span className="ml-2 text-fuchsia-400">
|
||||
— {experiments.length} experiment{experiments.length !== 1 ? 's' : ''} marked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={chartData} margin={{ top: 10, right: 20, left: 10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="week"
|
||||
stroke="#64748b"
|
||||
tick={{ fontSize: 9, fontWeight: 'bold' }}
|
||||
tickFormatter={(w: string) => `${w.split('-')[1]}/${w.split('-')[0].slice(-2)}`}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#64748b"
|
||||
tick={{ fontSize: 9 }}
|
||||
tickFormatter={(v: number) => primaryMetric === 'revenue' ? `€${(v / 1000).toFixed(0)}k` : v.toLocaleString('de-DE')}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<ChartTooltipContent experiments={experiments} />}
|
||||
cursor={{ stroke: '#6366f1', strokeWidth: 1, strokeDasharray: '4 4' }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={primaryMetric}
|
||||
stroke="#6366f1"
|
||||
strokeWidth={2.5}
|
||||
dot={{ fill: '#6366f1', r: 3, strokeWidth: 0 }}
|
||||
activeDot={{ r: 5, fill: '#818cf8', strokeWidth: 2, stroke: '#6366f1' }}
|
||||
name={primaryMetric === 'units' ? 'Units' : 'Revenue'}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="spend"
|
||||
stroke="#f43f5e"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 3"
|
||||
dot={false}
|
||||
name="Ads Spend"
|
||||
/>
|
||||
{/* Experiment markers */}
|
||||
{chartData.map((point) => {
|
||||
const exps = weekExperimentMap.get(point.week);
|
||||
if (!exps || exps.length === 0) return null;
|
||||
return (
|
||||
<ReferenceDot
|
||||
key={point.week}
|
||||
x={point.week}
|
||||
y={point[primaryMetric]}
|
||||
shape={<ExperimentDot experiments={exps} />}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Action Modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowForm(false)}>
|
||||
<div className="bg-slate-900 border border-white/15 rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="text-base font-black text-white uppercase tracking-wider">Log Experiment</h3>
|
||||
<button onClick={() => setShowForm(false)} className="text-slate-500 hover:text-white transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Week */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1.5">Week</label>
|
||||
<select
|
||||
value={formWeek}
|
||||
onChange={e => setFormWeek(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
{availableWeeks.map(w => (
|
||||
<option key={w} value={w}>Week {w.split('-')[1]}/{w.split('-')[0]} {w === getCurrentWeek() ? '(Current)' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Product Line */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1.5">Product Line</label>
|
||||
<select
|
||||
value={formLine}
|
||||
onChange={e => setFormLine(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">Select product line...</option>
|
||||
{productLines.map(line => (
|
||||
<option key={line} value={line}>{line}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Marketplace */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1.5">Marketplace</label>
|
||||
<select
|
||||
value={formMarketplace}
|
||||
onChange={e => setFormMarketplace(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">Select marketplace...</option>
|
||||
{ALL_MARKETPLACES.map(mk => (
|
||||
<option key={mk} value={mk}>{mk}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Action Type */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1.5">Action Type</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ACTION_TYPES.map(type => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setFormAction(type)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold border transition-all ${formAction === type
|
||||
? ACTION_BG_CLASSES[type] + ' ring-1 ring-white/20'
|
||||
: 'bg-slate-800 text-slate-500 border-white/5 hover:border-white/15'
|
||||
}`}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1.5">Description</label>
|
||||
<textarea
|
||||
value={formDesc}
|
||||
onChange={e => setFormDesc(e.target.value)}
|
||||
placeholder="e.g., Added keyword 'caja musical' to title..."
|
||||
rows={3}
|
||||
className="w-full bg-slate-950 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expected Impact */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1.5">Expected Impact</label>
|
||||
<div className="flex gap-2">
|
||||
{EXPECTED_IMPACTS.map(impact => (
|
||||
<button
|
||||
key={impact}
|
||||
onClick={() => setFormImpact(impact)}
|
||||
className={`flex-1 px-3 py-2 rounded-lg text-xs font-bold border transition-all ${formImpact === impact
|
||||
? 'bg-indigo-600/20 text-indigo-400 border-indigo-500/30 ring-1 ring-indigo-500/20'
|
||||
: 'bg-slate-800 text-slate-500 border-white/5 hover:border-white/15'
|
||||
}`}
|
||||
>
|
||||
{impact}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-white/10">
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
className="px-4 py-2 rounded-lg text-xs font-bold text-slate-400 bg-slate-800 hover:bg-slate-700 border border-white/5 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!formLine || !formMarketplace || !formDesc.trim()}
|
||||
className="px-5 py-2 rounded-lg text-xs font-bold text-white bg-indigo-600 hover:bg-indigo-500 border border-indigo-500/50 shadow-lg shadow-indigo-500/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Save Experiment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Experiments Panel */}
|
||||
{experiments.length > 0 && (
|
||||
<div className="bg-slate-900 border border-white/10 rounded-xl shadow-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowPanel(!showPanel)}
|
||||
className="w-full flex items-center justify-between p-3 px-4 hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||
Experiments Log ({experiments.length})
|
||||
</span>
|
||||
</div>
|
||||
<svg className={`w-4 h-4 text-slate-500 transition-transform ${showPanel ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showPanel && (
|
||||
<div className="overflow-x-auto border-t border-white/5">
|
||||
<table className="w-full text-left border-collapse min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Week</th>
|
||||
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Marketplace</th>
|
||||
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Product Line</th>
|
||||
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Action</th>
|
||||
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Description</th>
|
||||
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Expected</th>
|
||||
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Status</th>
|
||||
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest text-right">Delta %</th>
|
||||
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{[...experiments].sort((a, b) => b.week.localeCompare(a.week)).map(exp => {
|
||||
const { delta } = computeDelta(exp);
|
||||
return (
|
||||
<tr key={exp.id} className="hover:bg-white/[0.02] transition-colors">
|
||||
<td className="p-2.5 px-4 text-xs font-bold text-white whitespace-nowrap">
|
||||
W{exp.week.split('-')[1]}/{exp.week.split('-')[0].slice(-2)}
|
||||
</td>
|
||||
<td className="p-2.5 px-4">
|
||||
<span className="text-[10px] text-sky-400 font-bold">{exp.marketplace || '—'}</span>
|
||||
</td>
|
||||
<td className="p-2.5 px-4">
|
||||
<span className="text-[10px] text-fuchsia-400 font-bold uppercase tracking-widest">{exp.product_line}</span>
|
||||
</td>
|
||||
<td className="p-2.5 px-4">
|
||||
<span className={`text-[10px] font-bold px-2 py-1 rounded border ${ACTION_BG_CLASSES[exp.action_type]}`}>
|
||||
{exp.action_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2.5 px-4 text-xs text-white/70 max-w-[250px] truncate" title={exp.description}>
|
||||
{exp.description}
|
||||
</td>
|
||||
<td className="p-2.5 px-4 text-[10px] text-slate-400 font-bold whitespace-nowrap">
|
||||
{exp.expected_impact}
|
||||
</td>
|
||||
<td className="p-2.5 px-4">
|
||||
{editingStatus === exp.id ? (
|
||||
<select
|
||||
value={exp.status}
|
||||
onChange={e => handleStatusChange(exp.id, e.target.value as ExperimentStatus)}
|
||||
onBlur={() => setEditingStatus(null)}
|
||||
autoFocus
|
||||
className="bg-slate-950 border border-white/10 rounded px-2 py-1 text-[10px] text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
{STATUSES.map(s => (
|
||||
<option key={s} value={s}>{STATUS_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setEditingStatus(exp.id)}
|
||||
className={`text-[10px] font-bold px-2 py-1 rounded border cursor-pointer hover:ring-1 hover:ring-white/20 transition-all ${STATUS_CLASSES[exp.status]}`}
|
||||
>
|
||||
{STATUS_LABELS[exp.status]}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2.5 px-4 text-right">
|
||||
{delta !== null ? (
|
||||
<span className={`text-xs font-black ${delta >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{delta >= 0 ? '+' : ''}{delta.toFixed(1)}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-600 italic">Pending</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2.5 px-4">
|
||||
<button
|
||||
onClick={() => handleDelete(exp.id)}
|
||||
className="text-slate-600 hover:text-red-400 transition-colors"
|
||||
title="Delete experiment"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExperimentTracker;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,8 +11,6 @@ interface MetricDetailTooltipProps {
|
||||
metricName: string;
|
||||
metricColor: string;
|
||||
formatValue?: (val: number) => string;
|
||||
experimentDelta?: number | null;
|
||||
baselineValue?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,9 +27,7 @@ export const MetricDetailTooltip: React.FC<MetricDetailTooltipProps> = ({
|
||||
yoyWeekLabel,
|
||||
metricName,
|
||||
metricColor,
|
||||
formatValue,
|
||||
experimentDelta,
|
||||
baselineValue
|
||||
formatValue,
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
@@ -100,21 +96,6 @@ export const MetricDetailTooltip: React.FC<MetricDetailTooltipProps> = ({
|
||||
)}
|
||||
</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 */}
|
||||
|
||||
@@ -11,9 +11,7 @@ import { VendorStockBadge } from './VendorStockBadge';
|
||||
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
|
||||
import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons';
|
||||
import { ExcelFilter } from './ExcelFilter';
|
||||
import { ExperimentBadge } from './ExperimentBadge';
|
||||
import { WeeklyRow } from './WeeklyRow';
|
||||
import { ActiveExperiment } from '../types';
|
||||
|
||||
interface WeeklyGridProps {
|
||||
data: CombinedKPIs[];
|
||||
@@ -34,8 +32,6 @@ interface WeeklyGridProps {
|
||||
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 = {
|
||||
@@ -76,8 +72,6 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
||||
top50Mode,
|
||||
velocityMap,
|
||||
buyBoxLostMap,
|
||||
experimentMap,
|
||||
onOpenExperiment
|
||||
}) => {
|
||||
// Pivot data - memoized
|
||||
const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
|
||||
@@ -91,7 +85,6 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
||||
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);
|
||||
@@ -215,15 +208,6 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
||||
});
|
||||
}
|
||||
|
||||
// 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]) => {
|
||||
@@ -343,7 +327,7 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [rows, debouncedSearch, showOnlyTop50, showActiveExperiments, experimentMap, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks, top50Mode, wocFilter, velocityMap, vendorStockMap, numericFilters, columnFilters]);
|
||||
}, [rows, debouncedSearch, showOnlyTop50, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks, top50Mode, wocFilter, velocityMap, vendorStockMap, numericFilters, columnFilters]);
|
||||
|
||||
const aggregateWoc = useMemo(() => {
|
||||
if (!vendorStockMap || !velocityMap || filteredRows.length === 0) return null;
|
||||
@@ -524,25 +508,6 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
||||
</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
|
||||
@@ -848,8 +813,6 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
||||
velocityMap={velocityMap}
|
||||
buyBoxLostMap={buyBoxLostMap}
|
||||
primaryMetric={primaryMetric}
|
||||
experimentMap={experimentMap}
|
||||
onOpenExperiment={onOpenExperiment}
|
||||
/>
|
||||
))}
|
||||
{displayCount < sortedRows.length && (
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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';
|
||||
|
||||
@@ -26,61 +24,12 @@ interface WeeklyRowProps {
|
||||
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
|
||||
export const WeeklyRow: React.FC<WeeklyRowProps> = React.memo(({
|
||||
row, weeks, onDrillDown, stockMap, top50Ranking, top50Mode, sortConfig,
|
||||
renderGrowth, customerFilters, vendorStockMap, velocityMap, buyBoxLostMap,
|
||||
primaryMetric
|
||||
}) => {
|
||||
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
||||
const asin = row.asin.trim().toUpperCase();
|
||||
@@ -126,12 +75,6 @@ export const WeeklyRow: React.FC<WeeklyRowProps> = React.memo(({
|
||||
internalStock={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>
|
||||
@@ -158,33 +101,8 @@ export const WeeklyRow: React.FC<WeeklyRowProps> = React.memo(({
|
||||
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}`}>
|
||||
<td key={week} className={`p-3 py-2 text-center border-r border-white/5 align-middle ${sortConfig?.key === week ? 'bg-white/[0.01]' : ''}`}>
|
||||
{primaryMetric === 'units' ? (
|
||||
<MetricDetailTooltip
|
||||
currentValue={val}
|
||||
@@ -195,8 +113,6 @@ export const WeeklyRow: React.FC<WeeklyRowProps> = React.memo(({
|
||||
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'}`}>
|
||||
@@ -216,8 +132,6 @@ export const WeeklyRow: React.FC<WeeklyRowProps> = React.memo(({
|
||||
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'}`}>
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import React from 'react';
|
||||
import { formatCurrency, formatPercent, calculateProductMetrics } from './utils';
|
||||
import { Product } from './types';
|
||||
import { DollarSign, TrendingUp, AlertTriangle, Activity } from 'lucide-react';
|
||||
|
||||
interface KPICardsProps {
|
||||
products: Product[];
|
||||
includeCOGS: boolean;
|
||||
}
|
||||
|
||||
export const KPICards: React.FC<KPICardsProps> = ({ products, includeCOGS }) => {
|
||||
const totals = products.reduce(
|
||||
(acc, product) => {
|
||||
const metrics = calculateProductMetrics(product, includeCOGS);
|
||||
acc.grossSales += product.grossSales;
|
||||
acc.netMargin += metrics.netMargin;
|
||||
acc.ppcSpend += product.ppcSpend;
|
||||
acc.chargebacks += product.chargebacks;
|
||||
return acc;
|
||||
},
|
||||
{ grossSales: 0, netMargin: 0, ppcSpend: 0, chargebacks: 0 }
|
||||
);
|
||||
|
||||
const marginPercent = totals.grossSales > 0 ? totals.netMargin / totals.grossSales : 0;
|
||||
const tacos = totals.grossSales > 0 ? totals.ppcSpend / totals.grossSales : 0;
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: 'Total Sales',
|
||||
value: formatCurrency(totals.grossSales),
|
||||
icon: <DollarSign className="w-6 h-6 text-emerald-400" />,
|
||||
description: 'Gross revenue for the period',
|
||||
},
|
||||
{
|
||||
title: 'Est. Net Margin',
|
||||
value: formatPercent(marginPercent),
|
||||
subValue: formatCurrency(totals.netMargin),
|
||||
icon: <TrendingUp className="w-6 h-6 text-indigo-400" />,
|
||||
description: includeCOGS ? 'After COGS and expenses' : 'Before COGS',
|
||||
},
|
||||
{
|
||||
title: 'TACOS',
|
||||
value: formatPercent(tacos),
|
||||
icon: <Activity className="w-6 h-6 text-purple-400" />,
|
||||
description: 'Total advertising cost / Sales',
|
||||
},
|
||||
{
|
||||
title: 'Total Chargebacks',
|
||||
value: formatCurrency(totals.chargebacks),
|
||||
icon: <AlertTriangle className="w-6 h-6 text-rose-400" />,
|
||||
description: 'Logistics issues / returns',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{cards.map((card, idx) => (
|
||||
<div key={idx} className="bg-[#13161F] rounded-xl border border-[#1F2433] p-6 flex flex-col">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">{card.title}</h3>
|
||||
<div className="p-2 bg-[#0A0C10] rounded-lg border border-[#1F2433]">{card.icon}</div>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-white">{card.value}</span>
|
||||
{card.subValue && (
|
||||
<span className="text-sm font-medium text-slate-400">({card.subValue})</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-2">{card.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,286 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Product } from './types';
|
||||
import { formatCurrency, formatPercent, calculateProductMetrics, cn } from './utils';
|
||||
import { ArrowUpDown, AlertCircle, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { StockBadge } from '../StockBadge';
|
||||
import { VendorStockBadge } from '../VendorStockBadge';
|
||||
import { BuyBoxWarningBadge } from '../BuyBoxWarningBadge';
|
||||
import { Top50Badge } from '../Top50Badge';
|
||||
|
||||
interface MasterTableProps {
|
||||
products: Product[];
|
||||
includeCOGS: boolean;
|
||||
onProductClick: (product: Product) => void;
|
||||
stockMap?: Map<string, number>;
|
||||
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
||||
top50Mode?: 'eu' | 'uk';
|
||||
velocityMap?: Map<string, number>;
|
||||
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
|
||||
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
|
||||
}
|
||||
|
||||
type SortKey = 'name' | 'grossSales' | 'ppcSpend' | 'deals' | 'promos' | 'chargebacks' | 'netMargin' | 'marginPercent';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
export const MasterTable: React.FC<MasterTableProps> = ({
|
||||
products,
|
||||
includeCOGS,
|
||||
onProductClick,
|
||||
stockMap,
|
||||
vendorStockMap,
|
||||
top50Mode = 'eu',
|
||||
velocityMap,
|
||||
buyBoxLostMap,
|
||||
top50Ranking
|
||||
}) => {
|
||||
const [sortKey, setSortKey] = useState<SortKey>('marginPercent');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortOrder('desc'); // Default to desc for new sort
|
||||
}
|
||||
};
|
||||
|
||||
const totals = useMemo(() => {
|
||||
return products.reduce((acc, product) => {
|
||||
const metrics = calculateProductMetrics(product, includeCOGS);
|
||||
acc.grossSales += product.grossSales;
|
||||
acc.ppcSpend += product.ppcSpend;
|
||||
acc.deals += product.deals;
|
||||
acc.promos += product.promos;
|
||||
acc.chargebacks += product.chargebacks;
|
||||
acc.netMargin += metrics.netMargin;
|
||||
return acc;
|
||||
}, { grossSales: 0, ppcSpend: 0, deals: 0, promos: 0, chargebacks: 0, netMargin: 0 });
|
||||
}, [products, includeCOGS]);
|
||||
|
||||
const totalMarginPercent = totals.grossSales > 0 ? totals.netMargin / totals.grossSales : 0;
|
||||
const totalAcos = totals.grossSales > 0 ? totals.ppcSpend / totals.grossSales : 0;
|
||||
|
||||
const sortedProducts = useMemo(() => {
|
||||
return products.slice().sort((a, b) => {
|
||||
const metricsA = calculateProductMetrics(a, includeCOGS);
|
||||
const metricsB = calculateProductMetrics(b, includeCOGS);
|
||||
|
||||
let valA: number | string;
|
||||
let valB: number | string;
|
||||
|
||||
switch (sortKey) {
|
||||
case 'name':
|
||||
valA = a.name;
|
||||
valB = b.name;
|
||||
break;
|
||||
case 'grossSales':
|
||||
valA = a.grossSales;
|
||||
valB = b.grossSales;
|
||||
break;
|
||||
case 'ppcSpend':
|
||||
valA = a.ppcSpend;
|
||||
valB = b.ppcSpend;
|
||||
break;
|
||||
case 'deals':
|
||||
valA = a.deals;
|
||||
valB = b.deals;
|
||||
break;
|
||||
case 'promos':
|
||||
valA = a.promos;
|
||||
valB = b.promos;
|
||||
break;
|
||||
case 'chargebacks':
|
||||
valA = a.chargebacks;
|
||||
valB = b.chargebacks;
|
||||
break;
|
||||
case 'netMargin':
|
||||
valA = metricsA.netMargin;
|
||||
valB = metricsB.netMargin;
|
||||
break;
|
||||
case 'marginPercent':
|
||||
valA = metricsA.marginPercent;
|
||||
valB = metricsB.marginPercent;
|
||||
break;
|
||||
default:
|
||||
valA = 0;
|
||||
valB = 0;
|
||||
}
|
||||
|
||||
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [products, includeCOGS, sortKey, sortOrder]);
|
||||
|
||||
const SortIcon = ({ columnKey }: { columnKey: SortKey }) => {
|
||||
if (sortKey !== columnKey) return <ArrowUpDown className="w-4 h-4 text-slate-600 ml-1 inline-block" />;
|
||||
return sortOrder === 'asc' ?
|
||||
<ChevronUp className="w-4 h-4 text-indigo-400 ml-1 inline-block" /> :
|
||||
<ChevronDown className="w-4 h-4 text-indigo-400 ml-1 inline-block" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[#13161F] rounded-xl border border-[#1F2433] overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||
<thead className="bg-[#0A0C10] border-b border-[#1F2433] text-slate-400 font-medium text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors" onClick={() => handleSort('name')}>
|
||||
Product <SortIcon columnKey="name" />
|
||||
</th>
|
||||
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('grossSales')}>
|
||||
SELL OUT <SortIcon columnKey="grossSales" />
|
||||
</th>
|
||||
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('ppcSpend')}>
|
||||
PPC Spend (ACOS) <SortIcon columnKey="ppcSpend" />
|
||||
</th>
|
||||
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('deals')}>
|
||||
Deals <SortIcon columnKey="deals" />
|
||||
</th>
|
||||
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('promos')}>
|
||||
Promos <SortIcon columnKey="promos" />
|
||||
</th>
|
||||
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('chargebacks')}>
|
||||
Op. Chargebacks <SortIcon columnKey="chargebacks" />
|
||||
</th>
|
||||
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('netMargin')}>
|
||||
Est. Margin ($) <SortIcon columnKey="netMargin" />
|
||||
</th>
|
||||
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('marginPercent')}>
|
||||
Margin (%) <SortIcon columnKey="marginPercent" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#1F2433]">
|
||||
{/* Totals Row */}
|
||||
<tr className="bg-[#1A1E2A] border-b-2 border-[#2D3348] font-semibold">
|
||||
<td className="px-6 py-4 text-slate-200 uppercase tracking-wider text-xs">TOTALS</td>
|
||||
<td className="px-6 py-4 text-right text-slate-200">{formatCurrency(totals.grossSales)}</td>
|
||||
<td className="px-6 py-4 text-right text-slate-200">
|
||||
{formatCurrency(totals.ppcSpend)}
|
||||
<div className="text-xs text-amber-400 font-normal mt-0.5">ACOS: {formatPercent(totalAcos)}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-slate-200">{formatCurrency(totals.deals)}</td>
|
||||
<td className="px-6 py-4 text-right text-slate-200">{formatCurrency(totals.promos)}</td>
|
||||
<td className="px-6 py-4 text-right text-slate-200">{formatCurrency(totals.chargebacks)}</td>
|
||||
<td className="px-6 py-4 text-right text-emerald-400">{formatCurrency(totals.netMargin)}</td>
|
||||
<td className="px-6 py-4 text-right text-slate-200">
|
||||
<span className={cn(
|
||||
"inline-flex items-center px-2.5 py-1 rounded-md text-xs font-semibold",
|
||||
totalMarginPercent >= 0.20 ? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20" :
|
||||
totalMarginPercent >= 0.10 ? "bg-amber-500/10 text-amber-400 border border-amber-500/20" :
|
||||
"bg-rose-500/10 text-rose-400 border border-rose-500/20"
|
||||
)}>
|
||||
{formatPercent(totalMarginPercent)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Product Rows */}
|
||||
{(() => {
|
||||
const maxSales = products.reduce((max, p) => Math.max(max, p.grossSales), 0);
|
||||
return sortedProducts.map((product) => {
|
||||
const metrics = calculateProductMetrics(product, includeCOGS);
|
||||
|
||||
// Data bar width calculation (relative to max sales)
|
||||
const salesBarWidth = maxSales > 0 ? `${(product.grossSales / maxSales) * 100}%` : '0%';
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={product.id}
|
||||
className="hover:bg-[#1A1E2A] transition-colors cursor-pointer group"
|
||||
onClick={() => onProductClick(product)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-normal min-w-[300px] max-w-[400px]">
|
||||
<div className="font-medium text-slate-200 group-hover:text-indigo-400 transition-colors line-clamp-2" title={product.name}>{product.name}</div>
|
||||
<div className="text-xs text-slate-500 font-mono mt-0.5 mb-2">{product.sku} | {product.asin}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{stockMap && (
|
||||
<StockBadge stock={stockMap.get(product.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||
)}
|
||||
{(() => {
|
||||
const asin = product.asin.trim().toUpperCase();
|
||||
if (asin && top50Ranking) {
|
||||
if (top50Mode === 'eu') {
|
||||
const rank = top50Ranking.eu.get(asin);
|
||||
if (rank) return <Top50Badge rank={rank} label="EU" theme="indigo" />;
|
||||
} else {
|
||||
const rank = top50Ranking.uk.get(asin);
|
||||
if (rank) return <Top50Badge rank={rank} label="UK" theme="blue" />;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
<VendorStockBadge
|
||||
asin={product.asin}
|
||||
vendorStockMap={vendorStockMap}
|
||||
mode={top50Mode}
|
||||
avgWeeklySales={velocityMap?.get(product.asin.trim().toUpperCase())}
|
||||
internalStock={stockMap?.get(product.sku?.replace(/(DE|EN)$/i, ''))}
|
||||
/>
|
||||
<BuyBoxWarningBadge asin={product.asin} buyBoxLostMap={buyBoxLostMap} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-medium text-slate-200">{formatCurrency(product.grossSales)}</span>
|
||||
<div className="w-24 h-1.5 bg-[#1F2433] rounded-full mt-1.5 overflow-hidden flex justify-end">
|
||||
<div className="h-full bg-indigo-500 rounded-full" style={{ width: salesBarWidth }} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="font-medium text-slate-200">{formatCurrency(product.ppcSpend)}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">ACOS: <span className="text-amber-400">{formatPercent(metrics.acos)}</span></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="font-medium text-slate-200">{formatCurrency(product.deals)}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="font-medium text-slate-200">{formatCurrency(product.promos)}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{metrics.hasChargebackAnomaly && (
|
||||
<div className="group/tooltip relative" title={`${formatPercent(metrics.chargebackIncrease)} increase vs previous month`}>
|
||||
<AlertCircle className="w-4 h-4 text-rose-500" />
|
||||
</div>
|
||||
)}
|
||||
<span className={cn(
|
||||
"font-medium",
|
||||
metrics.hasChargebackAnomaly ? "text-rose-400" : "text-slate-200"
|
||||
)}>
|
||||
{formatCurrency(product.chargebacks)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<span className={cn(
|
||||
"font-medium",
|
||||
metrics.netMargin < 0 ? "text-rose-400" : "text-emerald-400"
|
||||
)}>
|
||||
{formatCurrency(metrics.netMargin)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className={cn(
|
||||
"inline-flex items-center px-2.5 py-1 rounded-md text-xs font-semibold",
|
||||
metrics.marginPercent >= 0.20 ? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20" :
|
||||
metrics.marginPercent >= 0.10 ? "bg-amber-500/10 text-amber-400 border border-amber-500/20" :
|
||||
"bg-rose-500/10 text-rose-400 border border-rose-500/20"
|
||||
)}>
|
||||
{metrics.marginPercent > 0 ? '▲' : '▼'} {formatPercent(Math.abs(metrics.marginPercent))}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})})()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,349 +0,0 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { SalesRecord, AdsRecord } from '../../types';
|
||||
import { Product } from './types';
|
||||
import { KPICards } from './KPICards';
|
||||
import { MasterTable } from './MasterTable';
|
||||
import { WaterfallModal } from './WaterfallModal';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Excel parsing helpers (all files use raw: true so numbers come back as JS numbers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseNumericCell(val: unknown): number {
|
||||
if (typeof val === 'number') return isNaN(val) ? 0 : val;
|
||||
if (typeof val === 'string') {
|
||||
const clean = val.replace(/[€$£\s]/g, '').trim();
|
||||
if (!clean) return 0;
|
||||
// EU format: "1.234,56" — comma is decimal, dot is thousands
|
||||
if (clean.includes(',') && clean.includes('.') && clean.indexOf(',') > clean.indexOf('.')) {
|
||||
return parseFloat(clean.replace(/\./g, '').replace(',', '.')) || 0;
|
||||
}
|
||||
// EU format: "263,83" — only comma, treat as decimal
|
||||
if (clean.includes(',') && !clean.includes('.')) {
|
||||
return parseFloat(clean.replace(',', '.')) || 0;
|
||||
}
|
||||
return parseFloat(clean.replace(/,/g, '')) || 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Deals file: ASIN = col A (idx 0), total deal cost = col C (idx 2)
|
||||
function parseDealsExcel(buffer: ArrayBuffer): Map<string, number> {
|
||||
const wb = XLSX.read(buffer, { type: 'array' });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { header: 1, raw: true });
|
||||
const map = new Map<string, number>();
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const row = rows[i] as unknown[];
|
||||
const asin = row[0];
|
||||
const cost = row[2];
|
||||
if (asin && typeof asin === 'string' && asin.trim()) {
|
||||
const key = asin.trim().toUpperCase();
|
||||
map.set(key, (map.get(key) || 0) + parseNumericCell(cost));
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// Promos file: ASIN = col E (idx 4), promo cost = col K (idx 10)
|
||||
function parsePromosExcel(buffer: ArrayBuffer): Map<string, number> {
|
||||
const wb = XLSX.read(buffer, { type: 'array' });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { header: 1, raw: true });
|
||||
const map = new Map<string, number>();
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const row = rows[i] as unknown[];
|
||||
const asin = row[4];
|
||||
const cost = row[10];
|
||||
if (asin && typeof asin === 'string' && asin.trim()) {
|
||||
const key = asin.trim().toUpperCase();
|
||||
map.set(key, (map.get(key) || 0) + parseNumericCell(cost));
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// Chargebacks file: ASIN = col AN (idx 39), chargeback cost = col B (idx 1)
|
||||
function parseChargebacksExcel(buffer: ArrayBuffer): Map<string, number> {
|
||||
const wb = XLSX.read(buffer, { type: 'array' });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { header: 1, raw: true });
|
||||
const map = new Map<string, number>();
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const row = rows[i] as unknown[];
|
||||
const asin = row[39];
|
||||
const cost = row[1];
|
||||
if (asin && typeof asin === 'string' && asin.trim()) {
|
||||
const key = asin.trim().toUpperCase();
|
||||
map.set(key, (map.get(key) || 0) + parseNumericCell(cost));
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MktDataViewProps {
|
||||
rawData: SalesRecord[];
|
||||
adsData: AdsRecord[];
|
||||
stockMap?: Map<string, number>;
|
||||
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
||||
top50Mode?: 'eu' | 'uk';
|
||||
velocityMap?: Map<string, number>;
|
||||
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
|
||||
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
|
||||
}
|
||||
|
||||
export default function MktDataView({
|
||||
rawData,
|
||||
adsData,
|
||||
stockMap,
|
||||
vendorStockMap,
|
||||
top50Mode = 'eu',
|
||||
velocityMap,
|
||||
buyBoxLostMap,
|
||||
top50Ranking
|
||||
}: MktDataViewProps) {
|
||||
const [includeCOGS, setIncludeCOGS] = useState(true);
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
|
||||
const [mktLoading, setMktLoading] = useState(true);
|
||||
const [dealsMap, setDealsMap] = useState<Map<string, number>>(new Map());
|
||||
const [promosMap, setPromosMap] = useState<Map<string, number>>(new Map());
|
||||
const [chargebacksMap, setChargebacksMap] = useState<Map<string, number>>(new Map());
|
||||
|
||||
// Fetch the 3 new marketing data files once on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchAll = async () => {
|
||||
setMktLoading(true);
|
||||
try {
|
||||
const [dealsRes, promosRes, chargesRes] = await Promise.all([
|
||||
fetch('/api/fetch-mkt-data?file=deals'),
|
||||
fetch('/api/fetch-mkt-data?file=promos'),
|
||||
fetch('/api/fetch-mkt-data?file=chargebacks'),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (dealsRes.ok) {
|
||||
const buf = await dealsRes.arrayBuffer();
|
||||
if (!cancelled) setDealsMap(parseDealsExcel(buf));
|
||||
} else {
|
||||
console.warn('[MktDataView] fetch-mkt-data?file=deals failed:', dealsRes.status);
|
||||
}
|
||||
|
||||
if (promosRes.ok) {
|
||||
const buf = await promosRes.arrayBuffer();
|
||||
if (!cancelled) setPromosMap(parsePromosExcel(buf));
|
||||
} else {
|
||||
console.warn('[MktDataView] fetch-mkt-data?file=promos failed:', promosRes.status);
|
||||
}
|
||||
|
||||
if (chargesRes.ok) {
|
||||
const buf = await chargesRes.arrayBuffer();
|
||||
if (!cancelled) setChargebacksMap(parseChargebacksExcel(buf));
|
||||
} else {
|
||||
console.warn('[MktDataView] fetch-mkt-data?file=chargebacks failed:', chargesRes.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[MktDataView] Error fetching marketing data:', e);
|
||||
} finally {
|
||||
if (!cancelled) setMktLoading(false);
|
||||
}
|
||||
};
|
||||
fetchAll();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Build Product[] from real data sources
|
||||
const allProducts = useMemo((): Product[] => {
|
||||
if (rawData.length === 0) return [];
|
||||
|
||||
// ASIN → best metadata (longest title wins)
|
||||
const metaMap = new Map<string, { sku: string; title: string; line: string }>();
|
||||
rawData.forEach(r => {
|
||||
const asin = r.asin.trim().toUpperCase();
|
||||
const existing = metaMap.get(asin);
|
||||
if (!existing || (r.title && r.title.length > (existing.title?.length || 0))) {
|
||||
metaMap.set(asin, { sku: r.sku || '', title: r.title || '', line: r.line || 'Other' });
|
||||
}
|
||||
});
|
||||
|
||||
// ASIN → global sell-out and units
|
||||
const sellOutMap = new Map<string, number>();
|
||||
const unitsMap = new Map<string, number>();
|
||||
rawData.forEach(r => {
|
||||
const asin = r.asin.trim().toUpperCase();
|
||||
sellOutMap.set(asin, (sellOutMap.get(asin) || 0) + r.sellOut);
|
||||
unitsMap.set(asin, (unitsMap.get(asin) || 0) + r.units);
|
||||
});
|
||||
|
||||
// ASIN → global ad spend
|
||||
const adsSpendMap = new Map<string, number>();
|
||||
let totalAdsPassed = 0;
|
||||
adsData.forEach(r => {
|
||||
const asin = r.asin.trim().toUpperCase();
|
||||
adsSpendMap.set(asin, (adsSpendMap.get(asin) || 0) + r.cost);
|
||||
totalAdsPassed += r.cost;
|
||||
});
|
||||
|
||||
// Deals/Promos/Chargebacks
|
||||
const has2025 = rawData.some(r => r.year === 2025) || adsData.some(r => r.year === 2025);
|
||||
const getDeals = (asin: string) => has2025 ? (dealsMap.get(asin) || 0) : 0;
|
||||
const getPromos = (asin: string) => has2025 ? (promosMap.get(asin) || 0) : 0;
|
||||
const getChargebacks = (asin: string) => has2025 ? (chargebacksMap.get(asin) || 0) : 0;
|
||||
|
||||
// 1. Start with all sales-based ASINs
|
||||
const products: Product[] = Array.from(metaMap.entries())
|
||||
.map(([asin, meta]): Product => {
|
||||
const spend = adsSpendMap.get(asin) || 0;
|
||||
adsSpendMap.delete(asin); // Mark as processed
|
||||
return {
|
||||
id: asin,
|
||||
asin,
|
||||
sku: meta.sku,
|
||||
name: meta.title || asin,
|
||||
image: '',
|
||||
category: meta.line,
|
||||
brand: '',
|
||||
grossSales: sellOutMap.get(asin) || 0,
|
||||
unitsSold: unitsMap.get(asin) || 0,
|
||||
ppcSpend: spend,
|
||||
deals: getDeals(asin),
|
||||
promos: getPromos(asin),
|
||||
chargebacks: getChargebacks(asin),
|
||||
chargebacksPrevMonth: 0,
|
||||
cogs: 0,
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Add remaining ad spend (including "" ASIN and ASINs without sales)
|
||||
adsSpendMap.forEach((spend, asin) => {
|
||||
if (spend === 0) return;
|
||||
products.push({
|
||||
id: asin || '__unassigned__',
|
||||
asin: asin || '—',
|
||||
sku: '',
|
||||
name: asin ? asin : 'Unassigned Ad Spend (incl. Sponsored Brands)',
|
||||
image: '',
|
||||
category: 'Other',
|
||||
brand: '',
|
||||
grossSales: 0,
|
||||
unitsSold: 0,
|
||||
ppcSpend: spend,
|
||||
deals: asin ? getDeals(asin) : 0,
|
||||
promos: asin ? getPromos(asin) : 0,
|
||||
chargebacks: asin ? getChargebacks(asin) : 0,
|
||||
chargebacksPrevMonth: 0,
|
||||
cogs: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Debug check
|
||||
const finalTotal = products.reduce((sum, p) => sum + p.ppcSpend, 0);
|
||||
if (Math.abs(finalTotal - totalAdsPassed) > 0.01) {
|
||||
console.warn(`[MktDataView] PPC Spend Mismatch: Total Ads=${totalAdsPassed.toFixed(2)}, Table Sum=${finalTotal.toFixed(2)}`);
|
||||
}
|
||||
|
||||
return products;
|
||||
}, [rawData, adsData, dealsMap, promosMap, chargebacksMap]);
|
||||
|
||||
const filteredProducts = allProducts;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loading skeleton — shown while MKT files are being fetched or rawData is empty
|
||||
// ---------------------------------------------------------------------------
|
||||
if (rawData.length === 0 || mktLoading) {
|
||||
return (
|
||||
<div className="font-sans text-slate-200 px-4 md:px-6 pb-24">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">Profitability Dashboard</h2>
|
||||
<p className="text-slate-400">Overview of product performance and margins.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Skeleton KPI cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="bg-[#13161F] rounded-xl border border-[#1F2433] p-6 h-32 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
{/* Skeleton table */}
|
||||
<div className="bg-[#13161F] rounded-xl border border-[#1F2433] overflow-hidden">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="px-6 py-4 border-b border-[#1F2433] animate-pulse flex gap-4">
|
||||
<div className="h-4 bg-[#1F2433] rounded w-1/3" />
|
||||
<div className="h-4 bg-[#1F2433] rounded w-1/6 ml-auto" />
|
||||
<div className="h-4 bg-[#1F2433] rounded w-1/6" />
|
||||
<div className="h-4 bg-[#1F2433] rounded w-1/6" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="font-sans text-slate-200 px-4 md:px-6 pb-24">
|
||||
{/* Header Controls */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">Profitability Dashboard</h2>
|
||||
<p className="text-slate-400">Overview of product performance and margins.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* COGS Toggle */}
|
||||
<div className="flex items-center gap-2 bg-[#0A0C10] px-3 py-1.5 rounded-lg border border-[#1F2433]">
|
||||
<Settings2 className="w-4 h-4 text-slate-400" />
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={includeCOGS}
|
||||
onChange={() => setIncludeCOGS(!includeCOGS)}
|
||||
/>
|
||||
<div className={`block w-10 h-6 rounded-full transition-colors ${includeCOGS ? 'bg-indigo-600' : 'bg-[#1F2433]'}`}></div>
|
||||
<div className={`absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform ${includeCOGS ? 'transform translate-x-4' : ''}`}></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-300">Include COGS</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KPICards products={filteredProducts} includeCOGS={includeCOGS} />
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white">Product Performance</h3>
|
||||
<p className="text-sm text-slate-400">Click on a product to view the waterfall breakdown.</p>
|
||||
</div>
|
||||
<MasterTable
|
||||
products={filteredProducts}
|
||||
includeCOGS={includeCOGS}
|
||||
onProductClick={setSelectedProduct}
|
||||
stockMap={stockMap}
|
||||
vendorStockMap={vendorStockMap}
|
||||
top50Mode={top50Mode}
|
||||
velocityMap={velocityMap}
|
||||
buyBoxLostMap={buyBoxLostMap}
|
||||
top50Ranking={top50Ranking}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
<WaterfallModal
|
||||
product={selectedProduct}
|
||||
includeCOGS={includeCOGS}
|
||||
onClose={() => setSelectedProduct(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Product } from './types';
|
||||
import { calculateProductMetrics, formatCurrency, formatPercent } from './utils';
|
||||
import { X } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, ReferenceLine } from 'recharts';
|
||||
|
||||
interface WaterfallModalProps {
|
||||
product: Product | null;
|
||||
includeCOGS: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const WaterfallModal: React.FC<WaterfallModalProps> = ({ product, includeCOGS, onClose }) => {
|
||||
const data = useMemo(() => {
|
||||
if (!product) return [];
|
||||
const metrics = calculateProductMetrics(product, includeCOGS);
|
||||
|
||||
let currentTotal = product.grossSales;
|
||||
|
||||
const steps = [
|
||||
{ name: 'Gross Sales', value: product.grossSales, isTotal: true, color: '#6366f1' }, // Indigo
|
||||
{ name: 'PPC', value: -product.ppcSpend, isTotal: false, color: '#ef4444' }, // Rose
|
||||
{ name: 'Deals', value: -product.deals, isTotal: false, color: '#f97316' }, // Orange
|
||||
{ name: 'Promos', value: -product.promos, isTotal: false, color: '#f59e0b' }, // Amber
|
||||
{ name: 'Chargebacks', value: -product.chargebacks, isTotal: false, color: '#eab308' }, // Yellow
|
||||
];
|
||||
|
||||
if (includeCOGS) {
|
||||
steps.push({ name: 'COGS', value: -product.cogs, isTotal: false, color: '#64748b' }); // Slate
|
||||
}
|
||||
|
||||
steps.push({ name: 'Net Margin', value: metrics.netMargin, isTotal: true, color: metrics.netMargin >= 0 ? '#10b981' : '#ef4444' });
|
||||
|
||||
const chartData = steps.map(step => {
|
||||
if (step.isTotal) {
|
||||
return {
|
||||
name: step.name,
|
||||
start: 0,
|
||||
end: step.value,
|
||||
val: step.value,
|
||||
color: step.color,
|
||||
isTotal: true
|
||||
};
|
||||
} else {
|
||||
const start = currentTotal;
|
||||
currentTotal += step.value; // value is negative
|
||||
return {
|
||||
name: step.name,
|
||||
start: currentTotal, // The bottom of the visible bar
|
||||
end: start, // The top of the visible bar
|
||||
val: step.value,
|
||||
color: step.color,
|
||||
isTotal: false
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Transform for stacked bar chart: [bottomTransparent, visibleBar]
|
||||
return chartData.map(d => ({
|
||||
name: d.name,
|
||||
transparent: d.start,
|
||||
visible: Math.abs(d.end - d.start),
|
||||
val: d.val,
|
||||
color: d.color,
|
||||
isTotal: d.isTotal
|
||||
}));
|
||||
}, [product, includeCOGS]);
|
||||
|
||||
if (!product) return null;
|
||||
|
||||
const metrics = calculateProductMetrics(product, includeCOGS);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="bg-[#13161F] rounded-2xl shadow-2xl border border-[#1F2433] w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-[#13161F] border-b border-[#1F2433] px-6 py-4 flex items-center justify-between z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">{product.name}</h2>
|
||||
<p className="text-sm text-slate-400 font-mono mt-0.5">{product.sku} | {product.asin}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 text-slate-400 hover:text-white hover:bg-[#1F2433] rounded-full transition-colors">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-[#0A0C10] p-4 rounded-xl border border-[#1F2433]">
|
||||
<p className="text-xs text-slate-400 font-medium mb-1 uppercase tracking-wider">Gross Sales</p>
|
||||
<p className="text-lg font-bold text-white">{formatCurrency(product.grossSales)}</p>
|
||||
</div>
|
||||
<div className="bg-[#0A0C10] p-4 rounded-xl border border-[#1F2433]">
|
||||
<p className="text-xs text-slate-400 font-medium mb-1 uppercase tracking-wider">Net Margin</p>
|
||||
<p className={`text-lg font-bold ${metrics.netMargin >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>{formatCurrency(metrics.netMargin)}</p>
|
||||
</div>
|
||||
<div className="bg-[#0A0C10] p-4 rounded-xl border border-[#1F2433]">
|
||||
<p className="text-xs text-slate-400 font-medium mb-1 uppercase tracking-wider">Margin %</p>
|
||||
<p className={`text-lg font-bold ${metrics.marginPercent >= 0.2 ? 'text-emerald-400' : metrics.marginPercent >= 0.1 ? 'text-amber-400' : 'text-rose-400'}`}>
|
||||
{formatPercent(metrics.marginPercent)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-[#0A0C10] p-4 rounded-xl border border-[#1F2433]">
|
||||
<p className="text-xs text-slate-400 font-medium mb-1 uppercase tracking-wider">ACOS</p>
|
||||
<p className="text-lg font-bold text-amber-400">{formatPercent(metrics.acos)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-white">Profitability Analysis (Waterfall)</h3>
|
||||
<p className="text-sm text-slate-400">Breakdown of deductions from gross sales to net margin.</p>
|
||||
</div>
|
||||
|
||||
<div className="h-[400px] w-full bg-[#0A0C10] p-4 rounded-xl border border-[#1F2433]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} margin={{ top: 20, right: 20, bottom: 40, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#1F2433" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 12, fill: '#8B949E' }}
|
||||
axisLine={{ stroke: '#1F2433' }}
|
||||
tickLine={false}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(val) => `$${val / 1000}k`}
|
||||
tick={{ fontSize: 12, fill: '#8B949E' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: '#13161F' }}
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-[#13161F] p-3 border border-[#1F2433] shadow-xl rounded-lg text-sm">
|
||||
<p className="font-medium text-white mb-1">{data.name}</p>
|
||||
<p className={`font-bold ${data.val < 0 ? 'text-rose-400' : 'text-emerald-400'}`}>
|
||||
{data.val > 0 && !data.isTotal ? '+' : ''}{formatCurrency(data.val)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<ReferenceLine y={0} stroke="#475569" />
|
||||
<Bar dataKey="transparent" stackId="a" fill="transparent" />
|
||||
<Bar dataKey="visible" stackId="a" radius={[4, 4, 4, 4]}>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,140 +0,0 @@
|
||||
import { Product } from './types';
|
||||
|
||||
export const mockProducts: Product[] = [
|
||||
{
|
||||
id: '1',
|
||||
asin: 'B08F7N8P1Q',
|
||||
sku: 'SKU-WIDGET-01',
|
||||
name: 'Premium Widget Pro Max',
|
||||
image: 'https://picsum.photos/seed/widget1/100/100',
|
||||
category: 'Electronics',
|
||||
brand: 'TechCorp',
|
||||
grossSales: 45000,
|
||||
unitsSold: 1500,
|
||||
ppcSpend: 4500,
|
||||
deals: 1200,
|
||||
promos: 800,
|
||||
chargebacks: 300,
|
||||
chargebacksPrevMonth: 250,
|
||||
cogs: 15000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
asin: 'B09G8M7P2R',
|
||||
sku: 'SKU-GADGET-02',
|
||||
name: 'Smart Gadget Mini',
|
||||
image: 'https://picsum.photos/seed/gadget2/100/100',
|
||||
category: 'Electronics',
|
||||
brand: 'TechCorp',
|
||||
grossSales: 12000,
|
||||
unitsSold: 800,
|
||||
ppcSpend: 3000,
|
||||
deals: 500,
|
||||
promos: 200,
|
||||
chargebacks: 800,
|
||||
chargebacksPrevMonth: 400, // Anomaly! > 15% increase
|
||||
cogs: 6000,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
asin: 'B07H6L5P3S',
|
||||
sku: 'SKU-HOME-03',
|
||||
name: 'Ergonomic Office Chair',
|
||||
image: 'https://picsum.photos/seed/chair3/100/100',
|
||||
category: 'Home & Office',
|
||||
brand: 'HomePlus',
|
||||
grossSales: 85000,
|
||||
unitsSold: 425,
|
||||
ppcSpend: 8500,
|
||||
deals: 2000,
|
||||
promos: 1500,
|
||||
chargebacks: 1200,
|
||||
chargebacksPrevMonth: 1100,
|
||||
cogs: 35000,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
asin: 'B06J5K4P4T',
|
||||
sku: 'SKU-KITCHEN-04',
|
||||
name: 'Stainless Steel Knife Set',
|
||||
image: 'https://picsum.photos/seed/knife4/100/100',
|
||||
category: 'Kitchen',
|
||||
brand: 'ChefMaster',
|
||||
grossSales: 28000,
|
||||
unitsSold: 700,
|
||||
ppcSpend: 4200,
|
||||
deals: 800,
|
||||
promos: 600,
|
||||
chargebacks: 150,
|
||||
chargebacksPrevMonth: 160,
|
||||
cogs: 9000,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
asin: 'B05K4J3P5U',
|
||||
sku: 'SKU-FITNESS-05',
|
||||
name: 'Yoga Mat Extra Thick',
|
||||
image: 'https://picsum.photos/seed/yoga5/100/100',
|
||||
category: 'Fitness',
|
||||
brand: 'FitLife',
|
||||
grossSales: 15000,
|
||||
unitsSold: 600,
|
||||
ppcSpend: 3500,
|
||||
deals: 1000,
|
||||
promos: 500,
|
||||
chargebacks: 400,
|
||||
chargebacksPrevMonth: 380,
|
||||
cogs: 5000,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
asin: 'B04L3H2P6V',
|
||||
sku: 'SKU-BEAUTY-06',
|
||||
name: 'Organic Face Serum',
|
||||
image: 'https://picsum.photos/seed/serum6/100/100',
|
||||
category: 'Beauty',
|
||||
brand: 'NatureGlow',
|
||||
grossSales: 32000,
|
||||
unitsSold: 1280,
|
||||
ppcSpend: 2800,
|
||||
deals: 500,
|
||||
promos: 300,
|
||||
chargebacks: 100,
|
||||
chargebacksPrevMonth: 90,
|
||||
cogs: 8000,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
asin: 'B03M2G1P7W',
|
||||
sku: 'SKU-TOY-07',
|
||||
name: 'Educational Building Blocks',
|
||||
image: 'https://picsum.photos/seed/toy7/100/100',
|
||||
category: 'Toys',
|
||||
brand: 'KidGenius',
|
||||
grossSales: 9500,
|
||||
unitsSold: 380,
|
||||
ppcSpend: 2500,
|
||||
deals: 400,
|
||||
promos: 200,
|
||||
chargebacks: 50,
|
||||
chargebacksPrevMonth: 45,
|
||||
cogs: 4000,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
asin: 'B02N1F0P8X',
|
||||
sku: 'SKU-PET-08',
|
||||
name: 'Automatic Pet Feeder',
|
||||
image: 'https://picsum.photos/seed/pet8/100/100',
|
||||
category: 'Pet Supplies',
|
||||
brand: 'PetCare',
|
||||
grossSales: 42000,
|
||||
unitsSold: 840,
|
||||
ppcSpend: 6000,
|
||||
deals: 1500,
|
||||
promos: 1000,
|
||||
chargebacks: 600,
|
||||
chargebacksPrevMonth: 550,
|
||||
cogs: 18000,
|
||||
}
|
||||
];
|
||||
@@ -1,17 +0,0 @@
|
||||
export interface Product {
|
||||
id: string;
|
||||
asin: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
image: string;
|
||||
category: string;
|
||||
brand: string;
|
||||
grossSales: number;
|
||||
unitsSold: number;
|
||||
ppcSpend: number;
|
||||
deals: number;
|
||||
promos: number;
|
||||
chargebacks: number;
|
||||
chargebacksPrevMonth: number;
|
||||
cogs: number;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Product } from './types';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
export const formatPercent = (value: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
export const calculateProductMetrics = (product: Product, includeCOGS: boolean) => {
|
||||
const incentives = product.deals + product.promos;
|
||||
const cogsDeduction = includeCOGS ? product.cogs : 0;
|
||||
const totalDeductions = product.ppcSpend + incentives + product.chargebacks + cogsDeduction;
|
||||
const netMargin = product.grossSales - totalDeductions;
|
||||
const marginPercent = product.grossSales > 0 ? netMargin / product.grossSales : 0;
|
||||
const acos = product.grossSales > 0 ? product.ppcSpend / product.grossSales : 0;
|
||||
|
||||
const chargebackIncrease = product.chargebacksPrevMonth > 0
|
||||
? (product.chargebacks - product.chargebacksPrevMonth) / product.chargebacksPrevMonth
|
||||
: 0;
|
||||
const hasChargebackAnomaly = chargebackIncrease > 0.15;
|
||||
|
||||
return {
|
||||
incentives,
|
||||
totalDeductions,
|
||||
netMargin,
|
||||
marginPercent,
|
||||
acos,
|
||||
chargebackIncrease,
|
||||
hasChargebackAnomaly
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user