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,159 @@
import { useMemo } from 'react';
export interface BulletChartProps {
label: string;
actual: number;
target: number;
ranges: [number, number, number]; // [poor, satisfactory, good/max]
unit?: string;
percentile?: number;
inverse?: boolean; // true if lower is better (e.g., AHT)
formatValue?: (value: number) => string;
}
export function BulletChart({
label,
actual,
target,
ranges,
unit = '',
percentile,
inverse = false,
formatValue = (v) => v.toLocaleString()
}: BulletChartProps) {
const [poor, satisfactory, max] = ranges;
const { actualPercent, targetPercent, rangePercents, performance } = useMemo(() => {
const actualPct = Math.min((actual / max) * 100, 100);
const targetPct = Math.min((target / max) * 100, 100);
const poorPct = (poor / max) * 100;
const satPct = (satisfactory / max) * 100;
// Determine performance level
let perf: 'poor' | 'satisfactory' | 'good';
if (inverse) {
// Lower is better (e.g., AHT, hold time)
if (actual <= satisfactory) perf = 'good';
else if (actual <= poor) perf = 'satisfactory';
else perf = 'poor';
} else {
// Higher is better (e.g., FCR, CSAT)
if (actual >= satisfactory) perf = 'good';
else if (actual >= poor) perf = 'satisfactory';
else perf = 'poor';
}
return {
actualPercent: actualPct,
targetPercent: targetPct,
rangePercents: { poor: poorPct, satisfactory: satPct },
performance: perf
};
}, [actual, target, ranges, inverse, poor, satisfactory, max]);
const performanceColors = {
poor: 'bg-red-500',
satisfactory: 'bg-amber-500',
good: 'bg-emerald-500'
};
const performanceLabels = {
poor: 'Crítico',
satisfactory: 'Aceptable',
good: 'Óptimo'
};
return (
<div className="bg-white rounded-lg p-4 border border-slate-200">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-800">{label}</span>
{percentile !== undefined && (
<span className="text-xs px-2 py-0.5 bg-slate-100 text-slate-600 rounded-full">
P{percentile}
</span>
)}
</div>
<span className={`text-xs px-2 py-1 rounded-full ${
performance === 'good' ? 'bg-emerald-100 text-emerald-700' :
performance === 'satisfactory' ? 'bg-amber-100 text-amber-700' :
'bg-red-100 text-red-700'
}`}>
{performanceLabels[performance]}
</span>
</div>
{/* Bullet Chart */}
<div className="relative h-8 mb-2">
{/* Background ranges */}
<div className="absolute inset-0 flex rounded overflow-hidden">
{inverse ? (
// Inverse: green on left, red on right
<>
<div
className="h-full bg-emerald-100"
style={{ width: `${rangePercents.satisfactory}%` }}
/>
<div
className="h-full bg-amber-100"
style={{ width: `${rangePercents.poor - rangePercents.satisfactory}%` }}
/>
<div
className="h-full bg-red-100"
style={{ width: `${100 - rangePercents.poor}%` }}
/>
</>
) : (
// Normal: red on left, green on right
<>
<div
className="h-full bg-red-100"
style={{ width: `${rangePercents.poor}%` }}
/>
<div
className="h-full bg-amber-100"
style={{ width: `${rangePercents.satisfactory - rangePercents.poor}%` }}
/>
<div
className="h-full bg-emerald-100"
style={{ width: `${100 - rangePercents.satisfactory}%` }}
/>
</>
)}
</div>
{/* Actual value bar */}
<div
className={`absolute top-1/2 -translate-y-1/2 h-4 rounded ${performanceColors[performance]}`}
style={{ width: `${actualPercent}%`, minWidth: '4px' }}
/>
{/* Target marker */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-slate-800"
style={{ left: `${targetPercent}%` }}
>
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-0 h-0 border-l-[4px] border-r-[4px] border-t-[6px] border-l-transparent border-r-transparent border-t-slate-800" />
</div>
</div>
{/* Values */}
<div className="flex items-center justify-between text-sm">
<div>
<span className="font-bold text-slate-800">{formatValue(actual)}</span>
<span className="text-slate-500">{unit}</span>
<span className="text-slate-400 ml-1">actual</span>
</div>
<div className="text-slate-500">
<span className="text-slate-600">{formatValue(target)}</span>
<span>{unit}</span>
<span className="ml-1">benchmark</span>
</div>
</div>
</div>
);
}
export default BulletChart;

View File

@@ -0,0 +1,214 @@
import { Treemap, ResponsiveContainer, Tooltip } from 'recharts';
export type ReadinessCategory = 'automate_now' | 'assist_copilot' | 'optimize_first';
export interface TreemapData {
name: string;
value: number; // Savings potential (determines size)
category: ReadinessCategory;
skill: string;
score: number; // Agentic readiness score 0-10
volume?: number;
}
export interface OpportunityTreemapProps {
data: TreemapData[];
title?: string;
height?: number;
onItemClick?: (item: TreemapData) => void;
}
const CATEGORY_COLORS: Record<ReadinessCategory, string> = {
automate_now: '#059669', // emerald-600
assist_copilot: '#6D84E3', // primary blue
optimize_first: '#D97706' // amber-600
};
const CATEGORY_LABELS: Record<ReadinessCategory, string> = {
automate_now: 'Automatizar Ahora',
assist_copilot: 'Asistir con Copilot',
optimize_first: 'Optimizar Primero'
};
interface TreemapContentProps {
x: number;
y: number;
width: number;
height: number;
name: string;
category: ReadinessCategory;
score: number;
value: number;
}
const CustomizedContent = ({
x,
y,
width,
height,
name,
category,
score,
value
}: TreemapContentProps) => {
const showLabel = width > 60 && height > 40;
const showScore = width > 80 && height > 55;
const showValue = width > 100 && height > 70;
const baseColor = CATEGORY_COLORS[category] || '#94A3B8';
return (
<g>
<rect
x={x}
y={y}
width={width}
height={height}
style={{
fill: baseColor,
stroke: '#fff',
strokeWidth: 2,
opacity: 0.85 + (score / 10) * 0.15 // Higher score = more opaque
}}
rx={4}
/>
{showLabel && (
<text
x={x + width / 2}
y={y + height / 2 - (showScore ? 8 : 0)}
textAnchor="middle"
dominantBaseline="middle"
style={{
fontSize: Math.min(12, width / 8),
fontWeight: 600,
fill: '#fff',
textShadow: '0 1px 2px rgba(0,0,0,0.3)'
}}
>
{name.length > 15 && width < 120 ? `${name.slice(0, 12)}...` : name}
</text>
)}
{showScore && (
<text
x={x + width / 2}
y={y + height / 2 + 10}
textAnchor="middle"
dominantBaseline="middle"
style={{
fontSize: 10,
fill: 'rgba(255,255,255,0.9)'
}}
>
Score: {score.toFixed(1)}
</text>
)}
{showValue && (
<text
x={x + width / 2}
y={y + height / 2 + 24}
textAnchor="middle"
dominantBaseline="middle"
style={{
fontSize: 9,
fill: 'rgba(255,255,255,0.8)'
}}
>
{(value / 1000).toFixed(0)}K
</text>
)}
</g>
);
};
interface TooltipPayload {
payload: TreemapData;
}
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-white px-3 py-2 shadow-lg rounded-lg border border-slate-200">
<p className="font-semibold text-slate-800">{data.name}</p>
<p className="text-xs text-slate-500 mb-2">{data.skill}</p>
<div className="space-y-1 text-sm">
<div className="flex justify-between gap-4">
<span className="text-slate-600">Readiness Score:</span>
<span className="font-medium">{data.score.toFixed(1)}/10</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-slate-600">Ahorro Potencial:</span>
<span className="font-medium text-emerald-600">{data.value.toLocaleString()}</span>
</div>
{data.volume && (
<div className="flex justify-between gap-4">
<span className="text-slate-600">Volumen:</span>
<span className="font-medium">{data.volume.toLocaleString()}/mes</span>
</div>
)}
<div className="flex justify-between gap-4">
<span className="text-slate-600">Categoría:</span>
<span
className="font-medium"
style={{ color: CATEGORY_COLORS[data.category] }}
>
{CATEGORY_LABELS[data.category]}
</span>
</div>
</div>
</div>
);
}
return null;
};
export function OpportunityTreemap({
data,
title,
height = 350,
onItemClick
}: OpportunityTreemapProps) {
// Group data by category for treemap
const treemapData = data.map(item => ({
...item,
size: item.value
}));
return (
<div className="bg-white rounded-lg p-4 border border-slate-200">
{title && (
<h3 className="font-semibold text-slate-800 mb-4">{title}</h3>
)}
<ResponsiveContainer width="100%" height={height}>
<Treemap
data={treemapData}
dataKey="size"
aspectRatio={4 / 3}
stroke="#fff"
content={<CustomizedContent x={0} y={0} width={0} height={0} name="" category="automate_now" score={0} value={0} />}
onClick={onItemClick ? (node) => onItemClick(node as unknown as TreemapData) : undefined}
>
<Tooltip content={<CustomTooltip />} />
</Treemap>
</ResponsiveContainer>
{/* Legend */}
<div className="flex items-center justify-center gap-6 mt-4 text-xs">
{Object.entries(CATEGORY_COLORS).map(([category, color]) => (
<div key={category} className="flex items-center gap-1.5">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: color }}
/>
<span className="text-slate-600">
{CATEGORY_LABELS[category as ReadinessCategory]}
</span>
</div>
))}
</div>
</div>
);
}
export default OpportunityTreemap;

View File

@@ -0,0 +1,197 @@
import {
ComposedChart,
Bar,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
LabelList
} from 'recharts';
export interface WaterfallDataPoint {
label: string;
value: number;
cumulative: number;
type: 'initial' | 'increase' | 'decrease' | 'total';
}
export interface WaterfallChartProps {
data: WaterfallDataPoint[];
title?: string;
height?: number;
formatValue?: (value: number) => string;
}
interface ProcessedDataPoint {
label: string;
value: number;
cumulative: number;
type: 'initial' | 'increase' | 'decrease' | 'total';
start: number;
end: number;
displayValue: number;
}
export function WaterfallChart({
data,
title,
height = 300,
formatValue = (v) => `${Math.abs(v).toLocaleString()}`
}: WaterfallChartProps) {
// Process data for waterfall visualization
const processedData: ProcessedDataPoint[] = data.map((item) => {
let start: number;
let end: number;
if (item.type === 'initial' || item.type === 'total') {
start = 0;
end = item.cumulative;
} else if (item.type === 'decrease') {
// Savings: bar goes down from previous cumulative
start = item.cumulative;
end = item.cumulative - item.value;
} else {
// Increase: bar goes up from previous cumulative
start = item.cumulative - item.value;
end = item.cumulative;
}
return {
...item,
start: Math.min(start, end),
end: Math.max(start, end),
displayValue: Math.abs(item.value)
};
});
const getBarColor = (type: string): string => {
switch (type) {
case 'initial':
return '#64748B'; // slate-500
case 'decrease':
return '#059669'; // emerald-600 (savings)
case 'increase':
return '#DC2626'; // red-600 (costs)
case 'total':
return '#6D84E3'; // primary blue
default:
return '#94A3B8';
}
};
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: ProcessedDataPoint }> }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-white px-3 py-2 shadow-lg rounded-lg border border-slate-200">
<p className="font-medium text-slate-800">{data.label}</p>
<p className={`text-sm ${
data.type === 'decrease' ? 'text-emerald-600' :
data.type === 'increase' ? 'text-red-600' :
'text-slate-600'
}`}>
{data.type === 'decrease' ? '-' : data.type === 'increase' ? '+' : ''}
{formatValue(data.value)}
</p>
{data.type !== 'initial' && data.type !== 'total' && (
<p className="text-xs text-slate-500">
Acumulado: {formatValue(data.cumulative)}
</p>
)}
</div>
);
}
return null;
};
// Find min/max for Y axis
const allValues = processedData.flatMap(d => [d.start, d.end]);
const minValue = Math.min(0, ...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
return (
<div className="bg-white rounded-lg p-4 border border-slate-200">
{title && (
<h3 className="font-semibold text-slate-800 mb-4">{title}</h3>
)}
<ResponsiveContainer width="100%" height={height}>
<ComposedChart
data={processedData}
margin={{ top: 20, right: 20, left: 20, bottom: 60 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="#E2E8F0"
vertical={false}
/>
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: '#64748B' }}
tickLine={false}
axisLine={{ stroke: '#E2E8F0' }}
angle={-45}
textAnchor="end"
height={80}
interval={0}
/>
<YAxis
domain={[minValue - padding, maxValue + padding]}
tick={{ fontSize: 11, fill: '#64748B' }}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${(value / 1000).toFixed(0)}K`}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine y={0} stroke="#94A3B8" strokeWidth={1} />
{/* Invisible bar for spacing (from 0 to start) */}
<Bar dataKey="start" stackId="waterfall" fill="transparent" />
{/* Visible bar (the actual segment) */}
<Bar
dataKey="displayValue"
stackId="waterfall"
radius={[4, 4, 0, 0]}
>
{processedData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={getBarColor(entry.type)} />
))}
<LabelList
dataKey="displayValue"
position="top"
formatter={(value: number) => formatValue(value)}
style={{ fontSize: 10, fill: '#475569' }}
/>
</Bar>
</ComposedChart>
</ResponsiveContainer>
{/* Legend */}
<div className="flex items-center justify-center gap-6 mt-4 text-xs">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-slate-500" />
<span className="text-slate-600">Coste Base</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-emerald-600" />
<span className="text-slate-600">Ahorro</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-red-600" />
<span className="text-slate-600">Inversión</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-[#6D84E3]" />
<span className="text-slate-600">Total</span>
</div>
</div>
</div>
);
}
export default WaterfallChart;