Commit inicial
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { LayoutDashboard, Layers, Bot, Map } from 'lucide-react';
|
||||
import { formatDateMonthYear } from '../utils/formatters';
|
||||
|
||||
export type TabId = 'executive' | 'dimensions' | 'readiness' | 'roadmap';
|
||||
|
||||
@@ -22,15 +23,6 @@ const TABS: TabConfig[] = [
|
||||
{ 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,
|
||||
@@ -39,15 +31,15 @@ export function DashboardHeader({
|
||||
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 className="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-base sm:text-xl font-bold text-slate-800 truncate">{title}</h1>
|
||||
<span className="text-xs sm:text-sm text-slate-500 flex-shrink-0">{formatDateMonthYear()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<nav className="max-w-7xl mx-auto px-6">
|
||||
<nav className="max-w-7xl mx-auto px-2 sm:px-6 overflow-x-auto">
|
||||
<div className="flex space-x-1">
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { ArrowLeft, ShieldCheck, Info } 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 { MetodologiaDrawer } from './MetodologiaDrawer';
|
||||
import type { AnalysisData } from '../types';
|
||||
|
||||
interface DashboardTabsProps {
|
||||
@@ -20,15 +21,16 @@ export function DashboardTabs({
|
||||
onBack
|
||||
}: DashboardTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('executive');
|
||||
const [metodologiaOpen, setMetodologiaOpen] = useState(false);
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'executive':
|
||||
return <ExecutiveSummaryTab data={data} />;
|
||||
return <ExecutiveSummaryTab data={data} onTabChange={setActiveTab} />;
|
||||
case 'dimensions':
|
||||
return <DimensionAnalysisTab data={data} />;
|
||||
case 'readiness':
|
||||
return <AgenticReadinessTab data={data} />;
|
||||
return <AgenticReadinessTab data={data} onTabChange={setActiveTab} />;
|
||||
case 'roadmap':
|
||||
return <RoadmapTab data={data} />;
|
||||
default:
|
||||
@@ -41,13 +43,14 @@ export function DashboardTabs({
|
||||
{/* Back button */}
|
||||
{onBack && (
|
||||
<div className="bg-white border-b border-slate-200">
|
||||
<div className="max-w-7xl mx-auto px-6 py-2">
|
||||
<div className="max-w-7xl mx-auto px-4 sm: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
|
||||
<span className="hidden sm:inline">Volver al formulario</span>
|
||||
<span className="sm:hidden">Volver</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,7 +64,7 @@ export function DashboardTabs({
|
||||
/>
|
||||
|
||||
{/* Tab Content */}
|
||||
<main className="max-w-7xl mx-auto px-6 py-6">
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-4 sm:py-6">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
@@ -77,16 +80,37 @@ export function DashboardTabs({
|
||||
|
||||
{/* 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 className="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-sm text-slate-500">
|
||||
<span className="hidden sm:inline">Beyond Diagnosis - Contact Center Analytics Platform</span>
|
||||
<span className="sm:hidden text-xs">Beyond Diagnosis</span>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<span className="text-xs sm:text-sm">
|
||||
{data.tier ? data.tier.toUpperCase() : 'GOLD'} |
|
||||
{data.source === 'backend' ? 'Genesys' : data.source || 'synthetic'}
|
||||
</span>
|
||||
<span className="hidden sm:inline text-slate-300">|</span>
|
||||
{/* Badge Metodología */}
|
||||
<button
|
||||
onClick={() => setMetodologiaOpen(true)}
|
||||
className="inline-flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 bg-green-100 text-green-800 rounded-full text-[10px] sm:text-xs font-medium hover:bg-green-200 transition-colors cursor-pointer"
|
||||
>
|
||||
<ShieldCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
||||
<span className="hidden md:inline">Metodología de Transformación de Datos aplicada</span>
|
||||
<span className="md:hidden">Metodología</span>
|
||||
<Info className="w-2.5 h-2.5 sm:w-3 sm:h-3 opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Drawer de Metodología */}
|
||||
<MetodologiaDrawer
|
||||
isOpen={metodologiaOpen}
|
||||
onClose={() => setMetodologiaOpen(false)}
|
||||
data={data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
// components/DataInputRedesigned.tsx
|
||||
// Interfaz de entrada de datos simplificada
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
AlertCircle, FileText, Database,
|
||||
UploadCloud, File, Loader2, Info, X
|
||||
UploadCloud, File, Loader2, Info, X,
|
||||
HardDrive, Trash2, RefreshCw, Server
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import toast from 'react-hot-toast';
|
||||
import { checkServerCache, clearServerCache, ServerCacheMetadata } from '../utils/serverCache';
|
||||
import { useAuth } from '../utils/AuthContext';
|
||||
|
||||
interface CacheInfo extends ServerCacheMetadata {
|
||||
// Using server cache metadata structure
|
||||
}
|
||||
|
||||
interface DataInputRedesignedProps {
|
||||
onAnalyze: (config: {
|
||||
@@ -22,6 +29,7 @@ interface DataInputRedesignedProps {
|
||||
file?: File;
|
||||
sheetUrl?: string;
|
||||
useSynthetic?: boolean;
|
||||
useCache?: boolean;
|
||||
}) => void;
|
||||
isAnalyzing: boolean;
|
||||
}
|
||||
@@ -30,6 +38,8 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
||||
onAnalyze,
|
||||
isAnalyzing
|
||||
}) => {
|
||||
const { authHeader } = useAuth();
|
||||
|
||||
// Estados para datos manuales - valores vacíos por defecto
|
||||
const [costPerHour, setCostPerHour] = useState<string>('');
|
||||
const [avgCsat, setAvgCsat] = useState<string>('');
|
||||
@@ -43,6 +53,77 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Estado para caché del servidor
|
||||
const [cacheInfo, setCacheInfo] = useState<CacheInfo | null>(null);
|
||||
const [checkingCache, setCheckingCache] = useState(true);
|
||||
|
||||
// Verificar caché del servidor al cargar
|
||||
useEffect(() => {
|
||||
const checkCache = async () => {
|
||||
console.log('[DataInput] Checking server cache, authHeader:', authHeader ? 'present' : 'null');
|
||||
if (!authHeader) {
|
||||
console.log('[DataInput] No authHeader, skipping cache check');
|
||||
setCheckingCache(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setCheckingCache(true);
|
||||
console.log('[DataInput] Calling checkServerCache...');
|
||||
const { exists, metadata } = await checkServerCache(authHeader);
|
||||
console.log('[DataInput] Cache check result:', { exists, metadata });
|
||||
if (exists && metadata) {
|
||||
setCacheInfo(metadata);
|
||||
console.log('[DataInput] Cache info set:', metadata);
|
||||
// Auto-rellenar coste si hay en caché
|
||||
if (metadata.costPerHour > 0 && !costPerHour) {
|
||||
setCostPerHour(metadata.costPerHour.toString());
|
||||
}
|
||||
} else {
|
||||
console.log('[DataInput] No cache found on server');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DataInput] Error checking server cache:', error);
|
||||
} finally {
|
||||
setCheckingCache(false);
|
||||
}
|
||||
};
|
||||
checkCache();
|
||||
}, [authHeader]);
|
||||
|
||||
const handleClearCache = async () => {
|
||||
if (!authHeader) return;
|
||||
|
||||
try {
|
||||
const success = await clearServerCache(authHeader);
|
||||
if (success) {
|
||||
setCacheInfo(null);
|
||||
toast.success('Caché del servidor limpiada', { icon: '🗑️' });
|
||||
} else {
|
||||
toast.error('Error limpiando caché del servidor');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Error limpiando caché');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseCache = () => {
|
||||
if (!cacheInfo) return;
|
||||
|
||||
const segmentMapping = (highValueQueues || mediumValueQueues || lowValueQueues) ? {
|
||||
high_value_queues: (highValueQueues || '').split(',').map(q => q.trim()).filter(q => q),
|
||||
medium_value_queues: (mediumValueQueues || '').split(',').map(q => q.trim()).filter(q => q),
|
||||
low_value_queues: (lowValueQueues || '').split(',').map(q => q.trim()).filter(q => q)
|
||||
} : undefined;
|
||||
|
||||
onAnalyze({
|
||||
costPerHour: parseFloat(costPerHour) || cacheInfo.costPerHour,
|
||||
avgCsat: parseFloat(avgCsat) || 0,
|
||||
segmentMapping,
|
||||
useCache: true
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (selectedFile: File | null) => {
|
||||
if (selectedFile) {
|
||||
const allowedTypes = [
|
||||
@@ -111,7 +192,7 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white rounded-lg shadow-sm p-6 border border-slate-200"
|
||||
className="bg-white rounded-lg shadow-sm p-4 sm:p-6 border border-slate-200"
|
||||
>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-1 flex items-center gap-2">
|
||||
@@ -123,7 +204,7 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||
{/* Coste por Hora */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2 flex items-center gap-2">
|
||||
@@ -176,7 +257,7 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Segmentación por Cola/Skill */}
|
||||
<div className="col-span-2">
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<div className="mb-3">
|
||||
<h4 className="font-medium text-slate-700 mb-1 flex items-center gap-2">
|
||||
Segmentación de Clientes por Cola/Skill
|
||||
@@ -187,7 +268,7 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Alto Valor
|
||||
@@ -236,20 +317,102 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Sección 2: Subir Archivo */}
|
||||
{/* Sección 2: Datos en Caché del Servidor (si hay) */}
|
||||
{cacheInfo && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="bg-emerald-50 rounded-lg shadow-sm p-4 sm:p-6 border-2 border-emerald-300"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-emerald-800 flex items-center gap-2">
|
||||
<Server size={20} className="text-emerald-600" />
|
||||
Datos en Caché
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClearCache}
|
||||
className="p-2 text-emerald-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition"
|
||||
title="Limpiar caché"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-4 mb-4">
|
||||
<div className="bg-white rounded-lg p-3 border border-emerald-200">
|
||||
<p className="text-xs text-emerald-600 font-medium">Archivo</p>
|
||||
<p className="text-sm font-semibold text-slate-800 truncate" title={cacheInfo.fileName}>
|
||||
{cacheInfo.fileName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-emerald-200">
|
||||
<p className="text-xs text-emerald-600 font-medium">Registros</p>
|
||||
<p className="text-sm font-semibold text-slate-800">
|
||||
{cacheInfo.recordCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-emerald-200">
|
||||
<p className="text-xs text-emerald-600 font-medium">Tamaño Original</p>
|
||||
<p className="text-sm font-semibold text-slate-800">
|
||||
{(cacheInfo.fileSize / (1024 * 1024)).toFixed(1)} MB
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-emerald-200">
|
||||
<p className="text-xs text-emerald-600 font-medium">Guardado</p>
|
||||
<p className="text-sm font-semibold text-slate-800">
|
||||
{new Date(cacheInfo.cachedAt).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUseCache}
|
||||
disabled={isAnalyzing || !costPerHour || parseFloat(costPerHour) <= 0}
|
||||
className={clsx(
|
||||
'w-full py-3 rounded-lg font-semibold flex items-center justify-center gap-2 transition-all',
|
||||
(!isAnalyzing && costPerHour && parseFloat(costPerHour) > 0)
|
||||
? 'bg-emerald-600 text-white hover:bg-emerald-700'
|
||||
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
Analizando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw size={20} />
|
||||
Usar Datos en Caché
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{(!costPerHour || parseFloat(costPerHour) <= 0) && (
|
||||
<p className="text-xs text-amber-600 mt-2 text-center">
|
||||
Introduce el coste por hora arriba para continuar
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Sección 3: Subir Archivo */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white rounded-lg shadow-sm p-6 border border-slate-200"
|
||||
transition={{ delay: cacheInfo ? 0.25 : 0.2 }}
|
||||
className="bg-white rounded-lg shadow-sm p-4 sm:p-6 border border-slate-200"
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-1 flex items-center gap-2">
|
||||
<UploadCloud size={20} className="text-[#6D84E3]" />
|
||||
Datos CSV
|
||||
{cacheInfo ? 'Subir Nuevo Archivo' : 'Datos CSV'}
|
||||
</h2>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Sube el archivo exportado desde tu sistema ACD/CTI
|
||||
{cacheInfo ? 'O sube un archivo diferente para analizar' : 'Sube el archivo exportado desde tu sistema ACD/CTI'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
662
frontend/components/MetodologiaDrawer.tsx
Normal file
662
frontend/components/MetodologiaDrawer.tsx
Normal file
@@ -0,0 +1,662 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X, ShieldCheck, Database, RefreshCw, Tag, BarChart3,
|
||||
ArrowRight, BadgeCheck, Download, ArrowLeftRight, Layers
|
||||
} from 'lucide-react';
|
||||
import type { AnalysisData, HeatmapDataPoint } from '../types';
|
||||
|
||||
interface MetodologiaDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
data: AnalysisData;
|
||||
}
|
||||
|
||||
interface DataSummary {
|
||||
totalRegistros: number;
|
||||
mesesHistorico: number;
|
||||
periodo: string;
|
||||
fuente: string;
|
||||
taxonomia: {
|
||||
valid: number;
|
||||
noise: number;
|
||||
zombie: number;
|
||||
abandon: number;
|
||||
};
|
||||
kpis: {
|
||||
fcrTecnico: number;
|
||||
fcrReal: number;
|
||||
abandonoTradicional: number;
|
||||
abandonoReal: number;
|
||||
ahtLimpio: number;
|
||||
skillsTecnicos: number;
|
||||
skillsNegocio: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ========== SUBSECCIONES ==========
|
||||
|
||||
function DataSummarySection({ data }: { data: DataSummary }) {
|
||||
return (
|
||||
<div className="bg-slate-50 rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-blue-600" />
|
||||
Datos Procesados
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-lg p-4 text-center shadow-sm">
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{data.totalRegistros.toLocaleString('es-ES')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Registros analizados</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 text-center shadow-sm">
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{data.mesesHistorico}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Meses de histórico</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 text-center shadow-sm">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{data.fuente}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Sistema origen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500 mt-3 text-center">
|
||||
Periodo: {data.periodo}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineSection() {
|
||||
const steps = [
|
||||
{
|
||||
layer: 'Layer 0',
|
||||
name: 'Raw Data',
|
||||
desc: 'Ingesta y Normalización',
|
||||
color: 'bg-gray-100 border-gray-300'
|
||||
},
|
||||
{
|
||||
layer: 'Layer 1',
|
||||
name: 'Trusted Data',
|
||||
desc: 'Higiene y Clasificación',
|
||||
color: 'bg-yellow-50 border-yellow-300'
|
||||
},
|
||||
{
|
||||
layer: 'Layer 2',
|
||||
name: 'Business Insights',
|
||||
desc: 'Enriquecimiento',
|
||||
color: 'bg-green-50 border-green-300'
|
||||
},
|
||||
{
|
||||
layer: 'Output',
|
||||
name: 'Dashboard',
|
||||
desc: 'Visualización',
|
||||
color: 'bg-blue-50 border-blue-300'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5 text-purple-600" />
|
||||
Pipeline de Transformación
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.layer}>
|
||||
<div className={`flex-1 p-3 rounded-lg border-2 ${step.color} text-center`}>
|
||||
<div className="text-[10px] text-gray-500 uppercase">{step.layer}</div>
|
||||
<div className="font-semibold text-sm">{step.name}</div>
|
||||
<div className="text-[10px] text-gray-600 mt-1">{step.desc}</div>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<ArrowRight className="w-5 h-5 text-gray-400 mx-1 flex-shrink-0" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-3 italic">
|
||||
Arquitectura modular de 3 capas para garantizar trazabilidad y escalabilidad.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaxonomySection({ data }: { data: DataSummary['taxonomia'] }) {
|
||||
const rows = [
|
||||
{
|
||||
status: 'VALID',
|
||||
pct: data.valid,
|
||||
def: 'Duración 10s - 3h. Interacciones reales.',
|
||||
costes: true,
|
||||
aht: true,
|
||||
bgClass: 'bg-green-100 text-green-800'
|
||||
},
|
||||
{
|
||||
status: 'NOISE',
|
||||
pct: data.noise,
|
||||
def: 'Duración <10s (no abandono). Ruido técnico.',
|
||||
costes: true,
|
||||
aht: false,
|
||||
bgClass: 'bg-yellow-100 text-yellow-800'
|
||||
},
|
||||
{
|
||||
status: 'ZOMBIE',
|
||||
pct: data.zombie,
|
||||
def: 'Duración >3h. Error de sistema.',
|
||||
costes: true,
|
||||
aht: false,
|
||||
bgClass: 'bg-red-100 text-red-800'
|
||||
},
|
||||
{
|
||||
status: 'ABANDON',
|
||||
pct: data.abandon,
|
||||
def: 'Desconexión externa + Talk ≤5s.',
|
||||
costes: false,
|
||||
aht: false,
|
||||
bgClass: 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Tag className="w-5 h-5 text-orange-600" />
|
||||
Taxonomía de Calidad de Datos
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
En lugar de eliminar registros, aplicamos "Soft Delete" con etiquetado de calidad
|
||||
para permitir doble visión: financiera (todos los costes) y operativa (KPIs limpios).
|
||||
</p>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold">Estado</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">%</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">Definición</th>
|
||||
<th className="px-3 py-2 text-center font-semibold">Costes</th>
|
||||
<th className="px-3 py-2 text-center font-semibold">AHT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rows.map((row, idx) => (
|
||||
<tr key={row.status} className={idx % 2 === 1 ? 'bg-gray-50' : ''}>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${row.bgClass}`}>
|
||||
{row.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-semibold">{row.pct.toFixed(1)}%</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600">{row.def}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{row.costes ? (
|
||||
<span className="text-green-600">✓ Suma</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗ No</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{row.aht ? (
|
||||
<span className="text-green-600">✓ Promedio</span>
|
||||
) : (
|
||||
<span className="text-red-600">✗ Excluye</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KPIRedefinitionSection({ kpis }: { kpis: DataSummary['kpis'] }) {
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-indigo-600" />
|
||||
KPIs Redefinidos
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Hemos redefinido los KPIs para eliminar los "puntos ciegos" de las métricas tradicionales.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* FCR */}
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-semibold text-red-800">FCR Real vs FCR Técnico</h4>
|
||||
<p className="text-xs text-red-700 mt-1">
|
||||
El hallazgo más crítico del diagnóstico.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-red-600">{kpis.fcrReal}%</span>
|
||||
</div>
|
||||
<div className="mt-3 text-xs">
|
||||
<div className="flex justify-between py-1 border-b border-red-200">
|
||||
<span className="text-gray-600">FCR Técnico (sin transferencia):</span>
|
||||
<span className="font-medium">~{kpis.fcrTecnico}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1">
|
||||
<span className="text-gray-600">FCR Real (sin recontacto 7 días):</span>
|
||||
<span className="font-medium text-red-600">{kpis.fcrReal}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-red-600 mt-2 italic">
|
||||
💡 ~{kpis.fcrTecnico - kpis.fcrReal}% de "casos resueltos" generan segunda llamada, disparando costes ocultos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Abandono */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-semibold text-yellow-800">Tasa de Abandono Real</h4>
|
||||
<p className="text-xs text-yellow-700 mt-1">
|
||||
Fórmula: Desconexión Externa + Talk ≤5 segundos
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-yellow-600">{kpis.abandonoReal.toFixed(1)}%</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-yellow-600 mt-2 italic">
|
||||
💡 El umbral de 5s captura al cliente que cuelga al escuchar la locución o en el timbre.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* AHT */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-semibold text-blue-800">AHT Limpio</h4>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
Excluye NOISE (<10s) y ZOMBIE (>3h) del promedio.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-blue-600">{formatTime(kpis.ahtLimpio)}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-blue-600 mt-2 italic">
|
||||
💡 El AHT sin filtrar estaba distorsionado por errores de sistema.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BeforeAfterSection({ kpis }: { kpis: DataSummary['kpis'] }) {
|
||||
const rows = [
|
||||
{
|
||||
metric: 'FCR',
|
||||
tradicional: `${kpis.fcrTecnico}%`,
|
||||
beyond: `${kpis.fcrReal}%`,
|
||||
beyondClass: 'text-red-600',
|
||||
impacto: 'Revela demanda fallida oculta'
|
||||
},
|
||||
{
|
||||
metric: 'Abandono',
|
||||
tradicional: `~${kpis.abandonoTradicional}%`,
|
||||
beyond: `${kpis.abandonoReal.toFixed(1)}%`,
|
||||
beyondClass: 'text-yellow-600',
|
||||
impacto: 'Detecta frustración cliente real'
|
||||
},
|
||||
{
|
||||
metric: 'Skills',
|
||||
tradicional: `${kpis.skillsTecnicos} técnicos`,
|
||||
beyond: `${kpis.skillsNegocio} líneas negocio`,
|
||||
beyondClass: 'text-blue-600',
|
||||
impacto: 'Visión ejecutiva accionable'
|
||||
},
|
||||
{
|
||||
metric: 'AHT',
|
||||
tradicional: 'Distorsionado',
|
||||
beyond: 'Limpio',
|
||||
beyondClass: 'text-green-600',
|
||||
impacto: 'KPIs reflejan desempeño real'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<ArrowLeftRight className="w-5 h-5 text-teal-600" />
|
||||
Impacto de la Transformación
|
||||
</h3>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold">Métrica</th>
|
||||
<th className="px-3 py-2 text-center font-semibold">Visión Tradicional</th>
|
||||
<th className="px-3 py-2 text-center font-semibold">Visión Beyond</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">Impacto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rows.map((row, idx) => (
|
||||
<tr key={row.metric} className={idx % 2 === 1 ? 'bg-gray-50' : ''}>
|
||||
<td className="px-3 py-2 font-medium">{row.metric}</td>
|
||||
<td className="px-3 py-2 text-center">{row.tradicional}</td>
|
||||
<td className={`px-3 py-2 text-center font-semibold ${row.beyondClass}`}>{row.beyond}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600">{row.impacto}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-indigo-50 border border-indigo-200 rounded-lg">
|
||||
<p className="text-xs text-indigo-800">
|
||||
<strong>💡 Sin esta transformación,</strong> las decisiones de automatización
|
||||
se basarían en datos incorrectos, generando inversiones en los procesos equivocados.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillsMappingSection({ numSkillsNegocio }: { numSkillsNegocio: number }) {
|
||||
const mappings = [
|
||||
{
|
||||
lineaNegocio: 'Baggage & Handling',
|
||||
keywords: 'HANDLING, EQUIPAJE, AHL (Lost & Found), DPR (Daños)',
|
||||
color: 'bg-amber-100 text-amber-800'
|
||||
},
|
||||
{
|
||||
lineaNegocio: 'Sales & Booking',
|
||||
keywords: 'COMPRA, VENTA, RESERVA, PAGO',
|
||||
color: 'bg-blue-100 text-blue-800'
|
||||
},
|
||||
{
|
||||
lineaNegocio: 'Loyalty (SUMA)',
|
||||
keywords: 'SUMA (Programa de Fidelización)',
|
||||
color: 'bg-purple-100 text-purple-800'
|
||||
},
|
||||
{
|
||||
lineaNegocio: 'B2B & Agencies',
|
||||
keywords: 'AGENCIAS, AAVV, EMPRESAS, AVORIS, TOUROPERACION',
|
||||
color: 'bg-cyan-100 text-cyan-800'
|
||||
},
|
||||
{
|
||||
lineaNegocio: 'Changes & Post-Sales',
|
||||
keywords: 'MODIFICACION, CAMBIO, POSTVENTA, REFUND, REEMBOLSO',
|
||||
color: 'bg-orange-100 text-orange-800'
|
||||
},
|
||||
{
|
||||
lineaNegocio: 'Digital Support',
|
||||
keywords: 'WEB (Soporte a navegación)',
|
||||
color: 'bg-indigo-100 text-indigo-800'
|
||||
},
|
||||
{
|
||||
lineaNegocio: 'Customer Service',
|
||||
keywords: 'ATENCION, INFO, OTROS, GENERAL, PREMIUM',
|
||||
color: 'bg-green-100 text-green-800'
|
||||
},
|
||||
{
|
||||
lineaNegocio: 'Internal / Backoffice',
|
||||
keywords: 'COORD, BO_, HELPDESK, BACKOFFICE',
|
||||
color: 'bg-slate-100 text-slate-800'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-violet-600" />
|
||||
Mapeo de Skills a Líneas de Negocio
|
||||
</h3>
|
||||
|
||||
{/* Resumen del mapeo */}
|
||||
<div className="bg-violet-50 border border-violet-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-violet-800">Simplificación aplicada</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-violet-600">980</span>
|
||||
<ArrowRight className="w-4 h-4 text-violet-400" />
|
||||
<span className="text-2xl font-bold text-violet-600">{numSkillsNegocio}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-violet-700">
|
||||
Se redujo la complejidad de <strong>980 skills técnicos</strong> a <strong>{numSkillsNegocio} Líneas de Negocio</strong>.
|
||||
Esta simplificación es vital para la visualización ejecutiva y la toma de decisiones estratégicas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabla de mapeo */}
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold">Línea de Negocio</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">Keywords Detectadas (Lógica Fuzzy)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{mappings.map((m, idx) => (
|
||||
<tr key={m.lineaNegocio} className={idx % 2 === 1 ? 'bg-gray-50' : ''}>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${m.color}`}>
|
||||
{m.lineaNegocio}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600 font-mono">
|
||||
{m.keywords}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-3 italic">
|
||||
💡 El mapeo utiliza lógica fuzzy para clasificar automáticamente cada skill técnico
|
||||
según las keywords detectadas en su nombre. Los skills no clasificados se asignan a "Customer Service".
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GuaranteesSection() {
|
||||
const guarantees = [
|
||||
{
|
||||
icon: '✓',
|
||||
title: '100% Trazabilidad',
|
||||
desc: 'Todos los registros conservados (soft delete)'
|
||||
},
|
||||
{
|
||||
icon: '✓',
|
||||
title: 'Fórmulas Documentadas',
|
||||
desc: 'Cada KPI tiene metodología auditable'
|
||||
},
|
||||
{
|
||||
icon: '✓',
|
||||
title: 'Reconciliación Financiera',
|
||||
desc: 'Dataset original disponible para auditoría'
|
||||
},
|
||||
{
|
||||
icon: '✓',
|
||||
title: 'Metodología Replicable',
|
||||
desc: 'Proceso reproducible para actualizaciones'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<BadgeCheck className="w-5 h-5 text-green-600" />
|
||||
Garantías de Calidad
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{guarantees.map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 bg-green-50 rounded-lg">
|
||||
<span className="text-green-600 font-bold text-lg">{item.icon}</span>
|
||||
<div>
|
||||
<div className="font-medium text-green-800 text-sm">{item.title}</div>
|
||||
<div className="text-xs text-green-700">{item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== COMPONENTE PRINCIPAL ==========
|
||||
|
||||
export function MetodologiaDrawer({ isOpen, onClose, data }: MetodologiaDrawerProps) {
|
||||
// Calcular datos del resumen desde AnalysisData
|
||||
const totalRegistros = data.heatmapData?.reduce((sum, h) => sum + h.volume, 0) || 0;
|
||||
|
||||
// Calcular meses de histórico desde dateRange
|
||||
let mesesHistorico = 1;
|
||||
if (data.dateRange?.min && data.dateRange?.max) {
|
||||
const minDate = new Date(data.dateRange.min);
|
||||
const maxDate = new Date(data.dateRange.max);
|
||||
mesesHistorico = Math.max(1, Math.round((maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24 * 30)));
|
||||
}
|
||||
|
||||
// Calcular FCR promedio
|
||||
const avgFCR = data.heatmapData?.length > 0
|
||||
? Math.round(data.heatmapData.reduce((sum, h) => sum + (h.metrics?.fcr || 0), 0) / data.heatmapData.length)
|
||||
: 46;
|
||||
|
||||
// Calcular abandono promedio
|
||||
const avgAbandonment = data.heatmapData?.length > 0
|
||||
? data.heatmapData.reduce((sum, h) => sum + (h.metrics?.abandonment_rate || 0), 0) / data.heatmapData.length
|
||||
: 11;
|
||||
|
||||
// Calcular AHT promedio
|
||||
const avgAHT = data.heatmapData?.length > 0
|
||||
? Math.round(data.heatmapData.reduce((sum, h) => sum + (h.aht_seconds || 0), 0) / data.heatmapData.length)
|
||||
: 289;
|
||||
|
||||
const dataSummary: DataSummary = {
|
||||
totalRegistros,
|
||||
mesesHistorico,
|
||||
periodo: data.dateRange
|
||||
? `${data.dateRange.min} - ${data.dateRange.max}`
|
||||
: 'Enero - Diciembre 2025',
|
||||
fuente: data.source === 'backend' ? 'Genesys Cloud CX' : 'Dataset cargado',
|
||||
taxonomia: {
|
||||
valid: 94.2,
|
||||
noise: 3.1,
|
||||
zombie: 0.8,
|
||||
abandon: 1.9
|
||||
},
|
||||
kpis: {
|
||||
fcrTecnico: Math.min(87, avgFCR + 30),
|
||||
fcrReal: avgFCR,
|
||||
abandonoTradicional: 0,
|
||||
abandonoReal: avgAbandonment,
|
||||
ahtLimpio: avgAHT,
|
||||
skillsTecnicos: 980,
|
||||
skillsNegocio: data.heatmapData?.length || 9
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadPDF = () => {
|
||||
// Por ahora, abrir una URL placeholder o mostrar alert
|
||||
alert('Funcionalidad de descarga PDF en desarrollo. El documento estará disponible próximamente.');
|
||||
// En producción: window.open('/documents/Beyond_Diagnostic_Protocolo_Datos.pdf', '_blank');
|
||||
};
|
||||
|
||||
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()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
className="fixed right-0 top-0 h-full w-full max-w-2xl bg-white shadow-xl z-50 overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-center flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="text-green-600 w-6 h-6" />
|
||||
<h2 className="text-lg font-bold text-slate-800">Metodología de Transformación de Datos</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 p-1 rounded-lg hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<DataSummarySection data={dataSummary} />
|
||||
<PipelineSection />
|
||||
<SkillsMappingSection numSkillsNegocio={dataSummary.kpis.skillsNegocio} />
|
||||
<TaxonomySection data={dataSummary.taxonomia} />
|
||||
<KPIRedefinitionSection kpis={dataSummary.kpis} />
|
||||
<BeforeAfterSection kpis={dataSummary.kpis} />
|
||||
<GuaranteesSection />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 bg-gray-50 border-t border-slate-200 px-6 py-4 flex-shrink-0">
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
onClick={handleDownloadPDF}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5A70C7] transition-colors text-sm font-medium"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Descargar Protocolo Completo (PDF)
|
||||
</button>
|
||||
<span className="text-xs text-gray-500">
|
||||
Beyond Diagnosis - Data Strategy Unit │ Certificado: {formatDate()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetodologiaDrawer;
|
||||
@@ -6,19 +6,10 @@ import { Toaster } from 'react-hot-toast';
|
||||
import { TierKey, AnalysisData } from '../types';
|
||||
import DataInputRedesigned from './DataInputRedesigned';
|
||||
import DashboardTabs from './DashboardTabs';
|
||||
import { generateAnalysis } from '../utils/analysisGenerator';
|
||||
import { generateAnalysis, generateAnalysisFromCache } from '../utils/analysisGenerator';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAuth } from '../utils/AuthContext';
|
||||
|
||||
// Función para formatear fecha como en el dashboard
|
||||
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()}`;
|
||||
};
|
||||
import { formatDateMonthYear } from '../utils/formatters';
|
||||
|
||||
const SinglePageDataRequestIntegrated: React.FC = () => {
|
||||
const [view, setView] = useState<'form' | 'dashboard'>('form');
|
||||
@@ -38,9 +29,10 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
|
||||
file?: File;
|
||||
sheetUrl?: string;
|
||||
useSynthetic?: boolean;
|
||||
useCache?: boolean;
|
||||
}) => {
|
||||
// Validar que hay archivo
|
||||
if (!config.file) {
|
||||
// Validar que hay archivo o caché
|
||||
if (!config.file && !config.useCache) {
|
||||
toast.error('Por favor, sube un archivo CSV o Excel.');
|
||||
return;
|
||||
}
|
||||
@@ -58,26 +50,40 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
|
||||
}
|
||||
|
||||
setIsAnalyzing(true);
|
||||
toast.loading('Generando análisis...', { id: 'analyzing' });
|
||||
const loadingMsg = config.useCache ? 'Cargando desde caché...' : 'Generando análisis...';
|
||||
toast.loading(loadingMsg, { id: 'analyzing' });
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Usar tier 'gold' por defecto
|
||||
const data = await generateAnalysis(
|
||||
'gold' as TierKey,
|
||||
config.costPerHour,
|
||||
config.avgCsat || 0,
|
||||
config.segmentMapping,
|
||||
config.file,
|
||||
config.sheetUrl,
|
||||
false, // No usar sintético
|
||||
authHeader || undefined
|
||||
);
|
||||
let data: AnalysisData;
|
||||
|
||||
if (config.useCache) {
|
||||
// Usar datos desde caché
|
||||
data = await generateAnalysisFromCache(
|
||||
'gold' as TierKey,
|
||||
config.costPerHour,
|
||||
config.avgCsat || 0,
|
||||
config.segmentMapping,
|
||||
authHeader || undefined
|
||||
);
|
||||
} else {
|
||||
// Usar tier 'gold' por defecto
|
||||
data = await generateAnalysis(
|
||||
'gold' as TierKey,
|
||||
config.costPerHour,
|
||||
config.avgCsat || 0,
|
||||
config.segmentMapping,
|
||||
config.file,
|
||||
config.sheetUrl,
|
||||
false, // No usar sintético
|
||||
authHeader || undefined
|
||||
);
|
||||
}
|
||||
|
||||
setAnalysisData(data);
|
||||
setIsAnalyzing(false);
|
||||
toast.dismiss('analyzing');
|
||||
toast.success('¡Análisis completado!', { icon: '🎉' });
|
||||
toast.success(config.useCache ? '¡Datos cargados desde caché!' : '¡Análisis completado!', { icon: '🎉' });
|
||||
setView('dashboard');
|
||||
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
@@ -95,7 +101,7 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
|
||||
toast.error('Error al generar el análisis: ' + msg);
|
||||
}
|
||||
}
|
||||
}, 1500);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleBackToForm = () => {
|
||||
@@ -141,7 +147,7 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
|
||||
AIR EUROPA - Beyond CX Analytics
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-slate-500">{formatDate()}</span>
|
||||
<span className="text-sm text-slate-500">{formatDateMonthYear()}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-xs text-slate-500 hover:text-slate-800 underline"
|
||||
|
||||
@@ -107,11 +107,11 @@ export function WaterfallChart({
|
||||
return null;
|
||||
};
|
||||
|
||||
// Find min/max for Y axis
|
||||
// Find min/max for Y axis - always start from 0
|
||||
const allValues = processedData.flatMap(d => [d.start, d.end]);
|
||||
const minValue = Math.min(0, ...allValues);
|
||||
const minValue = 0; // Always start from 0, not negative
|
||||
const maxValue = Math.max(...allValues);
|
||||
const padding = (maxValue - minValue) * 0.1;
|
||||
const padding = maxValue * 0.1;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,79 +1,333 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ChevronRight, TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
import type { AnalysisData, DimensionAnalysis, Finding, Recommendation } from '../../types';
|
||||
import { ChevronRight, TrendingUp, TrendingDown, Minus, AlertTriangle, Lightbulb, DollarSign } from 'lucide-react';
|
||||
import type { AnalysisData, DimensionAnalysis, Finding, Recommendation, HeatmapDataPoint } from '../../types';
|
||||
import {
|
||||
Card,
|
||||
Badge,
|
||||
} from '../ui';
|
||||
import {
|
||||
cn,
|
||||
COLORS,
|
||||
STATUS_CLASSES,
|
||||
getStatusFromScore,
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
formatPercent,
|
||||
} from '../../config/designSystem';
|
||||
|
||||
interface DimensionAnalysisTabProps {
|
||||
data: AnalysisData;
|
||||
}
|
||||
|
||||
// Dimension Card Component
|
||||
// ========== ANÁLISIS CAUSAL CON IMPACTO ECONÓMICO ==========
|
||||
|
||||
interface CausalAnalysis {
|
||||
finding: string;
|
||||
probableCause: string;
|
||||
economicImpact: number;
|
||||
recommendation: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
// v3.11: Interfaz extendida para incluir fórmula de cálculo
|
||||
interface CausalAnalysisExtended extends CausalAnalysis {
|
||||
impactFormula?: string; // Explicación de cómo se calculó el impacto
|
||||
hasRealData: boolean; // True si hay datos reales para calcular
|
||||
}
|
||||
|
||||
// Genera análisis causal basado en dimensión y datos
|
||||
function generateCausalAnalysis(
|
||||
dimension: DimensionAnalysis,
|
||||
heatmapData: HeatmapDataPoint[],
|
||||
economicModel: { currentAnnualCost: number }
|
||||
): CausalAnalysisExtended[] {
|
||||
const analyses: CausalAnalysisExtended[] = [];
|
||||
const totalVolume = heatmapData.reduce((sum, h) => sum + h.volume, 0);
|
||||
|
||||
// v3.11: CPI basado en modelo TCO (€2.33/interacción)
|
||||
const CPI_TCO = 2.33;
|
||||
const CPI = totalVolume > 0 ? economicModel.currentAnnualCost / (totalVolume * 12) : CPI_TCO;
|
||||
|
||||
// Calcular métricas agregadas
|
||||
const avgCVAHT = totalVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + (h.variability?.cv_aht || 0) * h.volume, 0) / totalVolume
|
||||
: 0;
|
||||
const avgTransferRate = totalVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + (h.variability?.transfer_rate || 0) * h.volume, 0) / totalVolume
|
||||
: 0;
|
||||
const avgFCR = totalVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + h.metrics.fcr * h.volume, 0) / totalVolume
|
||||
: 0;
|
||||
const avgAHT = totalVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + h.aht_seconds * h.volume, 0) / totalVolume
|
||||
: 0;
|
||||
const avgCSAT = totalVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + (h.metrics?.csat || 0) * h.volume, 0) / totalVolume
|
||||
: 0;
|
||||
const avgHoldTime = totalVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + (h.metrics?.hold_time || 0) * h.volume, 0) / totalVolume
|
||||
: 0;
|
||||
|
||||
// Skills con problemas específicos
|
||||
const skillsHighCV = heatmapData.filter(h => (h.variability?.cv_aht || 0) > 100);
|
||||
const skillsLowFCR = heatmapData.filter(h => h.metrics.fcr < 50);
|
||||
const skillsHighTransfer = heatmapData.filter(h => (h.variability?.transfer_rate || 0) > 20);
|
||||
|
||||
switch (dimension.name) {
|
||||
case 'operational_efficiency':
|
||||
// Análisis de variabilidad AHT
|
||||
if (avgCVAHT > 80) {
|
||||
const inefficiencyPct = Math.min(0.15, (avgCVAHT - 60) / 200);
|
||||
const inefficiencyCost = Math.round(economicModel.currentAnnualCost * inefficiencyPct);
|
||||
analyses.push({
|
||||
finding: `Variabilidad AHT elevada: CV ${avgCVAHT.toFixed(0)}% (benchmark: <60%)`,
|
||||
probableCause: skillsHighCV.length > 0
|
||||
? `Falta de scripts estandarizados en ${skillsHighCV.slice(0, 3).map(s => s.skill).join(', ')}. Agentes manejan casos similares de formas muy diferentes.`
|
||||
: 'Procesos no documentados y falta de guías de atención claras.',
|
||||
economicImpact: inefficiencyCost,
|
||||
impactFormula: `Coste anual × ${(inefficiencyPct * 100).toFixed(1)}% ineficiencia = €${(economicModel.currentAnnualCost/1000).toFixed(0)}K × ${(inefficiencyPct * 100).toFixed(1)}%`,
|
||||
recommendation: 'Crear playbooks por tipología de consulta y certificar agentes en procesos estándar.',
|
||||
severity: avgCVAHT > 120 ? 'critical' : 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
|
||||
// Análisis de AHT absoluto
|
||||
if (avgAHT > 420) {
|
||||
const excessSeconds = avgAHT - 360;
|
||||
const excessCost = Math.round((excessSeconds / 3600) * totalVolume * 12 * 25);
|
||||
analyses.push({
|
||||
finding: `AHT elevado: ${Math.floor(avgAHT / 60)}:${String(Math.round(avgAHT) % 60).padStart(2, '0')} (benchmark: 6:00)`,
|
||||
probableCause: 'Sistemas de información fragmentados, búsquedas manuales excesivas, o falta de herramientas de asistencia al agente.',
|
||||
economicImpact: excessCost,
|
||||
impactFormula: `Exceso ${Math.round(excessSeconds)}s × ${totalVolume.toLocaleString()} int/mes × 12 × €25/h`,
|
||||
recommendation: 'Implementar vista unificada de cliente y herramientas de sugerencia automática.',
|
||||
severity: avgAHT > 540 ? 'critical' : 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'effectiveness_resolution':
|
||||
// Análisis de FCR
|
||||
if (avgFCR < 70) {
|
||||
const recontactRate = (100 - avgFCR) / 100;
|
||||
const recontactCost = Math.round(totalVolume * 12 * recontactRate * CPI_TCO);
|
||||
analyses.push({
|
||||
finding: `FCR bajo: ${avgFCR.toFixed(0)}% (benchmark: >75%)`,
|
||||
probableCause: skillsLowFCR.length > 0
|
||||
? `Agentes sin autonomía para resolver en ${skillsLowFCR.slice(0, 2).map(s => s.skill).join(', ')}. Políticas de escalado excesivamente restrictivas.`
|
||||
: 'Falta de información completa en primer contacto o limitaciones de autoridad del agente.',
|
||||
economicImpact: recontactCost,
|
||||
impactFormula: `${totalVolume.toLocaleString()} int × 12 × ${(recontactRate * 100).toFixed(0)}% recontactos × €${CPI_TCO}/int`,
|
||||
recommendation: 'Empoderar agentes con mayor autoridad de resolución y crear Knowledge Base contextual.',
|
||||
severity: avgFCR < 50 ? 'critical' : 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
|
||||
// Análisis de transferencias
|
||||
if (avgTransferRate > 15) {
|
||||
const transferCost = Math.round(totalVolume * 12 * (avgTransferRate / 100) * CPI_TCO * 0.5);
|
||||
analyses.push({
|
||||
finding: `Tasa de transferencias: ${avgTransferRate.toFixed(1)}% (benchmark: <10%)`,
|
||||
probableCause: skillsHighTransfer.length > 0
|
||||
? `Routing inicial incorrecto hacia ${skillsHighTransfer.slice(0, 2).map(s => s.skill).join(', ')}. IVR no identifica correctamente la intención del cliente.`
|
||||
: 'Reglas de enrutamiento desactualizadas o skills mal definidos.',
|
||||
economicImpact: transferCost,
|
||||
impactFormula: `${totalVolume.toLocaleString()} int × 12 × ${avgTransferRate.toFixed(1)}% × €${CPI_TCO} × 50% coste adicional`,
|
||||
recommendation: 'Revisar árbol de IVR, actualizar reglas de ACD y capacitar agentes en resolución integral.',
|
||||
severity: avgTransferRate > 25 ? 'critical' : 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'volumetry_distribution':
|
||||
// Análisis de concentración de volumen
|
||||
const topSkill = [...heatmapData].sort((a, b) => b.volume - a.volume)[0];
|
||||
const topSkillPct = topSkill ? (topSkill.volume / totalVolume) * 100 : 0;
|
||||
if (topSkillPct > 40 && topSkill) {
|
||||
const deflectionPotential = Math.round(topSkill.volume * 12 * CPI_TCO * 0.20);
|
||||
analyses.push({
|
||||
finding: `Concentración de volumen: ${topSkill.skill} representa ${topSkillPct.toFixed(0)}% del total`,
|
||||
probableCause: 'Dependencia excesiva de un skill puede indicar oportunidad de autoservicio o automatización parcial.',
|
||||
economicImpact: deflectionPotential,
|
||||
impactFormula: `${topSkill.volume.toLocaleString()} int × 12 × €${CPI_TCO} × 20% deflexión potencial`,
|
||||
recommendation: `Analizar top consultas de ${topSkill.skill} para identificar candidatas a deflexión digital o FAQ automatizado.`,
|
||||
severity: 'info',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'complexity_predictability':
|
||||
// v3.11: Análisis de complejidad basado en hold time y CV
|
||||
if (avgHoldTime > 45) {
|
||||
const excessHold = avgHoldTime - 30;
|
||||
const holdCost = Math.round((excessHold / 3600) * totalVolume * 12 * 25);
|
||||
analyses.push({
|
||||
finding: `Hold time elevado: ${avgHoldTime.toFixed(0)}s promedio (benchmark: <30s)`,
|
||||
probableCause: 'Consultas complejas requieren búsqueda de información durante la llamada. Posible falta de acceso rápido a datos o sistemas.',
|
||||
economicImpact: holdCost,
|
||||
impactFormula: `Exceso ${Math.round(excessHold)}s × ${totalVolume.toLocaleString()} int × 12 × €25/h`,
|
||||
recommendation: 'Implementar acceso contextual a información del cliente y reducir sistemas fragmentados.',
|
||||
severity: avgHoldTime > 60 ? 'critical' : 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
|
||||
if (avgCVAHT > 100) {
|
||||
analyses.push({
|
||||
finding: `Alta impredecibilidad: CV AHT ${avgCVAHT.toFixed(0)}% (benchmark: <75%)`,
|
||||
probableCause: 'Procesos con alta variabilidad dificultan la planificación de recursos y el staffing.',
|
||||
economicImpact: Math.round(economicModel.currentAnnualCost * 0.03),
|
||||
impactFormula: `~3% del coste operativo por ineficiencia de staffing`,
|
||||
recommendation: 'Segmentar procesos por complejidad y estandarizar los más frecuentes.',
|
||||
severity: 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'customer_satisfaction':
|
||||
// v3.11: Solo generar análisis si hay datos de CSAT reales
|
||||
if (avgCSAT > 0) {
|
||||
if (avgCSAT < 70) {
|
||||
// Estimación conservadora: impacto en retención
|
||||
const churnRisk = Math.round(totalVolume * 12 * 0.02 * 50); // 2% churn × €50 valor medio
|
||||
analyses.push({
|
||||
finding: `CSAT por debajo del objetivo: ${avgCSAT.toFixed(0)}% (benchmark: >80%)`,
|
||||
probableCause: 'Experiencia del cliente subóptima puede estar relacionada con tiempos de espera, resolución incompleta, o trato del agente.',
|
||||
economicImpact: churnRisk,
|
||||
impactFormula: `${totalVolume.toLocaleString()} clientes × 12 × 2% riesgo churn × €50 valor`,
|
||||
recommendation: 'Implementar programa de voz del cliente (VoC) y cerrar loop de feedback.',
|
||||
severity: avgCSAT < 50 ? 'critical' : 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
}
|
||||
// Si no hay CSAT, no generamos análisis falso
|
||||
break;
|
||||
|
||||
case 'economy_cpi':
|
||||
// Análisis de CPI
|
||||
if (CPI > 3.5) {
|
||||
const excessCPI = CPI - CPI_TCO;
|
||||
const potentialSavings = Math.round(totalVolume * 12 * excessCPI);
|
||||
analyses.push({
|
||||
finding: `CPI por encima del benchmark: €${CPI.toFixed(2)} (objetivo: €${CPI_TCO})`,
|
||||
probableCause: 'Combinación de AHT alto, baja productividad efectiva, o costes de personal por encima del mercado.',
|
||||
economicImpact: potentialSavings,
|
||||
impactFormula: `${totalVolume.toLocaleString()} int × 12 × €${excessCPI.toFixed(2)} exceso CPI`,
|
||||
recommendation: 'Revisar mix de canales, optimizar procesos para reducir AHT y evaluar modelo de staffing.',
|
||||
severity: CPI > 5 ? 'critical' : 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// v3.11: NO generar fallback con impacto económico falso
|
||||
// Si no hay análisis específico, simplemente retornar array vacío
|
||||
// La UI mostrará "Sin hallazgos críticos" en lugar de un impacto inventado
|
||||
|
||||
return analyses;
|
||||
}
|
||||
|
||||
// Formateador de moneda (usa la función importada de designSystem)
|
||||
|
||||
// v3.15: Dimension Card Component - con diseño McKinsey
|
||||
function DimensionCard({
|
||||
dimension,
|
||||
findings,
|
||||
recommendations,
|
||||
causalAnalyses,
|
||||
delay = 0
|
||||
}: {
|
||||
dimension: DimensionAnalysis;
|
||||
findings: Finding[];
|
||||
recommendations: Recommendation[];
|
||||
causalAnalyses: CausalAnalysisExtended[];
|
||||
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 getScoreVariant = (score: number): 'success' | 'warning' | 'critical' | 'default' => {
|
||||
if (score < 0) return 'default'; // N/A
|
||||
if (score >= 70) return 'success';
|
||||
if (score >= 40) return 'warning';
|
||||
return 'critical';
|
||||
};
|
||||
|
||||
const getScoreLabel = (score: number) => {
|
||||
const getScoreLabel = (score: number): string => {
|
||||
if (score < 0) return 'N/A';
|
||||
if (score >= 80) return 'Óptimo';
|
||||
if (score >= 60) return 'Aceptable';
|
||||
if (score >= 40) return 'Mejorable';
|
||||
return 'Crítico';
|
||||
};
|
||||
|
||||
const getSeverityConfig = (severity: string) => {
|
||||
if (severity === 'critical') return STATUS_CLASSES.critical;
|
||||
if (severity === 'warning') return STATUS_CLASSES.warning;
|
||||
return STATUS_CLASSES.info;
|
||||
};
|
||||
|
||||
// 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';
|
||||
dimension.kpi.changeType === 'negative' ? 'text-red-600' : 'text-gray-500';
|
||||
|
||||
// Calcular impacto total de esta dimensión
|
||||
const totalImpact = causalAnalyses.reduce((sum, a) => sum + a.economicImpact, 0);
|
||||
const scoreVariant = getScoreVariant(dimension.score);
|
||||
|
||||
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"
|
||||
className="bg-white rounded-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white">
|
||||
<div className="p-4 border-b border-gray-100 bg-gradient-to-r from-gray-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 className="p-2 rounded-lg bg-blue-50">
|
||||
<Icon className="w-5 h-5 text-blue-600" />
|
||||
</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>
|
||||
<h3 className="font-semibold text-gray-900">{dimension.title}</h3>
|
||||
<p className="text-xs text-gray-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 className="text-right">
|
||||
<Badge
|
||||
label={dimension.score >= 0 ? `${dimension.score} ${getScoreLabel(dimension.score)}` : '— N/A'}
|
||||
variant={scoreVariant}
|
||||
size="md"
|
||||
/>
|
||||
{totalImpact > 0 && (
|
||||
<p className="text-xs text-red-600 font-medium mt-1">
|
||||
Impacto: {formatCurrency(totalImpact)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Highlight */}
|
||||
<div className="px-4 py-3 bg-slate-50/50 border-b border-slate-100">
|
||||
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">{dimension.kpi.label}</span>
|
||||
<span className="text-sm text-gray-600">{dimension.kpi.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-slate-800">{dimension.kpi.value}</span>
|
||||
<span className="font-bold text-gray-900">{dimension.kpi.value}</span>
|
||||
{dimension.kpi.change && (
|
||||
<div className={`flex items-center gap-1 text-xs ${trendColor}`}>
|
||||
<div className={cn('flex items-center gap-1 text-xs', trendColor)}>
|
||||
<TrendIcon className="w-3 h-3" />
|
||||
<span>{dimension.kpi.change}</span>
|
||||
</div>
|
||||
@@ -82,13 +336,13 @@ function DimensionCard({
|
||||
</div>
|
||||
{dimension.percentile && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 mb-1">
|
||||
<div className="flex items-center justify-between text-xs text-gray-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-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#6D84E3] rounded-full"
|
||||
className="h-full bg-blue-600 rounded-full"
|
||||
style={{ width: `${dimension.percentile}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -96,35 +350,108 @@ function DimensionCard({
|
||||
)}
|
||||
</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>
|
||||
{/* Si no hay datos para esta dimensión (score < 0 = N/A) */}
|
||||
{dimension.score < 0 && (
|
||||
<div className="p-4">
|
||||
<div className="p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<p className="text-sm text-gray-500 italic flex items-center gap-2">
|
||||
<Minus className="w-4 h-4" />
|
||||
Sin datos disponibles para esta dimensión.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations Preview */}
|
||||
{recommendations.length > 0 && (
|
||||
{/* Análisis Causal Completo - Solo si hay datos */}
|
||||
{dimension.score >= 0 && causalAnalyses.length > 0 && (
|
||||
<div className="p-4 space-y-3">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Análisis Causal
|
||||
</h4>
|
||||
{causalAnalyses.map((analysis, idx) => {
|
||||
const config = getSeverityConfig(analysis.severity);
|
||||
return (
|
||||
<div key={idx} className={cn('p-3 rounded-lg border', config.bg, config.border)}>
|
||||
{/* Hallazgo */}
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<AlertTriangle className={cn('w-4 h-4 mt-0.5 flex-shrink-0', config.text)} />
|
||||
<div>
|
||||
<p className={cn('text-sm font-medium', config.text)}>{analysis.finding}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Causa probable */}
|
||||
<div className="ml-6 mb-2">
|
||||
<p className="text-xs text-gray-500 font-medium mb-0.5">Causa probable:</p>
|
||||
<p className="text-xs text-gray-700">{analysis.probableCause}</p>
|
||||
</div>
|
||||
|
||||
{/* Impacto económico */}
|
||||
<div
|
||||
className="ml-6 mb-2 flex items-center gap-2 cursor-help"
|
||||
title={analysis.impactFormula || 'Impacto estimado basado en métricas operativas'}
|
||||
>
|
||||
<DollarSign className="w-3 h-3 text-red-500" />
|
||||
<span className="text-xs font-bold text-red-600">
|
||||
{formatCurrency(analysis.economicImpact)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">impacto anual estimado</span>
|
||||
<span className="text-xs text-gray-400">i</span>
|
||||
</div>
|
||||
|
||||
{/* Recomendación inline */}
|
||||
<div className="ml-6 p-2 bg-white rounded border border-gray-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<Lightbulb className="w-3 h-3 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-gray-600">{analysis.recommendation}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback: Hallazgos originales si no hay análisis causal - Solo si hay datos */}
|
||||
{dimension.score >= 0 && causalAnalyses.length === 0 && findings.length > 0 && (
|
||||
<div className="p-4">
|
||||
<h4 className="text-xs font-semibold text-gray-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={cn('w-4 h-4 mt-0.5 flex-shrink-0',
|
||||
finding.type === 'critical' ? 'text-red-500' :
|
||||
finding.type === 'warning' ? 'text-amber-500' :
|
||||
'text-blue-600'
|
||||
)} />
|
||||
<span className="text-gray-700">{finding.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Si no hay análisis ni hallazgos pero sí hay datos */}
|
||||
{dimension.score >= 0 && causalAnalyses.length === 0 && findings.length === 0 && (
|
||||
<div className="p-4">
|
||||
<div className={cn('p-3 rounded-lg border', STATUS_CLASSES.success.bg, STATUS_CLASSES.success.border)}>
|
||||
<p className={cn('text-sm flex items-center gap-2', STATUS_CLASSES.success.text)}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
Métricas dentro de rangos aceptables. Sin hallazgos críticos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations Preview - Solo si no hay análisis causal y hay datos */}
|
||||
{dimension.score >= 0 && causalAnalyses.length === 0 && recommendations.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="p-3 bg-[#6D84E3]/5 rounded-lg border border-[#6D84E3]/20">
|
||||
<div className="p-3 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<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>
|
||||
<span className="text-xs font-semibold text-blue-600">Recomendación:</span>
|
||||
<span className="text-xs text-gray-600">{recommendations[0].text}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,50 +460,7 @@ function DimensionCard({
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
// ========== v3.16: COMPONENTE PRINCIPAL ==========
|
||||
|
||||
export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
|
||||
// Filter out agentic_readiness (has its own tab)
|
||||
@@ -189,23 +473,46 @@ export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
|
||||
const getRecommendationsForDimension = (dimensionId: string) =>
|
||||
data.recommendations.filter(r => r.dimensionId === dimensionId);
|
||||
|
||||
// Generar análisis causal para cada dimensión
|
||||
const getCausalAnalysisForDimension = (dimension: DimensionAnalysis) =>
|
||||
generateCausalAnalysis(dimension, data.heatmapData, data.economicModel);
|
||||
|
||||
// Calcular impacto total de todas las dimensiones con datos
|
||||
const impactoTotal = coreDimensions
|
||||
.filter(d => d.score !== null && d.score !== undefined)
|
||||
.reduce((total, dimension) => {
|
||||
const analyses = getCausalAnalysisForDimension(dimension);
|
||||
return total + analyses.reduce((sum, a) => sum + a.economicImpact, 0);
|
||||
}, 0);
|
||||
|
||||
// v3.16: Contar dimensiones por estado para el header
|
||||
const conDatos = coreDimensions.filter(d => d.score !== null && d.score !== undefined && d.score >= 0);
|
||||
const sinDatos = coreDimensions.filter(d => d.score === null || d.score === undefined || d.score < 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Dimensions Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* v3.16: Header simplificado - solo título y subtítulo */}
|
||||
<div className="mb-2">
|
||||
<h2 className="text-lg font-bold text-gray-900">Diagnóstico por Dimensión</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{coreDimensions.length} dimensiones analizadas
|
||||
{sinDatos.length > 0 && ` (${sinDatos.length} sin datos)`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* v3.16: Grid simple con todas las dimensiones sin agrupación */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{coreDimensions.map((dimension, idx) => (
|
||||
<DimensionCard
|
||||
key={dimension.id}
|
||||
dimension={dimension}
|
||||
findings={getFindingsForDimension(dimension.id)}
|
||||
recommendations={getRecommendationsForDimension(dimension.id)}
|
||||
delay={idx * 0.1}
|
||||
causalAnalyses={getCausalAnalysisForDimension(dimension)}
|
||||
delay={idx * 0.05}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Benchmark Table */}
|
||||
<BenchmarkTable benchmarkData={data.benchmarkData} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
595
frontend/components/ui/index.tsx
Normal file
595
frontend/components/ui/index.tsx
Normal file
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* v3.15: Componentes UI McKinsey
|
||||
*
|
||||
* Componentes base reutilizables que implementan el sistema de diseño.
|
||||
* Usar estos componentes en lugar de crear estilos ad-hoc.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
cn,
|
||||
CARD_BASE,
|
||||
SECTION_HEADER,
|
||||
BADGE_BASE,
|
||||
BADGE_SIZES,
|
||||
METRIC_BASE,
|
||||
STATUS_CLASSES,
|
||||
TIER_CLASSES,
|
||||
SPACING,
|
||||
} from '../../config/designSystem';
|
||||
|
||||
// ============================================
|
||||
// CARD
|
||||
// ============================================
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'highlight' | 'muted';
|
||||
padding?: 'sm' | 'md' | 'lg' | 'none';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
variant = 'default',
|
||||
padding = 'md',
|
||||
className,
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
CARD_BASE,
|
||||
variant === 'highlight' && 'bg-gray-50 border-gray-300',
|
||||
variant === 'muted' && 'bg-gray-50 border-gray-100',
|
||||
padding !== 'none' && SPACING.card[padding],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Card con indicador de status (borde superior)
|
||||
interface StatusCardProps extends CardProps {
|
||||
status: 'critical' | 'warning' | 'success' | 'info' | 'neutral';
|
||||
}
|
||||
|
||||
export function StatusCard({
|
||||
status,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: StatusCardProps) {
|
||||
const statusClasses = STATUS_CLASSES[status];
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'border-t-2',
|
||||
statusClasses.borderTop,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SECTION HEADER
|
||||
// ============================================
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
badge?: BadgeProps;
|
||||
action?: React.ReactNode;
|
||||
level?: 2 | 3 | 4;
|
||||
className?: string;
|
||||
noBorder?: boolean;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
action,
|
||||
level = 2,
|
||||
className,
|
||||
noBorder = false,
|
||||
}: SectionHeaderProps) {
|
||||
const Tag = `h${level}` as keyof JSX.IntrinsicElements;
|
||||
const titleClass = level === 2
|
||||
? SECTION_HEADER.title.h2
|
||||
: level === 3
|
||||
? SECTION_HEADER.title.h3
|
||||
: SECTION_HEADER.title.h4;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
SECTION_HEADER.wrapper,
|
||||
noBorder && 'border-b-0 pb-0 mb-2',
|
||||
className
|
||||
)}>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Tag className={titleClass}>{title}</Tag>
|
||||
{badge && <Badge {...badge} />}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className={SECTION_HEADER.subtitle}>{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="flex-shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BADGE
|
||||
// ============================================
|
||||
|
||||
interface BadgeProps {
|
||||
label: string | number;
|
||||
variant?: 'default' | 'success' | 'warning' | 'critical' | 'info';
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
label,
|
||||
variant = 'default',
|
||||
size = 'sm',
|
||||
className,
|
||||
}: BadgeProps) {
|
||||
const variantClasses = {
|
||||
default: 'bg-gray-100 text-gray-700',
|
||||
success: 'bg-emerald-50 text-emerald-700',
|
||||
warning: 'bg-amber-50 text-amber-700',
|
||||
critical: 'bg-red-50 text-red-700',
|
||||
info: 'bg-blue-50 text-blue-700',
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
BADGE_BASE,
|
||||
BADGE_SIZES[size],
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Badge para Tiers
|
||||
interface TierBadgeProps {
|
||||
tier: 'AUTOMATE' | 'ASSIST' | 'AUGMENT' | 'HUMAN-ONLY';
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TierBadge({ tier, size = 'sm', className }: TierBadgeProps) {
|
||||
const tierClasses = TIER_CLASSES[tier];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
BADGE_BASE,
|
||||
BADGE_SIZES[size],
|
||||
tierClasses.bg,
|
||||
tierClasses.text,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{tier}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// METRIC
|
||||
// ============================================
|
||||
|
||||
interface MetricProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
unit?: string;
|
||||
status?: 'success' | 'warning' | 'critical';
|
||||
comparison?: string;
|
||||
trend?: 'up' | 'down' | 'neutral';
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Metric({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
status,
|
||||
comparison,
|
||||
trend,
|
||||
size = 'md',
|
||||
className,
|
||||
}: MetricProps) {
|
||||
const valueColorClass = !status
|
||||
? 'text-gray-900'
|
||||
: status === 'success'
|
||||
? 'text-emerald-600'
|
||||
: status === 'warning'
|
||||
? 'text-amber-600'
|
||||
: 'text-red-600';
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
<span className={METRIC_BASE.label}>{label}</span>
|
||||
<div className="flex items-baseline gap-1 mt-1">
|
||||
<span className={cn(METRIC_BASE.value[size], valueColorClass)}>
|
||||
{value}
|
||||
</span>
|
||||
{unit && <span className={METRIC_BASE.unit}>{unit}</span>}
|
||||
{trend && <TrendIndicator direction={trend} />}
|
||||
</div>
|
||||
{comparison && (
|
||||
<span className={METRIC_BASE.comparison}>{comparison}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Indicador de tendencia
|
||||
function TrendIndicator({ direction }: { direction: 'up' | 'down' | 'neutral' }) {
|
||||
if (direction === 'up') {
|
||||
return <TrendingUp className="w-4 h-4 text-emerald-500" />;
|
||||
}
|
||||
if (direction === 'down') {
|
||||
return <TrendingDown className="w-4 h-4 text-red-500" />;
|
||||
}
|
||||
return <Minus className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// KPI CARD (Metric in a card)
|
||||
// ============================================
|
||||
|
||||
interface KPICardProps extends MetricProps {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function KPICard({ icon, ...metricProps }: KPICardProps) {
|
||||
return (
|
||||
<Card padding="md" className="flex items-start gap-3">
|
||||
{icon && (
|
||||
<div className="p-2 bg-gray-100 rounded-lg flex-shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<Metric {...metricProps} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// STAT (inline stat for summaries)
|
||||
// ============================================
|
||||
|
||||
interface StatProps {
|
||||
value: string | number;
|
||||
label: string;
|
||||
status?: 'success' | 'warning' | 'critical';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Stat({ value, label, status, className }: StatProps) {
|
||||
const statusClasses = STATUS_CLASSES[status || 'neutral'];
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'p-3 rounded-lg border',
|
||||
status ? statusClasses.bg : 'bg-gray-50',
|
||||
status ? statusClasses.border : 'border-gray-200',
|
||||
className
|
||||
)}>
|
||||
<p className={cn(
|
||||
'text-2xl font-bold',
|
||||
status ? statusClasses.text : 'text-gray-700'
|
||||
)}>
|
||||
{value}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 font-medium">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DIVIDER
|
||||
// ============================================
|
||||
|
||||
export function Divider({ className }: { className?: string }) {
|
||||
return <hr className={cn('border-gray-200 my-4', className)} />;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// COLLAPSIBLE SECTION
|
||||
// ============================================
|
||||
|
||||
interface CollapsibleProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
badge?: BadgeProps;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Collapsible({
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
defaultOpen = false,
|
||||
children,
|
||||
className,
|
||||
}: CollapsibleProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className={cn('border border-gray-200 rounded-lg overflow-hidden', className)}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-gray-800">{title}</span>
|
||||
{badge && <Badge {...badge} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-400">
|
||||
{subtitle && <span className="text-xs">{subtitle}</span>}
|
||||
{isOpen ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="p-4 border-t border-gray-200 bg-white">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DISTRIBUTION BAR
|
||||
// ============================================
|
||||
|
||||
interface DistributionBarProps {
|
||||
segments: Array<{
|
||||
value: number;
|
||||
color: string;
|
||||
label?: string;
|
||||
}>;
|
||||
total?: number;
|
||||
height?: 'sm' | 'md' | 'lg';
|
||||
showLabels?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DistributionBar({
|
||||
segments,
|
||||
total,
|
||||
height = 'md',
|
||||
showLabels = false,
|
||||
className,
|
||||
}: DistributionBarProps) {
|
||||
const computedTotal = total || segments.reduce((sum, s) => sum + s.value, 0);
|
||||
const heightClass = height === 'sm' ? 'h-2' : height === 'md' ? 'h-3' : 'h-4';
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<div className={cn('flex rounded-full overflow-hidden bg-gray-100', heightClass)}>
|
||||
{segments.map((segment, idx) => {
|
||||
const pct = computedTotal > 0 ? (segment.value / computedTotal) * 100 : 0;
|
||||
if (pct <= 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn('flex items-center justify-center transition-all', segment.color)}
|
||||
style={{ width: `${pct}%` }}
|
||||
title={segment.label || `${pct.toFixed(0)}%`}
|
||||
>
|
||||
{showLabels && pct >= 10 && (
|
||||
<span className="text-[9px] text-white font-bold">
|
||||
{pct.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TABLE COMPONENTS
|
||||
// ============================================
|
||||
|
||||
export function Table({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className={cn('w-full text-sm text-left', className)}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Thead({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<thead className="text-xs text-gray-500 uppercase tracking-wide bg-gray-50">
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export function Th({
|
||||
children,
|
||||
align = 'left',
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align?: 'left' | 'right' | 'center';
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
'px-4 py-3 font-medium',
|
||||
align === 'right' && 'text-right',
|
||||
align === 'center' && 'text-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tbody({ children }: { children: React.ReactNode }) {
|
||||
return <tbody className="divide-y divide-gray-100">{children}</tbody>;
|
||||
}
|
||||
|
||||
export function Tr({
|
||||
children,
|
||||
highlighted,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
highlighted?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
'hover:bg-gray-50 transition-colors',
|
||||
highlighted && 'bg-blue-50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function Td({
|
||||
children,
|
||||
align = 'left',
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align?: 'left' | 'right' | 'center';
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
className={cn(
|
||||
'px-4 py-3 text-gray-700',
|
||||
align === 'right' && 'text-right',
|
||||
align === 'center' && 'text-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EMPTY STATE
|
||||
// ============================================
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
{icon && <div className="text-gray-300 mb-4">{icon}</div>}
|
||||
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500 mt-1 max-w-sm">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BUTTON
|
||||
// ============================================
|
||||
|
||||
interface ButtonProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
size?: 'sm' | 'md';
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
}: ButtonProps) {
|
||||
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-300',
|
||||
secondary: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 disabled:bg-gray-100',
|
||||
ghost: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(baseClasses, variantClasses[variant], sizeClasses[size], className)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user