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