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
+45 -12
View File
@@ -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 = () => {
@@ -181,6 +200,11 @@ const App: React.FC = () => {
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(() => {
// Check if we are filtering by specific items (SKU, ASIN, Title)
@@ -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}
+271
View File
@@ -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;
+43 -13
View File
@@ -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,9 +23,15 @@ 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]);
}
};
@@ -79,27 +87,49 @@ 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">
{/* 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 Local CSV</h3>
<p className="text-xs text-slate-500 mt-1">Click to select file</p>
<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="file-upload"
id="sales-upload"
type="file"
accept=".csv"
onChange={handleChange}
accept=".csv,.xlsx"
onChange={handleSalesChange}
disabled={isLoading}
className="hidden"
/>
</label>
</div>
<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>
</div>
<div className="flex items-center gap-4">
<div className="h-px bg-slate-800 flex-1"></div>
<span className="text-slate-600 text-xs font-bold uppercase">OR</span>
+6
View File
@@ -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>
);
+131 -83
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) {
// 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();
// Check Spanish mapping first
if (SPANISH_MONTHS[alpha]) {
m = SPANISH_MONTHS[alpha];
} else {
// Default to first 3 chars capitalize (English)
let alpha = alphaMatch[1];
if (alpha.length > 3) alpha = alpha.substring(0, 3);
m = alpha.charAt(0).toUpperCase() + alpha.slice(1);
}
// Check map again with short version
if (MONTH_MAP[alpha]) return MONTH_MAP[alpha];
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;
// 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)
// Map by Column Index (A=0, B=1... L=11)
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;
// Updated Mapping for Excel Column Letters
// A: Country
// B: Month
// C: Year
// D: ASIN
// E: Cost
// F: Clicks
// G: Impressions
// ...
// L: Units
// M: Sales
// 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
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,