Files
CrazeAnalytix/components/ForecastView.tsx
T

712 lines
31 KiB
TypeScript
Raw Normal View History

import React, { useState, useMemo, useCallback, useRef } from 'react';
import * as XLSX from 'xlsx';
import { ExcelFilter } from './ExcelFilter';
import { ColumnFilterCondition } from '../types';
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;
asins: string[];
title: 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 ───────────────────────────────────────────────────────────
interface ForecastViewProps {
asinMetadata?: Map<string, { sku: string; title: string; line: string }>;
}
const ForecastView: React.FC<ForecastViewProps> = ({ asinMetadata }) => {
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 [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [columnFilters, setColumnFilters] = useState<Record<string, ColumnFilterCondition>>({});
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 skuMetadata = useMemo(() => {
const map = new Map<string, { asins: string[]; title: string }>();
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);
rows.push({
sku: budgetRow.sku,
description: budgetRow.description,
asins: meta?.asins ?? [],
title: meta?.title ?? '',
actualUnits,
periodForecast,
budgetYearTotal,
budgetDiscrepancy,
pctPeriod,
pctAnnual,
});
});
return rows;
}, [budgetData, sellInData, cutoffMonth, skuMetadata]);
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(/[,;|\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<string, string[]>;
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 (
<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="sticky top-0 z-20 bg-slate-950 border-b border-slate-800/50">
<div className="px-4 py-2">
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Buscar por SKU, ASIN, o título... (separar varios con 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"
/>
</div>
</div>
<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%]">
<div className="flex items-center gap-0.5">
<span>SKU</span>
<ExcelFilter
columnKey="sku"
title="SKU"
uniqueValues={columnUniqueValues.sku}
currentFilter={columnFilters.sku}
onFilterChange={handleColumnFilterChange}
/>
</div>
</th>
<th className="px-4 py-3 text-left">
<div className="flex items-center gap-0.5">
<span>Descripción</span>
<ExcelFilter
columnKey="description"
title="Descripción"
uniqueValues={columnUniqueValues.description}
currentFilter={columnFilters.description}
onFilterChange={handleColumnFilterChange}
/>
</div>
</th>
<th className="px-4 py-3 text-right w-[11%]">
<div className="flex items-center justify-end gap-0.5">
<ExcelFilter
columnKey="actualUnits"
title="Real Sell-in"
uniqueValues={columnUniqueValues.actualUnits}
currentFilter={columnFilters.actualUnits}
onFilterChange={handleColumnFilterChange}
/>
<span>Real Sell-in</span>
</div>
</th>
<th className="px-4 py-3 text-right w-[13%]">
<div className="flex items-center justify-end gap-0.5">
<ExcelFilter
columnKey="periodForecast"
title="Forecast Período"
uniqueValues={columnUniqueValues.periodForecast}
currentFilter={columnFilters.periodForecast}
onFilterChange={handleColumnFilterChange}
/>
<div className="text-right">
<div>Forecast Período</div>
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">
Jan{MONTHS_SHORT[cutoffMonth]}
</div>
</div>
</div>
</th>
<th className="px-4 py-3 text-right w-[11%]">
<div className="flex items-center justify-end gap-0.5">
<ExcelFilter
columnKey="budgetYearTotal"
title="Budget Año"
uniqueValues={columnUniqueValues.budgetYearTotal}
currentFilter={columnFilters.budgetYearTotal}
onFilterChange={handleColumnFilterChange}
/>
<div className="text-right">
<div>Budget Año</div>
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">12 meses</div>
</div>
</div>
</th>
<th className="px-4 py-3 text-center w-[15%]">
<div className="flex items-center justify-center gap-0.5">
<ExcelFilter
columnKey="pctPeriod"
title="% Cumpl. Período"
uniqueValues={columnUniqueValues.pctPeriod}
currentFilter={columnFilters.pctPeriod}
onFilterChange={handleColumnFilterChange}
/>
<div className="text-center">
<div>% Cumpl. Período</div>
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">real / forecast período</div>
</div>
</div>
</th>
<th className="px-4 py-3 text-center w-[13%]">
<div className="flex items-center justify-center gap-0.5">
<ExcelFilter
columnKey="pctAnnual"
title="% Budget Consumido"
uniqueValues={columnUniqueValues.pctAnnual}
currentFilter={columnFilters.pctAnnual}
onFilterChange={handleColumnFilterChange}
/>
<div className="text-center">
<div>% Budget Consumido</div>
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">real / budget año</div>
</div>
</div>
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{filteredRows.map(row => (
<ComparisonTableRow key={row.sku} row={row} />
))}
</tbody>
{filteredRows.length === 0 && (
<div className="flex items-center justify-center py-20 text-slate-500 text-sm">
{searchQuery.trim() ? 'No se encontraron productos con esos criterios' : 'Sin datos de comparación'}
</div>
)}
{searchQuery.trim() && filteredRows.length > 0 && (
<div className="sticky bottom-0 bg-slate-950 border-t border-slate-800/50 px-4 py-1.5 text-[10px] text-slate-500 text-right">
{filteredRows.length} de {comparisonRows.length} SKUs
</div>
)}
</table>
</div>
</div>
)}
</div>
);
};
export default ForecastView;