mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 13:35:24 +02:00
611 lines
24 KiB
TypeScript
611 lines
24 KiB
TypeScript
|
|
import React, { useState, useMemo, useEffect, useCallback, Suspense, lazy } from 'react';
|
|
import FileUpload from './components/FileUpload';
|
|
import Dashboard from './components/Dashboard';
|
|
import FilterBar from './components/FilterBar';
|
|
import AIChat from './components/AIChat';
|
|
import CrazeLogo from './components/CrazeLogo';
|
|
import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData } from './types';
|
|
import { processCSV, filterData, filterAdsData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, processTrafficExcel, mergeSalesAndAdsData, processForecastExcel, calculateForecastViewData } from './services/dataProcessor';
|
|
import { queryGemini } from './services/geminiService';
|
|
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons';
|
|
import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage';
|
|
|
|
// Lazy load heavy components for better initial performance
|
|
const DataGrid = lazy(() => import('./components/DataGrid'));
|
|
const WeeklyGrid = lazy(() => import('./components/WeeklyGrid'));
|
|
const TopMovers = lazy(() => import('./components/TopMovers'));
|
|
const AdsPerformance = lazy(() => import('./components/AdsPerformance'));
|
|
const ForecastView = lazy(() => import('./components/ForecastView'));
|
|
|
|
// Loading fallback component
|
|
const LoadingSpinner = () => (
|
|
<div className="flex items-center justify-center h-96">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
|
|
</div>
|
|
);
|
|
|
|
// New Refresh Icon
|
|
const RefreshIcon = ({ className }: { className?: string }) => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className || "w-5 h-5"}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
|
</svg>
|
|
);
|
|
|
|
// 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<SalesRecord[]>([]);
|
|
const [adsData, setAdsData] = useState<AdsRecord[]>([]);
|
|
const [trafficData, setTrafficData] = useState<TrafficRecord[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [syncing, setSyncing] = useState(false);
|
|
const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'movers' | 'ads' | 'forecast'>('dashboard'); // Added 'forecast' view
|
|
const [forecastData, setForecastData] = useState<ProductForecastData[]>([]);
|
|
const [isChatOpen, setIsChatOpen] = useState(false);
|
|
const [activeUrl, setActiveUrl] = useState<string | null>(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL);
|
|
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
|
|
|
// Modal State
|
|
const [isDataModalOpen, setIsDataModalOpen] = useState(false);
|
|
|
|
// Filters State
|
|
const [filters, setFilters] = useState<FilterState>({
|
|
customer: [],
|
|
year: [],
|
|
month: [],
|
|
line: [],
|
|
asin: [],
|
|
sku: [],
|
|
title: [],
|
|
});
|
|
|
|
// Handle Data Fetch (Simplified)
|
|
const handleDataFetch = useCallback(async () => {
|
|
setSyncing(true);
|
|
try {
|
|
console.log('[App] Fetching data from /api/fetch-data...');
|
|
const response = await fetch('/api/fetch-data');
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch CSV: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const csvText = await response.text();
|
|
const data = await processCSV(csvText);
|
|
|
|
await saveSalesData(data);
|
|
|
|
initializeData(data);
|
|
setActiveUrl(PERMANENT_DROPBOX_URL);
|
|
const now = new Date().toISOString();
|
|
setLastUpdated(now);
|
|
localStorage.setItem('craze_last_updated', now);
|
|
localStorage.setItem('craze_csv_url', PERMANENT_DROPBOX_URL);
|
|
setIsDataModalOpen(false);
|
|
|
|
console.log('[App] Successfully loaded', data.length, 'rows');
|
|
|
|
// Refresh forecast too
|
|
handleForecastFetch(data, data, filters);
|
|
} catch (error) {
|
|
console.error("Failed to fetch/parse CSV", error);
|
|
alert("Error loading data. Please refresh the page.");
|
|
throw error;
|
|
} finally {
|
|
setSyncing(false);
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
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 handleTrafficFetch = useCallback(async () => {
|
|
try {
|
|
console.log('[App] Fetching traffic from /api/fetch-traffic...');
|
|
const response = await fetch('/api/fetch-traffic');
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch Traffic: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const buffer = await response.arrayBuffer();
|
|
const data = await processTrafficExcel(buffer);
|
|
|
|
setTrafficData(data);
|
|
console.log('[App] Successfully loaded', data.length, 'traffic records');
|
|
} catch (error) {
|
|
console.error("Failed to fetch/parse Traffic", error);
|
|
}
|
|
}, []);
|
|
|
|
const handleForecastFetch = useCallback(async (sales: SalesRecord[], globalSales: SalesRecord[], activeFilters: FilterState) => {
|
|
try {
|
|
const isUK = activeFilters.customer.includes('Amazon UK');
|
|
const filename = isUK ? '/fc UK 26.xlsx' : '/fc 26.xlsx';
|
|
|
|
console.log(`[App] Fetching forecast from ${filename}...`);
|
|
const response = await fetch(filename);
|
|
if (!response.ok) throw new Error(`Forecast file ${filename} not found`);
|
|
|
|
const buffer = await response.arrayBuffer();
|
|
const fcRecords = await processForecastExcel(buffer);
|
|
|
|
// Build precise metadata map from GLOBAL sales records to ensure info is found even if filtered for market
|
|
const meta = new Map<string, { sku: string; title: string; line: string }>();
|
|
const metaDataSource = globalSales.length > 0 ? globalSales : sales;
|
|
|
|
metaDataSource.forEach(r => {
|
|
const asin = r.asin.trim().toUpperCase();
|
|
const existing = meta.get(asin);
|
|
if (!existing || (r.title && r.title.length > (existing.title?.length || 0))) {
|
|
meta.set(asin, { sku: r.sku, title: r.title, line: r.line });
|
|
}
|
|
});
|
|
|
|
const viewData = calculateForecastViewData(sales, fcRecords, meta, activeFilters);
|
|
setForecastData(viewData);
|
|
console.log('[App] Forecast loaded:', viewData.length, 'records');
|
|
} catch (error) {
|
|
console.warn("Forecast fetch failed:", error);
|
|
}
|
|
}, []);
|
|
|
|
// Update forecast when filters change
|
|
useEffect(() => {
|
|
if (rawData.length > 0) {
|
|
// Filter rawData to respect customer selections but keep all years (2025 for seasonality, 2026 for actuals)
|
|
const forecastRelevantData = filterData(rawData, {
|
|
...filters,
|
|
year: [] // Ensure we don't filter out 2025/2026 if a single year is selected in UI
|
|
});
|
|
handleForecastFetch(forecastRelevantData, rawData, filters);
|
|
}
|
|
}, [filters, rawData, handleForecastFetch]);
|
|
|
|
const initializeData = (data: SalesRecord[]) => {
|
|
setRawData(data);
|
|
setFilters({
|
|
customer: [],
|
|
year: [],
|
|
month: [],
|
|
line: [],
|
|
asin: [],
|
|
sku: [],
|
|
title: [],
|
|
week: [],
|
|
});
|
|
};
|
|
|
|
const handleSkuDrillDown = useCallback((sku: string) => {
|
|
if (!sku) return;
|
|
setFilters(prev => ({
|
|
...prev,
|
|
sku: [sku]
|
|
}));
|
|
setView('ads');
|
|
}, []);
|
|
|
|
// 1. Initial Load from Cache (IndexedDB) or Auto-Fetch Permanent URL
|
|
useEffect(() => {
|
|
const initApp = async () => {
|
|
setLoading(true);
|
|
|
|
const currentStoredUrl = localStorage.getItem('craze_csv_url');
|
|
let shouldUseCache = true;
|
|
|
|
// Force refresh if URL has changed
|
|
if (currentStoredUrl !== PERMANENT_DROPBOX_URL) {
|
|
console.log("URL changed, forcing refresh...");
|
|
localStorage.setItem('craze_csv_url', PERMANENT_DROPBOX_URL);
|
|
setActiveUrl(PERMANENT_DROPBOX_URL);
|
|
shouldUseCache = false;
|
|
}
|
|
|
|
// Try to load from cache first IF the URL hasn't changed
|
|
let cachedData = null;
|
|
let cachedDate = null;
|
|
if (shouldUseCache) {
|
|
const result = await loadSalesData();
|
|
cachedData = result.data;
|
|
cachedDate = result.lastUpdated;
|
|
}
|
|
|
|
if (cachedData && cachedData.length > 0) {
|
|
console.log("Loaded data from cache:", cachedData.length, "rows");
|
|
initializeData(cachedData);
|
|
setLastUpdated(cachedDate);
|
|
setLoading(false);
|
|
} else {
|
|
console.log("Fetching fresh data from Permanent URL...");
|
|
handleDataFetch().catch(e => {
|
|
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();
|
|
}
|
|
|
|
// 1c. Always fetch Traffic data (no caching for now)
|
|
console.log("Fetching Traffic data...");
|
|
handleTrafficFetch();
|
|
|
|
// 1d. Fetch Forecast data
|
|
handleForecastFetch(cachedData || [], cachedData || [], filters);
|
|
};
|
|
initApp();
|
|
}, [handleDataFetch]);
|
|
|
|
// Handle uploaded Sales file (Manual)
|
|
const handleSalesUpload = async (file: File) => {
|
|
setSyncing(true);
|
|
try {
|
|
const data = await processCSV(file);
|
|
await saveSalesData(data);
|
|
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.");
|
|
} finally {
|
|
setSyncing(false);
|
|
}
|
|
};
|
|
|
|
// Handle uploaded Ads file (Manual) - Supports both CSV and Excel
|
|
const handleAdsUpload = async (file: File) => {
|
|
setSyncing(true);
|
|
try {
|
|
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: any) {
|
|
console.error("Failed to parse Ads file", error);
|
|
alert(`Error parsing Ads file: ${error.message || 'Unknown error'}. Please check the format.`);
|
|
} finally {
|
|
setSyncing(false);
|
|
}
|
|
};
|
|
|
|
// Handle uploaded Traffic file (Manual)
|
|
const handleTrafficUpload = async (file: File) => {
|
|
setSyncing(true);
|
|
try {
|
|
const data = await processTrafficExcel(file);
|
|
setTrafficData(data);
|
|
console.log("Traffic loaded:", data.length, "records from", file.name);
|
|
setIsDataModalOpen(false);
|
|
} catch (error: any) {
|
|
console.error("Failed to parse Traffic file", error);
|
|
alert(`Error parsing Traffic file: ${error.message || 'Unknown error'}. Please check the format.`);
|
|
} finally {
|
|
setSyncing(false);
|
|
}
|
|
};
|
|
|
|
// 2. Schedule Auto-Refresh (Background)
|
|
useEffect(() => {
|
|
const checkAndRefresh = () => {
|
|
const now = new Date();
|
|
const today = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
|
const lastRefreshDate = localStorage.getItem('craze_last_refresh_date');
|
|
|
|
// Refresh if it's after 7 AM and we haven't refreshed today
|
|
if (now.getHours() >= 7 && lastRefreshDate !== today) {
|
|
console.log("Triggering daily data refresh...");
|
|
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);
|
|
});
|
|
}
|
|
};
|
|
|
|
// Check immediately on load in case the user opens the app after 7 AM
|
|
checkAndRefresh();
|
|
|
|
// And then check periodically (e.g., every 15 minutes) in case app is left open across midnight
|
|
const interval = setInterval(checkAndRefresh, 15 * 60 * 1000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [handleDataFetch]);
|
|
|
|
|
|
// Derive Data
|
|
const globalAsinMetadata = useMemo(() => {
|
|
const metaMap = new Map<string, { sku: string; title: string; line: string }>();
|
|
rawData.forEach(r => {
|
|
const asin = r.asin.trim().toUpperCase();
|
|
const existing = metaMap.get(asin);
|
|
// Keep most complete title
|
|
if (!existing || (r.title && r.title.length > (existing.title?.length || 0))) {
|
|
metaMap.set(asin, { sku: r.sku, title: r.title, line: r.line });
|
|
}
|
|
});
|
|
return metaMap;
|
|
}, [rawData]);
|
|
|
|
const filteredData = useMemo(() => filterData(rawData, filters), [rawData, filters]);
|
|
const filteredAdsData = useMemo(() => filterAdsData(adsData, filters, globalAsinMetadata), [adsData, filters, globalAsinMetadata]);
|
|
const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]);
|
|
|
|
// Calculate country-aware Top 50 Best Sellers for 2025
|
|
const top50Ranking2025 = useMemo(() => {
|
|
// Filter only 2025 data
|
|
const data2025 = rawData.filter(r => r.year === 2025);
|
|
|
|
const calculateTop50 = (entries: SalesRecord[]) => {
|
|
const asinTotals = new Map<string, number>();
|
|
entries.forEach(r => {
|
|
const asin = r.asin.trim().toUpperCase();
|
|
asinTotals.set(asin, (asinTotals.get(asin) || 0) + r.sellOut);
|
|
});
|
|
|
|
const sorted = Array.from(asinTotals.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 50);
|
|
|
|
const rankMap = new Map<string, number>();
|
|
sorted.forEach(([asin], index) => {
|
|
rankMap.set(asin, index + 1);
|
|
});
|
|
return rankMap;
|
|
};
|
|
|
|
const euData = data2025.filter(r => !r.customer.toLowerCase().includes('uk'));
|
|
const ukData = data2025.filter(r => r.customer.toLowerCase().includes('uk'));
|
|
|
|
return {
|
|
eu: calculateTop50(euData),
|
|
uk: calculateTop50(ukData)
|
|
};
|
|
}, [rawData]);
|
|
|
|
// Combine Sales & Ads Data dynamically based on current filters
|
|
const combinedAdsData = useMemo(() => {
|
|
return mergeSalesAndAdsData(filteredData, filteredAdsData, globalAsinMetadata, trafficData);
|
|
}, [filteredData, filteredAdsData, globalAsinMetadata, trafficData]);
|
|
|
|
// Derive Context Data (Product Line Context when drilling down)
|
|
const contextAggregatedData = useMemo(() => {
|
|
// Check if we are filtering by specific items (SKU, ASIN, Title)
|
|
const hasItemFilters = filters.sku.length > 0 || filters.asin.length > 0 || filters.title.length > 0;
|
|
|
|
if (!hasItemFilters || filteredData.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// 1. Identify the Product Lines associated with the currently filtered items
|
|
const activeLines = Array.from(new Set(filteredData.map(r => r.line)));
|
|
|
|
// 2. Create a "broad" filter: Keep Year/Customer/Month, but CLEAR Item filters, and restrict to these Lines
|
|
const contextFilters: FilterState = {
|
|
...filters,
|
|
line: activeLines, // Force these lines
|
|
sku: [], // Clear specific item filters
|
|
asin: [],
|
|
title: []
|
|
};
|
|
|
|
// 3. Process this broader dataset
|
|
const broadData = filterData(rawData, contextFilters);
|
|
return aggregateData(broadData);
|
|
|
|
}, [rawData, filters, filteredData]);
|
|
|
|
|
|
// Derive Options for Filter Dropdowns
|
|
const filterOptions = useMemo(() => {
|
|
return {
|
|
customer: getUniqueValues(rawData, 'customer'),
|
|
year: getUniqueValues(rawData, 'year'),
|
|
month: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
|
line: getUniqueValues(rawData, 'line'),
|
|
asin: getUniqueValues(rawData, 'asin'),
|
|
sku: getUniqueValues(rawData, 'sku'),
|
|
title: getUniqueValues(rawData, 'title'),
|
|
week: Array.from(new Set(rawData.map(r => r.week).filter(w => w !== undefined))).sort((a, b) => (a as number) - (b as number)).map(w => `W${w}`),
|
|
};
|
|
}, [rawData]);
|
|
|
|
const handleFilterChange = (key: keyof FilterState, value: string[]) => {
|
|
setFilters(prev => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
const handleAskGemini = async (text: string) => {
|
|
return await queryGemini(text, aggregatedData, filteredData.length);
|
|
};
|
|
|
|
const disconnectUrl = async () => {
|
|
// 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 (
|
|
<div className="min-h-screen flex flex-col bg-background text-slate-200">
|
|
|
|
{/* Header */}
|
|
<header className="bg-slate-950/90 backdrop-blur border-b border-border py-4 px-6 relative z-40 shadow-2xl">
|
|
<div className="max-w-7xl mx-auto flex justify-between items-center">
|
|
<div className="flex items-center gap-8">
|
|
{/* Logo Container (Horizontal Box) - Persistent User Image */}
|
|
<div className="h-16 w-64 relative flex-shrink-0">
|
|
<CrazeLogo />
|
|
</div>
|
|
|
|
{/* Title & Status */}
|
|
<div className="hidden lg:block border-l border-slate-700 pl-6">
|
|
<h1 className="text-xl font-bold tracking-tight text-white text-shadow-sm">Analytics Dashboard</h1>
|
|
{activeUrl && lastUpdated && (
|
|
<p className="text-[10px] text-emerald-400 mt-1 flex items-center gap-1 uppercase font-bold tracking-wider">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
|
|
Live Sync Active
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
{/* View Switcher is the priority now */}
|
|
{/* View Switcher */}
|
|
<div className="flex bg-slate-900 rounded-lg p-1 border border-border">
|
|
<button
|
|
onClick={() => setView('dashboard')}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
|
${view === 'dashboard' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
|
>
|
|
<ChartIcon /> <span className="hidden sm:inline">Dashboard</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setView('table')}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
|
${view === 'table' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
|
>
|
|
<TableIcon /> <span className="hidden sm:inline">Grid</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setView('weekly')}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
|
${view === 'weekly' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
|
>
|
|
<TrendingIcon /> <span className="hidden sm:inline">Weekly Sales</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setView('movers')}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
|
${view === 'movers' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
|
>
|
|
<TrendingIcon /> <span className="hidden sm:inline">Movers</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setView('ads')}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
|
${view === 'ads' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
|
>
|
|
<MegaphoneIcon /> <span className="hidden sm:inline">Ads</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setView('forecast')}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
|
${view === 'forecast' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
|
>
|
|
<ChartIcon /> <span className="hidden sm:inline">Fc 26</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main Content */}
|
|
<main className="flex-1 relative">
|
|
{loading ? (
|
|
// Initial loading spinner
|
|
<div className="flex flex-col items-center justify-center h-[80vh] gap-4">
|
|
<div className="w-16 h-16 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
|
|
<h2 className="text-xl font-bold text-slate-300">Loading Dashboard...</h2>
|
|
<p className="text-sm text-slate-500">Syncing with Dropbox...</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<FilterBar filters={filters} onFilterChange={handleFilterChange} options={filterOptions} />
|
|
<div className="mt-6">
|
|
{view === 'dashboard' && (
|
|
<Dashboard
|
|
data={aggregatedData}
|
|
contextData={contextAggregatedData}
|
|
adsData={filteredAdsData}
|
|
/>
|
|
)}
|
|
<Suspense fallback={<LoadingSpinner />}>
|
|
{view === 'table' && <DataGrid data={combinedAdsData} hasCustomerFilter={filters.customer.length > 0} adsData={filteredAdsData} />}
|
|
{view === 'weekly' && <WeeklyGrid data={combinedAdsData} top50Ranking={top50Ranking2025} onDrillDown={handleSkuDrillDown} />}
|
|
{view === 'movers' && <TopMovers data={filteredData} />}
|
|
{view === 'ads' && <AdsPerformance data={combinedAdsData} filters={filters} top50Ranking={top50Ranking2025} />}
|
|
{view === 'forecast' && <ForecastView data={forecastData} filters={filters} top50Ranking={top50Ranking2025} />}
|
|
</Suspense>
|
|
</div>
|
|
</>
|
|
)}
|
|
</main>
|
|
|
|
{/* Chat Assistant */}
|
|
<AIChat onSendMessage={handleAskGemini} isOpen={isChatOpen} setIsOpen={setIsChatOpen} />
|
|
|
|
{/* DATA MODAL */}
|
|
{isDataModalOpen && (
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm animate-fade-in p-4">
|
|
<div className="bg-slate-950 border border-border rounded-2xl shadow-2xl w-full max-w-lg relative overflow-hidden">
|
|
<div className="bg-slate-900 px-6 py-4 border-b border-border flex justify-between items-center">
|
|
<h2 className="text-lg font-bold text-white">Data Source Settings</h2>
|
|
<button onClick={() => setIsDataModalOpen(false)} className="text-slate-400 hover:text-white transition-colors">
|
|
<CloseIcon />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
<FileUpload
|
|
onSalesUpload={handleSalesUpload}
|
|
onAdsUpload={handleAdsUpload}
|
|
onTrafficUpload={handleTrafficUpload}
|
|
onUrlSubmit={handleDataFetch}
|
|
isLoading={syncing}
|
|
activeUrl={activeUrl}
|
|
onDisconnect={disconnectUrl}
|
|
lastUpdated={lastUpdated}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App;
|