diff --git a/App.tsx b/App.tsx index 14e34cb..77f9531 100644 --- a/App.tsx +++ b/App.tsx @@ -9,10 +9,10 @@ import FilterBar from './components/FilterBar'; import AIChat from './components/AIChat'; import CrazeLogo from './components/CrazeLogo'; import { SalesRecord, FilterState, AggregatedData, AdsRecord } from './types'; // Imported AdsRecord -import { processCSV, filterData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, mergeSalesAndAdsData } from './services/dataProcessor'; // Imported new processors +import { processCSV, filterData, filterAdsData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, mergeSalesAndAdsData } from './services/dataProcessor'; import { queryGemini } from './services/geminiService'; import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon } from './components/Icons'; -import { loadSalesData, saveSalesData, clearSalesData } from './services/storage'; +import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage'; // New Refresh Icon const RefreshIcon = ({ className }: { className?: string }) => ( @@ -24,6 +24,7 @@ const RefreshIcon = ({ className }: { className?: string }) => ( // Hardcoded Permanent URL for Auto-Loading // Using the original share link to leverage Dropbox's redirect for robust fetching. const PERMANENT_DROPBOX_URL = "https://www.dropbox.com/scl/fi/b9zxn4z5i7sxwfakk5g5y/Amazon-Sell-Out-2023-2025.csv?rlkey=uoto6v0mm99py8nszy8ldtez8&st=pzn1zkrg&dl=0"; +const PERMANENT_ADS_URL = "https://www.dropbox.com/scl/fi/wng8tep7awhvzd65amwad/Ads-Weekly.xlsx?rlkey=kcmoq8dxgibsb2eb8zz47xvyo&st=z02m4w3g&dl=0"; const App: React.FC = () => { const [rawData, setRawData] = useState([]); @@ -84,6 +85,29 @@ const App: React.FC = () => { } }, []); + const handleAdsFetch = useCallback(async () => { + setSyncing(true); + try { + console.log('[App] Fetching ads from /api/fetch-ads...'); + const response = await fetch('/api/fetch-ads'); + + if (!response.ok) { + throw new Error(`Failed to fetch Ads: ${response.status} ${response.statusText}`); + } + + const buffer = await response.arrayBuffer(); + const data = await processAdsExcel(buffer); + + await saveAdsData(data); + setAdsData(data); + console.log('[App] Successfully loaded', data.length, 'ads records'); + } catch (error) { + console.error("Failed to fetch/parse Ads", error); + } finally { + setSyncing(false); + } + }, []); + const initializeData = (data: SalesRecord[]) => { setRawData(data); setFilters({ @@ -134,6 +158,16 @@ const App: React.FC = () => { console.error("Initial fetch failed."); }); } + + // 1b. Load Ads from cache or auto-fetch + const { data: cachedAds, lastUpdated: adsLastUpdated } = await loadAdsData(); + if (cachedAds && cachedAds.length > 0) { + console.log("Loaded ads from cache:", cachedAds.length, "records"); + setAdsData(cachedAds); + } else { + console.log("Fetching fresh Ads from URL..."); + handleAdsFetch(); + } }; initApp(); }, [handleDataFetch]); @@ -147,6 +181,8 @@ const App: React.FC = () => { initializeData(data); setLastUpdated(new Date().toISOString()); setIsDataModalOpen(false); + // Also trigger ads fetch if not loaded to maintain sync + if (adsData.length === 0) handleAdsFetch(); } catch (error) { console.error("Failed to parse CSV", error); alert("Error parsing CSV. Please check the format."); @@ -162,6 +198,7 @@ const App: React.FC = () => { const isExcel = file.name.endsWith('.xlsx') || file.name.endsWith('.xls'); const data = isExcel ? await processAdsExcel(file) : await processAdsCSV(file); setAdsData(data); + await saveAdsData(data); console.log("Ads loaded:", data.length, "records from", file.name); setIsDataModalOpen(false); } catch (error) { @@ -185,6 +222,8 @@ const App: React.FC = () => { handleDataFetch().then(() => { localStorage.setItem('craze_last_refresh_date', today); console.log("Daily refresh successful."); + // Also refresh ads + handleAdsFetch(); }).catch(err => { console.error("Daily refresh failed, will retry later.", err); }); @@ -203,6 +242,7 @@ const App: React.FC = () => { // Derive Data const filteredData = useMemo(() => filterData(rawData, filters), [rawData, filters]); + const filteredAdsData = useMemo(() => filterAdsData(adsData, filters), [adsData, filters]); const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]); // Combine Sales & Ads Data dynamically based on current filters @@ -264,8 +304,10 @@ const App: React.FC = () => { // Allow disconnecting to clear data, but the app will likely re-connect on next reload due to "Permanent" requirement localStorage.removeItem('craze_csv_url'); await clearSalesData(); + await clearAdsData(); setActiveUrl(null); setRawData([]); + setAdsData([]); }; return ( @@ -308,7 +350,10 @@ const App: React.FC = () => { {/* NEW REFRESH BUTTON */} )} + + {/* Attributed Sales Toggle - Only show when ads data is loaded and ads metrics are shown */} + {adsSummary && ( + + )}
@@ -906,6 +990,28 @@ const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData = )} + + {/* Ads Columns - Only show when ads data exists and toggle is on */} + {showAdsMetrics && adsSummary && ( + <> + requestSort(`adSpend_${year}`)} + > +
+ Ad Spend {year} {getSortIcon(`adSpend_${year}`)} +
+ + requestSort(`tacos_${year}`)} + > +
+ TACOS {year} {getSortIcon(`tacos_${year}`)} +
+ + + )} ); })} @@ -974,6 +1080,24 @@ const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData = )} + + {/* Ads Data Cells */} + {showAdsMetrics && adsSummary && (() => { + const adsYearData = row.adsByYear?.[year]; + return ( + <> + + {adsYearData ? `€${adsYearData.adSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}` : '-'} + + + {adsYearData ? `${adsYearData.tacos.toFixed(1)}%` : '-'} + + + ); + })()} ); })} diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index 90f8fd1..28c1f4c 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -352,9 +352,11 @@ export const processAdsCSV = (file: File): Promise => { }); }; -export const processAdsExcel = async (file: File): Promise => { +export const processAdsExcel = async (fileOrBuffer: File | ArrayBuffer): Promise => { try { - const arrayBuffer = await file.arrayBuffer(); + const arrayBuffer = fileOrBuffer instanceof File + ? await fileOrBuffer.arrayBuffer() + : fileOrBuffer; const workbook = XLSX.read(arrayBuffer); const allData: AdsRecord[] = []; @@ -542,6 +544,29 @@ export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecor // --- EXISTING HELPERS --- +// Filter Ads Data by Country, Year, Week, and ASIN +export const filterAdsData = (adsData: AdsRecord[], filters: FilterState): AdsRecord[] => { + return adsData.filter(ad => { + // Country/Customer match (ads use 'country', sales use 'customer') + const countryMatch = filters.customer.length === 0 || + filters.customer.some(c => c.toUpperCase() === ad.country.toUpperCase()); + + // Year match + const yearMatch = filters.year.length === 0 || + filters.year.includes(ad.year.toString()); + + // Week match (filters use "W1", "W2" format) + const weekStr = `W${ad.week}`; + const weekMatch = filters.week.length === 0 || filters.week.includes(weekStr); + + // ASIN match + const asinMatch = filters.asin.length === 0 || + filters.asin.some(a => a.toUpperCase() === ad.asin.toUpperCase()); + + return countryMatch && yearMatch && weekMatch && asinMatch; + }); +}; + export const filterData = (data: SalesRecord[], filters: FilterState): SalesRecord[] => { return data.filter(item => { // 1. Month Logic: Handle "Apr-23" matching "Apr" filter diff --git a/services/storage.ts b/services/storage.ts index 419d6f9..6ed6b9a 100644 --- a/services/storage.ts +++ b/services/storage.ts @@ -3,6 +3,7 @@ import { SalesRecord } from '../types'; const DB_NAME = 'CrazeAnalytixDB'; const STORE_NAME = 'salesData'; +const ADS_STORE_NAME = 'adsData'; const DB_VERSION = 1; // Increment this when data processing logic changes (e.g., field mapping changes) // This forces cache invalidation and re-processing of CSV data @@ -17,6 +18,9 @@ const initDB = (): Promise => { if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME); } + if (!db.objectStoreNames.contains(ADS_STORE_NAME)) { + db.createObjectStore(ADS_STORE_NAME); + } }; request.onsuccess = () => resolve(request.result); @@ -99,3 +103,63 @@ export const clearSalesData = async (): Promise => { console.error(e); } } + +// --- ADS STORAGE --- + +export const saveAdsData = async (data: any[]): Promise => { + try { + const db = await initDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(ADS_STORE_NAME, 'readwrite'); + const store = transaction.objectStore(ADS_STORE_NAME); + + store.put(data, 'currentData'); + store.put(new Date().toISOString(), 'lastUpdated'); + + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }); + } catch (error) { + console.error("Error saving ads to IndexedDB:", error); + } +}; + +export const loadAdsData = async (): Promise<{ data: any[]; lastUpdated: string | null }> => { + try { + const db = await initDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(ADS_STORE_NAME, 'readonly'); + const store = transaction.objectStore(ADS_STORE_NAME); + + const dataReq = store.get('currentData'); + const dateReq = store.get('lastUpdated'); + + transaction.oncomplete = () => { + resolve({ + data: dataReq.result || [], + lastUpdated: dateReq.result || null + }); + }; + + transaction.onerror = () => reject(transaction.error); + }); + } catch (error) { + console.error("Error loading ads from IndexedDB:", error); + return { data: [], lastUpdated: null }; + } +}; + +export const clearAdsData = async (): Promise => { + try { + const db = await initDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(ADS_STORE_NAME, 'readwrite'); + const store = transaction.objectStore(ADS_STORE_NAME); + store.clear(); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }); + } catch (e) { + console.error(e); + } +} diff --git a/types.ts b/types.ts index 316d589..9788884 100644 --- a/types.ts +++ b/types.ts @@ -112,6 +112,14 @@ export interface PivotRow { // Dynamic buckets totalsByYear: Record; months: MonthlyPivot[]; // Always 12 elements + + // Ads data (optional, aggregated by ASIN) + adsByYear?: Record; } export interface TimeSeriesData {