mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 13:35:24 +02:00
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:
+120
-91
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user