mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 13:15:24 +02:00
feat: add MKT tab and integrate profitability dashboard
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user