feat: Integrate advertising data and dashboard

Adds functionality to process and display advertising data alongside sales data. This includes:
- Introducing a new `AdvertisingDashboard` component.
- Modifying `FileUpload` to accept advertising CSVs.
- Updating `App.tsx` to manage and display both sales and ad data.
- Enhancing `dataProcessor.ts` to handle advertising CSV parsing and merging.
- Adding a `MegaphoneIcon` for advertising-related UI elements.
- Defining new types for advertising records and combined KPIs.
This commit is contained in:
Christian
2025-12-11 15:08:01 +01:00
parent 31eed97ec4
commit bde9d323f1
5 changed files with 507 additions and 119 deletions
+133 -85
View File
@@ -14,29 +14,26 @@ const parseCurrency = (value: string): number => {
// UNLESS it also contains a dot and the comma is before the dot (e.g. 1,000.50 - US format)
// But given the context (DE data), comma is usually decimal.
// Case A: European Format (e.g., "277.179,09" or "50,00")
if (clean.includes(',') && !clean.includes('.') && clean.indexOf(',') > clean.length - 4) {
clean = clean.replace(',', '.');
return parseFloat(clean);
// Case A: European Format (e.g., "277.179,09" or "50,00" or "263,83")
if (clean.includes(',') && !clean.includes('.')) {
// Likely EU decimal without thousands or with thousands implicitly handled
// e.g. "263,83" -> "263.83"
clean = clean.replace(',', '.');
return parseFloat(clean);
}
else if (clean.includes(',') && clean.includes('.')) {
// Mixed: 1.234,56
// Mixed: 1.234,56 -> EU
if (clean.indexOf(',') > clean.indexOf('.')) {
clean = clean.replace(/\./g, '').replace(',', '.');
} else {
// 1,234.56
// 1,234.56 -> US
clean = clean.replace(/,/g, '');
}
return parseFloat(clean);
}
else if (clean.includes(',')) {
// Likely EU decimal
clean = clean.replace(',', '.');
return parseFloat(clean);
}
// Case B: Standard/US Format or Clean Number (e.g. "277179.09" or "1000")
clean = clean.replace(/,/g, '');
clean = clean.replace(/,/g, ''); // Remove commas just in case
const num = parseFloat(clean);
return isNaN(num) ? 0 : num;
@@ -52,30 +49,45 @@ const parseUnits = (value: string): number => {
const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
// Mapping for Spanish Month Names
const SPANISH_MONTHS: Record<string, string> = {
'ene': 'Jan', 'enero': 'Jan',
'feb': 'Feb', 'febrero': 'Feb',
'mar': 'Mar', 'marzo': 'Mar',
'abr': 'Apr', 'abril': 'Apr',
'may': 'May', 'mayo': 'May',
'jun': 'Jun', 'junio': 'Jun',
'jul': 'Jul', 'julio': 'Jul',
'ago': 'Aug', 'agosto': 'Aug',
'sep': 'Sep', 'septiembre': 'Sep', 'set': 'Sep', 'setiembre': 'Sep',
'oct': 'Oct', 'octubre': 'Oct',
'nov': 'Nov', 'noviembre': 'Nov',
'dic': 'Dec', 'diciembre': 'Dec'
// Comprehensive Month Mapping (English + Spanish + Short/Full)
const MONTH_MAP: Record<string, string> = {
// English Short
'jan': 'Jan', 'feb': 'Feb', 'mar': 'Mar', 'apr': 'Apr', 'may': 'May', 'jun': 'Jun',
'jul': 'Jul', 'aug': 'Aug', 'sep': 'Sep', 'oct': 'Oct', 'nov': 'Nov', 'dec': 'Dec',
// Spanish Short
'ene': 'Jan', 'abr': 'Apr', 'ago': 'Aug', 'dic': 'Dec', 'set': 'Sep',
// Spanish Full
'enero': 'Jan', 'febrero': 'Feb', 'marzo': 'Mar', 'abril': 'Apr', 'mayo': 'May', 'junio': 'Jun',
'julio': 'Jul', 'agosto': 'Aug', 'septiembre': 'Sep', 'octubre': 'Oct', 'noviembre': 'Nov', 'diciembre': 'Dec',
// English Full
'january': 'Jan', 'february': 'Feb', 'march': 'Mar', 'april': 'Apr', 'june': 'Jun',
'july': 'Jul', 'august': 'Aug', 'september': 'Sep', 'october': 'Oct', 'november': 'Nov', 'december': 'Dec'
};
// Robust Month Normalizer
const normalizeMonth = (rawMonth: string): string => {
if (!rawMonth) return '';
let m = rawMonth.trim();
let m = String(rawMonth).trim().toLowerCase();
// Handle numeric months "01", "1", "01-2023" (start with digits)
// 0. Check for Excel Serial Date (e.g. 45544 -> Sep)
// 25569 is the offset days between Excel epoch (1899-12-30) and Unix epoch (1970-01-01)
// We check if it's a number > 20000 (roughly year 1954+) to avoid confusion with valid days like "31"
const potentialSerial = parseFloat(m);
if (!isNaN(potentialSerial) && potentialSerial > 20000) {
// Convert Excel serial to JS Date
const date = new Date(Math.round((potentialSerial - 25569) * 86400 * 1000));
if (!isNaN(date.getTime())) {
return MONTH_ORDER[date.getMonth()];
}
}
// 1. Direct Map Lookup (Handles "jan", "enero", "sep", etc.)
if (MONTH_MAP[m]) return MONTH_MAP[m];
// 2. Handle numeric months "01", "1", "01-2023"
// If it's a full date string like "2023-04-01" or "01/04/2023"
if (m.includes('/') || m.includes('-')) {
// Try parsing standard date
const date = new Date(m);
if (!isNaN(date.getTime())) {
const monthIdx = date.getMonth();
@@ -90,34 +102,30 @@ const normalizeMonth = (rawMonth: string): string => {
if (num >= 1 && num <= 12) return MONTH_ORDER[num - 1];
}
// Handle text months "Apr-23", "Apr 23", "April"
// Extract first sequence of letters
const alphaMatch = m.match(/([a-zA-Z\u00C0-\u00FF]+)/); // Include accented chars for Spanish
// 3. Fallback: Extract first 3 letters and capitalize
const alphaMatch = m.match(/([a-zA-Z\u00C0-\u00FF]+)/);
if (alphaMatch) {
let alpha = alphaMatch[1].toLowerCase();
let alpha = alphaMatch[1];
if (alpha.length > 3) alpha = alpha.substring(0, 3);
// Check map again with short version
if (MONTH_MAP[alpha]) return MONTH_MAP[alpha];
// Check Spanish mapping first
if (SPANISH_MONTHS[alpha]) {
m = SPANISH_MONTHS[alpha];
} else {
// Default to first 3 chars capitalize (English)
if (alpha.length > 3) alpha = alpha.substring(0, 3);
m = alpha.charAt(0).toUpperCase() + alpha.slice(1);
}
return alpha.charAt(0).toUpperCase() + alpha.slice(1);
}
// Try to grab year from original string to append (e.g. "Apr-23")
// Try to grab year from original string to append (e.g. "Apr-23") if strict matching failed
const yearMatch = rawMonth.match(/(\d{2,4})/);
if (yearMatch) {
let y = yearMatch[1];
if (y.length === 4) y = y.slice(2);
// Only append if year is not part of the month name logic
if (!m.includes('-')) {
return `${m}-${y}`;
// This part is likely fallback for Sales Data records
const letters = m.replace(/[^a-z]/g, '');
if (letters && MONTH_MAP[letters]) {
return `${MONTH_MAP[letters]}-${y}`;
}
}
return m;
return rawMonth; // Return as-is if all else fails
};
// Robust CSV Column Value Extractor
@@ -245,17 +253,13 @@ export const processExcel = async (file: File): Promise<SalesRecord[]> => {
// --- ADS DATA MAPPING ---
const mapCountryToMarketplace = (country: string): string => {
const c = country.toLowerCase().trim();
if (c.includes('germany') || c.includes('deutschland')) return 'AMAZON DE';
if (c.includes('spain') || c.includes('espana') || c.includes('españa')) return 'AMAZON ES';
if (c.includes('france')) return 'AMAZON FR';
if (c.includes('italy') || c.includes('italia')) return 'AMAZON IT';
if (c.includes('kingdom') || c.includes('uk') || c === 'gb') return 'AMAZON UK';
if (c.includes('netherlands') || c.includes('nederland') || c.includes('holland')) return 'AMAZON NL';
if (c.includes('sweden')) return 'AMAZON SE';
if (c.includes('poland')) return 'AMAZON PL';
if (c.includes('belgium')) return 'AMAZON BE';
if (c.includes('turkey')) return 'AMAZON TR';
const c = String(country).toLowerCase().trim();
if (c.includes('germany') || c.includes('deutschland') || c.includes('de')) return 'Amazon DE';
if (c.includes('spain') || c.includes('espana') || c.includes('españa') || c.includes('es')) return 'Amazon ES';
if (c.includes('france') || c.includes('fr')) return 'Amazon FR';
if (c.includes('italy') || c.includes('italia') || c.includes('it')) return 'Amazon IT';
if (c.includes('kingdom') || c.includes('uk') || c === 'gb') return 'Amazon UK';
if (c.includes('netherlands') || c.includes('nederland') || c.includes('holland') || c.includes('nl')) return 'Amazon NL';
return country.toUpperCase(); // Fallback
};
@@ -263,7 +267,7 @@ export const processAdsCSV = (file: File): Promise<AdsRecord[]> => {
return new Promise((resolve, reject) => {
// @ts-ignore
Papa.parse(file, {
header: false, // Index-based mapping (A=0, B=1...)
header: false, // Index-based mapping
skipEmptyLines: true,
complete: (results: any) => {
try {
@@ -273,28 +277,52 @@ export const processAdsCSV = (file: File): Promise<AdsRecord[]> => {
for (let i = 0; i < len; i++) {
const row = rows[i];
if (!Array.isArray(row) || row.length < 12) continue;
if (!Array.isArray(row) || row.length < 13) continue;
// Check header row (Column A: Country)
const c0 = String(row[0]).trim();
if (c0.toLowerCase() === 'country' || c0.toLowerCase() === 'marketplace') 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;
// Map by Column Index (A=0, B=1... L=11)
// 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)
const countryRaw = row[0];
const monthRaw = row[1];
const asin = row[2];
const costRaw = row[3];
const clicksRaw = row[4];
const impressionsRaw = row[5];
// G, H, I, J unused/calculated
const unitsRaw = row[10]; // K
const salesRaw = row[11]; // L
const yearRaw = row[2];
const asin = row[3];
const costRaw = row[4];
const clicksRaw = row[5];
const impressionsRaw = row[6];
const unitsRaw = row[11]; // L
const salesRaw = row[12]; // M
if (!asin || !countryRaw) 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}`;
}
data.push({
country: mapCountryToMarketplace(String(countryRaw)),
month: normalizeMonth(String(monthRaw)),
month: finalMonthStr,
asin: String(asin).trim(),
cost: parseCurrency(String(costRaw)),
clicks: parseUnits(String(clicksRaw)),
@@ -320,31 +348,48 @@ export const processAdsExcel = async (file: File): Promise<AdsRecord[]> => {
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
// Use header: 'A' to strictly map columns by index letter as requested
// 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'] === 'Country' && (row['C'] === 'ASIN' || row['C'] === 'Asin')) return null;
if (row['A'] === 'Customer' || row['A'] === 'Country') return null;
// Map by Column Letter as requested
// A: Country, B: Month, C: ASIN, D: Cost, E: Clicks, F: Impressions
// G: CPC, H: CTR, I: ACOS, J: Conversions
// K: Units, L: Sales
// 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 asin = row['C'];
const costRaw = row['D'];
const clicksRaw = row['E'];
const impressionsRaw = row['F'];
const unitsRaw = row['K'];
const salesRaw = row['L'];
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);
}
const finalMonthStr = yearShort ? `${pureMonth}-${yearShort}` : pureMonth;
return {
country: mapCountryToMarketplace(String(countryRaw)),
month: normalizeMonth(String(monthRaw)),
month: finalMonthStr,
asin: String(asin).trim(),
cost: parseCurrency(String(costRaw)),
clicks: parseUnits(String(clicksRaw)),
@@ -368,7 +413,10 @@ export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecor
const adsMap = new Map<string, AdsRecord>();
adsData.forEach(ad => {
const key = `${ad.asin.toUpperCase()}|${ad.country.toUpperCase()}|${ad.month}`;
// 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()}`;
// If duplicates exist (e.g. multiple campaigns for same ASIN), sum them up
if (adsMap.has(key)) {
const existing = adsMap.get(key)!;
@@ -384,7 +432,7 @@ export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecor
// 2. Iterate Sales Data and merge
const mergedData: CombinedKPIs[] = salesData.map(sale => {
const key = `${sale.asin.toUpperCase()}|${sale.customer.toUpperCase()}|${sale.month}`;
const key = `${sale.asin.trim().toUpperCase()}|${sale.customer.trim().toUpperCase()}|${sale.month.trim()}`;
const adData = adsMap.get(key) || {
country: sale.customer,
month: sale.month,