mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 16:45:24 +02:00
feat: add Experiment Tracker to Weekly Sales tab
Log weekly actions (SEO, pricing, ads, etc.) per product line and visualize their impact with a Recharts line chart showing experiment markers. Includes a collapsible experiments panel with auto-computed delta % (3-week before vs after comparison) and editable status tracking. Data persisted in localStorage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
557703c5ea
commit
9a115ff5be
@@ -0,0 +1,600 @@
|
||||
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;
|
||||
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 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';
|
||||
}
|
||||
|
||||
// --- 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>
|
||||
<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 }) => {
|
||||
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 [formAction, setFormAction] = useState<ActionType>('SEO');
|
||||
const [formDesc, setFormDesc] = useState('');
|
||||
const [formImpact, setFormImpact] = useState<ExpectedImpact>('More Sales');
|
||||
|
||||
// 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;
|
||||
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 || !formDesc.trim()) return;
|
||||
|
||||
const newExp: Experiment = {
|
||||
id: `exp_${Date.now()}`,
|
||||
week: formWeek,
|
||||
product_line: formLine,
|
||||
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, 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={() => 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>
|
||||
|
||||
{/* 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 || !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-[800px]">
|
||||
<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">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-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;
|
||||
@@ -10,6 +10,7 @@ import { VendorStockBadge } from './VendorStockBadge';
|
||||
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
|
||||
import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons';
|
||||
import { ExcelFilter } from './ExcelFilter';
|
||||
import ExperimentTracker from './ExperimentTracker';
|
||||
|
||||
interface WeeklyGridProps {
|
||||
data: CombinedKPIs[];
|
||||
@@ -664,6 +665,9 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:gap-4 animate-fade-in px-1 md:px-0">
|
||||
{/* Experiment Tracker */}
|
||||
<ExperimentTracker rows={rows} weeks={weeks} primaryMetric={primaryMetric} />
|
||||
|
||||
{/* Toolbar: Search & Pagination */}
|
||||
<div className="flex flex-col lg:flex-row justify-between items-center gap-3 md:gap-4 bg-slate-900 border border-white/10 p-2 md:p-4 rounded-xl shadow-lg">
|
||||
<div className="flex flex-wrap items-center gap-4 w-full lg:w-auto">
|
||||
|
||||
Reference in New Issue
Block a user