Files
CrazeAnalytix/components/ForecastView.tsx
T

457 lines
20 KiB
TypeScript
Raw Normal View History

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;
}
2026-01-28 12:49:41 +01:00
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;