feat: Add Amazon Ads Weekly integration with Dashboard KPIs, Grid summary, and chart lines

- Update AdsRecord interface to support weekly data (year, week, cpc, ctr, acos, conversions)
- Rewrite processAdsExcel to parse multiple sheets (2025, 2026) with 12-column format
- Update mergeSalesAndAdsData to match by ASIN+Country+Year+Week
- Add Advertising Performance section to Dashboard with Ad Spend, Attributed Sales, ACOS, ROAS
- Add Ads toggle button and summary bar to DataGrid
- Add Ad Spend lines (fuchsia dashed) to weekly comparison chart
- Fix import paths in Dashboard.tsx and AdvertisingDashboard.tsx
This commit is contained in:
Christian Vidal Wolf
2026-01-21 09:45:50 +01:00
parent 67951bcac8
commit 1c826c2487
6 changed files with 491 additions and 298 deletions
+120 -91
View File
@@ -285,58 +285,61 @@ export const processAdsCSV = (file: File): Promise<AdsRecord[]> => {
for (let i = 0; i < len; i++) {
const row = rows[i];
if (!Array.isArray(row) || row.length < 13) continue;
if (!Array.isArray(row) || row.length < 12) continue;
// Check header row (Column A: Customer or Country)
const c0 = String(row[0]).trim().toLowerCase();
if (c0.includes('customer') || c0.includes('country') || c0.includes('marketplace')) continue;
// A (0): Customer/Marketplace
// B (1): Month (Can be "may", "01", or Excel serial "45544")
// C (2): Year
// D (3): ASIN
// E (4): Ad Spend
// F (5): Clicks
// G (6): Impressions
// ...
// L (11): Units (Attributed)
// M (12): Sales (Sell Out)
// Column mapping for CSV (same as Excel):
// A (0): Country
// B (1): Week
// C (2): ASIN
// D (3): Cost
// E (4): Clicks
// F (5): Impressions
// G (6): CPC
// H (7): CTR %
// I (8): ACOS %
// J (9): Conversions (30d)
// K (10): Units (30d)
// L (11): Sales (30d)
const countryRaw = row[0];
const monthRaw = row[1];
const yearRaw = row[2];
const asin = row[3];
const costRaw = row[4];
const clicksRaw = row[5];
const impressionsRaw = row[6];
const weekRaw = row[1];
const asin = row[2];
const costRaw = row[3];
const clicksRaw = row[4];
const impressionsRaw = row[5];
const cpcRaw = row[6];
const ctrRaw = row[7];
const acosRaw = row[8];
const conversionsRaw = row[9];
const unitsRaw = row[10];
const salesRaw = row[11];
const unitsRaw = row[11]; // L
const salesRaw = row[12]; // M
if (!asin || !countryRaw || weekRaw === undefined) continue;
if (!asin || !countryRaw) continue;
const weekNum = parseInt(String(weekRaw));
if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue;
// Construct Normalized Month-Year String (e.g., "May-24")
const pureMonth = normalizeMonth(String(monthRaw)); // Returns "May"
let yearShort = '';
if (yearRaw) {
yearShort = String(yearRaw).trim().replace(/[,.]/g, '').slice(-2); // "2024" -> "24", handle "2,024"
}
// Avoid double year if normalizeMonth already extracted it (rare for serial numbers but possible for strings)
let finalMonthStr = pureMonth;
if (!finalMonthStr.includes('-') && yearShort) {
finalMonthStr = `${pureMonth}-${yearShort}`;
}
// For CSV without sheet names, assume current year
const currentYear = new Date().getFullYear();
data.push({
country: mapCountryToMarketplace(String(countryRaw)),
month: finalMonthStr,
year: currentYear,
week: weekNum,
asin: String(asin).trim(),
cost: parseCurrency(String(costRaw)),
clicks: parseUnits(String(clicksRaw)),
impressions: parseUnits(String(impressionsRaw)),
attributedSales30d: parseCurrency(String(salesRaw)),
cpc: parseCurrency(String(cpcRaw)),
ctr: parseCurrency(String(ctrRaw)),
acos: parseCurrency(String(acosRaw)),
conversions: parseUnits(String(conversionsRaw)),
attributedUnits30d: parseUnits(String(unitsRaw)),
attributedSales30d: parseCurrency(String(salesRaw)),
});
}
resolve(data);
@@ -353,61 +356,80 @@ export const processAdsExcel = async (file: File): Promise<AdsRecord[]> => {
try {
const arrayBuffer = await file.arrayBuffer();
const workbook = XLSX.read(arrayBuffer);
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const allData: AdsRecord[] = [];
// Use header: 'A' to strictly map columns by index letter
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: "A", defval: "" });
const data: AdsRecord[] = jsonData.map((row: any) => {
// Check if it's a header row
if (row['A'] === 'Customer' || row['A'] === 'Country') return null;
// Updated Mapping for Excel Column Letters
// A: Country
// B: Month
// C: Year
// D: ASIN
// E: Cost
// F: Clicks
// G: Impressions
// ...
// L: Units
// M: Sales
const countryRaw = row['A'];
const monthRaw = row['B'];
const yearRaw = row['C'];
const asin = row['D'];
const costRaw = row['E'];
const clicksRaw = row['F'];
const impressionsRaw = row['G'];
const unitsRaw = row['L'];
const salesRaw = row['M'];
if (!asin || !countryRaw) return null;
// Construct Normalized Month-Year String
const pureMonth = normalizeMonth(String(monthRaw));
let yearShort = '';
if (yearRaw) {
yearShort = String(yearRaw).trim().slice(-2);
// Process ALL sheets (e.g., "2025", "2026")
for (const sheetName of workbook.SheetNames) {
const year = parseInt(sheetName);
if (isNaN(year) || year < 2020 || year > 2100) {
console.warn(`Skipping sheet "${sheetName}" - not a valid year`);
continue;
}
const finalMonthStr = yearShort ? `${pureMonth}-${yearShort}` : pureMonth;
return {
country: mapCountryToMarketplace(String(countryRaw)),
month: finalMonthStr,
asin: String(asin).trim(),
cost: parseCurrency(String(costRaw)),
clicks: parseUnits(String(clicksRaw)),
impressions: parseUnits(String(impressionsRaw)),
attributedSales30d: parseCurrency(String(salesRaw)),
attributedUnits30d: parseUnits(String(unitsRaw)),
};
}).filter((r): r is AdsRecord => r !== null);
const worksheet = workbook.Sheets[sheetName];
// Use header: 1 to get array of arrays (row-based)
const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: "" });
return data;
console.log(`Processing sheet ${sheetName}: ${jsonData.length} rows`);
// Skip header row (index 0), process data rows
for (let i = 1; i < jsonData.length; i++) {
const row = jsonData[i];
if (!row || row.length < 12) continue;
// Column mapping for Ads Weekly.xlsx:
// A (0): Country
// B (1): Week
// C (2): ASIN
// D (3): Cost
// E (4): Clicks
// F (5): Impressions
// G (6): CPC
// H (7): CTR %
// I (8): ACOS %
// J (9): Conversions (30d)
// K (10): Units (30d)
// L (11): Sales (30d)
const countryRaw = row[0];
const weekRaw = row[1];
const asin = row[2];
const costRaw = row[3];
const clicksRaw = row[4];
const impressionsRaw = row[5];
const cpcRaw = row[6];
const ctrRaw = row[7];
const acosRaw = row[8];
const conversionsRaw = row[9];
const unitsRaw = row[10];
const salesRaw = row[11];
// Skip if missing essential data
if (!asin || !countryRaw || weekRaw === undefined || weekRaw === '') continue;
const weekNum = parseInt(String(weekRaw));
if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue;
allData.push({
country: mapCountryToMarketplace(String(countryRaw)),
year,
week: weekNum,
asin: String(asin).trim(),
cost: parseCurrency(String(costRaw)),
clicks: parseUnits(String(clicksRaw)),
impressions: parseUnits(String(impressionsRaw)),
cpc: parseCurrency(String(cpcRaw)),
ctr: parseCurrency(String(ctrRaw)),
acos: parseCurrency(String(acosRaw)),
conversions: parseUnits(String(conversionsRaw)),
attributedUnits30d: parseUnits(String(unitsRaw)),
attributedSales30d: parseCurrency(String(salesRaw)),
});
}
}
console.log(`Total Ads records loaded: ${allData.length}`);
return allData;
} catch (error) {
console.error("Error processing Ads Excel:", error);
throw error;
@@ -417,13 +439,12 @@ export const processAdsExcel = async (file: File): Promise<AdsRecord[]> => {
// --- DATA MERGING ---
export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecord[]): CombinedKPIs[] => {
// 1. Index Ads Data for fast lookup: Key = ASIN + Marketplace + Month
// 1. Index Ads Data for fast lookup: Key = ASIN + Marketplace + Year + Week
const adsMap = new Map<string, AdsRecord>();
adsData.forEach(ad => {
// Ensure month format matches sales data (e.g. "May-24" vs "May-24")
// Case-insensitive key
const key = `${ad.asin.trim().toUpperCase()}|${ad.country.trim().toUpperCase()}|${ad.month.trim()}`;
// Case-insensitive key using ASIN + Country + Year + Week
const key = `${ad.asin.trim().toUpperCase()}|${ad.country.trim().toUpperCase()}|${ad.year}|${ad.week}`;
// If duplicates exist (e.g. multiple campaigns for same ASIN), sum them up
if (adsMap.has(key)) {
@@ -433,6 +454,7 @@ export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecor
existing.impressions += ad.impressions;
existing.attributedSales30d += ad.attributedSales30d;
existing.attributedUnits30d += ad.attributedUnits30d;
existing.conversions += ad.conversions;
} else {
adsMap.set(key, { ...ad });
}
@@ -440,14 +462,21 @@ export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecor
// 2. Iterate Sales Data and merge
const mergedData: CombinedKPIs[] = salesData.map(sale => {
const key = `${sale.asin.trim().toUpperCase()}|${sale.customer.trim().toUpperCase()}|${sale.month.trim()}`;
// Use week from sales record if available
const weekNum = sale.week || 0;
const key = `${sale.asin.trim().toUpperCase()}|${sale.customer.trim().toUpperCase()}|${sale.year}|${weekNum}`;
const adData = adsMap.get(key) || {
country: sale.customer,
month: sale.month,
year: sale.year,
week: weekNum,
asin: sale.asin,
cost: 0,
clicks: 0,
impressions: 0,
cpc: 0,
ctr: 0,
acos: 0,
conversions: 0,
attributedSales30d: 0,
attributedUnits30d: 0
};