Files
CrazeAnalytix/App.tsx
T
Christian Vidal WolfandQwen-Coder 167273e659 fix: Top Movers table now responds to dashboard filters
Changed rawData prop from rawData to ytdFilteredData so the Top Movers
component respects all active filters (customer, year, month, week,
product line, SKU, title, ASIN, stock)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-16 14:57:09 +01:00

1015 lines
44 KiB
TypeScript

import React, { useState, useMemo, useEffect, useCallback, Suspense, lazy, useDeferredValue } 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 { processCSV, processExcel, filterData, aggregateData, processAdsCSV, processAdsExcel, mergeSalesAndAdsData, processTrafficExcel, processStockExcel, filterAdsData, calculateForecastViewData, processVendorStockExcel, processUKInventoryExcel, calculateVelocityMap, getUniqueValues, processForecastExcel, processBuyBoxExcel, processBSRExcel, filterBsrData } from './services/dataProcessor';
import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData, ActiveExperiment, BSRRecord } from './types';
import { queryGemini } from './services/geminiService';
import { ChartIcon, TableIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons';
import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage';
import {
listExperiments,
getActiveExperiments,
} from './services/experiments';
// Lazy load heavy components for better initial performance
const DataGrid = lazy(() => import('./components/DataGrid'));
const WeeklyGrid = lazy(() => import('./components/WeeklyGrid'));
const AdsPerformance = lazy(() => import('./components/AdsPerformance'));
const ForecastView = lazy(() => import('./components/ForecastView'));
const VendorDataView = lazy(() => import('./components/VendorDataView'));
const ExperimentsView = lazy(() => import('./components/ExperimentsView'));
const MktDataView = lazy(() => import('./components/mkt/MktDataView'));
// 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' | 'ads' | 'forecast' | 'vendor' | 'experiments' | 'mkt'>('dashboard');
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);
const [stockMap, setStockMap] = useState<Map<string, number>>(new Map());
const [vendorStockMap, setVendorStockMap] = useState<Map<string, { eu: number; uk: number }>>(new Map());
const [cachedForecastRecords, setCachedForecastRecords] = useState<ForecastRecord[]>([]);
const [lastForecastFile, setLastForecastFile] = useState<string | null>(null);
const [buyBoxLostMap, setBuyBoxLostMap] = useState<Map<string, { countries: string[]; reasons: Record<string, string> }>>(new Map());
const [bsrData, setBsrData] = useState<BSRRecord[]>([]);
// Experiments state
const [experimentMap, setExperimentMap] = useState<Map<string, ActiveExperiment[]>>(new Map());
// Experiment modal state removed — detail/create are now inline in ExperimentsView
// Modal State
const [isDataModalOpen, setIsDataModalOpen] = useState(false);
const [filters, setFilters] = useState<FilterState>({
customer: [],
year: [],
month: [],
line: [],
asin: [],
sku: [],
title: [],
week: [],
stock: [],
vendorStock: [],
woc: [],
bulkSearch: '',
});
const deferredFilters = useDeferredValue(filters);
const top50Mode = useMemo(() => {
const hasUK = filters.customer.includes('Amazon UK');
const hasEU = filters.customer.some(c => ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES'].includes(c));
if (hasUK) return 'uk';
return 'eu';
}, [filters.customer]);
// Calculate 4-week Sales Velocity Map (Context-Aware)
// MOVED HERE TO AVOID REFERENCE ERROR in handleForecastFetch
const velocityMap = useMemo(() => {
// Determine which dataset to use for velocity calculation based on region filter
// If 'uk', use UK data. If 'eu', use EU data.
// IMPOTANT: We do NOT filter by Week/Month here, so we get the full history for velocity calculation
const regionData = rawData.filter(r => {
if (top50Mode === 'uk') return r.customer.toLowerCase().includes('uk');
// for EU, exclude UK
return !r.customer.toLowerCase().includes('uk');
});
return calculateVelocityMap(regionData);
}, [rawData, top50Mode]);
// 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}`);
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 handleStockFetch = useCallback(async () => {
try {
console.log('[App] Fetching stock from /api/fetch-stock...');
const response = await fetch('/api/fetch-stock');
if (!response.ok) throw new Error(`Failed to fetch stock: ${response.status}`);
const buffer = await response.arrayBuffer();
const data = await processStockExcel(buffer);
setStockMap(data);
console.log('[App] Successfully loaded stock for', data.size, 'normalized SKUs');
} catch (error) {
console.error("Failed to fetch/parse Stock", error);
}
}, []);
const handleVendorStockFetch = useCallback(async () => {
try {
console.log('[App] Fetching vendor stock (PANEU + UK) from Dropbox...');
// Fetch both PANEU and UK inventory in parallel
const [paneuResponse, ukResponse] = await Promise.all([
fetch('/api/fetch-paneu-stock'),
fetch('/api/fetch-uk-inventory')
]);
let combinedMap = new Map<string, { eu: number; uk: number }>();
// Process PANEU stock (EU countries)
if (paneuResponse.ok) {
const paneuBuffer = await paneuResponse.arrayBuffer();
combinedMap = await processVendorStockExcel(paneuBuffer);
console.log('[App] PANEU stock loaded:', combinedMap.size, 'ASINs');
} else {
console.warn('[App] PANEU fetch failed:', paneuResponse.status);
}
// Process UK inventory and merge with existing map
if (ukResponse.ok) {
const ukBuffer = await ukResponse.arrayBuffer();
combinedMap = await processUKInventoryExcel(ukBuffer, combinedMap);
console.log('[App] UK inventory merged. Total ASINs:', combinedMap.size);
} else {
console.warn('[App] UK inventory fetch failed:', ukResponse.status);
}
setVendorStockMap(combinedMap);
console.log('[App] Successfully loaded combined vendor stock for', combinedMap.size, 'ASINs');
} catch (error) {
console.error("Failed to fetch/parse Vendor Stock", error);
}
}, []);
const handleBuyBoxFetch = useCallback(async () => {
try {
console.log('[App] Fetching Buy Box data from /api/fetch-buybox...');
const response = await fetch('/api/fetch-buybox');
if (!response.ok) throw new Error(`Failed to fetch Buy Box data: ${response.status}`);
const buffer = await response.arrayBuffer();
const data = await processBuyBoxExcel(buffer);
setBuyBoxLostMap(data);
(window as any).debugBuyBox = data;
console.log('[App] Successfully loaded Buy Box lost data for', data.size, 'ASINs. Access via window.debugBuyBox');
} catch (error) {
console.error("Failed to fetch/parse Buy Box data", error);
}
}, []);
const handleBSRFetch = useCallback(async () => {
try {
console.log('[App] Fetching BSR data from /api/fetch-bsr...');
const response = await fetch('/api/fetch-bsr');
if (!response.ok) throw new Error(`Failed to fetch BSR data: ${response.status}`);
const buffer = await response.arrayBuffer();
const data = await processBSRExcel(buffer);
setBsrData(data);
console.log('[App] Successfully loaded BSR data:', data.length, 'records');
} catch (error) {
console.error("Failed to fetch/parse BSR data", error);
}
}, []);
const handleExperimentsFetch = useCallback(async () => {
try {
const expMap = await getActiveExperiments(rawData);
setExperimentMap(expMap);
console.log('[App] Loaded experiments for', expMap.size, 'ASINs');
} catch (error) {
console.error("Failed to fetch experiments", error);
}
}, [rawData]);
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';
let fcRecords = cachedForecastRecords;
if (lastForecastFile !== filename || cachedForecastRecords.length === 0) {
console.log(`[App] Fetching fresh forecast from ${filename}...`);
const response = await fetch(filename);
if (!response.ok) throw new Error(`Forecast file ${filename} not found`);
const buffer = await response.arrayBuffer();
fcRecords = await processForecastExcel(buffer);
setCachedForecastRecords(fcRecords);
setLastForecastFile(filename);
} else {
console.log(`[App] Using cached forecast for ${filename}`);
}
// 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, velocityMap, globalSales.length > 0 ? globalSales : undefined);
setForecastData(viewData);
console.log('[App] Forecast calculation complete:', viewData.length, 'records');
} catch (error) {
console.warn("Forecast fetch failed:", error);
}
}, [velocityMap, cachedForecastRecords, lastForecastFile]);
// 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
month: [] // EXCLUDE month filtering to preserve seasonality calculation in calculateForecastViewData
});
handleForecastFetch(forecastRelevantData, rawData, filters);
}
}, [filters, rawData, handleForecastFetch]);
const initializeData = (data: SalesRecord[]) => {
setRawData(data);
setFilters({
customer: [],
year: [],
month: [],
line: [],
asin: [],
sku: [],
title: [],
week: [],
stock: [],
vendorStock: [],
woc: [],
});
};
const handleSkuDrillDown = useCallback((sku: string) => {
if (!sku) return;
setFilters(prev => ({
...prev,
sku: [sku]
}));
setView('ads');
}, []);
// Create experiment for a specific line — now handled inline by ExperimentsView
const handleCreateExperimentForLine = useCallback((_line: string) => {
setView('experiments');
}, []);
// 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);
// Trigger background sync to ensure data is fresh
handleDataFetch().catch(e => console.warn("Background sales sync failed", e));
} 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);
// Trigger background sync
handleAdsFetch();
} else {
console.log("Fetching fresh Ads from URL...");
handleAdsFetch();
}
// 1c-1f. Fetch other data in parallel (non-blocking)
Promise.all([
handleTrafficFetch(),
handleStockFetch(),
handleVendorStockFetch(),
handleBuyBoxFetch(),
handleExperimentsFetch(),
handleBSRFetch()
]).catch(e => console.warn("Background fetch failed", e));
// 1f. 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);
}
};
// Handle uploaded Vendor CSV (sends to Supabase via API with batching)
// 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, deferredFilters, stockMap, vendorStockMap, top50Mode), [rawData, deferredFilters, stockMap, vendorStockMap, top50Mode]);
const filteredAdsData = useMemo(() => filterAdsData(adsData, deferredFilters, globalAsinMetadata, stockMap, vendorStockMap, top50Mode), [adsData, deferredFilters, globalAsinMetadata, stockMap, vendorStockMap, top50Mode]);
// MKT tab: ads without PAN_EU default restriction — includes all countries unless
// the user explicitly selects a marketplace filter
const mktAdsData = useMemo(() => adsData.filter(ad => {
const countryMatch = deferredFilters.customer.length === 0
? true
: deferredFilters.customer.some(c => c.toUpperCase() === ad.country.toUpperCase());
const yearMatch = deferredFilters.year.length === 0 || deferredFilters.year.includes(ad.year.toString());
const weekMatch = deferredFilters.week.length === 0 || deferredFilters.week.includes(`W${ad.week}`);
return countryMatch && yearMatch && weekMatch;
}), [adsData, deferredFilters.customer, deferredFilters.year, deferredFilters.week]);
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]);
// Determine which dataset to use for velocity calculation based on top50Mode
// Derived Data for Vendor (BSR)
const filteredBsrData = useMemo(() => {
return filterBsrData(bsrData, deferredFilters, globalAsinMetadata, stockMap, vendorStockMap, top50Mode);
}, [bsrData, deferredFilters, globalAsinMetadata, stockMap, vendorStockMap, top50Mode]);
// Combine Sales & Ads Data dynamically based on current filters
const combinedAdsData = useMemo(() => {
return mergeSalesAndAdsData(filteredData, filteredAdsData, globalAsinMetadata, trafficData, velocityMap, filteredBsrData);
}, [filteredData, filteredAdsData, globalAsinMetadata, trafficData, velocityMap, filteredBsrData]);
// Unfiltered combined data for Experiment calculations (so they aren't affected by UI filters)
const unfilteredCombinedData = useMemo(() => {
return mergeSalesAndAdsData(rawData, adsData, globalAsinMetadata, trafficData, velocityMap, bsrData);
}, [rawData, adsData, globalAsinMetadata, trafficData, velocityMap, bsrData]);
// Derived Data for Grid (YTD Filtered - Isolated)
// 1. Identify "Current Year" max week
const gridContext = useMemo(() => {
if (rawData.length === 0) return { minWeek: 1, maxWeek: 53, currentYear: new Date().getFullYear() };
const currentYear = rawData.reduce((max, r) => Math.max(max, r.year), 0);
const currentYearData = rawData.filter(r => r.year === currentYear);
const maxWeek = currentYearData.map(r => r.week).filter((w): w is number => w !== undefined)
.reduce((max, w) => Math.max(max, w), 0);
return { minWeek: 1, maxWeek: maxWeek || 53, currentYear };
}, [rawData]);
const ytdGridData = useMemo(() => {
// Smart YTD Logic:
// Only apply the "Max Week" cutoff if the user has NOT explicitly selected a specific time period.
// If Month or Week filters are active, we show exactly what was asked (e.g., Full Year 2024).
// If no time filters are active, we default to YTD (Like-for-Like) comparison.
const isTimeFilterActive = deferredFilters.month.length > 0 || deferredFilters.week.length > 0;
if (!combinedAdsData) return [];
if (isTimeFilterActive) {
return combinedAdsData;
}
return combinedAdsData.filter(item => {
if (!item.week) return true;
return item.week <= gridContext.maxWeek;
});
}, [combinedAdsData, gridContext.maxWeek, deferredFilters.month, deferredFilters.week]);
// Derived Data for Dashboard (YTD Filtered - Isolated)
// Dashboard needs aggregated data respecting the max week limit of current year.
// Dashboard needs aggregated data respecting the max week limit of current year...
// UNLESS the user explicitly filters for a period.
const ytdFilteredData = useMemo(() => {
const isTimeFilterActive = deferredFilters.month.length > 0 || deferredFilters.week.length > 0;
if (isTimeFilterActive) return filteredData;
return filteredData.filter(r => r.week <= gridContext.maxWeek);
}, [filteredData, gridContext.maxWeek, deferredFilters.month, deferredFilters.week]);
const ytdFilteredAdsData = useMemo(() => {
const isTimeFilterActive = deferredFilters.month.length > 0 || deferredFilters.week.length > 0;
if (isTimeFilterActive) return filteredAdsData;
return filteredAdsData.filter(r => r.week <= gridContext.maxWeek);
}, [filteredAdsData, gridContext.maxWeek, deferredFilters.month, deferredFilters.week]);
const ytdAggregatedData = useMemo(() => {
return aggregateData(ytdFilteredData);
}, [ytdFilteredData]);
// Derived Data for Views
const years = useMemo(() => getUniqueValues(rawData, 'year').sort().reverse(), [rawData]);
// 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 = deferredFilters.sku.length > 0 || deferredFilters.asin.length > 0 || deferredFilters.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 = {
...deferredFilters,
line: activeLines, // Force these lines
sku: [], // Clear specific item filters
asin: [],
title: [],
stock: []
};
// 3. Process this broader dataset
const broadData = filterData(rawData, contextFilters);
return aggregateData(broadData);
}, [rawData, deferredFilters, 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').filter(v => v !== 'N/D' && v !== 'n/d' && v !== '#N/D' && v !== 'Unassigned'),
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}`),
stock: [
'Out of Stock (0)',
'In Stock (>0)',
'Low Stock (<10)',
...Array.from(new Set(Array.from(stockMap.values()).map(String))).sort((a, b) => parseFloat(a) - parseFloat(b))
]
};
}, [rawData, stockMap]);
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-2 px-3 md:py-4 md: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-3 md:gap-8">
{/* Logo Container - Smaller on mobile */}
<div className="h-10 w-32 md:h-16 md: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>
<div className="flex items-center gap-3 mt-1.5">
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-emerald-500/10 border border-emerald-500/20 rounded-full">
<span className={`w-1.5 h-1.5 rounded-full bg-emerald-400 ${syncing ? 'animate-ping' : ''}`}></span>
<span className="text-[9px] text-emerald-400 font-black uppercase tracking-wider">Live Sync</span>
</div>
{lastUpdated && (
<span className="text-[9px] text-slate-500 font-bold uppercase tracking-wider">
Last: {new Date(lastUpdated).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3 md:gap-6">
{/* Force Sync Action */}
<button
onClick={() => { handleDataFetch(); handleAdsFetch(); handleTrafficFetch(); handleVendorStockFetch(); handleBuyBoxFetch(); }}
disabled={syncing}
title={syncing ? 'Syncing...' : 'Sync Now'}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-semibold transition-all active:scale-95
${syncing
? 'text-indigo-400/70 cursor-not-allowed'
: 'text-slate-500 hover:text-slate-300 hover:bg-slate-800/60'}`}
>
<RefreshIcon className={`w-3.5 h-3.5 ${syncing ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">{syncing ? 'Syncing...' : 'Sync'}</span>
</button>
{/* View Switcher - Hidden on mobile (shown in bottom nav instead) */}
<div className="hidden md: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 lg: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 lg: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 lg:inline">Weekly Sales</span>
</button>
<button
onClick={() => setView('mkt')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
${view === 'mkt' ? '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 lg:inline">MKT</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 lg: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 lg:inline">Fc 26</span>
</button>
<button
onClick={() => setView('vendor')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
${view === 'vendor' ? '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 lg:inline">BSR</span>
</button>
<button
onClick={() => setView('experiments')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
${view === 'experiments' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<span className="text-lg">🧪</span> <span className="hidden lg:inline">Experiments</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">
<div className={view === 'dashboard' ? '' : 'hidden'}>
<Dashboard data={ytdAggregatedData} filterState={filters} adsData={ytdFilteredAdsData} stockMap={stockMap} rawData={ytdFilteredData} />
</div>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'table' ? '' : 'hidden'}>
<DataGrid
data={ytdGridData}
filters={filters}
hasCustomerFilter={filters.customer.length > 0}
adsData={filteredAdsData}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
top50Ranking={top50Ranking2025}
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
velocityMap={velocityMap}
buyBoxLostMap={buyBoxLostMap}
defaultSort={{ key: `total_sellOut_${gridContext.currentYear}`, direction: 'desc' }}
/>
</div>
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'weekly' ? '' : 'hidden'}>
<WeeklyGrid
data={combinedAdsData}
onDrillDown={handleSkuDrillDown}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
stockFilter={filters.stock}
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
vendorStockFilter={filters.vendorStock}
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
wocFilter={filters.woc}
onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))}
customerFilters={filters.customer}
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
top50Ranking={top50Ranking2025}
velocityMap={velocityMap}
buyBoxLostMap={buyBoxLostMap}
experimentMap={experimentMap}
onOpenExperiment={() => setView('experiments')}
/>
</div>
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'ads' ? '' : 'hidden'}>
<AdsPerformance
data={combinedAdsData}
filters={filters}
top50Ranking={top50Ranking2025}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
stockFilter={filters.stock}
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
vendorStockFilter={filters.vendorStock}
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
wocFilter={filters.woc}
onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))}
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
buyBoxLostMap={buyBoxLostMap}
/>
</div>
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'forecast' ? '' : 'hidden'}>
<ForecastView
data={forecastData}
filters={filters}
top50Ranking={top50Ranking2025}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
stockFilter={filters.stock}
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
vendorStockFilter={filters.vendorStock}
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
wocFilter={filters.woc}
onWocFilterChange={(s) => setFilters(prev => ({ ...prev, woc: s }))}
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
buyBoxLostMap={buyBoxLostMap}
/>
</div>
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'vendor' ? '' : 'hidden'}>
<VendorDataView bsrData={filteredBsrData} asinMetadata={globalAsinMetadata} buyBoxLostMap={buyBoxLostMap} combinedSalesData={combinedAdsData} />
</div>
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'experiments' ? '' : 'hidden'}>
<ExperimentsView
salesData={unfilteredCombinedData}
onExperimentsFetch={handleExperimentsFetch}
/>
</div>
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'mkt' ? '' : 'hidden'}>
<MktDataView rawData={filteredData} adsData={mktAdsData} />
</div>
</Suspense>
</div>
</>
)}
</main>
{/* Chat Assistant */}
<AIChat onSendMessage={handleAskGemini} isOpen={isChatOpen} setIsOpen={setIsChatOpen} />
{/* Mobile Bottom Navigation */}
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-slate-950/95 backdrop-blur border-t border-border md:hidden pb-safe">
<div className="grid grid-cols-8 gap-0">
{([
{ key: 'dashboard' as const, icon: <ChartIcon />, label: 'Home' },
{ key: 'table' as const, icon: <TableIcon />, label: 'Grid' },
{ key: 'weekly' as const, icon: <TrendingIcon />, label: 'Weekly' },
{ key: 'ads' as const, icon: <MegaphoneIcon />, label: 'Ads' },
{ key: 'forecast' as const, icon: <ChartIcon />, label: 'Fc 26' },
{ key: 'mkt' as const, icon: <ChartIcon />, label: 'MKT' },
{ key: 'vendor' as const, icon: <ChartIcon />, label: 'BSR' },
{ key: 'experiments' as const, icon: <span className="text-lg">🧪</span>, label: 'Exp' },
]).map(({ key, icon, label }) => (
<button
key={key}
onClick={() => setView(key)}
className={`flex flex-col items-center justify-center py-2 gap-0.5 transition-colors
${view === key ? 'text-primary' : 'text-slate-500 active:text-slate-300'}`}
>
{icon}
<span className="text-[9px] font-bold uppercase tracking-wider">{label}</span>
</button>
))}
</div>
</nav>
{/* 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>
)
}
{/* Experiment modals removed — detail/create are now inline in ExperimentsView */}
</div >
);
};
export default App;