2026-01-27 15:34:06 +01:00
|
|
|
import React, { useMemo, useState, useCallback } from 'react';
|
|
|
|
|
import * as XLSX from 'xlsx';
|
2026-01-22 13:37:07 +01:00
|
|
|
import { CombinedKPIs, FilterState } from '../types';
|
2026-01-28 08:55:09 +01:00
|
|
|
import { DownloadIcon, FunnelIcon, TrendingIcon, ChartIcon } from './Icons';
|
2026-01-27 16:51:38 +01:00
|
|
|
import { StockBadge } from './StockBadge';
|
2026-01-28 08:55:09 +01:00
|
|
|
import { Top50Badge } from './Top50Badge';
|
2026-01-28 09:46:59 +01:00
|
|
|
import { VendorStockBadge } from './VendorStockBadge';
|
2026-01-29 09:42:39 +01:00
|
|
|
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
|
2026-01-27 21:12:27 +01:00
|
|
|
import { InColumnStockFilter } from './InColumnStockFilter';
|
2026-01-29 08:51:36 +01:00
|
|
|
import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons';
|
2026-01-22 13:25:06 +01:00
|
|
|
|
|
|
|
|
interface AdsPerformanceProps {
|
|
|
|
|
data: CombinedKPIs[];
|
2026-01-22 13:37:07 +01:00
|
|
|
filters: FilterState;
|
2026-01-26 10:51:30 +01:00
|
|
|
top50Ranking?: {
|
|
|
|
|
eu: Map<string, number>;
|
|
|
|
|
uk: Map<string, number>;
|
|
|
|
|
};
|
2026-01-27 16:51:38 +01:00
|
|
|
stockMap?: Map<string, number>;
|
2026-01-27 21:12:27 +01:00
|
|
|
stockFilter: string[];
|
|
|
|
|
onStockFilterChange: (newFilters: string[]) => void;
|
2026-01-28 08:55:09 +01:00
|
|
|
top50Mode: 'eu' | 'uk';
|
2026-01-28 09:46:59 +01:00
|
|
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
2026-01-28 10:18:08 +01:00
|
|
|
vendorStockFilter: string[];
|
|
|
|
|
onVendorStockFilterChange: (newFilters: string[]) => void;
|
2026-01-29 08:47:03 +01:00
|
|
|
wocFilter: string[];
|
|
|
|
|
onWocFilterChange: (newFilters: string[]) => void;
|
2026-01-29 09:42:39 +01:00
|
|
|
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
|
2026-01-22 13:25:06 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits';
|
|
|
|
|
|
2026-01-28 08:55:09 +01:00
|
|
|
// Define AdsProductKPIs type for clarity
|
|
|
|
|
type AdsProductKPIs = CombinedKPIs & {
|
|
|
|
|
acos: number;
|
|
|
|
|
tacos: number;
|
|
|
|
|
roas: number;
|
|
|
|
|
ctr: number;
|
|
|
|
|
cpc: number;
|
|
|
|
|
cvrUnits: number;
|
|
|
|
|
prevData?: AdsProductKPIs;
|
2026-01-26 10:51:30 +01:00
|
|
|
};
|
|
|
|
|
|
2026-01-28 08:55:09 +01:00
|
|
|
const AdsRow: React.FC<{
|
|
|
|
|
item: AdsProductKPIs;
|
|
|
|
|
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
|
|
|
|
|
top50Mode: 'eu' | 'uk';
|
|
|
|
|
stockMap?: Map<string, number>;
|
2026-01-28 09:46:59 +01:00
|
|
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
2026-01-29 09:42:39 +01:00
|
|
|
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
|
|
|
|
|
}> = React.memo(({ item, top50Ranking, top50Mode, stockMap, vendorStockMap, buyBoxLostMap }) => {
|
2026-01-28 08:55:09 +01:00
|
|
|
const asin = item.asin.trim().toUpperCase();
|
|
|
|
|
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
|
|
|
|
|
|
|
|
|
if (top50Ranking) {
|
|
|
|
|
if (top50Mode === 'eu') {
|
|
|
|
|
const rankEU = top50Ranking.eu.get(asin);
|
|
|
|
|
if (rankEU) ranks.push({ rank: rankEU, label: 'EU', theme: 'indigo' });
|
|
|
|
|
} else {
|
|
|
|
|
const rankUK = top50Ranking.uk.get(asin);
|
|
|
|
|
if (rankUK) ranks.push({ rank: rankUK, label: 'UK', theme: 'blue' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-29 13:41:09 +01:00
|
|
|
<tr className="hover:bg-white/[0.02] transition-colors group relative hover:z-50">
|
2026-01-28 08:55:09 +01:00
|
|
|
<td className="p-4 py-3">
|
|
|
|
|
<div className="flex flex-col gap-0.5">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{ranks.map((r, i) => (
|
|
|
|
|
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
|
|
|
|
|
))}
|
|
|
|
|
<span className="text-white font-bold text-sm tracking-tight">{item.asin}</span>
|
|
|
|
|
</div>
|
2026-01-28 10:18:08 +01:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-[10px] uppercase font-black text-indigo-400 tracking-tighter">SKU: {item.sku}</span>
|
|
|
|
|
</div>
|
2026-01-28 08:55:09 +01:00
|
|
|
<div className="flex items-start gap-2">
|
|
|
|
|
<span className="text-[10px] text-slate-400 truncate max-w-[200px] leading-tight" title={item.title}>{item.title}</span>
|
|
|
|
|
{stockMap && (
|
|
|
|
|
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
|
|
|
|
|
)}
|
2026-01-28 10:34:46 +01:00
|
|
|
<VendorStockBadge asin={item.asin} vendorStockMap={vendorStockMap} mode={top50Mode} avgWeeklySales={item.avgWeeklySales} />
|
2026-01-29 09:42:39 +01:00
|
|
|
<BuyBoxWarningBadge asin={asin} buyBoxLostMap={buyBoxLostMap} />
|
2026-01-28 08:55:09 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
<TableCell value={item.salesTotal} prevValue={item.prevData?.salesTotal} format="currency" />
|
|
|
|
|
<TableCell value={item.salesAds} prevValue={item.prevData?.salesAds} format="currency" />
|
|
|
|
|
<TableCell value={item.salesOrganic} prevValue={item.prevData?.salesOrganic} format="currency" />
|
|
|
|
|
<TableCell value={item.cost} prevValue={item.prevData?.cost} format="currency" />
|
|
|
|
|
<TableCell value={item.impressions} prevValue={item.prevData?.impressions} format="number" />
|
|
|
|
|
<TableCell value={item.clicks} prevValue={item.prevData?.clicks} format="number" />
|
|
|
|
|
<TableCell value={item.conversions} prevValue={item.prevData?.conversions} format="number" />
|
|
|
|
|
<TableCell value={item.cpc} prevValue={item.prevData?.cpc} format="number" prefix="$" inverse />
|
|
|
|
|
<TableCell value={item.ctr} prevValue={item.prevData?.ctr} format="percent" />
|
|
|
|
|
<TableCell value={item.cvrUnits} prevValue={item.prevData?.cvrUnits} format="percent" />
|
|
|
|
|
<TableCell value={item.acos} prevValue={item.prevData?.acos} format="percent" inverse />
|
|
|
|
|
<TableCell value={item.roas} prevValue={item.prevData?.roas} format="number" highlight />
|
|
|
|
|
<TableCell value={item.tacos} prevValue={item.prevData?.tacos} format="percent" inverse />
|
|
|
|
|
<TableCell value={item.glanceViews || 0} prevValue={item.prevData?.glanceViews} format="number" />
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-28 10:18:08 +01:00
|
|
|
const AdsPerformance: React.FC<AdsPerformanceProps> = ({
|
|
|
|
|
data,
|
|
|
|
|
filters,
|
|
|
|
|
top50Ranking,
|
|
|
|
|
stockMap,
|
|
|
|
|
vendorStockMap,
|
|
|
|
|
stockFilter,
|
|
|
|
|
onStockFilterChange,
|
|
|
|
|
vendorStockFilter,
|
|
|
|
|
onVendorStockFilterChange,
|
2026-01-29 08:47:03 +01:00
|
|
|
wocFilter,
|
|
|
|
|
onWocFilterChange,
|
2026-01-29 09:42:39 +01:00
|
|
|
top50Mode,
|
|
|
|
|
buyBoxLostMap
|
2026-01-28 10:18:08 +01:00
|
|
|
}) => {
|
2026-01-22 13:25:06 +01:00
|
|
|
const [searchTerm, setSearchTerm] = useState('');
|
2026-01-26 10:51:30 +01:00
|
|
|
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
2026-01-22 13:25:06 +01:00
|
|
|
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({
|
|
|
|
|
key: 'cost',
|
|
|
|
|
direction: 'desc'
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-22 13:37:07 +01:00
|
|
|
const comparisonConfig = useMemo(() => {
|
|
|
|
|
let periods: string[] = [];
|
|
|
|
|
let type: 'week' | 'month' | 'year' | null = null;
|
|
|
|
|
|
|
|
|
|
if (filters.week.length === 2) {
|
|
|
|
|
periods = [...filters.week].sort((a, b) => {
|
|
|
|
|
const wA = parseInt(a.replace('W', ''));
|
|
|
|
|
const wB = parseInt(b.replace('W', ''));
|
|
|
|
|
return wA - wB;
|
|
|
|
|
});
|
|
|
|
|
type = 'week';
|
|
|
|
|
} else if (filters.month.length === 2) {
|
|
|
|
|
periods = [...filters.month];
|
|
|
|
|
type = 'month';
|
|
|
|
|
} else if (filters.year.length === 2) {
|
|
|
|
|
periods = [...filters.year].sort();
|
|
|
|
|
type = 'year';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (type && periods.length === 2) {
|
|
|
|
|
return { type, prev: periods[0], curr: periods[1] };
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}, [filters]);
|
|
|
|
|
|
2026-01-22 13:25:06 +01:00
|
|
|
const aggregatedByAsin = useMemo(() => {
|
2026-01-22 13:37:07 +01:00
|
|
|
const map = new Map<string, { curr: CombinedKPIs; prev?: CombinedKPIs }>();
|
2026-01-22 13:25:06 +01:00
|
|
|
|
|
|
|
|
data.forEach(item => {
|
|
|
|
|
const asin = item.asin.trim().toUpperCase();
|
2026-01-22 13:37:07 +01:00
|
|
|
const periodVal = comparisonConfig?.type === 'week' ? `W${item.week}` :
|
|
|
|
|
comparisonConfig?.type === 'month' ? item.month :
|
|
|
|
|
comparisonConfig?.type === 'year' ? item.year.toString() : null;
|
|
|
|
|
|
|
|
|
|
const isCurrent = !comparisonConfig || periodVal === comparisonConfig.curr;
|
|
|
|
|
const isPrevious = comparisonConfig && periodVal === comparisonConfig.prev;
|
|
|
|
|
|
2026-01-22 13:25:06 +01:00
|
|
|
if (!map.has(asin)) {
|
2026-01-22 13:37:07 +01:00
|
|
|
const defaultRec: CombinedKPIs = { ...item, salesTotal: 0, unitsTotal: 0, salesAds: 0, unitsAds: 0, cost: 0, clicks: 0, impressions: 0, conversions: 0, salesOrganic: 0 };
|
|
|
|
|
map.set(asin, {
|
|
|
|
|
curr: isCurrent ? { ...item } : { ...defaultRec },
|
|
|
|
|
prev: isPrevious ? { ...item } : undefined
|
|
|
|
|
});
|
2026-01-22 13:25:06 +01:00
|
|
|
} else {
|
2026-01-22 13:37:07 +01:00
|
|
|
const entry = map.get(asin)!;
|
2026-01-28 08:55:09 +01:00
|
|
|
if (isCurrent) {
|
|
|
|
|
entry.curr.salesTotal += item.salesTotal;
|
|
|
|
|
entry.curr.unitsTotal += item.unitsTotal;
|
|
|
|
|
entry.curr.salesAds += item.salesAds;
|
|
|
|
|
entry.curr.unitsAds += item.unitsAds;
|
|
|
|
|
entry.curr.cost += item.cost;
|
|
|
|
|
entry.curr.clicks += item.clicks;
|
|
|
|
|
entry.curr.impressions += item.impressions;
|
|
|
|
|
entry.curr.conversions += item.conversions;
|
|
|
|
|
entry.curr.salesOrganic += item.salesOrganic;
|
|
|
|
|
entry.curr.glanceViews = (entry.curr.glanceViews || 0) + (item.glanceViews || 0);
|
|
|
|
|
} else if (isPrevious) {
|
|
|
|
|
if (!entry.prev) {
|
|
|
|
|
entry.prev = { ...item, salesTotal: 0, unitsTotal: 0, salesAds: 0, unitsAds: 0, cost: 0, clicks: 0, impressions: 0, conversions: 0, salesOrganic: 0 };
|
|
|
|
|
}
|
|
|
|
|
entry.prev.salesTotal += item.salesTotal;
|
|
|
|
|
entry.prev.unitsTotal += item.unitsTotal;
|
|
|
|
|
entry.prev.salesAds += item.salesAds;
|
|
|
|
|
entry.prev.unitsAds += item.unitsAds;
|
|
|
|
|
entry.prev.cost += item.cost;
|
|
|
|
|
entry.prev.clicks += item.clicks;
|
|
|
|
|
entry.prev.impressions += item.impressions;
|
|
|
|
|
entry.prev.conversions += item.conversions;
|
|
|
|
|
entry.prev.salesOrganic += item.salesOrganic;
|
|
|
|
|
entry.prev.glanceViews = (entry.prev.glanceViews || 0) + (item.glanceViews || 0);
|
2026-01-22 13:37:07 +01:00
|
|
|
}
|
2026-01-22 13:25:06 +01:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-22 13:37:07 +01:00
|
|
|
return Array.from(map.values()).map(({ curr, prev }) => {
|
|
|
|
|
const calcMetrics = (item: CombinedKPIs) => ({
|
|
|
|
|
...item,
|
|
|
|
|
acos: item.salesAds > 0 ? (item.cost / item.salesAds) * 100 : 0,
|
|
|
|
|
tacos: item.salesTotal > 0 ? (item.cost / item.salesTotal) * 100 : 0,
|
|
|
|
|
roas: item.cost > 0 ? item.salesAds / item.cost : 0,
|
|
|
|
|
ctr: item.impressions > 0 ? (item.clicks / item.impressions) * 100 : 0,
|
|
|
|
|
cpc: item.clicks > 0 ? item.cost / item.clicks : 0,
|
|
|
|
|
cvrUnits: item.clicks > 0 ? (item.conversions / item.clicks) * 100 : 0,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const currFinal = calcMetrics(curr);
|
|
|
|
|
const prevFinal = prev ? calcMetrics(prev) : undefined;
|
2026-01-22 13:25:06 +01:00
|
|
|
|
|
|
|
|
return {
|
2026-01-22 13:37:07 +01:00
|
|
|
...currFinal,
|
|
|
|
|
prevData: prevFinal
|
2026-01-28 08:55:09 +01:00
|
|
|
} as AdsProductKPIs;
|
2026-01-22 13:25:06 +01:00
|
|
|
});
|
2026-01-22 13:37:07 +01:00
|
|
|
}, [data, comparisonConfig]);
|
2026-01-22 13:25:06 +01:00
|
|
|
|
|
|
|
|
const totals = useMemo(() => {
|
2026-01-22 13:37:07 +01:00
|
|
|
const sum = (items: any[]) => items.reduce((acc, curr) => ({
|
2026-01-22 13:25:06 +01:00
|
|
|
salesTotal: acc.salesTotal + curr.salesTotal,
|
|
|
|
|
salesAds: acc.salesAds + curr.salesAds,
|
|
|
|
|
salesOrganic: acc.salesOrganic + curr.salesOrganic,
|
|
|
|
|
cost: acc.cost + curr.cost,
|
|
|
|
|
impressions: acc.impressions + curr.impressions,
|
|
|
|
|
clicks: acc.clicks + curr.clicks,
|
|
|
|
|
conversions: acc.conversions + curr.conversions,
|
2026-01-23 09:07:38 +01:00
|
|
|
glanceViews: acc.glanceViews + (curr.glanceViews || 0),
|
|
|
|
|
}), { salesTotal: 0, salesAds: 0, salesOrganic: 0, cost: 0, impressions: 0, clicks: 0, conversions: 0, glanceViews: 0 });
|
2026-01-22 13:25:06 +01:00
|
|
|
|
2026-01-22 13:37:07 +01:00
|
|
|
const currTotals = sum(aggregatedByAsin);
|
|
|
|
|
const prevTotals = comparisonConfig ? sum(aggregatedByAsin.map(a => a.prevData).filter(Boolean)) : null;
|
|
|
|
|
|
|
|
|
|
return { curr: currTotals, prev: prevTotals };
|
|
|
|
|
}, [aggregatedByAsin, comparisonConfig]);
|
|
|
|
|
|
|
|
|
|
const calculateDerived = (t: any) => ({
|
|
|
|
|
acos: t.salesAds > 0 ? (t.cost / t.salesAds) * 100 : 0,
|
|
|
|
|
tacos: t.salesTotal > 0 ? (t.cost / t.salesTotal) * 100 : 0,
|
|
|
|
|
roas: t.cost > 0 ? t.salesAds / t.cost : 0,
|
|
|
|
|
ctr: t.impressions > 0 ? (t.clicks / t.impressions) * 100 : 0,
|
|
|
|
|
cpc: t.clicks > 0 ? t.cost / t.clicks : 0,
|
|
|
|
|
cvr: t.clicks > 0 ? (t.conversions / t.clicks) * 100 : 0,
|
2026-01-23 09:07:38 +01:00
|
|
|
cvrGv: t.glanceViews > 0 ? (t.conversions / t.glanceViews) * 100 : 0,
|
2026-01-22 13:37:07 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const currMetrics = calculateDerived(totals.curr);
|
|
|
|
|
const prevMetrics = totals.prev ? calculateDerived(totals.prev) : null;
|
2026-01-22 13:25:06 +01:00
|
|
|
|
|
|
|
|
const filteredAndSorted = useMemo(() => {
|
2026-01-26 10:51:30 +01:00
|
|
|
let result = aggregatedByAsin;
|
|
|
|
|
|
|
|
|
|
if (showOnlyTop50 && top50Ranking) {
|
2026-01-28 08:55:09 +01:00
|
|
|
const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
|
|
|
|
|
result = result.filter(p => currentRankMap.has(p.asin.toUpperCase()));
|
2026-01-26 10:51:30 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-28 08:55:09 +01:00
|
|
|
if (searchTerm) {
|
2026-01-29 13:14:28 +01:00
|
|
|
const searchTerms = searchTerm.toLowerCase().split(/[\s,]+/).filter(term => term.length > 0);
|
|
|
|
|
if (searchTerms.length > 0) {
|
|
|
|
|
result = result.filter(item => {
|
|
|
|
|
const rowValues = [
|
|
|
|
|
item.asin.toLowerCase(),
|
|
|
|
|
item.sku.toLowerCase(),
|
|
|
|
|
item.title.toLowerCase()
|
|
|
|
|
];
|
|
|
|
|
return searchTerms.some(term =>
|
|
|
|
|
rowValues.some(val => val.includes(term))
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-01-28 08:55:09 +01:00
|
|
|
}
|
2026-01-22 13:25:06 +01:00
|
|
|
|
2026-01-29 08:47:03 +01:00
|
|
|
// Apply WOC Filter
|
|
|
|
|
if (wocFilter && wocFilter.length > 0 && vendorStockMap) {
|
|
|
|
|
result = result.filter(item => {
|
|
|
|
|
const asin = item.asin.trim().toUpperCase();
|
|
|
|
|
const stockData = vendorStockMap.get(asin);
|
|
|
|
|
if (!stockData) return false;
|
|
|
|
|
|
|
|
|
|
const stock = top50Mode === 'uk' ? stockData.uk : stockData.eu;
|
|
|
|
|
const velocity = item.avgWeeklySales || 0;
|
|
|
|
|
|
|
|
|
|
let woc: number = 0;
|
|
|
|
|
if (velocity > 0) {
|
|
|
|
|
woc = stock / velocity;
|
|
|
|
|
} else if (stock > 0) {
|
|
|
|
|
woc = 999;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return wocFilter.some(f => {
|
|
|
|
|
if (f === '< 4 Weeks') return woc < 4;
|
|
|
|
|
if (f === '> 4 Weeks') return woc >= 4;
|
|
|
|
|
if (f === 'Out of Stock') return woc === 0;
|
|
|
|
|
if (f === 'Infinite Cover') return woc === 999;
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-26 10:54:50 +01:00
|
|
|
if (showOnlyTop50 && top50Ranking) {
|
|
|
|
|
result.sort((a, b) => {
|
|
|
|
|
const asinA = a.asin.trim().toUpperCase();
|
|
|
|
|
const asinB = b.asin.trim().toUpperCase();
|
2026-01-28 08:55:09 +01:00
|
|
|
const rankA = top50Mode === 'eu' ? (top50Ranking.eu.get(asinA) || 999) : (top50Ranking.uk.get(asinA) || 999);
|
|
|
|
|
const rankB = top50Mode === 'eu' ? (top50Ranking.eu.get(asinB) || 999) : (top50Ranking.uk.get(asinB) || 999);
|
2026-01-26 10:54:50 +01:00
|
|
|
return rankA - rankB;
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
result.sort((a, b) => {
|
|
|
|
|
const valA = a[sortConfig.key] as number;
|
|
|
|
|
const valB = b[sortConfig.key] as number;
|
|
|
|
|
return sortConfig.direction === 'asc' ? valA - valB : valB - valA;
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-01-22 13:25:06 +01:00
|
|
|
|
|
|
|
|
return result;
|
2026-01-26 10:51:30 +01:00
|
|
|
}, [aggregatedByAsin, searchTerm, sortConfig, showOnlyTop50, top50Ranking, top50Mode]);
|
2026-01-22 13:25:06 +01:00
|
|
|
|
|
|
|
|
const handleSort = (key: SortKey) => {
|
|
|
|
|
setSortConfig(prev => ({
|
|
|
|
|
key,
|
|
|
|
|
direction: prev.key === key && prev.direction === 'desc' ? 'asc' : 'desc'
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-27 15:34:06 +01:00
|
|
|
const handleExportExcel = useCallback(() => {
|
|
|
|
|
const exportData = filteredAndSorted.map(row => {
|
|
|
|
|
const rowData: any = {
|
|
|
|
|
ASIN: row.asin,
|
|
|
|
|
SKU: row.sku,
|
|
|
|
|
Title: row.title,
|
|
|
|
|
'Total Sales': Math.round(row.salesTotal),
|
|
|
|
|
'Ads Sales': Math.round(row.salesAds),
|
|
|
|
|
'Organic Sales': Math.round(row.salesOrganic),
|
|
|
|
|
'Ad Spend': Math.round(row.cost),
|
|
|
|
|
Impressions: row.impressions,
|
|
|
|
|
Clicks: row.clicks,
|
|
|
|
|
Orders: row.conversions,
|
|
|
|
|
CPC: Number(row.cpc.toFixed(2)),
|
|
|
|
|
'CTR (%)': Number(row.ctr.toFixed(2)),
|
|
|
|
|
'CVR (%)': Number(row.cvrUnits.toFixed(2)),
|
|
|
|
|
'ACOS (%)': Number(row.acos.toFixed(2)),
|
|
|
|
|
ROAS: Number(row.roas.toFixed(2)),
|
|
|
|
|
'TACOS (%)': Number(row.tacos.toFixed(2)),
|
|
|
|
|
'Glance Views': row.glanceViews || 0,
|
|
|
|
|
};
|
|
|
|
|
if (row.prevData) {
|
|
|
|
|
rowData['Prev Total Sales'] = Math.round(row.prevData.salesTotal);
|
|
|
|
|
rowData['Growth Sales (%)'] = row.prevData.salesTotal > 0 ? Number((((row.salesTotal - row.prevData.salesTotal) / row.prevData.salesTotal) * 100).toFixed(1)) : 0;
|
|
|
|
|
}
|
|
|
|
|
return rowData;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const ws = XLSX.utils.json_to_sheet(exportData);
|
|
|
|
|
const wb = XLSX.utils.book_new();
|
|
|
|
|
XLSX.utils.book_append_sheet(wb, ws, 'Ads Performance');
|
|
|
|
|
XLSX.writeFile(wb, `Ads_Performance_Export_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
|
|
|
|
}, [filteredAndSorted]);
|
|
|
|
|
|
2026-01-22 13:25:06 +01:00
|
|
|
return (
|
2026-02-16 09:27:46 +01:00
|
|
|
<div className="flex flex-col gap-4 md:gap-6 animate-fade-in p-3 md:p-6 bg-[#0B0E14] min-h-screen text-slate-300">
|
2026-01-22 13:25:06 +01:00
|
|
|
<div className="flex justify-between items-end">
|
|
|
|
|
<div>
|
2026-02-16 09:27:46 +01:00
|
|
|
<h1 className="text-lg md:text-2xl font-bold text-white mb-1">Performance Overview</h1>
|
2026-01-22 14:42:09 +01:00
|
|
|
<div className="flex items-center gap-2 text-xs text-slate-400">
|
2026-01-22 13:25:06 +01:00
|
|
|
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
|
2026-01-22 13:37:07 +01:00
|
|
|
<span>
|
2026-01-28 08:55:09 +01:00
|
|
|
{aggregatedByAsin.length} products • {comparisonConfig ? `Comparison: ${comparisonConfig.prev} vs ${comparisonConfig.curr}` : 'Direct Overview'}
|
2026-01-22 13:37:07 +01:00
|
|
|
</span>
|
2026-01-22 13:25:06 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-16 09:27:46 +01:00
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 xl:grid-cols-13 gap-2 md:gap-4 bg-[#141820] p-2 md:p-4 rounded-xl border border-white/5 shadow-xl">
|
2026-01-22 14:33:10 +01:00
|
|
|
<SummaryCard label="TOTAL SALES" value={totals.curr.salesTotal} prevValue={totals.prev?.salesTotal} format="currency" color="text-emerald-400" />
|
|
|
|
|
<SummaryCard label="ADS SALES" value={totals.curr.salesAds} prevValue={totals.prev?.salesAds} format="currency" color="text-orange-400" />
|
|
|
|
|
<SummaryCard label="ORGANIC SALES" value={totals.curr.salesOrganic} prevValue={totals.prev?.salesOrganic} format="currency" color="text-blue-400" />
|
|
|
|
|
<SummaryCard label="AD SPEND" value={totals.curr.cost} prevValue={totals.prev?.cost} format="currency" color="text-indigo-400" />
|
|
|
|
|
<SummaryCard label="IMPR." value={totals.curr.impressions} prevValue={totals.prev?.impressions} format="number" />
|
|
|
|
|
<SummaryCard label="CLICKS" value={totals.curr.clicks} prevValue={totals.prev?.clicks} format="number" />
|
|
|
|
|
<SummaryCard label="ADS ORDERS" value={totals.curr.conversions} prevValue={totals.prev?.conversions} format="number" />
|
|
|
|
|
<SummaryCard label="CPC" value={currMetrics.cpc} prevValue={prevMetrics?.cpc} format="number" prefix="$" decimals={2} inverse />
|
|
|
|
|
<SummaryCard label="CTR" value={currMetrics.ctr} prevValue={prevMetrics?.ctr} format="percent" />
|
|
|
|
|
<SummaryCard label="CVR" value={currMetrics.cvr} prevValue={prevMetrics?.cvr} format="percent" />
|
|
|
|
|
<SummaryCard label="ACOS" value={currMetrics.acos} prevValue={prevMetrics?.acos} format="percent" inverse />
|
|
|
|
|
<SummaryCard label="ROAS" value={currMetrics.roas} prevValue={prevMetrics?.roas} format="number" decimals={2} color="text-blue-300" />
|
|
|
|
|
<SummaryCard label="TACOS" value={currMetrics.tacos} prevValue={prevMetrics?.tacos} format="percent" inverse />
|
2026-01-23 09:07:38 +01:00
|
|
|
<SummaryCard label="GV" value={totals.curr.glanceViews} prevValue={totals.prev?.glanceViews} format="number" color="text-teal-400" />
|
|
|
|
|
<SummaryCard label="CVR (GV)" value={currMetrics.cvrGv} prevValue={prevMetrics?.cvrGv} format="percent" color="text-teal-300" />
|
2026-01-22 13:25:06 +01:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="bg-[#141820] rounded-xl border border-white/10 overflow-hidden shadow-2xl flex-1 flex flex-col">
|
2026-02-16 09:27:46 +01:00
|
|
|
<div className="p-2 md:p-4 border-b border-white/5 flex flex-wrap justify-between items-center gap-2 bg-[#1A1F29]">
|
2026-01-22 13:25:06 +01:00
|
|
|
<div>
|
|
|
|
|
<h2 className="text-sm font-bold text-white uppercase tracking-wider">Product Inventory</h2>
|
2026-01-22 14:42:09 +01:00
|
|
|
<p className="text-[10px] text-slate-400 mt-0.5">
|
2026-01-22 13:37:07 +01:00
|
|
|
{comparisonConfig ? `Comparing ${comparisonConfig.curr} vs ${comparisonConfig.prev}` : 'Displaying total data for selected filters.'}
|
|
|
|
|
</p>
|
2026-01-22 13:25:06 +01:00
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-4">
|
2026-01-28 08:55:09 +01:00
|
|
|
{top50Ranking && (top50Ranking.eu.size > 0 || top50Ranking.uk.size > 0) && (
|
|
|
|
|
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowOnlyTop50(!showOnlyTop50)}
|
|
|
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${showOnlyTop50 ? 'bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-lg' : 'text-slate-400 hover:text-amber-400'}`}
|
|
|
|
|
>
|
|
|
|
|
<span className="text-sm">🏆</span>
|
|
|
|
|
Top 50 ({top50Mode.toUpperCase()})
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-22 13:25:06 +01:00
|
|
|
<input
|
|
|
|
|
type="text"
|
2026-01-27 15:34:06 +01:00
|
|
|
placeholder="Search..."
|
2026-01-22 13:25:06 +01:00
|
|
|
value={searchTerm}
|
|
|
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
2026-01-28 08:55:09 +01:00
|
|
|
className="bg-[#0B0E14] border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white focus:outline-none w-48"
|
2026-01-22 13:25:06 +01:00
|
|
|
/>
|
2026-01-28 08:55:09 +01:00
|
|
|
<button onClick={handleExportExcel} className="flex items-center gap-2 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs font-bold border border-white/10 transition-all">
|
2026-01-27 15:34:06 +01:00
|
|
|
<DownloadIcon />
|
|
|
|
|
<span>Export</span>
|
|
|
|
|
</button>
|
2026-01-22 13:25:06 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-16 09:27:46 +01:00
|
|
|
<div className="overflow-x-auto flex-1 custom-scrollbar scroll-touch">
|
|
|
|
|
<table className="w-full text-left border-collapse text-[10px] md:text-[11px]">
|
2026-01-28 08:55:09 +01:00
|
|
|
<thead className="sticky top-0 z-20 bg-[#1A1F29]">
|
|
|
|
|
<tr className="border-b border-white/5">
|
2026-01-27 21:12:27 +01:00
|
|
|
<th className="p-4 font-black text-slate-400 uppercase tracking-widest min-w-[250px]">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span>PRODUCT</span>
|
2026-01-28 10:18:08 +01:00
|
|
|
<div className="flex items-center gap-1 scale-75 origin-left">
|
|
|
|
|
<InColumnStockFilter
|
|
|
|
|
currentFilters={stockFilter}
|
|
|
|
|
onFilterChange={onStockFilterChange}
|
|
|
|
|
title="Warehouse Stock"
|
2026-01-29 08:51:36 +01:00
|
|
|
icon={<WarehouseIcon className="w-3.5 h-3.5 text-fuchsia-400" />}
|
2026-01-28 10:18:08 +01:00
|
|
|
/>
|
|
|
|
|
<InColumnStockFilter
|
|
|
|
|
currentFilters={vendorStockFilter}
|
|
|
|
|
onFilterChange={onVendorStockFilterChange}
|
|
|
|
|
title="Vendor Stock"
|
2026-01-29 09:03:34 +01:00
|
|
|
icon={<AmazonSmileIcon className="w-5 h-5 text-amber-500" />}
|
2026-01-28 10:18:08 +01:00
|
|
|
options={[
|
|
|
|
|
'Out of Stock (0)',
|
|
|
|
|
'In Stock (>0)',
|
|
|
|
|
'In Stock (>20)',
|
|
|
|
|
'Low Stock (<10)',
|
|
|
|
|
]}
|
|
|
|
|
/>
|
2026-01-29 08:47:03 +01:00
|
|
|
<InColumnStockFilter
|
|
|
|
|
currentFilters={wocFilter}
|
|
|
|
|
onFilterChange={onWocFilterChange}
|
|
|
|
|
title="Week Coverage"
|
2026-01-29 08:51:36 +01:00
|
|
|
icon={<CoverageIcon className="w-3.5 h-3.5 text-emerald-400" />}
|
2026-01-29 08:47:03 +01:00
|
|
|
options={[
|
|
|
|
|
'< 4 Weeks',
|
|
|
|
|
'> 4 Weeks',
|
|
|
|
|
'Out of Stock',
|
|
|
|
|
'Infinite Cover'
|
|
|
|
|
]}
|
|
|
|
|
/>
|
2026-01-28 10:18:08 +01:00
|
|
|
</div>
|
2026-01-27 21:12:27 +01:00
|
|
|
</div>
|
|
|
|
|
</th>
|
2026-01-22 13:25:06 +01:00
|
|
|
<SortableHeader label="TOTAL SALES" sortKey="salesTotal" activeSort={sortConfig} onSort={handleSort} />
|
|
|
|
|
<SortableHeader label="ADS SALES" sortKey="salesAds" activeSort={sortConfig} onSort={handleSort} />
|
|
|
|
|
<SortableHeader label="ORGANIC SALES" sortKey="salesOrganic" activeSort={sortConfig} onSort={handleSort} />
|
|
|
|
|
<SortableHeader label="AD SPEND" sortKey="cost" activeSort={sortConfig} onSort={handleSort} />
|
|
|
|
|
<SortableHeader label="IMPR." sortKey="impressions" activeSort={sortConfig} onSort={handleSort} />
|
|
|
|
|
<SortableHeader label="CLICKS" sortKey="clicks" activeSort={sortConfig} onSort={handleSort} />
|
|
|
|
|
<SortableHeader label="ADS ORDERS" sortKey="conversions" activeSort={sortConfig} onSort={handleSort} />
|
|
|
|
|
<SortableHeader label="CPC" sortKey="cpc" activeSort={sortConfig} onSort={handleSort} />
|
|
|
|
|
<SortableHeader label="CTR" sortKey="ctr" activeSort={sortConfig} onSort={handleSort} />
|
|
|
|
|
<SortableHeader label="CVR" sortKey="cvrUnits" activeSort={sortConfig} onSort={handleSort} />
|
|
|
|
|
<SortableHeader label="ACOS" sortKey="acos" activeSort={sortConfig} onSort={handleSort} />
|
|
|
|
|
<SortableHeader label="ROAS" sortKey="roas" activeSort={sortConfig} onSort={handleSort} />
|
|
|
|
|
<SortableHeader label="TACOS" sortKey="tacos" activeSort={sortConfig} onSort={handleSort} />
|
2026-01-23 09:07:38 +01:00
|
|
|
<SortableHeader label="GV" sortKey="glanceViews" activeSort={sortConfig} onSort={handleSort} />
|
2026-01-22 13:25:06 +01:00
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-white/[0.03]">
|
2026-01-28 08:55:09 +01:00
|
|
|
{filteredAndSorted.map((p) => (
|
2026-01-29 09:42:39 +01:00
|
|
|
<AdsRow key={p.id} item={p} top50Ranking={top50Ranking} top50Mode={top50Mode} stockMap={stockMap} vendorStockMap={vendorStockMap} buyBoxLostMap={buyBoxLostMap} />
|
2026-01-28 08:55:09 +01:00
|
|
|
))}
|
2026-01-22 13:25:06 +01:00
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-28 08:55:09 +01:00
|
|
|
const SummaryCard = ({ label, value, prevValue, format, prefix = "", decimals = 0, color = "text-white", inverse = false }: any) => {
|
|
|
|
|
const growth = prevValue !== undefined && prevValue > 0 ? ((value - prevValue) / prevValue) * 100 : null;
|
2026-01-22 14:33:10 +01:00
|
|
|
const formatVal = (v: number) => {
|
|
|
|
|
if (format === 'currency') return `$${Math.round(v).toLocaleString('de-DE')}`;
|
|
|
|
|
if (format === 'percent') return v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + "%";
|
|
|
|
|
return prefix + v.toLocaleString('de-DE', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
|
|
|
|
};
|
2026-01-22 13:37:07 +01:00
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col items-center justify-center p-2 border-r last:border-r-0 border-white/5 w-full min-w-[80px]">
|
|
|
|
|
<span className={`${color} text-[10px] font-black tracking-widest mb-1 text-center leading-tight uppercase`}>{label}</span>
|
|
|
|
|
<div className="flex flex-col items-center">
|
2026-01-22 14:33:10 +01:00
|
|
|
<span className="text-sm font-bold text-white transition-all">{formatVal(value)}</span>
|
|
|
|
|
{prevValue !== undefined && (
|
|
|
|
|
<div className="flex flex-col items-center mt-0.5">
|
2026-01-22 14:42:09 +01:00
|
|
|
<span className="text-[9px] text-slate-400 font-medium whitespace-nowrap">
|
|
|
|
|
Prev: <span className="text-indigo-300 font-bold">{formatVal(prevValue)}</span>
|
|
|
|
|
</span>
|
2026-01-22 14:33:10 +01:00
|
|
|
{growth !== null && (
|
|
|
|
|
<span className={`text-[9px] font-black ${(growth > 0 ? !inverse : inverse) ? 'text-emerald-400' : 'text-rose-500'}`}>
|
|
|
|
|
{growth > 0 ? '↑' : '↓'} {Math.abs(growth).toFixed(1)}%
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-01-22 13:37:07 +01:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-28 08:55:09 +01:00
|
|
|
const TableCell = ({ value, prevValue, format, prefix = "", inverse = false, highlight = false }: any) => {
|
2026-01-22 13:37:07 +01:00
|
|
|
const growth = prevValue !== undefined && prevValue > 0 ? ((value - prevValue) / prevValue) * 100 : null;
|
2026-01-22 14:33:10 +01:00
|
|
|
const formatVal = (v: number) => {
|
|
|
|
|
if (format === 'currency') return Math.round(v).toLocaleString('de-DE');
|
|
|
|
|
if (format === 'number') {
|
2026-01-28 08:55:09 +01:00
|
|
|
if (prefix === "$") return "$" + v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
|
|
|
return prefix + Math.round(v).toLocaleString('de-DE');
|
2026-01-22 14:21:14 +01:00
|
|
|
}
|
2026-01-22 14:33:10 +01:00
|
|
|
return v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + "%";
|
|
|
|
|
};
|
2026-01-22 13:37:07 +01:00
|
|
|
return (
|
|
|
|
|
<td className="p-4 text-right">
|
|
|
|
|
<div className="flex flex-col items-end">
|
2026-01-22 14:33:10 +01:00
|
|
|
<span className={`font-bold ${highlight ? 'text-blue-300' : 'text-white'}`}>{formatVal(value)}</span>
|
|
|
|
|
{prevValue !== undefined && (
|
|
|
|
|
<div className="flex flex-col items-end scale-[0.85] origin-right opacity-80 mt-0.5">
|
2026-01-22 14:42:09 +01:00
|
|
|
<span className="text-[9px] text-slate-400 font-medium whitespace-nowrap">
|
|
|
|
|
Prev: <span className="text-indigo-300 font-bold">{formatVal(prevValue)}</span>
|
|
|
|
|
</span>
|
2026-01-22 14:33:10 +01:00
|
|
|
{growth !== null && (
|
|
|
|
|
<span className={`text-[10px] font-black ${(growth > 0 ? !inverse : inverse) ? 'text-emerald-400' : 'text-rose-400'}`}>
|
|
|
|
|
{growth > 0 ? '+' : ''}{growth.toFixed(1)}%
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-01-22 13:37:07 +01:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
);
|
2026-01-28 08:55:09 +01:00
|
|
|
};
|
2026-01-22 13:25:06 +01:00
|
|
|
|
2026-01-28 08:55:09 +01:00
|
|
|
const SortableHeader = ({ label, sortKey, activeSort, onSort }: any) => {
|
2026-01-22 13:25:06 +01:00
|
|
|
const isActive = activeSort.key === sortKey;
|
|
|
|
|
return (
|
2026-01-28 08:55:09 +01:00
|
|
|
<th onClick={() => onSort(sortKey)} className="p-4 font-black text-slate-400 uppercase tracking-widest text-right cursor-pointer hover:text-white transition-colors group min-w-[100px] whitespace-nowrap">
|
2026-01-22 13:25:06 +01:00
|
|
|
<div className="flex items-center justify-end gap-1.5">
|
|
|
|
|
<span className="text-[10px]">{label}</span>
|
|
|
|
|
<span className={`text-[10px] flex flex-col leading-[0.5] transition-opacity ${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-40'}`}>
|
|
|
|
|
<span className={isActive && activeSort.direction === 'asc' ? 'text-indigo-400' : ''}>▴</span>
|
|
|
|
|
<span className={isActive && activeSort.direction === 'desc' ? 'text-indigo-400' : ''}>▾</span>
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</th>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default AdsPerformance;
|