feat: add MKT tab and integrate profitability dashboard

This commit is contained in:
Christian Vidal Wolf
2026-03-11 09:27:57 +01:00
parent 0e1457bd3d
commit 455cd8ed08
10 changed files with 798 additions and 1 deletions
+15 -1
View File
@@ -22,6 +22,7 @@ const AdsPerformance = lazy(() => import('./components/AdsPerformance'));
const ForecastView = lazy(() => import('./components/ForecastView'));
const VendorDataView = lazy(() => import('./components/VendorDataView'));
const ExperimentsView = lazy(() => import('./components/ExperimentsView'));
const MktDataView = lazy(() => import('./components/mkt/MktDataView'));
// Loading fallback component
@@ -49,7 +50,7 @@ const App: React.FC = () => {
const [trafficData, setTrafficData] = useState<TrafficRecord[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'ads' | 'forecast' | 'vendor' | 'experiments'>('dashboard');
const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'ads' | 'forecast' | 'vendor' | 'experiments' | 'mkt'>('dashboard');
const [forecastData, setForecastData] = useState<ProductForecastData[]>([]);
const [isChatOpen, setIsChatOpen] = useState(false);
const [activeUrl, setActiveUrl] = useState<string | null>(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL);
@@ -768,6 +769,13 @@ const App: React.FC = () => {
>
<TrendingIcon /> <span className="hidden lg:inline">Weekly Sales</span>
</button>
<button
onClick={() => setView('mkt')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
${view === 'mkt' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<ChartIcon /> <span className="hidden lg:inline">MKT</span>
</button>
<button
onClick={() => setView('ads')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
@@ -916,6 +924,11 @@ const App: React.FC = () => {
</div>
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'mkt' ? '' : 'hidden'}>
<MktDataView />
</div>
</Suspense>
</div>
</>
@@ -934,6 +947,7 @@ const App: React.FC = () => {
{ key: 'weekly' as const, icon: <TrendingIcon />, label: 'Weekly' },
{ key: 'ads' as const, icon: <MegaphoneIcon />, label: 'Ads' },
{ key: 'forecast' as const, icon: <ChartIcon />, label: 'Fc 26' },
{ key: 'mkt' as const, icon: <ChartIcon />, label: 'MKT' },
{ key: 'vendor' as const, icon: <ChartIcon />, label: 'BSR' },
{ key: 'experiments' as const, icon: <span className="text-lg">🧪</span>, label: 'Exp' },
]).map(({ key, icon, label }) => (
+74
View File
@@ -0,0 +1,74 @@
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>
);
};
+228
View File
@@ -0,0 +1,228 @@
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';
interface MasterTableProps {
products: Product[];
includeCOGS: boolean;
onProductClick: (product: Product) => void;
}
type SortKey = 'name' | 'grossSales' | 'ppcSpend' | 'incentives' | 'chargebacks' | 'netMargin' | 'marginPercent';
type SortOrder = 'asc' | 'desc';
export const MasterTable: React.FC<MasterTableProps> = ({ products, includeCOGS, onProductClick }) => {
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.incentives += metrics.incentives;
acc.chargebacks += product.chargebacks;
acc.netMargin += metrics.netMargin;
return acc;
}, { grossSales: 0, ppcSpend: 0, incentives: 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].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 'incentives':
valA = metricsA.incentives;
valB = metricsB.incentives;
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')}>
Gross Sales <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('incentives')}>
Incentives <SortIcon columnKey="incentives" />
</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.incentives)}</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 */}
{sortedProducts.map((product) => {
const metrics = calculateProductMetrics(product, includeCOGS);
// Data bar width calculation (relative to max sales)
const maxSales = Math.max(...products.map(p => p.grossSales));
const salesBarWidth = `${(product.grossSales / maxSales) * 100}%`;
return (
<tr
key={product.id}
className="hover:bg-[#1A1E2A] transition-colors cursor-pointer group"
onClick={() => onProductClick(product)}
>
<td className="px-6 py-4">
<div className="font-medium text-slate-200 group-hover:text-indigo-400 transition-colors">{product.name}</div>
<div className="text-xs text-slate-500 font-mono mt-0.5">{product.sku} | {product.asin}</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(metrics.incentives)}</div>
<div className="text-xs text-slate-500 mt-0.5">D: {formatCurrency(product.deals)} | P: {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>
);
};
+87
View File
@@ -0,0 +1,87 @@
import React, { useState, useMemo } from 'react';
import { mockProducts } from './data';
import { Product } from './types';
import { KPICards } from './KPICards';
import { MasterTable } from './MasterTable';
import { WaterfallModal } from './WaterfallModal';
import { Filter, Settings2 } from 'lucide-react';
export default function MktDataView() {
const [includeCOGS, setIncludeCOGS] = useState(true);
const [selectedCategory, setSelectedCategory] = useState<string>('All');
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const categories = ['All', ...Array.from(new Set(mockProducts.map(p => p.category)))];
const filteredProducts = useMemo(() => {
if (selectedCategory === 'All') return mockProducts;
return mockProducts.filter(p => p.category === selectedCategory);
}, [selectedCategory]);
return (
<div className="font-sans text-slate-200">
{/* 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">
{/* Category Filter */}
<div className="flex items-center gap-2 bg-[#0A0C10] px-3 py-1.5 rounded-lg border border-[#1F2433]">
<Filter className="w-4 h-4 text-slate-400" />
<select
className="bg-transparent text-sm font-medium text-slate-300 outline-none cursor-pointer"
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
>
{categories.map(cat => (
<option key={cat} value={cat} className="bg-[#13161F]">{cat === 'All' ? 'All Categories' : cat}</option>
))}
</select>
</div>
{/* 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}
/>
</div>
{/* Modal */}
<WaterfallModal
product={selectedProduct}
includeCOGS={includeCOGS}
onClose={() => setSelectedProduct(null)}
/>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
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 }) => {
if (!product) return null;
const data = useMemo(() => {
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]);
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
@@ -0,0 +1,140 @@
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
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,48 @@
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
};
};
+22
View File
@@ -10,12 +10,15 @@
"dependencies": {
"@google/genai": "^1.30.0",
"@supabase/supabase-js": "^2.97.0",
"clsx": "^2.1.1",
"dotenv": "^17.3.1",
"lucide-react": "^0.577.0",
"node-fetch": "^3.3.2",
"papaparse": "^5.5.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.5.0",
"tailwind-merge": "^3.5.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
@@ -3681,6 +3684,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.577.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz",
"integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -4493,6 +4505,16 @@
"node": ">=8"
}
},
"node_modules/tailwind-merge": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tar": {
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz",
+3
View File
@@ -14,12 +14,15 @@
"dependencies": {
"@google/genai": "^1.30.0",
"@supabase/supabase-js": "^2.97.0",
"clsx": "^2.1.1",
"dotenv": "^17.3.1",
"lucide-react": "^0.577.0",
"node-fetch": "^3.3.2",
"papaparse": "^5.5.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.5.0",
"tailwind-merge": "^3.5.0",
"xlsx": "^0.18.5"
},
"devDependencies": {