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

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

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

View File

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

View File

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

View File

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

View File

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