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:
Christian Vidal Wolf
2026-01-21 09:45:50 +01:00
parent 67951bcac8
commit 1c826c2487
6 changed files with 491 additions and 298 deletions
+176 -176
View File
@@ -2,21 +2,21 @@
import React, { useMemo } from 'react';
import { CombinedKPIs } from './types';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
LineChart, Line, Legend, ComposedChart, Area
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
LineChart, Line, Legend, ComposedChart, Area
} from 'recharts';
interface AdvertisingDashboardProps {
data: CombinedKPIs[];
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)) {
@@ -44,7 +44,7 @@ const aggregateByMonth = (data: CombinedKPIs[]) => {
});
const result = Array.from(map.values()).map(r => ({
..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,
@@ -55,10 +55,10 @@ const aggregateByMonth = (data: CombinedKPIs[]) => {
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);
@@ -66,9 +66,9 @@ const aggregateByMonth = (data: CombinedKPIs[]) => {
};
const KPICard = ({ title, value, subValue, type = 'currency' }: { title: string, value: number, subValue?: string, type?: 'currency' | 'percent' | 'number' }) => {
const formatted = type === 'currency'
const formatted = type === 'currency'
? `${value.toLocaleString('de-DE', { maximumFractionDigits: 0 })}`
: type === 'percent'
: type === 'percent'
? `${value.toFixed(2)}%`
: value.toLocaleString('de-DE');
@@ -82,187 +82,187 @@ const KPICard = ({ title, value, subValue, type = 'currency' }: { title: string,
};
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;
const aggregated = useMemo(() => aggregateByMonth(data), [data]);
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>
)
}
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]);
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>
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;
{/* 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('de-DE')}`}
/>
<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>
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('de-DE')}`}
/>
<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>
{/* 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">
{/* 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('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>
{/* 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('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>
{/* 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>
{/* 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>
{/* 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>
{/* 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>
);
);
};
// Fix for AreaChart reference error in some bundlers, just export
+57 -2
View File
@@ -1,6 +1,6 @@
import React, { useState, useMemo, useEffect } from 'react';
import { AggregatedData, GrowthMetric } from './types';
import { AggregatedData, GrowthMetric, AdsRecord } from '../types';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
LineChart, Line, Legend
@@ -9,6 +9,7 @@ import {
interface DashboardProps {
data: AggregatedData;
contextData?: AggregatedData | null;
adsData?: AdsRecord[];
}
const COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#0ea5e9'];
@@ -454,10 +455,31 @@ const GrowthTable: React.FC<{
);
}
const Dashboard: React.FC<DashboardProps> = ({ data, contextData }) => {
const Dashboard: React.FC<DashboardProps> = ({ data, contextData, adsData = [] }) => {
const [seasonalityMetric, setSeasonalityMetric] = useState<'sellOut' | 'units'>('sellOut');
const [top10Metric, setTop10Metric] = useState<'sellOut' | 'units'>('sellOut');
// Calculate Ads KPIs
const adsKPIs = useMemo(() => {
if (!adsData || adsData.length === 0) return null;
const totals = adsData.reduce((acc, ad) => ({
cost: acc.cost + ad.cost,
attributedSales: acc.attributedSales + ad.attributedSales30d,
clicks: acc.clicks + ad.clicks,
impressions: acc.impressions + ad.impressions,
}), { cost: 0, attributedSales: 0, clicks: 0, impressions: 0 });
return {
totalSpend: totals.cost,
attributedSales: totals.attributedSales,
acos: totals.attributedSales > 0 ? (totals.cost / totals.attributedSales) * 100 : 0,
roas: totals.cost > 0 ? totals.attributedSales / totals.cost : 0,
cpc: totals.clicks > 0 ? totals.cost / totals.clicks : 0,
ctr: totals.impressions > 0 ? (totals.clicks / totals.impressions) * 100 : 0,
};
}, [adsData]);
// Decide which data source to use for Product Line charts
// If contextData is provided (drill down), we use that to show the "Total Line" view.
// Otherwise we use the standard filtered data.
@@ -470,6 +492,39 @@ const Dashboard: React.FC<DashboardProps> = ({ data, contextData }) => {
return (
<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 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<MultiYearKPICard
+120 -17
View File
@@ -2,13 +2,14 @@ import React, { useState, useMemo, useEffect } from 'react';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
} from 'recharts';
import { SalesRecord, PivotRow } from '../types';
import { SalesRecord, PivotRow, AdsRecord } from '../types';
import { pivotSalesData, generateCSV, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor';
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon } from './Icons';
interface DataGridProps {
data: SalesRecord[];
hasCustomerFilter: boolean;
adsData?: AdsRecord[];
}
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 [sortConfig, setSortConfig] = useState<SortConfig>({ key: null, direction: 'desc' });
const [showChart, setShowChart] = useState(true);
const [visibleMetrics, setVisibleMetrics] = useState<('sellOut' | 'units')[]>(['sellOut', 'units']);
const [showAdsMetrics, setShowAdsMetrics] = useState(true);
// Calculate Ads Summary for the Grid
const adsSummary = useMemo(() => {
if (!adsData || adsData.length === 0) return null;
const totals = adsData.reduce((acc, ad) => ({
cost: acc.cost + ad.cost,
attributedSales: acc.attributedSales + ad.attributedSales30d,
clicks: acc.clicks + ad.clicks,
impressions: acc.impressions + ad.impressions,
}), { cost: 0, attributedSales: 0, clicks: 0, impressions: 0 });
return {
totalSpend: totals.cost,
attributedSales: totals.attributedSales,
acos: totals.attributedSales > 0 ? (totals.cost / totals.attributedSales) * 100 : 0,
roas: totals.cost > 0 ? totals.attributedSales / totals.cost : 0,
recordCount: adsData.length,
};
}, [adsData]);
// State for dynamic grouping
const [selectedDimensions, setSelectedDimensions] = useState<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 isMultiYear = yearsInView.length > 1;
if (isMultiYear) {
return {
chartData: aggregateForComparisonTimeSeries(data),
uniqueYears: yearsInView,
isComparisonView: true,
chartTitle: `Weekly Sales Comparison: ${yearsInView.join(' vs ')}`
};
} else {
return {
chartData: aggregateForTimeSeries(data),
uniqueYears: yearsInView,
isComparisonView: false,
chartTitle: `Weekly Sales Evolution ${yearsInView[0] || ''}`
};
// Get base sales data
let salesChartData = isMultiYear
? aggregateForComparisonTimeSeries(data)
: aggregateForTimeSeries(data);
// Aggregate ads data by week/year and merge into chartData
if (adsData && adsData.length > 0) {
const adsMap = new Map<number, { [key: string]: number }>();
adsData.forEach(ad => {
if (ad.week >= 1 && ad.week <= 53) {
if (!adsMap.has(ad.week)) {
adsMap.set(ad.week, {});
}
const weekData = adsMap.get(ad.week)!;
const adSpendKey = `${ad.year}_adSpend`;
weekData[adSpendKey] = (weekData[adSpendKey] || 0) + ad.cost;
}
});
// Merge ads data into sales chart data
salesChartData = salesChartData.map(point => {
const adsWeekData = adsMap.get(point.week) || {};
return { ...point, ...adsWeekData };
});
}
}, [data]);
return {
chartData: salesChartData,
uniqueYears: yearsInView,
isComparisonView: isMultiYear,
chartTitle: isMultiYear
? `Weekly Sales Comparison: ${yearsInView.join(' vs ')}`
: `Weekly Sales Evolution ${yearsInView[0] || ''}`
};
}, [data, adsData]);
// Filter Options based on available data
const metricOptions = useMemo(() => {
@@ -484,6 +526,19 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter }) => {
strokeDasharray="5 5"
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 />
<span className="hidden sm:inline">{showChart ? 'Hide Chart' : 'Show Chart'}</span>
</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 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>
{/* 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 */}
<div className="overflow-x-auto min-h-[400px]">
<table className="w-full text-left text-sm border-collapse">