mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 11:45:23 +02:00
feat: implement Forecast 2026 (Fc 26) tab with seasonality logic and actual sales comparison
This commit is contained in:
@@ -5,8 +5,8 @@ import Dashboard from './components/Dashboard';
|
|||||||
import FilterBar from './components/FilterBar';
|
import FilterBar from './components/FilterBar';
|
||||||
import AIChat from './components/AIChat';
|
import AIChat from './components/AIChat';
|
||||||
import CrazeLogo from './components/CrazeLogo';
|
import CrazeLogo from './components/CrazeLogo';
|
||||||
import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord } from './types';
|
import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData } from './types';
|
||||||
import { processCSV, filterData, filterAdsData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, processTrafficExcel, mergeSalesAndAdsData } from './services/dataProcessor';
|
import { processCSV, filterData, filterAdsData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, processTrafficExcel, mergeSalesAndAdsData, processForecastExcel, calculateForecastViewData } from './services/dataProcessor';
|
||||||
import { queryGemini } from './services/geminiService';
|
import { queryGemini } from './services/geminiService';
|
||||||
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons';
|
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons';
|
||||||
import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage';
|
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 WeeklyGrid = lazy(() => import('./components/WeeklyGrid'));
|
||||||
const TopMovers = lazy(() => import('./components/TopMovers'));
|
const TopMovers = lazy(() => import('./components/TopMovers'));
|
||||||
const AdsPerformance = lazy(() => import('./components/AdsPerformance'));
|
const AdsPerformance = lazy(() => import('./components/AdsPerformance'));
|
||||||
|
const ForecastView = lazy(() => import('./components/ForecastView'));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const LoadingSpinner = () => (
|
const LoadingSpinner = () => (
|
||||||
@@ -42,7 +43,8 @@ const App: React.FC = () => {
|
|||||||
const [trafficData, setTrafficData] = useState<TrafficRecord[]>([]);
|
const [trafficData, setTrafficData] = useState<TrafficRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [syncing, setSyncing] = useState(false);
|
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<ProductForecastData[]>([]);
|
||||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||||
const [activeUrl, setActiveUrl] = useState<string | null>(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL);
|
const [activeUrl, setActiveUrl] = useState<string | null>(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL);
|
||||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
||||||
@@ -86,6 +88,9 @@ const App: React.FC = () => {
|
|||||||
setIsDataModalOpen(false);
|
setIsDataModalOpen(false);
|
||||||
|
|
||||||
console.log('[App] Successfully loaded', data.length, 'rows');
|
console.log('[App] Successfully loaded', data.length, 'rows');
|
||||||
|
|
||||||
|
// Refresh forecast too
|
||||||
|
handleForecastFetch(data, globalAsinMetadata);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch/parse CSV", error);
|
console.error("Failed to fetch/parse CSV", error);
|
||||||
alert("Error loading data. Please refresh the page.");
|
alert("Error loading data. Please refresh the page.");
|
||||||
@@ -138,6 +143,22 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleForecastFetch = useCallback(async (sales: SalesRecord[], meta: Map<string, { sku: string; title: string; line: string }>) => {
|
||||||
|
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[]) => {
|
const initializeData = (data: SalesRecord[]) => {
|
||||||
setRawData(data);
|
setRawData(data);
|
||||||
setFilters({
|
setFilters({
|
||||||
@@ -211,6 +232,9 @@ const App: React.FC = () => {
|
|||||||
// 1c. Always fetch Traffic data (no caching for now)
|
// 1c. Always fetch Traffic data (no caching for now)
|
||||||
console.log("Fetching Traffic data...");
|
console.log("Fetching Traffic data...");
|
||||||
handleTrafficFetch();
|
handleTrafficFetch();
|
||||||
|
|
||||||
|
// 1d. Fetch Forecast data
|
||||||
|
handleForecastFetch(cachedData || [], globalAsinMetadata);
|
||||||
};
|
};
|
||||||
initApp();
|
initApp();
|
||||||
}, [handleDataFetch]);
|
}, [handleDataFetch]);
|
||||||
@@ -502,6 +526,13 @@ const App: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<MegaphoneIcon /> <span className="hidden sm:inline">Ads</span>
|
<MegaphoneIcon /> <span className="hidden sm:inline">Ads</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('forecast')}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
|
||||||
|
${view === 'forecast' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
|
||||||
|
>
|
||||||
|
<ChartIcon /> <span className="hidden sm:inline">Fc 26</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -532,6 +563,7 @@ const App: React.FC = () => {
|
|||||||
{view === 'weekly' && <WeeklyGrid data={combinedAdsData} top50Ranking={top50Ranking2025} onDrillDown={handleSkuDrillDown} />}
|
{view === 'weekly' && <WeeklyGrid data={combinedAdsData} top50Ranking={top50Ranking2025} onDrillDown={handleSkuDrillDown} />}
|
||||||
{view === 'movers' && <TopMovers data={filteredData} />}
|
{view === 'movers' && <TopMovers data={filteredData} />}
|
||||||
{view === 'ads' && <AdsPerformance data={combinedAdsData} filters={filters} top50Ranking={top50Ranking2025} />}
|
{view === 'ads' && <AdsPerformance data={combinedAdsData} filters={filters} top50Ranking={top50Ranking2025} />}
|
||||||
|
{view === 'forecast' && <ForecastView data={forecastData} />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ForecastViewProps> = ({ 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 (
|
||||||
|
<div className="p-6 space-y-6 max-w-7xl mx-auto animate-fade-in pb-24">
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="bg-slate-900 border border-border rounded-2xl p-6 shadow-xl relative overflow-hidden group">
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-indigo-500/10 rounded-full -mr-16 -mt-16 blur-3xl group-hover:bg-indigo-500/20 transition-all"></div>
|
||||||
|
<h3 className="text-xs font-black text-slate-500 uppercase tracking-widest mb-4">Total Forecast 2026</h3>
|
||||||
|
<div className="text-4xl font-black text-white">
|
||||||
|
{globalSummary.totalForecast.toLocaleString('de-DE')} <span className="text-sm font-medium text-slate-500">Units</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-border rounded-2xl p-6 shadow-xl relative overflow-hidden group">
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-emerald-500/10 rounded-full -mr-16 -mt-16 blur-3xl group-hover:bg-emerald-500/20 transition-all"></div>
|
||||||
|
<h3 className="text-xs font-black text-slate-500 uppercase tracking-widest mb-4">Total Actual Sales 2026</h3>
|
||||||
|
<div className="text-4xl font-black text-emerald-400">
|
||||||
|
{globalSummary.totalActual.toLocaleString('de-DE')} <span className="text-sm font-medium text-slate-500">Units</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-indigo-500/30 rounded-2xl p-6 shadow-xl relative overflow-hidden group">
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-indigo-500/20 rounded-full -mr-16 -mt-16 blur-3xl"></div>
|
||||||
|
<h3 className="text-xs font-black text-indigo-400 uppercase tracking-widest mb-4">Global Fulfillment</h3>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<div className={`text-4xl font-black ${globalSummary.fulfillment >= 100 ? 'text-emerald-400' : 'text-indigo-400'}`}>
|
||||||
|
{globalSummary.fulfillment.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Fulfillment Progress Bar */}
|
||||||
|
<div className="mt-4 w-full h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-indigo-500 to-fuchsia-500 shadow-[0_0_8px_rgba(99,102,241,0.5)] transition-all duration-1000"
|
||||||
|
style={{ width: `${Math.min(100, globalSummary.fulfillment)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Trend Chart */}
|
||||||
|
<div className="bg-slate-900 border border-border rounded-2xl p-6 shadow-xl">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>
|
||||||
|
Monthly Evolution: Forecast vs Actual
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="h-96">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart data={globalMonthlyData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
|
||||||
|
<XAxis dataKey="name" stroke="#64748b" />
|
||||||
|
<YAxis stroke="#64748b" />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#1e293b', borderRadius: '12px', padding: '12px' }}
|
||||||
|
itemStyle={{ fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
<Legend verticalAlign="top" height={36} />
|
||||||
|
<Bar dataKey="Actual" fill="#10b981" radius={[4, 4, 0, 0]} name="Actual Sales" barSize={40} />
|
||||||
|
<Line type="monotone" dataKey="Forecast" stroke="#6366f1" strokeWidth={4} dot={{ r: 6, fill: '#6366f1' }} name="Forecast Target" />
|
||||||
|
<Area type="monotone" dataKey="Forecast" fill="#6366f1" fillOpacity={0.05} stroke="none" />
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Table */}
|
||||||
|
<div className="bg-slate-900 border border-border rounded-2xl shadow-xl overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-border flex flex-col md:flex-row justify-between gap-4">
|
||||||
|
<h3 className="text-lg font-bold text-white uppercase tracking-tight">Product Performance Comparison</h3>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by ASIN, SKU or Title..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<svg className="absolute left-3 top-2.5 w-4 h-4 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||||
|
<thead className="bg-slate-950 text-slate-500 uppercase text-[10px] font-black tracking-widest border-b border-border">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4">Product Info</th>
|
||||||
|
<th className="px-6 py-4 text-right">Annual Forecast</th>
|
||||||
|
<th className="px-6 py-4 text-right">Actual 2026</th>
|
||||||
|
<th className="px-6 py-4 text-right">Fulfillment %</th>
|
||||||
|
<th className="px-6 py-4">Monthly Status (YTD)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{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 (
|
||||||
|
<tr key={p.asin} className="hover:bg-indigo-500/5 transition-colors group">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-slate-200 group-hover:text-indigo-400 transition-colors">{p.asin}</span>
|
||||||
|
<span className="text-[10px] text-slate-500 truncate max-w-xs">{p.title}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right font-mono font-bold text-slate-400">
|
||||||
|
{p.annualForecast.toLocaleString('de-DE')}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right font-mono font-bold text-emerald-400">
|
||||||
|
{actualTotal.toLocaleString('de-DE')}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-black ${fulfillment >= 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)}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 min-w-[240px]">
|
||||||
|
<div className="flex gap-1 h-3 items-end">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={md.month}
|
||||||
|
className={`flex-1 rounded-t-sm transition-all ${isMet ? 'bg-emerald-500' : md.actualUnits > 0 ? 'bg-indigo-500' : 'bg-slate-800'}`}
|
||||||
|
style={{ height: `${Math.max(10, height)}%` }}
|
||||||
|
title={`${md.month}: ${md.actualUnits} / ${md.forecastUnits}`}
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ForecastView;
|
||||||
BIN
Binary file not shown.
Binary file not shown.
@@ -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 * as XLSX from 'xlsx';
|
||||||
import Papa from 'papaparse';
|
import Papa from 'papaparse';
|
||||||
|
|
||||||
@@ -1496,3 +1496,96 @@ export const pivotWeeklySalesData = (data: CombinedKPIs[]): {
|
|||||||
weeks: sortedWeeks
|
weeks: sortedWeeks
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const processForecastExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<ForecastRecord[]> => {
|
||||||
|
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<string, { sku: string; title: string; line: string }>
|
||||||
|
): 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<string, SalesRecord[]>();
|
||||||
|
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<string, Map<string, number>>();
|
||||||
|
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
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -194,3 +194,22 @@ export interface CombinedKPIs {
|
|||||||
cvrUnits: number;
|
cvrUnits: number;
|
||||||
glanceViews: number; // Traffic / page views
|
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[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user