diff --git a/App.tsx b/App.tsx index baf34a5..7d80d6b 100644 --- a/App.tsx +++ b/App.tsx @@ -6,7 +6,7 @@ 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 { processCSV, filterData, filterAdsData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, processTrafficExcel, mergeSalesAndAdsData, processForecastExcel, calculateForecastViewData, processStockExcel } 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'; @@ -48,6 +48,7 @@ const App: React.FC = () => { const [isChatOpen, setIsChatOpen] = useState(false); const [activeUrl, setActiveUrl] = useState(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL); const [lastUpdated, setLastUpdated] = useState(null); + const [stockMap, setStockMap] = useState>(new Map()); // Modal State const [isDataModalOpen, setIsDataModalOpen] = useState(false); @@ -124,508 +125,528 @@ const App: React.FC = () => { } }, []); - 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); - } + 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'; +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}`); - 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(); - 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); - // 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. 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]); + 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); + } +}, []); - // Derive Data - const globalAsinMetadata = useMemo(() => { - const metaMap = new Map(); - rawData.forEach(r => { +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(); + const metaDataSource = globalSales.length > 0 ? globalSales : sales; + + metaDataSource.forEach(r => { const asin = r.asin.trim().toUpperCase(); - const existing = metaMap.get(asin); - // Keep most complete title + const existing = meta.get(asin); if (!existing || (r.title && r.title.length > (existing.title?.length || 0))) { - metaMap.set(asin, { sku: r.sku, title: r.title, line: r.line }); + meta.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]); + 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); + } +}, []); - // Calculate country-aware Top 50 Best Sellers for 2025 - const top50Ranking2025 = useMemo(() => { - // Filter only 2025 data - const data2025 = rawData.filter(r => r.year === 2025); +// 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 calculateTop50 = (entries: SalesRecord[]) => { - const asinTotals = new Map(); - entries.forEach(r => { - const asin = r.asin.trim().toUpperCase(); - asinTotals.set(asin, (asinTotals.get(asin) || 0) + r.sellOut); - }); +const initializeData = (data: SalesRecord[]) => { + setRawData(data); + setFilters({ + customer: [], + year: [], + month: [], + line: [], + asin: [], + sku: [], + title: [], + week: [], + }); +}; - const sorted = Array.from(asinTotals.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 50); +const handleSkuDrillDown = useCallback((sku: string) => { + if (!sku) return; + setFilters(prev => ({ + ...prev, + sku: [sku] + })); + setView('ads'); +}, []); - const rankMap = new Map(); - sorted.forEach(([asin], index) => { - rankMap.set(asin, index + 1); - }); - return rankMap; - }; +// 1. Initial Load from Cache (IndexedDB) or Auto-Fetch Permanent URL +useEffect(() => { + const initApp = async () => { + setLoading(true); - const euData = data2025.filter(r => !r.customer.toLowerCase().includes('uk')); - const ukData = data2025.filter(r => r.customer.toLowerCase().includes('uk')); + const currentStoredUrl = localStorage.getItem('craze_csv_url'); + let shouldUseCache = true; - 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; + // 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; } - // 1. Identify the Product Lines associated with the currently filtered items - const activeLines = Array.from(new Set(filteredData.map(r => r.line))); + // 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; + } - // 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: [] - }; + 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."); + }); + } - // 3. Process this broader dataset - const broadData = filterData(rawData, contextFilters); - return aggregateData(broadData); + // 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(); + } - }, [rawData, filters, filteredData]); + // 1c. Always fetch Traffic data (no caching for now) + console.log("Fetching Traffic data..."); + handleTrafficFetch(); + // 1d. Always fetch Stock data + console.log("Fetching Stock data..."); + handleStockFetch(); - // 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]); + // 1e. Fetch Forecast data + handleForecastFetch(cachedData || [], cachedData || [], filters); + }; + initApp(); +}, [handleDataFetch]); - const handleFilterChange = (key: keyof FilterState, value: string[]) => { - setFilters(prev => ({ ...prev, [key]: value })); +// 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); + }); + } }; - const handleAskGemini = async (text: string) => { - return await queryGemini(text, aggregatedData, filteredData.length); + // 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(); + 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(); + 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(); + sorted.forEach(([asin], index) => { + rankMap.set(asin, index + 1); + }); + return rankMap; }; - 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([]); + 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: [] }; - return ( -
+ // 3. Process this broader dataset + const broadData = filterData(rawData, contextFilters); + return aggregateData(broadData); - {/* Header */} -
-
-
- {/* Logo Container (Horizontal Box) - Persistent User Image */} -
- -
+}, [rawData, filters, filteredData]); - {/* Title & Status */} -
-

Analytics Dashboard

-
-
- - Live Sync -
- {lastUpdated && ( - - Last: {new Date(lastUpdated).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - - )} + +// 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 ( +
+ + {/* Header */} +
+
+
+ {/* Logo Container (Horizontal Box) - Persistent User Image */} +
+ +
+ + {/* Title & Status */} +
+

Analytics Dashboard

+
+
+ + Live Sync
-
-
- -
- {/* Force Sync Action */} - - {/* View Switcher is the priority now */} - {/* View Switcher */} -
- - - - - - -
-
-
-
- - {/* Main Content */} -
- {loading ? ( - // Initial loading spinner -
-
-

Loading Dashboard...

-

Syncing with Dropbox...

-
- ) : ( - <> - -
- {view === 'dashboard' && ( - + {lastUpdated && ( + + Last: {new Date(lastUpdated).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + )} - }> - {view === 'table' && 0} adsData={filteredAdsData} />} - {view === 'weekly' && } - {view === 'movers' && } - {view === 'ads' && } - {view === 'forecast' && } - -
- - )} -
- - {/* Chat Assistant */} - - - {/* DATA MODAL */} - {isDataModalOpen && ( -
-
-
-

Data Source Settings

- -
- -
-
- )} -
- ); +
+ {/* Force Sync Action */} + + {/* View Switcher is the priority now */} + {/* View Switcher */} +
+ + + + + + +
+
+
+
+ + {/* Main Content */} +
+ {loading ? ( + // Initial loading spinner +
+
+

Loading Dashboard...

+

Syncing with Dropbox...

+
+ ) : ( + <> + +
+ {view === 'dashboard' && } + {view === 'table' && ( + }> + 0} adsData={filteredAdsData} stockMap={stockMap} /> + + )} + {view === 'weekly' && ( + }> + 0 ? mergeSalesAndAdsData(filteredData, filteredAdsData, undefined, trafficData) : filteredData} onDrillDown={handleSkuDrillDown} stockMap={stockMap} /> + + )} + {view === 'movers' && ( + }> + + + )} + {view === 'ads' && ( + }> + + + )} + {view === 'forecast' && ( + }> + + + )} +
+ + )} +
+ + {/* Chat Assistant */} + + + {/* DATA MODAL */} + {isDataModalOpen && ( +
+
+
+

Data Source Settings

+ +
+ +
+ +
+
+
+ )} + +
+); }; export default App; diff --git a/Item Availability.xlsx b/Item Availability.xlsx new file mode 100644 index 0000000..652a997 Binary files /dev/null and b/Item Availability.xlsx differ diff --git a/api/fetch-stock.ts b/api/fetch-stock.ts new file mode 100644 index 0000000..ce0f6a9 --- /dev/null +++ b/api/fetch-stock.ts @@ -0,0 +1,49 @@ + +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import fs from 'fs'; +import path from 'path'; + +export default async function handler(req: VercelRequest, res: VercelResponse) { + // CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + return res.status(200).end(); + } + + try { + console.log('[fetch-stock] Reading Stock file from local storage...'); + // Try multiple possible paths to be robust + const pathsToTry = [ + path.join(process.cwd(), 'Item Availability.xlsx'), + path.join(process.cwd(), 'public', 'Item Availability.xlsx'), + path.join('/Users/christianvidalwolf/github/CrazeAnalytix', 'Item Availability.xlsx') + ]; + + let buffer = null; + let foundPath = ''; + + for (const p of pathsToTry) { + console.log(`[fetch-stock] Checking path: ${p}`); + if (fs.existsSync(p)) { + buffer = fs.readFileSync(p); + foundPath = p; + break; + } + } + + if (!buffer) { + throw new Error(`Stock file 'Item Availability.xlsx' not found in any of: ${pathsToTry.join(', ')}`); + } + + console.log(`[fetch-stock] Successfully read Stock Excel from ${foundPath}, size: ${buffer.byteLength}`); + + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.status(200).send(buffer); + } catch (error: any) { + console.error('[fetch-stock] Error:', error); + res.status(500).json({ error: error.message }); + } +} diff --git a/components/AdsPerformance.tsx b/components/AdsPerformance.tsx index c140bd5..b8a03bb 100644 --- a/components/AdsPerformance.tsx +++ b/components/AdsPerformance.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useState, useCallback } from 'react'; import * as XLSX from 'xlsx'; import { CombinedKPIs, FilterState } from '../types'; import { DownloadIcon } from './Icons'; +import { StockBadge } from './StockBadge'; interface AdsPerformanceProps { data: CombinedKPIs[]; @@ -10,6 +11,7 @@ interface AdsPerformanceProps { eu: Map; uk: Map; }; + stockMap?: Map; } type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits'; @@ -34,7 +36,7 @@ const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'bl ); }; -const AdsPerformance: React.FC = ({ data, filters, top50Ranking }) => { +const AdsPerformance: React.FC = ({ data, filters, top50Ranking, stockMap }) => { const [searchTerm, setSearchTerm] = useState(''); const [showOnlyTop50, setShowOnlyTop50] = useState(false); const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu'); @@ -404,6 +406,9 @@ const AdsPerformance: React.FC = ({ data, filters, top50Ran ))} {row.asin} + {stockMap && ( + + )} SKU: {row.sku} {row.title} diff --git a/components/DataGrid.tsx b/components/DataGrid.tsx index 1d8459d..31fa4df 100644 --- a/components/DataGrid.tsx +++ b/components/DataGrid.tsx @@ -5,11 +5,13 @@ import { import { SalesRecord, PivotRow, AdsRecord, CombinedKPIs } from '../types'; import { pivotSalesData, generateXLSX, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor'; import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons'; +import { StockBadge } from './StockBadge'; interface DataGridProps { data: SalesRecord[] | CombinedKPIs[]; hasCustomerFilter: boolean; adsData?: AdsRecord[]; + stockMap?: Map; } type SortConfig = { @@ -226,7 +228,7 @@ const ExpandableChartCard: React.FC<{ title: string; children: React.ReactNode; }; -const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData = [] }) => { +const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData, stockMap }) => { const [currentPage, setCurrentPage] = useState(1); const [sortConfig, setSortConfig] = useState({ key: null, direction: 'desc' }); const [showChart, setShowChart] = useState(true); @@ -1081,7 +1083,14 @@ const DataGrid: React.FC = ({ data, hasCustomerFilter, adsData = {dim === 'title' ?
{row.title || '-'}
- : (row[dim as keyof PivotRow] as string) || '-' + : (dim === 'sku' || dim === 'asin') + ?
+ {(row[dim as keyof PivotRow] as string) || '-'} + {stockMap && dim === 'sku' && ( + + )} +
+ : (row[dim as keyof PivotRow] as string) || '-' } ))} diff --git a/components/ForecastView.tsx b/components/ForecastView.tsx index fbee0d9..ce94673 100644 --- a/components/ForecastView.tsx +++ b/components/ForecastView.tsx @@ -2,6 +2,8 @@ import React, { useMemo, useState, useEffect, useCallback } from 'react'; import * as XLSX from 'xlsx'; import { ProductForecastData, FilterState } from '../types'; import { DownloadIcon } from './Icons'; +import { StockBadge } from './StockBadge'; +import { StockBadge } from './StockBadge'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, Legend, ComposedChart, Area @@ -13,7 +15,9 @@ interface ForecastViewProps { top50Ranking?: { eu: Map; uk: Map; + stockMap?: Map; }; + stockMap?: Map; } const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; @@ -29,7 +33,7 @@ const Top50Badge: React.FC<{ rank: number; label: string; theme?: 'amber' | 'ind ); }; -const ForecastView: React.FC = ({ data, filters, top50Ranking }) => { +const ForecastView: React.FC = ({ data, filters, top50Ranking, stockMap, stockMap }) => { const [searchTerm, setSearchTerm] = useState(''); const [showOnlyTop50, setShowOnlyTop50] = useState(false); const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu'); diff --git a/components/StockBadge.tsx b/components/StockBadge.tsx new file mode 100644 index 0000000..e74720f --- /dev/null +++ b/components/StockBadge.tsx @@ -0,0 +1,34 @@ + +import React from 'react'; + +interface StockBadgeProps { + stock: number | undefined; +} + +export const StockBadge: React.FC = ({ stock }) => { + if (stock === undefined) return null; + + const isLow = stock < 50; + const isOut = stock <= 0; + + let colorClass = "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"; + let icon = "📦"; + + if (isOut) { + colorClass = "bg-rose-500/20 text-rose-400 border-rose-500/30"; + icon = "❌"; + } else if (isLow) { + colorClass = "bg-amber-500/20 text-amber-400 border-amber-500/30"; + icon = "⚠️"; + } + + return ( +
+ {icon} + {stock.toLocaleString('de-DE')} +
+ ); +}; diff --git a/components/WeeklyGrid.tsx b/components/WeeklyGrid.tsx index 036b4bd..9aa9b84 100644 --- a/components/WeeklyGrid.tsx +++ b/components/WeeklyGrid.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useState, useEffect, useCallback } from 'react'; import * as XLSX from 'xlsx'; import { CombinedKPIs } from '../types'; import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor'; +import { StockBadge } from './StockBadge'; interface WeeklyGridProps { data: CombinedKPIs[]; @@ -10,6 +11,7 @@ interface WeeklyGridProps { uk: Map; }; onDrillDown?: (sku: string) => void; + stockMap?: Map; } type SortConfig = { @@ -51,7 +53,7 @@ const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'bl ); }; -const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown }) => { +const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown, stockMap }) => { // Pivot data - memoized const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]); @@ -515,6 +517,9 @@ const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown {row.sku || '-'} {row.asin} + {stockMap && ( + + )} {row.title} {row.line} diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index 53e1a92..9bbca76 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -1619,3 +1619,40 @@ export const calculateForecastViewData = ( }; }); }; + +export const processStockExcel = async (fileOrBuffer: File | ArrayBuffer): Promise> => { + try { + const arrayBuffer = fileOrBuffer instanceof File + ? await fileOrBuffer.arrayBuffer() + : fileOrBuffer; + const workbook = XLSX.read(arrayBuffer, { type: 'array' }); + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 }); + + const stockMap = new Map(); + + // Skip headers (index 0) + for (let i = 1; i < jsonData.length; i++) { + const row = jsonData[i]; + const rawSku = String(row[0] || '').trim(); + if (!rawSku) continue; + + // Normalize SKU: Remove trailing EN or DE + const normalizedSku = rawSku.replace(/(DE|EN)$/i, ''); + + // User requested Column I which is index 8 (After Assembly Orders GMBH) + const stockValue = Number(row[8] || 0); + + if (!isNaN(stockValue)) { + const current = stockMap.get(normalizedSku) || 0; + stockMap.set(normalizedSku, current + stockValue); + } + } + + return stockMap; + } catch (error) { + console.error("Error processing Stock Excel:", error); + throw error; + } +};