feat: Rediseño dashboard con 4 pestañas estilo McKinsey

- Nueva estructura de tabs: Resumen, Dimensiones, Agentic Readiness, Roadmap
- Componentes de visualización McKinsey:
  - BulletChart: actual vs benchmark con rangos de color
  - WaterfallChart: impacto económico con costes y ahorros
  - OpportunityTreemap: priorización por volumen y readiness
- 5 dimensiones actualizadas (sin satisfaction ni economy)
- Header sticky con navegación animada
- Integración completa con datos existentes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Susana
2026-01-12 08:41:20 +00:00
parent fdfb520710
commit 7e24f4eb31
17 changed files with 2282 additions and 389 deletions

View File

@@ -7,8 +7,8 @@ services:
container_name: beyond-backend container_name: beyond-backend
environment: environment:
# credenciales del API (las mismas que usas ahora) # credenciales del API (las mismas que usas ahora)
BASIC_AUTH_USERNAME: admin BASIC_AUTH_USERNAME: "beyond"
BASIC_AUTH_PASSWORD: admin BASIC_AUTH_PASSWORD: "beyond2026"
expose: expose:
- "8000" - "8000"
networks: networks:
@@ -34,7 +34,9 @@ services:
- frontend - frontend
ports: ports:
- "80:80" - "80:80"
- "443:443"
volumes: volumes:
- /etc/letsencrypt:/etc/letsencrypt:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro
networks: networks:
- beyond-net - beyond-net

View File

@@ -0,0 +1,90 @@
import { motion } from 'framer-motion';
import { LayoutDashboard, Layers, Bot, Map } from 'lucide-react';
export type TabId = 'executive' | 'dimensions' | 'readiness' | 'roadmap';
export interface TabConfig {
id: TabId;
label: string;
icon: React.ElementType;
}
interface DashboardHeaderProps {
title?: string;
activeTab: TabId;
onTabChange: (id: TabId) => 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 },
];
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()}`;
};
export function DashboardHeader({
title = 'AIR EUROPA - Beyond CX Analytics',
activeTab,
onTabChange
}: DashboardHeaderProps) {
return (
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
{/* Top row: Title and Date */}
<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">{title}</h1>
<span className="text-sm text-slate-500">{formatDate()}</span>
</div>
</div>
{/* Tab Navigation */}
<nav className="max-w-7xl mx-auto px-6">
<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;

View File

@@ -0,0 +1,94 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeft } from 'lucide-react';
import { DashboardHeader, TabId } from './DashboardHeader';
import { ExecutiveSummaryTab } from './tabs/ExecutiveSummaryTab';
import { DimensionAnalysisTab } from './tabs/DimensionAnalysisTab';
import { AgenticReadinessTab } from './tabs/AgenticReadinessTab';
import { RoadmapTab } from './tabs/RoadmapTab';
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 renderTabContent = () => {
switch (activeTab) {
case 'executive':
return <ExecutiveSummaryTab data={data} />;
case 'dimensions':
return <DimensionAnalysisTab data={data} />;
case 'readiness':
return <AgenticReadinessTab data={data} />;
case 'roadmap':
return <RoadmapTab 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-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" />
Volver al formulario
</button>
</div>
</div>
)}
{/* Sticky Header with Tabs */}
<DashboardHeader
title={title}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* Tab Content */}
<main className="max-w-7xl mx-auto px-6 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-6 py-4">
<div className="flex items-center justify-between text-sm text-slate-500">
<span>Beyond Diagnosis - Contact Center Analytics Platform</span>
<span>
Análisis: {data.tier ? data.tier.toUpperCase() : 'GOLD'} |
Fuente: {data.source || 'synthetic'}
</span>
</div>
</div>
</footer>
</div>
);
}
export default DashboardTabs;

View File

@@ -7,7 +7,7 @@ import { Toaster } from 'react-hot-toast';
import { TierKey, AnalysisData } from '../types'; import { TierKey, AnalysisData } from '../types';
import TierSelectorEnhanced from './TierSelectorEnhanced'; import TierSelectorEnhanced from './TierSelectorEnhanced';
import DataInputRedesigned from './DataInputRedesigned'; import DataInputRedesigned from './DataInputRedesigned';
import DashboardReorganized from './DashboardReorganized'; import DashboardTabs from './DashboardTabs';
import { generateAnalysis } from '../utils/analysisGenerator'; import { generateAnalysis } from '../utils/analysisGenerator';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useAuth } from '../utils/AuthContext'; import { useAuth } from '../utils/AuthContext';
@@ -111,7 +111,7 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
console.log('📊 Dimensions length:', analysisData.dimensions?.length); console.log('📊 Dimensions length:', analysisData.dimensions?.length);
try { try {
return <DashboardReorganized analysisData={analysisData} onBack={handleBackToForm} />; return <DashboardTabs data={analysisData} onBack={handleBackToForm} />;
} catch (error) { } catch (error) {
console.error('❌ Error rendering dashboard:', error); console.error('❌ Error rendering dashboard:', error);
return ( return (

View 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;

View 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;

View 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
const allValues = processedData.flatMap(d => [d.start, d.end]);
const minValue = Math.min(0, ...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 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;

View File

@@ -0,0 +1,278 @@
import { motion } from 'framer-motion';
import { Bot, Zap, Brain, Activity, ChevronRight } from 'lucide-react';
import { OpportunityTreemap, TreemapData, ReadinessCategory } from '../charts/OpportunityTreemap';
import type { AnalysisData, HeatmapDataPoint, SubFactor } from '../../types';
interface AgenticReadinessTabProps {
data: AnalysisData;
}
// Global Score Gauge
function GlobalScoreGauge({ score, confidence }: { score: number; confidence?: string }) {
const getColor = (s: number) => {
if (s >= 7) return '#059669'; // emerald-600 - Ready to automate
if (s >= 5) return '#6D84E3'; // primary blue - Assist with copilot
if (s >= 3) return '#D97706'; // amber-600 - Optimize first
return '#DC2626'; // red-600 - Not ready
};
const getLabel = (s: number) => {
if (s >= 7) return 'Listo para Automatizar';
if (s >= 5) return 'Asistir con Copilot';
if (s >= 3) return 'Optimizar Primero';
return 'No Apto';
};
const color = getColor(score);
const percentage = (score / 10) * 100;
return (
<div className="bg-white rounded-lg p-6 border border-slate-200">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-slate-800 flex items-center gap-2">
<Bot className="w-5 h-5 text-[#6D84E3]" />
Agentic Readiness Score
</h3>
{confidence && (
<span className={`text-xs px-2 py-1 rounded-full ${
confidence === 'high' ? 'bg-emerald-100 text-emerald-700' :
confidence === 'medium' ? 'bg-amber-100 text-amber-700' :
'bg-slate-100 text-slate-600'
}`}>
Confianza: {confidence === 'high' ? 'Alta' : confidence === 'medium' ? 'Media' : 'Baja'}
</span>
)}
</div>
{/* Score Display */}
<div className="flex items-center gap-6">
<div className="relative w-28 h-28">
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r="42"
fill="none"
stroke="#E2E8F0"
strokeWidth="12"
/>
<circle
cx="50"
cy="50"
r="42"
fill="none"
stroke={color}
strokeWidth="12"
strokeLinecap="round"
strokeDasharray={`${percentage * 2.64} ${100 * 2.64}`}
className="transition-all duration-1000 ease-out"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-bold" style={{ color }}>{score.toFixed(1)}</span>
<span className="text-xs text-slate-500">/10</span>
</div>
</div>
<div className="flex-1">
<p className="font-medium text-lg" style={{ color }}>{getLabel(score)}</p>
<p className="text-sm text-slate-500 mt-1">
{score >= 7
? 'Skills con alta predictibilidad y bajo riesgo de error'
: score >= 5
? 'Skills aptos para asistencia AI con supervisión humana'
: 'Requiere optimización de procesos antes de automatizar'}
</p>
</div>
</div>
</div>
);
}
// Sub-factors Breakdown
function SubFactorsBreakdown({ subFactors }: { subFactors: SubFactor[] }) {
const getIcon = (name: string) => {
if (name.includes('repetitiv')) return Activity;
if (name.includes('predict')) return Brain;
if (name.includes('estructura') || name.includes('complex')) return Zap;
return Bot;
};
return (
<div className="bg-white rounded-lg p-4 border border-slate-200">
<h3 className="font-semibold text-slate-800 mb-4">Desglose de Factores</h3>
<div className="space-y-3">
{subFactors.map((factor) => {
const Icon = getIcon(factor.name);
const percentage = (factor.score / 10) * 100;
const weightPct = Math.round(factor.weight * 100);
return (
<div key={factor.name} className="p-3 bg-slate-50 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-[#6D84E3]" />
<span className="text-sm font-medium text-slate-700">{factor.displayName}</span>
<span className="text-xs text-slate-400">({weightPct}%)</span>
</div>
<span className="font-bold text-slate-800">{factor.score.toFixed(1)}</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-[#6D84E3] rounded-full transition-all duration-500"
style={{ width: `${percentage}%` }}
/>
</div>
<p className="text-xs text-slate-500 mt-1">{factor.description}</p>
</div>
);
})}
</div>
</div>
);
}
// Skills Heatmap/Table
function SkillsReadinessTable({ heatmapData }: { heatmapData: HeatmapDataPoint[] }) {
// Sort by automation_readiness descending
const sortedData = [...heatmapData].sort((a, b) => b.automation_readiness - a.automation_readiness);
const getReadinessColor = (score: number) => {
if (score >= 70) return 'bg-emerald-100 text-emerald-700';
if (score >= 50) return 'bg-[#6D84E3]/20 text-[#6D84E3]';
if (score >= 30) return 'bg-amber-100 text-amber-700';
return 'bg-red-100 text-red-700';
};
const getCategoryLabel = (category?: string) => {
switch (category) {
case 'automate_now': return 'Automatizar';
case 'assist_copilot': return 'Copilot';
case 'optimize_first': return 'Optimizar';
default: return 'Evaluar';
}
};
const getRecommendation = (dataPoint: HeatmapDataPoint): string => {
const score = dataPoint.automation_readiness;
if (score >= 70) return 'Implementar agente autónomo con supervisión mínima';
if (score >= 50) return 'Desplegar copilot con escalado humano';
if (score >= 30) return 'Reducir variabilidad antes de automatizar';
return 'Optimizar procesos y reducir transferencias';
};
return (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="px-4 py-3 border-b border-slate-100 bg-slate-50">
<h3 className="font-semibold text-slate-800">Análisis por Skill</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-xs text-slate-500 uppercase tracking-wider bg-slate-50">
<th className="px-4 py-2 text-left font-medium">Skill</th>
<th className="px-4 py-2 text-right font-medium">Volumen</th>
<th className="px-4 py-2 text-right font-medium">AHT</th>
<th className="px-4 py-2 text-right font-medium">CV AHT</th>
<th className="px-4 py-2 text-right font-medium">Transfer</th>
<th className="px-4 py-2 text-center font-medium">Score</th>
<th className="px-4 py-2 text-left font-medium">Categoría</th>
<th className="px-4 py-2 text-left font-medium">Siguiente Paso</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{sortedData.map((item) => (
<motion.tr
key={item.skill}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="hover:bg-slate-50"
>
<td className="px-4 py-3 text-sm font-medium text-slate-800">{item.skill}</td>
<td className="px-4 py-3 text-sm text-slate-600 text-right">
{item.volume.toLocaleString()}
</td>
<td className="px-4 py-3 text-sm text-slate-600 text-right">
{item.aht_seconds}s
</td>
<td className="px-4 py-3 text-sm text-right">
<span className={item.variability.cv_aht > 50 ? 'text-amber-600' : 'text-slate-600'}>
{item.variability.cv_aht.toFixed(0)}%
</span>
</td>
<td className="px-4 py-3 text-sm text-right">
<span className={item.variability.transfer_rate > 20 ? 'text-red-600' : 'text-slate-600'}>
{item.variability.transfer_rate.toFixed(0)}%
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 rounded-full text-xs font-semibold ${getReadinessColor(item.automation_readiness)}`}>
{(item.automation_readiness / 10).toFixed(1)}
</span>
</td>
<td className="px-4 py-3 text-sm">
<span className={`px-2 py-1 rounded text-xs font-medium ${
item.readiness_category === 'automate_now' ? 'bg-emerald-100 text-emerald-700' :
item.readiness_category === 'assist_copilot' ? 'bg-[#6D84E3]/20 text-[#6D84E3]' :
'bg-amber-100 text-amber-700'
}`}>
{getCategoryLabel(item.readiness_category)}
</span>
</td>
<td className="px-4 py-3 text-xs text-slate-600 max-w-xs">
<div className="flex items-start gap-1">
<ChevronRight className="w-3 h-3 mt-0.5 text-[#6D84E3] flex-shrink-0" />
{getRecommendation(item)}
</div>
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export function AgenticReadinessTab({ data }: AgenticReadinessTabProps) {
// Get agentic readiness dimension or use fallback
const agenticDimension = data.dimensions.find(d => d.name === 'agentic_readiness');
const globalScore = data.agenticReadiness?.score || agenticDimension?.score || 0;
const subFactors = data.agenticReadiness?.sub_factors || agenticDimension?.sub_factors || [];
const confidence = data.agenticReadiness?.confidence;
// Convert heatmap data to treemap format
const treemapData: TreemapData[] = data.heatmapData.map(item => ({
name: item.skill,
value: item.annual_cost || item.volume * item.aht_seconds * 0.005, // Use annual cost or estimate
category: item.readiness_category || (
item.automation_readiness >= 70 ? 'automate_now' :
item.automation_readiness >= 50 ? 'assist_copilot' : 'optimize_first'
) as ReadinessCategory,
skill: item.skill,
score: item.automation_readiness / 10,
volume: item.volume
}));
return (
<div className="space-y-6">
{/* Top Row: Score Gauge + Sub-factors */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<GlobalScoreGauge score={globalScore / 10} confidence={confidence} />
<SubFactorsBreakdown subFactors={subFactors} />
</div>
{/* Treemap */}
<OpportunityTreemap
data={treemapData}
title="Mapa de Oportunidades por Volumen y Readiness"
height={300}
/>
{/* Skills Table */}
<SkillsReadinessTable heatmapData={data.heatmapData} />
</div>
);
}
export default AgenticReadinessTab;

View File

@@ -0,0 +1,213 @@
import { motion } from 'framer-motion';
import { ChevronRight, TrendingUp, TrendingDown, Minus } from 'lucide-react';
import type { AnalysisData, DimensionAnalysis, Finding, Recommendation } from '../../types';
interface DimensionAnalysisTabProps {
data: AnalysisData;
}
// Dimension Card Component
function DimensionCard({
dimension,
findings,
recommendations,
delay = 0
}: {
dimension: DimensionAnalysis;
findings: Finding[];
recommendations: Recommendation[];
delay?: number;
}) {
const Icon = dimension.icon;
const getScoreColor = (score: number) => {
if (score >= 80) return 'text-emerald-600 bg-emerald-100';
if (score >= 60) return 'text-amber-600 bg-amber-100';
return 'text-red-600 bg-red-100';
};
const getScoreLabel = (score: number) => {
if (score >= 80) return 'Óptimo';
if (score >= 60) return 'Aceptable';
if (score >= 40) return 'Mejorable';
return 'Crítico';
};
// 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-slate-500';
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-slate-200 overflow-hidden"
>
{/* Header */}
<div className="p-4 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[#6D84E3]/10">
<Icon className="w-5 h-5 text-[#6D84E3]" />
</div>
<div>
<h3 className="font-semibold text-slate-800">{dimension.title}</h3>
<p className="text-xs text-slate-500 mt-0.5 max-w-xs">{dimension.summary}</p>
</div>
</div>
<div className={`px-3 py-1.5 rounded-full text-sm font-semibold ${getScoreColor(dimension.score)}`}>
{dimension.score}
<span className="text-xs font-normal ml-1">{getScoreLabel(dimension.score)}</span>
</div>
</div>
</div>
{/* KPI Highlight */}
<div className="px-4 py-3 bg-slate-50/50 border-b border-slate-100">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">{dimension.kpi.label}</span>
<div className="flex items-center gap-2">
<span className="font-bold text-slate-800">{dimension.kpi.value}</span>
{dimension.kpi.change && (
<div className={`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-slate-500 mb-1">
<span>Percentil</span>
<span>P{dimension.percentile}</span>
</div>
<div className="h-1.5 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-[#6D84E3] rounded-full"
style={{ width: `${dimension.percentile}%` }}
/>
</div>
</div>
)}
</div>
{/* Findings */}
<div className="p-4">
<h4 className="text-xs font-semibold text-slate-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={`w-4 h-4 mt-0.5 flex-shrink-0 ${
finding.type === 'critical' ? 'text-red-500' :
finding.type === 'warning' ? 'text-amber-500' :
'text-[#6D84E3]'
}`} />
<span className="text-slate-700">{finding.text}</span>
</li>
))}
{findings.length === 0 && (
<li className="text-sm text-slate-400 italic">Sin hallazgos destacados</li>
)}
</ul>
</div>
{/* Recommendations Preview */}
{recommendations.length > 0 && (
<div className="px-4 pb-4">
<div className="p-3 bg-[#6D84E3]/5 rounded-lg border border-[#6D84E3]/20">
<div className="flex items-start gap-2">
<span className="text-xs font-semibold text-[#6D84E3]">Recomendación:</span>
<span className="text-xs text-slate-600">{recommendations[0].text}</span>
</div>
</div>
</div>
)}
</motion.div>
);
}
// Benchmark Comparison Table
function BenchmarkTable({ benchmarkData }: { benchmarkData: AnalysisData['benchmarkData'] }) {
const getPercentileColor = (percentile: number) => {
if (percentile >= 75) return 'text-emerald-600';
if (percentile >= 50) return 'text-amber-600';
return 'text-red-600';
};
return (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="px-4 py-3 border-b border-slate-100 bg-slate-50">
<h3 className="font-semibold text-slate-800">Benchmark vs Industria</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-xs text-slate-500 uppercase tracking-wider">
<th className="px-4 py-2 text-left font-medium">KPI</th>
<th className="px-4 py-2 text-right font-medium">Actual</th>
<th className="px-4 py-2 text-right font-medium">Industria</th>
<th className="px-4 py-2 text-right font-medium">Percentil</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{benchmarkData.map((item) => (
<tr key={item.kpi} className="hover:bg-slate-50">
<td className="px-4 py-3 text-sm text-slate-700 font-medium">{item.kpi}</td>
<td className="px-4 py-3 text-sm text-slate-800 text-right font-semibold">
{item.userDisplay}
</td>
<td className="px-4 py-3 text-sm text-slate-500 text-right">
{item.industryDisplay}
</td>
<td className={`px-4 py-3 text-sm text-right font-medium ${getPercentileColor(item.percentile)}`}>
P{item.percentile}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
// 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);
return (
<div className="space-y-6">
{/* Dimensions Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{coreDimensions.map((dimension, idx) => (
<DimensionCard
key={dimension.id}
dimension={dimension}
findings={getFindingsForDimension(dimension.id)}
recommendations={getRecommendationsForDimension(dimension.id)}
delay={idx * 0.1}
/>
))}
</div>
{/* Benchmark Table */}
<BenchmarkTable benchmarkData={data.benchmarkData} />
</div>
);
}
export default DimensionAnalysisTab;

View File

@@ -0,0 +1,292 @@
import { TrendingUp, TrendingDown, Minus, AlertTriangle, CheckCircle, Target } from 'lucide-react';
import { BulletChart } from '../charts/BulletChart';
import type { AnalysisData, Finding } from '../../types';
interface ExecutiveSummaryTabProps {
data: AnalysisData;
}
// Health Score Gauge Component
function HealthScoreGauge({ score }: { score: number }) {
const getColor = (s: number) => {
if (s >= 80) return '#059669'; // emerald-600
if (s >= 60) return '#D97706'; // amber-600
return '#DC2626'; // red-600
};
const getLabel = (s: number) => {
if (s >= 80) return 'Excelente';
if (s >= 60) return 'Bueno';
if (s >= 40) return 'Regular';
return 'Crítico';
};
const color = getColor(score);
const circumference = 2 * Math.PI * 45;
const strokeDasharray = `${(score / 100) * circumference} ${circumference}`;
return (
<div className="bg-white rounded-lg p-6 border border-slate-200">
<h3 className="font-semibold text-slate-800 mb-4 text-center">Health Score General</h3>
<div className="relative w-32 h-32 mx-auto">
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
{/* Background circle */}
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="#E2E8F0"
strokeWidth="8"
/>
{/* Progress circle */}
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke={color}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={strokeDasharray}
className="transition-all duration-1000 ease-out"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-bold" style={{ color }}>{score}</span>
<span className="text-xs text-slate-500">/100</span>
</div>
</div>
<p className="text-center mt-3 text-sm font-medium" style={{ color }}>{getLabel(score)}</p>
</div>
);
}
// KPI Card Component
function KpiCard({ label, value, change, changeType }: {
label: string;
value: string;
change?: string;
changeType?: 'positive' | 'negative' | 'neutral';
}) {
const ChangeIcon = changeType === 'positive' ? TrendingUp :
changeType === 'negative' ? TrendingDown : Minus;
const changeColor = changeType === 'positive' ? 'text-emerald-600' :
changeType === 'negative' ? 'text-red-600' : 'text-slate-500';
return (
<div className="bg-white rounded-lg p-4 border border-slate-200">
<p className="text-sm text-slate-500 mb-1">{label}</p>
<p className="text-2xl font-bold text-slate-800">{value}</p>
{change && (
<div className={`flex items-center gap-1 mt-1 text-sm ${changeColor}`}>
<ChangeIcon className="w-4 h-4" />
<span>{change}</span>
</div>
)}
</div>
);
}
// Top Opportunities Component (McKinsey style)
function TopOpportunities({ findings, opportunities }: {
findings: Finding[];
opportunities: { name: string; impact: number; savings: number }[];
}) {
// Combine critical findings and high-impact opportunities
const items = [
...findings
.filter(f => f.type === 'critical' || f.type === 'warning')
.slice(0, 3)
.map((f, i) => ({
rank: i + 1,
title: f.title || f.text.split(':')[0],
metric: f.text.includes(':') ? f.text.split(':')[1].trim() : '',
action: f.description || 'Acción requerida',
type: f.type as 'critical' | 'warning' | 'info'
})),
].slice(0, 3);
// Fill with opportunities if not enough findings
if (items.length < 3) {
const remaining = 3 - items.length;
opportunities
.sort((a, b) => b.savings - a.savings)
.slice(0, remaining)
.forEach((opp, i) => {
items.push({
rank: items.length + 1,
title: opp.name,
metric: `${opp.savings.toLocaleString()} ahorro potencial`,
action: 'Implementar',
type: 'info' as const
});
});
}
const getIcon = (type: string) => {
if (type === 'critical') return <AlertTriangle className="w-5 h-5 text-red-500" />;
if (type === 'warning') return <Target className="w-5 h-5 text-amber-500" />;
return <CheckCircle className="w-5 h-5 text-emerald-500" />;
};
return (
<div className="bg-white rounded-lg p-4 border border-slate-200">
<h3 className="font-semibold text-slate-800 mb-4">Top 3 Oportunidades</h3>
<div className="space-y-3">
{items.map((item) => (
<div key={item.rank} className="flex items-start gap-3 p-3 bg-slate-50 rounded-lg">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-slate-200 flex items-center justify-center text-sm font-bold text-slate-700">
{item.rank}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{getIcon(item.type)}
<span className="font-medium text-slate-800">{item.title}</span>
</div>
{item.metric && (
<p className="text-sm text-slate-500 mt-0.5">{item.metric}</p>
)}
<p className="text-sm text-[#6D84E3] mt-1 font-medium">
{item.action}
</p>
</div>
</div>
))}
</div>
</div>
);
}
export function ExecutiveSummaryTab({ data }: ExecutiveSummaryTabProps) {
// Extract key KPIs for bullet charts
const totalInteractions = data.heatmapData.reduce((sum, h) => sum + h.volume, 0);
const avgAHT = data.heatmapData.length > 0
? Math.round(data.heatmapData.reduce((sum, h) => sum + h.aht_seconds, 0) / data.heatmapData.length)
: 0;
const avgFCR = data.heatmapData.length > 0
? Math.round(data.heatmapData.reduce((sum, h) => sum + h.metrics.fcr, 0) / data.heatmapData.length)
: 0;
const avgTransferRate = data.heatmapData.length > 0
? Math.round(data.heatmapData.reduce((sum, h) => sum + h.metrics.transfer_rate, 0) / data.heatmapData.length)
: 0;
// Find benchmark data
const ahtBenchmark = data.benchmarkData.find(b => b.kpi.toLowerCase().includes('aht'));
const fcrBenchmark = data.benchmarkData.find(b => b.kpi.toLowerCase().includes('fcr'));
return (
<div className="space-y-6">
{/* Main Grid: KPIs + Health Score */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
{/* Summary KPIs */}
{data.summaryKpis.slice(0, 3).map((kpi) => (
<KpiCard
key={kpi.label}
label={kpi.label}
value={kpi.value}
change={kpi.change}
changeType={kpi.changeType}
/>
))}
{/* Health Score Gauge */}
<HealthScoreGauge score={data.overallHealthScore} />
</div>
{/* Bullet Charts Row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<BulletChart
label="Total Interacciones"
actual={totalInteractions}
target={totalInteractions * 1.1}
ranges={[totalInteractions * 0.7, totalInteractions * 0.9, totalInteractions * 1.3]}
formatValue={(v) => v >= 1000 ? `${(v / 1000).toFixed(1)}K` : v.toString()}
/>
<BulletChart
label="AHT"
actual={avgAHT}
target={ahtBenchmark?.industryValue || 360}
ranges={[480, 420, 600]} // >480s poor, 420-480 ok, <420 good
unit="s"
percentile={ahtBenchmark?.percentile}
inverse={true}
formatValue={(v) => v.toString()}
/>
<BulletChart
label="FCR"
actual={avgFCR}
target={fcrBenchmark?.industryValue || 75}
ranges={[65, 75, 100]} // <65 poor, 65-75 ok, >75 good
unit="%"
percentile={fcrBenchmark?.percentile}
formatValue={(v) => v.toString()}
/>
<BulletChart
label="Tasa Transferencia"
actual={avgTransferRate}
target={15}
ranges={[25, 15, 40]} // >25% poor, 15-25 ok, <15 good
unit="%"
inverse={true}
formatValue={(v) => v.toString()}
/>
</div>
{/* Bottom Row: Top Opportunities + Economic Summary */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<TopOpportunities
findings={data.findings}
opportunities={data.opportunities}
/>
{/* Economic Impact Summary */}
<div className="bg-white rounded-lg p-4 border border-slate-200">
<h3 className="font-semibold text-slate-800 mb-4">Impacto Económico</h3>
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-slate-50 rounded-lg">
<p className="text-sm text-slate-500">Coste Anual Actual</p>
<p className="text-xl font-bold text-slate-800">
{data.economicModel.currentAnnualCost.toLocaleString()}
</p>
</div>
<div className="p-3 bg-emerald-50 rounded-lg">
<p className="text-sm text-emerald-600">Ahorro Potencial</p>
<p className="text-xl font-bold text-emerald-700">
{data.economicModel.annualSavings.toLocaleString()}
</p>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<p className="text-sm text-slate-500">Inversión Inicial</p>
<p className="text-xl font-bold text-slate-800">
{data.economicModel.initialInvestment.toLocaleString()}
</p>
</div>
<div className="p-3 bg-[#6D84E3]/10 rounded-lg">
<p className="text-sm text-[#6D84E3]">ROI a 3 Años</p>
<p className="text-xl font-bold text-[#6D84E3]">
{data.economicModel.roi3yr}%
</p>
</div>
</div>
{/* Payback indicator */}
<div className="mt-4 p-3 bg-gradient-to-r from-emerald-50 to-emerald-100 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-emerald-700">Payback</span>
<span className="font-bold text-emerald-800">
{data.economicModel.paybackMonths} meses
</span>
</div>
</div>
</div>
</div>
</div>
);
}
export default ExecutiveSummaryTab;

View File

@@ -0,0 +1,355 @@
import { motion } from 'framer-motion';
import { Zap, Clock, DollarSign, TrendingUp, AlertTriangle, CheckCircle, ArrowRight } from 'lucide-react';
import { WaterfallChart, WaterfallDataPoint } from '../charts/WaterfallChart';
import type { AnalysisData, RoadmapInitiative, RoadmapPhase } from '../../types';
interface RoadmapTabProps {
data: AnalysisData;
}
// Quick Wins Section
function QuickWins({ initiatives, economicModel }: {
initiatives: RoadmapInitiative[];
economicModel: AnalysisData['economicModel'];
}) {
// Filter for quick wins (low investment, quick timeline)
const quickWins = initiatives
.filter(i => i.phase === RoadmapPhase.Automate || i.risk === 'low')
.slice(0, 3);
if (quickWins.length === 0) {
// Create synthetic quick wins from savings breakdown
const topSavings = economicModel.savingsBreakdown.slice(0, 3);
return (
<div className="bg-gradient-to-r from-emerald-50 to-emerald-100 rounded-lg p-4 border border-emerald-200">
<div className="flex items-center gap-2 mb-4">
<Zap className="w-5 h-5 text-emerald-600" />
<h3 className="font-semibold text-emerald-800">Quick Wins Identificados</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{topSavings.map((saving, idx) => (
<div key={idx} className="bg-white rounded-lg p-3 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="w-4 h-4 text-emerald-500" />
<span className="text-sm font-medium text-slate-700">{saving.category}</span>
</div>
<p className="text-lg font-bold text-emerald-600">
{saving.amount.toLocaleString()}
</p>
<p className="text-xs text-slate-500">{saving.percentage}% del ahorro total</p>
</div>
))}
</div>
</div>
);
}
return (
<div className="bg-gradient-to-r from-emerald-50 to-emerald-100 rounded-lg p-4 border border-emerald-200">
<div className="flex items-center gap-2 mb-4">
<Zap className="w-5 h-5 text-emerald-600" />
<h3 className="font-semibold text-emerald-800">Quick Wins</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{quickWins.map((initiative) => (
<div key={initiative.id} className="bg-white rounded-lg p-3 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="w-4 h-4 text-emerald-500" />
<span className="text-sm font-medium text-slate-700">{initiative.name}</span>
</div>
<p className="text-xs text-slate-500 mb-1">{initiative.timeline}</p>
<p className="text-sm font-semibold text-emerald-600">
{initiative.investment.toLocaleString()} inversión
</p>
</div>
))}
</div>
</div>
);
}
// Initiative Card
function InitiativeCard({ initiative, delay = 0 }: { initiative: RoadmapInitiative; delay?: number }) {
const phaseColors = {
[RoadmapPhase.Automate]: 'bg-emerald-100 text-emerald-700 border-emerald-200',
[RoadmapPhase.Assist]: 'bg-[#6D84E3]/20 text-[#6D84E3] border-[#6D84E3]/30',
[RoadmapPhase.Augment]: 'bg-amber-100 text-amber-700 border-amber-200'
};
const riskColors = {
low: 'text-emerald-600',
medium: 'text-amber-600',
high: 'text-red-600'
};
return (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay }}
className="bg-white rounded-lg p-4 border border-slate-200 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="font-semibold text-slate-800">{initiative.name}</h4>
<span className={`inline-block mt-1 px-2 py-0.5 rounded text-xs font-medium border ${phaseColors[initiative.phase]}`}>
{initiative.phase}
</span>
</div>
{initiative.risk && (
<div className={`flex items-center gap-1 text-xs ${riskColors[initiative.risk]}`}>
<AlertTriangle className="w-3 h-3" />
Riesgo {initiative.risk === 'low' ? 'Bajo' : initiative.risk === 'medium' ? 'Medio' : 'Alto'}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3 mb-3">
<div className="flex items-center gap-2 text-sm text-slate-600">
<Clock className="w-4 h-4 text-slate-400" />
{initiative.timeline}
</div>
<div className="flex items-center gap-2 text-sm text-slate-600">
<DollarSign className="w-4 h-4 text-slate-400" />
{initiative.investment.toLocaleString()}
</div>
</div>
{initiative.resources.length > 0 && (
<div className="flex flex-wrap gap-1">
{initiative.resources.slice(0, 3).map((resource, idx) => (
<span key={idx} className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">
{resource}
</span>
))}
</div>
)}
</motion.div>
);
}
// Business Case Summary
function BusinessCaseSummary({ economicModel }: { economicModel: AnalysisData['economicModel'] }) {
return (
<div className="bg-white rounded-lg p-4 border border-slate-200">
<h3 className="font-semibold text-slate-800 mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-[#6D84E3]" />
Business Case Consolidado
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="text-center p-3 bg-slate-50 rounded-lg">
<p className="text-xs text-slate-500 mb-1">Inversión Total</p>
<p className="text-xl font-bold text-slate-800">
{economicModel.initialInvestment.toLocaleString()}
</p>
</div>
<div className="text-center p-3 bg-emerald-50 rounded-lg">
<p className="text-xs text-emerald-600 mb-1">Ahorro Anual</p>
<p className="text-xl font-bold text-emerald-700">
{economicModel.annualSavings.toLocaleString()}
</p>
</div>
<div className="text-center p-3 bg-[#6D84E3]/10 rounded-lg">
<p className="text-xs text-[#6D84E3] mb-1">Payback</p>
<p className="text-xl font-bold text-[#6D84E3]">
{economicModel.paybackMonths} meses
</p>
</div>
<div className="text-center p-3 bg-amber-50 rounded-lg">
<p className="text-xs text-amber-600 mb-1">ROI 3 Años</p>
<p className="text-xl font-bold text-amber-700">
{economicModel.roi3yr}%
</p>
</div>
</div>
{/* Savings Breakdown */}
<div className="space-y-2">
<p className="text-sm font-medium text-slate-700">Desglose de Ahorros:</p>
{economicModel.savingsBreakdown.map((item, idx) => (
<div key={idx} className="flex items-center gap-3">
<div className="flex-1">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-600">{item.category}</span>
<span className="font-medium text-slate-800">{item.amount.toLocaleString()}</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full"
style={{ width: `${item.percentage}%` }}
/>
</div>
</div>
<span className="text-xs text-slate-500 w-10 text-right">{item.percentage}%</span>
</div>
))}
</div>
</div>
);
}
// Timeline Visual
function TimelineVisual({ initiatives }: { initiatives: RoadmapInitiative[] }) {
const phases = [
{ phase: RoadmapPhase.Automate, label: 'Wave 1: Automatizar', color: 'bg-emerald-500' },
{ phase: RoadmapPhase.Assist, label: 'Wave 2: Asistir', color: 'bg-[#6D84E3]' },
{ phase: RoadmapPhase.Augment, label: 'Wave 3: Aumentar', color: 'bg-amber-500' }
];
return (
<div className="bg-white rounded-lg p-4 border border-slate-200">
<h3 className="font-semibold text-slate-800 mb-4">Timeline de Implementación</h3>
<div className="relative">
{/* Timeline line */}
<div className="absolute top-6 left-4 right-4 h-1 bg-slate-200 rounded-full" />
{/* Phases */}
<div className="flex justify-between relative">
{phases.map((phase, idx) => {
const phaseInitiatives = initiatives.filter(i => i.phase === phase.phase);
return (
<div key={phase.phase} className="flex flex-col items-center" style={{ width: '30%' }}>
{/* Circle */}
<div className={`w-12 h-12 rounded-full ${phase.color} flex items-center justify-center text-white font-bold text-lg z-10`}>
{idx + 1}
</div>
{/* Label */}
<p className="text-sm font-medium text-slate-700 mt-2 text-center">{phase.label}</p>
{/* Count */}
<p className="text-xs text-slate-500">
{phaseInitiatives.length} iniciativa{phaseInitiatives.length !== 1 ? 's' : ''}
</p>
</div>
);
})}
</div>
{/* Arrows */}
<div className="flex justify-center gap-4 mt-4">
<ArrowRight className="w-5 h-5 text-slate-400" />
<ArrowRight className="w-5 h-5 text-slate-400" />
</div>
</div>
</div>
);
}
export function RoadmapTab({ data }: RoadmapTabProps) {
// Generate waterfall data from economic model
const waterfallData: WaterfallDataPoint[] = [
{
label: 'Coste Actual',
value: data.economicModel.currentAnnualCost,
cumulative: data.economicModel.currentAnnualCost,
type: 'initial'
},
{
label: 'Inversión Inicial',
value: data.economicModel.initialInvestment,
cumulative: data.economicModel.currentAnnualCost + data.economicModel.initialInvestment,
type: 'increase'
},
...data.economicModel.savingsBreakdown.map((saving, idx) => ({
label: saving.category,
value: saving.amount,
cumulative: data.economicModel.currentAnnualCost + data.economicModel.initialInvestment -
data.economicModel.savingsBreakdown.slice(0, idx + 1).reduce((sum, s) => sum + s.amount, 0),
type: 'decrease' as const
})),
{
label: 'Coste Futuro',
value: data.economicModel.futureAnnualCost,
cumulative: data.economicModel.futureAnnualCost,
type: 'total'
}
];
// Group initiatives by phase
const automateInitiatives = data.roadmap.filter(i => i.phase === RoadmapPhase.Automate);
const assistInitiatives = data.roadmap.filter(i => i.phase === RoadmapPhase.Assist);
const augmentInitiatives = data.roadmap.filter(i => i.phase === RoadmapPhase.Augment);
return (
<div className="space-y-6">
{/* Quick Wins */}
<QuickWins initiatives={data.roadmap} economicModel={data.economicModel} />
{/* Timeline Visual */}
<TimelineVisual initiatives={data.roadmap} />
{/* Waterfall Chart */}
<WaterfallChart
data={waterfallData}
title="Impacto Económico: De Coste Actual a Futuro"
height={350}
/>
{/* Initiatives by Phase */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Wave 1: Automate */}
<div>
<h3 className="font-semibold text-slate-800 mb-3 flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-emerald-500" />
Wave 1: Automatizar
</h3>
<div className="space-y-3">
{automateInitiatives.length > 0 ? (
automateInitiatives.map((init, idx) => (
<InitiativeCard key={init.id} initiative={init} delay={idx * 0.1} />
))
) : (
<p className="text-sm text-slate-500 italic p-3 bg-slate-50 rounded-lg">
Sin iniciativas en esta fase
</p>
)}
</div>
</div>
{/* Wave 2: Assist */}
<div>
<h3 className="font-semibold text-slate-800 mb-3 flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-[#6D84E3]" />
Wave 2: Asistir
</h3>
<div className="space-y-3">
{assistInitiatives.length > 0 ? (
assistInitiatives.map((init, idx) => (
<InitiativeCard key={init.id} initiative={init} delay={idx * 0.1} />
))
) : (
<p className="text-sm text-slate-500 italic p-3 bg-slate-50 rounded-lg">
Sin iniciativas en esta fase
</p>
)}
</div>
</div>
{/* Wave 3: Augment */}
<div>
<h3 className="font-semibold text-slate-800 mb-3 flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-amber-500" />
Wave 3: Aumentar
</h3>
<div className="space-y-3">
{augmentInitiatives.length > 0 ? (
augmentInitiatives.map((init, idx) => (
<InitiativeCard key={init.id} initiative={init} delay={idx * 0.1} />
))
) : (
<p className="text-sm text-slate-500 italic p-3 bg-slate-50 rounded-lg">
Sin iniciativas en esta fase
</p>
)}
</div>
</div>
</div>
{/* Business Case Summary */}
<BusinessCaseSummary economicModel={data.economicModel} />
</div>
);
}
export default RoadmapTab;

View File

@@ -6,17 +6,17 @@ export const TIERS: TiersData = {
name: 'Análisis GOLD', name: 'Análisis GOLD',
price: 4900, price: 4900,
color: 'bg-yellow-500', color: 'bg-yellow-500',
description: '6 dimensiones completas con algoritmo Agentic Readiness avanzado', description: '5 dimensiones completas con Agentic Readiness avanzado',
requirements: 'CCaaS moderno (Genesys, Five9, NICE, Talkdesk)', requirements: 'CCaaS moderno (Genesys, Five9, NICE, Talkdesk)',
timeline: '3-4 semanas', timeline: '3-4 semanas',
features: [ features: [
'6 dimensiones completas', '5 dimensiones: Volumetría, Eficiencia, Efectividad, Complejidad, Agentic Readiness',
'Algoritmo Agentic Readiness avanzado (6 sub-factores)', 'Agentic Readiness Score 0-10 por cola',
'Análisis de distribución horaria', 'Análisis de distribución horaria y semanal',
'Segmentación de clientes (opcional)', 'Métricas P10/P50/P90 por cola',
'Benchmark con percentiles múltiples (P25, P50, P75, P90)', 'FCR proxy y tasa de transferencias',
'Análisis de variabilidad y predictibilidad',
'Roadmap ejecutable con 3 waves', 'Roadmap ejecutable con 3 waves',
'Modelo económico con NPV y análisis de sensibilidad',
'Sesión de presentación incluida' 'Sesión de presentación incluida'
] ]
}, },
@@ -24,15 +24,14 @@ export const TIERS: TiersData = {
name: 'Análisis SILVER', name: 'Análisis SILVER',
price: 3500, price: 3500,
color: 'bg-gray-400', color: 'bg-gray-400',
description: '4 dimensiones core con Agentic Readiness simplificado', description: '5 dimensiones con Agentic Readiness simplificado',
requirements: 'Sistema ACD/PBX con reporting básico', requirements: 'Sistema ACD/PBX con reporting básico',
timeline: '2-3 semanas', timeline: '2-3 semanas',
features: [ features: [
'4 dimensiones (Volumetría, Rendimiento, Economía, Agentic Readiness)', '5 dimensiones completas',
'Algoritmo Agentic Readiness simplificado (3 sub-factores)', 'Agentic Readiness simplificado (4 sub-factores)',
'Roadmap de implementación', 'Roadmap de implementación',
'Opportunity Matrix', 'Opportunity Matrix',
'Economic Model básico',
'Dashboard interactivo' 'Dashboard interactivo'
] ]
}, },
@@ -40,15 +39,14 @@ export const TIERS: TiersData = {
name: 'Análisis EXPRESS', name: 'Análisis EXPRESS',
price: 1950, price: 1950,
color: 'bg-orange-600', color: 'bg-orange-600',
description: '3 dimensiones fundamentales sin Agentic Readiness', description: '4 dimensiones fundamentales sin Agentic Readiness detallado',
requirements: 'Exportación básica de reportes', requirements: 'Exportación básica de reportes',
timeline: '1-2 semanas', timeline: '1-2 semanas',
features: [ features: [
'3 dimensiones core (Volumetría, Rendimiento, Economía)', '4 dimensiones core (Volumetría, Eficiencia, Efectividad, Complejidad)',
'Agentic Readiness básico',
'Roadmap cualitativo', 'Roadmap cualitativo',
'Análisis básico', 'Recomendaciones estratégicas'
'Recomendaciones estratégicas',
'Reporte ejecutivo'
] ]
} }
}; };
@@ -136,14 +134,13 @@ export const DATA_REQUIREMENTS: DataRequirementsData = {
} }
}; };
// v2.0: Dimensiones actualizadas (6 en lugar de 8) // v3.0: 5 dimensiones viables
export const DIMENSION_NAMES = { export const DIMENSION_NAMES = {
volumetry_distribution: 'Volumetría y Distribución Horaria', volumetry_distribution: 'Volumetría & Distribución',
performance: 'Rendimiento', operational_efficiency: 'Eficiencia Operativa',
satisfaction: 'Satisfacción', effectiveness_resolution: 'Efectividad & Resolución',
economy: 'Economía', complexity_predictability: 'Complejidad & Predictibilidad',
efficiency: 'Eficiencia', // Fusiona Eficiencia + Efectividad agentic_readiness: 'Agentic Readiness'
benchmark: 'Benchmark'
}; };
// v2.0: Ponderaciones para Agentic Readiness Score // v2.0: Ponderaciones para Agentic Readiness Score

View File

@@ -102,14 +102,13 @@ export interface Kpi {
changeType?: 'positive' | 'negative' | 'neutral'; changeType?: 'positive' | 'negative' | 'neutral';
} }
// v2.0: Dimensiones reducidas de 8 a 6 // v3.0: 5 dimensiones viables
export type DimensionName = export type DimensionName =
| 'volumetry_distribution' // Volumetría y Distribución Horaria (fusión + ampliación) | 'volumetry_distribution' // Volumetría & Distribución
| 'performance' // Rendimiento | 'operational_efficiency' // Eficiencia Operativa
| 'satisfaction' // Satisfacción | 'effectiveness_resolution' // Efectividad & Resolución
| 'economy' // Economía | 'complexity_predictability' // Complejidad & Predictibilidad
| 'efficiency' // Eficiencia (fusiona efficiency + effectiveness) | 'agentic_readiness'; // Agentic Readiness
| 'benchmark'; // Benchmark
export interface SubFactor { export interface SubFactor {
name: string; name: string;

View File

@@ -2,7 +2,7 @@
import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment } from '../types'; import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment } from '../types';
import { generateAnalysisFromRealData } from './realDataAnalysis'; import { generateAnalysisFromRealData } from './realDataAnalysis';
import { RoadmapPhase } from '../types'; import { RoadmapPhase } from '../types';
import { BarChartHorizontal, Zap, Smile, DollarSign, Target, Globe } from 'lucide-react'; import { BarChartHorizontal, Zap, Target, Brain, Bot } from 'lucide-react';
import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2'; import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2';
import { callAnalysisApiRaw } from './apiClient'; import { callAnalysisApiRaw } from './apiClient';
import { import {
@@ -30,14 +30,14 @@ const getScoreColor = (score: number): 'green' | 'yellow' | 'red' => {
return 'red'; return 'red';
}; };
// v2.0: 6 DIMENSIONES (eliminadas Complejidad y Efectividad) // v3.0: 5 DIMENSIONES VIABLES
const DIMENSIONS_CONTENT = { const DIMENSIONS_CONTENT = {
volumetry_distribution: { volumetry_distribution: {
icon: BarChartHorizontal, icon: BarChartHorizontal,
titles: ["Volumetría y Distribución Horaria", "Análisis de la Demanda"], titles: ["Volumetría & Distribución", "Análisis de la Demanda"],
summaries: { summaries: {
good: ["El volumen de interacciones se alinea con las previsiones, permitiendo una planificación de personal precisa.", "La distribución horaria es uniforme con picos predecibles, facilitando la automatización."], good: ["El volumen de interacciones se alinea con las previsiones, permitiendo una planificación de personal precisa.", "La distribución horaria es uniforme con picos predecibles. Concentración Pareto equilibrada."],
medium: ["Existen picos de demanda imprevistos que generan caídas en el nivel de servicio.", "Alto porcentaje de interacciones fuera de horario laboral (>30%), sugiriendo necesidad de cobertura 24/7."], medium: ["Existen picos de demanda imprevistos que generan caídas en el nivel de servicio.", "Alta concentración en pocas colas (>80% en 20% de colas), riesgo de cuellos de botella."],
bad: ["Desajuste crónico entre el forecast y el volumen real, resultando en sobrecostes o mal servicio.", "Distribución horaria muy irregular con múltiples picos impredecibles."] bad: ["Desajuste crónico entre el forecast y el volumen real, resultando en sobrecostes o mal servicio.", "Distribución horaria muy irregular con múltiples picos impredecibles."]
}, },
kpis: [ kpis: [
@@ -45,85 +45,72 @@ const DIMENSIONS_CONTENT = {
{ label: "% Fuera de Horario", value: `${randomInt(15, 45)}%` }, { label: "% Fuera de Horario", value: `${randomInt(15, 45)}%` },
], ],
}, },
performance: { operational_efficiency: {
icon: Zap, icon: Zap,
titles: ["Rendimiento Operativo", "Optimización de Tiempos"], titles: ["Eficiencia Operativa", "Optimización de Tiempos"],
summaries: { summaries: {
good: ["El AHT está bien controlado con baja variabilidad (CV<30%), indicando procesos estandarizados.", "Tiempos de espera y post-llamada (ACW) mínimos, maximizando la productividad del agente."], good: ["El ratio P90/P50 es bajo (<1.5), indicando tiempos consistentes y procesos estandarizados.", "Tiempos de espera, hold y ACW bien controlados, maximizando la productividad."],
medium: ["El AHT es competitivo, pero la variabilidad es alta (CV>40%), sugiriendo inconsistencia en procesos.", "El tiempo en espera (Hold Time) es ligeramente elevado, sugiriendo posibles mejoras en el acceso a la información."], medium: ["El ratio P90/P50 es moderado (1.5-2.0), existen casos outliers que afectan la eficiencia.", "El tiempo de hold es ligeramente elevado, sugiriendo mejoras en acceso a información."],
bad: ["El AHT excede los benchmarks de la industria con alta variabilidad, impactando directamente en los costes.", "Tiempos de ACW prolongados indican procesos manuales ineficientes o falta de integración de sistemas."] bad: ["Alto ratio P90/P50 (>2.0), indicando alta variabilidad en tiempos de gestión.", "Tiempos de ACW y hold prolongados indican procesos manuales ineficientes."]
}, },
kpis: [ kpis: [
{ label: "AHT Promedio", value: `${randomInt(280, 550)}s` }, { label: "AHT P50", value: `${randomInt(280, 450)}s` },
{ label: "CV AHT", value: `${randomInt(25, 60)}%` }, { label: "Ratio P90/P50", value: `${randomFloat(1.2, 2.5, 2)}` },
], ],
}, },
satisfaction: { effectiveness_resolution: {
icon: Smile,
titles: ["Satisfacción y Experiencia", "Voz del Cliente"],
summaries: {
good: ["Puntuaciones de CSAT muy positivas con distribución normal, reflejando un proceso estable y consistente.", "El análisis cualitativo muestra un sentimiento mayoritariamente positivo en las interacciones."],
medium: ["Los indicadores de satisfacción son neutros. La distribución de CSAT muestra cierta bimodalidad.", "Se observan comentarios mixtos, con puntos fuertes en la amabilidad del agente pero debilidades en los tiempos de respuesta."],
bad: ["Bajas puntuaciones de CSAT con distribución anormal, indicando un proceso inconsistente.", "Los clientes se quejan frecuentemente de largos tiempos de espera, repetición de información y falta de resolución."]
},
kpis: [
{ label: "CSAT Promedio", value: `${randomFloat(3.8, 4.9, 1)}/5` },
{ label: "NPS", value: `${randomInt(-20, 55)}` },
],
},
economy: {
icon: DollarSign,
titles: ["Economía y Costes", "Rentabilidad del Servicio"],
summaries: {
good: ["El coste por interacción está por debajo del promedio de la industria, indicando una operación rentable.", "El ROI potencial de automatización supera los €200K anuales con payback <12 meses."],
medium: ["Los costes son estables pero no se observa una tendencia a la baja, sugiriendo un estancamiento en la optimización.", "El ROI potencial es moderado (€100-200K), requiriendo inversión inicial significativa."],
bad: ["Coste por interacción elevado, erosionando los márgenes de beneficio de la compañía.", "Bajo ROI potencial (<€100K) debido a volumen insuficiente o procesos ya optimizados."]
},
kpis: [
{ label: "Coste por Interacción", value: `${randomFloat(2.5, 9.5, 2)}` },
{ label: "Ahorro Potencial", value: `${randomInt(50, 250)}K` },
],
},
efficiency: {
icon: Target, icon: Target,
titles: ["Eficiencia", "Resolución y Calidad"], titles: ["Efectividad & Resolución", "Calidad del Servicio"],
summaries: { summaries: {
good: ["Alta tasa de resolución en el primer contacto (FCR>85%), minimizando la repetición de llamadas.", "Bajo índice de transferencias y escalaciones (<10%), demostrando un correcto enrutamiento y alto conocimiento del agente."], good: ["FCR proxy >85%, mínima repetición de contactos a 7 días.", "Baja tasa de transferencias (<10%) y llamadas problemáticas (<5%)."],
medium: ["La tasa de FCR es aceptable (70-85%), aunque se detectan ciertos tipos de consulta que requieren múltiples contactos.", "Las transferencias son moderadas (10-20%), concentradas en departamentos específicos."], medium: ["FCR proxy 70-85%, hay oportunidad de reducir recontactos.", "Tasa de transferencias moderada (10-20%), concentradas en ciertas colas."],
bad: ["Bajo FCR (<70%), lo que genera frustración en el cliente y aumenta el volumen de interacciones innecesarias.", "Excesivas transferencias y escalaciones (>20%), creando una experiencia de cliente fragmentada y costosa."] bad: ["FCR proxy <70%, alto volumen de recontactos a 7 días.", "Alta tasa de llamadas problemáticas (>15%) y transferencias excesivas."]
}, },
kpis: [ kpis: [
{ label: "Tasa FCR", value: `${randomInt(65, 92)}%` }, { label: "FCR Proxy 7d", value: `${randomInt(65, 92)}%` },
{ label: "Tasa de Escalación", value: `${randomInt(5, 25)}%` }, { label: "Tasa Transfer", value: `${randomInt(5, 25)}%` },
], ],
}, },
benchmark: { complexity_predictability: {
icon: Globe, icon: Brain,
titles: ["Benchmark de Industria", "Contexto Competitivo"], titles: ["Complejidad & Predictibilidad", "Análisis de Variabilidad"],
summaries: { summaries: {
good: ["La operación se sitúa consistentemente por encima del P75 en los KPIs más críticos.", "El rendimiento en eficiencia y calidad es de 'top quartile', representando una ventaja competitiva."], good: ["Baja variabilidad AHT (ratio P90/P50 <1.5), proceso altamente predecible.", "Diversidad de tipificaciones controlada, bajo % de llamadas con múltiples holds."],
medium: ["El rendimiento general está en línea con la mediana de la industria (P50), sin claras fortalezas o debilidades.", "Se observan algunas áreas por debajo del P50 que representan oportunidades de mejora claras."], medium: ["Variabilidad AHT moderada, algunos casos outliers afectan la predictibilidad.", "% llamadas con múltiples holds elevado (15-30%), indicando complejidad."],
bad: ["La mayoría de los KPIs se encuentran por debajo del P25, indicando una necesidad urgente de mejora.", "El AHT y el CPI son significativamente más altos que los benchmarks, impactando la rentabilidad."] bad: ["Alta variabilidad AHT (ratio >2.0), proceso impredecible y difícil de automatizar.", "Alta diversidad de tipificaciones y % transferencias, indicando alta complejidad."]
}, },
kpis: [ kpis: [
{ label: "Posición vs P50 AHT", value: `P${randomInt(30, 70)}` }, { label: "Ratio P90/P50", value: `${randomFloat(1.2, 2.5, 2)}` },
{ label: "Posición vs P50 FCR", value: `P${randomInt(30, 70)}` }, { label: "% Transferencias", value: `${randomInt(5, 30)}%` },
],
},
agentic_readiness: {
icon: Bot,
titles: ["Agentic Readiness", "Potencial de Automatización"],
summaries: {
good: ["Score 8-10: Excelente candidato para automatización completa con agentes IA.", "Alto volumen, baja variabilidad, pocas transferencias. Proceso repetitivo y predecible."],
medium: ["Score 5-7: Candidato para asistencia con IA (copilot) o automatización parcial.", "Volumen moderado con algunas complejidades que requieren supervisión humana."],
bad: ["Score 0-4: Requiere optimización previa antes de automatizar.", "Alta complejidad, baja repetitividad o variabilidad excesiva."]
},
kpis: [
{ label: "Score Global", value: `${randomFloat(3.0, 9.5, 1)}/10` },
{ label: "Categoría", value: randomFromList(['Automatizar', 'Asistir', 'Optimizar']) },
], ],
}, },
}; };
const KEY_FINDINGS: Finding[] = [ const KEY_FINDINGS: Finding[] = [
{ {
text: "El canal de voz presenta un AHT un 35% superior al del chat, pero una tasa de resolución un 15% mayor.", text: "El ratio P90/P50 de AHT es alto (>2.0) en varias colas, indicando alta variabilidad.",
dimensionId: 'performance', dimensionId: 'operational_efficiency',
type: 'info', type: 'warning',
title: 'Diferencia de Canales: Voz vs Chat', title: 'Alta Variabilidad en Tiempos',
description: 'Análisis comparativo entre canales muestra trade-off entre velocidad y resolución.', description: 'Procesos poco estandarizados generan tiempos impredecibles y afectan la planificación.',
impact: 'medium' impact: 'high'
}, },
{ {
text: "Un 22% de las transferencias desde 'Soporte Técnico N1' hacia 'Facturación' son incorrectas.", text: "Un 22% de las transferencias desde 'Soporte Técnico N1' hacia otras colas son incorrectas.",
dimensionId: 'efficiency', dimensionId: 'effectiveness_resolution',
type: 'warning', type: 'warning',
title: 'Enrutamiento Incorrecto', title: 'Enrutamiento Incorrecto',
description: 'Existe un problema de routing que genera ineficiencias y experiencia pobre del cliente.', description: 'Existe un problema de routing que genera ineficiencias y experiencia pobre del cliente.',
@@ -147,46 +134,46 @@ const KEY_FINDINGS: Finding[] = [
}, },
{ {
text: "Las consultas sobre 'estado del pedido' representan el 30% de las interacciones y tienen alta repetitividad.", text: "Las consultas sobre 'estado del pedido' representan el 30% de las interacciones y tienen alta repetitividad.",
dimensionId: 'volumetry_distribution', dimensionId: 'agentic_readiness',
type: 'info', type: 'info',
title: 'Oportunidad de Automatización: Estado de Pedido', title: 'Oportunidad de Automatización: Estado de Pedido',
description: 'Volumen significativo en consultas altamente repetitivas y automatizables.', description: 'Volumen significativo en consultas altamente repetitivas y automatizables (Score Agentic >8).',
impact: 'high' impact: 'high'
}, },
{ {
text: "Baja puntuación de CSAT en interacciones relacionadas con problemas de facturación.", text: "FCR proxy <75% en colas de facturación, alto recontacto a 7 días.",
dimensionId: 'satisfaction', dimensionId: 'effectiveness_resolution',
type: 'warning', type: 'warning',
title: 'Satisfacción Baja en Facturación', title: 'Baja Resolución en Facturación',
description: 'El equipo de facturación tiene desempeño por debajo de la media en satisfacción del cliente.', description: 'El equipo de facturación tiene alto % de recontactos, indicando problemas de resolución.',
impact: 'high' impact: 'high'
}, },
{ {
text: "La variabilidad de AHT (CV=45%) sugiere procesos poco estandarizados.", text: "Alta diversidad de tipificaciones y >20% llamadas con múltiples holds en colas complejas.",
dimensionId: 'performance', dimensionId: 'complexity_predictability',
type: 'warning', type: 'warning',
title: 'Inconsistencia en Procesos', title: 'Alta Complejidad en Ciertas Colas',
description: 'Alta variabilidad indica falta de estandarización y diferencias significativas entre agentes.', description: 'Colas con alta complejidad requieren optimización antes de considerar automatización.',
impact: 'medium' impact: 'medium'
}, },
]; ];
const RECOMMENDATIONS: Recommendation[] = [ const RECOMMENDATIONS: Recommendation[] = [
{ {
text: "Implementar un programa de formación específico para agentes de Facturación sobre los nuevos planes.", text: "Estandarizar procesos en colas con alto ratio P90/P50 para reducir variabilidad.",
dimensionId: 'efficiency', dimensionId: 'operational_efficiency',
priority: 'high', priority: 'high',
title: 'Formación en Facturación', title: 'Estandarización de Procesos',
description: 'Capacitación intensiva en productos, políticas y procedimientos de facturación.', description: 'Implementar scripts y guías paso a paso para reducir la variabilidad en tiempos de gestión.',
impact: 'Mejora estimada de satisfacción: 15-25%', impact: 'Reducción ratio P90/P50: 20-30%, Mejora predictibilidad',
timeline: '2-3 semanas' timeline: '3-4 semanas'
}, },
{ {
text: "Desarrollar un bot de estado de pedido para WhatsApp para desviar el 30% de las consultas.", text: "Desarrollar un bot de estado de pedido para WhatsApp para desviar el 30% de las consultas.",
dimensionId: 'volumetry_distribution', dimensionId: 'agentic_readiness',
priority: 'high', priority: 'high',
title: 'Bot Automatizado de Seguimiento de Pedidos', title: 'Bot Automatizado de Seguimiento de Pedidos',
description: 'Implementar ChatBot en WhatsApp para responder consultas de estado de pedido automáticamente.', description: 'Implementar ChatBot en WhatsApp para consultas con alto Agentic Score (>8).',
impact: 'Reducción de volumen: 20-30%, Ahorro anual: €40-60K', impact: 'Reducción de volumen: 20-30%, Ahorro anual: €40-60K',
timeline: '1-2 meses' timeline: '1-2 meses'
}, },
@@ -200,12 +187,12 @@ const RECOMMENDATIONS: Recommendation[] = [
timeline: '1 mes' timeline: '1 mes'
}, },
{ {
text: "Crear una Knowledge Base más robusta y accesible para reducir el tiempo en espera.", text: "Crear una Knowledge Base más robusta para reducir hold time y mejorar FCR.",
dimensionId: 'performance', dimensionId: 'effectiveness_resolution',
priority: 'high', priority: 'high',
title: 'Mejora de Acceso a Información', title: 'Mejora de Acceso a Información',
description: 'Desarrollar una KB centralizada integrada en el sistema de agentes con búsqueda inteligente.', description: 'Desarrollar una KB centralizada para reducir búsquedas y mejorar resolución en primer contacto.',
impact: 'Reducción de AHT: 8-12%, Mejora de FCR: 5-10%', impact: 'Reducción hold time: 15-25%, Mejora FCR: 5-10%',
timeline: '6-8 semanas' timeline: '6-8 semanas'
}, },
{ {
@@ -213,18 +200,18 @@ const RECOMMENDATIONS: Recommendation[] = [
dimensionId: 'volumetry_distribution', dimensionId: 'volumetry_distribution',
priority: 'medium', priority: 'medium',
title: 'Cobertura 24/7 con IA', title: 'Cobertura 24/7 con IA',
description: 'Desplegar agentes virtuales para gestionar el 28% de interacciones nocturnas.', description: 'Desplegar agentes virtuales para gestionar interacciones nocturnas y fines de semana.',
impact: 'Captura de demanda: 20-25%, Coste incremental: €15-20K/mes', impact: 'Captura de demanda: 20-25%, Coste incremental: €15-20K/mes',
timeline: '2-3 meses' timeline: '2-3 meses'
}, },
{ {
text: "Realizar un análisis de causa raíz sobre las quejas de facturación para mejorar procesos.", text: "Simplificar tipificaciones y reducir complejidad en colas problemáticas.",
dimensionId: 'satisfaction', dimensionId: 'complexity_predictability',
priority: 'medium', priority: 'medium',
title: 'Análisis de Causa Raíz (Facturación)', title: 'Reducción de Complejidad',
description: 'Investigar las 50 últimas quejas de facturación para identificar patrones y causas.', description: 'Consolidar tipificaciones y simplificar flujos para mejorar predictibilidad.',
impact: 'Identificación de mejoras de proceso con ROI potencial de €20-50K', impact: 'Reducción de complejidad: 20-30%, Mejora Agentic Score',
timeline: '2-3 semanas' timeline: '4-6 semanas'
}, },
]; ];
@@ -651,25 +638,25 @@ const generateHeatmapData = (
}); });
}; };
// v2.0: Añadir segmentación de cliente // v3.0: Oportunidades con nuevas dimensiones
const generateOpportunityMatrixData = (): Opportunity[] => { const generateOpportunityMatrixData = (): Opportunity[] => {
const opportunities = [ const opportunities = [
{ id: 'opp1', name: 'Automatizar consulta de pedidos', savings: 85000, dimensionId: 'volumetry_distribution', customer_segment: 'medium' as CustomerSegment }, { id: 'opp1', name: 'Automatizar consulta de pedidos', savings: 85000, dimensionId: 'agentic_readiness', customer_segment: 'medium' as CustomerSegment },
{ id: 'opp2', name: 'Implementar Knowledge Base dinámica', savings: 45000, dimensionId: 'performance', customer_segment: 'high' as CustomerSegment }, { id: 'opp2', name: 'Implementar Knowledge Base dinámica', savings: 45000, dimensionId: 'operational_efficiency', customer_segment: 'high' as CustomerSegment },
{ id: 'opp3', name: 'Chatbot de triaje inicial', savings: 120000, dimensionId: 'efficiency', customer_segment: 'medium' as CustomerSegment }, { id: 'opp3', name: 'Chatbot de triaje inicial', savings: 120000, dimensionId: 'effectiveness_resolution', customer_segment: 'medium' as CustomerSegment },
{ id: 'opp4', name: 'Análisis de sentimiento en tiempo real', savings: 30000, dimensionId: 'satisfaction', customer_segment: 'high' as CustomerSegment }, { id: 'opp4', name: 'Reducir complejidad en colas críticas', savings: 30000, dimensionId: 'complexity_predictability', customer_segment: 'high' as CustomerSegment },
{ id: 'opp5', name: 'Cobertura 24/7 con agentes virtuales', savings: 65000, dimensionId: 'volumetry_distribution', customer_segment: 'low' as CustomerSegment }, { id: 'opp5', name: 'Cobertura 24/7 con agentes virtuales', savings: 65000, dimensionId: 'volumetry_distribution', customer_segment: 'low' as CustomerSegment },
]; ];
return opportunities.map(opp => ({ ...opp, impact: randomInt(3, 10), feasibility: randomInt(2, 9) })); return opportunities.map(opp => ({ ...opp, impact: randomInt(3, 10), feasibility: randomInt(2, 9) }));
}; };
// v2.0: Añadir risk level // v3.0: Roadmap con nuevas dimensiones
const generateRoadmapData = (): RoadmapInitiative[] => { const generateRoadmapData = (): RoadmapInitiative[] => {
return [ return [
{ id: 'r1', name: 'Chatbot de estado de pedido', phase: RoadmapPhase.Automate, timeline: 'Q1 2025', investment: 25000, resources: ['1x Bot Developer', 'API Access'], dimensionId: 'volumetry_distribution', risk: 'low' }, { id: 'r1', name: 'Chatbot de estado de pedido', phase: RoadmapPhase.Automate, timeline: 'Q1 2025', investment: 25000, resources: ['1x Bot Developer', 'API Access'], dimensionId: 'agentic_readiness', risk: 'low' },
{ id: 'r2', name: 'Implementar Knowledge Base dinámica', phase: RoadmapPhase.Assist, timeline: 'Q1 2025', investment: 15000, resources: ['1x PM', 'Content Team'], dimensionId: 'performance', risk: 'low' }, { id: 'r2', name: 'Implementar Knowledge Base dinámica', phase: RoadmapPhase.Assist, timeline: 'Q1 2025', investment: 15000, resources: ['1x PM', 'Content Team'], dimensionId: 'operational_efficiency', risk: 'low' },
{ id: 'r3', name: 'Agent Assist para sugerencias en real-time', phase: RoadmapPhase.Assist, timeline: 'Q2 2025', investment: 45000, resources: ['2x AI Devs', 'QA Team'], dimensionId: 'efficiency', risk: 'medium' }, { id: 'r3', name: 'Agent Assist para sugerencias en real-time', phase: RoadmapPhase.Assist, timeline: 'Q2 2025', investment: 45000, resources: ['2x AI Devs', 'QA Team'], dimensionId: 'effectiveness_resolution', risk: 'medium' },
{ id: 'r4', name: 'IVR conversacional con IA', phase: RoadmapPhase.Automate, timeline: 'Q3 2025', investment: 60000, resources: ['AI Voice Specialist', 'UX Designer'], dimensionId: 'efficiency', risk: 'medium' }, { id: 'r4', name: 'Estandarización de procesos complejos', phase: RoadmapPhase.Augment, timeline: 'Q3 2025', investment: 30000, resources: ['Process Analyst', 'Training Team'], dimensionId: 'complexity_predictability', risk: 'medium' },
{ id: 'r5', name: 'Cobertura 24/7 con agentes virtuales', phase: RoadmapPhase.Augment, timeline: 'Q4 2025', investment: 75000, resources: ['Lead AI Engineer', 'Data Scientist'], dimensionId: 'volumetry_distribution', risk: 'high' }, { id: 'r5', name: 'Cobertura 24/7 con agentes virtuales', phase: RoadmapPhase.Augment, timeline: 'Q4 2025', investment: 75000, resources: ['Lead AI Engineer', 'Data Scientist'], dimensionId: 'volumetry_distribution', risk: 'high' },
]; ];
}; };
@@ -797,13 +784,13 @@ const generateOpportunitiesFromHeatmap = (
Math.min(10, Math.round(feasibilityRaw)) Math.min(10, Math.round(feasibilityRaw))
); );
// Dimensión a la que lo vinculamos (solo decorativo de momento) // Dimensión a la que lo vinculamos
const dimensionId = const dimensionId =
readiness >= 70 readiness >= 70
? 'volumetry_distribution' ? 'agentic_readiness'
: readiness >= 40 : readiness >= 40
? 'efficiency' ? 'effectiveness_resolution'
: 'economy'; : 'complexity_predictability';
// Segmento de cliente (high/medium/low) si lo tenemos // Segmento de cliente (high/medium/low) si lo tenemos
const customer_segment = heat.segment; const customer_segment = heat.segment;
@@ -1031,8 +1018,8 @@ const generateSyntheticAnalysis = (
{ label: "CSAT", value: `${randomFloat(4.1, 4.8, 1)}/5`, change: `-${randomFloat(0.1, 0.3, 1)}`, changeType: 'negative' }, { label: "CSAT", value: `${randomFloat(4.1, 4.8, 1)}/5`, change: `-${randomFloat(0.1, 0.3, 1)}`, changeType: 'negative' },
]; ];
// v2.0: Solo 6 dimensiones // v3.0: 5 dimensiones viables
const dimensionKeys = ['volumetry_distribution', 'performance', 'satisfaction', 'economy', 'efficiency', 'benchmark']; const dimensionKeys = ['volumetry_distribution', 'operational_efficiency', 'effectiveness_resolution', 'complexity_predictability', 'agentic_readiness'];
const dimensions: DimensionAnalysis[] = dimensionKeys.map(key => { const dimensions: DimensionAnalysis[] = dimensionKeys.map(key => {
const content = DIMENSIONS_CONTENT[key as keyof typeof DIMENSIONS_CONTENT]; const content = DIMENSIONS_CONTENT[key as keyof typeof DIMENSIONS_CONTENT];

View File

@@ -9,7 +9,7 @@ import type {
EconomicModelData, EconomicModelData,
} from '../types'; } from '../types';
import type { BackendRawResults } from './apiClient'; import type { BackendRawResults } from './apiClient';
import { BarChartHorizontal, Zap, DollarSign, Smile, Target } from 'lucide-react'; import { BarChartHorizontal, Zap, Target, Brain, Bot } from 'lucide-react';
import type { HeatmapDataPoint, CustomerSegment } from '../types'; import type { HeatmapDataPoint, CustomerSegment } from '../types';
@@ -336,57 +336,40 @@ function buildVolumetryDimension(
return { dimension, extraKpis }; return { dimension, extraKpis };
} }
// ==== Performance (operational_performance) ==== // ==== Eficiencia Operativa (v3.0) ====
function buildPerformanceDimension( function buildOperationalEfficiencyDimension(
raw: BackendRawResults raw: BackendRawResults
): DimensionAnalysis | undefined { ): DimensionAnalysis | undefined {
const op = raw?.operational_performance; const op = raw?.operational_performance;
if (!op) return undefined; if (!op) return undefined;
const perfScore0_10 = safeNumber(op.performance_score?.score, NaN);
if (!Number.isFinite(perfScore0_10)) return undefined;
const score = Math.max(
0,
Math.min(100, Math.round(perfScore0_10 * 10))
);
const ahtP50 = safeNumber(op.aht_distribution?.p50, 0); const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
const ahtP90 = safeNumber(op.aht_distribution?.p90, 0); const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
const ratio = safeNumber(op.aht_distribution?.p90_p50_ratio, 0); const ratio = ahtP90 > 0 && ahtP50 > 0 ? ahtP90 / ahtP50 : safeNumber(op.aht_distribution?.p90_p50_ratio, 1.5);
const escRate = safeNumber(op.escalation_rate, 0);
let summary = `El AHT mediano se sitúa en ${Math.round( // Score: menor ratio = mejor score (1.0 = 100, 3.0 = 0)
ahtP50 const score = Math.max(0, Math.min(100, Math.round(100 - (ratio - 1) * 50)));
)} segundos, con un P90 de ${Math.round(
ahtP90
)}s (ratio P90/P50 ≈ ${ratio.toFixed(
2
)}) y una tasa de escalación del ${escRate.toFixed(
1
)}%. `;
if (score >= 80) { let summary = `AHT P50: ${Math.round(ahtP50)}s, P90: ${Math.round(ahtP90)}s. Ratio P90/P50: ${ratio.toFixed(2)}. `;
summary +=
'El rendimiento operativo es sólido y se encuentra claramente por encima de los umbrales objetivo.'; if (ratio < 1.5) {
} else if (score >= 60) { summary += 'Tiempos consistentes y procesos estandarizados.';
summary += } else if (ratio < 2.0) {
'El rendimiento es aceptable pero existen oportunidades claras de optimización en algunos flujos.'; summary += 'Variabilidad moderada, algunos casos outliers afectan la eficiencia.';
} else { } else {
summary += summary += 'Alta variabilidad en tiempos, requiere estandarización de procesos.';
'El rendimiento operativo está por debajo del nivel deseado y requiere un plan de mejora específico.';
} }
const kpi: Kpi = { const kpi: Kpi = {
label: 'AHT mediano (P50)', label: 'Ratio P90/P50',
value: ahtP50 ? `${Math.round(ahtP50)}s` : 'N/D', value: ratio.toFixed(2),
}; };
const dimension: DimensionAnalysis = { const dimension: DimensionAnalysis = {
id: 'performance', id: 'operational_efficiency',
name: 'performance', name: 'operational_efficiency',
title: 'Rendimiento operativo', title: 'Eficiencia Operativa',
score, score,
percentile: undefined, percentile: undefined,
summary, summary,
@@ -397,134 +380,49 @@ function buildPerformanceDimension(
return dimension; return dimension;
} }
// ==== Satisfacción (customer_satisfaction) ==== // ==== Efectividad & Resolución (v3.0) ====
function buildSatisfactionDimension( function buildEffectivenessResolutionDimension(
raw: BackendRawResults
): DimensionAnalysis | undefined {
const cs = raw?.customer_satisfaction;
if (!cs) return undefined;
// CSAT global viene ya calculado en el backend (15)
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
if (!Number.isFinite(csatGlobalRaw) || csatGlobalRaw <= 0) {
return undefined;
}
// Normalizamos 15 a 0100
const csat = Math.max(1, Math.min(5, csatGlobalRaw));
const score = Math.max(
0,
Math.min(100, Math.round((csat / 5) * 100))
);
let summary = `CSAT global de ${csat.toFixed(1)}/5. `;
if (score >= 85) {
summary +=
'La satisfacción del cliente es muy alta y consistente en la mayoría de interacciones.';
} else if (score >= 70) {
summary +=
'La satisfacción del cliente es razonable, pero existen áreas claras de mejora en algunos journeys o motivos de contacto.';
} else {
summary +=
'La satisfacción del cliente se sitúa por debajo de los niveles objetivo y requiere un plan de mejora específico sobre los principales drivers de insatisfacción.';
}
const kpi: Kpi = {
label: 'CSAT global (backend)',
value: `${csat.toFixed(1)}/5`,
};
const dimension: DimensionAnalysis = {
id: 'satisfaction',
name: 'satisfaction',
title: 'Voz del cliente y satisfacción',
score,
percentile: undefined,
summary,
kpi,
icon: Smile,
};
return dimension;
}
// ==== Eficiencia (FCR + escalaciones + recurrencia) ====
function buildEfficiencyDimension(
raw: BackendRawResults raw: BackendRawResults
): DimensionAnalysis | undefined { ): DimensionAnalysis | undefined {
const op = raw?.operational_performance; const op = raw?.operational_performance;
if (!op) return undefined; if (!op) return undefined;
// FCR: viene como porcentaje 0100, o lo aproximamos a partir de escalaciones
const fcrPctRaw = safeNumber(op.fcr_rate, NaN); const fcrPctRaw = safeNumber(op.fcr_rate, NaN);
const escRateRaw = safeNumber(op.escalation_rate, NaN); const escRateRaw = safeNumber(op.escalation_rate, NaN);
const recurrenceRaw = safeNumber(op.recurrence_rate_7d, NaN); const recurrenceRaw = safeNumber(op.recurrence_rate_7d, NaN);
const fcrPct = Number.isFinite(fcrPctRaw) && fcrPctRaw >= 0 // FCR proxy: usar fcr_rate o calcular desde recurrence
const fcrProxy = Number.isFinite(fcrPctRaw) && fcrPctRaw >= 0
? Math.max(0, Math.min(100, fcrPctRaw)) ? Math.max(0, Math.min(100, fcrPctRaw))
: Number.isFinite(escRateRaw) : Number.isFinite(recurrenceRaw)
? Math.max(0, Math.min(100, 100 - escRateRaw)) ? Math.max(0, Math.min(100, 100 - recurrenceRaw))
: NaN; : 75; // valor por defecto
if (!Number.isFinite(fcrPct)) { const transferRate = Number.isFinite(escRateRaw) ? escRateRaw : 15;
// Sin FCR ni escalaciones no podemos construir bien la dimensión
return undefined;
}
let score = fcrPct; // Score: FCR alto + transferencias bajas = mejor score
const score = Math.max(0, Math.min(100, Math.round(fcrProxy - transferRate * 0.5)));
// Penalizar por escalaciones altas let summary = `FCR proxy 7d: ${fcrProxy.toFixed(1)}%. Tasa de transferencias: ${transferRate.toFixed(1)}%. `;
if (Number.isFinite(escRateRaw)) {
const esc = escRateRaw as number;
if (esc > 20) score -= 20;
else if (esc > 10) score -= 10;
else if (esc > 5) score -= 5;
}
// Penalizar por recurrencia (repetición de contactos a 7 días) if (fcrProxy >= 85 && transferRate < 10) {
if (Number.isFinite(recurrenceRaw)) { summary += 'Excelente resolución en primer contacto, mínimas transferencias.';
const rec = recurrenceRaw as number; // asumimos ya en % } else if (fcrProxy >= 70) {
if (rec > 20) score -= 15; summary += 'Resolución aceptable, oportunidad de reducir recontactos y transferencias.';
else if (rec > 10) score -= 10;
else if (rec > 5) score -= 5;
}
score = Math.max(0, Math.min(100, Math.round(score)));
const escText = Number.isFinite(escRateRaw)
? `${(escRateRaw as number).toFixed(1)}%`
: 'N/D';
const recText = Number.isFinite(recurrenceRaw)
? `${(recurrenceRaw as number).toFixed(1)}%`
: 'N/D';
let summary = `FCR estimado de ${fcrPct.toFixed(
1
)}%, con una tasa de escalación del ${escText} y una recurrencia a 7 días de ${recText}. `;
if (score >= 80) {
summary +=
'La operación presenta una alta tasa de resolución en primer contacto y pocas escalaciones, lo que indica procesos eficientes.';
} else if (score >= 60) {
summary +=
'La eficiencia es razonable, aunque existen oportunidades de mejora en la resolución al primer contacto y en la reducción de contactos repetidos.';
} else { } else {
summary += summary += 'Baja resolución, alto recontacto a 7 días. Requiere mejora de procesos.';
'La eficiencia operativa es baja: hay demasiadas escalaciones o contactos repetidos, lo que impacta negativamente en costes y experiencia de cliente.';
} }
const kpi: Kpi = { const kpi: Kpi = {
label: 'FCR estimado (backend)', label: 'FCR Proxy 7d',
value: `${fcrPct.toFixed(1)}%`, value: `${fcrProxy.toFixed(1)}%`,
}; };
const dimension: DimensionAnalysis = { const dimension: DimensionAnalysis = {
id: 'efficiency', id: 'effectiveness_resolution',
name: 'efficiency', name: 'effectiveness_resolution',
title: 'Resolución y eficiencia', title: 'Efectividad & Resolución',
score, score,
percentile: undefined, percentile: undefined,
summary, summary,
@@ -535,6 +433,129 @@ function buildEfficiencyDimension(
return dimension; return dimension;
} }
// ==== Complejidad & Predictibilidad (v3.0) ====
function buildComplexityPredictabilityDimension(
raw: BackendRawResults
): DimensionAnalysis | undefined {
const op = raw?.operational_performance;
if (!op) return undefined;
const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
const ratio = ahtP50 > 0 ? ahtP90 / ahtP50 : 2;
const escalationRate = safeNumber(op.escalation_rate, 15);
// Score: menor ratio + menos escalaciones = mayor score (más predecible)
const ratioScore = Math.max(0, Math.min(50, 50 - (ratio - 1) * 25));
const escalationScore = Math.max(0, Math.min(50, 50 - escalationRate));
const score = Math.round(ratioScore + escalationScore);
let summary = `Variabilidad AHT (ratio P90/P50): ${ratio.toFixed(2)}. % transferencias: ${escalationRate.toFixed(1)}%. `;
if (ratio < 1.5 && escalationRate < 10) {
summary += 'Proceso altamente predecible y baja complejidad. Excelente candidato para automatización.';
} else if (ratio < 2.0) {
summary += 'Complejidad moderada, algunos casos requieren atención especial.';
} else {
summary += 'Alta complejidad y variabilidad. Requiere optimización antes de automatizar.';
}
const kpi: Kpi = {
label: 'Ratio P90/P50',
value: ratio.toFixed(2),
};
const dimension: DimensionAnalysis = {
id: 'complexity_predictability',
name: 'complexity_predictability',
title: 'Complejidad & Predictibilidad',
score,
percentile: undefined,
summary,
kpi,
icon: Brain,
};
return dimension;
}
// ==== Agentic Readiness como dimensión (v3.0) ====
function buildAgenticReadinessDimension(
raw: BackendRawResults,
fallbackTier: TierKey
): DimensionAnalysis | undefined {
const ar = raw?.agentic_readiness?.agentic_readiness;
// Si no hay datos de backend, calculamos un score aproximado
const op = raw?.operational_performance;
const volumetry = raw?.volumetry;
let score0_10: number;
let category: string;
if (ar) {
score0_10 = safeNumber(ar.final_score, 5);
} else {
// Calcular aproximado desde métricas disponibles
const ahtP50 = safeNumber(op?.aht_distribution?.p50, 0);
const ahtP90 = safeNumber(op?.aht_distribution?.p90, 0);
const ratio = ahtP50 > 0 ? ahtP90 / ahtP50 : 2;
const escalation = safeNumber(op?.escalation_rate, 15);
const skillVolumes = Array.isArray(volumetry?.volume_by_skill?.values)
? volumetry.volume_by_skill.values.map((v: any) => safeNumber(v, 0))
: [];
const totalVolume = skillVolumes.reduce((a: number, b: number) => a + b, 0);
// Calcular sub-scores
const predictability = Math.max(0, Math.min(10, 10 - (ratio - 1) * 5));
const complexityInverse = Math.max(0, Math.min(10, 10 - escalation / 5));
const repetitivity = Math.min(10, totalVolume / 500);
score0_10 = predictability * 0.30 + complexityInverse * 0.30 + repetitivity * 0.25 + 2.5; // base offset
}
const score0_100 = Math.max(0, Math.min(100, Math.round(score0_10 * 10)));
if (score0_10 >= 8) {
category = 'Automatizar';
} else if (score0_10 >= 5) {
category = 'Asistir (Copilot)';
} else {
category = 'Optimizar primero';
}
let summary = `Score global: ${score0_10.toFixed(1)}/10. Categoría: ${category}. `;
if (score0_10 >= 8) {
summary += 'Excelente candidato para automatización completa con agentes IA.';
} else if (score0_10 >= 5) {
summary += 'Candidato para asistencia con IA (copilot) o automatización parcial.';
} else {
summary += 'Requiere optimización de procesos antes de automatizar.';
}
const kpi: Kpi = {
label: 'Score Global',
value: `${score0_10.toFixed(1)}/10`,
};
const dimension: DimensionAnalysis = {
id: 'agentic_readiness',
name: 'agentic_readiness',
title: 'Agentic Readiness',
score: score0_100,
percentile: undefined,
summary,
kpi,
icon: Bot,
};
return dimension;
}
// ==== Economía y costes (economy_costs) ==== // ==== Economía y costes (economy_costs) ====
@@ -627,58 +648,7 @@ function buildEconomicModel(raw: BackendRawResults): EconomicModelData {
}; };
} }
function buildEconomyDimension( // buildEconomyDimension eliminado en v3.0 - economía integrada en otras dimensiones y modelo económico
raw: BackendRawResults
): DimensionAnalysis | undefined {
const econ = raw?.economy_costs;
if (!econ) return undefined;
const cost = econ.cost_breakdown || {};
const totalAnnual = safeNumber(cost.total_annual, 0);
const potential = econ.potential_savings || {};
const annualSavings = safeNumber(potential.annual_savings, 0);
if (!totalAnnual && !annualSavings) return undefined;
const savingsPct = totalAnnual
? (annualSavings / totalAnnual) * 100
: 0;
let summary = `El coste anual estimado de la operación es de aproximadamente €${totalAnnual.toFixed(
2
)}. `;
if (annualSavings > 0) {
summary += `El ahorro potencial anual asociado a la estrategia agentic se sitúa en torno a €${annualSavings.toFixed(
2
)}, equivalente a ~${savingsPct.toFixed(1)}% del coste actual.`;
} else {
summary +=
'Todavía no se dispone de una estimación robusta de ahorro potencial.';
}
const score =
totalAnnual && annualSavings
? Math.max(0, Math.min(100, Math.round(savingsPct)))
: 50;
const dimension: DimensionAnalysis = {
id: 'economy',
name: 'economy',
title: 'Economía y costes',
score,
percentile: undefined,
summary,
kpi: {
label: 'Coste anual actual',
value: totalAnnual
? `${totalAnnual.toFixed(0)}`
: 'N/D',
},
icon: DollarSign,
};
return dimension;
}
/** /**
* Transforma el JSON del backend (results) al AnalysisData * Transforma el JSON del backend (results) al AnalysisData
@@ -722,20 +692,20 @@ export function mapBackendResultsToAnalysisData(
Math.min(100, Math.round(arScore * 10)) Math.min(100, Math.round(arScore * 10))
); );
// Dimensiones // v3.0: 5 dimensiones viables
const { dimension: volumetryDimension, extraKpis } = const { dimension: volumetryDimension, extraKpis } =
buildVolumetryDimension(raw); buildVolumetryDimension(raw);
const performanceDimension = buildPerformanceDimension(raw); const operationalEfficiencyDimension = buildOperationalEfficiencyDimension(raw);
const satisfactionDimension = buildSatisfactionDimension(raw); const effectivenessResolutionDimension = buildEffectivenessResolutionDimension(raw);
const economyDimension = buildEconomyDimension(raw); const complexityPredictabilityDimension = buildComplexityPredictabilityDimension(raw);
const efficiencyDimension = buildEfficiencyDimension(raw); const agenticReadinessDimension = buildAgenticReadinessDimension(raw, tierFromFrontend || 'silver');
const dimensions: DimensionAnalysis[] = []; const dimensions: DimensionAnalysis[] = [];
if (volumetryDimension) dimensions.push(volumetryDimension); if (volumetryDimension) dimensions.push(volumetryDimension);
if (performanceDimension) dimensions.push(performanceDimension); if (operationalEfficiencyDimension) dimensions.push(operationalEfficiencyDimension);
if (satisfactionDimension) dimensions.push(satisfactionDimension); if (effectivenessResolutionDimension) dimensions.push(effectivenessResolutionDimension);
if (economyDimension) dimensions.push(economyDimension); if (complexityPredictabilityDimension) dimensions.push(complexityPredictabilityDimension);
if (efficiencyDimension) dimensions.push(efficiencyDimension); if (agenticReadinessDimension) dimensions.push(agenticReadinessDimension);
const op = raw?.operational_performance; const op = raw?.operational_performance;

View File

@@ -4,7 +4,7 @@
import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment, RawInteraction, AgenticReadinessResult, SubFactor, SkillMetrics } from '../types'; import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment, RawInteraction, AgenticReadinessResult, SubFactor, SkillMetrics } from '../types';
import { RoadmapPhase } from '../types'; import { RoadmapPhase } from '../types';
import { BarChartHorizontal, Zap, Smile, DollarSign, Target, Globe } from 'lucide-react'; import { BarChartHorizontal, Zap, Target, Brain, Bot } from 'lucide-react';
import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2'; import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2';
import { classifyQueue } from './segmentClassifier'; import { classifyQueue } from './segmentClassifier';
@@ -287,7 +287,7 @@ function calculateHealthScore(heatmapData: HeatmapDataPoint[]): number {
} }
/** /**
* Generar dimensiones desde datos reales * v3.0: Generar 5 dimensiones viables desde datos reales
*/ */
function generateDimensionsFromRealData( function generateDimensionsFromRealData(
interactions: RawInteraction[], interactions: RawInteraction[],
@@ -298,69 +298,72 @@ function generateDimensionsFromRealData(
const totalVolume = interactions.length; const totalVolume = interactions.length;
const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / metrics.length; const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / metrics.length;
const avgTransferRate = metrics.reduce((sum, m) => sum + m.transfer_rate, 0) / metrics.length; const avgTransferRate = metrics.reduce((sum, m) => sum + m.transfer_rate, 0) / metrics.length;
const avgHoldTime = metrics.reduce((sum, m) => sum + m.hold_time_mean, 0) / metrics.length;
// Calcular ratio P90/P50 aproximado desde CV
const avgRatio = 1 + avgCV * 1.5; // Aproximación: ratio ≈ 1 + 1.5*CV
// Calcular Agentic Score
const predictability = Math.max(0, Math.min(10, 10 - ((avgCV - 0.3) / 1.2 * 10)));
const complexityInverse = Math.max(0, Math.min(10, 10 - (avgTransferRate / 10)));
const repetitivity = Math.min(10, totalVolume / 500);
const agenticScore = predictability * 0.30 + complexityInverse * 0.30 + repetitivity * 0.25 + 2.5;
return [ return [
// 1. VOLUMETRÍA & DISTRIBUCIÓN
{ {
id: 'volumetry_distribution', id: 'volumetry_distribution',
name: 'volumetry_distribution', name: 'volumetry_distribution',
title: 'Análisis de la Demanda', title: 'Volumetría & Distribución',
score: Math.min(100, Math.round((totalVolume / 200))), // Score basado en volumen score: Math.min(100, Math.round((totalVolume / 200))),
percentile: 65, percentile: 65,
summary: `Se procesaron ${totalVolume.toLocaleString('es-ES')} interacciones distribuidas en ${metrics.length} skills diferentes.`, summary: `${totalVolume.toLocaleString('es-ES')} interacciones en ${metrics.length} colas. Distribución por skill disponible en el heatmap.`,
kpi: { label: 'Volumen Total', value: totalVolume.toLocaleString('es-ES') }, kpi: { label: 'Volumen Total', value: totalVolume.toLocaleString('es-ES') },
icon: BarChartHorizontal icon: BarChartHorizontal
}, },
// 2. EFICIENCIA OPERATIVA
{ {
id: 'performance', id: 'operational_efficiency',
name: 'performance', name: 'operational_efficiency',
title: 'Rendimiento Operativo', title: 'Eficiencia Operativa',
score: Math.round(100 - (avgCV * 100)), score: Math.max(0, Math.min(100, Math.round(100 - (avgRatio - 1) * 50))),
percentile: 70, percentile: 70,
summary: avgCV < 0.4 summary: `AHT P50: ${avgAHT}s. Ratio P90/P50 estimado: ${avgRatio.toFixed(2)}. Hold time promedio: ${Math.round(avgHoldTime)}s.`,
? 'El AHT muestra baja variabilidad, indicando procesos estandarizados.' kpi: { label: 'Ratio P90/P50', value: avgRatio.toFixed(2) },
: 'La variabilidad del AHT es alta, sugiriendo inconsistencia en procesos.',
kpi: { label: 'AHT Promedio', value: `${avgAHT}s` },
icon: Zap icon: Zap
}, },
// 3. EFECTIVIDAD & RESOLUCIÓN
{ {
id: 'satisfaction', id: 'effectiveness_resolution',
name: 'satisfaction', name: 'effectiveness_resolution',
title: 'Voz del Cliente', title: 'Efectividad & Resolución',
score: avgCsat,
percentile: 60,
summary: `CSAT promedio de ${(avgCsat / 20).toFixed(1)}/5.`,
kpi: { label: 'CSAT', value: `${(avgCsat / 20).toFixed(1)}/5` },
icon: Smile
},
{
id: 'economy',
name: 'economy',
title: 'Rentabilidad del Servicio',
score: Math.round(100 - avgTransferRate),
percentile: 55,
summary: `Tasa de transferencia del ${avgTransferRate.toFixed(1)}%.`,
kpi: { label: 'Transfer Rate', value: `${avgTransferRate.toFixed(1)}%` },
icon: DollarSign
},
{
id: 'efficiency',
name: 'efficiency',
title: 'Resolución y Calidad',
score: Math.round(100 - avgTransferRate), score: Math.round(100 - avgTransferRate),
percentile: 68, percentile: 68,
summary: `FCR estimado del ${(100 - avgTransferRate).toFixed(1)}%.`, summary: `FCR proxy: ${(100 - avgTransferRate).toFixed(1)}%. Tasa de transferencias: ${avgTransferRate.toFixed(1)}%.`,
kpi: { label: 'FCR', value: `${(100 - avgTransferRate).toFixed(1)}%` }, kpi: { label: 'FCR Proxy', value: `${(100 - avgTransferRate).toFixed(1)}%` },
icon: Target icon: Target
}, },
// 4. COMPLEJIDAD & PREDICTIBILIDAD
{ {
id: 'benchmark', id: 'complexity_predictability',
name: 'benchmark', name: 'complexity_predictability',
title: 'Contexto Competitivo', title: 'Complejidad & Predictibilidad',
score: 75, score: Math.round(100 - (avgCV * 100)),
percentile: 60,
summary: `CV AHT: ${(avgCV * 100).toFixed(1)}%. % transferencias: ${avgTransferRate.toFixed(1)}%. ${avgCV < 0.4 ? 'Proceso predecible.' : 'Alta variabilidad, considerar estandarización.'}`,
kpi: { label: 'CV AHT', value: `${(avgCV * 100).toFixed(1)}%` },
icon: Brain
},
// 5. AGENTIC READINESS
{
id: 'agentic_readiness',
name: 'agentic_readiness',
title: 'Agentic Readiness',
score: Math.round(agenticScore * 10),
percentile: 65, percentile: 65,
summary: 'Métricas alineadas con benchmarks de la industria.', summary: `Score: ${agenticScore.toFixed(1)}/10. ${agenticScore >= 8 ? 'Excelente para automatización.' : agenticScore >= 5 ? 'Candidato para asistencia IA.' : 'Requiere optimización previa.'}`,
kpi: { label: 'Benchmark', value: 'P65' }, kpi: { label: 'Score', value: `${agenticScore.toFixed(1)}/10` },
icon: Globe icon: Bot
} }
]; ];
} }

43
nginx/conf.d/beyond.conf Normal file
View File

@@ -0,0 +1,43 @@
server {
listen 80;
server_name ae-analytics.beyondcx.ai;
return 301 https://$host$request_uri;
client_max_body_size 1024M;
}
server {
listen 443 ssl;
server_name ae-analytics.beyondcx.ai;
client_max_body_size 1024M;
ssl_certificate /etc/letsencrypt/live/ae-analytics.beyondcx.ai/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ae-analytics.beyondcx.ai/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# FRONTEND (React)
location / {
proxy_pass http://frontend:4173/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# BACKEND (FastAPI)
location /api/ {
proxy_pass http://backend:8000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
send_timeout 600s;
}
}