Files
CrazeAnalytix/App.tsx
T

993 lines
42 KiB
TypeScript
Raw Normal View History

2025-12-11 14:03:33 +01:00
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, isAllowedCustomer, isRealSale } from './services/dataProcessor';
import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData, 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';
2026-01-22 11:55:23 +01:00
// Lazy load heavy components for better initial performance
const DataGrid = lazy(() => import('./components/DataGrid'));
const WeeklyGrid = lazy(() => import('./components/WeeklyGrid'));
2026-01-22 13:25:06 +01:00
const AdsPerformance = lazy(() => import('./components/AdsPerformance'));
const ForecastView = lazy(() => import('./components/ForecastView'));
const VendorDataView = lazy(() => import('./components/VendorDataView'));
2026-01-22 11:55:23 +01:00
// 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 = () => {
console.log('[App] Rendering Function Start');
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'>('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());
2026-03-02 15:22:12 +01:00
const [bsrData, setBsrData] = useState<BSRRecord[]>([]);
// Modal State
const [isDataModalOpen, setIsDataModalOpen] = useState(false);
const [showFilters, setShowFilters] = useState(true);
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 {
2026-02-02 09:24:49 +01:00
console.log('[App] Fetching vendor stock (PANEU + UK) from Dropbox...');
2026-02-02 09:24:49 +01:00
// 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) {
2026-02-02 09:24:49 +01:00
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);
}
}, []);
2026-03-02 15:22:12 +01:00
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 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');
}, []);
// 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(),
2026-03-02 15:22:12 +01:00
handleBSRFetch()
]).catch(e => console.warn("Background fetch failed", e));
2026-02-20 19:31:44 +01:00
// 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]);
console.log('[App] Render Start. RawData count:', rawData.length, 'AdsData count:', adsData.length);
// 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();
if (!asin) return;
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]);
console.log('[App] Metadata ready');
console.time('filteredData');
const filteredData = useMemo(() => {
const baseFiltered = filterData(rawData, deferredFilters, stockMap, vendorStockMap, top50Mode);
// DEFINITIVE REVENUE FILTER (BRUTE FORCE):
// This is the absolute final safeguard against revenue inflation.
// It enforces the strict isRealSale and isAllowedCustomer rules at the UI layer.
return baseFiltered.filter(r => {
return isAllowedCustomer(r.customer) && isRealSale(r);
});
}, [rawData, deferredFilters, stockMap, vendorStockMap, top50Mode]);
console.timeEnd('filteredData');
console.time('filteredAdsData');
const filteredAdsData = useMemo(() => filterAdsData(adsData, deferredFilters, globalAsinMetadata, stockMap, vendorStockMap, top50Mode), [adsData, deferredFilters, globalAsinMetadata, stockMap, vendorStockMap, top50Mode]);
console.timeEnd('filteredAdsData');
console.time('aggregatedData');
const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]);
console.timeEnd('aggregatedData');
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]);
const filteredBsrData = useMemo(() => filterBsrData(bsrData, deferredFilters, globalAsinMetadata, stockMap, vendorStockMap, top50Mode), [bsrData, deferredFilters, globalAsinMetadata, stockMap, vendorStockMap, top50Mode]);
console.time('combinedAdsData');
const combinedAdsData = useMemo(() => {
return mergeSalesAndAdsData(filteredData, filteredAdsData, globalAsinMetadata, trafficData, velocityMap, filteredBsrData);
}, [filteredData, filteredAdsData, globalAsinMetadata, trafficData, velocityMap, filteredBsrData]);
console.timeEnd('combinedAdsData');
// Defer or remove unfilteredCombinedData to save memory on initialization
const unfilteredCombinedData = null;
// 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]);
2026-01-29 20:31:44 +01:00
// 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.
2026-01-29 20:31:44 +01:00
const ytdFilteredData = useMemo(() => {
const isTimeFilterActive = deferredFilters.month.length > 0 || deferredFilters.week.length > 0;
if (isTimeFilterActive) return filteredData;
2026-01-29 20:31:44 +01:00
return filteredData.filter(r => r.week <= gridContext.maxWeek);
}, [filteredData, gridContext.maxWeek, deferredFilters.month, deferredFilters.week]);
2026-01-29 20:31:44 +01:00
const ytdFilteredAdsData = useMemo(() => {
const isTimeFilterActive = deferredFilters.month.length > 0 || deferredFilters.week.length > 0;
if (isTimeFilterActive) return filteredAdsData;
2026-01-29 20:31:44 +01:00
return filteredAdsData.filter(r => r.week <= gridContext.maxWeek);
}, [filteredAdsData, gridContext.maxWeek, deferredFilters.month, deferredFilters.week]);
2026-01-29 20:31:44 +01:00
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([]);
};
console.log('[App] Render Finish');
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-3 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-6">
{/* Logo Container - CraftAlley branding */}
<div className="flex items-center gap-2 relative flex-shrink-0 cursor-pointer" onClick={() => setView('dashboard')}>
<div className="bg-cyan-500 rounded-lg p-1.5 flex items-center justify-center w-8 h-8 shadow-[0_0_8px_rgba(6,182,212,0.4)]">
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9s2.015-9 4.5-9y" />
</svg>
</div>
<span className="font-extrabold text-slate-100 text-base tracking-tight select-none">CraftAlley</span>
</div>
{/* View Switcher */}
<div className="hidden md:flex bg-slate-900 rounded-lg p-0.5 border border-border/80 ml-6">
<button
onClick={() => setView('dashboard')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold transition-all
${view === 'dashboard' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white'}`}
>
Dashboard
</button>
<button
onClick={() => setView('table')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold transition-all
${view === 'table' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white'}`}
>
Grid
</button>
<button
onClick={() => setView('weekly')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold transition-all
${view === 'weekly' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white'}`}
>
Weekly Sales
</button>
<button
onClick={() => setView('ads')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold transition-all
${view === 'ads' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white'}`}
>
Ads
</button>
<button
onClick={() => setView('forecast')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold transition-all
${view === 'forecast' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white'}`}
>
Forecasting
</button>
<button
onClick={() => setView('vendor')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold transition-all
${view === 'vendor' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white'}`}
>
SSR
</button>
</div>
</div>
<div className="flex items-center gap-3 md:gap-4">
{/* Sync Indicators */}
<div className="flex items-center gap-1.5 text-xs text-slate-400 bg-slate-900 px-2.5 py-1 rounded border border-border/80">
<span className={`w-1.5 h-1.5 rounded-full bg-emerald-400 ${syncing ? 'animate-ping' : ''}`}></span>
<span className="font-mono text-[10px] uppercase font-bold tracking-wider">
Sync {lastUpdated ? new Date(lastUpdated).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '11:41'}
</span>
</div>
{/* Toggle Filters Action */}
<button
onClick={() => setShowFilters(prev => !prev)}
className="flex items-center gap-1 px-2.5 py-1 bg-slate-900 hover:bg-slate-800 border border-slate-800 rounded text-xs font-semibold text-slate-300 transition-all active:scale-95"
>
<span>{showFilters ? 'Ocultar' : '+ Filtrar'}</span>
</button>
{/* Export Action */}
<button
onClick={() => {
// simple mockup export alert
alert("Exportando datos filtrados...");
}}
className="px-3.5 py-1 bg-cyan-600 hover:bg-cyan-500 rounded text-xs font-bold text-white transition-all active:scale-95 shadow-[0_0_8px_rgba(6,182,212,0.2)]"
>
Exportar
</button>
</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>
) : (
<>
{/* Title & Subtitle inside main body above filters */}
<div className="max-w-7xl mx-auto px-3 md:px-6 pt-6 pb-2">
<h2 className="text-2xl font-extrabold text-white tracking-tight uppercase">Analytics Dashboard</h2>
<p className="text-[11px] text-slate-500 font-medium mt-1">
Actualizado {lastUpdated ? new Date(lastUpdated).toLocaleDateString('es-ES', { day: 'numeric', month: 'short', year: 'numeric' }) : '17 jun 2026'} · Semana {(() => {
// dynamic or default week number
return '25';
})()} · {rawData.length.toLocaleString('de-DE')} registros
</p>
</div>
{showFilters && <FilterBar filters={filters} onFilterChange={handleFilterChange} options={filterOptions} />}
<div className="mt-4">
<div className={view === 'dashboard' ? '' : 'hidden'}>
<Dashboard
data={ytdAggregatedData}
filterState={filters}
adsData={ytdFilteredAdsData}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
buyBoxLostMap={buyBoxLostMap}
top50Mode={top50Mode}
rawData={ytdFilteredData}
velocityMap={velocityMap}
top50Ranking={top50Ranking2025}
/>
</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}
/>
</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}
velocityMap={velocityMap}
/>
</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}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
velocityMap={velocityMap}
top50Ranking={top50Ranking2025}
/>
</div>
</Suspense>
2026-02-20 19:31:44 +01:00
</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-6 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' },
2026-03-10 13:36:21 +01:00
{ key: 'vendor' as const, icon: <ChartIcon />, label: 'BSR' },
]).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>
)
}
</div >
);
};
2025-12-11 14:03:33 +01:00
export default App;