import React, { useState, useMemo, useCallback, useRef } from 'react'; import * as XLSX from 'xlsx'; import { ExcelFilter } from './ExcelFilter'; import { ColumnFilterCondition } from '../types'; import { AmazonSmileIcon } from './Icons'; 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; asins: string[]; title: string; actualUnits: number; periodForecast: number; budgetYearTotal: number; budgetDiscrepancy: boolean; pctPeriod: number; pctAnnual: number; vendorStockEU: number; vendorStockUK: 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.vendorStockEU > 0 ? (
EU {row.vendorStockEU.toLocaleString('de-DE')}
) : (
EU
)} {row.vendorStockUK > 0 ? (
UK {row.vendorStockUK.toLocaleString('de-DE')}
) : (
UK
)}
{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 ─────────────────────────────────────────────────────────── interface ForecastViewProps { asinMetadata?: Map; vendorStockMap?: Map; } const ForecastView: React.FC = ({ asinMetadata, vendorStockMap }) => { 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 [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [columnFilters, setColumnFilters] = useState>({}); 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 skuMetadata = useMemo(() => { const map = new Map(); if (!asinMetadata) return map; asinMetadata.forEach((meta, asin) => { const existing = map.get(meta.sku); if (existing) { existing.asins.push(asin); if (meta.title && meta.title.length > (existing.title?.length || 0)) { existing.title = meta.title; } } else { map.set(meta.sku, { asins: [asin], title: meta.title || '' }); } }); return map; }, [asinMetadata]); 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; const meta = skuMetadata.get(budgetRow.sku); const asins = meta?.asins ?? []; const vendorStockEU = asins.reduce((sum, asin) => { return sum + (vendorStockMap?.get(asin.trim().toUpperCase())?.eu ?? 0); }, 0); const vendorStockUK = asins.reduce((sum, asin) => { return sum + (vendorStockMap?.get(asin.trim().toUpperCase())?.uk ?? 0); }, 0); rows.push({ sku: budgetRow.sku, description: budgetRow.description, asins, title: meta?.title ?? '', actualUnits, periodForecast, budgetYearTotal, budgetDiscrepancy, pctPeriod, pctAnnual, vendorStockEU, vendorStockUK, }); }); return rows; }, [budgetData, sellInData, cutoffMonth, skuMetadata, vendorStockMap]); const passesTextFilter = useCallback((value: string, filter?: ColumnFilterCondition): boolean => { if (!filter) return true; if (filter.selectedValues && filter.selectedValues.length > 0) { if (!filter.selectedValues.includes(value)) return false; } if (filter.textFilter) { const { operator, value: filterVal } = filter.textFilter; if (!filterVal) return true; const v = value.toLowerCase(); const fv = filterVal.toLowerCase(); switch (operator) { case 'equals': return v === fv; case 'notEquals': return v !== fv; case 'contains': return v.includes(fv); case 'notContains': return !v.includes(fv); case 'startsWith': return v.startsWith(fv); case 'notStartsWith': return !v.startsWith(fv); case 'endsWith': return v.endsWith(fv); case 'notEndsWith': return !v.endsWith(fv); default: return true; } } return true; }, []); const passesNumericFilter = useCallback((value: number, filter?: ColumnFilterCondition): boolean => { if (!filter) return true; if (filter.textFilter) { const { operator, value: filterVal } = filter.textFilter; const fv = parseFloat(filterVal); if (isNaN(fv)) return true; switch (operator) { case 'equals': return value === fv; case 'notEquals': return value !== fv; case 'gt': return value > fv; case 'gte': return value >= fv; case 'lt': return value < fv; case 'lte': return value <= fv; default: return true; } } return true; }, []); const filteredRows = useMemo(() => { let rows = comparisonRows; // Apply column filters const hasColumnFilter = Object.keys(columnFilters).some(k => { const f = columnFilters[k]; return f?.selectedValues || f?.textFilter; }); if (hasColumnFilter) { rows = rows.filter(row => { return Object.entries(columnFilters).every(([key, filter]) => { if (!filter) return true; if (key === 'sku') return passesTextFilter(row.sku, filter); if (key === 'description') return passesTextFilter(row.description, filter); if (key === 'actualUnits') return passesNumericFilter(row.actualUnits, filter); if (key === 'periodForecast') return passesNumericFilter(row.periodForecast, filter); if (key === 'budgetYearTotal') return passesNumericFilter(row.budgetYearTotal, filter); if (key === 'pctPeriod') return passesNumericFilter(row.pctPeriod, filter); if (key === 'pctAnnual') return passesNumericFilter(row.pctAnnual, filter); return true; }); }); } // Apply search filter if (searchQuery.trim()) { const terms = searchQuery .split(/[\s,;|\n]+/) .map(t => t.trim().toLowerCase()) .filter(Boolean); if (terms.length > 0) { rows = rows.filter(row => { return terms.some(term => { if (row.sku.toLowerCase().includes(term)) return true; if (row.description.toLowerCase().includes(term)) return true; if (row.title.toLowerCase().includes(term)) return true; if (row.asins.some(asin => asin.toLowerCase().includes(term))) return true; return false; }); }); } } // Apply column sort (first column with sort wins) const sortEntry = Object.entries(columnFilters).find(([_, f]) => f?.sort); if (sortEntry) { const [sortKey, sortFilter] = sortEntry; const dir = sortFilter.sort === 'desc' ? -1 : 1; rows = [...rows].sort((a, b) => { let va: any, vb: any; switch (sortKey) { case 'sku': va = a.sku; vb = b.sku; break; case 'description': va = a.description; vb = b.description; break; case 'actualUnits': va = a.actualUnits; vb = b.actualUnits; break; case 'periodForecast': va = a.periodForecast; vb = b.periodForecast; break; case 'budgetYearTotal': va = a.budgetYearTotal; vb = b.budgetYearTotal; break; case 'pctPeriod': va = a.pctPeriod; vb = b.pctPeriod; break; case 'pctAnnual': va = a.pctAnnual; vb = b.pctAnnual; break; default: return 0; } if (typeof va === 'string') return dir * va.localeCompare(vb); return dir * ((va as number) - (vb as number)); }); } return rows; }, [comparisonRows, searchQuery, columnFilters, passesTextFilter, passesNumericFilter]); 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 handleColumnFilterChange = useCallback((columnKey: string, condition: ColumnFilterCondition | undefined) => { setColumnFilters(prev => { const next = { ...prev }; if (condition) { next[columnKey] = condition; } else { delete next[columnKey]; } return next; }); }, []); const columnUniqueValues = useMemo(() => { if (comparisonRows.length === 0) return { sku: [], description: [] } as Record; return { sku: Array.from(new Set(comparisonRows.map(r => r.sku))).sort() as string[], description: Array.from(new Set(comparisonRows.map(r => r.description))).sort() as string[], actualUnits: Array.from(new Set(comparisonRows.map(r => String(r.actualUnits)))).sort((a, b) => Number(a) - Number(b)) as string[], periodForecast: Array.from(new Set(comparisonRows.map(r => String(r.periodForecast)))).sort((a, b) => Number(a) - Number(b)) as string[], budgetYearTotal: Array.from(new Set(comparisonRows.map(r => String(r.budgetYearTotal)))).sort((a, b) => Number(a) - Number(b)) as string[], pctPeriod: Array.from(new Set(comparisonRows.map(r => r.periodForecast > 0 ? r.pctPeriod.toFixed(1) + '%' : '—'))).sort() as string[], pctAnnual: Array.from(new Set(comparisonRows.map(r => r.budgetYearTotal > 0 ? r.pctAnnual.toFixed(1) + '%' : '—'))).sort() as string[], }; }, [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 && (
setSearchQuery(e.target.value)} placeholder="Buscar por SKU, ASIN, o título... (separar varios con espacio o coma)" className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
{filteredRows.map(row => ( ))} {filteredRows.length === 0 && (
{searchQuery.trim() ? 'No se encontraron productos con esos criterios' : 'Sin datos de comparación'}
)} {searchQuery.trim() && filteredRows.length > 0 && (
{filteredRows.length} de {comparisonRows.length} SKUs
)}
SKU
Descripción
Stock
Real Sell-in
Forecast Período
Jan–{MONTHS_SHORT[cutoffMonth]}
Budget Año
12 meses
% Cumpl. Período
real / forecast período
% Budget Consumido
real / budget año
)}
); }; export default ForecastView;