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:
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;
|
||||
Reference in New Issue
Block a user