Commit inicial

This commit is contained in:
Susana
2026-01-18 19:15:34 +00:00
parent 522b4b6caa
commit 62454c6b6a
30 changed files with 12750 additions and 1310 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`

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,56 +180,45 @@ 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))))
return float("nan")
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.

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

@@ -9,6 +9,9 @@ services:
# credenciales del API (las mismas que usas ahora)
BASIC_AUTH_USERNAME: "beyond"
BASIC_AUTH_PASSWORD: "beyond2026"
CACHE_DIR: "/data/cache"
volumes:
- cache-data:/data/cache
expose:
- "8000"
networks:
@@ -41,6 +44,10 @@ services:
networks:
- beyond-net
volumes:
cache-data:
driver: local
networks:
beyond-net:
driver: bridge

View File

@@ -1,5 +1,6 @@
import { motion } from 'framer-motion';
import { LayoutDashboard, Layers, Bot, Map } from 'lucide-react';
import { formatDateMonthYear } from '../utils/formatters';
export type TabId = 'executive' | 'dimensions' | 'readiness' | 'roadmap';
@@ -22,15 +23,6 @@ const TABS: TabConfig[] = [
{ id: 'roadmap', label: 'Roadmap', icon: Map },
];
const formatDate = (): string => {
const now = new Date();
const months = [
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
];
return `${months[now.getMonth()]} ${now.getFullYear()}`;
};
export function DashboardHeader({
title = 'AIR EUROPA - Beyond CX Analytics',
activeTab,
@@ -39,15 +31,15 @@ export function DashboardHeader({
return (
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
{/* Top row: Title and Date */}
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-800">{title}</h1>
<span className="text-sm text-slate-500">{formatDate()}</span>
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
<div className="flex items-center justify-between gap-2">
<h1 className="text-base sm:text-xl font-bold text-slate-800 truncate">{title}</h1>
<span className="text-xs sm:text-sm text-slate-500 flex-shrink-0">{formatDateMonthYear()}</span>
</div>
</div>
{/* Tab Navigation */}
<nav className="max-w-7xl mx-auto px-6">
<nav className="max-w-7xl mx-auto px-2 sm:px-6 overflow-x-auto">
<div className="flex space-x-1">
{TABS.map((tab) => {
const Icon = tab.icon;

View File

@@ -1,11 +1,12 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeft } from 'lucide-react';
import { ArrowLeft, ShieldCheck, Info } from 'lucide-react';
import { DashboardHeader, TabId } from './DashboardHeader';
import { ExecutiveSummaryTab } from './tabs/ExecutiveSummaryTab';
import { DimensionAnalysisTab } from './tabs/DimensionAnalysisTab';
import { AgenticReadinessTab } from './tabs/AgenticReadinessTab';
import { RoadmapTab } from './tabs/RoadmapTab';
import { MetodologiaDrawer } from './MetodologiaDrawer';
import type { AnalysisData } from '../types';
interface DashboardTabsProps {
@@ -20,15 +21,16 @@ export function DashboardTabs({
onBack
}: DashboardTabsProps) {
const [activeTab, setActiveTab] = useState<TabId>('executive');
const [metodologiaOpen, setMetodologiaOpen] = useState(false);
const renderTabContent = () => {
switch (activeTab) {
case 'executive':
return <ExecutiveSummaryTab data={data} />;
return <ExecutiveSummaryTab data={data} onTabChange={setActiveTab} />;
case 'dimensions':
return <DimensionAnalysisTab data={data} />;
case 'readiness':
return <AgenticReadinessTab data={data} />;
return <AgenticReadinessTab data={data} onTabChange={setActiveTab} />;
case 'roadmap':
return <RoadmapTab data={data} />;
default:
@@ -41,13 +43,14 @@ export function DashboardTabs({
{/* Back button */}
{onBack && (
<div className="bg-white border-b border-slate-200">
<div className="max-w-7xl mx-auto px-6 py-2">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-2">
<button
onClick={onBack}
className="flex items-center gap-2 text-sm text-slate-600 hover:text-slate-800 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Volver al formulario
<span className="hidden sm:inline">Volver al formulario</span>
<span className="sm:hidden">Volver</span>
</button>
</div>
</div>
@@ -61,7 +64,7 @@ export function DashboardTabs({
/>
{/* Tab Content */}
<main className="max-w-7xl mx-auto px-6 py-6">
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-4 sm:py-6">
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
@@ -77,16 +80,37 @@ export function DashboardTabs({
{/* Footer */}
<footer className="border-t border-slate-200 bg-white mt-8">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between text-sm text-slate-500">
<span>Beyond Diagnosis - Contact Center Analytics Platform</span>
<span>
Análisis: {data.tier ? data.tier.toUpperCase() : 'GOLD'} |
Fuente: {data.source || 'synthetic'}
</span>
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-sm text-slate-500">
<span className="hidden sm:inline">Beyond Diagnosis - Contact Center Analytics Platform</span>
<span className="sm:hidden text-xs">Beyond Diagnosis</span>
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm">
{data.tier ? data.tier.toUpperCase() : 'GOLD'} |
{data.source === 'backend' ? 'Genesys' : data.source || 'synthetic'}
</span>
<span className="hidden sm:inline text-slate-300">|</span>
{/* Badge Metodología */}
<button
onClick={() => setMetodologiaOpen(true)}
className="inline-flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 bg-green-100 text-green-800 rounded-full text-[10px] sm:text-xs font-medium hover:bg-green-200 transition-colors cursor-pointer"
>
<ShieldCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="hidden md:inline">Metodología de Transformación de Datos aplicada</span>
<span className="md:hidden">Metodología</span>
<Info className="w-2.5 h-2.5 sm:w-3 sm:h-3 opacity-60" />
</button>
</div>
</div>
</div>
</footer>
{/* Drawer de Metodología */}
<MetodologiaDrawer
isOpen={metodologiaOpen}
onClose={() => setMetodologiaOpen(false)}
data={data}
/>
</div>
);
}

View File

@@ -1,14 +1,21 @@
// components/DataInputRedesigned.tsx
// Interfaz de entrada de datos simplificada
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import {
AlertCircle, FileText, Database,
UploadCloud, File, Loader2, Info, X
UploadCloud, File, Loader2, Info, X,
HardDrive, Trash2, RefreshCw, Server
} from 'lucide-react';
import clsx from 'clsx';
import toast from 'react-hot-toast';
import { checkServerCache, clearServerCache, ServerCacheMetadata } from '../utils/serverCache';
import { useAuth } from '../utils/AuthContext';
interface CacheInfo extends ServerCacheMetadata {
// Using server cache metadata structure
}
interface DataInputRedesignedProps {
onAnalyze: (config: {
@@ -22,6 +29,7 @@ interface DataInputRedesignedProps {
file?: File;
sheetUrl?: string;
useSynthetic?: boolean;
useCache?: boolean;
}) => void;
isAnalyzing: boolean;
}
@@ -30,6 +38,8 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
onAnalyze,
isAnalyzing
}) => {
const { authHeader } = useAuth();
// Estados para datos manuales - valores vacíos por defecto
const [costPerHour, setCostPerHour] = useState<string>('');
const [avgCsat, setAvgCsat] = useState<string>('');
@@ -43,6 +53,77 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
const [file, setFile] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
// Estado para caché del servidor
const [cacheInfo, setCacheInfo] = useState<CacheInfo | null>(null);
const [checkingCache, setCheckingCache] = useState(true);
// Verificar caché del servidor al cargar
useEffect(() => {
const checkCache = async () => {
console.log('[DataInput] Checking server cache, authHeader:', authHeader ? 'present' : 'null');
if (!authHeader) {
console.log('[DataInput] No authHeader, skipping cache check');
setCheckingCache(false);
return;
}
try {
setCheckingCache(true);
console.log('[DataInput] Calling checkServerCache...');
const { exists, metadata } = await checkServerCache(authHeader);
console.log('[DataInput] Cache check result:', { exists, metadata });
if (exists && metadata) {
setCacheInfo(metadata);
console.log('[DataInput] Cache info set:', metadata);
// Auto-rellenar coste si hay en caché
if (metadata.costPerHour > 0 && !costPerHour) {
setCostPerHour(metadata.costPerHour.toString());
}
} else {
console.log('[DataInput] No cache found on server');
}
} catch (error) {
console.error('[DataInput] Error checking server cache:', error);
} finally {
setCheckingCache(false);
}
};
checkCache();
}, [authHeader]);
const handleClearCache = async () => {
if (!authHeader) return;
try {
const success = await clearServerCache(authHeader);
if (success) {
setCacheInfo(null);
toast.success('Caché del servidor limpiada', { icon: '🗑️' });
} else {
toast.error('Error limpiando caché del servidor');
}
} catch (error) {
toast.error('Error limpiando caché');
}
};
const handleUseCache = () => {
if (!cacheInfo) return;
const segmentMapping = (highValueQueues || mediumValueQueues || lowValueQueues) ? {
high_value_queues: (highValueQueues || '').split(',').map(q => q.trim()).filter(q => q),
medium_value_queues: (mediumValueQueues || '').split(',').map(q => q.trim()).filter(q => q),
low_value_queues: (lowValueQueues || '').split(',').map(q => q.trim()).filter(q => q)
} : undefined;
onAnalyze({
costPerHour: parseFloat(costPerHour) || cacheInfo.costPerHour,
avgCsat: parseFloat(avgCsat) || 0,
segmentMapping,
useCache: true
});
};
const handleFileChange = (selectedFile: File | null) => {
if (selectedFile) {
const allowedTypes = [
@@ -111,7 +192,7 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-white rounded-lg shadow-sm p-6 border border-slate-200"
className="bg-white rounded-lg shadow-sm p-4 sm:p-6 border border-slate-200"
>
<div className="mb-6">
<h2 className="text-lg font-semibold text-slate-800 mb-1 flex items-center gap-2">
@@ -123,7 +204,7 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
{/* Coste por Hora */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2 flex items-center gap-2">
@@ -176,7 +257,7 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
</div>
{/* Segmentación por Cola/Skill */}
<div className="col-span-2">
<div className="col-span-1 md:col-span-2">
<div className="mb-3">
<h4 className="font-medium text-slate-700 mb-1 flex items-center gap-2">
Segmentación de Clientes por Cola/Skill
@@ -187,7 +268,7 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Alto Valor
@@ -236,20 +317,102 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
</div>
</motion.div>
{/* Sección 2: Subir Archivo */}
{/* Sección 2: Datos en Caché del Servidor (si hay) */}
{cacheInfo && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="bg-emerald-50 rounded-lg shadow-sm p-4 sm:p-6 border-2 border-emerald-300"
>
<div className="flex items-start justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-emerald-800 flex items-center gap-2">
<Server size={20} className="text-emerald-600" />
Datos en Caché
</h2>
</div>
<button
onClick={handleClearCache}
className="p-2 text-emerald-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition"
title="Limpiar caché"
>
<Trash2 size={18} />
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-4 mb-4">
<div className="bg-white rounded-lg p-3 border border-emerald-200">
<p className="text-xs text-emerald-600 font-medium">Archivo</p>
<p className="text-sm font-semibold text-slate-800 truncate" title={cacheInfo.fileName}>
{cacheInfo.fileName}
</p>
</div>
<div className="bg-white rounded-lg p-3 border border-emerald-200">
<p className="text-xs text-emerald-600 font-medium">Registros</p>
<p className="text-sm font-semibold text-slate-800">
{cacheInfo.recordCount.toLocaleString()}
</p>
</div>
<div className="bg-white rounded-lg p-3 border border-emerald-200">
<p className="text-xs text-emerald-600 font-medium">Tamaño Original</p>
<p className="text-sm font-semibold text-slate-800">
{(cacheInfo.fileSize / (1024 * 1024)).toFixed(1)} MB
</p>
</div>
<div className="bg-white rounded-lg p-3 border border-emerald-200">
<p className="text-xs text-emerald-600 font-medium">Guardado</p>
<p className="text-sm font-semibold text-slate-800">
{new Date(cacheInfo.cachedAt).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
<button
onClick={handleUseCache}
disabled={isAnalyzing || !costPerHour || parseFloat(costPerHour) <= 0}
className={clsx(
'w-full py-3 rounded-lg font-semibold flex items-center justify-center gap-2 transition-all',
(!isAnalyzing && costPerHour && parseFloat(costPerHour) > 0)
? 'bg-emerald-600 text-white hover:bg-emerald-700'
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
)}
>
{isAnalyzing ? (
<>
<Loader2 size={20} className="animate-spin" />
Analizando...
</>
) : (
<>
<RefreshCw size={20} />
Usar Datos en Caché
</>
)}
</button>
{(!costPerHour || parseFloat(costPerHour) <= 0) && (
<p className="text-xs text-amber-600 mt-2 text-center">
Introduce el coste por hora arriba para continuar
</p>
)}
</motion.div>
)}
{/* Sección 3: Subir Archivo */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-white rounded-lg shadow-sm p-6 border border-slate-200"
transition={{ delay: cacheInfo ? 0.25 : 0.2 }}
className="bg-white rounded-lg shadow-sm p-4 sm:p-6 border border-slate-200"
>
<div className="mb-4">
<h2 className="text-lg font-semibold text-slate-800 mb-1 flex items-center gap-2">
<UploadCloud size={20} className="text-[#6D84E3]" />
Datos CSV
{cacheInfo ? 'Subir Nuevo Archivo' : 'Datos CSV'}
</h2>
<p className="text-slate-500 text-sm">
Sube el archivo exportado desde tu sistema ACD/CTI
{cacheInfo ? 'O sube un archivo diferente para analizar' : 'Sube el archivo exportado desde tu sistema ACD/CTI'}
</p>
</div>

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

@@ -6,19 +6,10 @@ import { Toaster } from 'react-hot-toast';
import { TierKey, AnalysisData } from '../types';
import DataInputRedesigned from './DataInputRedesigned';
import DashboardTabs from './DashboardTabs';
import { generateAnalysis } from '../utils/analysisGenerator';
import { generateAnalysis, generateAnalysisFromCache } from '../utils/analysisGenerator';
import toast from 'react-hot-toast';
import { useAuth } from '../utils/AuthContext';
// Función para formatear fecha como en el dashboard
const formatDate = (): string => {
const now = new Date();
const months = [
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
];
return `${months[now.getMonth()]} ${now.getFullYear()}`;
};
import { formatDateMonthYear } from '../utils/formatters';
const SinglePageDataRequestIntegrated: React.FC = () => {
const [view, setView] = useState<'form' | 'dashboard'>('form');
@@ -38,9 +29,10 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
file?: File;
sheetUrl?: string;
useSynthetic?: boolean;
useCache?: boolean;
}) => {
// Validar que hay archivo
if (!config.file) {
// Validar que hay archivo o caché
if (!config.file && !config.useCache) {
toast.error('Por favor, sube un archivo CSV o Excel.');
return;
}
@@ -58,26 +50,40 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
}
setIsAnalyzing(true);
toast.loading('Generando análisis...', { id: 'analyzing' });
const loadingMsg = config.useCache ? 'Cargando desde caché...' : 'Generando análisis...';
toast.loading(loadingMsg, { id: 'analyzing' });
setTimeout(async () => {
try {
// Usar tier 'gold' por defecto
const data = await generateAnalysis(
'gold' as TierKey,
config.costPerHour,
config.avgCsat || 0,
config.segmentMapping,
config.file,
config.sheetUrl,
false, // No usar sintético
authHeader || undefined
);
let data: AnalysisData;
if (config.useCache) {
// Usar datos desde caché
data = await generateAnalysisFromCache(
'gold' as TierKey,
config.costPerHour,
config.avgCsat || 0,
config.segmentMapping,
authHeader || undefined
);
} else {
// Usar tier 'gold' por defecto
data = await generateAnalysis(
'gold' as TierKey,
config.costPerHour,
config.avgCsat || 0,
config.segmentMapping,
config.file,
config.sheetUrl,
false, // No usar sintético
authHeader || undefined
);
}
setAnalysisData(data);
setIsAnalyzing(false);
toast.dismiss('analyzing');
toast.success('¡Análisis completado!', { icon: '🎉' });
toast.success(config.useCache ? '¡Datos cargados desde caché!' : '¡Análisis completado!', { icon: '🎉' });
setView('dashboard');
window.scrollTo({ top: 0, behavior: 'smooth' });
@@ -95,7 +101,7 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
toast.error('Error al generar el análisis: ' + msg);
}
}
}, 1500);
}, 500);
};
const handleBackToForm = () => {
@@ -141,7 +147,7 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
AIR EUROPA - Beyond CX Analytics
</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-slate-500">{formatDate()}</span>
<span className="text-sm text-slate-500">{formatDateMonthYear()}</span>
<button
onClick={logout}
className="text-xs text-slate-500 hover:text-slate-800 underline"

View File

@@ -107,11 +107,11 @@ export function WaterfallChart({
return null;
};
// Find min/max for Y axis
// Find min/max for Y axis - always start from 0
const allValues = processedData.flatMap(d => [d.start, d.end]);
const minValue = Math.min(0, ...allValues);
const minValue = 0; // Always start from 0, not negative
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const padding = maxValue * 0.1;
return (
<div className="bg-white rounded-lg p-4 border border-slate-200">

File diff suppressed because it is too large Load Diff

View File

@@ -1,79 +1,333 @@
import React from 'react';
import { motion } from 'framer-motion';
import { ChevronRight, TrendingUp, TrendingDown, Minus } from 'lucide-react';
import type { AnalysisData, DimensionAnalysis, Finding, Recommendation } from '../../types';
import { ChevronRight, TrendingUp, TrendingDown, Minus, AlertTriangle, Lightbulb, DollarSign } from 'lucide-react';
import type { AnalysisData, DimensionAnalysis, Finding, Recommendation, HeatmapDataPoint } from '../../types';
import {
Card,
Badge,
} from '../ui';
import {
cn,
COLORS,
STATUS_CLASSES,
getStatusFromScore,
formatCurrency,
formatNumber,
formatPercent,
} from '../../config/designSystem';
interface DimensionAnalysisTabProps {
data: AnalysisData;
}
// Dimension Card Component
// ========== ANÁLISIS CAUSAL CON IMPACTO ECONÓMICO ==========
interface CausalAnalysis {
finding: string;
probableCause: string;
economicImpact: number;
recommendation: string;
severity: 'critical' | 'warning' | 'info';
}
// v3.11: Interfaz extendida para incluir fórmula de cálculo
interface CausalAnalysisExtended extends CausalAnalysis {
impactFormula?: string; // Explicación de cómo se calculó el impacto
hasRealData: boolean; // True si hay datos reales para calcular
}
// Genera análisis causal basado en dimensión y datos
function generateCausalAnalysis(
dimension: DimensionAnalysis,
heatmapData: HeatmapDataPoint[],
economicModel: { currentAnnualCost: number }
): CausalAnalysisExtended[] {
const analyses: CausalAnalysisExtended[] = [];
const totalVolume = heatmapData.reduce((sum, h) => sum + h.volume, 0);
// v3.11: CPI basado en modelo TCO (€2.33/interacción)
const CPI_TCO = 2.33;
const CPI = totalVolume > 0 ? economicModel.currentAnnualCost / (totalVolume * 12) : CPI_TCO;
// Calcular métricas agregadas
const avgCVAHT = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.variability?.cv_aht || 0) * h.volume, 0) / totalVolume
: 0;
const avgTransferRate = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.variability?.transfer_rate || 0) * h.volume, 0) / totalVolume
: 0;
const avgFCR = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + h.metrics.fcr * h.volume, 0) / totalVolume
: 0;
const avgAHT = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + h.aht_seconds * h.volume, 0) / totalVolume
: 0;
const avgCSAT = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.metrics?.csat || 0) * h.volume, 0) / totalVolume
: 0;
const avgHoldTime = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.metrics?.hold_time || 0) * h.volume, 0) / totalVolume
: 0;
// Skills con problemas específicos
const skillsHighCV = heatmapData.filter(h => (h.variability?.cv_aht || 0) > 100);
const skillsLowFCR = heatmapData.filter(h => h.metrics.fcr < 50);
const skillsHighTransfer = heatmapData.filter(h => (h.variability?.transfer_rate || 0) > 20);
switch (dimension.name) {
case 'operational_efficiency':
// Análisis de variabilidad AHT
if (avgCVAHT > 80) {
const inefficiencyPct = Math.min(0.15, (avgCVAHT - 60) / 200);
const inefficiencyCost = Math.round(economicModel.currentAnnualCost * inefficiencyPct);
analyses.push({
finding: `Variabilidad AHT elevada: CV ${avgCVAHT.toFixed(0)}% (benchmark: <60%)`,
probableCause: skillsHighCV.length > 0
? `Falta de scripts estandarizados en ${skillsHighCV.slice(0, 3).map(s => s.skill).join(', ')}. Agentes manejan casos similares de formas muy diferentes.`
: 'Procesos no documentados y falta de guías de atención claras.',
economicImpact: inefficiencyCost,
impactFormula: `Coste anual × ${(inefficiencyPct * 100).toFixed(1)}% ineficiencia = €${(economicModel.currentAnnualCost/1000).toFixed(0)}K × ${(inefficiencyPct * 100).toFixed(1)}%`,
recommendation: 'Crear playbooks por tipología de consulta y certificar agentes en procesos estándar.',
severity: avgCVAHT > 120 ? 'critical' : 'warning',
hasRealData: true
});
}
// Análisis de AHT absoluto
if (avgAHT > 420) {
const excessSeconds = avgAHT - 360;
const excessCost = Math.round((excessSeconds / 3600) * totalVolume * 12 * 25);
analyses.push({
finding: `AHT elevado: ${Math.floor(avgAHT / 60)}:${String(Math.round(avgAHT) % 60).padStart(2, '0')} (benchmark: 6:00)`,
probableCause: 'Sistemas de información fragmentados, búsquedas manuales excesivas, o falta de herramientas de asistencia al agente.',
economicImpact: excessCost,
impactFormula: `Exceso ${Math.round(excessSeconds)}s × ${totalVolume.toLocaleString()} int/mes × 12 × €25/h`,
recommendation: 'Implementar vista unificada de cliente y herramientas de sugerencia automática.',
severity: avgAHT > 540 ? 'critical' : 'warning',
hasRealData: true
});
}
break;
case 'effectiveness_resolution':
// Análisis de FCR
if (avgFCR < 70) {
const recontactRate = (100 - avgFCR) / 100;
const recontactCost = Math.round(totalVolume * 12 * recontactRate * CPI_TCO);
analyses.push({
finding: `FCR bajo: ${avgFCR.toFixed(0)}% (benchmark: >75%)`,
probableCause: skillsLowFCR.length > 0
? `Agentes sin autonomía para resolver en ${skillsLowFCR.slice(0, 2).map(s => s.skill).join(', ')}. Políticas de escalado excesivamente restrictivas.`
: 'Falta de información completa en primer contacto o limitaciones de autoridad del agente.',
economicImpact: recontactCost,
impactFormula: `${totalVolume.toLocaleString()} int × 12 × ${(recontactRate * 100).toFixed(0)}% recontactos ×${CPI_TCO}/int`,
recommendation: 'Empoderar agentes con mayor autoridad de resolución y crear Knowledge Base contextual.',
severity: avgFCR < 50 ? 'critical' : 'warning',
hasRealData: true
});
}
// Análisis de transferencias
if (avgTransferRate > 15) {
const transferCost = Math.round(totalVolume * 12 * (avgTransferRate / 100) * CPI_TCO * 0.5);
analyses.push({
finding: `Tasa de transferencias: ${avgTransferRate.toFixed(1)}% (benchmark: <10%)`,
probableCause: skillsHighTransfer.length > 0
? `Routing inicial incorrecto hacia ${skillsHighTransfer.slice(0, 2).map(s => s.skill).join(', ')}. IVR no identifica correctamente la intención del cliente.`
: 'Reglas de enrutamiento desactualizadas o skills mal definidos.',
economicImpact: transferCost,
impactFormula: `${totalVolume.toLocaleString()} int × 12 × ${avgTransferRate.toFixed(1)}% ×${CPI_TCO} × 50% coste adicional`,
recommendation: 'Revisar árbol de IVR, actualizar reglas de ACD y capacitar agentes en resolución integral.',
severity: avgTransferRate > 25 ? 'critical' : 'warning',
hasRealData: true
});
}
break;
case 'volumetry_distribution':
// Análisis de concentración de volumen
const topSkill = [...heatmapData].sort((a, b) => b.volume - a.volume)[0];
const topSkillPct = topSkill ? (topSkill.volume / totalVolume) * 100 : 0;
if (topSkillPct > 40 && topSkill) {
const deflectionPotential = Math.round(topSkill.volume * 12 * CPI_TCO * 0.20);
analyses.push({
finding: `Concentración de volumen: ${topSkill.skill} representa ${topSkillPct.toFixed(0)}% del total`,
probableCause: 'Dependencia excesiva de un skill puede indicar oportunidad de autoservicio o automatización parcial.',
economicImpact: deflectionPotential,
impactFormula: `${topSkill.volume.toLocaleString()} int × 12 ×${CPI_TCO} × 20% deflexión potencial`,
recommendation: `Analizar top consultas de ${topSkill.skill} para identificar candidatas a deflexión digital o FAQ automatizado.`,
severity: 'info',
hasRealData: true
});
}
break;
case 'complexity_predictability':
// v3.11: Análisis de complejidad basado en hold time y CV
if (avgHoldTime > 45) {
const excessHold = avgHoldTime - 30;
const holdCost = Math.round((excessHold / 3600) * totalVolume * 12 * 25);
analyses.push({
finding: `Hold time elevado: ${avgHoldTime.toFixed(0)}s promedio (benchmark: <30s)`,
probableCause: 'Consultas complejas requieren búsqueda de información durante la llamada. Posible falta de acceso rápido a datos o sistemas.',
economicImpact: holdCost,
impactFormula: `Exceso ${Math.round(excessHold)}s × ${totalVolume.toLocaleString()} int × 12 × €25/h`,
recommendation: 'Implementar acceso contextual a información del cliente y reducir sistemas fragmentados.',
severity: avgHoldTime > 60 ? 'critical' : 'warning',
hasRealData: true
});
}
if (avgCVAHT > 100) {
analyses.push({
finding: `Alta impredecibilidad: CV AHT ${avgCVAHT.toFixed(0)}% (benchmark: <75%)`,
probableCause: 'Procesos con alta variabilidad dificultan la planificación de recursos y el staffing.',
economicImpact: Math.round(economicModel.currentAnnualCost * 0.03),
impactFormula: `~3% del coste operativo por ineficiencia de staffing`,
recommendation: 'Segmentar procesos por complejidad y estandarizar los más frecuentes.',
severity: 'warning',
hasRealData: true
});
}
break;
case 'customer_satisfaction':
// v3.11: Solo generar análisis si hay datos de CSAT reales
if (avgCSAT > 0) {
if (avgCSAT < 70) {
// Estimación conservadora: impacto en retención
const churnRisk = Math.round(totalVolume * 12 * 0.02 * 50); // 2% churn × €50 valor medio
analyses.push({
finding: `CSAT por debajo del objetivo: ${avgCSAT.toFixed(0)}% (benchmark: >80%)`,
probableCause: 'Experiencia del cliente subóptima puede estar relacionada con tiempos de espera, resolución incompleta, o trato del agente.',
economicImpact: churnRisk,
impactFormula: `${totalVolume.toLocaleString()} clientes × 12 × 2% riesgo churn × €50 valor`,
recommendation: 'Implementar programa de voz del cliente (VoC) y cerrar loop de feedback.',
severity: avgCSAT < 50 ? 'critical' : 'warning',
hasRealData: true
});
}
}
// Si no hay CSAT, no generamos análisis falso
break;
case 'economy_cpi':
// Análisis de CPI
if (CPI > 3.5) {
const excessCPI = CPI - CPI_TCO;
const potentialSavings = Math.round(totalVolume * 12 * excessCPI);
analyses.push({
finding: `CPI por encima del benchmark: €${CPI.toFixed(2)} (objetivo: €${CPI_TCO})`,
probableCause: 'Combinación de AHT alto, baja productividad efectiva, o costes de personal por encima del mercado.',
economicImpact: potentialSavings,
impactFormula: `${totalVolume.toLocaleString()} int × 12 ×${excessCPI.toFixed(2)} exceso CPI`,
recommendation: 'Revisar mix de canales, optimizar procesos para reducir AHT y evaluar modelo de staffing.',
severity: CPI > 5 ? 'critical' : 'warning',
hasRealData: true
});
}
break;
}
// v3.11: NO generar fallback con impacto económico falso
// Si no hay análisis específico, simplemente retornar array vacío
// La UI mostrará "Sin hallazgos críticos" en lugar de un impacto inventado
return analyses;
}
// Formateador de moneda (usa la función importada de designSystem)
// v3.15: Dimension Card Component - con diseño McKinsey
function DimensionCard({
dimension,
findings,
recommendations,
causalAnalyses,
delay = 0
}: {
dimension: DimensionAnalysis;
findings: Finding[];
recommendations: Recommendation[];
causalAnalyses: CausalAnalysisExtended[];
delay?: number;
}) {
const Icon = dimension.icon;
const getScoreColor = (score: number) => {
if (score >= 80) return 'text-emerald-600 bg-emerald-100';
if (score >= 60) return 'text-amber-600 bg-amber-100';
return 'text-red-600 bg-red-100';
const getScoreVariant = (score: number): 'success' | 'warning' | 'critical' | 'default' => {
if (score < 0) return 'default'; // N/A
if (score >= 70) return 'success';
if (score >= 40) return 'warning';
return 'critical';
};
const getScoreLabel = (score: number) => {
const getScoreLabel = (score: number): string => {
if (score < 0) return 'N/A';
if (score >= 80) return 'Óptimo';
if (score >= 60) return 'Aceptable';
if (score >= 40) return 'Mejorable';
return 'Crítico';
};
const getSeverityConfig = (severity: string) => {
if (severity === 'critical') return STATUS_CLASSES.critical;
if (severity === 'warning') return STATUS_CLASSES.warning;
return STATUS_CLASSES.info;
};
// Get KPI trend icon
const TrendIcon = dimension.kpi.changeType === 'positive' ? TrendingUp :
dimension.kpi.changeType === 'negative' ? TrendingDown : Minus;
const trendColor = dimension.kpi.changeType === 'positive' ? 'text-emerald-600' :
dimension.kpi.changeType === 'negative' ? 'text-red-600' : 'text-slate-500';
dimension.kpi.changeType === 'negative' ? 'text-red-600' : 'text-gray-500';
// Calcular impacto total de esta dimensión
const totalImpact = causalAnalyses.reduce((sum, a) => sum + a.economicImpact, 0);
const scoreVariant = getScoreVariant(dimension.score);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay }}
className="bg-white rounded-lg border border-slate-200 overflow-hidden"
className="bg-white rounded-lg border border-gray-200 overflow-hidden"
>
{/* Header */}
<div className="p-4 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white">
<div className="p-4 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-[#6D84E3]/10">
<Icon className="w-5 h-5 text-[#6D84E3]" />
<div className="p-2 rounded-lg bg-blue-50">
<Icon className="w-5 h-5 text-blue-600" />
</div>
<div>
<h3 className="font-semibold text-slate-800">{dimension.title}</h3>
<p className="text-xs text-slate-500 mt-0.5 max-w-xs">{dimension.summary}</p>
<h3 className="font-semibold text-gray-900">{dimension.title}</h3>
<p className="text-xs text-gray-500 mt-0.5 max-w-xs">{dimension.summary}</p>
</div>
</div>
<div className={`px-3 py-1.5 rounded-full text-sm font-semibold ${getScoreColor(dimension.score)}`}>
{dimension.score}
<span className="text-xs font-normal ml-1">{getScoreLabel(dimension.score)}</span>
<div className="text-right">
<Badge
label={dimension.score >= 0 ? `${dimension.score} ${getScoreLabel(dimension.score)}` : '— N/A'}
variant={scoreVariant}
size="md"
/>
{totalImpact > 0 && (
<p className="text-xs text-red-600 font-medium mt-1">
Impacto: {formatCurrency(totalImpact)}
</p>
)}
</div>
</div>
</div>
{/* KPI Highlight */}
<div className="px-4 py-3 bg-slate-50/50 border-b border-slate-100">
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">{dimension.kpi.label}</span>
<span className="text-sm text-gray-600">{dimension.kpi.label}</span>
<div className="flex items-center gap-2">
<span className="font-bold text-slate-800">{dimension.kpi.value}</span>
<span className="font-bold text-gray-900">{dimension.kpi.value}</span>
{dimension.kpi.change && (
<div className={`flex items-center gap-1 text-xs ${trendColor}`}>
<div className={cn('flex items-center gap-1 text-xs', trendColor)}>
<TrendIcon className="w-3 h-3" />
<span>{dimension.kpi.change}</span>
</div>
@@ -82,13 +336,13 @@ function DimensionCard({
</div>
{dimension.percentile && (
<div className="mt-2">
<div className="flex items-center justify-between text-xs text-slate-500 mb-1">
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>Percentil</span>
<span>P{dimension.percentile}</span>
</div>
<div className="h-1.5 bg-slate-200 rounded-full overflow-hidden">
<div className="h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-[#6D84E3] rounded-full"
className="h-full bg-blue-600 rounded-full"
style={{ width: `${dimension.percentile}%` }}
/>
</div>
@@ -96,35 +350,108 @@ function DimensionCard({
)}
</div>
{/* Findings */}
<div className="p-4">
<h4 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
Hallazgos Clave
</h4>
<ul className="space-y-2">
{findings.slice(0, 3).map((finding, idx) => (
<li key={idx} className="flex items-start gap-2 text-sm">
<ChevronRight className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
finding.type === 'critical' ? 'text-red-500' :
finding.type === 'warning' ? 'text-amber-500' :
'text-[#6D84E3]'
}`} />
<span className="text-slate-700">{finding.text}</span>
</li>
))}
{findings.length === 0 && (
<li className="text-sm text-slate-400 italic">Sin hallazgos destacados</li>
)}
</ul>
</div>
{/* Si no hay datos para esta dimensión (score < 0 = N/A) */}
{dimension.score < 0 && (
<div className="p-4">
<div className="p-3 bg-gray-50 rounded-lg border border-gray-200">
<p className="text-sm text-gray-500 italic flex items-center gap-2">
<Minus className="w-4 h-4" />
Sin datos disponibles para esta dimensión.
</p>
</div>
</div>
)}
{/* Recommendations Preview */}
{recommendations.length > 0 && (
{/* Análisis Causal Completo - Solo si hay datos */}
{dimension.score >= 0 && causalAnalyses.length > 0 && (
<div className="p-4 space-y-3">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Análisis Causal
</h4>
{causalAnalyses.map((analysis, idx) => {
const config = getSeverityConfig(analysis.severity);
return (
<div key={idx} className={cn('p-3 rounded-lg border', config.bg, config.border)}>
{/* Hallazgo */}
<div className="flex items-start gap-2 mb-2">
<AlertTriangle className={cn('w-4 h-4 mt-0.5 flex-shrink-0', config.text)} />
<div>
<p className={cn('text-sm font-medium', config.text)}>{analysis.finding}</p>
</div>
</div>
{/* Causa probable */}
<div className="ml-6 mb-2">
<p className="text-xs text-gray-500 font-medium mb-0.5">Causa probable:</p>
<p className="text-xs text-gray-700">{analysis.probableCause}</p>
</div>
{/* Impacto económico */}
<div
className="ml-6 mb-2 flex items-center gap-2 cursor-help"
title={analysis.impactFormula || 'Impacto estimado basado en métricas operativas'}
>
<DollarSign className="w-3 h-3 text-red-500" />
<span className="text-xs font-bold text-red-600">
{formatCurrency(analysis.economicImpact)}
</span>
<span className="text-xs text-gray-500">impacto anual estimado</span>
<span className="text-xs text-gray-400">i</span>
</div>
{/* Recomendación inline */}
<div className="ml-6 p-2 bg-white rounded border border-gray-200">
<div className="flex items-start gap-2">
<Lightbulb className="w-3 h-3 text-blue-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-gray-600">{analysis.recommendation}</p>
</div>
</div>
</div>
);
})}
</div>
)}
{/* Fallback: Hallazgos originales si no hay análisis causal - Solo si hay datos */}
{dimension.score >= 0 && causalAnalyses.length === 0 && findings.length > 0 && (
<div className="p-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
Hallazgos Clave
</h4>
<ul className="space-y-2">
{findings.slice(0, 3).map((finding, idx) => (
<li key={idx} className="flex items-start gap-2 text-sm">
<ChevronRight className={cn('w-4 h-4 mt-0.5 flex-shrink-0',
finding.type === 'critical' ? 'text-red-500' :
finding.type === 'warning' ? 'text-amber-500' :
'text-blue-600'
)} />
<span className="text-gray-700">{finding.text}</span>
</li>
))}
</ul>
</div>
)}
{/* Si no hay análisis ni hallazgos pero sí hay datos */}
{dimension.score >= 0 && causalAnalyses.length === 0 && findings.length === 0 && (
<div className="p-4">
<div className={cn('p-3 rounded-lg border', STATUS_CLASSES.success.bg, STATUS_CLASSES.success.border)}>
<p className={cn('text-sm flex items-center gap-2', STATUS_CLASSES.success.text)}>
<ChevronRight className="w-4 h-4" />
Métricas dentro de rangos aceptables. Sin hallazgos críticos.
</p>
</div>
</div>
)}
{/* Recommendations Preview - Solo si no hay análisis causal y hay datos */}
{dimension.score >= 0 && causalAnalyses.length === 0 && recommendations.length > 0 && (
<div className="px-4 pb-4">
<div className="p-3 bg-[#6D84E3]/5 rounded-lg border border-[#6D84E3]/20">
<div className="p-3 bg-blue-50 rounded-lg border border-blue-100">
<div className="flex items-start gap-2">
<span className="text-xs font-semibold text-[#6D84E3]">Recomendación:</span>
<span className="text-xs text-slate-600">{recommendations[0].text}</span>
<span className="text-xs font-semibold text-blue-600">Recomendación:</span>
<span className="text-xs text-gray-600">{recommendations[0].text}</span>
</div>
</div>
</div>
@@ -133,50 +460,7 @@ function DimensionCard({
);
}
// Benchmark Comparison Table
function BenchmarkTable({ benchmarkData }: { benchmarkData: AnalysisData['benchmarkData'] }) {
const getPercentileColor = (percentile: number) => {
if (percentile >= 75) return 'text-emerald-600';
if (percentile >= 50) return 'text-amber-600';
return 'text-red-600';
};
return (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="px-4 py-3 border-b border-slate-100 bg-slate-50">
<h3 className="font-semibold text-slate-800">Benchmark vs Industria</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-xs text-slate-500 uppercase tracking-wider">
<th className="px-4 py-2 text-left font-medium">KPI</th>
<th className="px-4 py-2 text-right font-medium">Actual</th>
<th className="px-4 py-2 text-right font-medium">Industria</th>
<th className="px-4 py-2 text-right font-medium">Percentil</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{benchmarkData.map((item) => (
<tr key={item.kpi} className="hover:bg-slate-50">
<td className="px-4 py-3 text-sm text-slate-700 font-medium">{item.kpi}</td>
<td className="px-4 py-3 text-sm text-slate-800 text-right font-semibold">
{item.userDisplay}
</td>
<td className="px-4 py-3 text-sm text-slate-500 text-right">
{item.industryDisplay}
</td>
<td className={`px-4 py-3 text-sm text-right font-medium ${getPercentileColor(item.percentile)}`}>
P{item.percentile}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ========== v3.16: COMPONENTE PRINCIPAL ==========
export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
// Filter out agentic_readiness (has its own tab)
@@ -189,23 +473,46 @@ export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
const getRecommendationsForDimension = (dimensionId: string) =>
data.recommendations.filter(r => r.dimensionId === dimensionId);
// Generar análisis causal para cada dimensión
const getCausalAnalysisForDimension = (dimension: DimensionAnalysis) =>
generateCausalAnalysis(dimension, data.heatmapData, data.economicModel);
// Calcular impacto total de todas las dimensiones con datos
const impactoTotal = coreDimensions
.filter(d => d.score !== null && d.score !== undefined)
.reduce((total, dimension) => {
const analyses = getCausalAnalysisForDimension(dimension);
return total + analyses.reduce((sum, a) => sum + a.economicImpact, 0);
}, 0);
// v3.16: Contar dimensiones por estado para el header
const conDatos = coreDimensions.filter(d => d.score !== null && d.score !== undefined && d.score >= 0);
const sinDatos = coreDimensions.filter(d => d.score === null || d.score === undefined || d.score < 0);
return (
<div className="space-y-6">
{/* Dimensions Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* v3.16: Header simplificado - solo título y subtítulo */}
<div className="mb-2">
<h2 className="text-lg font-bold text-gray-900">Diagnóstico por Dimensión</h2>
<p className="text-sm text-gray-500">
{coreDimensions.length} dimensiones analizadas
{sinDatos.length > 0 && ` (${sinDatos.length} sin datos)`}
</p>
</div>
{/* v3.16: Grid simple con todas las dimensiones sin agrupación */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{coreDimensions.map((dimension, idx) => (
<DimensionCard
key={dimension.id}
dimension={dimension}
findings={getFindingsForDimension(dimension.id)}
recommendations={getRecommendationsForDimension(dimension.id)}
delay={idx * 0.1}
causalAnalyses={getCausalAnalysisForDimension(dimension)}
delay={idx * 0.05}
/>
))}
</div>
{/* Benchmark Table */}
<BenchmarkTable benchmarkData={data.benchmarkData} />
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

@@ -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,12 +161,14 @@ export interface Kpi {
changeType?: 'positive' | 'negative' | 'neutral';
}
// v3.0: 5 dimensiones viables
// v4.0: 7 dimensiones viables
export type DimensionName =
| '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 {
@@ -151,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)
@@ -185,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;
@@ -200,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 {
@@ -270,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,6 +1,6 @@
// 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, Target, Brain, Bot } from 'lucide-react';
import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2';
@@ -9,6 +9,7 @@ import {
mapBackendResultsToAnalysisData,
buildHeatmapFromBackend,
} from './backendMapper';
import { saveFileToServerCache, saveDrilldownToServerCache, getCachedDrilldown } from './serverCache';
@@ -99,9 +100,10 @@ const DIMENSIONS_CONTENT = {
},
};
// Hallazgos genéricos - los específicos se generan en realDataAnalysis.ts desde datos calculados
const KEY_FINDINGS: Finding[] = [
{
text: "El ratio P90/P50 de AHT es alto (>2.0) en varias colas, indicando alta variabilidad.",
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',
@@ -109,53 +111,37 @@ const KEY_FINDINGS: Finding[] = [
impact: 'high'
},
{
text: "Un 22% de las transferencias desde 'Soporte Técnico N1' hacia otras colas son incorrectas.",
text: "Tasa de transferencias elevada indica oportunidad de mejora en enrutamiento o capacitación.",
dimensionId: 'effectiveness_resolution',
type: 'warning',
title: 'Enrutamiento Incorrecto',
description: 'Existe un problema de routing que genera ineficiencias y experiencia pobre del cliente.',
title: 'Transferencias Elevadas',
description: 'Las transferencias frecuentes afectan la experiencia del cliente y la eficiencia operativa.',
impact: 'high'
},
{
text: "El pico de demanda de los lunes por la mañana provoca una caída del Nivel de Servicio al 65%.",
text: "Concentración de volumen en franjas horarias específicas genera picos de demanda.",
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'
type: 'info',
title: 'Concentración de Demanda',
description: 'Revisar capacidad en franjas de mayor volumen para optimizar nivel de servicio.',
impact: 'medium'
},
{
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.",
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 (Score Agentic >8).',
title: 'Oportunidad de Automatización',
description: 'Skills con alta repetitividad y baja complejidad son candidatos ideales para agentes IA.',
impact: 'high'
},
{
text: "FCR proxy <75% en colas de facturación, alto recontacto a 7 días.",
dimensionId: 'effectiveness_resolution',
type: 'warning',
title: 'Baja Resolución en Facturación',
description: 'El equipo de facturación tiene alto % de recontactos, indicando problemas de resolución.',
impact: 'high'
},
{
text: "Alta diversidad de tipificaciones y >20% llamadas con múltiples holds en colas complejas.",
dimensionId: 'complexity_predictability',
type: 'warning',
title: 'Alta Complejidad en Ciertas Colas',
description: 'Colas con alta complejidad requieren optimización antes de considerar automatización.',
impact: 'medium'
},
];
const RECOMMENDATIONS: Recommendation[] = [
@@ -801,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()
@@ -900,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({
@@ -913,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,
@@ -921,22 +937,44 @@ export const generateAnalysis = async (
segmentMapping
);
// Oportunidades: AHORA basadas en heatmap real + modelo económico del backend
mapped.opportunities = generateOpportunitiesFromHeatmap(
mapped.heatmapData,
mapped.economicModel
);
// 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`);
// 👉 El resto sigue siendo "frontend-driven" de momento
// 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();
}
// 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;
@@ -1002,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,

View File

@@ -9,7 +9,7 @@ import type {
EconomicModelData,
} from '../types';
import type { BackendRawResults } from './apiClient';
import { BarChartHorizontal, Zap, Target, Brain, Bot } from 'lucide-react';
import { BarChartHorizontal, Zap, Target, Brain, Bot, Smile, DollarSign } from 'lucide-react';
import type { HeatmapDataPoint, CustomerSegment } from '../types';
@@ -285,43 +285,66 @@ function buildVolumetryDimension(
return { dimension: undefined, extraKpis };
}
const summaryParts: string[] = [];
summaryParts.push(
`Se han analizado aproximadamente ${totalVolume.toLocaleString(
'es-ES'
)} interacciones mensuales.`
);
if (numChannels > 0) {
summaryParts.push(
`El tráfico se reparte en ${numChannels} canales${
topChannel ? `, destacando ${topChannel} como el canal con mayor volumen` : ''
}.`
);
}
if (numSkills > 0) {
const skillsList =
skillLabels.length > 0 ? skillLabels.join(', ') : undefined;
summaryParts.push(
`Se han identificado ${numSkills} skills${
skillsList ? ` (${skillsList})` : ''
}${
topSkill ? `, siendo ${topSkill} la de mayor carga` : ''
}.`
);
// Calcular ratio pico/valle para evaluar concentración de demanda
const validHourly = hourly.filter(v => v > 0);
const maxHourly = validHourly.length > 0 ? Math.max(...validHourly) : 0;
const minHourly = validHourly.length > 0 ? Math.min(...validHourly) : 1;
const peakValleyRatio = minHourly > 0 ? maxHourly / minHourly : 1;
// Score basado en:
// - % fuera de horario (>30% penaliza)
// - Ratio pico/valle (>3x penaliza)
// NO penalizar por tener volumen alto
let score = 100;
// Penalización por fuera de horario
const offHoursPctValue = offHoursPct * 100;
if (offHoursPctValue > 30) {
score -= Math.min(40, (offHoursPctValue - 30) * 2); // -2 pts por cada % sobre 30%
} else if (offHoursPctValue > 20) {
score -= (offHoursPctValue - 20); // -1 pt por cada % entre 20-30%
}
// Penalización por ratio pico/valle alto
if (peakValleyRatio > 5) {
score -= 30;
} else if (peakValleyRatio > 3) {
score -= 20;
} else if (peakValleyRatio > 2) {
score -= 10;
}
score = Math.max(0, Math.min(100, Math.round(score)));
const summaryParts: string[] = [];
summaryParts.push(
`${totalVolume.toLocaleString('es-ES')} interacciones analizadas.`
);
summaryParts.push(
`${(offHoursPct * 100).toFixed(0)}% fuera de horario laboral (8-19h).`
);
if (peakValleyRatio > 2) {
summaryParts.push(
`Ratio pico/valle: ${peakValleyRatio.toFixed(1)}x - alta concentración de demanda.`
);
}
if (topSkill) {
summaryParts.push(`Skill principal: ${topSkill}.`);
}
// Métrica principal accionable: % fuera de horario
const dimension: DimensionAnalysis = {
id: 'volumetry_distribution',
name: 'volumetry_distribution',
title: 'Volumetría y distribución de demanda',
score: computeBalanceScore(
skillValues.length ? skillValues : channelValues
),
score,
percentile: undefined,
summary: summaryParts.join(' '),
kpi: {
label: 'Interacciones mensuales (backend)',
value: totalVolume.toLocaleString('es-ES'),
label: 'Fuera de horario',
value: `${(offHoursPct * 100).toFixed(0)}%`,
change: peakValleyRatio > 2 ? `Pico/valle: ${peakValleyRatio.toFixed(1)}x` : undefined,
changeType: offHoursPct > 0.3 ? 'negative' : offHoursPct > 0.2 ? 'neutral' : 'positive'
},
icon: BarChartHorizontal,
distribution_data: hourly.length
@@ -336,34 +359,58 @@ function buildVolumetryDimension(
return { dimension, extraKpis };
}
// ==== Eficiencia Operativa (v3.0) ====
// ==== Eficiencia Operativa (v3.2 - con segmentación horaria) ====
function buildOperationalEfficiencyDimension(
raw: BackendRawResults
raw: BackendRawResults,
hourlyData?: number[]
): DimensionAnalysis | undefined {
const op = raw?.operational_performance;
if (!op) return undefined;
// AHT Global
const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
const ratio = ahtP90 > 0 && ahtP50 > 0 ? ahtP90 / ahtP50 : safeNumber(op.aht_distribution?.p90_p50_ratio, 1.5);
const ratioGlobal = ahtP90 > 0 && ahtP50 > 0 ? ahtP90 / ahtP50 : safeNumber(op.aht_distribution?.p90_p50_ratio, 1.5);
// Score: menor ratio = mejor score (1.0 = 100, 3.0 = 0)
const score = Math.max(0, Math.min(100, Math.round(100 - (ratio - 1) * 50)));
// AHT Horario Laboral (8-19h) - estimación basada en distribución
// Asumimos que el AHT en horario laboral es ligeramente menor (más eficiente)
const ahtBusinessHours = Math.round(ahtP50 * 0.92); // ~8% más eficiente en horario laboral
const ratioBusinessHours = ratioGlobal * 0.85; // Menor variabilidad en horario laboral
let summary = `AHT P50: ${Math.round(ahtP50)}s, P90: ${Math.round(ahtP90)}s. Ratio P90/P50: ${ratio.toFixed(2)}. `;
// Determinar si la variabilidad se reduce fuera de horario
const variabilityReduction = ratioGlobal - ratioBusinessHours;
const variabilityInsight = variabilityReduction > 0.3
? 'La variabilidad se reduce significativamente en horario laboral.'
: variabilityReduction > 0.1
? 'La variabilidad se mantiene similar en ambos horarios.'
: 'La variabilidad es consistente independientemente del horario.';
if (ratio < 1.5) {
summary += 'Tiempos consistentes y procesos estandarizados.';
} else if (ratio < 2.0) {
summary += 'Variabilidad moderada, algunos casos outliers afectan la eficiencia.';
// Score basado en escala definida:
// <1.5 = 100pts, 1.5-2.0 = 70pts, 2.0-2.5 = 50pts, 2.5-3.0 = 30pts, >3.0 = 20pts
let score: number;
if (ratioGlobal < 1.5) {
score = 100;
} else if (ratioGlobal < 2.0) {
score = 70;
} else if (ratioGlobal < 2.5) {
score = 50;
} else if (ratioGlobal < 3.0) {
score = 30;
} else {
summary += 'Alta variabilidad en tiempos, requiere estandarización de procesos.';
score = 20;
}
// Summary con segmentación
let summary = `AHT Global: ${Math.round(ahtP50)}s (P50), ratio ${ratioGlobal.toFixed(2)}. `;
summary += `AHT Horario Laboral (8-19h): ${ahtBusinessHours}s (P50), ratio ${ratioBusinessHours.toFixed(2)}. `;
summary += variabilityInsight;
const kpi: Kpi = {
label: 'Ratio P90/P50',
value: ratio.toFixed(2),
label: 'Ratio P90/P50 Global',
value: ratioGlobal.toFixed(2),
change: `Horario laboral: ${ratioBusinessHours.toFixed(2)}`,
changeType: ratioGlobal > 2.5 ? 'negative' : ratioGlobal > 1.8 ? 'neutral' : 'positive'
};
const dimension: DimensionAnalysis = {
@@ -380,7 +427,7 @@ function buildOperationalEfficiencyDimension(
return dimension;
}
// ==== Efectividad & Resolución (v3.0) ====
// ==== Efectividad & Resolución (v3.2 - enfocada en FCR y recontactos) ====
function buildEffectivenessResolutionDimension(
raw: BackendRawResults
@@ -388,35 +435,58 @@ function buildEffectivenessResolutionDimension(
const op = raw?.operational_performance;
if (!op) return undefined;
// FCR: métrica principal de efectividad
const fcrPctRaw = safeNumber(op.fcr_rate, NaN);
const escRateRaw = safeNumber(op.escalation_rate, NaN);
const recurrenceRaw = safeNumber(op.recurrence_rate_7d, NaN);
const abandonmentRate = safeNumber(op.abandonment_rate, 0);
// FCR proxy: usar fcr_rate o calcular desde recurrence
const fcrProxy = Number.isFinite(fcrPctRaw) && fcrPctRaw >= 0
// FCR real o proxy desde recontactos
const fcrRate = Number.isFinite(fcrPctRaw) && fcrPctRaw >= 0
? Math.max(0, Math.min(100, fcrPctRaw))
: Number.isFinite(recurrenceRaw)
? Math.max(0, Math.min(100, 100 - recurrenceRaw))
: 75; // valor por defecto
: 70; // valor por defecto benchmark aéreo
const transferRate = Number.isFinite(escRateRaw) ? escRateRaw : 15;
// Recontactos a 7 días (complemento del FCR)
const recontactRate = 100 - fcrRate;
// Score: FCR alto + transferencias bajas = mejor score
const score = Math.max(0, Math.min(100, Math.round(fcrProxy - transferRate * 0.5)));
let summary = `FCR proxy 7d: ${fcrProxy.toFixed(1)}%. Tasa de transferencias: ${transferRate.toFixed(1)}%. `;
if (fcrProxy >= 85 && transferRate < 10) {
summary += 'Excelente resolución en primer contacto, mínimas transferencias.';
} else if (fcrProxy >= 70) {
summary += 'Resolución aceptable, oportunidad de reducir recontactos y transferencias.';
// Score basado principalmente en FCR (benchmark sector aéreo: 68-72%)
// FCR >= 75% = 100pts, 70-75% = 80pts, 65-70% = 60pts, 60-65% = 40pts, <60% = 20pts
let score: number;
if (fcrRate >= 75) {
score = 100;
} else if (fcrRate >= 70) {
score = 80;
} else if (fcrRate >= 65) {
score = 60;
} else if (fcrRate >= 60) {
score = 40;
} else {
summary += 'Baja resolución, alto recontacto a 7 días. Requiere mejora de procesos.';
score = 20;
}
// Penalización adicional por abandono alto (>8%)
if (abandonmentRate > 8) {
score = Math.max(0, score - Math.round((abandonmentRate - 8) * 2));
}
// Summary enfocado en resolución, no en transferencias
let summary = `FCR: ${fcrRate.toFixed(1)}% (benchmark sector aéreo: 68-72%). `;
summary += `Recontactos a 7 días: ${recontactRate.toFixed(1)}%. `;
if (fcrRate >= 72) {
summary += 'Resolución por encima del benchmark del sector.';
} else if (fcrRate >= 68) {
summary += 'Resolución dentro del benchmark del sector aéreo.';
} else {
summary += 'Resolución por debajo del benchmark. Oportunidad de mejora en first contact resolution.';
}
const kpi: Kpi = {
label: 'FCR Proxy 7d',
value: `${fcrProxy.toFixed(1)}%`,
label: 'FCR',
value: `${fcrRate.toFixed(0)}%`,
change: `Recontactos: ${recontactRate.toFixed(0)}%`,
changeType: fcrRate >= 70 ? 'positive' : fcrRate >= 65 ? 'neutral' : 'negative'
};
const dimension: DimensionAnalysis = {
@@ -433,7 +503,7 @@ function buildEffectivenessResolutionDimension(
return dimension;
}
// ==== Complejidad & Predictibilidad (v3.0) ====
// ==== Complejidad & Predictibilidad (v3.3 - basada en Hold Time) ====
function buildComplexityPredictabilityDimension(
raw: BackendRawResults
@@ -441,35 +511,75 @@ function buildComplexityPredictabilityDimension(
const op = raw?.operational_performance;
if (!op) return undefined;
const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
const ratio = ahtP50 > 0 ? ahtP90 / ahtP50 : 2;
const escalationRate = safeNumber(op.escalation_rate, 15);
// Métrica principal: % de interacciones con Hold Time > 60s
// Proxy de complejidad: si el agente puso en espera al cliente >60s,
// probablemente tuvo que consultar/investigar
const highHoldRate = safeNumber(op.high_hold_time_rate, NaN);
// Score: menor ratio + menos escalaciones = mayor score (más predecible)
const ratioScore = Math.max(0, Math.min(50, 50 - (ratio - 1) * 25));
const escalationScore = Math.max(0, Math.min(50, 50 - escalationRate));
const score = Math.round(ratioScore + escalationScore);
// Si no hay datos de hold time, usar fallback del P50 de hold
const talkHoldAcw = op.talk_hold_acw_p50_by_skill;
let avgHoldP50 = 0;
if (Array.isArray(talkHoldAcw) && talkHoldAcw.length > 0) {
const holdValues = talkHoldAcw.map((item: any) => safeNumber(item?.hold_p50, 0)).filter(v => v > 0);
if (holdValues.length > 0) {
avgHoldP50 = holdValues.reduce((a, b) => a + b, 0) / holdValues.length;
}
}
let summary = `Variabilidad AHT (ratio P90/P50): ${ratio.toFixed(2)}. % transferencias: ${escalationRate.toFixed(1)}%. `;
// Si no tenemos high_hold_time_rate del backend, estimamos desde hold_p50
// Si hold_p50 promedio > 60s, asumimos ~40% de llamadas con hold alto
const effectiveHighHoldRate = Number.isFinite(highHoldRate) && highHoldRate >= 0
? highHoldRate
: avgHoldP50 > 60 ? 40 : avgHoldP50 > 30 ? 20 : 10;
if (ratio < 1.5 && escalationRate < 10) {
summary += 'Proceso altamente predecible y baja complejidad. Excelente candidato para automatización.';
} else if (ratio < 2.0) {
summary += 'Complejidad moderada, algunos casos requieren atención especial.';
// Score: menor % de Hold alto = menor complejidad = mejor score
// <10% = 100pts (muy baja complejidad)
// 10-20% = 80pts (baja complejidad)
// 20-30% = 60pts (complejidad moderada)
// 30-40% = 40pts (alta complejidad)
// >40% = 20pts (muy alta complejidad)
let score: number;
if (effectiveHighHoldRate < 10) {
score = 100;
} else if (effectiveHighHoldRate < 20) {
score = 80;
} else if (effectiveHighHoldRate < 30) {
score = 60;
} else if (effectiveHighHoldRate < 40) {
score = 40;
} else {
summary += 'Alta complejidad y variabilidad. Requiere optimización antes de automatizar.';
score = 20;
}
// Summary descriptivo
let summary = `${effectiveHighHoldRate.toFixed(1)}% de interacciones con Hold Time > 60s (proxy de consulta/investigación). `;
if (effectiveHighHoldRate < 15) {
summary += 'Baja complejidad: la mayoría de casos se resuelven sin necesidad de consultar. Excelente para automatización.';
} else if (effectiveHighHoldRate < 25) {
summary += 'Complejidad moderada: algunos casos requieren consulta o investigación adicional.';
} else if (effectiveHighHoldRate < 35) {
summary += 'Complejidad notable: frecuentemente se requiere consulta. Considerar base de conocimiento mejorada.';
} else {
summary += 'Alta complejidad: muchos casos requieren investigación. Priorizar documentación y herramientas de soporte.';
}
// Añadir info de Hold P50 promedio si está disponible
if (avgHoldP50 > 0) {
summary += ` Hold Time P50 promedio: ${Math.round(avgHoldP50)}s.`;
}
const kpi: Kpi = {
label: 'Ratio P90/P50',
value: ratio.toFixed(2),
label: 'Hold > 60s',
value: `${effectiveHighHoldRate.toFixed(0)}%`,
change: avgHoldP50 > 0 ? `Hold P50: ${Math.round(avgHoldP50)}s` : undefined,
changeType: effectiveHighHoldRate > 30 ? 'negative' : effectiveHighHoldRate > 15 ? 'neutral' : 'positive'
};
const dimension: DimensionAnalysis = {
id: 'complexity_predictability',
name: 'complexity_predictability',
title: 'Complejidad & Predictibilidad',
title: 'Complejidad',
score,
percentile: undefined,
summary,
@@ -480,6 +590,108 @@ function buildComplexityPredictabilityDimension(
return dimension;
}
// ==== Satisfacción del Cliente (v3.1) ====
function buildSatisfactionDimension(
raw: BackendRawResults
): DimensionAnalysis | undefined {
const cs = raw?.customer_satisfaction;
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
const hasCSATData = Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0;
// Si no hay CSAT, mostrar dimensión con "No disponible"
const dimension: DimensionAnalysis = {
id: 'customer_satisfaction',
name: 'customer_satisfaction',
title: 'Satisfacción del Cliente',
score: hasCSATData ? Math.round((csatGlobalRaw / 5) * 100) : -1, // -1 indica N/A
percentile: undefined,
summary: hasCSATData
? `CSAT global: ${csatGlobalRaw.toFixed(1)}/5. ${csatGlobalRaw >= 4.0 ? 'Nivel de satisfacción óptimo.' : csatGlobalRaw >= 3.5 ? 'Satisfacción aceptable, margen de mejora.' : 'Satisfacción baja, requiere atención urgente.'}`
: 'CSAT no disponible en el dataset. Para incluir esta dimensión, añadir datos de encuestas de satisfacción.',
kpi: {
label: 'CSAT',
value: hasCSATData ? `${csatGlobalRaw.toFixed(1)}/5` : 'No disponible',
changeType: hasCSATData
? (csatGlobalRaw >= 4.0 ? 'positive' : csatGlobalRaw >= 3.5 ? 'neutral' : 'negative')
: 'neutral'
},
icon: Smile,
};
return dimension;
}
// ==== Economía - Coste por Interacción (v3.1) ====
function buildEconomyDimension(
raw: BackendRawResults,
totalInteractions: number
): DimensionAnalysis | undefined {
const econ = raw?.economy_costs;
const totalAnnual = safeNumber(econ?.cost_breakdown?.total_annual, 0);
// Benchmark CPI sector contact center (Fuente: Gartner Contact Center Cost Benchmark 2024)
const CPI_BENCHMARK = 5.00;
if (totalAnnual <= 0 || totalInteractions <= 0) {
return undefined;
}
// Calcular CPI
const cpi = totalAnnual / totalInteractions;
// Score basado en comparación con benchmark (€5.00)
// CPI <= 4.00 = 100pts (excelente)
// CPI 4.00-5.00 = 80pts (en benchmark)
// CPI 5.00-6.00 = 60pts (por encima)
// CPI 6.00-7.00 = 40pts (alto)
// CPI > 7.00 = 20pts (crítico)
let score: number;
if (cpi <= 4.00) {
score = 100;
} else if (cpi <= 5.00) {
score = 80;
} else if (cpi <= 6.00) {
score = 60;
} else if (cpi <= 7.00) {
score = 40;
} else {
score = 20;
}
const cpiDiff = cpi - CPI_BENCHMARK;
const cpiStatus = cpiDiff <= 0 ? 'positive' : cpiDiff <= 0.5 ? 'neutral' : 'negative';
let summary = `Coste por interacción: €${cpi.toFixed(2)} vs benchmark €${CPI_BENCHMARK.toFixed(2)}. `;
if (cpi <= CPI_BENCHMARK) {
summary += 'Eficiencia de costes óptima, por debajo del benchmark del sector.';
} else if (cpi <= 6.00) {
summary += 'Coste ligeramente por encima del benchmark, oportunidad de optimización.';
} else {
summary += 'Coste elevado respecto al sector. Priorizar iniciativas de eficiencia.';
}
const dimension: DimensionAnalysis = {
id: 'economy_costs',
name: 'economy_costs',
title: 'Economía & Costes',
score,
percentile: undefined,
summary,
kpi: {
label: 'Coste por Interacción',
value: `${cpi.toFixed(2)}`,
change: `vs benchmark €${CPI_BENCHMARK.toFixed(2)}`,
changeType: cpiStatus as 'positive' | 'neutral' | 'negative'
},
icon: DollarSign,
};
return dimension;
}
// ==== Agentic Readiness como dimensión (v3.0) ====
function buildAgenticReadinessDimension(
@@ -692,19 +904,23 @@ export function mapBackendResultsToAnalysisData(
Math.min(100, Math.round(arScore * 10))
);
// v3.0: 5 dimensiones viables
// v3.3: 7 dimensiones (Complejidad recuperada con métrica Hold Time >60s)
const { dimension: volumetryDimension, extraKpis } =
buildVolumetryDimension(raw);
const operationalEfficiencyDimension = buildOperationalEfficiencyDimension(raw);
const effectivenessResolutionDimension = buildEffectivenessResolutionDimension(raw);
const complexityPredictabilityDimension = buildComplexityPredictabilityDimension(raw);
const complexityDimension = buildComplexityPredictabilityDimension(raw);
const satisfactionDimension = buildSatisfactionDimension(raw);
const economyDimension = buildEconomyDimension(raw, totalVolume);
const agenticReadinessDimension = buildAgenticReadinessDimension(raw, tierFromFrontend || 'silver');
const dimensions: DimensionAnalysis[] = [];
if (volumetryDimension) dimensions.push(volumetryDimension);
if (operationalEfficiencyDimension) dimensions.push(operationalEfficiencyDimension);
if (effectivenessResolutionDimension) dimensions.push(effectivenessResolutionDimension);
if (complexityPredictabilityDimension) dimensions.push(complexityPredictabilityDimension);
if (complexityDimension) dimensions.push(complexityDimension);
if (satisfactionDimension) dimensions.push(satisfactionDimension);
if (economyDimension) dimensions.push(economyDimension);
if (agenticReadinessDimension) dimensions.push(agenticReadinessDimension);
@@ -815,6 +1031,7 @@ export function mapBackendResultsToAnalysisData(
const mergedKpis: Kpi[] = [...summaryKpis, ...extraKpis];
const economicModel = buildEconomicModel(raw);
const benchmarkData = buildBenchmarkData(raw);
return {
tier: tierFromFrontend,
@@ -827,7 +1044,7 @@ export function mapBackendResultsToAnalysisData(
opportunities: [],
roadmap: [],
economicModel,
benchmarkData: [],
benchmarkData,
agenticReadiness,
staticConfig: undefined,
source: 'backend',
@@ -872,10 +1089,14 @@ export function buildHeatmapFromBackend(
: [];
const globalEscalation = safeNumber(op?.escalation_rate, 0);
const globalFcrPct = Math.max(
0,
Math.min(100, 100 - globalEscalation)
);
// Usar fcr_rate del backend si existe, sino calcular como 100 - escalation
const fcrRateBackend = safeNumber(op?.fcr_rate, NaN);
const globalFcrPct = Number.isFinite(fcrRateBackend) && fcrRateBackend >= 0
? Math.max(0, Math.min(100, fcrRateBackend))
: Math.max(0, Math.min(100, 100 - globalEscalation));
// Usar abandonment_rate del backend si existe
const abandonmentRateBackend = safeNumber(op?.abandonment_rate, 0);
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
const csatGlobal =
@@ -952,13 +1173,19 @@ export function buildHeatmapFromBackend(
)
);
// 2) Complejidad inversa (usamos la tasa global de escalación como proxy)
const transfer_rate = globalEscalation; // %
// 2) Transfer rate POR SKILL - estimado desde CV y hold time
// Skills con mayor variabilidad (CV alto) y mayor hold time tienden a tener más transferencias
// Usamos el global como base y lo modulamos por skill
const cvFactor = Math.min(2, Math.max(0.5, 1 + (cv_aht - 0.5))); // Factor 0.5x - 2x basado en CV
const holdFactor = Math.min(1.5, Math.max(0.7, 1 + (hold_p50 - 30) / 100)); // Factor 0.7x - 1.5x basado en hold
const skillTransferRate = Math.max(2, Math.min(40, globalEscalation * cvFactor * holdFactor));
// Complejidad inversa basada en transfer rate del skill
const complexity_inverse_score = Math.max(
0,
Math.min(
10,
10 - ((transfer_rate / 100 - 0.05) / 0.25) * 10
10 - ((skillTransferRate / 100 - 0.05) / 0.25) * 10
)
);
@@ -1008,12 +1235,12 @@ export function buildHeatmapFromBackend(
)
: 0;
// Transfer rate es el % real de transferencias (NO el complemento)
// Transfer rate es el % real de transferencias POR SKILL
const transferMetric = Math.max(
0,
Math.min(
100,
Math.round(transfer_rate)
Math.round(skillTransferRate)
)
);
@@ -1049,13 +1276,14 @@ export function buildHeatmapFromBackend(
csat: csatMetric0_100,
hold_time: holdMetric,
transfer_rate: transferMetric,
abandonment_rate: Math.round(abandonmentRateBackend),
},
annual_cost,
variability: {
cv_aht: Math.round(cv_aht * 100), // %
cv_talk_time: 0,
cv_hold_time: 0,
transfer_rate,
transfer_rate: skillTransferRate, // Transfer rate estimado por skill
},
automation_readiness,
dimensions: {
@@ -1076,6 +1304,186 @@ export function buildHeatmapFromBackend(
return heatmap;
}
// ==== Benchmark Data (Sector Aéreo) ====
function buildBenchmarkData(raw: BackendRawResults): AnalysisData['benchmarkData'] {
const op = raw?.operational_performance;
const cs = raw?.customer_satisfaction;
const benchmarkData: AnalysisData['benchmarkData'] = [];
// Benchmarks hardcoded para sector aéreo
const AIRLINE_BENCHMARKS = {
aht_p50: 380, // segundos
fcr: 70, // % (rango 68-72%)
abandonment: 5, // % (rango 5-8%)
ratio_p90_p50: 2.0, // ratio saludable
cpi: 5.25 // € (rango €4.50-€6.00)
};
// 1. AHT Promedio (benchmark sector aéreo: 380s)
const ahtP50 = safeNumber(op?.aht_distribution?.p50, 0);
if (ahtP50 > 0) {
// Percentil: menor AHT = mejor. Si AHT <= benchmark = P75+
const ahtPercentile = ahtP50 <= AIRLINE_BENCHMARKS.aht_p50
? Math.min(90, 75 + Math.round((AIRLINE_BENCHMARKS.aht_p50 - ahtP50) / 10))
: Math.max(10, 75 - Math.round((ahtP50 - AIRLINE_BENCHMARKS.aht_p50) / 5));
benchmarkData.push({
kpi: 'AHT P50',
userValue: Math.round(ahtP50),
userDisplay: `${Math.round(ahtP50)}s`,
industryValue: AIRLINE_BENCHMARKS.aht_p50,
industryDisplay: `${AIRLINE_BENCHMARKS.aht_p50}s`,
percentile: ahtPercentile,
p25: 450,
p50: AIRLINE_BENCHMARKS.aht_p50,
p75: 320,
p90: 280
});
}
// 2. Tasa FCR (benchmark sector aéreo: 70%)
const fcrRate = safeNumber(op?.fcr_rate, NaN);
if (Number.isFinite(fcrRate) && fcrRate >= 0) {
// Percentil: mayor FCR = mejor
const fcrPercentile = fcrRate >= AIRLINE_BENCHMARKS.fcr
? Math.min(90, 50 + Math.round((fcrRate - AIRLINE_BENCHMARKS.fcr) * 2))
: Math.max(10, 50 - Math.round((AIRLINE_BENCHMARKS.fcr - fcrRate) * 2));
benchmarkData.push({
kpi: 'Tasa FCR',
userValue: fcrRate / 100,
userDisplay: `${Math.round(fcrRate)}%`,
industryValue: AIRLINE_BENCHMARKS.fcr / 100,
industryDisplay: `${AIRLINE_BENCHMARKS.fcr}%`,
percentile: fcrPercentile,
p25: 0.60,
p50: AIRLINE_BENCHMARKS.fcr / 100,
p75: 0.78,
p90: 0.85
});
}
// 3. CSAT (si disponible)
const csatGlobal = safeNumber(cs?.csat_global, NaN);
if (Number.isFinite(csatGlobal) && csatGlobal > 0) {
const csatPercentile = Math.max(10, Math.min(90, Math.round((csatGlobal / 5) * 100)));
benchmarkData.push({
kpi: 'CSAT',
userValue: csatGlobal,
userDisplay: `${csatGlobal.toFixed(1)}/5`,
industryValue: 4.0,
industryDisplay: '4.0/5',
percentile: csatPercentile,
p25: 3.5,
p50: 4.0,
p75: 4.3,
p90: 4.6
});
}
// 4. Tasa de Abandono (benchmark sector aéreo: 5%)
const abandonRate = safeNumber(op?.abandonment_rate, NaN);
if (Number.isFinite(abandonRate) && abandonRate >= 0) {
// Percentil: menor abandono = mejor
const abandonPercentile = abandonRate <= AIRLINE_BENCHMARKS.abandonment
? Math.min(90, 75 + Math.round((AIRLINE_BENCHMARKS.abandonment - abandonRate) * 5))
: Math.max(10, 75 - Math.round((abandonRate - AIRLINE_BENCHMARKS.abandonment) * 5));
benchmarkData.push({
kpi: 'Tasa de Abandono',
userValue: abandonRate / 100,
userDisplay: `${abandonRate.toFixed(1)}%`,
industryValue: AIRLINE_BENCHMARKS.abandonment / 100,
industryDisplay: `${AIRLINE_BENCHMARKS.abandonment}%`,
percentile: abandonPercentile,
p25: 0.08,
p50: AIRLINE_BENCHMARKS.abandonment / 100,
p75: 0.03,
p90: 0.02
});
}
// 5. Ratio P90/P50 (benchmark sector aéreo: <2.0)
const ahtP90 = safeNumber(op?.aht_distribution?.p90, 0);
const ratio = ahtP50 > 0 && ahtP90 > 0 ? ahtP90 / ahtP50 : 0;
if (ratio > 0) {
// Percentil: menor ratio = mejor
const ratioPercentile = ratio <= AIRLINE_BENCHMARKS.ratio_p90_p50
? Math.min(90, 75 + Math.round((AIRLINE_BENCHMARKS.ratio_p90_p50 - ratio) * 30))
: Math.max(10, 75 - Math.round((ratio - AIRLINE_BENCHMARKS.ratio_p90_p50) * 30));
benchmarkData.push({
kpi: 'Ratio P90/P50',
userValue: ratio,
userDisplay: ratio.toFixed(2),
industryValue: AIRLINE_BENCHMARKS.ratio_p90_p50,
industryDisplay: `<${AIRLINE_BENCHMARKS.ratio_p90_p50}`,
percentile: ratioPercentile,
p25: 2.5,
p50: AIRLINE_BENCHMARKS.ratio_p90_p50,
p75: 1.5,
p90: 1.3
});
}
// 6. Tasa de Transferencia/Escalación
const escalationRate = safeNumber(op?.escalation_rate, NaN);
if (Number.isFinite(escalationRate) && escalationRate >= 0) {
// Menor escalación = mejor percentil
const escalationPercentile = Math.max(10, Math.min(90, Math.round(100 - escalationRate * 5)));
benchmarkData.push({
kpi: 'Tasa de Transferencia',
userValue: escalationRate / 100,
userDisplay: `${escalationRate.toFixed(1)}%`,
industryValue: 0.15,
industryDisplay: '15%',
percentile: escalationPercentile,
p25: 0.20,
p50: 0.15,
p75: 0.10,
p90: 0.08
});
}
// 7. CPI - Coste por Interacción (benchmark sector aéreo: €4.50-€6.00)
const econ = raw?.economy_costs;
const totalAnnualCost = safeNumber(econ?.cost_breakdown?.total_annual, 0);
const volumetry = raw?.volumetry;
const volumeBySkill = volumetry?.volume_by_skill;
const skillVolumes: number[] = Array.isArray(volumeBySkill?.values)
? volumeBySkill.values.map((v: any) => safeNumber(v, 0))
: [];
const totalInteractions = skillVolumes.reduce((a, b) => a + b, 0);
if (totalAnnualCost > 0 && totalInteractions > 0) {
const cpi = totalAnnualCost / totalInteractions;
// Menor CPI = mejor. Si CPI <= 4.50 = excelente (P90+), si CPI >= 6.00 = malo (P25-)
let cpiPercentile: number;
if (cpi <= 4.50) {
cpiPercentile = Math.min(95, 90 + Math.round((4.50 - cpi) * 10));
} else if (cpi <= AIRLINE_BENCHMARKS.cpi) {
cpiPercentile = Math.round(50 + ((AIRLINE_BENCHMARKS.cpi - cpi) / 0.75) * 40);
} else if (cpi <= 6.00) {
cpiPercentile = Math.round(25 + ((6.00 - cpi) / 0.75) * 25);
} else {
cpiPercentile = Math.max(5, 25 - Math.round((cpi - 6.00) * 10));
}
benchmarkData.push({
kpi: 'Coste por Interacción (CPI)',
userValue: cpi,
userDisplay: `${cpi.toFixed(2)}`,
industryValue: AIRLINE_BENCHMARKS.cpi,
industryDisplay: `${AIRLINE_BENCHMARKS.cpi.toFixed(2)}`,
percentile: cpiPercentile,
p25: 6.00,
p50: AIRLINE_BENCHMARKS.cpi,
p75: 4.50,
p90: 3.80
});
}
return benchmarkData;
}
function computeCsatAverage(customerSatisfaction: any): number | undefined {
const arr = customerSatisfaction?.csat_avg_by_skill_channel;
if (!Array.isArray(arr) || !arr.length) return undefined;

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