feat: Simplificar página de entrada de datos

- Cabecera estilo dashboard (AIR EUROPA + fecha)
- Eliminar selección de tier (usar gold por defecto)
- Campos manuales vacíos por defecto
- Solo opción de subir archivo CSV/Excel
- Eliminar tabla de campos, plantilla y datos sintéticos

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Susana
2026-01-12 08:56:20 +00:00
parent 8082a14e1b
commit 806e32429d
2 changed files with 207 additions and 476 deletions

View File

@@ -1,12 +1,11 @@
// components/DataInputRedesigned.tsx // components/DataInputRedesigned.tsx
// Interfaz de entrada de datos rediseñada y organizada // Interfaz de entrada de datos simplificada
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { import {
Download, CheckCircle, AlertCircle, FileText, Database, AlertCircle, FileText, Database,
UploadCloud, File, Sheet, Loader2, Sparkles, Table, UploadCloud, File, Loader2, Info, X
Info, ExternalLink, X
} from 'lucide-react'; } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -27,55 +26,23 @@ interface DataInputRedesignedProps {
isAnalyzing: boolean; isAnalyzing: boolean;
} }
const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
onAnalyze, onAnalyze,
isAnalyzing isAnalyzing
}) => { }) => {
// Estados para datos manuales // Estados para datos manuales - valores vacíos por defecto
const [costPerHour, setCostPerHour] = useState<number>(20); const [costPerHour, setCostPerHour] = useState<string>('');
const [avgCsat, setAvgCsat] = useState<number>(85); const [avgCsat, setAvgCsat] = useState<string>('');
// Estados para mapeo de segmentación // Estados para mapeo de segmentación
const [highValueQueues, setHighValueQueues] = useState<string>(''); const [highValueQueues, setHighValueQueues] = useState<string>('');
const [mediumValueQueues, setMediumValueQueues] = useState<string>(''); const [mediumValueQueues, setMediumValueQueues] = useState<string>('');
const [lowValueQueues, setLowValueQueues] = useState<string>(''); const [lowValueQueues, setLowValueQueues] = useState<string>('');
// Estados para carga de datos // Estados para carga de datos
const [uploadMethod, setUploadMethod] = useState<'file' | 'url' | 'synthetic' | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [sheetUrl, setSheetUrl] = useState<string>('');
const [isGenerating, setIsGenerating] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
// Campos CSV requeridos
const csvFields = [
{ name: 'interaction_id', type: 'String único', example: 'call_8842910', required: true },
{ name: 'datetime_start', type: 'DateTime', example: '2024-10-01 09:15:22', required: true },
{ name: 'queue_skill', type: 'String', example: 'Soporte_Nivel1, Ventas', required: true },
{ name: 'channel', type: 'String', example: 'Voice, Chat, WhatsApp', required: true },
{ name: 'duration_talk', type: 'Segundos', example: '345', required: true },
{ name: 'hold_time', type: 'Segundos', example: '45', required: true },
{ name: 'wrap_up_time', type: 'Segundos', example: '30', required: true },
{ name: 'agent_id', type: 'String', example: 'Agente_045', required: true },
{ name: 'transfer_flag', type: 'Boolean', example: 'TRUE / FALSE', required: true },
{ name: 'caller_id', type: 'String (hash)', example: 'Hash_99283', required: false },
{ name: 'csat_score', type: 'Float', example: '4', required: false }
];
const handleDownloadTemplate = () => {
const headers = csvFields.map(f => f.name).join(',');
const exampleRow = csvFields.map(f => f.example).join(',');
const csvContent = `${headers}\n${exampleRow}\n`;
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'plantilla_beyond_diagnostic.csv';
link.click();
toast.success('Plantilla CSV descargada', { icon: '📥' });
};
const handleFileChange = (selectedFile: File | null) => { const handleFileChange = (selectedFile: File | null) => {
if (selectedFile) { if (selectedFile) {
const allowedTypes = [ const allowedTypes = [
@@ -83,456 +50,263 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
'application/vnd.ms-excel', 'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
]; ];
if (allowedTypes.includes(selectedFile.type) || if (allowedTypes.includes(selectedFile.type) ||
selectedFile.name.endsWith('.csv') || selectedFile.name.endsWith('.csv') ||
selectedFile.name.endsWith('.xlsx') || selectedFile.name.endsWith('.xlsx') ||
selectedFile.name.endsWith('.xls')) { selectedFile.name.endsWith('.xls')) {
setFile(selectedFile); setFile(selectedFile);
setUploadMethod('file');
toast.success(`Archivo "${selectedFile.name}" cargado`, { icon: '📄' }); toast.success(`Archivo "${selectedFile.name}" cargado`, { icon: '📄' });
} else { } else {
toast.error('Tipo de archivo no válido. Sube un CSV.', { icon: '❌' }); toast.error('Tipo de archivo no válido. Sube un CSV o Excel.', { icon: '❌' });
} }
} }
}; };
const onDragOver = (e: React.DragEvent) => { const onDragOver = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragging(true); setIsDragging(true);
}; };
const onDragLeave = (e: React.DragEvent) => { const onDragLeave = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragging(false); setIsDragging(false);
}; };
const onDrop = (e: React.DragEvent) => { const onDrop = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragging(false); setIsDragging(false);
const droppedFile = e.dataTransfer.files[0]; const droppedFile = e.dataTransfer.files[0];
if (droppedFile) { if (droppedFile) {
handleFileChange(droppedFile); handleFileChange(droppedFile);
} }
}; };
const handleGenerateSynthetic = () => { const canAnalyze = file !== null && costPerHour !== '' && parseFloat(costPerHour) > 0;
setIsGenerating(true);
setTimeout(() => { const handleSubmit = () => {
setUploadMethod('synthetic'); // Preparar segment_mapping
setIsGenerating(false); const segmentMapping = (highValueQueues || mediumValueQueues || lowValueQueues) ? {
toast.success('Datos sintéticos generados para demo', { icon: '✨' }); high_value_queues: (highValueQueues || '').split(',').map(q => q.trim()).filter(q => q),
}, 1500); medium_value_queues: (mediumValueQueues || '').split(',').map(q => q.trim()).filter(q => q),
low_value_queues: (lowValueQueues || '').split(',').map(q => q.trim()).filter(q => q)
} : undefined;
onAnalyze({
costPerHour: parseFloat(costPerHour) || 0,
avgCsat: parseFloat(avgCsat) || 0,
segmentMapping,
file: file || undefined,
useSynthetic: false
});
}; };
const handleSheetUrlSubmit = () => {
if (sheetUrl.trim()) {
setUploadMethod('url');
toast.success('URL de Google Sheets conectada', { icon: '🔗' });
} else {
toast.error('Introduce una URL válida', { icon: '❌' });
}
};
const canAnalyze = uploadMethod !== null && costPerHour > 0;
return ( return (
<div className="space-y-8"> <div className="space-y-6">
{/* Sección 1: Datos Manuales */} {/* Sección 1: Datos Manuales */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
className="bg-white rounded-xl shadow-lg p-8 border-2 border-slate-200" className="bg-white rounded-lg shadow-sm p-6 border border-slate-200"
> >
<div className="mb-6"> <div className="mb-6">
<h2 className="text-2xl font-bold text-slate-900 mb-2 flex items-center gap-2"> <h2 className="text-lg font-semibold text-slate-800 mb-1 flex items-center gap-2">
<Database size={24} className="text-[#6D84E3]" /> <Database size={20} className="text-[#6D84E3]" />
1. Datos Manuales Configuración Manual
</h2> </h2>
<p className="text-slate-600 text-sm"> <p className="text-slate-500 text-sm">
Introduce los parámetros de configuración para tu análisis Introduce los parámetros de configuración para tu análisis
</p> </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Coste por Hora */} {/* Coste por Hora */}
<div> <div>
<label className="block text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2"> <label className="block text-sm font-medium text-slate-700 mb-2 flex items-center gap-2">
Coste por Hora Agente (Fully Loaded) Coste por Hora Agente (Fully Loaded)
<span className="inline-flex items-center gap-1 text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full font-semibold"> <span className="inline-flex items-center gap-1 text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full font-medium">
<AlertCircle size={10} /> <AlertCircle size={10} />
Obligatorio Obligatorio
</span> </span>
</label> </label>
<div className="relative"> <div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 font-semibold text-lg"></span> <span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500"></span>
<input <input
type="number" type="number"
value={costPerHour} value={costPerHour}
onChange={(e) => setCostPerHour(parseFloat(e.target.value) || 0)} onChange={(e) => setCostPerHour(e.target.value)}
min="0" min="0"
step="0.5" step="0.5"
className="w-full pl-10 pr-20 py-3 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition text-lg font-semibold" className="w-full pl-8 pr-16 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition"
placeholder="20" placeholder="Ej: 20"
/> />
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-xs text-slate-500 font-medium">/hora</span> <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">/hora</span>
</div> </div>
<p className="text-xs text-slate-500 mt-1 flex items-start gap-1"> <p className="text-xs text-slate-500 mt-1">
<Info size={12} className="mt-0.5 flex-shrink-0" />
<span>Tipo: <strong>Número (decimal)</strong> | Ejemplo: <code className="bg-slate-100 px-1 rounded">20</code></span>
</p>
<p className="text-xs text-slate-600 mt-1">
Incluye salario, cargas sociales, infraestructura, etc. Incluye salario, cargas sociales, infraestructura, etc.
</p> </p>
</div> </div>
{/* CSAT Promedio */} {/* CSAT Promedio */}
<div> <div>
<label className="block text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2"> <label className="block text-sm font-medium text-slate-700 mb-2 flex items-center gap-2">
CSAT Promedio CSAT Promedio
<span className="inline-flex items-center gap-1 text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-semibold"> <span className="text-xs text-slate-400">(Opcional)</span>
<CheckCircle size={10} />
Opcional
</span>
</label> </label>
<div className="relative"> <div className="relative">
<input <input
type="number" type="number"
value={avgCsat} value={avgCsat}
onChange={(e) => setAvgCsat(parseFloat(e.target.value) || 0)} onChange={(e) => setAvgCsat(e.target.value)}
min="0" min="0"
max="100" max="100"
step="1" step="1"
className="w-full pr-16 py-3 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition text-lg font-semibold" className="w-full pr-12 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition"
placeholder="85" placeholder="Ej: 85"
/> />
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-xs text-slate-500 font-medium">/ 100</span> <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">/ 100</span>
</div> </div>
<p className="text-xs text-slate-500 mt-1 flex items-start gap-1"> <p className="text-xs text-slate-500 mt-1">
<Info size={12} className="mt-0.5 flex-shrink-0" />
<span>Tipo: <strong>Número (0-100)</strong> | Ejemplo: <code className="bg-slate-100 px-1 rounded">85</code></span>
</p>
<p className="text-xs text-slate-600 mt-1">
Puntuación promedio de satisfacción del cliente Puntuación promedio de satisfacción del cliente
</p> </p>
</div> </div>
{/* Segmentación por Cola/Skill */} {/* Segmentación por Cola/Skill */}
<div className="col-span-2"> <div className="col-span-2">
<div className="mb-4"> <div className="mb-3">
<h4 className="font-semibold text-slate-900 mb-2 flex items-center gap-2"> <h4 className="font-medium text-slate-700 mb-1 flex items-center gap-2">
<Database size={18} className="text-[#6D84E3]" />
Segmentación de Clientes por Cola/Skill Segmentación de Clientes por Cola/Skill
<span className="inline-flex items-center gap-1 text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-semibold"> <span className="text-xs text-slate-400">(Opcional)</span>
<CheckCircle size={10} />
Opcional
</span>
</h4> </h4>
<p className="text-sm text-slate-600"> <p className="text-sm text-slate-500">
Identifica qué colas/skills corresponden a cada segmento de cliente. Separa múltiples colas con comas. Identifica qué colas corresponden a cada segmento. Separa múltiples colas con comas.
</p> </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* High Value */}
<div> <div>
<label className="block text-sm font-semibold text-slate-700 mb-2"> <label className="block text-sm font-medium text-slate-700 mb-1">
🟢 Clientes Alto Valor (High) Alto Valor
</label> </label>
<input <input
type="text" type="text"
value={highValueQueues} value={highValueQueues}
onChange={(e) => setHighValueQueues(e.target.value)} onChange={(e) => setHighValueQueues(e.target.value)}
placeholder="VIP, Premium, Enterprise" placeholder="VIP, Premium, Enterprise"
className="w-full px-4 py-3 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition" className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition text-sm"
/> />
<p className="text-xs text-slate-500 mt-1">
Colas para clientes de alto valor
</p>
</div> </div>
{/* Medium Value */}
<div> <div>
<label className="block text-sm font-semibold text-slate-700 mb-2"> <label className="block text-sm font-medium text-slate-700 mb-1">
🟡 Clientes Valor Medio (Medium) Valor Medio
</label> </label>
<input <input
type="text" type="text"
value={mediumValueQueues} value={mediumValueQueues}
onChange={(e) => setMediumValueQueues(e.target.value)} onChange={(e) => setMediumValueQueues(e.target.value)}
placeholder="Soporte_General, Ventas" placeholder="Soporte_General, Ventas"
className="w-full px-4 py-3 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition" className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition text-sm"
/> />
<p className="text-xs text-slate-500 mt-1">
Colas para clientes estándar
</p>
</div> </div>
{/* Low Value */}
<div> <div>
<label className="block text-sm font-semibold text-slate-700 mb-2"> <label className="block text-sm font-medium text-slate-700 mb-1">
🔴 Clientes Bajo Valor (Low) Bajo Valor
</label> </label>
<input <input
type="text" type="text"
value={lowValueQueues} value={lowValueQueues}
onChange={(e) => setLowValueQueues(e.target.value)} onChange={(e) => setLowValueQueues(e.target.value)}
placeholder="Basico, Trial, Freemium" placeholder="Basico, Trial, Freemium"
className="w-full px-4 py-3 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition" className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition text-sm"
/> />
<p className="text-xs text-slate-500 mt-1">
Colas para clientes de bajo valor
</p>
</div> </div>
</div> </div>
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg"> <p className="text-xs text-slate-500 mt-2 flex items-start gap-1">
<p className="text-xs text-blue-800 flex items-start gap-2"> <Info size={12} className="mt-0.5 flex-shrink-0" />
<Info size={14} className="mt-0.5 flex-shrink-0" /> Las colas no mapeadas se clasificarán como "Valor Medio" por defecto.
<span> </p>
<strong>Nota:</strong> Las colas no mapeadas se clasificarán automáticamente como "Medium".
El matching es flexible (no distingue mayúsculas y permite coincidencias parciales).
</span>
</p>
</div>
</div> </div>
</div> </div>
</motion.div> </motion.div>
{/* Sección 2: Datos CSV */} {/* Sección 2: Subir Archivo */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
className="bg-white rounded-xl shadow-lg p-8 border-2 border-slate-200" className="bg-white rounded-lg shadow-sm p-6 border border-slate-200"
> >
<div className="mb-6"> <div className="mb-4">
<h2 className="text-2xl font-bold text-slate-900 mb-2 flex items-center gap-2"> <h2 className="text-lg font-semibold text-slate-800 mb-1 flex items-center gap-2">
<Table size={24} className="text-[#6D84E3]" /> <UploadCloud size={20} className="text-[#6D84E3]" />
2. Datos CSV (Raw Data de ACD) Datos CSV
</h2> </h2>
<p className="text-slate-600 text-sm"> <p className="text-slate-500 text-sm">
Exporta estos campos desde tu sistema ACD/CTI (Genesys, Avaya, Talkdesk, Zendesk, etc.) Sube el archivo exportado desde tu sistema ACD/CTI
</p> </p>
</div> </div>
{/* Tabla de campos requeridos */}
<div className="mb-6 overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead className="bg-slate-50">
<tr>
<th className="p-3 text-left font-semibold text-slate-700 border-b-2 border-slate-300">Campo</th>
<th className="p-3 text-left font-semibold text-slate-700 border-b-2 border-slate-300">Tipo</th>
<th className="p-3 text-left font-semibold text-slate-700 border-b-2 border-slate-300">Ejemplo</th>
<th className="p-3 text-center font-semibold text-slate-700 border-b-2 border-slate-300">Obligatorio</th>
</tr>
</thead>
<tbody>
{csvFields.map((field, index) => (
<tr key={field.name} className={clsx(
'border-b border-slate-200',
index % 2 === 0 ? 'bg-white' : 'bg-slate-50'
)}>
<td className="p-3 font-mono text-sm font-semibold text-slate-900">{field.name}</td>
<td className="p-3 text-slate-700">{field.type}</td>
<td className="p-3 font-mono text-xs text-slate-600">{field.example}</td>
<td className="p-3 text-center">
{field.required ? (
<span className="inline-flex items-center gap-1 text-xs bg-red-100 text-red-700 px-2 py-1 rounded-full font-semibold">
<AlertCircle size={10} />
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full font-semibold">
<CheckCircle size={10} />
No
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Botón de descarga de plantilla */}
<div className="mb-6">
<button
onClick={handleDownloadTemplate}
className="inline-flex items-center gap-2 px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5a6fc9] transition font-semibold"
>
<Download size={18} />
Descargar Plantilla CSV
</button>
<p className="text-xs text-slate-500 mt-2">
Descarga una plantilla con la estructura exacta de campos requeridos
</p>
</div>
{/* Opciones de carga */}
<div className="space-y-4">
<h3 className="font-semibold text-slate-900 mb-3">Elige cómo proporcionar tus datos:</h3>
{/* Opción 1: Subir archivo */}
<div className={clsx(
'border-2 rounded-lg p-4 transition-all',
uploadMethod === 'file' ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300'
)}>
<div className="flex items-start gap-3">
<input
type="radio"
name="uploadMethod"
checked={uploadMethod === 'file'}
onChange={() => setUploadMethod('file')}
className="mt-1"
/>
<div className="flex-1">
<h4 className="font-semibold text-slate-900 mb-2 flex items-center gap-2">
<UploadCloud size={18} className="text-[#6D84E3]" />
Subir Archivo CSV
</h4>
{uploadMethod === 'file' && (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={clsx(
'border-2 border-dashed rounded-lg p-6 text-center transition-all',
isDragging ? 'border-[#6D84E3] bg-blue-100' : 'border-slate-300 bg-slate-50'
)}
>
{file ? (
<div className="flex items-center justify-center gap-3">
<File size={24} className="text-green-600" />
<div className="text-left">
<p className="font-semibold text-slate-900">{file.name}</p>
<p className="text-xs text-slate-500">{(file.size / 1024).toFixed(1)} KB</p>
</div>
<button
onClick={() => setFile(null)}
className="ml-auto p-1 hover:bg-slate-200 rounded"
>
<X size={18} />
</button>
</div>
) : (
<>
<UploadCloud size={32} className="mx-auto text-slate-400 mb-2" />
<p className="text-sm text-slate-600 mb-2">
Arrastra tu archivo aquí o haz click para seleccionar
</p>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={(e) => handleFileChange(e.target.files?.[0] || null)}
className="hidden"
id="file-upload"
/>
<label
htmlFor="file-upload"
className="inline-block px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5a6fc9] transition cursor-pointer"
>
Seleccionar Archivo
</label>
</>
)}
</div>
)}
</div>
</div>
</div>
{/* Opción 2: URL Google Sheets {/* Zona de subida */}
<div className={clsx( <div
'border-2 rounded-lg p-4 transition-all', onDragOver={onDragOver}
uploadMethod === 'url' ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300' onDragLeave={onDragLeave}
)}> onDrop={onDrop}
<div className="flex items-start gap-3"> className={clsx(
<input 'border-2 border-dashed rounded-lg p-8 text-center transition-all cursor-pointer',
type="radio" isDragging ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300 bg-slate-50 hover:border-slate-400'
name="uploadMethod" )}
checked={uploadMethod === 'url'} >
onChange={() => setUploadMethod('url')} {file ? (
className="mt-1" <div className="flex items-center justify-center gap-3">
/> <File size={24} className="text-emerald-600" />
<div className="flex-1"> <div className="text-left">
<h4 className="font-semibold text-slate-900 mb-2 flex items-center gap-2"> <p className="font-medium text-slate-800">{file.name}</p>
<Sheet size={18} className="text-[#6D84E3]" /> <p className="text-xs text-slate-500">{(file.size / 1024).toFixed(1)} KB</p>
Conectar Google Sheets
</h4>
{uploadMethod === 'url' && (
<div className="flex gap-2">
<input
type="url"
value={sheetUrl}
onChange={(e) => setSheetUrl(e.target.value)}
placeholder="https://docs.google.com/spreadsheets/d/..."
className="flex-1 px-4 py-2 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition"
/>
<button
onClick={handleSheetUrlSubmit}
className="px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5a6fc9] transition font-semibold"
>
<ExternalLink size={18} />
</button>
</div>
)}
</div> </div>
<button
onClick={(e) => {
e.stopPropagation();
setFile(null);
}}
className="ml-4 p-1.5 hover:bg-slate-200 rounded-full transition"
>
<X size={18} className="text-slate-500" />
</button>
</div> </div>
</div> ) : (
*/} <>
<UploadCloud size={40} className="mx-auto text-slate-400 mb-3" />
{/* Opción 3: Datos sintéticos */} <p className="text-slate-600 mb-2">
<div className={clsx( Arrastra tu archivo aquí o haz click para seleccionar
'border-2 rounded-lg p-4 transition-all', </p>
uploadMethod === 'synthetic' ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300' <p className="text-xs text-slate-400 mb-4">
)}> Formatos aceptados: CSV, Excel (.xlsx, .xls)
<div className="flex items-start gap-3"> </p>
<input <input
type="radio" type="file"
name="uploadMethod" accept=".csv,.xlsx,.xls"
checked={uploadMethod === 'synthetic'} onChange={(e) => handleFileChange(e.target.files?.[0] || null)}
onChange={() => setUploadMethod('synthetic')} className="hidden"
className="mt-1" id="file-upload"
/> />
<div className="flex-1"> <label
<h4 className="font-semibold text-slate-900 mb-2 flex items-center gap-2"> htmlFor="file-upload"
<Sparkles size={18} className="text-[#6D84E3]" /> className="inline-block px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5a6fc9] transition cursor-pointer font-medium"
Generar Datos Sintéticos (Demo) >
</h4> Seleccionar Archivo
</label>
{uploadMethod === 'synthetic' && ( </>
<button )}
onClick={handleGenerateSynthetic}
disabled={isGenerating}
className="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg hover:from-purple-700 hover:to-pink-700 transition font-semibold disabled:opacity-50"
>
{isGenerating ? (
<>
<Loader2 size={18} className="animate-spin" />
Generando...
</>
) : (
<>
<Sparkles size={18} />
Generar Datos de Prueba
</>
)}
</button>
)}
</div>
</div>
</div>
</div> </div>
</motion.div> </motion.div>
{/* Botón de análisis */} {/* Botón de análisis */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@@ -541,40 +315,23 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
className="flex justify-center" className="flex justify-center"
> >
<button <button
onClick={() => { onClick={handleSubmit}
// Preparar segment_mapping
const segmentMapping = (highValueQueues || mediumValueQueues || lowValueQueues) ? {
high_value_queues: (highValueQueues || '').split(',').map(q => q.trim()).filter(q => q),
medium_value_queues: (mediumValueQueues || '').split(',').map(q => q.trim()).filter(q => q),
low_value_queues: (lowValueQueues || '').split(',').map(q => q.trim()).filter(q => q)
} : undefined;
// Llamar a onAnalyze con todos los datos
onAnalyze({
costPerHour,
avgCsat,
segmentMapping,
file: uploadMethod === 'file' ? file || undefined : undefined,
sheetUrl: uploadMethod === 'url' ? sheetUrl : undefined,
useSynthetic: uploadMethod === 'synthetic'
});
}}
disabled={!canAnalyze || isAnalyzing} disabled={!canAnalyze || isAnalyzing}
className={clsx( className={clsx(
'px-8 py-4 rounded-xl font-bold text-lg transition-all flex items-center gap-3', 'px-8 py-3 rounded-lg font-semibold text-lg transition-all flex items-center gap-3',
canAnalyze && !isAnalyzing canAnalyze && !isAnalyzing
? 'bg-gradient-to-r from-[#6D84E3] to-[#5a6fc9] text-white hover:scale-105 shadow-lg' ? 'bg-[#6D84E3] text-white hover:bg-[#5a6fc9] shadow-lg hover:shadow-xl'
: 'bg-slate-300 text-slate-500 cursor-not-allowed' : 'bg-slate-200 text-slate-400 cursor-not-allowed'
)} )}
> >
{isAnalyzing ? ( {isAnalyzing ? (
<> <>
<Loader2 size={24} className="animate-spin" /> <Loader2 size={22} className="animate-spin" />
Analizando... Analizando...
</> </>
) : ( ) : (
<> <>
<FileText size={24} /> <FileText size={22} />
Generar Análisis Generar Análisis
</> </>
)} )}

View File

@@ -1,29 +1,31 @@
// components/SinglePageDataRequestIntegrated.tsx // components/SinglePageDataRequestIntegrated.tsx
// Versión integrada con DataInputRedesigned + Dashboard actual // Versión simplificada con cabecera estilo dashboard
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { TierKey, AnalysisData } from '../types'; import { TierKey, AnalysisData } from '../types';
import TierSelectorEnhanced from './TierSelectorEnhanced';
import DataInputRedesigned from './DataInputRedesigned'; import DataInputRedesigned from './DataInputRedesigned';
import DashboardTabs from './DashboardTabs'; import DashboardTabs from './DashboardTabs';
import { generateAnalysis } from '../utils/analysisGenerator'; import { generateAnalysis } from '../utils/analysisGenerator';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useAuth } from '../utils/AuthContext'; import { useAuth } from '../utils/AuthContext';
// Función para formatear fecha como en el dashboard
const formatDate = (): string => {
const now = new Date();
const months = [
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
];
return `${months[now.getMonth()]} ${now.getFullYear()}`;
};
const SinglePageDataRequestIntegrated: React.FC = () => { const SinglePageDataRequestIntegrated: React.FC = () => {
const [selectedTier, setSelectedTier] = useState<TierKey>('silver');
const [view, setView] = useState<'form' | 'dashboard'>('form'); const [view, setView] = useState<'form' | 'dashboard'>('form');
const [analysisData, setAnalysisData] = useState<AnalysisData | null>(null); const [analysisData, setAnalysisData] = useState<AnalysisData | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false);
const handleTierSelect = (tier: TierKey) => {
setSelectedTier(tier);
};
const { authHeader, logout } = useAuth(); const { authHeader, logout } = useAuth();
const handleAnalyze = (config: { const handleAnalyze = (config: {
costPerHour: number; costPerHour: number;
@@ -37,52 +39,50 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
sheetUrl?: string; sheetUrl?: string;
useSynthetic?: boolean; useSynthetic?: boolean;
}) => { }) => {
console.log('🚀 handleAnalyze called with config:', config); // Validar que hay archivo
console.log('🎯 Selected tier:', selectedTier); if (!config.file) {
console.log('📄 File:', config.file); toast.error('Por favor, sube un archivo CSV o Excel.');
console.log('🔗 Sheet URL:', config.sheetUrl);
console.log('✨ Use Synthetic:', config.useSynthetic);
// Validar que hay datos
if (!config.file && !config.sheetUrl && !config.useSynthetic) {
toast.error('Por favor, sube un archivo, introduce una URL o genera datos sintéticos.');
return; return;
} }
// 🔐 Si usamos CSV real, exigir estar logado // Validar coste por hora
if (config.file && !config.useSynthetic && !authHeader) { if (!config.costPerHour || config.costPerHour <= 0) {
toast.error('Debes iniciar sesión para analizar datos reales.'); toast.error('Por favor, introduce el coste por hora del agente.');
return; return;
} }
// Exigir estar logado para analizar
if (!authHeader) {
toast.error('Debes iniciar sesión para analizar datos.');
return;
}
setIsAnalyzing(true); setIsAnalyzing(true);
toast.loading('Generando análisis...', { id: 'analyzing' }); toast.loading('Generando análisis...', { id: 'analyzing' });
setTimeout(async () => { setTimeout(async () => {
console.log('⏰ Generating analysis...');
try { try {
// Usar tier 'gold' por defecto
const data = await generateAnalysis( const data = await generateAnalysis(
selectedTier, 'gold' as TierKey,
config.costPerHour, config.costPerHour,
config.avgCsat, config.avgCsat || 0,
config.segmentMapping, config.segmentMapping,
config.file, config.file,
config.sheetUrl, config.sheetUrl,
config.useSynthetic, false, // No usar sintético
authHeader || undefined authHeader || undefined
); );
console.log('✅ Analysis generated successfully');
setAnalysisData(data); setAnalysisData(data);
setIsAnalyzing(false); setIsAnalyzing(false);
toast.dismiss('analyzing'); toast.dismiss('analyzing');
toast.success('¡Análisis completado!', { icon: '🎉' }); toast.success('¡Análisis completado!', { icon: '🎉' });
setView('dashboard'); setView('dashboard');
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (error) { } catch (error) {
console.error('Error generating analysis:', error); console.error('Error generating analysis:', error);
setIsAnalyzing(false); setIsAnalyzing(false);
toast.dismiss('analyzing'); toast.dismiss('analyzing');
@@ -106,14 +106,10 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
// Dashboard view // Dashboard view
if (view === 'dashboard' && analysisData) { if (view === 'dashboard' && analysisData) {
console.log('📊 Rendering dashboard with data:', analysisData);
console.log('📊 Heatmap data length:', analysisData.heatmapData?.length);
console.log('📊 Dimensions length:', analysisData.dimensions?.length);
try { try {
return <DashboardTabs data={analysisData} onBack={handleBackToForm} />; return <DashboardTabs data={analysisData} onBack={handleBackToForm} />;
} catch (error) { } catch (error) {
console.error('Error rendering dashboard:', error); console.error('Error rendering dashboard:', error);
return ( return (
<div className="min-h-screen bg-red-50 p-8"> <div className="min-h-screen bg-red-50 p-8">
<div className="max-w-2xl mx-auto bg-white rounded-xl shadow-lg p-6"> <div className="max-w-2xl mx-auto bg-white rounded-xl shadow-lg p-6">
@@ -135,57 +131,35 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
return ( return (
<> <>
<Toaster position="top-right" /> <Toaster position="top-right" />
<div className="w-full min-h-screen bg-gradient-to-br from-slate-50 via-[#E8EBFA] to-slate-100 font-sans">
<div className="w-full max-w-7xl mx-auto p-6 space-y-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center"
>
<h1 className="text-4xl md:text-5xl font-bold text-slate-900 mb-3">
Beyond Diagnostic
</h1>
<p className="text-lg text-slate-600">
Análisis de Readiness Agéntico para Contact Centers
</p>
<button
onClick={logout}
className="text-xs text-slate-500 hover:text-slate-800 underline mt-1"
>
Cerrar sesión
</button>
</motion.div>
{/* Tier Selection */} <div className="min-h-screen bg-slate-50">
<motion.div {/* Header estilo dashboard */}
initial={{ opacity: 0, y: 20 }} <header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
animate={{ opacity: 1, y: 0 }} <div className="max-w-7xl mx-auto px-6 py-4">
transition={{ delay: 0.1 }} <div className="flex items-center justify-between">
className="bg-white rounded-xl shadow-lg p-8" <h1 className="text-xl font-bold text-slate-800">
> AIR EUROPA - Beyond CX Analytics
<div className="mb-8"> </h1>
<h2 className="text-3xl font-bold text-slate-900 mb-2"> <div className="flex items-center gap-4">
Selecciona tu Tier de Análisis <span className="text-sm text-slate-500">{formatDate()}</span>
</h2> <button
<p className="text-slate-600"> onClick={logout}
Elige el nivel de profundidad que necesitas para tu diagnóstico className="text-xs text-slate-500 hover:text-slate-800 underline"
</p> >
Cerrar sesión
</button>
</div>
</div> </div>
</div>
</header>
<TierSelectorEnhanced {/* Contenido principal */}
selectedTier={selectedTier} <main className="max-w-7xl mx-auto px-6 py-6">
onSelectTier={handleTierSelect}
/>
</motion.div>
{/* Data Input - Using redesigned component */}
<DataInputRedesigned <DataInputRedesigned
onAnalyze={handleAnalyze} onAnalyze={handleAnalyze}
isAnalyzing={isAnalyzing} isAnalyzing={isAnalyzing}
/> />
</div> </main>
</div> </div>
</> </>
); );