From 219d050bf2fd7c101b42c13f02f7712fe8c1f204 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Tue, 27 Jan 2026 15:55:59 +0100 Subject: [PATCH] Refactor: Switched all exports to .xlsx and enhanced Weekly Grid export --- components/DataGrid.tsx | 4 +- components/TopMovers.tsx | 354 ++++++++++++++++++-------------------- components/WeeklyGrid.tsx | 17 +- services/dataProcessor.ts | 57 +++--- 4 files changed, 206 insertions(+), 226 deletions(-) diff --git a/components/DataGrid.tsx b/components/DataGrid.tsx index 11d852b..1d8459d 100644 --- a/components/DataGrid.tsx +++ b/components/DataGrid.tsx @@ -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 = ({ data, hasCustomerFilter, adsData = }; const handleExport = () => { - generateCSV(processedRows, effectiveDimensions, years); + generateXLSX(processedRows, effectiveDimensions, years); }; const addFilter = () => { diff --git a/components/TopMovers.tsx b/components/TopMovers.tsx index 875a8b3..a429cbe 100644 --- a/components/TopMovers.tsx +++ b/components/TopMovers.tsx @@ -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 ( -
-
-

- {type === 'growth' ? '🚀 ' : '📉 '} {title} -

-
- - Top 20 -
-
- -
- - - - - - - - - - - - - - {data.map((item, index) => { - const isPositive = item.diff >= 0; - - return ( - - - - - - - - - - - - - - ); - })} - - {data.length === 0 && ( - - - - )} - -
RankSKU DetailsProduct Line{previousYear}{currentYear}Diff% Change
- {index + 1} - -
- - {item.title || 'Unknown Title'} - - SKU: {item.sku} -
-
- - {item.line} - - - {formatValue(item.previousValue)} - - {formatValue(item.currentValue)} - - {isPositive ? '+' : ''}{formatValue(item.diff)} - - - {isPositive ? '↑' : '↓'} {Math.abs(item.pct).toFixed(1)}% - -
- No records found matching this criteria. -
-
+ return ( +
+
+

+ {type === 'growth' ? '🚀 ' : '📉 '} {title} +

+
+ + Top 20
- ); +
+ +
+ + + + + + + + + + + + + + {data.map((item, index) => { + const isPositive = item.diff >= 0; + + return ( + + + + + + + + + + + + + + ); + })} + + {data.length === 0 && ( + + + + )} + +
RankSKU DetailsProduct Line{previousYear}{currentYear}Diff% Change
+ {index + 1} + +
+ + {item.title || 'Unknown Title'} + + SKU: {item.sku} +
+
+ + {item.line} + + + {formatValue(item.previousValue)} + + {formatValue(item.currentValue)} + + {isPositive ? '+' : ''}{formatValue(item.diff)} + + + {isPositive ? '↑' : '↓'} {Math.abs(item.pct).toFixed(1)}% + +
+ No records found matching this criteria. +
+
+
+ ); }; const TopMovers: React.FC = ({ data }) => { @@ -196,7 +176,7 @@ const TopMovers: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ data }) => { return (
- + {/* Controls Header */}

- Analytics Overview + Analytics Overview

Comparing Performance: {previousYear} vs {currentYear} @@ -275,48 +255,48 @@ const TopMovers: React.FC = ({ data }) => {

- {/* Gainers / Losers Toggle */} -
- - -
+ {/* Gainers / Losers Toggle */} +
+ + +
- {/* Metric Toggle */} -
- - -
+ {/* Metric Toggle */} +
+ + +
{/* Single Active Table */} -
diff --git a/components/WeeklyGrid.tsx b/components/WeeklyGrid.tsx index 8879f39..036b4bd 100644 --- a/components/WeeklyGrid.tsx +++ b/components/WeeklyGrid.tsx @@ -246,15 +246,30 @@ const WeeklyGrid: React.FC = ({ 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 (
diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index 3d3230f..53e1a92 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -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`); };