Compare commits

..

10 Commits

Author SHA1 Message Date
Susana
62454c6b6a Commit inicial 2026-01-18 19:15:34 +00:00
Susana
522b4b6caa feat: Mejorar visualización del resumen ejecutivo
- KPIs compactos en una sola tarjeta horizontal (4 métricas)
- Health Score con desglose de factores (FCR, AHT, Transferencias, CSAT)
- Barras de progreso por cada factor con estado y insight
- Insight contextual según el score
- Diseño más profesional y menos espacio vacío

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:03:47 +00:00
Susana
806e32429d feat: Simplificar página de entrada de datos
- Cabecera estilo dashboard (AIR EUROPA + fecha)
- Eliminar selección de tier (usar gold por defecto)
- Campos manuales vacíos por defecto
- Solo opción de subir archivo CSV/Excel
- Eliminar tabla de campos, plantilla y datos sintéticos

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:56:20 +00:00
Susana
8082a14e1b fix: Corregir cálculo de transfer_rate en métricas
- transfer_rate ahora muestra el % real de transferencias
- FCR = 100 - transfer_rate (resolución en primer contacto)
- Antes ambos mostraban el mismo valor (FCR)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:48:43 +00:00
Susana
7e24f4eb31 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>
2026-01-12 08:41:20 +00:00
igferne
fdfb520710 Actualización de límites NGINX 2026-01-08 17:19:09 +01:00
igferne
5a1fb1e767 Refactor Recurrence 7d 2026-01-08 17:08:47 +01:00
igferne
d8fecb1cb7 Installer url repo fix 2026-01-07 15:57:29 +01:00
igferne
1315417c53 Despliegue con token git 2026-01-07 14:06:17 +01:00
igferne
49b2677783 Readme 2026-01-07 13:45:40 +01:00
37 changed files with 14776 additions and 1353 deletions

151
CLEANUP_PLAN.md Normal file
View File

@@ -0,0 +1,151 @@
# Code Cleanup Plan - Beyond Diagnosis
## Summary
After analyzing all project files, I've identified the following issues to clean up:
---
## 1. UNUSED COMPONENT FILES (25 files)
These components form orphaned chains - they are not imported anywhere in the active codebase. The main app flow is:
- `App.tsx``SinglePageDataRequestIntegrated``DashboardTabs` → Tab components
### DashboardEnhanced Chain (5 files)
Files only used by `DashboardEnhanced.tsx` which itself is never imported:
- `components/DashboardEnhanced.tsx`
- `components/DashboardNavigation.tsx`
- `components/HeatmapEnhanced.tsx`
- `components/OpportunityMatrixEnhanced.tsx`
- `components/EconomicModelEnhanced.tsx`
### DashboardReorganized Chain (12 files)
Files only used by `DashboardReorganized.tsx` which itself is never imported:
- `components/DashboardReorganized.tsx`
- `components/HeatmapPro.tsx`
- `components/OpportunityMatrixPro.tsx`
- `components/RoadmapPro.tsx`
- `components/EconomicModelPro.tsx`
- `components/BenchmarkReportPro.tsx`
- `components/VariabilityHeatmap.tsx`
- `components/AgenticReadinessBreakdown.tsx`
- `components/HourlyDistributionChart.tsx`
### Shared but now orphaned (3 files)
Used only by the orphaned DashboardEnhanced and DashboardReorganized:
- `components/HealthScoreGaugeEnhanced.tsx`
- `components/DimensionCard.tsx`
- `components/BadgePill.tsx`
### Completely orphaned (5 files)
Not imported anywhere at all:
- `components/DataUploader.tsx`
- `components/DataUploaderEnhanced.tsx`
- `components/Roadmap.tsx` (different from RoadmapTab.tsx which IS used)
- `components/BenchmarkReport.tsx`
- `components/ProgressStepper.tsx`
- `components/TierSelectorEnhanced.tsx`
- `components/DimensionDetailView.tsx`
- `components/TopOpportunitiesCard.tsx`
---
## 2. DUPLICATE IMPORTS (1 issue)
### RoadmapTab.tsx (lines 4-5)
`AlertCircle` is imported twice from lucide-react.
**Before:**
```tsx
import {
Clock, DollarSign, TrendingUp, AlertTriangle, CheckCircle,
ArrowRight, Info, Users, Target, Zap, Shield, AlertCircle,
ChevronDown, ChevronUp, BookOpen, Bot, Settings, Rocket
} from 'lucide-react';
```
Note: `AlertCircle` appears on line 5
**Fix:** Remove duplicate import
---
## 3. DUPLICATE FUNCTIONS (1 issue)
### formatDate function
Duplicated in two active files:
- `SinglePageDataRequestIntegrated.tsx` (lines 14-21)
- `DashboardHeader.tsx` (lines 25-32)
**Recommendation:** Create a shared utility function in `utils/formatters.ts` and import from there.
---
## 4. SHADOWED TYPES (1 issue)
### realDataAnalysis.ts
Has a local `SkillMetrics` interface (lines 235-252) that shadows the one imported from `types.ts`.
**Recommendation:** Remove local interface and use the imported one, or rename to avoid confusion.
---
## 5. UNUSED IMPORTS IN FILES (Minor)
Several files have console.log debug statements that could be removed for production:
- `HeatmapPro.tsx` - multiple debug console.logs
- `OpportunityMatrixPro.tsx` - debug console.logs
---
## Action Plan
### Phase 1: Safe Fixes (No functionality change)
1. Fix duplicate import in RoadmapTab.tsx
2. Consolidate formatDate function to shared utility
### Phase 2: Dead Code Removal (Files to delete)
Delete all 25 unused component files listed above.
### Phase 3: Type Cleanup
Fix shadowed SkillMetrics type in realDataAnalysis.ts
---
## Files to Keep (Active codebase)
### App Entry
- `App.tsx`
- `index.tsx`
### Components (Active)
- `SinglePageDataRequestIntegrated.tsx`
- `DashboardTabs.tsx`
- `DashboardHeader.tsx`
- `DataInputRedesigned.tsx`
- `LoginPage.tsx`
- `ErrorBoundary.tsx`
- `MethodologyFooter.tsx`
- `MetodologiaDrawer.tsx`
- `tabs/ExecutiveSummaryTab.tsx`
- `tabs/DimensionAnalysisTab.tsx`
- `tabs/AgenticReadinessTab.tsx`
- `tabs/RoadmapTab.tsx`
- `charts/WaterfallChart.tsx`
### Utils (Active)
- `apiClient.ts`
- `AuthContext.tsx`
- `analysisGenerator.ts`
- `backendMapper.ts`
- `realDataAnalysis.ts`
- `fileParser.ts`
- `syntheticDataGenerator.ts`
- `dataTransformation.ts`
- `segmentClassifier.ts`
- `agenticReadinessV2.ts`
### Config (Active)
- `types.ts`
- `constants.ts`
- `styles/colors.ts`
- `config/skillsConsolidation.ts`

192
README.md Normal file
View File

@@ -0,0 +1,192 @@
# Beyond Diagnosis
Beyond Diagnosis es una aplicación de análisis de operaciones de contact center.
Permite subir un CSV con interacciones y genera:
- Análisis de volumetría por canal y skill
- Métricas operativas (AHT, escalaciones, recurrencia, etc.)
- CSAT global y métricas de satisfacción
- Modelo económico (coste anual, ahorro potencial, etc.)
- Matriz de oportunidades y roadmap basados en datos reales
- Cálculo de *agentic readiness* para priorizar iniciativas de automatización
La arquitectura está compuesta por:
- **Frontend** (React + Vite)
- **Backend** (FastAPI + Python)
- **Nginx** como proxy inverso y terminación TLS
- **Docker Compose** para orquestar los tres servicios
En producción, la aplicación se sirve en **HTTPS (443)** con certificados de **Lets Encrypt**.
---
## Requisitos
Para instalación manual o con el script:
- Servidor **Ubuntu** reciente (20.04 o superior recomendado)
- Dominio apuntando al servidor (ej: `app.cliente.com`)
- Puertos **80** y **443** accesibles desde Internet (para Lets Encrypt)
- Usuario con permisos de `sudo`
> El script de instalación se encarga de instalar Docker, docker compose plugin y certbot si no están presentes.
---
## Instalación con script (recomendada)
### 1. Copiar el script al servidor
Conéctate al servidor por SSH y crea el fichero:
```bash
nano install_beyond.sh
```
Pega dentro el contenido del script de instalación que has preparado (el que:
- Instala Docker y dependencias
- Pide dominio, email, usuario y contraseña
- Clona/actualiza el repo en `/opt/beyonddiagnosis`
- Solicita el certificado de Lets Encrypt
- Genera la configuración de Nginx con SSL
- Lanza `docker compose build` + `docker compose up -d`
).
Guarda (`Ctrl + O`, Enter) y sal (`Ctrl + X`).
Hazlo ejecutable:
```bash
chmod +x install_beyond.sh
```
### 2. Ejecutar el instalador
Ejecuta el script como root (o con sudo):
```bash
sudo ./install_beyond.sh
```
El script te pedirá:
- **Dominio** de la aplicación (ej. `app.cliente.com`)
- **Email** para Lets Encrypt (avisos de renovación)
- **Usuario** de acceso (Basic Auth / login)
- **Contraseña** de acceso
- **URL del repositorio Git** (por defecto usará la que se haya dejado en el script)
Te mostrará un resumen y te preguntará si quieres continuar.
A partir de ahí, el proceso es **desatendido**, pero irá indicando cada paso:
- Instalación de Docker + docker compose plugin + certbot
- Descarga o actualización del repositorio en `/opt/beyonddiagnosis`
- Sustitución de credenciales en `docker-compose.yml`
- Obtención del certificado de Lets Encrypt para el dominio indicado
- Generación de `nginx/conf.d/beyond.conf` con configuración HTTPS
- Construcción de imágenes y arranque de contenedores con `docker compose up -d`
### 3. Acceso a la aplicación
Una vez finalizado:
- La aplicación estará disponible en:
**https://TU_DOMINIO**
- Inicia sesión con el **usuario** y **contraseña** que has introducido durante la instalación.
---
## Estructura de la instalación
Por defecto, el script instala todo en:
```text
/opt/beyonddiagnosis
├── backend/ # Código del backend (FastAPI)
├── frontend/ # Código del frontend (React + Vite)
├── nginx/
│ └── conf.d/
│ └── beyond.conf # Configuración nginx para este dominio
└── docker-compose.yml # Orquestación de backend, frontend y nginx
```
Servicios en Docker:
- `backend` → FastAPI en el puerto 8000 interno
- `frontend` → React en el puerto 4173 interno
- `nginx` → expone 80/443 y hace de proxy:
- `/` → frontend
- `/api/` → backend
Los certificados de Lets Encrypt se almacenan en:
```text
/etc/letsencrypt/live/TU_DOMINIO/
```
y se montan en el contenedor de Nginx como volumen de solo lectura.
---
## Actualización de la aplicación
Para desplegar una nueva versión del código:
```bash
cd /opt/beyonddiagnosis
sudo git pull
sudo docker compose build
sudo docker compose up -d
```
Esto:
- Actualiza el código desde el repositorio
- Reconstruye las imágenes
- Levanta los contenedores con la nueva versión sin perder datos de configuración ni certificados.
---
## Gestión de la aplicación
Desde `/opt/beyonddiagnosis`:
- Ver estado de los contenedores:
```bash
docker compose ps
```
- Ver logs en tiempo real:
```bash
docker compose logs -f
```
- Parar la aplicación:
```bash
docker compose down
```
---
## Uso básico
1. Accede a `https://TU_DOMINIO`.
2. Inicia sesión con las credenciales configuradas en la instalación.
3. Sube un fichero CSV con las columnas esperadas (canal, skill, tiempos, etc.).
4. La aplicación enviará el fichero al backend, que:
- Calcula métricas de volumetría, rendimiento, satisfacción y costes.
- Devuelve un JSON estructurado con el análisis.
5. El frontend muestra:
- Dashboard de métricas clave
- Dimensiones (volumetría, performance, satisfacción, economía, eficiencia…)
- Heatmap por skill
- Oportunidades y roadmap basado en datos reales.
Este README junto con el script de instalación permiten desplegar la aplicación de forma rápida y homogénea en un servidor por cliente.

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import os
from pathlib import Path
import json
import math
@@ -12,6 +13,10 @@ from fastapi.responses import JSONResponse
from beyond_api.security import get_current_user
from beyond_api.services.analysis_service import run_analysis_collect_json
# Cache paths - same as in cache.py
CACHE_DIR = Path(os.getenv("CACHE_DIR", "/data/cache"))
CACHED_FILE = CACHE_DIR / "cached_data.csv"
router = APIRouter(
prefix="",
tags=["analysis"],
@@ -117,3 +122,100 @@ async def analysis_endpoint(
"results": safe_results,
}
)
def extract_date_range_from_csv(file_path: Path) -> dict:
"""Extrae el rango de fechas del CSV."""
import pandas as pd
try:
# Leer solo la columna de fecha para eficiencia
df = pd.read_csv(file_path, usecols=['datetime_start'], parse_dates=['datetime_start'])
if 'datetime_start' in df.columns and len(df) > 0:
min_date = df['datetime_start'].min()
max_date = df['datetime_start'].max()
return {
"min": min_date.strftime('%Y-%m-%d') if pd.notna(min_date) else None,
"max": max_date.strftime('%Y-%m-%d') if pd.notna(max_date) else None,
}
except Exception as e:
print(f"Error extracting date range: {e}")
return {"min": None, "max": None}
def count_unique_queues_from_csv(file_path: Path) -> int:
"""Cuenta las colas únicas en el CSV."""
import pandas as pd
try:
df = pd.read_csv(file_path, usecols=['queue_skill'])
if 'queue_skill' in df.columns:
return df['queue_skill'].nunique()
except Exception as e:
print(f"Error counting queues: {e}")
return 0
@router.post("/analysis/cached")
async def analysis_cached_endpoint(
economy_json: Optional[str] = Form(default=None),
analysis: Literal["basic", "premium"] = Form(default="premium"),
current_user: str = Depends(get_current_user),
):
"""
Ejecuta el pipeline sobre el archivo CSV cacheado en el servidor.
Útil para re-analizar sin tener que subir el archivo de nuevo.
"""
# Validar que existe el archivo cacheado
if not CACHED_FILE.exists():
raise HTTPException(
status_code=404,
detail="No hay archivo cacheado en el servidor. Sube un archivo primero.",
)
# Validar `analysis`
if analysis not in {"basic", "premium"}:
raise HTTPException(
status_code=400,
detail="analysis debe ser 'basic' o 'premium'.",
)
# Parseo de economía (si viene)
economy_data = None
if economy_json:
try:
economy_data = json.loads(economy_json)
except json.JSONDecodeError:
raise HTTPException(
status_code=400,
detail="economy_json no es un JSON válido.",
)
# Extraer metadatos del CSV
date_range = extract_date_range_from_csv(CACHED_FILE)
unique_queues = count_unique_queues_from_csv(CACHED_FILE)
try:
# Ejecutar el análisis sobre el archivo cacheado
results_json = run_analysis_collect_json(
input_path=CACHED_FILE,
economy_data=economy_data,
analysis=analysis,
company_folder=None,
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Error ejecutando análisis: {str(e)}",
)
# Limpiar NaN/inf para que el JSON sea válido
safe_results = sanitize_for_json(results_json)
return JSONResponse(
content={
"user": current_user,
"results": safe_results,
"source": "cached",
"dateRange": date_range,
"uniqueQueues": unique_queues,
}
)

View File

@@ -0,0 +1,250 @@
# beyond_api/api/cache.py
"""
Server-side cache for CSV files.
Stores the uploaded CSV file and metadata for later re-analysis.
"""
from __future__ import annotations
import json
import os
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from beyond_api.security import get_current_user
router = APIRouter(
prefix="/cache",
tags=["cache"],
)
# Directory for cache files
CACHE_DIR = Path(os.getenv("CACHE_DIR", "/data/cache"))
CACHED_FILE = CACHE_DIR / "cached_data.csv"
METADATA_FILE = CACHE_DIR / "metadata.json"
DRILLDOWN_FILE = CACHE_DIR / "drilldown_data.json"
class CacheMetadata(BaseModel):
fileName: str
fileSize: int
recordCount: int
cachedAt: str
costPerHour: float
def ensure_cache_dir():
"""Create cache directory if it doesn't exist."""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
def count_csv_records(file_path: Path) -> int:
"""Count records in CSV file (excluding header)."""
try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
# Count lines minus header
return sum(1 for _ in f) - 1
except Exception:
return 0
@router.get("/check")
def check_cache(current_user: str = Depends(get_current_user)):
"""
Check if there's cached data available.
Returns metadata if cache exists, null otherwise.
"""
if not METADATA_FILE.exists() or not CACHED_FILE.exists():
return JSONResponse(content={"exists": False, "metadata": None})
try:
with open(METADATA_FILE, "r") as f:
metadata = json.load(f)
return JSONResponse(content={"exists": True, "metadata": metadata})
except Exception as e:
return JSONResponse(content={"exists": False, "metadata": None, "error": str(e)})
@router.get("/file")
def get_cached_file_path(current_user: str = Depends(get_current_user)):
"""
Returns the path to the cached CSV file for internal use.
"""
if not CACHED_FILE.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No cached file found"
)
return JSONResponse(content={"path": str(CACHED_FILE)})
@router.get("/download")
def download_cached_file(current_user: str = Depends(get_current_user)):
"""
Download the cached CSV file for frontend parsing.
Returns the file as a streaming response.
"""
from fastapi.responses import FileResponse
if not CACHED_FILE.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No cached file found"
)
return FileResponse(
path=CACHED_FILE,
media_type="text/csv",
filename="cached_data.csv"
)
@router.post("/file")
async def save_cached_file(
csv_file: UploadFile = File(...),
fileName: str = Form(...),
fileSize: int = Form(...),
costPerHour: float = Form(...),
current_user: str = Depends(get_current_user)
):
"""
Save uploaded CSV file to server cache.
"""
ensure_cache_dir()
try:
# Save the CSV file
with open(CACHED_FILE, "wb") as f:
while True:
chunk = await csv_file.read(1024 * 1024) # 1 MB chunks
if not chunk:
break
f.write(chunk)
# Count records
record_count = count_csv_records(CACHED_FILE)
# Save metadata
metadata = {
"fileName": fileName,
"fileSize": fileSize,
"recordCount": record_count,
"cachedAt": datetime.now().isoformat(),
"costPerHour": costPerHour,
}
with open(METADATA_FILE, "w") as f:
json.dump(metadata, f)
return JSONResponse(content={
"success": True,
"message": f"Cached file with {record_count} records",
"metadata": metadata
})
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error saving cache: {str(e)}"
)
@router.get("/drilldown")
def get_cached_drilldown(current_user: str = Depends(get_current_user)):
"""
Get the cached drilldownData JSON.
Returns the pre-calculated drilldown data for fast cache usage.
"""
if not DRILLDOWN_FILE.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No cached drilldown data found"
)
try:
with open(DRILLDOWN_FILE, "r", encoding="utf-8") as f:
drilldown_data = json.load(f)
return JSONResponse(content={"success": True, "drilldownData": drilldown_data})
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error reading drilldown data: {str(e)}"
)
@router.post("/drilldown")
async def save_cached_drilldown(
drilldown_json: str = Form(...),
current_user: str = Depends(get_current_user)
):
"""
Save drilldownData JSON to server cache.
Called by frontend after calculating drilldown from uploaded file.
Receives JSON as form field.
"""
ensure_cache_dir()
try:
# Parse and validate JSON
drilldown_data = json.loads(drilldown_json)
# Save to file
with open(DRILLDOWN_FILE, "w", encoding="utf-8") as f:
json.dump(drilldown_data, f)
return JSONResponse(content={
"success": True,
"message": f"Cached drilldown data with {len(drilldown_data)} skills"
})
except json.JSONDecodeError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid JSON: {str(e)}"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error saving drilldown data: {str(e)}"
)
@router.delete("/file")
def clear_cache(current_user: str = Depends(get_current_user)):
"""
Clear the server-side cache (CSV, metadata, and drilldown data).
"""
try:
if CACHED_FILE.exists():
CACHED_FILE.unlink()
if METADATA_FILE.exists():
METADATA_FILE.unlink()
if DRILLDOWN_FILE.exists():
DRILLDOWN_FILE.unlink()
return JSONResponse(content={"success": True, "message": "Cache cleared"})
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error clearing cache: {str(e)}"
)
# Keep old endpoints for backwards compatibility but mark as deprecated
@router.get("/interactions")
def get_cached_interactions_deprecated(current_user: str = Depends(get_current_user)):
"""DEPRECATED: Use /cache/file instead."""
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail="This endpoint is deprecated. Use /cache/file with re-analysis instead."
)
@router.post("/interactions")
def save_cached_interactions_deprecated(current_user: str = Depends(get_current_user)):
"""DEPRECATED: Use /cache/file instead."""
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail="This endpoint is deprecated. Use /cache/file instead."
)

View File

@@ -4,7 +4,8 @@ from fastapi.middleware.cors import CORSMiddleware
# importa tus routers
from beyond_api.api.analysis import router as analysis_router
from beyond_api.api.auth import router as auth_router # 👈 nuevo
from beyond_api.api.auth import router as auth_router
from beyond_api.api.cache import router as cache_router
def setup_basic_logging() -> None:
logging.basicConfig(
@@ -30,4 +31,5 @@ app.add_middleware(
)
app.include_router(analysis_router)
app.include_router(auth_router) # 👈 registrar el router de auth
app.include_router(auth_router)
app.include_router(cache_router)

View File

@@ -5,26 +5,33 @@ import secrets
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
security = HTTPBasic()
# auto_error=False para que no dispare el popup nativo del navegador automáticamente
security = HTTPBasic(auto_error=False)
# En producción: export BASIC_AUTH_USERNAME y BASIC_AUTH_PASSWORD.
BASIC_USER = os.getenv("BASIC_AUTH_USERNAME", "beyond")
BASIC_PASS = os.getenv("BASIC_AUTH_PASSWORD", "beyond2026")
def get_current_user(credentials: HTTPBasicCredentials = Depends(security)) -> str:
def get_current_user(credentials: HTTPBasicCredentials | None = Depends(security)) -> str:
"""
Valida el usuario/contraseña vía HTTP Basic.
NO envía WWW-Authenticate para evitar el popup nativo del navegador
(el frontend tiene su propio formulario de login).
"""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales requeridas",
)
correct_username = secrets.compare_digest(credentials.username, BASIC_USER)
correct_password = secrets.compare_digest(credentials.password, BASIC_PASS)
if not (correct_username and correct_password):
# Importante devolver el header WWW-Authenticate para que el navegador saque el prompt
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales incorrectas",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username

View File

@@ -506,11 +506,10 @@ def score_roi(annual_savings: Any) -> Dict[str, Any]:
def classify_agentic_score(score: Optional[float]) -> Dict[str, Any]:
"""
Clasificación final:
- 810: AUTOMATE 🤖
- 57.99: ASSIST 🤝
- 34.99: AUGMENT 🧠
- 02.99: HUMAN_ONLY 👤
Clasificación final (alineada con frontend):
- ≥6: COPILOT 🤖 (Listo para Copilot)
- 45.99: OPTIMIZE 🔧 (Optimizar Primero)
- <4: HUMAN 👤 (Requiere Gestión Humana)
Si score es None (ninguna dimensión disponible), devuelve NO_DATA.
"""
@@ -524,33 +523,26 @@ def classify_agentic_score(score: Optional[float]) -> Dict[str, Any]:
),
}
if score >= 8.0:
label = "AUTOMATE"
if score >= 6.0:
label = "COPILOT"
emoji = "🤖"
description = (
"Alta repetitividad, alta predictibilidad y ROI elevado. "
"Candidato a automatización completa (chatbot/IVR inteligente)."
"Listo para Copilot. Procesos con predictibilidad y simplicidad "
"suficientes para asistencia IA (sugerencias en tiempo real, autocompletado)."
)
elif score >= 5.0:
label = "ASSIST"
emoji = "🤝"
elif score >= 4.0:
label = "OPTIMIZE"
emoji = "🔧"
description = (
"Complejidad media o ROI limitado. Recomendado enfoque de copilot "
"para agentes (sugerencias en tiempo real, autocompletado, etc.)."
)
elif score >= 3.0:
label = "AUGMENT"
emoji = "🧠"
description = (
"Alta complejidad o bajo volumen. Mejor usar herramientas de apoyo "
"(knowledge base, guías dinámicas, scripts)."
"Optimizar primero. Estandarizar procesos y reducir variabilidad "
"antes de implementar asistencia IA."
)
else:
label = "HUMAN_ONLY"
label = "HUMAN"
emoji = "👤"
description = (
"Procesos de muy bajo volumen o extremadamente complejos. Mejor "
"mantener operación 100% humana de momento."
"Requiere gestión humana. Procesos complejos o variables que "
"necesitan intervención humana antes de considerar automatización."
)
return {

View File

@@ -23,6 +23,7 @@
"fcr_rate",
"escalation_rate",
"abandonment_rate",
"high_hold_time_rate",
"recurrence_rate_7d",
"repeat_channel_rate",
"occupancy_rate",

View File

@@ -86,6 +86,16 @@ class OperationalPerformanceMetrics:
+ df["wrap_up_time"].fillna(0)
)
# v3.0: Filtrar NOISE y ZOMBIE para cálculos de variabilidad
# record_status: 'valid', 'noise', 'zombie', 'abandon'
# Para AHT/CV solo usamos 'valid' (o sin status = legacy data)
if "record_status" in df.columns:
df["record_status"] = df["record_status"].astype(str).str.strip().str.upper()
# Crear máscara para registros válidos (para cálculos de CV/variabilidad)
df["_is_valid_for_cv"] = df["record_status"].isin(["VALID", "NAN", ""]) | df["record_status"].isna()
else:
df["_is_valid_for_cv"] = True
# Normalización básica
df["queue_skill"] = df["queue_skill"].astype(str).str.strip()
df["channel"] = df["channel"].astype(str).str.strip()
@@ -121,8 +131,13 @@ class OperationalPerformanceMetrics:
def aht_distribution(self) -> Dict[str, float]:
"""
Devuelve P10, P50, P90 del AHT y el ratio P90/P50 como medida de variabilidad.
v3.0: Filtra NOISE y ZOMBIE para el cálculo de variabilidad.
Solo usa registros con record_status='valid' o sin status (legacy).
"""
ht = self.df["handle_time"].dropna().astype(float)
# Filtrar solo registros válidos para cálculo de variabilidad
df_valid = self.df[self.df["_is_valid_for_cv"] == True]
ht = df_valid["handle_time"].dropna().astype(float)
if ht.empty:
return {}
@@ -165,57 +180,46 @@ class OperationalPerformanceMetrics:
# ------------------------------------------------------------------ #
def fcr_rate(self) -> float:
"""
FCR proxy = 100 - escalation_rate.
FCR (First Contact Resolution).
Usamos la métrica de escalación ya calculada a partir de transfer_flag.
Si no se puede calcular escalation_rate, intentamos derivarlo
directamente de la columna transfer_flag. Si todo falla, devolvemos NaN.
Prioridad 1: Usar fcr_real_flag del CSV si existe
Prioridad 2: Calcular como 100 - escalation_rate
"""
df = self.df
total = len(df)
if total == 0:
return float("nan")
# Prioridad 1: Usar fcr_real_flag si existe
if "fcr_real_flag" in df.columns:
col = df["fcr_real_flag"]
# Normalizar a booleano
if col.dtype == "O":
fcr_mask = (
col.astype(str)
.str.strip()
.str.lower()
.isin(["true", "t", "1", "yes", "y", "si", ""])
)
else:
fcr_mask = pd.to_numeric(col, errors="coerce").fillna(0) > 0
fcr_count = int(fcr_mask.sum())
fcr = (fcr_count / total) * 100.0
return float(max(0.0, min(100.0, round(fcr, 2))))
# Prioridad 2: Fallback a 100 - escalation_rate
try:
esc = self.escalation_rate()
except Exception:
esc = float("nan")
# Si escalation_rate es válido, usamos el proxy simple
if esc is not None and not math.isnan(esc):
fcr = 100.0 - esc
return float(max(0.0, min(100.0, round(fcr, 2))))
# Fallback: calcular directamente desde transfer_flag
df = self.df
if "transfer_flag" not in df.columns or len(df) == 0:
return float("nan")
col = df["transfer_flag"]
# Normalizar a booleano: TRUE/FALSE, 1/0, etc.
if col.dtype == "O":
col_norm = (
col.astype(str)
.str.strip()
.str.lower()
.map({
"true": True,
"t": True,
"1": True,
"yes": True,
"y": True,
})
).fillna(False)
transfer_mask = col_norm
else:
transfer_mask = pd.to_numeric(col, errors="coerce").fillna(0) > 0
total = len(df)
transfers = int(transfer_mask.sum())
esc_rate = transfers / total if total > 0 else float("nan")
if math.isnan(esc_rate):
return float("nan")
fcr = 100.0 - esc_rate * 100.0
return float(max(0.0, min(100.0, round(fcr, 2))))
def escalation_rate(self) -> float:
"""
@@ -233,20 +237,57 @@ class OperationalPerformanceMetrics:
"""
% de interacciones abandonadas.
Definido como % de filas con abandoned_flag == True.
Si la columna no existe, devuelve NaN.
Busca en orden: is_abandoned, abandoned_flag, abandoned
Si ninguna columna existe, devuelve NaN.
"""
df = self.df
if "abandoned_flag" not in df.columns:
return float("nan")
total = len(df)
if total == 0:
return float("nan")
abandoned = df["abandoned_flag"].sum()
# Buscar columna de abandono en orden de prioridad
abandon_col = None
for col_name in ["is_abandoned", "abandoned_flag", "abandoned"]:
if col_name in df.columns:
abandon_col = col_name
break
if abandon_col is None:
return float("nan")
col = df[abandon_col]
# Normalizar a booleano
if col.dtype == "O":
abandon_mask = (
col.astype(str)
.str.strip()
.str.lower()
.isin(["true", "t", "1", "yes", "y", "si", ""])
)
else:
abandon_mask = pd.to_numeric(col, errors="coerce").fillna(0) > 0
abandoned = int(abandon_mask.sum())
return float(round(abandoned / total * 100, 2))
def high_hold_time_rate(self, threshold_seconds: float = 60.0) -> float:
"""
% de interacciones con hold_time > threshold (por defecto 60s).
Proxy de complejidad: si el agente tuvo que poner en espera al cliente
más de 60 segundos, probablemente tuvo que consultar/investigar.
"""
df = self.df
total = len(df)
if total == 0:
return float("nan")
hold_times = df["hold_time"].fillna(0)
high_hold_count = (hold_times > threshold_seconds).sum()
return float(round(high_hold_count / total * 100, 2))
def recurrence_rate_7d(self) -> float:
"""
% de clientes que vuelven a contactar en < 7 días.
@@ -257,28 +298,40 @@ class OperationalPerformanceMetrics:
- Si hay dos contactos consecutivos separados < 7 días, cuenta como "recurrente"
- Tasa = nº clientes recurrentes / nº total de clientes
"""
df = self.df.dropna(subset=["datetime_start"]).copy()
if df["customer_id"].isna().all():
# Normalizar identificador de cliente
if "customer_id" not in df.columns:
if "caller_id" in df.columns:
df["customer_id"] = df["caller_id"]
else:
# No hay identificador de cliente -> no se puede calcular
return float("nan")
customers = df["customer_id"].dropna().unique()
if len(customers) == 0:
df = df.dropna(subset=["customer_id"])
if df.empty:
return float("nan")
recurrent_customers = 0
# Ordenar por cliente + fecha
df = df.sort_values(["customer_id", "datetime_start"])
for cust in customers:
sub = df[df["customer_id"] == cust].sort_values("datetime_start")
if len(sub) < 2:
continue
deltas = sub["datetime_start"].diff().dropna()
if (deltas < pd.Timedelta(days=7)).any():
recurrent_customers += 1
# Diferencia de tiempo entre contactos consecutivos por cliente
df["delta"] = df.groupby("customer_id")["datetime_start"].diff()
if len(customers) == 0:
# Marcamos los contactos que ocurren a menos de 7 días del anterior
recurrence_mask = df["delta"] < pd.Timedelta(days=7)
# Nº de clientes que tienen al menos un contacto recurrente
recurrent_customers = df.loc[recurrence_mask, "customer_id"].nunique()
total_customers = df["customer_id"].nunique()
if total_customers == 0:
return float("nan")
return float(round(recurrent_customers / len(customers) * 100, 2))
rate = recurrent_customers / total_customers * 100.0
return float(round(rate, 2))
def repeat_channel_rate(self) -> float:
"""

42
deploy.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
# Script para reconstruir y desplegar los contenedores de Beyond Diagnosis
# Ejecutar con: sudo ./deploy.sh
set -e
echo "=========================================="
echo " Beyond Diagnosis - Deploy Script"
echo "=========================================="
cd /opt/beyonddiagnosis
echo ""
echo "[1/4] Deteniendo contenedores actuales..."
docker compose down
echo ""
echo "[2/4] Reconstruyendo contenedor del frontend (con cambios)..."
docker compose build --no-cache frontend
echo ""
echo "[3/4] Reconstruyendo contenedor del backend (si hay cambios)..."
docker compose build backend
echo ""
echo "[4/4] Iniciando todos los contenedores..."
docker compose up -d
echo ""
echo "=========================================="
echo " Deploy completado!"
echo "=========================================="
echo ""
echo "Verificando estado de contenedores:"
docker compose ps
echo ""
echo "Logs del frontend (últimas 20 líneas):"
docker compose logs --tail=20 frontend
echo ""
echo "La aplicación está disponible en: https://diag.yourcompany.com"

View File

@@ -7,8 +7,11 @@ services:
container_name: beyond-backend
environment:
# credenciales del API (las mismas que usas ahora)
BASIC_AUTH_USERNAME: admin
BASIC_AUTH_PASSWORD: admin
BASIC_AUTH_USERNAME: "beyond"
BASIC_AUTH_PASSWORD: "beyond2026"
CACHE_DIR: "/data/cache"
volumes:
- cache-data:/data/cache
expose:
- "8000"
networks:
@@ -34,11 +37,17 @@ services:
- frontend
ports:
- "80:80"
- "443:443"
volumes:
- /etc/letsencrypt:/etc/letsencrypt:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
networks:
- beyond-net
volumes:
cache-data:
driver: local
networks:
beyond-net:
driver: bridge

View File

@@ -0,0 +1,82 @@
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';
export interface TabConfig {
id: TabId;
label: string;
icon: React.ElementType;
}
interface DashboardHeaderProps {
title?: string;
activeTab: TabId;
onTabChange: (id: TabId) => void;
}
const TABS: TabConfig[] = [
{ id: 'executive', label: 'Resumen', icon: LayoutDashboard },
{ id: 'dimensions', label: 'Dimensiones', icon: Layers },
{ id: 'readiness', label: 'Agentic Readiness', icon: Bot },
{ id: 'roadmap', label: 'Roadmap', icon: Map },
];
export function DashboardHeader({
title = 'AIR EUROPA - Beyond CX Analytics',
activeTab,
onTabChange
}: DashboardHeaderProps) {
return (
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
{/* Top row: Title and Date */}
<div className="max-w-7xl mx-auto px-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-2 sm:px-6 overflow-x-auto">
<div className="flex space-x-1">
{TABS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
relative flex items-center gap-2 px-4 py-3 text-sm font-medium
transition-colors duration-200
${isActive
? 'text-[#6D84E3]'
: 'text-slate-500 hover:text-slate-700'
}
`}
>
<Icon className="w-4 h-4" />
<span className="hidden sm:inline">{tab.label}</span>
{/* Active indicator */}
{isActive && (
<motion.div
layoutId="activeTab"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#6D84E3]"
initial={false}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
/>
)}
</button>
);
})}
</div>
</nav>
</header>
);
}
export default DashboardHeader;

View File

@@ -0,0 +1,118 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
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 {
data: AnalysisData;
title?: string;
onBack?: () => void;
}
export function DashboardTabs({
data,
title = 'AIR EUROPA - Beyond CX Analytics',
onBack
}: DashboardTabsProps) {
const [activeTab, setActiveTab] = useState<TabId>('executive');
const [metodologiaOpen, setMetodologiaOpen] = useState(false);
const renderTabContent = () => {
switch (activeTab) {
case 'executive':
return <ExecutiveSummaryTab data={data} onTabChange={setActiveTab} />;
case 'dimensions':
return <DimensionAnalysisTab data={data} />;
case 'readiness':
return <AgenticReadinessTab data={data} onTabChange={setActiveTab} />;
case 'roadmap':
return <RoadmapTab data={data} />;
default:
return <ExecutiveSummaryTab data={data} />;
}
};
return (
<div className="min-h-screen bg-slate-50">
{/* Back button */}
{onBack && (
<div className="bg-white border-b border-slate-200">
<div className="max-w-7xl mx-auto px-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" />
<span className="hidden sm:inline">Volver al formulario</span>
<span className="sm:hidden">Volver</span>
</button>
</div>
</div>
)}
{/* Sticky Header with Tabs */}
<DashboardHeader
title={title}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* Tab Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-4 sm:py-6">
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{renderTabContent()}
</motion.div>
</AnimatePresence>
</main>
{/* Footer */}
<footer className="border-t border-slate-200 bg-white mt-8">
<div className="max-w-7xl mx-auto px-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>
);
}
export default DashboardTabs;

View File

@@ -1,15 +1,21 @@
// components/DataInputRedesigned.tsx
// Interfaz de entrada de datos rediseñada y organizada
// Interfaz de entrada de datos simplificada
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import {
Download, CheckCircle, AlertCircle, FileText, Database,
UploadCloud, File, Sheet, Loader2, Sparkles, Table,
Info, ExternalLink, X
AlertCircle, FileText, Database,
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: {
@@ -23,6 +29,7 @@ interface DataInputRedesignedProps {
file?: File;
sheetUrl?: string;
useSynthetic?: boolean;
useCache?: boolean;
}) => void;
isAnalyzing: boolean;
}
@@ -31,9 +38,11 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
onAnalyze,
isAnalyzing
}) => {
// Estados para datos manuales
const [costPerHour, setCostPerHour] = useState<number>(20);
const [avgCsat, setAvgCsat] = useState<number>(85);
const { authHeader } = useAuth();
// Estados para datos manuales - valores vacíos por defecto
const [costPerHour, setCostPerHour] = useState<string>('');
const [avgCsat, setAvgCsat] = useState<string>('');
// Estados para mapeo de segmentación
const [highValueQueues, setHighValueQueues] = useState<string>('');
@@ -41,39 +50,78 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
const [lowValueQueues, setLowValueQueues] = useState<string>('');
// Estados para carga de datos
const [uploadMethod, setUploadMethod] = useState<'file' | 'url' | 'synthetic' | null>(null);
const [file, setFile] = useState<File | null>(null);
const [sheetUrl, setSheetUrl] = useState<string>('');
const [isGenerating, setIsGenerating] = useState(false);
const [isDragging, setIsDragging] = useState(false);
// Campos CSV requeridos
const csvFields = [
{ name: 'interaction_id', type: 'String único', example: 'call_8842910', required: true },
{ name: 'datetime_start', type: 'DateTime', example: '2024-10-01 09:15:22', required: true },
{ name: 'queue_skill', type: 'String', example: 'Soporte_Nivel1, Ventas', required: true },
{ name: 'channel', type: 'String', example: 'Voice, Chat, WhatsApp', required: true },
{ name: 'duration_talk', type: 'Segundos', example: '345', required: true },
{ name: 'hold_time', type: 'Segundos', example: '45', required: true },
{ name: 'wrap_up_time', type: 'Segundos', example: '30', required: true },
{ name: 'agent_id', type: 'String', example: 'Agente_045', required: true },
{ name: 'transfer_flag', type: 'Boolean', example: 'TRUE / FALSE', required: true },
{ name: 'caller_id', type: 'String (hash)', example: 'Hash_99283', required: false },
{ name: 'csat_score', type: 'Float', example: '4', required: false }
];
// Estado para caché del servidor
const [cacheInfo, setCacheInfo] = useState<CacheInfo | null>(null);
const [checkingCache, setCheckingCache] = useState(true);
const handleDownloadTemplate = () => {
const headers = csvFields.map(f => f.name).join(',');
const exampleRow = csvFields.map(f => f.example).join(',');
const csvContent = `${headers}\n${exampleRow}\n`;
// 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;
}
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'plantilla_beyond_diagnostic.csv';
link.click();
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]);
toast.success('Plantilla CSV descargada', { icon: '📥' });
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) => {
@@ -88,10 +136,9 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
selectedFile.name.endsWith('.xlsx') ||
selectedFile.name.endsWith('.xls')) {
setFile(selectedFile);
setUploadMethod('file');
toast.success(`Archivo "${selectedFile.name}" cargado`, { icon: '📄' });
} else {
toast.error('Tipo de archivo no válido. Sube un CSV.', { icon: '❌' });
toast.error('Tipo de archivo no válido. Sube un CSV o Excel.', { icon: '❌' });
}
}
};
@@ -119,313 +166,292 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
}
};
const handleGenerateSynthetic = () => {
setIsGenerating(true);
setTimeout(() => {
setUploadMethod('synthetic');
setIsGenerating(false);
toast.success('Datos sintéticos generados para demo', { icon: '✨' });
}, 1500);
};
const canAnalyze = file !== null && costPerHour !== '' && parseFloat(costPerHour) > 0;
const handleSheetUrlSubmit = () => {
if (sheetUrl.trim()) {
setUploadMethod('url');
toast.success('URL de Google Sheets conectada', { icon: '🔗' });
} else {
toast.error('Introduce una URL válida', { icon: '' });
}
};
const handleSubmit = () => {
// Preparar segment_mapping
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;
const canAnalyze = uploadMethod !== null && costPerHour > 0;
onAnalyze({
costPerHour: parseFloat(costPerHour) || 0,
avgCsat: parseFloat(avgCsat) || 0,
segmentMapping,
file: file || undefined,
useSynthetic: false
});
};
return (
<div className="space-y-8">
<div className="space-y-6">
{/* Sección 1: Datos Manuales */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-white rounded-xl shadow-lg p-8 border-2 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-2xl font-bold text-slate-900 mb-2 flex items-center gap-2">
<Database size={24} className="text-[#6D84E3]" />
1. Datos Manuales
<h2 className="text-lg font-semibold text-slate-800 mb-1 flex items-center gap-2">
<Database size={20} className="text-[#6D84E3]" />
Configuración Manual
</h2>
<p className="text-slate-600 text-sm">
<p className="text-slate-500 text-sm">
Introduce los parámetros de configuración para tu análisis
</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-semibold text-slate-700 mb-2 flex items-center gap-2">
<label className="block text-sm font-medium text-slate-700 mb-2 flex items-center gap-2">
Coste por Hora Agente (Fully Loaded)
<span className="inline-flex items-center gap-1 text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full font-semibold">
<span className="inline-flex items-center gap-1 text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full font-medium">
<AlertCircle size={10} />
Obligatorio
</span>
</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 font-semibold text-lg"></span>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500"></span>
<input
type="number"
value={costPerHour}
onChange={(e) => setCostPerHour(parseFloat(e.target.value) || 0)}
onChange={(e) => setCostPerHour(e.target.value)}
min="0"
step="0.5"
className="w-full pl-10 pr-20 py-3 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition text-lg font-semibold"
placeholder="20"
className="w-full pl-8 pr-16 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition"
placeholder="Ej: 20"
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-xs text-slate-500 font-medium">/hora</span>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">/hora</span>
</div>
<p className="text-xs text-slate-500 mt-1 flex items-start gap-1">
<Info size={12} className="mt-0.5 flex-shrink-0" />
<span>Tipo: <strong>Número (decimal)</strong> | Ejemplo: <code className="bg-slate-100 px-1 rounded">20</code></span>
</p>
<p className="text-xs text-slate-600 mt-1">
<p className="text-xs text-slate-500 mt-1">
Incluye salario, cargas sociales, infraestructura, etc.
</p>
</div>
{/* CSAT Promedio */}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2">
<label className="block text-sm font-medium text-slate-700 mb-2 flex items-center gap-2">
CSAT Promedio
<span className="inline-flex items-center gap-1 text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-semibold">
<CheckCircle size={10} />
Opcional
</span>
<span className="text-xs text-slate-400">(Opcional)</span>
</label>
<div className="relative">
<input
type="number"
value={avgCsat}
onChange={(e) => setAvgCsat(parseFloat(e.target.value) || 0)}
onChange={(e) => setAvgCsat(e.target.value)}
min="0"
max="100"
step="1"
className="w-full pr-16 py-3 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition text-lg font-semibold"
placeholder="85"
className="w-full pr-12 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition"
placeholder="Ej: 85"
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-xs text-slate-500 font-medium">/ 100</span>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">/ 100</span>
</div>
<p className="text-xs text-slate-500 mt-1 flex items-start gap-1">
<Info size={12} className="mt-0.5 flex-shrink-0" />
<span>Tipo: <strong>Número (0-100)</strong> | Ejemplo: <code className="bg-slate-100 px-1 rounded">85</code></span>
</p>
<p className="text-xs text-slate-600 mt-1">
<p className="text-xs text-slate-500 mt-1">
Puntuación promedio de satisfacción del cliente
</p>
</div>
{/* Segmentación por Cola/Skill */}
<div className="col-span-2">
<div className="mb-4">
<h4 className="font-semibold text-slate-900 mb-2 flex items-center gap-2">
<Database size={18} className="text-[#6D84E3]" />
<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
<span className="inline-flex items-center gap-1 text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-semibold">
<CheckCircle size={10} />
Opcional
</span>
<span className="text-xs text-slate-400">(Opcional)</span>
</h4>
<p className="text-sm text-slate-600">
Identifica qué colas/skills corresponden a cada segmento de cliente. Separa múltiples colas con comas.
<p className="text-sm text-slate-500">
Identifica qué colas corresponden a cada segmento. Separa múltiples colas con comas.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* High Value */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
🟢 Clientes Alto Valor (High)
<label className="block text-sm font-medium text-slate-700 mb-1">
Alto Valor
</label>
<input
type="text"
value={highValueQueues}
onChange={(e) => setHighValueQueues(e.target.value)}
placeholder="VIP, Premium, Enterprise"
className="w-full px-4 py-3 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition text-sm"
/>
<p className="text-xs text-slate-500 mt-1">
Colas para clientes de alto valor
</p>
</div>
{/* Medium Value */}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
🟡 Clientes Valor Medio (Medium)
<label className="block text-sm font-medium text-slate-700 mb-1">
Valor Medio
</label>
<input
type="text"
value={mediumValueQueues}
onChange={(e) => setMediumValueQueues(e.target.value)}
placeholder="Soporte_General, Ventas"
className="w-full px-4 py-3 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition text-sm"
/>
<p className="text-xs text-slate-500 mt-1">
Colas para clientes estándar
</p>
</div>
{/* Low Value */}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
🔴 Clientes Bajo Valor (Low)
<label className="block text-sm font-medium text-slate-700 mb-1">
Bajo Valor
</label>
<input
type="text"
value={lowValueQueues}
onChange={(e) => setLowValueQueues(e.target.value)}
placeholder="Basico, Trial, Freemium"
className="w-full px-4 py-3 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition text-sm"
/>
<p className="text-xs text-slate-500 mt-1">
Colas para clientes de bajo valor
</p>
</div>
</div>
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-xs text-blue-800 flex items-start gap-2">
<Info size={14} className="mt-0.5 flex-shrink-0" />
<span>
<strong>Nota:</strong> Las colas no mapeadas se clasificarán automáticamente como "Medium".
El matching es flexible (no distingue mayúsculas y permite coincidencias parciales).
</span>
<p className="text-xs text-slate-500 mt-2 flex items-start gap-1">
<Info size={12} className="mt-0.5 flex-shrink-0" />
Las colas no mapeadas se clasificarán como "Valor Medio" por defecto.
</p>
</div>
</div>
</div>
</motion.div>
{/* Sección 2: Datos CSV */}
{/* 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.2 }}
className="bg-white rounded-xl shadow-lg p-8 border-2 border-slate-200"
transition={{ delay: 0.15 }}
className="bg-emerald-50 rounded-lg shadow-sm p-4 sm:p-6 border-2 border-emerald-300"
>
<div className="mb-6">
<h2 className="text-2xl font-bold text-slate-900 mb-2 flex items-center gap-2">
<Table size={24} className="text-[#6D84E3]" />
2. Datos CSV (Raw Data de ACD)
<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>
<p className="text-slate-600 text-sm">
Exporta estos campos desde tu sistema ACD/CTI (Genesys, Avaya, Talkdesk, Zendesk, etc.)
</p>
</div>
{/* Tabla de campos requeridos */}
<div className="mb-6 overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead className="bg-slate-50">
<tr>
<th className="p-3 text-left font-semibold text-slate-700 border-b-2 border-slate-300">Campo</th>
<th className="p-3 text-left font-semibold text-slate-700 border-b-2 border-slate-300">Tipo</th>
<th className="p-3 text-left font-semibold text-slate-700 border-b-2 border-slate-300">Ejemplo</th>
<th className="p-3 text-center font-semibold text-slate-700 border-b-2 border-slate-300">Obligatorio</th>
</tr>
</thead>
<tbody>
{csvFields.map((field, index) => (
<tr key={field.name} className={clsx(
'border-b border-slate-200',
index % 2 === 0 ? 'bg-white' : 'bg-slate-50'
)}>
<td className="p-3 font-mono text-sm font-semibold text-slate-900">{field.name}</td>
<td className="p-3 text-slate-700">{field.type}</td>
<td className="p-3 font-mono text-xs text-slate-600">{field.example}</td>
<td className="p-3 text-center">
{field.required ? (
<span className="inline-flex items-center gap-1 text-xs bg-red-100 text-red-700 px-2 py-1 rounded-full font-semibold">
<AlertCircle size={10} />
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full font-semibold">
<CheckCircle size={10} />
No
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Botón de descarga de plantilla */}
<div className="mb-6">
<button
onClick={handleDownloadTemplate}
className="inline-flex items-center gap-2 px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5a6fc9] transition font-semibold"
onClick={handleClearCache}
className="p-2 text-emerald-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition"
title="Limpiar caché"
>
<Download size={18} />
Descargar Plantilla CSV
<Trash2 size={18} />
</button>
<p className="text-xs text-slate-500 mt-2">
Descarga una plantilla con la estructura exacta de campos requeridos
</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: 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]" />
{cacheInfo ? 'Subir Nuevo Archivo' : 'Datos CSV'}
</h2>
<p className="text-slate-500 text-sm">
{cacheInfo ? 'O sube un archivo diferente para analizar' : 'Sube el archivo exportado desde tu sistema ACD/CTI'}
</p>
</div>
{/* Opciones de carga */}
<div className="space-y-4">
<h3 className="font-semibold text-slate-900 mb-3">Elige cómo proporcionar tus datos:</h3>
{/* Opción 1: Subir archivo */}
<div className={clsx(
'border-2 rounded-lg p-4 transition-all',
uploadMethod === 'file' ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300'
)}>
<div className="flex items-start gap-3">
<input
type="radio"
name="uploadMethod"
checked={uploadMethod === 'file'}
onChange={() => setUploadMethod('file')}
className="mt-1"
/>
<div className="flex-1">
<h4 className="font-semibold text-slate-900 mb-2 flex items-center gap-2">
<UploadCloud size={18} className="text-[#6D84E3]" />
Subir Archivo CSV
</h4>
{uploadMethod === 'file' && (
{/* Zona de subida */}
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={clsx(
'border-2 border-dashed rounded-lg p-6 text-center transition-all',
isDragging ? 'border-[#6D84E3] bg-blue-100' : 'border-slate-300 bg-slate-50'
'border-2 border-dashed rounded-lg p-8 text-center transition-all cursor-pointer',
isDragging ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300 bg-slate-50 hover:border-slate-400'
)}
>
{file ? (
<div className="flex items-center justify-center gap-3">
<File size={24} className="text-green-600" />
<File size={24} className="text-emerald-600" />
<div className="text-left">
<p className="font-semibold text-slate-900">{file.name}</p>
<p className="font-medium text-slate-800">{file.name}</p>
<p className="text-xs text-slate-500">{(file.size / 1024).toFixed(1)} KB</p>
</div>
<button
onClick={() => setFile(null)}
className="ml-auto p-1 hover:bg-slate-200 rounded"
onClick={(e) => {
e.stopPropagation();
setFile(null);
}}
className="ml-4 p-1.5 hover:bg-slate-200 rounded-full transition"
>
<X size={18} />
<X size={18} className="text-slate-500" />
</button>
</div>
) : (
<>
<UploadCloud size={32} className="mx-auto text-slate-400 mb-2" />
<p className="text-sm text-slate-600 mb-2">
<UploadCloud size={40} className="mx-auto text-slate-400 mb-3" />
<p className="text-slate-600 mb-2">
Arrastra tu archivo aquí o haz click para seleccionar
</p>
<p className="text-xs text-slate-400 mb-4">
Formatos aceptados: CSV, Excel (.xlsx, .xls)
</p>
<input
type="file"
accept=".csv,.xlsx,.xls"
@@ -435,102 +461,13 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
/>
<label
htmlFor="file-upload"
className="inline-block px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5a6fc9] transition cursor-pointer"
className="inline-block px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5a6fc9] transition cursor-pointer font-medium"
>
Seleccionar Archivo
</label>
</>
)}
</div>
)}
</div>
</div>
</div>
{/* Opción 2: URL Google Sheets
<div className={clsx(
'border-2 rounded-lg p-4 transition-all',
uploadMethod === 'url' ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300'
)}>
<div className="flex items-start gap-3">
<input
type="radio"
name="uploadMethod"
checked={uploadMethod === 'url'}
onChange={() => setUploadMethod('url')}
className="mt-1"
/>
<div className="flex-1">
<h4 className="font-semibold text-slate-900 mb-2 flex items-center gap-2">
<Sheet size={18} className="text-[#6D84E3]" />
Conectar Google Sheets
</h4>
{uploadMethod === 'url' && (
<div className="flex gap-2">
<input
type="url"
value={sheetUrl}
onChange={(e) => setSheetUrl(e.target.value)}
placeholder="https://docs.google.com/spreadsheets/d/..."
className="flex-1 px-4 py-2 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition"
/>
<button
onClick={handleSheetUrlSubmit}
className="px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5a6fc9] transition font-semibold"
>
<ExternalLink size={18} />
</button>
</div>
)}
</div>
</div>
</div>
*/}
{/* Opción 3: Datos sintéticos */}
<div className={clsx(
'border-2 rounded-lg p-4 transition-all',
uploadMethod === 'synthetic' ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300'
)}>
<div className="flex items-start gap-3">
<input
type="radio"
name="uploadMethod"
checked={uploadMethod === 'synthetic'}
onChange={() => setUploadMethod('synthetic')}
className="mt-1"
/>
<div className="flex-1">
<h4 className="font-semibold text-slate-900 mb-2 flex items-center gap-2">
<Sparkles size={18} className="text-[#6D84E3]" />
Generar Datos Sintéticos (Demo)
</h4>
{uploadMethod === 'synthetic' && (
<button
onClick={handleGenerateSynthetic}
disabled={isGenerating}
className="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg hover:from-purple-700 hover:to-pink-700 transition font-semibold disabled:opacity-50"
>
{isGenerating ? (
<>
<Loader2 size={18} className="animate-spin" />
Generando...
</>
) : (
<>
<Sparkles size={18} />
Generar Datos de Prueba
</>
)}
</button>
)}
</div>
</div>
</div>
</div>
</motion.div>
{/* Botón de análisis */}
@@ -541,40 +478,23 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
className="flex justify-center"
>
<button
onClick={() => {
// Preparar segment_mapping
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;
// Llamar a onAnalyze con todos los datos
onAnalyze({
costPerHour,
avgCsat,
segmentMapping,
file: uploadMethod === 'file' ? file || undefined : undefined,
sheetUrl: uploadMethod === 'url' ? sheetUrl : undefined,
useSynthetic: uploadMethod === 'synthetic'
});
}}
onClick={handleSubmit}
disabled={!canAnalyze || isAnalyzing}
className={clsx(
'px-8 py-4 rounded-xl font-bold text-lg transition-all flex items-center gap-3',
'px-8 py-3 rounded-lg font-semibold text-lg transition-all flex items-center gap-3',
canAnalyze && !isAnalyzing
? 'bg-gradient-to-r from-[#6D84E3] to-[#5a6fc9] text-white hover:scale-105 shadow-lg'
: 'bg-slate-300 text-slate-500 cursor-not-allowed'
? 'bg-[#6D84E3] text-white hover:bg-[#5a6fc9] shadow-lg hover:shadow-xl'
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
)}
>
{isAnalyzing ? (
<>
<Loader2 size={24} className="animate-spin" />
<Loader2 size={22} className="animate-spin" />
Analizando...
</>
) : (
<>
<FileText size={24} />
<FileText size={22} />
Generar Análisis
</>
)}

View 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 (&lt;10s) y ZOMBIE (&gt;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;

View File

@@ -1,30 +1,23 @@
// components/SinglePageDataRequestIntegrated.tsx
// Versión integrada con DataInputRedesigned + Dashboard actual
// Versión simplificada con cabecera estilo dashboard
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { Toaster } from 'react-hot-toast';
import { TierKey, AnalysisData } from '../types';
import TierSelectorEnhanced from './TierSelectorEnhanced';
import DataInputRedesigned from './DataInputRedesigned';
import DashboardReorganized from './DashboardReorganized';
import { generateAnalysis } from '../utils/analysisGenerator';
import DashboardTabs from './DashboardTabs';
import { generateAnalysis, generateAnalysisFromCache } from '../utils/analysisGenerator';
import toast from 'react-hot-toast';
import { useAuth } from '../utils/AuthContext';
import { formatDateMonthYear } from '../utils/formatters';
const SinglePageDataRequestIntegrated: React.FC = () => {
const [selectedTier, setSelectedTier] = useState<TierKey>('silver');
const [view, setView] = useState<'form' | 'dashboard'>('form');
const [analysisData, setAnalysisData] = useState<AnalysisData | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const handleTierSelect = (tier: TierKey) => {
setSelectedTier(tier);
};
const { authHeader, logout } = useAuth();
const handleAnalyze = (config: {
costPerHour: number;
avgCsat: number;
@@ -36,53 +29,66 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
file?: File;
sheetUrl?: string;
useSynthetic?: boolean;
useCache?: boolean;
}) => {
console.log('🚀 handleAnalyze called with config:', config);
console.log('🎯 Selected tier:', selectedTier);
console.log('📄 File:', config.file);
console.log('🔗 Sheet URL:', config.sheetUrl);
console.log('✨ Use Synthetic:', config.useSynthetic);
// Validar que hay datos
if (!config.file && !config.sheetUrl && !config.useSynthetic) {
toast.error('Por favor, sube un archivo, introduce una URL o genera datos sintéticos.');
// Validar que hay archivo o caché
if (!config.file && !config.useCache) {
toast.error('Por favor, sube un archivo CSV o Excel.');
return;
}
// 🔐 Si usamos CSV real, exigir estar logado
if (config.file && !config.useSynthetic && !authHeader) {
toast.error('Debes iniciar sesión para analizar datos reales.');
// Validar coste por hora
if (!config.costPerHour || config.costPerHour <= 0) {
toast.error('Por favor, introduce el coste por hora del agente.');
return;
}
// Exigir estar logado para analizar
if (!authHeader) {
toast.error('Debes iniciar sesión para analizar datos.');
return;
}
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 () => {
console.log('⏰ Generating analysis...');
try {
const data = await generateAnalysis(
selectedTier,
let data: AnalysisData;
if (config.useCache) {
// Usar datos desde caché
data = await generateAnalysisFromCache(
'gold' as TierKey,
config.costPerHour,
config.avgCsat,
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,
config.useSynthetic,
false, // No usar sintético
authHeader || undefined
);
console.log('✅ Analysis generated successfully');
}
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');
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (error) {
console.error('Error generating analysis:', error);
console.error('Error generating analysis:', error);
setIsAnalyzing(false);
toast.dismiss('analyzing');
@@ -95,7 +101,7 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
toast.error('Error al generar el análisis: ' + msg);
}
}
}, 1500);
}, 500);
};
const handleBackToForm = () => {
@@ -106,14 +112,10 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
// Dashboard view
if (view === 'dashboard' && analysisData) {
console.log('📊 Rendering dashboard with data:', analysisData);
console.log('📊 Heatmap data length:', analysisData.heatmapData?.length);
console.log('📊 Dimensions length:', analysisData.dimensions?.length);
try {
return <DashboardReorganized analysisData={analysisData} onBack={handleBackToForm} />;
return <DashboardTabs data={analysisData} onBack={handleBackToForm} />;
} catch (error) {
console.error('Error rendering dashboard:', error);
console.error('Error rendering dashboard:', error);
return (
<div className="min-h-screen bg-red-50 p-8">
<div className="max-w-2xl mx-auto bg-white rounded-xl shadow-lg p-6">
@@ -136,56 +138,34 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
<>
<Toaster position="top-right" />
<div className="w-full min-h-screen bg-gradient-to-br from-slate-50 via-[#E8EBFA] to-slate-100 font-sans">
<div className="w-full max-w-7xl mx-auto p-6 space-y-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center"
>
<h1 className="text-4xl md:text-5xl font-bold text-slate-900 mb-3">
Beyond Diagnostic
<div className="min-h-screen bg-slate-50">
{/* Header estilo dashboard */}
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
<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">
AIR EUROPA - Beyond CX Analytics
</h1>
<p className="text-lg text-slate-600">
Análisis de Readiness Agéntico para Contact Centers
</p>
<div className="flex items-center gap-4">
<span className="text-sm text-slate-500">{formatDateMonthYear()}</span>
<button
onClick={logout}
className="text-xs text-slate-500 hover:text-slate-800 underline mt-1"
className="text-xs text-slate-500 hover:text-slate-800 underline"
>
Cerrar sesión
</button>
</motion.div>
{/* Tier Selection */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-white rounded-xl shadow-lg p-8"
>
<div className="mb-8">
<h2 className="text-3xl font-bold text-slate-900 mb-2">
Selecciona tu Tier de Análisis
</h2>
<p className="text-slate-600">
Elige el nivel de profundidad que necesitas para tu diagnóstico
</p>
</div>
</div>
</div>
</header>
<TierSelectorEnhanced
selectedTier={selectedTier}
onSelectTier={handleTierSelect}
/>
</motion.div>
{/* Data Input - Using redesigned component */}
{/* Contenido principal */}
<main className="max-w-7xl mx-auto px-6 py-6">
<DataInputRedesigned
onAnalyze={handleAnalyze}
isAnalyzing={isAnalyzing}
/>
</div>
</main>
</div>
</>
);

View File

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

View File

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

View File

@@ -0,0 +1,197 @@
import {
ComposedChart,
Bar,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
LabelList
} from 'recharts';
export interface WaterfallDataPoint {
label: string;
value: number;
cumulative: number;
type: 'initial' | 'increase' | 'decrease' | 'total';
}
export interface WaterfallChartProps {
data: WaterfallDataPoint[];
title?: string;
height?: number;
formatValue?: (value: number) => string;
}
interface ProcessedDataPoint {
label: string;
value: number;
cumulative: number;
type: 'initial' | 'increase' | 'decrease' | 'total';
start: number;
end: number;
displayValue: number;
}
export function WaterfallChart({
data,
title,
height = 300,
formatValue = (v) => `${Math.abs(v).toLocaleString()}`
}: WaterfallChartProps) {
// Process data for waterfall visualization
const processedData: ProcessedDataPoint[] = data.map((item) => {
let start: number;
let end: number;
if (item.type === 'initial' || item.type === 'total') {
start = 0;
end = item.cumulative;
} else if (item.type === 'decrease') {
// Savings: bar goes down from previous cumulative
start = item.cumulative;
end = item.cumulative - item.value;
} else {
// Increase: bar goes up from previous cumulative
start = item.cumulative - item.value;
end = item.cumulative;
}
return {
...item,
start: Math.min(start, end),
end: Math.max(start, end),
displayValue: Math.abs(item.value)
};
});
const getBarColor = (type: string): string => {
switch (type) {
case 'initial':
return '#64748B'; // slate-500
case 'decrease':
return '#059669'; // emerald-600 (savings)
case 'increase':
return '#DC2626'; // red-600 (costs)
case 'total':
return '#6D84E3'; // primary blue
default:
return '#94A3B8';
}
};
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: ProcessedDataPoint }> }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-white px-3 py-2 shadow-lg rounded-lg border border-slate-200">
<p className="font-medium text-slate-800">{data.label}</p>
<p className={`text-sm ${
data.type === 'decrease' ? 'text-emerald-600' :
data.type === 'increase' ? 'text-red-600' :
'text-slate-600'
}`}>
{data.type === 'decrease' ? '-' : data.type === 'increase' ? '+' : ''}
{formatValue(data.value)}
</p>
{data.type !== 'initial' && data.type !== 'total' && (
<p className="text-xs text-slate-500">
Acumulado: {formatValue(data.cumulative)}
</p>
)}
</div>
);
}
return null;
};
// Find min/max for Y axis - always start from 0
const allValues = processedData.flatMap(d => [d.start, d.end]);
const minValue = 0; // Always start from 0, not negative
const maxValue = Math.max(...allValues);
const padding = maxValue * 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;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,520 @@
import React from 'react';
import { motion } from 'framer-motion';
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;
}
// ========== 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 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): 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-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-gray-200 overflow-hidden"
>
{/* Header */}
<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-blue-50">
<Icon className="w-5 h-5 text-blue-600" />
</div>
<div>
<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="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-gray-50/50 border-b border-gray-100">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">{dimension.kpi.label}</span>
<div className="flex items-center gap-2">
<span className="font-bold text-gray-900">{dimension.kpi.value}</span>
{dimension.kpi.change && (
<div className={cn('flex items-center gap-1 text-xs', trendColor)}>
<TrendIcon className="w-3 h-3" />
<span>{dimension.kpi.change}</span>
</div>
)}
</div>
</div>
{dimension.percentile && (
<div className="mt-2">
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>Percentil</span>
<span>P{dimension.percentile}</span>
</div>
<div className="h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-600 rounded-full"
style={{ width: `${dimension.percentile}%` }}
/>
</div>
</div>
)}
</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>
)}
{/* 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-blue-50 rounded-lg border border-blue-100">
<div className="flex items-start gap-2">
<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>
)}
</motion.div>
);
}
// ========== v3.16: COMPONENTE PRINCIPAL ==========
export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
// Filter out agentic_readiness (has its own tab)
const coreDimensions = data.dimensions.filter(d => d.name !== 'agentic_readiness');
// Group findings and recommendations by dimension
const getFindingsForDimension = (dimensionId: string) =>
data.findings.filter(f => f.dimensionId === dimensionId);
const getRecommendationsForDimension = (dimensionId: string) =>
data.recommendations.filter(r => r.dimensionId === dimensionId);
// 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">
{/* 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)}
causalAnalyses={getCausalAnalysisForDimension(dimension)}
delay={idx * 0.05}
/>
))}
</div>
</div>
);
}
export default DimensionAnalysisTab;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>
);
}

View File

@@ -0,0 +1,268 @@
/**
* v3.15: Sistema de Diseño McKinsey
*
* Principios:
* 1. Minimalismo funcional: Cada elemento debe tener un propósito
* 2. Jerarquía clara: El ojo sabe dónde ir primero
* 3. Datos como protagonistas: Los números destacan, no los adornos
* 4. Color con significado: Solo para indicar status, no para decorar
* 5. Espacio en blanco: Respira, no satura
* 6. Consistencia absoluta: Mismo patrón en todas partes
*/
// ============================================
// PALETA DE COLORES (restringida)
// ============================================
export const COLORS = {
// Colores base
text: {
primary: '#1a1a1a', // Títulos, valores importantes
secondary: '#4a4a4a', // Texto normal
muted: '#6b7280', // Labels, texto secundario
inverse: '#ffffff', // Texto sobre fondos oscuros
},
// Fondos
background: {
page: '#f9fafb', // Fondo de página
card: '#ffffff', // Fondo de cards
subtle: '#f3f4f6', // Fondos de secciones
hover: '#f9fafb', // Hover states
},
// Bordes
border: {
light: '#e5e7eb', // Bordes sutiles
medium: '#d1d5db', // Bordes más visibles
},
// Semánticos (ÚNICOS colores con significado)
status: {
critical: '#dc2626', // Rojo - Requiere acción
warning: '#f59e0b', // Ámbar - Atención
success: '#10b981', // Verde - Óptimo
info: '#3b82f6', // Azul - Informativo/Habilitador
neutral: '#6b7280', // Gris - Sin datos/NA
},
// Tiers de automatización
tier: {
automate: '#10b981', // Verde
assist: '#06b6d4', // Cyan
augment: '#f59e0b', // Ámbar
human: '#6b7280', // Gris
},
// Acento (usar con moderación)
accent: {
primary: '#2563eb', // Azul corporativo - CTAs, links
primaryHover: '#1d4ed8',
}
};
// Mapeo de colores para clases Tailwind
export const STATUS_CLASSES = {
critical: {
text: 'text-red-600',
bg: 'bg-red-50',
border: 'border-red-200',
borderTop: 'border-t-red-500',
},
warning: {
text: 'text-amber-600',
bg: 'bg-amber-50',
border: 'border-amber-200',
borderTop: 'border-t-amber-500',
},
success: {
text: 'text-emerald-600',
bg: 'bg-emerald-50',
border: 'border-emerald-200',
borderTop: 'border-t-emerald-500',
},
info: {
text: 'text-blue-600',
bg: 'bg-blue-50',
border: 'border-blue-200',
borderTop: 'border-t-blue-500',
},
neutral: {
text: 'text-gray-500',
bg: 'bg-gray-50',
border: 'border-gray-200',
borderTop: 'border-t-gray-400',
},
};
export const TIER_CLASSES = {
AUTOMATE: {
text: 'text-emerald-600',
bg: 'bg-emerald-50',
border: 'border-emerald-200',
fill: '#10b981',
},
ASSIST: {
text: 'text-cyan-600',
bg: 'bg-cyan-50',
border: 'border-cyan-200',
fill: '#06b6d4',
},
AUGMENT: {
text: 'text-amber-600',
bg: 'bg-amber-50',
border: 'border-amber-200',
fill: '#f59e0b',
},
'HUMAN-ONLY': {
text: 'text-gray-500',
bg: 'bg-gray-50',
border: 'border-gray-200',
fill: '#6b7280',
},
};
// ============================================
// TIPOGRAFÍA
// ============================================
export const TYPOGRAPHY = {
// Tamaños (escala restringida)
fontSize: {
xs: 'text-xs', // 12px - Footnotes, badges
sm: 'text-sm', // 14px - Labels, texto secundario
base: 'text-base', // 16px - Texto normal
lg: 'text-lg', // 18px - Subtítulos
xl: 'text-xl', // 20px - Títulos de sección
'2xl': 'text-2xl', // 24px - Títulos de página
'3xl': 'text-3xl', // 32px - Métricas grandes
'4xl': 'text-4xl', // 40px - KPIs hero
},
// Pesos
fontWeight: {
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold',
},
};
// ============================================
// ESPACIADO
// ============================================
export const SPACING = {
// Padding de cards
card: {
sm: 'p-4', // Cards compactas
md: 'p-5', // Cards normales (changed from p-6)
lg: 'p-6', // Cards destacadas
},
// Gaps entre secciones
section: {
sm: 'space-y-4', // Entre elementos dentro de sección
md: 'space-y-6', // Entre secciones
lg: 'space-y-8', // Entre bloques principales
},
// Grid gaps
grid: {
sm: 'gap-3',
md: 'gap-4',
lg: 'gap-6',
}
};
// ============================================
// COMPONENTES BASE (clases)
// ============================================
// Card base
export const CARD_BASE = 'bg-white rounded-lg border border-gray-200';
// Section header
export const SECTION_HEADER = {
wrapper: 'flex items-start justify-between pb-3 mb-4 border-b border-gray-200',
title: {
h2: 'text-lg font-semibold text-gray-900',
h3: 'text-base font-semibold text-gray-900',
h4: 'text-sm font-medium text-gray-800',
},
subtitle: 'text-sm text-gray-500 mt-0.5',
};
// Badge
export const BADGE_BASE = 'inline-flex items-center font-medium rounded-md';
export const BADGE_SIZES = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-1 text-sm',
};
// Metric
export const METRIC_BASE = {
label: 'text-xs font-medium text-gray-500 uppercase tracking-wide',
value: {
sm: 'text-lg font-semibold',
md: 'text-2xl font-semibold',
lg: 'text-3xl font-semibold',
xl: 'text-4xl font-bold',
},
unit: 'text-sm text-gray-500',
comparison: 'text-xs text-gray-400',
};
// Table
export const TABLE_CLASSES = {
wrapper: 'overflow-x-auto',
table: 'w-full text-sm text-left',
thead: 'text-xs text-gray-500 uppercase tracking-wide bg-gray-50',
th: 'px-4 py-3 font-medium',
tbody: 'divide-y divide-gray-100',
tr: 'hover:bg-gray-50 transition-colors',
td: 'px-4 py-3 text-gray-700',
};
// ============================================
// HELPERS
// ============================================
/**
* Obtiene las clases de status basado en score
*/
export function getStatusFromScore(score: number | null | undefined): keyof typeof STATUS_CLASSES {
if (score === null || score === undefined) return 'neutral';
if (score < 40) return 'critical';
if (score < 70) return 'warning';
return 'success';
}
/**
* Formatea moneda de forma consistente
*/
export function formatCurrency(value: number): string {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${Math.round(value / 1000)}K`;
return `${value.toLocaleString()}`;
}
/**
* Formatea número grande
*/
export function formatNumber(value: number): string {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${Math.round(value / 1000)}K`;
return value.toLocaleString();
}
/**
* Formatea porcentaje
*/
export function formatPercent(value: number, decimals = 0): string {
return `${value.toFixed(decimals)}%`;
}
/**
* Combina clases de forma segura (simple cn helper)
*/
export function cn(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(' ');
}

View File

@@ -6,17 +6,17 @@ export const TIERS: TiersData = {
name: 'Análisis GOLD',
price: 4900,
color: 'bg-yellow-500',
description: '6 dimensiones completas con algoritmo Agentic Readiness avanzado',
description: '5 dimensiones completas con Agentic Readiness avanzado',
requirements: 'CCaaS moderno (Genesys, Five9, NICE, Talkdesk)',
timeline: '3-4 semanas',
features: [
'6 dimensiones completas',
'Algoritmo Agentic Readiness avanzado (6 sub-factores)',
'Análisis de distribución horaria',
'Segmentación de clientes (opcional)',
'Benchmark con percentiles múltiples (P25, P50, P75, P90)',
'5 dimensiones: Volumetría, Eficiencia, Efectividad, Complejidad, Agentic Readiness',
'Agentic Readiness Score 0-10 por cola',
'Análisis de distribución horaria y semanal',
'Métricas P10/P50/P90 por cola',
'FCR proxy y tasa de transferencias',
'Análisis de variabilidad y predictibilidad',
'Roadmap ejecutable con 3 waves',
'Modelo económico con NPV y análisis de sensibilidad',
'Sesión de presentación incluida'
]
},
@@ -24,15 +24,14 @@ export const TIERS: TiersData = {
name: 'Análisis SILVER',
price: 3500,
color: 'bg-gray-400',
description: '4 dimensiones core con Agentic Readiness simplificado',
description: '5 dimensiones con Agentic Readiness simplificado',
requirements: 'Sistema ACD/PBX con reporting básico',
timeline: '2-3 semanas',
features: [
'4 dimensiones (Volumetría, Rendimiento, Economía, Agentic Readiness)',
'Algoritmo Agentic Readiness simplificado (3 sub-factores)',
'5 dimensiones completas',
'Agentic Readiness simplificado (4 sub-factores)',
'Roadmap de implementación',
'Opportunity Matrix',
'Economic Model básico',
'Dashboard interactivo'
]
},
@@ -40,15 +39,14 @@ export const TIERS: TiersData = {
name: 'Análisis EXPRESS',
price: 1950,
color: 'bg-orange-600',
description: '3 dimensiones fundamentales sin Agentic Readiness',
description: '4 dimensiones fundamentales sin Agentic Readiness detallado',
requirements: 'Exportación básica de reportes',
timeline: '1-2 semanas',
features: [
'3 dimensiones core (Volumetría, Rendimiento, Economía)',
'4 dimensiones core (Volumetría, Eficiencia, Efectividad, Complejidad)',
'Agentic Readiness básico',
'Roadmap cualitativo',
'Análisis básico',
'Recomendaciones estratégicas',
'Reporte ejecutivo'
'Recomendaciones estratégicas'
]
}
};
@@ -136,14 +134,13 @@ export const DATA_REQUIREMENTS: DataRequirementsData = {
}
};
// v2.0: Dimensiones actualizadas (6 en lugar de 8)
// v3.0: 5 dimensiones viables
export const DIMENSION_NAMES = {
volumetry_distribution: 'Volumetría y Distribución Horaria',
performance: 'Rendimiento',
satisfaction: 'Satisfacción',
economy: 'Economía',
efficiency: 'Eficiencia', // Fusiona Eficiencia + Efectividad
benchmark: 'Benchmark'
volumetry_distribution: 'Volumetría & Distribución',
operational_efficiency: 'Eficiencia Operativa',
effectiveness_resolution: 'Efectividad & Resolución',
complexity_predictability: 'Complejidad & Predictibilidad',
agentic_readiness: 'Agentic Readiness'
};
// v2.0: Ponderaciones para Agentic Readiness Score

View File

@@ -60,7 +60,65 @@ export interface RawInteraction {
wrap_up_time: number; // Tiempo ACW post-llamada (segundos)
agent_id: string; // ID agente (anónimo/hash)
transfer_flag: boolean; // Indicador de transferencia
repeat_call_7d?: boolean; // True si el cliente llamó en los últimos 7 días (para FCR)
caller_id?: string; // ID cliente (opcional, hash/anónimo)
disconnection_type?: string; // Tipo de desconexión (Externo/Interno/etc.)
total_conversation?: number; // Conversación total en segundos (null/0 = abandono)
is_abandoned?: boolean; // Flag directo de abandono del CSV
record_status?: 'valid' | 'noise' | 'zombie' | 'abandon'; // Estado del registro para filtrado
fcr_real_flag?: boolean; // FCR pre-calculado en el CSV (TRUE = resuelto en primer contacto)
// v3.0: Campos para drill-down (jerarquía de 2 niveles)
original_queue_id?: string; // Nombre real de la cola en centralita (nivel operativo)
linea_negocio?: string; // Línea de negocio (business_unit) - 9 categorías C-Level
// queue_skill ya existe arriba como nivel estratégico
}
// Tipo para filtrado por record_status
export type RecordStatus = 'valid' | 'noise' | 'zombie' | 'abandon';
// v3.4: Tier de clasificación para roadmap
export type AgenticTier = 'AUTOMATE' | 'ASSIST' | 'AUGMENT' | 'HUMAN-ONLY';
// v3.4: Desglose del score por factores
export interface AgenticScoreBreakdown {
predictibilidad: number; // 30% - basado en CV AHT
resolutividad: number; // 25% - FCR (60%) + Transfer (40%)
volumen: number; // 25% - basado en volumen mensual
calidadDatos: number; // 10% - % registros válidos
simplicidad: number; // 10% - basado en AHT
}
// v3.4: Métricas por cola individual (original_queue_id - nivel operativo)
export interface OriginalQueueMetrics {
original_queue_id: string; // Nombre real de la cola en centralita
volume: number; // Total de interacciones
volumeValid: number; // Sin NOISE/ZOMBIE (para cálculo CV)
aht_mean: number; // AHT promedio (segundos)
cv_aht: number; // CV AHT calculado solo sobre VALID (%)
transfer_rate: number; // Tasa de transferencia (%)
fcr_rate: number; // FCR (%)
agenticScore: number; // Score de automatización (0-10)
scoreBreakdown?: AgenticScoreBreakdown; // v3.4: Desglose por factores
tier: AgenticTier; // v3.4: Clasificación para roadmap
tierMotivo?: string; // v3.4: Motivo de la clasificación
isPriorityCandidate: boolean; // Tier 1 (AUTOMATE)
annualCost?: number; // Coste anual estimado
}
// v3.1: Tipo para drill-down - Nivel 1: queue_skill (estratégico)
export interface DrilldownDataPoint {
skill: string; // queue_skill (categoría estratégica)
originalQueues: OriginalQueueMetrics[]; // Colas reales de centralita (nivel 2)
// Métricas agregadas del grupo
volume: number; // Total de interacciones del grupo
volumeValid: number; // Sin NOISE/ZOMBIE
aht_mean: number; // AHT promedio ponderado (segundos)
cv_aht: number; // CV AHT promedio ponderado (%)
transfer_rate: number; // Tasa de transferencia ponderada (%)
fcr_rate: number; // FCR ponderado (%)
agenticScore: number; // Score de automatización promedio (0-10)
isPriorityCandidate: boolean; // Al menos una cola con CV < 75%
annualCost?: number; // Coste anual total del grupo
}
// Métricas calculadas por skill
@@ -76,6 +134,7 @@ export interface SkillMetrics {
avg_hold_time: number; // Promedio hold_time
avg_wrap_up: number; // Promedio wrap_up_time
transfer_rate: number; // % con transfer_flag = true
abandonment_rate: number; // % abandonos (desconexión externa + sin conversación)
// Métricas de variabilidad
cv_aht: number; // Coeficiente de variación AHT (%)
@@ -102,14 +161,15 @@ export interface Kpi {
changeType?: 'positive' | 'negative' | 'neutral';
}
// v2.0: Dimensiones reducidas de 8 a 6
// v4.0: 7 dimensiones viables
export type DimensionName =
| 'volumetry_distribution' // Volumetría y Distribución Horaria (fusión + ampliación)
| 'performance' // Rendimiento
| 'satisfaction' // Satisfacción
| 'economy' // Economía
| 'efficiency' // Eficiencia (fusiona efficiency + effectiveness)
| 'benchmark'; // Benchmark
| 'volumetry_distribution' // Volumetría & Distribución
| 'operational_efficiency' // Eficiencia Operativa
| 'effectiveness_resolution' // Efectividad & Resolución
| 'complexity_predictability' // Complejidad & Predictibilidad
| 'customer_satisfaction' // Satisfacción del Cliente (CSAT)
| 'economy_cpi' // Economía Operacional (CPI)
| 'agentic_readiness'; // Agentic Readiness
export interface SubFactor {
name: string;
@@ -152,6 +212,7 @@ export interface HeatmapDataPoint {
csat: number; // Customer Satisfaction score (0-100) - MANUAL (estático)
hold_time: number; // Hold Time promedio (segundos) - CALCULADO
transfer_rate: number; // % transferencias - CALCULADO
abandonment_rate: number; // % abandonos - CALCULADO
};
annual_cost?: number; // Coste anual en euros (calculado con cost_per_hour)
@@ -186,11 +247,14 @@ export interface Opportunity {
customer_segment?: CustomerSegment; // v2.0: Nuevo campo opcional
}
export enum RoadmapPhase {
Automate = 'Automate',
Assist = 'Assist',
Augment = 'Augment'
}
// Usar objeto const en lugar de enum para evitar problemas de tree-shaking con Vite
export const RoadmapPhase = {
Automate: 'Automate',
Assist: 'Assist',
Augment: 'Augment'
} as const;
export type RoadmapPhase = typeof RoadmapPhase[keyof typeof RoadmapPhase];
export interface RoadmapInitiative {
id: string;
@@ -201,6 +265,15 @@ export interface RoadmapInitiative {
resources: string[];
dimensionId: string;
risk?: 'high' | 'medium' | 'low'; // v2.0: Nuevo campo
// v2.1: Campos para trazabilidad
skillsImpacted?: string[]; // Skills que impacta
savingsDetail?: string; // Detalle del cálculo de ahorro
estimatedSavings?: number; // Ahorro estimado €
resourceHours?: number; // Horas estimadas de recursos
// v3.0: Campos mejorados conectados con skills reales
volumeImpacted?: number; // Volumen de interacciones impactadas
kpiObjective?: string; // Objetivo KPI específico
rationale?: string; // Justificación de la iniciativa
}
export interface Finding {
@@ -271,4 +344,6 @@ export interface AnalysisData {
agenticReadiness?: AgenticReadinessResult; // v2.0: Nuevo campo
staticConfig?: StaticConfig; // v2.0: Configuración estática usada
source?: AnalysisSource;
dateRange?: { min: string; max: string }; // v2.1: Periodo analizado
drilldownData?: DrilldownDataPoint[]; // v3.0: Drill-down Cola + Tipificación
}

View File

@@ -1,14 +1,15 @@
// analysisGenerator.ts - v2.0 con 6 dimensiones
import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment } from '../types';
import { generateAnalysisFromRealData } from './realDataAnalysis';
import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment, RawInteraction, DrilldownDataPoint, AgenticTier } from '../types';
import { generateAnalysisFromRealData, calculateDrilldownMetrics, generateOpportunitiesFromDrilldown, generateRoadmapFromDrilldown } from './realDataAnalysis';
import { RoadmapPhase } from '../types';
import { BarChartHorizontal, Zap, Smile, DollarSign, Target, Globe } from 'lucide-react';
import { BarChartHorizontal, Zap, Target, Brain, Bot } from 'lucide-react';
import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2';
import { callAnalysisApiRaw } from './apiClient';
import {
mapBackendResultsToAnalysisData,
buildHeatmapFromBackend,
} from './backendMapper';
import { saveFileToServerCache, saveDrilldownToServerCache, getCachedDrilldown } from './serverCache';
@@ -30,14 +31,14 @@ const getScoreColor = (score: number): 'green' | 'yellow' | 'red' => {
return 'red';
};
// v2.0: 6 DIMENSIONES (eliminadas Complejidad y Efectividad)
// v3.0: 5 DIMENSIONES VIABLES
const DIMENSIONS_CONTENT = {
volumetry_distribution: {
icon: BarChartHorizontal,
titles: ["Volumetría y Distribución Horaria", "Análisis de la Demanda"],
titles: ["Volumetría & Distribución", "Análisis de la Demanda"],
summaries: {
good: ["El volumen de interacciones se alinea con las previsiones, permitiendo una planificación de personal precisa.", "La distribución horaria es uniforme con picos predecibles, facilitando la automatización."],
medium: ["Existen picos de demanda imprevistos que generan caídas en el nivel de servicio.", "Alto porcentaje de interacciones fuera de horario laboral (>30%), sugiriendo necesidad de cobertura 24/7."],
good: ["El volumen de interacciones se alinea con las previsiones, permitiendo una planificación de personal precisa.", "La distribución horaria es uniforme con picos predecibles. Concentración Pareto equilibrada."],
medium: ["Existen picos de demanda imprevistos que generan caídas en el nivel de servicio.", "Alta concentración en pocas colas (>80% en 20% de colas), riesgo de cuellos de botella."],
bad: ["Desajuste crónico entre el forecast y el volumen real, resultando en sobrecostes o mal servicio.", "Distribución horaria muy irregular con múltiples picos impredecibles."]
},
kpis: [
@@ -45,148 +46,120 @@ const DIMENSIONS_CONTENT = {
{ label: "% Fuera de Horario", value: `${randomInt(15, 45)}%` },
],
},
performance: {
operational_efficiency: {
icon: Zap,
titles: ["Rendimiento Operativo", "Optimización de Tiempos"],
titles: ["Eficiencia Operativa", "Optimización de Tiempos"],
summaries: {
good: ["El AHT está bien controlado con baja variabilidad (CV<30%), indicando procesos estandarizados.", "Tiempos de espera y post-llamada (ACW) mínimos, maximizando la productividad del agente."],
medium: ["El AHT es competitivo, pero la variabilidad es alta (CV>40%), sugiriendo inconsistencia en procesos.", "El tiempo en espera (Hold Time) es ligeramente elevado, sugiriendo posibles mejoras en el acceso a la información."],
bad: ["El AHT excede los benchmarks de la industria con alta variabilidad, impactando directamente en los costes.", "Tiempos de ACW prolongados indican procesos manuales ineficientes o falta de integración de sistemas."]
good: ["El ratio P90/P50 es bajo (<1.5), indicando tiempos consistentes y procesos estandarizados.", "Tiempos de espera, hold y ACW bien controlados, maximizando la productividad."],
medium: ["El ratio P90/P50 es moderado (1.5-2.0), existen casos outliers que afectan la eficiencia.", "El tiempo de hold es ligeramente elevado, sugiriendo mejoras en acceso a información."],
bad: ["Alto ratio P90/P50 (>2.0), indicando alta variabilidad en tiempos de gestión.", "Tiempos de ACW y hold prolongados indican procesos manuales ineficientes."]
},
kpis: [
{ label: "AHT Promedio", value: `${randomInt(280, 550)}s` },
{ label: "CV AHT", value: `${randomInt(25, 60)}%` },
{ label: "AHT P50", value: `${randomInt(280, 450)}s` },
{ label: "Ratio P90/P50", value: `${randomFloat(1.2, 2.5, 2)}` },
],
},
satisfaction: {
icon: Smile,
titles: ["Satisfacción y Experiencia", "Voz del Cliente"],
summaries: {
good: ["Puntuaciones de CSAT muy positivas con distribución normal, reflejando un proceso estable y consistente.", "El análisis cualitativo muestra un sentimiento mayoritariamente positivo en las interacciones."],
medium: ["Los indicadores de satisfacción son neutros. La distribución de CSAT muestra cierta bimodalidad.", "Se observan comentarios mixtos, con puntos fuertes en la amabilidad del agente pero debilidades en los tiempos de respuesta."],
bad: ["Bajas puntuaciones de CSAT con distribución anormal, indicando un proceso inconsistente.", "Los clientes se quejan frecuentemente de largos tiempos de espera, repetición de información y falta de resolución."]
},
kpis: [
{ label: "CSAT Promedio", value: `${randomFloat(3.8, 4.9, 1)}/5` },
{ label: "NPS", value: `${randomInt(-20, 55)}` },
],
},
economy: {
icon: DollarSign,
titles: ["Economía y Costes", "Rentabilidad del Servicio"],
summaries: {
good: ["El coste por interacción está por debajo del promedio de la industria, indicando una operación rentable.", "El ROI potencial de automatización supera los €200K anuales con payback <12 meses."],
medium: ["Los costes son estables pero no se observa una tendencia a la baja, sugiriendo un estancamiento en la optimización.", "El ROI potencial es moderado (€100-200K), requiriendo inversión inicial significativa."],
bad: ["Coste por interacción elevado, erosionando los márgenes de beneficio de la compañía.", "Bajo ROI potencial (<€100K) debido a volumen insuficiente o procesos ya optimizados."]
},
kpis: [
{ label: "Coste por Interacción", value: `${randomFloat(2.5, 9.5, 2)}` },
{ label: "Ahorro Potencial", value: `${randomInt(50, 250)}K` },
],
},
efficiency: {
effectiveness_resolution: {
icon: Target,
titles: ["Eficiencia", "Resolución y Calidad"],
titles: ["Efectividad & Resolución", "Calidad del Servicio"],
summaries: {
good: ["Alta tasa de resolución en el primer contacto (FCR>85%), minimizando la repetición de llamadas.", "Bajo índice de transferencias y escalaciones (<10%), demostrando un correcto enrutamiento y alto conocimiento del agente."],
medium: ["La tasa de FCR es aceptable (70-85%), aunque se detectan ciertos tipos de consulta que requieren múltiples contactos.", "Las transferencias son moderadas (10-20%), concentradas en departamentos específicos."],
bad: ["Bajo FCR (<70%), lo que genera frustración en el cliente y aumenta el volumen de interacciones innecesarias.", "Excesivas transferencias y escalaciones (>20%), creando una experiencia de cliente fragmentada y costosa."]
good: ["FCR proxy >85%, mínima repetición de contactos a 7 días.", "Baja tasa de transferencias (<10%) y llamadas problemáticas (<5%)."],
medium: ["FCR proxy 70-85%, hay oportunidad de reducir recontactos.", "Tasa de transferencias moderada (10-20%), concentradas en ciertas colas."],
bad: ["FCR proxy <70%, alto volumen de recontactos a 7 días.", "Alta tasa de llamadas problemáticas (>15%) y transferencias excesivas."]
},
kpis: [
{ label: "Tasa FCR", value: `${randomInt(65, 92)}%` },
{ label: "Tasa de Escalación", value: `${randomInt(5, 25)}%` },
{ label: "FCR Proxy 7d", value: `${randomInt(65, 92)}%` },
{ label: "Tasa Transfer", value: `${randomInt(5, 25)}%` },
],
},
benchmark: {
icon: Globe,
titles: ["Benchmark de Industria", "Contexto Competitivo"],
complexity_predictability: {
icon: Brain,
titles: ["Complejidad & Predictibilidad", "Análisis de Variabilidad"],
summaries: {
good: ["La operación se sitúa consistentemente por encima del P75 en los KPIs más críticos.", "El rendimiento en eficiencia y calidad es de 'top quartile', representando una ventaja competitiva."],
medium: ["El rendimiento general está en línea con la mediana de la industria (P50), sin claras fortalezas o debilidades.", "Se observan algunas áreas por debajo del P50 que representan oportunidades de mejora claras."],
bad: ["La mayoría de los KPIs se encuentran por debajo del P25, indicando una necesidad urgente de mejora.", "El AHT y el CPI son significativamente más altos que los benchmarks, impactando la rentabilidad."]
good: ["Baja variabilidad AHT (ratio P90/P50 <1.5), proceso altamente predecible.", "Diversidad de tipificaciones controlada, bajo % de llamadas con múltiples holds."],
medium: ["Variabilidad AHT moderada, algunos casos outliers afectan la predictibilidad.", "% llamadas con múltiples holds elevado (15-30%), indicando complejidad."],
bad: ["Alta variabilidad AHT (ratio >2.0), proceso impredecible y difícil de automatizar.", "Alta diversidad de tipificaciones y % transferencias, indicando alta complejidad."]
},
kpis: [
{ label: "Posición vs P50 AHT", value: `P${randomInt(30, 70)}` },
{ label: "Posición vs P50 FCR", value: `P${randomInt(30, 70)}` },
{ label: "Ratio P90/P50", value: `${randomFloat(1.2, 2.5, 2)}` },
{ label: "% Transferencias", value: `${randomInt(5, 30)}%` },
],
},
agentic_readiness: {
icon: Bot,
titles: ["Agentic Readiness", "Potencial de Automatización"],
summaries: {
good: ["Score 8-10: Excelente candidato para automatización completa con agentes IA.", "Alto volumen, baja variabilidad, pocas transferencias. Proceso repetitivo y predecible."],
medium: ["Score 5-7: Candidato para asistencia con IA (copilot) o automatización parcial.", "Volumen moderado con algunas complejidades que requieren supervisión humana."],
bad: ["Score 0-4: Requiere optimización previa antes de automatizar.", "Alta complejidad, baja repetitividad o variabilidad excesiva."]
},
kpis: [
{ label: "Score Global", value: `${randomFloat(3.0, 9.5, 1)}/10` },
{ label: "Categoría", value: randomFromList(['Automatizar', 'Asistir', 'Optimizar']) },
],
},
};
// Hallazgos genéricos - los específicos se generan en realDataAnalysis.ts desde datos calculados
const KEY_FINDINGS: Finding[] = [
{
text: "El canal de voz presenta un AHT un 35% superior al del chat, pero una tasa de resolución un 15% mayor.",
dimensionId: 'performance',
text: "El ratio P90/P50 de AHT es alto (>2.0), indicando alta variabilidad en tiempos de gestión.",
dimensionId: 'operational_efficiency',
type: 'warning',
title: 'Alta Variabilidad en Tiempos',
description: 'Procesos poco estandarizados generan tiempos impredecibles y afectan la planificación.',
impact: 'high'
},
{
text: "Tasa de transferencias elevada indica oportunidad de mejora en enrutamiento o capacitación.",
dimensionId: 'effectiveness_resolution',
type: 'warning',
title: 'Transferencias Elevadas',
description: 'Las transferencias frecuentes afectan la experiencia del cliente y la eficiencia operativa.',
impact: 'high'
},
{
text: "Concentración de volumen en franjas horarias específicas genera picos de demanda.",
dimensionId: 'volumetry_distribution',
type: 'info',
title: 'Diferencia de Canales: Voz vs Chat',
description: 'Análisis comparativo entre canales muestra trade-off entre velocidad y resolución.',
title: 'Concentración de Demanda',
description: 'Revisar capacidad en franjas de mayor volumen para optimizar nivel de servicio.',
impact: 'medium'
},
{
text: "Un 22% de las transferencias desde 'Soporte Técnico N1' hacia 'Facturación' son incorrectas.",
dimensionId: 'efficiency',
type: 'warning',
title: 'Enrutamiento Incorrecto',
description: 'Existe un problema de routing que genera ineficiencias y experiencia pobre del cliente.',
impact: 'high'
},
{
text: "El pico de demanda de los lunes por la mañana provoca una caída del Nivel de Servicio al 65%.",
dimensionId: 'volumetry_distribution',
type: 'critical',
title: 'Crisis de Capacidad (Lunes por la mañana)',
description: 'Los lunes 8-11h generan picos impredecibles que agotan la capacidad disponible.',
impact: 'high'
},
{
text: "El 28% de las interacciones ocurren fuera del horario laboral estándar (8-18h).",
text: "Porcentaje significativo de interacciones fuera del horario laboral estándar (8-19h).",
dimensionId: 'volumetry_distribution',
type: 'info',
title: 'Demanda Fuera de Horario',
description: 'Casi 1 de 3 interacciones se produce fuera del horario laboral, requiriendo cobertura extendida.',
description: 'Evaluar cobertura extendida o canales de autoservicio para demanda fuera de horario.',
impact: 'medium'
},
{
text: "Las consultas sobre 'estado del pedido' representan el 30% de las interacciones y tienen alta repetitividad.",
dimensionId: 'volumetry_distribution',
text: "Oportunidades de automatización identificadas en consultas repetitivas de alto volumen.",
dimensionId: 'agentic_readiness',
type: 'info',
title: 'Oportunidad de Automatización: Estado de Pedido',
description: 'Volumen significativo en consultas altamente repetitivas y automatizables.',
title: 'Oportunidad de Automatización',
description: 'Skills con alta repetitividad y baja complejidad son candidatos ideales para agentes IA.',
impact: 'high'
},
{
text: "Baja puntuación de CSAT en interacciones relacionadas con problemas de facturación.",
dimensionId: 'satisfaction',
type: 'warning',
title: 'Satisfacción Baja en Facturación',
description: 'El equipo de facturación tiene desempeño por debajo de la media en satisfacción del cliente.',
impact: 'high'
},
{
text: "La variabilidad de AHT (CV=45%) sugiere procesos poco estandarizados.",
dimensionId: 'performance',
type: 'warning',
title: 'Inconsistencia en Procesos',
description: 'Alta variabilidad indica falta de estandarización y diferencias significativas entre agentes.',
impact: 'medium'
},
];
const RECOMMENDATIONS: Recommendation[] = [
{
text: "Implementar un programa de formación específico para agentes de Facturación sobre los nuevos planes.",
dimensionId: 'efficiency',
text: "Estandarizar procesos en colas con alto ratio P90/P50 para reducir variabilidad.",
dimensionId: 'operational_efficiency',
priority: 'high',
title: 'Formación en Facturación',
description: 'Capacitación intensiva en productos, políticas y procedimientos de facturación.',
impact: 'Mejora estimada de satisfacción: 15-25%',
timeline: '2-3 semanas'
title: 'Estandarización de Procesos',
description: 'Implementar scripts y guías paso a paso para reducir la variabilidad en tiempos de gestión.',
impact: 'Reducción ratio P90/P50: 20-30%, Mejora predictibilidad',
timeline: '3-4 semanas'
},
{
text: "Desarrollar un bot de estado de pedido para WhatsApp para desviar el 30% de las consultas.",
dimensionId: 'volumetry_distribution',
dimensionId: 'agentic_readiness',
priority: 'high',
title: 'Bot Automatizado de Seguimiento de Pedidos',
description: 'Implementar ChatBot en WhatsApp para responder consultas de estado de pedido automáticamente.',
description: 'Implementar ChatBot en WhatsApp para consultas con alto Agentic Score (>8).',
impact: 'Reducción de volumen: 20-30%, Ahorro anual: €40-60K',
timeline: '1-2 meses'
},
@@ -200,12 +173,12 @@ const RECOMMENDATIONS: Recommendation[] = [
timeline: '1 mes'
},
{
text: "Crear una Knowledge Base más robusta y accesible para reducir el tiempo en espera.",
dimensionId: 'performance',
text: "Crear una Knowledge Base más robusta para reducir hold time y mejorar FCR.",
dimensionId: 'effectiveness_resolution',
priority: 'high',
title: 'Mejora de Acceso a Información',
description: 'Desarrollar una KB centralizada integrada en el sistema de agentes con búsqueda inteligente.',
impact: 'Reducción de AHT: 8-12%, Mejora de FCR: 5-10%',
description: 'Desarrollar una KB centralizada para reducir búsquedas y mejorar resolución en primer contacto.',
impact: 'Reducción hold time: 15-25%, Mejora FCR: 5-10%',
timeline: '6-8 semanas'
},
{
@@ -213,18 +186,18 @@ const RECOMMENDATIONS: Recommendation[] = [
dimensionId: 'volumetry_distribution',
priority: 'medium',
title: 'Cobertura 24/7 con IA',
description: 'Desplegar agentes virtuales para gestionar el 28% de interacciones nocturnas.',
description: 'Desplegar agentes virtuales para gestionar interacciones nocturnas y fines de semana.',
impact: 'Captura de demanda: 20-25%, Coste incremental: €15-20K/mes',
timeline: '2-3 meses'
},
{
text: "Realizar un análisis de causa raíz sobre las quejas de facturación para mejorar procesos.",
dimensionId: 'satisfaction',
text: "Simplificar tipificaciones y reducir complejidad en colas problemáticas.",
dimensionId: 'complexity_predictability',
priority: 'medium',
title: 'Análisis de Causa Raíz (Facturación)',
description: 'Investigar las 50 últimas quejas de facturación para identificar patrones y causas.',
impact: 'Identificación de mejoras de proceso con ROI potencial de €20-50K',
timeline: '2-3 semanas'
title: 'Reducción de Complejidad',
description: 'Consolidar tipificaciones y simplificar flujos para mejorar predictibilidad.',
impact: 'Reducción de complejidad: 20-30%, Mejora Agentic Score',
timeline: '4-6 semanas'
},
];
@@ -630,7 +603,7 @@ const generateHeatmapData = (
aht: isNaN(aht_mean) ? 0 : Math.max(0, Math.min(100, Math.round(100 - ((aht_mean - 240) / 310) * 100))),
csat: isNaN(avgCsat) ? 0 : Math.max(0, Math.min(100, Math.round(avgCsat))),
hold_time: isNaN(avg_hold_time) ? 0 : Math.max(0, Math.min(100, Math.round(100 - (avg_hold_time / 120) * 100))),
transfer_rate: isNaN(transfer_rate) ? 0 : Math.max(0, Math.min(100, Math.round(100 - (transfer_rate * 100))))
transfer_rate: isNaN(transfer_rate) ? 0 : Math.max(0, Math.min(100, Math.round(transfer_rate * 100)))
},
annual_cost,
variability: {
@@ -651,25 +624,25 @@ const generateHeatmapData = (
});
};
// v2.0: Añadir segmentación de cliente
// v3.0: Oportunidades con nuevas dimensiones
const generateOpportunityMatrixData = (): Opportunity[] => {
const opportunities = [
{ id: 'opp1', name: 'Automatizar consulta de pedidos', savings: 85000, dimensionId: 'volumetry_distribution', customer_segment: 'medium' as CustomerSegment },
{ id: 'opp2', name: 'Implementar Knowledge Base dinámica', savings: 45000, dimensionId: 'performance', customer_segment: 'high' as CustomerSegment },
{ id: 'opp3', name: 'Chatbot de triaje inicial', savings: 120000, dimensionId: 'efficiency', customer_segment: 'medium' as CustomerSegment },
{ id: 'opp4', name: 'Análisis de sentimiento en tiempo real', savings: 30000, dimensionId: 'satisfaction', customer_segment: 'high' as CustomerSegment },
{ id: 'opp1', name: 'Automatizar consulta de pedidos', savings: 85000, dimensionId: 'agentic_readiness', customer_segment: 'medium' as CustomerSegment },
{ id: 'opp2', name: 'Implementar Knowledge Base dinámica', savings: 45000, dimensionId: 'operational_efficiency', customer_segment: 'high' as CustomerSegment },
{ id: 'opp3', name: 'Chatbot de triaje inicial', savings: 120000, dimensionId: 'effectiveness_resolution', customer_segment: 'medium' as CustomerSegment },
{ id: 'opp4', name: 'Reducir complejidad en colas críticas', savings: 30000, dimensionId: 'complexity_predictability', customer_segment: 'high' as CustomerSegment },
{ id: 'opp5', name: 'Cobertura 24/7 con agentes virtuales', savings: 65000, dimensionId: 'volumetry_distribution', customer_segment: 'low' as CustomerSegment },
];
return opportunities.map(opp => ({ ...opp, impact: randomInt(3, 10), feasibility: randomInt(2, 9) }));
};
// v2.0: Añadir risk level
// v3.0: Roadmap con nuevas dimensiones
const generateRoadmapData = (): RoadmapInitiative[] => {
return [
{ id: 'r1', name: 'Chatbot de estado de pedido', phase: RoadmapPhase.Automate, timeline: 'Q1 2025', investment: 25000, resources: ['1x Bot Developer', 'API Access'], dimensionId: 'volumetry_distribution', risk: 'low' },
{ id: 'r2', name: 'Implementar Knowledge Base dinámica', phase: RoadmapPhase.Assist, timeline: 'Q1 2025', investment: 15000, resources: ['1x PM', 'Content Team'], dimensionId: 'performance', risk: 'low' },
{ id: 'r3', name: 'Agent Assist para sugerencias en real-time', phase: RoadmapPhase.Assist, timeline: 'Q2 2025', investment: 45000, resources: ['2x AI Devs', 'QA Team'], dimensionId: 'efficiency', risk: 'medium' },
{ id: 'r4', name: 'IVR conversacional con IA', phase: RoadmapPhase.Automate, timeline: 'Q3 2025', investment: 60000, resources: ['AI Voice Specialist', 'UX Designer'], dimensionId: 'efficiency', risk: 'medium' },
{ id: 'r1', name: 'Chatbot de estado de pedido', phase: RoadmapPhase.Automate, timeline: 'Q1 2025', investment: 25000, resources: ['1x Bot Developer', 'API Access'], dimensionId: 'agentic_readiness', risk: 'low' },
{ id: 'r2', name: 'Implementar Knowledge Base dinámica', phase: RoadmapPhase.Assist, timeline: 'Q1 2025', investment: 15000, resources: ['1x PM', 'Content Team'], dimensionId: 'operational_efficiency', risk: 'low' },
{ id: 'r3', name: 'Agent Assist para sugerencias en real-time', phase: RoadmapPhase.Assist, timeline: 'Q2 2025', investment: 45000, resources: ['2x AI Devs', 'QA Team'], dimensionId: 'effectiveness_resolution', risk: 'medium' },
{ id: 'r4', name: 'Estandarización de procesos complejos', phase: RoadmapPhase.Augment, timeline: 'Q3 2025', investment: 30000, resources: ['Process Analyst', 'Training Team'], dimensionId: 'complexity_predictability', risk: 'medium' },
{ id: 'r5', name: 'Cobertura 24/7 con agentes virtuales', phase: RoadmapPhase.Augment, timeline: 'Q4 2025', investment: 75000, resources: ['Lead AI Engineer', 'Data Scientist'], dimensionId: 'volumetry_distribution', risk: 'high' },
];
};
@@ -797,13 +770,13 @@ const generateOpportunitiesFromHeatmap = (
Math.min(10, Math.round(feasibilityRaw))
);
// Dimensión a la que lo vinculamos (solo decorativo de momento)
// Dimensión a la que lo vinculamos
const dimensionId =
readiness >= 70
? 'volumetry_distribution'
? 'agentic_readiness'
: readiness >= 40
? 'efficiency'
: 'economy';
? 'effectiveness_resolution'
: 'complexity_predictability';
// Segmento de cliente (high/medium/low) si lo tenemos
const customer_segment = heat.segment;
@@ -814,8 +787,8 @@ const generateOpportunitiesFromHeatmap = (
readiness >= 70
? 'Automatizar '
: readiness >= 40
? 'Augmentar con IA en '
: 'Optimizar proceso en ';
? 'Asistir con IA en '
: 'Optimizar procesos en ';
const idSlug = skillName
.toLowerCase()
@@ -913,6 +886,33 @@ export const generateAnalysis = async (
if (file && !useSynthetic) {
console.log('📡 Processing file (API first):', file.name);
// Pre-parsear archivo para obtener dateRange y interacciones (se usa en ambas rutas)
let dateRange: { min: string; max: string } | undefined;
let parsedInteractions: RawInteraction[] | undefined;
try {
const { parseFile, validateInteractions } = await import('./fileParser');
const interactions = await parseFile(file);
const validation = validateInteractions(interactions);
dateRange = validation.stats.dateRange || undefined;
parsedInteractions = interactions; // Guardar para usar en drilldownData
console.log(`📅 Date range extracted: ${dateRange?.min} to ${dateRange?.max}`);
console.log(`📊 Parsed ${interactions.length} interactions for drilldown`);
// Cachear el archivo CSV en el servidor para uso futuro
try {
if (authHeaderOverride && file) {
await saveFileToServerCache(authHeaderOverride, file, costPerHour);
console.log(`💾 Archivo CSV cacheado en el servidor para uso futuro`);
} else {
console.warn('⚠️ No se pudo cachear: falta authHeader o file');
}
} catch (cacheError) {
console.warn('⚠️ No se pudo cachear archivo:', cacheError);
}
} catch (e) {
console.warn('⚠️ Could not extract dateRange from file:', e);
}
// 1) Intentar backend + mapeo
try {
const raw = await callAnalysisApiRaw({
@@ -926,6 +926,9 @@ export const generateAnalysis = async (
const mapped = mapBackendResultsToAnalysisData(raw, tier);
// Añadir dateRange extraído del archivo
mapped.dateRange = dateRange;
// Heatmap: primero lo construimos a partir de datos reales del backend
mapped.heatmapData = buildHeatmapFromBackend(
raw,
@@ -934,22 +937,44 @@ export const generateAnalysis = async (
segmentMapping
);
// Oportunidades: AHORA basadas en heatmap real + modelo económico del backend
// v3.5: Calcular drilldownData PRIMERO (necesario para opportunities y roadmap)
if (parsedInteractions && parsedInteractions.length > 0) {
mapped.drilldownData = calculateDrilldownMetrics(parsedInteractions, costPerHour);
console.log(`📊 Drill-down calculado: ${mapped.drilldownData.length} skills, ${mapped.drilldownData.filter(d => d.isPriorityCandidate).length} candidatos prioritarios`);
// Cachear drilldownData en el servidor para uso futuro (no bloquea)
if (authHeaderOverride && mapped.drilldownData.length > 0) {
saveDrilldownToServerCache(authHeaderOverride, mapped.drilldownData)
.then(success => {
if (success) console.log('💾 DrilldownData cacheado en servidor');
else console.warn('⚠️ No se pudo cachear drilldownData');
})
.catch(err => console.warn('⚠️ Error cacheando drilldownData:', err));
}
// Usar oportunidades y roadmap basados en drilldownData (datos reales)
mapped.opportunities = generateOpportunitiesFromDrilldown(mapped.drilldownData, costPerHour);
mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour);
console.log(`📊 Opportunities: ${mapped.opportunities.length}, Roadmap: ${mapped.roadmap.length}`);
} else {
console.warn('⚠️ No hay interacciones parseadas, usando heatmap para opportunities');
// Fallback: usar heatmap (menos preciso)
mapped.opportunities = generateOpportunitiesFromHeatmap(
mapped.heatmapData,
mapped.economicModel
);
mapped.roadmap = generateRoadmapData();
}
// 👉 El resto sigue siendo "frontend-driven" de momento
// Findings y recommendations
mapped.findings = generateFindingsFromData(mapped);
mapped.recommendations = generateRecommendationsFromData(mapped);
mapped.roadmap = generateRoadmapData();
// Benchmark: de momento no tenemos datos reales -> no lo generamos en modo backend
// Benchmark: de momento no tenemos datos reales
mapped.benchmarkData = [];
console.log(
'✅ Usando resultados del backend mapeados (heatmap + opportunities reales)'
'✅ Usando resultados del backend mapeados (heatmap + opportunities + drilldown reales)'
);
return mapped;
@@ -1015,6 +1040,203 @@ export const generateAnalysis = async (
return generateSyntheticAnalysis(tier, costPerHour, avgCsat, segmentMapping);
};
/**
* Genera análisis usando el archivo CSV cacheado en el servidor
* Permite re-analizar sin necesidad de subir el archivo de nuevo
* Funciona entre diferentes navegadores y dispositivos
*
* v3.5: Descarga el CSV cacheado para parsear localmente y obtener
* todas las colas originales (original_queue_id) en lugar de solo
* las 9 categorías agregadas (queue_skill)
*/
export const generateAnalysisFromCache = async (
tier: TierKey,
costPerHour: number = 20,
avgCsat: number = 85,
segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] },
authHeaderOverride?: string
): Promise<AnalysisData> => {
console.log('💾 Analyzing from server-cached file...');
// Verificar que tenemos authHeader
if (!authHeaderOverride) {
throw new Error('Se requiere autenticación para acceder a la caché del servidor.');
}
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
// Preparar datos de economía
const economyData = {
costPerHour,
avgCsat,
segmentMapping,
};
// Crear FormData para el endpoint
const formData = new FormData();
formData.append('economy_json', JSON.stringify(economyData));
formData.append('analysis', 'premium');
console.log('📡 Running backend analysis and drilldown fetch in parallel...');
// === EJECUTAR EN PARALELO: Backend analysis + DrilldownData fetch ===
const backendAnalysisPromise = fetch(`${API_BASE_URL}/analysis/cached`, {
method: 'POST',
headers: {
Authorization: authHeaderOverride,
},
body: formData,
});
// Obtener drilldownData cacheado (pequeño JSON, muy rápido)
const drilldownPromise = getCachedDrilldown(authHeaderOverride);
// Esperar ambas operaciones en paralelo
const [response, cachedDrilldownData] = await Promise.all([backendAnalysisPromise, drilldownPromise]);
if (cachedDrilldownData) {
console.log(`✅ Got cached drilldownData: ${cachedDrilldownData.length} skills`);
} else {
console.warn('⚠️ No cached drilldownData found, will use heatmap fallback');
}
try {
if (response.status === 404) {
throw new Error('No hay archivo cacheado en el servidor. Por favor, sube un archivo CSV primero.');
}
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Backend error:', response.status, errorText);
throw new Error(`Error del servidor (${response.status}): ${errorText}`);
}
const rawResponse = await response.json();
const raw = rawResponse.results;
const dateRangeFromBackend = rawResponse.dateRange;
const uniqueQueuesFromBackend = rawResponse.uniqueQueues;
console.log('✅ Backend analysis from cache completed');
console.log('📅 Date range from backend:', dateRangeFromBackend);
console.log('📊 Unique queues from backend:', uniqueQueuesFromBackend);
// Mapear resultados del backend a AnalysisData (solo 2 parámetros)
console.log('📦 Raw backend results keys:', Object.keys(raw || {}));
console.log('📦 volumetry:', raw?.volumetry ? 'present' : 'missing');
console.log('📦 operational_performance:', raw?.operational_performance ? 'present' : 'missing');
console.log('📦 agentic_readiness:', raw?.agentic_readiness ? 'present' : 'missing');
const mapped = mapBackendResultsToAnalysisData(raw, tier);
console.log('📊 Mapped data summaryKpis:', mapped.summaryKpis?.length || 0);
console.log('📊 Mapped data dimensions:', mapped.dimensions?.length || 0);
// Añadir dateRange desde el backend
if (dateRangeFromBackend && dateRangeFromBackend.min && dateRangeFromBackend.max) {
mapped.dateRange = dateRangeFromBackend;
}
// Heatmap: construir a partir de datos reales del backend
mapped.heatmapData = buildHeatmapFromBackend(
raw,
costPerHour,
avgCsat,
segmentMapping
);
console.log('📊 Heatmap data points:', mapped.heatmapData?.length || 0);
// === DrilldownData: usar cacheado (rápido) o fallback a heatmap ===
if (cachedDrilldownData && cachedDrilldownData.length > 0) {
// Usar drilldownData cacheado directamente (ya calculado al subir archivo)
mapped.drilldownData = cachedDrilldownData;
console.log(`📊 Usando drilldownData cacheado: ${mapped.drilldownData.length} skills`);
// Contar colas originales para log
const uniqueOriginalQueues = new Set(
mapped.drilldownData.flatMap((d: any) =>
(d.originalQueues || []).map((q: any) => q.original_queue_id)
).filter((q: string) => q && q.trim() !== '')
).size;
console.log(`📊 Total original queues: ${uniqueOriginalQueues}`);
// Usar oportunidades y roadmap basados en drilldownData real
mapped.opportunities = generateOpportunitiesFromDrilldown(mapped.drilldownData, costPerHour);
mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour);
console.log(`📊 Opportunities: ${mapped.opportunities.length}, Roadmap: ${mapped.roadmap.length}`);
} else if (mapped.heatmapData && mapped.heatmapData.length > 0) {
// Fallback: usar heatmap (solo 9 skills agregados)
console.warn('⚠️ Sin drilldownData cacheado, usando heatmap fallback');
mapped.drilldownData = generateDrilldownFromHeatmap(mapped.heatmapData, costPerHour);
console.log(`📊 Drill-down desde heatmap (fallback): ${mapped.drilldownData.length} skills`);
mapped.opportunities = generateOpportunitiesFromHeatmap(
mapped.heatmapData,
mapped.economicModel
);
mapped.roadmap = generateRoadmapData();
}
// Findings y recommendations
mapped.findings = generateFindingsFromData(mapped);
mapped.recommendations = generateRecommendationsFromData(mapped);
// Benchmark: vacío por ahora
mapped.benchmarkData = [];
// Marcar que viene del backend/caché
mapped.source = 'backend';
console.log('✅ Analysis generated from server-cached file');
return mapped;
} catch (error) {
console.error('❌ Error analyzing from cache:', error);
throw error;
}
};
// Función auxiliar para generar drilldownData desde heatmapData cuando no tenemos parsedInteractions
function generateDrilldownFromHeatmap(
heatmapData: HeatmapDataPoint[],
costPerHour: number
): DrilldownDataPoint[] {
return heatmapData.map(hp => {
const cvAht = hp.variability?.cv_aht || 0;
const transferRate = hp.variability?.transfer_rate || hp.metrics?.transfer_rate || 0;
const fcrRate = hp.metrics?.fcr || 0;
const agenticScore = hp.dimensions
? (hp.dimensions.predictability * 0.4 + hp.dimensions.complexity_inverse * 0.35 + hp.dimensions.repetitivity * 0.25)
: (hp.automation_readiness || 0) / 10;
// Determinar tier basado en el score
let tier: AgenticTier = 'HUMAN-ONLY';
if (agenticScore >= 7.5) tier = 'AUTOMATE';
else if (agenticScore >= 5.5) tier = 'ASSIST';
else if (agenticScore >= 3.5) tier = 'AUGMENT';
return {
skill: hp.skill,
volume: hp.volume,
volumeValid: hp.volume,
aht_mean: hp.aht_seconds,
cv_aht: cvAht,
transfer_rate: transferRate,
fcr_rate: fcrRate,
agenticScore: agenticScore,
isPriorityCandidate: cvAht < 75,
originalQueues: [{
original_queue_id: hp.skill,
volume: hp.volume,
volumeValid: hp.volume,
aht_mean: hp.aht_seconds,
cv_aht: cvAht,
transfer_rate: transferRate,
fcr_rate: fcrRate,
agenticScore: agenticScore,
tier: tier,
isPriorityCandidate: cvAht < 75,
}],
};
});
}
// Función auxiliar para generar análisis con datos sintéticos
const generateSyntheticAnalysis = (
tier: TierKey,
@@ -1031,8 +1253,8 @@ const generateSyntheticAnalysis = (
{ label: "CSAT", value: `${randomFloat(4.1, 4.8, 1)}/5`, change: `-${randomFloat(0.1, 0.3, 1)}`, changeType: 'negative' },
];
// v2.0: Solo 6 dimensiones
const dimensionKeys = ['volumetry_distribution', 'performance', 'satisfaction', 'economy', 'efficiency', 'benchmark'];
// v3.0: 5 dimensiones viables
const dimensionKeys = ['volumetry_distribution', 'operational_efficiency', 'effectiveness_resolution', 'complexity_predictability', 'agentic_readiness'];
const dimensions: DimensionAnalysis[] = dimensionKeys.map(key => {
const content = DIMENSIONS_CONTENT[key as keyof typeof DIMENSIONS_CONTENT];

File diff suppressed because it is too large Load Diff

241
frontend/utils/dataCache.ts Normal file
View File

@@ -0,0 +1,241 @@
/**
* dataCache.ts - Sistema de caché para datos de análisis
*
* Usa IndexedDB para persistir los datos parseados entre rebuilds.
* El CSV de 500MB parseado a JSON es mucho más pequeño (~10-50MB).
*/
import { RawInteraction, AnalysisData } from '../types';
const DB_NAME = 'BeyondDiagnosisCache';
const DB_VERSION = 1;
const STORE_RAW = 'rawInteractions';
const STORE_ANALYSIS = 'analysisData';
const STORE_META = 'metadata';
interface CacheMetadata {
id: string;
fileName: string;
fileSize: number;
recordCount: number;
cachedAt: string;
costPerHour: number;
}
// Abrir conexión a IndexedDB
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Store para interacciones raw
if (!db.objectStoreNames.contains(STORE_RAW)) {
db.createObjectStore(STORE_RAW, { keyPath: 'id' });
}
// Store para datos de análisis
if (!db.objectStoreNames.contains(STORE_ANALYSIS)) {
db.createObjectStore(STORE_ANALYSIS, { keyPath: 'id' });
}
// Store para metadata
if (!db.objectStoreNames.contains(STORE_META)) {
db.createObjectStore(STORE_META, { keyPath: 'id' });
}
};
});
}
/**
* Guardar interacciones parseadas en caché
*/
export async function cacheRawInteractions(
interactions: RawInteraction[],
fileName: string,
fileSize: number,
costPerHour: number
): Promise<void> {
try {
// Validar que es un array antes de cachear
if (!Array.isArray(interactions)) {
console.error('[Cache] No se puede cachear: interactions no es un array');
return;
}
if (interactions.length === 0) {
console.warn('[Cache] No se cachea: array vacío');
return;
}
const db = await openDB();
// Guardar metadata
const metadata: CacheMetadata = {
id: 'current',
fileName,
fileSize,
recordCount: interactions.length,
cachedAt: new Date().toISOString(),
costPerHour
};
const metaTx = db.transaction(STORE_META, 'readwrite');
metaTx.objectStore(STORE_META).put(metadata);
// Guardar interacciones (en chunks para archivos grandes)
const rawTx = db.transaction(STORE_RAW, 'readwrite');
const store = rawTx.objectStore(STORE_RAW);
// Limpiar datos anteriores
store.clear();
// Guardar como un solo objeto (más eficiente para lectura)
// Aseguramos que guardamos el array directamente
const dataToStore = { id: 'interactions', data: [...interactions] };
store.put(dataToStore);
await new Promise((resolve, reject) => {
rawTx.oncomplete = resolve;
rawTx.onerror = () => reject(rawTx.error);
});
console.log(`[Cache] Guardadas ${interactions.length} interacciones en caché (verificado: Array)`);
} catch (error) {
console.error('[Cache] Error guardando en caché:', error);
}
}
/**
* Guardar resultado de análisis en caché
*/
export async function cacheAnalysisData(data: AnalysisData): Promise<void> {
try {
const db = await openDB();
const tx = db.transaction(STORE_ANALYSIS, 'readwrite');
tx.objectStore(STORE_ANALYSIS).put({ id: 'analysis', data });
await new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
console.log('[Cache] Análisis guardado en caché');
} catch (error) {
console.error('[Cache] Error guardando análisis:', error);
}
}
/**
* Obtener metadata de caché (para mostrar info al usuario)
*/
export async function getCacheMetadata(): Promise<CacheMetadata | null> {
try {
const db = await openDB();
const tx = db.transaction(STORE_META, 'readonly');
const request = tx.objectStore(STORE_META).get('current');
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
} catch (error) {
console.error('[Cache] Error leyendo metadata:', error);
return null;
}
}
/**
* Obtener interacciones cacheadas
*/
export async function getCachedInteractions(): Promise<RawInteraction[] | null> {
try {
const db = await openDB();
const tx = db.transaction(STORE_RAW, 'readonly');
const request = tx.objectStore(STORE_RAW).get('interactions');
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const result = request.result;
const data = result?.data;
// Validar que es un array
if (!data) {
console.log('[Cache] No hay datos en caché');
resolve(null);
return;
}
if (!Array.isArray(data)) {
console.error('[Cache] Datos en caché no son un array:', typeof data);
resolve(null);
return;
}
console.log(`[Cache] Recuperadas ${data.length} interacciones`);
resolve(data);
};
request.onerror = () => reject(request.error);
});
} catch (error) {
console.error('[Cache] Error leyendo interacciones:', error);
return null;
}
}
/**
* Obtener análisis cacheado
*/
export async function getCachedAnalysis(): Promise<AnalysisData | null> {
try {
const db = await openDB();
const tx = db.transaction(STORE_ANALYSIS, 'readonly');
const request = tx.objectStore(STORE_ANALYSIS).get('analysis');
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const result = request.result;
resolve(result?.data || null);
};
request.onerror = () => reject(request.error);
});
} catch (error) {
console.error('[Cache] Error leyendo análisis:', error);
return null;
}
}
/**
* Limpiar toda la caché
*/
export async function clearCache(): Promise<void> {
try {
const db = await openDB();
const tx = db.transaction([STORE_RAW, STORE_ANALYSIS, STORE_META], 'readwrite');
tx.objectStore(STORE_RAW).clear();
tx.objectStore(STORE_ANALYSIS).clear();
tx.objectStore(STORE_META).clear();
await new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
console.log('[Cache] Caché limpiada');
} catch (error) {
console.error('[Cache] Error limpiando caché:', error);
}
}
/**
* Verificar si hay datos en caché
*/
export async function hasCachedData(): Promise<boolean> {
const metadata = await getCacheMetadata();
return metadata !== null;
}

View File

@@ -5,6 +5,35 @@
import { RawInteraction } from '../types';
/**
* Helper: Parsear valor booleano de CSV (TRUE/FALSE, true/false, 1/0, yes/no, etc.)
*/
function parseBoolean(value: any): boolean {
if (value === undefined || value === null || value === '') {
return false;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
const strVal = String(value).toLowerCase().trim();
return strVal === 'true' || strVal === '1' || strVal === 'yes' || strVal === 'si' || strVal === 'sí' || strVal === 'y' || strVal === 's';
}
/**
* Helper: Obtener valor de columna buscando múltiples variaciones del nombre
*/
function getColumnValue(row: any, ...columnNames: string[]): string {
for (const name of columnNames) {
if (row[name] !== undefined && row[name] !== null && row[name] !== '') {
return String(row[name]);
}
}
return '';
}
/**
* Parsear archivo CSV a array de objetos
*/
@@ -18,21 +47,51 @@ export async function parseCSV(file: File): Promise<RawInteraction[]> {
// Parsear headers
const headers = lines[0].split(',').map(h => h.trim());
console.log('📋 Todos los headers del CSV:', headers);
// Validar headers requeridos
const requiredFields = [
'interaction_id',
'datetime_start',
'queue_skill',
'channel',
'duration_talk',
'hold_time',
'wrap_up_time',
'agent_id',
'transfer_flag'
// Verificar campos clave
const keyFields = ['is_abandoned', 'fcr_real_flag', 'repeat_call_7d', 'transfer_flag', 'record_status'];
const foundKeyFields = keyFields.filter(f => headers.includes(f));
const missingKeyFields = keyFields.filter(f => !headers.includes(f));
console.log('✅ Campos clave encontrados:', foundKeyFields);
console.log('⚠️ Campos clave NO encontrados:', missingKeyFields.length > 0 ? missingKeyFields : 'TODOS PRESENTES');
// Debug: Mostrar las primeras 5 filas con valores crudos de campos booleanos
console.log('📋 VALORES CRUDOS DE CAMPOS BOOLEANOS (primeras 5 filas):');
for (let rowNum = 1; rowNum <= Math.min(5, lines.length - 1); rowNum++) {
const rawValues = lines[rowNum].split(',').map(v => v.trim());
const rowData: Record<string, string> = {};
headers.forEach((header, idx) => {
rowData[header] = rawValues[idx] || '';
});
console.log(` Fila ${rowNum}:`, {
is_abandoned: rowData.is_abandoned,
fcr_real_flag: rowData.fcr_real_flag,
repeat_call_7d: rowData.repeat_call_7d,
transfer_flag: rowData.transfer_flag,
record_status: rowData.record_status
});
}
// Validar headers requeridos (con variantes aceptadas)
// v3.1: queue_skill (estratégico) y original_queue_id (operativo) son campos separados
const requiredFieldsWithVariants: { field: string; variants: string[] }[] = [
{ field: 'interaction_id', variants: ['interaction_id', 'Interaction_ID', 'Interaction ID'] },
{ field: 'datetime_start', variants: ['datetime_start', 'Datetime_Start', 'Datetime Start'] },
{ field: 'queue_skill', variants: ['queue_skill', 'Queue_Skill', 'Queue Skill', 'Skill'] },
{ field: 'original_queue_id', variants: ['original_queue_id', 'Original_Queue_ID', 'Original Queue ID', 'Cola'] },
{ field: 'channel', variants: ['channel', 'Channel'] },
{ field: 'duration_talk', variants: ['duration_talk', 'Duration_Talk', 'Duration Talk'] },
{ field: 'hold_time', variants: ['hold_time', 'Hold_Time', 'Hold Time'] },
{ field: 'wrap_up_time', variants: ['wrap_up_time', 'Wrap_Up_Time', 'Wrap Up Time'] },
{ field: 'agent_id', variants: ['agent_id', 'Agent_ID', 'Agent ID'] },
{ field: 'transfer_flag', variants: ['transfer_flag', 'Transfer_Flag', 'Transfer Flag'] }
];
const missingFields = requiredFields.filter(field => !headers.includes(field));
const missingFields = requiredFieldsWithVariants
.filter(({ variants }) => !variants.some(v => headers.includes(v)))
.map(({ field }) => field);
if (missingFields.length > 0) {
throw new Error(`Faltan campos requeridos: ${missingFields.join(', ')}`);
}
@@ -40,11 +99,21 @@ export async function parseCSV(file: File): Promise<RawInteraction[]> {
// Parsear filas
const interactions: RawInteraction[] = [];
// Contadores para debug
let abandonedTrueCount = 0;
let abandonedFalseCount = 0;
let fcrTrueCount = 0;
let fcrFalseCount = 0;
let repeatTrueCount = 0;
let repeatFalseCount = 0;
let transferTrueCount = 0;
let transferFalseCount = 0;
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim());
if (values.length !== headers.length) {
console.warn(`Fila ${i + 1} tiene número incorrecto de columnas, saltando...`);
console.warn(`Fila ${i + 1} tiene ${values.length} columnas, esperado ${headers.length}, saltando...`);
continue;
}
@@ -54,17 +123,61 @@ export async function parseCSV(file: File): Promise<RawInteraction[]> {
});
try {
// === PARSING SIMPLE Y DIRECTO ===
// is_abandoned: valor directo del CSV
const isAbandonedRaw = getColumnValue(row, 'is_abandoned', 'Is_Abandoned', 'Is Abandoned', 'abandoned');
const isAbandoned = parseBoolean(isAbandonedRaw);
if (isAbandoned) abandonedTrueCount++; else abandonedFalseCount++;
// fcr_real_flag: valor directo del CSV
const fcrRealRaw = getColumnValue(row, 'fcr_real_flag', 'FCR_Real_Flag', 'FCR Real Flag', 'fcr_flag', 'fcr');
const fcrRealFlag = parseBoolean(fcrRealRaw);
if (fcrRealFlag) fcrTrueCount++; else fcrFalseCount++;
// repeat_call_7d: valor directo del CSV
const repeatRaw = getColumnValue(row, 'repeat_call_7d', 'Repeat_Call_7d', 'Repeat Call 7d', 'repeat_call', 'rellamada', 'Rellamada');
const repeatCall7d = parseBoolean(repeatRaw);
if (repeatCall7d) repeatTrueCount++; else repeatFalseCount++;
// transfer_flag: valor directo del CSV
const transferRaw = getColumnValue(row, 'transfer_flag', 'Transfer_Flag', 'Transfer Flag');
const transferFlag = parseBoolean(transferRaw);
if (transferFlag) transferTrueCount++; else transferFalseCount++;
// record_status: valor directo, normalizado a lowercase
const recordStatusRaw = getColumnValue(row, 'record_status', 'Record_Status', 'Record Status').toLowerCase().trim();
const validStatuses = ['valid', 'noise', 'zombie', 'abandon'];
const recordStatus = validStatuses.includes(recordStatusRaw)
? recordStatusRaw as 'valid' | 'noise' | 'zombie' | 'abandon'
: undefined;
// v3.0: Parsear campos para drill-down
// business_unit = Línea de Negocio (9 categorías C-Level)
// queue_skill ya se usa como skill técnico (980 skills granulares)
const lineaNegocio = getColumnValue(row, 'business_unit', 'Business_Unit', 'BusinessUnit', 'linea_negocio', 'Linea_Negocio', 'business_line');
// v3.1: Parsear ambos niveles de jerarquía
const queueSkill = getColumnValue(row, 'queue_skill', 'Queue_Skill', 'Queue Skill', 'Skill');
const originalQueueId = getColumnValue(row, 'original_queue_id', 'Original_Queue_ID', 'Original Queue ID', 'Cola');
const interaction: RawInteraction = {
interaction_id: row.interaction_id,
datetime_start: row.datetime_start,
queue_skill: row.queue_skill,
queue_skill: queueSkill,
original_queue_id: originalQueueId || undefined,
channel: row.channel,
duration_talk: isNaN(parseFloat(row.duration_talk)) ? 0 : parseFloat(row.duration_talk),
hold_time: isNaN(parseFloat(row.hold_time)) ? 0 : parseFloat(row.hold_time),
wrap_up_time: isNaN(parseFloat(row.wrap_up_time)) ? 0 : parseFloat(row.wrap_up_time),
agent_id: row.agent_id,
transfer_flag: row.transfer_flag?.toLowerCase() === 'true' || row.transfer_flag === '1',
caller_id: row.caller_id || undefined
transfer_flag: transferFlag,
repeat_call_7d: repeatCall7d,
caller_id: row.caller_id || undefined,
is_abandoned: isAbandoned,
record_status: recordStatus,
fcr_real_flag: fcrRealFlag,
linea_negocio: lineaNegocio || undefined
};
interactions.push(interaction);
@@ -73,15 +186,51 @@ export async function parseCSV(file: File): Promise<RawInteraction[]> {
}
}
// === DEBUG SUMMARY ===
const total = interactions.length;
console.log('');
console.log('═══════════════════════════════════════════════════════════════');
console.log('📊 RESUMEN DE PARSING CSV - VALORES BOOLEANOS');
console.log('═══════════════════════════════════════════════════════════════');
console.log(`Total registros parseados: ${total}`);
console.log('');
console.log(`is_abandoned:`);
console.log(` TRUE: ${abandonedTrueCount} (${((abandonedTrueCount/total)*100).toFixed(1)}%)`);
console.log(` FALSE: ${abandonedFalseCount} (${((abandonedFalseCount/total)*100).toFixed(1)}%)`);
console.log('');
console.log(`fcr_real_flag:`);
console.log(` TRUE: ${fcrTrueCount} (${((fcrTrueCount/total)*100).toFixed(1)}%)`);
console.log(` FALSE: ${fcrFalseCount} (${((fcrFalseCount/total)*100).toFixed(1)}%)`);
console.log('');
console.log(`repeat_call_7d:`);
console.log(` TRUE: ${repeatTrueCount} (${((repeatTrueCount/total)*100).toFixed(1)}%)`);
console.log(` FALSE: ${repeatFalseCount} (${((repeatFalseCount/total)*100).toFixed(1)}%)`);
console.log('');
console.log(`transfer_flag:`);
console.log(` TRUE: ${transferTrueCount} (${((transferTrueCount/total)*100).toFixed(1)}%)`);
console.log(` FALSE: ${transferFalseCount} (${((transferFalseCount/total)*100).toFixed(1)}%)`);
console.log('');
// Calcular métricas esperadas
const expectedAbandonRate = (abandonedTrueCount / total) * 100;
const expectedFCR_fromFlag = (fcrTrueCount / total) * 100;
const expectedFCR_calculated = ((total - transferTrueCount - repeatTrueCount +
interactions.filter(i => i.transfer_flag && i.repeat_call_7d).length) / total) * 100;
console.log('📈 MÉTRICAS ESPERADAS:');
console.log(` Abandonment Rate (is_abandoned=TRUE): ${expectedAbandonRate.toFixed(1)}%`);
console.log(` FCR (fcr_real_flag=TRUE): ${expectedFCR_fromFlag.toFixed(1)}%`);
console.log(` FCR calculado (no transfer AND no repeat): ~${expectedFCR_calculated.toFixed(1)}%`);
console.log('═══════════════════════════════════════════════════════════════');
console.log('');
return interactions;
}
/**
* Parsear archivo Excel a array de objetos
* Usa la librería xlsx que ya está instalada
*/
export async function parseExcel(file: File): Promise<RawInteraction[]> {
// Importar xlsx dinámicamente
const XLSX = await import('xlsx');
return new Promise((resolve, reject) => {
@@ -92,11 +241,9 @@ export async function parseExcel(file: File): Promise<RawInteraction[]> {
const data = e.target?.result;
const workbook = XLSX.read(data, { type: 'binary' });
// Usar la primera hoja
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
// Convertir a JSON
const jsonData = XLSX.utils.sheet_to_json(worksheet);
if (jsonData.length === 0) {
@@ -104,35 +251,74 @@ export async function parseExcel(file: File): Promise<RawInteraction[]> {
return;
}
// Validar y transformar a RawInteraction[]
const interactions: RawInteraction[] = [];
// Contadores para debug
let abandonedTrueCount = 0;
let fcrTrueCount = 0;
let repeatTrueCount = 0;
let transferTrueCount = 0;
for (let i = 0; i < jsonData.length; i++) {
const row: any = jsonData[i];
try {
const durationStr = row.duration_talk || row.Duration_Talk || row['Duration Talk'] || '0';
const holdStr = row.hold_time || row.Hold_Time || row['Hold Time'] || '0';
const wrapStr = row.wrap_up_time || row.Wrap_Up_Time || row['Wrap Up Time'] || '0';
// === PARSING SIMPLE Y DIRECTO ===
const durationTalkVal = isNaN(parseFloat(durationStr)) ? 0 : parseFloat(durationStr);
const holdTimeVal = isNaN(parseFloat(holdStr)) ? 0 : parseFloat(holdStr);
const wrapUpTimeVal = isNaN(parseFloat(wrapStr)) ? 0 : parseFloat(wrapStr);
// is_abandoned
const isAbandonedRaw = getColumnValue(row, 'is_abandoned', 'Is_Abandoned', 'Is Abandoned', 'abandoned');
const isAbandoned = parseBoolean(isAbandonedRaw);
if (isAbandoned) abandonedTrueCount++;
// fcr_real_flag
const fcrRealRaw = getColumnValue(row, 'fcr_real_flag', 'FCR_Real_Flag', 'FCR Real Flag', 'fcr_flag', 'fcr');
const fcrRealFlag = parseBoolean(fcrRealRaw);
if (fcrRealFlag) fcrTrueCount++;
// repeat_call_7d
const repeatRaw = getColumnValue(row, 'repeat_call_7d', 'Repeat_Call_7d', 'Repeat Call 7d', 'repeat_call', 'rellamada');
const repeatCall7d = parseBoolean(repeatRaw);
if (repeatCall7d) repeatTrueCount++;
// transfer_flag
const transferRaw = getColumnValue(row, 'transfer_flag', 'Transfer_Flag', 'Transfer Flag');
const transferFlag = parseBoolean(transferRaw);
if (transferFlag) transferTrueCount++;
// record_status
const recordStatusRaw = getColumnValue(row, 'record_status', 'Record_Status', 'Record Status').toLowerCase().trim();
const validStatuses = ['valid', 'noise', 'zombie', 'abandon'];
const recordStatus = validStatuses.includes(recordStatusRaw)
? recordStatusRaw as 'valid' | 'noise' | 'zombie' | 'abandon'
: undefined;
const durationTalkVal = parseFloat(getColumnValue(row, 'duration_talk', 'Duration_Talk', 'Duration Talk') || '0');
const holdTimeVal = parseFloat(getColumnValue(row, 'hold_time', 'Hold_Time', 'Hold Time') || '0');
const wrapUpTimeVal = parseFloat(getColumnValue(row, 'wrap_up_time', 'Wrap_Up_Time', 'Wrap Up Time') || '0');
// v3.0: Parsear campos para drill-down
// business_unit = Línea de Negocio (9 categorías C-Level)
const lineaNegocio = getColumnValue(row, 'business_unit', 'Business_Unit', 'BusinessUnit', 'linea_negocio', 'Linea_Negocio', 'business_line');
const interaction: RawInteraction = {
interaction_id: String(row.interaction_id || row.Interaction_ID || row['Interaction ID'] || ''),
datetime_start: String(row.datetime_start || row.Datetime_Start || row['Datetime Start'] || row['Fecha/Hora de apertura'] || ''),
queue_skill: String(row.queue_skill || row.Queue_Skill || row['Queue Skill'] || row.Subtipo || row.Tipo || ''),
channel: String(row.channel || row.Channel || row['Origen del caso'] || 'Unknown'),
interaction_id: String(getColumnValue(row, 'interaction_id', 'Interaction_ID', 'Interaction ID') || ''),
datetime_start: String(getColumnValue(row, 'datetime_start', 'Datetime_Start', 'Datetime Start', 'Fecha/Hora de apertura') || ''),
queue_skill: String(getColumnValue(row, 'queue_skill', 'Queue_Skill', 'Queue Skill', 'Skill', 'Subtipo', 'Tipo') || ''),
original_queue_id: String(getColumnValue(row, 'original_queue_id', 'Original_Queue_ID', 'Original Queue ID', 'Cola') || '') || undefined,
channel: String(getColumnValue(row, 'channel', 'Channel', 'Origen del caso') || 'Unknown'),
duration_talk: isNaN(durationTalkVal) ? 0 : durationTalkVal,
hold_time: isNaN(holdTimeVal) ? 0 : holdTimeVal,
wrap_up_time: isNaN(wrapUpTimeVal) ? 0 : wrapUpTimeVal,
agent_id: String(row.agent_id || row.Agent_ID || row['Agent ID'] || row['Propietario del caso'] || 'Unknown'),
transfer_flag: Boolean(row.transfer_flag || row.Transfer_Flag || row['Transfer Flag'] || false),
caller_id: row.caller_id || row.Caller_ID || row['Caller ID'] || undefined
agent_id: String(getColumnValue(row, 'agent_id', 'Agent_ID', 'Agent ID', 'Propietario del caso') || 'Unknown'),
transfer_flag: transferFlag,
repeat_call_7d: repeatCall7d,
caller_id: getColumnValue(row, 'caller_id', 'Caller_ID', 'Caller ID') || undefined,
is_abandoned: isAbandoned,
record_status: recordStatus,
fcr_real_flag: fcrRealFlag,
linea_negocio: lineaNegocio || undefined
};
// Validar que tiene datos mínimos
if (interaction.interaction_id && interaction.queue_skill) {
interactions.push(interaction);
}
@@ -141,6 +327,16 @@ export async function parseExcel(file: File): Promise<RawInteraction[]> {
}
}
// Debug summary
const total = interactions.length;
console.log('📊 Excel Parsing Summary:', {
total,
is_abandoned_TRUE: `${abandonedTrueCount} (${((abandonedTrueCount/total)*100).toFixed(1)}%)`,
fcr_real_flag_TRUE: `${fcrTrueCount} (${((fcrTrueCount/total)*100).toFixed(1)}%)`,
repeat_call_7d_TRUE: `${repeatTrueCount} (${((repeatTrueCount/total)*100).toFixed(1)}%)`,
transfer_flag_TRUE: `${transferTrueCount} (${((transferTrueCount/total)*100).toFixed(1)}%)`
});
if (interactions.length === 0) {
reject(new Error('No se pudieron parsear datos válidos del Excel'));
return;
@@ -205,14 +401,22 @@ export function validateInteractions(interactions: RawInteraction[]): {
}
// Validar período mínimo (3 meses recomendado)
const dates = interactions
.map(i => new Date(i.datetime_start))
.filter(d => !isNaN(d.getTime()));
let minTime = Infinity;
let maxTime = -Infinity;
let validDatesCount = 0;
if (dates.length > 0) {
const minDate = new Date(Math.min(...dates.map(d => d.getTime())));
const maxDate = new Date(Math.max(...dates.map(d => d.getTime())));
const monthsDiff = (maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24 * 30);
for (const interaction of interactions) {
const date = new Date(interaction.datetime_start);
const time = date.getTime();
if (!isNaN(time)) {
validDatesCount++;
if (time < minTime) minTime = time;
if (time > maxTime) maxTime = time;
}
}
if (validDatesCount > 0) {
const monthsDiff = (maxTime - minTime) / (1000 * 60 * 60 * 24 * 30);
if (monthsDiff < 3) {
warnings.push(`Período de datos: ${monthsDiff.toFixed(1)} meses. Se recomiendan al menos 3 meses para análisis robusto.`);
@@ -246,9 +450,9 @@ export function validateInteractions(interactions: RawInteraction[]): {
invalid: invalidTimes,
skills: uniqueSkills,
agents: uniqueAgents,
dateRange: dates.length > 0 ? {
min: new Date(Math.min(...dates.map(d => d.getTime()))).toISOString().split('T')[0],
max: new Date(Math.max(...dates.map(d => d.getTime()))).toISOString().split('T')[0]
dateRange: validDatesCount > 0 ? {
min: new Date(minTime).toISOString().split('T')[0],
max: new Date(maxTime).toISOString().split('T')[0]
} : null
}
};

View File

@@ -0,0 +1,15 @@
// utils/formatters.ts
// Shared formatting utilities
/**
* Formats the current date as "Month Year" in Spanish
* Example: "Enero 2025"
*/
export const formatDateMonthYear = (): 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()}`;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,260 @@
/**
* serverCache.ts - Server-side cache for CSV files
*
* Uses backend API to store/retrieve cached CSV files.
* Works across browsers and computers (as long as they access the same server).
*/
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
export interface ServerCacheMetadata {
fileName: string;
fileSize: number;
recordCount: number;
cachedAt: string;
costPerHour: number;
}
/**
* Check if server has cached data
*/
export async function checkServerCache(authHeader: string): Promise<{
exists: boolean;
metadata: ServerCacheMetadata | null;
}> {
const url = `${API_BASE_URL}/cache/check`;
console.log('[ServerCache] Checking cache at:', url);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: authHeader,
},
});
console.log('[ServerCache] Response status:', response.status);
if (!response.ok) {
const text = await response.text();
console.error('[ServerCache] Error checking cache:', response.status, text);
return { exists: false, metadata: null };
}
const data = await response.json();
console.log('[ServerCache] Response data:', data);
return {
exists: data.exists || false,
metadata: data.metadata || null,
};
} catch (error) {
console.error('[ServerCache] Error checking cache:', error);
return { exists: false, metadata: null };
}
}
/**
* Save CSV file to server cache using FormData
* This sends the actual file, not parsed JSON data
*/
export async function saveFileToServerCache(
authHeader: string,
file: File,
costPerHour: number
): Promise<boolean> {
const url = `${API_BASE_URL}/cache/file`;
console.log(`[ServerCache] Saving file "${file.name}" (${(file.size / 1024 / 1024).toFixed(2)} MB) to server at:`, url);
try {
const formData = new FormData();
formData.append('csv_file', file);
formData.append('fileName', file.name);
formData.append('fileSize', file.size.toString());
formData.append('costPerHour', costPerHour.toString());
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: authHeader,
// Note: Don't set Content-Type - browser sets it automatically with boundary for FormData
},
body: formData,
});
console.log('[ServerCache] Save response status:', response.status);
if (!response.ok) {
const text = await response.text();
console.error('[ServerCache] Error saving cache:', response.status, text);
return false;
}
const data = await response.json();
console.log('[ServerCache] Save success:', data);
return true;
} catch (error) {
console.error('[ServerCache] Error saving cache:', error);
return false;
}
}
/**
* Download the cached CSV file from the server
* Returns a File object that can be parsed locally
*/
export async function downloadCachedFile(authHeader: string): Promise<File | null> {
const url = `${API_BASE_URL}/cache/download`;
console.log('[ServerCache] Downloading cached file from:', url);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: authHeader,
},
});
console.log('[ServerCache] Download response status:', response.status);
if (response.status === 404) {
console.error('[ServerCache] No cached file found');
return null;
}
if (!response.ok) {
const text = await response.text();
console.error('[ServerCache] Error downloading cached file:', response.status, text);
return null;
}
// Get the blob and create a File object
const blob = await response.blob();
const file = new File([blob], 'cached_data.csv', { type: 'text/csv' });
console.log(`[ServerCache] Downloaded file: ${(file.size / 1024 / 1024).toFixed(2)} MB`);
return file;
} catch (error) {
console.error('[ServerCache] Error downloading cached file:', error);
return null;
}
}
/**
* Save drilldownData JSON to server cache
* Called after calculating drilldown from uploaded file
*/
export async function saveDrilldownToServerCache(
authHeader: string,
drilldownData: any[]
): Promise<boolean> {
const url = `${API_BASE_URL}/cache/drilldown`;
console.log(`[ServerCache] Saving drilldownData (${drilldownData.length} skills) to server`);
try {
const formData = new FormData();
formData.append('drilldown_json', JSON.stringify(drilldownData));
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: authHeader,
},
body: formData,
});
console.log('[ServerCache] Save drilldown response status:', response.status);
if (!response.ok) {
const text = await response.text();
console.error('[ServerCache] Error saving drilldown:', response.status, text);
return false;
}
const data = await response.json();
console.log('[ServerCache] Drilldown save success:', data);
return true;
} catch (error) {
console.error('[ServerCache] Error saving drilldown:', error);
return false;
}
}
/**
* Get cached drilldownData from server
* Returns the pre-calculated drilldown data for fast cache usage
*/
export async function getCachedDrilldown(authHeader: string): Promise<any[] | null> {
const url = `${API_BASE_URL}/cache/drilldown`;
console.log('[ServerCache] Getting cached drilldown from:', url);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: authHeader,
},
});
console.log('[ServerCache] Get drilldown response status:', response.status);
if (response.status === 404) {
console.log('[ServerCache] No cached drilldown found');
return null;
}
if (!response.ok) {
const text = await response.text();
console.error('[ServerCache] Error getting drilldown:', response.status, text);
return null;
}
const data = await response.json();
console.log(`[ServerCache] Got cached drilldown: ${data.drilldownData?.length || 0} skills`);
return data.drilldownData || null;
} catch (error) {
console.error('[ServerCache] Error getting drilldown:', error);
return null;
}
}
/**
* Clear server cache
*/
export async function clearServerCache(authHeader: string): Promise<boolean> {
const url = `${API_BASE_URL}/cache/file`;
console.log('[ServerCache] Clearing cache at:', url);
try {
const response = await fetch(url, {
method: 'DELETE',
headers: {
Authorization: authHeader,
},
});
console.log('[ServerCache] Clear response status:', response.status);
if (!response.ok) {
const text = await response.text();
console.error('[ServerCache] Error clearing cache:', response.status, text);
return false;
}
console.log('[ServerCache] Cache cleared');
return true;
} catch (error) {
console.error('[ServerCache] Error clearing cache:', error);
return false;
}
}
// Legacy exports - kept for backwards compatibility during transition
// These will throw errors if called since the backend endpoints are deprecated
export async function saveServerCache(): Promise<boolean> {
console.error('[ServerCache] saveServerCache is deprecated - use saveFileToServerCache instead');
return false;
}
export async function getServerCachedInteractions(): Promise<null> {
console.error('[ServerCache] getServerCachedInteractions is deprecated - use cached file analysis instead');
return null;
}

View File

@@ -4,7 +4,7 @@ set -euo pipefail
###############################################
# CONFIGURACIÓN BÁSICA EDITA ESTO
###############################################
# TODO: pon aquí la URL real de tu repo
# TODO: pon aquí la URL real de tu repo (sin credenciales)
REPO_URL_DEFAULT="https://github.com/igferne/Beyond-Diagnosis.git"
INSTALL_DIR="/opt/beyonddiagnosis"
@@ -57,15 +57,44 @@ if [ -z "$API_PASS" ]; then
exit 1
fi
read -rp "URL del repositorio Git [$REPO_URL_DEFAULT]: " REPO_URL
echo
read -rp "URL del repositorio Git (HTTPS, sin credenciales) [$REPO_URL_DEFAULT]: " REPO_URL
REPO_URL=${REPO_URL:-$REPO_URL_DEFAULT}
echo
read -rp "¿El repositorio es PRIVADO en GitHub y necesitas token? [s/N]: " IS_PRIVATE
IS_PRIVATE=${IS_PRIVATE:-N}
GIT_CLONE_URL="$REPO_URL"
if [[ "$IS_PRIVATE" =~ ^[sS]$ ]]; then
echo "Introduce un Personal Access Token (PAT) de GitHub con permiso de lectura del repo."
read -rsp "GitHub PAT: " GITHUB_TOKEN
echo
if [ -z "$GITHUB_TOKEN" ]; then
echo "El token no puede estar vacío si el repo es privado."
exit 1
fi
# Construimos una URL del tipo: https://TOKEN@github.com/usuario/repo.git
if [[ "$REPO_URL" =~ ^https:// ]]; then
GIT_CLONE_URL="https://${GITHUB_TOKEN}@${REPO_URL#https://}"
else
echo "La URL del repositorio debe empezar por https:// para usar el token."
exit 1
fi
fi
echo
echo "Resumen de configuración:"
echo " Dominio: $DOMAIN"
echo " Email Let'sEnc: $EMAIL"
echo " Usuario API: $API_USER"
echo " Repo: $REPO_URL"
echo " Repo (visible): $REPO_URL"
if [[ "$IS_PRIVATE" =~ ^[sS]$ ]]; then
echo " Repo privado: Sí (se usará un PAT sólo para el clon inicial)"
else
echo " Repo privado: No"
fi
echo
read -rp "¿Continuar con la instalación? [s/N]: " CONFIRM
@@ -137,7 +166,8 @@ if [ -d "$INSTALL_DIR/.git" ]; then
git -C "$INSTALL_DIR" pull --ff-only
else
rm -rf "$INSTALL_DIR"
git clone "$REPO_URL" "$INSTALL_DIR"
echo "Clonando repositorio..."
git clone "$GIT_CLONE_URL" "$INSTALL_DIR"
fi
cd "$INSTALL_DIR"
@@ -206,12 +236,15 @@ server {
listen 80;
server_name $DOMAIN;
return 301 https://\$host\$request_uri;
client_max_body_size 1024M;
}
server {
listen 443 ssl;
server_name $DOMAIN;
client_max_body_size 1024M;
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
@@ -235,6 +268,11 @@ server {
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
send_timeout 600s;
}
}
EOF

43
nginx/conf.d/beyond.conf Normal file
View File

@@ -0,0 +1,43 @@
server {
listen 80;
server_name ae-analytics.beyondcx.ai;
return 301 https://$host$request_uri;
client_max_body_size 1024M;
}
server {
listen 443 ssl;
server_name ae-analytics.beyondcx.ai;
client_max_body_size 1024M;
ssl_certificate /etc/letsencrypt/live/ae-analytics.beyondcx.ai/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ae-analytics.beyondcx.ai/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# FRONTEND (React)
location / {
proxy_pass http://frontend:4173/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# BACKEND (FastAPI)
location /api/ {
proxy_pass http://backend:8000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
send_timeout 600s;
}
}

View File

@@ -23,5 +23,10 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
send_timeout 600s;
}
}