2026-06-17 16:03:20 +02:00
|
|
|
|
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
2026-01-27 15:34:06 +01:00
|
|
|
|
import * as XLSX from 'xlsx';
|
2026-06-17 16:16:41 +02:00
|
|
|
|
import { ExcelFilter } from './ExcelFilter';
|
|
|
|
|
|
import { ColumnFilterCondition } from '../types';
|
2026-06-17 16:25:41 +02:00
|
|
|
|
import { AmazonSmileIcon } from './Icons';
|
2026-01-26 15:47:36 +01:00
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
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;
|
2026-01-26 15:47:36 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
interface BudgetRow {
|
|
|
|
|
|
sku: string;
|
|
|
|
|
|
description: string;
|
|
|
|
|
|
monthlyBudget: number[]; // 12 values Jan–Dec
|
|
|
|
|
|
}
|
2026-01-26 15:47:36 +01:00
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
interface ComparisonRow {
|
|
|
|
|
|
sku: string;
|
|
|
|
|
|
description: string;
|
2026-06-17 16:10:00 +02:00
|
|
|
|
asins: string[];
|
|
|
|
|
|
title: string;
|
2026-06-17 16:03:20 +02:00
|
|
|
|
actualUnits: number;
|
|
|
|
|
|
periodForecast: number;
|
|
|
|
|
|
budgetYearTotal: number;
|
|
|
|
|
|
budgetDiscrepancy: boolean;
|
|
|
|
|
|
pctPeriod: number;
|
|
|
|
|
|
pctAnnual: number;
|
2026-06-17 16:25:41 +02:00
|
|
|
|
vendorStockEU: number;
|
|
|
|
|
|
vendorStockUK: number;
|
2026-06-17 16:03:20 +02:00
|
|
|
|
}
|
2026-01-27 21:43:25 +01:00
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-01-27 21:43:25 +01:00
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
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
|
|
|
|
|
2026-06-17 16:03:20 +02: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';
|
|
|
|
|
|
};
|
2026-01-28 12:16:41 +01:00
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
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';
|
|
|
|
|
|
};
|
2026-01-28 12:16:41 +01:00
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
// ── Sub-components ──────────────────────────────────────────────────────────
|
2026-01-28 12:16:41 +01:00
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
2026-01-27 21:43:25 +01:00
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
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">
|
2026-06-17 16:31:23 +02:00
|
|
|
|
<td className="px-4 py-3 whitespace-nowrap">
|
|
|
|
|
|
<span className="text-xs font-black text-indigo-400 tracking-tighter">{row.sku}</span>
|
2026-06-17 16:03:20 +02:00
|
|
|
|
</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>
|
2026-06-17 16:31:23 +02:00
|
|
|
|
<td className="px-4 py-3 whitespace-nowrap">
|
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
|
{row.vendorStockEU > 0 ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-gradient-to-br from-indigo-500 to-blue-600 text-white border border-indigo-400/50 shadow-lg text-[10px] font-bold w-fit"
|
|
|
|
|
|
title={`Stock Amazon Vendor EU: ${row.vendorStockEU.toLocaleString('de-DE')}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<AmazonSmileIcon className="w-3 h-3 shrink-0" />
|
|
|
|
|
|
<span className="text-[7px] font-black uppercase opacity-80">EU</span>
|
|
|
|
|
|
<span>{row.vendorStockEU.toLocaleString('de-DE')}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="inline-flex items-center gap-1.5 px-2 py-1 rounded border text-[10px] font-bold bg-slate-800/50 text-slate-500 border-slate-700/50 w-fit">
|
|
|
|
|
|
<span className="text-[7px] font-black uppercase">EU</span>
|
|
|
|
|
|
<span>—</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{row.vendorStockUK > 0 ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-gradient-to-br from-rose-500 to-red-600 text-white border border-rose-400/50 shadow-lg text-[10px] font-bold w-fit"
|
|
|
|
|
|
title={`Stock Amazon Vendor UK: ${row.vendorStockUK.toLocaleString('de-DE')}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<AmazonSmileIcon className="w-3 h-3 shrink-0" />
|
|
|
|
|
|
<span className="text-[7px] font-black uppercase opacity-80">UK</span>
|
|
|
|
|
|
<span>{row.vendorStockUK.toLocaleString('de-DE')}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="inline-flex items-center gap-1.5 px-2 py-1 rounded border text-[10px] font-bold bg-slate-800/50 text-slate-500 border-slate-700/50 w-fit">
|
|
|
|
|
|
<span className="text-[7px] font-black uppercase">UK</span>
|
|
|
|
|
|
<span>—</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
2026-06-17 16:03:20 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
);
|
2026-01-27 21:43:25 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
// ── Main Component ───────────────────────────────────────────────────────────
|
2026-01-28 14:55:35 +01:00
|
|
|
|
|
2026-06-17 16:10:00 +02:00
|
|
|
|
interface ForecastViewProps {
|
|
|
|
|
|
asinMetadata?: Map<string, { sku: string; title: string; line: string }>;
|
2026-06-17 16:25:41 +02:00
|
|
|
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
2026-06-17 16:10:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 16:25:41 +02:00
|
|
|
|
const ForecastView: React.FC<ForecastViewProps> = ({ asinMetadata, vendorStockMap }) => {
|
2026-06-17 16:03:20 +02:00
|
|
|
|
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);
|
2026-06-17 16:10:00 +02:00
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
2026-06-17 16:16:41 +02:00
|
|
|
|
const [columnFilters, setColumnFilters] = useState<Record<string, ColumnFilterCondition>>({});
|
2026-01-29 08:47:03 +01:00
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
const sellInInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
const budgetInputRef = useRef<HTMLInputElement>(null);
|
2026-01-29 08:47:03 +01:00
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
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}`);
|
|
|
|
|
|
}
|
2026-02-09 12:00:39 +01:00
|
|
|
|
};
|
2026-06-17 16:03:20 +02:00
|
|
|
|
reader.readAsArrayBuffer(file);
|
|
|
|
|
|
}, []);
|
2026-02-09 12:00:39 +01:00
|
|
|
|
|
2026-06-17 16:10:00 +02:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
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;
|
2026-06-17 16:10:00 +02:00
|
|
|
|
const meta = skuMetadata.get(budgetRow.sku);
|
2026-06-17 16:25:41 +02:00
|
|
|
|
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);
|
2026-06-17 16:03:20 +02:00
|
|
|
|
rows.push({
|
|
|
|
|
|
sku: budgetRow.sku,
|
|
|
|
|
|
description: budgetRow.description,
|
2026-06-17 16:25:41 +02:00
|
|
|
|
asins,
|
2026-06-17 16:10:00 +02:00
|
|
|
|
title: meta?.title ?? '',
|
2026-06-17 16:03:20 +02:00
|
|
|
|
actualUnits,
|
|
|
|
|
|
periodForecast,
|
|
|
|
|
|
budgetYearTotal,
|
|
|
|
|
|
budgetDiscrepancy,
|
|
|
|
|
|
pctPeriod,
|
|
|
|
|
|
pctAnnual,
|
2026-06-17 16:25:41 +02:00
|
|
|
|
vendorStockEU,
|
|
|
|
|
|
vendorStockUK,
|
2026-06-17 16:03:20 +02:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
return rows;
|
2026-06-17 16:25:41 +02:00
|
|
|
|
}, [budgetData, sellInData, cutoffMonth, skuMetadata, vendorStockMap]);
|
2026-06-17 16:16:41 +02:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}, []);
|
2026-06-17 16:10:00 +02:00
|
|
|
|
|
|
|
|
|
|
const filteredRows = useMemo(() => {
|
2026-06-17 16:16:41 +02:00
|
|
|
|
let rows = comparisonRows;
|
|
|
|
|
|
|
|
|
|
|
|
// Apply column filters
|
|
|
|
|
|
const hasColumnFilter = Object.keys(columnFilters).some(k => {
|
|
|
|
|
|
const f = columnFilters[k];
|
|
|
|
|
|
return f?.selectedValues || f?.textFilter;
|
2026-06-17 16:10:00 +02:00
|
|
|
|
});
|
2026-06-17 16:16:41 +02:00
|
|
|
|
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
|
2026-06-18 09:19:14 +02:00
|
|
|
|
.split(/[\s,;|\n]+/)
|
2026-06-17 16:16:41 +02:00
|
|
|
|
.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]);
|
2026-01-26 16:24:09 +01:00
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
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]);
|
2026-01-28 15:11:47 +01:00
|
|
|
|
|
2026-06-17 16:16:41 +02:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
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]);
|
2026-01-27 10:45:59 +01:00
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
const isReady = budgetData !== null;
|
2026-01-27 10:00:27 +01:00
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
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 = ''; }}
|
|
|
|
|
|
/>
|
2026-01-28 15:11:47 +01:00
|
|
|
|
|
2026-06-17 16:03:20 +02:00
|
|
|
|
{/* ── 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}
|
2026-01-28 12:20:55 +01:00
|
|
|
|
</div>
|
2026-06-17 16:03:20 +02:00
|
|
|
|
)}
|
2026-01-28 12:20:55 +01:00
|
|
|
|
</div>
|
2026-06-17 16:03:20 +02:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── 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 < 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">
|
2026-06-17 16:10:00 +02:00
|
|
|
|
<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)}
|
2026-06-18 09:19:14 +02:00
|
|
|
|
placeholder="Buscar por SKU, ASIN, o título... (separar varios con espacio o coma)"
|
2026-06-17 16:10:00 +02:00
|
|
|
|
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>
|
2026-06-17 16:03:20 +02:00
|
|
|
|
<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">
|
2026-06-17 16:26:58 +02:00
|
|
|
|
<tr className="text-[10px] font-black text-slate-300 uppercase tracking-wider">
|
2026-06-17 16:16:41 +02:00
|
|
|
|
<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}
|
|
|
|
|
|
/>
|
2026-06-17 16:03:20 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</th>
|
2026-06-17 16:31:23 +02:00
|
|
|
|
<th className="px-4 py-3 text-left w-[12%]">
|
|
|
|
|
|
<div className="flex items-center gap-0.5">
|
|
|
|
|
|
<span>Stock</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</th>
|
2026-06-17 16:03:20 +02:00
|
|
|
|
<th className="px-4 py-3 text-right w-[11%]">
|
2026-06-17 16:16:41 +02:00
|
|
|
|
<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>
|
2026-06-17 16:03:20 +02:00
|
|
|
|
</th>
|
2026-06-17 16:16:41 +02:00
|
|
|
|
<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>
|
2026-06-17 16:03:20 +02:00
|
|
|
|
</th>
|
|
|
|
|
|
<th className="px-4 py-3 text-center w-[13%]">
|
2026-06-17 16:16:41 +02:00
|
|
|
|
<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>
|
2026-06-17 16:03:20 +02:00
|
|
|
|
</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody className="divide-y divide-slate-800/50">
|
2026-06-17 16:10:00 +02:00
|
|
|
|
{filteredRows.map(row => (
|
2026-06-17 16:03:20 +02:00
|
|
|
|
<ComparisonTableRow key={row.sku} row={row} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
2026-06-17 16:10:00 +02:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-06-17 16:03:20 +02:00
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-01-26 15:47:36 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-28 12:20:55 +01:00
|
|
|
|
export default ForecastView;
|