Files

203 lines
12 KiB
TypeScript
Raw Permalink Normal View History

import React from 'react';
import { WeeklyPivotRow } from '../services/dataProcessor';
import { Top50Badge } from './Top50Badge';
import { StockBadge } from './StockBadge';
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
import { VendorStockBadge } from './VendorStockBadge';
import { MetricDetailTooltip } from './MetricDetailTooltip';
interface WeeklyRowProps {
row: WeeklyPivotRow;
weeks: string[];
onDrillDown?: (sku: string) => void;
stockMap?: Map<string, number>;
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
top50Mode: 'eu' | 'uk';
sortConfig: {
key: string;
direction: 'asc' | 'desc';
metric: 'units' | 'spend' | 'revenue' | 'rank' | 'gv';
} | null;
renderGrowth: (current: number, previous: number) => React.ReactNode;
customerFilters: string[];
vendorStockMap?: Map<string, { eu: number; uk: number }>;
velocityMap?: Map<string, number>;
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
primaryMetric: 'units' | 'revenue';
}
export const WeeklyRow: React.FC<WeeklyRowProps> = React.memo(({
row, weeks, onDrillDown, stockMap, top50Ranking, top50Mode, sortConfig,
renderGrowth, customerFilters, vendorStockMap, velocityMap, buyBoxLostMap,
primaryMetric
}) => {
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
const asin = row.asin.trim().toUpperCase();
if (top50Ranking) {
if (top50Mode === 'eu') {
const rank = top50Ranking.eu.get(asin);
if (rank) ranks.push({ rank, label: 'EU', theme: 'indigo' });
} else {
const rank = top50Ranking.uk.get(asin);
if (rank) ranks.push({ rank, label: 'UK', theme: 'blue' });
}
}
return (
<tr className="hover:bg-white/[0.02] transition-colors group relative hover:z-50">
<td className="p-3 py-2 sticky left-0 z-10 bg-slate-900 group-hover:bg-slate-800 border-r border-white/10 min-w-[240px]">
<div className="flex flex-col">
<div className="flex items-center gap-2 mb-0.5">
{ranks.map((r, i) => (
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
))}
<span
onClick={() => onDrillDown?.(row.sku)}
className={`text-xs font-black uppercase tracking-tighter truncate max-w-[120px] transition-all
${onDrillDown ? 'text-indigo-400 cursor-pointer hover:text-indigo-300 hover:underline' : 'text-indigo-400/70'}`}
title={onDrillDown ? `Click to see Ads detail for ${row.sku}` : ''}
>
{row.sku || '-'}
</span>
<span className="text-[10px] font-bold text-slate-500 bg-slate-800 px-1.5 py-0.5 rounded border border-white/5">{row.asin}</span>
</div>
<div className="flex items-start gap-2 mb-1">
<span className="text-[11px] text-white/70 truncate w-[190px] leading-tight" title={row.title}>{row.title}</span>
{stockMap && (
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
)}
<VendorStockBadge
asin={asin}
vendorStockMap={vendorStockMap}
mode={top50Mode}
avgWeeklySales={velocityMap?.get(asin)}
internalStock={stockMap?.get(row.sku?.replace(/(DE|EN)$/i, ''))}
/>
<BuyBoxWarningBadge asin={asin} buyBoxLostMap={buyBoxLostMap} />
</div>
<span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{row.line}</span>
</div>
</td>
{weeks.map((week, idx) => {
const val = row.unitsByWeek[week] || 0;
const prevVal = row.unitsByWeek[weeks[idx + 1]] || 0;
const spend = row.spendByWeek[week] || 0;
const prevSpend = row.spendByWeek[weeks[idx + 1]] || 0;
const revenue = row.revenueByWeek[week] || 0;
const prevRevenue = row.revenueByWeek[weeks[idx + 1]] || 0;
const gv = row.gvByWeek?.[week] || 0;
const prevGv = row.gvByWeek?.[weeks[idx + 1]] || 0;
const [year, weekNum] = week.split('-');
const prevWeekNum = weeks[idx + 1]?.split('-')[1] || '-';
const lastYearWeek = `${parseInt(year) - 1}-${weekNum}`;
const yoyUnits = row.unitsByWeek[lastYearWeek] || 0;
const yoySpend = row.spendByWeek[lastYearWeek] || 0;
const yoyRevenue = row.revenueByWeek[lastYearWeek] || 0;
const yoyGv = row.gvByWeek?.[lastYearWeek] || 0;
const cvr = gv > 0 ? (val / gv) * 100 : 0;
const prevCvr = prevGv > 0 ? (prevVal / prevGv) * 100 : 0;
return (
<td key={week} className={`p-3 py-2 text-center border-r border-white/5 align-middle ${sortConfig?.key === week ? 'bg-white/[0.01]' : ''}`}>
{primaryMetric === 'units' ? (
<MetricDetailTooltip
currentValue={val}
previousValue={prevVal}
yoyValue={yoyUnits}
currentWeekLabel={`Week ${weekNum} (${year})`}
previousWeekLabel={`Week ${prevWeekNum} (${year})`}
yoyWeekLabel={`Week ${weekNum} (${parseInt(year) - 1})`}
metricName="Units"
metricColor="text-white"
>
<div className="flex items-center gap-1">
<span className={`text-sm font-bold ${val > 0 ? (sortConfig?.key === week && sortConfig.metric === 'units' ? 'text-indigo-400' : 'text-white') : 'text-slate-700'}`}>
{val > 0 ? val.toLocaleString('de-DE') : '-'}
</span>
{val > 0 && renderGrowth(val, prevVal)}
</div>
</MetricDetailTooltip>
) : (
<MetricDetailTooltip
currentValue={revenue}
previousValue={prevRevenue}
yoyValue={yoyRevenue}
currentWeekLabel={`Week ${weekNum} (${year})`}
previousWeekLabel={`Week ${prevWeekNum} (${year})`}
yoyWeekLabel={`Week ${weekNum} (${parseInt(year) - 1})`}
metricName="Revenue"
metricColor="text-white"
formatValue={(v) => `€${v.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`}
>
<div className="flex items-center gap-1">
<span className={`text-sm font-bold ${revenue > 0 ? (sortConfig?.key === week && sortConfig.metric === 'revenue' ? 'text-amber-400' : 'text-white') : 'text-slate-700'}`}>
{revenue > 0 ? `€${revenue.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}` : '-'}
</span>
{revenue > 0 && renderGrowth(revenue, prevRevenue)}
</div>
</MetricDetailTooltip>
)}
<div className="flex flex-col items-center gap-0.5">
<div className="flex flex-col items-center gap-0.5">
{spend > 0 && (
<MetricDetailTooltip
currentValue={spend}
previousValue={prevSpend}
yoyValue={yoySpend}
currentWeekLabel={`Week ${weekNum} (${year})`}
previousWeekLabel={`Week ${prevWeekNum} (${year})`}
yoyWeekLabel={`Week ${weekNum} (${parseInt(year) - 1})`}
metricName="Ads Spend"
metricColor="text-rose-400/80"
formatValue={(v) => `€${v.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`}
>
<div className="flex items-center gap-1 text-[10px]">
<span className="font-bold text-rose-400/70 italic">
Ads: {spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</span>
{renderGrowth(spend, prevSpend)}
</div>
</MetricDetailTooltip>
)}
</div>
{gv > 0 && (
<MetricDetailTooltip
currentValue={gv}
previousValue={prevGv}
yoyValue={yoyGv}
currentWeekLabel={`Week ${weekNum} (${year})`}
previousWeekLabel={`Week ${prevWeekNum} (${year})`}
yoyWeekLabel={`Week ${weekNum} (${parseInt(year) - 1})`}
metricName="GV & CVR"
metricColor="text-teal-400"
>
<div className="flex flex-col items-center gap-0.5 mt-0.5">
<div className="flex items-center gap-1">
<span className={`text-[9px] font-black tracking-tighter ${sortConfig?.key === week && sortConfig.metric === 'gv' ? 'text-teal-300' : 'text-teal-500/70'}`}>
GV: {gv.toLocaleString('de-DE')}
</span>
{renderGrowth(gv, prevGv)}
</div>
<div className="flex items-center gap-1">
<span className="text-[9px] font-bold tracking-tighter text-indigo-400/70">
CVR: {cvr.toFixed(1)}%
</span>
{renderGrowth(cvr, prevCvr)}
</div>
</div>
</MetricDetailTooltip>
)}
</div>
</td>
);
})}
</tr>
);
});