feat: multi-year ads performance and YoY growth in Dashboard

This commit is contained in:
Christian Vidal Wolf
2026-01-21 12:49:21 +01:00
parent c7cef7954b
commit 0138b787b2
+111 -38
View File
@@ -459,27 +459,43 @@ 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 // Calculate Ads KPIs by Year
const adsKPIs = useMemo(() => { const adsKPIsByYear = useMemo(() => {
if (!adsData || adsData.length === 0) return null; if (!adsData || adsData.length === 0) return null;
const totals = adsData.reduce((acc, ad) => ({ const yearMap = new Map<string, { cost: number; attributedSales: number; clicks: number; impressions: number }>();
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 { adsData.forEach(ad => {
totalSpend: totals.cost, const y = ad.year.toString();
attributedSales: totals.attributedSales, if (!yearMap.has(y)) {
acos: totals.attributedSales > 0 ? (totals.cost / totals.attributedSales) * 100 : 0, yearMap.set(y, { cost: 0, attributedSales: 0, clicks: 0, impressions: 0 });
roas: totals.cost > 0 ? totals.attributedSales / totals.cost : 0, }
cpc: totals.clicks > 0 ? totals.cost / totals.clicks : 0, const t = yearMap.get(y)!;
ctr: totals.impressions > 0 ? (totals.clicks / totals.impressions) * 100 : 0, t.cost += ad.cost;
t.attributedSales += ad.attributedSales30d;
t.clicks += ad.clicks;
t.impressions += ad.impressions;
});
const result: Record<string, { totalSpend: number; attributedSales: number; acos: number; roas: number; cpc: number; ctr: number }> = {};
yearMap.forEach((t, year) => {
result[year] = {
totalSpend: t.cost,
attributedSales: t.attributedSales,
acos: t.attributedSales > 0 ? (t.cost / t.attributedSales) * 100 : 0,
roas: t.cost > 0 ? t.attributedSales / t.cost : 0,
cpc: t.clicks > 0 ? t.cost / t.clicks : 0,
ctr: t.impressions > 0 ? (t.clicks / t.impressions) * 100 : 0,
}; };
});
return result;
}, [adsData]); }, [adsData]);
const availableAdsYears = useMemo(() => {
if (!adsKPIsByYear) return [];
return Object.keys(adsKPIsByYear).sort((a, b) => parseInt(b) - parseInt(a));
}, [adsKPIsByYear]);
// 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.
@@ -492,36 +508,93 @@ const Dashboard: React.FC<DashboardProps> = ({ data, contextData, adsData = [] }
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 */} {/* Ads Performance Section - Supports Multiple Years and Growth */}
{adsKPIs && ( {adsKPIsByYear && availableAdsYears.length > 0 && (
<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="bg-gradient-to-br from-fuchsia-950/20 to-indigo-950/30 border border-fuchsia-500/30 rounded-2xl p-6 animate-fade-in shadow-xl">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-3 mb-6">
<span className="w-2 h-2 rounded-full bg-fuchsia-400 animate-pulse"></span> <div className="relative">
<h3 className="text-sm font-bold text-fuchsia-400 uppercase tracking-wider">Advertising Performance</h3> <span className="absolute inset-0 bg-fuchsia-400 rounded-full animate-ping opacity-20"></span>
<span className="text-xs text-slate-500 ml-auto">{adsData.length.toLocaleString('de-DE')} ad records loaded</span> <span className="relative block w-2.5 h-2.5 rounded-full bg-fuchsia-400 shadow-[0_0_8px_rgba(232,121,249,0.8)]"></span>
</div>
<h3 className="text-sm font-black text-fuchsia-400 uppercase tracking-[0.2em]">Advertising Performance Breakdown</h3>
<div className="ml-auto px-3 py-1 bg-slate-900/50 rounded-full border border-fuchsia-500/20 text-[10px] text-fuchsia-300 font-bold">
{adsData.length.toLocaleString('de-DE')} AD RECORDS LOADED
</div>
</div>
<div className="space-y-6">
{availableAdsYears.map((year, idx) => {
const kpi = adsKPIsByYear[year];
const prevYear = availableAdsYears[idx + 1];
const prevKpi = prevYear ? adsKPIsByYear[prevYear] : null;
const renderGrowth = (current: number, previous: number | undefined, inverse: boolean = false) => {
if (!previous || previous === 0) return null;
const pct = ((current - previous) / previous) * 100;
const isPositive = pct >= 0;
const colorClass = inverse
? (isPositive ? 'text-red-400' : 'text-emerald-400')
: (isPositive ? 'text-emerald-400' : 'text-red-400');
return (
<span className={`text-[10px] font-black ml-1.5 ${colorClass}`}>
{isPositive ? '▲' : '▼'}{Math.abs(pct).toFixed(1)}%
</span>
);
};
return (
<div key={year} className="relative">
<div className="flex items-center gap-3 mb-3">
<span className="text-xs font-black text-slate-500 font-mono tracking-widest">{year}</span>
<div className="h-[1px] flex-1 bg-gradient-to-r from-slate-800 to-transparent"></div>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-900/50 rounded-lg p-4"> {/* Ad Spend */}
<span className="text-xs text-slate-400 block mb-1">Total Ad Spend</span> <div className="bg-slate-900/40 rounded-xl p-4 border border-white/5 transition-all hover:border-fuchsia-500/20">
<span className="text-2xl font-bold text-fuchsia-400">{adsKPIs.totalSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}</span> <span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider block mb-1">Ad Spend</span>
</div> <div className="flex items-baseline">
<div className="bg-slate-900/50 rounded-lg p-4"> <span className="text-xl font-black text-fuchsia-400">
<span className="text-xs text-slate-400 block mb-1">Attributed Sales</span> {kpi.totalSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}
<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> </span>
{renderGrowth(kpi.totalSpend, prevKpi?.totalSpend)}
</div> </div>
<div className="bg-slate-900/50 rounded-lg p-4"> </div>
<span className="text-xs text-slate-400 block mb-1">ROAS</span> {/* Attributed Sales */}
<span className={`text-2xl font-bold ${adsKPIs.roas >= 3 ? 'text-emerald-400' : adsKPIs.roas >= 2 ? 'text-amber-400' : 'text-red-400'}`}> <div className="bg-slate-900/40 rounded-xl p-4 border border-white/5 transition-all hover:border-emerald-500/20">
{adsKPIs.roas.toFixed(2)}x <span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider block mb-1">Attr. Sales</span>
<div className="flex items-baseline">
<span className="text-xl font-black text-emerald-400">
{kpi.attributedSales.toLocaleString('de-DE', { maximumFractionDigits: 0 })}
</span> </span>
{renderGrowth(kpi.attributedSales, prevKpi?.attributedSales)}
</div> </div>
</div> </div>
{/* ACOS */}
<div className="bg-slate-900/40 rounded-xl p-4 border border-white/5 transition-all hover:border-amber-500/20">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider block mb-1">ACOS</span>
<div className="flex items-baseline">
<span className={`text-xl font-black ${kpi.acos <= 30 ? 'text-emerald-400' : kpi.acos <= 50 ? 'text-amber-400' : 'text-red-400'}`}>
{kpi.acos.toFixed(1)}%
</span>
{renderGrowth(kpi.acos, prevKpi?.acos, true)}
</div>
</div>
{/* ROAS */}
<div className="bg-slate-900/40 rounded-xl p-4 border border-white/5 transition-all hover:border-indigo-500/20">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider block mb-1">ROAS</span>
<div className="flex items-baseline">
<span className={`text-xl font-black ${kpi.roas >= 3 ? 'text-emerald-400' : kpi.roas >= 2 ? 'text-amber-400' : 'text-red-400'}`}>
{kpi.roas.toFixed(2)}x
</span>
{renderGrowth(kpi.roas, prevKpi?.roas)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div> </div>
)} )}