From bde9d323f19d063200b6d4aa5d4c11a21e07f842 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 11 Dec 2025 15:08:01 +0100 Subject: [PATCH] feat: Integrate advertising data and dashboard Adds functionality to process and display advertising data alongside sales data. This includes: - Introducing a new `AdvertisingDashboard` component. - Modifying `FileUpload` to accept advertising CSVs. - Updating `App.tsx` to manage and display both sales and ad data. - Enhancing `dataProcessor.ts` to handle advertising CSV parsing and merging. - Adding a `MegaphoneIcon` for advertising-related UI elements. - Defining new types for advertising records and combined KPIs. --- App.tsx | 57 ++++-- components/AdvertisingDashboard.tsx | 271 ++++++++++++++++++++++++++++ components/FileUpload.tsx | 74 +++++--- components/Icons.tsx | 6 + services/dataProcessor.ts | 218 +++++++++++++--------- 5 files changed, 507 insertions(+), 119 deletions(-) create mode 100644 components/AdvertisingDashboard.tsx diff --git a/App.tsx b/App.tsx index ba1d09c..ea83b05 100644 --- a/App.tsx +++ b/App.tsx @@ -4,13 +4,14 @@ import FileUpload from './components/FileUpload'; import Dashboard from './components/Dashboard'; import DataGrid from './components/DataGrid'; import TopMovers from './components/TopMovers'; +import AdvertisingDashboard from './components/AdvertisingDashboard'; // Imported import FilterBar from './components/FilterBar'; import AIChat from './components/AIChat'; import CrazeLogo from './components/CrazeLogo'; -import { SalesRecord, FilterState, AggregatedData } from './types'; -import { processCSV, filterData, aggregateData, getUniqueValues } from './services/dataProcessor'; +import { SalesRecord, FilterState, AggregatedData, AdsRecord } from './types'; // Imported AdsRecord +import { processCSV, filterData, aggregateData, getUniqueValues, processAdsCSV, mergeSalesAndAdsData } from './services/dataProcessor'; // Imported new processors import { queryGemini } from './services/geminiService'; -import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon } from './components/Icons'; +import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons'; // Imported MegaphoneIcon import { loadSalesData, saveSalesData, clearSalesData } from './services/storage'; // New Refresh Icon @@ -26,9 +27,10 @@ const PERMANENT_DROPBOX_URL = "https://www.dropbox.com/scl/fi/b9zxn4z5i7sxwfakk5 const App: React.FC = () => { const [rawData, setRawData] = useState([]); + const [adsData, setAdsData] = useState([]); // New Ads State const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); - const [view, setView] = useState<'dashboard' | 'table' | 'movers'>('dashboard'); + const [view, setView] = useState<'dashboard' | 'table' | 'movers' | 'ads'>('dashboard'); // Added 'ads' view const [isChatOpen, setIsChatOpen] = useState(false); const [activeUrl, setActiveUrl] = useState(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL); const [lastUpdated, setLastUpdated] = useState(null); @@ -131,8 +133,8 @@ const App: React.FC = () => { initApp(); }, [handleUrlFetch]); - // Handle uploaded file (Manual) - const handleFileUpload = async (file: File) => { + // Handle uploaded Sales file (Manual) + const handleSalesUpload = async (file: File) => { setSyncing(true); try { const data = await processCSV(file); @@ -148,6 +150,23 @@ const App: React.FC = () => { } }; + // Handle uploaded Ads file (Manual) + const handleAdsUpload = async (file: File) => { + setSyncing(true); + try { + const data = await processAdsCSV(file); + setAdsData(data); + console.log("Ads loaded:", data.length); + setIsDataModalOpen(false); + setView('ads'); // Switch to ads view automatically + } catch (error) { + console.error("Failed to parse Ads CSV", error); + alert("Error parsing Ads CSV. Please check the format."); + } finally { + setSyncing(false); + } + }; + // 2. Schedule Auto-Refresh (Background) useEffect(() => { const checkAndRefresh = () => { @@ -180,6 +199,11 @@ const App: React.FC = () => { // Derive Data const filteredData = useMemo(() => filterData(rawData, filters), [rawData, filters]); const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]); + + // Combine Sales & Ads Data dynamically based on current filters + const combinedAdsData = useMemo(() => { + return mergeSalesAndAdsData(filteredData, adsData); + }, [filteredData, adsData]); // Derive Context Data (Product Line Context when drilling down) const contextAggregatedData = useMemo(() => { @@ -290,24 +314,31 @@ const App: React.FC = () => {
+
@@ -335,6 +366,7 @@ const App: React.FC = () => { )} {view === 'table' && } {view === 'movers' && } + {view === 'ads' && } )} @@ -356,7 +388,8 @@ const App: React.FC = () => {
{ + const map = new Map(); + + // Sort chronologically if needed, but assuming input might be mixed + // We'll create keys like "Jan 23", "Feb 23" and sort them later + + data.forEach(item => { + const key = item.month; // e.g. "May-24" + if (!map.has(key)) { + map.set(key, { + name: key, + salesTotal: 0, + salesAds: 0, + cost: 0, + salesOrganic: 0, + impressions: 0, + clicks: 0, + unitsTotal: 0, + unitsAds: 0 + }); + } + const entry = map.get(key); + entry.salesTotal += item.salesTotal; + entry.salesAds += item.salesAds; + entry.cost += item.cost; + entry.salesOrganic += item.salesOrganic; + entry.impressions += item.impressions; + entry.clicks += item.clicks; + entry.unitsTotal += item.unitsTotal; + entry.unitsAds += item.unitsAds; + }); + + const result = Array.from(map.values()).map(r => ({ + ...r, + acos: r.salesAds > 0 ? (r.cost / r.salesAds) * 100 : 0, + tacos: r.salesTotal > 0 ? (r.cost / r.salesTotal) * 100 : 0, + ctr: r.impressions > 0 ? (r.clicks / r.impressions) * 100 : 0, + cpc: r.clicks > 0 ? r.cost / r.clicks : 0 + })); + + // Sort by Date + return result.sort((a, b) => { + const [mA, yA] = a.name.split('-'); + const [mB, yB] = b.name.split('-'); + + // Year comparison + if (yA !== yB) return parseInt(yA) - parseInt(yB); + + // Month comparison + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return months.indexOf(mA) - months.indexOf(mB); + }); +}; + +const KPICard = ({ title, value, subValue, type = 'currency' }: { title: string, value: number, subValue?: string, type?: 'currency' | 'percent' | 'number' }) => { + const formatted = type === 'currency' + ? `€${value.toLocaleString(undefined, { maximumFractionDigits: 0 })}` + : type === 'percent' + ? `${value.toFixed(2)}%` + : value.toLocaleString(); + + return ( +
+ {title} + {formatted} + {subValue && {subValue}} +
+ ); +}; + +const AdvertisingDashboard: React.FC = ({ data }) => { + + const aggregated = useMemo(() => aggregateByMonth(data), [data]); + + const totals = useMemo(() => { + return data.reduce((acc, curr) => ({ + salesTotal: acc.salesTotal + curr.salesTotal, + salesAds: acc.salesAds + curr.salesAds, + cost: acc.cost + curr.cost, + clicks: acc.clicks + curr.clicks, + impressions: acc.impressions + curr.impressions, + salesOrganic: acc.salesOrganic + curr.salesOrganic + }), { salesTotal: 0, salesAds: 0, cost: 0, clicks: 0, impressions: 0, salesOrganic: 0 }); + }, [data]); + + const kpiAcos = totals.salesAds > 0 ? (totals.cost / totals.salesAds) * 100 : 0; + const kpiTacos = totals.salesTotal > 0 ? (totals.cost / totals.salesTotal) * 100 : 0; + const kpiRoas = totals.cost > 0 ? totals.salesAds / totals.cost : 0; + + if (data.length === 0) { + return ( +
+

No Advertising Data Available

+

Please upload an Ads CSV file via "Connect Data".

+
+ ) + } + + return ( +
+ + {/* KPI Grid */} +
+ + + + + + +
+ + {/* Charts Row 1 */} +
+ + {/* Spend vs Sales Trend */} +
+

Ad Spend vs Ad Sales Trend

+
+ + + + + `€${v/1000}k`} /> + `€${val.toLocaleString()}`} + /> + + + + + +
+
+ + {/* ACOS vs TACOS Trend */} +
+

Efficiency: ACOS vs TACOS

+
+ + + + + + `${val.toFixed(2)}%`} + /> + + + + + +
+
+
+ + {/* Charts Row 2 */} +
+ + {/* Organic vs Paid Sales */} +
+

Total Sales Composition (Organic vs Paid)

+
+ + + + + + + + + + + + + + + `€${v/1000}k`} /> + `€${val.toLocaleString()}`} + /> + + + + + +
+
+ + {/* Funnel: Impressions -> Clicks */} +
+

Marketing Funnel (Impressions & Clicks)

+
+ + + + + `${(v/1000).toFixed(0)}k`} label={{ value: 'Impressions', angle: -90, position: 'insideLeft', fill: '#8b5cf6' }} /> + + + + + + + +
+
+
+ + {/* Detailed Table */} +
+
+

Monthly Advertising Breakdown

+
+
+ + + + + + + + + + + + + + + {[...aggregated].reverse().map((row, idx) => ( + + + + + + + + + + + ))} + +
PeriodSpendAd SalesTotal SalesACOSTACOSClicksCPC
{row.name}€{row.cost.toLocaleString(undefined, {maximumFractionDigits:0})}€{row.salesAds.toLocaleString(undefined, {maximumFractionDigits:0})}€{row.salesTotal.toLocaleString(undefined, {maximumFractionDigits:0})}{row.acos.toFixed(2)}%{row.tacos.toFixed(2)}%{row.clicks.toLocaleString()}€{row.cpc.toFixed(2)}
+
+
+ +
+ ); +}; + +// Fix for AreaChart reference error in some bundlers, just export +import { AreaChart } from 'recharts'; + +export default AdvertisingDashboard; diff --git a/components/FileUpload.tsx b/components/FileUpload.tsx index 3859660..ed0f67b 100644 --- a/components/FileUpload.tsx +++ b/components/FileUpload.tsx @@ -1,9 +1,10 @@ import React, { ChangeEvent, useState } from 'react'; -import { UploadIcon } from './Icons'; +import { UploadIcon, MegaphoneIcon } from './Icons'; interface FileUploadProps { - onFileUpload: (file: File) => void; + onSalesUpload: (file: File) => void; // Renamed from onFileUpload + onAdsUpload: (file: File) => void; // New Prop onUrlSubmit: (url: string) => void; isLoading: boolean; activeUrl?: string | null; @@ -12,7 +13,8 @@ interface FileUploadProps { } const FileUpload: React.FC = ({ - onFileUpload, + onSalesUpload, + onAdsUpload, onUrlSubmit, isLoading, activeUrl, @@ -21,12 +23,18 @@ const FileUpload: React.FC = ({ }) => { const [url, setUrl] = useState(''); - const handleChange = (e: ChangeEvent) => { + const handleSalesChange = (e: ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { - onFileUpload(e.target.files[0]); + onSalesUpload(e.target.files[0]); } }; + const handleAdsChange = (e: ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + onAdsUpload(e.target.files[0]); + } + }; + const handleUrlSubmit = (e: React.FormEvent) => { e.preventDefault(); if (url.trim()) { @@ -79,25 +87,47 @@ const FileUpload: React.FC = ({
)} - {/* Manual File Upload */} -
-