From 1c826c2487b61b30a8691af3b50bc34198a88e63 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Wed, 21 Jan 2026 09:45:50 +0100 Subject: [PATCH] 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 --- App.tsx | 17 +- components/AdvertisingDashboard.tsx | 352 ++++++++++++++-------------- components/Dashboard.tsx | 59 ++++- components/DataGrid.tsx | 137 +++++++++-- services/dataProcessor.ts | 211 ++++++++++------- types.ts | 13 +- 6 files changed, 491 insertions(+), 298 deletions(-) diff --git a/App.tsx b/App.tsx index 7fcbee8..14e34cb 100644 --- a/App.tsx +++ b/App.tsx @@ -9,7 +9,7 @@ import FilterBar from './components/FilterBar'; import AIChat from './components/AIChat'; import CrazeLogo from './components/CrazeLogo'; import { SalesRecord, FilterState, AggregatedData, AdsRecord } from './types'; // Imported AdsRecord -import { processCSV, filterData, aggregateData, getUniqueValues, processAdsCSV, mergeSalesAndAdsData } from './services/dataProcessor'; // Imported new processors +import { processCSV, filterData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, mergeSalesAndAdsData } from './services/dataProcessor'; // Imported new processors import { queryGemini } from './services/geminiService'; import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon } from './components/Icons'; import { loadSalesData, saveSalesData, clearSalesData } from './services/storage'; @@ -155,18 +155,18 @@ const App: React.FC = () => { } }; - // Handle uploaded Ads file (Manual) + // Handle uploaded Ads file (Manual) - Supports both CSV and Excel const handleAdsUpload = async (file: File) => { setSyncing(true); try { - const data = await processAdsCSV(file); + const isExcel = file.name.endsWith('.xlsx') || file.name.endsWith('.xls'); + const data = isExcel ? await processAdsExcel(file) : await processAdsCSV(file); setAdsData(data); - console.log("Ads loaded:", data.length); + console.log("Ads loaded:", data.length, "records from", file.name); setIsDataModalOpen(false); - // setView('ads'); // Removed switching to ads view } catch (error) { - console.error("Failed to parse Ads CSV", error); - alert("Error parsing Ads CSV. Please check the format."); + console.error("Failed to parse Ads file", error); + alert("Error parsing Ads file. Please check the format."); } finally { setSyncing(false); } @@ -361,9 +361,10 @@ const App: React.FC = () => { )} - {view === 'table' && 0} />} + {view === 'table' && 0} adsData={adsData} />} {view === 'movers' && } diff --git a/components/AdvertisingDashboard.tsx b/components/AdvertisingDashboard.tsx index 612464e..081926e 100644 --- a/components/AdvertisingDashboard.tsx +++ b/components/AdvertisingDashboard.tsx @@ -2,21 +2,21 @@ import React, { useMemo } from 'react'; import { CombinedKPIs } from './types'; import { - BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, - LineChart, Line, Legend, ComposedChart, Area + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + LineChart, Line, Legend, ComposedChart, Area } from 'recharts'; interface AdvertisingDashboardProps { - data: CombinedKPIs[]; + data: CombinedKPIs[]; } // Helper to aggregate data for charts const aggregateByMonth = (data: CombinedKPIs[]) => { const map = new Map(); - + // Sort chronologically if needed, but assuming input might be mixed // We'll create keys like "Jan 23", "Feb 23" and sort them later - + data.forEach(item => { const key = item.month; // e.g. "May-24" if (!map.has(key)) { @@ -44,7 +44,7 @@ const aggregateByMonth = (data: CombinedKPIs[]) => { }); const result = Array.from(map.values()).map(r => ({ - ..r, + ...r, acos: r.salesAds > 0 ? (r.cost / r.salesAds) * 100 : 0, tacos: r.salesTotal > 0 ? (r.cost / r.salesTotal) * 100 : 0, ctr: r.impressions > 0 ? (r.clicks / r.impressions) * 100 : 0, @@ -55,10 +55,10 @@ const aggregateByMonth = (data: CombinedKPIs[]) => { return result.sort((a, b) => { const [mA, yA] = a.name.split('-'); const [mB, yB] = b.name.split('-'); - + // Year comparison if (yA !== yB) return parseInt(yA) - parseInt(yB); - + // Month comparison const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; return months.indexOf(mA) - months.indexOf(mB); @@ -66,9 +66,9 @@ const aggregateByMonth = (data: CombinedKPIs[]) => { }; const KPICard = ({ title, value, subValue, type = 'currency' }: { title: string, value: number, subValue?: string, type?: 'currency' | 'percent' | 'number' }) => { - const formatted = type === 'currency' + const formatted = type === 'currency' ? `€${value.toLocaleString('de-DE', { maximumFractionDigits: 0 })}` - : type === 'percent' + : type === 'percent' ? `${value.toFixed(2)}%` : value.toLocaleString('de-DE'); @@ -82,187 +82,187 @@ const KPICard = ({ title, value, subValue, type = 'currency' }: { title: string, }; const AdvertisingDashboard: React.FC = ({ data }) => { - - const aggregated = useMemo(() => aggregateByMonth(data), [data]); - - const totals = useMemo(() => { - return data.reduce((acc, curr) => ({ - salesTotal: acc.salesTotal + curr.salesTotal, - salesAds: acc.salesAds + curr.salesAds, - cost: acc.cost + curr.cost, - clicks: acc.clicks + curr.clicks, - impressions: acc.impressions + curr.impressions, - salesOrganic: acc.salesOrganic + curr.salesOrganic - }), { salesTotal: 0, salesAds: 0, cost: 0, clicks: 0, impressions: 0, salesOrganic: 0 }); - }, [data]); - const kpiAcos = totals.salesAds > 0 ? (totals.cost / totals.salesAds) * 100 : 0; - const kpiTacos = totals.salesTotal > 0 ? (totals.cost / totals.salesTotal) * 100 : 0; - const kpiRoas = totals.cost > 0 ? totals.salesAds / totals.cost : 0; + const aggregated = useMemo(() => aggregateByMonth(data), [data]); - if (data.length === 0) { - return ( -
-

No Advertising Data Available

-

Please upload an Ads CSV file via "Connect Data".

-
- ) - } + const totals = useMemo(() => { + return data.reduce((acc, curr) => ({ + salesTotal: acc.salesTotal + curr.salesTotal, + salesAds: acc.salesAds + curr.salesAds, + cost: acc.cost + curr.cost, + clicks: acc.clicks + curr.clicks, + impressions: acc.impressions + curr.impressions, + salesOrganic: acc.salesOrganic + curr.salesOrganic + }), { salesTotal: 0, salesAds: 0, cost: 0, clicks: 0, impressions: 0, salesOrganic: 0 }); + }, [data]); - return ( -
- - {/* KPI Grid */} -
- - - - - - -
+ const kpiAcos = totals.salesAds > 0 ? (totals.cost / totals.salesAds) * 100 : 0; + const kpiTacos = totals.salesTotal > 0 ? (totals.cost / totals.salesTotal) * 100 : 0; + const kpiRoas = totals.cost > 0 ? totals.salesAds / totals.cost : 0; - {/* Charts Row 1 */} -
- - {/* Spend vs Sales Trend */} -
-

Ad Spend vs Ad Sales Trend

-
- - - - - `€${v/1000}k`} /> - `€${val.toLocaleString('de-DE')}`} - /> - - - - - + if (data.length === 0) { + return ( +
+

No Advertising Data Available

+

Please upload an Ads CSV file via "Connect Data".

+
+ ) + } + + return ( +
+ + {/* KPI Grid */} +
+ + + + + + +
+ + {/* Charts Row 1 */} +
+ + {/* Spend vs Sales Trend */} +
+

Ad Spend vs Ad Sales Trend

+
+ + + + + `€${v / 1000}k`} /> + `€${val.toLocaleString('de-DE')}`} + /> + + + + + +
+
+ + {/* ACOS vs TACOS Trend */} +
+

Efficiency: ACOS vs TACOS

+
+ + + + + + `${val.toFixed(2)}%`} + /> + + + + + +
- {/* ACOS vs TACOS Trend */} -
-

Efficiency: ACOS vs TACOS

-
- - - - - - `${val.toFixed(2)}%`} - /> - - - - - -
-
-
+ {/* Charts Row 2 */} +
- {/* Charts Row 2 */} -
- - {/* Organic vs Paid Sales */} -
-

Total Sales Composition (Organic vs Paid)

-
- - - - - - - - - - - - - - - `€${v/1000}k`} /> - `€${val.toLocaleString('de-DE')}`} - /> - - - - - + {/* Organic vs Paid Sales */} +
+

Total Sales Composition (Organic vs Paid)

+
+ + + + + + + + + + + + + + + `€${v / 1000}k`} /> + `€${val.toLocaleString('de-DE')}`} + /> + + + + + +
+
+ + {/* Funnel: Impressions -> Clicks */} +
+

Marketing Funnel (Impressions & Clicks)

+
+ + + + + `${(v / 1000).toFixed(0)}k`} label={{ value: 'Impressions', angle: -90, position: 'insideLeft', fill: '#8b5cf6' }} /> + + + + + + + +
- {/* Funnel: Impressions -> Clicks */} -
-

Marketing Funnel (Impressions & Clicks)

-
- - - - - `${(v/1000).toFixed(0)}k`} label={{ value: 'Impressions', angle: -90, position: 'insideLeft', fill: '#8b5cf6' }} /> - - - - - - - + {/* Detailed Table */} +
+
+

Monthly Advertising Breakdown

+
+
+ + + + + + + + + + + + + + + {[...aggregated].reverse().map((row, idx) => ( + + + + + + + + + + + ))} + +
PeriodSpendAd SalesTotal SalesACOSTACOSClicksCPC
{row.name}€{row.cost.toLocaleString('de-DE', { maximumFractionDigits: 0 })}€{row.salesAds.toLocaleString('de-DE', { maximumFractionDigits: 0 })}€{row.salesTotal.toLocaleString('de-DE', { maximumFractionDigits: 0 })}{row.acos.toFixed(2)}%{row.tacos.toFixed(2)}%{row.clicks.toLocaleString('de-DE')}€{row.cpc.toFixed(2)}
-
- {/* Detailed Table */} -
-
-

Monthly Advertising Breakdown

-
-
- - - - - - - - - - - - - - - {[...aggregated].reverse().map((row, idx) => ( - - - - - - - - - - - ))} - -
PeriodSpendAd SalesTotal SalesACOSTACOSClicksCPC
{row.name}€{row.cost.toLocaleString('de-DE', {maximumFractionDigits:0})}€{row.salesAds.toLocaleString('de-DE', {maximumFractionDigits:0})}€{row.salesTotal.toLocaleString('de-DE', {maximumFractionDigits:0})}{row.acos.toFixed(2)}%{row.tacos.toFixed(2)}%{row.clicks.toLocaleString('de-DE')}€{row.cpc.toFixed(2)}
-
- -
- ); + ); }; // Fix for AreaChart reference error in some bundlers, just export diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index 6adacaa..067d664 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -1,6 +1,6 @@ import React, { useState, useMemo, useEffect } from 'react'; -import { AggregatedData, GrowthMetric } from './types'; +import { AggregatedData, GrowthMetric, AdsRecord } from '../types'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, Legend @@ -9,6 +9,7 @@ import { interface DashboardProps { data: AggregatedData; contextData?: AggregatedData | null; + adsData?: AdsRecord[]; } const COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#0ea5e9']; @@ -454,10 +455,31 @@ const GrowthTable: React.FC<{ ); } -const Dashboard: React.FC = ({ data, contextData }) => { +const Dashboard: React.FC = ({ data, contextData, adsData = [] }) => { const [seasonalityMetric, setSeasonalityMetric] = useState<'sellOut' | 'units'>('sellOut'); const [top10Metric, setTop10Metric] = useState<'sellOut' | 'units'>('sellOut'); + // Calculate Ads KPIs + const adsKPIs = useMemo(() => { + if (!adsData || adsData.length === 0) return null; + + const totals = adsData.reduce((acc, ad) => ({ + cost: acc.cost + ad.cost, + attributedSales: acc.attributedSales + ad.attributedSales30d, + clicks: acc.clicks + ad.clicks, + impressions: acc.impressions + ad.impressions, + }), { cost: 0, attributedSales: 0, clicks: 0, impressions: 0 }); + + return { + totalSpend: totals.cost, + attributedSales: totals.attributedSales, + acos: totals.attributedSales > 0 ? (totals.cost / totals.attributedSales) * 100 : 0, + roas: totals.cost > 0 ? totals.attributedSales / totals.cost : 0, + cpc: totals.clicks > 0 ? totals.cost / totals.clicks : 0, + ctr: totals.impressions > 0 ? (totals.clicks / totals.impressions) * 100 : 0, + }; + }, [adsData]); + // Decide which data source to use for Product Line charts // If contextData is provided (drill down), we use that to show the "Total Line" view. // Otherwise we use the standard filtered data. @@ -470,6 +492,39 @@ const Dashboard: React.FC = ({ data, contextData }) => { return (
+ {/* Ads Performance Section - Only shown when ads data is loaded */} + {adsKPIs && ( +
+
+ +

Advertising Performance

+ {adsData.length.toLocaleString('de-DE')} ad records loaded +
+
+
+ Total Ad Spend + €{adsKPIs.totalSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })} +
+
+ Attributed Sales + €{adsKPIs.attributedSales.toLocaleString('de-DE', { maximumFractionDigits: 0 })} +
+
+ ACOS + + {adsKPIs.acos.toFixed(1)}% + +
+
+ ROAS + = 3 ? 'text-emerald-400' : adsKPIs.roas >= 2 ? 'text-amber-400' : 'text-red-400'}`}> + {adsKPIs.roas.toFixed(2)}x + +
+
+
+ )} + {/* KPI Section - Pass both specific data and context data */}
= ({ data, hasCustomerFilter }) => { +const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData = [] }) => { const [currentPage, setCurrentPage] = useState(1); const [sortConfig, setSortConfig] = useState({ key: null, direction: 'desc' }); const [showChart, setShowChart] = useState(true); const [visibleMetrics, setVisibleMetrics] = useState<('sellOut' | 'units')[]>(['sellOut', 'units']); + const [showAdsMetrics, setShowAdsMetrics] = useState(true); + + // Calculate Ads Summary for the Grid + const adsSummary = useMemo(() => { + if (!adsData || adsData.length === 0) return null; + + const totals = adsData.reduce((acc, ad) => ({ + cost: acc.cost + ad.cost, + attributedSales: acc.attributedSales + ad.attributedSales30d, + clicks: acc.clicks + ad.clicks, + impressions: acc.impressions + ad.impressions, + }), { cost: 0, attributedSales: 0, clicks: 0, impressions: 0 }); + + return { + totalSpend: totals.cost, + attributedSales: totals.attributedSales, + acos: totals.attributedSales > 0 ? (totals.cost / totals.attributedSales) * 100 : 0, + roas: totals.cost > 0 ? totals.attributedSales / totals.cost : 0, + recordCount: adsData.length, + }; + }, [adsData]); // State for dynamic grouping const [selectedDimensions, setSelectedDimensions] = useState(['line', 'customer', 'sku', 'title']); @@ -260,22 +282,42 @@ const DataGrid: React.FC = ({ data, hasCustomerFilter }) => { const yearsInView = Array.from(new Set(data.map(d => d.year.toString()))).sort((a: string, b: string) => parseInt(b) - parseInt(a)); const isMultiYear = yearsInView.length > 1; - if (isMultiYear) { - return { - chartData: aggregateForComparisonTimeSeries(data), - uniqueYears: yearsInView, - isComparisonView: true, - chartTitle: `Weekly Sales Comparison: ${yearsInView.join(' vs ')}` - }; - } else { - return { - chartData: aggregateForTimeSeries(data), - uniqueYears: yearsInView, - isComparisonView: false, - chartTitle: `Weekly Sales Evolution ${yearsInView[0] || ''}` - }; + // Get base sales data + let salesChartData = isMultiYear + ? aggregateForComparisonTimeSeries(data) + : aggregateForTimeSeries(data); + + // Aggregate ads data by week/year and merge into chartData + if (adsData && adsData.length > 0) { + const adsMap = new Map(); + + adsData.forEach(ad => { + if (ad.week >= 1 && ad.week <= 53) { + if (!adsMap.has(ad.week)) { + adsMap.set(ad.week, {}); + } + const weekData = adsMap.get(ad.week)!; + const adSpendKey = `${ad.year}_adSpend`; + weekData[adSpendKey] = (weekData[adSpendKey] || 0) + ad.cost; + } + }); + + // Merge ads data into sales chart data + salesChartData = salesChartData.map(point => { + const adsWeekData = adsMap.get(point.week) || {}; + return { ...point, ...adsWeekData }; + }); } - }, [data]); + + return { + chartData: salesChartData, + uniqueYears: yearsInView, + isComparisonView: isMultiYear, + chartTitle: isMultiYear + ? `Weekly Sales Comparison: ${yearsInView.join(' vs ')}` + : `Weekly Sales Evolution ${yearsInView[0] || ''}` + }; + }, [data, adsData]); // Filter Options based on available data const metricOptions = useMemo(() => { @@ -484,6 +526,19 @@ const DataGrid: React.FC = ({ data, hasCustomerFilter }) => { strokeDasharray="5 5" dot={false} /> + ), + // Ad Spend line (only when ads data exists and showAdsMetrics is on) + showAdsMetrics && adsSummary && ( + ) ]) ) : ( @@ -571,6 +626,19 @@ const DataGrid: React.FC = ({ data, hasCustomerFilter }) => { {showChart ? 'Hide Chart' : 'Show Chart'} + + {/* Ads Toggle - Only show when ads data is loaded */} + {adsSummary && ( + + )}
@@ -738,6 +806,41 @@ const DataGrid: React.FC = ({ data, hasCustomerFilter }) => {
+ {/* Ads Performance Summary - Shows when ads data is loaded */} + {adsSummary && showAdsMetrics && ( +
+
+
+ + Advertising Data: + {adsSummary.recordCount.toLocaleString('de-DE')} records +
+
+
+ Ad Spend: + €{adsSummary.totalSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })} +
+
+ Attr. Sales: + €{adsSummary.attributedSales.toLocaleString('de-DE', { maximumFractionDigits: 0 })} +
+
+ ACOS: + + {adsSummary.acos.toFixed(1)}% + +
+
+ ROAS: + = 3 ? 'text-emerald-400' : adsSummary.roas >= 2 ? 'text-amber-400' : 'text-red-400'}`}> + {adsSummary.roas.toFixed(2)}x + +
+
+
+
+ )} + {/* Data Table */}
diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index e4eec50..90f8fd1 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -285,58 +285,61 @@ export const processAdsCSV = (file: File): Promise => { 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 => { 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 => { // --- 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(); 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 }; diff --git a/types.ts b/types.ts index 0094428..316d589 100644 --- a/types.ts +++ b/types.ts @@ -127,14 +127,19 @@ export interface ComparisonTimeSeriesPoint { } export interface AdsRecord { - country: string; - month: string; + country: string; // Marketplace (Amazon DE, IT, ES, FR, UK) + year: number; // Year extracted from sheet name + week: number; // Week number (1-52) asin: string; - cost: number; + cost: number; // Ad spend € clicks: number; impressions: number; - attributedSales30d: number; + cpc: number; // Cost per click € + ctr: number; // Click-through rate % + acos: number; // Advertising cost of sale % + conversions: number; // Attributed conversions (30d) attributedUnits30d: number; + attributedSales30d: number; } export interface CombinedKPIs {