mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:45:23 +02:00
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:
@@ -4,13 +4,14 @@ import FileUpload from './components/FileUpload';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import DataGrid from './components/DataGrid';
|
||||
import TopMovers from './components/TopMovers';
|
||||
import AdvertisingDashboard from './components/AdvertisingDashboard'; // Imported
|
||||
import FilterBar from './components/FilterBar';
|
||||
import AIChat from './components/AIChat';
|
||||
import CrazeLogo from './components/CrazeLogo';
|
||||
import { SalesRecord, FilterState, AggregatedData } from './types';
|
||||
import { processCSV, filterData, aggregateData, getUniqueValues } from './services/dataProcessor';
|
||||
import { SalesRecord, FilterState, AggregatedData, AdsRecord } from './types'; // Imported AdsRecord
|
||||
import { processCSV, filterData, aggregateData, getUniqueValues, processAdsCSV, mergeSalesAndAdsData } from './services/dataProcessor'; // Imported new processors
|
||||
import { queryGemini } from './services/geminiService';
|
||||
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon } from './components/Icons';
|
||||
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons'; // Imported MegaphoneIcon
|
||||
import { loadSalesData, saveSalesData, clearSalesData } from './services/storage';
|
||||
|
||||
// New Refresh Icon
|
||||
@@ -26,9 +27,10 @@ const PERMANENT_DROPBOX_URL = "https://www.dropbox.com/scl/fi/b9zxn4z5i7sxwfakk5
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [rawData, setRawData] = useState<SalesRecord[]>([]);
|
||||
const [adsData, setAdsData] = useState<AdsRecord[]>([]); // New Ads State
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [view, setView] = useState<'dashboard' | 'table' | 'movers'>('dashboard');
|
||||
const [view, setView] = useState<'dashboard' | 'table' | 'movers' | 'ads'>('dashboard'); // Added 'ads' view
|
||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||
const [activeUrl, setActiveUrl] = useState<string | null>(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL);
|
||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
||||
@@ -131,8 +133,8 @@ const App: React.FC = () => {
|
||||
initApp();
|
||||
}, [handleUrlFetch]);
|
||||
|
||||
// Handle uploaded file (Manual)
|
||||
const handleFileUpload = async (file: File) => {
|
||||
// Handle uploaded Sales file (Manual)
|
||||
const handleSalesUpload = async (file: File) => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const data = await processCSV(file);
|
||||
@@ -148,6 +150,23 @@ const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle uploaded Ads file (Manual)
|
||||
const handleAdsUpload = async (file: File) => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const data = await processAdsCSV(file);
|
||||
setAdsData(data);
|
||||
console.log("Ads loaded:", data.length);
|
||||
setIsDataModalOpen(false);
|
||||
setView('ads'); // Switch to ads view automatically
|
||||
} catch (error) {
|
||||
console.error("Failed to parse Ads CSV", error);
|
||||
alert("Error parsing Ads CSV. Please check the format.");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Schedule Auto-Refresh (Background)
|
||||
useEffect(() => {
|
||||
const checkAndRefresh = () => {
|
||||
@@ -180,6 +199,11 @@ const App: React.FC = () => {
|
||||
// Derive Data
|
||||
const filteredData = useMemo(() => filterData(rawData, filters), [rawData, filters]);
|
||||
const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]);
|
||||
|
||||
// Combine Sales & Ads Data dynamically based on current filters
|
||||
const combinedAdsData = useMemo(() => {
|
||||
return mergeSalesAndAdsData(filteredData, adsData);
|
||||
}, [filteredData, adsData]);
|
||||
|
||||
// Derive Context Data (Product Line Context when drilling down)
|
||||
const contextAggregatedData = useMemo(() => {
|
||||
@@ -290,24 +314,31 @@ const App: React.FC = () => {
|
||||
<div className="flex bg-slate-900 rounded-lg p-1 border border-border">
|
||||
<button
|
||||
onClick={() => setView('dashboard')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
||||
${view === 'dashboard' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
||||
>
|
||||
<ChartIcon /> <span className="hidden sm:inline">Dashboard</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('table')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
||||
${view === 'table' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
||||
>
|
||||
<TableIcon /> <span className="hidden sm:inline">Data Grid</span>
|
||||
<TableIcon /> <span className="hidden sm:inline">Grid</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('movers')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
||||
${view === 'movers' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
||||
>
|
||||
<TrendingIcon /> <span className="hidden sm:inline">Top Movers</span>
|
||||
<TrendingIcon /> <span className="hidden sm:inline">Movers</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('ads')}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
||||
${view === 'ads' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
||||
>
|
||||
<MegaphoneIcon /> <span className="hidden sm:inline">Ads</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,6 +366,7 @@ const App: React.FC = () => {
|
||||
)}
|
||||
{view === 'table' && <DataGrid data={filteredData} />}
|
||||
{view === 'movers' && <TopMovers data={filteredData} />}
|
||||
{view === 'ads' && <AdvertisingDashboard data={combinedAdsData} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -356,7 +388,8 @@ const App: React.FC = () => {
|
||||
|
||||
<div className="p-6">
|
||||
<FileUpload
|
||||
onFileUpload={handleFileUpload}
|
||||
onSalesUpload={handleSalesUpload} // CORRECTED: Was handleFileUpload
|
||||
onAdsUpload={handleAdsUpload} // ADDED: Missing prop causing error
|
||||
onUrlSubmit={handleUrlFetch}
|
||||
isLoading={syncing}
|
||||
activeUrl={activeUrl}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { CombinedKPIs } from '../types';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
LineChart, Line, Legend, ComposedChart, Area
|
||||
} from 'recharts';
|
||||
|
||||
interface AdvertisingDashboardProps {
|
||||
data: CombinedKPIs[];
|
||||
}
|
||||
|
||||
// Helper to aggregate data for charts
|
||||
const aggregateByMonth = (data: CombinedKPIs[]) => {
|
||||
const map = new Map<string, any>();
|
||||
|
||||
// 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)) {
|
||||
map.set(key, {
|
||||
name: key,
|
||||
salesTotal: 0,
|
||||
salesAds: 0,
|
||||
cost: 0,
|
||||
salesOrganic: 0,
|
||||
impressions: 0,
|
||||
clicks: 0,
|
||||
unitsTotal: 0,
|
||||
unitsAds: 0
|
||||
});
|
||||
}
|
||||
const entry = map.get(key);
|
||||
entry.salesTotal += item.salesTotal;
|
||||
entry.salesAds += item.salesAds;
|
||||
entry.cost += item.cost;
|
||||
entry.salesOrganic += item.salesOrganic;
|
||||
entry.impressions += item.impressions;
|
||||
entry.clicks += item.clicks;
|
||||
entry.unitsTotal += item.unitsTotal;
|
||||
entry.unitsAds += item.unitsAds;
|
||||
});
|
||||
|
||||
const result = Array.from(map.values()).map(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,
|
||||
cpc: r.clicks > 0 ? r.cost / r.clicks : 0
|
||||
}));
|
||||
|
||||
// Sort by Date
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
const KPICard = ({ title, value, subValue, type = 'currency' }: { title: string, value: number, subValue?: string, type?: 'currency' | 'percent' | 'number' }) => {
|
||||
const formatted = type === 'currency'
|
||||
? `€${value.toLocaleString(undefined, { maximumFractionDigits: 0 })}`
|
||||
: type === 'percent'
|
||||
? `${value.toFixed(2)}%`
|
||||
: value.toLocaleString();
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-border rounded-xl p-5 flex flex-col shadow-sm hover:shadow-md transition-shadow">
|
||||
<span className="text-slate-400 text-xs font-semibold uppercase tracking-wider mb-2">{title}</span>
|
||||
<span className="text-2xl font-bold text-slate-100">{formatted}</span>
|
||||
{subValue && <span className="text-xs text-slate-500 mt-1">{subValue}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AdvertisingDashboard: React.FC<AdvertisingDashboardProps> = ({ 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;
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 text-slate-500">
|
||||
<p className="text-lg font-medium">No Advertising Data Available</p>
|
||||
<p className="text-sm">Please upload an Ads CSV file via "Connect Data".</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-7xl mx-auto pb-24 animate-fade-in">
|
||||
|
||||
{/* KPI Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<KPICard title="Ad Spend" value={totals.cost} />
|
||||
<KPICard title="Ad Sales" value={totals.salesAds} subValue={`${((totals.salesAds / totals.salesTotal) * 100).toFixed(1)}% of Total`} />
|
||||
<KPICard title="Total Sales" value={totals.salesTotal} />
|
||||
<KPICard title="ACOS" value={kpiAcos} type="percent" />
|
||||
<KPICard title="TACOS" value={kpiTacos} type="percent" />
|
||||
<KPICard title="ROAS" value={kpiRoas} type="number" subValue="Return on Ad Spend" />
|
||||
</div>
|
||||
|
||||
{/* Charts Row 1 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* Spend vs Sales Trend */}
|
||||
<div className="bg-surface border border-border rounded-xl p-6 h-96 flex flex-col">
|
||||
<h3 className="text-sm font-bold text-slate-300 uppercase mb-4">Ad Spend vs Ad Sales Trend</h3>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={aggregated}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="name" stroke="#64748b" />
|
||||
<YAxis yAxisId="left" stroke="#64748b" tickFormatter={(v) => `€${v/1000}k`} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#1e293b', color: '#e2e8f0' }}
|
||||
formatter={(val: number) => `€${val.toLocaleString()}`}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar yAxisId="left" dataKey="cost" name="Ad Spend" fill="#f43f5e" radius={[4, 4, 0, 0]} />
|
||||
<Line yAxisId="left" type="monotone" dataKey="salesAds" name="Ad Sales" stroke="#10b981" strokeWidth={3} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ACOS vs TACOS Trend */}
|
||||
<div className="bg-surface border border-border rounded-xl p-6 h-96 flex flex-col">
|
||||
<h3 className="text-sm font-bold text-slate-300 uppercase mb-4">Efficiency: ACOS vs TACOS</h3>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={aggregated}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="name" stroke="#64748b" />
|
||||
<YAxis stroke="#64748b" unit="%" />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#1e293b', color: '#e2e8f0' }}
|
||||
formatter={(val: number) => `${val.toFixed(2)}%`}
|
||||
/>
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="acos" name="ACOS" stroke="#f59e0b" strokeWidth={2} dot={{r:4}} />
|
||||
<Line type="monotone" dataKey="tacos" name="TACOS" stroke="#3b82f6" strokeWidth={2} dot={{r:4}} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts Row 2 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* Organic vs Paid Sales */}
|
||||
<div className="bg-surface border border-border rounded-xl p-6 h-96 flex flex-col">
|
||||
<h3 className="text-sm font-bold text-slate-300 uppercase mb-4">Total Sales Composition (Organic vs Paid)</h3>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={aggregated}>
|
||||
<defs>
|
||||
<linearGradient id="colorOrganic" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#6366f1" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="colorPaid" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#ec4899" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#ec4899" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="name" stroke="#64748b" />
|
||||
<YAxis stroke="#64748b" tickFormatter={(v) => `€${v/1000}k`} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#1e293b', color: '#e2e8f0' }}
|
||||
formatter={(val: number) => `€${val.toLocaleString()}`}
|
||||
/>
|
||||
<Legend />
|
||||
<Area type="monotone" dataKey="salesOrganic" name="Organic Sales" stroke="#6366f1" fillOpacity={1} fill="url(#colorOrganic)" stackId="1" />
|
||||
<Area type="monotone" dataKey="salesAds" name="Paid Sales" stroke="#ec4899" fillOpacity={1} fill="url(#colorPaid)" stackId="1" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Funnel: Impressions -> Clicks */}
|
||||
<div className="bg-surface border border-border rounded-xl p-6 h-96 flex flex-col">
|
||||
<h3 className="text-sm font-bold text-slate-300 uppercase mb-4">Marketing Funnel (Impressions & Clicks)</h3>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={aggregated}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="name" stroke="#64748b" />
|
||||
<YAxis yAxisId="left" stroke="#8b5cf6" tickFormatter={(v) => `${(v/1000).toFixed(0)}k`} label={{ value: 'Impressions', angle: -90, position: 'insideLeft', fill: '#8b5cf6' }} />
|
||||
<YAxis yAxisId="right" orientation="right" stroke="#0ea5e9" label={{ value: 'Clicks', angle: 90, position: 'insideRight', fill: '#0ea5e9' }} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#1e293b', color: '#e2e8f0' }}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar yAxisId="left" dataKey="impressions" name="Impressions" fill="#8b5cf6" radius={[4, 4, 0, 0]} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="clicks" name="Clicks" stroke="#0ea5e9" strokeWidth={2} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Table */}
|
||||
<div className="bg-surface border border-border rounded-xl shadow-lg overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-border bg-slate-900/50">
|
||||
<h3 className="font-bold text-slate-200">Monthly Advertising Breakdown</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm text-slate-300">
|
||||
<thead className="bg-slate-950 text-xs uppercase font-semibold text-slate-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Period</th>
|
||||
<th className="px-4 py-3 text-right">Spend</th>
|
||||
<th className="px-4 py-3 text-right">Ad Sales</th>
|
||||
<th className="px-4 py-3 text-right">Total Sales</th>
|
||||
<th className="px-4 py-3 text-right">ACOS</th>
|
||||
<th className="px-4 py-3 text-right">TACOS</th>
|
||||
<th className="px-4 py-3 text-right">Clicks</th>
|
||||
<th className="px-4 py-3 text-right">CPC</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{[...aggregated].reverse().map((row, idx) => (
|
||||
<tr key={idx} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 font-medium text-slate-200">{row.name}</td>
|
||||
<td className="px-4 py-3 text-right">€{row.cost.toLocaleString(undefined, {maximumFractionDigits:0})}</td>
|
||||
<td className="px-4 py-3 text-right text-emerald-400">€{row.salesAds.toLocaleString(undefined, {maximumFractionDigits:0})}</td>
|
||||
<td className="px-4 py-3 text-right">€{row.salesTotal.toLocaleString(undefined, {maximumFractionDigits:0})}</td>
|
||||
<td className="px-4 py-3 text-right">{row.acos.toFixed(2)}%</td>
|
||||
<td className="px-4 py-3 text-right">{row.tacos.toFixed(2)}%</td>
|
||||
<td className="px-4 py-3 text-right">{row.clicks.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-right">€{row.cpc.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Fix for AreaChart reference error in some bundlers, just export
|
||||
import { AreaChart } from 'recharts';
|
||||
|
||||
export default AdvertisingDashboard;
|
||||
+52
-22
@@ -1,9 +1,10 @@
|
||||
|
||||
import React, { ChangeEvent, useState } from 'react';
|
||||
import { UploadIcon } from './Icons';
|
||||
import { UploadIcon, MegaphoneIcon } from './Icons';
|
||||
|
||||
interface FileUploadProps {
|
||||
onFileUpload: (file: File) => void;
|
||||
onSalesUpload: (file: File) => void; // Renamed from onFileUpload
|
||||
onAdsUpload: (file: File) => void; // New Prop
|
||||
onUrlSubmit: (url: string) => void;
|
||||
isLoading: boolean;
|
||||
activeUrl?: string | null;
|
||||
@@ -12,7 +13,8 @@ interface FileUploadProps {
|
||||
}
|
||||
|
||||
const FileUpload: React.FC<FileUploadProps> = ({
|
||||
onFileUpload,
|
||||
onSalesUpload,
|
||||
onAdsUpload,
|
||||
onUrlSubmit,
|
||||
isLoading,
|
||||
activeUrl,
|
||||
@@ -21,12 +23,18 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
}) => {
|
||||
const [url, setUrl] = useState('');
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const handleSalesChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
onFileUpload(e.target.files[0]);
|
||||
onSalesUpload(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdsChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
onAdsUpload(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (url.trim()) {
|
||||
@@ -79,25 +87,47 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual File Upload */}
|
||||
<div className="rounded-xl border border-dashed border-slate-700 bg-slate-900/50 hover:bg-slate-900 transition-colors group">
|
||||
<label htmlFor="file-upload" className="cursor-pointer flex flex-col items-center gap-3 p-6">
|
||||
<div className="p-3 bg-indigo-500/10 rounded-full text-indigo-400 group-hover:scale-110 transition-transform">
|
||||
<UploadIcon />
|
||||
{/* Manual File Uploads */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-xl border border-dashed border-slate-700 bg-slate-900/50 hover:bg-slate-900 transition-colors group h-full">
|
||||
<label htmlFor="sales-upload" className="cursor-pointer flex flex-col items-center justify-center gap-3 p-6 h-full">
|
||||
<div className="p-3 bg-indigo-500/10 rounded-full text-indigo-400 group-hover:scale-110 transition-transform">
|
||||
<UploadIcon />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-slate-200">Upload Sales CSV</h3>
|
||||
<p className="text-[10px] text-slate-500 mt-1">Standard Sales Data</p>
|
||||
</div>
|
||||
<input
|
||||
id="sales-upload"
|
||||
type="file"
|
||||
accept=".csv,.xlsx"
|
||||
onChange={handleSalesChange}
|
||||
disabled={isLoading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-slate-200">Upload Local CSV</h3>
|
||||
<p className="text-xs text-slate-500 mt-1">Click to select file</p>
|
||||
|
||||
<div className="rounded-xl border border-dashed border-slate-700 bg-slate-900/50 hover:bg-slate-900 transition-colors group h-full">
|
||||
<label htmlFor="ads-upload" className="cursor-pointer flex flex-col items-center justify-center gap-3 p-6 h-full">
|
||||
<div className="p-3 bg-fuchsia-500/10 rounded-full text-fuchsia-400 group-hover:scale-110 transition-transform">
|
||||
<MegaphoneIcon />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-slate-200">Upload Ads CSV</h3>
|
||||
<p className="text-[10px] text-slate-500 mt-1">Advertising Expenses</p>
|
||||
</div>
|
||||
<input
|
||||
id="ads-upload"
|
||||
type="file"
|
||||
accept=".csv,.xlsx"
|
||||
onChange={handleAdsChange}
|
||||
disabled={isLoading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleChange}
|
||||
disabled={isLoading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@@ -61,3 +61,9 @@ export const TrendingIcon = () => (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18 9 11.25l4.306 4.307a11.95 11.95 0 0 1 5.814-5.519l2.74-1.22m0 0-5.94-2.28m5.94 2.28-2.28 5.941" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const MegaphoneIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 1 1 0-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 0 1-1.44-4.282m3.102.069a18.03 18.03 0 0 1-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 0 1 8.835 2.535M10.34 6.66a23.847 23.847 0 0 0 8.835-2.535m0 0A23.74 23.74 0 0 0 18.795 3m.38 1.125a23.91 23.91 0 0 1 1.014 5.795 23.91 23.91 0 0 1-1.014 5.795m0-11.589a23.917 23.917 0 0 1 6.391 5.34" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
+133
-85
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user