Files
CrazeAnalytix/components/ForecastView.tsx
T

712 lines
31 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';
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;
}
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;