Files
CrazeAnalytix/components/ForecastView.tsx
T
Christian Vidal WolfandClaude Sonnet 4.6 6e2db6be1a 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 <noreply@anthropic.com>
2026-06-17 16:03:20 +02:00

457 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 JanDec
}
interface ComparisonRow {
sku: string;
description: string;
actualUnits: number;
periodForecast: number;
budgetYearTotal: number;
budgetDiscrepancy: boolean;
pctPeriod: number;
pctAnnual: number;
}
function parseSellIn(buffer: ArrayBuffer): Map<string, SellInRow> {
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<any[]>(ws, { header: 1, defval: '' });
const result = new Map<string, SellInRow>();
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<string, BudgetRow> {
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<any[]>(ws, { header: 1, defval: '' });
const result = new Map<string, BudgetRow>();
// 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 }) => (
<div
className={`border-2 border-dashed rounded-xl p-6 flex flex-col items-center justify-center gap-3 cursor-pointer transition-all min-h-[140px] ${
loaded
? 'border-emerald-500/50 bg-emerald-500/5'
: 'border-slate-700 hover:border-indigo-500/50 hover:bg-indigo-500/5'
}`}
onClick={onClick}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) onDrop(f); }}
>
<div className={`text-3xl ${loaded ? 'text-emerald-400' : 'text-slate-600'}`}>
{loaded ? '✓' : '📊'}
</div>
<div className="text-center">
<div className="text-sm font-black text-white">{label}</div>
<div className="text-xs text-slate-500 mt-1">{subtitle}</div>
{loaded
? <div className="text-xs text-emerald-400 mt-2">{fileName} {count} SKUs cargados</div>
: <div className="text-[10px] text-slate-600 mt-2 uppercase tracking-wider">Clic o arrastra aquí</div>
}
</div>
</div>
);
const ComparisonTableRow: React.FC<{ row: ComparisonRow }> = React.memo(({ row }) => {
const barWidth = Math.min(100, row.pctPeriod);
return (
<tr className="hover:bg-indigo-500/5 transition-colors border-b border-slate-800/50 last:border-0">
<td className="px-4 py-3 whitespace-nowrap">
<span className="text-xs font-black text-indigo-400 tracking-tighter">{row.sku}</span>
</td>
<td className="px-4 py-3 max-w-[280px]">
<span className="text-sm text-slate-200 line-clamp-1" title={row.description}>
{row.description}
</span>
</td>
<td className="px-4 py-3 text-right whitespace-nowrap">
<span className="text-sm font-black text-emerald-400">
{row.actualUnits.toLocaleString('de-DE')}
</span>
</td>
<td className="px-4 py-3 text-right whitespace-nowrap">
<span className="text-sm font-bold text-slate-300">
{row.periodForecast.toLocaleString('de-DE')}
</span>
</td>
<td className="px-4 py-3 text-right whitespace-nowrap">
<div className="flex flex-col items-end gap-0.5">
<span className="text-sm font-bold text-slate-400">
{row.budgetYearTotal.toLocaleString('de-DE')}
</span>
{row.budgetDiscrepancy && (
<span className="text-[9px] font-black text-amber-400 uppercase"> discrepancia</span>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-col items-center gap-1">
<span className={`text-sm font-black ${getPctColor(row.pctPeriod)}`}>
{row.periodForecast > 0 ? `${row.pctPeriod.toFixed(1)}%` : '—'}
</span>
{row.periodForecast > 0 && (
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden max-w-[100px]">
<div
className={`h-full rounded-full ${getPctBg(row.pctPeriod)}`}
style={{ width: `${barWidth}%` }}
/>
</div>
)}
</div>
</td>
<td className="px-4 py-3 text-center whitespace-nowrap">
<span className={`text-sm font-bold ${getPctColor(row.pctAnnual)}`}>
{row.budgetYearTotal > 0 ? `${row.pctAnnual.toFixed(1)}%` : '—'}
</span>
</td>
</tr>
);
});
// ── Main Component ───────────────────────────────────────────────────────────
const ForecastView: React.FC = () => {
const [sellInData, setSellInData] = useState<Map<string, SellInRow> | null>(null);
const [budgetData, setBudgetData] = useState<Map<string, BudgetRow> | null>(null);
const [sellInFileName, setSellInFileName] = useState('');
const [budgetFileName, setBudgetFileName] = useState('');
// Default cutoff: May (index 4) — adjust as needed
const [cutoffMonth, setCutoffMonth] = useState<number>(4);
const [sortAsc, setSortAsc] = useState(true);
const [error, setError] = useState<string | null>(null);
const sellInInputRef = useRef<HTMLInputElement>(null);
const budgetInputRef = useRef<HTMLInputElement>(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 (
<div className="flex flex-col gap-4 md:gap-6 animate-fade-in p-3 md:p-6 h-full overflow-hidden">
{/* Hidden file inputs — always in DOM */}
<input
ref={sellInInputRef}
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={e => e.target.files?.[0] && loadFile(e.target.files[0], 'sellin')}
onClick={e => { (e.target as HTMLInputElement).value = ''; }}
/>
<input
ref={budgetInputRef}
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={e => 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 && (
<div className="bg-slate-900 border border-white/5 rounded-2xl p-6 flex flex-col gap-6">
<h2 className="text-sm font-black text-white uppercase tracking-widest">
Sell-in vs Budget 2026 Carga los ficheros
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<DropZone
label="Sell in (*.xlsx)"
subtitle="Col A: Raw Article · Col I: Sell-in Units Year · Col K: Budget Units"
loaded={sellInData !== null}
fileName={sellInFileName}
count={sellInData?.size}
onDrop={f => loadFile(f, 'sellin')}
onClick={() => sellInInputRef.current?.click()}
/>
<DropZone
label="Budget 26 (*.xlsx)"
subtitle="Hoja Export · Fila 1: meses · Fila 2: sub-campos · Datos desde fila 3"
loaded={budgetData !== null}
fileName={budgetFileName}
count={budgetData?.size}
onDrop={f => loadFile(f, 'budget')}
onClick={() => budgetInputRef.current?.click()}
/>
</div>
{error && (
<div className="p-3 bg-rose-500/10 border border-rose-500/20 rounded-lg text-xs text-rose-400">
{error}
</div>
)}
</div>
)}
{/* ── Compact toolbar (shown when budget is loaded) ── */}
{isReady && (
<div className="flex flex-wrap items-center gap-3 shrink-0">
<button
onClick={() => sellInInputRef.current?.click()}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border text-[10px] font-black uppercase tracking-wider transition-all ${
sellInData
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
: 'border-slate-600 text-slate-400 hover:border-indigo-500/50'
}`}
>
{sellInData ? '✓' : '+'} Sell In{sellInData ? ` (${sellInData.size} SKUs)` : ' — sin cargar'}
</button>
<button
onClick={() => budgetInputRef.current?.click()}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg border text-[10px] font-black uppercase tracking-wider border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
>
Budget 26 ({budgetData!.size} SKUs)
</button>
{error && <span className="text-xs text-rose-400">{error}</span>}
<div className="flex items-center gap-2 ml-auto">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-wider">Período hasta</span>
<select
value={cutoffMonth}
onChange={e => setCutoffMonth(Number(e.target.value))}
className="bg-slate-800 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
{MONTHS.map((m, i) => <option key={m} value={i}>{m}</option>)}
</select>
</div>
<button
onClick={handleExport}
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white rounded-lg border border-white/5 transition-all text-xs font-black uppercase tracking-wider"
title="Exportar a Excel"
>
Excel
</button>
</div>
)}
{/* ── KPI Cards ── */}
{isReady && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 shrink-0">
<div className="bg-slate-900 border border-white/5 p-4 rounded-xl shadow-lg">
<div className="text-[10px] font-black text-emerald-400 uppercase tracking-widest mb-1">Total Real</div>
<div className="text-2xl font-black text-emerald-400">{kpis.totalActual.toLocaleString('de-DE')}</div>
<div className="text-[9px] text-slate-500 mt-1 uppercase">Unidades vendidas a Amazon</div>
</div>
<div className="bg-slate-900 border border-white/5 p-4 rounded-xl shadow-lg">
<div className="text-[10px] font-black text-indigo-400 uppercase tracking-widest mb-1">
Forecast Jan{MONTHS_SHORT[cutoffMonth]}
</div>
<div className="text-2xl font-black text-white">{kpis.totalPeriodForecast.toLocaleString('de-DE')}</div>
<div className={`text-[11px] font-black mt-1 ${getPctColor(kpis.pctOverall)}`}>
{kpis.pctOverall.toFixed(1)}% cumplimiento global
</div>
</div>
<div className="bg-slate-900 border border-white/5 p-4 rounded-xl shadow-lg">
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">Budget Total Año</div>
<div className="text-2xl font-black text-white">{kpis.totalBudgetYear.toLocaleString('de-DE')}</div>
<div className="text-[9px] text-slate-500 mt-1 uppercase">12 meses acumulados</div>
</div>
<div className="bg-slate-900 border border-white/5 p-4 rounded-xl shadow-lg">
<div className="text-[10px] font-black text-rose-400 uppercase tracking-widest mb-1">SKUs &lt; 80% Forecast</div>
<div className="text-2xl font-black text-rose-400">{kpis.below80}</div>
<div className="text-[9px] text-slate-500 mt-1 uppercase">
de {kpis.withForecastCount} con forecast período
</div>
</div>
</div>
)}
{/* ── Comparison Table ── */}
{isReady && (
<div className="bg-slate-900 border border-white/10 rounded-2xl overflow-hidden shadow-2xl flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-auto min-h-0 custom-scrollbar">
<table className="w-full text-left border-collapse text-xs md:text-sm">
<thead className="sticky top-0 z-20 bg-slate-950 shadow-sm">
<tr className="text-[10px] font-black text-slate-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left w-[7%]">SKU</th>
<th className="px-4 py-3 text-left">Descripción</th>
<th className="px-4 py-3 text-right w-[11%]">Real Sell-in</th>
<th className="px-4 py-3 text-right w-[13%]">
Forecast Período
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">
Jan{MONTHS_SHORT[cutoffMonth]}
</div>
</th>
<th className="px-4 py-3 text-right w-[11%]">
Budget Año
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">12 meses</div>
</th>
<th
className="px-4 py-3 text-center w-[15%] cursor-pointer hover:text-white select-none"
onClick={() => setSortAsc(prev => !prev)}
>
% Cumpl. Período {sortAsc ? '↑' : '↓'}
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">real / forecast período</div>
</th>
<th className="px-4 py-3 text-center w-[13%]">
% Budget Consumido
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">real / budget año</div>
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{comparisonRows.map(row => (
<ComparisonTableRow key={row.sku} row={row} />
))}
</tbody>
</table>
{comparisonRows.length === 0 && (
<div className="flex items-center justify-center py-20 text-slate-500 text-sm">
Sin datos de comparación
</div>
)}
</div>
</div>
)}
</div>
);
};
export default ForecastView;