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:
@@ -7,8 +7,8 @@ services:
|
||||
container_name: beyond-backend
|
||||
environment:
|
||||
# credenciales del API (las mismas que usas ahora)
|
||||
BASIC_AUTH_USERNAME: admin
|
||||
BASIC_AUTH_PASSWORD: admin
|
||||
BASIC_AUTH_USERNAME: "beyond"
|
||||
BASIC_AUTH_PASSWORD: "beyond2026"
|
||||
expose:
|
||||
- "8000"
|
||||
networks:
|
||||
@@ -34,7 +34,9 @@ services:
|
||||
- frontend
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
networks:
|
||||
- beyond-net
|
||||
|
||||
90
frontend/components/DashboardHeader.tsx
Normal file
90
frontend/components/DashboardHeader.tsx
Normal 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;
|
||||
94
frontend/components/DashboardTabs.tsx
Normal file
94
frontend/components/DashboardTabs.tsx
Normal 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;
|
||||
@@ -7,7 +7,7 @@ import { Toaster } from 'react-hot-toast';
|
||||
import { TierKey, AnalysisData } from '../types';
|
||||
import TierSelectorEnhanced from './TierSelectorEnhanced';
|
||||
import DataInputRedesigned from './DataInputRedesigned';
|
||||
import DashboardReorganized from './DashboardReorganized';
|
||||
import DashboardTabs from './DashboardTabs';
|
||||
import { generateAnalysis } from '../utils/analysisGenerator';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAuth } from '../utils/AuthContext';
|
||||
@@ -111,7 +111,7 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
|
||||
console.log('📊 Dimensions length:', analysisData.dimensions?.length);
|
||||
|
||||
try {
|
||||
return <DashboardReorganized analysisData={analysisData} onBack={handleBackToForm} />;
|
||||
return <DashboardTabs data={analysisData} onBack={handleBackToForm} />;
|
||||
} catch (error) {
|
||||
console.error('❌ Error rendering dashboard:', error);
|
||||
return (
|
||||
|
||||
159
frontend/components/charts/BulletChart.tsx
Normal file
159
frontend/components/charts/BulletChart.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export interface BulletChartProps {
|
||||
label: string;
|
||||
actual: number;
|
||||
target: number;
|
||||
ranges: [number, number, number]; // [poor, satisfactory, good/max]
|
||||
unit?: string;
|
||||
percentile?: number;
|
||||
inverse?: boolean; // true if lower is better (e.g., AHT)
|
||||
formatValue?: (value: number) => string;
|
||||
}
|
||||
|
||||
export function BulletChart({
|
||||
label,
|
||||
actual,
|
||||
target,
|
||||
ranges,
|
||||
unit = '',
|
||||
percentile,
|
||||
inverse = false,
|
||||
formatValue = (v) => v.toLocaleString()
|
||||
}: BulletChartProps) {
|
||||
const [poor, satisfactory, max] = ranges;
|
||||
|
||||
const { actualPercent, targetPercent, rangePercents, performance } = useMemo(() => {
|
||||
const actualPct = Math.min((actual / max) * 100, 100);
|
||||
const targetPct = Math.min((target / max) * 100, 100);
|
||||
|
||||
const poorPct = (poor / max) * 100;
|
||||
const satPct = (satisfactory / max) * 100;
|
||||
|
||||
// Determine performance level
|
||||
let perf: 'poor' | 'satisfactory' | 'good';
|
||||
if (inverse) {
|
||||
// Lower is better (e.g., AHT, hold time)
|
||||
if (actual <= satisfactory) perf = 'good';
|
||||
else if (actual <= poor) perf = 'satisfactory';
|
||||
else perf = 'poor';
|
||||
} else {
|
||||
// Higher is better (e.g., FCR, CSAT)
|
||||
if (actual >= satisfactory) perf = 'good';
|
||||
else if (actual >= poor) perf = 'satisfactory';
|
||||
else perf = 'poor';
|
||||
}
|
||||
|
||||
return {
|
||||
actualPercent: actualPct,
|
||||
targetPercent: targetPct,
|
||||
rangePercents: { poor: poorPct, satisfactory: satPct },
|
||||
performance: perf
|
||||
};
|
||||
}, [actual, target, ranges, inverse, poor, satisfactory, max]);
|
||||
|
||||
const performanceColors = {
|
||||
poor: 'bg-red-500',
|
||||
satisfactory: 'bg-amber-500',
|
||||
good: 'bg-emerald-500'
|
||||
};
|
||||
|
||||
const performanceLabels = {
|
||||
poor: 'Crítico',
|
||||
satisfactory: 'Aceptable',
|
||||
good: 'Óptimo'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-slate-800">{label}</span>
|
||||
{percentile !== undefined && (
|
||||
<span className="text-xs px-2 py-0.5 bg-slate-100 text-slate-600 rounded-full">
|
||||
P{percentile}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||
performance === 'good' ? 'bg-emerald-100 text-emerald-700' :
|
||||
performance === 'satisfactory' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{performanceLabels[performance]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bullet Chart */}
|
||||
<div className="relative h-8 mb-2">
|
||||
{/* Background ranges */}
|
||||
<div className="absolute inset-0 flex rounded overflow-hidden">
|
||||
{inverse ? (
|
||||
// Inverse: green on left, red on right
|
||||
<>
|
||||
<div
|
||||
className="h-full bg-emerald-100"
|
||||
style={{ width: `${rangePercents.satisfactory}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-amber-100"
|
||||
style={{ width: `${rangePercents.poor - rangePercents.satisfactory}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-red-100"
|
||||
style={{ width: `${100 - rangePercents.poor}%` }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Normal: red on left, green on right
|
||||
<>
|
||||
<div
|
||||
className="h-full bg-red-100"
|
||||
style={{ width: `${rangePercents.poor}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-amber-100"
|
||||
style={{ width: `${rangePercents.satisfactory - rangePercents.poor}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-emerald-100"
|
||||
style={{ width: `${100 - rangePercents.satisfactory}%` }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actual value bar */}
|
||||
<div
|
||||
className={`absolute top-1/2 -translate-y-1/2 h-4 rounded ${performanceColors[performance]}`}
|
||||
style={{ width: `${actualPercent}%`, minWidth: '4px' }}
|
||||
/>
|
||||
|
||||
{/* Target marker */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-slate-800"
|
||||
style={{ left: `${targetPercent}%` }}
|
||||
>
|
||||
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-0 h-0 border-l-[4px] border-r-[4px] border-t-[6px] border-l-transparent border-r-transparent border-t-slate-800" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Values */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span className="font-bold text-slate-800">{formatValue(actual)}</span>
|
||||
<span className="text-slate-500">{unit}</span>
|
||||
<span className="text-slate-400 ml-1">actual</span>
|
||||
</div>
|
||||
<div className="text-slate-500">
|
||||
<span className="text-slate-600">{formatValue(target)}</span>
|
||||
<span>{unit}</span>
|
||||
<span className="ml-1">benchmark</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BulletChart;
|
||||
214
frontend/components/charts/OpportunityTreemap.tsx
Normal file
214
frontend/components/charts/OpportunityTreemap.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { Treemap, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
|
||||
export type ReadinessCategory = 'automate_now' | 'assist_copilot' | 'optimize_first';
|
||||
|
||||
export interface TreemapData {
|
||||
name: string;
|
||||
value: number; // Savings potential (determines size)
|
||||
category: ReadinessCategory;
|
||||
skill: string;
|
||||
score: number; // Agentic readiness score 0-10
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
export interface OpportunityTreemapProps {
|
||||
data: TreemapData[];
|
||||
title?: string;
|
||||
height?: number;
|
||||
onItemClick?: (item: TreemapData) => void;
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<ReadinessCategory, string> = {
|
||||
automate_now: '#059669', // emerald-600
|
||||
assist_copilot: '#6D84E3', // primary blue
|
||||
optimize_first: '#D97706' // amber-600
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<ReadinessCategory, string> = {
|
||||
automate_now: 'Automatizar Ahora',
|
||||
assist_copilot: 'Asistir con Copilot',
|
||||
optimize_first: 'Optimizar Primero'
|
||||
};
|
||||
|
||||
interface TreemapContentProps {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
name: string;
|
||||
category: ReadinessCategory;
|
||||
score: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const CustomizedContent = ({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
name,
|
||||
category,
|
||||
score,
|
||||
value
|
||||
}: TreemapContentProps) => {
|
||||
const showLabel = width > 60 && height > 40;
|
||||
const showScore = width > 80 && height > 55;
|
||||
const showValue = width > 100 && height > 70;
|
||||
|
||||
const baseColor = CATEGORY_COLORS[category] || '#94A3B8';
|
||||
|
||||
return (
|
||||
<g>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
fill: baseColor,
|
||||
stroke: '#fff',
|
||||
strokeWidth: 2,
|
||||
opacity: 0.85 + (score / 10) * 0.15 // Higher score = more opaque
|
||||
}}
|
||||
rx={4}
|
||||
/>
|
||||
{showLabel && (
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={y + height / 2 - (showScore ? 8 : 0)}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
style={{
|
||||
fontSize: Math.min(12, width / 8),
|
||||
fontWeight: 600,
|
||||
fill: '#fff',
|
||||
textShadow: '0 1px 2px rgba(0,0,0,0.3)'
|
||||
}}
|
||||
>
|
||||
{name.length > 15 && width < 120 ? `${name.slice(0, 12)}...` : name}
|
||||
</text>
|
||||
)}
|
||||
{showScore && (
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={y + height / 2 + 10}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fill: 'rgba(255,255,255,0.9)'
|
||||
}}
|
||||
>
|
||||
Score: {score.toFixed(1)}
|
||||
</text>
|
||||
)}
|
||||
{showValue && (
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={y + height / 2 + 24}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fill: 'rgba(255,255,255,0.8)'
|
||||
}}
|
||||
>
|
||||
€{(value / 1000).toFixed(0)}K
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
interface TooltipPayload {
|
||||
payload: TreemapData;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-white px-3 py-2 shadow-lg rounded-lg border border-slate-200">
|
||||
<p className="font-semibold text-slate-800">{data.name}</p>
|
||||
<p className="text-xs text-slate-500 mb-2">{data.skill}</p>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-slate-600">Readiness Score:</span>
|
||||
<span className="font-medium">{data.score.toFixed(1)}/10</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-slate-600">Ahorro Potencial:</span>
|
||||
<span className="font-medium text-emerald-600">€{data.value.toLocaleString()}</span>
|
||||
</div>
|
||||
{data.volume && (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-slate-600">Volumen:</span>
|
||||
<span className="font-medium">{data.volume.toLocaleString()}/mes</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-slate-600">Categoría:</span>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: CATEGORY_COLORS[data.category] }}
|
||||
>
|
||||
{CATEGORY_LABELS[data.category]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function OpportunityTreemap({
|
||||
data,
|
||||
title,
|
||||
height = 350,
|
||||
onItemClick
|
||||
}: OpportunityTreemapProps) {
|
||||
// Group data by category for treemap
|
||||
const treemapData = data.map(item => ({
|
||||
...item,
|
||||
size: item.value
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200">
|
||||
{title && (
|
||||
<h3 className="font-semibold text-slate-800 mb-4">{title}</h3>
|
||||
)}
|
||||
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<Treemap
|
||||
data={treemapData}
|
||||
dataKey="size"
|
||||
aspectRatio={4 / 3}
|
||||
stroke="#fff"
|
||||
content={<CustomizedContent x={0} y={0} width={0} height={0} name="" category="automate_now" score={0} value={0} />}
|
||||
onClick={onItemClick ? (node) => onItemClick(node as unknown as TreemapData) : undefined}
|
||||
>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</Treemap>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-center gap-6 mt-4 text-xs">
|
||||
{Object.entries(CATEGORY_COLORS).map(([category, color]) => (
|
||||
<div key={category} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-slate-600">
|
||||
{CATEGORY_LABELS[category as ReadinessCategory]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OpportunityTreemap;
|
||||
197
frontend/components/charts/WaterfallChart.tsx
Normal file
197
frontend/components/charts/WaterfallChart.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
ComposedChart,
|
||||
Bar,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
LabelList
|
||||
} from 'recharts';
|
||||
|
||||
export interface WaterfallDataPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
cumulative: number;
|
||||
type: 'initial' | 'increase' | 'decrease' | 'total';
|
||||
}
|
||||
|
||||
export interface WaterfallChartProps {
|
||||
data: WaterfallDataPoint[];
|
||||
title?: string;
|
||||
height?: number;
|
||||
formatValue?: (value: number) => string;
|
||||
}
|
||||
|
||||
interface ProcessedDataPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
cumulative: number;
|
||||
type: 'initial' | 'increase' | 'decrease' | 'total';
|
||||
start: number;
|
||||
end: number;
|
||||
displayValue: number;
|
||||
}
|
||||
|
||||
export function WaterfallChart({
|
||||
data,
|
||||
title,
|
||||
height = 300,
|
||||
formatValue = (v) => `€${Math.abs(v).toLocaleString()}`
|
||||
}: WaterfallChartProps) {
|
||||
// Process data for waterfall visualization
|
||||
const processedData: ProcessedDataPoint[] = data.map((item) => {
|
||||
let start: number;
|
||||
let end: number;
|
||||
|
||||
if (item.type === 'initial' || item.type === 'total') {
|
||||
start = 0;
|
||||
end = item.cumulative;
|
||||
} else if (item.type === 'decrease') {
|
||||
// Savings: bar goes down from previous cumulative
|
||||
start = item.cumulative;
|
||||
end = item.cumulative - item.value;
|
||||
} else {
|
||||
// Increase: bar goes up from previous cumulative
|
||||
start = item.cumulative - item.value;
|
||||
end = item.cumulative;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
start: Math.min(start, end),
|
||||
end: Math.max(start, end),
|
||||
displayValue: Math.abs(item.value)
|
||||
};
|
||||
});
|
||||
|
||||
const getBarColor = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'initial':
|
||||
return '#64748B'; // slate-500
|
||||
case 'decrease':
|
||||
return '#059669'; // emerald-600 (savings)
|
||||
case 'increase':
|
||||
return '#DC2626'; // red-600 (costs)
|
||||
case 'total':
|
||||
return '#6D84E3'; // primary blue
|
||||
default:
|
||||
return '#94A3B8';
|
||||
}
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: ProcessedDataPoint }> }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-white px-3 py-2 shadow-lg rounded-lg border border-slate-200">
|
||||
<p className="font-medium text-slate-800">{data.label}</p>
|
||||
<p className={`text-sm ${
|
||||
data.type === 'decrease' ? 'text-emerald-600' :
|
||||
data.type === 'increase' ? 'text-red-600' :
|
||||
'text-slate-600'
|
||||
}`}>
|
||||
{data.type === 'decrease' ? '-' : data.type === 'increase' ? '+' : ''}
|
||||
{formatValue(data.value)}
|
||||
</p>
|
||||
{data.type !== 'initial' && data.type !== 'total' && (
|
||||
<p className="text-xs text-slate-500">
|
||||
Acumulado: {formatValue(data.cumulative)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Find min/max for Y axis
|
||||
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;
|
||||
278
frontend/components/tabs/AgenticReadinessTab.tsx
Normal file
278
frontend/components/tabs/AgenticReadinessTab.tsx
Normal 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;
|
||||
213
frontend/components/tabs/DimensionAnalysisTab.tsx
Normal file
213
frontend/components/tabs/DimensionAnalysisTab.tsx
Normal 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;
|
||||
292
frontend/components/tabs/ExecutiveSummaryTab.tsx
Normal file
292
frontend/components/tabs/ExecutiveSummaryTab.tsx
Normal 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;
|
||||
355
frontend/components/tabs/RoadmapTab.tsx
Normal file
355
frontend/components/tabs/RoadmapTab.tsx
Normal 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;
|
||||
@@ -6,17 +6,17 @@ export const TIERS: TiersData = {
|
||||
name: 'Análisis GOLD',
|
||||
price: 4900,
|
||||
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)',
|
||||
timeline: '3-4 semanas',
|
||||
features: [
|
||||
'6 dimensiones completas',
|
||||
'Algoritmo Agentic Readiness avanzado (6 sub-factores)',
|
||||
'Análisis de distribución horaria',
|
||||
'Segmentación de clientes (opcional)',
|
||||
'Benchmark con percentiles múltiples (P25, P50, P75, P90)',
|
||||
'5 dimensiones: Volumetría, Eficiencia, Efectividad, Complejidad, Agentic Readiness',
|
||||
'Agentic Readiness Score 0-10 por cola',
|
||||
'Análisis de distribución horaria y semanal',
|
||||
'Métricas P10/P50/P90 por cola',
|
||||
'FCR proxy y tasa de transferencias',
|
||||
'Análisis de variabilidad y predictibilidad',
|
||||
'Roadmap ejecutable con 3 waves',
|
||||
'Modelo económico con NPV y análisis de sensibilidad',
|
||||
'Sesión de presentación incluida'
|
||||
]
|
||||
},
|
||||
@@ -24,15 +24,14 @@ export const TIERS: TiersData = {
|
||||
name: 'Análisis SILVER',
|
||||
price: 3500,
|
||||
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',
|
||||
timeline: '2-3 semanas',
|
||||
features: [
|
||||
'4 dimensiones (Volumetría, Rendimiento, Economía, Agentic Readiness)',
|
||||
'Algoritmo Agentic Readiness simplificado (3 sub-factores)',
|
||||
'5 dimensiones completas',
|
||||
'Agentic Readiness simplificado (4 sub-factores)',
|
||||
'Roadmap de implementación',
|
||||
'Opportunity Matrix',
|
||||
'Economic Model básico',
|
||||
'Dashboard interactivo'
|
||||
]
|
||||
},
|
||||
@@ -40,15 +39,14 @@ export const TIERS: TiersData = {
|
||||
name: 'Análisis EXPRESS',
|
||||
price: 1950,
|
||||
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',
|
||||
timeline: '1-2 semanas',
|
||||
features: [
|
||||
'3 dimensiones core (Volumetría, Rendimiento, Economía)',
|
||||
'4 dimensiones core (Volumetría, Eficiencia, Efectividad, Complejidad)',
|
||||
'Agentic Readiness básico',
|
||||
'Roadmap cualitativo',
|
||||
'Análisis básico',
|
||||
'Recomendaciones estratégicas',
|
||||
'Reporte ejecutivo'
|
||||
'Recomendaciones estratégicas'
|
||||
]
|
||||
}
|
||||
};
|
||||
@@ -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 = {
|
||||
volumetry_distribution: 'Volumetría y Distribución Horaria',
|
||||
performance: 'Rendimiento',
|
||||
satisfaction: 'Satisfacción',
|
||||
economy: 'Economía',
|
||||
efficiency: 'Eficiencia', // Fusiona Eficiencia + Efectividad
|
||||
benchmark: 'Benchmark'
|
||||
volumetry_distribution: 'Volumetría & Distribución',
|
||||
operational_efficiency: 'Eficiencia Operativa',
|
||||
effectiveness_resolution: 'Efectividad & Resolución',
|
||||
complexity_predictability: 'Complejidad & Predictibilidad',
|
||||
agentic_readiness: 'Agentic Readiness'
|
||||
};
|
||||
|
||||
// v2.0: Ponderaciones para Agentic Readiness Score
|
||||
|
||||
@@ -102,14 +102,13 @@ export interface Kpi {
|
||||
changeType?: 'positive' | 'negative' | 'neutral';
|
||||
}
|
||||
|
||||
// v2.0: Dimensiones reducidas de 8 a 6
|
||||
// v3.0: 5 dimensiones viables
|
||||
export type DimensionName =
|
||||
| 'volumetry_distribution' // Volumetría y Distribución Horaria (fusión + ampliación)
|
||||
| 'performance' // Rendimiento
|
||||
| 'satisfaction' // Satisfacción
|
||||
| 'economy' // Economía
|
||||
| 'efficiency' // Eficiencia (fusiona efficiency + effectiveness)
|
||||
| 'benchmark'; // Benchmark
|
||||
| 'volumetry_distribution' // Volumetría & Distribución
|
||||
| 'operational_efficiency' // Eficiencia Operativa
|
||||
| 'effectiveness_resolution' // Efectividad & Resolución
|
||||
| 'complexity_predictability' // Complejidad & Predictibilidad
|
||||
| 'agentic_readiness'; // Agentic Readiness
|
||||
|
||||
export interface SubFactor {
|
||||
name: string;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment } from '../types';
|
||||
import { generateAnalysisFromRealData } from './realDataAnalysis';
|
||||
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 { callAnalysisApiRaw } from './apiClient';
|
||||
import {
|
||||
@@ -30,14 +30,14 @@ const getScoreColor = (score: number): 'green' | 'yellow' | 'red' => {
|
||||
return 'red';
|
||||
};
|
||||
|
||||
// v2.0: 6 DIMENSIONES (eliminadas Complejidad y Efectividad)
|
||||
// v3.0: 5 DIMENSIONES VIABLES
|
||||
const DIMENSIONS_CONTENT = {
|
||||
volumetry_distribution: {
|
||||
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: {
|
||||
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."],
|
||||
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."],
|
||||
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.", "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."]
|
||||
},
|
||||
kpis: [
|
||||
@@ -45,85 +45,72 @@ const DIMENSIONS_CONTENT = {
|
||||
{ label: "% Fuera de Horario", value: `${randomInt(15, 45)}%` },
|
||||
],
|
||||
},
|
||||
performance: {
|
||||
operational_efficiency: {
|
||||
icon: Zap,
|
||||
titles: ["Rendimiento Operativo", "Optimización de Tiempos"],
|
||||
titles: ["Eficiencia Operativa", "Optimización de Tiempos"],
|
||||
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."],
|
||||
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."],
|
||||
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."]
|
||||
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 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: ["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: [
|
||||
{ label: "AHT Promedio", value: `${randomInt(280, 550)}s` },
|
||||
{ label: "CV AHT", value: `${randomInt(25, 60)}%` },
|
||||
{ label: "AHT P50", value: `${randomInt(280, 450)}s` },
|
||||
{ label: "Ratio P90/P50", value: `${randomFloat(1.2, 2.5, 2)}` },
|
||||
],
|
||||
},
|
||||
satisfaction: {
|
||||
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: {
|
||||
effectiveness_resolution: {
|
||||
icon: Target,
|
||||
titles: ["Eficiencia", "Resolución y Calidad"],
|
||||
titles: ["Efectividad & Resolución", "Calidad del Servicio"],
|
||||
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."],
|
||||
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."],
|
||||
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."]
|
||||
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: ["FCR proxy 70-85%, hay oportunidad de reducir recontactos.", "Tasa de transferencias moderada (10-20%), concentradas en ciertas colas."],
|
||||
bad: ["FCR proxy <70%, alto volumen de recontactos a 7 días.", "Alta tasa de llamadas problemáticas (>15%) y transferencias excesivas."]
|
||||
},
|
||||
kpis: [
|
||||
{ label: "Tasa FCR", value: `${randomInt(65, 92)}%` },
|
||||
{ label: "Tasa de Escalación", value: `${randomInt(5, 25)}%` },
|
||||
{ label: "FCR Proxy 7d", value: `${randomInt(65, 92)}%` },
|
||||
{ label: "Tasa Transfer", value: `${randomInt(5, 25)}%` },
|
||||
],
|
||||
},
|
||||
benchmark: {
|
||||
icon: Globe,
|
||||
titles: ["Benchmark de Industria", "Contexto Competitivo"],
|
||||
complexity_predictability: {
|
||||
icon: Brain,
|
||||
titles: ["Complejidad & Predictibilidad", "Análisis de Variabilidad"],
|
||||
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."],
|
||||
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."],
|
||||
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."]
|
||||
good: ["Baja variabilidad AHT (ratio P90/P50 <1.5), proceso altamente predecible.", "Diversidad de tipificaciones controlada, bajo % de llamadas con múltiples holds."],
|
||||
medium: ["Variabilidad AHT moderada, algunos casos outliers afectan la predictibilidad.", "% llamadas con múltiples holds elevado (15-30%), indicando complejidad."],
|
||||
bad: ["Alta variabilidad AHT (ratio >2.0), proceso impredecible y difícil de automatizar.", "Alta diversidad de tipificaciones y % transferencias, indicando alta complejidad."]
|
||||
},
|
||||
kpis: [
|
||||
{ label: "Posición vs P50 AHT", value: `P${randomInt(30, 70)}` },
|
||||
{ label: "Posición vs P50 FCR", value: `P${randomInt(30, 70)}` },
|
||||
{ label: "Ratio P90/P50", value: `${randomFloat(1.2, 2.5, 2)}` },
|
||||
{ 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[] = [
|
||||
{
|
||||
text: "El canal de voz presenta un AHT un 35% superior al del chat, pero una tasa de resolución un 15% mayor.",
|
||||
dimensionId: 'performance',
|
||||
type: 'info',
|
||||
title: 'Diferencia de Canales: Voz vs Chat',
|
||||
description: 'Análisis comparativo entre canales muestra trade-off entre velocidad y resolución.',
|
||||
impact: 'medium'
|
||||
text: "El ratio P90/P50 de AHT es alto (>2.0) en varias colas, indicando alta variabilidad.",
|
||||
dimensionId: 'operational_efficiency',
|
||||
type: 'warning',
|
||||
title: 'Alta Variabilidad en Tiempos',
|
||||
description: 'Procesos poco estandarizados generan tiempos impredecibles y afectan la planificación.',
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
text: "Un 22% de las transferencias desde 'Soporte Técnico N1' hacia 'Facturación' son incorrectas.",
|
||||
dimensionId: 'efficiency',
|
||||
text: "Un 22% de las transferencias desde 'Soporte Técnico N1' hacia otras colas son incorrectas.",
|
||||
dimensionId: 'effectiveness_resolution',
|
||||
type: 'warning',
|
||||
title: 'Enrutamiento Incorrecto',
|
||||
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.",
|
||||
dimensionId: 'volumetry_distribution',
|
||||
dimensionId: 'agentic_readiness',
|
||||
type: 'info',
|
||||
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'
|
||||
},
|
||||
{
|
||||
text: "Baja puntuación de CSAT en interacciones relacionadas con problemas de facturación.",
|
||||
dimensionId: 'satisfaction',
|
||||
text: "FCR proxy <75% en colas de facturación, alto recontacto a 7 días.",
|
||||
dimensionId: 'effectiveness_resolution',
|
||||
type: 'warning',
|
||||
title: 'Satisfacción Baja en Facturación',
|
||||
description: 'El equipo de facturación tiene desempeño por debajo de la media en satisfacción del cliente.',
|
||||
title: 'Baja Resolución en Facturación',
|
||||
description: 'El equipo de facturación tiene alto % de recontactos, indicando problemas de resolución.',
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
text: "La variabilidad de AHT (CV=45%) sugiere procesos poco estandarizados.",
|
||||
dimensionId: 'performance',
|
||||
text: "Alta diversidad de tipificaciones y >20% llamadas con múltiples holds en colas complejas.",
|
||||
dimensionId: 'complexity_predictability',
|
||||
type: 'warning',
|
||||
title: 'Inconsistencia en Procesos',
|
||||
description: 'Alta variabilidad indica falta de estandarización y diferencias significativas entre agentes.',
|
||||
title: 'Alta Complejidad en Ciertas Colas',
|
||||
description: 'Colas con alta complejidad requieren optimización antes de considerar automatización.',
|
||||
impact: 'medium'
|
||||
},
|
||||
];
|
||||
|
||||
const RECOMMENDATIONS: Recommendation[] = [
|
||||
{
|
||||
text: "Implementar un programa de formación específico para agentes de Facturación sobre los nuevos planes.",
|
||||
dimensionId: 'efficiency',
|
||||
text: "Estandarizar procesos en colas con alto ratio P90/P50 para reducir variabilidad.",
|
||||
dimensionId: 'operational_efficiency',
|
||||
priority: 'high',
|
||||
title: 'Formación en Facturación',
|
||||
description: 'Capacitación intensiva en productos, políticas y procedimientos de facturación.',
|
||||
impact: 'Mejora estimada de satisfacción: 15-25%',
|
||||
timeline: '2-3 semanas'
|
||||
title: 'Estandarización de Procesos',
|
||||
description: 'Implementar scripts y guías paso a paso para reducir la variabilidad en tiempos de gestión.',
|
||||
impact: 'Reducción ratio P90/P50: 20-30%, Mejora predictibilidad',
|
||||
timeline: '3-4 semanas'
|
||||
},
|
||||
{
|
||||
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',
|
||||
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',
|
||||
timeline: '1-2 meses'
|
||||
},
|
||||
@@ -200,12 +187,12 @@ const RECOMMENDATIONS: Recommendation[] = [
|
||||
timeline: '1 mes'
|
||||
},
|
||||
{
|
||||
text: "Crear una Knowledge Base más robusta y accesible para reducir el tiempo en espera.",
|
||||
dimensionId: 'performance',
|
||||
text: "Crear una Knowledge Base más robusta para reducir hold time y mejorar FCR.",
|
||||
dimensionId: 'effectiveness_resolution',
|
||||
priority: 'high',
|
||||
title: 'Mejora de Acceso a Información',
|
||||
description: 'Desarrollar una KB centralizada integrada en el sistema de agentes con búsqueda inteligente.',
|
||||
impact: 'Reducción de AHT: 8-12%, Mejora de FCR: 5-10%',
|
||||
description: 'Desarrollar una KB centralizada para reducir búsquedas y mejorar resolución en primer contacto.',
|
||||
impact: 'Reducción hold time: 15-25%, Mejora FCR: 5-10%',
|
||||
timeline: '6-8 semanas'
|
||||
},
|
||||
{
|
||||
@@ -213,18 +200,18 @@ const RECOMMENDATIONS: Recommendation[] = [
|
||||
dimensionId: 'volumetry_distribution',
|
||||
priority: 'medium',
|
||||
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',
|
||||
timeline: '2-3 meses'
|
||||
},
|
||||
{
|
||||
text: "Realizar un análisis de causa raíz sobre las quejas de facturación para mejorar procesos.",
|
||||
dimensionId: 'satisfaction',
|
||||
text: "Simplificar tipificaciones y reducir complejidad en colas problemáticas.",
|
||||
dimensionId: 'complexity_predictability',
|
||||
priority: 'medium',
|
||||
title: 'Análisis de Causa Raíz (Facturación)',
|
||||
description: 'Investigar las 50 últimas quejas de facturación para identificar patrones y causas.',
|
||||
impact: 'Identificación de mejoras de proceso con ROI potencial de €20-50K',
|
||||
timeline: '2-3 semanas'
|
||||
title: 'Reducción de Complejidad',
|
||||
description: 'Consolidar tipificaciones y simplificar flujos para mejorar predictibilidad.',
|
||||
impact: 'Reducción de complejidad: 20-30%, Mejora Agentic Score',
|
||||
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 opportunities = [
|
||||
{ id: 'opp1', name: 'Automatizar consulta de pedidos', savings: 85000, dimensionId: 'volumetry_distribution', customer_segment: 'medium' as CustomerSegment },
|
||||
{ id: 'opp2', name: 'Implementar Knowledge Base dinámica', savings: 45000, dimensionId: 'performance', customer_segment: 'high' as CustomerSegment },
|
||||
{ id: 'opp3', name: 'Chatbot de triaje inicial', savings: 120000, dimensionId: 'efficiency', 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: '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: 'operational_efficiency', customer_segment: 'high' as CustomerSegment },
|
||||
{ id: 'opp3', name: 'Chatbot de triaje inicial', savings: 120000, dimensionId: 'effectiveness_resolution', customer_segment: 'medium' 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 },
|
||||
];
|
||||
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[] => {
|
||||
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: '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: '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: '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: '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: '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: 'effectiveness_resolution', 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' },
|
||||
];
|
||||
};
|
||||
@@ -797,13 +784,13 @@ const generateOpportunitiesFromHeatmap = (
|
||||
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 =
|
||||
readiness >= 70
|
||||
? 'volumetry_distribution'
|
||||
? 'agentic_readiness'
|
||||
: readiness >= 40
|
||||
? 'efficiency'
|
||||
: 'economy';
|
||||
? 'effectiveness_resolution'
|
||||
: 'complexity_predictability';
|
||||
|
||||
// Segmento de cliente (high/medium/low) si lo tenemos
|
||||
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' },
|
||||
];
|
||||
|
||||
// v2.0: Solo 6 dimensiones
|
||||
const dimensionKeys = ['volumetry_distribution', 'performance', 'satisfaction', 'economy', 'efficiency', 'benchmark'];
|
||||
// v3.0: 5 dimensiones viables
|
||||
const dimensionKeys = ['volumetry_distribution', 'operational_efficiency', 'effectiveness_resolution', 'complexity_predictability', 'agentic_readiness'];
|
||||
|
||||
const dimensions: DimensionAnalysis[] = dimensionKeys.map(key => {
|
||||
const content = DIMENSIONS_CONTENT[key as keyof typeof DIMENSIONS_CONTENT];
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
EconomicModelData,
|
||||
} from '../types';
|
||||
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';
|
||||
|
||||
|
||||
@@ -336,57 +336,40 @@ function buildVolumetryDimension(
|
||||
return { dimension, extraKpis };
|
||||
}
|
||||
|
||||
// ==== Performance (operational_performance) ====
|
||||
// ==== Eficiencia Operativa (v3.0) ====
|
||||
|
||||
function buildPerformanceDimension(
|
||||
function buildOperationalEfficiencyDimension(
|
||||
raw: BackendRawResults
|
||||
): DimensionAnalysis | undefined {
|
||||
const op = raw?.operational_performance;
|
||||
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 ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
|
||||
const ratio = safeNumber(op.aht_distribution?.p90_p50_ratio, 0);
|
||||
const escRate = safeNumber(op.escalation_rate, 0);
|
||||
const ratio = ahtP90 > 0 && ahtP50 > 0 ? ahtP90 / ahtP50 : safeNumber(op.aht_distribution?.p90_p50_ratio, 1.5);
|
||||
|
||||
let summary = `El AHT mediano se sitúa en ${Math.round(
|
||||
ahtP50
|
||||
)} 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
|
||||
)}%. `;
|
||||
// Score: menor ratio = mejor score (1.0 = 100, 3.0 = 0)
|
||||
const score = Math.max(0, Math.min(100, Math.round(100 - (ratio - 1) * 50)));
|
||||
|
||||
if (score >= 80) {
|
||||
summary +=
|
||||
'El rendimiento operativo es sólido y se encuentra claramente por encima de los umbrales objetivo.';
|
||||
} else if (score >= 60) {
|
||||
summary +=
|
||||
'El rendimiento es aceptable pero existen oportunidades claras de optimización en algunos flujos.';
|
||||
let summary = `AHT P50: ${Math.round(ahtP50)}s, P90: ${Math.round(ahtP90)}s. Ratio P90/P50: ${ratio.toFixed(2)}. `;
|
||||
|
||||
if (ratio < 1.5) {
|
||||
summary += 'Tiempos consistentes y procesos estandarizados.';
|
||||
} else if (ratio < 2.0) {
|
||||
summary += 'Variabilidad moderada, algunos casos outliers afectan la eficiencia.';
|
||||
} else {
|
||||
summary +=
|
||||
'El rendimiento operativo está por debajo del nivel deseado y requiere un plan de mejora específico.';
|
||||
summary += 'Alta variabilidad en tiempos, requiere estandarización de procesos.';
|
||||
}
|
||||
|
||||
const kpi: Kpi = {
|
||||
label: 'AHT mediano (P50)',
|
||||
value: ahtP50 ? `${Math.round(ahtP50)}s` : 'N/D',
|
||||
label: 'Ratio P90/P50',
|
||||
value: ratio.toFixed(2),
|
||||
};
|
||||
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'performance',
|
||||
name: 'performance',
|
||||
title: 'Rendimiento operativo',
|
||||
id: 'operational_efficiency',
|
||||
name: 'operational_efficiency',
|
||||
title: 'Eficiencia Operativa',
|
||||
score,
|
||||
percentile: undefined,
|
||||
summary,
|
||||
@@ -397,134 +380,49 @@ function buildPerformanceDimension(
|
||||
return dimension;
|
||||
}
|
||||
|
||||
// ==== Satisfacción (customer_satisfaction) ====
|
||||
// ==== Efectividad & Resolución (v3.0) ====
|
||||
|
||||
function buildSatisfactionDimension(
|
||||
raw: BackendRawResults
|
||||
): DimensionAnalysis | undefined {
|
||||
const cs = raw?.customer_satisfaction;
|
||||
if (!cs) return undefined;
|
||||
|
||||
// CSAT global viene ya calculado en el backend (1–5)
|
||||
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
|
||||
if (!Number.isFinite(csatGlobalRaw) || csatGlobalRaw <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Normalizamos 1–5 a 0–100
|
||||
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(
|
||||
function buildEffectivenessResolutionDimension(
|
||||
raw: BackendRawResults
|
||||
): DimensionAnalysis | undefined {
|
||||
const op = raw?.operational_performance;
|
||||
if (!op) return undefined;
|
||||
|
||||
// FCR: viene como porcentaje 0–100, o lo aproximamos a partir de escalaciones
|
||||
const fcrPctRaw = safeNumber(op.fcr_rate, NaN);
|
||||
const escRateRaw = safeNumber(op.escalation_rate, 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))
|
||||
: Number.isFinite(escRateRaw)
|
||||
? Math.max(0, Math.min(100, 100 - escRateRaw))
|
||||
: NaN;
|
||||
: Number.isFinite(recurrenceRaw)
|
||||
? Math.max(0, Math.min(100, 100 - recurrenceRaw))
|
||||
: 75; // valor por defecto
|
||||
|
||||
if (!Number.isFinite(fcrPct)) {
|
||||
// Sin FCR ni escalaciones no podemos construir bien la dimensión
|
||||
return undefined;
|
||||
}
|
||||
const transferRate = Number.isFinite(escRateRaw) ? escRateRaw : 15;
|
||||
|
||||
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
|
||||
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;
|
||||
}
|
||||
let summary = `FCR proxy 7d: ${fcrProxy.toFixed(1)}%. Tasa de transferencias: ${transferRate.toFixed(1)}%. `;
|
||||
|
||||
// Penalizar por recurrencia (repetición de contactos a 7 días)
|
||||
if (Number.isFinite(recurrenceRaw)) {
|
||||
const rec = recurrenceRaw as number; // asumimos ya en %
|
||||
if (rec > 20) score -= 15;
|
||||
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.';
|
||||
if (fcrProxy >= 85 && transferRate < 10) {
|
||||
summary += 'Excelente resolución en primer contacto, mínimas transferencias.';
|
||||
} else if (fcrProxy >= 70) {
|
||||
summary += 'Resolución aceptable, oportunidad de reducir recontactos y transferencias.';
|
||||
} else {
|
||||
summary +=
|
||||
'La eficiencia operativa es baja: hay demasiadas escalaciones o contactos repetidos, lo que impacta negativamente en costes y experiencia de cliente.';
|
||||
summary += 'Baja resolución, alto recontacto a 7 días. Requiere mejora de procesos.';
|
||||
}
|
||||
|
||||
const kpi: Kpi = {
|
||||
label: 'FCR estimado (backend)',
|
||||
value: `${fcrPct.toFixed(1)}%`,
|
||||
label: 'FCR Proxy 7d',
|
||||
value: `${fcrProxy.toFixed(1)}%`,
|
||||
};
|
||||
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'efficiency',
|
||||
name: 'efficiency',
|
||||
title: 'Resolución y eficiencia',
|
||||
id: 'effectiveness_resolution',
|
||||
name: 'effectiveness_resolution',
|
||||
title: 'Efectividad & Resolución',
|
||||
score,
|
||||
percentile: undefined,
|
||||
summary,
|
||||
@@ -535,6 +433,129 @@ function buildEfficiencyDimension(
|
||||
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) ====
|
||||
|
||||
@@ -627,58 +648,7 @@ function buildEconomicModel(raw: BackendRawResults): EconomicModelData {
|
||||
};
|
||||
}
|
||||
|
||||
function buildEconomyDimension(
|
||||
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;
|
||||
}
|
||||
// buildEconomyDimension eliminado en v3.0 - economía integrada en otras dimensiones y modelo económico
|
||||
|
||||
/**
|
||||
* Transforma el JSON del backend (results) al AnalysisData
|
||||
@@ -722,20 +692,20 @@ export function mapBackendResultsToAnalysisData(
|
||||
Math.min(100, Math.round(arScore * 10))
|
||||
);
|
||||
|
||||
// Dimensiones
|
||||
// v3.0: 5 dimensiones viables
|
||||
const { dimension: volumetryDimension, extraKpis } =
|
||||
buildVolumetryDimension(raw);
|
||||
const performanceDimension = buildPerformanceDimension(raw);
|
||||
const satisfactionDimension = buildSatisfactionDimension(raw);
|
||||
const economyDimension = buildEconomyDimension(raw);
|
||||
const efficiencyDimension = buildEfficiencyDimension(raw);
|
||||
const operationalEfficiencyDimension = buildOperationalEfficiencyDimension(raw);
|
||||
const effectivenessResolutionDimension = buildEffectivenessResolutionDimension(raw);
|
||||
const complexityPredictabilityDimension = buildComplexityPredictabilityDimension(raw);
|
||||
const agenticReadinessDimension = buildAgenticReadinessDimension(raw, tierFromFrontend || 'silver');
|
||||
|
||||
const dimensions: DimensionAnalysis[] = [];
|
||||
if (volumetryDimension) dimensions.push(volumetryDimension);
|
||||
if (performanceDimension) dimensions.push(performanceDimension);
|
||||
if (satisfactionDimension) dimensions.push(satisfactionDimension);
|
||||
if (economyDimension) dimensions.push(economyDimension);
|
||||
if (efficiencyDimension) dimensions.push(efficiencyDimension);
|
||||
if (operationalEfficiencyDimension) dimensions.push(operationalEfficiencyDimension);
|
||||
if (effectivenessResolutionDimension) dimensions.push(effectivenessResolutionDimension);
|
||||
if (complexityPredictabilityDimension) dimensions.push(complexityPredictabilityDimension);
|
||||
if (agenticReadinessDimension) dimensions.push(agenticReadinessDimension);
|
||||
|
||||
|
||||
const op = raw?.operational_performance;
|
||||
|
||||
@@ -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 { 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 { 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(
|
||||
interactions: RawInteraction[],
|
||||
@@ -298,69 +298,72 @@ function generateDimensionsFromRealData(
|
||||
const totalVolume = interactions.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 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 [
|
||||
// 1. VOLUMETRÍA & DISTRIBUCIÓN
|
||||
{
|
||||
id: 'volumetry_distribution',
|
||||
name: 'volumetry_distribution',
|
||||
title: 'Análisis de la Demanda',
|
||||
score: Math.min(100, Math.round((totalVolume / 200))), // Score basado en volumen
|
||||
title: 'Volumetría & Distribución',
|
||||
score: Math.min(100, Math.round((totalVolume / 200))),
|
||||
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') },
|
||||
icon: BarChartHorizontal
|
||||
},
|
||||
// 2. EFICIENCIA OPERATIVA
|
||||
{
|
||||
id: 'performance',
|
||||
name: 'performance',
|
||||
title: 'Rendimiento Operativo',
|
||||
score: Math.round(100 - (avgCV * 100)),
|
||||
id: 'operational_efficiency',
|
||||
name: 'operational_efficiency',
|
||||
title: 'Eficiencia Operativa',
|
||||
score: Math.max(0, Math.min(100, Math.round(100 - (avgRatio - 1) * 50))),
|
||||
percentile: 70,
|
||||
summary: avgCV < 0.4
|
||||
? 'El AHT muestra baja variabilidad, indicando procesos estandarizados.'
|
||||
: 'La variabilidad del AHT es alta, sugiriendo inconsistencia en procesos.',
|
||||
kpi: { label: 'AHT Promedio', value: `${avgAHT}s` },
|
||||
summary: `AHT P50: ${avgAHT}s. Ratio P90/P50 estimado: ${avgRatio.toFixed(2)}. Hold time promedio: ${Math.round(avgHoldTime)}s.`,
|
||||
kpi: { label: 'Ratio P90/P50', value: avgRatio.toFixed(2) },
|
||||
icon: Zap
|
||||
},
|
||||
// 3. EFECTIVIDAD & RESOLUCIÓN
|
||||
{
|
||||
id: 'satisfaction',
|
||||
name: 'satisfaction',
|
||||
title: 'Voz del Cliente',
|
||||
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',
|
||||
id: 'effectiveness_resolution',
|
||||
name: 'effectiveness_resolution',
|
||||
title: 'Efectividad & Resolución',
|
||||
score: Math.round(100 - avgTransferRate),
|
||||
percentile: 68,
|
||||
summary: `FCR estimado del ${(100 - avgTransferRate).toFixed(1)}%.`,
|
||||
kpi: { label: 'FCR', value: `${(100 - avgTransferRate).toFixed(1)}%` },
|
||||
summary: `FCR proxy: ${(100 - avgTransferRate).toFixed(1)}%. Tasa de transferencias: ${avgTransferRate.toFixed(1)}%.`,
|
||||
kpi: { label: 'FCR Proxy', value: `${(100 - avgTransferRate).toFixed(1)}%` },
|
||||
icon: Target
|
||||
},
|
||||
// 4. COMPLEJIDAD & PREDICTIBILIDAD
|
||||
{
|
||||
id: 'benchmark',
|
||||
name: 'benchmark',
|
||||
title: 'Contexto Competitivo',
|
||||
score: 75,
|
||||
id: 'complexity_predictability',
|
||||
name: 'complexity_predictability',
|
||||
title: 'Complejidad & Predictibilidad',
|
||||
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,
|
||||
summary: 'Métricas alineadas con benchmarks de la industria.',
|
||||
kpi: { label: 'Benchmark', value: 'P65' },
|
||||
icon: Globe
|
||||
summary: `Score: ${agenticScore.toFixed(1)}/10. ${agenticScore >= 8 ? 'Excelente para automatización.' : agenticScore >= 5 ? 'Candidato para asistencia IA.' : 'Requiere optimización previa.'}`,
|
||||
kpi: { label: 'Score', value: `${agenticScore.toFixed(1)}/10` },
|
||||
icon: Bot
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
43
nginx/conf.d/beyond.conf
Normal file
43
nginx/conf.d/beyond.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user