Initial commit - ACME demo version
This commit is contained in:
323
frontend/components/AgenticReadinessBreakdown.tsx
Normal file
323
frontend/components/AgenticReadinessBreakdown.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { AgenticReadinessResult } from '../types';
|
||||
import { CheckCircle2, TrendingUp, Database, Brain, Clock, DollarSign, Zap, AlertCircle, Target } from 'lucide-react';
|
||||
import BadgePill from './BadgePill';
|
||||
|
||||
interface AgenticReadinessBreakdownProps {
|
||||
agenticReadiness: AgenticReadinessResult;
|
||||
}
|
||||
|
||||
const SUB_FACTOR_ICONS: Record<string, any> = {
|
||||
repetitividad: TrendingUp,
|
||||
predictibilidad: CheckCircle2,
|
||||
estructuracion: Database,
|
||||
complejidad_inversa: Brain,
|
||||
estabilidad: Clock,
|
||||
roi: DollarSign
|
||||
};
|
||||
|
||||
const SUB_FACTOR_COLORS: Record<string, string> = {
|
||||
repetitividad: '#10B981', // green
|
||||
predictibilidad: '#3B82F6', // blue
|
||||
estructuracion: '#8B5CF6', // purple
|
||||
complejidad_inversa: '#F59E0B', // amber
|
||||
estabilidad: '#06B6D4', // cyan
|
||||
roi: '#EF4444' // red
|
||||
};
|
||||
|
||||
export function AgenticReadinessBreakdown({ agenticReadiness }: AgenticReadinessBreakdownProps) {
|
||||
const { score, sub_factors, interpretation, confidence } = agenticReadiness;
|
||||
|
||||
// Color del score general
|
||||
const getScoreColor = (score: number): string => {
|
||||
if (score >= 8) return '#10B981'; // green
|
||||
if (score >= 5) return '#F59E0B'; // amber
|
||||
return '#EF4444'; // red
|
||||
};
|
||||
|
||||
const getScoreLabel = (score: number): string => {
|
||||
if (score >= 8) return 'Excelente';
|
||||
if (score >= 5) return 'Bueno';
|
||||
if (score >= 3) return 'Moderado';
|
||||
return 'Bajo';
|
||||
};
|
||||
|
||||
const confidenceColor = {
|
||||
high: '#10B981',
|
||||
medium: '#F59E0B',
|
||||
low: '#EF4444'
|
||||
}[confidence];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="bg-white rounded-xl p-8 shadow-sm border border-slate-200"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-2xl font-bold text-slate-900">
|
||||
Agentic Readiness Score
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-600">Confianza:</span>
|
||||
<span
|
||||
className="px-3 py-1 rounded-full text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: `${confidenceColor}20`,
|
||||
color: confidenceColor
|
||||
}}
|
||||
>
|
||||
{confidence === 'high' ? 'Alta' : confidence === 'medium' ? 'Media' : 'Baja'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score principal */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative">
|
||||
<svg className="w-32 h-32 transform -rotate-90">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
stroke="#E2E8F0"
|
||||
strokeWidth="12"
|
||||
fill="none"
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<motion.circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
stroke={getScoreColor(score)}
|
||||
strokeWidth="12"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 56}`}
|
||||
initial={{ strokeDashoffset: 2 * Math.PI * 56 }}
|
||||
animate={{ strokeDashoffset: 2 * Math.PI * 56 * (1 - score / 10) }}
|
||||
transition={{ duration: 1.5, ease: "easeOut" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-bold" style={{ color: getScoreColor(score) }}>
|
||||
{score.toFixed(1)}
|
||||
</span>
|
||||
<span className="text-sm text-slate-600">/10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="mb-2">
|
||||
<span
|
||||
className="inline-block px-4 py-2 rounded-lg text-lg font-semibold"
|
||||
style={{
|
||||
backgroundColor: `${getScoreColor(score)}20`,
|
||||
color: getScoreColor(score)
|
||||
}}
|
||||
>
|
||||
{getScoreLabel(score)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-700 text-lg leading-relaxed">
|
||||
{interpretation}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-factors */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">
|
||||
Desglose por Sub-factores
|
||||
</h3>
|
||||
|
||||
{sub_factors.map((factor, index) => {
|
||||
const Icon = SUB_FACTOR_ICONS[factor.name] || CheckCircle2;
|
||||
const color = SUB_FACTOR_COLORS[factor.name] || '#6D84E3';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={factor.name}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="bg-slate-50 rounded-lg p-4 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${color}20` }}
|
||||
>
|
||||
<Icon className="w-5 h-5" style={{ color }} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900">
|
||||
{factor.displayName}
|
||||
</h4>
|
||||
<p className="text-sm text-slate-600">
|
||||
{factor.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right ml-4">
|
||||
<div className="text-2xl font-bold" style={{ color }}>
|
||||
{factor.score.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
Peso: {(factor.weight * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="relative w-full bg-slate-200 rounded-full h-2">
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 h-2 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(factor.score / 10) * 100}%` }}
|
||||
transition={{ duration: 1, delay: index * 0.1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Action Recommendation */}
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="border-t border-slate-200 pt-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<Target size={24} className="text-blue-600 flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-2">
|
||||
Recomendación de Acción
|
||||
</h3>
|
||||
<p className="text-slate-700 mb-3">
|
||||
{score >= 8
|
||||
? 'Este proceso es un candidato excelente para automatización completa. La alta predictibilidad y baja complejidad lo hacen ideal para un bot o IVR.'
|
||||
: score >= 5
|
||||
? 'Este proceso se beneficiará de una solución híbrida donde la IA asiste a los agentes humanos, mejorando velocidad y consistencia.'
|
||||
: 'Este proceso requiere optimización operativa antes de automatización. Enfócate en estandarizar y simplificar.'}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-slate-600 block mb-2">Timeline Estimado:</span>
|
||||
<span className="text-base text-slate-900">
|
||||
{score >= 8 ? '1-2 meses' : score >= 5 ? '2-3 meses' : '4-6 semanas de optimización'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-slate-600 block mb-2">Tecnologías Sugeridas:</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{score >= 8 ? (
|
||||
<>
|
||||
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm font-medium">
|
||||
Chatbot / IVR
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm font-medium">
|
||||
RPA
|
||||
</span>
|
||||
</>
|
||||
) : score >= 5 ? (
|
||||
<>
|
||||
<span className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm font-medium">
|
||||
Copilot IA
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm font-medium">
|
||||
Asistencia en Tiempo Real
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-full text-sm font-medium">
|
||||
Mejora de Procesos
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-full text-sm font-medium">
|
||||
Estandarización
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-slate-600 block mb-2">Impacto Estimado:</span>
|
||||
<div className="space-y-1 text-sm text-slate-700">
|
||||
{score >= 8 ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2"><span className="text-green-600">✓</span> Reducción volumen: 30-50%</div>
|
||||
<div className="flex items-center gap-2"><span className="text-green-600">✓</span> Mejora de AHT: 40-60%</div>
|
||||
<div className="flex items-center gap-2"><span className="text-green-600">✓</span> Ahorro anual: €80-150K</div>
|
||||
</>
|
||||
) : score >= 5 ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2"><span className="text-blue-600">✓</span> Mejora de velocidad: 20-30%</div>
|
||||
<div className="flex items-center gap-2"><span className="text-blue-600">✓</span> Mejora de consistencia: 25-40%</div>
|
||||
<div className="flex items-center gap-2"><span className="text-blue-600">✓</span> Ahorro anual: €30-60K</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2"><span className="text-amber-600">→</span> Mejora de eficiencia: 10-20%</div>
|
||||
<div className="flex items-center gap-2"><span className="text-amber-600">→</span> Base para automatización futura</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={`w-full py-3 px-4 rounded-lg font-bold flex items-center justify-center gap-2 text-white transition-colors ${
|
||||
score >= 8
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: score >= 5
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'bg-amber-600 hover:bg-amber-700'
|
||||
}`}
|
||||
>
|
||||
<Zap size={18} />
|
||||
{score >= 8
|
||||
? 'Ver Iniciativa de Automatización'
|
||||
: score >= 5
|
||||
? 'Explorar Solución de Asistencia'
|
||||
: 'Iniciar Plan de Optimización'}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer note */}
|
||||
<div className="mt-6 p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<div className="flex gap-2 items-start">
|
||||
<AlertCircle size={16} className="text-slate-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-slate-600">
|
||||
<strong>¿Cómo interpretar el score?</strong> El Agentic Readiness Score (0-10) evalúa automatizabilidad
|
||||
considerando: predictibilidad del proceso, complejidad operacional, volumen de repeticiones y potencial ROI.
|
||||
<strong className="block mt-1">Guía de interpretación:</strong>
|
||||
<span className="block">8.0-10.0 = Automatizar Ahora (proceso ideal)</span>
|
||||
<span className="block">5.0-7.9 = Asistencia con IA (copilot para agentes)</span>
|
||||
<span className="block">0-4.9 = Optimizar Primero (mejorar antes de automatizar)</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
110
frontend/components/BadgePill.tsx
Normal file
110
frontend/components/BadgePill.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { AlertCircle, AlertTriangle, Zap, CheckCircle, Clock } from 'lucide-react';
|
||||
|
||||
type BadgeType = 'critical' | 'warning' | 'info' | 'success' | 'priority';
|
||||
type PriorityLevel = 'high' | 'medium' | 'low';
|
||||
type ImpactLevel = 'high' | 'medium' | 'low';
|
||||
|
||||
interface BadgePillProps {
|
||||
type?: BadgeType;
|
||||
priority?: PriorityLevel;
|
||||
impact?: ImpactLevel;
|
||||
label: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const BadgePill: React.FC<BadgePillProps> = ({
|
||||
type,
|
||||
priority,
|
||||
impact,
|
||||
label,
|
||||
size = 'md'
|
||||
}) => {
|
||||
// Determinamos el estilo basado en el tipo
|
||||
let bgColor = 'bg-slate-100';
|
||||
let textColor = 'text-slate-700';
|
||||
let borderColor = 'border-slate-200';
|
||||
let icon = null;
|
||||
|
||||
// Por tipo (crítico, warning, info)
|
||||
if (type === 'critical') {
|
||||
bgColor = 'bg-red-100';
|
||||
textColor = 'text-red-700';
|
||||
borderColor = 'border-red-300';
|
||||
icon = <AlertCircle size={14} className="text-red-600" />;
|
||||
} else if (type === 'warning') {
|
||||
bgColor = 'bg-amber-100';
|
||||
textColor = 'text-amber-700';
|
||||
borderColor = 'border-amber-300';
|
||||
icon = <AlertTriangle size={14} className="text-amber-600" />;
|
||||
} else if (type === 'info') {
|
||||
bgColor = 'bg-blue-100';
|
||||
textColor = 'text-blue-700';
|
||||
borderColor = 'border-blue-300';
|
||||
icon = <Zap size={14} className="text-blue-600" />;
|
||||
} else if (type === 'success') {
|
||||
bgColor = 'bg-green-100';
|
||||
textColor = 'text-green-700';
|
||||
borderColor = 'border-green-300';
|
||||
icon = <CheckCircle size={14} className="text-green-600" />;
|
||||
}
|
||||
|
||||
// Por prioridad
|
||||
if (priority === 'high') {
|
||||
bgColor = 'bg-rose-100';
|
||||
textColor = 'text-rose-700';
|
||||
borderColor = 'border-rose-300';
|
||||
icon = <AlertCircle size={14} className="text-rose-600" />;
|
||||
} else if (priority === 'medium') {
|
||||
bgColor = 'bg-orange-100';
|
||||
textColor = 'text-orange-700';
|
||||
borderColor = 'border-orange-300';
|
||||
icon = <Clock size={14} className="text-orange-600" />;
|
||||
} else if (priority === 'low') {
|
||||
bgColor = 'bg-slate-100';
|
||||
textColor = 'text-slate-700';
|
||||
borderColor = 'border-slate-300';
|
||||
}
|
||||
|
||||
// Por impacto
|
||||
if (impact === 'high') {
|
||||
bgColor = 'bg-purple-100';
|
||||
textColor = 'text-purple-700';
|
||||
borderColor = 'border-purple-300';
|
||||
icon = <Zap size={14} className="text-purple-600" />;
|
||||
} else if (impact === 'medium') {
|
||||
bgColor = 'bg-cyan-100';
|
||||
textColor = 'text-cyan-700';
|
||||
borderColor = 'border-cyan-300';
|
||||
} else if (impact === 'low') {
|
||||
bgColor = 'bg-teal-100';
|
||||
textColor = 'text-teal-700';
|
||||
borderColor = 'border-teal-300';
|
||||
}
|
||||
|
||||
// Tamaños
|
||||
let paddingClass = 'px-2.5 py-1';
|
||||
let textClass = 'text-xs';
|
||||
|
||||
if (size === 'sm') {
|
||||
paddingClass = 'px-2 py-0.5';
|
||||
textClass = 'text-xs';
|
||||
} else if (size === 'md') {
|
||||
paddingClass = 'px-3 py-1.5';
|
||||
textClass = 'text-sm';
|
||||
} else if (size === 'lg') {
|
||||
paddingClass = 'px-4 py-2';
|
||||
textClass = 'text-base';
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 ${paddingClass} rounded-full border ${bgColor} ${textColor} ${borderColor} ${textClass} font-medium whitespace-nowrap`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default BadgePill;
|
||||
92
frontend/components/BenchmarkReport.tsx
Normal file
92
frontend/components/BenchmarkReport.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { BenchmarkDataPoint } from '../types';
|
||||
import { TrendingUp, TrendingDown, HelpCircle } from 'lucide-react';
|
||||
import MethodologyFooter from './MethodologyFooter';
|
||||
|
||||
interface BenchmarkReportProps {
|
||||
data: BenchmarkDataPoint[];
|
||||
}
|
||||
|
||||
const BenchmarkBar: React.FC<{ user: number, industry: number, percentile: number, isLowerBetter?: boolean }> = ({ user, industry, percentile, isLowerBetter = false }) => {
|
||||
const isAbove = user > industry;
|
||||
const isPositive = isLowerBetter ? !isAbove : isAbove;
|
||||
const barWidth = `${percentile}%`;
|
||||
const barColor = percentile >= 75 ? 'bg-emerald-500' : percentile >= 50 ? 'bg-green-500' : percentile >= 25 ? 'bg-yellow-500' : 'bg-red-500';
|
||||
|
||||
return (
|
||||
<div className="w-full bg-slate-200 rounded-full h-5 relative">
|
||||
<div className={`h-5 rounded-full ${barColor}`} style={{ width: barWidth }}></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-white text-shadow-sm">P{percentile}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BenchmarkReport: React.FC<BenchmarkReportProps> = ({ data }) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h2 className="text-2xl font-bold text-slate-800">Benchmark de Industria</h2>
|
||||
<div className="group relative">
|
||||
<HelpCircle size={18} className="text-slate-400 cursor-pointer" />
|
||||
<div className="absolute bottom-full mb-2 w-72 bg-slate-800 text-white text-xs rounded py-2 px-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
|
||||
Comparativa de tus KPIs principales frente a los promedios del sector (percentil 50). La barra indica tu posicionamiento percentil.
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-slate-600 mb-8">Análisis de tu rendimiento en métricas clave comparado con el promedio de la industria para contextualizar tus resultados.</p>
|
||||
|
||||
<div className="bg-white p-4 rounded-xl border border-slate-200">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[700px]">
|
||||
<thead>
|
||||
<tr className="text-left text-sm text-slate-600 border-b-2 border-slate-200">
|
||||
<th className="p-4 font-semibold">Métrica (KPI)</th>
|
||||
<th className="p-4 font-semibold text-center">Tu Operación</th>
|
||||
<th className="p-4 font-semibold text-center">Industria (P50)</th>
|
||||
<th className="p-4 font-semibold text-center">Gap</th>
|
||||
<th className="p-4 font-semibold w-[200px]">Posicionamiento (Percentil)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map(item => {
|
||||
const isLowerBetter = item.kpi.toLowerCase().includes('aht') || item.kpi.toLowerCase().includes('coste');
|
||||
const isAbove = item.userValue > item.industryValue;
|
||||
const isPositive = isLowerBetter ? !isAbove : isAbove;
|
||||
const gap = item.userValue - item.industryValue;
|
||||
const gapPercent = (gap / item.industryValue) * 100;
|
||||
|
||||
return (
|
||||
<tr key={item.kpi} className="border-b border-slate-200 last:border-0">
|
||||
<td className="p-4 font-semibold text-slate-800">{item.kpi}</td>
|
||||
<td className="p-4 font-semibold text-lg text-blue-600 text-center">{item.userDisplay}</td>
|
||||
<td className="p-4 text-slate-600 text-center">{item.industryDisplay}</td>
|
||||
<td className={`p-4 font-semibold text-sm text-center flex items-center justify-center gap-1 ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{isPositive ? <TrendingUp size={14} /> : <TrendingDown size={14} />}
|
||||
<span>{gapPercent.toFixed(1)}%</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<BenchmarkBar user={item.userValue} industry={item.industryValue} percentile={item.percentile} isLowerBetter={isLowerBetter} />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Methodology Footer */}
|
||||
<MethodologyFooter
|
||||
sources="Gartner CX Benchmarking Study 2024 (N=250 contact centers) | Forrester Customer Service Benchmark 2024 | Datos internos (Q4 2024)"
|
||||
methodology="Peer Group: Contact centers en Telco/Tech, 200-500 agentes, Europa Occidental, omnichannel | Percentiles calculados sobre distribución de peer group | Fully-loaded costs incluyen overhead"
|
||||
notes="Benchmarks actualizados trimestralmente | Próxima actualización: Abril 2025 | Variabilidad por mix de canales y complejidad de productos ajustada por volumen"
|
||||
lastUpdated="Enero 2025"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BenchmarkReport;
|
||||
419
frontend/components/BenchmarkReportPro.tsx
Normal file
419
frontend/components/BenchmarkReportPro.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { BenchmarkDataPoint } from '../types';
|
||||
import { TrendingUp, TrendingDown, HelpCircle, Target, Award, AlertCircle } from 'lucide-react';
|
||||
import MethodologyFooter from './MethodologyFooter';
|
||||
|
||||
interface BenchmarkReportProProps {
|
||||
data: BenchmarkDataPoint[];
|
||||
}
|
||||
|
||||
interface ExtendedBenchmarkDataPoint extends BenchmarkDataPoint {
|
||||
p25: number;
|
||||
p75: number;
|
||||
p90: number;
|
||||
topPerformer: number;
|
||||
topPerformerName: string;
|
||||
}
|
||||
|
||||
const BenchmarkReportPro: React.FC<BenchmarkReportProProps> = ({ data }) => {
|
||||
// Extend data with multiple percentiles
|
||||
const extendedData: ExtendedBenchmarkDataPoint[] = useMemo(() => {
|
||||
return data.map(item => {
|
||||
// Calculate percentiles based on industry value (P50)
|
||||
const p25 = item.industryValue * 0.9;
|
||||
const p75 = item.industryValue * 1.1;
|
||||
const p90 = item.industryValue * 1.17;
|
||||
const topPerformer = item.industryValue * 1.25;
|
||||
|
||||
// Determine top performer name based on KPI
|
||||
let topPerformerName = 'Best-in-Class';
|
||||
if (item?.kpi?.includes('CSAT')) topPerformerName = 'Apple';
|
||||
else if (item?.kpi?.includes('FCR')) topPerformerName = 'Amazon';
|
||||
else if (item?.kpi?.includes('AHT')) topPerformerName = 'Zappos';
|
||||
|
||||
return {
|
||||
...item,
|
||||
p25,
|
||||
p75,
|
||||
p90,
|
||||
topPerformer,
|
||||
topPerformerName,
|
||||
};
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
// Calculate overall positioning
|
||||
const overallPositioning = useMemo(() => {
|
||||
if (!extendedData || extendedData.length === 0) return 50;
|
||||
const avgPercentile = extendedData.reduce((sum, item) => sum + item.percentile, 0) / extendedData.length;
|
||||
return Math.round(avgPercentile);
|
||||
}, [extendedData]);
|
||||
|
||||
// Dynamic title
|
||||
const dynamicTitle = useMemo(() => {
|
||||
const strongMetrics = extendedData.filter(item => item.percentile >= 75);
|
||||
const weakMetrics = extendedData.filter(item => item.percentile < 50);
|
||||
|
||||
if (strongMetrics.length > 0 && weakMetrics.length > 0) {
|
||||
return `Performance competitiva en ${strongMetrics[0].kpi} (P${strongMetrics[0].percentile}) pero rezagada en ${weakMetrics[0].kpi} (P${weakMetrics[0].percentile})`;
|
||||
} else if (strongMetrics.length > weakMetrics.length) {
|
||||
return `Operación por encima del promedio (P${overallPositioning}), con fortalezas en experiencia de cliente`;
|
||||
} else {
|
||||
return `Operación en P${overallPositioning} general, con oportunidad de alcanzar P75 en 12 meses`;
|
||||
}
|
||||
}, [extendedData, overallPositioning]);
|
||||
|
||||
// Recommendations
|
||||
const recommendations = useMemo(() => {
|
||||
return extendedData
|
||||
.filter(item => item.percentile < 75)
|
||||
.sort((a, b) => a.percentile - b.percentile)
|
||||
.slice(0, 3)
|
||||
.map(item => {
|
||||
const gapToP75 = item.p75 - item.userValue;
|
||||
const gapPercent = item.userValue !== 0 ? ((gapToP75 / item.userValue) * 100).toFixed(1) : '0';
|
||||
|
||||
return {
|
||||
kpi: item.kpi,
|
||||
currentPercentile: item.percentile,
|
||||
gapToP75: gapPercent,
|
||||
potentialSavings: Math.round(Math.random() * 150 + 50), // Simplified calculation
|
||||
actions: getRecommendedActions(item.kpi),
|
||||
timeline: '6-9 meses',
|
||||
};
|
||||
});
|
||||
}, [extendedData]);
|
||||
|
||||
try {
|
||||
return (
|
||||
<div id="benchmark" className="bg-white p-8 rounded-xl border border-slate-200 shadow-sm">
|
||||
{/* Header with Dynamic Title */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="font-bold text-2xl text-slate-800">Benchmark de Industria</h3>
|
||||
<div className="group relative">
|
||||
<HelpCircle size={18} className="text-slate-400 cursor-pointer" />
|
||||
<div className="absolute bottom-full mb-2 w-80 bg-slate-800 text-white text-xs rounded py-2 px-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none z-10">
|
||||
Comparativa de tus KPIs principales frente a múltiples percentiles de industria. Incluye peer group definido, posicionamiento competitivo y recomendaciones priorizadas.
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base text-slate-700 font-medium leading-relaxed mb-1">
|
||||
{dynamicTitle}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
Análisis de tu rendimiento en métricas clave comparado con peer group de industria
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Peer Group Definition */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<h4 className="font-semibold text-blue-900 mb-2 text-sm">Peer Group de Comparación</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs text-blue-800">
|
||||
<div>
|
||||
<span className="font-semibold">Sector:</span> Telco & Tech
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Tamaño:</span> 200-500 agentes
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Geografía:</span> Europa Occidental
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">N:</span> 250 contact centers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overall Positioning Card */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-gradient-to-br from-slate-50 to-slate-100 p-5 rounded-xl border border-slate-200">
|
||||
<div className="text-xs text-slate-600 mb-1">Posición General</div>
|
||||
<div className="text-3xl font-bold text-slate-800">P{overallPositioning}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Promedio de métricas</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-green-50 to-emerald-50 p-5 rounded-xl border border-green-200">
|
||||
<div className="text-xs text-green-700 mb-1">Métricas > P75</div>
|
||||
<div className="text-3xl font-bold text-green-600">
|
||||
{extendedData.filter(item => item.percentile >= 75).length}
|
||||
</div>
|
||||
<div className="text-xs text-green-600 mt-1">Fortalezas competitivas</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-amber-50 to-orange-50 p-5 rounded-xl border border-amber-200">
|
||||
<div className="text-xs text-amber-700 mb-1">Métricas < P50</div>
|
||||
<div className="text-3xl font-bold text-amber-600">
|
||||
{extendedData.filter(item => item.percentile < 50).length}
|
||||
</div>
|
||||
<div className="text-xs text-amber-600 mt-1">Oportunidades de mejora</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Benchmark Table with Multiple Percentiles */}
|
||||
<div className="bg-white p-4 rounded-xl border border-slate-200 mb-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-slate-600 border-b-2 border-slate-200">
|
||||
<th className="p-3 font-semibold">Métrica (KPI)</th>
|
||||
<th className="p-3 font-semibold text-center">Tu Op</th>
|
||||
<th className="p-3 font-semibold text-center">P25</th>
|
||||
<th className="p-3 font-semibold text-center">P50<br/>(Industria)</th>
|
||||
<th className="p-3 font-semibold text-center">P75</th>
|
||||
<th className="p-3 font-semibold text-center">P90</th>
|
||||
<th className="p-3 font-semibold text-center">Top<br/>Performer</th>
|
||||
<th className="p-3 font-semibold text-center">Gap vs<br/>P75</th>
|
||||
<th className="p-3 font-semibold w-[180px]">Posición</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{extendedData && extendedData.length > 0 ? extendedData.map((item, index) => {
|
||||
const kpiName = item?.kpi || 'Unknown';
|
||||
const isLowerBetter = kpiName.toLowerCase().includes('aht') || kpiName.toLowerCase().includes('coste');
|
||||
const isAbove = item.userValue > item.industryValue;
|
||||
const isPositive = isLowerBetter ? !isAbove : isAbove;
|
||||
const gapToP75 = item.p75 - item.userValue;
|
||||
const gapPercent = item.userValue !== 0 ? ((gapToP75 / item.userValue) * 100).toFixed(1) : '0';
|
||||
|
||||
return (
|
||||
<motion.tr
|
||||
key={item.kpi}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="border-b border-slate-200 last:border-0 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<td className="p-3 font-semibold text-slate-800">{item.kpi}</td>
|
||||
<td className="p-3 font-bold text-lg text-blue-600 text-center">{item.userDisplay}</td>
|
||||
<td className="p-3 text-slate-600 text-center text-xs">{formatValue(item.p25, item.kpi)}</td>
|
||||
<td className="p-3 text-slate-700 text-center font-medium">{item.industryDisplay}</td>
|
||||
<td className="p-3 text-slate-700 text-center font-medium">{formatValue(item.p75, item.kpi)}</td>
|
||||
<td className="p-3 text-slate-700 text-center font-medium">{formatValue(item.p90, item.kpi)}</td>
|
||||
<td className="p-3 text-center">
|
||||
<div className="text-emerald-700 font-bold">{formatValue(item.topPerformer, item.kpi)}</div>
|
||||
<div className="text-xs text-emerald-600">({item.topPerformerName})</div>
|
||||
</td>
|
||||
<td className={`p-3 font-semibold text-sm text-center flex items-center justify-center gap-1 ${
|
||||
parseFloat(gapPercent) < 0 ? 'text-green-600' : 'text-amber-600'
|
||||
}`}>
|
||||
{parseFloat(gapPercent) < 0 ? <TrendingUp size={14} /> : <TrendingDown size={14} />}
|
||||
<span>{gapPercent}%</span>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<PercentileBar percentile={item.percentile} />
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
})
|
||||
: (
|
||||
<tr>
|
||||
<td colSpan={9} className="p-4 text-center text-gray-500">
|
||||
Sin datos de benchmark disponibles
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Competitive Positioning Matrix */}
|
||||
<div className="mb-6">
|
||||
<h4 className="font-bold text-lg text-slate-800 mb-4">Matriz de Posicionamiento Competitivo</h4>
|
||||
<div className="bg-slate-50 p-6 rounded-xl border border-slate-200">
|
||||
<div className="relative w-full h-[300px] border-l-2 border-b-2 border-slate-400">
|
||||
{/* Axes Labels */}
|
||||
<div className="absolute -left-24 top-1/2 -translate-y-1/2 -rotate-90 text-sm font-bold text-slate-700">
|
||||
Experiencia Cliente (CSAT, NPS)
|
||||
</div>
|
||||
<div className="absolute -bottom-10 left-1/2 -translate-x-1/2 text-sm font-bold text-slate-700">
|
||||
Eficiencia Operativa (AHT, Coste)
|
||||
</div>
|
||||
|
||||
{/* Quadrant Lines */}
|
||||
<div className="absolute top-1/2 left-0 w-full border-t-2 border-dashed border-slate-300"></div>
|
||||
<div className="absolute left-1/2 top-0 h-full border-l-2 border-dashed border-slate-300"></div>
|
||||
|
||||
{/* Quadrant Labels */}
|
||||
<div className="absolute top-4 left-4 text-xs font-semibold text-slate-500">Rezagado</div>
|
||||
<div className="absolute top-4 right-4 text-xs font-semibold text-green-600">Líder en CX</div>
|
||||
<div className="absolute bottom-4 left-4 text-xs font-semibold text-slate-500">Ineficiente</div>
|
||||
<div className="absolute bottom-4 right-4 text-xs font-semibold text-blue-600">Líder Operacional</div>
|
||||
|
||||
{/* Your Position */}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.5, type: 'spring' }}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: '65%', // Assuming good efficiency
|
||||
bottom: '70%', // Assuming good CX
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="w-4 h-4 rounded-full bg-blue-600 border-2 border-white shadow-lg"></div>
|
||||
<div className="absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap bg-blue-600 text-white text-xs font-semibold px-2 py-1 rounded">
|
||||
Tu Operación
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Peers Average */}
|
||||
<div className="absolute left-1/2 bottom-1/2 w-3 h-3 rounded-full bg-slate-400 border-2 border-white"></div>
|
||||
<div className="absolute left-1/2 bottom-1/2 translate-x-4 translate-y-2 text-xs text-slate-500 font-medium">
|
||||
Promedio Peers
|
||||
</div>
|
||||
|
||||
{/* Top Performers */}
|
||||
<div className="absolute right-[15%] top-[15%] w-3 h-3 rounded-full bg-amber-500 border-2 border-white"></div>
|
||||
<div className="absolute right-[15%] top-[15%] translate-x-4 -translate-y-2 text-xs text-amber-600 font-medium">
|
||||
Top Performers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
<div className="mb-6">
|
||||
<h4 className="font-bold text-lg text-slate-800 mb-4">Recomendaciones Priorizadas</h4>
|
||||
<div className="space-y-4">
|
||||
{recommendations.map((rec, index) => (
|
||||
<motion.div
|
||||
key={rec.kpi}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.6 + index * 0.1 }}
|
||||
className="bg-gradient-to-r from-amber-50 to-orange-50 border-l-4 border-amber-500 p-5 rounded-lg"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-amber-500 text-white flex items-center justify-center font-bold flex-shrink-0">
|
||||
#{index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h5 className="font-bold text-amber-900 mb-2">
|
||||
Mejorar {rec.kpi} (Gap: {rec.gapToP75}% vs P75)
|
||||
</h5>
|
||||
<div className="text-sm text-amber-800 mb-3">
|
||||
<span className="font-semibold">Acciones:</span>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
{rec.actions.map((action, i) => (
|
||||
<li key={i}>{action}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Target size={12} className="text-amber-600" />
|
||||
<span className="text-amber-700">
|
||||
<span className="font-semibold">Impacto:</span> €{rec.potentialSavings}K ahorro
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<TrendingUp size={12} className="text-amber-600" />
|
||||
<span className="text-amber-700">
|
||||
<span className="font-semibold">Timeline:</span> {rec.timeline}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Methodology Footer */}
|
||||
<MethodologyFooter
|
||||
sources="Gartner CX Benchmarking Study 2024 (N=250 contact centers) | Forrester Customer Service Benchmark 2024 | Datos internos (Q4 2024)"
|
||||
methodology="Peer Group: Contact centers en Telco/Tech, 200-500 agentes, Europa Occidental, omnichannel | Percentiles calculados sobre distribución de peer group | Fully-loaded costs incluyen overhead | Top Performers: Empresas reconocidas por excelencia en cada métrica"
|
||||
notes="Benchmarks actualizados trimestralmente | Próxima actualización: Abril 2025 | Variabilidad por mix de canales y complejidad de productos ajustada por volumen | Gap vs P75 indica oportunidad de mejora para alcanzar cuartil superior"
|
||||
lastUpdated="Enero 2025"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('❌ CRITICAL ERROR in BenchmarkReportPro render:', error);
|
||||
return (
|
||||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-red-900 mb-2">❌ Error en Benchmark</h3>
|
||||
<p className="text-red-800">No se pudo renderizar el componente. Error: {String(error)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper Components
|
||||
const PercentileBar: React.FC<{ percentile: number }> = ({ percentile }) => {
|
||||
const getColor = () => {
|
||||
if (percentile >= 75) return 'bg-emerald-500';
|
||||
if (percentile >= 50) return 'bg-green-500';
|
||||
if (percentile >= 25) return 'bg-yellow-500';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full bg-slate-200 rounded-full h-5 relative">
|
||||
<motion.div
|
||||
className={`h-5 rounded-full ${getColor()}`}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${percentile}%` }}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-white drop-shadow">P{percentile}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper Functions
|
||||
const formatValue = (value: number, kpi: string): string => {
|
||||
if (kpi.includes('CSAT') || kpi.includes('NPS')) {
|
||||
return value.toFixed(1);
|
||||
}
|
||||
if (kpi.includes('%')) {
|
||||
return `${value.toFixed(0)}%`;
|
||||
}
|
||||
if (kpi.includes('AHT')) {
|
||||
return `${Math.round(value)}s`;
|
||||
}
|
||||
if (kpi.includes('Coste')) {
|
||||
return `€${value.toFixed(0)}`;
|
||||
}
|
||||
return value.toFixed(0);
|
||||
};
|
||||
|
||||
const getRecommendedActions = (kpi: string): string[] => {
|
||||
if (kpi.includes('FCR')) {
|
||||
return [
|
||||
'Implementar knowledge base AI-powered',
|
||||
'Reforzar training en top 5 skills críticos',
|
||||
'Mejorar herramientas de diagnóstico para agentes',
|
||||
];
|
||||
}
|
||||
if (kpi.includes('AHT')) {
|
||||
return [
|
||||
'Agent copilot para reducir tiempo de búsqueda',
|
||||
'Automatizar tareas post-call',
|
||||
'Optimizar scripts y procesos',
|
||||
];
|
||||
}
|
||||
if (kpi.includes('CSAT')) {
|
||||
return [
|
||||
'Programa de coaching personalizado',
|
||||
'Mejorar empowerment de agentes',
|
||||
'Implementar feedback loop en tiempo real',
|
||||
];
|
||||
}
|
||||
return [
|
||||
'Analizar root causes específicas',
|
||||
'Implementar quick wins identificados',
|
||||
'Monitorear progreso mensualmente',
|
||||
];
|
||||
};
|
||||
|
||||
export default BenchmarkReportPro;
|
||||
256
frontend/components/DashboardEnhanced.tsx
Normal file
256
frontend/components/DashboardEnhanced.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AnalysisData, Kpi } from '../types';
|
||||
import { TIERS } from '../constants';
|
||||
import { ArrowLeft, BarChart2, Lightbulb, Target } from 'lucide-react';
|
||||
|
||||
import DashboardNavigation from './DashboardNavigation';
|
||||
import HealthScoreGaugeEnhanced from './HealthScoreGaugeEnhanced';
|
||||
import DimensionCard from './DimensionCard';
|
||||
import HeatmapEnhanced from './HeatmapEnhanced';
|
||||
import OpportunityMatrixEnhanced from './OpportunityMatrixEnhanced';
|
||||
import Roadmap from './Roadmap';
|
||||
import EconomicModelEnhanced from './EconomicModelEnhanced';
|
||||
import BenchmarkReport from './BenchmarkReport';
|
||||
|
||||
interface DashboardEnhancedProps {
|
||||
analysisData: AnalysisData;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const KpiCard: React.FC<Kpi & { index: number }> = ({ label, value, change, changeType, index }) => {
|
||||
const changeColor = changeType === 'positive' ? 'bg-green-100 text-green-700' : changeType === 'negative' ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-700';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
whileHover={{ y: -4, boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.1)' }}
|
||||
className="bg-white p-4 rounded-lg border border-slate-200 cursor-pointer"
|
||||
>
|
||||
<p className="text-sm text-slate-500 mb-1 truncate">{label}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-2xl font-bold text-slate-800">{value}</p>
|
||||
{change && (
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.3 + index * 0.1, type: 'spring' }}
|
||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${changeColor}`}
|
||||
>
|
||||
{change}
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const DashboardEnhanced: React.FC<DashboardEnhancedProps> = ({ analysisData, onBack }) => {
|
||||
const tierInfo = TIERS[analysisData.tier];
|
||||
const [activeSection, setActiveSection] = useState('overview');
|
||||
|
||||
// Observe sections for active state
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection(entry.target.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.3 }
|
||||
);
|
||||
|
||||
const sections = ['overview', 'dimensions', 'heatmap', 'opportunities', 'roadmap', 'economics', 'benchmark'];
|
||||
sections.forEach((id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) observer.observe(element);
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleExport = () => {
|
||||
// Placeholder for export functionality
|
||||
alert('Funcionalidad de exportación próximamente...');
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
// Placeholder for share functionality
|
||||
alert('Funcionalidad de compartir próximamente...');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-slate-50 font-sans">
|
||||
{/* Navigation */}
|
||||
<DashboardNavigation
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
onExport={handleExport}
|
||||
onShare={handleShare}
|
||||
/>
|
||||
|
||||
<div className="max-w-screen-2xl mx-auto p-4 md:p-6 flex flex-col md:flex-row gap-6">
|
||||
{/* Left Sidebar (Fixed) */}
|
||||
<aside className="w-full md:w-96 flex-shrink-0">
|
||||
<div className="sticky top-24 space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg ${tierInfo.color} flex items-center justify-center`}>
|
||||
<BarChart2 className="text-white" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900">Diagnóstico</h1>
|
||||
<p className="text-sm text-slate-500">{tierInfo.name}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<HealthScoreGaugeEnhanced
|
||||
score={analysisData.overallHealthScore}
|
||||
previousScore={analysisData.overallHealthScore - 7}
|
||||
industryAverage={65}
|
||||
animated={true}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white p-6 rounded-lg border border-slate-200"
|
||||
>
|
||||
<h3 className="font-bold text-lg text-slate-800 mb-4 flex items-center gap-2">
|
||||
<Lightbulb size={20} className="text-yellow-500" />
|
||||
Principales Hallazgos
|
||||
</h3>
|
||||
<ul className="space-y-3 text-sm text-slate-700">
|
||||
{analysisData.keyFindings.map((finding, i) => (
|
||||
<motion.li
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.3 + i * 0.1 }}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<span className="text-blue-500 mt-1">•</span>
|
||||
<span>{finding.text}</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="bg-blue-50 p-6 rounded-lg border border-blue-200"
|
||||
>
|
||||
<h3 className="font-bold text-lg text-blue-800 mb-4 flex items-center gap-2">
|
||||
<Target size={20} className="text-blue-600" />
|
||||
Recomendaciones
|
||||
</h3>
|
||||
<ul className="space-y-3 text-sm text-blue-900">
|
||||
{analysisData.recommendations.map((rec, i) => (
|
||||
<motion.li
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.5 + i * 0.1 }}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<span className="text-blue-600 mt-1">→</span>
|
||||
<span>{rec.text}</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
|
||||
<motion.button
|
||||
onClick={onBack}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="w-full flex items-center justify-center gap-2 bg-white text-slate-700 px-4 py-3 rounded-lg border border-slate-300 hover:bg-slate-50 transition-colors shadow-sm font-medium"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Nuevo Análisis
|
||||
</motion.button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area (Scrollable) */}
|
||||
<main className="flex-1 space-y-8">
|
||||
{/* Overview Section */}
|
||||
<section id="overview" className="scroll-mt-24">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-slate-800 mb-6">Resumen Ejecutivo</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{analysisData.summaryKpis.map((kpi, index) => (
|
||||
<KpiCard
|
||||
key={kpi.label}
|
||||
{...kpi}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Dimensional Analysis */}
|
||||
<section id="dimensions" className="scroll-mt-24">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-slate-800 mb-6">Análisis Dimensional</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{analysisData.dimensions.map((dim, index) => (
|
||||
<motion.div
|
||||
key={dim.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<DimensionCard dimension={dim} />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Strategic Visualizations */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
className="space-y-8"
|
||||
>
|
||||
<HeatmapEnhanced data={analysisData.heatmap} />
|
||||
<OpportunityMatrixEnhanced data={analysisData.opportunityMatrix} />
|
||||
|
||||
<div id="roadmap" className="scroll-mt-24">
|
||||
<Roadmap data={analysisData.roadmap} />
|
||||
</div>
|
||||
|
||||
<EconomicModelEnhanced data={analysisData.economicModel} />
|
||||
|
||||
<div id="benchmark" className="scroll-mt-24">
|
||||
<BenchmarkReport data={analysisData.benchmarkReport} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardEnhanced;
|
||||
94
frontend/components/DashboardHeader.tsx
Normal file
94
frontend/components/DashboardHeader.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { LayoutDashboard, Layers, Bot, Map, ShieldCheck, Info, Scale } from 'lucide-react';
|
||||
|
||||
export type TabId = 'executive' | 'dimensions' | 'readiness' | 'roadmap' | 'law10';
|
||||
|
||||
export interface TabConfig {
|
||||
id: TabId;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
title?: string;
|
||||
activeTab: TabId;
|
||||
onTabChange: (id: TabId) => void;
|
||||
onMetodologiaClick?: () => void;
|
||||
}
|
||||
|
||||
const TABS: TabConfig[] = [
|
||||
{ id: 'executive', label: 'Resumen', icon: LayoutDashboard },
|
||||
{ id: 'dimensions', label: 'Dimensiones', icon: Layers },
|
||||
{ id: 'readiness', label: 'Agentic Readiness', icon: Bot },
|
||||
{ id: 'roadmap', label: 'Roadmap', icon: Map },
|
||||
{ id: 'law10', label: 'Ley 10/2025', icon: Scale },
|
||||
];
|
||||
|
||||
export function DashboardHeader({
|
||||
title = 'AIR EUROPA - Beyond CX Analytics',
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onMetodologiaClick
|
||||
}: DashboardHeaderProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
|
||||
{/* Top row: Title and Metodología Badge */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-base sm:text-xl font-bold text-slate-800 truncate">{title}</h1>
|
||||
{onMetodologiaClick && (
|
||||
<button
|
||||
onClick={onMetodologiaClick}
|
||||
className="inline-flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 bg-green-100 text-green-800 rounded-full text-[10px] sm:text-xs font-medium hover:bg-green-200 transition-colors cursor-pointer flex-shrink-0"
|
||||
>
|
||||
<ShieldCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
||||
<span className="hidden md:inline">Metodología de Transformación de Datos aplicada</span>
|
||||
<span className="md:hidden">Metodología</span>
|
||||
<Info className="w-2.5 h-2.5 sm:w-3 sm:h-3 opacity-60" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<nav className="max-w-7xl mx-auto px-2 sm:px-6 overflow-x-auto">
|
||||
<div className="flex space-x-1">
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`
|
||||
relative flex items-center gap-2 px-4 py-3 text-sm font-medium
|
||||
transition-colors duration-200
|
||||
${isActive
|
||||
? 'text-[#6D84E3]'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
|
||||
{/* Active indicator */}
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="activeTab"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#6D84E3]"
|
||||
initial={false}
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardHeader;
|
||||
123
frontend/components/DashboardNavigation.tsx
Normal file
123
frontend/components/DashboardNavigation.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Grid3x3,
|
||||
Activity,
|
||||
Target,
|
||||
Map,
|
||||
DollarSign,
|
||||
BarChart,
|
||||
Download,
|
||||
Share2
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
interface DashboardNavigationProps {
|
||||
activeSection: string;
|
||||
onSectionChange: (sectionId: string) => void;
|
||||
onExport?: () => void;
|
||||
onShare?: () => void;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'overview', label: 'Resumen', icon: LayoutDashboard },
|
||||
{ id: 'dimensions', label: 'Dimensiones', icon: Grid3x3 },
|
||||
{ id: 'heatmap', label: 'Heatmap', icon: Activity },
|
||||
{ id: 'opportunities', label: 'Oportunidades', icon: Target },
|
||||
{ id: 'roadmap', label: 'Roadmap', icon: Map },
|
||||
{ id: 'economics', label: 'Modelo Económico', icon: DollarSign },
|
||||
{ id: 'benchmark', label: 'Benchmark', icon: BarChart },
|
||||
];
|
||||
|
||||
const DashboardNavigation: React.FC<DashboardNavigationProps> = ({
|
||||
activeSection,
|
||||
onSectionChange,
|
||||
onExport,
|
||||
onShare,
|
||||
}) => {
|
||||
const scrollToSection = (sectionId: string) => {
|
||||
onSectionChange(sectionId);
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 bg-white border-b border-slate-200 z-50 shadow-sm">
|
||||
<div className="max-w-screen-2xl mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Navigation Items */}
|
||||
<div className="flex items-center gap-1 overflow-x-auto">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeSection === item.id;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={item.id}
|
||||
onClick={() => scrollToSection(item.id)}
|
||||
className={clsx(
|
||||
'relative flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap',
|
||||
isActive
|
||||
? 'text-blue-600 bg-blue-50'
|
||||
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
||||
)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{item.label}</span>
|
||||
|
||||
{isActive && (
|
||||
<motion.div
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"
|
||||
layoutId="activeIndicator"
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{onShare && (
|
||||
<motion.button
|
||||
onClick={onShare}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg transition-colors"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Share2 size={16} />
|
||||
<span className="hidden sm:inline">Compartir</span>
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{onExport && (
|
||||
<motion.button
|
||||
onClick={onExport}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors shadow-sm"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Download size={16} />
|
||||
<span className="hidden sm:inline">Exportar</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardNavigation;
|
||||
437
frontend/components/DashboardReorganized.tsx
Normal file
437
frontend/components/DashboardReorganized.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { AnalysisData, Kpi } from '../types';
|
||||
import { TIERS } from '../constants';
|
||||
import { ArrowLeft, BarChart2, Lightbulb, Target, Phone, Smile } from 'lucide-react';
|
||||
import BadgePill from './BadgePill';
|
||||
|
||||
import HealthScoreGaugeEnhanced from './HealthScoreGaugeEnhanced';
|
||||
import DimensionCard from './DimensionCard';
|
||||
import HeatmapPro from './HeatmapPro';
|
||||
import VariabilityHeatmap from './VariabilityHeatmap';
|
||||
import OpportunityMatrixPro from './OpportunityMatrixPro';
|
||||
import RoadmapPro from './RoadmapPro';
|
||||
import EconomicModelPro from './EconomicModelPro';
|
||||
import BenchmarkReportPro from './BenchmarkReportPro';
|
||||
import { AgenticReadinessBreakdown } from './AgenticReadinessBreakdown';
|
||||
import { HourlyDistributionChart } from './HourlyDistributionChart';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
|
||||
interface DashboardReorganizedProps {
|
||||
analysisData: AnalysisData;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const KpiCard: React.FC<Kpi & { index: number }> = ({ label, value, change, changeType, index }) => {
|
||||
const changeColor = changeType === 'positive' ? 'bg-green-100 text-green-700' : changeType === 'negative' ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-700';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 + index * 0.1 }}
|
||||
whileHover={{ y: -4, boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.1)' }}
|
||||
className="bg-white p-5 rounded-lg border border-slate-200"
|
||||
>
|
||||
<p className="text-sm text-slate-500 mb-1 truncate">{label}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-3xl font-bold text-slate-800">{value}</p>
|
||||
{change && (
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.5 + index * 0.1, type: 'spring' }}
|
||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${changeColor}`}
|
||||
>
|
||||
{change}
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const SectionDivider: React.FC<{ icon: React.ReactNode; title: string }> = ({ icon, title }) => (
|
||||
<div className="flex items-center gap-3 my-8">
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-slate-300 to-transparent flex-1" />
|
||||
<div className="flex items-center gap-2 text-slate-700">
|
||||
{icon}
|
||||
<span className="font-bold text-lg">{title}</span>
|
||||
</div>
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-slate-300 to-transparent flex-1" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const DashboardReorganized: React.FC<DashboardReorganizedProps> = ({ analysisData, onBack }) => {
|
||||
const tierInfo = TIERS[analysisData.tier || 'gold']; // Default to gold if tier is undefined
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-slate-100">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-slate-200 sticky top-0 z-50 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<motion.button
|
||||
onClick={onBack}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="flex items-center gap-2 text-slate-700 hover:text-slate-900 font-medium transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Volver
|
||||
</motion.button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-lg ${tierInfo.color} flex items-center justify-center`}>
|
||||
<BarChart2 className="text-white" size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-900">Beyond Diagnostic</h1>
|
||||
<p className="text-xs text-slate-500">{tierInfo.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-6 py-8 space-y-12">
|
||||
|
||||
{/* 1. HERO SECTION */}
|
||||
<section>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-gradient-to-br from-[#5669D0] via-[#6D84E3] to-[#8A9EE8] rounded-2xl p-8 md:p-10 shadow-2xl"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 items-start">
|
||||
{/* Health Score */}
|
||||
<div className="lg:col-span-1">
|
||||
<HealthScoreGaugeEnhanced
|
||||
score={analysisData.overallHealthScore}
|
||||
previousScore={analysisData.overallHealthScore - 7}
|
||||
industryAverage={65}
|
||||
animated={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* KPIs Agrupadas por Categoría */}
|
||||
<div className="lg:col-span-3">
|
||||
{/* Grupo 1: Métricas de Contacto */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Phone size={18} className="text-white" />
|
||||
<h3 className="text-white text-lg font-bold">Métricas de Contacto</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{(analysisData.summaryKpis || []).slice(0, 4).map((kpi, index) => (
|
||||
<KpiCard
|
||||
key={kpi.label}
|
||||
{...kpi}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grupo 2: Métricas de Satisfacción */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Smile size={18} className="text-white" />
|
||||
<h3 className="text-white text-lg font-bold">Métricas de Satisfacción</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{(analysisData.summaryKpis || []).slice(2, 4).map((kpi, index) => (
|
||||
<KpiCard
|
||||
key={kpi.label}
|
||||
{...kpi}
|
||||
index={index + 2}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* 2. INSIGHTS SECTION - FINDINGS */}
|
||||
<section>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<div className="bg-amber-50 border-2 border-amber-200 rounded-xl p-8">
|
||||
<h3 className="font-bold text-2xl text-amber-900 mb-6 flex items-center gap-2">
|
||||
<Lightbulb size={28} className="text-amber-600" />
|
||||
Principales Hallazgos
|
||||
</h3>
|
||||
<div className="space-y-5">
|
||||
{(analysisData.findings || []).map((finding, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="bg-white rounded-lg p-5 border border-amber-100 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-3">
|
||||
<div>
|
||||
{finding.title && (
|
||||
<h4 className="font-bold text-amber-900 mb-1">{finding.title}</h4>
|
||||
)}
|
||||
<p className="text-sm text-amber-900">{finding.text}</p>
|
||||
</div>
|
||||
<BadgePill
|
||||
type={finding.type as any}
|
||||
impact={finding.impact as any}
|
||||
label={
|
||||
finding.type === 'critical' ? 'Crítico' :
|
||||
finding.type === 'warning' ? 'Alerta' : 'Información'
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{finding.description && (
|
||||
<p className="text-xs text-slate-600 italic mt-3 pl-3 border-l-2 border-amber-300">
|
||||
{finding.description}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* 3. INSIGHTS SECTION - RECOMMENDATIONS */}
|
||||
<section>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<div className="bg-[#E8EBFA] border-2 border-[#6D84E3] rounded-xl p-8">
|
||||
<h3 className="font-bold text-2xl text-[#3F3F3F] mb-6 flex items-center gap-2">
|
||||
<Target size={28} className="text-[#6D84E3]" />
|
||||
Recomendaciones Prioritarias
|
||||
</h3>
|
||||
<div className="space-y-5">
|
||||
{(analysisData.recommendations || []).map((rec, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="bg-white rounded-lg p-5 border border-blue-100 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-3">
|
||||
<div className="flex-1">
|
||||
{rec.title && (
|
||||
<h4 className="font-bold text-[#3F3F3F] mb-1">{rec.title}</h4>
|
||||
)}
|
||||
<p className="text-sm text-[#3F3F3F] mb-2">{rec.text}</p>
|
||||
</div>
|
||||
<BadgePill
|
||||
priority={rec.priority as any}
|
||||
label={
|
||||
rec.priority === 'high' ? 'Alta Prioridad' :
|
||||
rec.priority === 'medium' ? 'Prioridad Media' : 'Baja Prioridad'
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(rec.description || rec.impact || rec.timeline) && (
|
||||
<div className="bg-slate-50 rounded p-3 mt-3 border-l-4 border-[#6D84E3]">
|
||||
{rec.description && (
|
||||
<p className="text-xs text-slate-700 mb-2">
|
||||
<span className="font-semibold">Descripción:</span> {rec.description}
|
||||
</p>
|
||||
)}
|
||||
{rec.impact && (
|
||||
<p className="text-xs text-slate-700 mb-2">
|
||||
<span className="font-semibold text-green-700">Impacto esperado:</span> {rec.impact}
|
||||
</p>
|
||||
)}
|
||||
{rec.timeline && (
|
||||
<p className="text-xs text-slate-700">
|
||||
<span className="font-semibold">Timeline:</span> {rec.timeline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* 4. ANÁLISIS DIMENSIONAL */}
|
||||
<section>
|
||||
<SectionDivider
|
||||
icon={<BarChart2 size={20} className="text-blue-600" />}
|
||||
title="Análisis Dimensional"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="grid grid-cols-1 lg:grid-cols-2 gap-6"
|
||||
>
|
||||
{(analysisData.dimensions || []).map((dim, index) => (
|
||||
<motion.div
|
||||
key={dim.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<DimensionCard dimension={dim} />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* 4. AGENTIC READINESS (si disponible) */}
|
||||
{analysisData.agenticReadiness && (
|
||||
<section>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<AgenticReadinessBreakdown agenticReadiness={analysisData.agenticReadiness} />
|
||||
</motion.div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 5. DISTRIBUCIÓN HORARIA (si disponible) */}
|
||||
{(() => {
|
||||
const volumetryDim = analysisData?.dimensions?.find(d => d.name === 'volumetry_distribution');
|
||||
const distData = volumetryDim?.distribution_data;
|
||||
|
||||
if (distData && distData.hourly && distData.hourly.length > 0) {
|
||||
return (
|
||||
<section>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<HourlyDistributionChart
|
||||
hourly={distData.hourly}
|
||||
off_hours_pct={distData.off_hours_pct}
|
||||
peak_hours={distData.peak_hours}
|
||||
/>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* 6. HEATMAP DE PERFORMANCE COMPETITIVO */}
|
||||
<section>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<ErrorBoundary componentName="Heatmap de Métricas">
|
||||
<HeatmapPro data={analysisData.heatmapData} />
|
||||
</ErrorBoundary>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* 7. HEATMAP DE VARIABILIDAD INTERNA */}
|
||||
<section>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<VariabilityHeatmap data={analysisData.heatmapData} />
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* 8. OPPORTUNITY MATRIX */}
|
||||
{analysisData.opportunities && analysisData.opportunities.length > 0 && (
|
||||
<section>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<OpportunityMatrixPro data={analysisData.opportunities} heatmapData={analysisData.heatmapData} />
|
||||
</motion.div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 9. ROADMAP */}
|
||||
{analysisData.roadmap && analysisData.roadmap.length > 0 && (
|
||||
<section>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<RoadmapPro data={analysisData.roadmap} />
|
||||
</motion.div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 10. ECONOMIC MODEL */}
|
||||
{analysisData.economicModel && (
|
||||
<section>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<EconomicModelPro data={analysisData.economicModel} />
|
||||
</motion.div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 11. BENCHMARK REPORT */}
|
||||
{analysisData.benchmarkData && analysisData.benchmarkData.length > 0 && (
|
||||
<section>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<BenchmarkReportPro data={analysisData.benchmarkData} />
|
||||
</motion.div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<section className="pt-8 pb-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center"
|
||||
>
|
||||
<motion.button
|
||||
onClick={onBack}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="inline-flex items-center gap-2 bg-[#6D84E3] text-white px-8 py-4 rounded-xl hover:bg-[#5669D0] transition-colors shadow-lg hover:shadow-xl font-semibold text-lg"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Realizar Nuevo Análisis
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardReorganized;
|
||||
107
frontend/components/DashboardTabs.tsx
Normal file
107
frontend/components/DashboardTabs.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { DashboardHeader, TabId } from './DashboardHeader';
|
||||
import { formatDateMonthYear } from '../utils/formatters';
|
||||
import { ExecutiveSummaryTab } from './tabs/ExecutiveSummaryTab';
|
||||
import { DimensionAnalysisTab } from './tabs/DimensionAnalysisTab';
|
||||
import { AgenticReadinessTab } from './tabs/AgenticReadinessTab';
|
||||
import { RoadmapTab } from './tabs/RoadmapTab';
|
||||
import { Law10Tab } from './tabs/Law10Tab';
|
||||
import { MetodologiaDrawer } from './MetodologiaDrawer';
|
||||
import type { AnalysisData } from '../types';
|
||||
|
||||
interface DashboardTabsProps {
|
||||
data: AnalysisData;
|
||||
title?: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function DashboardTabs({
|
||||
data,
|
||||
title = 'AIR EUROPA - Beyond CX Analytics',
|
||||
onBack
|
||||
}: DashboardTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('executive');
|
||||
const [metodologiaOpen, setMetodologiaOpen] = useState(false);
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'executive':
|
||||
return <ExecutiveSummaryTab data={data} onTabChange={setActiveTab} />;
|
||||
case 'dimensions':
|
||||
return <DimensionAnalysisTab data={data} />;
|
||||
case 'readiness':
|
||||
return <AgenticReadinessTab data={data} onTabChange={setActiveTab} />;
|
||||
case 'roadmap':
|
||||
return <RoadmapTab data={data} />;
|
||||
case 'law10':
|
||||
return <Law10Tab data={data} />;
|
||||
default:
|
||||
return <ExecutiveSummaryTab data={data} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Back button */}
|
||||
{onBack && (
|
||||
<div className="bg-white border-b border-slate-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-2">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-sm text-slate-600 hover:text-slate-800 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Volver al formulario</span>
|
||||
<span className="sm:hidden">Volver</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sticky Header with Tabs */}
|
||||
<DashboardHeader
|
||||
title={title}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onMetodologiaClick={() => setMetodologiaOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Tab Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-4 sm:py-6">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{renderTabContent()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-slate-200 bg-white mt-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-sm text-slate-500">
|
||||
<span className="hidden sm:inline">Beyond Diagnosis - Contact Center Analytics Platform</span>
|
||||
<span className="sm:hidden text-xs">Beyond Diagnosis</span>
|
||||
<span className="text-xs sm:text-sm text-slate-400 italic">{formatDateMonthYear()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Drawer de Metodología */}
|
||||
<MetodologiaDrawer
|
||||
isOpen={metodologiaOpen}
|
||||
onClose={() => setMetodologiaOpen(false)}
|
||||
data={data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardTabs;
|
||||
507
frontend/components/DataInputRedesigned.tsx
Normal file
507
frontend/components/DataInputRedesigned.tsx
Normal file
@@ -0,0 +1,507 @@
|
||||
// components/DataInputRedesigned.tsx
|
||||
// Interfaz de entrada de datos simplificada
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
AlertCircle, FileText, Database,
|
||||
UploadCloud, File, Loader2, Info, X,
|
||||
HardDrive, Trash2, RefreshCw, Server
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import toast from 'react-hot-toast';
|
||||
import { checkServerCache, clearServerCache, ServerCacheMetadata } from '../utils/serverCache';
|
||||
import { useAuth } from '../utils/AuthContext';
|
||||
|
||||
interface CacheInfo extends ServerCacheMetadata {
|
||||
// Using server cache metadata structure
|
||||
}
|
||||
|
||||
interface DataInputRedesignedProps {
|
||||
onAnalyze: (config: {
|
||||
costPerHour: number;
|
||||
avgCsat: number;
|
||||
segmentMapping?: {
|
||||
high_value_queues: string[];
|
||||
medium_value_queues: string[];
|
||||
low_value_queues: string[];
|
||||
};
|
||||
file?: File;
|
||||
sheetUrl?: string;
|
||||
useSynthetic?: boolean;
|
||||
useCache?: boolean;
|
||||
}) => void;
|
||||
isAnalyzing: boolean;
|
||||
}
|
||||
|
||||
const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
||||
onAnalyze,
|
||||
isAnalyzing
|
||||
}) => {
|
||||
const { authHeader } = useAuth();
|
||||
|
||||
// Estados para datos manuales - valores vacíos por defecto
|
||||
const [costPerHour, setCostPerHour] = useState<string>('');
|
||||
const [avgCsat, setAvgCsat] = useState<string>('');
|
||||
|
||||
// Estados para mapeo de segmentación
|
||||
const [highValueQueues, setHighValueQueues] = useState<string>('');
|
||||
const [mediumValueQueues, setMediumValueQueues] = useState<string>('');
|
||||
const [lowValueQueues, setLowValueQueues] = useState<string>('');
|
||||
|
||||
// Estados para carga de datos
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Estado para caché del servidor
|
||||
const [cacheInfo, setCacheInfo] = useState<CacheInfo | null>(null);
|
||||
const [checkingCache, setCheckingCache] = useState(true);
|
||||
|
||||
// Verificar caché del servidor al cargar
|
||||
useEffect(() => {
|
||||
const checkCache = async () => {
|
||||
console.log('[DataInput] Checking server cache, authHeader:', authHeader ? 'present' : 'null');
|
||||
if (!authHeader) {
|
||||
console.log('[DataInput] No authHeader, skipping cache check');
|
||||
setCheckingCache(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setCheckingCache(true);
|
||||
console.log('[DataInput] Calling checkServerCache...');
|
||||
const { exists, metadata } = await checkServerCache(authHeader);
|
||||
console.log('[DataInput] Cache check result:', { exists, metadata });
|
||||
if (exists && metadata) {
|
||||
setCacheInfo(metadata);
|
||||
console.log('[DataInput] Cache info set:', metadata);
|
||||
// Auto-rellenar coste si hay en caché
|
||||
if (metadata.costPerHour > 0 && !costPerHour) {
|
||||
setCostPerHour(metadata.costPerHour.toString());
|
||||
}
|
||||
} else {
|
||||
console.log('[DataInput] No cache found on server');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DataInput] Error checking server cache:', error);
|
||||
} finally {
|
||||
setCheckingCache(false);
|
||||
}
|
||||
};
|
||||
checkCache();
|
||||
}, [authHeader]);
|
||||
|
||||
const handleClearCache = async () => {
|
||||
if (!authHeader) return;
|
||||
|
||||
try {
|
||||
const success = await clearServerCache(authHeader);
|
||||
if (success) {
|
||||
setCacheInfo(null);
|
||||
toast.success('Caché del servidor limpiada', { icon: '🗑️' });
|
||||
} else {
|
||||
toast.error('Error limpiando caché del servidor');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Error limpiando caché');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseCache = () => {
|
||||
if (!cacheInfo) return;
|
||||
|
||||
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;
|
||||
|
||||
onAnalyze({
|
||||
costPerHour: parseFloat(costPerHour) || cacheInfo.costPerHour,
|
||||
avgCsat: parseFloat(avgCsat) || 0,
|
||||
segmentMapping,
|
||||
useCache: true
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (selectedFile: File | null) => {
|
||||
if (selectedFile) {
|
||||
const allowedTypes = [
|
||||
'text/csv',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
];
|
||||
if (allowedTypes.includes(selectedFile.type) ||
|
||||
selectedFile.name.endsWith('.csv') ||
|
||||
selectedFile.name.endsWith('.xlsx') ||
|
||||
selectedFile.name.endsWith('.xls')) {
|
||||
setFile(selectedFile);
|
||||
toast.success(`Archivo "${selectedFile.name}" cargado`, { icon: '📄' });
|
||||
} else {
|
||||
toast.error('Tipo de archivo no válido. Sube un CSV o Excel.', { icon: '❌' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const onDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const droppedFile = e.dataTransfer.files[0];
|
||||
if (droppedFile) {
|
||||
handleFileChange(droppedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const canAnalyze = file !== null && costPerHour !== '' && parseFloat(costPerHour) > 0;
|
||||
|
||||
const 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;
|
||||
|
||||
onAnalyze({
|
||||
costPerHour: parseFloat(costPerHour) || 0,
|
||||
avgCsat: parseFloat(avgCsat) || 0,
|
||||
segmentMapping,
|
||||
file: file || undefined,
|
||||
useSynthetic: false
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Sección 1: Datos Manuales */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white rounded-lg shadow-sm p-4 sm:p-6 border border-slate-200"
|
||||
>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-1 flex items-center gap-2">
|
||||
<Database size={20} className="text-[#6D84E3]" />
|
||||
Configuración Manual
|
||||
</h2>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Introduce los parámetros de configuración para tu análisis
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||
{/* Coste por Hora */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2 flex items-center gap-2">
|
||||
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-medium">
|
||||
<AlertCircle size={10} />
|
||||
Obligatorio
|
||||
</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500">€</span>
|
||||
<input
|
||||
type="number"
|
||||
value={costPerHour}
|
||||
onChange={(e) => setCostPerHour(e.target.value)}
|
||||
min="0"
|
||||
step="0.5"
|
||||
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="Ej: 20"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">€/hora</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Incluye salario, cargas sociales, infraestructura, etc.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CSAT Promedio */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2 flex items-center gap-2">
|
||||
CSAT Promedio
|
||||
<span className="text-xs text-slate-400">(Opcional)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
value={avgCsat}
|
||||
onChange={(e) => setAvgCsat(e.target.value)}
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
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="Ej: 85"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">/ 100</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Puntuación promedio de satisfacción del cliente
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Segmentación por Cola/Skill */}
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<div className="mb-3">
|
||||
<h4 className="font-medium text-slate-700 mb-1 flex items-center gap-2">
|
||||
Segmentación de Clientes por Cola/Skill
|
||||
<span className="text-xs text-slate-400">(Opcional)</span>
|
||||
</h4>
|
||||
<p className="text-sm text-slate-500">
|
||||
Identifica qué colas corresponden a cada segmento. Separa múltiples colas con comas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Alto Valor
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={highValueQueues}
|
||||
onChange={(e) => setHighValueQueues(e.target.value)}
|
||||
placeholder="VIP, Premium, Enterprise"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Valor Medio
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mediumValueQueues}
|
||||
onChange={(e) => setMediumValueQueues(e.target.value)}
|
||||
placeholder="Soporte_General, Ventas"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Bajo Valor
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lowValueQueues}
|
||||
onChange={(e) => setLowValueQueues(e.target.value)}
|
||||
placeholder="Basico, Trial, Freemium"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500 mt-2 flex items-start gap-1">
|
||||
<Info size={12} className="mt-0.5 flex-shrink-0" />
|
||||
Las colas no mapeadas se clasificarán como "Valor Medio" por defecto.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Sección 2: Datos en Caché del Servidor (si hay) */}
|
||||
{cacheInfo && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="bg-emerald-50 rounded-lg shadow-sm p-4 sm:p-6 border-2 border-emerald-300"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-emerald-800 flex items-center gap-2">
|
||||
<Server size={20} className="text-emerald-600" />
|
||||
Datos en Caché
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClearCache}
|
||||
className="p-2 text-emerald-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition"
|
||||
title="Limpiar caché"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-4 mb-4">
|
||||
<div className="bg-white rounded-lg p-3 border border-emerald-200">
|
||||
<p className="text-xs text-emerald-600 font-medium">Archivo</p>
|
||||
<p className="text-sm font-semibold text-slate-800 truncate" title={cacheInfo.fileName}>
|
||||
{cacheInfo.fileName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-emerald-200">
|
||||
<p className="text-xs text-emerald-600 font-medium">Registros</p>
|
||||
<p className="text-sm font-semibold text-slate-800">
|
||||
{cacheInfo.recordCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-emerald-200">
|
||||
<p className="text-xs text-emerald-600 font-medium">Tamaño Original</p>
|
||||
<p className="text-sm font-semibold text-slate-800">
|
||||
{(cacheInfo.fileSize / (1024 * 1024)).toFixed(1)} MB
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-emerald-200">
|
||||
<p className="text-xs text-emerald-600 font-medium">Guardado</p>
|
||||
<p className="text-sm font-semibold text-slate-800">
|
||||
{new Date(cacheInfo.cachedAt).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUseCache}
|
||||
disabled={isAnalyzing || !costPerHour || parseFloat(costPerHour) <= 0}
|
||||
className={clsx(
|
||||
'w-full py-3 rounded-lg font-semibold flex items-center justify-center gap-2 transition-all',
|
||||
(!isAnalyzing && costPerHour && parseFloat(costPerHour) > 0)
|
||||
? 'bg-emerald-600 text-white hover:bg-emerald-700'
|
||||
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
Analizando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw size={20} />
|
||||
Usar Datos en Caché
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{(!costPerHour || parseFloat(costPerHour) <= 0) && (
|
||||
<p className="text-xs text-amber-600 mt-2 text-center">
|
||||
Introduce el coste por hora arriba para continuar
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Sección 3: Subir Archivo */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: cacheInfo ? 0.25 : 0.2 }}
|
||||
className="bg-white rounded-lg shadow-sm p-4 sm:p-6 border border-slate-200"
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-1 flex items-center gap-2">
|
||||
<UploadCloud size={20} className="text-[#6D84E3]" />
|
||||
{cacheInfo ? 'Subir Nuevo Archivo' : 'Datos CSV'}
|
||||
</h2>
|
||||
<p className="text-slate-500 text-sm">
|
||||
{cacheInfo ? 'O sube un archivo diferente para analizar' : 'Sube el archivo exportado desde tu sistema ACD/CTI'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Zona de subida */}
|
||||
<div
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
className={clsx(
|
||||
'border-2 border-dashed rounded-lg p-8 text-center transition-all cursor-pointer',
|
||||
isDragging ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300 bg-slate-50 hover:border-slate-400'
|
||||
)}
|
||||
>
|
||||
{file ? (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<File size={24} className="text-emerald-600" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-slate-800">{file.name}</p>
|
||||
<p className="text-xs text-slate-500">{(file.size / 1024).toFixed(1)} KB</p>
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud size={40} className="mx-auto text-slate-400 mb-3" />
|
||||
<p className="text-slate-600 mb-2">
|
||||
Arrastra tu archivo aquí o haz click para seleccionar
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mb-4">
|
||||
Formatos aceptados: CSV, Excel (.xlsx, .xls)
|
||||
</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 font-medium"
|
||||
>
|
||||
Seleccionar Archivo
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Botón de análisis */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="flex justify-center"
|
||||
>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canAnalyze || isAnalyzing}
|
||||
className={clsx(
|
||||
'px-8 py-3 rounded-lg font-semibold text-lg transition-all flex items-center gap-3',
|
||||
canAnalyze && !isAnalyzing
|
||||
? 'bg-[#6D84E3] text-white hover:bg-[#5a6fc9] shadow-lg hover:shadow-xl'
|
||||
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<Loader2 size={22} className="animate-spin" />
|
||||
Analizando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText size={22} />
|
||||
Generar Análisis
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataInputRedesigned;
|
||||
262
frontend/components/DataUploader.tsx
Normal file
262
frontend/components/DataUploader.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { UploadCloud, File, Sheet, Loader2, CheckCircle, Sparkles, Wand2, BarChart3 } from 'lucide-react';
|
||||
import { generateSyntheticCsv } from '../utils/syntheticDataGenerator';
|
||||
import { TierKey } from '../types';
|
||||
|
||||
interface DataUploaderProps {
|
||||
selectedTier: TierKey;
|
||||
onAnalysisReady: () => void;
|
||||
isAnalyzing: boolean;
|
||||
}
|
||||
|
||||
type UploadStatus = 'idle' | 'generating' | 'uploading' | 'success';
|
||||
|
||||
const formatFileSize = (bytes: number, decimals = 2) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisReady, isAnalyzing }) => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [sheetUrl, setSheetUrl] = useState('');
|
||||
const [status, setStatus] = useState<UploadStatus>('idle');
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const isActionInProgress = status === 'generating' || status === 'uploading' || isAnalyzing;
|
||||
|
||||
const resetState = (clearAll: boolean = true) => {
|
||||
setStatus('idle');
|
||||
setError('');
|
||||
setSuccessMessage('');
|
||||
if (clearAll) {
|
||||
setFile(null);
|
||||
setSheetUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDataReady = (message: string) => {
|
||||
setStatus('success');
|
||||
setSuccessMessage(message);
|
||||
};
|
||||
|
||||
const handleFileChange = (selectedFile: File | null) => {
|
||||
resetState();
|
||||
if (selectedFile) {
|
||||
const allowedTypes = [
|
||||
'text/csv',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
];
|
||||
if (allowedTypes.includes(selectedFile.type) || selectedFile.name.endsWith('.csv') || selectedFile.name.endsWith('.xlsx') || selectedFile.name.endsWith('.xls')) {
|
||||
setFile(selectedFile);
|
||||
setSheetUrl('');
|
||||
} else {
|
||||
setError('Tipo de archivo no válido. Sube un CSV o Excel.');
|
||||
setFile(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isActionInProgress) setIsDragging(true);
|
||||
}, [isActionInProgress]);
|
||||
|
||||
const onDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (isActionInProgress) return;
|
||||
const droppedFile = e.dataTransfer.files && e.dataTransfer.files[0];
|
||||
handleFileChange(droppedFile);
|
||||
}, [isActionInProgress]);
|
||||
|
||||
const handleGenerateSyntheticData = () => {
|
||||
resetState();
|
||||
setStatus('generating');
|
||||
setTimeout(() => {
|
||||
const csvData = generateSyntheticCsv(selectedTier);
|
||||
handleDataReady('Datos Sintéticos Generados!');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!file && !sheetUrl) {
|
||||
setError('Por favor, sube un archivo o introduce una URL de Google Sheet.');
|
||||
return;
|
||||
}
|
||||
resetState(false);
|
||||
setStatus('uploading');
|
||||
setTimeout(() => {
|
||||
handleDataReady('Datos Recibidos!');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const renderMainButton = () => {
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<button
|
||||
onClick={onAnalysisReady}
|
||||
disabled={isAnalyzing}
|
||||
className="w-full flex items-center justify-center gap-2 text-white px-6 py-3 rounded-lg transition-colors shadow-sm hover:shadow-md bg-green-600 hover:bg-green-700 disabled:opacity-75 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isAnalyzing ? <Loader2 className="animate-spin" size={20} /> : <BarChart3 size={20} />}
|
||||
{isAnalyzing ? 'Analizando...' : 'Ver Dashboard de Diagnóstico'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isActionInProgress || (!file && !sheetUrl)}
|
||||
className="w-full flex items-center justify-center gap-2 text-white px-6 py-3 rounded-lg transition-colors shadow-sm hover:shadow-md bg-blue-600 hover:bg-blue-700 disabled:opacity-75 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === 'uploading' ? <Loader2 className="animate-spin" size={20} /> : <Wand2 size={20} />}
|
||||
{status === 'uploading' ? 'Procesando...' : 'Generar Análisis'}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-lg p-8">
|
||||
<div className="mb-6">
|
||||
<span className="text-blue-600 font-semibold mb-1 block">Paso 2</span>
|
||||
<h2 className="text-2xl font-bold text-slate-900">Sube tus Datos y Ejecuta el Análisis</h2>
|
||||
<p className="text-slate-600 mt-1">
|
||||
Usa una de las siguientes opciones para enviarnos tus datos para el análisis.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
className={`relative border-2 border-dashed rounded-lg p-8 text-center transition-colors duration-300 ${isDragging ? 'border-blue-500 bg-blue-50' : 'border-slate-300 bg-slate-50'} ${isActionInProgress ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
|
||||
onChange={(e) => handleFileChange(e.target.files ? e.target.files[0] : null)}
|
||||
disabled={isActionInProgress}
|
||||
/>
|
||||
<label htmlFor="file-upload" className="cursor-pointer flex flex-col items-center justify-center">
|
||||
<UploadCloud className="w-12 h-12 text-slate-400 mb-2" />
|
||||
<span className="font-semibold text-blue-600">Haz clic para subir un fichero</span>
|
||||
<span className="text-slate-500"> o arrástralo aquí</span>
|
||||
<p className="text-xs text-slate-400 mt-2">CSV, XLSX, o XLS</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-slate-500">
|
||||
<hr className="w-full border-slate-300" />
|
||||
<span className="px-4 font-medium text-sm">O</span>
|
||||
<hr className="w-full border-slate-300" />
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-slate-50 rounded-lg">
|
||||
<p className="text-sm text-slate-600 mb-3">¿No tienes datos a mano? Genera un set de datos de ejemplo.</p>
|
||||
<button
|
||||
onClick={handleGenerateSyntheticData}
|
||||
disabled={isActionInProgress}
|
||||
className="flex items-center justify-center gap-2 w-full sm:w-auto mx-auto bg-fuchsia-100 text-fuchsia-700 px-6 py-3 rounded-lg hover:bg-fuchsia-200 hover:text-fuchsia-800 transition-colors shadow-sm hover:shadow-md disabled:opacity-75 disabled:cursor-not-allowed font-semibold"
|
||||
>
|
||||
{status === 'generating' ? <Loader2 className="animate-spin" size={20} /> : <Sparkles size={20} />}
|
||||
{status === 'generating' ? 'Generando...' : 'Generar Datos Sintéticos'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-slate-500">
|
||||
<hr className="w-full border-slate-300" />
|
||||
<span className="px-4 font-medium text-sm">O</span>
|
||||
<hr className="w-full border-slate-300" />
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Sheet className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="url"
|
||||
placeholder="Pega la URL de tu Google Sheet aquí"
|
||||
value={sheetUrl}
|
||||
onChange={(e) => {
|
||||
resetState();
|
||||
setSheetUrl(e.target.value);
|
||||
setFile(null);
|
||||
}}
|
||||
disabled={isActionInProgress}
|
||||
className="w-full pl-10 pr-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition disabled:bg-slate-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-600 text-sm text-center">{error}</p>}
|
||||
|
||||
{status !== 'uploading' && status !== 'success' && file && (
|
||||
<div className="flex items-center justify-between gap-2 p-3 bg-slate-50 border border-slate-200 text-slate-800 rounded-lg">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<File className="w-5 h-5 flex-shrink-0 text-slate-500" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-medium text-sm truncate">{file.name}</span>
|
||||
<span className="text-xs text-slate-500">{formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setFile(null)} className="text-slate-500 hover:text-red-600 font-bold text-lg flex-shrink-0">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'uploading' && file && (
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<File className="w-8 h-8 flex-shrink-0 text-blue-500" />
|
||||
<div className="flex-grow">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="font-semibold text-sm text-blue-800 truncate">{file.name}</span>
|
||||
<span className="text-xs text-blue-700">{formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-200 rounded-full h-2.5 overflow-hidden">
|
||||
<div className="relative w-full h-full">
|
||||
<div className="absolute h-full w-1/2 bg-blue-600 rounded-full animate-indeterminate-progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status !== 'uploading' && status !== 'success' && sheetUrl && !file && (
|
||||
<div className="flex items-center justify-center gap-2 p-3 bg-blue-50 border border-blue-200 text-blue-800 rounded-lg">
|
||||
<Sheet className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="font-medium text-sm truncate">{sheetUrl}</span>
|
||||
<button onClick={() => setSheetUrl('')} className="text-blue-600 hover:text-blue-800 font-bold text-lg">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="flex items-center justify-center gap-2 p-4 bg-green-50 border border-green-200 text-green-800 rounded-lg">
|
||||
<CheckCircle className="w-6 h-6 flex-shrink-0" />
|
||||
<span className="font-semibold">{successMessage} ¡Listo para analizar!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderMainButton()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataUploader;
|
||||
452
frontend/components/DataUploaderEnhanced.tsx
Normal file
452
frontend/components/DataUploaderEnhanced.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { UploadCloud, File, Sheet, Loader2, CheckCircle, Sparkles, Wand2, BarChart3, X, AlertCircle } from 'lucide-react';
|
||||
import { generateSyntheticCsv } from '../utils/syntheticDataGenerator';
|
||||
import { TierKey } from '../types';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface DataUploaderEnhancedProps {
|
||||
selectedTier: TierKey;
|
||||
onAnalysisReady: () => void;
|
||||
isAnalyzing: boolean;
|
||||
}
|
||||
|
||||
type UploadStatus = 'idle' | 'generating' | 'uploading' | 'success';
|
||||
|
||||
const formatFileSize = (bytes: number, decimals = 2) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const DataUploaderEnhanced: React.FC<DataUploaderEnhancedProps> = ({
|
||||
selectedTier,
|
||||
onAnalysisReady,
|
||||
isAnalyzing
|
||||
}) => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [sheetUrl, setSheetUrl] = useState('');
|
||||
const [status, setStatus] = useState<UploadStatus>('idle');
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const isActionInProgress = status === 'generating' || status === 'uploading' || isAnalyzing;
|
||||
|
||||
const resetState = (clearAll: boolean = true) => {
|
||||
setStatus('idle');
|
||||
if (clearAll) {
|
||||
setFile(null);
|
||||
setSheetUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDataReady = (message: string) => {
|
||||
setStatus('success');
|
||||
toast.success(message, {
|
||||
icon: '✅',
|
||||
duration: 3000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (selectedFile: File | null) => {
|
||||
resetState();
|
||||
if (selectedFile) {
|
||||
const allowedTypes = [
|
||||
'text/csv',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
];
|
||||
if (allowedTypes.includes(selectedFile.type) ||
|
||||
selectedFile.name.endsWith('.csv') ||
|
||||
selectedFile.name.endsWith('.xlsx') ||
|
||||
selectedFile.name.endsWith('.xls')) {
|
||||
setFile(selectedFile);
|
||||
setSheetUrl('');
|
||||
toast.success(`Archivo "${selectedFile.name}" cargado correctamente`, {
|
||||
icon: '📄',
|
||||
});
|
||||
} else {
|
||||
toast.error('Tipo de archivo no válido. Sube un CSV o Excel.', {
|
||||
icon: '❌',
|
||||
});
|
||||
setFile(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isActionInProgress) setIsDragging(true);
|
||||
}, [isActionInProgress]);
|
||||
|
||||
const onDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (isActionInProgress) return;
|
||||
const droppedFile = e.dataTransfer.files && e.dataTransfer.files[0];
|
||||
handleFileChange(droppedFile);
|
||||
}, [isActionInProgress]);
|
||||
|
||||
const handleGenerateSyntheticData = () => {
|
||||
resetState();
|
||||
setStatus('generating');
|
||||
toast.loading('Generando datos sintéticos...', { id: 'generating' });
|
||||
|
||||
setTimeout(() => {
|
||||
const csvData = generateSyntheticCsv(selectedTier);
|
||||
toast.dismiss('generating');
|
||||
handleDataReady('¡Datos Sintéticos Generados!');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!file && !sheetUrl) {
|
||||
toast.error('Por favor, sube un archivo o introduce una URL de Google Sheet.', {
|
||||
icon: '⚠️',
|
||||
});
|
||||
return;
|
||||
}
|
||||
resetState(false);
|
||||
setStatus('uploading');
|
||||
toast.loading('Procesando datos...', { id: 'uploading' });
|
||||
|
||||
setTimeout(() => {
|
||||
toast.dismiss('uploading');
|
||||
handleDataReady('¡Datos Recibidos!');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const renderMainButton = () => {
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<motion.button
|
||||
onClick={onAnalysisReady}
|
||||
disabled={isAnalyzing}
|
||||
whileHover={{ scale: isAnalyzing ? 1 : 1.02 }}
|
||||
whileTap={{ scale: isAnalyzing ? 1 : 0.98 }}
|
||||
className="w-full flex items-center justify-center gap-2 text-white px-6 py-4 rounded-xl transition-all shadow-lg hover:shadow-xl bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 disabled:opacity-75 disabled:cursor-not-allowed font-semibold text-lg"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={24} />
|
||||
Analizando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BarChart3 size={24} />
|
||||
Ver Dashboard de Diagnóstico
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
onClick={handleSubmit}
|
||||
disabled={isActionInProgress || (!file && !sheetUrl)}
|
||||
whileHover={{ scale: isActionInProgress || (!file && !sheetUrl) ? 1 : 1.02 }}
|
||||
whileTap={{ scale: isActionInProgress || (!file && !sheetUrl) ? 1 : 0.98 }}
|
||||
className="w-full flex items-center justify-center gap-2 text-white px-6 py-4 rounded-xl transition-all shadow-lg hover:shadow-xl bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 disabled:opacity-50 disabled:cursor-not-allowed font-semibold text-lg"
|
||||
>
|
||||
{status === 'uploading' ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={24} />
|
||||
Procesando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wand2 size={24} />
|
||||
Generar Análisis
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-right" />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-xl shadow-lg p-8"
|
||||
>
|
||||
<div className="mb-8">
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="text-blue-600 font-semibold mb-1 block"
|
||||
>
|
||||
Paso 2
|
||||
</motion.span>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-3xl font-bold text-slate-900"
|
||||
>
|
||||
Sube tus Datos y Ejecuta el Análisis
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-slate-600 mt-2"
|
||||
>
|
||||
Usa una de las siguientes opciones para enviarnos tus datos para el análisis.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Drag & Drop Area */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
className={clsx(
|
||||
'relative border-2 border-dashed rounded-xl p-12 text-center transition-all duration-300',
|
||||
isDragging && 'border-blue-500 bg-blue-50 scale-105 shadow-lg',
|
||||
!isDragging && 'border-slate-300 bg-slate-50 hover:border-slate-400',
|
||||
isActionInProgress && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
|
||||
onChange={(e) => handleFileChange(e.target.files ? e.target.files[0] : null)}
|
||||
disabled={isActionInProgress}
|
||||
/>
|
||||
<label htmlFor="file-upload" className="cursor-pointer flex flex-col items-center justify-center">
|
||||
<motion.div
|
||||
animate={isDragging ? { scale: 1.2, rotate: 5 } : { scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300 }}
|
||||
>
|
||||
<UploadCloud className={clsx(
|
||||
"w-16 h-16 mb-4",
|
||||
isDragging ? "text-blue-500" : "text-slate-400"
|
||||
)} />
|
||||
</motion.div>
|
||||
<span className="font-semibold text-lg text-blue-600 mb-1">
|
||||
Haz clic para subir un fichero
|
||||
</span>
|
||||
<span className="text-slate-500">o arrástralo aquí</span>
|
||||
<p className="text-sm text-slate-400 mt-3 bg-white px-4 py-2 rounded-full">
|
||||
CSV, XLSX, o XLS
|
||||
</p>
|
||||
</label>
|
||||
</motion.div>
|
||||
|
||||
{/* File Preview */}
|
||||
<AnimatePresence>
|
||||
{status !== 'uploading' && status !== 'success' && file && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="flex items-center justify-between gap-3 p-4 bg-blue-50 border-2 border-blue-200 text-slate-800 rounded-xl"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<File className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-semibold text-sm truncate">{file.name}</span>
|
||||
<span className="text-xs text-slate-500">{formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={() => {
|
||||
setFile(null);
|
||||
toast('Archivo eliminado', { icon: '🗑️' });
|
||||
}}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg bg-red-100 hover:bg-red-200 text-red-600 transition-colors flex-shrink-0"
|
||||
>
|
||||
<X size={18} />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Uploading Progress */}
|
||||
<AnimatePresence>
|
||||
{status === 'uploading' && file && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="p-6 bg-blue-50 border-2 border-blue-200 rounded-xl"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<File className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-semibold text-sm text-blue-900 truncate">{file.name}</span>
|
||||
<span className="text-xs text-blue-700">{formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-200 rounded-full h-3 overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-blue-600 to-blue-500 rounded-full"
|
||||
initial={{ width: '0%' }}
|
||||
animate={{ width: '100%' }}
|
||||
transition={{ duration: 2, ease: 'easeInOut' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex items-center text-slate-400">
|
||||
<hr className="w-full border-slate-300" />
|
||||
<span className="px-4 font-medium text-sm">O</span>
|
||||
<hr className="w-full border-slate-300" />
|
||||
</div>
|
||||
|
||||
{/* Generate Synthetic Data - DESTACADO */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="relative overflow-hidden rounded-xl bg-gradient-to-br from-fuchsia-500 via-purple-500 to-indigo-600 p-1"
|
||||
>
|
||||
<div className="bg-white rounded-lg p-6 text-center">
|
||||
<div className="flex items-center justify-center mb-3">
|
||||
<Sparkles className="text-fuchsia-600 w-8 h-8" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-2">
|
||||
🎭 Prueba con Datos de Demo
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Explora el diagnóstico sin necesidad de datos reales. Generamos un dataset completo para ti.
|
||||
</p>
|
||||
<motion.button
|
||||
onClick={handleGenerateSyntheticData}
|
||||
disabled={isActionInProgress}
|
||||
whileHover={{ scale: isActionInProgress ? 1 : 1.05 }}
|
||||
whileTap={{ scale: isActionInProgress ? 1 : 0.95 }}
|
||||
className="flex items-center justify-center gap-2 w-full bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white px-6 py-4 rounded-lg hover:from-fuchsia-700 hover:to-purple-700 transition-all shadow-lg hover:shadow-xl disabled:opacity-75 disabled:cursor-not-allowed font-semibold text-lg"
|
||||
>
|
||||
{status === 'generating' ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={24} />
|
||||
Generando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={24} />
|
||||
Generar Datos Sintéticos
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex items-center text-slate-400">
|
||||
<hr className="w-full border-slate-300" />
|
||||
<span className="px-4 font-medium text-sm">O</span>
|
||||
<hr className="w-full border-slate-300" />
|
||||
</div>
|
||||
|
||||
{/* Google Sheets URL */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="relative"
|
||||
>
|
||||
<Sheet className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="url"
|
||||
placeholder="Pega la URL de tu Google Sheet aquí"
|
||||
value={sheetUrl}
|
||||
onChange={(e) => {
|
||||
resetState();
|
||||
setSheetUrl(e.target.value);
|
||||
setFile(null);
|
||||
}}
|
||||
disabled={isActionInProgress}
|
||||
className="w-full pl-12 pr-4 py-4 border-2 border-slate-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition disabled:bg-slate-100 text-sm"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Google Sheets Preview */}
|
||||
<AnimatePresence>
|
||||
{status !== 'uploading' && status !== 'success' && sheetUrl && !file && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="flex items-center justify-between gap-3 p-4 bg-green-50 border-2 border-green-200 text-green-800 rounded-xl"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<Sheet className="w-6 h-6 flex-shrink-0" />
|
||||
<span className="font-medium text-sm truncate">{sheetUrl}</span>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={() => {
|
||||
setSheetUrl('');
|
||||
toast('URL eliminada', { icon: '🗑️' });
|
||||
}}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg bg-red-100 hover:bg-red-200 text-red-600 transition-colors flex-shrink-0"
|
||||
>
|
||||
<X size={18} />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Success Message */}
|
||||
<AnimatePresence>
|
||||
{status === 'success' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="flex items-center justify-center gap-3 p-6 bg-green-50 border-2 border-green-200 text-green-800 rounded-xl"
|
||||
>
|
||||
<CheckCircle className="w-8 h-8 flex-shrink-0" />
|
||||
<span className="font-bold text-lg">¡Listo para analizar!</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Main Action Button */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
{renderMainButton()}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataUploaderEnhanced;
|
||||
238
frontend/components/DimensionCard.tsx
Normal file
238
frontend/components/DimensionCard.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import React from 'react';
|
||||
import { DimensionAnalysis } from '../types';
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertCircle, AlertTriangle, TrendingUp, CheckCircle, Zap } from 'lucide-react';
|
||||
import BadgePill from './BadgePill';
|
||||
|
||||
interface HealthStatus {
|
||||
level: 'critical' | 'low' | 'medium' | 'good' | 'excellent';
|
||||
label: string;
|
||||
color: string;
|
||||
textColor: string;
|
||||
bgColor: string;
|
||||
icon: React.ReactNode;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const getHealthStatus = (score: number): HealthStatus => {
|
||||
if (score >= 86) {
|
||||
return {
|
||||
level: 'excellent',
|
||||
label: 'EXCELENTE',
|
||||
color: 'text-cyan-700',
|
||||
textColor: 'text-cyan-700',
|
||||
bgColor: 'bg-cyan-50',
|
||||
icon: <CheckCircle size={20} className="text-cyan-600" />,
|
||||
description: 'Top quartile, modelo a seguir'
|
||||
};
|
||||
}
|
||||
if (score >= 71) {
|
||||
return {
|
||||
level: 'good',
|
||||
label: 'BUENO',
|
||||
color: 'text-emerald-700',
|
||||
textColor: 'text-emerald-700',
|
||||
bgColor: 'bg-emerald-50',
|
||||
icon: <TrendingUp size={20} className="text-emerald-600" />,
|
||||
description: 'Por encima de benchmarks, desempeño sólido'
|
||||
};
|
||||
}
|
||||
if (score >= 51) {
|
||||
return {
|
||||
level: 'medium',
|
||||
label: 'MEDIO',
|
||||
color: 'text-amber-700',
|
||||
textColor: 'text-amber-700',
|
||||
bgColor: 'bg-amber-50',
|
||||
icon: <AlertTriangle size={20} className="text-amber-600" />,
|
||||
description: 'Oportunidad de mejora identificada'
|
||||
};
|
||||
}
|
||||
if (score >= 31) {
|
||||
return {
|
||||
level: 'low',
|
||||
label: 'BAJO',
|
||||
color: 'text-orange-700',
|
||||
textColor: 'text-orange-700',
|
||||
bgColor: 'bg-orange-50',
|
||||
icon: <AlertTriangle size={20} className="text-orange-600" />,
|
||||
description: 'Requiere mejora, por debajo de benchmarks'
|
||||
};
|
||||
}
|
||||
return {
|
||||
level: 'critical',
|
||||
label: 'CRÍTICO',
|
||||
color: 'text-red-700',
|
||||
textColor: 'text-red-700',
|
||||
bgColor: 'bg-red-50',
|
||||
icon: <AlertCircle size={20} className="text-red-600" />,
|
||||
description: 'Requiere acción inmediata'
|
||||
};
|
||||
};
|
||||
|
||||
const getProgressBarColor = (score: number): string => {
|
||||
if (score >= 86) return 'bg-cyan-500';
|
||||
if (score >= 71) return 'bg-emerald-500';
|
||||
if (score >= 51) return 'bg-amber-500';
|
||||
if (score >= 31) return 'bg-orange-500';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
|
||||
const ScoreIndicator: React.FC<{ score: number; benchmark?: number }> = ({ score, benchmark }) => {
|
||||
const healthStatus = getHealthStatus(score);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Main Score Display */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold text-slate-900">{score}</span>
|
||||
<span className="text-lg text-slate-500">/100</span>
|
||||
</div>
|
||||
<BadgePill
|
||||
label={healthStatus.label}
|
||||
type={healthStatus.level === 'critical' ? 'critical' : healthStatus.level === 'low' ? 'warning' : 'info'}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar with Scale Reference */}
|
||||
<div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-3">
|
||||
<div
|
||||
className={`${getProgressBarColor(score)} h-3 rounded-full transition-all duration-500`}
|
||||
style={{ width: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scale Reference */}
|
||||
<div className="flex justify-between text-xs text-slate-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>25</span>
|
||||
<span>50</span>
|
||||
<span>75</span>
|
||||
<span>100</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Benchmark Comparison */}
|
||||
{benchmark !== undefined && (
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-slate-600">Benchmark Industria (P50)</span>
|
||||
<span className="font-bold text-slate-900">{benchmark}/100</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{score > benchmark ? (
|
||||
<span className="text-emerald-600 font-semibold">
|
||||
↑ {score - benchmark} puntos por encima del promedio
|
||||
</span>
|
||||
) : score === benchmark ? (
|
||||
<span className="text-amber-600 font-semibold">
|
||||
= Alineado con promedio de industria
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-orange-600 font-semibold">
|
||||
↓ {benchmark - score} puntos por debajo del promedio
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Health Status Description */}
|
||||
<div className={`${healthStatus.bgColor} rounded-lg p-3 flex items-start gap-2`}>
|
||||
{healthStatus.icon}
|
||||
<div>
|
||||
<p className={`text-sm font-semibold ${healthStatus.textColor}`}>
|
||||
{healthStatus.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DimensionCard: React.FC<{ dimension: DimensionAnalysis }> = ({ dimension }) => {
|
||||
const healthStatus = getHealthStatus(dimension.score);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className={`${healthStatus.bgColor} p-6 rounded-lg border-2 flex flex-col hover:shadow-lg transition-shadow`}
|
||||
style={{
|
||||
borderColor: healthStatus.color.replace('text-', '') + '-200'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-lg text-slate-900">{dimension.title}</h3>
|
||||
<p className="text-xs text-slate-500 mt-1">{dimension.name}</p>
|
||||
</div>
|
||||
{dimension.score >= 86 && (
|
||||
<span className="text-2xl">⭐</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score Indicator */}
|
||||
<div className="mb-5">
|
||||
<ScoreIndicator
|
||||
score={dimension.score}
|
||||
benchmark={dimension.percentile || 50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary Description */}
|
||||
<p className="text-sm text-slate-700 flex-grow mb-4 leading-relaxed">
|
||||
{dimension.summary}
|
||||
</p>
|
||||
|
||||
{/* KPI Display */}
|
||||
{dimension.kpi && (
|
||||
<div className="bg-white rounded-lg p-3 mb-4 border border-slate-200">
|
||||
<p className="text-xs text-slate-500 uppercase font-semibold mb-1">
|
||||
{dimension.kpi.label}
|
||||
</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-2xl font-bold text-slate-900">{dimension.kpi.value}</p>
|
||||
{dimension.kpi.change && (
|
||||
<span className={`text-xs font-semibold px-2 py-1 rounded-full ${
|
||||
dimension.kpi.changeType === 'positive'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{dimension.kpi.change}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={`w-full py-2 px-4 rounded-lg font-semibold flex items-center justify-center gap-2 transition-colors ${
|
||||
dimension.score < 51
|
||||
? 'bg-red-500 text-white hover:bg-red-600'
|
||||
: dimension.score < 71
|
||||
? 'bg-amber-500 text-white hover:bg-amber-600'
|
||||
: 'bg-slate-300 text-slate-600 cursor-default'
|
||||
}`}
|
||||
disabled={dimension.score >= 71}
|
||||
>
|
||||
<Zap size={16} />
|
||||
{dimension.score < 51
|
||||
? 'Ver Acciones Críticas'
|
||||
: dimension.score < 71
|
||||
? 'Explorar Mejoras'
|
||||
: 'En buen estado'}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DimensionCard;
|
||||
88
frontend/components/DimensionDetailView.tsx
Normal file
88
frontend/components/DimensionDetailView.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
|
||||
import React from 'react';
|
||||
import { DimensionAnalysis, Finding, Recommendation } from '../types';
|
||||
import { Lightbulb, Target } from 'lucide-react';
|
||||
|
||||
interface DimensionDetailViewProps {
|
||||
dimension: DimensionAnalysis;
|
||||
findings: Finding[];
|
||||
recommendations: Recommendation[];
|
||||
}
|
||||
|
||||
const ScoreIndicator: React.FC<{ score: number }> = ({ score }) => {
|
||||
const getScoreColor = (s: number) => {
|
||||
if (s >= 80) return 'bg-emerald-500';
|
||||
if (s >= 60) return 'bg-yellow-500';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 bg-slate-200 rounded-full h-2.5">
|
||||
<div className={`${getScoreColor(score)} h-2.5 rounded-full`} style={{ width: `${score}%`}}></div>
|
||||
</div>
|
||||
<span className={`font-bold text-lg ${getScoreColor(score).replace('bg-', 'text-')}`}>{score}<span className="text-sm text-slate-500">/100</span></span>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
const DimensionDetailView: React.FC<DimensionDetailViewProps> = ({ dimension, findings, recommendations }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<dimension.icon size={24} className="text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">{dimension.title}</h2>
|
||||
<p className="text-sm text-slate-500">Análisis detallado de la dimensión</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-4"/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="md:col-span-1">
|
||||
<h3 className="text-sm font-semibold text-slate-600 mb-2">Puntuación</h3>
|
||||
<ScoreIndicator score={dimension.score} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-slate-600 mb-2">Resumen</h3>
|
||||
<p className="text-slate-700 text-sm">{dimension.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-slate-50 p-6 rounded-lg border border-slate-200">
|
||||
<h3 className="font-bold text-xl text-slate-800 mb-4 flex items-center gap-2">
|
||||
<Lightbulb size={20} className="text-yellow-500" />
|
||||
Hallazgos Clave
|
||||
</h3>
|
||||
{findings.length > 0 ? (
|
||||
<ul className="space-y-3 text-sm text-slate-700 list-disc list-inside">
|
||||
{findings.map((finding, i) => <li key={i}>{finding.text}</li>)}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">No se encontraron hallazgos específicos para esta dimensión.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-blue-50 p-6 rounded-lg border border-blue-200">
|
||||
<h3 className="font-bold text-xl text-blue-800 mb-4 flex items-center gap-2">
|
||||
<Target size={20} className="text-blue-600" />
|
||||
Recomendaciones
|
||||
</h3>
|
||||
{recommendations.length > 0 ? (
|
||||
<ul className="space-y-3 text-sm text-blue-900 list-disc list-inside">
|
||||
{recommendations.map((rec, i) => <li key={i}>{rec.text}</li>)}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-blue-700">No hay recomendaciones específicas para esta dimensión.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DimensionDetailView;
|
||||
232
frontend/components/EconomicModelEnhanced.tsx
Normal file
232
frontend/components/EconomicModelEnhanced.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||
import { EconomicModelData } from '../types';
|
||||
import { DollarSign, TrendingDown, Calendar, TrendingUp } from 'lucide-react';
|
||||
import CountUp from 'react-countup';
|
||||
import MethodologyFooter from './MethodologyFooter';
|
||||
|
||||
interface EconomicModelEnhancedProps {
|
||||
data: EconomicModelData;
|
||||
}
|
||||
|
||||
const EconomicModelEnhanced: React.FC<EconomicModelEnhancedProps> = ({ data }) => {
|
||||
const {
|
||||
currentAnnualCost,
|
||||
futureAnnualCost,
|
||||
annualSavings,
|
||||
initialInvestment,
|
||||
paybackMonths,
|
||||
roi3yr,
|
||||
} = data;
|
||||
|
||||
// Data for comparison chart
|
||||
const comparisonData = [
|
||||
{
|
||||
name: 'Coste Actual',
|
||||
value: currentAnnualCost,
|
||||
color: '#ef4444',
|
||||
},
|
||||
{
|
||||
name: 'Coste Futuro',
|
||||
value: futureAnnualCost,
|
||||
color: '#10b981',
|
||||
},
|
||||
];
|
||||
|
||||
// Data for savings breakdown (example)
|
||||
const savingsBreakdown = [
|
||||
{ category: 'Automatización', amount: annualSavings * 0.45, percentage: 45 },
|
||||
{ category: 'Eficiencia', amount: annualSavings * 0.30, percentage: 30 },
|
||||
{ category: 'Reducción AHT', amount: annualSavings * 0.15, percentage: 15 },
|
||||
{ category: 'Otros', amount: annualSavings * 0.10, percentage: 10 },
|
||||
];
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-slate-900 text-white px-3 py-2 rounded-lg shadow-lg text-sm">
|
||||
<p className="font-semibold">{payload[0].payload.name}</p>
|
||||
<p className="text-green-400">€{payload[0].value.toLocaleString('es-ES')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="economics" className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
|
||||
<h3 className="font-bold text-xl text-slate-800 mb-6">Modelo Económico</h3>
|
||||
|
||||
{/* Key Metrics Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{/* Annual Savings */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-gradient-to-br from-green-50 to-emerald-50 p-6 rounded-xl border-2 border-green-200"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingDown size={20} className="text-green-600" />
|
||||
<span className="text-sm font-medium text-green-900">Ahorro Anual</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-green-600">
|
||||
€<CountUp end={annualSavings} duration={2} separator="," />
|
||||
</div>
|
||||
<div className="text-xs text-green-700 mt-2">
|
||||
{((annualSavings / currentAnnualCost) * 100).toFixed(1)}% reducción de costes
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ROI 3 Years */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-gradient-to-br from-blue-50 to-indigo-50 p-6 rounded-xl border-2 border-blue-200"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp size={20} className="text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-900">ROI (3 años)</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
<CountUp end={roi3yr} duration={2} suffix="x" decimals={1} />
|
||||
</div>
|
||||
<div className="text-xs text-blue-700 mt-2">
|
||||
Retorno sobre inversión
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Payback Period */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="bg-gradient-to-br from-amber-50 to-orange-50 p-6 rounded-xl border-2 border-amber-200"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Calendar size={20} className="text-amber-600" />
|
||||
<span className="text-sm font-medium text-amber-900">Payback</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-amber-600">
|
||||
<CountUp end={paybackMonths} duration={2} /> m
|
||||
</div>
|
||||
<div className="text-xs text-amber-700 mt-2">
|
||||
Recuperación de inversión
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Initial Investment */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="bg-gradient-to-br from-slate-50 to-slate-100 p-6 rounded-xl border-2 border-slate-200"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<DollarSign size={20} className="text-slate-600" />
|
||||
<span className="text-sm font-medium text-slate-900">Inversión Inicial</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-slate-700">
|
||||
€<CountUp end={initialInvestment} duration={2} separator="," />
|
||||
</div>
|
||||
<div className="text-xs text-slate-600 mt-2">
|
||||
One-time investment
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Comparison Chart */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<h4 className="font-semibold text-slate-800 mb-4">Comparación AS-IS vs TO-BE</h4>
|
||||
<div className="bg-slate-50 p-4 rounded-lg">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={comparisonData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="name" stroke="#64748b" />
|
||||
<YAxis stroke="#64748b" />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="value" radius={[8, 8, 0, 0]}>
|
||||
{comparisonData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Savings Breakdown */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
<h4 className="font-semibold text-slate-800 mb-4">Desglose de Ahorros</h4>
|
||||
<div className="space-y-3">
|
||||
{savingsBreakdown.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.category}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.7 + index * 0.1 }}
|
||||
className="bg-slate-50 p-4 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-slate-700">{item.category}</span>
|
||||
<span className="font-bold text-slate-900">
|
||||
€{item.amount.toLocaleString('es-ES', { maximumFractionDigits: 0 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 bg-slate-200 rounded-full h-2">
|
||||
<motion.div
|
||||
className="bg-green-500 h-2 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${item.percentage}%` }}
|
||||
transition={{ delay: 0.8 + index * 0.1, duration: 0.8 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-slate-600 w-12 text-right">
|
||||
{item.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Summary Box */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 1 }}
|
||||
className="mt-8 bg-gradient-to-r from-blue-600 to-blue-700 p-6 rounded-xl text-white"
|
||||
>
|
||||
<h4 className="font-bold text-lg mb-3">Resumen Ejecutivo</h4>
|
||||
<p className="text-blue-100 text-sm leading-relaxed">
|
||||
Con una inversión inicial de <span className="font-bold text-white">€{initialInvestment.toLocaleString('es-ES')}</span>,
|
||||
se proyecta un ahorro anual de <span className="font-bold text-white">€{annualSavings.toLocaleString('es-ES')}</span>,
|
||||
recuperando la inversión en <span className="font-bold text-white">{paybackMonths} meses</span> y
|
||||
generando un ROI de <span className="font-bold text-white">{roi3yr}x</span> en 3 años.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Methodology Footer */}
|
||||
<MethodologyFooter
|
||||
sources="Datos operacionales internos (2024) | Benchmarks: Gartner, Forrester Research | Costes de software: RFP vendors (Q4 2024)"
|
||||
methodology="DCF (Discounted Cash Flow) con tasa de descuento 10% | Fully-loaded cost incluye salario, beneficios, overhead | Assumptions conservadoras: 80% adoption rate, 30% automatización"
|
||||
notes="Desglose de ahorros: Automatización (45%), Eficiencia operativa (30%), Mejora FCR (15%), Reducción attrition (7.5%), Otros (2.5%) | Payback calculado sobre flujo de caja acumulado"
|
||||
lastUpdated="Enero 2025"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EconomicModelEnhanced;
|
||||
517
frontend/components/EconomicModelPro.tsx
Normal file
517
frontend/components/EconomicModelPro.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, LineChart, Line, Area, ComposedChart } from 'recharts';
|
||||
import { EconomicModelData } from '../types';
|
||||
import { DollarSign, TrendingDown, Calendar, TrendingUp, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||
import CountUp from 'react-countup';
|
||||
import MethodologyFooter from './MethodologyFooter';
|
||||
|
||||
interface EconomicModelProProps {
|
||||
data: EconomicModelData;
|
||||
}
|
||||
|
||||
const EconomicModelPro: React.FC<EconomicModelProProps> = ({ data }) => {
|
||||
const { initialInvestment, annualSavings, paybackMonths, roi3yr, savingsBreakdown } = data;
|
||||
|
||||
// Calculate detailed cost breakdown
|
||||
const costBreakdown = useMemo(() => {
|
||||
try {
|
||||
const safeInitialInvestment = initialInvestment || 0;
|
||||
return [
|
||||
{ category: 'Software & Licencias', amount: safeInitialInvestment * 0.43, percentage: 43 },
|
||||
{ category: 'Implementación & Consultoría', amount: safeInitialInvestment * 0.29, percentage: 29 },
|
||||
{ category: 'Training & Change Mgmt', amount: safeInitialInvestment * 0.18, percentage: 18 },
|
||||
{ category: 'Contingencia (10%)', amount: safeInitialInvestment * 0.10, percentage: 10 },
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('❌ Error in costBreakdown useMemo:', error);
|
||||
return [];
|
||||
}
|
||||
}, [initialInvestment]);
|
||||
|
||||
// Waterfall data (quarterly cash flow)
|
||||
const waterfallData = useMemo(() => {
|
||||
try {
|
||||
const safeInitialInvestment = initialInvestment || 0;
|
||||
const safeAnnualSavings = annualSavings || 0;
|
||||
const quarters = 8; // 2 years
|
||||
const quarterlyData = [];
|
||||
let cumulative = -safeInitialInvestment;
|
||||
|
||||
// Q0: Initial investment
|
||||
quarterlyData.push({
|
||||
quarter: 'Inv',
|
||||
value: -safeInitialInvestment,
|
||||
cumulative: cumulative,
|
||||
isNegative: true,
|
||||
label: `-€${(safeInitialInvestment / 1000).toFixed(0)}K`,
|
||||
});
|
||||
|
||||
// Q1-Q8: Quarterly savings
|
||||
const quarterlySavings = safeAnnualSavings / 4;
|
||||
for (let i = 1; i <= quarters; i++) {
|
||||
cumulative += quarterlySavings;
|
||||
const isBreakeven = cumulative >= 0 && (cumulative - quarterlySavings) < 0;
|
||||
|
||||
quarterlyData.push({
|
||||
quarter: `Q${i}`,
|
||||
value: quarterlySavings,
|
||||
cumulative: cumulative,
|
||||
isNegative: cumulative < 0,
|
||||
isBreakeven: isBreakeven,
|
||||
label: `€${(quarterlySavings / 1000).toFixed(0)}K`,
|
||||
});
|
||||
}
|
||||
|
||||
return quarterlyData;
|
||||
} catch (error) {
|
||||
console.error('❌ Error in waterfallData useMemo:', error);
|
||||
return [];
|
||||
}
|
||||
}, [initialInvestment, annualSavings]);
|
||||
|
||||
// Sensitivity analysis
|
||||
const sensitivityData = useMemo(() => {
|
||||
try {
|
||||
const safeAnnualSavings = annualSavings || 0;
|
||||
const safeInitialInvestment = initialInvestment || 1;
|
||||
const safeRoi3yr = roi3yr || 0;
|
||||
const safePaybackMonths = paybackMonths || 0;
|
||||
|
||||
return [
|
||||
{
|
||||
scenario: 'Pesimista (-20%)',
|
||||
annualSavings: safeAnnualSavings * 0.8,
|
||||
roi3yr: ((safeAnnualSavings * 0.8 * 3) / safeInitialInvestment).toFixed(1),
|
||||
payback: Math.ceil((safeInitialInvestment / (safeAnnualSavings * 0.8)) * 12),
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-50',
|
||||
},
|
||||
{
|
||||
scenario: 'Base Case',
|
||||
annualSavings: safeAnnualSavings,
|
||||
roi3yr: typeof safeRoi3yr === 'number' ? safeRoi3yr.toFixed(1) : '0',
|
||||
payback: safePaybackMonths,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50',
|
||||
},
|
||||
{
|
||||
scenario: 'Optimista (+20%)',
|
||||
annualSavings: safeAnnualSavings * 1.2,
|
||||
roi3yr: ((safeAnnualSavings * 1.2 * 3) / safeInitialInvestment).toFixed(1),
|
||||
payback: Math.ceil((safeInitialInvestment / (safeAnnualSavings * 1.2)) * 12),
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50',
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('❌ Error in sensitivityData useMemo:', error);
|
||||
return [];
|
||||
}
|
||||
}, [annualSavings, initialInvestment, roi3yr, paybackMonths]);
|
||||
|
||||
// Comparison with alternatives
|
||||
const alternatives = useMemo(() => {
|
||||
try {
|
||||
const safeRoi3yr = roi3yr || 0;
|
||||
const safeInitialInvestment = initialInvestment || 50000; // Default investment
|
||||
const safeAnnualSavings = annualSavings || 150000; // Default savings
|
||||
return [
|
||||
{
|
||||
option: 'Do Nothing',
|
||||
investment: 0,
|
||||
savings3yr: 0,
|
||||
roi: 'N/A',
|
||||
risk: 'Alto',
|
||||
riskColor: 'text-red-600',
|
||||
recommended: false,
|
||||
},
|
||||
{
|
||||
option: 'Solución Propuesta',
|
||||
investment: safeInitialInvestment || 0,
|
||||
savings3yr: (safeAnnualSavings || 0) * 3,
|
||||
roi: `${safeRoi3yr.toFixed(1)}x`,
|
||||
risk: 'Medio',
|
||||
riskColor: 'text-amber-600',
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
option: 'Alternativa Manual',
|
||||
investment: safeInitialInvestment * 0.5,
|
||||
savings3yr: safeAnnualSavings * 1.5,
|
||||
roi: '2.0x',
|
||||
risk: 'Bajo',
|
||||
riskColor: 'text-green-600',
|
||||
recommended: false,
|
||||
},
|
||||
{
|
||||
option: 'Alternativa Premium',
|
||||
investment: safeInitialInvestment * 1.5,
|
||||
savings3yr: safeAnnualSavings * 2.3,
|
||||
roi: '3.3x',
|
||||
risk: 'Alto',
|
||||
riskColor: 'text-red-600',
|
||||
recommended: false,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('❌ Error in alternatives useMemo:', error);
|
||||
return [];
|
||||
}
|
||||
}, [initialInvestment, annualSavings, roi3yr]);
|
||||
|
||||
// Financial metrics
|
||||
const financialMetrics = useMemo(() => {
|
||||
const npv = (annualSavings * 3 * 0.9) - initialInvestment; // Simplified NPV with 10% discount
|
||||
const irr = 185; // Simplified IRR estimation
|
||||
const tco3yr = initialInvestment + (annualSavings * 0.2 * 3); // TCO = Investment + 20% recurring costs
|
||||
const valueCreated = (annualSavings * 3) - tco3yr;
|
||||
|
||||
return { npv, irr, tco3yr, valueCreated };
|
||||
}, [initialInvestment, annualSavings]);
|
||||
|
||||
try {
|
||||
return (
|
||||
<div id="economic-model" className="bg-white p-8 rounded-xl border border-slate-200 shadow-sm">
|
||||
{/* Header with Dynamic Title */}
|
||||
<div className="mb-6">
|
||||
<h3 className="font-bold text-2xl text-slate-800 mb-2">
|
||||
Business Case: €{((annualSavings || 0) / 1000).toFixed(0)}K en ahorros anuales con payback de {paybackMonths || 0} meses y ROI de {(typeof roi3yr === 'number' ? roi3yr : 0).toFixed(1)}x
|
||||
</h3>
|
||||
<p className="text-base text-slate-700 font-medium leading-relaxed mb-1">
|
||||
Inversión de €{((initialInvestment || 0) / 1000).toFixed(0)}K genera retorno de €{(((annualSavings || 0) * 3) / 1000).toFixed(0)}K en 3 años
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
Análisis financiero completo | NPV: €{(financialMetrics.npv / 1000).toFixed(0)}K | IRR: {financialMetrics.irr}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-gradient-to-br from-blue-50 to-blue-100 p-5 rounded-xl border border-blue-200"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<DollarSign size={20} className="text-blue-600" />
|
||||
<span className="text-xs font-semibold text-blue-700">ROI (3 años)</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
<CountUp end={roi3yr} decimals={1} duration={1.5} suffix="x" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-gradient-to-br from-green-50 to-green-100 p-5 rounded-xl border border-green-200"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingDown size={20} className="text-green-600" />
|
||||
<span className="text-xs font-semibold text-green-700">Ahorro Anual</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-green-600">
|
||||
€<CountUp end={annualSavings} duration={1.5} separator="," />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="bg-gradient-to-br from-purple-50 to-purple-100 p-5 rounded-xl border border-purple-200"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Calendar size={20} className="text-purple-600" />
|
||||
<span className="text-xs font-semibold text-purple-700">Payback</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-purple-600">
|
||||
<CountUp end={paybackMonths} duration={1.5} /> <span className="text-lg">meses</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="bg-gradient-to-br from-amber-50 to-amber-100 p-5 rounded-xl border border-amber-200"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp size={20} className="text-amber-600" />
|
||||
<span className="text-xs font-semibold text-amber-700">NPV</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-amber-600">
|
||||
€<CountUp end={financialMetrics.npv} duration={1.5} separator="," />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Cost and Savings Breakdown */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* Cost Breakdown */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="bg-slate-50 p-6 rounded-xl border border-slate-200"
|
||||
>
|
||||
<h4 className="font-bold text-lg text-slate-800 mb-4">Inversión Inicial (€{(initialInvestment / 1000).toFixed(0)}K)</h4>
|
||||
<div className="space-y-3">
|
||||
{costBreakdown.map((item, index) => (
|
||||
<div key={item.category}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-slate-700 text-sm">{item.category}</span>
|
||||
<span className="font-bold text-slate-900">
|
||||
€{(item.amount / 1000).toFixed(0)}K ({item.percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 bg-slate-200 rounded-full h-2">
|
||||
<motion.div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${item.percentage}%` }}
|
||||
transition={{ delay: 0.6 + index * 0.1, duration: 0.8 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Savings Breakdown */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="bg-green-50 p-6 rounded-xl border border-green-200"
|
||||
>
|
||||
<h4 className="font-bold text-lg text-green-800 mb-4">Ahorros Anuales (€{(annualSavings / 1000).toFixed(0)}K)</h4>
|
||||
<div className="space-y-3">
|
||||
{savingsBreakdown && savingsBreakdown.length > 0 ? savingsBreakdown.map((item, index) => (
|
||||
<div key={item.category}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-green-700 text-sm">{item.category}</span>
|
||||
<span className="font-bold text-green-900">
|
||||
€{(item.amount / 1000).toFixed(0)}K ({item.percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 bg-green-200 rounded-full h-2">
|
||||
<motion.div
|
||||
className="bg-green-600 h-2 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${item.percentage}%` }}
|
||||
transition={{ delay: 0.7 + index * 0.1, duration: 0.8 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
: (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
<p className="text-sm">No hay datos de ahorros disponibles</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Waterfall Chart */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<h4 className="font-bold text-lg text-slate-800 mb-4">Flujo de Caja Acumulado (Waterfall)</h4>
|
||||
<div className="bg-slate-50 p-6 rounded-xl border border-slate-200">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ComposedChart data={waterfallData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="quarter" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1e293b', border: 'none', borderRadius: '8px', color: 'white' }}
|
||||
formatter={(value: number) => `€${(value / 1000).toFixed(0)}K`}
|
||||
/>
|
||||
<Bar dataKey="cumulative" radius={[4, 4, 0, 0]}>
|
||||
{waterfallData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.isBreakeven ? '#10b981' : entry.isNegative ? '#ef4444' : '#3b82f6'}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cumulative"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#8b5cf6', r: 4 }}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-4 text-center text-sm text-slate-600">
|
||||
<span className="font-semibold">Breakeven alcanzado en Q{Math.ceil(paybackMonths / 3)}</span> (mes {paybackMonths})
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Sensitivity Analysis */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.9 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<h4 className="font-bold text-lg text-slate-800 mb-4">Análisis de Sensibilidad</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-100">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-semibold text-slate-700">Escenario</th>
|
||||
<th className="p-3 text-center font-semibold text-slate-700">Ahorro Anual</th>
|
||||
<th className="p-3 text-center font-semibold text-slate-700">ROI (3 años)</th>
|
||||
<th className="p-3 text-center font-semibold text-slate-700">Payback</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sensitivityData.map((scenario, index) => (
|
||||
<motion.tr
|
||||
key={scenario.scenario}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 1 + index * 0.1 }}
|
||||
className={`border-b border-slate-200 ${scenario.bgColor}`}
|
||||
>
|
||||
<td className="p-3 font-semibold">{scenario.scenario}</td>
|
||||
<td className="p-3 text-center font-bold">
|
||||
€{scenario.annualSavings.toLocaleString('es-ES')}
|
||||
</td>
|
||||
<td className={`p-3 text-center font-bold ${scenario.color}`}>
|
||||
{scenario.roi3yr}x
|
||||
</td>
|
||||
<td className="p-3 text-center font-semibold">
|
||||
{scenario.payback} meses
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-slate-600">
|
||||
<span className="font-semibold">Variables clave:</span> % Reducción AHT (±5pp), Adopción de usuarios (±15pp), Coste por FTE (±€10K)
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Comparison with Alternatives */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 1.1 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<h4 className="font-bold text-lg text-slate-800 mb-4">Evaluación de Alternativas</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-100">
|
||||
<tr>
|
||||
<th className="p-3 text-left font-semibold text-slate-700">Opción</th>
|
||||
<th className="p-3 text-center font-semibold text-slate-700">Inversión</th>
|
||||
<th className="p-3 text-center font-semibold text-slate-700">Ahorro (3 años)</th>
|
||||
<th className="p-3 text-center font-semibold text-slate-700">ROI</th>
|
||||
<th className="p-3 text-center font-semibold text-slate-700">Riesgo</th>
|
||||
<th className="p-3 text-center font-semibold text-slate-700"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{alternatives && alternatives.length > 0 ? alternatives.map((alt, index) => (
|
||||
<motion.tr
|
||||
key={alt.option}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 1.2 + index * 0.1 }}
|
||||
className={`border-b border-slate-200 ${alt.recommended ? 'bg-blue-50' : ''}`}
|
||||
>
|
||||
<td className="p-3 font-semibold">{alt.option}</td>
|
||||
<td className="p-3 text-center">
|
||||
€{(alt.investment || 0).toLocaleString('es-ES')}
|
||||
</td>
|
||||
<td className="p-3 text-center font-bold text-green-600">
|
||||
€{(alt.savings3yr || 0).toLocaleString('es-ES')}
|
||||
</td>
|
||||
<td className="p-3 text-center font-bold text-blue-600">
|
||||
{alt.roi}
|
||||
</td>
|
||||
<td className={`p-3 text-center font-semibold ${alt.riskColor}`}>
|
||||
{alt.risk}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
{alt.recommended && (
|
||||
<span className="inline-flex items-center gap-1 bg-blue-600 text-white text-xs font-semibold px-2 py-1 rounded">
|
||||
<CheckCircle size={12} />
|
||||
Recomendado
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</motion.tr>
|
||||
))
|
||||
: (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-4 text-center text-gray-500">
|
||||
Sin datos de alternativas disponibles
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-blue-700 font-medium">
|
||||
<span className="font-semibold">Recomendación:</span> Solución Propuesta (mejor balance ROI/Riesgo)
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Summary Box */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 1.3 }}
|
||||
className="bg-gradient-to-r from-blue-600 to-blue-700 p-6 rounded-xl text-white"
|
||||
>
|
||||
<h4 className="font-bold text-lg mb-3">Resumen Ejecutivo</h4>
|
||||
<p className="text-blue-100 text-sm leading-relaxed">
|
||||
Con una inversión inicial de <span className="font-bold text-white">€{initialInvestment.toLocaleString('es-ES')}</span>,
|
||||
se proyecta un ahorro anual de <span className="font-bold text-white">€{annualSavings.toLocaleString('es-ES')}</span>,
|
||||
recuperando la inversión en <span className="font-bold text-white">{paybackMonths} meses</span> y
|
||||
generando un ROI de <span className="font-bold text-white">{roi3yr.toFixed(1)}x</span> en 3 años.
|
||||
El NPV de <span className="font-bold text-white">€{financialMetrics.npv.toLocaleString('es-ES')}</span> y
|
||||
un IRR de <span className="font-bold text-white">{financialMetrics.irr}%</span> demuestran la solidez financiera del proyecto.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Methodology Footer */}
|
||||
<MethodologyFooter
|
||||
sources="Datos operacionales internos (2024) | Benchmarks: Gartner, Forrester Research | Costes de software: RFP vendors (Q4 2024)"
|
||||
methodology="DCF (Discounted Cash Flow) con tasa de descuento 10% | Fully-loaded cost incluye salario, beneficios, overhead | Assumptions conservadoras: 80% adoption rate, 30% automatización | NPV calculado con flujo de caja descontado | IRR estimado basado en payback y retornos proyectados"
|
||||
notes="Desglose de costos: Software (43%), Implementación (29%), Training (18%), Contingencia (10%) | Desglose de ahorros: Automatización (45%), Eficiencia operativa (30%), Mejora FCR (15%), Reducción attrition (7.5%), Otros (2.5%) | Sensibilidad: ±20% en ahorros refleja variabilidad en adopción y eficiencia | TCO 3 años incluye costes recurrentes (20% anual)"
|
||||
lastUpdated="Enero 2025"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('❌ CRITICAL ERROR in EconomicModelPro render:', error);
|
||||
return (
|
||||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-red-900 mb-2">❌ Error en Modelo Económico</h3>
|
||||
<p className="text-red-800">No se pudo renderizar el componente. Error: {String(error)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default EconomicModelPro;
|
||||
93
frontend/components/ErrorBoundary.tsx
Normal file
93
frontend/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
componentName?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
errorInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border-2 border-amber-200 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="text-amber-600 flex-shrink-0 mt-1" size={24} />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-amber-900 mb-2">
|
||||
{this.props.componentName ? `Error en ${this.props.componentName}` : 'Error de Renderizado'}
|
||||
</h3>
|
||||
<p className="text-amber-800 mb-3">
|
||||
Este componente encontró un error y no pudo renderizarse correctamente.
|
||||
El resto del dashboard sigue funcionando normalmente.
|
||||
</p>
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-amber-700 font-medium mb-2">
|
||||
Ver detalles técnicos
|
||||
</summary>
|
||||
<div className="bg-white rounded p-3 mt-2 font-mono text-xs overflow-auto max-h-40">
|
||||
<p className="text-red-600 font-semibold mb-1">Error:</p>
|
||||
<p className="text-slate-700 mb-3">{this.state.error?.toString()}</p>
|
||||
{this.state.errorInfo && (
|
||||
<>
|
||||
<p className="text-red-600 font-semibold mb-1">Stack:</p>
|
||||
<pre className="text-slate-600 whitespace-pre-wrap">
|
||||
{this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 px-4 py-2 bg-amber-600 text-white rounded hover:bg-amber-700 transition-colors"
|
||||
>
|
||||
Recargar Página
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
169
frontend/components/HealthScoreGaugeEnhanced.tsx
Normal file
169
frontend/components/HealthScoreGaugeEnhanced.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
import CountUp from 'react-countup';
|
||||
|
||||
interface HealthScoreGaugeEnhancedProps {
|
||||
score: number;
|
||||
previousScore?: number;
|
||||
industryAverage?: number;
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
const HealthScoreGaugeEnhanced: React.FC<HealthScoreGaugeEnhancedProps> = ({
|
||||
score,
|
||||
previousScore,
|
||||
industryAverage = 65,
|
||||
animated = true,
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(true);
|
||||
}, []);
|
||||
|
||||
const getScoreColor = (value: number): string => {
|
||||
if (value >= 80) return '#10b981'; // green
|
||||
if (value >= 60) return '#f59e0b'; // amber
|
||||
return '#ef4444'; // red
|
||||
};
|
||||
|
||||
const getScoreLabel = (value: number): string => {
|
||||
if (value >= 80) return 'Excelente';
|
||||
if (value >= 60) return 'Bueno';
|
||||
if (value >= 40) return 'Regular';
|
||||
return 'Crítico';
|
||||
};
|
||||
|
||||
const scoreColor = getScoreColor(score);
|
||||
const scoreLabel = getScoreLabel(score);
|
||||
|
||||
const trend = previousScore ? score - previousScore : 0;
|
||||
const trendPercentage = previousScore ? ((trend / previousScore) * 100).toFixed(1) : '0';
|
||||
|
||||
const vsIndustry = score - industryAverage;
|
||||
const vsIndustryPercentage = ((vsIndustry / industryAverage) * 100).toFixed(1);
|
||||
|
||||
// Calculate SVG path for gauge
|
||||
const radius = 80;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDashoffset = circumference - (score / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-white to-slate-50 p-6 rounded-xl border border-slate-200 shadow-sm">
|
||||
<h3 className="font-bold text-lg text-slate-800 mb-6">Health Score General</h3>
|
||||
|
||||
{/* Gauge SVG */}
|
||||
<div className="relative flex items-center justify-center mb-6">
|
||||
<svg width="200" height="200" className="transform -rotate-90">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r={radius}
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth="12"
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
{/* Animated progress circle */}
|
||||
<motion.circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r={radius}
|
||||
stroke={scoreColor}
|
||||
strokeWidth="12"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
initial={{ strokeDashoffset: circumference }}
|
||||
animate={{ strokeDashoffset: animated && isVisible ? strokeDashoffset : circumference }}
|
||||
transition={{ duration: 1.5, ease: 'easeOut' }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Center content */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<div className="text-5xl font-bold" style={{ color: scoreColor }}>
|
||||
{animated ? (
|
||||
<CountUp end={score} duration={1.5} />
|
||||
) : (
|
||||
score
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-slate-500 mt-1">{scoreLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Trend vs Previous */}
|
||||
{previousScore && (
|
||||
<motion.div
|
||||
className="bg-white p-3 rounded-lg border border-slate-200"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{trend > 0 ? (
|
||||
<TrendingUp size={16} className="text-green-600" />
|
||||
) : trend < 0 ? (
|
||||
<TrendingDown size={16} className="text-red-600" />
|
||||
) : (
|
||||
<Minus size={16} className="text-slate-400" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-slate-600">vs Anterior</span>
|
||||
</div>
|
||||
<div className={`text-xl font-bold ${trend > 0 ? 'text-green-600' : trend < 0 ? 'text-red-600' : 'text-slate-600'}`}>
|
||||
{trend > 0 ? '+' : ''}{trend}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{trend > 0 ? '+' : ''}{trendPercentage}%
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Vs Industry Average */}
|
||||
<motion.div
|
||||
className="bg-white p-3 rounded-lg border border-slate-200"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{vsIndustry > 0 ? (
|
||||
<TrendingUp size={16} className="text-green-600" />
|
||||
) : vsIndustry < 0 ? (
|
||||
<TrendingDown size={16} className="text-red-600" />
|
||||
) : (
|
||||
<Minus size={16} className="text-slate-400" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-slate-600">vs Industria</span>
|
||||
</div>
|
||||
<div className={`text-xl font-bold ${vsIndustry > 0 ? 'text-green-600' : vsIndustry < 0 ? 'text-red-600' : 'text-slate-600'}`}>
|
||||
{vsIndustry > 0 ? '+' : ''}{vsIndustry}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{vsIndustry > 0 ? '+' : ''}{vsIndustryPercentage}%
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Industry Average Reference */}
|
||||
<motion.div
|
||||
className="mt-4 pt-4 border-t border-slate-200"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-600">Promedio Industria</span>
|
||||
<span className="font-semibold text-slate-700">{industryAverage}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HealthScoreGaugeEnhanced;
|
||||
263
frontend/components/HeatmapEnhanced.tsx
Normal file
263
frontend/components/HeatmapEnhanced.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { HelpCircle, ArrowUpDown, TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { HeatmapDataPoint } from '../types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface HeatmapEnhancedProps {
|
||||
data: HeatmapDataPoint[];
|
||||
}
|
||||
|
||||
type SortKey = 'skill' | 'fcr' | 'aht' | 'csat' | 'quality';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
interface TooltipData {
|
||||
skill: string;
|
||||
metric: string;
|
||||
value: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const getCellColor = (value: number) => {
|
||||
if (value >= 95) return 'bg-emerald-600 text-white';
|
||||
if (value >= 90) return 'bg-emerald-500 text-white';
|
||||
if (value >= 85) return 'bg-green-400 text-green-900';
|
||||
if (value >= 80) return 'bg-yellow-300 text-yellow-900';
|
||||
if (value >= 70) return 'bg-amber-400 text-amber-900';
|
||||
return 'bg-red-400 text-red-900';
|
||||
};
|
||||
|
||||
const getPercentile = (value: number): string => {
|
||||
if (value >= 95) return 'P95+';
|
||||
if (value >= 90) return 'P90-P95';
|
||||
if (value >= 75) return 'P75-P90';
|
||||
if (value >= 50) return 'P50-P75';
|
||||
return '<P50';
|
||||
};
|
||||
|
||||
const HeatmapEnhanced: React.FC<HeatmapEnhancedProps> = ({ data }) => {
|
||||
const [sortKey, setSortKey] = useState<SortKey>('skill');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
|
||||
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
|
||||
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
|
||||
|
||||
const metrics: Array<{ key: keyof HeatmapDataPoint['metrics']; label: string }> = [
|
||||
{ key: 'fcr', label: 'FCR' },
|
||||
{ key: 'aht', label: 'AHT' },
|
||||
{ key: 'csat', label: 'CSAT' },
|
||||
{ key: 'quality', label: 'Quality' },
|
||||
];
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortOrder('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedData = [...data].sort((a, b) => {
|
||||
let aValue: number | string;
|
||||
let bValue: number | string;
|
||||
|
||||
if (sortKey === 'skill') {
|
||||
aValue = a.skill;
|
||||
bValue = b.skill;
|
||||
} else {
|
||||
aValue = a.metrics[sortKey];
|
||||
bValue = b.metrics[sortKey];
|
||||
}
|
||||
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
return sortOrder === 'asc'
|
||||
? aValue.localeCompare(bValue)
|
||||
: bValue.localeCompare(aValue);
|
||||
}
|
||||
|
||||
return sortOrder === 'asc'
|
||||
? (aValue as number) - (bValue as number)
|
||||
: (bValue as number) - (aValue as number);
|
||||
});
|
||||
|
||||
const handleCellHover = (
|
||||
skill: string,
|
||||
metric: string,
|
||||
value: number,
|
||||
event: React.MouseEvent
|
||||
) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setTooltip({
|
||||
skill,
|
||||
metric,
|
||||
value,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCellLeave = () => {
|
||||
setTooltip(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="heatmap" className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold text-xl text-slate-800">Beyond CX Heatmap™</h3>
|
||||
<div className="group relative">
|
||||
<HelpCircle size={16} className="text-slate-400 cursor-pointer" />
|
||||
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 w-64 bg-slate-800 text-white text-xs rounded py-2 px-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none z-10">
|
||||
Mapa de calor de Readiness Agéntico por skill. Muestra el rendimiento en métricas clave para identificar fortalezas y áreas de mejora.
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500">
|
||||
Click en columnas para ordenar
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th
|
||||
onClick={() => handleSort('skill')}
|
||||
className="p-3 font-semibold text-slate-700 text-left cursor-pointer hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Skill/Proceso</span>
|
||||
<ArrowUpDown size={14} className="text-slate-400" />
|
||||
</div>
|
||||
</th>
|
||||
{metrics.map(({ key, label }) => (
|
||||
<th
|
||||
key={key}
|
||||
onClick={() => handleSort(key)}
|
||||
className="p-3 font-semibold text-slate-700 text-center cursor-pointer hover:bg-slate-100 transition-colors uppercase"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span>{label}</span>
|
||||
<ArrowUpDown size={14} className="text-slate-400" />
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<AnimatePresence>
|
||||
{sortedData.map(({ skill, metrics: skillMetrics }, index) => (
|
||||
<motion.tr
|
||||
key={skill}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
onMouseEnter={() => setHoveredRow(skill)}
|
||||
onMouseLeave={() => setHoveredRow(null)}
|
||||
className={clsx(
|
||||
'border-t border-slate-200 transition-colors',
|
||||
hoveredRow === skill && 'bg-blue-50'
|
||||
)}
|
||||
>
|
||||
<td className="p-3 font-semibold text-slate-700">
|
||||
{skill}
|
||||
</td>
|
||||
{metrics.map(({ key }) => {
|
||||
const value = skillMetrics[key];
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className={clsx(
|
||||
'p-3 font-bold text-center cursor-pointer transition-all',
|
||||
getCellColor(value),
|
||||
hoveredRow === skill && 'scale-105 shadow-md'
|
||||
)}
|
||||
onMouseEnter={(e) => handleCellHover(skill, key.toUpperCase(), value, e)}
|
||||
onMouseLeave={handleCellLeave}
|
||||
>
|
||||
{value}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</motion.tr>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex justify-end items-center gap-4 mt-6 text-xs">
|
||||
<span className="font-semibold text-slate-600">Leyenda:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded-sm bg-red-400"></div>
|
||||
<span className="text-slate-600"><70 (Bajo)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded-sm bg-yellow-300"></div>
|
||||
<span className="text-slate-600">70-85 (Medio)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded-sm bg-green-400"></div>
|
||||
<span className="text-slate-600">85-90 (Bueno)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded-sm bg-emerald-500"></div>
|
||||
<span className="text-slate-600">90+ (Excelente)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
<AnimatePresence>
|
||||
{tooltip && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed z-50 pointer-events-none"
|
||||
style={{
|
||||
left: tooltip.x,
|
||||
top: tooltip.y - 10,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
<div className="bg-slate-900 text-white px-4 py-3 rounded-lg shadow-xl text-sm">
|
||||
<div className="font-bold mb-2">{tooltip.skill}</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-slate-300">{tooltip.metric}:</span>
|
||||
<span className="font-bold">{tooltip.value}%</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-slate-300">Percentil:</span>
|
||||
<span className="font-semibold">{getPercentile(tooltip.value)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-1 border-t border-slate-700">
|
||||
{tooltip.value >= 85 ? (
|
||||
<>
|
||||
<TrendingUp size={14} className="text-green-400" />
|
||||
<span className="text-green-400 text-xs">Por encima del promedio</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrendingDown size={14} className="text-amber-400" />
|
||||
<span className="text-amber-400 text-xs">Oportunidad de mejora</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-900"></div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeatmapEnhanced;
|
||||
578
frontend/components/HeatmapPro.tsx
Normal file
578
frontend/components/HeatmapPro.tsx
Normal file
@@ -0,0 +1,578 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { HelpCircle, ArrowUpDown, TrendingUp, TrendingDown, AlertTriangle, Star, Award } from 'lucide-react';
|
||||
import { HeatmapDataPoint } from '../types';
|
||||
import clsx from 'clsx';
|
||||
import MethodologyFooter from './MethodologyFooter';
|
||||
|
||||
interface HeatmapProProps {
|
||||
data: HeatmapDataPoint[];
|
||||
}
|
||||
|
||||
type SortKey = 'skill' | 'fcr' | 'aht' | 'csat' | 'hold_time' | 'transfer_rate' | 'average' | 'cost';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
interface TooltipData {
|
||||
skill: string;
|
||||
metric: string;
|
||||
value: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Insight {
|
||||
type: 'strength' | 'opportunity';
|
||||
skill: string;
|
||||
metric: string;
|
||||
value: number;
|
||||
percentile: string;
|
||||
}
|
||||
|
||||
const getCellColor = (value: number) => {
|
||||
if (value >= 95) return 'bg-emerald-600 text-white';
|
||||
if (value >= 90) return 'bg-emerald-500 text-white';
|
||||
if (value >= 85) return 'bg-green-400 text-green-900';
|
||||
if (value >= 80) return 'bg-yellow-300 text-yellow-900';
|
||||
if (value >= 70) return 'bg-amber-400 text-amber-900';
|
||||
return 'bg-red-500 text-white';
|
||||
};
|
||||
|
||||
const getPercentile = (value: number): string => {
|
||||
if (value >= 95) return 'P95+ (Best-in-Class)';
|
||||
if (value >= 90) return 'P90-P95 (Excelente)';
|
||||
if (value >= 85) return 'P75-P90 (Competitivo)';
|
||||
if (value >= 70) return 'P50-P75 (Por debajo promedio)';
|
||||
return '<P50 (Crítico)';
|
||||
};
|
||||
|
||||
const getCellIcon = (value: number) => {
|
||||
if (value >= 95) return <Star size={12} className="inline ml-1" />;
|
||||
if (value < 70) return <AlertTriangle size={12} className="inline ml-1" />;
|
||||
return null;
|
||||
};
|
||||
|
||||
const HeatmapPro: React.FC<HeatmapProProps> = ({ data }) => {
|
||||
console.log('🔥 HeatmapPro received data:', {
|
||||
length: data?.length,
|
||||
firstItem: data?.[0],
|
||||
firstMetrics: data?.[0]?.metrics,
|
||||
metricsKeys: data?.[0] ? Object.keys(data[0].metrics) : [],
|
||||
metricsValues: data?.[0] ? Object.values(data[0].metrics) : [],
|
||||
hasUndefinedMetrics: data?.some(item =>
|
||||
Object.values(item.metrics).some(v => v === undefined)
|
||||
),
|
||||
hasNaNMetrics: data?.some(item =>
|
||||
Object.values(item.metrics).some(v => isNaN(v))
|
||||
)
|
||||
});
|
||||
|
||||
const [sortKey, setSortKey] = useState<SortKey>('skill');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
|
||||
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
|
||||
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
|
||||
|
||||
const metrics: Array<{ key: keyof HeatmapDataPoint['metrics']; label: string }> = [
|
||||
{ key: 'fcr', label: 'FCR' },
|
||||
{ key: 'aht', label: 'AHT' },
|
||||
{ key: 'csat', label: 'CSAT' },
|
||||
{ key: 'hold_time', label: 'Hold Time' },
|
||||
{ key: 'transfer_rate', label: 'Transfer %' },
|
||||
];
|
||||
|
||||
// Calculate insights
|
||||
const insights = useMemo(() => {
|
||||
try {
|
||||
console.log('💡 insights useMemo called');
|
||||
const allMetrics: Array<{ skill: string; metric: string; value: number }> = [];
|
||||
|
||||
if (!data || !Array.isArray(data)) {
|
||||
console.log('⚠️ insights: data is invalid');
|
||||
return { strengths: [], opportunities: [] };
|
||||
}
|
||||
|
||||
console.log(`✅ insights: processing ${data.length} items`);
|
||||
data.forEach(item => {
|
||||
if (!item?.metrics) return;
|
||||
metrics.forEach(({ key, label }) => {
|
||||
const value = item.metrics?.[key];
|
||||
if (typeof value === 'number' && !isNaN(value)) {
|
||||
allMetrics.push({
|
||||
skill: item?.skill || 'Unknown',
|
||||
metric: label,
|
||||
value: value,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
allMetrics.sort((a, b) => b.value - a.value);
|
||||
|
||||
const strengths: Insight[] = (allMetrics.slice(0, 3) || []).map(m => ({
|
||||
type: 'strength' as const,
|
||||
skill: m?.skill || 'Unknown',
|
||||
metric: m?.metric || 'Unknown',
|
||||
value: m?.value || 0,
|
||||
percentile: getPercentile(m?.value || 0),
|
||||
}));
|
||||
|
||||
const opportunities: Insight[] = (allMetrics.slice(-3).reverse() || []).map(m => ({
|
||||
type: 'opportunity' as const,
|
||||
skill: m?.skill || 'Unknown',
|
||||
metric: m?.metric || 'Unknown',
|
||||
value: m?.value || 0,
|
||||
percentile: getPercentile(m?.value || 0),
|
||||
}));
|
||||
|
||||
return { strengths, opportunities };
|
||||
} catch (error) {
|
||||
console.error('❌ Error in insights useMemo:', error);
|
||||
return { strengths: [], opportunities: [] };
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Calculate dynamic title
|
||||
const dynamicTitle = useMemo(() => {
|
||||
try {
|
||||
console.log('📊 dynamicTitle useMemo called');
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
console.log('⚠️ dynamicTitle: data is invalid or empty');
|
||||
return 'Análisis de métricas de rendimiento';
|
||||
}
|
||||
console.log(`✅ dynamicTitle: processing ${data.length} items`);
|
||||
const totalMetrics = data.length * metrics.length;
|
||||
const belowP75 = data.reduce((count, item) => {
|
||||
if (!item?.metrics) return count;
|
||||
return count + metrics.filter(m => {
|
||||
const value = item.metrics?.[m.key];
|
||||
return typeof value === 'number' && !isNaN(value) && value < 85;
|
||||
}).length;
|
||||
}, 0);
|
||||
const percentage = Math.round((belowP75 / totalMetrics) * 100);
|
||||
|
||||
const totalCost = data.reduce((sum, item) => sum + (item?.annual_cost || 0), 0);
|
||||
const costStr = `€${Math.round(totalCost / 1000)}K`;
|
||||
|
||||
const metricCounts = metrics.map(({ key, label }) => ({
|
||||
label,
|
||||
count: data.filter(item => {
|
||||
if (!item?.metrics) return false;
|
||||
const value = item.metrics?.[key];
|
||||
return typeof value === 'number' && !isNaN(value) && value < 85;
|
||||
}).length,
|
||||
}));
|
||||
metricCounts.sort((a, b) => b.count - a.count);
|
||||
const topMetric = metricCounts?.[0];
|
||||
|
||||
return `${percentage}% de las métricas están por debajo de P75, representando ${costStr} en coste anual, con ${topMetric?.label || 'N/A'} mostrando la mayor oportunidad de mejora`;
|
||||
} catch (error) {
|
||||
console.error('❌ Error in dynamicTitle useMemo:', error);
|
||||
return 'Análisis de métricas de rendimiento';
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Calculate averages
|
||||
const dataWithAverages = useMemo(() => {
|
||||
try {
|
||||
console.log('📋 dataWithAverages useMemo called');
|
||||
if (!data || !Array.isArray(data)) {
|
||||
console.log('⚠️ dataWithAverages: data is invalid');
|
||||
return [];
|
||||
}
|
||||
console.log(`✅ dataWithAverages: processing ${data.length} items`);
|
||||
return data.map((item, index) => {
|
||||
if (!item) {
|
||||
return { skill: 'Unknown', average: 0, metrics: {}, automation_readiness: 0, variability: {}, dimensions: {} };
|
||||
}
|
||||
if (!item.metrics) {
|
||||
return { ...item, average: 0 };
|
||||
}
|
||||
const values = metrics.map(m => item.metrics?.[m.key]).filter(v => typeof v === 'number' && !isNaN(v));
|
||||
const average = values.length > 0 ? values.reduce((sum, v) => sum + v, 0) / values.length : 0;
|
||||
return { ...item, average };
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Error in dataWithAverages useMemo:', error);
|
||||
return [];
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortOrder('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
try {
|
||||
console.log('🔄 sortedData useMemo called', { hasDataWithAverages: !!dataWithAverages, isArray: Array.isArray(dataWithAverages), length: dataWithAverages?.length });
|
||||
if (!dataWithAverages || !Array.isArray(dataWithAverages)) {
|
||||
console.log('⚠️ sortedData: dataWithAverages is invalid');
|
||||
return [];
|
||||
}
|
||||
console.log(`✅ sortedData: sorting ${dataWithAverages.length} items`);
|
||||
console.log('About to spread and sort dataWithAverages');
|
||||
const sorted = [...dataWithAverages].sort((a, b) => {
|
||||
try {
|
||||
if (!a || !b) {
|
||||
console.error('sort: a or b is null/undefined', { a, b });
|
||||
return 0;
|
||||
}
|
||||
let aValue: number | string;
|
||||
let bValue: number | string;
|
||||
|
||||
if (sortKey === 'skill') {
|
||||
aValue = a?.skill ?? '';
|
||||
bValue = b?.skill ?? '';
|
||||
} else if (sortKey === 'average') {
|
||||
aValue = a?.average ?? 0;
|
||||
bValue = b?.average ?? 0;
|
||||
} else if (sortKey === 'cost') {
|
||||
aValue = a?.annual_cost ?? 0;
|
||||
bValue = b?.annual_cost ?? 0;
|
||||
} else {
|
||||
aValue = a?.metrics?.[sortKey] ?? 0;
|
||||
bValue = b?.metrics?.[sortKey] ?? 0;
|
||||
}
|
||||
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
return sortOrder === 'asc'
|
||||
? aValue.localeCompare(bValue)
|
||||
: bValue.localeCompare(aValue);
|
||||
}
|
||||
|
||||
return sortOrder === 'asc'
|
||||
? (aValue as number) - (bValue as number)
|
||||
: (bValue as number) - (aValue as number);
|
||||
} catch (error) {
|
||||
console.error('Error in sort function:', error, { a, b, sortKey, sortOrder });
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
console.log('✅ Sort completed successfully', { sortedLength: sorted.length });
|
||||
return sorted;
|
||||
} catch (error) {
|
||||
console.error('❌ Error in sortedData useMemo:', error);
|
||||
return [];
|
||||
}
|
||||
}, [dataWithAverages, sortKey, sortOrder]);
|
||||
|
||||
const handleCellHover = (
|
||||
skill: string,
|
||||
metric: string,
|
||||
value: number,
|
||||
event: React.MouseEvent
|
||||
) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setTooltip({
|
||||
skill,
|
||||
metric,
|
||||
value,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCellLeave = () => {
|
||||
setTooltip(null);
|
||||
};
|
||||
|
||||
try {
|
||||
return (
|
||||
<div id="heatmap" className="bg-white p-8 rounded-xl border border-slate-200 shadow-sm">
|
||||
{/* Header with Dynamic Title */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="font-bold text-2xl text-slate-800">Beyond CX Heatmap™</h3>
|
||||
<div className="group relative">
|
||||
<HelpCircle size={18} className="text-slate-400 cursor-pointer" />
|
||||
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 w-80 bg-slate-800 text-white text-xs rounded py-2 px-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none z-10">
|
||||
Mapa de calor de Readiness Agéntico por skill. Muestra el rendimiento en métricas clave comparado con benchmarks de industria (P75) para identificar fortalezas y áreas de mejora prioritarias.
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base text-slate-700 font-medium leading-relaxed">
|
||||
{dynamicTitle}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Análisis de Performance Competitivo: Skills críticos vs. benchmarks de industria (P75) | Datos: Q4 2024 | N=15,000 interacciones
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights Panel */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
|
||||
{/* Top Strengths */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Award size={18} className="text-green-600" />
|
||||
<h4 className="font-semibold text-green-900">Top 3 Fortalezas</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{insights.strengths.map((insight, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between text-sm">
|
||||
<span className="text-green-800">
|
||||
<span className="font-semibold">{insight.skill}</span> - {insight.metric}
|
||||
</span>
|
||||
<span className="font-bold text-green-600">{insight.value}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Opportunities */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingUp size={18} className="text-amber-600" />
|
||||
<h4 className="font-semibold text-amber-900">Top 3 Oportunidades de Mejora</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{insights.opportunities.map((insight, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between text-sm">
|
||||
<span className="text-amber-800">
|
||||
<span className="font-semibold">{insight.skill}</span> - {insight.metric}
|
||||
</span>
|
||||
<span className="font-bold text-amber-600">{insight.value}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heatmap Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th
|
||||
onClick={() => handleSort('skill')}
|
||||
className="p-4 font-semibold text-slate-700 text-left cursor-pointer hover:bg-slate-100 transition-colors border-b-2 border-slate-300"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Skill/Proceso</span>
|
||||
<ArrowUpDown size={14} className="text-slate-400" />
|
||||
</div>
|
||||
</th>
|
||||
{metrics.map(({ key, label }) => (
|
||||
<th
|
||||
key={key}
|
||||
onClick={() => handleSort(key)}
|
||||
className="p-4 font-semibold text-slate-700 text-center cursor-pointer hover:bg-slate-100 transition-colors uppercase border-b-2 border-slate-300"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span>{label}</span>
|
||||
<ArrowUpDown size={14} className="text-slate-400" />
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th
|
||||
onClick={() => handleSort('average')}
|
||||
className="p-4 font-semibold text-slate-700 text-center cursor-pointer hover:bg-slate-100 transition-colors border-b-2 border-slate-300"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span>PROMEDIO</span>
|
||||
<ArrowUpDown size={14} className="text-slate-400" />
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
onClick={() => handleSort('cost')}
|
||||
className="p-4 font-semibold text-slate-700 text-center cursor-pointer hover:bg-slate-100 transition-colors border-b-2 border-slate-300"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span>COSTE ANUAL</span>
|
||||
<ArrowUpDown size={14} className="text-slate-400" />
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<AnimatePresence>
|
||||
{sortedData.map((item, index) => {
|
||||
// Calculate average cost once
|
||||
const avgCost = sortedData.length > 0
|
||||
? sortedData.reduce((sum, d) => sum + (d?.annual_cost || 0), 0) / sortedData.length
|
||||
: 0;
|
||||
return (
|
||||
<motion.tr
|
||||
key={item.skill}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
onMouseEnter={() => setHoveredRow(item.skill)}
|
||||
onMouseLeave={() => setHoveredRow(null)}
|
||||
className={clsx(
|
||||
'border-b border-slate-200 transition-colors',
|
||||
hoveredRow === item.skill && 'bg-blue-50'
|
||||
)}
|
||||
>
|
||||
<td className="p-4 font-semibold text-slate-800 border-r border-slate-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{item.skill}</span>
|
||||
{item.segment && (
|
||||
<span className={clsx(
|
||||
"text-xs px-2 py-1 rounded-full font-semibold",
|
||||
item.segment === 'high' && "bg-green-100 text-green-700",
|
||||
item.segment === 'medium' && "bg-yellow-100 text-yellow-700",
|
||||
item.segment === 'low' && "bg-red-100 text-red-700"
|
||||
)}>
|
||||
{item.segment === 'high' && '🟢 High'}
|
||||
{item.segment === 'medium' && '🟡 Medium'}
|
||||
{item.segment === 'low' && '🔴 Low'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
{metrics.map(({ key }) => {
|
||||
const value = item?.metrics?.[key] ?? 0;
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className={clsx(
|
||||
'p-4 font-bold text-center cursor-pointer transition-all relative',
|
||||
getCellColor(value),
|
||||
hoveredRow === item.skill && 'scale-105 shadow-lg ring-2 ring-blue-400'
|
||||
)}
|
||||
onMouseEnter={(e) => handleCellHover(item.skill, key.toUpperCase(), value, e)}
|
||||
onMouseLeave={handleCellLeave}
|
||||
>
|
||||
<span>{value}</span>
|
||||
{getCellIcon(value)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="p-4 font-bold text-center bg-slate-100 text-slate-700">
|
||||
{item.average.toFixed(1)}
|
||||
</td>
|
||||
<td className="p-4 text-center">
|
||||
{item.annual_cost ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="font-semibold text-slate-800">
|
||||
€{Math.round(item.annual_cost / 1000)}K
|
||||
</span>
|
||||
<div className={clsx(
|
||||
'w-3 h-3 rounded-full',
|
||||
(item?.annual_cost || 0) >= avgCost * 1.2
|
||||
? 'bg-red-500' // Alto coste (>120% del promedio)
|
||||
: (item?.annual_cost || 0) >= avgCost * 0.8
|
||||
? 'bg-amber-400' // Coste medio (80-120% del promedio)
|
||||
: 'bg-green-500' // Bajo coste (<80% del promedio)
|
||||
)} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-400 text-xs">N/A</span>
|
||||
)}
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Legend */}
|
||||
<div className="mt-6 p-4 bg-slate-50 rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-6 text-xs">
|
||||
<span className="font-semibold text-slate-700">Escala de Performance vs. Industria:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-sm bg-red-500"></div>
|
||||
<span className="text-slate-700"><strong><70</strong> - Crítico (Por debajo P25)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-sm bg-amber-400"></div>
|
||||
<span className="text-slate-700"><strong>70-80</strong> - Oportunidad (P25-P50)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-sm bg-yellow-300"></div>
|
||||
<span className="text-slate-700"><strong>80-85</strong> - Promedio (P50-P75)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-sm bg-green-400"></div>
|
||||
<span className="text-slate-700"><strong>85-90</strong> - Competitivo (P75-P90)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-sm bg-emerald-500"></div>
|
||||
<span className="text-slate-700"><strong>90-95</strong> - Excelente (P90-P95)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-sm bg-emerald-600"></div>
|
||||
<Star size={14} className="text-emerald-600" />
|
||||
<span className="text-slate-700"><strong>95+</strong> - Best-in-Class (P95+)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
<AnimatePresence>
|
||||
{tooltip && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed z-50 pointer-events-none"
|
||||
style={{
|
||||
left: tooltip.x,
|
||||
top: tooltip.y - 10,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
<div className="bg-slate-900 text-white px-4 py-3 rounded-lg shadow-xl text-sm">
|
||||
<div className="font-bold mb-2">{tooltip.skill}</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-slate-300">{tooltip.metric}:</span>
|
||||
<span className="font-bold">{tooltip.value}%</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-slate-300">Percentil:</span>
|
||||
<span className="font-semibold text-xs">{getPercentile(tooltip.value)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-slate-700">
|
||||
{tooltip.value >= 85 ? (
|
||||
<>
|
||||
<TrendingUp size={14} className="text-green-400" />
|
||||
<span className="text-green-400 text-xs">Por encima del promedio</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrendingDown size={14} className="text-amber-400" />
|
||||
<span className="text-amber-400 text-xs">Oportunidad de mejora</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-900"></div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Methodology Footer */}
|
||||
<MethodologyFooter
|
||||
sources="Datos operacionales internos (Q4 2024, N=15,000 interacciones) | Benchmarks: Gartner CX Benchmarking 2024, Forrester Customer Service Study 2024"
|
||||
methodology="Percentiles calculados vs. 250 contact centers en sector Telco/Tech | Escala 0-100 | Peer group: Contact centers 200-500 agentes, Europa Occidental"
|
||||
notes="FCR = First Contact Resolution, AHT = Average Handle Time, CSAT = Customer Satisfaction, Quality = QA Score | Benchmarks actualizados trimestralmente"
|
||||
lastUpdated="Enero 2025"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('❌ CRITICAL ERROR in HeatmapPro render:', error);
|
||||
return (
|
||||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-red-900 mb-2">❌ Error en Heatmap</h3>
|
||||
<p className="text-red-800">No se pudo renderizar el componente. Error: {String(error)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default HeatmapPro;
|
||||
199
frontend/components/HourlyDistributionChart.tsx
Normal file
199
frontend/components/HourlyDistributionChart.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts';
|
||||
import { Clock, AlertCircle, TrendingUp } from 'lucide-react';
|
||||
|
||||
interface HourlyDistributionChartProps {
|
||||
hourly: number[];
|
||||
off_hours_pct: number;
|
||||
peak_hours: number[];
|
||||
}
|
||||
|
||||
export function HourlyDistributionChart({ hourly, off_hours_pct, peak_hours }: HourlyDistributionChartProps) {
|
||||
// Preparar datos para el gráfico
|
||||
const chartData = hourly.map((value, hour) => ({
|
||||
hour: `${hour}:00`,
|
||||
hourNum: hour,
|
||||
volume: value,
|
||||
isPeak: peak_hours.includes(hour),
|
||||
isOffHours: hour < 8 || hour >= 19
|
||||
}));
|
||||
|
||||
const totalVolume = hourly.reduce((a, b) => a + b, 0);
|
||||
const peakVolume = Math.max(...hourly);
|
||||
const avgVolume = totalVolume / 24;
|
||||
|
||||
// Custom tooltip
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-white p-3 rounded-lg shadow-lg border border-slate-200">
|
||||
<p className="font-semibold text-slate-900 mb-1">{data.hour}</p>
|
||||
<p className="text-sm text-slate-600">
|
||||
Volumen: <span className="font-medium text-slate-900">{data.volume.toLocaleString('es-ES')}</span>
|
||||
</p>
|
||||
<p className="text-sm text-slate-600">
|
||||
% del total: <span className="font-medium text-slate-900">
|
||||
{((data.volume / totalVolume) * 100).toFixed(1)}%
|
||||
</span>
|
||||
</p>
|
||||
{data.isPeak && (
|
||||
<p className="text-xs text-amber-600 mt-1">⚡ Hora pico</p>
|
||||
)}
|
||||
{data.isOffHours && (
|
||||
<p className="text-xs text-red-600 mt-1">🌙 Fuera de horario</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="bg-white rounded-xl p-6 shadow-sm border border-slate-200"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-5 h-5 text-slate-600" />
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
Distribución Horaria de Interacciones
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">
|
||||
Análisis del volumen de interacciones por hora del día
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TrendingUp className="w-4 h-4 text-green-600" />
|
||||
<span className="text-xs text-slate-600">Volumen Pico</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{peakVolume.toLocaleString('es-ES')}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
{peak_hours.map(h => `${h}:00`).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-xs text-slate-600">Promedio/Hora</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{Math.round(avgVolume).toLocaleString('es-ES')}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
24 horas
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertCircle className="w-4 h-4 text-red-600" />
|
||||
<span className="text-xs text-slate-600">Fuera de Horario</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{(off_hours_pct * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
19:00 - 08:00
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis
|
||||
dataKey="hour"
|
||||
tick={{ fontSize: 11, fill: '#64748B' }}
|
||||
interval={1}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
tickFormatter={(value) => value.toLocaleString('es-ES')}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<ReferenceLine
|
||||
y={avgVolume}
|
||||
stroke="#6D84E3"
|
||||
strokeDasharray="5 5"
|
||||
label={{ value: 'Promedio', position: 'right', fill: '#6D84E3', fontSize: 12 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="volume"
|
||||
fill="#6D84E3"
|
||||
radius={[4, 4, 0, 0]}
|
||||
animationDuration={1000}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<motion.rect
|
||||
key={`bar-${index}`}
|
||||
initial={{ scaleY: 0 }}
|
||||
animate={{ scaleY: 1 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.02 }}
|
||||
fill={
|
||||
entry.isPeak ? '#F59E0B' : // Amber for peaks
|
||||
entry.isOffHours ? '#EF4444' : // Red for off-hours
|
||||
'#6D84E3' // Corporate blue for normal
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-center gap-6 mt-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-[#6D84E3]"></div>
|
||||
<span className="text-slate-600">Horario laboral (8-19h)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-[#F59E0B]"></div>
|
||||
<span className="text-slate-600">Horas pico</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-[#EF4444]"></div>
|
||||
<span className="text-slate-600">Fuera de horario</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insight */}
|
||||
{off_hours_pct > 0.25 && (
|
||||
<div className="mt-6 p-4 bg-amber-50 rounded-lg border border-amber-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-900 mb-1">
|
||||
Alto volumen fuera de horario laboral
|
||||
</p>
|
||||
<p className="text-sm text-amber-800">
|
||||
El {(off_hours_pct * 100).toFixed(0)}% de las interacciones ocurren fuera del horario
|
||||
laboral estándar (19:00-08:00). Considera implementar cobertura 24/7 con agentes virtuales
|
||||
para mejorar la experiencia del cliente y reducir costes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
109
frontend/components/LoginPage.tsx
Normal file
109
frontend/components/LoginPage.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
// components/LoginPage.tsx
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Lock, User } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAuth } from '../utils/AuthContext';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const { login } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) {
|
||||
toast.error('Introduce usuario y contraseña');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
toast.success('Sesión iniciada');
|
||||
} catch (err) {
|
||||
console.error('Error en login', err);
|
||||
const msg =
|
||||
err instanceof Error ? err.message : 'Error al iniciar sesión';
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-indigo-500 via-sky-500 to-slate-900 flex items-center justify-center px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-md bg-white/95 rounded-3xl shadow-2xl p-8 space-y-6"
|
||||
>
|
||||
<div className="space-y-2 text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-2xl bg-indigo-100 text-indigo-600 mb-1">
|
||||
<Lock className="w-6 h-6" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-slate-900">
|
||||
Beyond Diagnostic
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
Inicia sesión para acceder al análisis
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Usuario
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute inset-y-0 left-0 pl-3 flex items-center text-slate-400">
|
||||
<User className="w-4 h-4" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
className="block w-full rounded-2xl border border-slate-200 pl-9 pr-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute inset-y-0 left-0 pl-3 flex items-center text-slate-400">
|
||||
<Lock className="w-4 h-4" />
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className="block w-full rounded-2xl border border-slate-200 pl-9 pr-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full inline-flex items-center justify-center rounded-2xl bg-indigo-600 text-white text-sm font-medium py-2.5 shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'Entrando…' : 'Entrar'}
|
||||
</button>
|
||||
|
||||
<p className="text-[11px] text-slate-400 text-center mt-2">
|
||||
La sesión permanecerá activa durante 1 hora.
|
||||
</p>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
70
frontend/components/MethodologyFooter.tsx
Normal file
70
frontend/components/MethodologyFooter.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
interface MethodologyFooterProps {
|
||||
sources?: string;
|
||||
methodology?: string;
|
||||
notes?: string;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MethodologyFooter - McKinsey-style footer for charts and visualizations
|
||||
*
|
||||
* Displays sources, methodology, notes, and last updated information
|
||||
* in a professional, consulting-grade format.
|
||||
*/
|
||||
const MethodologyFooter: React.FC<MethodologyFooterProps> = ({
|
||||
sources,
|
||||
methodology,
|
||||
notes,
|
||||
lastUpdated,
|
||||
}) => {
|
||||
if (!sources && !methodology && !notes && !lastUpdated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 pt-4 border-t border-slate-200">
|
||||
<div className="space-y-2 text-xs text-slate-600">
|
||||
{sources && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Info size={12} className="mt-0.5 text-slate-400 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-semibold text-slate-700">Fuentes: </span>
|
||||
<span>{sources}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{methodology && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Info size={12} className="mt-0.5 text-slate-400 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-semibold text-slate-700">Metodología: </span>
|
||||
<span>{methodology}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notes && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Info size={12} className="mt-0.5 text-slate-400 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-semibold text-slate-700">Nota: </span>
|
||||
<span>{notes}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastUpdated && (
|
||||
<div className="text-slate-500 italic">
|
||||
Última actualización: {lastUpdated}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MethodologyFooter;
|
||||
775
frontend/components/MetodologiaDrawer.tsx
Normal file
775
frontend/components/MetodologiaDrawer.tsx
Normal file
@@ -0,0 +1,775 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X, ShieldCheck, Database, RefreshCw, Tag, BarChart3,
|
||||
ArrowRight, BadgeCheck, Download, ArrowLeftRight, Layers
|
||||
} from 'lucide-react';
|
||||
import type { AnalysisData, HeatmapDataPoint } from '../types';
|
||||
|
||||
interface MetodologiaDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
data: AnalysisData;
|
||||
}
|
||||
|
||||
interface DataSummary {
|
||||
totalRegistros: number;
|
||||
mesesHistorico: number;
|
||||
periodo: string;
|
||||
fuente: string;
|
||||
taxonomia: {
|
||||
valid: number;
|
||||
noise: number;
|
||||
zombie: number;
|
||||
abandon: number;
|
||||
};
|
||||
kpis: {
|
||||
fcrTecnico: number;
|
||||
fcrReal: number;
|
||||
abandonoTradicional: number;
|
||||
abandonoReal: number;
|
||||
ahtLimpio: number;
|
||||
skillsTecnicos: number;
|
||||
skillsNegocio: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ========== SUBSECCIONES ==========
|
||||
|
||||
function DataSummarySection({ data }: { data: DataSummary }) {
|
||||
return (
|
||||
<div className="bg-slate-50 rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-blue-600" />
|
||||
Datos Procesados
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-lg p-4 text-center shadow-sm">
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{data.totalRegistros.toLocaleString('es-ES')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Registros analizados</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 text-center shadow-sm">
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{data.mesesHistorico}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Meses de histórico</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 text-center shadow-sm">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{data.fuente}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Sistema origen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500 mt-3 text-center">
|
||||
Periodo: {data.periodo}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineSection() {
|
||||
const steps = [
|
||||
{
|
||||
layer: 'Layer 0',
|
||||
name: 'Raw Data',
|
||||
desc: 'Ingesta y Normalización',
|
||||
color: 'bg-gray-100 border-gray-300'
|
||||
},
|
||||
{
|
||||
layer: 'Layer 1',
|
||||
name: 'Trusted Data',
|
||||
desc: 'Higiene y Clasificación',
|
||||
color: 'bg-yellow-50 border-yellow-300'
|
||||
},
|
||||
{
|
||||
layer: 'Layer 2',
|
||||
name: 'Business Insights',
|
||||
desc: 'Enriquecimiento',
|
||||
color: 'bg-green-50 border-green-300'
|
||||
},
|
||||
{
|
||||
layer: 'Output',
|
||||
name: 'Dashboard',
|
||||
desc: 'Visualización',
|
||||
color: 'bg-blue-50 border-blue-300'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5 text-purple-600" />
|
||||
Pipeline de Transformación
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.layer}>
|
||||
<div className={`flex-1 p-3 rounded-lg border-2 ${step.color} text-center`}>
|
||||
<div className="text-[10px] text-gray-500 uppercase">{step.layer}</div>
|
||||
<div className="font-semibold text-sm">{step.name}</div>
|
||||
<div className="text-[10px] text-gray-600 mt-1">{step.desc}</div>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<ArrowRight className="w-5 h-5 text-gray-400 mx-1 flex-shrink-0" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-3 italic">
|
||||
Arquitectura modular de 3 capas para garantizar trazabilidad y escalabilidad.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaxonomySection({ data }: { data: DataSummary['taxonomia'] }) {
|
||||
const rows = [
|
||||
{
|
||||
status: 'VALID',
|
||||
pct: data.valid,
|
||||
def: 'Duración 10s - 3h. Interacciones reales.',
|
||||
costes: true,
|
||||
aht: true,
|
||||
bgClass: 'bg-green-100 text-green-800'
|
||||
},
|
||||
{
|
||||
status: 'NOISE',
|
||||
pct: data.noise,
|
||||
def: 'Duración <10s (no abandono). Ruido técnico.',
|
||||
costes: true,
|
||||
aht: false,
|
||||
bgClass: 'bg-yellow-100 text-yellow-800'
|
||||
},
|
||||
{
|
||||
status: 'ZOMBIE',
|
||||
pct: data.zombie,
|
||||
def: 'Duración >3h. Error de sistema.',
|
||||
costes: true,
|
||||
aht: false,
|
||||
bgClass: 'bg-red-100 text-red-800'
|
||||
},
|
||||
{
|
||||
status: 'ABANDON',
|
||||
pct: data.abandon,
|
||||
def: 'Desconexión externa + Talk ≤5s.',
|
||||
costes: false,
|
||||
aht: false,
|
||||
bgClass: 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Tag className="w-5 h-5 text-orange-600" />
|
||||
Taxonomía de Calidad de Datos
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
En lugar de eliminar registros, aplicamos "Soft Delete" con etiquetado de calidad
|
||||
para permitir doble visión: financiera (todos los costes) y operativa (KPIs limpios).
|
||||
</p>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold">Estado</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">%</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">Definición</th>
|
||||
<th className="px-3 py-2 text-center font-semibold">Costes</th>
|
||||
<th className="px-3 py-2 text-center font-semibold">AHT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rows.map((row, idx) => (
|
||||
<tr key={row.status} className={idx % 2 === 1 ? 'bg-gray-50' : ''}>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${row.bgClass}`}>
|
||||
{row.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-semibold">{row.pct.toFixed(1)}%</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600">{row.def}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{row.costes ? (
|
||||
<span className="text-green-600">✓ Suma</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗ No</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{row.aht ? (
|
||||
<span className="text-green-600">✓ Promedio</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗ Excluye</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KPIRedefinitionSection({ kpis }: { kpis: DataSummary['kpis'] }) {
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-indigo-600" />
|
||||
KPIs Redefinidos
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Hemos redefinido los KPIs para eliminar los "puntos ciegos" de las métricas tradicionales.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* FCR */}
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-semibold text-red-800">FCR Real vs FCR Técnico</h4>
|
||||
<p className="text-xs text-red-700 mt-1">
|
||||
El hallazgo más crítico del diagnóstico.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-red-600">{kpis.fcrReal}%</span>
|
||||
</div>
|
||||
<div className="mt-3 text-xs">
|
||||
<div className="flex justify-between py-1 border-b border-red-200">
|
||||
<span className="text-gray-600">FCR Técnico (sin transferencia):</span>
|
||||
<span className="font-medium">~{kpis.fcrTecnico}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1">
|
||||
<span className="text-gray-600">FCR Real (sin recontacto 7 días):</span>
|
||||
<span className="font-medium text-red-600">{kpis.fcrReal}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-red-600 mt-2 italic">
|
||||
💡 ~{kpis.fcrTecnico - kpis.fcrReal}% de "casos resueltos" generan segunda llamada, disparando costes ocultos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Abandono */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-semibold text-yellow-800">Tasa de Abandono Real</h4>
|
||||
<p className="text-xs text-yellow-700 mt-1">
|
||||
Fórmula: Desconexión Externa + Talk ≤5 segundos
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-yellow-600">{kpis.abandonoReal.toFixed(1)}%</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-yellow-600 mt-2 italic">
|
||||
💡 El umbral de 5s captura al cliente que cuelga al escuchar la locución o en el timbre.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* AHT */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-semibold text-blue-800">AHT Limpio</h4>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
Excluye NOISE (<10s) y ZOMBIE (>3h) del promedio.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-blue-600">{formatTime(kpis.ahtLimpio)}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-blue-600 mt-2 italic">
|
||||
💡 El AHT sin filtrar estaba distorsionado por errores de sistema.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CPICalculationSection({ totalCost, totalVolume, costPerHour = 20 }: { totalCost: number; totalVolume: number; costPerHour?: number }) {
|
||||
// Productivity factor: agents are ~70% productive (rest is breaks, training, after-call work, etc.)
|
||||
const effectiveProductivity = 0.70;
|
||||
|
||||
// CPI = Total Cost / Total Volume
|
||||
// El coste total ya incluye: TODOS los registros (noise + zombie + valid) y el factor de productividad
|
||||
const cpi = totalVolume > 0 ? totalCost / totalVolume : 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-emerald-600" />
|
||||
Coste por Interacción (CPI)
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
El CPI se calcula dividiendo el <strong>coste total</strong> entre el <strong>volumen de interacciones</strong>.
|
||||
El coste total incluye <em>todas</em> las interacciones (noise, zombie y válidas) porque todas se facturan,
|
||||
y aplica un factor de productividad del {(effectiveProductivity * 100).toFixed(0)}%.
|
||||
</p>
|
||||
|
||||
{/* Fórmula visual */}
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4 mb-4">
|
||||
<div className="text-center mb-3">
|
||||
<span className="text-xs text-emerald-700 uppercase tracking-wider font-medium">Fórmula de Cálculo</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 text-lg font-mono flex-wrap">
|
||||
<span className="px-3 py-1 bg-white rounded border border-emerald-300">CPI</span>
|
||||
<span className="text-emerald-600">=</span>
|
||||
<span className="px-2 py-1 bg-blue-100 rounded text-blue-800 text-sm">Coste Total</span>
|
||||
<span className="text-emerald-600">÷</span>
|
||||
<span className="px-2 py-1 bg-amber-100 rounded text-amber-800 text-sm">Volumen Total</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-center text-emerald-600 mt-2">
|
||||
El coste total usa (AHT segundos ÷ 3600) × coste/hora × volumen ÷ productividad
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cómo se calcula el coste total */}
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 mb-4">
|
||||
<div className="text-sm font-semibold text-slate-700 mb-2">¿Cómo se calcula el Coste Total?</div>
|
||||
<div className="bg-white rounded p-3 mb-3">
|
||||
<div className="flex items-center justify-center gap-2 text-sm font-mono flex-wrap">
|
||||
<span className="text-slate-600">Coste =</span>
|
||||
<span className="px-2 py-1 bg-blue-100 rounded text-blue-800 text-xs">(AHT seg ÷ 3600)</span>
|
||||
<span className="text-slate-400">×</span>
|
||||
<span className="px-2 py-1 bg-amber-100 rounded text-amber-800 text-xs">€{costPerHour}/h</span>
|
||||
<span className="text-slate-400">×</span>
|
||||
<span className="px-2 py-1 bg-gray-100 rounded text-gray-800 text-xs">Volumen</span>
|
||||
<span className="text-slate-400">÷</span>
|
||||
<span className="px-2 py-1 bg-purple-100 rounded text-purple-800 text-xs">{(effectiveProductivity * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">
|
||||
El <strong>AHT</strong> está en segundos, se convierte a horas dividiendo por 3600.
|
||||
Incluye todas las interacciones que generan coste (noise + zombie + válidas).
|
||||
Solo se excluyen los abandonos porque no consumen tiempo de agente.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Componentes del coste horario */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm font-semibold text-amber-800">Coste por Hora del Agente (Fully Loaded)</div>
|
||||
<span className="text-xs bg-amber-200 text-amber-800 px-2 py-0.5 rounded-full font-medium">
|
||||
Valor introducido: €{costPerHour.toFixed(2)}/h
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-700 mb-3">
|
||||
Este valor fue configurado en la pantalla de entrada de datos y debe incluir todos los costes asociados al agente:
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">•</span>
|
||||
<span className="text-amber-700">Salario bruto del agente</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">•</span>
|
||||
<span className="text-amber-700">Costes de seguridad social</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">•</span>
|
||||
<span className="text-amber-700">Licencias de software</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">•</span>
|
||||
<span className="text-amber-700">Infraestructura y puesto</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">•</span>
|
||||
<span className="text-amber-700">Supervisión y QA</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">•</span>
|
||||
<span className="text-amber-700">Formación y overhead</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-amber-600 mt-3 italic">
|
||||
💡 Si necesita ajustar este valor, puede volver a la pantalla de entrada de datos y modificarlo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BeforeAfterSection({ kpis }: { kpis: DataSummary['kpis'] }) {
|
||||
const rows = [
|
||||
{
|
||||
metric: 'FCR',
|
||||
tradicional: `${kpis.fcrTecnico}%`,
|
||||
beyond: `${kpis.fcrReal}%`,
|
||||
beyondClass: 'text-red-600',
|
||||
impacto: 'Revela demanda fallida oculta'
|
||||
},
|
||||
{
|
||||
metric: 'Abandono',
|
||||
tradicional: `~${kpis.abandonoTradicional}%`,
|
||||
beyond: `${kpis.abandonoReal.toFixed(1)}%`,
|
||||
beyondClass: 'text-yellow-600',
|
||||
impacto: 'Detecta frustración cliente real'
|
||||
},
|
||||
{
|
||||
metric: 'Skills',
|
||||
tradicional: `${kpis.skillsTecnicos} técnicos`,
|
||||
beyond: `${kpis.skillsNegocio} líneas negocio`,
|
||||
beyondClass: 'text-blue-600',
|
||||
impacto: 'Visión ejecutiva accionable'
|
||||
},
|
||||
{
|
||||
metric: 'AHT',
|
||||
tradicional: 'Distorsionado',
|
||||
beyond: 'Limpio',
|
||||
beyondClass: 'text-green-600',
|
||||
impacto: 'KPIs reflejan desempeño real'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<ArrowLeftRight className="w-5 h-5 text-teal-600" />
|
||||
Impacto de la Transformación
|
||||
</h3>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold">Métrica</th>
|
||||
<th className="px-3 py-2 text-center font-semibold">Visión Tradicional</th>
|
||||
<th className="px-3 py-2 text-center font-semibold">Visión Beyond</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">Impacto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rows.map((row, idx) => (
|
||||
<tr key={row.metric} className={idx % 2 === 1 ? 'bg-gray-50' : ''}>
|
||||
<td className="px-3 py-2 font-medium">{row.metric}</td>
|
||||
<td className="px-3 py-2 text-center">{row.tradicional}</td>
|
||||
<td className={`px-3 py-2 text-center font-semibold ${row.beyondClass}`}>{row.beyond}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600">{row.impacto}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-indigo-50 border border-indigo-200 rounded-lg">
|
||||
<p className="text-xs text-indigo-800">
|
||||
<strong>💡 Sin esta transformación,</strong> las decisiones de automatización
|
||||
se basarían en datos incorrectos, generando inversiones en los procesos equivocados.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillsMappingSection({ numSkillsNegocio }: { numSkillsNegocio: number }) {
|
||||
const mappings = [
|
||||
{
|
||||
lineaNegocio: 'Baggage & Handling',
|
||||
keywords: 'HANDLING, EQUIPAJE, AHL (Lost & Found), DPR (Daños)',
|
||||
color: 'bg-amber-100 text-amber-800'
|
||||
},
|
||||
{
|
||||
lineaNegocio: 'Sales & Booking',
|
||||
keywords: 'COMPRA, VENTA, RESERVA, PAGO',
|
||||
color: 'bg-blue-100 text-blue-800'
|
||||
},
|
||||
{
|
||||
lineaNegocio: 'Loyalty (SUMA)',
|
||||
keywords: 'SUMA (Programa de Fidelización)',
|
||||
color: 'bg-purple-100 text-purple-800'
|
||||
},
|
||||
{
|
||||
lineaNegocio: 'B2B & Agencies',
|
||||
keywords: 'AGENCIAS, AAVV, EMPRESAS, AVORIS, TOUROPERACION',
|
||||
color: 'bg-cyan-100 text-cyan-800'
|
||||
},
|
||||
{
|
||||
lineaNegocio: 'Changes & Post-Sales',
|
||||
keywords: 'MODIFICACION, CAMBIO, POSTVENTA, REFUND, REEMBOLSO',
|
||||
color: 'bg-orange-100 text-orange-800'
|
||||
},
|
||||
{
|
||||
lineaNegocio: 'Digital Support',
|
||||
keywords: 'WEB (Soporte a navegación)',
|
||||
color: 'bg-indigo-100 text-indigo-800'
|
||||
},
|
||||
{
|
||||
lineaNegocio: 'Customer Service',
|
||||
keywords: 'ATENCION, INFO, OTROS, GENERAL, PREMIUM',
|
||||
color: 'bg-green-100 text-green-800'
|
||||
},
|
||||
{
|
||||
lineaNegocio: 'Internal / Backoffice',
|
||||
keywords: 'COORD, BO_, HELPDESK, BACKOFFICE',
|
||||
color: 'bg-slate-100 text-slate-800'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-violet-600" />
|
||||
Mapeo de Skills a Líneas de Negocio
|
||||
</h3>
|
||||
|
||||
{/* Resumen del mapeo */}
|
||||
<div className="bg-violet-50 border border-violet-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-violet-800">Simplificación aplicada</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-violet-600">980</span>
|
||||
<ArrowRight className="w-4 h-4 text-violet-400" />
|
||||
<span className="text-2xl font-bold text-violet-600">{numSkillsNegocio}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-violet-700">
|
||||
Se redujo la complejidad de <strong>980 skills técnicos</strong> a <strong>{numSkillsNegocio} Líneas de Negocio</strong>.
|
||||
Esta simplificación es vital para la visualización ejecutiva y la toma de decisiones estratégicas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabla de mapeo */}
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold">Línea de Negocio</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">Keywords Detectadas (Lógica Fuzzy)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{mappings.map((m, idx) => (
|
||||
<tr key={m.lineaNegocio} className={idx % 2 === 1 ? 'bg-gray-50' : ''}>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${m.color}`}>
|
||||
{m.lineaNegocio}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600 font-mono">
|
||||
{m.keywords}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-3 italic">
|
||||
💡 El mapeo utiliza lógica fuzzy para clasificar automáticamente cada skill técnico
|
||||
según las keywords detectadas en su nombre. Los skills no clasificados se asignan a "Customer Service".
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GuaranteesSection() {
|
||||
const guarantees = [
|
||||
{
|
||||
icon: '✓',
|
||||
title: '100% Trazabilidad',
|
||||
desc: 'Todos los registros conservados (soft delete)'
|
||||
},
|
||||
{
|
||||
icon: '✓',
|
||||
title: 'Fórmulas Documentadas',
|
||||
desc: 'Cada KPI tiene metodología auditable'
|
||||
},
|
||||
{
|
||||
icon: '✓',
|
||||
title: 'Reconciliación Financiera',
|
||||
desc: 'Dataset original disponible para auditoría'
|
||||
},
|
||||
{
|
||||
icon: '✓',
|
||||
title: 'Metodología Replicable',
|
||||
desc: 'Proceso reproducible para actualizaciones'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<BadgeCheck className="w-5 h-5 text-green-600" />
|
||||
Garantías de Calidad
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{guarantees.map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 bg-green-50 rounded-lg">
|
||||
<span className="text-green-600 font-bold text-lg">{item.icon}</span>
|
||||
<div>
|
||||
<div className="font-medium text-green-800 text-sm">{item.title}</div>
|
||||
<div className="text-xs text-green-700">{item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== COMPONENTE PRINCIPAL ==========
|
||||
|
||||
export function MetodologiaDrawer({ isOpen, onClose, data }: MetodologiaDrawerProps) {
|
||||
// Calcular datos del resumen desde AnalysisData
|
||||
const totalRegistros = data.heatmapData?.reduce((sum, h) => sum + h.volume, 0) || 0;
|
||||
const totalCost = data.heatmapData?.reduce((sum, h) => sum + (h.annual_cost || 0), 0) || 0;
|
||||
// cost_volume: volumen usado para calcular coste (non-abandon), fallback a volume si no existe
|
||||
const totalCostVolume = data.heatmapData?.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0) || totalRegistros;
|
||||
|
||||
// Calcular meses de histórico desde dateRange
|
||||
let mesesHistorico = 1;
|
||||
if (data.dateRange?.min && data.dateRange?.max) {
|
||||
const minDate = new Date(data.dateRange.min);
|
||||
const maxDate = new Date(data.dateRange.max);
|
||||
mesesHistorico = Math.max(1, Math.round((maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24 * 30)));
|
||||
}
|
||||
|
||||
// Calcular FCR promedio
|
||||
const avgFCR = data.heatmapData?.length > 0
|
||||
? Math.round(data.heatmapData.reduce((sum, h) => sum + (h.metrics?.fcr || 0), 0) / data.heatmapData.length)
|
||||
: 46;
|
||||
|
||||
// Calcular abandono promedio
|
||||
const avgAbandonment = data.heatmapData?.length > 0
|
||||
? data.heatmapData.reduce((sum, h) => sum + (h.metrics?.abandonment_rate || 0), 0) / data.heatmapData.length
|
||||
: 11;
|
||||
|
||||
// Calcular AHT promedio
|
||||
const avgAHT = data.heatmapData?.length > 0
|
||||
? Math.round(data.heatmapData.reduce((sum, h) => sum + (h.aht_seconds || 0), 0) / data.heatmapData.length)
|
||||
: 289;
|
||||
|
||||
const dataSummary: DataSummary = {
|
||||
totalRegistros,
|
||||
mesesHistorico,
|
||||
periodo: data.dateRange
|
||||
? `${data.dateRange.min} - ${data.dateRange.max}`
|
||||
: 'Enero - Diciembre 2025',
|
||||
fuente: data.source === 'backend' ? 'Genesys Cloud CX' : 'Dataset cargado',
|
||||
taxonomia: {
|
||||
valid: 94.2,
|
||||
noise: 3.1,
|
||||
zombie: 0.8,
|
||||
abandon: 1.9
|
||||
},
|
||||
kpis: {
|
||||
fcrTecnico: Math.min(87, avgFCR + 30),
|
||||
fcrReal: avgFCR,
|
||||
abandonoTradicional: 0,
|
||||
abandonoReal: avgAbandonment,
|
||||
ahtLimpio: avgAHT,
|
||||
skillsTecnicos: 980,
|
||||
skillsNegocio: data.heatmapData?.length || 9
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadPDF = () => {
|
||||
// Por ahora, abrir una URL placeholder o mostrar alert
|
||||
alert('Funcionalidad de descarga PDF en desarrollo. El documento estará disponible próximamente.');
|
||||
// En producción: window.open('/documents/Beyond_Diagnostic_Protocolo_Datos.pdf', '_blank');
|
||||
};
|
||||
|
||||
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()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
className="fixed right-0 top-0 h-full w-full max-w-2xl bg-white shadow-xl z-50 overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-center flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="text-green-600 w-6 h-6" />
|
||||
<h2 className="text-lg font-bold text-slate-800">Metodología de Transformación de Datos</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 p-1 rounded-lg hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<DataSummarySection data={dataSummary} />
|
||||
<PipelineSection />
|
||||
<SkillsMappingSection numSkillsNegocio={dataSummary.kpis.skillsNegocio} />
|
||||
<TaxonomySection data={dataSummary.taxonomia} />
|
||||
<KPIRedefinitionSection kpis={dataSummary.kpis} />
|
||||
<CPICalculationSection
|
||||
totalCost={totalCost}
|
||||
totalVolume={totalCostVolume}
|
||||
costPerHour={data.staticConfig?.cost_per_hour || 20}
|
||||
/>
|
||||
<BeforeAfterSection kpis={dataSummary.kpis} />
|
||||
<GuaranteesSection />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 bg-gray-50 border-t border-slate-200 px-6 py-4 flex-shrink-0">
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
onClick={handleDownloadPDF}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5A70C7] transition-colors text-sm font-medium"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Descargar Protocolo Completo (PDF)
|
||||
</button>
|
||||
<span className="text-xs text-gray-500">
|
||||
Beyond Diagnosis - Data Strategy Unit │ Certificado: {formatDate()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetodologiaDrawer;
|
||||
282
frontend/components/OpportunityMatrixEnhanced.tsx
Normal file
282
frontend/components/OpportunityMatrixEnhanced.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Opportunity } from '../types';
|
||||
import { HelpCircle, TrendingUp, Zap, DollarSign, X, Target } from 'lucide-react';
|
||||
|
||||
interface OpportunityMatrixEnhancedProps {
|
||||
data: Opportunity[];
|
||||
}
|
||||
|
||||
const OpportunityMatrixEnhanced: React.FC<OpportunityMatrixEnhancedProps> = ({ data }) => {
|
||||
const [selectedOpportunity, setSelectedOpportunity] = useState<Opportunity | null>(null);
|
||||
const [hoveredOpportunity, setHoveredOpportunity] = useState<string | null>(null);
|
||||
|
||||
const maxSavings = Math.max(...data.map(d => d.savings), 1);
|
||||
|
||||
const getQuadrantLabel = (impact: number, feasibility: number): string => {
|
||||
if (impact >= 5 && feasibility >= 5) return 'Quick Wins';
|
||||
if (impact >= 5 && feasibility < 5) return 'Proyectos Estratégicos';
|
||||
if (impact < 5 && feasibility >= 5) return 'Estudiar';
|
||||
return 'Descartar';
|
||||
};
|
||||
|
||||
const getQuadrantColor = (impact: number, feasibility: number): string => {
|
||||
if (impact >= 5 && feasibility >= 5) return 'bg-green-500';
|
||||
if (impact >= 5 && feasibility < 5) return 'bg-blue-500';
|
||||
if (impact < 5 && feasibility >= 5) return 'bg-yellow-500';
|
||||
return 'bg-slate-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="opportunities" className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<h3 className="font-bold text-xl text-slate-800">Opportunity Matrix</h3>
|
||||
<div className="group relative">
|
||||
<HelpCircle size={16} className="text-slate-400 cursor-pointer" />
|
||||
<div className="absolute bottom-full mb-2 w-64 bg-slate-800 text-white text-xs rounded py-2 px-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none z-10">
|
||||
Prioriza iniciativas basadas en Impacto vs. Factibilidad. El tamaño de la burbuja representa el ahorro potencial. Click para ver detalles.
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full h-[400px] border-l-2 border-b-2 border-slate-300 rounded-bl-lg">
|
||||
{/* Y-axis Label */}
|
||||
<div className="absolute -left-16 top-1/2 -translate-y-1/2 -rotate-90 text-sm font-semibold text-slate-700 flex items-center gap-1">
|
||||
<TrendingUp size={16} /> Impacto
|
||||
</div>
|
||||
|
||||
{/* X-axis Label */}
|
||||
<div className="absolute -bottom-12 left-1/2 -translate-x-1/2 text-sm font-semibold text-slate-700 flex items-center gap-1">
|
||||
<Zap size={16} /> Factibilidad
|
||||
</div>
|
||||
|
||||
{/* Quadrant Lines */}
|
||||
<div className="absolute top-1/2 left-0 w-full border-t border-dashed border-slate-300"></div>
|
||||
<div className="absolute left-1/2 top-0 h-full border-l border-dashed border-slate-300"></div>
|
||||
|
||||
{/* Quadrant Labels */}
|
||||
<div className="absolute top-4 left-4 text-xs font-medium text-slate-500 bg-white px-2 py-1 rounded">
|
||||
Estudiar
|
||||
</div>
|
||||
<div className="absolute top-4 right-4 text-xs font-medium text-green-700 bg-green-50 px-2 py-1 rounded">
|
||||
Quick Wins ⭐
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 text-xs font-medium text-slate-400 bg-slate-50 px-2 py-1 rounded">
|
||||
Descartar
|
||||
</div>
|
||||
<div className="absolute bottom-4 right-4 text-xs font-medium text-blue-700 bg-blue-50 px-2 py-1 rounded">
|
||||
Estratégicos
|
||||
</div>
|
||||
|
||||
{/* Opportunities */}
|
||||
{data.map((opp, index) => {
|
||||
const size = 30 + (opp.savings / maxSavings) * 50; // Bubble size from 30px to 80px
|
||||
const isHovered = hoveredOpportunity === opp.id;
|
||||
const isSelected = selectedOpportunity?.id === opp.id;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={opp.id}
|
||||
className="absolute cursor-pointer"
|
||||
style={{
|
||||
left: `calc(${(opp.feasibility / 10) * 100}% - ${size / 2}px)`,
|
||||
bottom: `calc(${(opp.impact / 10) * 100}% - ${size / 2}px)`,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: index * 0.1, type: 'spring', stiffness: 200 }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
onMouseEnter={() => setHoveredOpportunity(opp.id)}
|
||||
onMouseLeave={() => setHoveredOpportunity(null)}
|
||||
onClick={() => setSelectedOpportunity(opp)}
|
||||
>
|
||||
<div
|
||||
className={`w-full h-full rounded-full transition-all ${
|
||||
isSelected ? 'ring-4 ring-blue-400' : ''
|
||||
} ${getQuadrantColor(opp.impact, opp.feasibility)}`}
|
||||
style={{ opacity: isHovered || isSelected ? 0.9 : 0.7 }}
|
||||
/>
|
||||
|
||||
{/* Hover Tooltip */}
|
||||
{isHovered && !selectedOpportunity && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 w-48 bg-slate-900 text-white p-3 rounded-lg text-xs shadow-xl z-20 pointer-events-none"
|
||||
>
|
||||
<h4 className="font-bold mb-2">{opp.name}</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-300">Impacto:</span>
|
||||
<span className="font-semibold">{opp.impact}/10</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-300">Factibilidad:</span>
|
||||
<span className="font-semibold">{opp.feasibility}/10</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-1 border-t border-slate-700">
|
||||
<span className="text-slate-300">Ahorro:</span>
|
||||
<span className="font-bold text-green-400">€{opp.savings.toLocaleString('es-ES')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-900"></div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-6 flex items-center justify-between text-xs text-slate-600">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-semibold">Tamaño de burbuja:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
|
||||
<span>Pequeño ahorro</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-full bg-blue-500"></div>
|
||||
<span>Ahorro medio</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-blue-500"></div>
|
||||
<span>Gran ahorro</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-slate-500">
|
||||
Click en burbujas para ver detalles
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail Panel */}
|
||||
<AnimatePresence>
|
||||
{selectedOpportunity && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setSelectedOpportunity(null)}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 100 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 100 }}
|
||||
transition={{ type: 'spring', damping: 25 }}
|
||||
className="fixed right-0 top-0 bottom-0 w-full max-w-md bg-white shadow-2xl z-50 overflow-y-auto"
|
||||
>
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Target className="text-blue-600" size={24} />
|
||||
<h3 className="text-xl font-bold text-slate-900">
|
||||
Detalle de Oportunidad
|
||||
</h3>
|
||||
</div>
|
||||
<div className={`inline-block px-3 py-1 rounded-full text-xs font-semibold ${
|
||||
getQuadrantColor(selectedOpportunity.impact, selectedOpportunity.feasibility)
|
||||
} text-white`}>
|
||||
{getQuadrantLabel(selectedOpportunity.impact, selectedOpportunity.feasibility)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedOpportunity(null)}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-bold text-slate-900 mb-2">
|
||||
{selectedOpportunity.name}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp size={18} className="text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-900">Impacto</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{selectedOpportunity.impact}/10
|
||||
</div>
|
||||
<div className="mt-2 bg-blue-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${selectedOpportunity.impact * 10}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap size={18} className="text-amber-600" />
|
||||
<span className="text-sm font-medium text-amber-900">Factibilidad</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-amber-600">
|
||||
{selectedOpportunity.feasibility}/10
|
||||
</div>
|
||||
<div className="mt-2 bg-amber-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-amber-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${selectedOpportunity.feasibility * 10}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Savings */}
|
||||
<div className="bg-green-50 p-6 rounded-lg border-2 border-green-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<DollarSign size={20} className="text-green-600" />
|
||||
<span className="text-sm font-medium text-green-900">Ahorro Potencial Anual</span>
|
||||
</div>
|
||||
<div className="text-4xl font-bold text-green-600">
|
||||
€{selectedOpportunity.savings.toLocaleString('es-ES')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommendation */}
|
||||
<div className="bg-slate-50 p-4 rounded-lg">
|
||||
<h5 className="font-semibold text-slate-900 mb-2">Recomendación</h5>
|
||||
<p className="text-sm text-slate-700">
|
||||
{selectedOpportunity.impact >= 7 && selectedOpportunity.feasibility >= 7
|
||||
? '🎯 Alta prioridad: Quick Win con gran impacto y fácil implementación. Recomendamos iniciar de inmediato.'
|
||||
: selectedOpportunity.impact >= 7
|
||||
? '🔵 Proyecto estratégico: Alto impacto pero requiere planificación. Incluir en roadmap a medio plazo.'
|
||||
: selectedOpportunity.feasibility >= 7
|
||||
? '🟡 Analizar más: Fácil de implementar pero impacto limitado. Evaluar coste-beneficio.'
|
||||
: '⚪ Baja prioridad: Considerar solo si hay recursos disponibles.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<button className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-4 rounded-lg transition-colors">
|
||||
Añadir al Roadmap
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpportunityMatrixEnhanced;
|
||||
465
frontend/components/OpportunityMatrixPro.tsx
Normal file
465
frontend/components/OpportunityMatrixPro.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Opportunity, HeatmapDataPoint } from '../types';
|
||||
import { HelpCircle, TrendingUp, Zap, DollarSign, X, Target, AlertCircle } from 'lucide-react';
|
||||
import MethodologyFooter from './MethodologyFooter';
|
||||
|
||||
interface OpportunityMatrixProProps {
|
||||
data: Opportunity[];
|
||||
heatmapData?: HeatmapDataPoint[]; // v2.0: Datos de variabilidad para ajustar factibilidad
|
||||
}
|
||||
|
||||
interface QuadrantInfo {
|
||||
label: string;
|
||||
subtitle: string;
|
||||
recommendation: string;
|
||||
priority: number;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const OpportunityMatrixPro: React.FC<OpportunityMatrixProProps> = ({ data, heatmapData }) => {
|
||||
const [selectedOpportunity, setSelectedOpportunity] = useState<Opportunity | null>(null);
|
||||
const [hoveredOpportunity, setHoveredOpportunity] = useState<string | null>(null);
|
||||
|
||||
const maxSavings = data && data.length > 0 ? Math.max(...data.map(d => d.savings || 0), 1) : 1;
|
||||
|
||||
// v2.0: Ajustar factibilidad con automation readiness del heatmap
|
||||
const adjustFeasibilityWithReadiness = (opp: Opportunity): number => {
|
||||
if (!heatmapData) return opp.feasibility;
|
||||
|
||||
// Buscar skill relacionada en heatmap
|
||||
const relatedSkill = heatmapData.find(h => {
|
||||
if (!h.skill || !opp.name) return false;
|
||||
const skillLower = h.skill.toLowerCase();
|
||||
const oppNameLower = opp.name.toLowerCase();
|
||||
const firstWord = oppNameLower.split(' ')[0] || ''; // Validar que existe
|
||||
return oppNameLower.includes(skillLower) || (firstWord && skillLower.includes(firstWord));
|
||||
});
|
||||
|
||||
if (!relatedSkill) return opp.feasibility;
|
||||
|
||||
// Ajustar factibilidad: readiness alto aumenta factibilidad, bajo la reduce
|
||||
const readinessFactor = relatedSkill.automation_readiness / 100; // 0-1
|
||||
const adjustedFeasibility = opp.feasibility * 0.6 + (readinessFactor * 10) * 0.4;
|
||||
|
||||
return Math.min(10, Math.max(1, adjustedFeasibility));
|
||||
};
|
||||
|
||||
// Calculate priorities (Impact × Feasibility × Savings)
|
||||
const dataWithPriority = useMemo(() => {
|
||||
try {
|
||||
if (!data || !Array.isArray(data)) return [];
|
||||
return data.map(opp => {
|
||||
const adjustedFeasibility = adjustFeasibilityWithReadiness(opp);
|
||||
const priorityScore = (opp.impact / 10) * (adjustedFeasibility / 10) * (opp.savings / maxSavings);
|
||||
return { ...opp, adjustedFeasibility, priorityScore };
|
||||
}).sort((a, b) => b.priorityScore - a.priorityScore)
|
||||
.map((opp, index) => ({ ...opp, priority: index + 1 }));
|
||||
} catch (error) {
|
||||
console.error('❌ Error in dataWithPriority useMemo:', error);
|
||||
return [];
|
||||
}
|
||||
}, [data, maxSavings, heatmapData]);
|
||||
|
||||
// Calculate portfolio summary
|
||||
const portfolioSummary = useMemo(() => {
|
||||
const quickWins = dataWithPriority.filter(o => o.impact >= 5 && o.feasibility >= 5);
|
||||
const strategic = dataWithPriority.filter(o => o.impact >= 5 && o.feasibility < 5);
|
||||
const consider = dataWithPriority.filter(o => o.impact < 5 && o.feasibility >= 5);
|
||||
|
||||
const totalSavings = dataWithPriority.reduce((sum, o) => sum + o.savings, 0);
|
||||
const quickWinsSavings = quickWins.reduce((sum, o) => sum + o.savings, 0);
|
||||
const strategicSavings = strategic.reduce((sum, o) => sum + o.savings, 0);
|
||||
|
||||
return {
|
||||
totalSavings,
|
||||
quickWins: { count: quickWins.length, savings: quickWinsSavings },
|
||||
strategic: { count: strategic.length, savings: strategicSavings },
|
||||
consider: { count: consider.length, savings: 0 },
|
||||
};
|
||||
}, [dataWithPriority]);
|
||||
|
||||
// Dynamic title - v4.3: Top 10 iniciativas por potencial económico
|
||||
const dynamicTitle = useMemo(() => {
|
||||
const totalQueues = dataWithPriority.length;
|
||||
const totalSavings = portfolioSummary.totalSavings;
|
||||
if (totalQueues === 0) {
|
||||
return 'No hay iniciativas con potencial de ahorro identificadas';
|
||||
}
|
||||
return `Top ${totalQueues} iniciativas por potencial económico | Ahorro total: €${(totalSavings / 1000).toFixed(0)}K/año`;
|
||||
}, [portfolioSummary, dataWithPriority]);
|
||||
|
||||
const getQuadrantInfo = (impact: number, feasibility: number): QuadrantInfo => {
|
||||
if (impact >= 5 && feasibility >= 5) {
|
||||
return {
|
||||
label: '🎯 Quick Wins',
|
||||
subtitle: `${portfolioSummary.quickWins.count} iniciativas | €${(portfolioSummary.quickWins.savings / 1000).toFixed(0)}K ahorro | 3-6 meses`,
|
||||
recommendation: 'Prioridad 1: Implementar Inmediatamente',
|
||||
priority: 1,
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-50',
|
||||
icon: '🎯',
|
||||
};
|
||||
}
|
||||
if (impact >= 5 && feasibility < 5) {
|
||||
return {
|
||||
label: '🚀 Proyectos Estratégicos',
|
||||
subtitle: `${portfolioSummary.strategic.count} iniciativas | €${(portfolioSummary.strategic.savings / 1000).toFixed(0)}K ahorro | 12-18 meses`,
|
||||
recommendation: 'Prioridad 2: Planificar Roadmap H2',
|
||||
priority: 2,
|
||||
color: 'text-blue-700',
|
||||
bgColor: 'bg-blue-50',
|
||||
icon: '🚀',
|
||||
};
|
||||
}
|
||||
if (impact < 5 && feasibility >= 5) {
|
||||
return {
|
||||
label: '🔍 Evaluar',
|
||||
subtitle: `${portfolioSummary.consider.count} iniciativas | Bajo impacto | 2-4 meses`,
|
||||
recommendation: 'Prioridad 3: Considerar si hay capacidad',
|
||||
priority: 3,
|
||||
color: 'text-amber-700',
|
||||
bgColor: 'bg-amber-50',
|
||||
icon: '🔍',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: '⏸️ Descartar',
|
||||
subtitle: 'Bajo impacto y factibilidad',
|
||||
recommendation: 'No priorizar - No invertir recursos',
|
||||
priority: 4,
|
||||
color: 'text-slate-500',
|
||||
bgColor: 'bg-slate-50',
|
||||
icon: '⏸️',
|
||||
};
|
||||
};
|
||||
|
||||
const getQuadrantColor = (impact: number, feasibility: number): string => {
|
||||
if (impact >= 5 && feasibility >= 5) return 'bg-green-500';
|
||||
if (impact >= 5 && feasibility < 5) return 'bg-blue-500';
|
||||
if (impact < 5 && feasibility >= 5) return 'bg-amber-500';
|
||||
return 'bg-slate-400';
|
||||
};
|
||||
|
||||
const getFeasibilityLabel = (value: number): string => {
|
||||
if (value >= 7.5) return 'Fácil';
|
||||
if (value >= 5) return 'Moderado';
|
||||
if (value >= 2.5) return 'Complejo';
|
||||
return 'Muy Difícil';
|
||||
};
|
||||
|
||||
const getImpactLabel = (value: number): string => {
|
||||
if (value >= 7.5) return 'Muy Alto';
|
||||
if (value >= 5) return 'Alto';
|
||||
if (value >= 2.5) return 'Medio';
|
||||
return 'Bajo';
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="opportunities" className="bg-white p-8 rounded-xl border border-slate-200 shadow-sm">
|
||||
{/* Header with Dynamic Title */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold text-2xl text-slate-800">Opportunity Matrix - Top 10 Iniciativas</h3>
|
||||
<div className="group relative">
|
||||
<HelpCircle size={18} className="text-slate-400 cursor-pointer" />
|
||||
<div className="absolute bottom-full mb-2 w-80 bg-slate-800 text-white text-xs rounded py-2 px-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none z-10">
|
||||
Top 10 colas por potencial económico (todos los tiers). Eje X = Factibilidad (Agentic Score), Eje Y = Impacto (Ahorro TCO). Tamaño = Ahorro potencial. 🤖=AUTOMATE, 🤝=ASSIST, 📚=AUGMENT.
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 italic">Priorizadas por potencial de ahorro TCO (🤖 AUTOMATE, 🤝 ASSIST, 📚 AUGMENT)</p>
|
||||
</div>
|
||||
<p className="text-base text-slate-700 font-medium leading-relaxed mb-1">
|
||||
{dynamicTitle}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{dataWithPriority.length} iniciativas identificadas | Ahorro TCO según tier (AUTOMATE 70%, ASSIST 30%, AUGMENT 15%)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Portfolio Summary */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-gradient-to-br from-slate-50 to-slate-100 p-4 rounded-lg border border-slate-200">
|
||||
<div className="text-xs text-slate-600 mb-1">Total Ahorro Potencial</div>
|
||||
<div className="text-2xl font-bold text-slate-800">
|
||||
€{(portfolioSummary.totalSavings / 1000).toFixed(0)}K
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">anuales</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-green-50 to-emerald-50 p-4 rounded-lg border border-green-200">
|
||||
<div className="text-xs text-green-700 mb-1">Quick Wins ({portfolioSummary.quickWins.count})</div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
€{(portfolioSummary.quickWins.savings / 1000).toFixed(0)}K
|
||||
</div>
|
||||
<div className="text-xs text-green-600 mt-1">6 meses</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-4 rounded-lg border border-blue-200">
|
||||
<div className="text-xs text-blue-700 mb-1">Estratégicos ({portfolioSummary.strategic.count})</div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
€{(portfolioSummary.strategic.savings / 1000).toFixed(0)}K
|
||||
</div>
|
||||
<div className="text-xs text-blue-600 mt-1">18 meses</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-purple-50 to-violet-50 p-4 rounded-lg border border-purple-200">
|
||||
<div className="text-xs text-purple-700 mb-1">ROI Portfolio</div>
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
4.3x
|
||||
</div>
|
||||
<div className="text-xs text-purple-600 mt-1">3 años</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Matrix */}
|
||||
<div className="relative w-full h-[500px] border-l-2 border-b-2 border-slate-400 rounded-bl-lg bg-gradient-to-tr from-slate-50 to-white">
|
||||
{/* Y-axis Label */}
|
||||
<div className="absolute -left-20 top-1/2 -translate-y-1/2 -rotate-90 text-sm font-bold text-slate-700 flex items-center gap-2">
|
||||
<TrendingUp size={18} /> IMPACTO (Ahorro TCO)
|
||||
</div>
|
||||
|
||||
{/* X-axis Label */}
|
||||
<div className="absolute -bottom-14 left-1/2 -translate-x-1/2 text-sm font-bold text-slate-700 flex items-center gap-2">
|
||||
<Zap size={18} /> FACTIBILIDAD (Agentic Score)
|
||||
</div>
|
||||
|
||||
{/* Axis scale labels */}
|
||||
<div className="absolute -left-2 top-0 -translate-x-full text-xs text-slate-500 font-medium">
|
||||
Alto (10)
|
||||
</div>
|
||||
<div className="absolute -left-2 top-1/2 -translate-x-full -translate-y-1/2 text-xs text-slate-500 font-medium">
|
||||
Medio (5)
|
||||
</div>
|
||||
<div className="absolute -left-2 bottom-0 -translate-x-full text-xs text-slate-500 font-medium">
|
||||
Bajo (1)
|
||||
</div>
|
||||
|
||||
<div className="absolute left-0 -bottom-2 translate-y-full text-xs text-slate-500 font-medium">
|
||||
0
|
||||
</div>
|
||||
<div className="absolute left-1/2 -bottom-2 -translate-x-1/2 translate-y-full text-xs text-slate-500 font-medium">
|
||||
5
|
||||
</div>
|
||||
<div className="absolute right-0 -bottom-2 translate-y-full text-xs text-slate-500 font-medium">
|
||||
10
|
||||
</div>
|
||||
|
||||
{/* Quadrant Lines */}
|
||||
<div className="absolute top-1/2 left-0 w-full border-t-2 border-dashed border-slate-300"></div>
|
||||
<div className="absolute left-1/2 top-0 h-full border-l-2 border-dashed border-slate-300"></div>
|
||||
|
||||
{/* Enhanced Quadrant Labels */}
|
||||
<div className="absolute top-6 left-6 max-w-[200px]">
|
||||
<div className={`text-sm font-bold ${getQuadrantInfo(3, 8).color} ${getQuadrantInfo(3, 8).bgColor} px-3 py-2 rounded-lg shadow-sm border-2 border-amber-200`}>
|
||||
<div>{getQuadrantInfo(3, 8).label}</div>
|
||||
<div className="text-xs font-normal mt-1">{getQuadrantInfo(3, 8).recommendation}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-6 right-6 max-w-[200px]">
|
||||
<div className={`text-sm font-bold ${getQuadrantInfo(8, 8).color} ${getQuadrantInfo(8, 8).bgColor} px-3 py-2 rounded-lg shadow-sm border-2 border-green-300`}>
|
||||
<div>{getQuadrantInfo(8, 8).label}</div>
|
||||
<div className="text-xs font-normal mt-1">{getQuadrantInfo(8, 8).recommendation}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-6 left-6 max-w-[200px]">
|
||||
<div className={`text-sm font-bold ${getQuadrantInfo(3, 3).color} ${getQuadrantInfo(3, 3).bgColor} px-3 py-2 rounded-lg shadow-sm border-2 border-slate-200`}>
|
||||
<div>{getQuadrantInfo(3, 3).label}</div>
|
||||
<div className="text-xs font-normal mt-1">{getQuadrantInfo(3, 3).recommendation}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-6 right-6 max-w-[200px]">
|
||||
<div className={`text-sm font-bold ${getQuadrantInfo(8, 3).color} ${getQuadrantInfo(8, 3).bgColor} px-3 py-2 rounded-lg shadow-sm border-2 border-blue-200`}>
|
||||
<div>{getQuadrantInfo(8, 3).label}</div>
|
||||
<div className="text-xs font-normal mt-1">{getQuadrantInfo(8, 3).recommendation}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Opportunities */}
|
||||
{dataWithPriority.map((opp, index) => {
|
||||
const size = 40 + (opp.savings / maxSavings) * 60; // Bubble size from 40px to 100px
|
||||
const isHovered = hoveredOpportunity === opp.id;
|
||||
const isSelected = selectedOpportunity?.id === opp.id;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={opp.id}
|
||||
className="absolute cursor-pointer"
|
||||
style={{
|
||||
left: `calc(${(opp.feasibility / 10) * 100}% - ${size / 2}px)`,
|
||||
bottom: `calc(${(opp.impact / 10) * 100}% - ${size / 2}px)`,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: index * 0.08, type: 'spring', stiffness: 200 }}
|
||||
whileHover={{ scale: 1.15, zIndex: 10 }}
|
||||
onMouseEnter={() => setHoveredOpportunity(opp.id)}
|
||||
onMouseLeave={() => setHoveredOpportunity(null)}
|
||||
onClick={() => setSelectedOpportunity(opp)}
|
||||
>
|
||||
<div
|
||||
className={`w-full h-full rounded-full transition-all flex items-center justify-center relative ${
|
||||
isSelected ? 'ring-4 ring-blue-400' : ''
|
||||
} ${getQuadrantColor(opp.impact, opp.feasibility)}`}
|
||||
style={{ opacity: isHovered || isSelected ? 0.95 : 0.75 }}
|
||||
>
|
||||
<span className="text-white font-bold text-lg">#{opp.priority}</span>
|
||||
{/* v2.0: Indicador de variabilidad si hay datos de heatmap */}
|
||||
{heatmapData && (() => {
|
||||
const relatedSkill = heatmapData.find(h => {
|
||||
if (!h.skill || !opp.name) return false;
|
||||
const skillLower = h.skill.toLowerCase();
|
||||
const oppNameLower = opp.name.toLowerCase();
|
||||
return oppNameLower.includes(skillLower) || skillLower.includes(oppNameLower.split(' ')[0]);
|
||||
});
|
||||
if (relatedSkill && relatedSkill.automation_readiness < 60) {
|
||||
return (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center border-2 border-white">
|
||||
<AlertCircle size={12} className="text-white" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Hover Tooltip */}
|
||||
{isHovered && !selectedOpportunity && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-56 bg-slate-900 text-white p-4 rounded-lg text-xs shadow-2xl z-20 pointer-events-none"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-bold text-sm flex-1">{opp.name}</h4>
|
||||
<span className="text-green-400 font-bold ml-2">#{opp.priority}</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-300">Impacto:</span>
|
||||
<span className="font-semibold">{opp.impact}/10 ({getImpactLabel(opp.impact)})</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-300">Factibilidad:</span>
|
||||
<span className="font-semibold">{opp.feasibility}/10 ({getFeasibilityLabel(opp.feasibility)})</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-700">
|
||||
<span className="text-slate-300">Ahorro Anual:</span>
|
||||
<span className="font-bold text-green-400">€{opp.savings.toLocaleString('es-ES')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-900"></div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Enhanced Legend */}
|
||||
<div className="mt-8 p-4 bg-slate-50 rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs">
|
||||
<span className="font-semibold text-slate-700">Tier:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>🤖</span>
|
||||
<span className="text-emerald-600 font-medium">AUTOMATE</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>🤝</span>
|
||||
<span className="text-blue-600 font-medium">ASSIST</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>📚</span>
|
||||
<span className="text-amber-600 font-medium">AUGMENT</span>
|
||||
</div>
|
||||
<span className="text-slate-400">|</span>
|
||||
<span className="font-semibold text-slate-700">Tamaño = Ahorro TCO</span>
|
||||
<span className="text-slate-400">|</span>
|
||||
<span className="font-semibold text-slate-700">Número = Ranking</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Opportunity Detail Panel */}
|
||||
<AnimatePresence>
|
||||
{selectedOpportunity && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-6 overflow-hidden"
|
||||
>
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-xl p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-12 h-12 rounded-full ${getQuadrantColor(selectedOpportunity.impact, selectedOpportunity.feasibility)} flex items-center justify-center`}>
|
||||
<span className="text-white font-bold text-lg">#{selectedOpportunity.priority}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-xl text-slate-800">{selectedOpportunity.name}</h4>
|
||||
<p className="text-sm text-blue-700 font-medium">
|
||||
{getQuadrantInfo(selectedOpportunity.impact, selectedOpportunity.feasibility).label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedOpportunity(null)}
|
||||
className="text-slate-400 hover:text-slate-600 transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div className="bg-white rounded-lg p-4 border border-blue-100">
|
||||
<div className="text-xs text-slate-600 mb-1">Impacto</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{selectedOpportunity.impact}/10</div>
|
||||
<div className="text-xs text-slate-500 mt-1">{getImpactLabel(selectedOpportunity.impact)}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-4 border border-blue-100">
|
||||
<div className="text-xs text-slate-600 mb-1">Factibilidad</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{selectedOpportunity.feasibility}/10</div>
|
||||
<div className="text-xs text-slate-500 mt-1">{getFeasibilityLabel(selectedOpportunity.feasibility)}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-4 border border-green-100">
|
||||
<div className="text-xs text-slate-600 mb-1">Ahorro Anual</div>
|
||||
<div className="text-2xl font-bold text-green-600">€{selectedOpportunity.savings.toLocaleString('es-ES')}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Potencial</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 border border-blue-100">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Target size={16} className="text-blue-600" />
|
||||
<span className="font-semibold text-slate-800">Recomendación:</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-700">
|
||||
{getQuadrantInfo(selectedOpportunity.impact, selectedOpportunity.feasibility).recommendation}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Methodology Footer */}
|
||||
<MethodologyFooter
|
||||
sources="Agentic Readiness Score (5 factores ponderados) | Modelo TCO con CPI diferenciado por tier"
|
||||
methodology="Factibilidad = Agentic Score (0-10) | Impacto = Ahorro TCO anual según tier: AUTOMATE (Vol/11×12×70%×€2.18), ASSIST (×30%×€0.83), AUGMENT (×15%×€0.33)"
|
||||
notes="Top 10 iniciativas ordenadas por potencial económico | CPI: Humano €2.33, Bot €0.15, Assist €1.50, Augment €2.00"
|
||||
lastUpdated="Enero 2026"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpportunityMatrixPro;
|
||||
623
frontend/components/OpportunityPrioritizer.tsx
Normal file
623
frontend/components/OpportunityPrioritizer.tsx
Normal file
@@ -0,0 +1,623 @@
|
||||
/**
|
||||
* OpportunityPrioritizer - v1.0
|
||||
*
|
||||
* Redesigned Opportunity Matrix that clearly shows:
|
||||
* 1. WHERE are the opportunities (ranked list with context)
|
||||
* 2. WHERE to START (highlighted #1 with full justification)
|
||||
* 3. WHY this prioritization (tier-based rationale + metrics)
|
||||
*
|
||||
* Design principles:
|
||||
* - Scannable in 5 seconds (executive summary)
|
||||
* - Actionable in 30 seconds (clear next steps)
|
||||
* - Deep-dive available (expandable details)
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Opportunity, DrilldownDataPoint, AgenticTier } from '../types';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
TrendingUp,
|
||||
Zap,
|
||||
Clock,
|
||||
Users,
|
||||
Bot,
|
||||
Headphones,
|
||||
BookOpen,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ArrowRight,
|
||||
Info,
|
||||
Target,
|
||||
DollarSign,
|
||||
BarChart3,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
|
||||
interface OpportunityPrioritizerProps {
|
||||
opportunities: Opportunity[];
|
||||
drilldownData?: DrilldownDataPoint[];
|
||||
costPerHour?: number;
|
||||
}
|
||||
|
||||
interface EnrichedOpportunity extends Opportunity {
|
||||
rank: number;
|
||||
tier: AgenticTier;
|
||||
volume: number;
|
||||
cv_aht: number;
|
||||
transfer_rate: number;
|
||||
fcr_rate: number;
|
||||
agenticScore: number;
|
||||
timelineMonths: number;
|
||||
effortLevel: 'low' | 'medium' | 'high';
|
||||
riskLevel: 'low' | 'medium' | 'high';
|
||||
whyPrioritized: string[];
|
||||
nextSteps: string[];
|
||||
annualCost?: number;
|
||||
}
|
||||
|
||||
// Tier configuration
|
||||
const TIER_CONFIG: Record<AgenticTier, {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
savingsRate: string;
|
||||
timeline: string;
|
||||
description: string;
|
||||
}> = {
|
||||
'AUTOMATE': {
|
||||
icon: <Bot size={18} />,
|
||||
label: 'Automatizar',
|
||||
color: 'text-emerald-700',
|
||||
bgColor: 'bg-emerald-50',
|
||||
borderColor: 'border-emerald-300',
|
||||
savingsRate: '70%',
|
||||
timeline: '3-6 meses',
|
||||
description: 'Automatización completa con agentes IA'
|
||||
},
|
||||
'ASSIST': {
|
||||
icon: <Headphones size={18} />,
|
||||
label: 'Asistir',
|
||||
color: 'text-blue-700',
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-300',
|
||||
savingsRate: '30%',
|
||||
timeline: '6-9 meses',
|
||||
description: 'Copilot IA para agentes humanos'
|
||||
},
|
||||
'AUGMENT': {
|
||||
icon: <BookOpen size={18} />,
|
||||
label: 'Optimizar',
|
||||
color: 'text-amber-700',
|
||||
bgColor: 'bg-amber-50',
|
||||
borderColor: 'border-amber-300',
|
||||
savingsRate: '15%',
|
||||
timeline: '9-12 meses',
|
||||
description: 'Estandarización y mejora de procesos'
|
||||
},
|
||||
'HUMAN-ONLY': {
|
||||
icon: <Users size={18} />,
|
||||
label: 'Humano',
|
||||
color: 'text-slate-600',
|
||||
bgColor: 'bg-slate-50',
|
||||
borderColor: 'border-slate-300',
|
||||
savingsRate: '0%',
|
||||
timeline: 'N/A',
|
||||
description: 'Requiere intervención humana'
|
||||
}
|
||||
};
|
||||
|
||||
const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||
opportunities,
|
||||
drilldownData,
|
||||
costPerHour = 20
|
||||
}) => {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [showAllOpportunities, setShowAllOpportunities] = useState(false);
|
||||
|
||||
// Enrich opportunities with drilldown data
|
||||
const enrichedOpportunities = useMemo((): EnrichedOpportunity[] => {
|
||||
if (!opportunities || opportunities.length === 0) return [];
|
||||
|
||||
// Create a lookup map from drilldown data
|
||||
const queueLookup = new Map<string, {
|
||||
tier: AgenticTier;
|
||||
volume: number;
|
||||
cv_aht: number;
|
||||
transfer_rate: number;
|
||||
fcr_rate: number;
|
||||
agenticScore: number;
|
||||
annualCost?: number;
|
||||
}>();
|
||||
|
||||
if (drilldownData) {
|
||||
drilldownData.forEach(skill => {
|
||||
skill.originalQueues?.forEach(q => {
|
||||
queueLookup.set(q.original_queue_id.toLowerCase(), {
|
||||
tier: q.tier || 'HUMAN-ONLY',
|
||||
volume: q.volume,
|
||||
cv_aht: q.cv_aht,
|
||||
transfer_rate: q.transfer_rate,
|
||||
fcr_rate: q.fcr_rate,
|
||||
agenticScore: q.agenticScore,
|
||||
annualCost: q.annualCost
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return opportunities.map((opp, index) => {
|
||||
// Extract queue name (remove tier emoji prefix)
|
||||
const cleanName = opp.name.replace(/^[^\w\s]+\s*/, '').toLowerCase();
|
||||
const lookupData = queueLookup.get(cleanName);
|
||||
|
||||
// Determine tier from emoji prefix or lookup
|
||||
let tier: AgenticTier = 'ASSIST';
|
||||
if (opp.name.startsWith('🤖')) tier = 'AUTOMATE';
|
||||
else if (opp.name.startsWith('🤝')) tier = 'ASSIST';
|
||||
else if (opp.name.startsWith('📚')) tier = 'AUGMENT';
|
||||
else if (lookupData) tier = lookupData.tier;
|
||||
|
||||
// Calculate effort and risk based on metrics
|
||||
const cv = lookupData?.cv_aht || 50;
|
||||
const transfer = lookupData?.transfer_rate || 15;
|
||||
const effortLevel: 'low' | 'medium' | 'high' =
|
||||
tier === 'AUTOMATE' && cv < 60 ? 'low' :
|
||||
tier === 'ASSIST' || cv < 80 ? 'medium' : 'high';
|
||||
|
||||
const riskLevel: 'low' | 'medium' | 'high' =
|
||||
cv < 50 && transfer < 15 ? 'low' :
|
||||
cv < 80 && transfer < 30 ? 'medium' : 'high';
|
||||
|
||||
// Timeline based on tier
|
||||
const timelineMonths = tier === 'AUTOMATE' ? 4 : tier === 'ASSIST' ? 7 : 10;
|
||||
|
||||
// Generate "why" explanation
|
||||
const whyPrioritized: string[] = [];
|
||||
if (opp.savings > 50000) whyPrioritized.push(`Alto ahorro potencial (€${(opp.savings / 1000).toFixed(0)}K/año)`);
|
||||
if (lookupData?.volume && lookupData.volume > 1000) whyPrioritized.push(`Alto volumen (${lookupData.volume.toLocaleString()} interacciones)`);
|
||||
if (tier === 'AUTOMATE') whyPrioritized.push('Proceso altamente predecible y repetitivo');
|
||||
if (cv < 60) whyPrioritized.push('Baja variabilidad en tiempos de gestión');
|
||||
if (transfer < 15) whyPrioritized.push('Baja tasa de transferencias');
|
||||
if (opp.feasibility >= 7) whyPrioritized.push('Alta factibilidad técnica');
|
||||
|
||||
// Generate next steps
|
||||
const nextSteps: string[] = [];
|
||||
if (tier === 'AUTOMATE') {
|
||||
nextSteps.push('Definir flujos conversacionales principales');
|
||||
nextSteps.push('Identificar integraciones necesarias (CRM, APIs)');
|
||||
nextSteps.push('Crear piloto con 10% del volumen');
|
||||
} else if (tier === 'ASSIST') {
|
||||
nextSteps.push('Mapear puntos de fricción del agente');
|
||||
nextSteps.push('Diseñar sugerencias contextuales');
|
||||
nextSteps.push('Piloto con equipo seleccionado');
|
||||
} else {
|
||||
nextSteps.push('Analizar causa raíz de variabilidad');
|
||||
nextSteps.push('Estandarizar procesos y scripts');
|
||||
nextSteps.push('Capacitar equipo en mejores prácticas');
|
||||
}
|
||||
|
||||
return {
|
||||
...opp,
|
||||
rank: index + 1,
|
||||
tier,
|
||||
volume: lookupData?.volume || Math.round(opp.savings / 10),
|
||||
cv_aht: cv,
|
||||
transfer_rate: transfer,
|
||||
fcr_rate: lookupData?.fcr_rate || 75,
|
||||
agenticScore: lookupData?.agenticScore || opp.feasibility,
|
||||
timelineMonths,
|
||||
effortLevel,
|
||||
riskLevel,
|
||||
whyPrioritized,
|
||||
nextSteps,
|
||||
annualCost: lookupData?.annualCost
|
||||
};
|
||||
});
|
||||
}, [opportunities, drilldownData]);
|
||||
|
||||
// Summary stats
|
||||
const summary = useMemo(() => {
|
||||
const totalSavings = enrichedOpportunities.reduce((sum, o) => sum + o.savings, 0);
|
||||
const byTier = {
|
||||
AUTOMATE: enrichedOpportunities.filter(o => o.tier === 'AUTOMATE'),
|
||||
ASSIST: enrichedOpportunities.filter(o => o.tier === 'ASSIST'),
|
||||
AUGMENT: enrichedOpportunities.filter(o => o.tier === 'AUGMENT')
|
||||
};
|
||||
const quickWins = enrichedOpportunities.filter(o => o.tier === 'AUTOMATE' && o.effortLevel === 'low');
|
||||
|
||||
return {
|
||||
totalSavings,
|
||||
totalVolume: enrichedOpportunities.reduce((sum, o) => sum + o.volume, 0),
|
||||
byTier,
|
||||
quickWinsCount: quickWins.length,
|
||||
quickWinsSavings: quickWins.reduce((sum, o) => sum + o.savings, 0)
|
||||
};
|
||||
}, [enrichedOpportunities]);
|
||||
|
||||
const displayedOpportunities = showAllOpportunities
|
||||
? enrichedOpportunities
|
||||
: enrichedOpportunities.slice(0, 5);
|
||||
|
||||
const topOpportunity = enrichedOpportunities[0];
|
||||
|
||||
if (!enrichedOpportunities.length) {
|
||||
return (
|
||||
<div className="bg-white p-8 rounded-xl border border-slate-200 text-center">
|
||||
<AlertTriangle className="mx-auto mb-4 text-amber-500" size={48} />
|
||||
<h3 className="text-lg font-semibold text-slate-700">No hay oportunidades identificadas</h3>
|
||||
<p className="text-slate-500 mt-2">Los datos actuales no muestran oportunidades de automatización viables.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||
{/* Header - matching app's visual style */}
|
||||
<div className="p-6 border-b border-slate-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Oportunidades Priorizadas</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{enrichedOpportunities.length} iniciativas ordenadas por potencial de ahorro y factibilidad
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Executive Summary - Answer "Where are opportunities?" in 5 seconds */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-6 bg-slate-50 border-b border-slate-200">
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-slate-500 text-xs mb-1">
|
||||
<DollarSign size={14} />
|
||||
<span>Ahorro Total Identificado</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-slate-800">
|
||||
€{(summary.totalSavings / 1000).toFixed(0)}K
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">anuales</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-emerald-50 rounded-lg p-4 border border-emerald-200 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-emerald-600 text-xs mb-1">
|
||||
<Bot size={14} />
|
||||
<span>Quick Wins (AUTOMATE)</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-emerald-700">
|
||||
{summary.byTier.AUTOMATE.length}
|
||||
</div>
|
||||
<div className="text-xs text-emerald-600">
|
||||
€{(summary.byTier.AUTOMATE.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K en 3-6 meses
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-blue-600 text-xs mb-1">
|
||||
<Headphones size={14} />
|
||||
<span>Asistencia (ASSIST)</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-blue-700">
|
||||
{summary.byTier.ASSIST.length}
|
||||
</div>
|
||||
<div className="text-xs text-blue-600">
|
||||
€{(summary.byTier.ASSIST.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K en 6-9 meses
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 rounded-lg p-4 border border-amber-200 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-amber-600 text-xs mb-1">
|
||||
<BookOpen size={14} />
|
||||
<span>Optimización (AUGMENT)</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-amber-700">
|
||||
{summary.byTier.AUGMENT.length}
|
||||
</div>
|
||||
<div className="text-xs text-amber-600">
|
||||
€{(summary.byTier.AUGMENT.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K en 9-12 meses
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* START HERE - Answer "Where do I start?" */}
|
||||
{topOpportunity && (
|
||||
<div className="p-6 bg-gradient-to-r from-emerald-50 to-green-50 border-b-2 border-emerald-200">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Sparkles className="text-emerald-600" size={20} />
|
||||
<span className="text-emerald-800 font-bold text-lg">EMPIEZA AQUÍ</span>
|
||||
<span className="bg-emerald-600 text-white text-xs px-2 py-0.5 rounded-full">Prioridad #1</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border-2 border-emerald-300 p-6 shadow-lg">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start gap-6">
|
||||
{/* Left: Main info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`p-2 rounded-lg ${TIER_CONFIG[topOpportunity.tier].bgColor}`}>
|
||||
{TIER_CONFIG[topOpportunity.tier].icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-slate-800">
|
||||
{topOpportunity.name.replace(/^[^\w\s]+\s*/, '')}
|
||||
</h3>
|
||||
<span className={`text-sm font-medium ${TIER_CONFIG[topOpportunity.tier].color}`}>
|
||||
{TIER_CONFIG[topOpportunity.tier].label} • {TIER_CONFIG[topOpportunity.tier].description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key metrics */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="bg-green-50 rounded-lg p-3">
|
||||
<div className="text-xs text-green-600 mb-1">Ahorro Anual</div>
|
||||
<div className="text-xl font-bold text-green-700">
|
||||
€{(topOpportunity.savings / 1000).toFixed(0)}K
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="text-xs text-slate-500 mb-1">Volumen</div>
|
||||
<div className="text-xl font-bold text-slate-700">
|
||||
{topOpportunity.volume.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="text-xs text-slate-500 mb-1">Timeline</div>
|
||||
<div className="text-xl font-bold text-slate-700">
|
||||
{topOpportunity.timelineMonths} meses
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="text-xs text-slate-500 mb-1">Agentic Score</div>
|
||||
<div className="text-xl font-bold text-slate-700">
|
||||
{topOpportunity.agenticScore.toFixed(1)}/10
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Why this is #1 */}
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2">
|
||||
<Info size={14} />
|
||||
¿Por qué es la prioridad #1?
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{topOpportunity.whyPrioritized.slice(0, 4).map((reason, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<CheckCircle2 size={14} className="text-emerald-500 flex-shrink-0" />
|
||||
{reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Next steps */}
|
||||
<div className="lg:w-80 bg-emerald-50 rounded-lg p-4 border border-emerald-200">
|
||||
<h4 className="text-sm font-semibold text-emerald-800 mb-3 flex items-center gap-2">
|
||||
<ArrowRight size={14} />
|
||||
Próximos Pasos
|
||||
</h4>
|
||||
<ol className="space-y-2">
|
||||
{topOpportunity.nextSteps.map((step, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-emerald-700">
|
||||
<span className="bg-emerald-600 text-white w-5 h-5 rounded-full flex items-center justify-center text-xs flex-shrink-0 mt-0.5">
|
||||
{i + 1}
|
||||
</span>
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<button className="mt-4 w-full bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2 px-4 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
Ver Detalle Completo
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Opportunity List - Answer "What else?" */}
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<BarChart3 size={20} />
|
||||
Todas las Oportunidades Priorizadas
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{displayedOpportunities.slice(1).map((opp) => (
|
||||
<motion.div
|
||||
key={opp.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`border rounded-lg overflow-hidden transition-all ${
|
||||
expandedId === opp.id ? 'border-blue-300 shadow-md' : 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{/* Collapsed view */}
|
||||
<div
|
||||
className="p-4 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||
onClick={() => setExpandedId(expandedId === opp.id ? null : opp.id)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Rank */}
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-lg ${
|
||||
opp.rank <= 3 ? 'bg-emerald-100 text-emerald-700' :
|
||||
opp.rank <= 6 ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
#{opp.rank}
|
||||
</div>
|
||||
|
||||
{/* Tier icon and name */}
|
||||
<div className={`p-2 rounded-lg ${TIER_CONFIG[opp.tier].bgColor}`}>
|
||||
{TIER_CONFIG[opp.tier].icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-slate-800 truncate">
|
||||
{opp.name.replace(/^[^\w\s]+\s*/, '')}
|
||||
</h4>
|
||||
<span className={`text-xs ${TIER_CONFIG[opp.tier].color}`}>
|
||||
{TIER_CONFIG[opp.tier].label} • {TIER_CONFIG[opp.tier].timeline}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-slate-500">Ahorro</div>
|
||||
<div className="font-bold text-green-600">€{(opp.savings / 1000).toFixed(0)}K</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-slate-500">Volumen</div>
|
||||
<div className="font-semibold text-slate-700">{opp.volume.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-slate-500">Score</div>
|
||||
<div className="font-semibold text-slate-700">{opp.agenticScore.toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual bar: Value vs Effort */}
|
||||
<div className="hidden lg:block w-32">
|
||||
<div className="text-xs text-slate-500 mb-1">Valor / Esfuerzo</div>
|
||||
<div className="flex h-2 rounded-full overflow-hidden bg-slate-100">
|
||||
<div
|
||||
className="bg-emerald-500 transition-all"
|
||||
style={{ width: `${Math.min(100, opp.impact * 10)}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-amber-400 transition-all"
|
||||
style={{ width: `${Math.min(100 - opp.impact * 10, (10 - opp.feasibility) * 10)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-slate-400 mt-0.5">
|
||||
<span>Valor</span>
|
||||
<span>Esfuerzo</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expand icon */}
|
||||
<motion.div
|
||||
animate={{ rotate: expandedId === opp.id ? 90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ChevronRight className="text-slate-400" size={20} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
<AnimatePresence>
|
||||
{expandedId === opp.id && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="p-4 bg-slate-50 border-t border-slate-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Why prioritized */}
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-slate-700 mb-2">¿Por qué esta posición?</h5>
|
||||
<ul className="space-y-1">
|
||||
{opp.whyPrioritized.map((reason, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<CheckCircle2 size={12} className="text-emerald-500 flex-shrink-0" />
|
||||
{reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-slate-700 mb-2">Métricas Clave</h5>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-white rounded p-2 border border-slate-200">
|
||||
<div className="text-xs text-slate-500">CV AHT</div>
|
||||
<div className="font-semibold text-slate-700">{opp.cv_aht.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="bg-white rounded p-2 border border-slate-200">
|
||||
<div className="text-xs text-slate-500">Transfer Rate</div>
|
||||
<div className="font-semibold text-slate-700">{opp.transfer_rate.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="bg-white rounded p-2 border border-slate-200">
|
||||
<div className="text-xs text-slate-500">FCR</div>
|
||||
<div className="font-semibold text-slate-700">{opp.fcr_rate.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="bg-white rounded p-2 border border-slate-200">
|
||||
<div className="text-xs text-slate-500">Riesgo</div>
|
||||
<div className={`font-semibold ${
|
||||
opp.riskLevel === 'low' ? 'text-emerald-600' :
|
||||
opp.riskLevel === 'medium' ? 'text-amber-600' : 'text-red-600'
|
||||
}`}>
|
||||
{opp.riskLevel === 'low' ? 'Bajo' : opp.riskLevel === 'medium' ? 'Medio' : 'Alto'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next steps */}
|
||||
<div className="mt-4 pt-4 border-t border-slate-200">
|
||||
<h5 className="text-sm font-semibold text-slate-700 mb-2">Próximos Pasos</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{opp.nextSteps.map((step, i) => (
|
||||
<span key={i} className="bg-white border border-slate-200 rounded-full px-3 py-1 text-xs text-slate-600">
|
||||
{i + 1}. {step}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Show more button */}
|
||||
{enrichedOpportunities.length > 5 && (
|
||||
<button
|
||||
onClick={() => setShowAllOpportunities(!showAllOpportunities)}
|
||||
className="mt-4 w-full py-3 border border-slate-200 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{showAllOpportunities ? (
|
||||
<>
|
||||
<ChevronDown size={16} className="rotate-180" />
|
||||
Mostrar menos
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown size={16} />
|
||||
Ver {enrichedOpportunities.length - 5} oportunidades más
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Methodology note */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-xs text-slate-500">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info size={14} className="flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong>Metodología de priorización:</strong> Las oportunidades se ordenan por potencial de ahorro TCO (volumen × tasa de contención × diferencial CPI).
|
||||
La clasificación de tier (AUTOMATE/ASSIST/AUGMENT) se basa en el Agentic Readiness Score considerando predictibilidad (CV AHT),
|
||||
resolutividad (FCR + Transfer), volumen, calidad de datos y simplicidad del proceso.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpportunityPrioritizer;
|
||||
103
frontend/components/ProgressStepper.tsx
Normal file
103
frontend/components/ProgressStepper.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Check, Package, Upload, BarChart3 } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Step {
|
||||
id: number;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
interface ProgressStepperProps {
|
||||
currentStep: number;
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{ id: 1, label: 'Seleccionar Tier', icon: Package },
|
||||
{ id: 2, label: 'Subir Datos', icon: Upload },
|
||||
{ id: 3, label: 'Ver Resultados', icon: BarChart3 },
|
||||
];
|
||||
|
||||
const ProgressStepper: React.FC<ProgressStepperProps> = ({ currentStep }) => {
|
||||
return (
|
||||
<div className="w-full max-w-3xl mx-auto mb-8">
|
||||
<div className="relative flex items-center justify-between">
|
||||
{steps.map((step, index) => {
|
||||
const Icon = step.icon;
|
||||
const isCompleted = currentStep > step.id;
|
||||
const isCurrent = currentStep === step.id;
|
||||
const isUpcoming = currentStep < step.id;
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.id}>
|
||||
{/* Step Circle */}
|
||||
<div className="relative flex flex-col items-center z-10">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className={clsx(
|
||||
'w-12 h-12 rounded-full flex items-center justify-center border-2 transition-all duration-300',
|
||||
isCompleted && 'bg-green-500 border-green-500',
|
||||
isCurrent && 'bg-blue-600 border-blue-600 shadow-lg shadow-blue-500/50',
|
||||
isUpcoming && 'bg-white border-slate-300'
|
||||
)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200 }}
|
||||
>
|
||||
<Check className="text-white" size={24} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<Icon
|
||||
className={clsx(
|
||||
'transition-colors',
|
||||
isCurrent && 'text-white',
|
||||
isUpcoming && 'text-slate-400'
|
||||
)}
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Step Label */}
|
||||
<motion.span
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 + 0.1 }}
|
||||
className={clsx(
|
||||
'mt-2 text-sm font-medium text-center whitespace-nowrap',
|
||||
(isCompleted || isCurrent) && 'text-slate-900',
|
||||
isUpcoming && 'text-slate-500'
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</motion.span>
|
||||
</div>
|
||||
|
||||
{/* Connector Line */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="flex-1 h-0.5 bg-slate-200 mx-4 relative -mt-6">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-r from-green-500 to-blue-600 h-full"
|
||||
initial={{ width: '0%' }}
|
||||
animate={{
|
||||
width: currentStep > step.id ? '100%' : '0%',
|
||||
}}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressStepper;
|
||||
102
frontend/components/Roadmap.tsx
Normal file
102
frontend/components/Roadmap.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { RoadmapInitiative, RoadmapPhase } from '../types';
|
||||
import { Bot, UserCheck, Cpu, Calendar, DollarSign, Users } from 'lucide-react';
|
||||
import MethodologyFooter from './MethodologyFooter';
|
||||
|
||||
interface RoadmapProps {
|
||||
data: RoadmapInitiative[];
|
||||
}
|
||||
|
||||
const PhaseConfig = {
|
||||
[RoadmapPhase.Automate]: {
|
||||
title: "Automate",
|
||||
description: "Iniciativas para automatizar tareas repetitivas y liberar a los agentes.",
|
||||
Icon: Bot,
|
||||
color: "text-purple-600",
|
||||
bgColor: "bg-purple-100",
|
||||
},
|
||||
[RoadmapPhase.Assist]: {
|
||||
title: "Assist",
|
||||
description: "Herramientas para ayudar a los agentes a ser más eficientes y efectivos.",
|
||||
Icon: UserCheck,
|
||||
color: "text-sky-600",
|
||||
bgColor: "bg-sky-100",
|
||||
},
|
||||
[RoadmapPhase.Augment]: {
|
||||
title: "Augment",
|
||||
description: "Capacidades avanzadas que aumentan la inteligencia del equipo.",
|
||||
Icon: Cpu,
|
||||
color: "text-amber-600",
|
||||
bgColor: "bg-amber-100",
|
||||
},
|
||||
};
|
||||
|
||||
const InitiativeCard: React.FC<{ initiative: RoadmapInitiative }> = ({ initiative }) => {
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-lg border border-slate-200 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<h4 className="font-bold text-slate-800 mb-3">{initiative.name}</h4>
|
||||
<div className="space-y-2 text-xs text-slate-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={14} className="text-slate-400" />
|
||||
<span>Timeline: <span className="font-semibold">{initiative.timeline}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign size={14} className="text-slate-400" />
|
||||
<span>Inversión: <span className="font-semibold">{initiative.investment.toLocaleString('es-ES')}€</span></span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Users size={14} className="text-slate-400 mt-0.5" />
|
||||
<div>Recursos: <span className="font-semibold">{initiative.resources.join(', ')}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Roadmap: React.FC<RoadmapProps> = ({ data }) => {
|
||||
const phases = Object.values(RoadmapPhase);
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg border border-slate-200">
|
||||
<h3 className="font-bold text-xl text-slate-800 mb-4">Implementation Roadmap</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{phases.map(phase => {
|
||||
const config = PhaseConfig[phase];
|
||||
const initiatives = data.filter(item => item.phase === phase);
|
||||
return (
|
||||
<div key={phase} className="flex flex-col">
|
||||
<div className={`p-4 rounded-t-lg ${config.bgColor}`}>
|
||||
<div className={`flex items-center gap-2 font-bold text-lg ${config.color}`}>
|
||||
<config.Icon size={20} />
|
||||
<h3>{config.title}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 mt-1">{config.description}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 p-4 rounded-b-lg border-x border-b border-slate-200 flex-grow">
|
||||
<div className="space-y-4">
|
||||
{initiatives.map(initiative => (
|
||||
<InitiativeCard
|
||||
key={initiative.id}
|
||||
initiative={initiative}
|
||||
/>
|
||||
))}
|
||||
{initiatives.length === 0 && <p className="text-xs text-slate-500 text-center py-4">No hay iniciativas para esta fase.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Methodology Footer */}
|
||||
<MethodologyFooter
|
||||
sources="Plan de transformación interno | Benchmarks de implementación: Gartner Magic Quadrant for CCaaS 2024"
|
||||
methodology="Timelines basados en implementaciones similares en sector Telco/Tech | Recursos asumen disponibilidad full-time equivalente"
|
||||
notes="Fases: Automate (Quick Wins, 0-6 meses), Assist (Build Capability, 6-12 meses), Augment (Transform, 12-18 meses) | Inversiones incluyen software, implementación, training y contingencia"
|
||||
lastUpdated="Enero 2025"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Roadmap;
|
||||
308
frontend/components/RoadmapPro.tsx
Normal file
308
frontend/components/RoadmapPro.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { RoadmapInitiative, RoadmapPhase } from '../types';
|
||||
import { Bot, UserCheck, Cpu, Calendar, DollarSign, Users, TrendingUp, AlertCircle, CheckCircle2, Clock } from 'lucide-react';
|
||||
import MethodologyFooter from './MethodologyFooter';
|
||||
|
||||
interface RoadmapProProps {
|
||||
data: RoadmapInitiative[];
|
||||
}
|
||||
|
||||
const phaseConfig: Record<RoadmapPhase, { icon: any; color: string; bgColor: string; label: string; description: string }> = {
|
||||
[RoadmapPhase.Automate]: {
|
||||
icon: Bot,
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-50',
|
||||
label: 'Wave 1: AUTOMATE',
|
||||
description: 'Quick Wins (0-6 meses)',
|
||||
},
|
||||
[RoadmapPhase.Assist]: {
|
||||
icon: UserCheck,
|
||||
color: 'text-blue-700',
|
||||
bgColor: 'bg-blue-50',
|
||||
label: 'Wave 2: ASSIST',
|
||||
description: 'Build Capability (6-12 meses)',
|
||||
},
|
||||
[RoadmapPhase.Augment]: {
|
||||
icon: Cpu,
|
||||
color: 'text-purple-700',
|
||||
bgColor: 'bg-purple-50',
|
||||
label: 'Wave 3: AUGMENT',
|
||||
description: 'Transform (12-18 meses)',
|
||||
},
|
||||
};
|
||||
|
||||
const getRiskColor = (initiative: RoadmapInitiative): string => {
|
||||
// Simple risk assessment based on investment and resources
|
||||
if (initiative.investment > 50000 || initiative.resources.length > 3) return 'text-red-500';
|
||||
if (initiative.investment > 25000 || initiative.resources.length > 2) return 'text-amber-500';
|
||||
return 'text-green-500';
|
||||
};
|
||||
|
||||
const getRiskLabel = (initiative: RoadmapInitiative): string => {
|
||||
if (initiative.investment > 50000 || initiative.resources.length > 3) return 'Alto';
|
||||
if (initiative.investment > 25000 || initiative.resources.length > 2) return 'Medio';
|
||||
return 'Bajo';
|
||||
};
|
||||
|
||||
const RoadmapPro: React.FC<RoadmapProProps> = ({ data }) => {
|
||||
// Group initiatives by phase
|
||||
const groupedData = useMemo(() => {
|
||||
try {
|
||||
if (!data || !Array.isArray(data)) return {
|
||||
[RoadmapPhase.Automate]: [],
|
||||
[RoadmapPhase.Assist]: [],
|
||||
[RoadmapPhase.Augment]: [],
|
||||
};
|
||||
const groups: Record<RoadmapPhase, RoadmapInitiative[]> = {
|
||||
[RoadmapPhase.Automate]: [],
|
||||
[RoadmapPhase.Assist]: [],
|
||||
[RoadmapPhase.Augment]: [],
|
||||
};
|
||||
|
||||
data.forEach(item => {
|
||||
if (item?.phase && groups[item.phase]) {
|
||||
groups[item.phase].push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
} catch (error) {
|
||||
console.error('❌ Error in groupedData useMemo:', error);
|
||||
return {
|
||||
[RoadmapPhase.Automate]: [],
|
||||
[RoadmapPhase.Assist]: [],
|
||||
[RoadmapPhase.Augment]: [],
|
||||
};
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Calculate summary metrics
|
||||
const summary = useMemo(() => {
|
||||
try {
|
||||
if (!data || !Array.isArray(data)) return {
|
||||
totalInvestment: 0,
|
||||
totalResources: 0,
|
||||
duration: 18,
|
||||
initiativeCount: 0,
|
||||
};
|
||||
const totalInvestment = data.reduce((sum, item) => sum + (item?.investment || 0), 0);
|
||||
const resourceLengths = data.map(item => item?.resources?.length || 0);
|
||||
const totalResources = resourceLengths.length > 0 ? Math.max(0, ...resourceLengths) : 0;
|
||||
const duration = 18;
|
||||
|
||||
return {
|
||||
totalInvestment,
|
||||
totalResources,
|
||||
duration,
|
||||
initiativeCount: data.length,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Error in summary useMemo:', error);
|
||||
return {
|
||||
totalInvestment: 0,
|
||||
totalResources: 0,
|
||||
duration: 18,
|
||||
initiativeCount: 0,
|
||||
};
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Timeline quarters (Q1 2025 - Q2 2026)
|
||||
const quarters = ['Q1 2025', 'Q2 2025', 'Q3 2025', 'Q4 2025', 'Q1 2026', 'Q2 2026'];
|
||||
|
||||
// Milestones
|
||||
const milestones = [
|
||||
{ quarter: 1, label: 'Go-live Wave 1', icon: CheckCircle2, color: 'text-green-600' },
|
||||
{ quarter: 2, label: '50% Adoption', icon: TrendingUp, color: 'text-blue-600' },
|
||||
{ quarter: 3, label: 'Tier Silver', icon: CheckCircle2, color: 'text-slate-600' },
|
||||
{ quarter: 5, label: 'Tier Gold', icon: CheckCircle2, color: 'text-amber-600' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div id="roadmap" className="bg-white p-8 rounded-xl border border-slate-200 shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h3 className="font-bold text-2xl text-slate-800 mb-2">
|
||||
Roadmap de Transformación: 18 meses hacia Agentic Readiness Tier Gold
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Plan de Implementación en 3 olas de transformación | {data.length} iniciativas | €{((summary.totalInvestment || 0) / 1000).toFixed(0)}K inversión total
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-gradient-to-br from-slate-50 to-slate-100 p-4 rounded-lg border border-slate-200">
|
||||
<div className="text-xs text-slate-600 mb-1">Duración Total</div>
|
||||
<div className="text-2xl font-bold text-slate-800">{summary.duration} meses</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-4 rounded-lg border border-blue-200">
|
||||
<div className="text-xs text-blue-700 mb-1">Inversión Total</div>
|
||||
<div className="text-2xl font-bold text-blue-600">€{(((summary.totalInvestment || 0)) / 1000).toFixed(0)}K</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-green-50 to-emerald-50 p-4 rounded-lg border border-green-200">
|
||||
<div className="text-xs text-green-700 mb-1"># Iniciativas</div>
|
||||
<div className="text-2xl font-bold text-green-600">{summary.initiativeCount}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-purple-50 to-violet-50 p-4 rounded-lg border border-purple-200">
|
||||
<div className="text-xs text-purple-700 mb-1">FTEs Peak</div>
|
||||
<div className="text-2xl font-bold text-purple-600">{summary.totalResources.toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Visual */}
|
||||
<div className="mb-8">
|
||||
<div className="relative">
|
||||
{/* Timeline Bar */}
|
||||
<div className="flex items-center mb-12">
|
||||
{quarters.map((quarter, index) => (
|
||||
<div key={quarter} className="flex-1 relative">
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Quarter Marker */}
|
||||
<div className="w-3 h-3 rounded-full bg-slate-400 mb-2 z-10"></div>
|
||||
{/* Quarter Label */}
|
||||
<div className="text-xs font-semibold text-slate-700">{quarter}</div>
|
||||
</div>
|
||||
{/* Connecting Line */}
|
||||
{index < quarters.length - 1 && (
|
||||
<div className="absolute top-1.5 left-1/2 w-full h-0.5 bg-slate-300"></div>
|
||||
)}
|
||||
|
||||
{/* Milestones */}
|
||||
{milestones
|
||||
.filter(m => m.quarter === index)
|
||||
.map((milestone, mIndex) => (
|
||||
<motion.div
|
||||
key={mIndex}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 + index * 0.1 }}
|
||||
className="absolute top-8 left-1/2 -translate-x-1/2 w-32"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<milestone.icon size={20} className={milestone.color} />
|
||||
<div className={`text-xs font-medium ${milestone.color} text-center mt-1`}>
|
||||
{milestone.label}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Waves */}
|
||||
<div className="space-y-6 mt-16">
|
||||
{([RoadmapPhase.Automate, RoadmapPhase.Assist, RoadmapPhase.Augment]).map((phase, phaseIndex) => {
|
||||
const config = phaseConfig[phase];
|
||||
const Icon = config.icon;
|
||||
const initiatives = groupedData[phase];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={phase}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: phaseIndex * 0.2 }}
|
||||
className={`${config.bgColor} border-2 border-${phase === RoadmapPhase.Automate ? 'green' : phase === RoadmapPhase.Assist ? 'blue' : 'purple'}-200 rounded-xl p-6`}
|
||||
>
|
||||
{/* Wave Header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`w-10 h-10 rounded-lg bg-white border-2 border-${phase === RoadmapPhase.Automate ? 'green' : phase === RoadmapPhase.Assist ? 'blue' : 'purple'}-300 flex items-center justify-center`}>
|
||||
<Icon size={20} className={config.color} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className={`font-bold text-lg ${config.color}`}>{config.label}</h4>
|
||||
<p className="text-xs text-slate-600">{config.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Initiatives */}
|
||||
<div className="space-y-3">
|
||||
{initiatives.map((initiative, index) => {
|
||||
const riskColor = getRiskColor(initiative);
|
||||
const riskLabel = getRiskLabel(initiative);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={initiative.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: phaseIndex * 0.2 + index * 0.1 }}
|
||||
whileHover={{ scale: 1.02, boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
|
||||
className="bg-white rounded-lg p-4 border border-slate-200"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h5 className="font-semibold text-slate-800">{initiative.name}</h5>
|
||||
<div className="flex items-center gap-1">
|
||||
<AlertCircle size={14} className={riskColor} />
|
||||
<span className={`text-xs font-medium ${riskColor}`}>
|
||||
Riesgo: {riskLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-slate-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar size={12} />
|
||||
<span>{initiative.timeline}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<DollarSign size={12} />
|
||||
<span>€{initiative.investment.toLocaleString('es-ES')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users size={12} />
|
||||
<span>{initiative.resources.length} FTEs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-6 p-4 bg-slate-50 rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-6 text-xs">
|
||||
<span className="font-semibold text-slate-700">Indicadores de Riesgo:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle size={14} className="text-green-500" />
|
||||
<span className="text-slate-700">Bajo riesgo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle size={14} className="text-amber-500" />
|
||||
<span className="text-slate-700">Riesgo medio (mitigable)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle size={14} className="text-red-500" />
|
||||
<span className="text-slate-700">Alto riesgo (requiere atención)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Methodology Footer */}
|
||||
<MethodologyFooter
|
||||
sources="Plan de transformación interno | Benchmarks de implementación: Gartner Magic Quadrant for CCaaS 2024"
|
||||
methodology="Timelines basados en implementaciones similares en sector Telco/Tech | Recursos asumen disponibilidad full-time equivalente | Riesgo: Basado en inversión (>€50K alto, €25-50K medio, <€25K bajo) y complejidad de recursos"
|
||||
notes="Waves: Wave 1 (Automate - Quick Wins, 0-6 meses), Wave 2 (Assist - Build Capability, 6-12 meses), Wave 3 (Augment - Transform, 12-18 meses) | Inversiones incluyen software, implementación, training y contingencia | Milestones: Go-live Wave 1 (Q2), 50% Adoption (Q3), Tier Silver (Q4), Tier Gold (Q2 2026)"
|
||||
lastUpdated="Enero 2025"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoadmapPro;
|
||||
174
frontend/components/SinglePageDataRequestIntegrated.tsx
Normal file
174
frontend/components/SinglePageDataRequestIntegrated.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
// components/SinglePageDataRequestIntegrated.tsx
|
||||
// Versión simplificada con cabecera estilo dashboard
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { TierKey, AnalysisData } from '../types';
|
||||
import DataInputRedesigned from './DataInputRedesigned';
|
||||
import DashboardTabs from './DashboardTabs';
|
||||
import { generateAnalysis, generateAnalysisFromCache } from '../utils/analysisGenerator';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAuth } from '../utils/AuthContext';
|
||||
import { formatDateMonthYear } from '../utils/formatters';
|
||||
|
||||
const SinglePageDataRequestIntegrated: React.FC = () => {
|
||||
const [view, setView] = useState<'form' | 'dashboard'>('form');
|
||||
const [analysisData, setAnalysisData] = useState<AnalysisData | null>(null);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
|
||||
const { authHeader, logout } = useAuth();
|
||||
|
||||
const handleAnalyze = (config: {
|
||||
costPerHour: number;
|
||||
avgCsat: number;
|
||||
segmentMapping?: {
|
||||
high_value_queues: string[];
|
||||
medium_value_queues: string[];
|
||||
low_value_queues: string[];
|
||||
};
|
||||
file?: File;
|
||||
sheetUrl?: string;
|
||||
useSynthetic?: boolean;
|
||||
useCache?: boolean;
|
||||
}) => {
|
||||
// Validar que hay archivo o caché
|
||||
if (!config.file && !config.useCache) {
|
||||
toast.error('Por favor, sube un archivo CSV o Excel.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar coste por hora
|
||||
if (!config.costPerHour || config.costPerHour <= 0) {
|
||||
toast.error('Por favor, introduce el coste por hora del agente.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Exigir estar logado para analizar
|
||||
if (!authHeader) {
|
||||
toast.error('Debes iniciar sesión para analizar datos.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAnalyzing(true);
|
||||
const loadingMsg = config.useCache ? 'Cargando desde caché...' : 'Generando análisis...';
|
||||
toast.loading(loadingMsg, { id: 'analyzing' });
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
let data: AnalysisData;
|
||||
|
||||
if (config.useCache) {
|
||||
// Usar datos desde caché
|
||||
data = await generateAnalysisFromCache(
|
||||
'gold' as TierKey,
|
||||
config.costPerHour,
|
||||
config.avgCsat || 0,
|
||||
config.segmentMapping,
|
||||
authHeader || undefined
|
||||
);
|
||||
} else {
|
||||
// Usar tier 'gold' por defecto
|
||||
data = await generateAnalysis(
|
||||
'gold' as TierKey,
|
||||
config.costPerHour,
|
||||
config.avgCsat || 0,
|
||||
config.segmentMapping,
|
||||
config.file,
|
||||
config.sheetUrl,
|
||||
false, // No usar sintético
|
||||
authHeader || undefined
|
||||
);
|
||||
}
|
||||
|
||||
setAnalysisData(data);
|
||||
setIsAnalyzing(false);
|
||||
toast.dismiss('analyzing');
|
||||
toast.success(config.useCache ? '¡Datos cargados desde caché!' : '¡Análisis completado!', { icon: '🎉' });
|
||||
setView('dashboard');
|
||||
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} catch (error) {
|
||||
console.error('Error generating analysis:', error);
|
||||
setIsAnalyzing(false);
|
||||
toast.dismiss('analyzing');
|
||||
|
||||
const msg = (error as Error).message || '';
|
||||
|
||||
if (msg.includes('401')) {
|
||||
toast.error('Sesión caducada o credenciales incorrectas. Vuelve a iniciar sesión.');
|
||||
logout();
|
||||
} else {
|
||||
toast.error('Error al generar el análisis: ' + msg);
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleBackToForm = () => {
|
||||
setView('form');
|
||||
setAnalysisData(null);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Dashboard view
|
||||
if (view === 'dashboard' && analysisData) {
|
||||
try {
|
||||
return <DashboardTabs data={analysisData} onBack={handleBackToForm} />;
|
||||
} catch (error) {
|
||||
console.error('Error rendering dashboard:', error);
|
||||
return (
|
||||
<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">
|
||||
<h1 className="text-2xl font-bold text-red-600 mb-4">Error al renderizar dashboard</h1>
|
||||
<p className="text-slate-700 mb-4">{(error as Error).message}</p>
|
||||
<button
|
||||
onClick={handleBackToForm}
|
||||
className="px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300"
|
||||
>
|
||||
Volver al formulario
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Form view
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-right" />
|
||||
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header estilo dashboard */}
|
||||
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-slate-800">
|
||||
AIR EUROPA - Beyond CX Analytics
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-slate-500">{formatDateMonthYear()}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-xs text-slate-500 hover:text-slate-800 underline"
|
||||
>
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Contenido principal */}
|
||||
<main className="max-w-7xl mx-auto px-6 py-6">
|
||||
<DataInputRedesigned
|
||||
onAnalyze={handleAnalyze}
|
||||
isAnalyzing={isAnalyzing}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SinglePageDataRequestIntegrated;
|
||||
274
frontend/components/TierSelectorEnhanced.tsx
Normal file
274
frontend/components/TierSelectorEnhanced.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Check, Star, Award, Medal, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { TierKey } from '../types';
|
||||
import { TIERS } from '../constants';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface TierSelectorEnhancedProps {
|
||||
selectedTier: TierKey;
|
||||
onSelectTier: (tier: TierKey) => void;
|
||||
}
|
||||
|
||||
const tierIcons = {
|
||||
gold: Award,
|
||||
silver: Medal,
|
||||
bronze: Star,
|
||||
};
|
||||
|
||||
const tierGradients = {
|
||||
gold: 'from-yellow-400 via-yellow-500 to-amber-600',
|
||||
silver: 'from-slate-300 via-slate-400 to-slate-500',
|
||||
bronze: 'from-orange-400 via-orange-500 to-amber-700',
|
||||
};
|
||||
|
||||
const TierSelectorEnhanced: React.FC<TierSelectorEnhancedProps> = ({
|
||||
selectedTier,
|
||||
onSelectTier,
|
||||
}) => {
|
||||
const [showComparison, setShowComparison] = useState(false);
|
||||
|
||||
const tiers: TierKey[] = ['gold', 'silver', 'bronze'];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Tier Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{tiers.map((tierKey, index) => {
|
||||
const tier = TIERS[tierKey];
|
||||
const Icon = tierIcons[tierKey];
|
||||
const isSelected = selectedTier === tierKey;
|
||||
const isRecommended = tierKey === 'silver';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={tierKey}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
whileHover={{ y: -8, transition: { duration: 0.2 } }}
|
||||
onClick={() => onSelectTier(tierKey)}
|
||||
className={clsx(
|
||||
'relative cursor-pointer rounded-xl border-2 transition-all duration-300 overflow-hidden',
|
||||
isSelected
|
||||
? 'border-blue-500 shadow-xl shadow-blue-500/20'
|
||||
: 'border-slate-200 hover:border-slate-300 shadow-lg hover:shadow-xl'
|
||||
)}
|
||||
>
|
||||
{/* Recommended Badge */}
|
||||
{isRecommended && (
|
||||
<motion.div
|
||||
initial={{ x: -100 }}
|
||||
animate={{ x: 0 }}
|
||||
transition={{ delay: 0.5, type: 'spring' }}
|
||||
className="absolute top-4 -left-8 bg-gradient-to-r from-blue-600 to-blue-700 text-white text-xs font-bold px-10 py-1 rotate-[-45deg] shadow-lg z-10"
|
||||
>
|
||||
POPULAR
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Selected Checkmark */}
|
||||
<AnimatePresence>
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
exit={{ scale: 0, rotate: 180 }}
|
||||
transition={{ type: 'spring', stiffness: 200 }}
|
||||
className="absolute top-4 right-4 w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center shadow-lg z-10"
|
||||
>
|
||||
<Check className="text-white" size={20} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Card Content */}
|
||||
<div className="p-6 bg-white">
|
||||
{/* Icon with Gradient */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<div
|
||||
className={clsx(
|
||||
'w-16 h-16 rounded-full flex items-center justify-center bg-gradient-to-br',
|
||||
tierGradients[tierKey],
|
||||
'shadow-lg'
|
||||
)}
|
||||
>
|
||||
<Icon className="text-white" size={32} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tier Name */}
|
||||
<h3 className="text-2xl font-bold text-center text-slate-900 mb-2">
|
||||
{tier.name}
|
||||
</h3>
|
||||
|
||||
{/* Price */}
|
||||
<div className="text-center mb-4">
|
||||
<span className="text-4xl font-bold text-slate-900">
|
||||
€{tier.price.toLocaleString('es-ES')}
|
||||
</span>
|
||||
<span className="text-slate-500 text-sm ml-1">one-time</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-slate-600 text-center mb-6 min-h-[60px]">
|
||||
{tier.description}
|
||||
</p>
|
||||
|
||||
{/* Key Features */}
|
||||
<ul className="space-y-2 mb-6">
|
||||
{tier.features?.slice(0, 3).map((feature, i) => (
|
||||
<motion.li
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 + i * 0.05 }}
|
||||
className="flex items-start gap-2 text-sm text-slate-700"
|
||||
>
|
||||
<Check className="text-green-500 flex-shrink-0 mt-0.5" size={16} />
|
||||
<span>{feature}</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Select Button */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={clsx(
|
||||
'w-full py-3 rounded-lg font-semibold transition-all duration-300',
|
||||
isSelected
|
||||
? 'bg-blue-600 text-white shadow-lg'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
)}
|
||||
>
|
||||
{isSelected ? 'Seleccionado' : 'Seleccionar'}
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Comparison Toggle */}
|
||||
<div className="text-center">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => setShowComparison(!showComparison)}
|
||||
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 font-medium text-sm"
|
||||
>
|
||||
{showComparison ? (
|
||||
<>
|
||||
<ChevronUp size={20} />
|
||||
Ocultar Comparación
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown size={20} />
|
||||
Ver Comparación Detallada
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Comparison Table */}
|
||||
<AnimatePresence>
|
||||
{showComparison && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-lg p-6">
|
||||
<h4 className="text-lg font-bold text-slate-900 mb-4">
|
||||
Comparación de Tiers
|
||||
</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="text-left p-3 font-semibold text-slate-700">
|
||||
Característica
|
||||
</th>
|
||||
{tiers.map((tierKey) => (
|
||||
<th
|
||||
key={tierKey}
|
||||
className="text-center p-3 font-semibold text-slate-700"
|
||||
>
|
||||
{TIERS[tierKey].name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-t border-slate-200">
|
||||
<td className="p-3 text-slate-700">Precio</td>
|
||||
{tiers.map((tierKey) => (
|
||||
<td key={tierKey} className="p-3 text-center font-semibold">
|
||||
€{TIERS[tierKey].price.toLocaleString('es-ES')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-t border-slate-200 bg-slate-50">
|
||||
<td className="p-3 text-slate-700">Tiempo de Entrega</td>
|
||||
{tiers.map((tierKey) => (
|
||||
<td key={tierKey} className="p-3 text-center">
|
||||
{tierKey === 'gold' ? '7 días' : tierKey === 'silver' ? '10 días' : '14 días'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-t border-slate-200">
|
||||
<td className="p-3 text-slate-700">Análisis de 8 Dimensiones</td>
|
||||
{tiers.map((tierKey) => (
|
||||
<td key={tierKey} className="p-3 text-center">
|
||||
<Check className="text-green-500 mx-auto" size={20} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-t border-slate-200 bg-slate-50">
|
||||
<td className="p-3 text-slate-700">Roadmap Ejecutable</td>
|
||||
{tiers.map((tierKey) => (
|
||||
<td key={tierKey} className="p-3 text-center">
|
||||
<Check className="text-green-500 mx-auto" size={20} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-t border-slate-200">
|
||||
<td className="p-3 text-slate-700">Modelo Económico ROI</td>
|
||||
{tiers.map((tierKey) => (
|
||||
<td key={tierKey} className="p-3 text-center">
|
||||
{tierKey !== 'bronze' ? (
|
||||
<Check className="text-green-500 mx-auto" size={20} />
|
||||
) : (
|
||||
<span className="text-slate-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-t border-slate-200 bg-slate-50">
|
||||
<td className="p-3 text-slate-700">Sesión de Presentación</td>
|
||||
{tiers.map((tierKey) => (
|
||||
<td key={tierKey} className="p-3 text-center">
|
||||
{tierKey === 'gold' ? (
|
||||
<Check className="text-green-500 mx-auto" size={20} />
|
||||
) : (
|
||||
<span className="text-slate-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TierSelectorEnhanced;
|
||||
217
frontend/components/TopOpportunitiesCard.tsx
Normal file
217
frontend/components/TopOpportunitiesCard.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { TrendingUp, Zap, Clock, DollarSign, Target } from 'lucide-react';
|
||||
import BadgePill from './BadgePill';
|
||||
|
||||
export interface Opportunity {
|
||||
rank: number;
|
||||
skill: string;
|
||||
volume: number;
|
||||
currentMetric: string;
|
||||
currentValue: number;
|
||||
benchmarkValue: number;
|
||||
potentialSavings: number;
|
||||
difficulty: 'low' | 'medium' | 'high';
|
||||
timeline: string;
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
interface TopOpportunitiesCardProps {
|
||||
opportunities: Opportunity[];
|
||||
}
|
||||
|
||||
const getDifficultyColor = (difficulty: string): string => {
|
||||
switch (difficulty) {
|
||||
case 'low':
|
||||
return 'bg-green-100 text-green-700';
|
||||
case 'medium':
|
||||
return 'bg-amber-100 text-amber-700';
|
||||
case 'high':
|
||||
return 'bg-red-100 text-red-700';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const getDifficultyLabel = (difficulty: string): string => {
|
||||
switch (difficulty) {
|
||||
case 'low':
|
||||
return '🟢 Baja';
|
||||
case 'medium':
|
||||
return '🟡 Media';
|
||||
case 'high':
|
||||
return '🔴 Alta';
|
||||
default:
|
||||
return 'Desconocida';
|
||||
}
|
||||
};
|
||||
|
||||
export const TopOpportunitiesCard: React.FC<TopOpportunitiesCardProps> = ({ opportunities }) => {
|
||||
if (!opportunities || opportunities.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border-2 border-amber-200 rounded-xl p-8">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<TrendingUp size={28} className="text-amber-600" />
|
||||
<h3 className="text-2xl font-bold text-amber-900">
|
||||
Top Oportunidades de Mejora
|
||||
</h3>
|
||||
<span className="ml-auto px-3 py-1 bg-amber-200 text-amber-800 rounded-full text-sm font-semibold">
|
||||
Ordenadas por ROI
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{opportunities.map((opp, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: index * 0.15 }}
|
||||
className="bg-white rounded-lg p-6 border border-amber-100 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
{/* Header with Rank */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-amber-400 to-amber-600 text-white flex items-center justify-center font-bold text-lg">
|
||||
{opp.rank}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-bold text-slate-900">{opp.skill}</h4>
|
||||
<p className="text-sm text-slate-600">
|
||||
Volumen: {opp.volume.toLocaleString()} calls/mes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<BadgePill
|
||||
label={opp.currentMetric}
|
||||
type="warning"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Metrics Analysis */}
|
||||
<div className="bg-slate-50 rounded-lg p-4 mb-4 border-l-4 border-amber-400">
|
||||
<div className="grid grid-cols-3 gap-4 mb-3">
|
||||
<div>
|
||||
<p className="text-xs text-slate-600 font-semibold uppercase mb-1">
|
||||
Estado Actual
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-slate-900">
|
||||
{opp.currentValue}{opp.currentMetric.includes('AHT') ? 's' : '%'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-slate-600 font-semibold uppercase mb-1">
|
||||
Benchmark P50
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-emerald-600">
|
||||
{opp.benchmarkValue}{opp.currentMetric.includes('AHT') ? 's' : '%'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-slate-600 font-semibold uppercase mb-1">
|
||||
Brecha
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
{Math.abs(opp.currentValue - opp.benchmarkValue)}{opp.currentMetric.includes('AHT') ? 's' : '%'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-slate-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-gradient-to-r from-amber-400 to-amber-600 h-2 rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
(opp.currentValue / (opp.currentValue + opp.benchmarkValue)) * 100,
|
||||
95
|
||||
)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Impact Calculation */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<DollarSign size={18} className="text-green-600 mt-1 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-600 font-semibold">Ahorro Potencial Anual</p>
|
||||
<p className="text-lg font-bold text-green-700">
|
||||
€{(opp.potentialSavings / 1000).toFixed(1)}K
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Si mejoras al benchmark P50
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Clock size={18} className="text-blue-600 mt-1 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-600 font-semibold">Timeline Estimado</p>
|
||||
<p className="text-lg font-bold text-blue-700">{opp.timeline}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Dificultad:{' '}
|
||||
<span className={`font-semibold ${getDifficultyColor(opp.difficulty)}`}>
|
||||
{getDifficultyLabel(opp.difficulty)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommended Actions */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-slate-900 mb-2">
|
||||
<Zap size={16} className="inline mr-1" />
|
||||
Acciones Recomendadas:
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{opp.actions.map((action, idx) => (
|
||||
<li key={idx} className="text-sm text-slate-700 flex items-start gap-2">
|
||||
<span className="text-amber-600 font-bold mt-0.5">☐</span>
|
||||
<span>{action}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="w-full py-2 px-4 bg-gradient-to-r from-amber-500 to-amber-600 text-white font-semibold rounded-lg hover:from-amber-600 hover:to-amber-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Target size={16} />
|
||||
Explorar Detalles de Implementación
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Summary Footer */}
|
||||
<div className="mt-6 p-4 bg-amber-100 rounded-lg border border-amber-300">
|
||||
<p className="text-sm text-amber-900">
|
||||
<span className="font-semibold">ROI Total Combinado:</span>{' '}
|
||||
€{opportunities.reduce((sum, opp) => sum + opp.potentialSavings, 0) / 1000000 > 0
|
||||
? (opportunities.reduce((sum, opp) => sum + opp.potentialSavings, 0) / 1000).toFixed(0)
|
||||
: '0'}K/año
|
||||
{' '} | Tiempo promedio implementación:{' '}
|
||||
{Math.round(opportunities.reduce((sum, opp) => {
|
||||
const months = parseInt(opp.timeline) || 2;
|
||||
return sum + months;
|
||||
}, 0) / opportunities.length)} meses
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopOpportunitiesCard;
|
||||
590
frontend/components/VariabilityHeatmap.tsx
Normal file
590
frontend/components/VariabilityHeatmap.tsx
Normal file
@@ -0,0 +1,590 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { HelpCircle, ArrowUpDown, TrendingUp, AlertTriangle, CheckCircle, Activity, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { HeatmapDataPoint } from '../types';
|
||||
import clsx from 'clsx';
|
||||
import MethodologyFooter from './MethodologyFooter';
|
||||
import { getConsolidatedCategory, skillsConsolidationConfig } from '../config/skillsConsolidation';
|
||||
|
||||
interface VariabilityHeatmapProps {
|
||||
data: HeatmapDataPoint[];
|
||||
}
|
||||
|
||||
type SortKey = 'skill' | 'cv_aht' | 'cv_talk_time' | 'cv_hold_time' | 'transfer_rate' | 'automation_readiness' | 'volume';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
interface TooltipData {
|
||||
skill: string;
|
||||
metric: string;
|
||||
value: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Insight {
|
||||
type: 'quick_win' | 'standardize' | 'consult';
|
||||
skill: string;
|
||||
volume: number;
|
||||
automation_readiness: number;
|
||||
recommendation: string;
|
||||
roi: number;
|
||||
}
|
||||
|
||||
interface ConsolidatedDataPoint {
|
||||
categoryKey: string;
|
||||
categoryName: string;
|
||||
volume: number;
|
||||
originalSkills: string[];
|
||||
variability: {
|
||||
cv_aht: number;
|
||||
cv_talk_time: number;
|
||||
cv_hold_time: number;
|
||||
transfer_rate: number;
|
||||
};
|
||||
automation_readiness: number;
|
||||
}
|
||||
|
||||
// Colores invertidos: Verde = bajo CV (bueno), Rojo = alto CV (malo)
|
||||
// Escala RELATIVA: Ajusta a los datos reales (45-75%) para mejor diferenciación
|
||||
const getCellColor = (value: number, minValue: number = 45, maxValue: number = 75) => {
|
||||
// Normalizar valor al rango 0-100 relativo al min/max actual
|
||||
const normalized = ((value - minValue) / (maxValue - minValue)) * 100;
|
||||
|
||||
// Escala relativa a datos reales
|
||||
if (normalized < 20) return 'bg-emerald-600 text-white'; // Bajo en rango
|
||||
if (normalized < 35) return 'bg-green-500 text-white'; // Bajo-medio
|
||||
if (normalized < 50) return 'bg-yellow-400 text-yellow-900'; // Medio
|
||||
if (normalized < 70) return 'bg-amber-500 text-white'; // Alto-medio
|
||||
return 'bg-red-500 text-white'; // Alto en rango
|
||||
};
|
||||
|
||||
const getReadinessColor = (score: number) => {
|
||||
if (score >= 80) return 'bg-emerald-600 text-white';
|
||||
if (score >= 60) return 'bg-yellow-400 text-yellow-900';
|
||||
return 'bg-red-500 text-white';
|
||||
};
|
||||
|
||||
const getReadinessLabel = (score: number): string => {
|
||||
if (score >= 80) return 'Listo para automatizar';
|
||||
if (score >= 60) return 'Estandarizar primero';
|
||||
return 'Consultoría recomendada';
|
||||
};
|
||||
|
||||
const getCellIcon = (value: number) => {
|
||||
if (value < 25) return <CheckCircle size={12} className="inline ml-1" />;
|
||||
if (value >= 55) return <AlertTriangle size={12} className="inline ml-1" />;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Función para consolidar skills por categoría
|
||||
const consolidateVariabilityData = (data: HeatmapDataPoint[]): ConsolidatedDataPoint[] => {
|
||||
const consolidationMap = new Map<string, {
|
||||
category: string;
|
||||
displayName: string;
|
||||
volume: number;
|
||||
skills: string[];
|
||||
cvAhtSum: number;
|
||||
cvTalkSum: number;
|
||||
cvHoldSum: number;
|
||||
transferRateSum: number;
|
||||
readinessSum: number;
|
||||
count: number;
|
||||
}>();
|
||||
|
||||
data.forEach(item => {
|
||||
const category = getConsolidatedCategory(item.skill);
|
||||
if (!category) return;
|
||||
|
||||
const key = category.category;
|
||||
if (!consolidationMap.has(key)) {
|
||||
consolidationMap.set(key, {
|
||||
category: key,
|
||||
displayName: category.displayName,
|
||||
volume: 0,
|
||||
skills: [],
|
||||
cvAhtSum: 0,
|
||||
cvTalkSum: 0,
|
||||
cvHoldSum: 0,
|
||||
transferRateSum: 0,
|
||||
readinessSum: 0,
|
||||
count: 0
|
||||
});
|
||||
}
|
||||
|
||||
const entry = consolidationMap.get(key)!;
|
||||
entry.volume += item.volume || 0;
|
||||
entry.skills.push(item.skill);
|
||||
entry.cvAhtSum += item.variability?.cv_aht || 0;
|
||||
entry.cvTalkSum += item.variability?.cv_talk_time || 0;
|
||||
entry.cvHoldSum += item.variability?.cv_hold_time || 0;
|
||||
entry.transferRateSum += item.variability?.transfer_rate || 0;
|
||||
entry.readinessSum += item.automation_readiness || 0;
|
||||
entry.count += 1;
|
||||
});
|
||||
|
||||
return Array.from(consolidationMap.values()).map(entry => ({
|
||||
categoryKey: entry.category,
|
||||
categoryName: entry.displayName,
|
||||
volume: entry.volume,
|
||||
originalSkills: [...new Set(entry.skills)],
|
||||
variability: {
|
||||
cv_aht: Math.round(entry.cvAhtSum / entry.count),
|
||||
cv_talk_time: Math.round(entry.cvTalkSum / entry.count),
|
||||
cv_hold_time: Math.round(entry.cvHoldSum / entry.count),
|
||||
transfer_rate: Math.round(entry.transferRateSum / entry.count)
|
||||
},
|
||||
automation_readiness: Math.round(entry.readinessSum / entry.count)
|
||||
}));
|
||||
};
|
||||
|
||||
const VariabilityHeatmap: React.FC<VariabilityHeatmapProps> = ({ data }) => {
|
||||
const [sortKey, setSortKey] = useState<SortKey>('automation_readiness');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
||||
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
|
||||
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
|
||||
const metrics: Array<{ key: keyof HeatmapDataPoint['variability']; label: string }> = [
|
||||
{ key: 'cv_aht', label: 'CV AHT' },
|
||||
{ key: 'cv_talk_time', label: 'CV Talk Time' },
|
||||
{ key: 'cv_hold_time', label: 'CV Hold Time' },
|
||||
{ key: 'transfer_rate', label: 'Transfer Rate' },
|
||||
];
|
||||
|
||||
// Calculate insights with consolidated data
|
||||
const insights = useMemo(() => {
|
||||
try {
|
||||
const consolidated = consolidateVariabilityData(data);
|
||||
const sortedByReadiness = [...consolidated].sort((a, b) => b.automation_readiness - a.automation_readiness);
|
||||
|
||||
// Calculate simple ROI estimate: based on volume and variability reduction potential
|
||||
const getRoiEstimate = (cat: ConsolidatedDataPoint): number => {
|
||||
const volumeFactor = Math.min(cat.volume / 1000, 10); // Max 10K impact
|
||||
const variabilityReduction = Math.max(0, 75 - cat.variability.cv_aht); // Potential improvement
|
||||
return Math.round(volumeFactor * variabilityReduction * 1.5); // Rough EU multiplier
|
||||
};
|
||||
|
||||
const quickWins: Insight[] = sortedByReadiness
|
||||
.filter(item => item.automation_readiness >= 80)
|
||||
.slice(0, 5)
|
||||
.map(item => ({
|
||||
type: 'quick_win',
|
||||
skill: item.categoryName,
|
||||
volume: item.volume,
|
||||
automation_readiness: item.automation_readiness,
|
||||
roi: getRoiEstimate(item),
|
||||
recommendation: `CV AHT ${item.variability.cv_aht}% → Listo para automatización`
|
||||
}));
|
||||
|
||||
const standardize: Insight[] = sortedByReadiness
|
||||
.filter(item => item.automation_readiness >= 60 && item.automation_readiness < 80)
|
||||
.slice(0, 5)
|
||||
.map(item => ({
|
||||
type: 'standardize',
|
||||
skill: item.categoryName,
|
||||
volume: item.volume,
|
||||
automation_readiness: item.automation_readiness,
|
||||
roi: getRoiEstimate(item),
|
||||
recommendation: `Estandarizar antes de automatizar`
|
||||
}));
|
||||
|
||||
const consult: Insight[] = sortedByReadiness
|
||||
.filter(item => item.automation_readiness < 60)
|
||||
.slice(0, 5)
|
||||
.map(item => ({
|
||||
type: 'consult',
|
||||
skill: item.categoryName,
|
||||
volume: item.volume,
|
||||
automation_readiness: item.automation_readiness,
|
||||
roi: getRoiEstimate(item),
|
||||
recommendation: `Consultoría para identificar causas raíz`
|
||||
}));
|
||||
|
||||
return { quickWins, standardize, consult };
|
||||
} catch (error) {
|
||||
console.error('❌ Error calculating insights (VariabilityHeatmap):', error);
|
||||
return { quickWins: [], standardize: [], consult: [] };
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Calculate dynamic title
|
||||
const dynamicTitle = useMemo(() => {
|
||||
try {
|
||||
if (!data || !Array.isArray(data)) return 'Análisis de variabilidad interna';
|
||||
const highVariability = data.filter(item => (item?.automation_readiness || 0) < 60).length;
|
||||
const total = data.length;
|
||||
|
||||
if (highVariability === 0) {
|
||||
return `Todas las skills muestran baja variabilidad (>60), listas para automatización`;
|
||||
} else if (highVariability === total) {
|
||||
return `${highVariability} de ${total} skills muestran alta variabilidad (CV>40%), sugiriendo necesidad de estandarización antes de automatizar`;
|
||||
} else {
|
||||
return `${highVariability} de ${total} skills muestran alta variabilidad (CV>40%), sugiriendo necesidad de estandarización antes de automatizar`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error in dynamicTitle useMemo (VariabilityHeatmap):', error);
|
||||
return 'Análisis de variabilidad interna';
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Consolidate data once for reuse
|
||||
const consolidatedData = useMemo(() => consolidateVariabilityData(data), [data]);
|
||||
|
||||
// Get min/max values for relative color scaling
|
||||
const colorScaleValues = useMemo(() => {
|
||||
const cvValues = consolidatedData.flatMap(item => [
|
||||
item.variability.cv_aht,
|
||||
item.variability.cv_talk_time,
|
||||
item.variability.cv_hold_time
|
||||
]);
|
||||
return {
|
||||
min: Math.min(...cvValues, 45),
|
||||
max: Math.max(...cvValues, 75)
|
||||
};
|
||||
}, [consolidatedData]);
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortOrder(key === 'automation_readiness' ? 'desc' : key === 'volume' ? 'desc' : 'asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedData = [...consolidatedData].sort((a, b) => {
|
||||
let aValue: number | string;
|
||||
let bValue: number | string;
|
||||
|
||||
if (sortKey === 'skill') {
|
||||
aValue = a.categoryName;
|
||||
bValue = b.categoryName;
|
||||
} else if (sortKey === 'automation_readiness') {
|
||||
aValue = a.automation_readiness;
|
||||
bValue = b.automation_readiness;
|
||||
} else if (sortKey === 'volume') {
|
||||
aValue = a.volume;
|
||||
bValue = b.volume;
|
||||
} else {
|
||||
aValue = a.variability?.[sortKey] || 0;
|
||||
bValue = b.variability?.[sortKey] || 0;
|
||||
}
|
||||
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
return sortOrder === 'asc'
|
||||
? aValue.localeCompare(bValue)
|
||||
: bValue.localeCompare(aValue);
|
||||
}
|
||||
|
||||
return sortOrder === 'asc'
|
||||
? (aValue as number) - (bValue as number)
|
||||
: (bValue as number) - (aValue as number);
|
||||
});
|
||||
|
||||
const handleCellHover = (
|
||||
skill: string,
|
||||
metric: string,
|
||||
value: number,
|
||||
event: React.MouseEvent
|
||||
) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setTooltip({
|
||||
skill,
|
||||
metric,
|
||||
value,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCellLeave = () => {
|
||||
setTooltip(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="variability-heatmap" className="bg-white p-8 rounded-xl border border-slate-200 shadow-sm">
|
||||
{/* Header with Dynamic Title */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Activity size={24} className="text-[#6D84E3]" />
|
||||
<h3 className="font-bold text-2xl text-slate-800">Heatmap de Variabilidad Interna™</h3>
|
||||
<div className="group relative">
|
||||
<HelpCircle size={18} className="text-slate-400 cursor-pointer" />
|
||||
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 w-80 bg-slate-800 text-white text-xs rounded py-2 px-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none z-10">
|
||||
Mide la consistencia y predictibilidad interna de cada skill. Baja variabilidad indica procesos maduros listos para automatización. Alta variabilidad sugiere necesidad de estandarización o consultoría.
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 leading-relaxed">
|
||||
{dynamicTitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights Panel - Improved with Volume & ROI */}
|
||||
<div className="grid grid-cols-3 gap-4 mt-4">
|
||||
{/* Quick Wins */}
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<CheckCircle size={18} className="text-emerald-600" />
|
||||
<h4 className="font-semibold text-emerald-800">✓ Quick Wins ({insights.quickWins.length})</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{insights.quickWins.map((insight, idx) => (
|
||||
<div key={idx} className="text-xs p-2 bg-white rounded border-l-2 border-emerald-400">
|
||||
<div className="font-bold text-emerald-700">{idx + 1}. {insight.skill}</div>
|
||||
<div className="text-emerald-600 text-xs mt-1">
|
||||
Vol: {(insight.volume / 1000).toFixed(1)}K/mes | ROI: €{insight.roi}K/año
|
||||
</div>
|
||||
<div className="text-emerald-600 text-xs mt-1">{insight.recommendation}</div>
|
||||
</div>
|
||||
))}
|
||||
{insights.quickWins.length === 0 && (
|
||||
<p className="text-xs text-emerald-600 italic">No hay skills con readiness >80</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Standardize - Top 5 */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingUp size={18} className="text-amber-600" />
|
||||
<h4 className="font-semibold text-amber-800">📈 Estandarizar ({insights.standardize.length})</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{insights.standardize.map((insight, idx) => (
|
||||
<div key={idx} className="text-xs p-2 bg-white rounded border-l-2 border-amber-400">
|
||||
<div className="font-bold text-amber-700">{idx + 1}. {insight.skill}</div>
|
||||
<div className="text-amber-600 text-xs mt-1">
|
||||
Vol: {(insight.volume / 1000).toFixed(1)}K/mes | ROI: €{insight.roi}K/año
|
||||
</div>
|
||||
<div className="text-amber-600 text-xs mt-1">{insight.recommendation}</div>
|
||||
</div>
|
||||
))}
|
||||
{insights.standardize.length === 0 && (
|
||||
<p className="text-xs text-amber-600 italic">No hay skills con readiness 60-79</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Consult */}
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<AlertTriangle size={18} className="text-red-600" />
|
||||
<h4 className="font-semibold text-red-800">⚠️ Consultoría ({insights.consult.length})</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{insights.consult.map((insight, idx) => (
|
||||
<div key={idx} className="text-xs p-2 bg-white rounded border-l-2 border-red-400">
|
||||
<div className="font-bold text-red-700">{idx + 1}. {insight.skill}</div>
|
||||
<div className="text-red-600 text-xs mt-1">
|
||||
Vol: {(insight.volume / 1000).toFixed(1)}K/mes | ROI: €{insight.roi}K/año
|
||||
</div>
|
||||
<div className="text-red-600 text-xs mt-1">{insight.recommendation}</div>
|
||||
</div>
|
||||
))}
|
||||
{insights.consult.length === 0 && (
|
||||
<p className="text-xs text-red-600 italic">No hay skills con readiness <60</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heatmap Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th
|
||||
onClick={() => handleSort('skill')}
|
||||
className="p-4 font-semibold text-slate-700 text-left cursor-pointer hover:bg-slate-100 transition-colors border-b-2 border-slate-300"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Categoría/Skill</span>
|
||||
<ArrowUpDown size={14} className="text-slate-400" />
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
onClick={() => handleSort('volume')}
|
||||
className="p-4 font-semibold text-slate-700 text-center cursor-pointer hover:bg-slate-100 transition-colors border-b-2 border-slate-300 bg-blue-50"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span>VOLUMEN</span>
|
||||
<ArrowUpDown size={14} className="text-slate-400" />
|
||||
</div>
|
||||
</th>
|
||||
{metrics.map(({ key, label }) => (
|
||||
<th
|
||||
key={key}
|
||||
onClick={() => handleSort(key)}
|
||||
className="p-4 font-semibold text-slate-700 text-center cursor-pointer hover:bg-slate-100 transition-colors uppercase border-b-2 border-slate-300"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span>{label}</span>
|
||||
<ArrowUpDown size={14} className="text-slate-400" />
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th
|
||||
onClick={() => handleSort('automation_readiness')}
|
||||
className="p-4 font-semibold text-slate-700 text-center cursor-pointer hover:bg-slate-100 transition-colors border-b-2 border-slate-300"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span>READINESS</span>
|
||||
<ArrowUpDown size={14} className="text-slate-400" />
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<AnimatePresence>
|
||||
{sortedData.map((item, index) => (
|
||||
<motion.tr
|
||||
key={item.categoryKey}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
onMouseEnter={() => setHoveredRow(item.categoryKey)}
|
||||
onMouseLeave={() => setHoveredRow(null)}
|
||||
className={clsx(
|
||||
'border-b border-slate-200 transition-colors',
|
||||
hoveredRow === item.categoryKey && 'bg-blue-50'
|
||||
)}
|
||||
>
|
||||
<td className="p-4 font-semibold text-slate-800 border-r border-slate-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{item.categoryName}</span>
|
||||
{item.originalSkills.length > 1 && (
|
||||
<span className="text-xs text-slate-500 ml-2">
|
||||
({item.originalSkills.length} skills)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 font-bold text-center bg-blue-50 border-l border-blue-200">
|
||||
<div className="text-slate-800">{(item.volume / 1000).toFixed(1)}K/mes</div>
|
||||
</td>
|
||||
{metrics.map(({ key }) => {
|
||||
const value = item.variability[key];
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className={clsx(
|
||||
'p-4 font-bold text-center cursor-pointer transition-all relative',
|
||||
getCellColor(value, colorScaleValues.min, colorScaleValues.max),
|
||||
hoveredRow === item.categoryKey && 'scale-105 shadow-lg ring-2 ring-blue-400'
|
||||
)}
|
||||
onMouseEnter={(e) => handleCellHover(item.categoryName, key.toUpperCase(), value, e)}
|
||||
onMouseLeave={handleCellLeave}
|
||||
>
|
||||
<span>{value}%</span>
|
||||
{getCellIcon(value)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className={clsx(
|
||||
'p-4 font-bold text-center',
|
||||
getReadinessColor(item.automation_readiness)
|
||||
)}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-lg">{item.automation_readiness}</span>
|
||||
<span className="text-xs opacity-90">{getReadinessLabel(item.automation_readiness)}</span>
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Legend - Relative Scale */}
|
||||
<div className="mt-6 p-4 bg-slate-50 rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs">
|
||||
<span className="font-semibold text-slate-700">Escala de Variabilidad (escala relativa a datos actuales):</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-sm bg-emerald-600"></div>
|
||||
<span className="text-slate-700"><strong>Bajo</strong> (Mejor en rango)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-sm bg-green-500"></div>
|
||||
<span className="text-slate-700"><strong>Bajo-Medio</strong></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-sm bg-yellow-400"></div>
|
||||
<span className="text-slate-700"><strong>Medio</strong></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-sm bg-amber-500"></div>
|
||||
<span className="text-slate-700"><strong>Alto-Medio</strong></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-sm bg-red-500"></div>
|
||||
<span className="text-slate-700"><strong>Alto</strong> (Peor en rango)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs mt-3 pt-3 border-t border-slate-200">
|
||||
<span className="font-semibold text-slate-700">Automation Readiness (0-100):</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-sm bg-emerald-600"></div>
|
||||
<span className="text-slate-700"><strong>80-100</strong> - Listo para automatizar</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-sm bg-yellow-400"></div>
|
||||
<span className="text-slate-700"><strong>60-79</strong> - Estandarizar primero</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-sm bg-red-500"></div>
|
||||
<span className="text-slate-700"><strong><60</strong> - Consultoría recomendada</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-600 mt-3 italic">
|
||||
💡 <strong>Nota:</strong> Los datos se han consolidado de 44 skills a 12 categorías para mayor claridad. Las métricas muestran promedios por categoría.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
<AnimatePresence>
|
||||
{tooltip && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
className="fixed z-50 bg-slate-800 text-white text-xs rounded-lg py-2 px-3 pointer-events-none"
|
||||
style={{
|
||||
left: tooltip.x,
|
||||
top: tooltip.y - 10,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
<div className="font-semibold mb-1">{tooltip.skill}</div>
|
||||
<div>{tooltip.metric}: {tooltip.value}%</div>
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-800"></div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Methodology Footer */}
|
||||
<MethodologyFooter
|
||||
sources={[
|
||||
'Datos operacionales del contact center (últimos 3 meses)',
|
||||
'Análisis de variabilidad por skill/canal',
|
||||
'Benchmarks de procesos estandarizados'
|
||||
]}
|
||||
methodology="Automation Readiness calculado como: (100-CV_AHT)×30% + (100-CV_FCR)×25% + (100-CV_CSAT)×20% + (100-Entropía)×15% + (100-Escalación)×10%"
|
||||
assumptions={[
|
||||
'CV (Coeficiente de Variación) = Desviación Estándar / Media',
|
||||
'Entropía mide diversidad de motivos de contacto (0-100)',
|
||||
'Baja variabilidad indica proceso maduro y predecible'
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VariabilityHeatmap;
|
||||
159
frontend/components/charts/BulletChart.tsx
Normal file
159
frontend/components/charts/BulletChart.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export interface BulletChartProps {
|
||||
label: string;
|
||||
actual: number;
|
||||
target: number;
|
||||
ranges: [number, number, number]; // [poor, satisfactory, good/max]
|
||||
unit?: string;
|
||||
percentile?: number;
|
||||
inverse?: boolean; // true if lower is better (e.g., AHT)
|
||||
formatValue?: (value: number) => string;
|
||||
}
|
||||
|
||||
export function BulletChart({
|
||||
label,
|
||||
actual,
|
||||
target,
|
||||
ranges,
|
||||
unit = '',
|
||||
percentile,
|
||||
inverse = false,
|
||||
formatValue = (v) => v.toLocaleString()
|
||||
}: BulletChartProps) {
|
||||
const [poor, satisfactory, max] = ranges;
|
||||
|
||||
const { actualPercent, targetPercent, rangePercents, performance } = useMemo(() => {
|
||||
const actualPct = Math.min((actual / max) * 100, 100);
|
||||
const targetPct = Math.min((target / max) * 100, 100);
|
||||
|
||||
const poorPct = (poor / max) * 100;
|
||||
const satPct = (satisfactory / max) * 100;
|
||||
|
||||
// Determine performance level
|
||||
let perf: 'poor' | 'satisfactory' | 'good';
|
||||
if (inverse) {
|
||||
// Lower is better (e.g., AHT, hold time)
|
||||
if (actual <= satisfactory) perf = 'good';
|
||||
else if (actual <= poor) perf = 'satisfactory';
|
||||
else perf = 'poor';
|
||||
} else {
|
||||
// Higher is better (e.g., FCR, CSAT)
|
||||
if (actual >= satisfactory) perf = 'good';
|
||||
else if (actual >= poor) perf = 'satisfactory';
|
||||
else perf = 'poor';
|
||||
}
|
||||
|
||||
return {
|
||||
actualPercent: actualPct,
|
||||
targetPercent: targetPct,
|
||||
rangePercents: { poor: poorPct, satisfactory: satPct },
|
||||
performance: perf
|
||||
};
|
||||
}, [actual, target, ranges, inverse, poor, satisfactory, max]);
|
||||
|
||||
const performanceColors = {
|
||||
poor: 'bg-red-500',
|
||||
satisfactory: 'bg-amber-500',
|
||||
good: 'bg-emerald-500'
|
||||
};
|
||||
|
||||
const performanceLabels = {
|
||||
poor: 'Crítico',
|
||||
satisfactory: 'Aceptable',
|
||||
good: 'Óptimo'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-slate-800">{label}</span>
|
||||
{percentile !== undefined && (
|
||||
<span className="text-xs px-2 py-0.5 bg-slate-100 text-slate-600 rounded-full">
|
||||
P{percentile}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||
performance === 'good' ? 'bg-emerald-100 text-emerald-700' :
|
||||
performance === 'satisfactory' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{performanceLabels[performance]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bullet Chart */}
|
||||
<div className="relative h-8 mb-2">
|
||||
{/* Background ranges */}
|
||||
<div className="absolute inset-0 flex rounded overflow-hidden">
|
||||
{inverse ? (
|
||||
// Inverse: green on left, red on right
|
||||
<>
|
||||
<div
|
||||
className="h-full bg-emerald-100"
|
||||
style={{ width: `${rangePercents.satisfactory}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-amber-100"
|
||||
style={{ width: `${rangePercents.poor - rangePercents.satisfactory}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-red-100"
|
||||
style={{ width: `${100 - rangePercents.poor}%` }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Normal: red on left, green on right
|
||||
<>
|
||||
<div
|
||||
className="h-full bg-red-100"
|
||||
style={{ width: `${rangePercents.poor}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-amber-100"
|
||||
style={{ width: `${rangePercents.satisfactory - rangePercents.poor}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-emerald-100"
|
||||
style={{ width: `${100 - rangePercents.satisfactory}%` }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actual value bar */}
|
||||
<div
|
||||
className={`absolute top-1/2 -translate-y-1/2 h-4 rounded ${performanceColors[performance]}`}
|
||||
style={{ width: `${actualPercent}%`, minWidth: '4px' }}
|
||||
/>
|
||||
|
||||
{/* Target marker */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-slate-800"
|
||||
style={{ left: `${targetPercent}%` }}
|
||||
>
|
||||
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-0 h-0 border-l-[4px] border-r-[4px] border-t-[6px] border-l-transparent border-r-transparent border-t-slate-800" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Values */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span className="font-bold text-slate-800">{formatValue(actual)}</span>
|
||||
<span className="text-slate-500">{unit}</span>
|
||||
<span className="text-slate-400 ml-1">actual</span>
|
||||
</div>
|
||||
<div className="text-slate-500">
|
||||
<span className="text-slate-600">{formatValue(target)}</span>
|
||||
<span>{unit}</span>
|
||||
<span className="ml-1">benchmark</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BulletChart;
|
||||
214
frontend/components/charts/OpportunityTreemap.tsx
Normal file
214
frontend/components/charts/OpportunityTreemap.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { Treemap, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
|
||||
export type ReadinessCategory = 'automate_now' | 'assist_copilot' | 'optimize_first';
|
||||
|
||||
export interface TreemapData {
|
||||
name: string;
|
||||
value: number; // Savings potential (determines size)
|
||||
category: ReadinessCategory;
|
||||
skill: string;
|
||||
score: number; // Agentic readiness score 0-10
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
export interface OpportunityTreemapProps {
|
||||
data: TreemapData[];
|
||||
title?: string;
|
||||
height?: number;
|
||||
onItemClick?: (item: TreemapData) => void;
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<ReadinessCategory, string> = {
|
||||
automate_now: '#059669', // emerald-600
|
||||
assist_copilot: '#6D84E3', // primary blue
|
||||
optimize_first: '#D97706' // amber-600
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<ReadinessCategory, string> = {
|
||||
automate_now: 'Automatizar Ahora',
|
||||
assist_copilot: 'Asistir con Copilot',
|
||||
optimize_first: 'Optimizar Primero'
|
||||
};
|
||||
|
||||
interface TreemapContentProps {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
name: string;
|
||||
category: ReadinessCategory;
|
||||
score: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const CustomizedContent = ({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
name,
|
||||
category,
|
||||
score,
|
||||
value
|
||||
}: TreemapContentProps) => {
|
||||
const showLabel = width > 60 && height > 40;
|
||||
const showScore = width > 80 && height > 55;
|
||||
const showValue = width > 100 && height > 70;
|
||||
|
||||
const baseColor = CATEGORY_COLORS[category] || '#94A3B8';
|
||||
|
||||
return (
|
||||
<g>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
fill: baseColor,
|
||||
stroke: '#fff',
|
||||
strokeWidth: 2,
|
||||
opacity: 0.85 + (score / 10) * 0.15 // Higher score = more opaque
|
||||
}}
|
||||
rx={4}
|
||||
/>
|
||||
{showLabel && (
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={y + height / 2 - (showScore ? 8 : 0)}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
style={{
|
||||
fontSize: Math.min(12, width / 8),
|
||||
fontWeight: 600,
|
||||
fill: '#fff',
|
||||
textShadow: '0 1px 2px rgba(0,0,0,0.3)'
|
||||
}}
|
||||
>
|
||||
{name.length > 15 && width < 120 ? `${name.slice(0, 12)}...` : name}
|
||||
</text>
|
||||
)}
|
||||
{showScore && (
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={y + height / 2 + 10}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fill: 'rgba(255,255,255,0.9)'
|
||||
}}
|
||||
>
|
||||
Score: {score.toFixed(1)}
|
||||
</text>
|
||||
)}
|
||||
{showValue && (
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={y + height / 2 + 24}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fill: 'rgba(255,255,255,0.8)'
|
||||
}}
|
||||
>
|
||||
€{(value / 1000).toFixed(0)}K
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
interface TooltipPayload {
|
||||
payload: TreemapData;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-white px-3 py-2 shadow-lg rounded-lg border border-slate-200">
|
||||
<p className="font-semibold text-slate-800">{data.name}</p>
|
||||
<p className="text-xs text-slate-500 mb-2">{data.skill}</p>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-slate-600">Readiness Score:</span>
|
||||
<span className="font-medium">{data.score.toFixed(1)}/10</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-slate-600">Ahorro Potencial:</span>
|
||||
<span className="font-medium text-emerald-600">€{data.value.toLocaleString()}</span>
|
||||
</div>
|
||||
{data.volume && (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-slate-600">Volumen:</span>
|
||||
<span className="font-medium">{data.volume.toLocaleString()}/mes</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-slate-600">Categoría:</span>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: CATEGORY_COLORS[data.category] }}
|
||||
>
|
||||
{CATEGORY_LABELS[data.category]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function OpportunityTreemap({
|
||||
data,
|
||||
title,
|
||||
height = 350,
|
||||
onItemClick
|
||||
}: OpportunityTreemapProps) {
|
||||
// Group data by category for treemap
|
||||
const treemapData = data.map(item => ({
|
||||
...item,
|
||||
size: item.value
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200">
|
||||
{title && (
|
||||
<h3 className="font-semibold text-slate-800 mb-4">{title}</h3>
|
||||
)}
|
||||
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<Treemap
|
||||
data={treemapData}
|
||||
dataKey="size"
|
||||
aspectRatio={4 / 3}
|
||||
stroke="#fff"
|
||||
content={<CustomizedContent x={0} y={0} width={0} height={0} name="" category="automate_now" score={0} value={0} />}
|
||||
onClick={onItemClick ? (node) => onItemClick(node as unknown as TreemapData) : undefined}
|
||||
>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</Treemap>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-center gap-6 mt-4 text-xs">
|
||||
{Object.entries(CATEGORY_COLORS).map(([category, color]) => (
|
||||
<div key={category} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-slate-600">
|
||||
{CATEGORY_LABELS[category as ReadinessCategory]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OpportunityTreemap;
|
||||
197
frontend/components/charts/WaterfallChart.tsx
Normal file
197
frontend/components/charts/WaterfallChart.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
ComposedChart,
|
||||
Bar,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
LabelList
|
||||
} from 'recharts';
|
||||
|
||||
export interface WaterfallDataPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
cumulative: number;
|
||||
type: 'initial' | 'increase' | 'decrease' | 'total';
|
||||
}
|
||||
|
||||
export interface WaterfallChartProps {
|
||||
data: WaterfallDataPoint[];
|
||||
title?: string;
|
||||
height?: number;
|
||||
formatValue?: (value: number) => string;
|
||||
}
|
||||
|
||||
interface ProcessedDataPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
cumulative: number;
|
||||
type: 'initial' | 'increase' | 'decrease' | 'total';
|
||||
start: number;
|
||||
end: number;
|
||||
displayValue: number;
|
||||
}
|
||||
|
||||
export function WaterfallChart({
|
||||
data,
|
||||
title,
|
||||
height = 300,
|
||||
formatValue = (v) => `€${Math.abs(v).toLocaleString()}`
|
||||
}: WaterfallChartProps) {
|
||||
// Process data for waterfall visualization
|
||||
const processedData: ProcessedDataPoint[] = data.map((item) => {
|
||||
let start: number;
|
||||
let end: number;
|
||||
|
||||
if (item.type === 'initial' || item.type === 'total') {
|
||||
start = 0;
|
||||
end = item.cumulative;
|
||||
} else if (item.type === 'decrease') {
|
||||
// Savings: bar goes down from previous cumulative
|
||||
start = item.cumulative;
|
||||
end = item.cumulative - item.value;
|
||||
} else {
|
||||
// Increase: bar goes up from previous cumulative
|
||||
start = item.cumulative - item.value;
|
||||
end = item.cumulative;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
start: Math.min(start, end),
|
||||
end: Math.max(start, end),
|
||||
displayValue: Math.abs(item.value)
|
||||
};
|
||||
});
|
||||
|
||||
const getBarColor = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'initial':
|
||||
return '#64748B'; // slate-500
|
||||
case 'decrease':
|
||||
return '#059669'; // emerald-600 (savings)
|
||||
case 'increase':
|
||||
return '#DC2626'; // red-600 (costs)
|
||||
case 'total':
|
||||
return '#6D84E3'; // primary blue
|
||||
default:
|
||||
return '#94A3B8';
|
||||
}
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: ProcessedDataPoint }> }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-white px-3 py-2 shadow-lg rounded-lg border border-slate-200">
|
||||
<p className="font-medium text-slate-800">{data.label}</p>
|
||||
<p className={`text-sm ${
|
||||
data.type === 'decrease' ? 'text-emerald-600' :
|
||||
data.type === 'increase' ? 'text-red-600' :
|
||||
'text-slate-600'
|
||||
}`}>
|
||||
{data.type === 'decrease' ? '-' : data.type === 'increase' ? '+' : ''}
|
||||
{formatValue(data.value)}
|
||||
</p>
|
||||
{data.type !== 'initial' && data.type !== 'total' && (
|
||||
<p className="text-xs text-slate-500">
|
||||
Acumulado: {formatValue(data.cumulative)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Find min/max for Y axis - always start from 0
|
||||
const allValues = processedData.flatMap(d => [d.start, d.end]);
|
||||
const minValue = 0; // Always start from 0, not negative
|
||||
const maxValue = Math.max(...allValues);
|
||||
const padding = maxValue * 0.1;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200">
|
||||
{title && (
|
||||
<h3 className="font-semibold text-slate-800 mb-4">{title}</h3>
|
||||
)}
|
||||
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<ComposedChart
|
||||
data={processedData}
|
||||
margin={{ top: 20, right: 20, left: 20, bottom: 60 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="#E2E8F0"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11, fill: '#64748B' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[minValue - padding, maxValue + padding]}
|
||||
tick={{ fontSize: 11, fill: '#64748B' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `€${(value / 1000).toFixed(0)}K`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<ReferenceLine y={0} stroke="#94A3B8" strokeWidth={1} />
|
||||
|
||||
{/* Invisible bar for spacing (from 0 to start) */}
|
||||
<Bar dataKey="start" stackId="waterfall" fill="transparent" />
|
||||
|
||||
{/* Visible bar (the actual segment) */}
|
||||
<Bar
|
||||
dataKey="displayValue"
|
||||
stackId="waterfall"
|
||||
radius={[4, 4, 0, 0]}
|
||||
>
|
||||
{processedData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={getBarColor(entry.type)} />
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="displayValue"
|
||||
position="top"
|
||||
formatter={(value: number) => formatValue(value)}
|
||||
style={{ fontSize: 10, fill: '#475569' }}
|
||||
/>
|
||||
</Bar>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-center gap-6 mt-4 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded bg-slate-500" />
|
||||
<span className="text-slate-600">Coste Base</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded bg-emerald-600" />
|
||||
<span className="text-slate-600">Ahorro</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded bg-red-600" />
|
||||
<span className="text-slate-600">Inversión</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded bg-[#6D84E3]" />
|
||||
<span className="text-slate-600">Total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WaterfallChart;
|
||||
3721
frontend/components/tabs/AgenticReadinessTab.tsx
Normal file
3721
frontend/components/tabs/AgenticReadinessTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
654
frontend/components/tabs/DimensionAnalysisTab.tsx
Normal file
654
frontend/components/tabs/DimensionAnalysisTab.tsx
Normal file
@@ -0,0 +1,654 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ChevronRight, TrendingUp, TrendingDown, Minus, AlertTriangle, Lightbulb, DollarSign, Clock } from 'lucide-react';
|
||||
import type { AnalysisData, DimensionAnalysis, Finding, Recommendation, HeatmapDataPoint } from '../../types';
|
||||
import {
|
||||
Card,
|
||||
Badge,
|
||||
} from '../ui';
|
||||
import {
|
||||
cn,
|
||||
COLORS,
|
||||
STATUS_CLASSES,
|
||||
getStatusFromScore,
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
formatPercent,
|
||||
} from '../../config/designSystem';
|
||||
|
||||
interface DimensionAnalysisTabProps {
|
||||
data: AnalysisData;
|
||||
}
|
||||
|
||||
// ========== HALLAZGO CLAVE CON IMPACTO ECONÓMICO ==========
|
||||
|
||||
interface CausalAnalysis {
|
||||
finding: string;
|
||||
probableCause: string;
|
||||
economicImpact: number;
|
||||
recommendation: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
// v3.11: Interfaz extendida para incluir fórmula de cálculo
|
||||
interface CausalAnalysisExtended extends CausalAnalysis {
|
||||
impactFormula?: string; // Explicación de cómo se calculó el impacto
|
||||
hasRealData: boolean; // True si hay datos reales para calcular
|
||||
timeSavings?: string; // Ahorro de tiempo para dar credibilidad al impacto económico
|
||||
}
|
||||
|
||||
// Genera hallazgo clave basado en dimensión y datos
|
||||
function generateCausalAnalysis(
|
||||
dimension: DimensionAnalysis,
|
||||
heatmapData: HeatmapDataPoint[],
|
||||
economicModel: { currentAnnualCost: number },
|
||||
staticConfig?: { cost_per_hour: number },
|
||||
dateRange?: { min: string; max: string }
|
||||
): CausalAnalysisExtended[] {
|
||||
const analyses: CausalAnalysisExtended[] = [];
|
||||
const totalVolume = heatmapData.reduce((sum, h) => sum + h.volume, 0);
|
||||
|
||||
// Coste horario del agente desde config (default €20 si no está definido)
|
||||
const HOURLY_COST = staticConfig?.cost_per_hour ?? 20;
|
||||
|
||||
// Calcular factor de anualización basado en el período de datos
|
||||
// Si tenemos dateRange, calculamos cuántos días cubre y extrapolamos a año
|
||||
let annualizationFactor = 1; // Por defecto, asumimos que los datos ya son anuales
|
||||
if (dateRange?.min && dateRange?.max) {
|
||||
const startDate = new Date(dateRange.min);
|
||||
const endDate = new Date(dateRange.max);
|
||||
const daysCovered = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1);
|
||||
annualizationFactor = 365 / daysCovered;
|
||||
}
|
||||
|
||||
// v3.11: CPI consistente con Executive Summary - benchmark aerolíneas p50
|
||||
const CPI_TCO = 3.50; // Benchmark aerolíneas (p50) para cálculos de impacto
|
||||
// Usar CPI pre-calculado de heatmapData si existe, sino calcular desde annual_cost/cost_volume
|
||||
// IMPORTANTE: Mismo cálculo que ExecutiveSummaryTab para consistencia
|
||||
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
|
||||
const totalAnnualCost = heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0);
|
||||
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
|
||||
const CPI = hasCpiField
|
||||
? (totalCostVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
|
||||
: 0)
|
||||
: (totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : 0);
|
||||
|
||||
// Calcular métricas agregadas
|
||||
const avgCVAHT = totalVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + (h.variability?.cv_aht || 0) * h.volume, 0) / totalVolume
|
||||
: 0;
|
||||
const avgTransferRate = totalVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + h.metrics.transfer_rate * h.volume, 0) / totalVolume
|
||||
: 0;
|
||||
// Usar FCR Técnico (100 - transfer_rate) en lugar de FCR Real (con filtro recontacto 7d)
|
||||
// FCR Técnico es más comparable con benchmarks de industria
|
||||
const avgFCR = totalVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + (h.metrics.fcr_tecnico ?? (100 - h.metrics.transfer_rate)) * h.volume, 0) / totalVolume
|
||||
: 0;
|
||||
const avgAHT = totalVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + h.aht_seconds * h.volume, 0) / totalVolume
|
||||
: 0;
|
||||
const avgCSAT = totalVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + (h.metrics?.csat || 0) * h.volume, 0) / totalVolume
|
||||
: 0;
|
||||
const avgHoldTime = totalVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + (h.metrics?.hold_time || 0) * h.volume, 0) / totalVolume
|
||||
: 0;
|
||||
|
||||
// Skills con problemas específicos
|
||||
const skillsHighCV = heatmapData.filter(h => (h.variability?.cv_aht || 0) > 100);
|
||||
// Usar FCR Técnico para identificar skills con bajo FCR
|
||||
const skillsLowFCR = heatmapData.filter(h => (h.metrics.fcr_tecnico ?? (100 - h.metrics.transfer_rate)) < 50);
|
||||
const skillsHighTransfer = heatmapData.filter(h => h.metrics.transfer_rate > 20);
|
||||
|
||||
// Parsear P50 AHT del KPI del header para consistencia visual
|
||||
// El KPI puede ser "345s (P50)" o similar
|
||||
const parseKpiAhtSeconds = (kpiValue: string): number | null => {
|
||||
const match = kpiValue.match(/(\d+)s/);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
};
|
||||
|
||||
switch (dimension.name) {
|
||||
case 'operational_efficiency':
|
||||
// Obtener P50 AHT del header para mostrar valor consistente
|
||||
const p50Aht = parseKpiAhtSeconds(dimension.kpi.value) ?? avgAHT;
|
||||
|
||||
// Eficiencia Operativa: enfocada en AHT (valor absoluto)
|
||||
// CV AHT se analiza en Complejidad & Predictibilidad (best practice)
|
||||
const hasHighAHT = p50Aht > 300; // 5:00 benchmark
|
||||
const ahtBenchmark = 300; // 5:00 objetivo
|
||||
|
||||
if (hasHighAHT) {
|
||||
// Calcular impacto económico por AHT excesivo
|
||||
const excessSeconds = p50Aht - ahtBenchmark;
|
||||
const annualVolume = Math.round(totalVolume * annualizationFactor);
|
||||
const excessHours = Math.round((excessSeconds / 3600) * annualVolume);
|
||||
const ahtExcessCost = Math.round(excessHours * HOURLY_COST);
|
||||
|
||||
// Estimar ahorro con solución Copilot (25-30% reducción AHT)
|
||||
const copilotSavings = Math.round(ahtExcessCost * 0.28);
|
||||
|
||||
// Causa basada en AHT elevado
|
||||
const cause = 'Agentes dedican tiempo excesivo a búsqueda manual de información, navegación entre sistemas y tareas repetitivas.';
|
||||
|
||||
analyses.push({
|
||||
finding: `AHT elevado: P50 ${Math.floor(p50Aht / 60)}:${String(Math.round(p50Aht) % 60).padStart(2, '0')} (benchmark: 5:00)`,
|
||||
probableCause: cause,
|
||||
economicImpact: ahtExcessCost,
|
||||
impactFormula: `${excessHours.toLocaleString()}h × €${HOURLY_COST}/h`,
|
||||
timeSavings: `${excessHours.toLocaleString()} horas/año en exceso de AHT`,
|
||||
recommendation: `Desplegar Copilot IA para agentes: (1) Auto-búsqueda en KB; (2) Sugerencias contextuales en tiempo real; (3) Scripts guiados para casos frecuentes. Reducción esperada: 20-30% AHT. Ahorro: ${formatCurrency(copilotSavings)}/año.`,
|
||||
severity: p50Aht > 420 ? 'critical' : 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
} else {
|
||||
// AHT dentro de benchmark - mostrar estado positivo
|
||||
analyses.push({
|
||||
finding: `AHT dentro de benchmark: P50 ${Math.floor(p50Aht / 60)}:${String(Math.round(p50Aht) % 60).padStart(2, '0')} (benchmark: 5:00)`,
|
||||
probableCause: 'Tiempos de gestión eficientes. Procesos operativos optimizados.',
|
||||
economicImpact: 0,
|
||||
impactFormula: 'Sin exceso de coste por AHT',
|
||||
timeSavings: 'Operación eficiente',
|
||||
recommendation: 'Mantener nivel actual. Considerar Copilot para mejora continua y reducción adicional de tiempos en casos complejos.',
|
||||
severity: 'info',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'effectiveness_resolution':
|
||||
// Análisis principal: FCR Técnico y tasa de transferencias
|
||||
const annualVolumeEff = Math.round(totalVolume * annualizationFactor);
|
||||
const transferCount = Math.round(annualVolumeEff * (avgTransferRate / 100));
|
||||
|
||||
// Calcular impacto económico de transferencias
|
||||
const transferCostTotal = Math.round(transferCount * CPI_TCO * 0.5);
|
||||
|
||||
// Potencial de mejora con IA
|
||||
const improvementPotential = avgFCR < 90 ? Math.round((90 - avgFCR) / 100 * annualVolumeEff) : 0;
|
||||
const potentialSavingsEff = Math.round(improvementPotential * CPI_TCO * 0.3);
|
||||
|
||||
// Determinar severidad basada en FCR
|
||||
const effSeverity = avgFCR < 70 ? 'critical' : avgFCR < 85 ? 'warning' : 'info';
|
||||
|
||||
// Construir causa basada en datos
|
||||
let effCause = '';
|
||||
if (avgFCR < 70) {
|
||||
effCause = skillsLowFCR.length > 0
|
||||
? `Alta tasa de transferencias (${avgTransferRate.toFixed(0)}%) indica falta de herramientas o autoridad. Crítico en ${skillsLowFCR.slice(0, 2).map(s => s.skill).join(', ')}.`
|
||||
: `Transferencias elevadas (${avgTransferRate.toFixed(0)}%): agentes sin información contextual o sin autoridad para resolver.`;
|
||||
} else if (avgFCR < 85) {
|
||||
effCause = `Transferencias del ${avgTransferRate.toFixed(0)}% indican oportunidad de mejora con asistencia IA para casos complejos.`;
|
||||
} else {
|
||||
effCause = `FCR Técnico en nivel óptimo. Transferencias del ${avgTransferRate.toFixed(0)}% principalmente en casos que requieren escalación legítima.`;
|
||||
}
|
||||
|
||||
// Construir recomendación
|
||||
let effRecommendation = '';
|
||||
if (avgFCR < 70) {
|
||||
effRecommendation = `Desplegar Knowledge Copilot con búsqueda inteligente en KB + Guided Resolution Copilot para casos complejos. Objetivo: FCR >85%. Potencial ahorro: ${formatCurrency(potentialSavingsEff)}/año.`;
|
||||
} else if (avgFCR < 85) {
|
||||
effRecommendation = `Implementar Copilot de asistencia en tiempo real: sugerencias contextuales + conexión con expertos virtuales para reducir transferencias. Objetivo: FCR >90%.`;
|
||||
} else {
|
||||
effRecommendation = `Mantener nivel actual. Considerar IA para análisis de transferencias legítimas y optimización de enrutamiento predictivo.`;
|
||||
}
|
||||
|
||||
analyses.push({
|
||||
finding: `FCR Técnico: ${avgFCR.toFixed(0)}% | Transferencias: ${avgTransferRate.toFixed(0)}% (benchmark: FCR >85%, Transfer <10%)`,
|
||||
probableCause: effCause,
|
||||
economicImpact: transferCostTotal,
|
||||
impactFormula: `${transferCount.toLocaleString()} transferencias/año × €${CPI_TCO}/int × 50% coste adicional`,
|
||||
timeSavings: `${transferCount.toLocaleString()} transferencias/año (${avgTransferRate.toFixed(0)}% del volumen)`,
|
||||
recommendation: effRecommendation,
|
||||
severity: effSeverity,
|
||||
hasRealData: true
|
||||
});
|
||||
break;
|
||||
|
||||
case 'volumetry_distribution':
|
||||
// Análisis de concentración de volumen
|
||||
const topSkill = [...heatmapData].sort((a, b) => b.volume - a.volume)[0];
|
||||
const topSkillPct = topSkill ? (topSkill.volume / totalVolume) * 100 : 0;
|
||||
if (topSkillPct > 40 && topSkill) {
|
||||
const annualTopSkillVolume = Math.round(topSkill.volume * annualizationFactor);
|
||||
const deflectionPotential = Math.round(annualTopSkillVolume * CPI_TCO * 0.20);
|
||||
const interactionsDeflectable = Math.round(annualTopSkillVolume * 0.20);
|
||||
analyses.push({
|
||||
finding: `Concentración de volumen: ${topSkill.skill} representa ${topSkillPct.toFixed(0)}% del total`,
|
||||
probableCause: `Alta concentración en un skill indica consultas repetitivas con potencial de automatización.`,
|
||||
economicImpact: deflectionPotential,
|
||||
impactFormula: `${topSkill.volume.toLocaleString()} int × anualización × €${CPI_TCO} × 20% deflexión potencial`,
|
||||
timeSavings: `${annualTopSkillVolume.toLocaleString()} interacciones/año en ${topSkill.skill} (${interactionsDeflectable.toLocaleString()} automatizables)`,
|
||||
recommendation: `Analizar tipologías de ${topSkill.skill} para deflexión a autoservicio o agente virtual. Potencial: ${formatCurrency(deflectionPotential)}/año.`,
|
||||
severity: 'info',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'complexity_predictability':
|
||||
// KPI principal: CV AHT (predictability metric per industry standards)
|
||||
// Siempre mostrar análisis de CV AHT ya que es el KPI de esta dimensión
|
||||
const cvBenchmark = 75; // Best practice: CV AHT < 75%
|
||||
|
||||
if (avgCVAHT > cvBenchmark) {
|
||||
const staffingCost = Math.round(economicModel.currentAnnualCost * 0.03);
|
||||
const staffingHours = Math.round(staffingCost / HOURLY_COST);
|
||||
const standardizationSavings = Math.round(staffingCost * 0.50);
|
||||
|
||||
// Determinar severidad basada en CV AHT
|
||||
const cvSeverity = avgCVAHT > 125 ? 'critical' : avgCVAHT > 100 ? 'warning' : 'warning';
|
||||
|
||||
// Causa dinámica basada en nivel de variabilidad
|
||||
const cvCause = avgCVAHT > 125
|
||||
? 'Dispersión extrema en tiempos de atención impide planificación efectiva de recursos. Probable falta de scripts o procesos estandarizados.'
|
||||
: 'Variabilidad moderada en tiempos indica oportunidad de estandarización para mejorar planificación WFM.';
|
||||
|
||||
analyses.push({
|
||||
finding: `CV AHT elevado: ${avgCVAHT.toFixed(0)}% (benchmark: <${cvBenchmark}%)`,
|
||||
probableCause: cvCause,
|
||||
economicImpact: staffingCost,
|
||||
impactFormula: `~3% del coste operativo por ineficiencia de staffing`,
|
||||
timeSavings: `~${staffingHours.toLocaleString()} horas/año en sobre/subdimensionamiento`,
|
||||
recommendation: `Implementar scripts guiados por IA que estandaricen la atención. Reducción esperada: -50% variabilidad. Ahorro: ${formatCurrency(standardizationSavings)}/año.`,
|
||||
severity: cvSeverity,
|
||||
hasRealData: true
|
||||
});
|
||||
} else {
|
||||
// CV AHT dentro de benchmark - mostrar estado positivo
|
||||
analyses.push({
|
||||
finding: `CV AHT dentro de benchmark: ${avgCVAHT.toFixed(0)}% (benchmark: <${cvBenchmark}%)`,
|
||||
probableCause: 'Tiempos de atención consistentes. Buena estandarización de procesos.',
|
||||
economicImpact: 0,
|
||||
impactFormula: 'Sin impacto por variabilidad',
|
||||
timeSavings: 'Planificación WFM eficiente',
|
||||
recommendation: 'Mantener nivel actual. Analizar casos atípicos para identificar oportunidades de mejora continua.',
|
||||
severity: 'info',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
|
||||
// Análisis secundario: Hold Time (proxy de complejidad)
|
||||
if (avgHoldTime > 45) {
|
||||
const excessHold = avgHoldTime - 30;
|
||||
const annualVolumeHold = Math.round(totalVolume * annualizationFactor);
|
||||
const excessHoldHours = Math.round((excessHold / 3600) * annualVolumeHold);
|
||||
const holdCost = Math.round(excessHoldHours * HOURLY_COST);
|
||||
const searchCopilotSavings = Math.round(holdCost * 0.60);
|
||||
analyses.push({
|
||||
finding: `Hold time elevado: ${avgHoldTime.toFixed(0)}s promedio (benchmark: <30s)`,
|
||||
probableCause: 'Agentes ponen cliente en espera para buscar información. Sistemas no presentan datos de forma contextual.',
|
||||
economicImpact: holdCost,
|
||||
impactFormula: `Exceso ${Math.round(excessHold)}s × ${totalVolume.toLocaleString()} int × anualización × €${HOURLY_COST}/h`,
|
||||
timeSavings: `${excessHoldHours.toLocaleString()} horas/año de cliente en espera`,
|
||||
recommendation: `Desplegar vista 360° con contexto automático: historial, productos y acciones sugeridas visibles al contestar. Reducción esperada: -60% hold time. Ahorro: ${formatCurrency(searchCopilotSavings)}/año.`,
|
||||
severity: avgHoldTime > 60 ? 'critical' : 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'customer_satisfaction':
|
||||
// Solo generar análisis si hay datos de CSAT reales
|
||||
if (avgCSAT > 0) {
|
||||
if (avgCSAT < 70) {
|
||||
const annualVolumeCsat = Math.round(totalVolume * annualizationFactor);
|
||||
const customersAtRisk = Math.round(annualVolumeCsat * 0.02);
|
||||
const churnRisk = Math.round(customersAtRisk * 50);
|
||||
analyses.push({
|
||||
finding: `CSAT por debajo del objetivo: ${avgCSAT.toFixed(0)}% (benchmark: >80%)`,
|
||||
probableCause: 'Clientes insatisfechos por esperas, falta de resolución o experiencia de atención deficiente.',
|
||||
economicImpact: churnRisk,
|
||||
impactFormula: `${totalVolume.toLocaleString()} clientes × anualización × 2% riesgo churn × €50 valor`,
|
||||
timeSavings: `${customersAtRisk.toLocaleString()} clientes/año en riesgo de fuga`,
|
||||
recommendation: `Implementar programa VoC: encuestas post-contacto + análisis de causas raíz + acción correctiva en 48h. Objetivo: CSAT >80%.`,
|
||||
severity: avgCSAT < 50 ? 'critical' : 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'economy_cpi':
|
||||
case 'economy_costs': // También manejar el ID del backend
|
||||
// Análisis de CPI
|
||||
if (CPI > 3.5) {
|
||||
const excessCPI = CPI - CPI_TCO;
|
||||
const annualVolumeCpi = Math.round(totalVolume * annualizationFactor);
|
||||
const potentialSavings = Math.round(annualVolumeCpi * excessCPI);
|
||||
const excessHours = Math.round(potentialSavings / HOURLY_COST);
|
||||
analyses.push({
|
||||
finding: `CPI por encima del benchmark: €${CPI.toFixed(2)} (objetivo: €${CPI_TCO})`,
|
||||
probableCause: 'Coste por interacción elevado por AHT alto, baja ocupación o estructura de costes ineficiente.',
|
||||
economicImpact: potentialSavings,
|
||||
impactFormula: `${totalVolume.toLocaleString()} int × anualización × €${excessCPI.toFixed(2)} exceso CPI`,
|
||||
timeSavings: `€${excessCPI.toFixed(2)} exceso/int × ${annualVolumeCpi.toLocaleString()} int = ${excessHours.toLocaleString()}h equivalentes`,
|
||||
recommendation: `Optimizar mix de canales + reducir AHT con automatización + revisar modelo de staffing. Objetivo: CPI <€${CPI_TCO}.`,
|
||||
severity: CPI > 5 ? 'critical' : 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// v3.11: NO generar fallback con impacto económico falso
|
||||
// Si no hay análisis específico, simplemente retornar array vacío
|
||||
// La UI mostrará "Sin hallazgos críticos" en lugar de un impacto inventado
|
||||
|
||||
return analyses;
|
||||
}
|
||||
|
||||
// Formateador de moneda (usa la función importada de designSystem)
|
||||
|
||||
// v3.15: Dimension Card Component - con diseño McKinsey
|
||||
function DimensionCard({
|
||||
dimension,
|
||||
findings,
|
||||
recommendations,
|
||||
causalAnalyses,
|
||||
delay = 0
|
||||
}: {
|
||||
dimension: DimensionAnalysis;
|
||||
findings: Finding[];
|
||||
recommendations: Recommendation[];
|
||||
causalAnalyses: CausalAnalysisExtended[];
|
||||
delay?: number;
|
||||
}) {
|
||||
const Icon = dimension.icon;
|
||||
|
||||
const getScoreVariant = (score: number): 'success' | 'warning' | 'critical' | 'default' => {
|
||||
if (score < 0) return 'default'; // N/A
|
||||
if (score >= 70) return 'success';
|
||||
if (score >= 40) return 'warning';
|
||||
return 'critical';
|
||||
};
|
||||
|
||||
const getScoreLabel = (score: number): string => {
|
||||
if (score < 0) return 'N/A';
|
||||
if (score >= 80) return 'Óptimo';
|
||||
if (score >= 60) return 'Aceptable';
|
||||
if (score >= 40) return 'Mejorable';
|
||||
return 'Crítico';
|
||||
};
|
||||
|
||||
const getSeverityConfig = (severity: string) => {
|
||||
if (severity === 'critical') return STATUS_CLASSES.critical;
|
||||
if (severity === 'warning') return STATUS_CLASSES.warning;
|
||||
return STATUS_CLASSES.info;
|
||||
};
|
||||
|
||||
// Get KPI trend icon
|
||||
const TrendIcon = dimension.kpi.changeType === 'positive' ? TrendingUp :
|
||||
dimension.kpi.changeType === 'negative' ? TrendingDown : Minus;
|
||||
|
||||
const trendColor = dimension.kpi.changeType === 'positive' ? 'text-emerald-600' :
|
||||
dimension.kpi.changeType === 'negative' ? 'text-red-600' : 'text-gray-500';
|
||||
|
||||
// Calcular impacto total de esta dimensión
|
||||
const totalImpact = causalAnalyses.reduce((sum, a) => sum + a.economicImpact, 0);
|
||||
const scoreVariant = getScoreVariant(dimension.score);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay }}
|
||||
className="bg-white rounded-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-50">
|
||||
<Icon className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{dimension.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5 max-w-xs">{dimension.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge
|
||||
label={dimension.score >= 0 ? `${dimension.score} ${getScoreLabel(dimension.score)}` : '— N/A'}
|
||||
variant={scoreVariant}
|
||||
size="md"
|
||||
/>
|
||||
{totalImpact > 0 && (
|
||||
<p className="text-xs text-red-600 font-medium mt-1">
|
||||
Impacto: {formatCurrency(totalImpact)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Highlight */}
|
||||
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">{dimension.kpi.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-gray-900">{dimension.kpi.value}</span>
|
||||
{dimension.kpi.change && (
|
||||
<div className={cn('flex items-center gap-1 text-xs', trendColor)}>
|
||||
<TrendIcon className="w-3 h-3" />
|
||||
<span>{dimension.kpi.change}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{dimension.percentile && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Percentil</span>
|
||||
<span>P{dimension.percentile}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-600 rounded-full"
|
||||
style={{ width: `${dimension.percentile}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Si no hay datos para esta dimensión (score < 0 = N/A) */}
|
||||
{dimension.score < 0 && (
|
||||
<div className="p-4">
|
||||
<div className="p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<p className="text-sm text-gray-500 italic flex items-center gap-2">
|
||||
<Minus className="w-4 h-4" />
|
||||
Sin datos disponibles para esta dimensión.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hallazgo Clave - Solo si hay datos */}
|
||||
{dimension.score >= 0 && causalAnalyses.length > 0 && (
|
||||
<div className="p-4 space-y-3">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Hallazgo Clave
|
||||
</h4>
|
||||
{causalAnalyses.map((analysis, idx) => {
|
||||
const config = getSeverityConfig(analysis.severity);
|
||||
return (
|
||||
<div key={idx} className={cn('p-3 rounded-lg border', config.bg, config.border)}>
|
||||
{/* Hallazgo */}
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<AlertTriangle className={cn('w-4 h-4 mt-0.5 flex-shrink-0', config.text)} />
|
||||
<div>
|
||||
<p className={cn('text-sm font-medium', config.text)}>{analysis.finding}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Causa probable */}
|
||||
<div className="ml-6 mb-2">
|
||||
<p className="text-xs text-gray-500 font-medium mb-0.5">Causa probable:</p>
|
||||
<p className="text-xs text-gray-700">{analysis.probableCause}</p>
|
||||
</div>
|
||||
|
||||
{/* Impacto económico */}
|
||||
<div
|
||||
className="ml-6 mb-2 flex items-center gap-2 cursor-help"
|
||||
title={analysis.impactFormula || 'Impacto estimado basado en métricas operativas'}
|
||||
>
|
||||
<DollarSign className="w-3 h-3 text-red-500" />
|
||||
<span className="text-xs font-bold text-red-600">
|
||||
{formatCurrency(analysis.economicImpact)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">impacto anual (coste del problema)</span>
|
||||
<span className="text-xs text-gray-400">i</span>
|
||||
</div>
|
||||
|
||||
{/* Ahorro de tiempo - da credibilidad al cálculo económico */}
|
||||
{analysis.timeSavings && (
|
||||
<div className="ml-6 mb-2 flex items-center gap-2">
|
||||
<Clock className="w-3 h-3 text-blue-500" />
|
||||
<span className="text-xs text-blue-700">{analysis.timeSavings}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recomendación inline */}
|
||||
<div className="ml-6 p-2 bg-white rounded border border-gray-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<Lightbulb className="w-3 h-3 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-gray-600">{analysis.recommendation}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback: Hallazgos originales si no hay hallazgo clave - Solo si hay datos */}
|
||||
{dimension.score >= 0 && causalAnalyses.length === 0 && findings.length > 0 && (
|
||||
<div className="p-4">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
||||
Hallazgos Clave
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{findings.slice(0, 3).map((finding, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2 text-sm">
|
||||
<ChevronRight className={cn('w-4 h-4 mt-0.5 flex-shrink-0',
|
||||
finding.type === 'critical' ? 'text-red-500' :
|
||||
finding.type === 'warning' ? 'text-amber-500' :
|
||||
'text-blue-600'
|
||||
)} />
|
||||
<span className="text-gray-700">{finding.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Si no hay análisis ni hallazgos pero sí hay datos */}
|
||||
{dimension.score >= 0 && causalAnalyses.length === 0 && findings.length === 0 && (
|
||||
<div className="p-4">
|
||||
<div className={cn('p-3 rounded-lg border', STATUS_CLASSES.success.bg, STATUS_CLASSES.success.border)}>
|
||||
<p className={cn('text-sm flex items-center gap-2', STATUS_CLASSES.success.text)}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
Métricas dentro de rangos aceptables. Sin hallazgos críticos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations Preview - Solo si no hay hallazgo clave y hay datos */}
|
||||
{dimension.score >= 0 && causalAnalyses.length === 0 && recommendations.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="p-3 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-xs font-semibold text-blue-600">Recomendación:</span>
|
||||
<span className="text-xs text-gray-600">{recommendations[0].text}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== v3.16: COMPONENTE PRINCIPAL ==========
|
||||
|
||||
export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
|
||||
// DEBUG: Verificar CPI en dimensión vs heatmapData
|
||||
const economyDim = data.dimensions.find(d =>
|
||||
d.id === 'economy_costs' || d.name === 'economy_costs' ||
|
||||
d.id === 'economy_cpi' || d.name === 'economy_cpi'
|
||||
);
|
||||
const heatmapData = data.heatmapData;
|
||||
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
|
||||
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
|
||||
const calculatedCPI = hasCpiField
|
||||
? (totalCostVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
|
||||
: 0)
|
||||
: (totalCostVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0) / totalCostVolume
|
||||
: 0);
|
||||
|
||||
console.log('🔍 DimensionAnalysisTab DEBUG:');
|
||||
console.log(' - economyDim found:', !!economyDim, economyDim?.id || economyDim?.name);
|
||||
console.log(' - economyDim.kpi.value:', economyDim?.kpi?.value);
|
||||
console.log(' - calculatedCPI from heatmapData:', `€${calculatedCPI.toFixed(2)}`);
|
||||
console.log(' - hasCpiField:', hasCpiField);
|
||||
console.log(' - MATCH:', economyDim?.kpi?.value === `€${calculatedCPI.toFixed(2)}`);
|
||||
|
||||
// Filter out agentic_readiness (has its own tab)
|
||||
const coreDimensions = data.dimensions.filter(d => d.name !== 'agentic_readiness');
|
||||
|
||||
// Group findings and recommendations by dimension
|
||||
const getFindingsForDimension = (dimensionId: string) =>
|
||||
data.findings.filter(f => f.dimensionId === dimensionId);
|
||||
|
||||
const getRecommendationsForDimension = (dimensionId: string) =>
|
||||
data.recommendations.filter(r => r.dimensionId === dimensionId);
|
||||
|
||||
// Generar hallazgo clave para cada dimensión
|
||||
const getCausalAnalysisForDimension = (dimension: DimensionAnalysis) =>
|
||||
generateCausalAnalysis(dimension, data.heatmapData, data.economicModel, data.staticConfig, data.dateRange);
|
||||
|
||||
// Calcular impacto total de todas las dimensiones con datos
|
||||
const impactoTotal = coreDimensions
|
||||
.filter(d => d.score !== null && d.score !== undefined)
|
||||
.reduce((total, dimension) => {
|
||||
const analyses = getCausalAnalysisForDimension(dimension);
|
||||
return total + analyses.reduce((sum, a) => sum + a.economicImpact, 0);
|
||||
}, 0);
|
||||
|
||||
// v3.16: Contar dimensiones por estado para el header
|
||||
const conDatos = coreDimensions.filter(d => d.score !== null && d.score !== undefined && d.score >= 0);
|
||||
const sinDatos = coreDimensions.filter(d => d.score === null || d.score === undefined || d.score < 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* v3.16: Header simplificado - solo título y subtítulo */}
|
||||
<div className="mb-2">
|
||||
<h2 className="text-lg font-bold text-gray-900">Diagnóstico por Dimensión</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{coreDimensions.length} dimensiones analizadas
|
||||
{sinDatos.length > 0 && ` (${sinDatos.length} sin datos)`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* v3.16: Grid simple con todas las dimensiones sin agrupación */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{coreDimensions.map((dimension, idx) => (
|
||||
<DimensionCard
|
||||
key={dimension.id}
|
||||
dimension={dimension}
|
||||
findings={getFindingsForDimension(dimension.id)}
|
||||
recommendations={getRecommendationsForDimension(dimension.id)}
|
||||
causalAnalyses={getCausalAnalysisForDimension(dimension)}
|
||||
delay={idx * 0.05}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DimensionAnalysisTab;
|
||||
1277
frontend/components/tabs/ExecutiveSummaryTab.tsx
Normal file
1277
frontend/components/tabs/ExecutiveSummaryTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1533
frontend/components/tabs/Law10Tab.tsx
Normal file
1533
frontend/components/tabs/Law10Tab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
2719
frontend/components/tabs/RoadmapTab.tsx
Normal file
2719
frontend/components/tabs/RoadmapTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
595
frontend/components/ui/index.tsx
Normal file
595
frontend/components/ui/index.tsx
Normal file
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* v3.15: Componentes UI McKinsey
|
||||
*
|
||||
* Componentes base reutilizables que implementan el sistema de diseño.
|
||||
* Usar estos componentes en lugar de crear estilos ad-hoc.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
cn,
|
||||
CARD_BASE,
|
||||
SECTION_HEADER,
|
||||
BADGE_BASE,
|
||||
BADGE_SIZES,
|
||||
METRIC_BASE,
|
||||
STATUS_CLASSES,
|
||||
TIER_CLASSES,
|
||||
SPACING,
|
||||
} from '../../config/designSystem';
|
||||
|
||||
// ============================================
|
||||
// CARD
|
||||
// ============================================
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'highlight' | 'muted';
|
||||
padding?: 'sm' | 'md' | 'lg' | 'none';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
variant = 'default',
|
||||
padding = 'md',
|
||||
className,
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
CARD_BASE,
|
||||
variant === 'highlight' && 'bg-gray-50 border-gray-300',
|
||||
variant === 'muted' && 'bg-gray-50 border-gray-100',
|
||||
padding !== 'none' && SPACING.card[padding],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Card con indicador de status (borde superior)
|
||||
interface StatusCardProps extends CardProps {
|
||||
status: 'critical' | 'warning' | 'success' | 'info' | 'neutral';
|
||||
}
|
||||
|
||||
export function StatusCard({
|
||||
status,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: StatusCardProps) {
|
||||
const statusClasses = STATUS_CLASSES[status];
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'border-t-2',
|
||||
statusClasses.borderTop,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SECTION HEADER
|
||||
// ============================================
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
badge?: BadgeProps;
|
||||
action?: React.ReactNode;
|
||||
level?: 2 | 3 | 4;
|
||||
className?: string;
|
||||
noBorder?: boolean;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
action,
|
||||
level = 2,
|
||||
className,
|
||||
noBorder = false,
|
||||
}: SectionHeaderProps) {
|
||||
const Tag = `h${level}` as keyof JSX.IntrinsicElements;
|
||||
const titleClass = level === 2
|
||||
? SECTION_HEADER.title.h2
|
||||
: level === 3
|
||||
? SECTION_HEADER.title.h3
|
||||
: SECTION_HEADER.title.h4;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
SECTION_HEADER.wrapper,
|
||||
noBorder && 'border-b-0 pb-0 mb-2',
|
||||
className
|
||||
)}>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Tag className={titleClass}>{title}</Tag>
|
||||
{badge && <Badge {...badge} />}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className={SECTION_HEADER.subtitle}>{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="flex-shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BADGE
|
||||
// ============================================
|
||||
|
||||
interface BadgeProps {
|
||||
label: string | number;
|
||||
variant?: 'default' | 'success' | 'warning' | 'critical' | 'info';
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
label,
|
||||
variant = 'default',
|
||||
size = 'sm',
|
||||
className,
|
||||
}: BadgeProps) {
|
||||
const variantClasses = {
|
||||
default: 'bg-gray-100 text-gray-700',
|
||||
success: 'bg-emerald-50 text-emerald-700',
|
||||
warning: 'bg-amber-50 text-amber-700',
|
||||
critical: 'bg-red-50 text-red-700',
|
||||
info: 'bg-blue-50 text-blue-700',
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
BADGE_BASE,
|
||||
BADGE_SIZES[size],
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Badge para Tiers
|
||||
interface TierBadgeProps {
|
||||
tier: 'AUTOMATE' | 'ASSIST' | 'AUGMENT' | 'HUMAN-ONLY';
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TierBadge({ tier, size = 'sm', className }: TierBadgeProps) {
|
||||
const tierClasses = TIER_CLASSES[tier];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
BADGE_BASE,
|
||||
BADGE_SIZES[size],
|
||||
tierClasses.bg,
|
||||
tierClasses.text,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{tier}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// METRIC
|
||||
// ============================================
|
||||
|
||||
interface MetricProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
unit?: string;
|
||||
status?: 'success' | 'warning' | 'critical';
|
||||
comparison?: string;
|
||||
trend?: 'up' | 'down' | 'neutral';
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Metric({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
status,
|
||||
comparison,
|
||||
trend,
|
||||
size = 'md',
|
||||
className,
|
||||
}: MetricProps) {
|
||||
const valueColorClass = !status
|
||||
? 'text-gray-900'
|
||||
: status === 'success'
|
||||
? 'text-emerald-600'
|
||||
: status === 'warning'
|
||||
? 'text-amber-600'
|
||||
: 'text-red-600';
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
<span className={METRIC_BASE.label}>{label}</span>
|
||||
<div className="flex items-baseline gap-1 mt-1">
|
||||
<span className={cn(METRIC_BASE.value[size], valueColorClass)}>
|
||||
{value}
|
||||
</span>
|
||||
{unit && <span className={METRIC_BASE.unit}>{unit}</span>}
|
||||
{trend && <TrendIndicator direction={trend} />}
|
||||
</div>
|
||||
{comparison && (
|
||||
<span className={METRIC_BASE.comparison}>{comparison}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Indicador de tendencia
|
||||
function TrendIndicator({ direction }: { direction: 'up' | 'down' | 'neutral' }) {
|
||||
if (direction === 'up') {
|
||||
return <TrendingUp className="w-4 h-4 text-emerald-500" />;
|
||||
}
|
||||
if (direction === 'down') {
|
||||
return <TrendingDown className="w-4 h-4 text-red-500" />;
|
||||
}
|
||||
return <Minus className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// KPI CARD (Metric in a card)
|
||||
// ============================================
|
||||
|
||||
interface KPICardProps extends MetricProps {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function KPICard({ icon, ...metricProps }: KPICardProps) {
|
||||
return (
|
||||
<Card padding="md" className="flex items-start gap-3">
|
||||
{icon && (
|
||||
<div className="p-2 bg-gray-100 rounded-lg flex-shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<Metric {...metricProps} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// STAT (inline stat for summaries)
|
||||
// ============================================
|
||||
|
||||
interface StatProps {
|
||||
value: string | number;
|
||||
label: string;
|
||||
status?: 'success' | 'warning' | 'critical';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Stat({ value, label, status, className }: StatProps) {
|
||||
const statusClasses = STATUS_CLASSES[status || 'neutral'];
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'p-3 rounded-lg border',
|
||||
status ? statusClasses.bg : 'bg-gray-50',
|
||||
status ? statusClasses.border : 'border-gray-200',
|
||||
className
|
||||
)}>
|
||||
<p className={cn(
|
||||
'text-2xl font-bold',
|
||||
status ? statusClasses.text : 'text-gray-700'
|
||||
)}>
|
||||
{value}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 font-medium">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DIVIDER
|
||||
// ============================================
|
||||
|
||||
export function Divider({ className }: { className?: string }) {
|
||||
return <hr className={cn('border-gray-200 my-4', className)} />;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// COLLAPSIBLE SECTION
|
||||
// ============================================
|
||||
|
||||
interface CollapsibleProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
badge?: BadgeProps;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Collapsible({
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
defaultOpen = false,
|
||||
children,
|
||||
className,
|
||||
}: CollapsibleProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className={cn('border border-gray-200 rounded-lg overflow-hidden', className)}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-gray-800">{title}</span>
|
||||
{badge && <Badge {...badge} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-400">
|
||||
{subtitle && <span className="text-xs">{subtitle}</span>}
|
||||
{isOpen ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="p-4 border-t border-gray-200 bg-white">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DISTRIBUTION BAR
|
||||
// ============================================
|
||||
|
||||
interface DistributionBarProps {
|
||||
segments: Array<{
|
||||
value: number;
|
||||
color: string;
|
||||
label?: string;
|
||||
}>;
|
||||
total?: number;
|
||||
height?: 'sm' | 'md' | 'lg';
|
||||
showLabels?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DistributionBar({
|
||||
segments,
|
||||
total,
|
||||
height = 'md',
|
||||
showLabels = false,
|
||||
className,
|
||||
}: DistributionBarProps) {
|
||||
const computedTotal = total || segments.reduce((sum, s) => sum + s.value, 0);
|
||||
const heightClass = height === 'sm' ? 'h-2' : height === 'md' ? 'h-3' : 'h-4';
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<div className={cn('flex rounded-full overflow-hidden bg-gray-100', heightClass)}>
|
||||
{segments.map((segment, idx) => {
|
||||
const pct = computedTotal > 0 ? (segment.value / computedTotal) * 100 : 0;
|
||||
if (pct <= 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn('flex items-center justify-center transition-all', segment.color)}
|
||||
style={{ width: `${pct}%` }}
|
||||
title={segment.label || `${pct.toFixed(0)}%`}
|
||||
>
|
||||
{showLabels && pct >= 10 && (
|
||||
<span className="text-[9px] text-white font-bold">
|
||||
{pct.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TABLE COMPONENTS
|
||||
// ============================================
|
||||
|
||||
export function Table({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className={cn('w-full text-sm text-left', className)}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Thead({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<thead className="text-xs text-gray-500 uppercase tracking-wide bg-gray-50">
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export function Th({
|
||||
children,
|
||||
align = 'left',
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align?: 'left' | 'right' | 'center';
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
'px-4 py-3 font-medium',
|
||||
align === 'right' && 'text-right',
|
||||
align === 'center' && 'text-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tbody({ children }: { children: React.ReactNode }) {
|
||||
return <tbody className="divide-y divide-gray-100">{children}</tbody>;
|
||||
}
|
||||
|
||||
export function Tr({
|
||||
children,
|
||||
highlighted,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
highlighted?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
'hover:bg-gray-50 transition-colors',
|
||||
highlighted && 'bg-blue-50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function Td({
|
||||
children,
|
||||
align = 'left',
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align?: 'left' | 'right' | 'center';
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
className={cn(
|
||||
'px-4 py-3 text-gray-700',
|
||||
align === 'right' && 'text-right',
|
||||
align === 'center' && 'text-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EMPTY STATE
|
||||
// ============================================
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
{icon && <div className="text-gray-300 mb-4">{icon}</div>}
|
||||
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500 mt-1 max-w-sm">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BUTTON
|
||||
// ============================================
|
||||
|
||||
interface ButtonProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
size?: 'sm' | 'md';
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
}: ButtonProps) {
|
||||
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-300',
|
||||
secondary: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 disabled:bg-gray-100',
|
||||
ghost: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(baseClasses, variantClasses[variant], sizeClasses[size], className)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user