Files
CrazeAnalytix/components/TopMovers.tsx
T
Christian Vidal WolfandQwen-Coder 579e96cf5d fix: add null safety checks for years in TopMovers
- Add || null to years array access to prevent undefined values
- Add !currentYear || !previousYear to insufficient data check
- This prevents errors when filtered data has less than 2 years

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-16 15:12:10 +01:00

346 lines
14 KiB
TypeScript

import React, { useState, useMemo, useCallback } from 'react';
import * as XLSX from 'xlsx';
import { SalesRecord } from '../types';
import { DownloadIcon } from './Icons';
import { StockBadge } from './StockBadge';
import { VendorStockBadge } from './VendorStockBadge';
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
interface TopMoversProps {
data: SalesRecord[];
stockMap?: Map<string, number>;
vendorStockMap?: Map<string, { eu: number; uk: number }>;
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
top50Mode?: 'eu' | 'uk';
}
type Metric = 'sellOut' | 'units';
interface SkuAggr {
sku: string;
asin: string;
title: string;
line: string;
previousValue: number;
currentValue: number;
diff: number;
pct: number;
}
// Reusable Table Component
const MoversTable: React.FC<{
title: string;
data: SkuAggr[];
metric: Metric;
previousYear: number;
currentYear: number;
type: 'growth' | 'decline';
stockMap?: Map<string, number>;
vendorStockMap?: Map<string, { eu: number; uk: number }>;
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
top50Mode?: 'eu' | 'uk';
}> = ({ title, data, metric, previousYear, currentYear, type, stockMap, vendorStockMap, buyBoxLostMap, top50Mode = 'eu' }) => {
const formatValue = (val: number) => {
if (metric === 'sellOut') return `€${val.toLocaleString('de-DE', { maximumFractionDigits: 0 })}`;
return val.toLocaleString('de-DE');
};
const handleExport = useCallback(() => {
if (!data || data.length === 0) return;
const exportData = data.map((item, index) => ({
Rank: index + 1,
Title: item.title,
SKU: item.sku,
'Product Line': item.line,
[`${previousYear} ${metric === 'sellOut' ? 'Sell Out' : 'Units'}`]: item.previousValue,
[`${currentYear} ${metric === 'sellOut' ? 'Sell Out' : 'Units'}`]: item.currentValue,
'Difference': item.diff,
'% Change': Number(item.pct.toFixed(2))
}));
const ws = XLSX.utils.json_to_sheet(exportData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Top Movers');
XLSX.writeFile(wb, `${title.replace(/\s+/g, '_')}_${currentYear}_vs_${previousYear}.xlsx`);
}, [data, title, currentYear, previousYear, metric]);
const colorClass = type === 'growth' ? 'text-emerald-400' : 'text-rose-400';
const bgClass = type === 'growth' ? 'bg-emerald-500/10 text-emerald-400' : 'bg-rose-500/10 text-rose-400';
const headerColor = type === 'growth' ? 'border-emerald-500/30' : 'border-rose-500/30';
return (
<div className="bg-surface border border-border rounded-xl shadow-lg overflow-hidden flex flex-col h-full">
<div className={`px-6 py-4 border-b ${headerColor} bg-slate-900/50 flex justify-between items-center`}>
<h3 className={`text-lg font-bold flex items-center gap-2 ${colorClass}`}>
{type === 'growth' ? '🚀 ' : '📉 '} {title}
</h3>
<div className="flex items-center gap-4">
<button
onClick={handleExport}
className="flex items-center gap-2 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-medium border border-slate-700 transition-colors"
title="Export to CSV"
>
<DownloadIcon />
<span className="hidden sm:inline">Export</span>
</button>
<span className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Top 20</span>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm border-collapse">
<thead>
<tr className="bg-slate-950 text-slate-400 uppercase text-xs font-semibold tracking-wider">
<th className="px-6 py-3 border-b border-border w-16 text-center">Rank</th>
<th className="px-6 py-3 border-b border-border">SKU Details</th>
<th className="px-6 py-3 border-b border-border">Product Line</th>
<th className="px-6 py-3 border-b border-border text-center">Stock</th>
<th className="px-6 py-3 border-b border-border text-center">Vendor</th>
<th className="px-6 py-3 border-b border-border text-center">Buy Box</th>
<th className="px-6 py-3 border-b border-border text-right">{previousYear}</th>
<th className="px-6 py-3 border-b border-border text-right">{currentYear}</th>
<th className="px-6 py-3 border-b border-border text-right">Diff</th>
<th className="px-6 py-3 border-b border-border text-right">% Change</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{data.map((item, index) => {
const isPositive = item.diff >= 0;
return (
<tr key={item.sku} className="hover:bg-slate-800/50 transition-colors group">
<td className="px-6 py-3 text-center font-mono text-slate-500 font-bold">
{index + 1}
</td>
<td className="px-6 py-3">
<div className="flex flex-col">
<span className="text-white font-medium text-base truncate max-w-xs" title={item.title}>
{item.title || 'Unknown Title'}
</span>
<span className="text-xs text-slate-500 font-mono mt-0.5">SKU: {item.sku}</span>
</div>
</td>
<td className="px-6 py-3 text-slate-400">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-800 text-slate-300 border border-slate-700">
{item.line}
</span>
</td>
{/* Stock Badge */}
<td className="px-6 py-3 text-center">
<StockBadge stock={stockMap?.get(item.sku)} />
</td>
{/* Vendor Stock Badge */}
<td className="px-6 py-3 text-center">
<VendorStockBadge
asin={item.asin}
vendorStockMap={vendorStockMap}
mode={top50Mode}
/>
</td>
{/* Buy Box Warning Badge */}
<td className="px-6 py-3 text-center">
<BuyBoxWarningBadge asin={item.asin} buyBoxLostMap={buyBoxLostMap} />
</td>
<td className="px-6 py-3 text-right text-slate-500">
{formatValue(item.previousValue)}
</td>
<td className="px-6 py-3 text-right font-bold text-slate-200 group-hover:text-white">
{formatValue(item.currentValue)}
</td>
<td className={`px-6 py-3 text-right font-medium ${isPositive ? 'text-emerald-400' : 'text-rose-400'}`}>
{isPositive ? '+' : ''}{formatValue(item.diff)}
</td>
<td className="px-6 py-3 text-right">
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-bold w-20 justify-center ${bgClass}`}>
{isPositive ? '↑' : '↓'} {Math.abs(item.pct).toFixed(1)}%
</span>
</td>
</tr>
);
})}
{data.length === 0 && (
<tr>
<td colSpan={10} className="px-6 py-12 text-center text-slate-500 italic">
No records found matching this criteria.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
};
const TopMovers: React.FC<TopMoversProps> = ({ data, stockMap, vendorStockMap, buyBoxLostMap, top50Mode = 'eu' }) => {
const [metric, setMetric] = useState<Metric>('sellOut');
const [viewMode, setViewMode] = useState<'growth' | 'decline'>('growth');
// 1. Determine comparison years from filtered data
const { currentYear, previousYear, availableYears } = useMemo(() => {
const years = Array.from(new Set(data.map(d => d.year))).sort((a: number, b: number) => b - a);
return {
currentYear: years[0] || null,
previousYear: years[1] || null,
availableYears: years
};
}, [data]);
// 2. Aggregation Logic
const { growers, decliners } = useMemo(() => {
if (!currentYear || !previousYear) return { growers: [], decliners: [] };
// Map: SKU -> { currentVal, previousVal, metadata }
const map = new Map<string, { current: number; previous: number; title: string; line: string; asin: string }>();
data.forEach(row => {
// Only care about the two comparison years
if (row.year !== currentYear && row.year !== previousYear) return;
if (!map.has(row.sku)) {
map.set(row.sku, { current: 0, previous: 0, title: row.title, line: row.line, asin: row.asin });
}
const entry = map.get(row.sku)!;
const value = metric === 'sellOut' ? row.sellOut : row.units;
if (row.year === currentYear) {
entry.current += value;
} else {
entry.previous += value;
}
});
// Convert to Array and Calculate Deltas
const list: SkuAggr[] = [];
map.forEach((val, sku) => {
// Filter out items that have 0 in BOTH years (irrelevant)
if (val.current === 0 && val.previous === 0) return;
const diff = val.current - val.previous;
let pct = 0;
if (val.previous !== 0) {
pct = (diff / val.previous) * 100;
} else if (val.current !== 0) {
// Infinite growth (0 -> 100)
pct = 100;
}
list.push({
sku,
asin: val.asin,
title: val.title,
line: val.line,
previousValue: val.previous,
currentValue: val.current,
diff,
pct
});
});
// Separate and Sort
const growers = list
.filter(i => i.diff > 0)
.sort((a, b) => b.diff - a.diff) // Descending by Growth
.slice(0, 20);
const decliners = list
.filter(i => i.diff < 0)
.sort((a, b) => a.diff - b.diff) // Ascending by Decline (Most negative first)
.slice(0, 20);
return { growers, decliners };
}, [data, metric, currentYear, previousYear]);
if (availableYears.length < 2 || !currentYear || !previousYear) {
return (
<div className="flex flex-col items-center justify-center h-96 bg-slate-900 rounded-xl border border-border p-8">
<h3 className="text-xl font-bold text-slate-300 mb-2">Insufficient Data for Comparison</h3>
<p className="text-slate-500 text-center max-w-md">
To see Top Movers, please ensure your filters include at least <b>two different years</b> (e.g., 2024 and 2025).
</p>
<p className="mt-4 text-xs text-slate-600">Current Years Available: {availableYears.join(', ') || 'None'}</p>
</div>
);
}
return (
<div className="space-y-4 md:space-y-6 max-w-7xl mx-auto px-1 md:px-0 pb-24 animate-fade-in">
{/* Controls Header */}
<div className="bg-surface border border-border rounded-xl p-6 shadow-sm flex flex-col md:flex-row justify-between items-center gap-4">
<div>
<h2 className="text-2xl font-bold text-blue-400 flex items-center gap-2">
Analytics Overview
</h2>
<p className="text-sm text-slate-400 mt-1">
Comparing Performance: <span className="font-mono text-indigo-300 font-bold">{previousYear}</span> vs <span className="font-mono text-indigo-300 font-bold">{currentYear}</span>
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 items-center">
{/* Gainers / Losers Toggle */}
<div className="bg-slate-900 p-1 rounded-lg border border-slate-800 flex text-sm">
<button
onClick={() => setViewMode('growth')}
className={`px-4 py-2 rounded-md transition-colors font-medium flex items-center gap-2 ${viewMode === 'growth' ? 'bg-emerald-600/20 text-emerald-400 border border-emerald-500/50 shadow-sm' : 'text-slate-400 hover:text-white'}`}
>
<span>🚀 Top Gainers</span>
</button>
<button
onClick={() => setViewMode('decline')}
className={`px-4 py-2 rounded-md transition-colors font-medium flex items-center gap-2 ${viewMode === 'decline' ? 'bg-rose-600/20 text-rose-400 border border-rose-500/50 shadow-sm' : 'text-slate-400 hover:text-white'}`}
>
<span>📉 Top Losers</span>
</button>
</div>
{/* Metric Toggle */}
<div className="bg-slate-900 p-1 rounded-lg border border-slate-800 flex text-sm">
<button
onClick={() => setMetric('sellOut')}
className={`px-4 py-2 rounded-md transition-colors font-medium ${metric === 'sellOut' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'}`}
>
Sell Out ()
</button>
<button
onClick={() => setMetric('units')}
className={`px-4 py-2 rounded-md transition-colors font-medium ${metric === 'units' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'}`}
>
Units
</button>
</div>
</div>
</div>
{/* Single Active Table */}
<MoversTable
title={viewMode === 'growth' ? "Fastest Growing SKUs" : "Biggest Declining SKUs"}
data={viewMode === 'growth' ? growers : decliners}
metric={metric}
previousYear={previousYear}
currentYear={currentYear}
type={viewMode}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
buyBoxLostMap={buyBoxLostMap}
top50Mode={top50Mode}
/>
</div>
);
};
export default TopMovers;