diff --git a/App.tsx b/App.tsx index bbe9711..d658029 100644 --- a/App.tsx +++ b/App.tsx @@ -5,8 +5,8 @@ import Dashboard from './components/Dashboard'; import FilterBar from './components/FilterBar'; import AIChat from './components/AIChat'; import CrazeLogo from './components/CrazeLogo'; -import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord } from './types'; -import { processCSV, filterData, filterAdsData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, processTrafficExcel, mergeSalesAndAdsData } from './services/dataProcessor'; +import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData } from './types'; +import { processCSV, filterData, filterAdsData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, processTrafficExcel, mergeSalesAndAdsData, processForecastExcel, calculateForecastViewData } from './services/dataProcessor'; import { queryGemini } from './services/geminiService'; import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons'; import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage'; @@ -16,6 +16,7 @@ const DataGrid = lazy(() => import('./components/DataGrid')); const WeeklyGrid = lazy(() => import('./components/WeeklyGrid')); const TopMovers = lazy(() => import('./components/TopMovers')); const AdsPerformance = lazy(() => import('./components/AdsPerformance')); +const ForecastView = lazy(() => import('./components/ForecastView')); // Loading fallback component const LoadingSpinner = () => ( @@ -42,7 +43,8 @@ const App: React.FC = () => { const [trafficData, setTrafficData] = useState([]); const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); - const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'movers' | 'ads'>('dashboard'); // Added 'weekly' view + const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'movers' | 'ads' | 'forecast'>('dashboard'); // Added 'forecast' view + const [forecastData, setForecastData] = useState([]); const [isChatOpen, setIsChatOpen] = useState(false); const [activeUrl, setActiveUrl] = useState(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL); const [lastUpdated, setLastUpdated] = useState(null); @@ -86,6 +88,9 @@ const App: React.FC = () => { setIsDataModalOpen(false); console.log('[App] Successfully loaded', data.length, 'rows'); + + // Refresh forecast too + handleForecastFetch(data, globalAsinMetadata); } catch (error) { console.error("Failed to fetch/parse CSV", error); alert("Error loading data. Please refresh the page."); @@ -138,6 +143,22 @@ const App: React.FC = () => { } }, []); + const handleForecastFetch = useCallback(async (sales: SalesRecord[], meta: Map) => { + try { + console.log('[App] Fetching forecast from /forecast.xlsx...'); + const response = await fetch('/forecast.xlsx'); + if (!response.ok) throw new Error("Forecast file not found"); + + const buffer = await response.arrayBuffer(); + const fcRecords = await processForecastExcel(buffer); + const viewData = calculateForecastViewData(sales, fcRecords, meta); + setForecastData(viewData); + console.log('[App] Forecast loaded:', viewData.length, 'records'); + } catch (error) { + console.warn("Forecast fetch failed (expected if local file not set up yet):", error); + } + }, []); + const initializeData = (data: SalesRecord[]) => { setRawData(data); setFilters({ @@ -211,6 +232,9 @@ const App: React.FC = () => { // 1c. Always fetch Traffic data (no caching for now) console.log("Fetching Traffic data..."); handleTrafficFetch(); + + // 1d. Fetch Forecast data + handleForecastFetch(cachedData || [], globalAsinMetadata); }; initApp(); }, [handleDataFetch]); @@ -502,6 +526,13 @@ const App: React.FC = () => { > Ads + @@ -532,6 +563,7 @@ const App: React.FC = () => { {view === 'weekly' && } {view === 'movers' && } {view === 'ads' && } + {view === 'forecast' && } diff --git a/api/fetch-forecast.ts b/api/fetch-forecast.ts new file mode 100644 index 0000000..ca60504 --- /dev/null +++ b/api/fetch-forecast.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-forecast] Reading Forecast file from local storage...'); + // Try multiple possible paths to be robust + const pathsToTry = [ + path.join(process.cwd(), 'fc 26.xlsx'), + path.join(process.cwd(), 'public', 'fc 26.xlsx'), + path.join('/Users/christianvidalwolf/github/CrazeAnalytix', 'fc 26.xlsx') // Direct path as fallback for this environment + ]; + + let buffer = null; + let foundPath = ''; + + for (const p of pathsToTry) { + console.log(`[fetch-forecast] Checking path: ${p}`); + if (fs.existsSync(p)) { + buffer = fs.readFileSync(p); + foundPath = p; + break; + } + } + + if (!buffer) { + throw new Error(`Forecast file 'fc 26.xlsx' not found in any of: ${pathsToTry.join(', ')}`); + } + + console.log(`[fetch-forecast] Successfully read Forecast 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-forecast] Error:', error); + res.status(500).json({ error: error.message }); + } +} diff --git a/components/ForecastView.tsx b/components/ForecastView.tsx new file mode 100644 index 0000000..3dadaa5 --- /dev/null +++ b/components/ForecastView.tsx @@ -0,0 +1,205 @@ + +import React, { useMemo, useState } from 'react'; +import { ProductForecastData } from '../types'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + LineChart, Line, Legend, ComposedChart, Area +} from 'recharts'; + +interface ForecastViewProps { + data: ProductForecastData[]; +} + +const COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#0ea5e9']; + +const ForecastView: React.FC = ({ data }) => { + const [searchTerm, setSearchTerm] = useState(''); + + // Global Aggregate Data + const globalMonthlyData = useMemo(() => { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return months.map(m => { + let forecast = 0; + let actual = 0; + data.forEach(p => { + const monthPoint = p.monthlyData.find(md => md.month === m); + if (monthPoint) { + forecast += monthPoint.forecastUnits; + actual += monthPoint.actualUnits; + } + }); + return { name: m, Forecast: forecast, Actual: actual }; + }); + }, [data]); + + const globalSummary = useMemo(() => { + let totalForecast = 0; + let totalActual = 0; + data.forEach(p => { + totalForecast += p.annualForecast; + p.monthlyData.forEach(md => { + totalActual += md.actualUnits; + }); + }); + const fulfillment = totalForecast > 0 ? (totalActual / totalForecast) * 100 : 0; + return { totalForecast, totalActual, fulfillment }; + }, [data]); + + const filteredProducts = useMemo(() => { + if (!searchTerm) return data; + const s = searchTerm.toLowerCase(); + return data.filter(p => + p.asin.toLowerCase().includes(s) || + p.sku.toLowerCase().includes(s) || + p.title.toLowerCase().includes(s) + ); + }, [data, searchTerm]); + + const sortedProducts = useMemo(() => { + return [...filteredProducts].sort((a, b) => b.annualForecast - a.annualForecast); + }, [filteredProducts]); + + return ( +
+ + {/* Summary Cards */} +
+
+
+

Total Forecast 2026

+
+ {globalSummary.totalForecast.toLocaleString('de-DE')} Units +
+
+ +
+
+

Total Actual Sales 2026

+
+ {globalSummary.totalActual.toLocaleString('de-DE')} Units +
+
+ +
+
+

Global Fulfillment

+
+
= 100 ? 'text-emerald-400' : 'text-indigo-400'}`}> + {globalSummary.fulfillment.toFixed(1)}% +
+
+ {/* Fulfillment Progress Bar */} +
+
+
+
+
+ + {/* Main Trend Chart */} +
+
+

+ + Monthly Evolution: Forecast vs Actual +

+
+
+ + + + + + + + + + + + +
+
+ + {/* Product Table */} +
+
+

Product Performance Comparison

+
+ setSearchTerm(e.target.value)} + className="bg-slate-950 border border-slate-700 rounded-lg px-10 py-2 text-sm text-slate-200 focus:outline-none focus:border-indigo-500 w-full md:w-80" + /> + +
+
+
+ + + + + + + + + + + + {sortedProducts.map((p) => { + const actualTotal = p.monthlyData.reduce((acc, md) => acc + md.actualUnits, 0); + const fulfillment = p.annualForecast > 0 ? (actualTotal / p.annualForecast) * 100 : 0; + + return ( + + + + + + + + ); + })} + +
Product InfoAnnual ForecastActual 2026Fulfillment %Monthly Status (YTD)
+
+ {p.asin} + {p.title} +
+
+ {p.annualForecast.toLocaleString('de-DE')} + + {actualTotal.toLocaleString('de-DE')} + + = 50 ? 'bg-emerald-500/10 text-emerald-400' : fulfillment >= 20 ? 'bg-indigo-500/10 text-indigo-400' : 'bg-slate-800 text-slate-500'}`}> + {fulfillment.toFixed(1)}% + + +
+ {p.monthlyData.map((md, idx) => { + const isMet = md.actualUnits >= md.forecastUnits && md.forecastUnits > 0; + const height = md.forecastUnits > 0 ? (Math.min(1.5, md.actualUnits / md.forecastUnits) * 100) : 0; + return ( +
0 ? 'bg-indigo-500' : 'bg-slate-800'}`} + style={{ height: `${Math.max(10, height)}%` }} + title={`${md.month}: ${md.actualUnits} / ${md.forecastUnits}`} + >
+ ); + })} +
+
+
+
+
+ ); +}; + +export default ForecastView; diff --git a/fc 26.xlsx b/fc 26.xlsx new file mode 100644 index 0000000..3e71333 Binary files /dev/null and b/fc 26.xlsx differ diff --git a/public/forecast.xlsx b/public/forecast.xlsx new file mode 100644 index 0000000..3e71333 Binary files /dev/null and b/public/forecast.xlsx differ diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index 7bbf088..357420d 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -1,4 +1,4 @@ -import { SalesRecord, AdsRecord, TrafficRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types'; +import { SalesRecord, AdsRecord, TrafficRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint, ForecastRecord, MonthlyForecastPoint, ProductForecastData } from '../types'; import * as XLSX from 'xlsx'; import Papa from 'papaparse'; @@ -1496,3 +1496,96 @@ export const pivotWeeklySalesData = (data: CombinedKPIs[]): { weeks: sortedWeeks }; }; + +export const processForecastExcel = 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, { defval: "" }); + + return jsonData.map(row => ({ + asin: String(row['ASIN'] || row['asin'] || '').trim().toUpperCase(), + annualForecast: parseUnits(String(row['Forecast 2026'] || row['forecast 2026'] || '0')) + })).filter(r => r.asin && r.annualForecast > 0); + } catch (error) { + console.error("Error processing Forecast Excel:", error); + throw error; + } +}; + +export const calculateForecastViewData = ( + rawData: SalesRecord[], + forecastData: ForecastRecord[], + asinMetadata: Map +): ProductForecastData[] => { + const data2025 = rawData.filter(r => r.year === 2025); + const data2026 = rawData.filter(r => r.year === 2026); + + // Calculate Global Seasonality weights for 2025 + const getWeights = (records: SalesRecord[]) => { + const weights = new Array(12).fill(0); + let total = 0; + records.forEach(r => { + const m = r.month.split('-')[0]; + const idx = MONTH_ORDER.indexOf(m); + if (idx !== -1) { + weights[idx] += r.units; + total += r.units; + } + }); + if (total === 0) return new Array(12).fill(1 / 12); + return weights.map(w => w / total); + }; + + const globalWeights = getWeights(data2025); + + // Map 2025 data by ASIN for quick access + const dataByAsin2025 = new Map(); + data2025.forEach(r => { + const key = r.asin.trim().toUpperCase(); + if (!dataByAsin2025.has(key)) dataByAsin2025.set(key, []); + dataByAsin2025.get(key)!.push(r); + }); + + // Map 2026 actual sales by ASIN and Month + const actuals2026 = new Map>(); + data2026.forEach(r => { + const key = r.asin.trim().toUpperCase(); + const m = r.month.split('-')[0]; + if (!actuals2026.has(key)) actuals2026.set(key, new Map()); + const monthMap = actuals2026.get(key)!; + monthMap.set(m, (monthMap.get(m) || 0) + r.units); + }); + + return forecastData.map(fc => { + const identifier = fc.asin.toUpperCase(); + const meta = asinMetadata.get(identifier); + + // 1. Determine weights (Product specific or global backup) + const productRecords2025 = dataByAsin2025.get(identifier) || []; + const weights = productRecords2025.length > 0 ? getWeights(productRecords2025) : globalWeights; + + // 2. Build monthly points + const monthlyData: MonthlyForecastPoint[] = MONTH_ORDER.map((m, idx) => { + const forecastUnits = Math.round(fc.annualForecast * weights[idx]); + const actualUnits = actuals2026.get(identifier)?.get(m) || 0; + return { + month: m, + forecastUnits, + actualUnits + }; + }); + + return { + asin: identifier, + sku: meta?.sku || identifier, // Fallback to ASIN if SKU not found + title: meta?.title || identifier, + annualForecast: fc.annualForecast, + monthlyData + }; + }); +}; diff --git a/types.ts b/types.ts index 84e9a8b..850b181 100644 --- a/types.ts +++ b/types.ts @@ -194,3 +194,22 @@ export interface CombinedKPIs { cvrUnits: number; glanceViews: number; // Traffic / page views } + +export interface ForecastRecord { + asin: string; + annualForecast: number; +} + +export interface MonthlyForecastPoint { + month: string; + forecastUnits: number; + actualUnits: number; +} + +export interface ProductForecastData { + asin: string; + sku: string; + title: string; + annualForecast: number; + monthlyData: MonthlyForecastPoint[]; +}