feat: automatic ads data loading and attributed sales line in chart

This commit is contained in:
Christian Vidal Wolf
2026-01-21 10:46:50 +01:00
parent 1c826c2487
commit e5c72c48c5
6 changed files with 307 additions and 9 deletions
+126 -2
View File
@@ -4,7 +4,7 @@ import {
} from 'recharts';
import { SalesRecord, PivotRow, AdsRecord } from '../types';
import { pivotSalesData, generateCSV, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor';
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon } from './Icons';
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons';
interface DataGridProps {
data: SalesRecord[];
@@ -232,6 +232,7 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
const [showChart, setShowChart] = useState(true);
const [visibleMetrics, setVisibleMetrics] = useState<('sellOut' | 'units')[]>(['sellOut', 'units']);
const [showAdsMetrics, setShowAdsMetrics] = useState(true);
const [showAttributedSales, setShowAttributedSales] = useState(false);
// Calculate Ads Summary for the Grid
const adsSummary = useMemo(() => {
@@ -270,13 +271,60 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
[selectedDimensions]);
// Transform flat data into Pivot structure
const { rows: pivotRows, years } = useMemo(() => {
const { rows: basePivotRows, years } = useMemo(() => {
// Apply Pan-EU grouping when no customer filter is applied
const processedData = applyPanEUGrouping(data, hasCustomerFilter);
return pivotSalesData(processedData, effectiveDimensions);
}, [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<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
const { chartData, uniqueYears, isComparisonView, chartTitle } = useMemo(() => {
const yearsInView = Array.from(new Set(data.map(d => d.year.toString()))).sort((a: string, b: string) => parseInt(b) - parseInt(a));
@@ -298,7 +346,9 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
}
const weekData = adsMap.get(ad.week)!;
const adSpendKey = `${ad.year}_adSpend`;
const attrSalesKey = `${ad.year}_attributedSales`;
weekData[adSpendKey] = (weekData[adSpendKey] || 0) + ad.cost;
weekData[attrSalesKey] = (weekData[attrSalesKey] || 0) + ad.attributedSales30d;
}
});
@@ -420,6 +470,17 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
}
}
// Handle sorting by Ads Metrics (adSpend_2025, tacos_2025)
else if ((sortConfig.key as string).includes('_') && !((sortConfig.key as string).startsWith('total_') || (sortConfig.key as string).startsWith('growth_'))) {
const parts = (sortConfig.key as string).split('_');
if (parts.length === 2) {
const metric = parts[0] as 'adSpend' | 'tacos';
const year = parts[1];
valA = a.adsByYear?.[year]?.[metric] || 0;
valB = b.adsByYear?.[year]?.[metric] || 0;
}
}
if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1;
if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
@@ -539,6 +600,18 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
strokeDasharray="3 3"
dot={false}
/>
),
// Attributed Sales line
showAttributedSales && adsSummary && (
<Line
key={`${year}_attrSales`}
type="monotone"
dataKey={`${year}_attributedSales`}
name={`Attr. Sales ${year}`}
stroke="#fbbf24"
strokeWidth={2}
dot={false}
/>
)
])
) : (
@@ -639,6 +712,17 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
<span className="hidden sm:inline">{showAdsMetrics ? 'Hide Ads' : 'Show Ads'}</span>
</button>
)}
{/* Attributed Sales Toggle - Only show when ads data is loaded and ads metrics are shown */}
{adsSummary && (
<button
onClick={() => setShowAttributedSales(!showAttributedSales)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${showAttributedSales ? 'bg-amber-600/20 text-amber-400 border-amber-500/50' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'}`}
>
<TrendingIcon className="w-4 h-4" />
<span className="hidden sm:inline">{showAttributedSales ? 'Hide Attr. Sales' : 'Show Attr. Sales'}</span>
</button>
)}
</div>
<div className="flex items-center gap-3 w-full lg:w-auto justify-between lg:justify-end">
@@ -906,6 +990,28 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
</th>
</>
)}
{/* Ads Columns - Only show when ads data exists and toggle is on */}
{showAdsMetrics && adsSummary && (
<>
<th
className="px-3 py-3 border-b border-fuchsia-500/30 text-right bg-fuchsia-950/30 min-w-[90px] cursor-pointer hover:bg-fuchsia-900/40 transition-colors"
onClick={() => requestSort(`adSpend_${year}`)}
>
<div className="flex items-center justify-end text-fuchsia-400 text-[10px] uppercase">
Ad Spend {year} {getSortIcon(`adSpend_${year}`)}
</div>
</th>
<th
className="px-3 py-3 border-b border-fuchsia-500/30 text-right bg-fuchsia-950/30 min-w-[70px] cursor-pointer hover:bg-fuchsia-900/40 transition-colors"
onClick={() => requestSort(`tacos_${year}`)}
>
<div className="flex items-center justify-end text-fuchsia-400 text-[10px] uppercase">
TACOS {year} {getSortIcon(`tacos_${year}`)}
</div>
</th>
</>
)}
</React.Fragment>
);
})}
@@ -974,6 +1080,24 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
</td>
</>
)}
{/* Ads Data Cells */}
{showAdsMetrics && adsSummary && (() => {
const adsYearData = row.adsByYear?.[year];
return (
<>
<td className="px-3 py-3 text-right text-fuchsia-400 font-medium bg-fuchsia-950/10">
{adsYearData ? `${adsYearData.adSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}` : '-'}
</td>
<td className={`px-3 py-3 text-right font-bold text-xs bg-fuchsia-950/10 ${adsYearData
? (adsYearData.tacos <= 10 ? 'text-emerald-400' : adsYearData.tacos <= 20 ? 'text-amber-400' : 'text-red-400')
: 'text-slate-600'
}`}>
{adsYearData ? `${adsYearData.tacos.toFixed(1)}%` : '-'}
</td>
</>
);
})()}
</React.Fragment>
);
})}