Files
BeyondCXAnalytics-Demo/frontend/components/OpportunityMatrixPro.tsx
2026-02-04 11:08:21 +01:00

466 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useMemo } 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;