mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 14:15:23 +02:00
307 lines
12 KiB
TypeScript
307 lines
12 KiB
TypeScript
import React, { useState, useMemo, useCallback } from 'react';
|
|
import * as XLSX from 'xlsx';
|
|
import { SalesRecord } from '../types';
|
|
import { DownloadIcon } from './Icons';
|
|
|
|
interface TopMoversProps {
|
|
data: SalesRecord[];
|
|
}
|
|
|
|
type Metric = 'sellOut' | 'units';
|
|
|
|
interface SkuAggr {
|
|
sku: 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';
|
|
}> = ({ title, data, metric, previousYear, currentYear, type }) => {
|
|
|
|
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-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>
|
|
|
|
<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={7} 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 }) => {
|
|
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],
|
|
previousYear: years[1],
|
|
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 }>();
|
|
|
|
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 });
|
|
}
|
|
|
|
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,
|
|
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) {
|
|
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-6 max-w-7xl mx-auto 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}
|
|
/>
|
|
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TopMovers;
|