From b6573ebe158851bb5ee3e779b9e66a844dfff51f Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Wed, 21 Jan 2026 11:02:12 +0100 Subject: [PATCH] feat: enable ads metrics at any grouping level (SKU, Line, etc.) in Grid --- App.tsx | 2 +- components/DataGrid.tsx | 63 ++++++++------------------------------- services/dataProcessor.ts | 36 ++++++++++++++++------ 3 files changed, 40 insertions(+), 61 deletions(-) diff --git a/App.tsx b/App.tsx index 2574efa..5daa6bc 100644 --- a/App.tsx +++ b/App.tsx @@ -409,7 +409,7 @@ const App: React.FC = () => { adsData={filteredAdsData} /> )} - {view === 'table' && 0} adsData={filteredAdsData} />} + {view === 'table' && 0} adsData={filteredAdsData} />} {view === 'movers' && } diff --git a/components/DataGrid.tsx b/components/DataGrid.tsx index 99859da..6b8172f 100644 --- a/components/DataGrid.tsx +++ b/components/DataGrid.tsx @@ -2,12 +2,12 @@ import React, { useState, useMemo, useEffect } from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; -import { SalesRecord, PivotRow, AdsRecord } from '../types'; +import { SalesRecord, PivotRow, AdsRecord, CombinedKPIs } from '../types'; import { pivotSalesData, generateCSV, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor'; import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons'; interface DataGridProps { - data: SalesRecord[]; + data: SalesRecord[] | CombinedKPIs[]; hasCustomerFilter: boolean; adsData?: AdsRecord[]; } @@ -271,59 +271,20 @@ const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData = [selectedDimensions]); // Transform flat data into Pivot structure - const { rows: basePivotRows, years } = useMemo(() => { + const pivotRows = useMemo(() => { // Apply Pan-EU grouping when no customer filter is applied - const processedData = applyPanEUGrouping(data, hasCustomerFilter); + const processedData = applyPanEUGrouping(data as SalesRecord[], hasCustomerFilter); - return pivotSalesData(processedData, effectiveDimensions); + // pivotSalesData now handles ads aggregation correctly because it receives CombinedKPIs + const { rows } = pivotSalesData(processedData, effectiveDimensions); + return rows; }, [data, effectiveDimensions, hasCustomerFilter]); - // Enrich pivot rows with ads data aggregated by ASIN - const pivotRows = useMemo(() => { - if (!adsData || adsData.length === 0) return basePivotRows; - - // Aggregate ads by ASIN + Year - const adsAggMap = new Map>(); - - adsData.forEach(ad => { - const asinKey = ad.asin.toUpperCase(); - const yearKey = ad.year.toString(); - - if (!adsAggMap.has(asinKey)) { - adsAggMap.set(asinKey, new Map()); - } - const yearMap = adsAggMap.get(asinKey)!; - - if (!yearMap.has(yearKey)) { - yearMap.set(yearKey, { adSpend: 0, attributedSales: 0 }); - } - const yearData = yearMap.get(yearKey)!; - yearData.adSpend += ad.cost; - yearData.attributedSales += ad.attributedSales30d; - }); - - // Enrich each pivot row with ads data - return basePivotRows.map(row => { - const asinKey = row.asin.toUpperCase(); - const yearMap = adsAggMap.get(asinKey); - - if (!yearMap) return row; - - const adsByYear: Record = {}; - - yearMap.forEach((adsYearData, yearKey) => { - const salesForYear = row.totalsByYear[yearKey]?.sellOut || 0; - adsByYear[yearKey] = { - adSpend: adsYearData.adSpend, - attributedSales: adsYearData.attributedSales, - acos: adsYearData.attributedSales > 0 ? (adsYearData.adSpend / adsYearData.attributedSales) * 100 : 0, - tacos: salesForYear > 0 ? (adsYearData.adSpend / salesForYear) * 100 : 0, - }; - }); - - return { ...row, adsByYear }; - }); - }, [basePivotRows, adsData]); + const { years } = useMemo(() => { + // We still need unique years for columns + const yearsSet = new Set(data.map(d => String((d as any).year))); + return { years: Array.from(yearsSet).sort((a, b) => parseInt(b) - parseInt(a)) }; + }, [data]); // Data for the time series chart, supporting single and multi-year comparison const { chartData, uniqueYears, isComparisonView, chartTitle } = useMemo(() => { diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index b321fd9..72430cf 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -1013,7 +1013,7 @@ export const getUniqueValues = (data: SalesRecord[], field: keyof SalesRecord): return Array.from(values).sort(); }; -export const pivotSalesData = (data: SalesRecord[], dimensions: string[] = ['title', 'customer', 'line', 'sku']): { rows: PivotRow[], years: string[] } => { +export const pivotSalesData = (data: any[], dimensions: string[] = ['title', 'customer', 'line', 'sku']): { rows: PivotRow[], years: string[] } => { // 1. Determine all years present in the data for columns const yearsSet = new Set(data.map(d => d.year)); const years = Array.from(yearsSet).sort((a, b) => b - a).map(String); @@ -1022,7 +1022,7 @@ export const pivotSalesData = (data: SalesRecord[], dimensions: string[] = ['tit data.forEach(record => { // Group by Dynamic Dimensions - const keyParts = dimensions.map(dim => String(record[dim as keyof SalesRecord] || '')); + const keyParts = dimensions.map(dim => String(record[dim] || '')); const key = keyParts.join('||'); if (!map.has(key)) { @@ -1039,12 +1039,14 @@ export const pivotSalesData = (data: SalesRecord[], dimensions: string[] = ['tit monthIndex: i, byYear: {} })), - totalsByYear: {} + totalsByYear: {}, + adsByYear: {} }); } const row = map.get(key)!; - const monthPart = record.month.split('-')[0]; // Handle "Apr-23" -> "Apr" + const monthRaw = record.month || ''; + const monthPart = monthRaw.split('-')[0]; // Handle "Apr-23" -> "Apr" const monthIdx = MONTH_ORDER.indexOf(monthPart); const yearStr = record.year.toString(); @@ -1052,17 +1054,33 @@ export const pivotSalesData = (data: SalesRecord[], dimensions: string[] = ['tit if (!row.totalsByYear[yearStr]) { row.totalsByYear[yearStr] = { sellOut: 0, units: 0 }; } - row.totalsByYear[yearStr].sellOut += record.sellOut; - row.totalsByYear[yearStr].units += record.units; + row.totalsByYear[yearStr].sellOut += (record.sellOut || record.salesTotal || 0); + row.totalsByYear[yearStr].units += (record.units || record.unitsTotal || 0); - // 2. Update Monthly Data + // 2. Update Ads Data (if present in the record) + if (record.cost !== undefined || record.salesAds !== undefined) { + if (!row.adsByYear) row.adsByYear = {}; + if (!row.adsByYear[yearStr]) { + row.adsByYear[yearStr] = { adSpend: 0, attributedSales: 0, acos: 0, tacos: 0 }; + } + row.adsByYear[yearStr].adSpend += (record.cost || 0); + row.adsByYear[yearStr].attributedSales += (record.salesAds || 0); + + // Recalculate ACOS/TACOS at the aggregated level + const ads = row.adsByYear[yearStr]; + const sales = row.totalsByYear[yearStr].sellOut; + ads.acos = ads.attributedSales > 0 ? (ads.adSpend / ads.attributedSales) * 100 : 0; + ads.tacos = sales > 0 ? (ads.adSpend / sales) * 100 : 0; + } + + // 3. Update Monthly Data if (monthIdx !== -1) { const m = row.months[monthIdx]; if (!m.byYear[yearStr]) { m.byYear[yearStr] = { sellOut: 0, units: 0 }; } - m.byYear[yearStr].sellOut += record.sellOut; - m.byYear[yearStr].units += record.units; + m.byYear[yearStr].sellOut += (record.sellOut || record.salesTotal || 0); + m.byYear[yearStr].units += (record.units || record.unitsTotal || 0); } });