mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 18:35:23 +02:00
240 lines
11 KiB
TypeScript
240 lines
11 KiB
TypeScript
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' | 'deals' | 'promos' | '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.deals += product.deals;
|
|
acc.promos += product.promos;
|
|
acc.chargebacks += product.chargebacks;
|
|
acc.netMargin += metrics.netMargin;
|
|
return acc;
|
|
}, { grossSales: 0, ppcSpend: 0, deals: 0, promos: 0, chargebacks: 0, netMargin: 0 });
|
|
}, [products, includeCOGS]);
|
|
|
|
const totalMarginPercent = totals.grossSales > 0 ? totals.netMargin / totals.grossSales : 0;
|
|
const totalAcos = totals.grossSales > 0 ? totals.ppcSpend / totals.grossSales : 0;
|
|
|
|
const sortedProducts = useMemo(() => {
|
|
return [...products].sort((a, b) => {
|
|
const metricsA = calculateProductMetrics(a, includeCOGS);
|
|
const metricsB = calculateProductMetrics(b, includeCOGS);
|
|
|
|
let valA: number | string;
|
|
let valB: number | string;
|
|
|
|
switch (sortKey) {
|
|
case 'name':
|
|
valA = a.name;
|
|
valB = b.name;
|
|
break;
|
|
case 'grossSales':
|
|
valA = a.grossSales;
|
|
valB = b.grossSales;
|
|
break;
|
|
case 'ppcSpend':
|
|
valA = a.ppcSpend;
|
|
valB = b.ppcSpend;
|
|
break;
|
|
case 'deals':
|
|
valA = a.deals;
|
|
valB = b.deals;
|
|
break;
|
|
case 'promos':
|
|
valA = a.promos;
|
|
valB = b.promos;
|
|
break;
|
|
case 'chargebacks':
|
|
valA = a.chargebacks;
|
|
valB = b.chargebacks;
|
|
break;
|
|
case 'netMargin':
|
|
valA = metricsA.netMargin;
|
|
valB = metricsB.netMargin;
|
|
break;
|
|
case 'marginPercent':
|
|
valA = metricsA.marginPercent;
|
|
valB = metricsB.marginPercent;
|
|
break;
|
|
default:
|
|
valA = 0;
|
|
valB = 0;
|
|
}
|
|
|
|
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
|
|
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
}, [products, includeCOGS, sortKey, sortOrder]);
|
|
|
|
const SortIcon = ({ columnKey }: { columnKey: SortKey }) => {
|
|
if (sortKey !== columnKey) return <ArrowUpDown className="w-4 h-4 text-slate-600 ml-1 inline-block" />;
|
|
return sortOrder === 'asc' ?
|
|
<ChevronUp className="w-4 h-4 text-indigo-400 ml-1 inline-block" /> :
|
|
<ChevronDown className="w-4 h-4 text-indigo-400 ml-1 inline-block" />;
|
|
};
|
|
|
|
return (
|
|
<div className="bg-[#13161F] rounded-xl border border-[#1F2433] overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm whitespace-nowrap">
|
|
<thead className="bg-[#0A0C10] border-b border-[#1F2433] text-slate-400 font-medium text-xs uppercase tracking-wider">
|
|
<tr>
|
|
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors" onClick={() => handleSort('name')}>
|
|
Product <SortIcon columnKey="name" />
|
|
</th>
|
|
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('grossSales')}>
|
|
SELL OUT <SortIcon columnKey="grossSales" />
|
|
</th>
|
|
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('ppcSpend')}>
|
|
PPC Spend (ACOS) <SortIcon columnKey="ppcSpend" />
|
|
</th>
|
|
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('deals')}>
|
|
Deals <SortIcon columnKey="deals" />
|
|
</th>
|
|
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('promos')}>
|
|
Promos <SortIcon columnKey="promos" />
|
|
</th>
|
|
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('chargebacks')}>
|
|
Op. Chargebacks <SortIcon columnKey="chargebacks" />
|
|
</th>
|
|
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('netMargin')}>
|
|
Est. Margin ($) <SortIcon columnKey="netMargin" />
|
|
</th>
|
|
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('marginPercent')}>
|
|
Margin (%) <SortIcon columnKey="marginPercent" />
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[#1F2433]">
|
|
{/* Totals Row */}
|
|
<tr className="bg-[#1A1E2A] border-b-2 border-[#2D3348] font-semibold">
|
|
<td className="px-6 py-4 text-slate-200 uppercase tracking-wider text-xs">TOTALS</td>
|
|
<td className="px-6 py-4 text-right text-slate-200">{formatCurrency(totals.grossSales)}</td>
|
|
<td className="px-6 py-4 text-right text-slate-200">
|
|
{formatCurrency(totals.ppcSpend)}
|
|
<div className="text-xs text-amber-400 font-normal mt-0.5">ACOS: {formatPercent(totalAcos)}</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-right text-slate-200">{formatCurrency(totals.deals)}</td>
|
|
<td className="px-6 py-4 text-right text-slate-200">{formatCurrency(totals.promos)}</td>
|
|
<td className="px-6 py-4 text-right text-slate-200">{formatCurrency(totals.chargebacks)}</td>
|
|
<td className="px-6 py-4 text-right text-emerald-400">{formatCurrency(totals.netMargin)}</td>
|
|
<td className="px-6 py-4 text-right text-slate-200">
|
|
<span className={cn(
|
|
"inline-flex items-center px-2.5 py-1 rounded-md text-xs font-semibold",
|
|
totalMarginPercent >= 0.20 ? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20" :
|
|
totalMarginPercent >= 0.10 ? "bg-amber-500/10 text-amber-400 border border-amber-500/20" :
|
|
"bg-rose-500/10 text-rose-400 border border-rose-500/20"
|
|
)}>
|
|
{formatPercent(totalMarginPercent)}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
|
|
{/* Product Rows */}
|
|
{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 whitespace-normal min-w-[250px] max-w-[300px]">
|
|
<div className="font-medium text-slate-200 group-hover:text-indigo-400 transition-colors line-clamp-2" title={product.name}>{product.name}</div>
|
|
<div className="text-xs text-slate-500 font-mono mt-0.5">{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(product.deals)}</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<div className="font-medium text-slate-200">{formatCurrency(product.promos)}</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<div className="flex items-center justify-end gap-2">
|
|
{metrics.hasChargebackAnomaly && (
|
|
<div className="group/tooltip relative" title={`${formatPercent(metrics.chargebackIncrease)} increase vs previous month`}>
|
|
<AlertCircle className="w-4 h-4 text-rose-500" />
|
|
</div>
|
|
)}
|
|
<span className={cn(
|
|
"font-medium",
|
|
metrics.hasChargebackAnomaly ? "text-rose-400" : "text-slate-200"
|
|
)}>
|
|
{formatCurrency(product.chargebacks)}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<span className={cn(
|
|
"font-medium",
|
|
metrics.netMargin < 0 ? "text-rose-400" : "text-emerald-400"
|
|
)}>
|
|
{formatCurrency(metrics.netMargin)}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<div className="flex items-center justify-end gap-2">
|
|
<span className={cn(
|
|
"inline-flex items-center px-2.5 py-1 rounded-md text-xs font-semibold",
|
|
metrics.marginPercent >= 0.20 ? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20" :
|
|
metrics.marginPercent >= 0.10 ? "bg-amber-500/10 text-amber-400 border border-amber-500/20" :
|
|
"bg-rose-500/10 text-rose-400 border border-rose-500/20"
|
|
)}>
|
|
{metrics.marginPercent > 0 ? '▲' : '▼'} {formatPercent(Math.abs(metrics.marginPercent))}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|