mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 15:05:22 +02:00
Move useMemo before early return in WaterfallModal to comply with Rules of Hooks. Wrap App with ErrorBoundary so future render crashes show an error panel instead of a silent black screen. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
166 lines
7.3 KiB
TypeScript
166 lines
7.3 KiB
TypeScript
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>
|
|
);
|
|
};
|