From 6e2db6be1ac4f2981b339f3b11917f075091a258 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Wed, 17 Jun 2026 16:03:20 +0200 Subject: [PATCH] feat: replace Forecast tab with Sell-in vs Budget 2026 dashboard Self-contained view with file upload for Sell in + Budget 26 Excel files. Parses SKUs (5-digit prefix), compares real sell-in units vs monthly budget forecast, shows KPIs and per-SKU table sorted by period achievement ascending. Co-Authored-By: Claude Sonnet 4.6 --- App.tsx | 16 +- components/ForecastView.tsx | 1092 ++++++++++++++--------------------- 2 files changed, 428 insertions(+), 680 deletions(-) diff --git a/App.tsx b/App.tsx index f7b01a1..1af60f5 100644 --- a/App.tsx +++ b/App.tsx @@ -898,21 +898,7 @@ const App: React.FC = () => { }>
- 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} - /> +
diff --git a/components/ForecastView.tsx b/components/ForecastView.tsx index cfd629e..6b5b4f0 100644 --- a/components/ForecastView.tsx +++ b/components/ForecastView.tsx @@ -1,694 +1,456 @@ -import React, { useMemo, useState, useEffect, useCallback } from 'react'; +import React, { useState, useMemo, useCallback, useRef } from 'react'; import * as XLSX from 'xlsx'; -import { ProductForecastData, FilterState, CombinedKPIs } from '../types'; -import { DownloadIcon, FunnelIcon, TrendingIcon, ChartIcon } from './Icons'; -import { StockBadge } from './StockBadge'; -import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons'; -import { Top50Badge } from './Top50Badge'; -import { VendorStockBadge } from './VendorStockBadge'; -import { BuyBoxWarningBadge } from './BuyBoxWarningBadge'; -import { InColumnStockFilter } from './InColumnStockFilter'; -import { PAN_EU_COUNTRIES } from '../services/dataProcessor'; -import { - BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, - LineChart, Line, Legend, ComposedChart, Area -} from 'recharts'; -import { ExcelFilter } from './ExcelFilter'; -import { ColumnFilterCondition } from '../types'; -interface ForecastViewProps { - data: ProductForecastData[]; - filters: FilterState; - top50Ranking: { eu: Map; uk: Map }; - stockMap: Map; - top50Mode: 'eu' | 'uk'; - vendorStockMap?: Map; - stockFilter: string[]; - onStockFilterChange: (newFilters: string[]) => void; - vendorStockFilter: string[]; - onVendorStockFilterChange: (newFilters: string[]) => void; - wocFilter: string[]; - onWocFilterChange: (newFilters: string[]) => void; - buyBoxLostMap?: Map }>; - velocityMap?: Map; +const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']; +const MONTHS_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + +// Column indices of "Budget Units" in Budget 26 file (one per month, every 8 columns starting at 8) +const BUDGET_UNITS_COLS = [8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96]; + +interface SellInRow { + sku: string; + description: string; + actualUnits: number; + sellInBudget: number; } -const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; +interface BudgetRow { + sku: string; + description: string; + monthlyBudget: number[]; // 12 values Jan–Dec +} -const ForecastRow: React.FC<{ - item: ProductForecastData; - activeMonths: string[]; - top50Ranking?: { eu: Map; uk: Map }; - top50Mode: 'eu' | 'uk'; - stockMap?: Map; - vendorStockMap?: Map; - buyBoxLostMap?: Map }>; - velocityMap?: Map; -}> = React.memo(({ item, activeMonths, top50Ranking, top50Mode, stockMap, vendorStockMap, buyBoxLostMap, velocityMap }) => { - const asin = item.asin.trim().toUpperCase(); - const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = []; +interface ComparisonRow { + sku: string; + description: string; + actualUnits: number; + periodForecast: number; + budgetYearTotal: number; + budgetDiscrepancy: boolean; + pctPeriod: number; + pctAnnual: number; +} - if (top50Ranking) { - if (top50Mode === 'eu') { - const rankEU = top50Ranking.eu.get(asin); - if (rankEU) ranks.push({ rank: rankEU, label: 'EU', theme: 'indigo' }); - } else { - const rankUK = top50Ranking.uk.get(asin); - if (rankUK) ranks.push({ rank: rankUK, label: 'UK', theme: 'blue' }); - } - } +function parseSellIn(buffer: ArrayBuffer): Map { + const wb = XLSX.read(buffer, { type: 'array' }); + const ws = wb.Sheets['Export']; + if (!ws) throw new Error('Hoja "Export" no encontrada en fichero Sell in'); + const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }); + const result = new Map(); + for (let i = 1; i < rows.length; i++) { + const row = rows[i]; + const rawArticle = String(row[0] ?? '').trim(); + if (!rawArticle) continue; + const sku = rawArticle.slice(0, 5); + if (!/^\d{5}$/.test(sku)) continue; + result.set(sku, { + sku, + description: rawArticle.slice(6).trim(), + actualUnits: Number(row[8]) || 0, + sellInBudget: Number(row[10]) || 0, + }); + } + return result; +} - // Calculations for new columns - // Forecast Period: Sum of forecast units for active months (YTD) - const forecastPeriod = useMemo(() => { - return activeMonths.reduce((sum, month) => { - return sum + (item.monthlyData?.[month]?.forecastUnits || 0); - }, 0); - }, [item, activeMonths]); +function parseBudget(buffer: ArrayBuffer): Map { + const wb = XLSX.read(buffer, { type: 'array' }); + const ws = wb.Sheets['Export']; + if (!ws) throw new Error('Hoja "Export" no encontrada en fichero Budget'); + const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }); + const result = new Map(); + // Row 0 = months, Row 1 = sub-fields, data from row 2 + for (let i = 2; i < rows.length; i++) { + const row = rows[i]; + const rawArticle = String(row[0] ?? '').trim(); + if (!rawArticle) continue; + const sku = rawArticle.slice(0, 5); + if (!/^\d{5}$/.test(sku)) continue; + result.set(sku, { + sku, + description: rawArticle.slice(6).trim(), + monthlyBudget: BUDGET_UNITS_COLS.map(c => Number(row[c]) || 0), + }); + } + return result; +} - const actualUnits = item.actualUnits || 0; - const annualForecast = item.annualForecast || 0; +const getPctColor = (pct: number) => { + if (pct <= 0) return 'text-slate-500'; + if (pct >= 100) return 'text-emerald-400'; + if (pct >= 80) return 'text-amber-400'; + return 'text-rose-400'; +}; - // FC 26 Achievement: Actual / Annual Forecast - const fcAchievement = annualForecast > 0 ? (actualUnits / annualForecast) * 100 : 0; +const getPctBg = (pct: number) => { + if (pct <= 0) return 'bg-slate-700'; + if (pct >= 100) return 'bg-emerald-500'; + if (pct >= 80) return 'bg-amber-400'; + return 'bg-rose-500'; +}; - // YTD Achievement: Actual / Period Forecast - // If activeMonths is basically "Year To Date" (which it usually is in this view unless filtered), this logic holds. - // If user selects specific months, it becomes "Period Achievement". - const ytdAchievement = forecastPeriod > 0 ? (actualUnits / forecastPeriod) * 100 : 0; +// ── Sub-components ────────────────────────────────────────────────────────── - let achievementColor = 'bg-slate-700'; - if (ytdAchievement >= 100) achievementColor = 'bg-emerald-500'; - else if (ytdAchievement >= 80) achievementColor = 'bg-emerald-400'; - else if (ytdAchievement >= 50) achievementColor = 'bg-amber-400'; - else achievementColor = 'bg-rose-500'; +const DropZone: React.FC<{ + label: string; + subtitle: string; + loaded: boolean; + fileName: string; + count?: number; + onDrop: (f: File) => void; + onClick: () => void; +}> = ({ label, subtitle, loaded, fileName, count, onDrop, onClick }) => ( +
e.preventDefault()} + onDrop={e => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) onDrop(f); }} + > +
+ {loaded ? '✓' : '📊'} +
+
+
{label}
+
{subtitle}
+ {loaded + ?
{fileName} — {count} SKUs cargados
+ :
Clic o arrastra aquí
+ } +
+
+); - return ( - - {/* Product Info */} - -
-
- {ranks.map((r, i) => ( - - ))} - - {item.sku} - -
- {item.asin} -
-
- - {item.title} - -
- - {item.line} - - {/* Own Stock Indicator */} - {stockMap && ( - - )} - {/* WOC Indicator preserved */} - - -
-
- - {/* Annual Forecast */} - - - {(item.annualForecast || 0).toLocaleString('de-DE')} - - - - {/* Forecast (Period) */} - - - {forecastPeriod.toLocaleString('de-DE')} - - - - {/* Actual Units Sales 2026 */} - - - {actualUnits.toLocaleString('de-DE')} - - - - {/* FC 26 Achievement (Annual %) */} - -
-
- {fcAchievement.toFixed(1)}% -
- - OF ANNUAL {Math.round(annualForecast / 1000)}K - -
- - - {/* YTD Achievement (Period %) */} - -
-
- = 100 ? 'text-emerald-400' : 'text-slate-300'}`}> - {ytdAchievement.toFixed(1)}% - -
-
-
-
-
- - - ); +const ComparisonTableRow: React.FC<{ row: ComparisonRow }> = React.memo(({ row }) => { + const barWidth = Math.min(100, row.pctPeriod); + return ( + + + {row.sku} + + + + {row.description} + + + + + {row.actualUnits.toLocaleString('de-DE')} + + + + + {row.periodForecast.toLocaleString('de-DE')} + + + +
+ + {row.budgetYearTotal.toLocaleString('de-DE')} + + {row.budgetDiscrepancy && ( + ⚠ discrepancia + )} +
+ + +
+ + {row.periodForecast > 0 ? `${row.pctPeriod.toFixed(1)}%` : '—'} + + {row.periodForecast > 0 && ( +
+
+
+ )} +
+ + + + {row.budgetYearTotal > 0 ? `${row.pctAnnual.toFixed(1)}%` : '—'} + + + + ); }); +// ── Main Component ─────────────────────────────────────────────────────────── -const ForecastView: React.FC = ({ - data, - filters, - top50Ranking, - stockMap, - vendorStockMap, - stockFilter, - onStockFilterChange, - vendorStockFilter, - onVendorStockFilterChange, - wocFilter, - onWocFilterChange, - top50Mode, - buyBoxLostMap, - velocityMap -}) => { - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); +const ForecastView: React.FC = () => { + const [sellInData, setSellInData] = useState | null>(null); + const [budgetData, setBudgetData] = useState | null>(null); + const [sellInFileName, setSellInFileName] = useState(''); + const [budgetFileName, setBudgetFileName] = useState(''); + // Default cutoff: May (index 4) — adjust as needed + const [cutoffMonth, setCutoffMonth] = useState(4); + const [sortAsc, setSortAsc] = useState(true); + const [error, setError] = useState(null); - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearch(searchTerm.toLowerCase()); - }, 300); - return () => clearTimeout(timer); - }, [searchTerm]); + const sellInInputRef = useRef(null); + const budgetInputRef = useRef(null); - const [showOnlyTop50, setShowOnlyTop50] = useState(false); - const [displayCount, setDisplayCount] = useState(50); - const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' }>({ key: 'actualUnits', direction: 'desc' }); - - // State for Column Filters (SKU, ASIN, Title, Line) - const [columnFilters, setColumnFilters] = useState>({}); - - const handleColumnFilterChange = (columnKey: string, condition: ColumnFilterCondition | undefined) => { - setColumnFilters(prev => { - const next = { ...prev }; - if (condition) next[columnKey] = condition; - else delete next[columnKey]; - return next; - }); + const loadFile = useCallback((file: File, type: 'sellin' | 'budget') => { + setError(null); + const reader = new FileReader(); + reader.onload = (e) => { + try { + const buf = e.target!.result as ArrayBuffer; + if (type === 'sellin') { + const data = parseSellIn(buf); + setSellInData(data); + setSellInFileName(file.name); + } else { + const data = parseBudget(buf); + setBudgetData(data); + setBudgetFileName(file.name); + } + } catch (err: any) { + setError(`Error en ${type === 'sellin' ? 'Sell in' : 'Budget'}: ${err.message}`); + } }; + reader.readAsArrayBuffer(file); + }, []); - const activeMonths = useMemo(() => { - if (filters.month && filters.month.length > 0) { - return filters.month.map(m => m.split('-')[0]); - } - const currentMonthIdx = new Date().getMonth(); - return MONTH_ORDER.slice(0, currentMonthIdx + 1); - }, [filters.month]); + const comparisonRows = useMemo((): ComparisonRow[] => { + if (!budgetData) return []; + const rows: ComparisonRow[] = []; + budgetData.forEach((budgetRow) => { + const sellIn = sellInData?.get(budgetRow.sku); + const actualUnits = sellIn?.actualUnits ?? 0; + const sellInBudget = sellIn?.sellInBudget ?? 0; + const periodForecast = budgetRow.monthlyBudget + .slice(0, cutoffMonth + 1) + .reduce((a, b) => a + b, 0); + const budgetYearTotal = budgetRow.monthlyBudget.reduce((a, b) => a + b, 0); + const budgetDiscrepancy = sellIn != null && Math.abs(budgetYearTotal - sellInBudget) > 1; + const pctPeriod = periodForecast > 0 ? (actualUnits / periodForecast) * 100 : 0; + const pctAnnual = budgetYearTotal > 0 ? (actualUnits / budgetYearTotal) * 100 : 0; + rows.push({ + sku: budgetRow.sku, + description: budgetRow.description, + actualUnits, + periodForecast, + budgetYearTotal, + budgetDiscrepancy, + pctPeriod, + pctAnnual, + }); + }); + rows.sort((a, b) => sortAsc ? a.pctPeriod - b.pctPeriod : b.pctPeriod - a.pctPeriod); + return rows; + }, [budgetData, sellInData, cutoffMonth, sortAsc]); - const handleSort = (key: string) => { - setSortConfig(prev => ({ - key, - direction: prev.key === key && prev.direction === 'desc' ? 'asc' : 'desc' - })); - }; + const kpis = useMemo(() => { + const totalActual = comparisonRows.reduce((s, r) => s + r.actualUnits, 0); + const totalPeriodForecast = comparisonRows.reduce((s, r) => s + r.periodForecast, 0); + const totalBudgetYear = comparisonRows.reduce((s, r) => s + r.budgetYearTotal, 0); + const withForecast = comparisonRows.filter(r => r.periodForecast > 0); + const below80 = withForecast.filter(r => r.pctPeriod < 80).length; + const pctOverall = totalPeriodForecast > 0 ? (totalActual / totalPeriodForecast) * 100 : 0; + return { totalActual, totalPeriodForecast, totalBudgetYear, below80, pctOverall, withForecastCount: withForecast.length }; + }, [comparisonRows]); - const baseFilteredData = useMemo(() => { - let result = data; - if (filters.line && filters.line.length > 0) result = result.filter(p => filters.line.includes(p.line)); - if (filters.asin && filters.asin.length > 0) result = result.filter(p => filters.asin.includes(p.asin)); - if (filters.sku && filters.sku.length > 0) result = result.filter(p => filters.sku.includes(p.sku)); - return result; - }, [data, filters.line, filters.asin, filters.sku]); + const handleExport = useCallback(() => { + const data = comparisonRows.map(r => ({ + SKU: r.sku, + Descripción: r.description, + 'Real Sell-in (Uds)': r.actualUnits, + [`Forecast Período Jan–${MONTHS_SHORT[cutoffMonth]}`]: r.periodForecast, + 'Budget Total Año': r.budgetYearTotal, + 'Discrepancia Budget': r.budgetDiscrepancy ? 'SÍ' : '', + '% Cumpl. Período': r.periodForecast > 0 ? r.pctPeriod.toFixed(1) + '%' : '—', + '% Budget Consumido': r.budgetYearTotal > 0 ? r.pctAnnual.toFixed(1) + '%' : '—', + })); + const ws = XLSX.utils.json_to_sheet(data); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Sell-in vs Budget'); + XLSX.writeFile(wb, `SellIn_vs_Budget_${new Date().toISOString().slice(0, 10)}.xlsx`); + }, [comparisonRows, cutoffMonth]); - // [MOVED] chartData moved below processedData to depend on it - // [MOVED] globalSummary moved below processedData to depend on it + const isReady = budgetData !== null; - const calculatedData = useMemo(() => { - return baseFilteredData.map(item => { - const forecastPeriod = activeMonths.reduce((sum, month) => { - return sum + (item.monthlyData?.[month]?.forecastUnits || 0); - }, 0); - const ytdAchievement = forecastPeriod > 0 ? ((item.actualUnits || 0) / forecastPeriod) * 100 : 0; - const fcAchievement = (item.annualForecast || 0) > 0 ? ((item.actualUnits || 0) / item.annualForecast) * 100 : 0; + return ( +
+ {/* Hidden file inputs — always in DOM */} + e.target.files?.[0] && loadFile(e.target.files[0], 'sellin')} + onClick={e => { (e.target as HTMLInputElement).value = ''; }} + /> + e.target.files?.[0] && loadFile(e.target.files[0], 'budget')} + onClick={e => { (e.target as HTMLInputElement).value = ''; }} + /> - return { - ...item, - forecastPeriod, - ytdAchievement, - fcAchievement - }; - }); - }, [baseFilteredData, activeMonths]); - - const processedData = useMemo(() => { - let result = calculatedData.slice(); - - if (showOnlyTop50 && top50Ranking) { - const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk; - result = result.filter(p => currentRankMap.has(p.asin.toUpperCase())); - } - - if (debouncedSearch) { - const searchTerms = debouncedSearch.toLowerCase().split(/[\s,]+/).filter(term => term.length > 0); - if (searchTerms.length > 0) { - result = result.filter(p => { - const rowValues = [ - p.sku.toLowerCase(), - p.asin.toLowerCase(), - p.title.toLowerCase() - ]; - return searchTerms.some(term => - rowValues.some(val => val.includes(term)) - ); - }); - } - } - - // Column Filters (Excel-style) - if (Object.keys(columnFilters).length > 0) { - (Object.entries(columnFilters) as [string, ColumnFilterCondition][]).forEach(([key, condition]) => { - if (!condition) return; - - if (condition.selectedValues && condition.selectedValues.length > 0) { - result = result.filter(p => { - const val = String((p as any)[key] || ''); - return condition.selectedValues?.includes(val); - }); - } - - if (condition.textFilter) { - const { operator, value } = condition.textFilter; - const lowerValue = value.toLowerCase(); - - result = result.filter(p => { - const rowVal = String((p as any)[key] || '').toLowerCase(); - switch (operator) { - case 'equals': return rowVal === lowerValue; - case 'notEquals': return rowVal !== lowerValue; - case 'contains': return rowVal.includes(lowerValue); - case 'notContains': return !rowVal.includes(lowerValue); - case 'startsWith': return rowVal.startsWith(lowerValue); - case 'notStartsWith': return !rowVal.startsWith(lowerValue); - case 'endsWith': return rowVal.endsWith(lowerValue); - case 'notEndsWith': return !rowVal.endsWith(lowerValue); - default: return true; - } - }); - } - }); - } - - // Apply WOC Filter - if (wocFilter && wocFilter.length > 0 && vendorStockMap) { - result = result.filter(p => { - const asin = p.asin.trim().toUpperCase(); - const stockData = vendorStockMap.get(asin); - if (!stockData) return false; - - const stock = top50Mode === 'uk' ? stockData.uk : stockData.eu; - const velocity = p.avgWeeklySales || 0; - - let woc: number = 0; - if (velocity > 0) { - woc = stock / velocity; - } else if (stock > 0) { - woc = 999; - } - - return wocFilter.some(f => { - if (f === '< 4 Weeks') return woc < 4; - if (f === '> 4 Weeks') return woc >= 4; - if (f === 'Out of Stock') return woc === 0; - if (f === 'Infinite Cover') return woc === 999; - return true; - }); - }); - } - - // Apply Sorting - const { key, direction } = sortConfig; - result.sort((a: any, b: any) => { - let valA = a[key]; - let valB = b[key]; - - if (typeof valA === 'string') valA = valA.toLowerCase(); - if (typeof valB === 'string') valB = valB.toLowerCase(); - - if (valA < valB) return direction === 'asc' ? -1 : 1; - if (valA > valB) return direction === 'asc' ? 1 : -1; - return 0; - }); - - return result; - }, [calculatedData, debouncedSearch, showOnlyTop50, top50Ranking, top50Mode, sortConfig, wocFilter, vendorStockMap, columnFilters]); - - // [MOVED HERE] Global Summary - Now respects all filters including Search/WOC - const globalSummary = useMemo(() => { - return processedData.reduce((acc, curr) => { - const itemPeriodForecast = activeMonths.reduce((sum, month) => { - return sum + (curr.monthlyData?.[month]?.forecastUnits || 0); - }, 0); - - const asin = curr.asin.trim().toUpperCase(); - const stockData = vendorStockMap?.get(asin); - const stock = stockData ? (top50Mode === 'uk' ? stockData.uk : stockData.eu) : 0; - const velocity = curr.avgWeeklySales || 0; - - return { - actualUnits: acc.actualUnits + (curr.actualUnits || 0), - forecastUnits: acc.forecastUnits + itemPeriodForecast, - annualForecast: acc.annualForecast + (curr.annualForecast || 0), - totalStock: acc.totalStock + stock, - totalVelocity: acc.totalVelocity + velocity - }; - }, { actualUnits: 0, forecastUnits: 0, annualForecast: 0, totalStock: 0, totalVelocity: 0 }); - }, [processedData, activeMonths, vendorStockMap, top50Mode]); - - // [MOVED HERE] Chart Data - Now respects all filters including Search/WOC - const chartData = useMemo(() => { - const dataMap = new Map(); - MONTH_ORDER.forEach(m => dataMap.set(m, { name: m, actual: 0, forecast: 0 })); - - processedData.forEach(item => { - if (item.monthlyData) { - Object.values(item.monthlyData).forEach((m: any) => { - const entry = dataMap.get(m.month); - if (entry) { - entry.actual += m.actualUnits || 0; - entry.forecast += m.forecastUnits || 0; - } - }); - } - }); - return Array.from(dataMap.values()); - }, [processedData]); - - const paginatedData = useMemo(() => processedData.slice(0, displayCount), [processedData, displayCount]); - - const handleExportExcel = useCallback(() => { - const exportData = processedData.map(p => ({ - SKU: p.sku, - ASIN: p.asin, - Title: p.title, - Line: p.line, - 'Annual Forecast': p.annualForecast, - 'Forecast (Period)': p.forecastPeriod, - 'Actual Units (2026)': p.actualUnits, - 'FC 26 Achievement (%)': p.fcAchievement, - 'YTD Achievement (%)': p.ytdAchievement - })); - const ws = XLSX.utils.json_to_sheet(exportData); - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, 'Forecast'); - XLSX.writeFile(wb, `Forecast_Export_${new Date().toISOString().slice(0, 10)}.xlsx`); - }, [processedData]); - - const SortIndicator = ({ column }: { column: string }) => { - if (sortConfig.key !== column) return ; - return {sortConfig.direction === 'desc' ? '↓' : '↑'}; - }; - - return ( -
-
-
-
- Annual Forecast Total -
{(globalSummary.annualForecast || 0).toLocaleString('de-DE')} Units
-
-
- Total Forecast (Period) -
{(globalSummary.forecastUnits || 0).toLocaleString('de-DE')} Units
-
-
- Total Actual Sales (Period) -
{(globalSummary.actualUnits || 0).toLocaleString('de-DE')} Units
-
-
-
- Fulfillment (Period) -
{globalSummary.forecastUnits > 0 ? ((globalSummary.actualUnits / globalSummary.forecastUnits) * 100).toFixed(1) : '0.0'}%
-
-
0 ? ((globalSummary.actualUnits / globalSummary.forecastUnits) * 100) : 0)}%` }} /> -
-
-
- Annual Fulfillment % -
{globalSummary.annualForecast > 0 ? ((globalSummary.actualUnits / globalSummary.annualForecast) * 100).toFixed(1) : '0.0'}%
-
-
- {/* New Aggregate WOC Card */} -
- Average Week Coverage -
-
0 && (globalSummary.totalStock / globalSummary.totalVelocity) < 4 ? 'text-rose-400' : 'text-emerald-400'}`}> - {globalSummary.totalVelocity > 0 - ? (globalSummary.totalStock / globalSummary.totalVelocity).toFixed(1) - : (globalSummary.totalStock > 0 ? '> 52' : '0.0')} -
- Weeks -
-
- Total Stock: {globalSummary.totalStock.toLocaleString('de-DE')} | Velocity: {globalSummary.totalVelocity.toFixed(1)}/wk -
-
-
- -
-

- Monthly Evolution: Forecast vs Actual -

-
- - - - - - - - - - - - - - - - - - - - - -
-
-
- -
-
-

- Product Performance Comparison (v2.5 optimized) -

-
-
Showing {paginatedData.length} of {processedData.length}
- {top50Ranking && (top50Ranking.eu.size > 0 || top50Ranking.uk.size > 0) && ( -
- -
- )} - setSearchTerm(e.target.value)} - className="bg-slate-950 border border-white/10 rounded-xl px-4 py-2 text-sm text-white focus:outline-none w-64" - /> - - } - /> - } - options={[ - 'Out of Stock (0)', - 'In Stock (>0)', - 'In Stock (>20)', - 'Low Stock (<10)', - ]} - /> - } - options={[ - '< 4 Weeks', - '> 4 Weeks', - 'Out of Stock', - 'Infinite Cover' - ]} - /> -
-
- -
- - - - - - - - - - - - - {paginatedData.map(item => ( - - ))} - -
-
-
- handleSort('sku')} - > - Product Info - -
- d.sku))).sort()} - currentFilter={columnFilters['sku']} - onFilterChange={handleColumnFilterChange} - icon={SKU} - /> - d.asin))).sort()} - currentFilter={columnFilters['asin']} - onFilterChange={handleColumnFilterChange} - icon={ASIN} - /> - d.title))).sort()} - currentFilter={columnFilters['title']} - onFilterChange={handleColumnFilterChange} - icon={Title} - /> -
-
-
- } - /> - } - options={[ - 'Out of Stock (0)', - 'In Stock (>0)', - 'In Stock (>20)', - 'Low Stock (<10)', - ]} - /> - } - options={[ - '< 4 Weeks', - '> 4 Weeks', - 'Out of Stock', - 'Infinite Cover' - ]} - /> -
-
-
handleSort('annualForecast')}> - Annual Forecast - handleSort('forecastPeriod')}> - Forecast (Period) - handleSort('actualUnits')}> - Actual Units 2026 - handleSort('fcAchievement')}> - FC 26 Achievement - handleSort('ytdAchievement')}> - YTD Achievement -
- - {displayCount < processedData.length && ( -
- -
- )} - - {processedData.length === 0 && ( -
-

No forecast data found for current filters

-
- )} -
+ {/* ── Upload UI (shown until budget is loaded) ── */} + {!isReady && ( +
+

+ Sell-in vs Budget 2026 — Carga los ficheros +

+
+ loadFile(f, 'sellin')} + onClick={() => sellInInputRef.current?.click()} + /> + loadFile(f, 'budget')} + onClick={() => budgetInputRef.current?.click()} + /> +
+ {error && ( +
+ {error}
+ )}
- ); + )} + + {/* ── Compact toolbar (shown when budget is loaded) ── */} + {isReady && ( +
+ + + {error && {error}} + +
+ Período hasta + +
+ +
+ )} + + {/* ── KPI Cards ── */} + {isReady && ( +
+
+
Total Real
+
{kpis.totalActual.toLocaleString('de-DE')}
+
Unidades vendidas a Amazon
+
+
+
+ Forecast Jan–{MONTHS_SHORT[cutoffMonth]} +
+
{kpis.totalPeriodForecast.toLocaleString('de-DE')}
+
+ {kpis.pctOverall.toFixed(1)}% cumplimiento global +
+
+
+
Budget Total Año
+
{kpis.totalBudgetYear.toLocaleString('de-DE')}
+
12 meses acumulados
+
+
+
SKUs < 80% Forecast
+
{kpis.below80}
+
+ de {kpis.withForecastCount} con forecast período +
+
+
+ )} + + {/* ── Comparison Table ── */} + {isReady && ( +
+
+ + + + + + + + + + + + + + {comparisonRows.map(row => ( + + ))} + +
SKUDescripciónReal Sell-in + Forecast Período +
+ Jan–{MONTHS_SHORT[cutoffMonth]} +
+
+ Budget Año +
12 meses
+
setSortAsc(prev => !prev)} + > + % Cumpl. Período {sortAsc ? '↑' : '↓'} +
real / forecast período
+
+ % Budget Consumido +
real / budget año
+
+ {comparisonRows.length === 0 && ( +
+ Sin datos de comparación +
+ )} +
+
+ )} +
+ ); }; export default ForecastView;