Files
CrazeAnalytix/components/mkt/MasterTable.tsx
T

286 lines
14 KiB
TypeScript
Raw Normal View History

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';
import { StockBadge } from '../StockBadge';
import { VendorStockBadge } from '../VendorStockBadge';
import { BuyBoxWarningBadge } from '../BuyBoxWarningBadge';
import { Top50Badge } from '../Top50Badge';
interface MasterTableProps {
products: Product[];
includeCOGS: boolean;
onProductClick: (product: Product) => void;
stockMap?: Map<string, number>;
vendorStockMap?: Map<string, { eu: number; uk: number }>;
top50Mode?: 'eu' | 'uk';
velocityMap?: Map<string, number>;
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
}
type SortKey = 'name' | 'grossSales' | 'ppcSpend' | 'deals' | 'promos' | 'chargebacks' | 'netMargin' | 'marginPercent';
type SortOrder = 'asc' | 'desc';
export const MasterTable: React.FC<MasterTableProps> = ({
products,
includeCOGS,
onProductClick,
stockMap,
vendorStockMap,
top50Mode = 'eu',
velocityMap,
buyBoxLostMap,
top50Ranking
}) => {
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.slice().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 */}
{(() => {
const maxSales = products.reduce((max, p) => Math.max(max, p.grossSales), 0);
return sortedProducts.map((product) => {
const metrics = calculateProductMetrics(product, includeCOGS);
// Data bar width calculation (relative to max sales)
const salesBarWidth = maxSales > 0 ? `${(product.grossSales / maxSales) * 100}%` : '0%';
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-[300px] max-w-[400px]">
<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 mb-2">{product.sku} | {product.asin}</div>
<div className="flex items-center gap-2">
{stockMap && (
<StockBadge stock={stockMap.get(product.sku?.replace(/(DE|EN)$/i, ''))} />
)}
{(() => {
const asin = product.asin.trim().toUpperCase();
if (asin && top50Ranking) {
if (top50Mode === 'eu') {
const rank = top50Ranking.eu.get(asin);
if (rank) return <Top50Badge rank={rank} label="EU" theme="indigo" />;
} else {
const rank = top50Ranking.uk.get(asin);
if (rank) return <Top50Badge rank={rank} label="UK" theme="blue" />;
}
}
return null;
})()}
<VendorStockBadge
asin={product.asin}
vendorStockMap={vendorStockMap}
mode={top50Mode}
avgWeeklySales={velocityMap?.get(product.asin.trim().toUpperCase())}
/>
<BuyBoxWarningBadge asin={product.asin} buyBoxLostMap={buyBoxLostMap} />
</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>
);
};