mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 13:45:23 +02:00
feat: Add Amazon Ads Weekly integration with Dashboard KPIs, Grid summary, and chart lines
- Update AdsRecord interface to support weekly data (year, week, cpc, ctr, acos, conversions) - Rewrite processAdsExcel to parse multiple sheets (2025, 2026) with 12-column format - Update mergeSalesAndAdsData to match by ASIN+Country+Year+Week - Add Advertising Performance section to Dashboard with Ad Spend, Attributed Sales, ACOS, ROAS - Add Ads toggle button and summary bar to DataGrid - Add Ad Spend lines (fuchsia dashed) to weekly comparison chart - Fix import paths in Dashboard.tsx and AdvertisingDashboard.tsx
This commit is contained in:
@@ -9,7 +9,7 @@ import FilterBar from './components/FilterBar';
|
|||||||
import AIChat from './components/AIChat';
|
import AIChat from './components/AIChat';
|
||||||
import CrazeLogo from './components/CrazeLogo';
|
import CrazeLogo from './components/CrazeLogo';
|
||||||
import { SalesRecord, FilterState, AggregatedData, AdsRecord } from './types'; // Imported AdsRecord
|
import { SalesRecord, FilterState, AggregatedData, AdsRecord } from './types'; // Imported AdsRecord
|
||||||
import { processCSV, filterData, aggregateData, getUniqueValues, processAdsCSV, mergeSalesAndAdsData } from './services/dataProcessor'; // Imported new processors
|
import { processCSV, filterData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, mergeSalesAndAdsData } from './services/dataProcessor'; // Imported new processors
|
||||||
import { queryGemini } from './services/geminiService';
|
import { queryGemini } from './services/geminiService';
|
||||||
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon } from './components/Icons';
|
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon } from './components/Icons';
|
||||||
import { loadSalesData, saveSalesData, clearSalesData } from './services/storage';
|
import { loadSalesData, saveSalesData, clearSalesData } from './services/storage';
|
||||||
@@ -155,18 +155,18 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle uploaded Ads file (Manual)
|
// Handle uploaded Ads file (Manual) - Supports both CSV and Excel
|
||||||
const handleAdsUpload = async (file: File) => {
|
const handleAdsUpload = async (file: File) => {
|
||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
try {
|
try {
|
||||||
const data = await processAdsCSV(file);
|
const isExcel = file.name.endsWith('.xlsx') || file.name.endsWith('.xls');
|
||||||
|
const data = isExcel ? await processAdsExcel(file) : await processAdsCSV(file);
|
||||||
setAdsData(data);
|
setAdsData(data);
|
||||||
console.log("Ads loaded:", data.length);
|
console.log("Ads loaded:", data.length, "records from", file.name);
|
||||||
setIsDataModalOpen(false);
|
setIsDataModalOpen(false);
|
||||||
// setView('ads'); // Removed switching to ads view
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to parse Ads CSV", error);
|
console.error("Failed to parse Ads file", error);
|
||||||
alert("Error parsing Ads CSV. Please check the format.");
|
alert("Error parsing Ads file. Please check the format.");
|
||||||
} finally {
|
} finally {
|
||||||
setSyncing(false);
|
setSyncing(false);
|
||||||
}
|
}
|
||||||
@@ -361,9 +361,10 @@ const App: React.FC = () => {
|
|||||||
<Dashboard
|
<Dashboard
|
||||||
data={aggregatedData}
|
data={aggregatedData}
|
||||||
contextData={contextAggregatedData}
|
contextData={contextAggregatedData}
|
||||||
|
adsData={adsData}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{view === 'table' && <DataGrid data={filteredData} hasCustomerFilter={filters.customer.length > 0} />}
|
{view === 'table' && <DataGrid data={filteredData} hasCustomerFilter={filters.customer.length > 0} adsData={adsData} />}
|
||||||
{view === 'movers' && <TopMovers data={filteredData} />}
|
{view === 'movers' && <TopMovers data={filteredData} />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+165
-165
@@ -2,12 +2,12 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { CombinedKPIs } from './types';
|
import { CombinedKPIs } from './types';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
LineChart, Line, Legend, ComposedChart, Area
|
LineChart, Line, Legend, ComposedChart, Area
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
interface AdvertisingDashboardProps {
|
interface AdvertisingDashboardProps {
|
||||||
data: CombinedKPIs[];
|
data: CombinedKPIs[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to aggregate data for charts
|
// Helper to aggregate data for charts
|
||||||
@@ -44,7 +44,7 @@ const aggregateByMonth = (data: CombinedKPIs[]) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = Array.from(map.values()).map(r => ({
|
const result = Array.from(map.values()).map(r => ({
|
||||||
..r,
|
...r,
|
||||||
acos: r.salesAds > 0 ? (r.cost / r.salesAds) * 100 : 0,
|
acos: r.salesAds > 0 ? (r.cost / r.salesAds) * 100 : 0,
|
||||||
tacos: r.salesTotal > 0 ? (r.cost / r.salesTotal) * 100 : 0,
|
tacos: r.salesTotal > 0 ? (r.cost / r.salesTotal) * 100 : 0,
|
||||||
ctr: r.impressions > 0 ? (r.clicks / r.impressions) * 100 : 0,
|
ctr: r.impressions > 0 ? (r.clicks / r.impressions) * 100 : 0,
|
||||||
@@ -83,186 +83,186 @@ const KPICard = ({ title, value, subValue, type = 'currency' }: { title: string,
|
|||||||
|
|
||||||
const AdvertisingDashboard: React.FC<AdvertisingDashboardProps> = ({ data }) => {
|
const AdvertisingDashboard: React.FC<AdvertisingDashboardProps> = ({ data }) => {
|
||||||
|
|
||||||
const aggregated = useMemo(() => aggregateByMonth(data), [data]);
|
const aggregated = useMemo(() => aggregateByMonth(data), [data]);
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
return data.reduce((acc, curr) => ({
|
return data.reduce((acc, curr) => ({
|
||||||
salesTotal: acc.salesTotal + curr.salesTotal,
|
salesTotal: acc.salesTotal + curr.salesTotal,
|
||||||
salesAds: acc.salesAds + curr.salesAds,
|
salesAds: acc.salesAds + curr.salesAds,
|
||||||
cost: acc.cost + curr.cost,
|
cost: acc.cost + curr.cost,
|
||||||
clicks: acc.clicks + curr.clicks,
|
clicks: acc.clicks + curr.clicks,
|
||||||
impressions: acc.impressions + curr.impressions,
|
impressions: acc.impressions + curr.impressions,
|
||||||
salesOrganic: acc.salesOrganic + curr.salesOrganic
|
salesOrganic: acc.salesOrganic + curr.salesOrganic
|
||||||
}), { salesTotal: 0, salesAds: 0, cost: 0, clicks: 0, impressions: 0, salesOrganic: 0 });
|
}), { salesTotal: 0, salesAds: 0, cost: 0, clicks: 0, impressions: 0, salesOrganic: 0 });
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const kpiAcos = totals.salesAds > 0 ? (totals.cost / totals.salesAds) * 100 : 0;
|
const kpiAcos = totals.salesAds > 0 ? (totals.cost / totals.salesAds) * 100 : 0;
|
||||||
const kpiTacos = totals.salesTotal > 0 ? (totals.cost / totals.salesTotal) * 100 : 0;
|
const kpiTacos = totals.salesTotal > 0 ? (totals.cost / totals.salesTotal) * 100 : 0;
|
||||||
const kpiRoas = totals.cost > 0 ? totals.salesAds / totals.cost : 0;
|
const kpiRoas = totals.cost > 0 ? totals.salesAds / totals.cost : 0;
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-96 text-slate-500">
|
<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-lg font-medium">No Advertising Data Available</p>
|
||||||
<p className="text-sm">Please upload an Ads CSV file via "Connect Data".</p>
|
<p className="text-sm">Please upload an Ads CSV file via "Connect Data".</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto pb-24 animate-fade-in">
|
<div className="space-y-6 max-w-7xl mx-auto pb-24 animate-fade-in">
|
||||||
|
|
||||||
{/* KPI Grid */}
|
{/* KPI Grid */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
<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 Spend" value={totals.cost} />
|
||||||
<KPICard title="Ad Sales" value={totals.salesAds} subValue={`${((totals.salesAds / totals.salesTotal) * 100).toFixed(1)}% of Total`} />
|
<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="Total Sales" value={totals.salesTotal} />
|
||||||
<KPICard title="ACOS" value={kpiAcos} type="percent" />
|
<KPICard title="ACOS" value={kpiAcos} type="percent" />
|
||||||
<KPICard title="TACOS" value={kpiTacos} type="percent" />
|
<KPICard title="TACOS" value={kpiTacos} type="percent" />
|
||||||
<KPICard title="ROAS" value={kpiRoas} type="number" subValue="Return on Ad Spend" />
|
<KPICard title="ROAS" value={kpiRoas} type="number" subValue="Return on Ad Spend" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts Row 1 */}
|
{/* Charts Row 1 */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
|
||||||
{/* Spend vs Sales Trend */}
|
{/* Spend vs Sales Trend */}
|
||||||
<div className="bg-surface border border-border rounded-xl p-6 h-96 flex flex-col">
|
<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>
|
<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">
|
<div className="flex-1 min-h-0">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ComposedChart data={aggregated}>
|
<ComposedChart data={aggregated}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||||
<XAxis dataKey="name" stroke="#64748b" />
|
<XAxis dataKey="name" stroke="#64748b" />
|
||||||
<YAxis yAxisId="left" stroke="#64748b" tickFormatter={(v) => `€${v/1000}k`} />
|
<YAxis yAxisId="left" stroke="#64748b" tickFormatter={(v) => `€${v / 1000}k`} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#1e293b', color: '#e2e8f0' }}
|
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#1e293b', color: '#e2e8f0' }}
|
||||||
formatter={(val: number) => `€${val.toLocaleString('de-DE')}`}
|
formatter={(val: number) => `€${val.toLocaleString('de-DE')}`}
|
||||||
/>
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
<Bar yAxisId="left" dataKey="cost" name="Ad Spend" fill="#f43f5e" radius={[4, 4, 0, 0]} />
|
<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} />
|
<Line yAxisId="left" type="monotone" dataKey="salesAds" name="Ad Sales" stroke="#10b981" strokeWidth={3} />
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ACOS vs TACOS Trend */}
|
{/* Charts Row 2 */}
|
||||||
<div className="bg-surface border border-border rounded-xl p-6 h-96 flex flex-col">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<h3 className="text-sm font-bold text-slate-300 uppercase mb-4">Efficiency: ACOS vs TACOS</h3>
|
|
||||||
<div className="flex-1 min-h-0">
|
{/* Organic vs Paid Sales */}
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<div className="bg-surface border border-border rounded-xl p-6 h-96 flex flex-col">
|
||||||
<LineChart data={aggregated}>
|
<h3 className="text-sm font-bold text-slate-300 uppercase mb-4">Total Sales Composition (Organic vs Paid)</h3>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
<div className="flex-1 min-h-0">
|
||||||
<XAxis dataKey="name" stroke="#64748b" />
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<YAxis stroke="#64748b" unit="%" />
|
<AreaChart data={aggregated}>
|
||||||
<Tooltip
|
<defs>
|
||||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#1e293b', color: '#e2e8f0' }}
|
<linearGradient id="colorOrganic" x1="0" y1="0" x2="0" y2="1">
|
||||||
formatter={(val: number) => `${val.toFixed(2)}%`}
|
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
|
||||||
/>
|
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
|
||||||
<Legend />
|
</linearGradient>
|
||||||
<Line type="monotone" dataKey="acos" name="ACOS" stroke="#f59e0b" strokeWidth={2} dot={{r:4}} />
|
<linearGradient id="colorPaid" x1="0" y1="0" x2="0" y2="1">
|
||||||
<Line type="monotone" dataKey="tacos" name="TACOS" stroke="#3b82f6" strokeWidth={2} dot={{r:4}} />
|
<stop offset="5%" stopColor="#ec4899" stopOpacity={0.3} />
|
||||||
</LineChart>
|
<stop offset="95%" stopColor="#ec4899" stopOpacity={0} />
|
||||||
</ResponsiveContainer>
|
</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('de-DE')}`}
|
||||||
|
/>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Charts Row 2 */}
|
{/* Funnel: Impressions -> Clicks */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<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>
|
||||||
{/* Organic vs Paid Sales */}
|
<div className="flex-1 min-h-0">
|
||||||
<div className="bg-surface border border-border rounded-xl p-6 h-96 flex flex-col">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<h3 className="text-sm font-bold text-slate-300 uppercase mb-4">Total Sales Composition (Organic vs Paid)</h3>
|
<ComposedChart data={aggregated}>
|
||||||
<div className="flex-1 min-h-0">
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<XAxis dataKey="name" stroke="#64748b" />
|
||||||
<AreaChart data={aggregated}>
|
<YAxis yAxisId="left" stroke="#8b5cf6" tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`} label={{ value: 'Impressions', angle: -90, position: 'insideLeft', fill: '#8b5cf6' }} />
|
||||||
<defs>
|
<YAxis yAxisId="right" orientation="right" stroke="#0ea5e9" label={{ value: 'Clicks', angle: 90, position: 'insideRight', fill: '#0ea5e9' }} />
|
||||||
<linearGradient id="colorOrganic" x1="0" y1="0" x2="0" y2="1">
|
<Tooltip
|
||||||
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3}/>
|
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#1e293b', color: '#e2e8f0' }}
|
||||||
<stop offset="95%" stopColor="#6366f1" stopOpacity={0}/>
|
/>
|
||||||
</linearGradient>
|
<Legend />
|
||||||
<linearGradient id="colorPaid" x1="0" y1="0" x2="0" y2="1">
|
<Bar yAxisId="left" dataKey="impressions" name="Impressions" fill="#8b5cf6" radius={[4, 4, 0, 0]} />
|
||||||
<stop offset="5%" stopColor="#ec4899" stopOpacity={0.3}/>
|
<Line yAxisId="right" type="monotone" dataKey="clicks" name="Clicks" stroke="#0ea5e9" strokeWidth={2} />
|
||||||
<stop offset="95%" stopColor="#ec4899" stopOpacity={0}/>
|
</ComposedChart>
|
||||||
</linearGradient>
|
</ResponsiveContainer>
|
||||||
</defs>
|
</div>
|
||||||
<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('de-DE')}`}
|
|
||||||
/>
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Funnel: Impressions -> Clicks */}
|
{/* Detailed Table */}
|
||||||
<div className="bg-surface border border-border rounded-xl p-6 h-96 flex flex-col">
|
<div className="bg-surface border border-border rounded-xl shadow-lg overflow-hidden flex flex-col">
|
||||||
<h3 className="text-sm font-bold text-slate-300 uppercase mb-4">Marketing Funnel (Impressions & Clicks)</h3>
|
<div className="p-4 border-b border-border bg-slate-900/50">
|
||||||
<div className="flex-1 min-h-0">
|
<h3 className="font-bold text-slate-200">Monthly Advertising Breakdown</h3>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
</div>
|
||||||
<ComposedChart data={aggregated}>
|
<div className="overflow-x-auto">
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
<table className="w-full text-left text-sm text-slate-300">
|
||||||
<XAxis dataKey="name" stroke="#64748b" />
|
<thead className="bg-slate-950 text-xs uppercase font-semibold text-slate-500">
|
||||||
<YAxis yAxisId="left" stroke="#8b5cf6" tickFormatter={(v) => `${(v/1000).toFixed(0)}k`} label={{ value: 'Impressions', angle: -90, position: 'insideLeft', fill: '#8b5cf6' }} />
|
<tr>
|
||||||
<YAxis yAxisId="right" orientation="right" stroke="#0ea5e9" label={{ value: 'Clicks', angle: 90, position: 'insideRight', fill: '#0ea5e9' }} />
|
<th className="px-4 py-3">Period</th>
|
||||||
<Tooltip
|
<th className="px-4 py-3 text-right">Spend</th>
|
||||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#1e293b', color: '#e2e8f0' }}
|
<th className="px-4 py-3 text-right">Ad Sales</th>
|
||||||
/>
|
<th className="px-4 py-3 text-right">Total Sales</th>
|
||||||
<Legend />
|
<th className="px-4 py-3 text-right">ACOS</th>
|
||||||
<Bar yAxisId="left" dataKey="impressions" name="Impressions" fill="#8b5cf6" radius={[4, 4, 0, 0]} />
|
<th className="px-4 py-3 text-right">TACOS</th>
|
||||||
<Line yAxisId="right" type="monotone" dataKey="clicks" name="Clicks" stroke="#0ea5e9" strokeWidth={2} />
|
<th className="px-4 py-3 text-right">Clicks</th>
|
||||||
</ComposedChart>
|
<th className="px-4 py-3 text-right">CPC</th>
|
||||||
</ResponsiveContainer>
|
</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('de-DE', { maximumFractionDigits: 0 })}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-emerald-400">€{row.salesAds.toLocaleString('de-DE', { maximumFractionDigits: 0 })}</td>
|
||||||
|
<td className="px-4 py-3 text-right">€{row.salesTotal.toLocaleString('de-DE', { 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('de-DE')}</td>
|
||||||
|
<td className="px-4 py-3 text-right">€{row.cpc.toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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('de-DE', {maximumFractionDigits:0})}</td>
|
|
||||||
<td className="px-4 py-3 text-right text-emerald-400">€{row.salesAds.toLocaleString('de-DE', {maximumFractionDigits:0})}</td>
|
|
||||||
<td className="px-4 py-3 text-right">€{row.salesTotal.toLocaleString('de-DE', {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('de-DE')}</td>
|
|
||||||
<td className="px-4 py-3 text-right">€{row.cpc.toFixed(2)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fix for AreaChart reference error in some bundlers, just export
|
// Fix for AreaChart reference error in some bundlers, just export
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { AggregatedData, GrowthMetric } from './types';
|
import { AggregatedData, GrowthMetric, AdsRecord } from '../types';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
LineChart, Line, Legend
|
LineChart, Line, Legend
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
data: AggregatedData;
|
data: AggregatedData;
|
||||||
contextData?: AggregatedData | null;
|
contextData?: AggregatedData | null;
|
||||||
|
adsData?: AdsRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#0ea5e9'];
|
const COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#0ea5e9'];
|
||||||
@@ -454,10 +455,31 @@ const GrowthTable: React.FC<{
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Dashboard: React.FC<DashboardProps> = ({ data, contextData }) => {
|
const Dashboard: React.FC<DashboardProps> = ({ data, contextData, adsData = [] }) => {
|
||||||
const [seasonalityMetric, setSeasonalityMetric] = useState<'sellOut' | 'units'>('sellOut');
|
const [seasonalityMetric, setSeasonalityMetric] = useState<'sellOut' | 'units'>('sellOut');
|
||||||
const [top10Metric, setTop10Metric] = 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
|
// 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.
|
// If contextData is provided (drill down), we use that to show the "Total Line" view.
|
||||||
// Otherwise we use the standard filtered data.
|
// Otherwise we use the standard filtered data.
|
||||||
@@ -470,6 +492,39 @@ const Dashboard: React.FC<DashboardProps> = ({ data, contextData }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6 max-w-7xl mx-auto animate-fade-in pb-24">
|
<div className="p-6 space-y-6 max-w-7xl mx-auto animate-fade-in pb-24">
|
||||||
|
|
||||||
|
{/* Ads Performance Section - Only shown when ads data is loaded */}
|
||||||
|
{adsKPIs && (
|
||||||
|
<div className="bg-gradient-to-r from-fuchsia-900/20 to-indigo-900/20 border border-fuchsia-500/30 rounded-xl p-6 animate-fade-in">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-fuchsia-400 animate-pulse"></span>
|
||||||
|
<h3 className="text-sm font-bold text-fuchsia-400 uppercase tracking-wider">Advertising Performance</h3>
|
||||||
|
<span className="text-xs text-slate-500 ml-auto">{adsData.length.toLocaleString('de-DE')} ad records loaded</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-slate-900/50 rounded-lg p-4">
|
||||||
|
<span className="text-xs text-slate-400 block mb-1">Total Ad Spend</span>
|
||||||
|
<span className="text-2xl font-bold text-fuchsia-400">€{adsKPIs.totalSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900/50 rounded-lg p-4">
|
||||||
|
<span className="text-xs text-slate-400 block mb-1">Attributed Sales</span>
|
||||||
|
<span className="text-2xl font-bold text-emerald-400">€{adsKPIs.attributedSales.toLocaleString('de-DE', { maximumFractionDigits: 0 })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900/50 rounded-lg p-4">
|
||||||
|
<span className="text-xs text-slate-400 block mb-1">ACOS</span>
|
||||||
|
<span className={`text-2xl font-bold ${adsKPIs.acos <= 30 ? 'text-emerald-400' : adsKPIs.acos <= 50 ? 'text-amber-400' : 'text-red-400'}`}>
|
||||||
|
{adsKPIs.acos.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900/50 rounded-lg p-4">
|
||||||
|
<span className="text-xs text-slate-400 block mb-1">ROAS</span>
|
||||||
|
<span className={`text-2xl font-bold ${adsKPIs.roas >= 3 ? 'text-emerald-400' : adsKPIs.roas >= 2 ? 'text-amber-400' : 'text-red-400'}`}>
|
||||||
|
{adsKPIs.roas.toFixed(2)}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* KPI Section - Pass both specific data and context data */}
|
{/* KPI Section - Pass both specific data and context data */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<MultiYearKPICard
|
<MultiYearKPICard
|
||||||
|
|||||||
+120
-17
@@ -2,13 +2,14 @@ import React, { useState, useMemo, useEffect } from 'react';
|
|||||||
import {
|
import {
|
||||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
|
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { SalesRecord, PivotRow } from '../types';
|
import { SalesRecord, PivotRow, AdsRecord } from '../types';
|
||||||
import { pivotSalesData, generateCSV, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor';
|
import { pivotSalesData, generateCSV, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor';
|
||||||
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon } from './Icons';
|
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon } from './Icons';
|
||||||
|
|
||||||
interface DataGridProps {
|
interface DataGridProps {
|
||||||
data: SalesRecord[];
|
data: SalesRecord[];
|
||||||
hasCustomerFilter: boolean;
|
hasCustomerFilter: boolean;
|
||||||
|
adsData?: AdsRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortConfig = {
|
type SortConfig = {
|
||||||
@@ -225,11 +226,32 @@ const ExpandableChartCard: React.FC<{ title: string; children: React.ReactNode;
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter }) => {
|
const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData = [] }) => {
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: null, direction: 'desc' });
|
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: null, direction: 'desc' });
|
||||||
const [showChart, setShowChart] = useState(true);
|
const [showChart, setShowChart] = useState(true);
|
||||||
const [visibleMetrics, setVisibleMetrics] = useState<('sellOut' | 'units')[]>(['sellOut', 'units']);
|
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
|
// State for dynamic grouping
|
||||||
const [selectedDimensions, setSelectedDimensions] = useState<string[]>(['line', 'customer', 'sku', 'title']);
|
const [selectedDimensions, setSelectedDimensions] = useState<string[]>(['line', 'customer', 'sku', 'title']);
|
||||||
@@ -260,22 +282,42 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter }) => {
|
|||||||
const yearsInView = Array.from(new Set(data.map(d => d.year.toString()))).sort((a: string, b: string) => parseInt(b) - parseInt(a));
|
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;
|
const isMultiYear = yearsInView.length > 1;
|
||||||
|
|
||||||
if (isMultiYear) {
|
// Get base sales data
|
||||||
return {
|
let salesChartData = isMultiYear
|
||||||
chartData: aggregateForComparisonTimeSeries(data),
|
? aggregateForComparisonTimeSeries(data)
|
||||||
uniqueYears: yearsInView,
|
: aggregateForTimeSeries(data);
|
||||||
isComparisonView: true,
|
|
||||||
chartTitle: `Weekly Sales Comparison: ${yearsInView.join(' vs ')}`
|
// Aggregate ads data by week/year and merge into chartData
|
||||||
};
|
if (adsData && adsData.length > 0) {
|
||||||
} else {
|
const adsMap = new Map<number, { [key: string]: number }>();
|
||||||
return {
|
|
||||||
chartData: aggregateForTimeSeries(data),
|
adsData.forEach(ad => {
|
||||||
uniqueYears: yearsInView,
|
if (ad.week >= 1 && ad.week <= 53) {
|
||||||
isComparisonView: false,
|
if (!adsMap.has(ad.week)) {
|
||||||
chartTitle: `Weekly Sales Evolution ${yearsInView[0] || ''}`
|
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
|
// Filter Options based on available data
|
||||||
const metricOptions = useMemo(() => {
|
const metricOptions = useMemo(() => {
|
||||||
@@ -484,6 +526,19 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter }) => {
|
|||||||
strokeDasharray="5 5"
|
strokeDasharray="5 5"
|
||||||
dot={false}
|
dot={false}
|
||||||
/>
|
/>
|
||||||
|
),
|
||||||
|
// Ad Spend line (only when ads data exists and showAdsMetrics is on)
|
||||||
|
showAdsMetrics && adsSummary && (
|
||||||
|
<Line
|
||||||
|
key={`${year}_adSpend`}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={`${year}_adSpend`}
|
||||||
|
name={`Ad Spend ${year}`}
|
||||||
|
stroke="#d946ef"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
) : (
|
) : (
|
||||||
@@ -571,6 +626,19 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter }) => {
|
|||||||
<ChartIcon />
|
<ChartIcon />
|
||||||
<span className="hidden sm:inline">{showChart ? 'Hide Chart' : 'Show Chart'}</span>
|
<span className="hidden sm:inline">{showChart ? 'Hide Chart' : 'Show Chart'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Ads Toggle - Only show when ads data is loaded */}
|
||||||
|
{adsSummary && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAdsMetrics(!showAdsMetrics)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${showAdsMetrics ? 'bg-fuchsia-600/20 text-fuchsia-400 border-fuchsia-500/50' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'}`}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 0h.008v.008H18V10.5Zm-12 0h.008v.008H6V10.5Z" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">{showAdsMetrics ? 'Hide Ads' : 'Show Ads'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 w-full lg:w-auto justify-between lg:justify-end">
|
<div className="flex items-center gap-3 w-full lg:w-auto justify-between lg:justify-end">
|
||||||
@@ -738,6 +806,41 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Ads Performance Summary - Shows when ads data is loaded */}
|
||||||
|
{adsSummary && showAdsMetrics && (
|
||||||
|
<div className="bg-gradient-to-r from-fuchsia-900/20 to-indigo-900/20 border-y border-fuchsia-500/30 px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-fuchsia-400 animate-pulse"></span>
|
||||||
|
<span className="text-xs font-bold text-fuchsia-300 uppercase tracking-wide">Advertising Data:</span>
|
||||||
|
<span className="text-xs text-slate-400">{adsSummary.recordCount.toLocaleString('de-DE')} records</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
|
||||||
|
<span className="text-xs text-slate-400 mr-2">Ad Spend:</span>
|
||||||
|
<span className="text-sm font-bold text-fuchsia-400">€{adsSummary.totalSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
|
||||||
|
<span className="text-xs text-slate-400 mr-2">Attr. Sales:</span>
|
||||||
|
<span className="text-sm font-bold text-emerald-400">€{adsSummary.attributedSales.toLocaleString('de-DE', { maximumFractionDigits: 0 })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
|
||||||
|
<span className="text-xs text-slate-400 mr-2">ACOS:</span>
|
||||||
|
<span className={`text-sm font-bold ${adsSummary.acos <= 30 ? 'text-emerald-400' : adsSummary.acos <= 50 ? 'text-amber-400' : 'text-red-400'}`}>
|
||||||
|
{adsSummary.acos.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
|
||||||
|
<span className="text-xs text-slate-400 mr-2">ROAS:</span>
|
||||||
|
<span className={`text-sm font-bold ${adsSummary.roas >= 3 ? 'text-emerald-400' : adsSummary.roas >= 2 ? 'text-amber-400' : 'text-red-400'}`}>
|
||||||
|
{adsSummary.roas.toFixed(2)}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Data Table */}
|
{/* Data Table */}
|
||||||
<div className="overflow-x-auto min-h-[400px]">
|
<div className="overflow-x-auto min-h-[400px]">
|
||||||
<table className="w-full text-left text-sm border-collapse">
|
<table className="w-full text-left text-sm border-collapse">
|
||||||
|
|||||||
+120
-91
@@ -285,58 +285,61 @@ export const processAdsCSV = (file: File): Promise<AdsRecord[]> => {
|
|||||||
|
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
const row = rows[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)
|
// Check header row (Column A: Customer or Country)
|
||||||
const c0 = String(row[0]).trim().toLowerCase();
|
const c0 = String(row[0]).trim().toLowerCase();
|
||||||
if (c0.includes('customer') || c0.includes('country') || c0.includes('marketplace')) continue;
|
if (c0.includes('customer') || c0.includes('country') || c0.includes('marketplace')) continue;
|
||||||
|
|
||||||
// A (0): Customer/Marketplace
|
// Column mapping for CSV (same as Excel):
|
||||||
// B (1): Month (Can be "may", "01", or Excel serial "45544")
|
// A (0): Country
|
||||||
// C (2): Year
|
// B (1): Week
|
||||||
// D (3): ASIN
|
// C (2): ASIN
|
||||||
// E (4): Ad Spend
|
// D (3): Cost
|
||||||
// F (5): Clicks
|
// E (4): Clicks
|
||||||
// G (6): Impressions
|
// F (5): Impressions
|
||||||
// ...
|
// G (6): CPC
|
||||||
// L (11): Units (Attributed)
|
// H (7): CTR %
|
||||||
// M (12): Sales (Sell Out)
|
// I (8): ACOS %
|
||||||
|
// J (9): Conversions (30d)
|
||||||
|
// K (10): Units (30d)
|
||||||
|
// L (11): Sales (30d)
|
||||||
|
|
||||||
const countryRaw = row[0];
|
const countryRaw = row[0];
|
||||||
const monthRaw = row[1];
|
const weekRaw = row[1];
|
||||||
const yearRaw = row[2];
|
const asin = row[2];
|
||||||
const asin = row[3];
|
const costRaw = row[3];
|
||||||
const costRaw = row[4];
|
const clicksRaw = row[4];
|
||||||
const clicksRaw = row[5];
|
const impressionsRaw = row[5];
|
||||||
const impressionsRaw = row[6];
|
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
|
if (!asin || !countryRaw || weekRaw === undefined) continue;
|
||||||
const salesRaw = row[12]; // M
|
|
||||||
|
|
||||||
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")
|
// For CSV without sheet names, assume current year
|
||||||
const pureMonth = normalizeMonth(String(monthRaw)); // Returns "May"
|
const currentYear = new Date().getFullYear();
|
||||||
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({
|
data.push({
|
||||||
country: mapCountryToMarketplace(String(countryRaw)),
|
country: mapCountryToMarketplace(String(countryRaw)),
|
||||||
month: finalMonthStr,
|
year: currentYear,
|
||||||
|
week: weekNum,
|
||||||
asin: String(asin).trim(),
|
asin: String(asin).trim(),
|
||||||
cost: parseCurrency(String(costRaw)),
|
cost: parseCurrency(String(costRaw)),
|
||||||
clicks: parseUnits(String(clicksRaw)),
|
clicks: parseUnits(String(clicksRaw)),
|
||||||
impressions: parseUnits(String(impressionsRaw)),
|
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)),
|
attributedUnits30d: parseUnits(String(unitsRaw)),
|
||||||
|
attributedSales30d: parseCurrency(String(salesRaw)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
resolve(data);
|
resolve(data);
|
||||||
@@ -353,61 +356,80 @@ export const processAdsExcel = async (file: File): Promise<AdsRecord[]> => {
|
|||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const workbook = XLSX.read(arrayBuffer);
|
const workbook = XLSX.read(arrayBuffer);
|
||||||
const firstSheetName = workbook.SheetNames[0];
|
const allData: AdsRecord[] = [];
|
||||||
const worksheet = workbook.Sheets[firstSheetName];
|
|
||||||
|
|
||||||
// Use header: 'A' to strictly map columns by index letter
|
// Process ALL sheets (e.g., "2025", "2026")
|
||||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: "A", defval: "" });
|
for (const sheetName of workbook.SheetNames) {
|
||||||
|
const year = parseInt(sheetName);
|
||||||
const data: AdsRecord[] = jsonData.map((row: any) => {
|
if (isNaN(year) || year < 2020 || year > 2100) {
|
||||||
// Check if it's a header row
|
console.warn(`Skipping sheet "${sheetName}" - not a valid year`);
|
||||||
if (row['A'] === 'Customer' || row['A'] === 'Country') return null;
|
continue;
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
const finalMonthStr = yearShort ? `${pureMonth}-${yearShort}` : pureMonth;
|
|
||||||
|
|
||||||
return {
|
const worksheet = workbook.Sheets[sheetName];
|
||||||
country: mapCountryToMarketplace(String(countryRaw)),
|
// Use header: 1 to get array of arrays (row-based)
|
||||||
month: finalMonthStr,
|
const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: "" });
|
||||||
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);
|
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error("Error processing Ads Excel:", error);
|
console.error("Error processing Ads Excel:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -417,13 +439,12 @@ export const processAdsExcel = async (file: File): Promise<AdsRecord[]> => {
|
|||||||
// --- DATA MERGING ---
|
// --- DATA MERGING ---
|
||||||
|
|
||||||
export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecord[]): CombinedKPIs[] => {
|
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<string, AdsRecord>();
|
const adsMap = new Map<string, AdsRecord>();
|
||||||
|
|
||||||
adsData.forEach(ad => {
|
adsData.forEach(ad => {
|
||||||
// Ensure month format matches sales data (e.g. "May-24" vs "May-24")
|
// Case-insensitive key using ASIN + Country + Year + Week
|
||||||
// Case-insensitive key
|
const key = `${ad.asin.trim().toUpperCase()}|${ad.country.trim().toUpperCase()}|${ad.year}|${ad.week}`;
|
||||||
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 duplicates exist (e.g. multiple campaigns for same ASIN), sum them up
|
||||||
if (adsMap.has(key)) {
|
if (adsMap.has(key)) {
|
||||||
@@ -433,6 +454,7 @@ export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecor
|
|||||||
existing.impressions += ad.impressions;
|
existing.impressions += ad.impressions;
|
||||||
existing.attributedSales30d += ad.attributedSales30d;
|
existing.attributedSales30d += ad.attributedSales30d;
|
||||||
existing.attributedUnits30d += ad.attributedUnits30d;
|
existing.attributedUnits30d += ad.attributedUnits30d;
|
||||||
|
existing.conversions += ad.conversions;
|
||||||
} else {
|
} else {
|
||||||
adsMap.set(key, { ...ad });
|
adsMap.set(key, { ...ad });
|
||||||
}
|
}
|
||||||
@@ -440,14 +462,21 @@ export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecor
|
|||||||
|
|
||||||
// 2. Iterate Sales Data and merge
|
// 2. Iterate Sales Data and merge
|
||||||
const mergedData: CombinedKPIs[] = salesData.map(sale => {
|
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) || {
|
const adData = adsMap.get(key) || {
|
||||||
country: sale.customer,
|
country: sale.customer,
|
||||||
month: sale.month,
|
year: sale.year,
|
||||||
|
week: weekNum,
|
||||||
asin: sale.asin,
|
asin: sale.asin,
|
||||||
cost: 0,
|
cost: 0,
|
||||||
clicks: 0,
|
clicks: 0,
|
||||||
impressions: 0,
|
impressions: 0,
|
||||||
|
cpc: 0,
|
||||||
|
ctr: 0,
|
||||||
|
acos: 0,
|
||||||
|
conversions: 0,
|
||||||
attributedSales30d: 0,
|
attributedSales30d: 0,
|
||||||
attributedUnits30d: 0
|
attributedUnits30d: 0
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -127,14 +127,19 @@ export interface ComparisonTimeSeriesPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AdsRecord {
|
export interface AdsRecord {
|
||||||
country: string;
|
country: string; // Marketplace (Amazon DE, IT, ES, FR, UK)
|
||||||
month: string;
|
year: number; // Year extracted from sheet name
|
||||||
|
week: number; // Week number (1-52)
|
||||||
asin: string;
|
asin: string;
|
||||||
cost: number;
|
cost: number; // Ad spend €
|
||||||
clicks: number;
|
clicks: number;
|
||||||
impressions: 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;
|
attributedUnits30d: number;
|
||||||
|
attributedSales30d: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CombinedKPIs {
|
export interface CombinedKPIs {
|
||||||
|
|||||||
Reference in New Issue
Block a user