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:
Christian Vidal Wolf
2026-06-12 08:29:47 +02:00
co-authored by Claude Sonnet 4.6
parent 9f9c0c2975
commit 76d901fcde
25 changed files with 11 additions and 4985 deletions
-115
View File
@@ -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>
);
};
-634
View File
@@ -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
+1 -20
View File
@@ -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 */}
+1 -38
View File
@@ -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 && (
+5 -91
View File
@@ -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'}`}>
-74
View File
@@ -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>
);
};
-286
View File
@@ -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>
);
};
-349
View File
@@ -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>
);
}
-165
View File
@@ -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>
);
};
-140
View File
@@ -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,
}
];
-17
View File
@@ -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;
}
-48
View File
@@ -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
};
};