feat: Add Law 10/2025 compliance analysis tab

- Add new Law10Tab with compliance analysis for Spanish Law 10/2025
- Sections: LAW-01 (Response Speed), LAW-02 (Resolution Quality), LAW-07 (Time Coverage)
- Add Data Maturity Summary showing available/estimable/missing data
- Add Validation Questionnaire for manual data input
- Add Dimension Connections linking to other analysis tabs
- Fix KPI consistency: use correct field names (abandonment_rate, aht_seconds)
- Fix cache directory path for Windows compatibility
- Update economic calculations to use actual economicModel data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
sujucu70
2026-01-22 21:58:26 +01:00
parent 62454c6b6a
commit 88d7e4c10d
20 changed files with 5554 additions and 1285 deletions

103
CLAUDE.md Normal file
View File

@@ -0,0 +1,103 @@
# CLAUDE.md - Beyond CX Analytics
## Project Overview
Beyond CX Analytics is a Contact Center Analytics Platform that analyzes operational data and provides AI-assisted insights. The application processes CSV data from contact centers to generate volumetry analysis, performance metrics, CSAT scores, economic models, and automation readiness scoring.
## Tech Stack
**Frontend:** React 19 + TypeScript + Vite
**Backend:** Python 3.11 + FastAPI
**Infrastructure:** Docker Compose + Nginx
**Charts:** Recharts
**UI Components:** Radix UI + Lucide React
**Data Processing:** Pandas, NumPy
**AI Integration:** OpenAI API
## Project Structure
```
BeyondCXAnalytics_AE/
├── backend/
│ ├── beyond_api/ # FastAPI REST API
│ ├── beyond_metrics/ # Core metrics calculation library
│ ├── beyond_flows/ # AI agents and scoring engines
│ └── tests/ # pytest test suite
├── frontend/
│ ├── components/ # React components
│ ├── utils/ # Utility functions and API client
│ └── styles/ # CSS and color definitions
├── nginx/ # Reverse proxy configuration
└── docker-compose.yml # Service orchestration
```
## Common Commands
### Frontend
```bash
cd frontend
npm install # Install dependencies
npm run dev # Start dev server (port 3000)
npm run build # Production build
npm run preview # Preview production build
```
### Backend
```bash
cd backend
pip install . # Install from pyproject.toml
python -m pytest tests/ # Run tests
uvicorn beyond_api.main:app --reload # Start dev server
```
### Docker
```bash
docker compose build # Build all services
docker compose up -d # Start all services
docker compose down # Stop all services
docker compose logs -f # Stream logs
```
### Deployment
```bash
./deploy.sh # Redeploy containers
sudo ./install_beyond.sh # Full server installation
```
## Key Entry Points
| Component | File |
|-----------|------|
| Frontend App | `frontend/App.tsx` |
| Backend API | `backend/beyond_api/main.py` |
| Main Endpoint | `POST /analysis` |
| Metrics Engine | `backend/beyond_metrics/agent.py` |
| AI Agents | `backend/beyond_flows/agents/` |
## Architecture
- **4 Analytics Dimensions:** Volumetry, Operational Performance, Satisfaction/Experience, Economy/Cost
- **Data Flow:** CSV Upload → FastAPI → Metrics Pipeline → AI Agents → JSON Response → React Dashboard
- **Authentication:** Basic Auth middleware
## Code Style Notes
- Documentation and comments are in **Spanish**
- Follow existing patterns when adding new components
- Frontend uses functional components with hooks
- Backend follows FastAPI conventions with Pydantic models
## Git Workflow
- **Main branch:** `main`
- **Development branch:** `desarrollo`
- Create feature branches from `desarrollo`
## Environment Variables
Backend expects:
- `OPENAI_API_KEY` - For AI-powered analysis
- `BASIC_AUTH_USER` / `BASIC_AUTH_PASS` - API authentication
Frontend expects:
- `VITE_API_BASE_URL` - API endpoint (default: `/api`)

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
import json import json
import os import os
import shutil import shutil
import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -23,12 +24,38 @@ router = APIRouter(
tags=["cache"], tags=["cache"],
) )
# Directory for cache files # Directory for cache files - use platform-appropriate default
CACHE_DIR = Path(os.getenv("CACHE_DIR", "/data/cache")) def _get_default_cache_dir() -> Path:
"""Get a platform-appropriate default cache directory."""
env_cache_dir = os.getenv("CACHE_DIR")
if env_cache_dir:
return Path(env_cache_dir)
# On Windows, check if C:/data/cache exists (legacy location)
# Otherwise use a local .cache directory relative to the backend
# On Unix/Docker, use /data/cache
if sys.platform == "win32":
# Check legacy location first (for backwards compatibility)
legacy_cache = Path("C:/data/cache")
if legacy_cache.exists():
return legacy_cache
# Fallback to local .cache directory in the backend folder
backend_dir = Path(__file__).parent.parent.parent
return backend_dir / ".cache"
else:
return Path("/data/cache")
CACHE_DIR = _get_default_cache_dir()
CACHED_FILE = CACHE_DIR / "cached_data.csv" CACHED_FILE = CACHE_DIR / "cached_data.csv"
METADATA_FILE = CACHE_DIR / "metadata.json" METADATA_FILE = CACHE_DIR / "metadata.json"
DRILLDOWN_FILE = CACHE_DIR / "drilldown_data.json" DRILLDOWN_FILE = CACHE_DIR / "drilldown_data.json"
# Log cache directory on module load
import logging
logger = logging.getLogger(__name__)
logger.info(f"[Cache] Using cache directory: {CACHE_DIR}")
logger.info(f"[Cache] Drilldown file path: {DRILLDOWN_FILE}")
class CacheMetadata(BaseModel): class CacheMetadata(BaseModel):
fileName: str fileName: str
@@ -158,7 +185,11 @@ def get_cached_drilldown(current_user: str = Depends(get_current_user)):
Get the cached drilldownData JSON. Get the cached drilldownData JSON.
Returns the pre-calculated drilldown data for fast cache usage. Returns the pre-calculated drilldown data for fast cache usage.
""" """
logger.info(f"[Cache] GET /drilldown - checking file: {DRILLDOWN_FILE}")
logger.info(f"[Cache] File exists: {DRILLDOWN_FILE.exists()}")
if not DRILLDOWN_FILE.exists(): if not DRILLDOWN_FILE.exists():
logger.warning(f"[Cache] Drilldown file not found at: {DRILLDOWN_FILE}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="No cached drilldown data found" detail="No cached drilldown data found"
@@ -167,8 +198,10 @@ def get_cached_drilldown(current_user: str = Depends(get_current_user)):
try: try:
with open(DRILLDOWN_FILE, "r", encoding="utf-8") as f: with open(DRILLDOWN_FILE, "r", encoding="utf-8") as f:
drilldown_data = json.load(f) drilldown_data = json.load(f)
logger.info(f"[Cache] Loaded drilldown with {len(drilldown_data)} skills")
return JSONResponse(content={"success": True, "drilldownData": drilldown_data}) return JSONResponse(content={"success": True, "drilldownData": drilldown_data})
except Exception as e: except Exception as e:
logger.error(f"[Cache] Error reading drilldown: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error reading drilldown data: {str(e)}" detail=f"Error reading drilldown data: {str(e)}"
@@ -185,16 +218,21 @@ async def save_cached_drilldown(
Called by frontend after calculating drilldown from uploaded file. Called by frontend after calculating drilldown from uploaded file.
Receives JSON as form field. Receives JSON as form field.
""" """
logger.info(f"[Cache] POST /drilldown - saving to: {DRILLDOWN_FILE}")
logger.info(f"[Cache] Cache directory: {CACHE_DIR}")
ensure_cache_dir() ensure_cache_dir()
logger.info(f"[Cache] Cache dir exists after ensure: {CACHE_DIR.exists()}")
try: try:
# Parse and validate JSON # Parse and validate JSON
drilldown_data = json.loads(drilldown_json) drilldown_data = json.loads(drilldown_json)
logger.info(f"[Cache] Parsed drilldown JSON with {len(drilldown_data)} skills")
# Save to file # Save to file
with open(DRILLDOWN_FILE, "w", encoding="utf-8") as f: with open(DRILLDOWN_FILE, "w", encoding="utf-8") as f:
json.dump(drilldown_data, f) json.dump(drilldown_data, f)
logger.info(f"[Cache] Drilldown saved successfully, file exists: {DRILLDOWN_FILE.exists()}")
return JSONResponse(content={ return JSONResponse(content={
"success": True, "success": True,
"message": f"Cached drilldown data with {len(drilldown_data)} skills" "message": f"Cached drilldown data with {len(drilldown_data)} skills"

View File

@@ -19,7 +19,9 @@ app = FastAPI()
origins = [ origins = [
"http://localhost:3000", "http://localhost:3000",
"http://localhost:3001",
"http://127.0.0.1:3000", "http://127.0.0.1:3000",
"http://127.0.0.1:3001",
] ]
app.add_middleware( app.add_middleware(

View File

@@ -20,6 +20,7 @@
"metrics": [ "metrics": [
"aht_distribution", "aht_distribution",
"talk_hold_acw_p50_by_skill", "talk_hold_acw_p50_by_skill",
"metrics_by_skill",
"fcr_rate", "fcr_rate",
"escalation_rate", "escalation_rate",
"abandonment_rate", "abandonment_rate",

View File

@@ -99,6 +99,15 @@ class EconomyCostMetrics:
+ df["wrap_up_time"].fillna(0) + df["wrap_up_time"].fillna(0)
) # segundos ) # segundos
# Filtrar por record_status para cálculos de AHT/CPI
# Solo incluir registros VALID (excluir NOISE, ZOMBIE, ABANDON)
if "record_status" in df.columns:
df["record_status"] = df["record_status"].astype(str).str.strip().str.upper()
df["_is_valid_for_cost"] = df["record_status"] == "VALID"
else:
# Legacy data sin record_status: incluir todo
df["_is_valid_for_cost"] = True
self.df = df self.df = df
@property @property
@@ -115,12 +124,19 @@ class EconomyCostMetrics:
""" """
CPI (Coste Por Interacción) por skill/canal. CPI (Coste Por Interacción) por skill/canal.
CPI = Labor_cost_per_interaction + Overhead_variable CPI = (Labor_cost_per_interaction + Overhead_variable) / EFFECTIVE_PRODUCTIVITY
- Labor_cost_per_interaction = (labor_cost_per_hour * AHT_hours) - Labor_cost_per_interaction = (labor_cost_per_hour * AHT_hours)
- Overhead_variable = overhead_rate * Labor_cost_per_interaction - Overhead_variable = overhead_rate * Labor_cost_per_interaction
- EFFECTIVE_PRODUCTIVITY = 0.70 (70% - accounts for non-productive time)
Excluye registros abandonados del cálculo de costes para consistencia
con el path del frontend (fresh CSV).
Si no hay config de costes -> devuelve DataFrame vacío. Si no hay config de costes -> devuelve DataFrame vacío.
Incluye queue_skill y channel como columnas (no solo índice) para que
el frontend pueda hacer lookup por nombre de skill.
""" """
if not self._has_cost_config(): if not self._has_cost_config():
return pd.DataFrame() return pd.DataFrame()
@@ -132,8 +148,22 @@ class EconomyCostMetrics:
if df.empty: if df.empty:
return pd.DataFrame() return pd.DataFrame()
# AHT por skill/canal (en segundos) # Filter out abandonments for cost calculation (consistency with frontend)
grouped = df.groupby(["queue_skill", "channel"])["handle_time"].mean() if "is_abandoned" in df.columns:
df_cost = df[df["is_abandoned"] != True]
else:
df_cost = df
# Filtrar por record_status: solo VALID para cálculo de AHT
# Excluye NOISE, ZOMBIE, ABANDON
if "_is_valid_for_cost" in df_cost.columns:
df_cost = df_cost[df_cost["_is_valid_for_cost"] == True]
if df_cost.empty:
return pd.DataFrame()
# AHT por skill/canal (en segundos) - solo registros VALID
grouped = df_cost.groupby(["queue_skill", "channel"])["handle_time"].mean()
if grouped.empty: if grouped.empty:
return pd.DataFrame() return pd.DataFrame()
@@ -141,9 +171,14 @@ class EconomyCostMetrics:
aht_sec = grouped aht_sec = grouped
aht_hours = aht_sec / 3600.0 aht_hours = aht_sec / 3600.0
# Apply productivity factor (70% effectiveness)
# This accounts for non-productive agent time (breaks, training, etc.)
EFFECTIVE_PRODUCTIVITY = 0.70
labor_cost = cfg.labor_cost_per_hour * aht_hours labor_cost = cfg.labor_cost_per_hour * aht_hours
overhead = labor_cost * cfg.overhead_rate overhead = labor_cost * cfg.overhead_rate
cpi = labor_cost + overhead raw_cpi = labor_cost + overhead
cpi = raw_cpi / EFFECTIVE_PRODUCTIVITY
out = pd.DataFrame( out = pd.DataFrame(
{ {
@@ -154,7 +189,8 @@ class EconomyCostMetrics:
} }
) )
return out.sort_index() # Reset index to include queue_skill and channel as columns for frontend lookup
return out.sort_index().reset_index()
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# KPI 2: coste anual por skill/canal # KPI 2: coste anual por skill/canal
@@ -180,7 +216,9 @@ class EconomyCostMetrics:
.rename("volume") .rename("volume")
) )
joined = cpi_table.join(volume, how="left").fillna({"volume": 0}) # Set index on cpi_table to match volume's MultiIndex for join
cpi_indexed = cpi_table.set_index(["queue_skill", "channel"])
joined = cpi_indexed.join(volume, how="left").fillna({"volume": 0})
joined["annual_cost"] = (joined["cpi_total"] * joined["volume"]).round(2) joined["annual_cost"] = (joined["cpi_total"] * joined["volume"]).round(2)
return joined return joined
@@ -216,7 +254,9 @@ class EconomyCostMetrics:
.rename("volume") .rename("volume")
) )
joined = cpi_table.join(volume, how="left").fillna({"volume": 0}) # Set index on cpi_table to match volume's MultiIndex for join
cpi_indexed = cpi_table.set_index(["queue_skill", "channel"])
joined = cpi_indexed.join(volume, how="left").fillna({"volume": 0})
# Costes anuales de labor y overhead # Costes anuales de labor y overhead
annual_labor = (joined["labor_cost"] * joined["volume"]).sum() annual_labor = (joined["labor_cost"] * joined["volume"]).sum()
@@ -252,7 +292,7 @@ class EconomyCostMetrics:
- Ineff_seconds = Delta * volume * 0.4 - Ineff_seconds = Delta * volume * 0.4
- Ineff_cost = LaborCPI_per_second * Ineff_seconds - Ineff_cost = LaborCPI_per_second * Ineff_seconds
⚠️ Es un modelo aproximado para cuantificar "orden de magnitud". NOTA: Es un modelo aproximado para cuantificar "orden de magnitud".
""" """
if not self._has_cost_config(): if not self._has_cost_config():
return pd.DataFrame() return pd.DataFrame()
@@ -261,6 +301,12 @@ class EconomyCostMetrics:
assert cfg is not None assert cfg is not None
df = self.df.copy() df = self.df.copy()
# Filtrar por record_status: solo VALID para cálculo de AHT
# Excluye NOISE, ZOMBIE, ABANDON
if "_is_valid_for_cost" in df.columns:
df = df[df["_is_valid_for_cost"] == True]
grouped = df.groupby(["queue_skill", "channel"]) grouped = df.groupby(["queue_skill", "channel"])
stats = grouped["handle_time"].agg( stats = grouped["handle_time"].agg(
@@ -273,10 +319,14 @@ class EconomyCostMetrics:
return pd.DataFrame() return pd.DataFrame()
# CPI para obtener coste/segundo de labor # CPI para obtener coste/segundo de labor
cpi_table = self.cpi_by_skill_channel() # cpi_by_skill_channel now returns with reset_index, so we need to set index for join
if cpi_table.empty: cpi_table_raw = self.cpi_by_skill_channel()
if cpi_table_raw.empty:
return pd.DataFrame() return pd.DataFrame()
# Set queue_skill+channel as index for the join
cpi_table = cpi_table_raw.set_index(["queue_skill", "channel"])
merged = stats.join(cpi_table[["labor_cost"]], how="left") merged = stats.join(cpi_table[["labor_cost"]], how="left")
merged = merged.fillna(0.0) merged = merged.fillna(0.0)
@@ -297,7 +347,8 @@ class EconomyCostMetrics:
merged["ineff_seconds"] = ineff_seconds.round(2) merged["ineff_seconds"] = ineff_seconds.round(2)
merged["ineff_cost"] = ineff_cost merged["ineff_cost"] = ineff_cost
return merged[["aht_p50", "aht_p90", "volume", "ineff_seconds", "ineff_cost"]] # Reset index to include queue_skill and channel as columns for frontend lookup
return merged[["aht_p50", "aht_p90", "volume", "ineff_seconds", "ineff_cost"]].reset_index()
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# KPI 5: ahorro potencial anual por automatización # KPI 5: ahorro potencial anual por automatización
@@ -419,7 +470,9 @@ class EconomyCostMetrics:
.rename("volume") .rename("volume")
) )
joined = cpi_table.join(volume, how="left").fillna({"volume": 0}) # Set index on cpi_table to match volume's MultiIndex for join
cpi_indexed = cpi_table.set_index(["queue_skill", "channel"])
joined = cpi_indexed.join(volume, how="left").fillna({"volume": 0})
# CPI medio ponderado por canal # CPI medio ponderado por canal
per_channel = ( per_channel = (

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, List from typing import Any, Dict, List
import numpy as np import numpy as np
import pandas as pd import pandas as pd
@@ -87,14 +87,26 @@ class OperationalPerformanceMetrics:
) )
# v3.0: Filtrar NOISE y ZOMBIE para cálculos de variabilidad # v3.0: Filtrar NOISE y ZOMBIE para cálculos de variabilidad
# record_status: 'valid', 'noise', 'zombie', 'abandon' # record_status: 'VALID', 'NOISE', 'ZOMBIE', 'ABANDON'
# Para AHT/CV solo usamos 'valid' (o sin status = legacy data) # Para AHT/CV solo usamos 'VALID' (excluye noise, zombie, abandon)
if "record_status" in df.columns: if "record_status" in df.columns:
df["record_status"] = df["record_status"].astype(str).str.strip().str.upper() 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) # Crear máscara para registros válidos: SOLO "VALID"
df["_is_valid_for_cv"] = df["record_status"].isin(["VALID", "NAN", ""]) | df["record_status"].isna() # Excluye explícitamente NOISE, ZOMBIE, ABANDON y cualquier otro valor
df["_is_valid_for_cv"] = df["record_status"] == "VALID"
# Log record_status breakdown for debugging
status_counts = df["record_status"].value_counts()
valid_count = int(df["_is_valid_for_cv"].sum())
print(f"[OperationalPerformance] Record status breakdown:")
print(f" Total rows: {len(df)}")
for status, count in status_counts.items():
print(f" - {status}: {count}")
print(f" VALID rows for AHT calculation: {valid_count}")
else: else:
# Legacy data sin record_status: incluir todo
df["_is_valid_for_cv"] = True df["_is_valid_for_cv"] = True
print(f"[OperationalPerformance] No record_status column - using all {len(df)} rows")
# Normalización básica # Normalización básica
df["queue_skill"] = df["queue_skill"].astype(str).str.strip() df["queue_skill"] = df["queue_skill"].astype(str).str.strip()
@@ -156,6 +168,9 @@ class OperationalPerformanceMetrics:
def talk_hold_acw_p50_by_skill(self) -> pd.DataFrame: def talk_hold_acw_p50_by_skill(self) -> pd.DataFrame:
""" """
P50 de talk_time, hold_time y wrap_up_time por skill. P50 de talk_time, hold_time y wrap_up_time por skill.
Incluye queue_skill como columna (no solo índice) para que
el frontend pueda hacer lookup por nombre de skill.
""" """
df = self.df df = self.df
@@ -173,7 +188,8 @@ class OperationalPerformanceMetrics:
"acw_p50": grouped["wrap_up_time"].apply(lambda s: perc(s, 50)), "acw_p50": grouped["wrap_up_time"].apply(lambda s: perc(s, 50)),
} }
) )
return result.round(2).sort_index() # Reset index to include queue_skill as column for frontend lookup
return result.round(2).sort_index().reset_index()
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# FCR, escalación, abandono, reincidencia, repetición canal # FCR, escalación, abandono, reincidencia, repetición canal
@@ -290,13 +306,17 @@ class OperationalPerformanceMetrics:
def recurrence_rate_7d(self) -> float: def recurrence_rate_7d(self) -> float:
""" """
% de clientes que vuelven a contactar en < 7 días. % de clientes que vuelven a contactar en < 7 días para el MISMO skill.
Se basa en customer_id (o caller_id si no hay customer_id). Se basa en customer_id (o caller_id si no hay customer_id) + queue_skill.
Calcula: Calcula:
- Para cada cliente, ordena por datetime_start - Para cada combinación cliente + skill, ordena por datetime_start
- Si hay dos contactos consecutivos separados < 7 días, cuenta como "recurrente" - Si hay dos contactos consecutivos separados < 7 días (mismo cliente, mismo skill),
cuenta como "recurrente"
- Tasa = nº clientes recurrentes / nº total de clientes - Tasa = nº clientes recurrentes / nº total de clientes
NOTA: Solo cuenta como recurrencia si el cliente llama por el MISMO skill.
Un cliente que llama a "Ventas" y luego a "Soporte" NO es recurrente.
""" """
df = self.df.dropna(subset=["datetime_start"]).copy() df = self.df.dropna(subset=["datetime_start"]).copy()
@@ -313,16 +333,17 @@ class OperationalPerformanceMetrics:
if df.empty: if df.empty:
return float("nan") return float("nan")
# Ordenar por cliente + fecha # Ordenar por cliente + skill + fecha
df = df.sort_values(["customer_id", "datetime_start"]) df = df.sort_values(["customer_id", "queue_skill", "datetime_start"])
# Diferencia de tiempo entre contactos consecutivos por cliente # Diferencia de tiempo entre contactos consecutivos por cliente Y skill
df["delta"] = df.groupby("customer_id")["datetime_start"].diff() # Esto asegura que solo contamos recontactos del mismo cliente para el mismo skill
df["delta"] = df.groupby(["customer_id", "queue_skill"])["datetime_start"].diff()
# Marcamos los contactos que ocurren a menos de 7 días del anterior # Marcamos los contactos que ocurren a menos de 7 días del anterior (mismo skill)
recurrence_mask = df["delta"] < pd.Timedelta(days=7) recurrence_mask = df["delta"] < pd.Timedelta(days=7)
# Nº de clientes que tienen al menos un contacto recurrente # Nº de clientes que tienen al menos un contacto recurrente (para cualquier skill)
recurrent_customers = df.loc[recurrence_mask, "customer_id"].nunique() recurrent_customers = df.loc[recurrence_mask, "customer_id"].nunique()
total_customers = df["customer_id"].nunique() total_customers = df["customer_id"].nunique()
@@ -568,3 +589,128 @@ class OperationalPerformanceMetrics:
ax.grid(axis="y", alpha=0.3) ax.grid(axis="y", alpha=0.3)
return ax return ax
# ------------------------------------------------------------------ #
# Métricas por skill (para consistencia frontend cached/fresh)
# ------------------------------------------------------------------ #
def metrics_by_skill(self) -> List[Dict[str, Any]]:
"""
Calcula métricas operacionales por skill:
- transfer_rate: % de interacciones con transfer_flag == True
- abandonment_rate: % de interacciones abandonadas
- fcr_tecnico: 100 - transfer_rate (sin transferencia)
- fcr_real: % sin transferencia Y sin recontacto 7d (si hay datos)
- volume: número de interacciones
Devuelve una lista de dicts, uno por skill, para que el frontend
tenga acceso a las métricas reales por skill (no estimadas).
"""
df = self.df
if df.empty:
return []
results = []
# Detectar columna de abandono
abandon_col = None
for col_name in ["is_abandoned", "abandoned_flag", "abandoned"]:
if col_name in df.columns:
abandon_col = col_name
break
# Detectar columna de repeat_call_7d para FCR real
repeat_col = None
for col_name in ["repeat_call_7d", "repeat_7d", "is_repeat_7d"]:
if col_name in df.columns:
repeat_col = col_name
break
for skill, group in df.groupby("queue_skill"):
total = len(group)
if total == 0:
continue
# Transfer rate
if "transfer_flag" in group.columns:
transfer_count = group["transfer_flag"].sum()
transfer_rate = float(round(transfer_count / total * 100, 2))
else:
transfer_rate = 0.0
# FCR Técnico = 100 - transfer_rate
fcr_tecnico = float(round(100.0 - transfer_rate, 2))
# Abandonment rate
abandonment_rate = 0.0
if abandon_col:
col = group[abandon_col]
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())
abandonment_rate = float(round(abandoned / total * 100, 2))
# FCR Real (sin transferencia Y sin recontacto 7d)
fcr_real = fcr_tecnico # default to fcr_tecnico if no repeat data
if repeat_col and "transfer_flag" in group.columns:
repeat_data = group[repeat_col]
if repeat_data.dtype == "O":
repeat_mask = (
repeat_data.astype(str)
.str.strip()
.str.lower()
.isin(["true", "t", "1", "yes", "y", "si", ""])
)
else:
repeat_mask = pd.to_numeric(repeat_data, errors="coerce").fillna(0) > 0
# FCR Real: no transfer AND no repeat
fcr_real_mask = (~group["transfer_flag"]) & (~repeat_mask)
fcr_real_count = fcr_real_mask.sum()
fcr_real = float(round(fcr_real_count / total * 100, 2))
# AHT Mean (promedio de handle_time sobre registros válidos)
# Filtramos solo registros 'valid' (excluye noise/zombie) para consistencia
if "_is_valid_for_cv" in group.columns:
valid_records = group[group["_is_valid_for_cv"]]
else:
valid_records = group
if len(valid_records) > 0 and "handle_time" in valid_records.columns:
aht_mean = float(round(valid_records["handle_time"].mean(), 2))
else:
aht_mean = 0.0
# AHT Total (promedio de handle_time sobre TODOS los registros)
# Incluye NOISE, ZOMBIE, ABANDON - solo para información/comparación
if len(group) > 0 and "handle_time" in group.columns:
aht_total = float(round(group["handle_time"].mean(), 2))
else:
aht_total = 0.0
# Hold Time Mean (promedio de hold_time sobre registros válidos)
# Consistente con fresh path que usa MEAN, no P50
if len(valid_records) > 0 and "hold_time" in valid_records.columns:
hold_time_mean = float(round(valid_records["hold_time"].mean(), 2))
else:
hold_time_mean = 0.0
results.append({
"skill": str(skill),
"volume": int(total),
"transfer_rate": transfer_rate,
"abandonment_rate": abandonment_rate,
"fcr_tecnico": fcr_tecnico,
"fcr_real": fcr_real,
"aht_mean": aht_mean,
"aht_total": aht_total,
"hold_time_mean": hold_time_mean,
})
return results

View File

@@ -1,8 +1,7 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { LayoutDashboard, Layers, Bot, Map } from 'lucide-react'; import { LayoutDashboard, Layers, Bot, Map, ShieldCheck, Info, Scale } from 'lucide-react';
import { formatDateMonthYear } from '../utils/formatters';
export type TabId = 'executive' | 'dimensions' | 'readiness' | 'roadmap'; export type TabId = 'executive' | 'dimensions' | 'readiness' | 'roadmap' | 'law10';
export interface TabConfig { export interface TabConfig {
id: TabId; id: TabId;
@@ -14,6 +13,7 @@ interface DashboardHeaderProps {
title?: string; title?: string;
activeTab: TabId; activeTab: TabId;
onTabChange: (id: TabId) => void; onTabChange: (id: TabId) => void;
onMetodologiaClick?: () => void;
} }
const TABS: TabConfig[] = [ const TABS: TabConfig[] = [
@@ -21,20 +21,32 @@ const TABS: TabConfig[] = [
{ id: 'dimensions', label: 'Dimensiones', icon: Layers }, { id: 'dimensions', label: 'Dimensiones', icon: Layers },
{ id: 'readiness', label: 'Agentic Readiness', icon: Bot }, { id: 'readiness', label: 'Agentic Readiness', icon: Bot },
{ id: 'roadmap', label: 'Roadmap', icon: Map }, { id: 'roadmap', label: 'Roadmap', icon: Map },
{ id: 'law10', label: 'Ley 10/2025', icon: Scale },
]; ];
export function DashboardHeader({ export function DashboardHeader({
title = 'AIR EUROPA - Beyond CX Analytics', title = 'AIR EUROPA - Beyond CX Analytics',
activeTab, activeTab,
onTabChange onTabChange,
onMetodologiaClick
}: DashboardHeaderProps) { }: DashboardHeaderProps) {
return ( return (
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm"> <header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
{/* Top row: Title and Date */} {/* Top row: Title and Metodología Badge */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4"> <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"> <div className="flex items-center justify-between gap-2">
<h1 className="text-base sm:text-xl font-bold text-slate-800 truncate">{title}</h1> <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> {onMetodologiaClick && (
<button
onClick={onMetodologiaClick}
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 flex-shrink-0"
>
<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> </div>

View File

@@ -1,11 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeft, ShieldCheck, Info } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { DashboardHeader, TabId } from './DashboardHeader'; import { DashboardHeader, TabId } from './DashboardHeader';
import { formatDateMonthYear } from '../utils/formatters';
import { ExecutiveSummaryTab } from './tabs/ExecutiveSummaryTab'; import { ExecutiveSummaryTab } from './tabs/ExecutiveSummaryTab';
import { DimensionAnalysisTab } from './tabs/DimensionAnalysisTab'; import { DimensionAnalysisTab } from './tabs/DimensionAnalysisTab';
import { AgenticReadinessTab } from './tabs/AgenticReadinessTab'; import { AgenticReadinessTab } from './tabs/AgenticReadinessTab';
import { RoadmapTab } from './tabs/RoadmapTab'; import { RoadmapTab } from './tabs/RoadmapTab';
import { Law10Tab } from './tabs/Law10Tab';
import { MetodologiaDrawer } from './MetodologiaDrawer'; import { MetodologiaDrawer } from './MetodologiaDrawer';
import type { AnalysisData } from '../types'; import type { AnalysisData } from '../types';
@@ -33,6 +35,8 @@ export function DashboardTabs({
return <AgenticReadinessTab data={data} onTabChange={setActiveTab} />; return <AgenticReadinessTab data={data} onTabChange={setActiveTab} />;
case 'roadmap': case 'roadmap':
return <RoadmapTab data={data} />; return <RoadmapTab data={data} />;
case 'law10':
return <Law10Tab data={data} onTabChange={setActiveTab} />;
default: default:
return <ExecutiveSummaryTab data={data} />; return <ExecutiveSummaryTab data={data} />;
} }
@@ -61,6 +65,7 @@ export function DashboardTabs({
title={title} title={title}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={setActiveTab}
onMetodologiaClick={() => setMetodologiaOpen(true)}
/> />
{/* Tab Content */} {/* Tab Content */}
@@ -84,23 +89,7 @@ export function DashboardTabs({
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-sm text-slate-500"> <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="hidden sm:inline">Beyond Diagnosis - Contact Center Analytics Platform</span>
<span className="sm:hidden text-xs">Beyond Diagnosis</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 text-slate-400 italic">{formatDateMonthYear()}</span>
<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>
</div> </div>
</footer> </footer>

View File

@@ -304,6 +304,111 @@ function KPIRedefinitionSection({ kpis }: { kpis: DataSummary['kpis'] }) {
); );
} }
function CPICalculationSection({ totalCost, totalVolume, costPerHour = 20 }: { totalCost: number; totalVolume: number; costPerHour?: number }) {
// Productivity factor: agents are ~70% productive (rest is breaks, training, after-call work, etc.)
const effectiveProductivity = 0.70;
// CPI = Total Cost / Total Volume
// El coste total ya incluye: TODOS los registros (noise + zombie + valid) y el factor de productividad
const cpi = totalVolume > 0 ? totalCost / totalVolume : 0;
return (
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-emerald-600" />
Coste por Interacción (CPI)
</h3>
<p className="text-sm text-gray-600 mb-4">
El CPI se calcula dividiendo el <strong>coste total</strong> entre el <strong>volumen de interacciones</strong>.
El coste total incluye <em>todas</em> las interacciones (noise, zombie y válidas) porque todas se facturan,
y aplica un factor de productividad del {(effectiveProductivity * 100).toFixed(0)}%.
</p>
{/* Fórmula visual */}
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4 mb-4">
<div className="text-center mb-3">
<span className="text-xs text-emerald-700 uppercase tracking-wider font-medium">Fórmula de Cálculo</span>
</div>
<div className="flex items-center justify-center gap-2 text-lg font-mono flex-wrap">
<span className="px-3 py-1 bg-white rounded border border-emerald-300">CPI</span>
<span className="text-emerald-600">=</span>
<span className="px-2 py-1 bg-blue-100 rounded text-blue-800 text-sm">Coste Total</span>
<span className="text-emerald-600">÷</span>
<span className="px-2 py-1 bg-amber-100 rounded text-amber-800 text-sm">Volumen Total</span>
</div>
<p className="text-[10px] text-center text-emerald-600 mt-2">
El coste total usa (AHT segundos ÷ 3600) × coste/hora × volumen ÷ productividad
</p>
</div>
{/* Cómo se calcula el coste total */}
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 mb-4">
<div className="text-sm font-semibold text-slate-700 mb-2">¿Cómo se calcula el Coste Total?</div>
<div className="bg-white rounded p-3 mb-3">
<div className="flex items-center justify-center gap-2 text-sm font-mono flex-wrap">
<span className="text-slate-600">Coste =</span>
<span className="px-2 py-1 bg-blue-100 rounded text-blue-800 text-xs">(AHT seg ÷ 3600)</span>
<span className="text-slate-400">×</span>
<span className="px-2 py-1 bg-amber-100 rounded text-amber-800 text-xs">{costPerHour}/h</span>
<span className="text-slate-400">×</span>
<span className="px-2 py-1 bg-gray-100 rounded text-gray-800 text-xs">Volumen</span>
<span className="text-slate-400">÷</span>
<span className="px-2 py-1 bg-purple-100 rounded text-purple-800 text-xs">{(effectiveProductivity * 100).toFixed(0)}%</span>
</div>
</div>
<p className="text-xs text-slate-600">
El <strong>AHT</strong> está en segundos, se convierte a horas dividiendo por 3600.
Incluye todas las interacciones que generan coste (noise + zombie + válidas).
Solo se excluyen los abandonos porque no consumen tiempo de agente.
</p>
</div>
{/* Componentes del coste horario */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-semibold text-amber-800">Coste por Hora del Agente (Fully Loaded)</div>
<span className="text-xs bg-amber-200 text-amber-800 px-2 py-0.5 rounded-full font-medium">
Valor introducido: {costPerHour.toFixed(2)}/h
</span>
</div>
<p className="text-xs text-amber-700 mb-3">
Este valor fue configurado en la pantalla de entrada de datos y debe incluir todos los costes asociados al agente:
</p>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-2">
<span className="text-amber-500"></span>
<span className="text-amber-700">Salario bruto del agente</span>
</div>
<div className="flex items-center gap-2">
<span className="text-amber-500"></span>
<span className="text-amber-700">Costes de seguridad social</span>
</div>
<div className="flex items-center gap-2">
<span className="text-amber-500"></span>
<span className="text-amber-700">Licencias de software</span>
</div>
<div className="flex items-center gap-2">
<span className="text-amber-500"></span>
<span className="text-amber-700">Infraestructura y puesto</span>
</div>
<div className="flex items-center gap-2">
<span className="text-amber-500"></span>
<span className="text-amber-700">Supervisión y QA</span>
</div>
<div className="flex items-center gap-2">
<span className="text-amber-500"></span>
<span className="text-amber-700">Formación y overhead</span>
</div>
</div>
<p className="text-[10px] text-amber-600 mt-3 italic">
💡 Si necesita ajustar este valor, puede volver a la pantalla de entrada de datos y modificarlo.
</p>
</div>
</div>
);
}
function BeforeAfterSection({ kpis }: { kpis: DataSummary['kpis'] }) { function BeforeAfterSection({ kpis }: { kpis: DataSummary['kpis'] }) {
const rows = [ const rows = [
{ {
@@ -528,6 +633,9 @@ function GuaranteesSection() {
export function MetodologiaDrawer({ isOpen, onClose, data }: MetodologiaDrawerProps) { export function MetodologiaDrawer({ isOpen, onClose, data }: MetodologiaDrawerProps) {
// Calcular datos del resumen desde AnalysisData // Calcular datos del resumen desde AnalysisData
const totalRegistros = data.heatmapData?.reduce((sum, h) => sum + h.volume, 0) || 0; const totalRegistros = data.heatmapData?.reduce((sum, h) => sum + h.volume, 0) || 0;
const totalCost = data.heatmapData?.reduce((sum, h) => sum + (h.annual_cost || 0), 0) || 0;
// cost_volume: volumen usado para calcular coste (non-abandon), fallback a volume si no existe
const totalCostVolume = data.heatmapData?.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0) || totalRegistros;
// Calcular meses de histórico desde dateRange // Calcular meses de histórico desde dateRange
let mesesHistorico = 1; let mesesHistorico = 1;
@@ -633,6 +741,11 @@ export function MetodologiaDrawer({ isOpen, onClose, data }: MetodologiaDrawerPr
<SkillsMappingSection numSkillsNegocio={dataSummary.kpis.skillsNegocio} /> <SkillsMappingSection numSkillsNegocio={dataSummary.kpis.skillsNegocio} />
<TaxonomySection data={dataSummary.taxonomia} /> <TaxonomySection data={dataSummary.taxonomia} />
<KPIRedefinitionSection kpis={dataSummary.kpis} /> <KPIRedefinitionSection kpis={dataSummary.kpis} />
<CPICalculationSection
totalCost={totalCost}
totalVolume={totalCostVolume}
costPerHour={data.staticConfig?.cost_per_hour || 20}
/>
<BeforeAfterSection kpis={dataSummary.kpis} /> <BeforeAfterSection kpis={dataSummary.kpis} />
<GuaranteesSection /> <GuaranteesSection />
</div> </div>

View File

@@ -81,13 +81,14 @@ const OpportunityMatrixPro: React.FC<OpportunityMatrixProProps> = ({ data, heatm
}; };
}, [dataWithPriority]); }, [dataWithPriority]);
// Dynamic title // Dynamic title - v4.3: Top 10 iniciativas por potencial económico
const dynamicTitle = useMemo(() => { const dynamicTitle = useMemo(() => {
const { quickWins } = portfolioSummary; const totalQueues = dataWithPriority.length;
if (quickWins.count > 0) { const totalSavings = portfolioSummary.totalSavings;
return `${quickWins.count} Quick Wins pueden generar €${(quickWins.savings / 1000).toFixed(0)}K en ahorros con implementación en Q1-Q2`; if (totalQueues === 0) {
return 'No hay iniciativas con potencial de ahorro identificadas';
} }
return `Portfolio de ${dataWithPriority.length} oportunidades identificadas con potencial de €${(portfolioSummary.totalSavings / 1000).toFixed(0)}K`; return `Top ${totalQueues} iniciativas por potencial económico | Ahorro total: €${(totalSavings / 1000).toFixed(0)}K/año`;
}, [portfolioSummary, dataWithPriority]); }, [portfolioSummary, dataWithPriority]);
const getQuadrantInfo = (impact: number, feasibility: number): QuadrantInfo => { const getQuadrantInfo = (impact: number, feasibility: number): QuadrantInfo => {
@@ -160,21 +161,24 @@ const OpportunityMatrixPro: React.FC<OpportunityMatrixProProps> = ({ data, heatm
<div id="opportunities" className="bg-white p-8 rounded-xl border border-slate-200 shadow-sm"> <div id="opportunities" className="bg-white p-8 rounded-xl border border-slate-200 shadow-sm">
{/* Header with Dynamic Title */} {/* Header with Dynamic Title */}
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center justify-between mb-2">
<h3 className="font-bold text-2xl text-slate-800">Opportunity Matrix</h3> <div className="flex items-center gap-2">
<div className="group relative"> <h3 className="font-bold text-2xl text-slate-800">Opportunity Matrix - Top 10 Iniciativas</h3>
<HelpCircle size={18} className="text-slate-400 cursor-pointer" /> <div className="group relative">
<div className="absolute bottom-full mb-2 w-80 bg-slate-800 text-white text-xs rounded py-2 px-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none z-10"> <HelpCircle size={18} className="text-slate-400 cursor-pointer" />
Prioriza iniciativas basadas en Impacto vs. Factibilidad. El tamaño de la burbuja representa el ahorro potencial. Los números indican la priorización estratégica. Click para ver detalles completos. <div className="absolute bottom-full mb-2 w-80 bg-slate-800 text-white text-xs rounded py-2 px-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none z-10">
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-800"></div> Top 10 colas por potencial económico (todos los tiers). Eje X = Factibilidad (Agentic Score), Eje Y = Impacto (Ahorro TCO). Tamaño = Ahorro potencial. 🤖=AUTOMATE, 🤝=ASSIST, 📚=AUGMENT.
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-800"></div>
</div>
</div> </div>
</div> </div>
<p className="text-xs text-slate-500 italic">Priorizadas por potencial de ahorro TCO (🤖 AUTOMATE, 🤝 ASSIST, 📚 AUGMENT)</p>
</div> </div>
<p className="text-base text-slate-700 font-medium leading-relaxed mb-1"> <p className="text-base text-slate-700 font-medium leading-relaxed mb-1">
{dynamicTitle} {dynamicTitle}
</p> </p>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
Portfolio de Oportunidades | Análisis de {dataWithPriority.length} iniciativas identificadas {dataWithPriority.length} iniciativas identificadas | Ahorro TCO según tier (AUTOMATE 70%, ASSIST 30%, AUGMENT 15%)
</p> </p>
</div> </div>
@@ -217,33 +221,33 @@ const OpportunityMatrixPro: React.FC<OpportunityMatrixProProps> = ({ data, heatm
<div className="relative w-full h-[500px] border-l-2 border-b-2 border-slate-400 rounded-bl-lg bg-gradient-to-tr from-slate-50 to-white"> <div className="relative w-full h-[500px] border-l-2 border-b-2 border-slate-400 rounded-bl-lg bg-gradient-to-tr from-slate-50 to-white">
{/* Y-axis Label */} {/* Y-axis Label */}
<div className="absolute -left-20 top-1/2 -translate-y-1/2 -rotate-90 text-sm font-bold text-slate-700 flex items-center gap-2"> <div className="absolute -left-20 top-1/2 -translate-y-1/2 -rotate-90 text-sm font-bold text-slate-700 flex items-center gap-2">
<TrendingUp size={18} /> IMPACTO <TrendingUp size={18} /> IMPACTO (Ahorro TCO)
</div> </div>
{/* X-axis Label */} {/* X-axis Label */}
<div className="absolute -bottom-14 left-1/2 -translate-x-1/2 text-sm font-bold text-slate-700 flex items-center gap-2"> <div className="absolute -bottom-14 left-1/2 -translate-x-1/2 text-sm font-bold text-slate-700 flex items-center gap-2">
<Zap size={18} /> FACTIBILIDAD <Zap size={18} /> FACTIBILIDAD (Agentic Score)
</div> </div>
{/* Axis scale labels */} {/* Axis scale labels */}
<div className="absolute -left-2 top-0 -translate-x-full text-xs text-slate-500 font-medium"> <div className="absolute -left-2 top-0 -translate-x-full text-xs text-slate-500 font-medium">
Muy Alto Alto (10)
</div> </div>
<div className="absolute -left-2 top-1/2 -translate-x-full -translate-y-1/2 text-xs text-slate-500 font-medium"> <div className="absolute -left-2 top-1/2 -translate-x-full -translate-y-1/2 text-xs text-slate-500 font-medium">
Medio Medio (5)
</div> </div>
<div className="absolute -left-2 bottom-0 -translate-x-full text-xs text-slate-500 font-medium"> <div className="absolute -left-2 bottom-0 -translate-x-full text-xs text-slate-500 font-medium">
Bajo Bajo (1)
</div> </div>
<div className="absolute left-0 -bottom-2 translate-y-full text-xs text-slate-500 font-medium"> <div className="absolute left-0 -bottom-2 translate-y-full text-xs text-slate-500 font-medium">
Muy Difícil 0
</div> </div>
<div className="absolute left-1/2 -bottom-2 -translate-x-1/2 translate-y-full text-xs text-slate-500 font-medium"> <div className="absolute left-1/2 -bottom-2 -translate-x-1/2 translate-y-full text-xs text-slate-500 font-medium">
Moderado 5
</div> </div>
<div className="absolute right-0 -bottom-2 translate-y-full text-xs text-slate-500 font-medium"> <div className="absolute right-0 -bottom-2 translate-y-full text-xs text-slate-500 font-medium">
Fácil 10
</div> </div>
{/* Quadrant Lines */} {/* Quadrant Lines */}
@@ -364,22 +368,24 @@ const OpportunityMatrixPro: React.FC<OpportunityMatrixProProps> = ({ data, heatm
{/* Enhanced Legend */} {/* Enhanced Legend */}
<div className="mt-8 p-4 bg-slate-50 rounded-lg"> <div className="mt-8 p-4 bg-slate-50 rounded-lg">
<div className="flex flex-wrap items-center gap-6 text-xs"> <div className="flex flex-wrap items-center gap-4 text-xs">
<span className="font-semibold text-slate-700">Tamaño de burbuja = Ahorro potencial:</span> <span className="font-semibold text-slate-700">Tier:</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
<div className="w-4 h-4 rounded-full bg-slate-400"></div> <span>🤖</span>
<span className="text-slate-700">Pequeño (&lt;50K)</span> <span className="text-emerald-600 font-medium">AUTOMATE</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
<div className="w-6 h-6 rounded-full bg-slate-400"></div> <span>🤝</span>
<span className="text-slate-700">Medio (50-150K)</span> <span className="text-blue-600 font-medium">ASSIST</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
<div className="w-8 h-8 rounded-full bg-slate-400"></div> <span>📚</span>
<span className="text-slate-700">Grande (&gt;150K)</span> <span className="text-amber-600 font-medium">AUGMENT</span>
</div> </div>
<span className="ml-4 text-slate-500">|</span> <span className="text-slate-400">|</span>
<span className="font-semibold text-slate-700">Número = Prioridad estratégica</span> <span className="font-semibold text-slate-700">Tamaño = Ahorro TCO</span>
<span className="text-slate-400">|</span>
<span className="font-semibold text-slate-700">Número = Ranking</span>
</div> </div>
</div> </div>
@@ -447,10 +453,10 @@ const OpportunityMatrixPro: React.FC<OpportunityMatrixProProps> = ({ data, heatm
{/* Methodology Footer */} {/* Methodology Footer */}
<MethodologyFooter <MethodologyFooter
sources="Análisis interno de procesos operacionales | Benchmarks de implementación: Gartner Magic Quadrant for CCaaS 2024, Forrester Wave Contact Center 2024" sources="Agentic Readiness Score (5 factores ponderados) | Modelo TCO con CPI diferenciado por tier"
methodology="Impacto: Basado en % reducción de AHT, mejora de FCR, y reducción de costes operacionales | Factibilidad: Evaluación de complejidad técnica (40%), cambio organizacional (30%), inversión requerida (30%) | Priorización: Score = (Impacto/10) × (Factibilidad/10) × (Ahorro/Max Ahorro)" methodology="Factibilidad = Agentic Score (0-10) | Impacto = Ahorro TCO anual según tier: AUTOMATE (Vol/11×12×70%×€2.18), ASSIST (×30%×€0.83), AUGMENT (×15%×€0.33)"
notes="Ahorros calculados en escenario conservador (base case) sin incluir upside potencial | ROI calculado a 3 años con tasa de descuento 10%" notes="Top 10 iniciativas ordenadas por potencial económico | CPI: Humano €2.33, Bot €0.15, Assist €1.50, Augment €2.00"
lastUpdated="Enero 2025" lastUpdated="Enero 2026"
/> />
</div> </div>
); );

View File

@@ -0,0 +1,623 @@
/**
* OpportunityPrioritizer - v1.0
*
* Redesigned Opportunity Matrix that clearly shows:
* 1. WHERE are the opportunities (ranked list with context)
* 2. WHERE to START (highlighted #1 with full justification)
* 3. WHY this prioritization (tier-based rationale + metrics)
*
* Design principles:
* - Scannable in 5 seconds (executive summary)
* - Actionable in 30 seconds (clear next steps)
* - Deep-dive available (expandable details)
*/
import React, { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Opportunity, DrilldownDataPoint, AgenticTier } from '../types';
import {
ChevronRight,
ChevronDown,
TrendingUp,
Zap,
Clock,
Users,
Bot,
Headphones,
BookOpen,
AlertTriangle,
CheckCircle2,
ArrowRight,
Info,
Target,
DollarSign,
BarChart3,
Sparkles
} from 'lucide-react';
interface OpportunityPrioritizerProps {
opportunities: Opportunity[];
drilldownData?: DrilldownDataPoint[];
costPerHour?: number;
}
interface EnrichedOpportunity extends Opportunity {
rank: number;
tier: AgenticTier;
volume: number;
cv_aht: number;
transfer_rate: number;
fcr_rate: number;
agenticScore: number;
timelineMonths: number;
effortLevel: 'low' | 'medium' | 'high';
riskLevel: 'low' | 'medium' | 'high';
whyPrioritized: string[];
nextSteps: string[];
annualCost?: number;
}
// Tier configuration
const TIER_CONFIG: Record<AgenticTier, {
icon: React.ReactNode;
label: string;
color: string;
bgColor: string;
borderColor: string;
savingsRate: string;
timeline: string;
description: string;
}> = {
'AUTOMATE': {
icon: <Bot size={18} />,
label: 'Automatizar',
color: 'text-emerald-700',
bgColor: 'bg-emerald-50',
borderColor: 'border-emerald-300',
savingsRate: '70%',
timeline: '3-6 meses',
description: 'Automatización completa con agentes IA'
},
'ASSIST': {
icon: <Headphones size={18} />,
label: 'Asistir',
color: 'text-blue-700',
bgColor: 'bg-blue-50',
borderColor: 'border-blue-300',
savingsRate: '30%',
timeline: '6-9 meses',
description: 'Copilot IA para agentes humanos'
},
'AUGMENT': {
icon: <BookOpen size={18} />,
label: 'Optimizar',
color: 'text-amber-700',
bgColor: 'bg-amber-50',
borderColor: 'border-amber-300',
savingsRate: '15%',
timeline: '9-12 meses',
description: 'Estandarización y mejora de procesos'
},
'HUMAN-ONLY': {
icon: <Users size={18} />,
label: 'Humano',
color: 'text-slate-600',
bgColor: 'bg-slate-50',
borderColor: 'border-slate-300',
savingsRate: '0%',
timeline: 'N/A',
description: 'Requiere intervención humana'
}
};
const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
opportunities,
drilldownData,
costPerHour = 20
}) => {
const [expandedId, setExpandedId] = useState<string | null>(null);
const [showAllOpportunities, setShowAllOpportunities] = useState(false);
// Enrich opportunities with drilldown data
const enrichedOpportunities = useMemo((): EnrichedOpportunity[] => {
if (!opportunities || opportunities.length === 0) return [];
// Create a lookup map from drilldown data
const queueLookup = new Map<string, {
tier: AgenticTier;
volume: number;
cv_aht: number;
transfer_rate: number;
fcr_rate: number;
agenticScore: number;
annualCost?: number;
}>();
if (drilldownData) {
drilldownData.forEach(skill => {
skill.originalQueues?.forEach(q => {
queueLookup.set(q.original_queue_id.toLowerCase(), {
tier: q.tier || 'HUMAN-ONLY',
volume: q.volume,
cv_aht: q.cv_aht,
transfer_rate: q.transfer_rate,
fcr_rate: q.fcr_rate,
agenticScore: q.agenticScore,
annualCost: q.annualCost
});
});
});
}
return opportunities.map((opp, index) => {
// Extract queue name (remove tier emoji prefix)
const cleanName = opp.name.replace(/^[^\w\s]+\s*/, '').toLowerCase();
const lookupData = queueLookup.get(cleanName);
// Determine tier from emoji prefix or lookup
let tier: AgenticTier = 'ASSIST';
if (opp.name.startsWith('🤖')) tier = 'AUTOMATE';
else if (opp.name.startsWith('🤝')) tier = 'ASSIST';
else if (opp.name.startsWith('📚')) tier = 'AUGMENT';
else if (lookupData) tier = lookupData.tier;
// Calculate effort and risk based on metrics
const cv = lookupData?.cv_aht || 50;
const transfer = lookupData?.transfer_rate || 15;
const effortLevel: 'low' | 'medium' | 'high' =
tier === 'AUTOMATE' && cv < 60 ? 'low' :
tier === 'ASSIST' || cv < 80 ? 'medium' : 'high';
const riskLevel: 'low' | 'medium' | 'high' =
cv < 50 && transfer < 15 ? 'low' :
cv < 80 && transfer < 30 ? 'medium' : 'high';
// Timeline based on tier
const timelineMonths = tier === 'AUTOMATE' ? 4 : tier === 'ASSIST' ? 7 : 10;
// Generate "why" explanation
const whyPrioritized: string[] = [];
if (opp.savings > 50000) whyPrioritized.push(`Alto ahorro potencial (€${(opp.savings / 1000).toFixed(0)}K/año)`);
if (lookupData?.volume && lookupData.volume > 1000) whyPrioritized.push(`Alto volumen (${lookupData.volume.toLocaleString()} interacciones)`);
if (tier === 'AUTOMATE') whyPrioritized.push('Proceso altamente predecible y repetitivo');
if (cv < 60) whyPrioritized.push('Baja variabilidad en tiempos de gestión');
if (transfer < 15) whyPrioritized.push('Baja tasa de transferencias');
if (opp.feasibility >= 7) whyPrioritized.push('Alta factibilidad técnica');
// Generate next steps
const nextSteps: string[] = [];
if (tier === 'AUTOMATE') {
nextSteps.push('Definir flujos conversacionales principales');
nextSteps.push('Identificar integraciones necesarias (CRM, APIs)');
nextSteps.push('Crear piloto con 10% del volumen');
} else if (tier === 'ASSIST') {
nextSteps.push('Mapear puntos de fricción del agente');
nextSteps.push('Diseñar sugerencias contextuales');
nextSteps.push('Piloto con equipo seleccionado');
} else {
nextSteps.push('Analizar causa raíz de variabilidad');
nextSteps.push('Estandarizar procesos y scripts');
nextSteps.push('Capacitar equipo en mejores prácticas');
}
return {
...opp,
rank: index + 1,
tier,
volume: lookupData?.volume || Math.round(opp.savings / 10),
cv_aht: cv,
transfer_rate: transfer,
fcr_rate: lookupData?.fcr_rate || 75,
agenticScore: lookupData?.agenticScore || opp.feasibility,
timelineMonths,
effortLevel,
riskLevel,
whyPrioritized,
nextSteps,
annualCost: lookupData?.annualCost
};
});
}, [opportunities, drilldownData]);
// Summary stats
const summary = useMemo(() => {
const totalSavings = enrichedOpportunities.reduce((sum, o) => sum + o.savings, 0);
const byTier = {
AUTOMATE: enrichedOpportunities.filter(o => o.tier === 'AUTOMATE'),
ASSIST: enrichedOpportunities.filter(o => o.tier === 'ASSIST'),
AUGMENT: enrichedOpportunities.filter(o => o.tier === 'AUGMENT')
};
const quickWins = enrichedOpportunities.filter(o => o.tier === 'AUTOMATE' && o.effortLevel === 'low');
return {
totalSavings,
totalVolume: enrichedOpportunities.reduce((sum, o) => sum + o.volume, 0),
byTier,
quickWinsCount: quickWins.length,
quickWinsSavings: quickWins.reduce((sum, o) => sum + o.savings, 0)
};
}, [enrichedOpportunities]);
const displayedOpportunities = showAllOpportunities
? enrichedOpportunities
: enrichedOpportunities.slice(0, 5);
const topOpportunity = enrichedOpportunities[0];
if (!enrichedOpportunities.length) {
return (
<div className="bg-white p-8 rounded-xl border border-slate-200 text-center">
<AlertTriangle className="mx-auto mb-4 text-amber-500" size={48} />
<h3 className="text-lg font-semibold text-slate-700">No hay oportunidades identificadas</h3>
<p className="text-slate-500 mt-2">Los datos actuales no muestran oportunidades de automatización viables.</p>
</div>
);
}
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
{/* Header - matching app's visual style */}
<div className="p-6 border-b border-slate-200">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-gray-900">Oportunidades Priorizadas</h2>
<p className="text-sm text-gray-500 mt-1">
{enrichedOpportunities.length} iniciativas ordenadas por potencial de ahorro y factibilidad
</p>
</div>
</div>
</div>
{/* Executive Summary - Answer "Where are opportunities?" in 5 seconds */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-6 bg-slate-50 border-b border-slate-200">
<div className="bg-white rounded-lg p-4 border border-slate-200 shadow-sm">
<div className="flex items-center gap-2 text-slate-500 text-xs mb-1">
<DollarSign size={14} />
<span>Ahorro Total Identificado</span>
</div>
<div className="text-3xl font-bold text-slate-800">
{(summary.totalSavings / 1000).toFixed(0)}K
</div>
<div className="text-xs text-slate-500">anuales</div>
</div>
<div className="bg-emerald-50 rounded-lg p-4 border border-emerald-200 shadow-sm">
<div className="flex items-center gap-2 text-emerald-600 text-xs mb-1">
<Bot size={14} />
<span>Quick Wins (AUTOMATE)</span>
</div>
<div className="text-3xl font-bold text-emerald-700">
{summary.byTier.AUTOMATE.length}
</div>
<div className="text-xs text-emerald-600">
{(summary.byTier.AUTOMATE.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K en 3-6 meses
</div>
</div>
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200 shadow-sm">
<div className="flex items-center gap-2 text-blue-600 text-xs mb-1">
<Headphones size={14} />
<span>Asistencia (ASSIST)</span>
</div>
<div className="text-3xl font-bold text-blue-700">
{summary.byTier.ASSIST.length}
</div>
<div className="text-xs text-blue-600">
{(summary.byTier.ASSIST.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K en 6-9 meses
</div>
</div>
<div className="bg-amber-50 rounded-lg p-4 border border-amber-200 shadow-sm">
<div className="flex items-center gap-2 text-amber-600 text-xs mb-1">
<BookOpen size={14} />
<span>Optimización (AUGMENT)</span>
</div>
<div className="text-3xl font-bold text-amber-700">
{summary.byTier.AUGMENT.length}
</div>
<div className="text-xs text-amber-600">
{(summary.byTier.AUGMENT.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K en 9-12 meses
</div>
</div>
</div>
{/* START HERE - Answer "Where do I start?" */}
{topOpportunity && (
<div className="p-6 bg-gradient-to-r from-emerald-50 to-green-50 border-b-2 border-emerald-200">
<div className="flex items-center gap-2 mb-4">
<Sparkles className="text-emerald-600" size={20} />
<span className="text-emerald-800 font-bold text-lg">EMPIEZA AQUÍ</span>
<span className="bg-emerald-600 text-white text-xs px-2 py-0.5 rounded-full">Prioridad #1</span>
</div>
<div className="bg-white rounded-xl border-2 border-emerald-300 p-6 shadow-lg">
<div className="flex flex-col lg:flex-row lg:items-start gap-6">
{/* Left: Main info */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<div className={`p-2 rounded-lg ${TIER_CONFIG[topOpportunity.tier].bgColor}`}>
{TIER_CONFIG[topOpportunity.tier].icon}
</div>
<div>
<h3 className="text-xl font-bold text-slate-800">
{topOpportunity.name.replace(/^[^\w\s]+\s*/, '')}
</h3>
<span className={`text-sm font-medium ${TIER_CONFIG[topOpportunity.tier].color}`}>
{TIER_CONFIG[topOpportunity.tier].label} {TIER_CONFIG[topOpportunity.tier].description}
</span>
</div>
</div>
{/* Key metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="bg-green-50 rounded-lg p-3">
<div className="text-xs text-green-600 mb-1">Ahorro Anual</div>
<div className="text-xl font-bold text-green-700">
{(topOpportunity.savings / 1000).toFixed(0)}K
</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-xs text-slate-500 mb-1">Volumen</div>
<div className="text-xl font-bold text-slate-700">
{topOpportunity.volume.toLocaleString()}
</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-xs text-slate-500 mb-1">Timeline</div>
<div className="text-xl font-bold text-slate-700">
{topOpportunity.timelineMonths} meses
</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="text-xs text-slate-500 mb-1">Agentic Score</div>
<div className="text-xl font-bold text-slate-700">
{topOpportunity.agenticScore.toFixed(1)}/10
</div>
</div>
</div>
{/* Why this is #1 */}
<div className="mb-4">
<h4 className="text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2">
<Info size={14} />
¿Por qué es la prioridad #1?
</h4>
<ul className="space-y-1">
{topOpportunity.whyPrioritized.slice(0, 4).map((reason, i) => (
<li key={i} className="flex items-center gap-2 text-sm text-slate-600">
<CheckCircle2 size={14} className="text-emerald-500 flex-shrink-0" />
{reason}
</li>
))}
</ul>
</div>
</div>
{/* Right: Next steps */}
<div className="lg:w-80 bg-emerald-50 rounded-lg p-4 border border-emerald-200">
<h4 className="text-sm font-semibold text-emerald-800 mb-3 flex items-center gap-2">
<ArrowRight size={14} />
Próximos Pasos
</h4>
<ol className="space-y-2">
{topOpportunity.nextSteps.map((step, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-emerald-700">
<span className="bg-emerald-600 text-white w-5 h-5 rounded-full flex items-center justify-center text-xs flex-shrink-0 mt-0.5">
{i + 1}
</span>
{step}
</li>
))}
</ol>
<button className="mt-4 w-full bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2 px-4 rounded-lg transition-colors flex items-center justify-center gap-2">
Ver Detalle Completo
<ChevronRight size={16} />
</button>
</div>
</div>
</div>
</div>
)}
{/* Full Opportunity List - Answer "What else?" */}
<div className="p-6">
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2">
<BarChart3 size={20} />
Todas las Oportunidades Priorizadas
</h3>
<div className="space-y-3">
{displayedOpportunities.slice(1).map((opp) => (
<motion.div
key={opp.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`border rounded-lg overflow-hidden transition-all ${
expandedId === opp.id ? 'border-blue-300 shadow-md' : 'border-slate-200 hover:border-slate-300'
}`}
>
{/* Collapsed view */}
<div
className="p-4 cursor-pointer hover:bg-slate-50 transition-colors"
onClick={() => setExpandedId(expandedId === opp.id ? null : opp.id)}
>
<div className="flex items-center gap-4">
{/* Rank */}
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-lg ${
opp.rank <= 3 ? 'bg-emerald-100 text-emerald-700' :
opp.rank <= 6 ? 'bg-blue-100 text-blue-700' :
'bg-slate-100 text-slate-600'
}`}>
#{opp.rank}
</div>
{/* Tier icon and name */}
<div className={`p-2 rounded-lg ${TIER_CONFIG[opp.tier].bgColor}`}>
{TIER_CONFIG[opp.tier].icon}
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-slate-800 truncate">
{opp.name.replace(/^[^\w\s]+\s*/, '')}
</h4>
<span className={`text-xs ${TIER_CONFIG[opp.tier].color}`}>
{TIER_CONFIG[opp.tier].label} {TIER_CONFIG[opp.tier].timeline}
</span>
</div>
{/* Quick stats */}
<div className="hidden md:flex items-center gap-6">
<div className="text-right">
<div className="text-xs text-slate-500">Ahorro</div>
<div className="font-bold text-green-600">{(opp.savings / 1000).toFixed(0)}K</div>
</div>
<div className="text-right">
<div className="text-xs text-slate-500">Volumen</div>
<div className="font-semibold text-slate-700">{opp.volume.toLocaleString()}</div>
</div>
<div className="text-right">
<div className="text-xs text-slate-500">Score</div>
<div className="font-semibold text-slate-700">{opp.agenticScore.toFixed(1)}</div>
</div>
</div>
{/* Visual bar: Value vs Effort */}
<div className="hidden lg:block w-32">
<div className="text-xs text-slate-500 mb-1">Valor / Esfuerzo</div>
<div className="flex h-2 rounded-full overflow-hidden bg-slate-100">
<div
className="bg-emerald-500 transition-all"
style={{ width: `${Math.min(100, opp.impact * 10)}%` }}
/>
<div
className="bg-amber-400 transition-all"
style={{ width: `${Math.min(100 - opp.impact * 10, (10 - opp.feasibility) * 10)}%` }}
/>
</div>
<div className="flex justify-between text-[10px] text-slate-400 mt-0.5">
<span>Valor</span>
<span>Esfuerzo</span>
</div>
</div>
{/* Expand icon */}
<motion.div
animate={{ rotate: expandedId === opp.id ? 90 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronRight className="text-slate-400" size={20} />
</motion.div>
</div>
</div>
{/* Expanded details */}
<AnimatePresence>
{expandedId === opp.id && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="p-4 bg-slate-50 border-t border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Why prioritized */}
<div>
<h5 className="text-sm font-semibold text-slate-700 mb-2">¿Por qué esta posición?</h5>
<ul className="space-y-1">
{opp.whyPrioritized.map((reason, i) => (
<li key={i} className="flex items-center gap-2 text-sm text-slate-600">
<CheckCircle2 size={12} className="text-emerald-500 flex-shrink-0" />
{reason}
</li>
))}
</ul>
</div>
{/* Metrics */}
<div>
<h5 className="text-sm font-semibold text-slate-700 mb-2">Métricas Clave</h5>
<div className="grid grid-cols-2 gap-2">
<div className="bg-white rounded p-2 border border-slate-200">
<div className="text-xs text-slate-500">CV AHT</div>
<div className="font-semibold text-slate-700">{opp.cv_aht.toFixed(1)}%</div>
</div>
<div className="bg-white rounded p-2 border border-slate-200">
<div className="text-xs text-slate-500">Transfer Rate</div>
<div className="font-semibold text-slate-700">{opp.transfer_rate.toFixed(1)}%</div>
</div>
<div className="bg-white rounded p-2 border border-slate-200">
<div className="text-xs text-slate-500">FCR</div>
<div className="font-semibold text-slate-700">{opp.fcr_rate.toFixed(1)}%</div>
</div>
<div className="bg-white rounded p-2 border border-slate-200">
<div className="text-xs text-slate-500">Riesgo</div>
<div className={`font-semibold ${
opp.riskLevel === 'low' ? 'text-emerald-600' :
opp.riskLevel === 'medium' ? 'text-amber-600' : 'text-red-600'
}`}>
{opp.riskLevel === 'low' ? 'Bajo' : opp.riskLevel === 'medium' ? 'Medio' : 'Alto'}
</div>
</div>
</div>
</div>
</div>
{/* Next steps */}
<div className="mt-4 pt-4 border-t border-slate-200">
<h5 className="text-sm font-semibold text-slate-700 mb-2">Próximos Pasos</h5>
<div className="flex flex-wrap gap-2">
{opp.nextSteps.map((step, i) => (
<span key={i} className="bg-white border border-slate-200 rounded-full px-3 py-1 text-xs text-slate-600">
{i + 1}. {step}
</span>
))}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</div>
{/* Show more button */}
{enrichedOpportunities.length > 5 && (
<button
onClick={() => setShowAllOpportunities(!showAllOpportunities)}
className="mt-4 w-full py-3 border border-slate-200 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors flex items-center justify-center gap-2"
>
{showAllOpportunities ? (
<>
<ChevronDown size={16} className="rotate-180" />
Mostrar menos
</>
) : (
<>
<ChevronDown size={16} />
Ver {enrichedOpportunities.length - 5} oportunidades más
</>
)}
</button>
)}
</div>
{/* Methodology note */}
<div className="px-6 pb-6">
<div className="bg-slate-50 rounded-lg p-4 text-xs text-slate-500">
<div className="flex items-start gap-2">
<Info size={14} className="flex-shrink-0 mt-0.5" />
<div>
<strong>Metodología de priorización:</strong> Las oportunidades se ordenan por potencial de ahorro TCO (volumen × tasa de contención × diferencial CPI).
La clasificación de tier (AUTOMATE/ASSIST/AUGMENT) se basa en el Agentic Readiness Score considerando predictibilidad (CV AHT),
resolutividad (FCR + Transfer), volumen, calidad de datos y simplicidad del proceso.
</div>
</div>
</div>
</div>
</div>
);
};
export default OpportunityPrioritizer;

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ChevronRight, TrendingUp, TrendingDown, Minus, AlertTriangle, Lightbulb, DollarSign } from 'lucide-react'; import { ChevronRight, TrendingUp, TrendingDown, Minus, AlertTriangle, Lightbulb, DollarSign, Clock } from 'lucide-react';
import type { AnalysisData, DimensionAnalysis, Finding, Recommendation, HeatmapDataPoint } from '../../types'; import type { AnalysisData, DimensionAnalysis, Finding, Recommendation, HeatmapDataPoint } from '../../types';
import { import {
Card, Card,
@@ -20,7 +20,7 @@ interface DimensionAnalysisTabProps {
data: AnalysisData; data: AnalysisData;
} }
// ========== ANÁLISIS CAUSAL CON IMPACTO ECONÓMICO ========== // ========== HALLAZGO CLAVE CON IMPACTO ECONÓMICO ==========
interface CausalAnalysis { interface CausalAnalysis {
finding: string; finding: string;
@@ -34,20 +34,44 @@ interface CausalAnalysis {
interface CausalAnalysisExtended extends CausalAnalysis { interface CausalAnalysisExtended extends CausalAnalysis {
impactFormula?: string; // Explicación de cómo se calculó el impacto impactFormula?: string; // Explicación de cómo se calculó el impacto
hasRealData: boolean; // True si hay datos reales para calcular hasRealData: boolean; // True si hay datos reales para calcular
timeSavings?: string; // Ahorro de tiempo para dar credibilidad al impacto económico
} }
// Genera análisis causal basado en dimensión y datos // Genera hallazgo clave basado en dimensión y datos
function generateCausalAnalysis( function generateCausalAnalysis(
dimension: DimensionAnalysis, dimension: DimensionAnalysis,
heatmapData: HeatmapDataPoint[], heatmapData: HeatmapDataPoint[],
economicModel: { currentAnnualCost: number } economicModel: { currentAnnualCost: number },
staticConfig?: { cost_per_hour: number },
dateRange?: { min: string; max: string }
): CausalAnalysisExtended[] { ): CausalAnalysisExtended[] {
const analyses: CausalAnalysisExtended[] = []; const analyses: CausalAnalysisExtended[] = [];
const totalVolume = heatmapData.reduce((sum, h) => sum + h.volume, 0); const totalVolume = heatmapData.reduce((sum, h) => sum + h.volume, 0);
// v3.11: CPI basado en modelo TCO (€2.33/interacción) // Coste horario del agente desde config (default €20 si no está definido)
const HOURLY_COST = staticConfig?.cost_per_hour ?? 20;
// Calcular factor de anualización basado en el período de datos
// Si tenemos dateRange, calculamos cuántos días cubre y extrapolamos a año
let annualizationFactor = 1; // Por defecto, asumimos que los datos ya son anuales
if (dateRange?.min && dateRange?.max) {
const startDate = new Date(dateRange.min);
const endDate = new Date(dateRange.max);
const daysCovered = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1);
annualizationFactor = 365 / daysCovered;
}
// v3.11: CPI consistente con Executive Summary
const CPI_TCO = 2.33; const CPI_TCO = 2.33;
const CPI = totalVolume > 0 ? economicModel.currentAnnualCost / (totalVolume * 12) : CPI_TCO; // Usar CPI pre-calculado de heatmapData si existe, sino calcular desde annual_cost/cost_volume
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
const totalAnnualCost = heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0);
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
const CPI = hasCpiField
? (totalCostVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
: CPI_TCO)
: (totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : CPI_TCO);
// Calcular métricas agregadas // Calcular métricas agregadas
const avgCVAHT = totalVolume > 0 const avgCVAHT = totalVolume > 0
@@ -56,8 +80,10 @@ function generateCausalAnalysis(
const avgTransferRate = totalVolume > 0 const avgTransferRate = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.variability?.transfer_rate || 0) * h.volume, 0) / totalVolume ? heatmapData.reduce((sum, h) => sum + (h.variability?.transfer_rate || 0) * h.volume, 0) / totalVolume
: 0; : 0;
// Usar FCR Técnico (100 - transfer_rate) en lugar de FCR Real (con filtro recontacto 7d)
// FCR Técnico es más comparable con benchmarks de industria
const avgFCR = totalVolume > 0 const avgFCR = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + h.metrics.fcr * h.volume, 0) / totalVolume ? heatmapData.reduce((sum, h) => sum + (h.metrics.fcr_tecnico ?? (100 - h.metrics.transfer_rate)) * h.volume, 0) / totalVolume
: 0; : 0;
const avgAHT = totalVolume > 0 const avgAHT = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + h.aht_seconds * h.volume, 0) / totalVolume ? heatmapData.reduce((sum, h) => sum + h.aht_seconds * h.volume, 0) / totalVolume
@@ -71,77 +97,112 @@ function generateCausalAnalysis(
// Skills con problemas específicos // Skills con problemas específicos
const skillsHighCV = heatmapData.filter(h => (h.variability?.cv_aht || 0) > 100); const skillsHighCV = heatmapData.filter(h => (h.variability?.cv_aht || 0) > 100);
const skillsLowFCR = heatmapData.filter(h => h.metrics.fcr < 50); // Usar FCR Técnico para identificar skills con bajo FCR
const skillsLowFCR = heatmapData.filter(h => (h.metrics.fcr_tecnico ?? (100 - h.metrics.transfer_rate)) < 50);
const skillsHighTransfer = heatmapData.filter(h => (h.variability?.transfer_rate || 0) > 20); const skillsHighTransfer = heatmapData.filter(h => (h.variability?.transfer_rate || 0) > 20);
// Parsear P50 AHT del KPI del header para consistencia visual
// El KPI puede ser "345s (P50)" o similar
const parseKpiAhtSeconds = (kpiValue: string): number | null => {
const match = kpiValue.match(/(\d+)s/);
return match ? parseInt(match[1], 10) : null;
};
switch (dimension.name) { switch (dimension.name) {
case 'operational_efficiency': case 'operational_efficiency':
// Análisis de variabilidad AHT // Obtener P50 AHT del header para mostrar valor consistente
if (avgCVAHT > 80) { const p50Aht = parseKpiAhtSeconds(dimension.kpi.value) ?? avgAHT;
const inefficiencyPct = Math.min(0.15, (avgCVAHT - 60) / 200);
const inefficiencyCost = Math.round(economicModel.currentAnnualCost * inefficiencyPct); // Eficiencia Operativa: enfocada en AHT (valor absoluto)
// CV AHT se analiza en Complejidad & Predictibilidad (best practice)
const hasHighAHT = p50Aht > 300; // 5:00 benchmark
const ahtBenchmark = 300; // 5:00 objetivo
if (hasHighAHT) {
// Calcular impacto económico por AHT excesivo
const excessSeconds = p50Aht - ahtBenchmark;
const annualVolume = Math.round(totalVolume * annualizationFactor);
const excessHours = Math.round((excessSeconds / 3600) * annualVolume);
const ahtExcessCost = Math.round(excessHours * HOURLY_COST);
// Estimar ahorro con solución Copilot (25-30% reducción AHT)
const copilotSavings = Math.round(ahtExcessCost * 0.28);
// Causa basada en AHT elevado
const cause = 'Agentes dedican tiempo excesivo a búsqueda manual de información, navegación entre sistemas y tareas repetitivas.';
analyses.push({ analyses.push({
finding: `Variabilidad AHT elevada: CV ${avgCVAHT.toFixed(0)}% (benchmark: <60%)`, finding: `AHT elevado: P50 ${Math.floor(p50Aht / 60)}:${String(Math.round(p50Aht) % 60).padStart(2, '0')} (benchmark: 5:00)`,
probableCause: skillsHighCV.length > 0 probableCause: cause,
? `Falta de scripts estandarizados en ${skillsHighCV.slice(0, 3).map(s => s.skill).join(', ')}. Agentes manejan casos similares de formas muy diferentes.` economicImpact: ahtExcessCost,
: 'Procesos no documentados y falta de guías de atención claras.', impactFormula: `${excessHours.toLocaleString()}h ×${HOURLY_COST}/h`,
economicImpact: inefficiencyCost, timeSavings: `${excessHours.toLocaleString()} horas/año en exceso de AHT`,
impactFormula: `Coste anual × ${(inefficiencyPct * 100).toFixed(1)}% ineficiencia = €${(economicModel.currentAnnualCost/1000).toFixed(0)}K × ${(inefficiencyPct * 100).toFixed(1)}%`, recommendation: `Desplegar Copilot IA para agentes: (1) Auto-búsqueda en KB; (2) Sugerencias contextuales en tiempo real; (3) Scripts guiados para casos frecuentes. Reducción esperada: 20-30% AHT. Ahorro: ${formatCurrency(copilotSavings)}/año.`,
recommendation: 'Crear playbooks por tipología de consulta y certificar agentes en procesos estándar.', severity: p50Aht > 420 ? 'critical' : 'warning',
severity: avgCVAHT > 120 ? 'critical' : 'warning',
hasRealData: true hasRealData: true
}); });
} } else {
// AHT dentro de benchmark - mostrar estado positivo
// Análisis de AHT absoluto
if (avgAHT > 420) {
const excessSeconds = avgAHT - 360;
const excessCost = Math.round((excessSeconds / 3600) * totalVolume * 12 * 25);
analyses.push({ analyses.push({
finding: `AHT elevado: ${Math.floor(avgAHT / 60)}:${String(Math.round(avgAHT) % 60).padStart(2, '0')} (benchmark: 6:00)`, finding: `AHT dentro de benchmark: P50 ${Math.floor(p50Aht / 60)}:${String(Math.round(p50Aht) % 60).padStart(2, '0')} (benchmark: 5:00)`,
probableCause: 'Sistemas de información fragmentados, búsquedas manuales excesivas, o falta de herramientas de asistencia al agente.', probableCause: 'Tiempos de gestión eficientes. Procesos operativos optimizados.',
economicImpact: excessCost, economicImpact: 0,
impactFormula: `Exceso ${Math.round(excessSeconds)}s × ${totalVolume.toLocaleString()} int/mes × 12 × €25/h`, impactFormula: 'Sin exceso de coste por AHT',
recommendation: 'Implementar vista unificada de cliente y herramientas de sugerencia automática.', timeSavings: 'Operación eficiente',
severity: avgAHT > 540 ? 'critical' : 'warning', recommendation: 'Mantener nivel actual. Considerar Copilot para mejora continua y reducción adicional de tiempos en casos complejos.',
severity: 'info',
hasRealData: true hasRealData: true
}); });
} }
break; break;
case 'effectiveness_resolution': case 'effectiveness_resolution':
// Análisis de FCR // Análisis principal: FCR Técnico y tasa de transferencias
const annualVolumeEff = Math.round(totalVolume * annualizationFactor);
const transferCount = Math.round(annualVolumeEff * (avgTransferRate / 100));
// Calcular impacto económico de transferencias
const transferCostTotal = Math.round(transferCount * CPI_TCO * 0.5);
// Potencial de mejora con IA
const improvementPotential = avgFCR < 90 ? Math.round((90 - avgFCR) / 100 * annualVolumeEff) : 0;
const potentialSavingsEff = Math.round(improvementPotential * CPI_TCO * 0.3);
// Determinar severidad basada en FCR
const effSeverity = avgFCR < 70 ? 'critical' : avgFCR < 85 ? 'warning' : 'info';
// Construir causa basada en datos
let effCause = '';
if (avgFCR < 70) { if (avgFCR < 70) {
const recontactRate = (100 - avgFCR) / 100; effCause = skillsLowFCR.length > 0
const recontactCost = Math.round(totalVolume * 12 * recontactRate * CPI_TCO); ? `Alta tasa de transferencias (${avgTransferRate.toFixed(0)}%) indica falta de herramientas o autoridad. Crítico en ${skillsLowFCR.slice(0, 2).map(s => s.skill).join(', ')}.`
analyses.push({ : `Transferencias elevadas (${avgTransferRate.toFixed(0)}%): agentes sin información contextual o sin autoridad para resolver.`;
finding: `FCR bajo: ${avgFCR.toFixed(0)}% (benchmark: >75%)`, } else if (avgFCR < 85) {
probableCause: skillsLowFCR.length > 0 effCause = `Transferencias del ${avgTransferRate.toFixed(0)}% indican oportunidad de mejora con asistencia IA para casos complejos.`;
? `Agentes sin autonomía para resolver en ${skillsLowFCR.slice(0, 2).map(s => s.skill).join(', ')}. Políticas de escalado excesivamente restrictivas.` } else {
: 'Falta de información completa en primer contacto o limitaciones de autoridad del agente.', effCause = `FCR Técnico en nivel óptimo. Transferencias del ${avgTransferRate.toFixed(0)}% principalmente en casos que requieren escalación legítima.`;
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 // Construir recomendación
if (avgTransferRate > 15) { let effRecommendation = '';
const transferCost = Math.round(totalVolume * 12 * (avgTransferRate / 100) * CPI_TCO * 0.5); if (avgFCR < 70) {
analyses.push({ effRecommendation = `Desplegar Knowledge Copilot con búsqueda inteligente en KB + Guided Resolution Copilot para casos complejos. Objetivo: FCR >85%. Potencial ahorro: ${formatCurrency(potentialSavingsEff)}/año.`;
finding: `Tasa de transferencias: ${avgTransferRate.toFixed(1)}% (benchmark: <10%)`, } else if (avgFCR < 85) {
probableCause: skillsHighTransfer.length > 0 effRecommendation = `Implementar Copilot de asistencia en tiempo real: sugerencias contextuales + conexión con expertos virtuales para reducir transferencias. Objetivo: FCR >90%.`;
? `Routing inicial incorrecto hacia ${skillsHighTransfer.slice(0, 2).map(s => s.skill).join(', ')}. IVR no identifica correctamente la intención del cliente.` } else {
: 'Reglas de enrutamiento desactualizadas o skills mal definidos.', effRecommendation = `Mantener nivel actual. Considerar IA para análisis de transferencias legítimas y optimización de enrutamiento predictivo.`;
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
});
} }
analyses.push({
finding: `FCR Técnico: ${avgFCR.toFixed(0)}% | Transferencias: ${avgTransferRate.toFixed(0)}% (benchmark: FCR >85%, Transfer <10%)`,
probableCause: effCause,
economicImpact: transferCostTotal,
impactFormula: `${transferCount.toLocaleString()} transferencias/año ×${CPI_TCO}/int × 50% coste adicional`,
timeSavings: `${transferCount.toLocaleString()} transferencias/año (${avgTransferRate.toFixed(0)}% del volumen)`,
recommendation: effRecommendation,
severity: effSeverity,
hasRealData: true
});
break; break;
case 'volumetry_distribution': case 'volumetry_distribution':
@@ -149,13 +210,16 @@ function generateCausalAnalysis(
const topSkill = [...heatmapData].sort((a, b) => b.volume - a.volume)[0]; const topSkill = [...heatmapData].sort((a, b) => b.volume - a.volume)[0];
const topSkillPct = topSkill ? (topSkill.volume / totalVolume) * 100 : 0; const topSkillPct = topSkill ? (topSkill.volume / totalVolume) * 100 : 0;
if (topSkillPct > 40 && topSkill) { if (topSkillPct > 40 && topSkill) {
const deflectionPotential = Math.round(topSkill.volume * 12 * CPI_TCO * 0.20); const annualTopSkillVolume = Math.round(topSkill.volume * annualizationFactor);
const deflectionPotential = Math.round(annualTopSkillVolume * CPI_TCO * 0.20);
const interactionsDeflectable = Math.round(annualTopSkillVolume * 0.20);
analyses.push({ analyses.push({
finding: `Concentración de volumen: ${topSkill.skill} representa ${topSkillPct.toFixed(0)}% del total`, 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.', probableCause: `Alta concentración en un skill indica consultas repetitivas con potencial de automatización.`,
economicImpact: deflectionPotential, economicImpact: deflectionPotential,
impactFormula: `${topSkill.volume.toLocaleString()} int × 12 ×${CPI_TCO} × 20% deflexión potencial`, impactFormula: `${topSkill.volume.toLocaleString()} int × anualización ×${CPI_TCO} × 20% deflexión potencial`,
recommendation: `Analizar top consultas de ${topSkill.skill} para identificar candidatas a deflexión digital o FAQ automatizado.`, timeSavings: `${annualTopSkillVolume.toLocaleString()} interacciones/año en ${topSkill.skill} (${interactionsDeflectable.toLocaleString()} automatizables)`,
recommendation: `Analizar tipologías de ${topSkill.skill} para deflexión a autoservicio o agente virtual. Potencial: ${formatCurrency(deflectionPotential)}/año.`,
severity: 'info', severity: 'info',
hasRealData: true hasRealData: true
}); });
@@ -163,65 +227,102 @@ function generateCausalAnalysis(
break; break;
case 'complexity_predictability': case 'complexity_predictability':
// v3.11: Análisis de complejidad basado en hold time y CV // KPI principal: CV AHT (predictability metric per industry standards)
if (avgHoldTime > 45) { // Siempre mostrar análisis de CV AHT ya que es el KPI de esta dimensión
const excessHold = avgHoldTime - 30; const cvBenchmark = 75; // Best practice: CV AHT < 75%
const holdCost = Math.round((excessHold / 3600) * totalVolume * 12 * 25);
if (avgCVAHT > cvBenchmark) {
const staffingCost = Math.round(economicModel.currentAnnualCost * 0.03);
const staffingHours = Math.round(staffingCost / HOURLY_COST);
const standardizationSavings = Math.round(staffingCost * 0.50);
// Determinar severidad basada en CV AHT
const cvSeverity = avgCVAHT > 125 ? 'critical' : avgCVAHT > 100 ? 'warning' : 'warning';
// Causa dinámica basada en nivel de variabilidad
const cvCause = avgCVAHT > 125
? 'Dispersión extrema en tiempos de atención impide planificación efectiva de recursos. Probable falta de scripts o procesos estandarizados.'
: 'Variabilidad moderada en tiempos indica oportunidad de estandarización para mejorar planificación WFM.';
analyses.push({ analyses.push({
finding: `Hold time elevado: ${avgHoldTime.toFixed(0)}s promedio (benchmark: <30s)`, finding: `CV AHT elevado: ${avgCVAHT.toFixed(0)}% (benchmark: <${cvBenchmark}%)`,
probableCause: 'Consultas complejas requieren búsqueda de información durante la llamada. Posible falta de acceso rápido a datos o sistemas.', probableCause: cvCause,
economicImpact: holdCost, economicImpact: staffingCost,
impactFormula: `Exceso ${Math.round(excessHold)}s × ${totalVolume.toLocaleString()} int × 12 × €25/h`, impactFormula: `~3% del coste operativo por ineficiencia de staffing`,
recommendation: 'Implementar acceso contextual a información del cliente y reducir sistemas fragmentados.', timeSavings: `~${staffingHours.toLocaleString()} horas/año en sobre/subdimensionamiento`,
severity: avgHoldTime > 60 ? 'critical' : 'warning', recommendation: `Implementar scripts guiados por IA que estandaricen la atención. Reducción esperada: -50% variabilidad. Ahorro: ${formatCurrency(standardizationSavings)}/año.`,
severity: cvSeverity,
hasRealData: true
});
} else {
// CV AHT dentro de benchmark - mostrar estado positivo
analyses.push({
finding: `CV AHT dentro de benchmark: ${avgCVAHT.toFixed(0)}% (benchmark: <${cvBenchmark}%)`,
probableCause: 'Tiempos de atención consistentes. Buena estandarización de procesos.',
economicImpact: 0,
impactFormula: 'Sin impacto por variabilidad',
timeSavings: 'Planificación WFM eficiente',
recommendation: 'Mantener nivel actual. Analizar casos atípicos para identificar oportunidades de mejora continua.',
severity: 'info',
hasRealData: true hasRealData: true
}); });
} }
if (avgCVAHT > 100) { // Análisis secundario: Hold Time (proxy de complejidad)
if (avgHoldTime > 45) {
const excessHold = avgHoldTime - 30;
const annualVolumeHold = Math.round(totalVolume * annualizationFactor);
const excessHoldHours = Math.round((excessHold / 3600) * annualVolumeHold);
const holdCost = Math.round(excessHoldHours * HOURLY_COST);
const searchCopilotSavings = Math.round(holdCost * 0.60);
analyses.push({ analyses.push({
finding: `Alta impredecibilidad: CV AHT ${avgCVAHT.toFixed(0)}% (benchmark: <75%)`, finding: `Hold time elevado: ${avgHoldTime.toFixed(0)}s promedio (benchmark: <30s)`,
probableCause: 'Procesos con alta variabilidad dificultan la planificación de recursos y el staffing.', probableCause: 'Agentes ponen cliente en espera para buscar información. Sistemas no presentan datos de forma contextual.',
economicImpact: Math.round(economicModel.currentAnnualCost * 0.03), economicImpact: holdCost,
impactFormula: `~3% del coste operativo por ineficiencia de staffing`, impactFormula: `Exceso ${Math.round(excessHold)}s × ${totalVolume.toLocaleString()} int × anualización ×${HOURLY_COST}/h`,
recommendation: 'Segmentar procesos por complejidad y estandarizar los más frecuentes.', timeSavings: `${excessHoldHours.toLocaleString()} horas/año de cliente en espera`,
severity: 'warning', recommendation: `Desplegar vista 360° con contexto automático: historial, productos y acciones sugeridas visibles al contestar. Reducción esperada: -60% hold time. Ahorro: ${formatCurrency(searchCopilotSavings)}/año.`,
severity: avgHoldTime > 60 ? 'critical' : 'warning',
hasRealData: true hasRealData: true
}); });
} }
break; break;
case 'customer_satisfaction': case 'customer_satisfaction':
// v3.11: Solo generar análisis si hay datos de CSAT reales // Solo generar análisis si hay datos de CSAT reales
if (avgCSAT > 0) { if (avgCSAT > 0) {
if (avgCSAT < 70) { if (avgCSAT < 70) {
// Estimación conservadora: impacto en retención const annualVolumeCsat = Math.round(totalVolume * annualizationFactor);
const churnRisk = Math.round(totalVolume * 12 * 0.02 * 50); // 2% churn × €50 valor medio const customersAtRisk = Math.round(annualVolumeCsat * 0.02);
const churnRisk = Math.round(customersAtRisk * 50);
analyses.push({ analyses.push({
finding: `CSAT por debajo del objetivo: ${avgCSAT.toFixed(0)}% (benchmark: >80%)`, 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.', probableCause: 'Clientes insatisfechos por esperas, falta de resolución o experiencia de atención deficiente.',
economicImpact: churnRisk, economicImpact: churnRisk,
impactFormula: `${totalVolume.toLocaleString()} clientes × 12 × 2% riesgo churn × €50 valor`, impactFormula: `${totalVolume.toLocaleString()} clientes × anualización × 2% riesgo churn × €50 valor`,
recommendation: 'Implementar programa de voz del cliente (VoC) y cerrar loop de feedback.', timeSavings: `${customersAtRisk.toLocaleString()} clientes/año en riesgo de fuga`,
recommendation: `Implementar programa VoC: encuestas post-contacto + análisis de causas raíz + acción correctiva en 48h. Objetivo: CSAT >80%.`,
severity: avgCSAT < 50 ? 'critical' : 'warning', severity: avgCSAT < 50 ? 'critical' : 'warning',
hasRealData: true hasRealData: true
}); });
} }
} }
// Si no hay CSAT, no generamos análisis falso
break; break;
case 'economy_cpi': case 'economy_cpi':
// Análisis de CPI // Análisis de CPI
if (CPI > 3.5) { if (CPI > 3.5) {
const excessCPI = CPI - CPI_TCO; const excessCPI = CPI - CPI_TCO;
const potentialSavings = Math.round(totalVolume * 12 * excessCPI); const annualVolumeCpi = Math.round(totalVolume * annualizationFactor);
const potentialSavings = Math.round(annualVolumeCpi * excessCPI);
const excessHours = Math.round(potentialSavings / HOURLY_COST);
analyses.push({ analyses.push({
finding: `CPI por encima del benchmark: €${CPI.toFixed(2)} (objetivo: €${CPI_TCO})`, 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.', probableCause: 'Coste por interacción elevado por AHT alto, baja ocupación o estructura de costes ineficiente.',
economicImpact: potentialSavings, economicImpact: potentialSavings,
impactFormula: `${totalVolume.toLocaleString()} int × 12 ×${excessCPI.toFixed(2)} exceso CPI`, impactFormula: `${totalVolume.toLocaleString()} int × anualización ×${excessCPI.toFixed(2)} exceso CPI`,
recommendation: 'Revisar mix de canales, optimizar procesos para reducir AHT y evaluar modelo de staffing.', timeSavings: `${excessCPI.toFixed(2)} exceso/int × ${annualVolumeCpi.toLocaleString()} int = ${excessHours.toLocaleString()}h equivalentes`,
recommendation: `Optimizar mix de canales + reducir AHT con automatización + revisar modelo de staffing. Objetivo: CPI <€${CPI_TCO}.`,
severity: CPI > 5 ? 'critical' : 'warning', severity: CPI > 5 ? 'critical' : 'warning',
hasRealData: true hasRealData: true
}); });
@@ -362,11 +463,11 @@ function DimensionCard({
</div> </div>
)} )}
{/* Análisis Causal Completo - Solo si hay datos */} {/* Hallazgo Clave - Solo si hay datos */}
{dimension.score >= 0 && causalAnalyses.length > 0 && ( {dimension.score >= 0 && causalAnalyses.length > 0 && (
<div className="p-4 space-y-3"> <div className="p-4 space-y-3">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider"> <h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Análisis Causal Hallazgo Clave
</h4> </h4>
{causalAnalyses.map((analysis, idx) => { {causalAnalyses.map((analysis, idx) => {
const config = getSeverityConfig(analysis.severity); const config = getSeverityConfig(analysis.severity);
@@ -395,10 +496,18 @@ function DimensionCard({
<span className="text-xs font-bold text-red-600"> <span className="text-xs font-bold text-red-600">
{formatCurrency(analysis.economicImpact)} {formatCurrency(analysis.economicImpact)}
</span> </span>
<span className="text-xs text-gray-500">impacto anual estimado</span> <span className="text-xs text-gray-500">impacto anual (coste del problema)</span>
<span className="text-xs text-gray-400">i</span> <span className="text-xs text-gray-400">i</span>
</div> </div>
{/* Ahorro de tiempo - da credibilidad al cálculo económico */}
{analysis.timeSavings && (
<div className="ml-6 mb-2 flex items-center gap-2">
<Clock className="w-3 h-3 text-blue-500" />
<span className="text-xs text-blue-700">{analysis.timeSavings}</span>
</div>
)}
{/* Recomendación inline */} {/* Recomendación inline */}
<div className="ml-6 p-2 bg-white rounded border border-gray-200"> <div className="ml-6 p-2 bg-white rounded border border-gray-200">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
@@ -412,7 +521,7 @@ function DimensionCard({
</div> </div>
)} )}
{/* Fallback: Hallazgos originales si no hay análisis causal - Solo si hay datos */} {/* Fallback: Hallazgos originales si no hay hallazgo clave - Solo si hay datos */}
{dimension.score >= 0 && causalAnalyses.length === 0 && findings.length > 0 && ( {dimension.score >= 0 && causalAnalyses.length === 0 && findings.length > 0 && (
<div className="p-4"> <div className="p-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2"> <h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
@@ -445,7 +554,7 @@ function DimensionCard({
</div> </div>
)} )}
{/* Recommendations Preview - Solo si no hay análisis causal y hay datos */} {/* Recommendations Preview - Solo si no hay hallazgo clave y hay datos */}
{dimension.score >= 0 && causalAnalyses.length === 0 && recommendations.length > 0 && ( {dimension.score >= 0 && causalAnalyses.length === 0 && recommendations.length > 0 && (
<div className="px-4 pb-4"> <div className="px-4 pb-4">
<div className="p-3 bg-blue-50 rounded-lg border border-blue-100"> <div className="p-3 bg-blue-50 rounded-lg border border-blue-100">
@@ -473,9 +582,9 @@ export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
const getRecommendationsForDimension = (dimensionId: string) => const getRecommendationsForDimension = (dimensionId: string) =>
data.recommendations.filter(r => r.dimensionId === dimensionId); data.recommendations.filter(r => r.dimensionId === dimensionId);
// Generar análisis causal para cada dimensión // Generar hallazgo clave para cada dimensión
const getCausalAnalysisForDimension = (dimension: DimensionAnalysis) => const getCausalAnalysisForDimension = (dimension: DimensionAnalysis) =>
generateCausalAnalysis(dimension, data.heatmapData, data.economicModel); generateCausalAnalysis(dimension, data.heatmapData, data.economicModel, data.staticConfig, data.dateRange);
// Calcular impacto total de todas las dimensiones con datos // Calcular impacto total de todas las dimensiones con datos
const impactoTotal = coreDimensions const impactoTotal = coreDimensions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,8 @@ import {
formatNumber, formatNumber,
formatPercent, formatPercent,
} from '../../config/designSystem'; } from '../../config/designSystem';
import OpportunityMatrixPro from '../OpportunityMatrixPro';
import OpportunityPrioritizer from '../OpportunityPrioritizer';
interface RoadmapTabProps { interface RoadmapTabProps {
data: AnalysisData; data: AnalysisData;
@@ -372,12 +374,6 @@ const formatROI = (roi: number, roiAjustado: number): {
return { text: roiDisplay, showAjustado, isHighWarning }; return { text: roiDisplay, showAjustado, isHighWarning };
}; };
const 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()}`;
};
// ========== COMPONENTE: MAPA DE OPORTUNIDADES v3.5 ========== // ========== COMPONENTE: MAPA DE OPORTUNIDADES v3.5 ==========
// Ejes actualizados: // Ejes actualizados:
// - X: FACTIBILIDAD = Score Agentic Readiness (0-10) // - X: FACTIBILIDAD = Score Agentic Readiness (0-10)
@@ -415,24 +411,31 @@ const CPI_CONFIG = {
RATE_AUGMENT: 0.15 // 15% mejora en optimización RATE_AUGMENT: 0.15 // 15% mejora en optimización
}; };
// v3.6: Calcular ahorro TCO realista con fórmula explícita // Período de datos: el volumen corresponde a 11 meses, no es mensual
const DATA_PERIOD_MONTHS = 11;
// v4.2: Calcular ahorro TCO realista con fórmula explícita
// IMPORTANTE: El volumen es de 11 meses, se convierte a anual: (Vol/11) × 12
function calculateTCOSavings(volume: number, tier: AgenticTier): number { function calculateTCOSavings(volume: number, tier: AgenticTier): number {
if (volume === 0) return 0; if (volume === 0) return 0;
const { CPI_HUMANO, CPI_BOT, CPI_ASSIST, CPI_AUGMENT, RATE_AUTOMATE, RATE_ASSIST, RATE_AUGMENT } = CPI_CONFIG; const { CPI_HUMANO, CPI_BOT, CPI_ASSIST, CPI_AUGMENT, RATE_AUTOMATE, RATE_ASSIST, RATE_AUGMENT } = CPI_CONFIG;
// Convertir volumen del período (11 meses) a volumen anual
const annualVolume = (volume / DATA_PERIOD_MONTHS) * 12;
switch (tier) { switch (tier) {
case 'AUTOMATE': case 'AUTOMATE':
// Ahorro = Vol × 12 × 70% × (CPI_humano - CPI_bot) // Ahorro = VolAnual × 70% × (CPI_humano - CPI_bot)
return Math.round(volume * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT)); return Math.round(annualVolume * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT));
case 'ASSIST': case 'ASSIST':
// Ahorro = Vol × 12 × 30% × (CPI_humano - CPI_assist) // Ahorro = VolAnual × 30% × (CPI_humano - CPI_assist)
return Math.round(volume * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST)); return Math.round(annualVolume * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST));
case 'AUGMENT': case 'AUGMENT':
// Ahorro = Vol × 12 × 15% × (CPI_humano - CPI_augment) // Ahorro = VolAnual × 15% × (CPI_humano - CPI_augment)
return Math.round(volume * 12 * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT)); return Math.round(annualVolume * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT));
case 'HUMAN-ONLY': case 'HUMAN-ONLY':
default: default:
@@ -1736,12 +1739,13 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
const totalVolume = Object.values(tierVolumes).reduce((a, b) => a + b, 0) || 1; const totalVolume = Object.values(tierVolumes).reduce((a, b) => a + b, 0) || 1;
// Calcular ahorros potenciales por tier usando fórmula TCO // Calcular ahorros potenciales por tier usando fórmula TCO
// IMPORTANTE: El volumen es de 11 meses, se convierte a anual: (Vol/11) × 12
const { CPI_HUMANO, CPI_BOT, CPI_ASSIST, CPI_AUGMENT, RATE_AUTOMATE, RATE_ASSIST, RATE_AUGMENT } = CPI_CONFIG; const { CPI_HUMANO, CPI_BOT, CPI_ASSIST, CPI_AUGMENT, RATE_AUTOMATE, RATE_ASSIST, RATE_AUGMENT } = CPI_CONFIG;
const potentialSavings = { const potentialSavings = {
AUTOMATE: Math.round(tierVolumes.AUTOMATE * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT)), AUTOMATE: Math.round((tierVolumes.AUTOMATE / DATA_PERIOD_MONTHS) * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT)),
ASSIST: Math.round(tierVolumes.ASSIST * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST)), ASSIST: Math.round((tierVolumes.ASSIST / DATA_PERIOD_MONTHS) * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST)),
AUGMENT: Math.round(tierVolumes.AUGMENT * 12 * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT)) AUGMENT: Math.round((tierVolumes.AUGMENT / DATA_PERIOD_MONTHS) * 12 * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT))
}; };
// Colas que necesitan Wave 1 (Tier 3 + 4) // Colas que necesitan Wave 1 (Tier 3 + 4)
@@ -1797,7 +1801,7 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
borderColor: 'border-amber-200', borderColor: 'border-amber-200',
inversionSetup: 35000, inversionSetup: 35000,
costoRecurrenteAnual: 40000, costoRecurrenteAnual: 40000,
ahorroAnual: potentialSavings.AUGMENT || 58000, // 15% efficiency ahorroAnual: potentialSavings.AUGMENT, // 15% efficiency - calculado desde datos reales
esCondicional: true, esCondicional: true,
condicion: 'Requiere CV ≤75% post-Wave 1 en colas target', condicion: 'Requiere CV ≤75% post-Wave 1 en colas target',
porQueNecesario: `Implementar herramientas de soporte para colas Tier 3 (Score 3.5-5.5). Objetivo: elevar score a ≥5.5 para habilitar Wave 3. Foco en ${tierCounts.AUGMENT.length} colas con ${tierVolumes.AUGMENT.toLocaleString()} int/mes.`, porQueNecesario: `Implementar herramientas de soporte para colas Tier 3 (Score 3.5-5.5). Objetivo: elevar score a ≥5.5 para habilitar Wave 3. Foco en ${tierCounts.AUGMENT.length} colas con ${tierVolumes.AUGMENT.toLocaleString()} int/mes.`,
@@ -1830,7 +1834,7 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
borderColor: 'border-blue-200', borderColor: 'border-blue-200',
inversionSetup: 70000, inversionSetup: 70000,
costoRecurrenteAnual: 78000, costoRecurrenteAnual: 78000,
ahorroAnual: potentialSavings.ASSIST || 145000, // 30% efficiency ahorroAnual: potentialSavings.ASSIST, // 30% efficiency - calculado desde datos reales
esCondicional: true, esCondicional: true,
condicion: 'Requiere Score ≥5.5 Y CV ≤90% Y Transfer ≤30%', condicion: 'Requiere Score ≥5.5 Y CV ≤90% Y Transfer ≤30%',
porQueNecesario: `Copilot IA para agentes en colas Tier 2. Sugerencias en tiempo real, autocompletado, next-best-action. Objetivo: elevar score a ≥7.5 para Wave 4. Target: ${tierCounts.ASSIST.length} colas con ${tierVolumes.ASSIST.toLocaleString()} int/mes.`, porQueNecesario: `Copilot IA para agentes en colas Tier 2. Sugerencias en tiempo real, autocompletado, next-best-action. Objetivo: elevar score a ≥7.5 para Wave 4. Target: ${tierCounts.ASSIST.length} colas con ${tierVolumes.ASSIST.toLocaleString()} int/mes.`,
@@ -1864,7 +1868,7 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
borderColor: 'border-emerald-200', borderColor: 'border-emerald-200',
inversionSetup: 85000, inversionSetup: 85000,
costoRecurrenteAnual: 108000, costoRecurrenteAnual: 108000,
ahorroAnual: potentialSavings.AUTOMATE || 380000, // 70% containment ahorroAnual: potentialSavings.AUTOMATE, // 70% containment - calculado desde datos reales
esCondicional: true, esCondicional: true,
condicion: 'Requiere Score ≥7.5 Y CV ≤75% Y Transfer ≤20% Y FCR ≥50%', condicion: 'Requiere Score ≥7.5 Y CV ≤75% Y Transfer ≤20% Y FCR ≥50%',
porQueNecesario: `Automatización end-to-end para colas Tier 1. Voicebot/Chatbot transaccional con 70% contención. Solo viable con procesos maduros. Target actual: ${tierCounts.AUTOMATE.length} colas con ${tierVolumes.AUTOMATE.toLocaleString()} int/mes.`, porQueNecesario: `Automatización end-to-end para colas Tier 1. Voicebot/Chatbot transaccional con 70% contención. Solo viable con procesos maduros. Target actual: ${tierCounts.AUTOMATE.length} colas con ${tierVolumes.AUTOMATE.toLocaleString()} int/mes.`,
@@ -1906,9 +1910,10 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
const wave4Setup = 85000; const wave4Setup = 85000;
const wave4Rec = 108000; const wave4Rec = 108000;
const wave2Savings = potentialSavings.AUGMENT || Math.round(tierVolumes.AUGMENT * 12 * 0.15 * 0.33); // Usar potentialSavings (ya corregidos con factor 12/11)
const wave3Savings = potentialSavings.ASSIST || Math.round(tierVolumes.ASSIST * 12 * 0.30 * 0.83); const wave2Savings = potentialSavings.AUGMENT;
const wave4Savings = potentialSavings.AUTOMATE || Math.round(tierVolumes.AUTOMATE * 12 * 0.70 * 2.18); const wave3Savings = potentialSavings.ASSIST;
const wave4Savings = potentialSavings.AUTOMATE;
// Escenario 1: Conservador (Wave 1-2: FOUNDATION + AUGMENT) // Escenario 1: Conservador (Wave 1-2: FOUNDATION + AUGMENT)
const consInversion = wave1Setup + wave2Setup; const consInversion = wave1Setup + wave2Setup;
@@ -2520,85 +2525,17 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
</div> </div>
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
{/* ENFOQUE DUAL: Explicación + Tabla comparativa */} {/* ENFOQUE DUAL: Párrafo explicativo */}
{recType === 'DUAL' && ( {recType === 'DUAL' && (
<> <p className="text-sm text-gray-600 leading-relaxed">
{/* Explicación de los dos tracks */} La Estrategia Dual consiste en ejecutar dos líneas de trabajo en paralelo:
<div className="grid grid-cols-2 gap-4 text-sm"> <strong className="text-gray-800"> Quick Win</strong> automatiza inmediatamente las {pilotQueues.length} colas
<div className="p-3 bg-gray-50 rounded-lg"> ya preparadas (Tier AUTOMATE, {Math.round(totalVolume > 0 ? (tierVolumes.AUTOMATE / totalVolume) * 100 : 0)}% del volumen), generando retorno desde el primer mes;
<p className="font-semibold text-gray-800 mb-1">Track A: Quick Win</p> mientras que <strong className="text-gray-800">Foundation</strong> prepara el {Math.round(assistPct + augmentPct)}%
<p className="text-xs text-gray-600"> restante del volumen (Tiers ASSIST y AUGMENT) estandarizando procesos y reduciendo variabilidad para habilitar
Automatización inmediata de las colas ya preparadas (Tier AUTOMATE). automatización futura. Este enfoque maximiza el time-to-value: Quick Win financia la transformación y genera
Genera retorno desde el primer mes y valida el modelo de IA con bajo riesgo. confianza organizacional, mientras Foundation amplía progresivamente el alcance de la automatización.
</p> </p>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<p className="font-semibold text-gray-800 mb-1">Track B: Foundation</p>
<p className="text-xs text-gray-600">
Preparación de las colas que aún no están listas (Tier 3-4).
Estandariza procesos y reduce variabilidad para habilitar automatización futura.
</p>
</div>
</div>
{/* Tabla comparativa */}
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 font-medium text-gray-500"></th>
<th className="text-center py-2 font-medium text-gray-700">Quick Win</th>
<th className="text-center py-2 font-medium text-gray-700">Foundation</th>
</tr>
</thead>
<tbody className="text-gray-600">
<tr className="border-b border-gray-100">
<td className="py-2 text-gray-500 text-xs">Alcance</td>
<td className="py-2 text-center">
<span className="font-medium text-gray-800">{pilotQueues.length} colas</span>
<span className="text-xs text-gray-400 block">{pilotVolume.toLocaleString()} int/mes</span>
</td>
<td className="py-2 text-center">
<span className="font-medium text-gray-800">{tierCounts['HUMAN-ONLY'].length + tierCounts.AUGMENT.length} colas</span>
<span className="text-xs text-gray-400 block">Wave 1 + Wave 2</span>
</td>
</tr>
<tr className="border-b border-gray-100">
<td className="py-2 text-gray-500 text-xs">Inversión</td>
<td className="py-2 text-center font-medium text-gray-800">{formatCurrency(pilotInversionTotal)}</td>
<td className="py-2 text-center font-medium text-gray-800">{formatCurrency(wave1Setup + wave2Setup)}</td>
</tr>
<tr className="border-b border-gray-100">
<td className="py-2 text-gray-500 text-xs">Retorno</td>
<td className="py-2 text-center">
<span className="font-medium text-gray-800">{formatCurrency(pilotAhorroAjustado)}/año</span>
<span className="text-xs text-gray-400 block">directo (ajustado 50%)</span>
</td>
<td className="py-2 text-center">
<span className="font-medium text-gray-800">{formatCurrency(potentialSavings.ASSIST + potentialSavings.AUGMENT)}/año</span>
<span className="text-xs text-gray-400 block">habilitado (indirecto)</span>
</td>
</tr>
<tr className="border-b border-gray-100">
<td className="py-2 text-gray-500 text-xs">Timeline</td>
<td className="py-2 text-center text-gray-800">2-3 meses</td>
<td className="py-2 text-center text-gray-800">6-9 meses</td>
</tr>
<tr>
<td className="py-2 text-gray-500 text-xs">ROI Year 1</td>
<td className="py-2 text-center">
<span className="font-semibold text-gray-900">{pilotROIDisplay.display}</span>
</td>
<td className="py-2 text-center text-gray-500 text-xs">No aplica (habilitador)</td>
</tr>
</tbody>
</table>
<div className="text-xs text-gray-500 border-t border-gray-100 pt-3">
<strong className="text-gray-700">¿Por qué dos tracks?</strong> Quick Win genera caja y confianza desde el inicio.
Foundation prepara el {Math.round(assistPct + augmentPct)}% restante del volumen para fases posteriores.
Ejecutarlos en paralelo acelera el time-to-value total.
</div>
</>
)} )}
{/* FOUNDATION PRIMERO */} {/* FOUNDATION PRIMERO */}
@@ -2765,6 +2702,16 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
)} )}
</Card> </Card>
{/* ═══════════════════════════════════════════════════════════════════════════
OPORTUNIDADES PRIORIZADAS - Nueva visualización clara y accionable
═══════════════════════════════════════════════════════════════════════════ */}
{data.opportunities && data.opportunities.length > 0 && (
<OpportunityPrioritizer
opportunities={data.opportunities}
drilldownData={data.drilldownData}
/>
)}
</div> </div>
); );
} }

View File

@@ -96,7 +96,8 @@ export interface OriginalQueueMetrics {
aht_mean: number; // AHT promedio (segundos) aht_mean: number; // AHT promedio (segundos)
cv_aht: number; // CV AHT calculado solo sobre VALID (%) cv_aht: number; // CV AHT calculado solo sobre VALID (%)
transfer_rate: number; // Tasa de transferencia (%) transfer_rate: number; // Tasa de transferencia (%)
fcr_rate: number; // FCR (%) fcr_rate: number; // FCR Real (%) - usa fcr_real_flag, incluye filtro recontacto 7d
fcr_tecnico: number; // FCR Técnico (%) = 100 - transfer_rate, comparable con benchmarks
agenticScore: number; // Score de automatización (0-10) agenticScore: number; // Score de automatización (0-10)
scoreBreakdown?: AgenticScoreBreakdown; // v3.4: Desglose por factores scoreBreakdown?: AgenticScoreBreakdown; // v3.4: Desglose por factores
tier: AgenticTier; // v3.4: Clasificación para roadmap tier: AgenticTier; // v3.4: Clasificación para roadmap
@@ -115,7 +116,8 @@ export interface DrilldownDataPoint {
aht_mean: number; // AHT promedio ponderado (segundos) aht_mean: number; // AHT promedio ponderado (segundos)
cv_aht: number; // CV AHT promedio ponderado (%) cv_aht: number; // CV AHT promedio ponderado (%)
transfer_rate: number; // Tasa de transferencia ponderada (%) transfer_rate: number; // Tasa de transferencia ponderada (%)
fcr_rate: number; // FCR ponderado (%) fcr_rate: number; // FCR Real ponderado (%) - usa fcr_real_flag
fcr_tecnico: number; // FCR Técnico ponderado (%) = 100 - transfer_rate
agenticScore: number; // Score de automatización promedio (0-10) agenticScore: number; // Score de automatización promedio (0-10)
isPriorityCandidate: boolean; // Al menos una cola con CV < 75% isPriorityCandidate: boolean; // Al menos una cola con CV < 75%
annualCost?: number; // Coste anual total del grupo annualCost?: number; // Coste anual total del grupo
@@ -128,7 +130,9 @@ export interface SkillMetrics {
channel: string; // Canal predominante channel: string; // Canal predominante
// Métricas de rendimiento (calculadas) // Métricas de rendimiento (calculadas)
fcr: number; // FCR aproximado: 100% - transfer_rate fcr: number; // FCR Real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE) - sin recontacto 7 días
fcr_tecnico: number; // FCR Técnico: 100% - transfer_rate (comparable con benchmarks de industria)
fcr_real: number; // Alias de fcr - FCR Real con filtro de recontacto 7 días
aht: number; // AHT = duration_talk + hold_time + wrap_up_time aht: number; // AHT = duration_talk + hold_time + wrap_up_time
avg_talk_time: number; // Promedio duration_talk avg_talk_time: number; // Promedio duration_talk
avg_hold_time: number; // Promedio hold_time avg_hold_time: number; // Promedio hold_time
@@ -205,16 +209,21 @@ export interface HeatmapDataPoint {
skill: string; skill: string;
segment?: CustomerSegment; // Segmento de cliente (high/medium/low) segment?: CustomerSegment; // Segmento de cliente (high/medium/low)
volume: number; // Volumen mensual de interacciones volume: number; // Volumen mensual de interacciones
aht_seconds: number; // AHT en segundos (para cálculo de coste) cost_volume?: number; // Volumen usado para calcular coste (non-abandon)
aht_seconds: number; // AHT "limpio" en segundos (solo valid, excluye noise/zombie/abandon) - para métricas de calidad
aht_total?: number; // AHT "total" en segundos (ALL rows incluyendo noise/zombie/abandon) - solo informativo
aht_benchmark?: number; // AHT "tradicional" en segundos (incluye noise, excluye zombie/abandon) - para comparación con benchmarks de industria
metrics: { metrics: {
fcr: number; // First Contact Resolution score (0-100) - CALCULADO fcr: number; // FCR Real: sin transferencia Y sin recontacto 7 días (0-100) - CALCULADO
fcr_tecnico?: number; // FCR Técnico: sin transferencia (comparable con benchmarks industria)
aht: number; // Average Handle Time score (0-100, donde 100 es óptimo) - CALCULADO aht: number; // Average Handle Time score (0-100, donde 100 es óptimo) - CALCULADO
csat: number; // Customer Satisfaction score (0-100) - MANUAL (estático) csat: number; // Customer Satisfaction score (0-100) - MANUAL (estático)
hold_time: number; // Hold Time promedio (segundos) - CALCULADO hold_time: number; // Hold Time promedio (segundos) - CALCULADO
transfer_rate: number; // % transferencias - CALCULADO transfer_rate: number; // % transferencias - CALCULADO
abandonment_rate: number; // % abandonos - CALCULADO abandonment_rate: number; // % abandonos - CALCULADO
}; };
annual_cost?: number; // Coste anual en euros (calculado con cost_per_hour) annual_cost?: number; // Coste total del período (calculado con cost_per_hour)
cpi?: number; // Coste por interacción = total_cost / cost_volume
// v2.0: Métricas de variabilidad interna // v2.0: Métricas de variabilidad interna
variability: { variability: {

View File

@@ -1,6 +1,6 @@
// analysisGenerator.ts - v2.0 con 6 dimensiones // analysisGenerator.ts - v2.0 con 6 dimensiones
import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment, RawInteraction, DrilldownDataPoint, AgenticTier } from '../types'; 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 { generateAnalysisFromRealData, calculateDrilldownMetrics, generateOpportunitiesFromDrilldown, generateRoadmapFromDrilldown, calculateSkillMetrics, generateHeatmapFromMetrics, clasificarTierSimple } from './realDataAnalysis';
import { RoadmapPhase } from '../types'; import { RoadmapPhase } from '../types';
import { BarChartHorizontal, Zap, Target, Brain, Bot } from 'lucide-react'; import { BarChartHorizontal, Zap, Target, Brain, Bot } from 'lucide-react';
import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2'; import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2';
@@ -9,7 +9,7 @@ import {
mapBackendResultsToAnalysisData, mapBackendResultsToAnalysisData,
buildHeatmapFromBackend, buildHeatmapFromBackend,
} from './backendMapper'; } from './backendMapper';
import { saveFileToServerCache, saveDrilldownToServerCache, getCachedDrilldown } from './serverCache'; import { saveFileToServerCache, saveDrilldownToServerCache, getCachedDrilldown, downloadCachedFile } from './serverCache';
@@ -532,9 +532,12 @@ const generateHeatmapData = (
const transfer_rate = randomInt(5, 35); // % const transfer_rate = randomInt(5, 35); // %
const fcr_approx = 100 - transfer_rate; // FCR aproximado const fcr_approx = 100 - transfer_rate; // FCR aproximado
// Coste anual // Coste del período (mensual) - con factor de productividad 70%
const annual_volume = volume * 12; const effectiveProductivity = 0.70;
const annual_cost = Math.round(annual_volume * aht_mean * COST_PER_SECOND); const period_cost = Math.round((aht_mean / 3600) * costPerHour * volume / effectiveProductivity);
const annual_cost = period_cost; // Renombrado por compatibilidad, pero es coste mensual
// CPI = coste por interacción
const cpi = volume > 0 ? period_cost / volume : 0;
// === NUEVA LÓGICA: 3 DIMENSIONES === // === NUEVA LÓGICA: 3 DIMENSIONES ===
@@ -597,6 +600,7 @@ const generateHeatmapData = (
skill, skill,
segment, segment,
volume, volume,
cost_volume: volume, // En datos sintéticos, asumimos que todos son non-abandon
aht_seconds: aht_mean, // Renombrado para compatibilidad aht_seconds: aht_mean, // Renombrado para compatibilidad
metrics: { metrics: {
fcr: isNaN(fcr_approx) ? 0 : Math.max(0, Math.min(100, Math.round(fcr_approx))), fcr: isNaN(fcr_approx) ? 0 : Math.max(0, Math.min(100, Math.round(fcr_approx))),
@@ -606,6 +610,7 @@ const generateHeatmapData = (
transfer_rate: isNaN(transfer_rate) ? 0 : Math.max(0, Math.min(100, Math.round(transfer_rate * 100))) transfer_rate: isNaN(transfer_rate) ? 0 : Math.max(0, Math.min(100, Math.round(transfer_rate * 100)))
}, },
annual_cost, annual_cost,
cpi,
variability: { variability: {
cv_aht: Math.round(cv_aht * 100), // Convertir a porcentaje cv_aht: Math.round(cv_aht * 100), // Convertir a porcentaje
cv_talk_time: 0, // Deprecado en v2.1 cv_talk_time: 0, // Deprecado en v2.1
@@ -624,29 +629,6 @@ const generateHeatmapData = (
}); });
}; };
// v3.0: Oportunidades con nuevas dimensiones
const generateOpportunityMatrixData = (): Opportunity[] => {
const opportunities = [
{ id: 'opp1', name: 'Automatizar consulta de pedidos', savings: 85000, dimensionId: 'agentic_readiness', customer_segment: 'medium' as CustomerSegment },
{ id: 'opp2', name: 'Implementar Knowledge Base dinámica', savings: 45000, dimensionId: 'operational_efficiency', customer_segment: 'high' as CustomerSegment },
{ id: 'opp3', name: 'Chatbot de triaje inicial', savings: 120000, dimensionId: 'effectiveness_resolution', customer_segment: 'medium' as CustomerSegment },
{ id: 'opp4', name: 'Reducir complejidad en colas críticas', savings: 30000, dimensionId: 'complexity_predictability', customer_segment: 'high' as CustomerSegment },
{ id: 'opp5', name: 'Cobertura 24/7 con agentes virtuales', savings: 65000, dimensionId: 'volumetry_distribution', customer_segment: 'low' as CustomerSegment },
];
return opportunities.map(opp => ({ ...opp, impact: randomInt(3, 10), feasibility: randomInt(2, 9) }));
};
// v3.0: Roadmap con nuevas dimensiones
const generateRoadmapData = (): RoadmapInitiative[] => {
return [
{ id: 'r1', name: 'Chatbot de estado de pedido', phase: RoadmapPhase.Automate, timeline: 'Q1 2025', investment: 25000, resources: ['1x Bot Developer', 'API Access'], dimensionId: 'agentic_readiness', risk: 'low' },
{ id: 'r2', name: 'Implementar Knowledge Base dinámica', phase: RoadmapPhase.Assist, timeline: 'Q1 2025', investment: 15000, resources: ['1x PM', 'Content Team'], dimensionId: 'operational_efficiency', risk: 'low' },
{ id: 'r3', name: 'Agent Assist para sugerencias en real-time', phase: RoadmapPhase.Assist, timeline: 'Q2 2025', investment: 45000, resources: ['2x AI Devs', 'QA Team'], dimensionId: 'effectiveness_resolution', risk: 'medium' },
{ id: 'r4', name: 'Estandarización de procesos complejos', phase: RoadmapPhase.Augment, timeline: 'Q3 2025', investment: 30000, resources: ['Process Analyst', 'Training Team'], dimensionId: 'complexity_predictability', risk: 'medium' },
{ id: 'r5', name: 'Cobertura 24/7 con agentes virtuales', phase: RoadmapPhase.Augment, timeline: 'Q4 2025', investment: 75000, resources: ['Lead AI Engineer', 'Data Scientist'], dimensionId: 'volumetry_distribution', risk: 'high' },
];
};
// v2.0: Añadir NPV y costBreakdown // v2.0: Añadir NPV y costBreakdown
const generateEconomicModelData = (): EconomicModelData => { const generateEconomicModelData = (): EconomicModelData => {
const currentAnnualCost = randomInt(800000, 2500000); const currentAnnualCost = randomInt(800000, 2500000);
@@ -691,123 +673,6 @@ const generateEconomicModelData = (): EconomicModelData => {
}; };
}; };
// v2.x: Generar Opportunity Matrix a partir de datos REALES (heatmap + modelo económico)
const generateOpportunitiesFromHeatmap = (
heatmapData: HeatmapDataPoint[],
economicModel?: EconomicModelData
): Opportunity[] => {
if (!heatmapData || heatmapData.length === 0) return [];
// Ahorro anual total calculado por el backend (si existe)
const globalSavings = economicModel?.annualSavings ?? 0;
// 1) Calculamos un "peso" por skill en función de:
// - coste anual
// - ineficiencia (FCR bajo)
// - readiness (facilidad para automatizar)
const scored = heatmapData.map((h) => {
const annualCost = h.annual_cost ?? 0;
const readiness = h.automation_readiness ?? 0;
const fcrScore = h.metrics?.fcr ?? 0;
// FCR bajo => más ineficiencia
const ineffPenalty = Math.max(0, 100 - fcrScore); // 0100
// Peso base: coste alto + ineficiencia alta + readiness alto
const baseWeight =
annualCost *
(1 + ineffPenalty / 100) *
(0.3 + 0.7 * (readiness / 100));
const weight = !Number.isFinite(baseWeight) || baseWeight < 0 ? 0 : baseWeight;
return { heat: h, weight };
});
const totalWeight =
scored.reduce((sum, s) => sum + s.weight, 0) || 1;
// 2) Asignamos "savings" (ahorro potencial) por skill
const opportunitiesWithSavings = scored.map((s) => {
const { heat } = s;
const annualCost = heat.annual_cost ?? 0;
// Si el backend nos da un ahorro anual total, lo distribuimos proporcionalmente
const savings =
globalSavings > 0 && totalWeight > 0
? (globalSavings * s.weight) / totalWeight
: // Si no hay dato de ahorro global, suponemos un 20% del coste anual
annualCost * 0.2;
return {
heat,
savings: Math.max(0, savings),
};
});
const maxSavings =
opportunitiesWithSavings.reduce(
(max, s) => (s.savings > max ? s.savings : max),
0
) || 1;
// 3) Construimos cada oportunidad
return opportunitiesWithSavings.map((item, index) => {
const { heat, savings } = item;
const skillName = heat.skill || `Skill ${index + 1}`;
// Impacto: relativo al mayor ahorro
const impactRaw = (savings / maxSavings) * 10;
const impact = Math.max(
3,
Math.min(10, Math.round(impactRaw))
);
// Factibilidad base: a partir del automation_readiness (0100)
const readiness = heat.automation_readiness ?? 0;
const feasibilityRaw = (readiness / 100) * 7 + 3; // 310
const feasibility = Math.max(
3,
Math.min(10, Math.round(feasibilityRaw))
);
// Dimensión a la que lo vinculamos
const dimensionId =
readiness >= 70
? 'agentic_readiness'
: readiness >= 40
? 'effectiveness_resolution'
: 'complexity_predictability';
// Segmento de cliente (high/medium/low) si lo tenemos
const customer_segment = heat.segment;
// Nombre legible que incluye el skill -> esto ayuda a
// OpportunityMatrixPro a encontrar el skill en el heatmap
const namePrefix =
readiness >= 70
? 'Automatizar '
: readiness >= 40
? 'Asistir con IA en '
: 'Optimizar procesos en ';
const idSlug = skillName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return {
id: `opp_${index + 1}_${idSlug}`,
name: `${namePrefix}${skillName}`,
impact,
feasibility,
savings: Math.round(savings),
dimensionId,
customer_segment,
};
});
};
// v2.0: Añadir percentiles múltiples // v2.0: Añadir percentiles múltiples
const generateBenchmarkData = (): BenchmarkDataPoint[] => { const generateBenchmarkData = (): BenchmarkDataPoint[] => {
const userAHT = randomInt(380, 450); const userAHT = randomInt(380, 450);
@@ -929,27 +794,41 @@ export const generateAnalysis = async (
// Añadir dateRange extraído del archivo // Añadir dateRange extraído del archivo
mapped.dateRange = dateRange; mapped.dateRange = dateRange;
// Heatmap: primero lo construimos a partir de datos reales del backend // Heatmap: usar cálculos del frontend (parsedInteractions) para consistencia
mapped.heatmapData = buildHeatmapFromBackend( // Esto asegura que dashboard muestre los mismos valores que los logs de realDataAnalysis
raw, if (parsedInteractions && parsedInteractions.length > 0) {
costPerHour, const skillMetrics = calculateSkillMetrics(parsedInteractions, costPerHour);
avgCsat, mapped.heatmapData = generateHeatmapFromMetrics(skillMetrics, avgCsat, segmentMapping);
segmentMapping console.log('📊 Heatmap generado desde frontend (parsedInteractions) - métricas consistentes');
); } else {
// Fallback: usar backend si no hay parsedInteractions
mapped.heatmapData = buildHeatmapFromBackend(
raw,
costPerHour,
avgCsat,
segmentMapping
);
console.log('📊 Heatmap generado desde backend (fallback - sin parsedInteractions)');
}
// v3.5: Calcular drilldownData PRIMERO (necesario para opportunities y roadmap) // v3.5: Calcular drilldownData PRIMERO (necesario para opportunities y roadmap)
if (parsedInteractions && parsedInteractions.length > 0) { if (parsedInteractions && parsedInteractions.length > 0) {
mapped.drilldownData = calculateDrilldownMetrics(parsedInteractions, costPerHour); mapped.drilldownData = calculateDrilldownMetrics(parsedInteractions, costPerHour);
console.log(`📊 Drill-down calculado: ${mapped.drilldownData.length} skills, ${mapped.drilldownData.filter(d => d.isPriorityCandidate).length} candidatos prioritarios`); console.log(`📊 Drill-down calculado: ${mapped.drilldownData.length} skills, ${mapped.drilldownData.filter(d => d.isPriorityCandidate).length} candidatos prioritarios`);
// Cachear drilldownData en el servidor para uso futuro (no bloquea) // v4.4: Cachear drilldownData en el servidor ANTES de retornar (fix: era fire-and-forget)
// Esto asegura que el cache esté disponible cuando el usuario haga "Usar Cache"
if (authHeaderOverride && mapped.drilldownData.length > 0) { if (authHeaderOverride && mapped.drilldownData.length > 0) {
saveDrilldownToServerCache(authHeaderOverride, mapped.drilldownData) try {
.then(success => { const cacheSuccess = await saveDrilldownToServerCache(authHeaderOverride, mapped.drilldownData);
if (success) console.log('💾 DrilldownData cacheado en servidor'); if (cacheSuccess) {
else console.warn('⚠️ No se pudo cachear drilldownData'); console.log('💾 DrilldownData cacheado en servidor correctamente');
}) } else {
.catch(err => console.warn('⚠️ Error cacheando drilldownData:', err)); console.warn('⚠️ No se pudo cachear drilldownData - fallback a heatmap en próximo uso');
}
} catch (cacheErr) {
console.warn('⚠️ Error cacheando drilldownData:', cacheErr);
}
} }
// Usar oportunidades y roadmap basados en drilldownData (datos reales) // Usar oportunidades y roadmap basados en drilldownData (datos reales)
@@ -957,13 +836,11 @@ export const generateAnalysis = async (
mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour); mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour);
console.log(`📊 Opportunities: ${mapped.opportunities.length}, Roadmap: ${mapped.roadmap.length}`); console.log(`📊 Opportunities: ${mapped.opportunities.length}, Roadmap: ${mapped.roadmap.length}`);
} else { } else {
console.warn('⚠️ No hay interacciones parseadas, usando heatmap para opportunities'); console.warn('⚠️ No hay interacciones parseadas, usando heatmap para drilldown');
// Fallback: usar heatmap (menos preciso) // v4.3: Generar drilldownData desde heatmap para usar mismas funciones
mapped.opportunities = generateOpportunitiesFromHeatmap( mapped.drilldownData = generateDrilldownFromHeatmap(mapped.heatmapData, costPerHour);
mapped.heatmapData, mapped.opportunities = generateOpportunitiesFromDrilldown(mapped.drilldownData, costPerHour);
mapped.economicModel mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour);
);
mapped.roadmap = generateRoadmapData();
} }
// Findings y recommendations // Findings y recommendations
@@ -1162,16 +1039,62 @@ export const generateAnalysisFromCache = async (
mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour); mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour);
console.log(`📊 Opportunities: ${mapped.opportunities.length}, Roadmap: ${mapped.roadmap.length}`); console.log(`📊 Opportunities: ${mapped.opportunities.length}, Roadmap: ${mapped.roadmap.length}`);
} else if (mapped.heatmapData && mapped.heatmapData.length > 0) { } else if (mapped.heatmapData && mapped.heatmapData.length > 0) {
// Fallback: usar heatmap (solo 9 skills agregados) // v4.5: No hay drilldownData cacheado - intentar calcularlo desde el CSV cacheado
console.warn('⚠️ Sin drilldownData cacheado, usando heatmap fallback'); console.log('⚠️ No cached drilldownData found, attempting to calculate from cached CSV...');
mapped.drilldownData = generateDrilldownFromHeatmap(mapped.heatmapData, costPerHour);
console.log(`📊 Drill-down desde heatmap (fallback): ${mapped.drilldownData.length} skills`);
mapped.opportunities = generateOpportunitiesFromHeatmap( let calculatedDrilldown = false;
mapped.heatmapData,
mapped.economicModel try {
); // Descargar y parsear el CSV cacheado para calcular drilldown real
mapped.roadmap = generateRoadmapData(); const cachedFile = await downloadCachedFile(authHeaderOverride);
if (cachedFile) {
console.log(`📥 Downloaded cached CSV: ${(cachedFile.size / 1024 / 1024).toFixed(2)} MB`);
const { parseFile } = await import('./fileParser');
const parsedInteractions = await parseFile(cachedFile);
if (parsedInteractions && parsedInteractions.length > 0) {
console.log(`📊 Parsed ${parsedInteractions.length} interactions from cached CSV`);
// Calcular drilldown real desde interacciones
mapped.drilldownData = calculateDrilldownMetrics(parsedInteractions, costPerHour);
console.log(`📊 Calculated drilldown: ${mapped.drilldownData.length} skills`);
// Guardar drilldown en cache para próximo uso
try {
const saveSuccess = await saveDrilldownToServerCache(authHeaderOverride, mapped.drilldownData);
if (saveSuccess) {
console.log('💾 DrilldownData saved to cache for future use');
} else {
console.warn('⚠️ Failed to save drilldownData to cache');
}
} catch (saveErr) {
console.warn('⚠️ Error saving drilldownData to cache:', saveErr);
}
calculatedDrilldown = true;
}
}
} catch (csvErr) {
console.warn('⚠️ Could not calculate drilldown from cached CSV:', csvErr);
}
if (!calculatedDrilldown) {
// Fallback final: usar heatmap (datos aproximados)
console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.warn('⚠️ FALLBACK ACTIVO: No hay drilldownData cacheado');
console.warn(' Causa probable: El CSV no se subió correctamente o la caché expiró');
console.warn(' Consecuencia: Usando datos agregados del heatmap (menos precisos)');
console.warn(' Solución: Vuelva a subir el archivo CSV para obtener datos completos');
console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
mapped.drilldownData = generateDrilldownFromHeatmap(mapped.heatmapData, costPerHour);
console.log(`📊 Drill-down desde heatmap (fallback): ${mapped.drilldownData.length} skills agregados`);
}
// Usar mismas funciones que ruta fresh para consistencia
mapped.opportunities = generateOpportunitiesFromDrilldown(mapped.drilldownData, costPerHour);
mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour);
} }
// Findings y recommendations // Findings y recommendations
@@ -1201,15 +1124,21 @@ function generateDrilldownFromHeatmap(
const cvAht = hp.variability?.cv_aht || 0; const cvAht = hp.variability?.cv_aht || 0;
const transferRate = hp.variability?.transfer_rate || hp.metrics?.transfer_rate || 0; const transferRate = hp.variability?.transfer_rate || hp.metrics?.transfer_rate || 0;
const fcrRate = hp.metrics?.fcr || 0; const fcrRate = hp.metrics?.fcr || 0;
// FCR Técnico: usar el campo si existe, sino calcular como 100 - transfer_rate
const fcrTecnico = hp.metrics?.fcr_tecnico ?? (100 - transferRate);
const agenticScore = hp.dimensions const agenticScore = hp.dimensions
? (hp.dimensions.predictability * 0.4 + hp.dimensions.complexity_inverse * 0.35 + hp.dimensions.repetitivity * 0.25) ? (hp.dimensions.predictability * 0.4 + hp.dimensions.complexity_inverse * 0.35 + hp.dimensions.repetitivity * 0.25)
: (hp.automation_readiness || 0) / 10; : (hp.automation_readiness || 0) / 10;
// Determinar tier basado en el score // v4.4: Usar clasificarTierSimple con TODOS los datos disponibles del heatmap
let tier: AgenticTier = 'HUMAN-ONLY'; // cvAht, transferRate y fcrRate están en % (ej: 75), clasificarTierSimple espera decimal (ej: 0.75)
if (agenticScore >= 7.5) tier = 'AUTOMATE'; const tier = clasificarTierSimple(
else if (agenticScore >= 5.5) tier = 'ASSIST'; agenticScore,
else if (agenticScore >= 3.5) tier = 'AUGMENT'; cvAht / 100, // CV como decimal
transferRate / 100, // Transfer como decimal
fcrRate / 100, // FCR como decimal (nuevo en v4.4)
hp.volume // Volumen para red flag check (nuevo en v4.4)
);
return { return {
skill: hp.skill, skill: hp.skill,
@@ -1219,6 +1148,7 @@ function generateDrilldownFromHeatmap(
cv_aht: cvAht, cv_aht: cvAht,
transfer_rate: transferRate, transfer_rate: transferRate,
fcr_rate: fcrRate, fcr_rate: fcrRate,
fcr_tecnico: fcrTecnico, // FCR Técnico para consistencia con Summary
agenticScore: agenticScore, agenticScore: agenticScore,
isPriorityCandidate: cvAht < 75, isPriorityCandidate: cvAht < 75,
originalQueues: [{ originalQueues: [{
@@ -1229,6 +1159,7 @@ function generateDrilldownFromHeatmap(
cv_aht: cvAht, cv_aht: cvAht,
transfer_rate: transferRate, transfer_rate: transferRate,
fcr_rate: fcrRate, fcr_rate: fcrRate,
fcr_tecnico: fcrTecnico, // FCR Técnico para consistencia con Summary
agenticScore: agenticScore, agenticScore: agenticScore,
tier: tier, tier: tier,
isPriorityCandidate: cvAht < 75, isPriorityCandidate: cvAht < 75,
@@ -1333,21 +1264,26 @@ const generateSyntheticAnalysis = (
hasNaN: heatmapData.some(item => hasNaN: heatmapData.some(item =>
Object.values(item.metrics).some(v => isNaN(v)) Object.values(item.metrics).some(v => isNaN(v))
) )
}); });
// v4.3: Generar drilldownData desde heatmap para usar mismas funciones
const drilldownData = generateDrilldownFromHeatmap(heatmapData, costPerHour);
return { return {
tier, tier,
overallHealthScore, overallHealthScore,
summaryKpis, summaryKpis,
dimensions, dimensions,
heatmapData, heatmapData,
drilldownData,
agenticReadiness, agenticReadiness,
findings: generateFindingsFromTemplates(), findings: generateFindingsFromTemplates(),
recommendations: generateRecommendationsFromTemplates(), recommendations: generateRecommendationsFromTemplates(),
opportunities: generateOpportunityMatrixData(), opportunities: generateOpportunitiesFromDrilldown(drilldownData, costPerHour),
economicModel: generateEconomicModelData(), economicModel: generateEconomicModelData(),
roadmap: generateRoadmapData(), roadmap: generateRoadmapFromDrilldown(drilldownData, costPerHour),
benchmarkData: generateBenchmarkData(), benchmarkData: generateBenchmarkData(),
source: 'synthetic', source: 'synthetic',
}; };
}; };

View File

@@ -7,6 +7,8 @@ import type {
DimensionAnalysis, DimensionAnalysis,
Kpi, Kpi,
EconomicModelData, EconomicModelData,
Finding,
Recommendation,
} from '../types'; } from '../types';
import type { BackendRawResults } from './apiClient'; import type { BackendRawResults } from './apiClient';
import { BarChartHorizontal, Zap, Target, Brain, Bot, Smile, DollarSign } from 'lucide-react'; import { BarChartHorizontal, Zap, Target, Brain, Bot, Smile, DollarSign } from 'lucide-react';
@@ -290,6 +292,7 @@ function buildVolumetryDimension(
const maxHourly = validHourly.length > 0 ? Math.max(...validHourly) : 0; const maxHourly = validHourly.length > 0 ? Math.max(...validHourly) : 0;
const minHourly = validHourly.length > 0 ? Math.min(...validHourly) : 1; const minHourly = validHourly.length > 0 ? Math.min(...validHourly) : 1;
const peakValleyRatio = minHourly > 0 ? maxHourly / minHourly : 1; const peakValleyRatio = minHourly > 0 ? maxHourly / minHourly : 1;
console.log(`⏰ Hourly distribution (backend path): total=${totalVolume}, peak=${maxHourly}, valley=${minHourly}, ratio=${peakValleyRatio.toFixed(2)}`);
// Score basado en: // Score basado en:
// - % fuera de horario (>30% penaliza) // - % fuera de horario (>30% penaliza)
@@ -406,11 +409,12 @@ function buildOperationalEfficiencyDimension(
summary += `AHT Horario Laboral (8-19h): ${ahtBusinessHours}s (P50), ratio ${ratioBusinessHours.toFixed(2)}. `; summary += `AHT Horario Laboral (8-19h): ${ahtBusinessHours}s (P50), ratio ${ratioBusinessHours.toFixed(2)}. `;
summary += variabilityInsight; summary += variabilityInsight;
// KPI principal: AHT P50 (industry standard for operational efficiency)
const kpi: Kpi = { const kpi: Kpi = {
label: 'Ratio P90/P50 Global', label: 'AHT P50',
value: ratioGlobal.toFixed(2), value: `${Math.round(ahtP50)}s`,
change: `Horario laboral: ${ratioBusinessHours.toFixed(2)}`, change: `Ratio: ${ratioGlobal.toFixed(2)}`,
changeType: ratioGlobal > 2.5 ? 'negative' : ratioGlobal > 1.8 ? 'neutral' : 'positive' changeType: ahtP50 > 360 ? 'negative' : ahtP50 > 300 ? 'neutral' : 'positive'
}; };
const dimension: DimensionAnalysis = { const dimension: DimensionAnalysis = {
@@ -427,7 +431,7 @@ function buildOperationalEfficiencyDimension(
return dimension; return dimension;
} }
// ==== Efectividad & Resolución (v3.2 - enfocada en FCR y recontactos) ==== // ==== Efectividad & Resolución (v3.2 - enfocada en FCR Técnico) ====
function buildEffectivenessResolutionDimension( function buildEffectivenessResolutionDimension(
raw: BackendRawResults raw: BackendRawResults
@@ -435,31 +439,29 @@ function buildEffectivenessResolutionDimension(
const op = raw?.operational_performance; const op = raw?.operational_performance;
if (!op) return undefined; if (!op) return undefined;
// FCR: métrica principal de efectividad // FCR Técnico = 100 - transfer_rate (comparable con benchmarks de industria)
const fcrPctRaw = safeNumber(op.fcr_rate, NaN); // Usamos escalation_rate que es la tasa de transferencias
const recurrenceRaw = safeNumber(op.recurrence_rate_7d, NaN); const escalationRate = safeNumber(op.escalation_rate, NaN);
const abandonmentRate = safeNumber(op.abandonment_rate, 0); const abandonmentRate = safeNumber(op.abandonment_rate, 0);
// FCR real o proxy desde recontactos // FCR Técnico: 100 - tasa de transferencia
const fcrRate = Number.isFinite(fcrPctRaw) && fcrPctRaw >= 0 const fcrRate = Number.isFinite(escalationRate) && escalationRate >= 0
? Math.max(0, Math.min(100, fcrPctRaw)) ? Math.max(0, Math.min(100, 100 - escalationRate))
: Number.isFinite(recurrenceRaw) : 70; // valor por defecto benchmark aéreo
? Math.max(0, Math.min(100, 100 - recurrenceRaw))
: 70; // valor por defecto benchmark aéreo
// Recontactos a 7 días (complemento del FCR) // Tasa de transferencia (complemento del FCR Técnico)
const recontactRate = 100 - fcrRate; const transferRate = Number.isFinite(escalationRate) ? escalationRate : 100 - fcrRate;
// Score basado principalmente en FCR (benchmark sector aéreo: 68-72%) // Score basado en FCR Técnico (benchmark sector aéreo: 85-90%)
// FCR >= 75% = 100pts, 70-75% = 80pts, 65-70% = 60pts, 60-65% = 40pts, <60% = 20pts // FCR >= 90% = 100pts, 85-90% = 80pts, 80-85% = 60pts, 75-80% = 40pts, <75% = 20pts
let score: number; let score: number;
if (fcrRate >= 75) { if (fcrRate >= 90) {
score = 100; score = 100;
} else if (fcrRate >= 70) { } else if (fcrRate >= 85) {
score = 80; score = 80;
} else if (fcrRate >= 65) { } else if (fcrRate >= 80) {
score = 60; score = 60;
} else if (fcrRate >= 60) { } else if (fcrRate >= 75) {
score = 40; score = 40;
} else { } else {
score = 20; score = 20;
@@ -470,23 +472,23 @@ function buildEffectivenessResolutionDimension(
score = Math.max(0, score - Math.round((abandonmentRate - 8) * 2)); score = Math.max(0, score - Math.round((abandonmentRate - 8) * 2));
} }
// Summary enfocado en resolución, no en transferencias // Summary enfocado en FCR Técnico
let summary = `FCR: ${fcrRate.toFixed(1)}% (benchmark sector aéreo: 68-72%). `; let summary = `FCR Técnico: ${fcrRate.toFixed(1)}% (benchmark: 85-90%). `;
summary += `Recontactos a 7 días: ${recontactRate.toFixed(1)}%. `; summary += `Tasa de transferencia: ${transferRate.toFixed(1)}%. `;
if (fcrRate >= 72) { if (fcrRate >= 90) {
summary += 'Resolución por encima del benchmark del sector.'; summary += 'Excelente resolución en primer contacto.';
} else if (fcrRate >= 68) { } else if (fcrRate >= 85) {
summary += 'Resolución dentro del benchmark del sector aéreo.'; summary += 'Resolución dentro del benchmark del sector.';
} else { } else {
summary += 'Resolución por debajo del benchmark. Oportunidad de mejora en first contact resolution.'; summary += 'Oportunidad de mejora reduciendo transferencias.';
} }
const kpi: Kpi = { const kpi: Kpi = {
label: 'FCR', label: 'FCR Técnico',
value: `${fcrRate.toFixed(0)}%`, value: `${fcrRate.toFixed(0)}%`,
change: `Recontactos: ${recontactRate.toFixed(0)}%`, change: `Transfer: ${transferRate.toFixed(0)}%`,
changeType: fcrRate >= 70 ? 'positive' : fcrRate >= 65 ? 'neutral' : 'negative' changeType: fcrRate >= 85 ? 'positive' : fcrRate >= 80 ? 'neutral' : 'negative'
}; };
const dimension: DimensionAnalysis = { const dimension: DimensionAnalysis = {
@@ -503,7 +505,7 @@ function buildEffectivenessResolutionDimension(
return dimension; return dimension;
} }
// ==== Complejidad & Predictibilidad (v3.3 - basada en Hold Time) ==== // ==== Complejidad & Predictibilidad (v3.4 - basada en CV AHT per industry standards) ====
function buildComplexityPredictabilityDimension( function buildComplexityPredictabilityDimension(
raw: BackendRawResults raw: BackendRawResults
@@ -511,12 +513,19 @@ function buildComplexityPredictabilityDimension(
const op = raw?.operational_performance; const op = raw?.operational_performance;
if (!op) return undefined; if (!op) return undefined;
// Métrica principal: % de interacciones con Hold Time > 60s // KPI principal: CV AHT (industry standard for predictability/WFM)
// Proxy de complejidad: si el agente puso en espera al cliente >60s, // CV AHT = (P90 - P50) / P50 como proxy de coeficiente de variación
// probablemente tuvo que consultar/investigar const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
const highHoldRate = safeNumber(op.high_hold_time_rate, NaN); const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
// Si no hay datos de hold time, usar fallback del P50 de hold // Calcular CV AHT como (P90-P50)/P50 (proxy del coeficiente de variación real)
let cvAht = 0;
if (ahtP50 > 0 && ahtP90 > 0) {
cvAht = (ahtP90 - ahtP50) / ahtP50;
}
const cvAhtPercent = Math.round(cvAht * 100);
// Hold Time como métrica secundaria de complejidad
const talkHoldAcw = op.talk_hold_acw_p50_by_skill; const talkHoldAcw = op.talk_hold_acw_p50_by_skill;
let avgHoldP50 = 0; let avgHoldP50 = 0;
if (Array.isArray(talkHoldAcw) && talkHoldAcw.length > 0) { if (Array.isArray(talkHoldAcw) && talkHoldAcw.length > 0) {
@@ -526,60 +535,55 @@ function buildComplexityPredictabilityDimension(
} }
} }
// Si no tenemos high_hold_time_rate del backend, estimamos desde hold_p50 // Score basado en CV AHT (benchmark: <75% = excelente, <100% = aceptable)
// Si hold_p50 promedio > 60s, asumimos ~40% de llamadas con hold alto // CV <= 75% = 100pts (alta predictibilidad)
const effectiveHighHoldRate = Number.isFinite(highHoldRate) && highHoldRate >= 0 // CV 75-100% = 80pts (predictibilidad aceptable)
? highHoldRate // CV 100-125% = 60pts (variabilidad moderada)
: avgHoldP50 > 60 ? 40 : avgHoldP50 > 30 ? 20 : 10; // CV 125-150% = 40pts (alta variabilidad)
// CV > 150% = 20pts (muy alta variabilidad)
// 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; let score: number;
if (effectiveHighHoldRate < 10) { if (cvAhtPercent <= 75) {
score = 100; score = 100;
} else if (effectiveHighHoldRate < 20) { } else if (cvAhtPercent <= 100) {
score = 80; score = 80;
} else if (effectiveHighHoldRate < 30) { } else if (cvAhtPercent <= 125) {
score = 60; score = 60;
} else if (effectiveHighHoldRate < 40) { } else if (cvAhtPercent <= 150) {
score = 40; score = 40;
} else { } else {
score = 20; score = 20;
} }
// Summary descriptivo // Summary descriptivo
let summary = `${effectiveHighHoldRate.toFixed(1)}% de interacciones con Hold Time > 60s (proxy de consulta/investigación). `; let summary = `CV AHT: ${cvAhtPercent}% (benchmark: <75%). `;
if (effectiveHighHoldRate < 15) { if (cvAhtPercent <= 75) {
summary += 'Baja complejidad: la mayoría de casos se resuelven sin necesidad de consultar. Excelente para automatización.'; summary += 'Alta predictibilidad: tiempos de atención consistentes. Excelente para planificación WFM.';
} else if (effectiveHighHoldRate < 25) { } else if (cvAhtPercent <= 100) {
summary += 'Complejidad moderada: algunos casos requieren consulta o investigación adicional.'; summary += 'Predictibilidad aceptable: variabilidad moderada en tiempos de atención.';
} else if (effectiveHighHoldRate < 35) { } else if (cvAhtPercent <= 125) {
summary += 'Complejidad notable: frecuentemente se requiere consulta. Considerar base de conocimiento mejorada.'; summary += 'Variabilidad notable: dificulta la planificación de recursos. Considerar estandarización.';
} else { } else {
summary += 'Alta complejidad: muchos casos requieren investigación. Priorizar documentación y herramientas de soporte.'; summary += 'Alta variabilidad: tiempos muy dispersos. Priorizar scripts guiados y estandarización.';
} }
// Añadir info de Hold P50 promedio si está disponible // Añadir info de Hold P50 promedio si está disponible (proxy de complejidad)
if (avgHoldP50 > 0) { if (avgHoldP50 > 0) {
summary += ` Hold Time P50 promedio: ${Math.round(avgHoldP50)}s.`; summary += ` Hold Time P50: ${Math.round(avgHoldP50)}s.`;
} }
// KPI principal: CV AHT (predictability metric per industry standards)
const kpi: Kpi = { const kpi: Kpi = {
label: 'Hold > 60s', label: 'CV AHT',
value: `${effectiveHighHoldRate.toFixed(0)}%`, value: `${cvAhtPercent}%`,
change: avgHoldP50 > 0 ? `Hold P50: ${Math.round(avgHoldP50)}s` : undefined, change: avgHoldP50 > 0 ? `Hold: ${Math.round(avgHoldP50)}s` : undefined,
changeType: effectiveHighHoldRate > 30 ? 'negative' : effectiveHighHoldRate > 15 ? 'neutral' : 'positive' changeType: cvAhtPercent > 125 ? 'negative' : cvAhtPercent > 75 ? 'neutral' : 'positive'
}; };
const dimension: DimensionAnalysis = { const dimension: DimensionAnalysis = {
id: 'complexity_predictability', id: 'complexity_predictability',
name: 'complexity_predictability', name: 'complexity_predictability',
title: 'Complejidad', title: 'Complejidad & Predictibilidad',
score, score,
percentile: undefined, percentile: undefined,
summary, summary,
@@ -630,6 +634,7 @@ function buildEconomyDimension(
totalInteractions: number totalInteractions: number
): DimensionAnalysis | undefined { ): DimensionAnalysis | undefined {
const econ = raw?.economy_costs; const econ = raw?.economy_costs;
const op = raw?.operational_performance;
const totalAnnual = safeNumber(econ?.cost_breakdown?.total_annual, 0); const totalAnnual = safeNumber(econ?.cost_breakdown?.total_annual, 0);
// Benchmark CPI sector contact center (Fuente: Gartner Contact Center Cost Benchmark 2024) // Benchmark CPI sector contact center (Fuente: Gartner Contact Center Cost Benchmark 2024)
@@ -639,8 +644,12 @@ function buildEconomyDimension(
return undefined; return undefined;
} }
// Calcular CPI // Calcular cost_volume (non-abandoned) para consistencia con Executive Summary
const cpi = totalAnnual / totalInteractions; const abandonmentRate = safeNumber(op?.abandonment_rate, 0) / 100;
const costVolume = Math.round(totalInteractions * (1 - abandonmentRate));
// Calcular CPI usando cost_volume (non-abandoned) como denominador
const cpi = costVolume > 0 ? totalAnnual / costVolume : totalAnnual / totalInteractions;
// Score basado en comparación con benchmark (€5.00) // Score basado en comparación con benchmark (€5.00)
// CPI <= 4.00 = 100pts (excelente) // CPI <= 4.00 = 100pts (excelente)
@@ -1033,14 +1042,46 @@ export function mapBackendResultsToAnalysisData(
const economicModel = buildEconomicModel(raw); const economicModel = buildEconomicModel(raw);
const benchmarkData = buildBenchmarkData(raw); const benchmarkData = buildBenchmarkData(raw);
// Generar findings y recommendations basados en volumetría
const findings: Finding[] = [];
const recommendations: Recommendation[] = [];
// Extraer offHoursPct de la dimensión de volumetría
const offHoursPct = volumetryDimension?.distribution_data?.off_hours_pct ?? 0;
const offHoursPctValue = offHoursPct * 100; // Convertir de 0-1 a 0-100
if (offHoursPctValue > 20) {
const offHoursVolume = Math.round(totalVolume * offHoursPctValue / 100);
findings.push({
type: offHoursPctValue > 30 ? 'critical' : 'warning',
title: 'Alto Volumen Fuera de Horario',
text: `${offHoursPctValue.toFixed(0)}% de interacciones fuera de horario (8-19h)`,
dimensionId: 'volumetry_distribution',
description: `${offHoursVolume.toLocaleString()} interacciones (${offHoursPctValue.toFixed(1)}%) ocurren fuera de horario laboral. Oportunidad ideal para implementar agentes virtuales 24/7.`,
impact: offHoursPctValue > 30 ? 'high' : 'medium'
});
const estimatedContainment = offHoursPctValue > 30 ? 60 : 45;
const estimatedSavings = Math.round(offHoursVolume * estimatedContainment / 100);
recommendations.push({
priority: 'high',
title: 'Implementar Agente Virtual 24/7',
text: `Desplegar agente virtual para atender ${offHoursPctValue.toFixed(0)}% de interacciones fuera de horario`,
description: `${offHoursVolume.toLocaleString()} interacciones ocurren fuera de horario laboral (19:00-08:00). Un agente virtual puede resolver ~${estimatedContainment}% de estas consultas automáticamente.`,
dimensionId: 'volumetry_distribution',
impact: `Potencial de contención: ${estimatedSavings.toLocaleString()} interacciones/período`,
timeline: '1-3 meses'
});
}
return { return {
tier: tierFromFrontend, tier: tierFromFrontend,
overallHealthScore, overallHealthScore,
summaryKpis: mergedKpis, summaryKpis: mergedKpis,
dimensions, dimensions,
heatmapData: [], // el heatmap por skill lo seguimos generando en el front heatmapData: [], // el heatmap por skill lo seguimos generando en el front
findings: [], findings,
recommendations: [], recommendations,
opportunities: [], opportunities: [],
roadmap: [], roadmap: [],
economicModel, economicModel,
@@ -1082,12 +1123,24 @@ export function buildHeatmapFromBackend(
const econ = raw?.economy_costs; const econ = raw?.economy_costs;
const cs = raw?.customer_satisfaction; const cs = raw?.customer_satisfaction;
const talkHoldAcwBySkill = Array.isArray( const talkHoldAcwBySkillRaw = Array.isArray(
op?.talk_hold_acw_p50_by_skill op?.talk_hold_acw_p50_by_skill
) )
? op.talk_hold_acw_p50_by_skill ? op.talk_hold_acw_p50_by_skill
: []; : [];
// Crear lookup map por skill name para talk_hold_acw_p50
const talkHoldAcwMap = new Map<string, { talk_p50: number; hold_p50: number; acw_p50: number }>();
for (const item of talkHoldAcwBySkillRaw) {
if (item?.queue_skill) {
talkHoldAcwMap.set(String(item.queue_skill), {
talk_p50: safeNumber(item.talk_p50, 0),
hold_p50: safeNumber(item.hold_p50, 0),
acw_p50: safeNumber(item.acw_p50, 0),
});
}
}
const globalEscalation = safeNumber(op?.escalation_rate, 0); const globalEscalation = safeNumber(op?.escalation_rate, 0);
// Usar fcr_rate del backend si existe, sino calcular como 100 - escalation // Usar fcr_rate del backend si existe, sino calcular como 100 - escalation
const fcrRateBackend = safeNumber(op?.fcr_rate, NaN); const fcrRateBackend = safeNumber(op?.fcr_rate, NaN);
@@ -1098,6 +1151,71 @@ export function buildHeatmapFromBackend(
// Usar abandonment_rate del backend si existe // Usar abandonment_rate del backend si existe
const abandonmentRateBackend = safeNumber(op?.abandonment_rate, 0); const abandonmentRateBackend = safeNumber(op?.abandonment_rate, 0);
// ========================================================================
// NUEVO: Métricas REALES por skill (transfer, abandonment, FCR)
// Esto elimina la estimación de transfer rate basada en CV y hold time
// ========================================================================
const metricsBySkillRaw = Array.isArray(op?.metrics_by_skill)
? op.metrics_by_skill
: [];
// Crear lookup por nombre de skill para acceso O(1)
const metricsBySkillMap = new Map<string, {
transfer_rate: number;
abandonment_rate: number;
fcr_tecnico: number;
fcr_real: number;
aht_mean: number; // AHT promedio del backend (solo VALID - consistente con fresh path)
aht_total: number; // AHT total (ALL rows incluyendo NOISE/ZOMBIE/ABANDON) - solo informativo
hold_time_mean: number; // Hold time promedio (consistente con fresh path - MEAN, no P50)
}>();
for (const m of metricsBySkillRaw) {
if (m?.skill) {
metricsBySkillMap.set(String(m.skill), {
transfer_rate: safeNumber(m.transfer_rate, NaN),
abandonment_rate: safeNumber(m.abandonment_rate, NaN),
fcr_tecnico: safeNumber(m.fcr_tecnico, NaN),
fcr_real: safeNumber(m.fcr_real, NaN),
aht_mean: safeNumber(m.aht_mean, NaN), // AHT promedio (solo VALID)
aht_total: safeNumber(m.aht_total, NaN), // AHT total (ALL rows)
hold_time_mean: safeNumber(m.hold_time_mean, NaN), // Hold time promedio (MEAN)
});
}
}
const hasRealMetricsBySkill = metricsBySkillMap.size > 0;
if (hasRealMetricsBySkill) {
console.log('✅ Usando métricas REALES por skill del backend:', metricsBySkillMap.size, 'skills');
} else {
console.warn('⚠️ No hay metrics_by_skill del backend, usando estimación basada en CV/hold');
}
// ========================================================================
// NUEVO: CPI por skill desde cpi_by_skill_channel
// Esto permite que el cached path tenga CPI real como el fresh path
// ========================================================================
const cpiBySkillRaw = Array.isArray(econ?.cpi_by_skill_channel)
? econ.cpi_by_skill_channel
: [];
// Crear lookup por nombre de skill para CPI
const cpiBySkillMap = new Map<string, number>();
for (const item of cpiBySkillRaw) {
if (item?.queue_skill || item?.skill) {
const skillKey = String(item.queue_skill ?? item.skill);
const cpiValue = safeNumber(item.cpi_total ?? item.cpi, NaN);
if (Number.isFinite(cpiValue)) {
cpiBySkillMap.set(skillKey, cpiValue);
}
}
}
const hasCpiBySkill = cpiBySkillMap.size > 0;
if (hasCpiBySkill) {
console.log('✅ Usando CPI por skill del backend:', cpiBySkillMap.size, 'skills');
}
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN); const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
const csatGlobal = const csatGlobal =
Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0 Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0
@@ -1110,12 +1228,24 @@ export function buildHeatmapFromBackend(
) )
: 0; : 0;
const ineffBySkill = Array.isArray( const ineffBySkillRaw = Array.isArray(
econ?.inefficiency_cost_by_skill_channel econ?.inefficiency_cost_by_skill_channel
) )
? econ.inefficiency_cost_by_skill_channel ? econ.inefficiency_cost_by_skill_channel
: []; : [];
// Crear lookup map por skill name para inefficiency data
const ineffBySkillMap = new Map<string, { aht_p50: number; aht_p90: number; volume: number }>();
for (const item of ineffBySkillRaw) {
if (item?.queue_skill) {
ineffBySkillMap.set(String(item.queue_skill), {
aht_p50: safeNumber(item.aht_p50, 0),
aht_p90: safeNumber(item.aht_p90, 0),
volume: safeNumber(item.volume, 0),
});
}
}
const COST_PER_SECOND = costPerHour / 3600; const COST_PER_SECOND = costPerHour / 3600;
if (!skillLabels.length) return []; if (!skillLabels.length) return [];
@@ -1137,12 +1267,30 @@ export function buildHeatmapFromBackend(
const skill = skillLabels[i]; const skill = skillLabels[i];
const volume = safeNumber(skillVolumes[i], 0); const volume = safeNumber(skillVolumes[i], 0);
const talkHold = talkHoldAcwBySkill[i] || {}; // Buscar P50s por nombre de skill (no por índice)
const talk_p50 = safeNumber(talkHold.talk_p50, 0); const talkHold = talkHoldAcwMap.get(skill);
const hold_p50 = safeNumber(talkHold.hold_p50, 0); const talk_p50 = talkHold?.talk_p50 ?? 0;
const acw_p50 = safeNumber(talkHold.acw_p50, 0); const hold_p50 = talkHold?.hold_p50 ?? 0;
const acw_p50 = talkHold?.acw_p50 ?? 0;
const aht_mean = talk_p50 + hold_p50 + acw_p50; // Buscar métricas REALES del backend (metrics_by_skill)
const realSkillMetrics = metricsBySkillMap.get(skill);
// AHT: Use ONLY aht_mean from backend metrics_by_skill
// NEVER use P50 sum as fallback - it's mathematically different from mean AHT
const aht_mean = (realSkillMetrics && Number.isFinite(realSkillMetrics.aht_mean) && realSkillMetrics.aht_mean > 0)
? realSkillMetrics.aht_mean
: 0;
// AHT Total: AHT calculado con TODAS las filas (incluye NOISE/ZOMBIE/ABANDON)
// Solo para información/comparación - no se usa en cálculos
const aht_total = (realSkillMetrics && Number.isFinite(realSkillMetrics.aht_total) && realSkillMetrics.aht_total > 0)
? realSkillMetrics.aht_total
: aht_mean; // fallback to aht_mean if not available
if (aht_mean === 0) {
console.warn(`⚠️ No aht_mean for skill ${skill} - data may be incomplete`);
}
// Coste anual aproximado // Coste anual aproximado
const annual_volume = volume * 12; const annual_volume = volume * 12;
@@ -1150,9 +1298,10 @@ export function buildHeatmapFromBackend(
annual_volume * aht_mean * COST_PER_SECOND annual_volume * aht_mean * COST_PER_SECOND
); );
const ineff = ineffBySkill[i] || {}; // Buscar inefficiency data por nombre de skill (no por índice)
const aht_p50_backend = safeNumber(ineff.aht_p50, aht_mean); const ineff = ineffBySkillMap.get(skill);
const aht_p90_backend = safeNumber(ineff.aht_p90, aht_mean); const aht_p50_backend = ineff?.aht_p50 ?? aht_mean;
const aht_p90_backend = ineff?.aht_p90 ?? aht_mean;
// Variabilidad proxy: aproximamos CV a partir de P90-P50 // Variabilidad proxy: aproximamos CV a partir de P90-P50
let cv_aht = 0; let cv_aht = 0;
@@ -1173,12 +1322,36 @@ export function buildHeatmapFromBackend(
) )
); );
// 2) Transfer rate POR SKILL - estimado desde CV y hold time // 2) Transfer rate POR SKILL
// Skills con mayor variabilidad (CV alto) y mayor hold time tienden a tener más transferencias // PRIORIDAD 1: Usar métricas REALES del backend (metrics_by_skill)
// Usamos el global como base y lo modulamos por skill // PRIORIDAD 2: Fallback a estimación basada en CV y hold time
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 let skillTransferRate: number;
const skillTransferRate = Math.max(2, Math.min(40, globalEscalation * cvFactor * holdFactor)); let skillAbandonmentRate: number;
let skillFcrTecnico: number;
let skillFcrReal: number;
if (realSkillMetrics && Number.isFinite(realSkillMetrics.transfer_rate)) {
// Usar métricas REALES del backend
skillTransferRate = realSkillMetrics.transfer_rate;
skillAbandonmentRate = Number.isFinite(realSkillMetrics.abandonment_rate)
? realSkillMetrics.abandonment_rate
: abandonmentRateBackend;
skillFcrTecnico = Number.isFinite(realSkillMetrics.fcr_tecnico)
? realSkillMetrics.fcr_tecnico
: 100 - skillTransferRate;
skillFcrReal = Number.isFinite(realSkillMetrics.fcr_real)
? realSkillMetrics.fcr_real
: skillFcrTecnico;
} else {
// NO usar estimación - usar valores globales del backend directamente
// Esto asegura consistencia con el fresh path que usa valores directos del CSV
skillTransferRate = globalEscalation; // Usar tasa global, sin estimación
skillAbandonmentRate = abandonmentRateBackend;
skillFcrTecnico = 100 - skillTransferRate;
skillFcrReal = globalFcrPct;
console.warn(`⚠️ No metrics_by_skill for skill ${skill} - using global rates`);
}
// Complejidad inversa basada en transfer rate del skill // Complejidad inversa basada en transfer rate del skill
const complexity_inverse_score = Math.max( const complexity_inverse_score = Math.max(
@@ -1221,29 +1394,18 @@ export function buildHeatmapFromBackend(
// Métricas normalizadas 0-100 para el color del heatmap // Métricas normalizadas 0-100 para el color del heatmap
const ahtMetric = normalizeAhtMetric(aht_mean); const ahtMetric = normalizeAhtMetric(aht_mean);
;
const holdMetric = hold_p50 // Hold time metric: use hold_time_mean from backend (MEAN, not P50)
? Math.max( // Formula matches fresh path: 100 - (hold_time_mean / 60) * 10
0, // This gives: 0s = 100, 60s = 90, 120s = 80, etc.
Math.min( const skillHoldTimeMean = (realSkillMetrics && Number.isFinite(realSkillMetrics.hold_time_mean))
100, ? realSkillMetrics.hold_time_mean
Math.round( : hold_p50; // Fallback to P50 only if no mean available
100 - (hold_p50 / 120) * 100
) const holdMetric = skillHoldTimeMean > 0
) ? Math.round(Math.max(0, Math.min(100, 100 - (skillHoldTimeMean / 60) * 10)))
)
: 0; : 0;
// Transfer rate es el % real de transferencias POR SKILL
const transferMetric = Math.max(
0,
Math.min(
100,
Math.round(skillTransferRate)
)
);
// Clasificación por segmento (si nos pasan mapeo) // Clasificación por segmento (si nos pasan mapeo)
let segment: CustomerSegment | undefined; let segment: CustomerSegment | undefined;
if (segmentMapping) { if (segmentMapping) {
@@ -1265,25 +1427,41 @@ export function buildHeatmapFromBackend(
} }
} }
// Métricas de transferencia y FCR (ahora usando valores REALES cuando disponibles)
const transferMetricFinal = Math.max(0, Math.min(100, Math.round(skillTransferRate)));
// CPI should be extracted from cpi_by_skill_channel using cpi_total field
const skillCpiRaw = cpiBySkillMap.get(skill);
// Only use if it's a valid number
const skillCpi = (Number.isFinite(skillCpiRaw) && skillCpiRaw > 0) ? skillCpiRaw : undefined;
// cost_volume: volumen sin abandonos (para cálculo de CPI consistente)
// Si tenemos abandonment_rate, restamos los abandonos
const costVolume = Math.round(volume * (1 - skillAbandonmentRate / 100));
heatmap.push({ heatmap.push({
skill, skill,
segment, segment,
volume, volume,
cost_volume: costVolume,
aht_seconds: aht_mean, aht_seconds: aht_mean,
aht_total: aht_total, // AHT con TODAS las filas (solo informativo)
metrics: { metrics: {
fcr: Math.round(globalFcrPct), fcr: Math.round(skillFcrReal), // FCR Real (sin transfer Y sin recontacto 7d)
fcr_tecnico: Math.round(skillFcrTecnico), // FCR Técnico (comparable con benchmarks)
aht: ahtMetric, aht: ahtMetric,
csat: csatMetric0_100, csat: csatMetric0_100,
hold_time: holdMetric, hold_time: holdMetric,
transfer_rate: transferMetric, transfer_rate: transferMetricFinal,
abandonment_rate: Math.round(abandonmentRateBackend), abandonment_rate: Math.round(skillAbandonmentRate),
}, },
annual_cost, annual_cost,
cpi: skillCpi, // CPI real del backend (si disponible)
variability: { variability: {
cv_aht: Math.round(cv_aht * 100), // % cv_aht: Math.round(cv_aht * 100), // %
cv_talk_time: 0, cv_talk_time: 0,
cv_hold_time: 0, cv_hold_time: 0,
transfer_rate: skillTransferRate, // Transfer rate estimado por skill transfer_rate: skillTransferRate, // Transfer rate REAL o estimado
}, },
automation_readiness, automation_readiness,
dimensions: { dimensions: {

View File

@@ -10,11 +10,24 @@ import { classifyQueue } from './segmentClassifier';
/** /**
* Calcular distribución horaria desde interacciones * Calcular distribución horaria desde interacciones
* NOTA: Usa interaction_id únicos para consistencia con backend (aggfunc="nunique")
*/ */
function calculateHourlyDistribution(interactions: RawInteraction[]): { hourly: number[]; off_hours_pct: number; peak_hours: number[] } { function calculateHourlyDistribution(interactions: RawInteraction[]): { hourly: number[]; off_hours_pct: number; peak_hours: number[] } {
const hourly = new Array(24).fill(0); const hourly = new Array(24).fill(0);
// Deduplicar por interaction_id para consistencia con backend (nunique)
const seenIds = new Set<string>();
let duplicateCount = 0;
for (const interaction of interactions) { for (const interaction of interactions) {
// Saltar duplicados de interaction_id
const id = interaction.interaction_id;
if (id && seenIds.has(id)) {
duplicateCount++;
continue;
}
if (id) seenIds.add(id);
try { try {
const date = new Date(interaction.datetime_start); const date = new Date(interaction.datetime_start);
if (!isNaN(date.getTime())) { if (!isNaN(date.getTime())) {
@@ -26,6 +39,10 @@ function calculateHourlyDistribution(interactions: RawInteraction[]): { hourly:
} }
} }
if (duplicateCount > 0) {
console.log(`⏰ calculateHourlyDistribution: ${duplicateCount} interaction_ids duplicados ignorados`);
}
const total = hourly.reduce((a, b) => a + b, 0); const total = hourly.reduce((a, b) => a + b, 0);
// Fuera de horario: 19:00-08:00 // Fuera de horario: 19:00-08:00
@@ -45,6 +62,12 @@ function calculateHourlyDistribution(interactions: RawInteraction[]): { hourly:
} }
const peak_hours = [peakStart, peakStart + 1, peakStart + 2]; const peak_hours = [peakStart, peakStart + 1, peakStart + 2];
// Log para debugging
const hourlyNonZero = hourly.filter(v => v > 0);
const peakVolume = Math.max(...hourlyNonZero, 1);
const valleyVolume = Math.min(...hourlyNonZero.filter(v => v > 0), 1);
console.log(`⏰ Hourly distribution: total=${total}, peak=${peakVolume}, valley=${valleyVolume}, ratio=${(peakVolume/valleyVolume).toFixed(2)}`);
return { hourly, off_hours_pct, peak_hours }; return { hourly, off_hours_pct, peak_hours };
} }
@@ -124,11 +147,13 @@ export function generateAnalysisFromRealData(
console.log(`📅 Date range: ${dateRange?.min} to ${dateRange?.max}`); console.log(`📅 Date range: ${dateRange?.min} to ${dateRange?.max}`);
// PASO 1: Analizar record_status (ya no filtramos, el filtrado se hace internamente en calculateSkillMetrics) // PASO 1: Analizar record_status (ya no filtramos, el filtrado se hace internamente en calculateSkillMetrics)
// Normalizar a uppercase para comparación case-insensitive
const getStatus = (i: RawInteraction) => (i.record_status || '').toString().toUpperCase().trim();
const statusCounts = { const statusCounts = {
valid: interactions.filter(i => !i.record_status || i.record_status === 'valid').length, valid: interactions.filter(i => !i.record_status || getStatus(i) === 'VALID').length,
noise: interactions.filter(i => i.record_status === 'noise').length, noise: interactions.filter(i => getStatus(i) === 'NOISE').length,
zombie: interactions.filter(i => i.record_status === 'zombie').length, zombie: interactions.filter(i => getStatus(i) === 'ZOMBIE').length,
abandon: interactions.filter(i => i.record_status === 'abandon').length abandon: interactions.filter(i => getStatus(i) === 'ABANDON').length
}; };
console.log(`📊 Record status breakdown:`, statusCounts); console.log(`📊 Record status breakdown:`, statusCounts);
@@ -154,11 +179,11 @@ export function generateAnalysisFromRealData(
const totalWeightedAHT = skillMetrics.reduce((sum, s) => sum + (s.aht_mean * s.volume_valid), 0); const totalWeightedAHT = skillMetrics.reduce((sum, s) => sum + (s.aht_mean * s.volume_valid), 0);
const avgAHT = totalValidInteractions > 0 ? Math.round(totalWeightedAHT / totalValidInteractions) : 0; const avgAHT = totalValidInteractions > 0 ? Math.round(totalWeightedAHT / totalValidInteractions) : 0;
// FCR Real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE) // FCR Técnico: 100 - transfer_rate (comparable con benchmarks de industria)
// Ponderado por volumen de cada skill // Ponderado por volumen de cada skill
const totalVolumeForFCR = skillMetrics.reduce((sum, s) => sum + s.volume_valid, 0); const totalVolumeForFCR = skillMetrics.reduce((sum, s) => sum + s.volume_valid, 0);
const avgFCR = totalVolumeForFCR > 0 const avgFCR = totalVolumeForFCR > 0
? Math.round(skillMetrics.reduce((sum, s) => sum + (s.fcr_rate * s.volume_valid), 0) / totalVolumeForFCR) ? Math.round(skillMetrics.reduce((sum, s) => sum + (s.fcr_tecnico * s.volume_valid), 0) / totalVolumeForFCR)
: 0; : 0;
// Coste total // Coste total
@@ -168,7 +193,7 @@ export function generateAnalysisFromRealData(
const summaryKpis: Kpi[] = [ const summaryKpis: Kpi[] = [
{ label: "Interacciones Totales", value: totalInteractions.toLocaleString('es-ES') }, { label: "Interacciones Totales", value: totalInteractions.toLocaleString('es-ES') },
{ label: "AHT Promedio", value: `${avgAHT}s` }, { label: "AHT Promedio", value: `${avgAHT}s` },
{ label: "Tasa FCR", value: `${avgFCR}%` }, { label: "FCR Técnico", value: `${avgFCR}%` },
{ label: "CSAT", value: `${(avgCsat / 20).toFixed(1)}/5` } { label: "CSAT", value: `${(avgCsat / 20).toFixed(1)}/5` }
]; ];
@@ -187,9 +212,9 @@ export function generateAnalysisFromRealData(
// Agentic Readiness Score // Agentic Readiness Score
const agenticReadiness = calculateAgenticReadinessFromRealData(skillMetrics); const agenticReadiness = calculateAgenticReadinessFromRealData(skillMetrics);
// Findings y Recommendations // Findings y Recommendations (incluyendo análisis de fuera de horario)
const findings = generateFindingsFromRealData(skillMetrics, interactions); const findings = generateFindingsFromRealData(skillMetrics, interactions, hourlyDistribution);
const recommendations = generateRecommendationsFromRealData(skillMetrics); const recommendations = generateRecommendationsFromRealData(skillMetrics, hourlyDistribution, interactions.length);
// v3.3: Drill-down por Cola + Tipificación - CALCULAR PRIMERO para usar en opportunities y roadmap // v3.3: Drill-down por Cola + Tipificación - CALCULAR PRIMERO para usar en opportunities y roadmap
const drilldownData = calculateDrilldownMetrics(interactions, costPerHour); const drilldownData = calculateDrilldownMetrics(interactions, costPerHour);
@@ -240,13 +265,18 @@ interface SkillMetrics {
skill: string; skill: string;
volume: number; // Total de interacciones (todas) volume: number; // Total de interacciones (todas)
volume_valid: number; // Interacciones válidas para AHT (valid + abandon) volume_valid: number; // Interacciones válidas para AHT (valid + abandon)
aht_mean: number; // AHT calculado solo sobre valid (sin noise/zombie/abandon) aht_mean: number; // AHT "limpio" calculado solo sobre valid (sin noise/zombie/abandon) - para métricas de calidad, CV
aht_total: number; // AHT "total" calculado con TODAS las filas (noise/zombie/abandon incluidas) - solo informativo
aht_benchmark: number; // AHT "tradicional" (incluye noise, excluye zombie/abandon) - para comparación con benchmarks de industria
aht_std: number; aht_std: number;
cv_aht: number; cv_aht: number;
transfer_rate: number; // Calculado sobre valid + abandon transfer_rate: number; // Calculado sobre valid + abandon
fcr_rate: number; // FCR real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE) fcr_rate: number; // FCR Real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE) - sin recontacto 7 días
fcr_tecnico: number; // FCR Técnico: (transfer_flag == FALSE) - solo sin transferencia, comparable con benchmarks de industria
abandonment_rate: number; // % de abandonos sobre total abandonment_rate: number; // % de abandonos sobre total
total_cost: number; // Coste total (todas las interacciones excepto abandon) total_cost: number; // Coste total (todas las interacciones excepto abandon)
cost_volume: number; // Volumen usado para calcular coste (non-abandon)
cpi: number; // Coste por interacción = total_cost / cost_volume
hold_time_mean: number; // Calculado sobre valid hold_time_mean: number; // Calculado sobre valid
cv_talk_time: number; cv_talk_time: number;
// Métricas adicionales para debug // Métricas adicionales para debug
@@ -255,7 +285,7 @@ interface SkillMetrics {
abandon_count: number; abandon_count: number;
} }
function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: number): SkillMetrics[] { export function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: number): SkillMetrics[] {
// Agrupar por skill // Agrupar por skill
const skillGroups = new Map<string, RawInteraction[]>(); const skillGroups = new Map<string, RawInteraction[]>();
@@ -279,7 +309,9 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb
const abandon_count = group.filter(i => i.is_abandoned === true).length; const abandon_count = group.filter(i => i.is_abandoned === true).length;
const abandonment_rate = (abandon_count / volume) * 100; const abandonment_rate = (abandon_count / volume) * 100;
// FCR: DIRECTO del campo fcr_real_flag del CSV // FCR Real: DIRECTO del campo fcr_real_flag del CSV
// Definición: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE)
// Esta es la métrica MÁS ESTRICTA - sin transferencia Y sin recontacto en 7 días
const fcrTrueCount = group.filter(i => i.fcr_real_flag === true).length; const fcrTrueCount = group.filter(i => i.fcr_real_flag === true).length;
const fcr_rate = (fcrTrueCount / volume) * 100; const fcr_rate = (fcrTrueCount / volume) * 100;
@@ -287,10 +319,17 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb
const transfers = group.filter(i => i.transfer_flag === true).length; const transfers = group.filter(i => i.transfer_flag === true).length;
const transfer_rate = (transfers / volume) * 100; const transfer_rate = (transfers / volume) * 100;
// Separar por record_status para AHT // FCR Técnico: 100 - transfer_rate
const noiseRecords = group.filter(i => i.record_status === 'noise'); // Definición: (transfer_flag == FALSE) - solo sin transferencia
const zombieRecords = group.filter(i => i.record_status === 'zombie'); // Esta métrica es COMPARABLE con benchmarks de industria (COPC, Dimension Data)
const validRecords = group.filter(i => !i.record_status || i.record_status === 'valid'); // Los benchmarks de industria (~70%) miden FCR sin transferencia, NO sin recontacto
const fcr_tecnico = 100 - transfer_rate;
// Separar por record_status para AHT (normalizar a uppercase para comparación case-insensitive)
const getStatus = (i: RawInteraction) => (i.record_status || '').toString().toUpperCase().trim();
const noiseRecords = group.filter(i => getStatus(i) === 'NOISE');
const zombieRecords = group.filter(i => getStatus(i) === 'ZOMBIE');
const validRecords = group.filter(i => !i.record_status || getStatus(i) === 'VALID');
// Registros que generan coste (todo excepto abandonos) // Registros que generan coste (todo excepto abandonos)
const nonAbandonRecords = group.filter(i => i.is_abandoned !== true); const nonAbandonRecords = group.filter(i => i.is_abandoned !== true);
@@ -325,6 +364,30 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb
hold_time_mean = ahtRecords.reduce((sum, i) => sum + i.hold_time, 0) / volume_valid; hold_time_mean = ahtRecords.reduce((sum, i) => sum + i.hold_time, 0) / volume_valid;
} }
// === AHT BENCHMARK: para comparación con benchmarks de industria ===
// Incluye NOISE (llamadas cortas son trabajo real), excluye ZOMBIE (errores) y ABANDON (sin handle time)
// Los benchmarks de industria (COPC, Dimension Data) NO filtran llamadas cortas
const benchmarkRecords = group.filter(i =>
getStatus(i) !== 'ZOMBIE' &&
getStatus(i) !== 'ABANDON' &&
i.is_abandoned !== true
);
const volume_benchmark = benchmarkRecords.length;
let aht_benchmark = aht_mean; // Fallback al AHT limpio si no hay registros benchmark
if (volume_benchmark > 0) {
const benchmarkAhts = benchmarkRecords.map(i => i.duration_talk + i.hold_time + i.wrap_up_time);
aht_benchmark = benchmarkAhts.reduce((sum, v) => sum + v, 0) / volume_benchmark;
}
// === AHT TOTAL: calculado con TODAS las filas (solo informativo) ===
// Incluye NOISE, ZOMBIE, ABANDON - para comparación con AHT limpio
let aht_total = 0;
if (volume > 0) {
const allAhts = group.map(i => i.duration_talk + i.hold_time + i.wrap_up_time);
aht_total = allAhts.reduce((sum, v) => sum + v, 0) / volume;
}
// === CÁLCULOS FINANCIEROS: usar TODAS las interacciones === // === CÁLCULOS FINANCIEROS: usar TODAS las interacciones ===
// Coste total con productividad efectiva del 70% // Coste total con productividad efectiva del 70%
const effectiveProductivity = 0.70; const effectiveProductivity = 0.70;
@@ -342,21 +405,29 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb
aht_for_cost = costAhts.reduce((sum, v) => sum + v, 0) / costVolume; aht_for_cost = costAhts.reduce((sum, v) => sum + v, 0) / costVolume;
} }
// Coste Real = (Volumen × AHT × Coste/hora) / Productividad Efectiva // Coste Real = (AHT en horas × Coste/hora × Volumen) / Productividad Efectiva
const rawCost = (aht_for_cost / 3600) * costPerHour * costVolume; const rawCost = (aht_for_cost / 3600) * costPerHour * costVolume;
const total_cost = rawCost / effectiveProductivity; const total_cost = rawCost / effectiveProductivity;
// CPI = Coste por interacción (usando el volumen correcto)
const cpi = costVolume > 0 ? total_cost / costVolume : 0;
metrics.push({ metrics.push({
skill, skill,
volume, volume,
volume_valid, volume_valid,
aht_mean, aht_mean,
aht_total, // AHT con TODAS las filas (solo informativo)
aht_benchmark,
aht_std, aht_std,
cv_aht, cv_aht,
transfer_rate, transfer_rate,
fcr_rate, fcr_rate,
fcr_tecnico,
abandonment_rate, abandonment_rate,
total_cost, total_cost,
cost_volume: costVolume,
cpi,
hold_time_mean, hold_time_mean,
cv_talk_time, cv_talk_time,
noise_count, noise_count,
@@ -375,6 +446,9 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb
const avgFCRRate = totalVolume > 0 const avgFCRRate = totalVolume > 0
? metrics.reduce((sum, m) => sum + m.fcr_rate * m.volume, 0) / totalVolume ? metrics.reduce((sum, m) => sum + m.fcr_rate * m.volume, 0) / totalVolume
: 0; : 0;
const avgFCRTecnicoRate = totalVolume > 0
? metrics.reduce((sum, m) => sum + m.fcr_tecnico * m.volume, 0) / totalVolume
: 0;
const avgTransferRate = totalVolume > 0 const avgTransferRate = totalVolume > 0
? metrics.reduce((sum, m) => sum + m.transfer_rate * m.volume, 0) / totalVolume ? metrics.reduce((sum, m) => sum + m.transfer_rate * m.volume, 0) / totalVolume
: 0; : 0;
@@ -389,12 +463,13 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb
console.log(''); console.log('');
console.log('MÉTRICAS GLOBALES (ponderadas por volumen):'); console.log('MÉTRICAS GLOBALES (ponderadas por volumen):');
console.log(` Abandonment Rate: ${globalAbandonRate.toFixed(2)}%`); console.log(` Abandonment Rate: ${globalAbandonRate.toFixed(2)}%`);
console.log(` FCR Rate (fcr_real_flag=TRUE): ${avgFCRRate.toFixed(2)}%`); console.log(` FCR Real (sin transfer + sin recontacto 7d): ${avgFCRRate.toFixed(2)}%`);
console.log(` FCR Técnico (solo sin transfer, comparable con benchmarks): ${avgFCRTecnicoRate.toFixed(2)}%`);
console.log(` Transfer Rate: ${avgTransferRate.toFixed(2)}%`); console.log(` Transfer Rate: ${avgTransferRate.toFixed(2)}%`);
console.log(''); console.log('');
console.log('Detalle por skill (top 5):'); console.log('Detalle por skill (top 5):');
metrics.slice(0, 5).forEach(m => { metrics.slice(0, 5).forEach(m => {
console.log(` ${m.skill}: vol=${m.volume}, abandon=${m.abandon_count} (${m.abandonment_rate.toFixed(1)}%), FCR=${m.fcr_rate.toFixed(1)}%, transfer=${m.transfer_rate.toFixed(1)}%`); console.log(` ${m.skill}: vol=${m.volume}, abandon=${m.abandon_count} (${m.abandonment_rate.toFixed(1)}%), FCR Real=${m.fcr_rate.toFixed(1)}%, FCR Técnico=${m.fcr_tecnico.toFixed(1)}%, transfer=${m.transfer_rate.toFixed(1)}%`);
}); });
console.log('═══════════════════════════════════════════════════════════════'); console.log('═══════════════════════════════════════════════════════════════');
console.log(''); console.log('');
@@ -415,6 +490,62 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb
return metrics.sort((a, b) => b.volume - a.volume); // Ordenar por volumen descendente return metrics.sort((a, b) => b.volume - a.volume); // Ordenar por volumen descendente
} }
/**
* v4.4: Clasificar tier de automatización con datos del heatmap
*
* Esta función replica la lógica de clasificarTier() usando los datos
* disponibles en el heatmap. Acepta parámetros opcionales (fcr, volume)
* para mayor precisión cuando están disponibles.
*
* Se usa en generateDrilldownFromHeatmap() de analysisGenerator.ts para
* asegurar consistencia entre la ruta fresh (datos completos) y la ruta
* cached (datos del heatmap).
*
* @param score - Agentic Readiness Score (0-10)
* @param cv - Coeficiente de Variación del AHT como decimal (0.75 = 75%)
* @param transfer - Tasa de transferencia como decimal (0.20 = 20%)
* @param fcr - FCR rate como decimal (0.80 = 80%), opcional
* @param volume - Volumen mensual de interacciones, opcional
* @returns AgenticTier ('AUTOMATE' | 'ASSIST' | 'AUGMENT' | 'HUMAN-ONLY')
*/
export function clasificarTierSimple(
score: number,
cv: number, // CV como decimal (0.75 = 75%)
transfer: number, // Transfer como decimal (0.20 = 20%)
fcr?: number, // FCR como decimal (0.80 = 80%)
volume?: number // Volumen mensual
): import('../types').AgenticTier {
// RED FLAGS críticos - mismos que clasificarTier() completa
// CV > 120% o Transfer > 50% son red flags absolutos
if (cv > 1.20 || transfer > 0.50) {
return 'HUMAN-ONLY';
}
// Volume < 50/mes es red flag si tenemos el dato
if (volume !== undefined && volume < 50) {
return 'HUMAN-ONLY';
}
// TIER 1: AUTOMATE - requiere métricas óptimas
// Mismo criterio que clasificarTier(): score >= 7.5, cv <= 0.75, transfer <= 0.20, fcr >= 0.50
const fcrOk = fcr === undefined || fcr >= 0.50; // Si no tenemos FCR, asumimos OK
if (score >= 7.5 && cv <= 0.75 && transfer <= 0.20 && fcrOk) {
return 'AUTOMATE';
}
// TIER 2: ASSIST - apto para copilot/asistencia
if (score >= 5.5 && cv <= 0.90 && transfer <= 0.30) {
return 'ASSIST';
}
// TIER 3: AUGMENT - requiere optimización previa
if (score >= 3.5) {
return 'AUGMENT';
}
// TIER 4: HUMAN-ONLY - proceso complejo
return 'HUMAN-ONLY';
}
/** /**
* v3.4: Calcular métricas drill-down con nueva fórmula de Agentic Readiness Score * v3.4: Calcular métricas drill-down con nueva fórmula de Agentic Readiness Score
* *
@@ -627,8 +758,9 @@ export function calculateDrilldownMetrics(
const volume = group.length; const volume = group.length;
if (volume < 5) return null; if (volume < 5) return null;
// Filtrar solo VALID para cálculo de CV // Filtrar solo VALID para cálculo de CV (normalizar a uppercase para comparación case-insensitive)
const validRecords = group.filter(i => !i.record_status || i.record_status === 'valid'); const getStatus = (i: RawInteraction) => (i.record_status || '').toString().toUpperCase().trim();
const validRecords = group.filter(i => !i.record_status || getStatus(i) === 'VALID');
const volumeValid = validRecords.length; const volumeValid = validRecords.length;
if (volumeValid < 3) return null; if (volumeValid < 3) return null;
@@ -647,10 +779,14 @@ export function calculateDrilldownMetrics(
const transfer_decimal = transfers / volume; const transfer_decimal = transfers / volume;
const transfer_percent = transfer_decimal * 100; const transfer_percent = transfer_decimal * 100;
// FCR Real: usa fcr_real_flag del CSV (sin transferencia Y sin recontacto 7d)
const fcrCount = group.filter(i => i.fcr_real_flag === true).length; const fcrCount = group.filter(i => i.fcr_real_flag === true).length;
const fcr_decimal = fcrCount / volume; const fcr_decimal = fcrCount / volume;
const fcr_percent = fcr_decimal * 100; const fcr_percent = fcr_decimal * 100;
// FCR Técnico: 100 - transfer_rate (comparable con benchmarks de industria)
const fcr_tecnico_percent = 100 - transfer_percent;
// Calcular score con nueva fórmula v3.4 // Calcular score con nueva fórmula v3.4
const { score, breakdown } = calcularScoreCola( const { score, breakdown } = calcularScoreCola(
cv_aht_decimal, cv_aht_decimal,
@@ -671,7 +807,9 @@ export function calculateDrilldownMetrics(
validPct validPct
); );
const annualCost = Math.round((aht_mean / 3600) * costPerHour * volume / effectiveProductivity); // v4.2: Convertir volumen de 11 meses a anual para el coste
const annualVolume = (volume / 11) * 12; // 11 meses → anual
const annualCost = Math.round((aht_mean / 3600) * costPerHour * annualVolume / effectiveProductivity);
return { return {
original_queue_id: '', // Se asigna después original_queue_id: '', // Se asigna después
@@ -681,6 +819,7 @@ export function calculateDrilldownMetrics(
cv_aht: Math.round(cv_aht_percent * 10) / 10, cv_aht: Math.round(cv_aht_percent * 10) / 10,
transfer_rate: Math.round(transfer_percent * 10) / 10, transfer_rate: Math.round(transfer_percent * 10) / 10,
fcr_rate: Math.round(fcr_percent * 10) / 10, fcr_rate: Math.round(fcr_percent * 10) / 10,
fcr_tecnico: Math.round(fcr_tecnico_percent * 10) / 10, // FCR Técnico para consistencia con Summary
agenticScore: score, agenticScore: score,
scoreBreakdown: breakdown, scoreBreakdown: breakdown,
tier, tier,
@@ -753,6 +892,7 @@ export function calculateDrilldownMetrics(
const avgCv = originalQueues.reduce((sum, q) => sum + q.cv_aht * q.volume, 0) / totalVolume; const avgCv = originalQueues.reduce((sum, q) => sum + q.cv_aht * q.volume, 0) / totalVolume;
const avgTransfer = originalQueues.reduce((sum, q) => sum + q.transfer_rate * q.volume, 0) / totalVolume; const avgTransfer = originalQueues.reduce((sum, q) => sum + q.transfer_rate * q.volume, 0) / totalVolume;
const avgFcr = originalQueues.reduce((sum, q) => sum + q.fcr_rate * q.volume, 0) / totalVolume; const avgFcr = originalQueues.reduce((sum, q) => sum + q.fcr_rate * q.volume, 0) / totalVolume;
const avgFcrTecnico = originalQueues.reduce((sum, q) => sum + q.fcr_tecnico * q.volume, 0) / totalVolume;
// Score global ponderado por volumen // Score global ponderado por volumen
const avgScore = originalQueues.reduce((sum, q) => sum + q.agenticScore * q.volume, 0) / totalVolume; const avgScore = originalQueues.reduce((sum, q) => sum + q.agenticScore * q.volume, 0) / totalVolume;
@@ -775,6 +915,7 @@ export function calculateDrilldownMetrics(
cv_aht: Math.round(avgCv * 10) / 10, cv_aht: Math.round(avgCv * 10) / 10,
transfer_rate: Math.round(avgTransfer * 10) / 10, transfer_rate: Math.round(avgTransfer * 10) / 10,
fcr_rate: Math.round(avgFcr * 10) / 10, fcr_rate: Math.round(avgFcr * 10) / 10,
fcr_tecnico: Math.round(avgFcrTecnico * 10) / 10, // FCR Técnico para consistencia
agenticScore: Math.round(avgScore * 10) / 10, agenticScore: Math.round(avgScore * 10) / 10,
isPriorityCandidate: hasAutomateQueue, isPriorityCandidate: hasAutomateQueue,
annualCost: totalCost annualCost: totalCost
@@ -804,7 +945,7 @@ export function calculateDrilldownMetrics(
/** /**
* PASO 3: Transformar métricas a dimensiones (0-10) * PASO 3: Transformar métricas a dimensiones (0-10)
*/ */
function generateHeatmapFromMetrics( export function generateHeatmapFromMetrics(
metrics: SkillMetrics[], metrics: SkillMetrics[],
avgCsat: number, avgCsat: number,
segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] } segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] }
@@ -858,8 +999,10 @@ function generateHeatmapFromMetrics(
// Scores de performance (normalizados 0-100) // Scores de performance (normalizados 0-100)
// FCR Real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE) // FCR Real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE)
// Usamos el fcr_rate calculado correctamente // Esta es la métrica más estricta - sin transferencia Y sin recontacto en 7 días
const fcr_score = Math.round(m.fcr_rate); const fcr_score = Math.round(m.fcr_rate);
// FCR Técnico: solo sin transferencia (comparable con benchmarks de industria COPC, Dimension Data)
const fcr_tecnico_score = Math.round(m.fcr_tecnico);
const aht_score = Math.round(Math.max(0, Math.min(100, 100 - ((m.aht_mean - 240) / 310) * 100))); const aht_score = Math.round(Math.max(0, Math.min(100, 100 - ((m.aht_mean - 240) / 310) * 100)));
const csat_score = avgCsat; const csat_score = avgCsat;
const hold_time_score = Math.round(Math.max(0, Math.min(100, 100 - (m.hold_time_mean / 60) * 10))); const hold_time_score = Math.round(Math.max(0, Math.min(100, 100 - (m.hold_time_mean / 60) * 10)));
@@ -871,9 +1014,15 @@ function generateHeatmapFromMetrics(
return { return {
skill: m.skill, skill: m.skill,
volume: m.volume, volume: m.volume,
cost_volume: m.cost_volume, // Volumen usado para calcular coste (non-abandon)
aht_seconds: Math.round(m.aht_mean), aht_seconds: Math.round(m.aht_mean),
aht_total: Math.round(m.aht_total), // AHT con TODAS las filas (solo informativo)
aht_benchmark: Math.round(m.aht_benchmark), // AHT tradicional para comparación con benchmarks de industria
annual_cost: Math.round(m.total_cost), // Coste calculado con TODOS los registros (noise + zombie + valid)
cpi: m.cpi, // Coste por interacción (calculado correctamente)
metrics: { metrics: {
fcr: fcr_score, fcr: fcr_score, // FCR Real (más estricto, con filtro de recontacto 7d)
fcr_tecnico: fcr_tecnico_score, // FCR Técnico (comparable con benchmarks industria)
aht: aht_score, aht: aht_score,
csat: csat_score, csat: csat_score,
hold_time: hold_time_score, hold_time: hold_time_score,
@@ -912,17 +1061,146 @@ function generateHeatmapFromMetrics(
} }
/** /**
* Calcular Health Score global * Calcular Health Score global - Nueva fórmula basada en benchmarks de industria
*
* PASO 1: Normalización de componentes usando percentiles de industria
* PASO 2: Ponderación (FCR 35%, Abandono 30%, CSAT Proxy 20%, AHT 15%)
* PASO 3: Penalizaciones por umbrales críticos
*
* Benchmarks de industria (Cross-Industry):
* - FCR Técnico: P10=85%, P50=68%, P90=50%
* - Abandono: P10=3%, P50=5%, P90=10%
* - AHT: P10=240s, P50=380s, P90=540s
*/ */
function calculateHealthScore(heatmapData: HeatmapDataPoint[]): number { function calculateHealthScore(heatmapData: HeatmapDataPoint[]): number {
if (heatmapData.length === 0) return 50; if (heatmapData.length === 0) return 50;
const avgFCR = heatmapData.reduce((sum, d) => sum + (d.metrics?.fcr || 0), 0) / heatmapData.length; const totalVolume = heatmapData.reduce((sum, d) => sum + d.volume, 0);
const avgAHT = heatmapData.reduce((sum, d) => sum + (d.metrics?.aht || 0), 0) / heatmapData.length; if (totalVolume === 0) return 50;
const avgCSAT = heatmapData.reduce((sum, d) => sum + (d.metrics?.csat || 0), 0) / heatmapData.length;
const avgVariability = heatmapData.reduce((sum, d) => sum + (100 - (d.variability?.cv_aht || 0)), 0) / heatmapData.length; // ═══════════════════════════════════════════════════════════════
// PASO 0: Extraer métricas ponderadas por volumen
return Math.round((avgFCR + avgAHT + avgCSAT + avgVariability) / 4); // ═══════════════════════════════════════════════════════════════
// FCR Técnico (%)
const fcrTecnico = heatmapData.reduce((sum, d) =>
sum + (d.metrics?.fcr_tecnico ?? (100 - d.metrics.transfer_rate)) * d.volume, 0) / totalVolume;
// Abandono (%)
const abandono = heatmapData.reduce((sum, d) =>
sum + (d.metrics?.abandonment_rate || 0) * d.volume, 0) / totalVolume;
// AHT (segundos) - usar aht_seconds (AHT limpio sin noise/zombies)
const aht = heatmapData.reduce((sum, d) =>
sum + d.aht_seconds * d.volume, 0) / totalVolume;
// Transferencia (%)
const transferencia = heatmapData.reduce((sum, d) =>
sum + (d.metrics?.transfer_rate || 0) * d.volume, 0) / totalVolume;
// ═══════════════════════════════════════════════════════════════
// PASO 1: Normalización de componentes (0-100 score)
// ═══════════════════════════════════════════════════════════════
// FCR Técnico: P10=85%, P50=68%, P90=50%
// Más alto = mejor
let fcrScore: number;
if (fcrTecnico >= 85) {
fcrScore = 95 + 5 * Math.min(1, (fcrTecnico - 85) / 15); // 95-100
} else if (fcrTecnico >= 68) {
fcrScore = 50 + 50 * (fcrTecnico - 68) / (85 - 68); // 50-100
} else if (fcrTecnico >= 50) {
fcrScore = 20 + 30 * (fcrTecnico - 50) / (68 - 50); // 20-50
} else {
fcrScore = Math.max(0, 20 * fcrTecnico / 50); // 0-20
}
// Abandono: P10=3%, P50=5%, P90=10%
// Más bajo = mejor (invertido)
let abandonoScore: number;
if (abandono <= 3) {
abandonoScore = 95 + 5 * Math.max(0, (3 - abandono) / 3); // 95-100
} else if (abandono <= 5) {
abandonoScore = 50 + 45 * (5 - abandono) / (5 - 3); // 50-95
} else if (abandono <= 10) {
abandonoScore = 20 + 30 * (10 - abandono) / (10 - 5); // 20-50
} else {
// Por encima de P90 (crítico): penalización fuerte
abandonoScore = Math.max(0, 20 - 2 * (abandono - 10)); // 0-20, decrece rápido
}
// AHT: P10=240s, P50=380s, P90=540s
// Más bajo = mejor (invertido)
// PERO: Si FCR es bajo, AHT bajo puede indicar llamadas rushed (mala calidad)
let ahtScore: number;
if (aht <= 240) {
// Por debajo de P10 (excelente eficiencia)
// Si FCR > 65%, es genuinamente eficiente; si no, puede ser rushed
if (fcrTecnico > 65) {
ahtScore = 95 + 5 * Math.max(0, (240 - aht) / 60); // 95-100
} else {
ahtScore = 70; // Cap score si FCR es bajo (posible rushed calls)
}
} else if (aht <= 380) {
ahtScore = 50 + 45 * (380 - aht) / (380 - 240); // 50-95
} else if (aht <= 540) {
ahtScore = 20 + 30 * (540 - aht) / (540 - 380); // 20-50
} else {
ahtScore = Math.max(0, 20 * (600 - aht) / 60); // 0-20
}
// CSAT Proxy: Calculado desde FCR + Abandono
// Sin datos reales de CSAT, usamos proxy
const csatProxy = 0.60 * fcrScore + 0.40 * abandonoScore;
// ═══════════════════════════════════════════════════════════════
// PASO 2: Aplicar pesos
// FCR 35% + Abandono 30% + CSAT Proxy 20% + AHT 15%
// ═══════════════════════════════════════════════════════════════
const subtotal = (
fcrScore * 0.35 +
abandonoScore * 0.30 +
csatProxy * 0.20 +
ahtScore * 0.15
);
// ═══════════════════════════════════════════════════════════════
// PASO 3: Calcular penalizaciones
// ═══════════════════════════════════════════════════════════════
let penalties = 0;
// Penalización por abandono crítico (>10%)
if (abandono > 10) {
penalties += 10;
}
// Penalización por transferencia alta (>20%)
if (transferencia > 20) {
penalties += 5;
}
// Penalización combo: Abandono alto + FCR bajo
// Indica problemas sistémicos de capacidad Y resolución
if (abandono > 8 && fcrTecnico < 78) {
penalties += 5;
}
// ═══════════════════════════════════════════════════════════════
// PASO 4: Score final
// ═══════════════════════════════════════════════════════════════
const finalScore = Math.max(0, Math.min(100, subtotal - penalties));
// Debug logging
console.log('📊 Health Score Calculation:', {
inputs: { fcrTecnico: fcrTecnico.toFixed(1), abandono: abandono.toFixed(1), aht: Math.round(aht), transferencia: transferencia.toFixed(1) },
scores: { fcrScore: fcrScore.toFixed(1), abandonoScore: abandonoScore.toFixed(1), ahtScore: ahtScore.toFixed(1), csatProxy: csatProxy.toFixed(1) },
weighted: { subtotal: subtotal.toFixed(1), penalties, final: Math.round(finalScore) }
});
return Math.round(finalScore);
} }
/** /**
@@ -942,10 +1220,10 @@ function generateDimensionsFromRealData(
const avgHoldTime = metrics.reduce((sum, m) => sum + m.hold_time_mean, 0) / metrics.length; const avgHoldTime = metrics.reduce((sum, m) => sum + m.hold_time_mean, 0) / metrics.length;
const totalCost = metrics.reduce((sum, m) => sum + m.total_cost, 0); const totalCost = metrics.reduce((sum, m) => sum + m.total_cost, 0);
// FCR real (ponderado por volumen) // FCR Técnico (100 - transfer_rate, ponderado por volumen) - comparable con benchmarks
const totalVolumeForFCR = metrics.reduce((sum, m) => sum + m.volume_valid, 0); const totalVolumeForFCR = metrics.reduce((sum, m) => sum + m.volume_valid, 0);
const avgFCR = totalVolumeForFCR > 0 const avgFCR = totalVolumeForFCR > 0
? metrics.reduce((sum, m) => sum + (m.fcr_rate * m.volume_valid), 0) / totalVolumeForFCR ? metrics.reduce((sum, m) => sum + (m.fcr_tecnico * m.volume_valid), 0) / totalVolumeForFCR
: 0; : 0;
// Calcular ratio P90/P50 aproximado desde CV // Calcular ratio P90/P50 aproximado desde CV
@@ -964,20 +1242,41 @@ function generateDimensionsFromRealData(
// % fuera horario >30% penaliza, ratio pico/valle >3x penaliza // % fuera horario >30% penaliza, ratio pico/valle >3x penaliza
const offHoursPct = hourlyDistribution.off_hours_pct; const offHoursPct = hourlyDistribution.off_hours_pct;
// Calcular ratio pico/valle // Calcular ratio pico/valle (consistente con backendMapper.ts)
const hourlyValues = hourlyDistribution.hourly.filter(v => v > 0); const hourlyValues = hourlyDistribution.hourly.filter(v => v > 0);
const peakVolume = Math.max(...hourlyValues, 1); const peakVolume = hourlyValues.length > 0 ? Math.max(...hourlyValues) : 0;
const valleyVolume = Math.min(...hourlyValues.filter(v => v > 0), 1); const valleyVolume = hourlyValues.length > 0 ? Math.min(...hourlyValues) : 1;
const peakValleyRatio = peakVolume / valleyVolume; const peakValleyRatio = valleyVolume > 0 ? peakVolume / valleyVolume : 1;
// Score volumetría: 100 base, penalizar por fuera de horario y ratio pico/valle // Score volumetría: 100 base, penalizar por fuera de horario y ratio pico/valle
// NOTA: Fórmulas sincronizadas con backendMapper.ts buildVolumetryDimension()
let volumetryScore = 100; let volumetryScore = 100;
if (offHoursPct > 30) volumetryScore -= (offHoursPct - 30) * 1.5; // Penalizar por % fuera horario
if (peakValleyRatio > 3) volumetryScore -= (peakValleyRatio - 3) * 10; // Penalizar por ratio pico/valle
volumetryScore = Math.max(20, Math.min(100, Math.round(volumetryScore)));
// === CPI: Coste por interacción === // Penalización por fuera de horario (misma fórmula que backendMapper)
const costPerInteraction = totalVolume > 0 ? totalCost / totalVolume : 0; if (offHoursPct > 30) {
volumetryScore -= Math.min(40, (offHoursPct - 30) * 2); // -2 pts por cada % sobre 30%
} else if (offHoursPct > 20) {
volumetryScore -= (offHoursPct - 20); // -1 pt por cada % entre 20-30%
}
// Penalización por ratio pico/valle alto (misma fórmula que backendMapper)
if (peakValleyRatio > 5) {
volumetryScore -= 30;
} else if (peakValleyRatio > 3) {
volumetryScore -= 20;
} else if (peakValleyRatio > 2) {
volumetryScore -= 10;
}
volumetryScore = Math.max(0, Math.min(100, Math.round(volumetryScore)));
// === CPI: Coste por interacción (consistente con Executive Summary) ===
// Usar cost_volume (non-abandon) como denominador, igual que heatmapData
const totalCostVolume = metrics.reduce((sum, m) => sum + m.cost_volume, 0);
// Usar CPI pre-calculado si disponible, sino calcular desde total_cost / cost_volume
const costPerInteraction = totalCostVolume > 0
? metrics.reduce((sum, m) => sum + (m.cpi * m.cost_volume), 0) / totalCostVolume
: (totalCost / totalVolume);
// Calcular Agentic Score // Calcular Agentic Score
const predictability = Math.max(0, Math.min(10, 10 - ((avgCV - 0.3) / 1.2 * 10))); const predictability = Math.max(0, Math.min(10, 10 - ((avgCV - 0.3) / 1.2 * 10)));
@@ -1008,37 +1307,37 @@ function generateDimensionsFromRealData(
peak_hours: hourlyDistribution.peak_hours peak_hours: hourlyDistribution.peak_hours
} }
}, },
// 2. EFICIENCIA OPERATIVA // 2. EFICIENCIA OPERATIVA - KPI principal: AHT P50 (industry standard)
{ {
id: 'operational_efficiency', id: 'operational_efficiency',
name: 'operational_efficiency', name: 'operational_efficiency',
title: 'Eficiencia Operativa', title: 'Eficiencia Operativa',
score: Math.round(efficiencyScore), score: Math.round(efficiencyScore),
percentile: efficiencyPercentile, percentile: efficiencyPercentile,
summary: `Ratio P90/P50: ${avgRatio.toFixed(2)} (benchmark: <2.0). AHT P50: ${avgAHT}s (benchmark: 380s). Hold time: ${Math.round(avgHoldTime)}s.`, summary: `AHT P50: ${avgAHT}s (benchmark: 300s). Ratio P90/P50: ${avgRatio.toFixed(2)} (benchmark: <2.0). Hold time: ${Math.round(avgHoldTime)}s.`,
kpi: { label: 'Ratio P90/P50', value: avgRatio.toFixed(2) }, kpi: { label: 'AHT P50', value: `${avgAHT}s` },
icon: Zap icon: Zap
}, },
// 3. EFECTIVIDAD & RESOLUCIÓN // 3. EFECTIVIDAD & RESOLUCIÓN (FCR Técnico = 100 - transfer_rate)
{ {
id: 'effectiveness_resolution', id: 'effectiveness_resolution',
name: 'effectiveness_resolution', name: 'effectiveness_resolution',
title: 'Efectividad & Resolución', title: 'Efectividad & Resolución',
score: Math.round(avgFCR), score: avgFCR >= 90 ? 100 : avgFCR >= 85 ? 80 : avgFCR >= 80 ? 60 : avgFCR >= 75 ? 40 : 20,
percentile: fcrPercentile, percentile: fcrPercentile,
summary: `FCR: ${avgFCR.toFixed(1)}% (benchmark: 70%). Calculado como: (sin transferencia) AND (sin rellamada 7d).`, summary: `FCR Técnico: ${avgFCR.toFixed(1)}% (benchmark: 85-90%). Transfer: ${avgTransferRate.toFixed(1)}%.`,
kpi: { label: 'FCR Real', value: `${Math.round(avgFCR)}%` }, kpi: { label: 'FCR Técnico', value: `${Math.round(avgFCR)}%` },
icon: Target icon: Target
}, },
// 4. COMPLEJIDAD & PREDICTIBILIDAD - Usar % transferencias como métrica principal // 4. COMPLEJIDAD & PREDICTIBILIDAD - KPI principal: CV AHT (industry standard for predictability)
{ {
id: 'complexity_predictability', id: 'complexity_predictability',
name: 'complexity_predictability', name: 'complexity_predictability',
title: 'Complejidad & Predictibilidad', title: 'Complejidad & Predictibilidad',
score: Math.round(100 - avgTransferRate), // Inverso de transfer rate score: avgCV <= 0.75 ? 100 : avgCV <= 1.0 ? 80 : avgCV <= 1.25 ? 60 : avgCV <= 1.5 ? 40 : 20, // Basado en CV AHT
percentile: avgTransferRate < 15 ? 75 : avgTransferRate < 25 ? 50 : 30, percentile: avgCV <= 0.75 ? 75 : avgCV <= 1.0 ? 55 : avgCV <= 1.25 ? 40 : 25,
summary: `Tasa transferencias: ${avgTransferRate.toFixed(1)}%. CV AHT: ${(avgCV * 100).toFixed(1)}%. ${avgTransferRate < 15 ? 'Baja complejidad.' : 'Alta complejidad, considerar capacitación.'}`, summary: `CV AHT: ${(avgCV * 100).toFixed(0)}% (benchmark: <75%). Hold time: ${Math.round(avgHoldTime)}s. ${avgCV <= 0.75 ? 'Alta predictibilidad para WFM.' : avgCV <= 1.0 ? 'Predictibilidad aceptable.' : 'Alta variabilidad, dificulta planificación.'}`,
kpi: { label: '% Transferencias', value: `${avgTransferRate.toFixed(1)}%` }, kpi: { label: 'CV AHT', value: `${(avgCV * 100).toFixed(0)}%` },
icon: Brain icon: Brain
}, },
// 5. SATISFACCIÓN - CSAT // 5. SATISFACCIÓN - CSAT
@@ -1205,7 +1504,11 @@ function calculateAgenticReadinessFromRealData(metrics: SkillMetrics[]): Agentic
/** /**
* Generar findings desde datos reales - SOLO datos calculados del dataset * Generar findings desde datos reales - SOLO datos calculados del dataset
*/ */
function generateFindingsFromRealData(metrics: SkillMetrics[], interactions: RawInteraction[]): Finding[] { function generateFindingsFromRealData(
metrics: SkillMetrics[],
interactions: RawInteraction[],
hourlyDistribution?: { hourly: number[]; off_hours_pct: number; peak_hours: number[] }
): Finding[] {
const findings: Finding[] = []; const findings: Finding[] = [];
const totalVolume = interactions.length; const totalVolume = interactions.length;
@@ -1218,6 +1521,20 @@ function generateFindingsFromRealData(metrics: SkillMetrics[], interactions: Raw
const totalAbandoned = metrics.reduce((sum, m) => sum + m.abandon_count, 0); const totalAbandoned = metrics.reduce((sum, m) => sum + m.abandon_count, 0);
const abandonRate = totalVolume > 0 ? (totalAbandoned / totalVolume) * 100 : 0; const abandonRate = totalVolume > 0 ? (totalAbandoned / totalVolume) * 100 : 0;
// Finding 0: Alto volumen fuera de horario - oportunidad para agente virtual
const offHoursPct = hourlyDistribution?.off_hours_pct ?? 0;
if (offHoursPct > 20) {
const offHoursVolume = Math.round(totalVolume * offHoursPct / 100);
findings.push({
type: offHoursPct > 30 ? 'critical' : 'warning',
title: 'Alto Volumen Fuera de Horario',
text: `${offHoursPct.toFixed(0)}% de interacciones fuera de horario (8-19h)`,
dimensionId: 'volumetry_distribution',
description: `${offHoursVolume.toLocaleString()} interacciones (${offHoursPct.toFixed(1)}%) ocurren fuera de horario laboral. Oportunidad ideal para implementar agentes virtuales 24/7.`,
impact: offHoursPct > 30 ? 'high' : 'medium'
});
}
// Finding 1: Ratio P90/P50 si está fuera de benchmark // Finding 1: Ratio P90/P50 si está fuera de benchmark
if (avgRatio > 2.0) { if (avgRatio > 2.0) {
findings.push({ findings.push({
@@ -1284,29 +1601,53 @@ function generateFindingsFromRealData(metrics: SkillMetrics[], interactions: Raw
/** /**
* Generar recomendaciones desde datos reales * Generar recomendaciones desde datos reales
*/ */
function generateRecommendationsFromRealData(metrics: SkillMetrics[]): Recommendation[] { function generateRecommendationsFromRealData(
metrics: SkillMetrics[],
hourlyDistribution?: { hourly: number[]; off_hours_pct: number; peak_hours: number[] },
totalVolume?: number
): Recommendation[] {
const recommendations: Recommendation[] = []; const recommendations: Recommendation[] = [];
// Recomendación prioritaria: Agente virtual para fuera de horario
const offHoursPct = hourlyDistribution?.off_hours_pct ?? 0;
const volume = totalVolume ?? metrics.reduce((sum, m) => sum + m.volume, 0);
if (offHoursPct > 20) {
const offHoursVolume = Math.round(volume * offHoursPct / 100);
const estimatedContainment = offHoursPct > 30 ? 60 : 45; // % que puede resolver el bot
const estimatedSavings = Math.round(offHoursVolume * estimatedContainment / 100);
recommendations.push({
priority: 'high',
title: 'Implementar Agente Virtual 24/7',
text: `Desplegar agente virtual para atender ${offHoursPct.toFixed(0)}% de interacciones fuera de horario`,
description: `${offHoursVolume.toLocaleString()} interacciones ocurren fuera de horario laboral (19:00-08:00). Un agente virtual puede resolver ~${estimatedContainment}% de estas consultas automáticamente, liberando recursos humanos y mejorando la experiencia del cliente con atención inmediata 24/7.`,
dimensionId: 'volumetry_distribution',
impact: `Potencial de contención: ${estimatedSavings.toLocaleString()} interacciones/período`,
timeline: '1-3 meses'
});
}
const highVariabilitySkills = metrics.filter(m => m.cv_aht > 0.45); const highVariabilitySkills = metrics.filter(m => m.cv_aht > 0.45);
if (highVariabilitySkills.length > 0) { if (highVariabilitySkills.length > 0) {
recommendations.push({ recommendations.push({
priority: 'high', priority: 'high',
title: 'Estandarizar Procesos', title: 'Estandarizar Procesos',
text: `Crear guías y scripts para los ${highVariabilitySkills.length} skills con alta variabilidad`,
description: `Crear guías y scripts para los ${highVariabilitySkills.length} skills con alta variabilidad.`, description: `Crear guías y scripts para los ${highVariabilitySkills.length} skills con alta variabilidad.`,
impact: 'Reducción del 20-30% en AHT' impact: 'Reducción del 20-30% en AHT'
}); });
} }
const highVolumeSkills = metrics.filter(m => m.volume > 500); const highVolumeSkills = metrics.filter(m => m.volume > 500);
if (highVolumeSkills.length > 0) { if (highVolumeSkills.length > 0) {
recommendations.push({ recommendations.push({
priority: 'high', priority: 'high',
title: 'Automatizar Skills de Alto Volumen', title: 'Automatizar Skills de Alto Volumen',
text: `Implementar bots para los ${highVolumeSkills.length} skills con > 500 interacciones`,
description: `Implementar bots para los ${highVolumeSkills.length} skills con > 500 interacciones.`, description: `Implementar bots para los ${highVolumeSkills.length} skills con > 500 interacciones.`,
impact: 'Ahorro estimado del 40-60%' impact: 'Ahorro estimado del 40-60%'
}); });
} }
return recommendations; return recommendations;
} }
@@ -1347,12 +1688,18 @@ const CPI_CONFIG = {
RATE_AUGMENT: 0.15 // 15% mejora en optimización RATE_AUGMENT: 0.15 // 15% mejora en optimización
}; };
// Período de datos: el volumen en los datos corresponde a 11 meses, no es mensual
const DATA_PERIOD_MONTHS = 11;
/** /**
* v3.6: Calcular ahorro TCO realista usando fórmula explícita con CPI fijos * v4.2: Calcular ahorro TCO realista usando fórmula explícita con CPI fijos
* IMPORTANTE: El volumen de los datos corresponde a 11 meses, por lo que:
* - Primero calculamos volumen mensual: Vol / 11
* - Luego anualizamos: × 12
* Fórmulas: * Fórmulas:
* - AUTOMATE: Vol × 12 × 70% × (CPI_humano - CPI_bot) * - AUTOMATE: (Vol/11) × 12 × 70% × (CPI_humano - CPI_bot)
* - ASSIST: Vol × 12 × 30% × (CPI_humano - CPI_assist) * - ASSIST: (Vol/11) × 12 × 30% × (CPI_humano - CPI_assist)
* - AUGMENT: Vol × 12 × 15% × (CPI_humano - CPI_augment) * - AUGMENT: (Vol/11) × 12 × 15% × (CPI_humano - CPI_augment)
* - HUMAN-ONLY: 0€ * - HUMAN-ONLY: 0€
*/ */
function calculateRealisticSavings( function calculateRealisticSavings(
@@ -1364,18 +1711,21 @@ function calculateRealisticSavings(
const { CPI_HUMANO, CPI_BOT, CPI_ASSIST, CPI_AUGMENT, RATE_AUTOMATE, RATE_ASSIST, RATE_AUGMENT } = CPI_CONFIG; const { CPI_HUMANO, CPI_BOT, CPI_ASSIST, CPI_AUGMENT, RATE_AUTOMATE, RATE_ASSIST, RATE_AUGMENT } = CPI_CONFIG;
// Convertir volumen del período (11 meses) a volumen anual
const annualVolume = (volume / DATA_PERIOD_MONTHS) * 12;
switch (tier) { switch (tier) {
case 'AUTOMATE': case 'AUTOMATE':
// Ahorro = Vol × 12 × 70% × (CPI_humano - CPI_bot) // Ahorro = VolAnual × 70% × (CPI_humano - CPI_bot)
return Math.round(volume * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT)); return Math.round(annualVolume * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT));
case 'ASSIST': case 'ASSIST':
// Ahorro = Vol × 12 × 30% × (CPI_humano - CPI_assist) // Ahorro = VolAnual × 30% × (CPI_humano - CPI_assist)
return Math.round(volume * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST)); return Math.round(annualVolume * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST));
case 'AUGMENT': case 'AUGMENT':
// Ahorro = Vol × 12 × 15% × (CPI_humano - CPI_augment) // Ahorro = VolAnual × 15% × (CPI_humano - CPI_augment)
return Math.round(volume * 12 * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT)); return Math.round(annualVolume * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT));
case 'HUMAN-ONLY': case 'HUMAN-ONLY':
default: default:
@@ -1384,118 +1734,79 @@ function calculateRealisticSavings(
} }
export function generateOpportunitiesFromDrilldown(drilldownData: DrilldownDataPoint[], costPerHour: number): Opportunity[] { export function generateOpportunitiesFromDrilldown(drilldownData: DrilldownDataPoint[], costPerHour: number): Opportunity[] {
const opportunities: Opportunity[] = []; // v4.3: Top 10 iniciativas por potencial económico (todos los tiers, no solo AUTOMATE)
// Cada cola = 1 burbuja con su score real y ahorro TCO real según su tier
// Extraer todas las colas usando el nuevo sistema de Tiers // Extraer todas las colas con su skill padre (excluir HUMAN-ONLY, no tienen ahorro)
const allQueues = drilldownData.flatMap(skill => const allQueues = drilldownData.flatMap(skill =>
skill.originalQueues.map(q => ({ skill.originalQueues
...q, .filter(q => q.tier !== 'HUMAN-ONLY') // HUMAN-ONLY no genera ahorro
skillName: skill.skill .map(q => ({
})) ...q,
skillName: skill.skill
}))
); );
// v3.5: Clasificar colas por TIER (no por CV) if (allQueues.length === 0) {
const automateQueues = allQueues.filter(q => q.tier === 'AUTOMATE'); console.warn('⚠️ No hay colas con potencial de ahorro para mostrar en Opportunity Matrix');
const assistQueues = allQueues.filter(q => q.tier === 'ASSIST'); return [];
const augmentQueues = allQueues.filter(q => q.tier === 'AUGMENT'); }
const humanQueues = allQueues.filter(q => q.tier === 'HUMAN-ONLY');
// Calcular volúmenes y costes por tier // Calcular ahorro TCO por cola individual según su tier
const automateVolume = automateQueues.reduce((sum, q) => sum + q.volume, 0); const queuesWithSavings = allQueues.map(q => {
const automateCost = automateQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0); const savings = calculateRealisticSavings(q.volume, q.annualCost || 0, q.tier);
const assistVolume = assistQueues.reduce((sum, q) => sum + q.volume, 0); return { ...q, savings };
const assistCost = assistQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0); });
const augmentVolume = augmentQueues.reduce((sum, q) => sum + q.volume, 0);
const augmentCost = augmentQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0);
const totalCost = automateCost + assistCost + augmentCost;
// v3.5: Calcular ahorros REALISTAS con fórmula TCO // Ordenar por ahorro descendente
const automateSavings = calculateRealisticSavings(automateVolume, automateCost, 'AUTOMATE'); queuesWithSavings.sort((a, b) => b.savings - a.savings);
const assistSavings = calculateRealisticSavings(assistVolume, assistCost, 'ASSIST');
const augmentSavings = calculateRealisticSavings(augmentVolume, augmentCost, 'AUGMENT');
// Helper para obtener top skills // Calcular max savings para escalar impact a 0-10
const getTopSkills = (queues: typeof allQueues, limit: number = 3): string[] => { const maxSavings = Math.max(...queuesWithSavings.map(q => q.savings), 1);
const skillVolumes = new Map<string, number>();
queues.forEach(q => { // Mapeo de tier a dimensionId y customer_segment
skillVolumes.set(q.skillName, (skillVolumes.get(q.skillName) || 0) + q.volume); const tierToDimension: Record<string, string> = {
}); 'AUTOMATE': 'agentic_readiness',
return Array.from(skillVolumes.entries()) 'ASSIST': 'effectiveness_resolution',
.sort((a, b) => b[1] - a[1]) 'AUGMENT': 'complexity_predictability'
.slice(0, limit) };
.map(([name]) => name); const tierToSegment: Record<string, CustomerSegment> = {
'AUTOMATE': 'high',
'ASSIST': 'medium',
'AUGMENT': 'low'
}; };
let oppIndex = 1; // Generar oportunidades individuales (TOP 10 por potencial económico)
const opportunities: Opportunity[] = queuesWithSavings
.slice(0, 10)
.map((q, idx) => {
// Impact: ahorro escalado a 0-10
const impactRaw = (q.savings / maxSavings) * 10;
const impact = Math.max(1, Math.min(10, Math.round(impactRaw * 10) / 10));
// Oportunidad 1: AUTOMATE (70% containment) // Feasibility: agenticScore directo (ya es 0-10)
if (automateQueues.length > 0) { const feasibility = Math.round(q.agenticScore * 10) / 10;
opportunities.push({
id: `opp-${oppIndex++}`, // Nombre con prefijo de tier para claridad
name: `Automatizar ${automateQueues.length} colas tier AUTOMATE`, const tierPrefix = q.tier === 'AUTOMATE' ? '🤖' : q.tier === 'ASSIST' ? '🤝' : '📚';
impact: Math.min(10, Math.round((automateCost / totalCost) * 10) + 3), const shortName = q.original_queue_id.length > 22
feasibility: 9, ? `${tierPrefix} ${q.original_queue_id.substring(0, 19)}...`
savings: automateSavings, : `${tierPrefix} ${q.original_queue_id}`;
dimensionId: 'agentic_readiness',
customer_segment: 'high' as CustomerSegment return {
id: `opp-${q.tier.toLowerCase()}-${idx + 1}`,
name: shortName,
impact,
feasibility,
savings: q.savings,
dimensionId: tierToDimension[q.tier] || 'agentic_readiness',
customer_segment: tierToSegment[q.tier] || 'medium'
};
}); });
}
// Oportunidad 2: ASSIST (30% efficiency) console.log(`📊 Opportunity Matrix: Top ${opportunities.length} iniciativas por potencial económico (de ${allQueues.length} colas con ahorro)`);
if (assistQueues.length > 0) {
opportunities.push({
id: `opp-${oppIndex++}`,
name: `Copilot IA en ${assistQueues.length} colas tier ASSIST`,
impact: Math.min(10, Math.round((assistCost / totalCost) * 10) + 2),
feasibility: 7,
savings: assistSavings,
dimensionId: 'effectiveness_resolution',
customer_segment: 'medium' as CustomerSegment
});
}
// Oportunidad 3: AUGMENT (15% optimization) return opportunities;
if (augmentQueues.length > 0) {
opportunities.push({
id: `opp-${oppIndex++}`,
name: `Optimizar ${augmentQueues.length} colas tier AUGMENT`,
impact: Math.min(10, Math.round((augmentCost / totalCost) * 10) + 1),
feasibility: 5,
savings: augmentSavings,
dimensionId: 'complexity_predictability',
customer_segment: 'medium' as CustomerSegment
});
}
// Oportunidades específicas por skill con alto volumen
const skillsWithHighVolume = drilldownData
.filter(s => s.volume > 10000)
.sort((a, b) => b.volume - a.volume)
.slice(0, 3);
for (const skill of skillsWithHighVolume) {
const autoQueues = skill.originalQueues.filter(q => q.tier === 'AUTOMATE');
if (autoQueues.length > 0) {
const skillVolume = autoQueues.reduce((sum, q) => sum + q.volume, 0);
const skillCost = autoQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0);
const savings = calculateRealisticSavings(skillVolume, skillCost, 'AUTOMATE');
opportunities.push({
id: `opp-${oppIndex++}`,
name: `Quick win: ${skill.skill}`,
impact: Math.min(8, Math.round(skillVolume / 30000) + 3),
feasibility: 8,
savings,
dimensionId: 'operational_efficiency',
customer_segment: 'high' as CustomerSegment
});
}
}
// Ordenar por ahorro (ya es realista)
opportunities.sort((a, b) => b.savings - a.savings);
return opportunities.slice(0, 8);
} }
/** /**
@@ -2115,10 +2426,10 @@ function generateBenchmarkFromRealData(metrics: SkillMetrics[]): BenchmarkDataPo
const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / (metrics.length || 1); const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / (metrics.length || 1);
const avgRatio = 1 + avgCV * 1.5; // Ratio P90/P50 aproximado const avgRatio = 1 + avgCV * 1.5; // Ratio P90/P50 aproximado
// FCR Real: ponderado por volumen // FCR Técnico: 100 - transfer_rate (ponderado por volumen)
const totalVolume = metrics.reduce((sum, m) => sum + m.volume_valid, 0); const totalVolume = metrics.reduce((sum, m) => sum + m.volume_valid, 0);
const avgFCR = totalVolume > 0 const avgFCR = totalVolume > 0
? metrics.reduce((sum, m) => sum + (m.fcr_rate * m.volume_valid), 0) / totalVolume ? metrics.reduce((sum, m) => sum + (m.fcr_tecnico * m.volume_valid), 0) / totalVolume
: 0; : 0;
// Abandono real // Abandono real