feat: enable ads metrics at any grouping level (SKU, Line, etc.) in Grid

This commit is contained in:
Christian Vidal Wolf
2026-01-21 11:02:12 +01:00
parent 1c2a0e86aa
commit b6573ebe15
3 changed files with 40 additions and 61 deletions
+1 -1
View File
@@ -409,7 +409,7 @@ const App: React.FC = () => {
adsData={filteredAdsData} adsData={filteredAdsData}
/> />
)} )}
{view === 'table' && <DataGrid data={filteredData} hasCustomerFilter={filters.customer.length > 0} adsData={filteredAdsData} />} {view === 'table' && <DataGrid data={combinedAdsData} hasCustomerFilter={filters.customer.length > 0} adsData={filteredAdsData} />}
{view === 'movers' && <TopMovers data={filteredData} />} {view === 'movers' && <TopMovers data={filteredData} />}
</div> </div>
</> </>
+12 -51
View File
@@ -2,12 +2,12 @@ import React, { useState, useMemo, useEffect } from 'react';
import { import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
} from 'recharts'; } 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 { pivotSalesData, generateCSV, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor';
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons'; import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons';
interface DataGridProps { interface DataGridProps {
data: SalesRecord[]; data: SalesRecord[] | CombinedKPIs[];
hasCustomerFilter: boolean; hasCustomerFilter: boolean;
adsData?: AdsRecord[]; adsData?: AdsRecord[];
} }
@@ -271,59 +271,20 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
[selectedDimensions]); [selectedDimensions]);
// Transform flat data into Pivot structure // Transform flat data into Pivot structure
const { rows: basePivotRows, years } = useMemo(() => { const pivotRows = useMemo(() => {
// Apply Pan-EU grouping when no customer filter is applied // 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]); }, [data, effectiveDimensions, hasCustomerFilter]);
// Enrich pivot rows with ads data aggregated by ASIN const { years } = useMemo(() => {
const pivotRows = useMemo(() => { // We still need unique years for columns
if (!adsData || adsData.length === 0) return basePivotRows; const yearsSet = new Set(data.map(d => String((d as any).year)));
return { years: Array.from(yearsSet).sort((a, b) => parseInt(b) - parseInt(a)) };
// Aggregate ads by ASIN + Year }, [data]);
const adsAggMap = new Map<string, Map<string, { adSpend: number; attributedSales: number }>>();
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<string, { adSpend: number; attributedSales: number; acos: number; tacos: number }> = {};
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]);
// Data for the time series chart, supporting single and multi-year comparison // Data for the time series chart, supporting single and multi-year comparison
const { chartData, uniqueYears, isComparisonView, chartTitle } = useMemo(() => { const { chartData, uniqueYears, isComparisonView, chartTitle } = useMemo(() => {
+27 -9
View File
@@ -1013,7 +1013,7 @@ export const getUniqueValues = (data: SalesRecord[], field: keyof SalesRecord):
return Array.from(values).sort(); 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 // 1. Determine all years present in the data for columns
const yearsSet = new Set(data.map(d => d.year)); const yearsSet = new Set(data.map(d => d.year));
const years = Array.from(yearsSet).sort((a, b) => b - a).map(String); 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 => { data.forEach(record => {
// Group by Dynamic Dimensions // 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('||'); const key = keyParts.join('||');
if (!map.has(key)) { if (!map.has(key)) {
@@ -1039,12 +1039,14 @@ export const pivotSalesData = (data: SalesRecord[], dimensions: string[] = ['tit
monthIndex: i, monthIndex: i,
byYear: {} byYear: {}
})), })),
totalsByYear: {} totalsByYear: {},
adsByYear: {}
}); });
} }
const row = map.get(key)!; 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 monthIdx = MONTH_ORDER.indexOf(monthPart);
const yearStr = record.year.toString(); const yearStr = record.year.toString();
@@ -1052,17 +1054,33 @@ export const pivotSalesData = (data: SalesRecord[], dimensions: string[] = ['tit
if (!row.totalsByYear[yearStr]) { if (!row.totalsByYear[yearStr]) {
row.totalsByYear[yearStr] = { sellOut: 0, units: 0 }; row.totalsByYear[yearStr] = { sellOut: 0, units: 0 };
} }
row.totalsByYear[yearStr].sellOut += record.sellOut; row.totalsByYear[yearStr].sellOut += (record.sellOut || record.salesTotal || 0);
row.totalsByYear[yearStr].units += record.units; 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) { if (monthIdx !== -1) {
const m = row.months[monthIdx]; const m = row.months[monthIdx];
if (!m.byYear[yearStr]) { if (!m.byYear[yearStr]) {
m.byYear[yearStr] = { sellOut: 0, units: 0 }; m.byYear[yearStr] = { sellOut: 0, units: 0 };
} }
m.byYear[yearStr].sellOut += record.sellOut; m.byYear[yearStr].sellOut += (record.sellOut || record.salesTotal || 0);
m.byYear[yearStr].units += record.units; m.byYear[yearStr].units += (record.units || record.unitsTotal || 0);
} }
}); });