Initial commit - ACME demo version

This commit is contained in:
sujucu70
2026-02-04 11:08:21 +01:00
commit 1bb0765766
180 changed files with 52249 additions and 0 deletions

View 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 &gt;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 &lt;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>&lt;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;