import React, { useState, useMemo, useCallback, useRef } from 'react'; import * as XLSX from 'xlsx'; 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; } interface BudgetRow { sku: string; description: string; monthlyBudget: number[]; // 12 values Jan–Dec } interface ComparisonRow { sku: string; description: string; actualUnits: number; periodForecast: number; budgetYearTotal: number; budgetDiscrepancy: boolean; pctPeriod: number; pctAnnual: number; } 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; } 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 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'; }; 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'; }; // ── Sub-components ────────────────────────────────────────────────────────── 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í
}
); 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 = () => { 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); const sellInInputRef = useRef(null); const budgetInputRef = useRef(null); 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 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 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 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]); const isReady = budgetData !== null; 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 = ''; }} /> {/* ── 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 => ( ))}
SKU Descripción Real 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;