-
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
+
+
+
+
+
+ | Period |
+ Spend |
+ Ad Sales |
+ Total Sales |
+ ACOS |
+ TACOS |
+ Clicks |
+ CPC |
+
+
+
+ {[...aggregated].reverse().map((row, idx) => (
+
+ | {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
-
-
-
-
-
- | Period |
- Spend |
- Ad Sales |
- Total Sales |
- ACOS |
- TACOS |
- Clicks |
- CPC |
-
-
-
- {[...aggregated].reverse().map((row, idx) => (
-
- | {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 {