Refactor: Switched all exports to .xlsx and enhanced Weekly Grid export

This commit is contained in:
Christian Vidal Wolf
2026-01-27 15:55:59 +01:00
parent 7178b57073
commit 219d050bf2
4 changed files with 206 additions and 226 deletions
+2 -2
View File
@@ -3,7 +3,7 @@ import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
} from 'recharts';
import { SalesRecord, PivotRow, AdsRecord, CombinedKPIs } from '../types';
import { pivotSalesData, generateCSV, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor';
import { pivotSalesData, generateXLSX, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor';
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons';
interface DataGridProps {
@@ -551,7 +551,7 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
};
const handleExport = () => {
generateCSV(processedRows, effectiveDimensions, years);
generateXLSX(processedRows, effectiveDimensions, years);
};
const addFilter = () => {
+167 -187
View File
@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { SalesRecord } from './types';
import React, { useState, useMemo, useCallback } from 'react';
import * as XLSX from 'xlsx';
import { SalesRecord } from '../types';
import { DownloadIcon } from './Icons';
interface TopMoversProps {
@@ -21,151 +21,131 @@ interface SkuAggr {
// Reusable Table Component
const MoversTable: React.FC<{
title: string;
data: SkuAggr[];
metric: Metric;
previousYear: number;
currentYear: number;
type: 'growth' | 'decline';
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 = () => {
if (!data || data.length === 0) return;
const formatValue = (val: number) => {
if (metric === 'sellOut') return `${val.toLocaleString('de-DE', { maximumFractionDigits: 0 })}`;
return val.toLocaleString('de-DE');
};
// Helper to force Comma as thousands separator (US Locale)
const formatForCSV = (val: number) => {
return val.toLocaleString('de-DE', {
useGrouping: true,
minimumFractionDigits: metric === 'sellOut' ? 2 : 0,
maximumFractionDigits: metric === 'sellOut' ? 2 : 0,
});
};
const handleExport = useCallback(() => {
if (!data || data.length === 0) return;
// Prepare data for CSV
const csvData = data.map((item, index) => ({
Rank: index + 1,
Title: item.title,
SKU: item.sku,
'Product Line': item.line,
[`${previousYear} ${metric === 'sellOut' ? 'Sell Out' : 'Units'}`]: formatForCSV(item.previousValue),
[`${currentYear} ${metric === 'sellOut' ? 'Sell Out' : 'Units'}`]: formatForCSV(item.currentValue),
'Difference': formatForCSV(item.diff),
'% Change': `${item.pct.toFixed(2)}%`
}));
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))
}));
// Generate CSV string
// @ts-ignore - Papa is loaded globally via CDN
const csv = Papa.unparse(csvData);
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]);
// Create download link
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const filename = `${title.replace(/\s+/g, '_')}_${currentYear}_vs_${previousYear}.csv`;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
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';
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>
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 }) => {
@@ -196,7 +176,7 @@ const TopMovers: React.FC<TopMoversProps> = ({ data }) => {
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;
@@ -219,7 +199,7 @@ const TopMovers: React.FC<TopMoversProps> = ({ data }) => {
pct = (diff / val.previous) * 100;
} else if (val.current !== 0) {
// Infinite growth (0 -> 100)
pct = 100;
pct = 100;
}
list.push({
@@ -235,14 +215,14 @@ const TopMovers: React.FC<TopMoversProps> = ({ data }) => {
// Separate and Sort
const growers = list
.filter(i => i.diff > 0)
.sort((a, b) => b.diff - a.diff) // Descending by Growth
.slice(0, 20);
.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);
.filter(i => i.diff < 0)
.sort((a, b) => a.diff - b.diff) // Ascending by Decline (Most negative first)
.slice(0, 20);
return { growers, decliners };
@@ -262,12 +242,12 @@ const TopMovers: React.FC<TopMoversProps> = ({ data }) => {
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
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>
@@ -275,48 +255,48 @@ const TopMovers: React.FC<TopMoversProps> = ({ data }) => {
</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>
{/* 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>
{/* 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}
<MoversTable
title={viewMode === 'growth' ? "Fastest Growing SKUs" : "Biggest Declining SKUs"}
data={viewMode === 'growth' ? growers : decliners}
metric={metric}
previousYear={previousYear}
currentYear={currentYear}
type={viewMode}
/>
</div>
+16 -1
View File
@@ -246,15 +246,30 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown
allWeeks.forEach(week => {
rowData[`${week} Units`] = row.unitsByWeek[week] || 0;
rowData[`${week} Spend`] = row.spendByWeek[week] || 0;
rowData[`${week} GV`] = row.gvByWeek[week] || 0;
});
return rowData;
});
// Add Totals row
const totalsRow: any = {
SKU: 'TOTALS',
ASIN: '',
Title: 'ALL FILTERED PRODUCTS',
Line: '',
};
allWeeks.forEach(week => {
totalsRow[`${week} Units`] = weekTotals[week]?.units || 0;
totalsRow[`${week} Spend`] = weekTotals[week]?.spend || 0;
totalsRow[`${week} GV`] = weekTotals[week]?.gv || 0;
});
exportData.push(totalsRow);
const ws = XLSX.utils.json_to_sheet(exportData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Weekly Sales');
XLSX.writeFile(wb, `Weekly_Sales_Export_${new Date().toISOString().slice(0, 10)}.xlsx`);
}, [sortedRows, allWeeks]);
}, [sortedRows, allWeeks, weekTotals]);
return (
<div className="flex flex-col gap-4 animate-fade-in">
+21 -36
View File
@@ -1270,18 +1270,19 @@ export const pivotSalesData = (data: any[], dimensions: string[] = ['title', 'cu
};
};
export const generateCSV = (rows: PivotRow[], dimensions: string[], years: string[]) => {
// Flatten PivotRows into CSV-friendly objects
export const generateXLSX = (rows: PivotRow[], dimensions: string[], years: string[]) => {
// Flatten PivotRows into Excel-friendly objects
const flatData = rows.map(row => {
const flatRow: any = {};
// Add Dimension Columns
dimensions.forEach(dim => {
// Map internal key to nicer Header if needed
let header = dim;
if (dim === 'line') header = 'Product Line';
if (dim === 'title') header = 'Title';
if (dim === 'customer') header = 'Customer';
if (dim === 'sku' || dim === 'SKU') header = 'SKU';
if (dim === 'asin' || dim === 'ASIN') header = 'ASIN';
flatRow[header] = row[dim as keyof PivotRow];
});
@@ -1306,22 +1307,13 @@ export const generateCSV = (rows: PivotRow[], dimensions: string[], years: strin
return flatRow;
});
// Generate CSV string
// @ts-ignore
const csv = Papa.unparse(flatData);
// Trigger Download
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `sales_export_${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const ws = XLSX.utils.json_to_sheet(flatData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Business Data');
XLSX.writeFile(wb, `Business_Data_Export_${new Date().toISOString().slice(0, 10)}.xlsx`);
};
export const generateItemMoversCSV = (
export const generateItemMoversXLSX = (
data: ItemGrowthMetric[],
periods: { current: string; previous: string },
type: 'Gainers' | 'Losers'
@@ -1331,27 +1323,20 @@ export const generateItemMoversCSV = (
ASIN: item.asin || '-',
'Product Title': item.title || '-',
'Product Line': item.line || '-',
[`Sell Out ${periods.previous}`]: item.previousYearSellOut.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 }),
[`Sell Out ${periods.current}`]: item.currentYearSellOut.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 }),
'SO Diff': item.sellOutGrowthValue.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 }),
'SO Growth %': item.sellOutGrowthPercentage.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + '%',
[`Units ${periods.previous}`]: item.previousYearUnits.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 }),
[`Units ${periods.current}`]: item.currentYearUnits.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 }),
'Units Diff': item.unitsGrowthValue.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 }),
'Units Growth %': item.unitsGrowthPercentage.toLocaleString('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + '%',
[`Sell Out ${periods.previous}`]: item.previousYearSellOut,
[`Sell Out ${periods.current}`]: item.currentYearSellOut,
'SO Diff': item.sellOutGrowthValue,
'SO Growth %': Number(item.sellOutGrowthPercentage.toFixed(2)),
[`Units ${periods.previous}`]: item.previousYearUnits,
[`Units ${periods.current}`]: item.currentYearUnits,
'Units Diff': item.unitsGrowthValue,
'Units Growth %': Number(item.unitsGrowthPercentage.toFixed(2)),
}));
// @ts-ignore
const csv = Papa.unparse(flatData);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `${type}_${periods.current}_vs_${periods.previous}_${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const ws = XLSX.utils.json_to_sheet(flatData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, type);
XLSX.writeFile(wb, `${type}_${periods.current}_vs_${periods.previous}_${new Date().toISOString().split('T')[0]}.xlsx`);
};