diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3536986 --- /dev/null +++ b/CLAUDE.md @@ -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`) diff --git a/backend/beyond_api/api/cache.py b/backend/beyond_api/api/cache.py index 690fbae..26686b6 100644 --- a/backend/beyond_api/api/cache.py +++ b/backend/beyond_api/api/cache.py @@ -8,6 +8,7 @@ from __future__ import annotations import json import os import shutil +import sys from datetime import datetime from pathlib import Path from typing import Any, Optional @@ -23,12 +24,38 @@ router = APIRouter( tags=["cache"], ) -# Directory for cache files -CACHE_DIR = Path(os.getenv("CACHE_DIR", "/data/cache")) +# Directory for cache files - use platform-appropriate default +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" METADATA_FILE = CACHE_DIR / "metadata.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): fileName: str @@ -158,7 +185,11 @@ def get_cached_drilldown(current_user: str = Depends(get_current_user)): Get the cached drilldownData JSON. Returns the pre-calculated drilldown data for fast cache usage. """ + logger.info(f"[Cache] GET /drilldown - checking file: {DRILLDOWN_FILE}") + logger.info(f"[Cache] File exists: {DRILLDOWN_FILE.exists()}") + if not DRILLDOWN_FILE.exists(): + logger.warning(f"[Cache] Drilldown file not found at: {DRILLDOWN_FILE}") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No cached drilldown data found" @@ -167,8 +198,10 @@ def get_cached_drilldown(current_user: str = Depends(get_current_user)): try: with open(DRILLDOWN_FILE, "r", encoding="utf-8") as 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}) except Exception as e: + logger.error(f"[Cache] Error reading drilldown: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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. 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() + logger.info(f"[Cache] Cache dir exists after ensure: {CACHE_DIR.exists()}") try: # Parse and validate JSON drilldown_data = json.loads(drilldown_json) + logger.info(f"[Cache] Parsed drilldown JSON with {len(drilldown_data)} skills") # Save to file with open(DRILLDOWN_FILE, "w", encoding="utf-8") as f: json.dump(drilldown_data, f) + logger.info(f"[Cache] Drilldown saved successfully, file exists: {DRILLDOWN_FILE.exists()}") return JSONResponse(content={ "success": True, "message": f"Cached drilldown data with {len(drilldown_data)} skills" diff --git a/backend/beyond_api/main.py b/backend/beyond_api/main.py index f0b0dae..8022145 100644 --- a/backend/beyond_api/main.py +++ b/backend/beyond_api/main.py @@ -19,7 +19,9 @@ app = FastAPI() origins = [ "http://localhost:3000", + "http://localhost:3001", "http://127.0.0.1:3000", + "http://127.0.0.1:3001", ] app.add_middleware( diff --git a/backend/beyond_metrics/configs/beyond_metrics_config.json b/backend/beyond_metrics/configs/beyond_metrics_config.json index 7ebbfc9..0f44f2c 100644 --- a/backend/beyond_metrics/configs/beyond_metrics_config.json +++ b/backend/beyond_metrics/configs/beyond_metrics_config.json @@ -20,6 +20,7 @@ "metrics": [ "aht_distribution", "talk_hold_acw_p50_by_skill", + "metrics_by_skill", "fcr_rate", "escalation_rate", "abandonment_rate", diff --git a/backend/beyond_metrics/dimensions/EconomyCost.py b/backend/beyond_metrics/dimensions/EconomyCost.py index 3cd9cd0..09261f0 100644 --- a/backend/beyond_metrics/dimensions/EconomyCost.py +++ b/backend/beyond_metrics/dimensions/EconomyCost.py @@ -99,6 +99,15 @@ class EconomyCostMetrics: + df["wrap_up_time"].fillna(0) ) # 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 @property @@ -115,12 +124,19 @@ class EconomyCostMetrics: """ 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) - 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. + + 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(): return pd.DataFrame() @@ -132,8 +148,22 @@ class EconomyCostMetrics: if df.empty: return pd.DataFrame() - # AHT por skill/canal (en segundos) - grouped = df.groupby(["queue_skill", "channel"])["handle_time"].mean() + # Filter out abandonments for cost calculation (consistency with frontend) + 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: return pd.DataFrame() @@ -141,9 +171,14 @@ class EconomyCostMetrics: aht_sec = grouped 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 overhead = labor_cost * cfg.overhead_rate - cpi = labor_cost + overhead + raw_cpi = labor_cost + overhead + cpi = raw_cpi / EFFECTIVE_PRODUCTIVITY 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 @@ -180,7 +216,9 @@ class EconomyCostMetrics: .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) return joined @@ -216,7 +254,9 @@ class EconomyCostMetrics: .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 annual_labor = (joined["labor_cost"] * joined["volume"]).sum() @@ -252,7 +292,7 @@ class EconomyCostMetrics: - Ineff_seconds = Delta * volume * 0.4 - 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(): return pd.DataFrame() @@ -261,6 +301,12 @@ class EconomyCostMetrics: assert cfg is not None 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"]) stats = grouped["handle_time"].agg( @@ -273,10 +319,14 @@ class EconomyCostMetrics: return pd.DataFrame() # CPI para obtener coste/segundo de labor - cpi_table = self.cpi_by_skill_channel() - if cpi_table.empty: + # cpi_by_skill_channel now returns with reset_index, so we need to set index for join + cpi_table_raw = self.cpi_by_skill_channel() + if cpi_table_raw.empty: 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 = merged.fillna(0.0) @@ -297,7 +347,8 @@ class EconomyCostMetrics: merged["ineff_seconds"] = ineff_seconds.round(2) 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 @@ -419,7 +470,9 @@ class EconomyCostMetrics: .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 per_channel = ( diff --git a/backend/beyond_metrics/dimensions/OperationalPerformance.py b/backend/beyond_metrics/dimensions/OperationalPerformance.py index 4543791..db0a2e9 100644 --- a/backend/beyond_metrics/dimensions/OperationalPerformance.py +++ b/backend/beyond_metrics/dimensions/OperationalPerformance.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Dict, List +from typing import Any, Dict, List import numpy as np import pandas as pd @@ -87,14 +87,26 @@ class OperationalPerformanceMetrics: ) # v3.0: Filtrar NOISE y ZOMBIE para cálculos de variabilidad - # record_status: 'valid', 'noise', 'zombie', 'abandon' - # Para AHT/CV solo usamos 'valid' (o sin status = legacy data) + # record_status: 'VALID', 'NOISE', 'ZOMBIE', 'ABANDON' + # Para AHT/CV solo usamos 'VALID' (excluye noise, zombie, abandon) if "record_status" in df.columns: df["record_status"] = df["record_status"].astype(str).str.strip().str.upper() - # Crear máscara para registros válidos (para cálculos de CV/variabilidad) - df["_is_valid_for_cv"] = df["record_status"].isin(["VALID", "NAN", ""]) | df["record_status"].isna() + # Crear máscara para registros válidos: SOLO "VALID" + # 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: + # Legacy data sin record_status: incluir todo df["_is_valid_for_cv"] = True + print(f"[OperationalPerformance] No record_status column - using all {len(df)} rows") # Normalización básica 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: """ 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 @@ -173,7 +188,8 @@ class OperationalPerformanceMetrics: "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 @@ -290,13 +306,17 @@ class OperationalPerformanceMetrics: 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: - - Para cada cliente, ordena por datetime_start - - Si hay dos contactos consecutivos separados < 7 días, cuenta como "recurrente" + - Para cada combinación cliente + skill, ordena por datetime_start + - Si hay dos contactos consecutivos separados < 7 días (mismo cliente, mismo skill), + cuenta como "recurrente" - 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() @@ -313,16 +333,17 @@ class OperationalPerformanceMetrics: if df.empty: return float("nan") - # Ordenar por cliente + fecha - df = df.sort_values(["customer_id", "datetime_start"]) + # Ordenar por cliente + skill + fecha + df = df.sort_values(["customer_id", "queue_skill", "datetime_start"]) - # Diferencia de tiempo entre contactos consecutivos por cliente - df["delta"] = df.groupby("customer_id")["datetime_start"].diff() + # Diferencia de tiempo entre contactos consecutivos por cliente Y skill + # 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) - # 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() total_customers = df["customer_id"].nunique() @@ -568,3 +589,128 @@ class OperationalPerformanceMetrics: ax.grid(axis="y", alpha=0.3) 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", "sí"]) + ) + 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", "sí"]) + ) + 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 diff --git a/frontend/components/DashboardHeader.tsx b/frontend/components/DashboardHeader.tsx index 622b0eb..ef9f987 100644 --- a/frontend/components/DashboardHeader.tsx +++ b/frontend/components/DashboardHeader.tsx @@ -1,8 +1,7 @@ import { motion } from 'framer-motion'; -import { LayoutDashboard, Layers, Bot, Map } from 'lucide-react'; -import { formatDateMonthYear } from '../utils/formatters'; +import { LayoutDashboard, Layers, Bot, Map, ShieldCheck, Info, Scale } from 'lucide-react'; -export type TabId = 'executive' | 'dimensions' | 'readiness' | 'roadmap'; +export type TabId = 'executive' | 'dimensions' | 'readiness' | 'roadmap' | 'law10'; export interface TabConfig { id: TabId; @@ -14,6 +13,7 @@ interface DashboardHeaderProps { title?: string; activeTab: TabId; onTabChange: (id: TabId) => void; + onMetodologiaClick?: () => void; } const TABS: TabConfig[] = [ @@ -21,20 +21,32 @@ const TABS: TabConfig[] = [ { id: 'dimensions', label: 'Dimensiones', icon: Layers }, { id: 'readiness', label: 'Agentic Readiness', icon: Bot }, { id: 'roadmap', label: 'Roadmap', icon: Map }, + { id: 'law10', label: 'Ley 10/2025', icon: Scale }, ]; export function DashboardHeader({ title = 'AIR EUROPA - Beyond CX Analytics', activeTab, - onTabChange + onTabChange, + onMetodologiaClick }: DashboardHeaderProps) { return (
- {/* Top row: Title and Date */} + {/* Top row: Title and Metodología Badge */}

{title}

- {formatDateMonthYear()} + {onMetodologiaClick && ( + + )}
diff --git a/frontend/components/DashboardTabs.tsx b/frontend/components/DashboardTabs.tsx index ef557d5..a9a5a52 100644 --- a/frontend/components/DashboardTabs.tsx +++ b/frontend/components/DashboardTabs.tsx @@ -1,11 +1,13 @@ import { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { ArrowLeft, ShieldCheck, Info } from 'lucide-react'; +import { ArrowLeft } from 'lucide-react'; import { DashboardHeader, TabId } from './DashboardHeader'; +import { formatDateMonthYear } from '../utils/formatters'; import { ExecutiveSummaryTab } from './tabs/ExecutiveSummaryTab'; import { DimensionAnalysisTab } from './tabs/DimensionAnalysisTab'; import { AgenticReadinessTab } from './tabs/AgenticReadinessTab'; import { RoadmapTab } from './tabs/RoadmapTab'; +import { Law10Tab } from './tabs/Law10Tab'; import { MetodologiaDrawer } from './MetodologiaDrawer'; import type { AnalysisData } from '../types'; @@ -33,6 +35,8 @@ export function DashboardTabs({ return ; case 'roadmap': return ; + case 'law10': + return ; default: return ; } @@ -61,6 +65,7 @@ export function DashboardTabs({ title={title} activeTab={activeTab} onTabChange={setActiveTab} + onMetodologiaClick={() => setMetodologiaOpen(true)} /> {/* Tab Content */} @@ -84,23 +89,7 @@ export function DashboardTabs({
Beyond Diagnosis - Contact Center Analytics Platform Beyond Diagnosis -
- - {data.tier ? data.tier.toUpperCase() : 'GOLD'} | - {data.source === 'backend' ? 'Genesys' : data.source || 'synthetic'} - - | - {/* Badge Metodología */} - -
+ {formatDateMonthYear()}
diff --git a/frontend/components/MetodologiaDrawer.tsx b/frontend/components/MetodologiaDrawer.tsx index 50c8bf4..b2bff33 100644 --- a/frontend/components/MetodologiaDrawer.tsx +++ b/frontend/components/MetodologiaDrawer.tsx @@ -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 ( +
+

+ + Coste por Interacción (CPI) +

+ +

+ El CPI se calcula dividiendo el coste total entre el volumen de interacciones. + El coste total incluye todas las interacciones (noise, zombie y válidas) porque todas se facturan, + y aplica un factor de productividad del {(effectiveProductivity * 100).toFixed(0)}%. +

+ + {/* Fórmula visual */} +
+
+ Fórmula de Cálculo +
+
+ CPI + = + Coste Total + ÷ + Volumen Total +
+

+ El coste total usa (AHT segundos ÷ 3600) × coste/hora × volumen ÷ productividad +

+
+ + {/* Cómo se calcula el coste total */} +
+
¿Cómo se calcula el Coste Total?
+
+
+ Coste = + (AHT seg ÷ 3600) + × + €{costPerHour}/h + × + Volumen + ÷ + {(effectiveProductivity * 100).toFixed(0)}% +
+
+

+ El AHT 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. +

+
+ + {/* Componentes del coste horario */} +
+
+
Coste por Hora del Agente (Fully Loaded)
+ + Valor introducido: €{costPerHour.toFixed(2)}/h + +
+

+ Este valor fue configurado en la pantalla de entrada de datos y debe incluir todos los costes asociados al agente: +

+
+
+ + Salario bruto del agente +
+
+ + Costes de seguridad social +
+
+ + Licencias de software +
+
+ + Infraestructura y puesto +
+
+ + Supervisión y QA +
+
+ + Formación y overhead +
+
+

+ 💡 Si necesita ajustar este valor, puede volver a la pantalla de entrada de datos y modificarlo. +

+
+
+ ); +} + function BeforeAfterSection({ kpis }: { kpis: DataSummary['kpis'] }) { const rows = [ { @@ -528,6 +633,9 @@ function GuaranteesSection() { export function MetodologiaDrawer({ isOpen, onClose, data }: MetodologiaDrawerProps) { // Calcular datos del resumen desde AnalysisData const totalRegistros = data.heatmapData?.reduce((sum, h) => sum + h.volume, 0) || 0; + 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 let mesesHistorico = 1; @@ -633,6 +741,11 @@ export function MetodologiaDrawer({ isOpen, onClose, data }: MetodologiaDrawerPr + diff --git a/frontend/components/OpportunityMatrixPro.tsx b/frontend/components/OpportunityMatrixPro.tsx index a676278..876e118 100644 --- a/frontend/components/OpportunityMatrixPro.tsx +++ b/frontend/components/OpportunityMatrixPro.tsx @@ -81,13 +81,14 @@ const OpportunityMatrixPro: React.FC = ({ data, heatm }; }, [dataWithPriority]); - // Dynamic title + // Dynamic title - v4.3: Top 10 iniciativas por potencial económico const dynamicTitle = useMemo(() => { - const { quickWins } = portfolioSummary; - if (quickWins.count > 0) { - return `${quickWins.count} Quick Wins pueden generar €${(quickWins.savings / 1000).toFixed(0)}K en ahorros con implementación en Q1-Q2`; + const totalQueues = dataWithPriority.length; + const totalSavings = portfolioSummary.totalSavings; + 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]); const getQuadrantInfo = (impact: number, feasibility: number): QuadrantInfo => { @@ -160,21 +161,24 @@ const OpportunityMatrixPro: React.FC = ({ data, heatm
{/* Header with Dynamic Title */}
-
-

Opportunity Matrix

-
- -
- 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. -
+
+
+

Opportunity Matrix - Top 10 Iniciativas

+
+ +
+ 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. +
+
+

Priorizadas por potencial de ahorro TCO (🤖 AUTOMATE, 🤝 ASSIST, 📚 AUGMENT)

{dynamicTitle}

- 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%)

@@ -217,33 +221,33 @@ const OpportunityMatrixPro: React.FC = ({ data, heatm
{/* Y-axis Label */}
- IMPACTO + IMPACTO (Ahorro TCO)
- + {/* X-axis Label */}
- FACTIBILIDAD + FACTIBILIDAD (Agentic Score)
{/* Axis scale labels */}
- Muy Alto + Alto (10)
- Medio + Medio (5)
- Bajo + Bajo (1)
- +
- Muy Difícil + 0
- Moderado + 5
- Fácil + 10
{/* Quadrant Lines */} @@ -364,22 +368,24 @@ const OpportunityMatrixPro: React.FC = ({ data, heatm {/* Enhanced Legend */}
-
- Tamaño de burbuja = Ahorro potencial: -
-
- Pequeño (<€50K) +
+ Tier: +
+ 🤖 + AUTOMATE
-
-
- Medio (€50-150K) +
+ 🤝 + ASSIST
-
-
- Grande (>€150K) +
+ 📚 + AUGMENT
- | - Número = Prioridad estratégica + | + Tamaño = Ahorro TCO + | + Número = Ranking
@@ -447,10 +453,10 @@ const OpportunityMatrixPro: React.FC = ({ data, heatm {/* Methodology Footer */}
); diff --git a/frontend/components/OpportunityPrioritizer.tsx b/frontend/components/OpportunityPrioritizer.tsx new file mode 100644 index 0000000..d8dc83f --- /dev/null +++ b/frontend/components/OpportunityPrioritizer.tsx @@ -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 = { + 'AUTOMATE': { + icon: , + 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: , + 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: , + 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: , + 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 = ({ + opportunities, + drilldownData, + costPerHour = 20 +}) => { + const [expandedId, setExpandedId] = useState(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(); + + 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 ( +
+ +

No hay oportunidades identificadas

+

Los datos actuales no muestran oportunidades de automatización viables.

+
+ ); + } + + return ( +
+ {/* Header - matching app's visual style */} +
+
+
+

Oportunidades Priorizadas

+

+ {enrichedOpportunities.length} iniciativas ordenadas por potencial de ahorro y factibilidad +

+
+
+
+ + {/* Executive Summary - Answer "Where are opportunities?" in 5 seconds */} +
+
+
+ + Ahorro Total Identificado +
+
+ €{(summary.totalSavings / 1000).toFixed(0)}K +
+
anuales
+
+ +
+
+ + Quick Wins (AUTOMATE) +
+
+ {summary.byTier.AUTOMATE.length} +
+
+ €{(summary.byTier.AUTOMATE.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K en 3-6 meses +
+
+ +
+
+ + Asistencia (ASSIST) +
+
+ {summary.byTier.ASSIST.length} +
+
+ €{(summary.byTier.ASSIST.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K en 6-9 meses +
+
+ +
+
+ + Optimización (AUGMENT) +
+
+ {summary.byTier.AUGMENT.length} +
+
+ €{(summary.byTier.AUGMENT.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K en 9-12 meses +
+
+
+ + {/* START HERE - Answer "Where do I start?" */} + {topOpportunity && ( +
+
+ + EMPIEZA AQUÍ + Prioridad #1 +
+ +
+
+ {/* Left: Main info */} +
+
+
+ {TIER_CONFIG[topOpportunity.tier].icon} +
+
+

+ {topOpportunity.name.replace(/^[^\w\s]+\s*/, '')} +

+ + {TIER_CONFIG[topOpportunity.tier].label} • {TIER_CONFIG[topOpportunity.tier].description} + +
+
+ + {/* Key metrics */} +
+
+
Ahorro Anual
+
+ €{(topOpportunity.savings / 1000).toFixed(0)}K +
+
+
+
Volumen
+
+ {topOpportunity.volume.toLocaleString()} +
+
+
+
Timeline
+
+ {topOpportunity.timelineMonths} meses +
+
+
+
Agentic Score
+
+ {topOpportunity.agenticScore.toFixed(1)}/10 +
+
+
+ + {/* Why this is #1 */} +
+

+ + ¿Por qué es la prioridad #1? +

+
    + {topOpportunity.whyPrioritized.slice(0, 4).map((reason, i) => ( +
  • + + {reason} +
  • + ))} +
+
+
+ + {/* Right: Next steps */} +
+

+ + Próximos Pasos +

+
    + {topOpportunity.nextSteps.map((step, i) => ( +
  1. + + {i + 1} + + {step} +
  2. + ))} +
+ +
+
+
+
+ )} + + {/* Full Opportunity List - Answer "What else?" */} +
+

+ + Todas las Oportunidades Priorizadas +

+ +
+ {displayedOpportunities.slice(1).map((opp) => ( + + {/* Collapsed view */} +
setExpandedId(expandedId === opp.id ? null : opp.id)} + > +
+ {/* Rank */} +
+ #{opp.rank} +
+ + {/* Tier icon and name */} +
+ {TIER_CONFIG[opp.tier].icon} +
+
+

+ {opp.name.replace(/^[^\w\s]+\s*/, '')} +

+ + {TIER_CONFIG[opp.tier].label} • {TIER_CONFIG[opp.tier].timeline} + +
+ + {/* Quick stats */} +
+
+
Ahorro
+
€{(opp.savings / 1000).toFixed(0)}K
+
+
+
Volumen
+
{opp.volume.toLocaleString()}
+
+
+
Score
+
{opp.agenticScore.toFixed(1)}
+
+
+ + {/* Visual bar: Value vs Effort */} +
+
Valor / Esfuerzo
+
+
+
+
+
+ Valor + Esfuerzo +
+
+ + {/* Expand icon */} + + + +
+
+ + {/* Expanded details */} + + {expandedId === opp.id && ( + +
+
+ {/* Why prioritized */} +
+
¿Por qué esta posición?
+
    + {opp.whyPrioritized.map((reason, i) => ( +
  • + + {reason} +
  • + ))} +
+
+ + {/* Metrics */} +
+
Métricas Clave
+
+
+
CV AHT
+
{opp.cv_aht.toFixed(1)}%
+
+
+
Transfer Rate
+
{opp.transfer_rate.toFixed(1)}%
+
+
+
FCR
+
{opp.fcr_rate.toFixed(1)}%
+
+
+
Riesgo
+
+ {opp.riskLevel === 'low' ? 'Bajo' : opp.riskLevel === 'medium' ? 'Medio' : 'Alto'} +
+
+
+
+
+ + {/* Next steps */} +
+
Próximos Pasos
+
+ {opp.nextSteps.map((step, i) => ( + + {i + 1}. {step} + + ))} +
+
+
+
+ )} +
+ + ))} +
+ + {/* Show more button */} + {enrichedOpportunities.length > 5 && ( + + )} +
+ + {/* Methodology note */} +
+
+
+ +
+ Metodología de priorización: 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. +
+
+
+
+
+ ); +}; + +export default OpportunityPrioritizer; diff --git a/frontend/components/tabs/AgenticReadinessTab.tsx b/frontend/components/tabs/AgenticReadinessTab.tsx index a85fa2d..246d85e 100644 --- a/frontend/components/tabs/AgenticReadinessTab.tsx +++ b/frontend/components/tabs/AgenticReadinessTab.tsx @@ -158,6 +158,303 @@ interface AgenticReadinessTabProps { onTabChange?: (tab: string) => void; } +// ============================================ +// METHODOLOGY INTRODUCTION SECTION +// ============================================ + +interface TierExplanation { + tier: AgenticTier; + label: string; + emoji: string; + color: string; + bgColor: string; + description: string; + criteria: string; + recommendation: string; +} + +const TIER_EXPLANATIONS: TierExplanation[] = [ + { + tier: 'AUTOMATE', + label: 'Automatizable', + emoji: '🤖', + color: '#10b981', + bgColor: '#d1fae5', + description: 'Procesos maduros listos para automatización completa con agente virtual.', + criteria: 'Score ≥7.5: CV AHT <75%, Transfer <15%, Volumen >500/mes', + recommendation: 'Desplegar agente virtual con resolución autónoma' + }, + { + tier: 'ASSIST', + label: 'Asistible', + emoji: '🤝', + color: '#3b82f6', + bgColor: '#dbeafe', + description: 'Candidatos a Copilot: IA asiste al agente humano en tiempo real.', + criteria: 'Score 5.5-7.5: Procesos semiestructurados con variabilidad moderada', + recommendation: 'Implementar Copilot con sugerencias y búsqueda inteligente' + }, + { + tier: 'AUGMENT', + label: 'Optimizable', + emoji: '📚', + color: '#f59e0b', + bgColor: '#fef3c7', + description: 'Requiere herramientas y estandarización antes de automatizar.', + criteria: 'Score 3.5-5.5: Alta variabilidad o complejidad, necesita optimización', + recommendation: 'Desplegar KB mejorada, scripts guiados, herramientas de soporte' + }, + { + tier: 'HUMAN-ONLY', + label: 'Solo Humano', + emoji: '👤', + color: '#6b7280', + bgColor: '#f3f4f6', + description: 'No apto para automatización: volumen insuficiente o complejidad extrema.', + criteria: 'Score <3.5 o Red Flags: CV >120%, Transfer >50%, Vol <50', + recommendation: 'Mantener gestión humana, evaluar periódicamente' + } +]; + +function AgenticMethodologyIntro({ + tierData, + totalVolume, + totalQueues +}: { + tierData: TierDataType; + totalVolume: number; + totalQueues: number; +}) { + const [isExpanded, setIsExpanded] = useState(false); + + // Calcular estadísticas para el roadmap + const automatizableQueues = tierData.AUTOMATE.count + tierData.ASSIST.count; + const optimizableQueues = tierData.AUGMENT.count; + const humanOnlyQueues = tierData['HUMAN-ONLY'].count; + + const automatizablePct = totalVolume > 0 + ? Math.round((tierData.AUTOMATE.volume + tierData.ASSIST.volume) / totalVolume * 100) + : 0; + + return ( + + {/* Header con toggle */} +
setIsExpanded(!isExpanded)} + > +
+
+
+ +
+
+

+ ¿Qué es el Índice de Agentic Readiness? +

+

+ Metodología de evaluación y guía de navegación de este análisis +

+
+
+ +
+
+ + {/* Contenido expandible */} + {isExpanded && ( +
+ {/* Sección 1: Definición del índice */} +
+

+ + Definición del Índice +

+
+

+ El Índice de Agentic Readiness evalúa qué porcentaje del volumen de interacciones + está preparado para ser gestionado por agentes virtuales o asistido por IA. Se calcula + analizando cada cola individualmente según 5 factores clave: +

+
+
+
Predictibilidad
+
30% peso
+
CV AHT <75%
+
+
+
Resolutividad
+
25% peso
+
FCR alto, Transfer bajo
+
+
+
Volumen
+
25% peso
+
ROI positivo >500/mes
+
+
+
Calidad Datos
+
10% peso
+
% registros válidos
+
+
+
Simplicidad
+
10% peso
+
AHT bajo, proceso simple
+
+
+
+
+ + {/* Sección 2: Los 4 Tiers explicados */} +
+

+ + Las 4 Categorías de Clasificación +

+

+ Cada cola se clasifica en uno de los siguientes tiers según su score compuesto: +

+
+ {TIER_EXPLANATIONS.map(tier => ( +
+
+ {tier.emoji} + {tier.label} + + {tier.tier} + +
+

{tier.description}

+
+
Criterios: {tier.criteria}
+
Acción: {tier.recommendation}
+
+
+ ))} +
+
+ + {/* Sección 3: Roadmap de navegación */} +
+

+ + Contenido de este Análisis +

+
+

+ Este tab presenta el análisis de automatización en el siguiente orden: +

+ +
+ {/* Paso 1 */} +
+
+ 1 +
+
+
Visión Global de Distribución
+

+ Porcentaje de volumen en cada categoría ({automatizablePct}% automatizable). + Las 4 cajas muestran cómo se distribuyen las {totalVolume.toLocaleString()} interacciones. +

+
+
+ + {/* Paso 2 */} +
+
+ 2 +
+
+
+ Candidatos Prioritarios + + {automatizableQueues} colas + +
+

+ Colas AUTOMATE y ASSIST ordenadas por potencial de ahorro. + Quick wins con mayor ROI para priorizar en el roadmap. +

+
+
+ + {/* Paso 3 */} +
+
+ 3 +
+
+
+ Colas a Optimizar + + {optimizableQueues} colas + +
+

+ Tier AUGMENT: requieren estandarización previa (reducir variabilidad, + mejorar FCR, documentar procesos) antes de automatizar. +

+
+
+ + {/* Paso 4 */} +
+
+ 4 +
+
+
+ No Automatizables + + {humanOnlyQueues} colas + +
+

+ Tier HUMAN-ONLY: volumen insuficiente (ROI negativo), calidad de datos baja, + variabilidad extrema, o complejidad que requiere juicio humano. +

+
+
+
+
+
+ + {/* Nota metodológica */} +
+ Nota metodológica: El índice se calcula por cola individual, no como promedio global. + Esto permite identificar oportunidades específicas incluso cuando la media operativa sea baja. + Los umbrales están calibrados según benchmarks de industria (COPC, Gartner). +
+
+ )} + + {/* Mini resumen cuando está colapsado */} + {!isExpanded && ( +
+ 5 factores ponderados + + 4 categorías de clasificación + + {totalQueues} colas analizadas + Click para expandir metodología +
+ )} +
+ ); +} + // Factor configuration with weights (must sum to 1.0) interface FactorConfig { id: string; @@ -259,19 +556,6 @@ function getTierStyle(tier: AgenticTier): { bg: string; text: string; icon: Reac } } -// v3.4: Componente de badge de Tier -function TierBadge({ tier, size = 'sm' }: { tier: AgenticTier; size?: 'sm' | 'md' }) { - const style = getTierStyle(tier); - const sizeClasses = size === 'md' ? 'px-2.5 py-1 text-xs' : 'px-2 py-0.5 text-xs'; - - return ( - - {style.icon} - {style.label} - - ); -} - // v3.4: Componente de desglose de score function ScoreBreakdownTooltip({ breakdown }: { breakdown: AgenticScoreBreakdown }) { return ( @@ -390,15 +674,6 @@ interface TierDataType { 'HUMAN-ONLY': { count: number; volume: number }; } -// Colores corporativos -const COLORS = { - primary: '#6d84e3', - dark: '#3f3f3f', - medium: '#b1b1b0', - light: '#e4e3e3', - white: '#ffffff' -}; - // ============================================ // v3.10: OPPORTUNITY BUBBLE CHART // ============================================ @@ -420,7 +695,11 @@ function calcularRadioBurbuja(volumen: number, maxVolumen: number): number { return minRadio + (maxRadio - minRadio) * escala; } +// Período de datos: el volumen corresponde a 11 meses, no es mensual +const DATA_PERIOD_MONTHS = 11; + // Calcular ahorro TCO por cola +// v4.2: Corregido para convertir volumen de 11 meses a anual function calcularAhorroTCO(queue: OriginalQueueMetrics): number { // CPI Config similar a RoadmapTab const CPI_HUMANO = 2.33; @@ -436,8 +715,9 @@ function calcularAhorroTCO(queue: OriginalQueueMetrics): number { }; const config = ratesByTier[queue.tier]; - // Ahorro anual = volumen × 12 × rate × (CPI_humano - CPI_target) - const ahorroAnual = queue.volume * 12 * config.rate * (CPI_HUMANO - config.cpi); + // Ahorro anual = (volumen/11) × 12 × rate × (CPI_humano - CPI_target) + const annualVolume = (queue.volume / DATA_PERIOD_MONTHS) * 12; + const ahorroAnual = annualVolume * config.rate * (CPI_HUMANO - config.cpi); return Math.round(ahorroAnual); } @@ -532,7 +812,8 @@ function OpportunityBubbleChart({ drilldownData }: { drilldownData: DrilldownDat volume: q.volume, ahorro: q.ahorro, cv: q.cv_aht, - fcr: q.fcr_rate, + // FCR Técnico para consistencia con Executive Summary (fallback: 100 - transfer_rate) + fcr: q.fcr_tecnico ?? (100 - q.transfer_rate), transfer: q.transfer_rate, // Escala X: score 0-10 -> 0-innerWidth x: (q.agenticScore / 10) * innerWidth, @@ -1121,12 +1402,12 @@ function AgenticReadinessHeader({ return v.toLocaleString(); }; - // Tier card config con colores corporativos + // Tier card config con colores consistentes con la sección introductoria const tierConfigs = [ - { key: 'AUTOMATE', label: 'AUTOMATE', emoji: '🤖', sublabel: 'Full IA', color: COLORS.primary }, - { key: 'ASSIST', label: 'ASSIST', emoji: '🤝', sublabel: 'Copilot', color: COLORS.dark }, - { key: 'AUGMENT', label: 'AUGMENT', emoji: '📚', sublabel: 'Tools', color: COLORS.medium }, - { key: 'HUMAN-ONLY', label: 'HUMAN', emoji: '👤', sublabel: 'Manual', color: COLORS.medium } + { key: 'AUTOMATE', label: 'AUTOMATE', emoji: '🤖', sublabel: 'Full IA', color: '#10b981', bgColor: '#d1fae5' }, + { key: 'ASSIST', label: 'ASSIST', emoji: '🤝', sublabel: 'Copilot', color: '#3b82f6', bgColor: '#dbeafe' }, + { key: 'AUGMENT', label: 'AUGMENT', emoji: '📚', sublabel: 'Tools', color: '#f59e0b', bgColor: '#fef3c7' }, + { key: 'HUMAN-ONLY', label: 'HUMAN', emoji: '👤', sublabel: 'Manual', color: '#6b7280', bgColor: '#f3f4f6' } ]; // Calcular porcentaje de colas AUTOMATE @@ -1168,7 +1449,7 @@ function AgenticReadinessHeader({
- {/* 4 Tier Cards */} + {/* 4 Tier Cards - colores consistentes con sección introductoria */}
{tierConfigs.map(config => { const tierKey = config.key as keyof TierDataType; @@ -1179,7 +1460,7 @@ function AgenticReadinessHeader({
{config.label} @@ -1187,13 +1468,13 @@ function AgenticReadinessHeader({
{Math.round(pct)}%
-
+
{formatVolume(data.volume)} int
-
+
{config.emoji} {config.sublabel}
-
+
{data.count} colas
@@ -1201,35 +1482,35 @@ function AgenticReadinessHeader({ })}
- {/* Barra de distribución visual */} + {/* Barra de distribución visual - colores consistentes */}
-
+
{tierPcts.AUTOMATE > 0 && (
)} {tierPcts.ASSIST > 0 && (
)} {tierPcts.AUGMENT > 0 && (
)} {tierPcts['HUMAN-ONLY'] > 0 && (
)}
-
+
0% 50% 100% @@ -1278,9 +1559,9 @@ function GlobalFactorsSection({ ? allQueues.reduce((sum, q) => sum + q.cv_aht * q.volume, 0) / totalQueueVolume : 0; - // FCR promedio ponderado + // FCR Técnico promedio ponderado (consistente con Executive Summary) const avgFCR = totalQueueVolume > 0 - ? allQueues.reduce((sum, q) => sum + q.fcr_rate * q.volume, 0) / totalQueueVolume + ? allQueues.reduce((sum, q) => sum + (q.fcr_tecnico ?? (100 - q.transfer_rate)) * q.volume, 0) / totalQueueVolume : 0; // Transfer rate promedio ponderado @@ -1719,13 +2000,6 @@ function formatAHT(seconds: number): string { return `${mins}:${secs.toString().padStart(2, '0')}`; } -// Formatear moneda -function formatCurrency(value: number): string { - if (value >= 1000000) return `€${(value / 1000000).toFixed(1)}M`; - if (value >= 1000) return `€${Math.round(value / 1000)}K`; - return `€${value.toLocaleString()}`; -} - // v3.4: Fila expandible por queue_skill (muestra original_queue_id al expandir con Tiers) function ExpandableSkillRow({ dataPoint, @@ -1859,7 +2133,7 @@ function ExpandableSkillRow({
- FCR: {dataPoint.fcr_rate.toFixed(0)}% + FCR: {(dataPoint.fcr_tecnico ?? (100 - dataPoint.transfer_rate)).toFixed(0)}% | Transfer: {dataPoint.transfer_rate.toFixed(0)}%
@@ -1919,7 +2193,7 @@ function ExpandableSkillRow({ {queue.transfer_rate.toFixed(0)}% - {queue.fcr_rate.toFixed(0)}% + {(queue.fcr_tecnico ?? (100 - queue.transfer_rate)).toFixed(0)}% {queue.scoreBreakdown ? ( }> @@ -1962,7 +2236,7 @@ function ExpandableSkillRow({ {formatAHT(dataPoint.aht_mean)} {dataPoint.cv_aht.toFixed(0)}% {dataPoint.transfer_rate.toFixed(0)}% - {dataPoint.fcr_rate.toFixed(0)}% + {(dataPoint.fcr_tecnico ?? (100 - dataPoint.transfer_rate)).toFixed(0)}% {dataPoint.agenticScore.toFixed(1)} @@ -1985,6 +2259,523 @@ function ExpandableSkillRow({ ); } +// ============================================ +// v4.0: NUEVAS SECCIONES POR TIER +// ============================================ + +// Configuración de colores y estilos por tier +const TIER_SECTION_CONFIG: Record = { + 'AUTOMATE': { + color: '#10b981', + bgColor: '#d1fae5', + borderColor: '#10b98140', + gradientFrom: 'from-emerald-50', + gradientTo: 'to-emerald-100/50', + icon: Sparkles, + title: 'Colas AUTOMATE', + subtitle: 'Listas para automatización completa con agente virtual (Score ≥7.5)', + emptyMessage: 'No hay colas clasificadas como AUTOMATE' + }, + 'ASSIST': { + color: '#3b82f6', + bgColor: '#dbeafe', + borderColor: '#3b82f640', + gradientFrom: 'from-blue-50', + gradientTo: 'to-blue-100/50', + icon: Bot, + title: 'Colas ASSIST', + subtitle: 'Candidatas a Copilot - IA asiste al agente humano (Score 5.5-7.5)', + emptyMessage: 'No hay colas clasificadas como ASSIST' + }, + 'AUGMENT': { + color: '#f59e0b', + bgColor: '#fef3c7', + borderColor: '#f59e0b40', + gradientFrom: 'from-amber-50', + gradientTo: 'to-amber-100/50', + icon: TrendingUp, + title: 'Colas AUGMENT', + subtitle: 'Requieren optimización previa: estandarizar procesos, reducir variabilidad (Score 3.5-5.5)', + emptyMessage: 'No hay colas clasificadas como AUGMENT' + }, + 'HUMAN-ONLY': { + color: '#6b7280', + bgColor: '#f3f4f6', + borderColor: '#6b728040', + gradientFrom: 'from-gray-50', + gradientTo: 'to-gray-100/50', + icon: Users, + title: 'Colas HUMAN-ONLY', + subtitle: 'No aptas para automatización: volumen insuficiente, datos de baja calidad o complejidad extrema', + emptyMessage: 'No hay colas clasificadas como HUMAN-ONLY' + } +}; + +// Componente de tabla de colas por Tier (AUTOMATE, ASSIST, AUGMENT) +function TierQueueSection({ + drilldownData, + tier +}: { + drilldownData: DrilldownDataPoint[]; + tier: 'AUTOMATE' | 'ASSIST' | 'AUGMENT'; +}) { + const [expandedSkills, setExpandedSkills] = useState>(new Set()); + const config = TIER_SECTION_CONFIG[tier]; + const IconComponent = config.icon; + + // Extraer todas las colas del tier específico, agrupadas por skill + const skillsWithTierQueues = drilldownData + .map(skill => ({ + skill: skill.skill, + queues: skill.originalQueues.filter(q => q.tier === tier), + totalVolume: skill.originalQueues.filter(q => q.tier === tier).reduce((s, q) => s + q.volume, 0), + totalAnnualCost: skill.originalQueues.filter(q => q.tier === tier).reduce((s, q) => s + (q.annualCost || 0), 0) + })) + .filter(s => s.queues.length > 0) + .sort((a, b) => b.totalVolume - a.totalVolume); + + const totalQueues = skillsWithTierQueues.reduce((sum, s) => sum + s.queues.length, 0); + const totalVolume = skillsWithTierQueues.reduce((sum, s) => sum + s.totalVolume, 0); + const totalCost = skillsWithTierQueues.reduce((sum, s) => sum + s.totalAnnualCost, 0); + + // Calcular ahorro potencial según tier + const savingsRate = tier === 'AUTOMATE' ? 0.70 : tier === 'ASSIST' ? 0.30 : 0.15; + const potentialSavings = Math.round(totalCost * savingsRate); + + const toggleSkill = (skill: string) => { + const newExpanded = new Set(expandedSkills); + if (newExpanded.has(skill)) { + newExpanded.delete(skill); + } else { + newExpanded.add(skill); + } + setExpandedSkills(newExpanded); + }; + + if (totalQueues === 0) { + return null; + } + + return ( +
+ {/* Header */} +
+
+
+

+ + {config.title} +

+

+ {config.subtitle} +

+
+
+ {totalQueues} +

colas en {skillsWithTierQueues.length} skills

+
+
+
+ + {/* Resumen */} +
+
+ + Volumen: {totalVolume.toLocaleString()} int/mes + + + Coste: {formatCurrency(totalCost)}/año + +
+ + Ahorro potencial: {formatCurrency(potentialSavings)}/año + +
+ + {/* Tabla por Business Unit (skill) */} +
+ + + + + + + + + + + + + + + {skillsWithTierQueues.map((skillData, idx) => { + const isExpanded = expandedSkills.has(skillData.skill); + const avgAHT = skillData.queues.reduce((s, q) => s + q.aht_mean * q.volume, 0) / skillData.totalVolume; + const avgCV = skillData.queues.reduce((s, q) => s + q.cv_aht * q.volume, 0) / skillData.totalVolume; + const avgFCR = skillData.queues.reduce((s, q) => s + (q.fcr_tecnico ?? (100 - q.transfer_rate)) * q.volume, 0) / skillData.totalVolume; + const skillSavings = Math.round(skillData.totalAnnualCost * savingsRate); + + return ( + + {/* Fila del Skill */} + toggleSkill(skillData.skill)} + > + + + + + + + + + + + {/* Detalle expandible: colas individuales */} + {isExpanded && ( + + + + )} + + ); + })} + +
Business Unit (Skill)ColasVolumenAHT Prom.CV Prom.FCRAhorro Potencial
+ {isExpanded ? ( + + ) : ( + + )} + + {skillData.skill} + + + {skillData.queues.length} + + + {skillData.totalVolume.toLocaleString()} + + {formatAHT(avgAHT)} + + + {avgCV.toFixed(0)}% + + + {avgFCR.toFixed(0)}% + + {formatCurrency(skillSavings)} +
+
+ + + + + + + + + + + + + + + {skillData.queues.map((queue, qIdx) => { + const queueSavings = Math.round((queue.annualCost || 0) * savingsRate); + return ( + + + + + + + + + + + ); + })} + +
Cola (ID)VolumenAHTCVTransferFCRScoreAhorro
+ {queue.original_queue_id} + {queue.volume.toLocaleString()}{formatAHT(queue.aht_mean)} + + {queue.cv_aht.toFixed(0)}% + + {queue.transfer_rate.toFixed(0)}%{(queue.fcr_tecnico ?? (100 - queue.transfer_rate)).toFixed(0)}% + + {queue.agenticScore.toFixed(1)} + + + {formatCurrency(queueSavings)} +
+
+
+
+ + {/* Footer */} +
+ Click en un skill para ver el detalle de colas individuales +
+
+ ); +} + +// Componente para colas HUMAN-ONLY agrupadas por razón/red flag +function HumanOnlyByReasonSection({ drilldownData }: { drilldownData: DrilldownDataPoint[] }) { + const [expandedReasons, setExpandedReasons] = useState>(new Set()); + const config = TIER_SECTION_CONFIG['HUMAN-ONLY']; + + // Extraer todas las colas HUMAN-ONLY + const allHumanOnlyQueues = drilldownData.flatMap(skill => + skill.originalQueues + .filter(q => q.tier === 'HUMAN-ONLY') + .map(q => ({ ...q, skillName: skill.skill })) + ); + + if (allHumanOnlyQueues.length === 0) { + return null; + } + + // Agrupar por razón principal (red flag dominante o "Sin red flags") + const queuesByReason: Record = {}; + + allHumanOnlyQueues.forEach(queue => { + const flags = detectRedFlags(queue); + // Determinar razón principal (prioridad: cv_high > transfer_high > volume_low > valid_low) + let reason = 'Sin Red Flags específicos'; + let reasonId = 'no_flags'; + + if (flags.length > 0) { + // Ordenar por severidad implícita + const priorityOrder = ['cv_high', 'transfer_high', 'volume_low', 'valid_low']; + const sortedFlags = [...flags].sort((a, b) => + priorityOrder.indexOf(a.config.id) - priorityOrder.indexOf(b.config.id) + ); + reasonId = sortedFlags[0].config.id; + reason = sortedFlags[0].config.label; + } + + if (!queuesByReason[reasonId]) { + queuesByReason[reasonId] = []; + } + queuesByReason[reasonId].push(queue); + }); + + // Convertir a array y ordenar por volumen + const reasonGroups = Object.entries(queuesByReason) + .map(([reasonId, queues]) => { + const flagConfig = RED_FLAG_CONFIGS.find(c => c.id === reasonId); + return { + reasonId, + reason: flagConfig?.label || 'Sin Red Flags específicos', + description: flagConfig?.description || 'Colas que no cumplen criterios de automatización', + action: flagConfig ? getActionForFlag(flagConfig.id) : 'Revisar manualmente', + queues, + totalVolume: queues.reduce((s, q) => s + q.volume, 0), + queueCount: queues.length + }; + }) + .sort((a, b) => b.totalVolume - a.totalVolume); + + const totalQueues = allHumanOnlyQueues.length; + const totalVolume = allHumanOnlyQueues.reduce((s, q) => s + q.volume, 0); + + const toggleReason = (reasonId: string) => { + const newExpanded = new Set(expandedReasons); + if (newExpanded.has(reasonId)) { + newExpanded.delete(reasonId); + } else { + newExpanded.add(reasonId); + } + setExpandedReasons(newExpanded); + }; + + function getActionForFlag(flagId: string): string { + switch (flagId) { + case 'cv_high': return 'Estandarizar procesos y scripts'; + case 'transfer_high': return 'Simplificar flujo, capacitar agentes'; + case 'volume_low': return 'Consolidar con colas similares'; + case 'valid_low': return 'Mejorar captura de datos'; + default: return 'Revisar manualmente'; + } + } + + return ( +
+ {/* Header */} +
+
+
+

+ + {config.title} +

+

+ {config.subtitle} +

+
+
+ {totalQueues} +

colas agrupadas por {reasonGroups.length} razones

+
+
+
+ + {/* Resumen */} +
+ + Volumen total: {totalVolume.toLocaleString()} int/mes + + + Estas colas requieren intervención antes de considerar automatización + +
+ + {/* Tabla agrupada por razón */} +
+ + + + + + + + + + + + {reasonGroups.map((group) => { + const isExpanded = expandedReasons.has(group.reasonId); + + return ( + + {/* Fila de la razón */} + toggleReason(group.reasonId)} + > + + + + + + + + {/* Detalle expandible: colas de esta razón */} + {isExpanded && ( + + + + )} + + ); + })} + +
Razón / Red FlagColasVolumenAcción Recomendada
+ {isExpanded ? ( + + ) : ( + + )} + +
+ +
+ {group.reason} +

{group.description}

+
+
+
+ + {group.queueCount} + + + {group.totalVolume.toLocaleString()} + + + {group.action} + +
+
+ + + + + + + + + + + + + + {group.queues.slice(0, 20).map((queue) => { + const flags = detectRedFlags(queue); + return ( + + + + + + + + + + ); + })} + +
Cola (ID)SkillVolumenCV AHTTransferScoreRed Flags
+ {queue.original_queue_id} + {queue.skillName}{queue.volume.toLocaleString()} + 120 ? 'text-red-600 font-medium' : 'text-gray-600'}> + {queue.cv_aht.toFixed(0)}% + + + 50 ? 'text-red-600 font-medium' : 'text-gray-600'}> + {queue.transfer_rate.toFixed(0)}% + + + + {queue.agenticScore.toFixed(1)} + + +
+ {flags.map(flag => ( + + ))} +
+
+ {group.queues.length > 20 && ( +
+ Mostrando 20 de {group.queues.length} colas +
+ )} +
+
+
+ + {/* Footer */} +
+ Click en una razón para ver las colas afectadas. Priorizar acciones según volumen impactado. +
+
+ ); +} + // v3.4: Sección de Candidatos Prioritarios - Por queue_skill con drill-down a original_queue_id function PriorityCandidatesSection({ drilldownData }: { drilldownData: DrilldownDataPoint[] }) { const [expandedRows, setExpandedRows] = useState>(new Set()); @@ -2169,10 +2960,11 @@ function HumanOnlyRedFlagsSection({ drilldownData }: { drilldownData: DrilldownD const totalVolumeRedFlags = queuesWithFlags.reduce((sum, qf) => sum + qf.queue.volume, 0); const pctVolumeRedFlags = totalVolumeAllQueues > 0 ? (totalVolumeRedFlags / totalVolumeAllQueues) * 100 : 0; - // v3.11: Coste usando modelo CPI (consistente con Roadmap y Executive Summary) + // v4.2: Coste usando modelo CPI (consistente con Roadmap y Executive Summary) + // IMPORTANTE: El volumen es de 11 meses, se convierte a anual: (Vol/11) × 12 const CPI_HUMANO_RF = 2.33; // €/interacción (coste unitario humano) - const costeAnualRedFlags = Math.round(totalVolumeRedFlags * 12 * CPI_HUMANO_RF); - const costeAnualTotal = Math.round(totalVolumeAllQueues * 12 * CPI_HUMANO_RF); + const costeAnualRedFlags = Math.round((totalVolumeRedFlags / DATA_PERIOD_MONTHS) * 12 * CPI_HUMANO_RF); + const costeAnualTotal = Math.round((totalVolumeAllQueues / DATA_PERIOD_MONTHS) * 12 * CPI_HUMANO_RF); const pctCosteRedFlags = costeAnualTotal > 0 ? (costeAnualRedFlags / costeAnualTotal) * 100 : 0; // Estadísticas detalladas por tipo de red flag @@ -2634,7 +3426,7 @@ function SkillsToOptimizeSection({ drilldownData }: { drilldownData: DrilldownDa {formatAHT(item.aht_mean)} - + @@ -2691,8 +3483,9 @@ function RoadmapConnectionSection({ drilldownData }: { drilldownData: DrilldownD q.tier === 'HUMAN-ONLY' && q.transfer_rate > 50 ); - // v3.10: Cálculo de ahorros alineado con modelo TCO del Roadmap - // Fórmula: Vol × 12 × Rate × (CPI_humano - CPI_target) + // v4.2: Cálculo de ahorros alineado con modelo TCO del Roadmap + // Fórmula: (Vol/11) × 12 × Rate × (CPI_humano - CPI_target) + // IMPORTANTE: El volumen es de 11 meses, se convierte a anual const CPI_HUMANO = 2.33; const CPI_BOT = 0.15; const CPI_ASSIST_TARGET = 1.50; @@ -2700,11 +3493,11 @@ function RoadmapConnectionSection({ drilldownData }: { drilldownData: DrilldownD const RATE_ASSIST = 0.30; // 30% deflection // Quick Wins (AUTOMATE): 70% de interacciones pueden ser atendidas por bot - const annualSavingsAutomate = Math.round(automateVolume * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT)); + const annualSavingsAutomate = Math.round((automateVolume / DATA_PERIOD_MONTHS) * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT)); const monthlySavingsAutomate = Math.round(annualSavingsAutomate / 12); // Potential savings from ASSIST (si implementan Copilot): 30% deflection - const potentialAnnualAssist = Math.round(assistVolume * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST_TARGET)); + const potentialAnnualAssist = Math.round((assistVolume / DATA_PERIOD_MONTHS) * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST_TARGET)); // Get top skills with AUTOMATE queues const skillsWithAutomate = drilldownData @@ -2876,34 +3669,34 @@ export function AgenticReadinessTab({ data, onTabChange }: AgenticReadinessTabPr return (
- {/* Cabecera Agentic Readiness Score - Rediseñada */} + {/* SECCIÓN 0: Introducción Metodológica (colapsable) */} + + + {/* SECCIÓN 1: Cabecera Agentic Readiness Score - Visión Global */} - {/* Factores del Score Global */} - {data.drilldownData && data.drilldownData.length > 0 && ( - - )} - - {/* v3.10: Mapa de Oportunidades de Automatización (Bubble Chart) */} - {data.drilldownData && data.drilldownData.length > 0 && ( - - )} - - {/* v3.1: Primero lo positivo - Candidatos Prioritarios (panel principal expandible) */} + {/* SECCIÓN 2-5: Desglose por Colas en 4 Tablas por Tier */} {data.drilldownData && data.drilldownData.length > 0 ? ( <> - - - {/* v3.5: Red Flags para colas HUMAN-ONLY */} - + {/* TABLA 1: Colas AUTOMATE - Listas para automatización */} + + + {/* TABLA 2: Colas ASSIST - Candidatas a Copilot */} + + + {/* TABLA 3: Colas AUGMENT - Requieren optimización */} + + + {/* TABLA 4: Colas HUMAN-ONLY - Agrupadas por razón/red flag */} + ) : ( /* Fallback a tabla por Línea de Negocio si no hay drilldown data */ diff --git a/frontend/components/tabs/DimensionAnalysisTab.tsx b/frontend/components/tabs/DimensionAnalysisTab.tsx index 793b9cf..58c76b3 100644 --- a/frontend/components/tabs/DimensionAnalysisTab.tsx +++ b/frontend/components/tabs/DimensionAnalysisTab.tsx @@ -1,6 +1,6 @@ import React from 'react'; 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 { Card, @@ -20,7 +20,7 @@ interface DimensionAnalysisTabProps { data: AnalysisData; } -// ========== ANÁLISIS CAUSAL CON IMPACTO ECONÓMICO ========== +// ========== HALLAZGO CLAVE CON IMPACTO ECONÓMICO ========== interface CausalAnalysis { finding: string; @@ -34,20 +34,44 @@ interface CausalAnalysis { interface CausalAnalysisExtended extends CausalAnalysis { impactFormula?: string; // Explicación de cómo se calculó el impacto 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( dimension: DimensionAnalysis, heatmapData: HeatmapDataPoint[], - economicModel: { currentAnnualCost: number } + economicModel: { currentAnnualCost: number }, + staticConfig?: { cost_per_hour: number }, + dateRange?: { min: string; max: string } ): CausalAnalysisExtended[] { const analyses: CausalAnalysisExtended[] = []; const totalVolume = heatmapData.reduce((sum, h) => sum + h.volume, 0); - // v3.11: CPI basado en modelo TCO (€2.33/interacción) + // 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 = 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 const avgCVAHT = totalVolume > 0 @@ -56,8 +80,10 @@ function generateCausalAnalysis( const avgTransferRate = totalVolume > 0 ? heatmapData.reduce((sum, h) => sum + (h.variability?.transfer_rate || 0) * h.volume, 0) / totalVolume : 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 - ? 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; const avgAHT = totalVolume > 0 ? heatmapData.reduce((sum, h) => sum + h.aht_seconds * h.volume, 0) / totalVolume @@ -71,77 +97,112 @@ function generateCausalAnalysis( // Skills con problemas específicos const skillsHighCV = heatmapData.filter(h => (h.variability?.cv_aht || 0) > 100); - const skillsLowFCR = heatmapData.filter(h => h.metrics.fcr < 50); + // 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); + // 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) { case 'operational_efficiency': - // Análisis de variabilidad AHT - if (avgCVAHT > 80) { - const inefficiencyPct = Math.min(0.15, (avgCVAHT - 60) / 200); - const inefficiencyCost = Math.round(economicModel.currentAnnualCost * inefficiencyPct); + // Obtener P50 AHT del header para mostrar valor consistente + const p50Aht = parseKpiAhtSeconds(dimension.kpi.value) ?? avgAHT; + + // 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({ - finding: `Variabilidad AHT elevada: CV ${avgCVAHT.toFixed(0)}% (benchmark: <60%)`, - probableCause: skillsHighCV.length > 0 - ? `Falta de scripts estandarizados en ${skillsHighCV.slice(0, 3).map(s => s.skill).join(', ')}. Agentes manejan casos similares de formas muy diferentes.` - : 'Procesos no documentados y falta de guías de atención claras.', - economicImpact: inefficiencyCost, - impactFormula: `Coste anual × ${(inefficiencyPct * 100).toFixed(1)}% ineficiencia = €${(economicModel.currentAnnualCost/1000).toFixed(0)}K × ${(inefficiencyPct * 100).toFixed(1)}%`, - recommendation: 'Crear playbooks por tipología de consulta y certificar agentes en procesos estándar.', - severity: avgCVAHT > 120 ? 'critical' : 'warning', + finding: `AHT elevado: P50 ${Math.floor(p50Aht / 60)}:${String(Math.round(p50Aht) % 60).padStart(2, '0')} (benchmark: 5:00)`, + probableCause: cause, + economicImpact: ahtExcessCost, + impactFormula: `${excessHours.toLocaleString()}h × €${HOURLY_COST}/h`, + timeSavings: `${excessHours.toLocaleString()} horas/año en exceso de AHT`, + 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.`, + severity: p50Aht > 420 ? 'critical' : 'warning', hasRealData: true }); - } - - // Análisis de AHT absoluto - if (avgAHT > 420) { - const excessSeconds = avgAHT - 360; - const excessCost = Math.round((excessSeconds / 3600) * totalVolume * 12 * 25); + } else { + // AHT dentro de benchmark - mostrar estado positivo analyses.push({ - finding: `AHT elevado: ${Math.floor(avgAHT / 60)}:${String(Math.round(avgAHT) % 60).padStart(2, '0')} (benchmark: 6:00)`, - probableCause: 'Sistemas de información fragmentados, búsquedas manuales excesivas, o falta de herramientas de asistencia al agente.', - economicImpact: excessCost, - impactFormula: `Exceso ${Math.round(excessSeconds)}s × ${totalVolume.toLocaleString()} int/mes × 12 × €25/h`, - recommendation: 'Implementar vista unificada de cliente y herramientas de sugerencia automática.', - severity: avgAHT > 540 ? 'critical' : 'warning', + finding: `AHT dentro de benchmark: P50 ${Math.floor(p50Aht / 60)}:${String(Math.round(p50Aht) % 60).padStart(2, '0')} (benchmark: 5:00)`, + probableCause: 'Tiempos de gestión eficientes. Procesos operativos optimizados.', + economicImpact: 0, + impactFormula: 'Sin exceso de coste por AHT', + timeSavings: 'Operación eficiente', + recommendation: 'Mantener nivel actual. Considerar Copilot para mejora continua y reducción adicional de tiempos en casos complejos.', + severity: 'info', hasRealData: true }); } break; 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) { - const recontactRate = (100 - avgFCR) / 100; - const recontactCost = Math.round(totalVolume * 12 * recontactRate * CPI_TCO); - analyses.push({ - finding: `FCR bajo: ${avgFCR.toFixed(0)}% (benchmark: >75%)`, - probableCause: skillsLowFCR.length > 0 - ? `Agentes sin autonomía para resolver en ${skillsLowFCR.slice(0, 2).map(s => s.skill).join(', ')}. Políticas de escalado excesivamente restrictivas.` - : 'Falta de información completa en primer contacto o limitaciones de autoridad del agente.', - economicImpact: recontactCost, - impactFormula: `${totalVolume.toLocaleString()} int × 12 × ${(recontactRate * 100).toFixed(0)}% recontactos × €${CPI_TCO}/int`, - recommendation: 'Empoderar agentes con mayor autoridad de resolución y crear Knowledge Base contextual.', - severity: avgFCR < 50 ? 'critical' : 'warning', - hasRealData: true - }); + effCause = skillsLowFCR.length > 0 + ? `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(', ')}.` + : `Transferencias elevadas (${avgTransferRate.toFixed(0)}%): agentes sin información contextual o sin autoridad para resolver.`; + } else if (avgFCR < 85) { + effCause = `Transferencias del ${avgTransferRate.toFixed(0)}% indican oportunidad de mejora con asistencia IA para casos complejos.`; + } else { + effCause = `FCR Técnico en nivel óptimo. Transferencias del ${avgTransferRate.toFixed(0)}% principalmente en casos que requieren escalación legítima.`; } - // Análisis de transferencias - if (avgTransferRate > 15) { - const transferCost = Math.round(totalVolume * 12 * (avgTransferRate / 100) * CPI_TCO * 0.5); - analyses.push({ - finding: `Tasa de transferencias: ${avgTransferRate.toFixed(1)}% (benchmark: <10%)`, - probableCause: skillsHighTransfer.length > 0 - ? `Routing inicial incorrecto hacia ${skillsHighTransfer.slice(0, 2).map(s => s.skill).join(', ')}. IVR no identifica correctamente la intención del cliente.` - : 'Reglas de enrutamiento desactualizadas o skills mal definidos.', - economicImpact: transferCost, - impactFormula: `${totalVolume.toLocaleString()} int × 12 × ${avgTransferRate.toFixed(1)}% × €${CPI_TCO} × 50% coste adicional`, - recommendation: 'Revisar árbol de IVR, actualizar reglas de ACD y capacitar agentes en resolución integral.', - severity: avgTransferRate > 25 ? 'critical' : 'warning', - hasRealData: true - }); + // Construir recomendación + let effRecommendation = ''; + if (avgFCR < 70) { + 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.`; + } else if (avgFCR < 85) { + effRecommendation = `Implementar Copilot de asistencia en tiempo real: sugerencias contextuales + conexión con expertos virtuales para reducir transferencias. Objetivo: FCR >90%.`; + } else { + effRecommendation = `Mantener nivel actual. Considerar IA para análisis de transferencias legítimas y optimización de enrutamiento predictivo.`; } + + 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; case 'volumetry_distribution': @@ -149,13 +210,16 @@ function generateCausalAnalysis( const topSkill = [...heatmapData].sort((a, b) => b.volume - a.volume)[0]; const topSkillPct = topSkill ? (topSkill.volume / totalVolume) * 100 : 0; if (topSkillPct > 40 && topSkill) { - const deflectionPotential = Math.round(topSkill.volume * 12 * CPI_TCO * 0.20); + 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({ 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, - impactFormula: `${topSkill.volume.toLocaleString()} int × 12 × €${CPI_TCO} × 20% deflexión potencial`, - recommendation: `Analizar top consultas de ${topSkill.skill} para identificar candidatas a deflexión digital o FAQ automatizado.`, + impactFormula: `${topSkill.volume.toLocaleString()} int × anualización × €${CPI_TCO} × 20% deflexión potencial`, + 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', hasRealData: true }); @@ -163,65 +227,102 @@ function generateCausalAnalysis( break; case 'complexity_predictability': - // v3.11: Análisis de complejidad basado en hold time y CV - if (avgHoldTime > 45) { - const excessHold = avgHoldTime - 30; - const holdCost = Math.round((excessHold / 3600) * totalVolume * 12 * 25); + // KPI principal: CV AHT (predictability metric per industry standards) + // Siempre mostrar análisis de CV AHT ya que es el KPI de esta dimensión + const cvBenchmark = 75; // Best practice: CV AHT < 75% + + 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({ - finding: `Hold time elevado: ${avgHoldTime.toFixed(0)}s promedio (benchmark: <30s)`, - probableCause: 'Consultas complejas requieren búsqueda de información durante la llamada. Posible falta de acceso rápido a datos o sistemas.', - economicImpact: holdCost, - impactFormula: `Exceso ${Math.round(excessHold)}s × ${totalVolume.toLocaleString()} int × 12 × €25/h`, - recommendation: 'Implementar acceso contextual a información del cliente y reducir sistemas fragmentados.', - severity: avgHoldTime > 60 ? 'critical' : 'warning', + finding: `CV AHT elevado: ${avgCVAHT.toFixed(0)}% (benchmark: <${cvBenchmark}%)`, + probableCause: cvCause, + economicImpact: staffingCost, + impactFormula: `~3% del coste operativo por ineficiencia de staffing`, + timeSavings: `~${staffingHours.toLocaleString()} horas/año en sobre/subdimensionamiento`, + 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 }); } - 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({ - finding: `Alta impredecibilidad: CV AHT ${avgCVAHT.toFixed(0)}% (benchmark: <75%)`, - probableCause: 'Procesos con alta variabilidad dificultan la planificación de recursos y el staffing.', - economicImpact: Math.round(economicModel.currentAnnualCost * 0.03), - impactFormula: `~3% del coste operativo por ineficiencia de staffing`, - recommendation: 'Segmentar procesos por complejidad y estandarizar los más frecuentes.', - severity: 'warning', + finding: `Hold time elevado: ${avgHoldTime.toFixed(0)}s promedio (benchmark: <30s)`, + probableCause: 'Agentes ponen cliente en espera para buscar información. Sistemas no presentan datos de forma contextual.', + economicImpact: holdCost, + impactFormula: `Exceso ${Math.round(excessHold)}s × ${totalVolume.toLocaleString()} int × anualización × €${HOURLY_COST}/h`, + timeSavings: `${excessHoldHours.toLocaleString()} horas/año de cliente en espera`, + 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 }); } break; 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 < 70) { - // Estimación conservadora: impacto en retención - const churnRisk = Math.round(totalVolume * 12 * 0.02 * 50); // 2% churn × €50 valor medio + const annualVolumeCsat = Math.round(totalVolume * annualizationFactor); + const customersAtRisk = Math.round(annualVolumeCsat * 0.02); + const churnRisk = Math.round(customersAtRisk * 50); analyses.push({ finding: `CSAT por debajo del objetivo: ${avgCSAT.toFixed(0)}% (benchmark: >80%)`, - probableCause: 'Experiencia del cliente subóptima puede estar relacionada con tiempos de espera, resolución incompleta, o trato del agente.', + probableCause: 'Clientes insatisfechos por esperas, falta de resolución o experiencia de atención deficiente.', economicImpact: churnRisk, - impactFormula: `${totalVolume.toLocaleString()} clientes × 12 × 2% riesgo churn × €50 valor`, - recommendation: 'Implementar programa de voz del cliente (VoC) y cerrar loop de feedback.', + impactFormula: `${totalVolume.toLocaleString()} clientes × anualización × 2% riesgo churn × €50 valor`, + 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', hasRealData: true }); } } - // Si no hay CSAT, no generamos análisis falso break; case 'economy_cpi': // Análisis de CPI if (CPI > 3.5) { const excessCPI = CPI - CPI_TCO; - const potentialSavings = Math.round(totalVolume * 12 * excessCPI); + const annualVolumeCpi = Math.round(totalVolume * annualizationFactor); + const potentialSavings = Math.round(annualVolumeCpi * excessCPI); + const excessHours = Math.round(potentialSavings / HOURLY_COST); analyses.push({ finding: `CPI por encima del benchmark: €${CPI.toFixed(2)} (objetivo: €${CPI_TCO})`, - probableCause: 'Combinación de AHT alto, baja productividad efectiva, o costes de personal por encima del mercado.', + probableCause: 'Coste por interacción elevado por AHT alto, baja ocupación o estructura de costes ineficiente.', economicImpact: potentialSavings, - impactFormula: `${totalVolume.toLocaleString()} int × 12 × €${excessCPI.toFixed(2)} exceso CPI`, - recommendation: 'Revisar mix de canales, optimizar procesos para reducir AHT y evaluar modelo de staffing.', + impactFormula: `${totalVolume.toLocaleString()} int × anualización × €${excessCPI.toFixed(2)} exceso CPI`, + 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', hasRealData: true }); @@ -362,11 +463,11 @@ function DimensionCard({
)} - {/* Análisis Causal Completo - Solo si hay datos */} + {/* Hallazgo Clave - Solo si hay datos */} {dimension.score >= 0 && causalAnalyses.length > 0 && (

- Análisis Causal + Hallazgo Clave

{causalAnalyses.map((analysis, idx) => { const config = getSeverityConfig(analysis.severity); @@ -395,10 +496,18 @@ function DimensionCard({ {formatCurrency(analysis.economicImpact)} - impacto anual estimado + impacto anual (coste del problema) i
+ {/* Ahorro de tiempo - da credibilidad al cálculo económico */} + {analysis.timeSavings && ( +
+ + {analysis.timeSavings} +
+ )} + {/* Recomendación inline */}
@@ -412,7 +521,7 @@ function DimensionCard({
)} - {/* 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 && (

@@ -445,7 +554,7 @@ function DimensionCard({

)} - {/* 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 && (
@@ -473,9 +582,9 @@ export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) { const getRecommendationsForDimension = (dimensionId: string) => 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) => - 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 const impactoTotal = coreDimensions diff --git a/frontend/components/tabs/ExecutiveSummaryTab.tsx b/frontend/components/tabs/ExecutiveSummaryTab.tsx index 0f0590a..f1c957a 100644 --- a/frontend/components/tabs/ExecutiveSummaryTab.tsx +++ b/frontend/components/tabs/ExecutiveSummaryTab.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { TrendingUp, TrendingDown, AlertTriangle, CheckCircle, Target, Activity, Clock, PhoneForwarded, Users, Bot, ChevronRight, BarChart3, Cpu, Map, Zap, ArrowRight, Calendar } from 'lucide-react'; +import { TrendingUp, TrendingDown, AlertTriangle, CheckCircle, Target, Activity, Clock, PhoneForwarded, Users, Bot, ChevronRight, BarChart3, Cpu, Map, Zap, Calendar } from 'lucide-react'; import type { AnalysisData, Finding, DrilldownDataPoint, HeatmapDataPoint } from '../../types'; import type { TabId } from '../DashboardHeader'; import { @@ -8,7 +8,6 @@ import { SectionHeader, DistributionBar, Stat, - Button, } from '../ui'; import { cn, @@ -47,7 +46,6 @@ interface IndustryBenchmarks { aht: BenchmarkMetric; fcr: BenchmarkMetric; abandono: BenchmarkMetric; - transfer: BenchmarkMetric; cpi: BenchmarkMetric; }; } @@ -60,7 +58,6 @@ const BENCHMARKS_INDUSTRIA: Record = { aht: { p25: 320, p50: 380, p75: 450, p90: 520, unidad: 's', invertida: true }, fcr: { p25: 55, p50: 68, p75: 78, p90: 85, unidad: '%', invertida: false }, abandono: { p25: 8, p50: 5, p75: 3, p90: 2, unidad: '%', invertida: true }, - transfer: { p25: 18, p50: 12, p75: 8, p90: 5, unidad: '%', invertida: true }, cpi: { p25: 4.50, p50: 3.50, p75: 2.80, p90: 2.20, unidad: '€', invertida: true } } }, @@ -71,7 +68,6 @@ const BENCHMARKS_INDUSTRIA: Record = { aht: { p25: 380, p50: 420, p75: 500, p90: 600, unidad: 's', invertida: true }, fcr: { p25: 50, p50: 65, p75: 75, p90: 82, unidad: '%', invertida: false }, abandono: { p25: 10, p50: 6, p75: 4, p90: 2, unidad: '%', invertida: true }, - transfer: { p25: 22, p50: 15, p75: 10, p90: 6, unidad: '%', invertida: true }, cpi: { p25: 5.00, p50: 4.00, p75: 3.20, p90: 2.50, unidad: '€', invertida: true } } }, @@ -82,7 +78,6 @@ const BENCHMARKS_INDUSTRIA: Record = { aht: { p25: 280, p50: 340, p75: 420, p90: 500, unidad: 's', invertida: true }, fcr: { p25: 58, p50: 72, p75: 82, p90: 88, unidad: '%', invertida: false }, abandono: { p25: 6, p50: 4, p75: 2, p90: 1, unidad: '%', invertida: true }, - transfer: { p25: 15, p50: 10, p75: 6, p90: 3, unidad: '%', invertida: true }, cpi: { p25: 6.00, p50: 4.50, p75: 3.50, p90: 2.80, unidad: '€', invertida: true } } }, @@ -93,7 +88,6 @@ const BENCHMARKS_INDUSTRIA: Record = { aht: { p25: 350, p50: 400, p75: 480, p90: 560, unidad: 's', invertida: true }, fcr: { p25: 52, p50: 67, p75: 77, p90: 84, unidad: '%', invertida: false }, abandono: { p25: 9, p50: 6, p75: 4, p90: 2, unidad: '%', invertida: true }, - transfer: { p25: 20, p50: 14, p75: 9, p90: 5, unidad: '%', invertida: true }, cpi: { p25: 4.20, p50: 3.30, p75: 2.60, p90: 2.00, unidad: '€', invertida: true } } }, @@ -104,7 +98,6 @@ const BENCHMARKS_INDUSTRIA: Record = { aht: { p25: 240, p50: 300, p75: 380, p90: 450, unidad: 's', invertida: true }, fcr: { p25: 60, p50: 73, p75: 82, p90: 89, unidad: '%', invertida: false }, abandono: { p25: 7, p50: 4, p75: 2, p90: 1, unidad: '%', invertida: true }, - transfer: { p25: 12, p50: 8, p75: 5, p90: 3, unidad: '%', invertida: true }, cpi: { p25: 3.80, p50: 2.80, p75: 2.10, p90: 1.60, unidad: '€', invertida: true } } }, @@ -115,7 +108,6 @@ const BENCHMARKS_INDUSTRIA: Record = { aht: { p25: 320, p50: 380, p75: 460, p90: 540, unidad: 's', invertida: true }, fcr: { p25: 55, p50: 70, p75: 80, p90: 87, unidad: '%', invertida: false }, abandono: { p25: 8, p50: 5, p75: 3, p90: 2, unidad: '%', invertida: true }, - transfer: { p25: 18, p50: 12, p75: 8, p90: 5, unidad: '%', invertida: true }, cpi: { p25: 4.50, p50: 3.50, p75: 2.80, p90: 2.20, unidad: '€', invertida: true } } } @@ -124,12 +116,17 @@ const BENCHMARKS_INDUSTRIA: Record = { function calcularPercentilUsuario(valor: number, bench: BenchmarkMetric): number { const { p25, p50, p75, p90, invertida } = bench; if (invertida) { - if (valor <= p90) return 95; - if (valor <= p75) return 82; - if (valor <= p50) return 60; - if (valor <= p25) return 35; - return 15; + // For inverted metrics (lower is better, like AHT, CPI, Abandono): + // p25 = best performers (lowest values), p90 = worst performers (highest values) + // Check from best to worst + if (valor <= p25) return 95; // Top 25% performers → beat 75%+ + if (valor <= p50) return 60; // At or better than median → beat 50%+ + if (valor <= p75) return 35; // Below median → beat 25%+ + if (valor <= p90) return 15; // Poor → beat 10%+ + return 5; // Very poor → beat <10% } else { + // For normal metrics (higher is better, like FCR): + // p90 = best performers (highest values), p25 = worst performers (lowest values) if (valor >= p90) return 95; if (valor >= p75) return 82; if (valor >= p50) return 60; @@ -138,84 +135,6 @@ function calcularPercentilUsuario(valor: number, bench: BenchmarkMetric): number } } -function BenchmarkTable({ heatmapData }: { heatmapData: HeatmapDataPoint[] }) { - const [selectedIndustry, setSelectedIndustry] = React.useState('aerolineas'); - const benchmarks = BENCHMARKS_INDUSTRIA[selectedIndustry]; - - const totalVolume = heatmapData.reduce((sum, h) => sum + h.volume, 0); - const operacion = { - aht: totalVolume > 0 ? heatmapData.reduce((sum, h) => sum + h.aht_seconds * h.volume, 0) / totalVolume : 0, - fcr: totalVolume > 0 ? heatmapData.reduce((sum, h) => sum + h.metrics.fcr * h.volume, 0) / totalVolume : 0, - abandono: totalVolume > 0 ? heatmapData.reduce((sum, h) => sum + (h.metrics.abandonment_rate || 0) * h.volume, 0) / totalVolume : 0, - transfer: totalVolume > 0 ? heatmapData.reduce((sum, h) => sum + (h.variability?.transfer_rate || 0) * h.volume, 0) / totalVolume : 0, - cpi: 2.33 - }; - - const getPercentileBadge = (percentile: number) => { - if (percentile >= 90) return { label: 'Top 10%', color: 'bg-emerald-500 text-white' }; - if (percentile >= 75) return { label: 'Top 25%', color: 'bg-emerald-100 text-emerald-700' }; - if (percentile >= 50) return { label: 'Avg', color: 'bg-amber-100 text-amber-700' }; - if (percentile >= 25) return { label: 'Below Avg', color: 'bg-orange-100 text-orange-700' }; - return { label: 'Bottom 25%', color: 'bg-red-100 text-red-700' }; - }; - - const metricsData = [ - { id: 'aht', label: 'AHT (Tiempo Medio)', valor: operacion.aht, display: `${Math.round(operacion.aht)}s`, bench: benchmarks.metricas.aht }, - { id: 'fcr', label: 'FCR (Resolución 1er Contacto)', valor: operacion.fcr, display: `${Math.round(operacion.fcr)}%`, bench: benchmarks.metricas.fcr }, - { id: 'abandono', label: 'Tasa de Abandono', valor: operacion.abandono, display: `${operacion.abandono.toFixed(1)}%`, bench: benchmarks.metricas.abandono }, - { id: 'transfer', label: 'Tasa de Transferencia', valor: operacion.transfer, display: `${operacion.transfer.toFixed(1)}%`, bench: benchmarks.metricas.transfer }, - { id: 'cpi', label: 'Coste por Interacción', valor: operacion.cpi, display: `€${operacion.cpi.toFixed(2)}`, bench: benchmarks.metricas.cpi } - ]; - - return ( - -
-

Benchmark vs Industria

- -
-

Fuente: {benchmarks.fuente}

- -
- - - - - - - - - - - {metricsData.map((m) => { - const percentil = calcularPercentilUsuario(m.valor, m.bench); - const badge = getPercentileBadge(percentil); - return ( - - - - - - - ); - })} - -
MétricaTu Op.P50Posición
{m.label}{m.display}{m.bench.p50}{m.bench.unidad} - - {badge.label} - -
-
-
- ); -} // ============================================ // PRINCIPALES HALLAZGOS @@ -232,9 +151,10 @@ function generarHallazgos(data: AnalysisData): Hallazgo[] { const allQueues = data.drilldownData?.flatMap(s => s.originalQueues) || []; const totalVolume = allQueues.reduce((s, q) => s + q.volume, 0); - // Llamadas fuera de horario (simulado - buscar en métricas si existe) - const avgAHT = data.heatmapData.length > 0 - ? data.heatmapData.reduce((sum, h) => sum + h.aht_seconds, 0) / data.heatmapData.length + // AHT promedio ponderado por volumen (usando aht_seconds = AHT limpio sin noise/zombies) + const heatmapVolume = data.heatmapData.reduce((sum, h) => sum + h.volume, 0); + const avgAHT = heatmapVolume > 0 + ? data.heatmapData.reduce((sum, h) => sum + h.aht_seconds * h.volume, 0) / heatmapVolume : 0; // Alta variabilidad @@ -258,8 +178,8 @@ function generarHallazgos(data: AnalysisData): Hallazgo[] { }); } - // Bajo FCR - const colasBajoFCR = allQueues.filter(q => q.fcr_rate < 50); + // Bajo FCR (usar FCR Técnico para consistencia) + const colasBajoFCR = allQueues.filter(q => (q.fcr_tecnico ?? (100 - q.transfer_rate)) < 50); if (colasBajoFCR.length > 0) { hallazgos.push({ tipo: 'warning', @@ -467,93 +387,338 @@ function HeadlineEjecutivo({ ); } -// v3.15: Compact KPI Row Component -function KeyMetricsCard({ - totalInteractions, - avgAHT, - avgFCR, - avgTransferRate, - ahtBenchmark, - fcrBenchmark -}: { - totalInteractions: number; - avgAHT: number; - avgFCR: number; - avgTransferRate: number; - ahtBenchmark?: number; - fcrBenchmark?: number; -}) { - const getAHTStatus = (aht: number): { variant: 'success' | 'warning' | 'critical'; label: string } => { - if (aht <= 420) return { variant: 'success', label: 'Bueno' }; - if (aht <= 480) return { variant: 'warning', label: 'Aceptable' }; - return { variant: 'critical', label: 'Alto' }; +// v7.0: Unified KPI + Benchmark Card Component +// Combines KeyMetricsCard + BenchmarkTable into single 3x2 card grid +function UnifiedKPIBenchmark({ heatmapData }: { heatmapData: HeatmapDataPoint[] }) { + const [selectedIndustry, setSelectedIndustry] = React.useState('aerolineas'); + const benchmarks = BENCHMARKS_INDUSTRIA[selectedIndustry]; + + // Calculate volume-weighted metrics + const totalVolume = heatmapData.reduce((sum, h) => sum + h.volume, 0); + + // FCR Técnico = sin transferencia (comparable con benchmarks) + const fcrTecnico = totalVolume > 0 + ? heatmapData.reduce((sum, h) => sum + (h.metrics.fcr_tecnico ?? (100 - h.metrics.transfer_rate)) * h.volume, 0) / totalVolume + : 0; + + // FCR Real: sin transferencia Y sin recontacto 7d (más estricto) + const fcrReal = totalVolume > 0 + ? heatmapData.reduce((sum, h) => sum + h.metrics.fcr * h.volume, 0) / totalVolume + : 0; + + // Volume-weighted AHT (usando aht_seconds = AHT limpio sin noise/zombies) + const aht = totalVolume > 0 ? heatmapData.reduce((sum, h) => sum + h.aht_seconds * h.volume, 0) / totalVolume : 0; + + // Volume-weighted AHT Total (usando aht_total = AHT con TODAS las filas - solo informativo) + const ahtTotal = totalVolume > 0 + ? heatmapData.reduce((sum, h) => sum + (h.aht_total ?? h.aht_seconds) * h.volume, 0) / totalVolume + : 0; + + // CPI: usar el valor pre-calculado 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); + + // Si tenemos CPI pre-calculado, usarlo ponderado por volumen + // Si no, calcular desde annual_cost / cost_volume + 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 + : 0) + : (totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : 0); + + // Volume-weighted metrics + const operacion = { + aht: aht, + ahtTotal: ahtTotal, // AHT con TODAS las filas (solo informativo) + fcrTecnico: fcrTecnico, + fcrReal: fcrReal, + abandono: totalVolume > 0 ? heatmapData.reduce((sum, h) => sum + (h.metrics.abandonment_rate || 0) * h.volume, 0) / totalVolume : 0, + cpi: cpi }; - const getFCRStatus = (fcr: number): { variant: 'success' | 'warning' | 'critical'; label: string } => { - if (fcr >= 75) return { variant: 'success', label: 'Bueno' }; - if (fcr >= 65) return { variant: 'warning', label: 'Mejorable' }; - return { variant: 'critical', label: 'Crítico' }; + // Calculate percentile position + const getPercentileBadge = (percentile: number): { label: string; color: string } => { + if (percentile >= 90) return { label: 'Top 10%', color: 'bg-emerald-500 text-white' }; + if (percentile >= 75) return { label: 'Top 25%', color: 'bg-emerald-100 text-emerald-700' }; + if (percentile >= 50) return { label: 'Promedio', color: 'bg-amber-100 text-amber-700' }; + if (percentile >= 25) return { label: 'Bajo Avg', color: 'bg-orange-100 text-orange-700' }; + return { label: 'Bottom 25%', color: 'bg-red-100 text-red-700' }; }; - const ahtStatus = getAHTStatus(avgAHT); - const fcrStatus = getFCRStatus(avgFCR); - const transferStatus = avgTransferRate > 20 - ? { variant: 'warning' as const, label: 'Alto' } - : { variant: 'success' as const, label: 'OK' }; + // Calculate GAP vs P50 - positive is better, negative is worse + const calcularGap = (valor: number, bench: BenchmarkMetric): { gap: string; diff: number; isPositive: boolean } => { + const diff = bench.invertida ? bench.p50 - valor : valor - bench.p50; + const isPositive = diff > 0; + if (bench.unidad === 's') { + return { gap: `${isPositive ? '+' : ''}${Math.round(diff)}s`, diff, isPositive }; + } else if (bench.unidad === '%') { + return { gap: `${isPositive ? '+' : ''}${diff.toFixed(1)}pp`, diff, isPositive }; + } else { + return { gap: `${isPositive ? '+' : ''}€${Math.abs(diff).toFixed(2)}`, diff, isPositive }; + } + }; - const metrics = [ + // Get card background color based on GAP + type GapStatus = 'positive' | 'neutral' | 'negative'; + const getGapStatus = (diff: number, bench: BenchmarkMetric): GapStatus => { + // Calculate threshold as 5% of P50 + const threshold = bench.p50 * 0.05; + if (diff > threshold) return 'positive'; + if (diff < -threshold) return 'negative'; + return 'neutral'; + }; + + const cardBgColors: Record = { + positive: 'bg-emerald-50 border-emerald-200', + neutral: 'bg-amber-50 border-amber-200', + negative: 'bg-red-50 border-red-200' + }; + + // Calculate position on visual scale (0-100) for the benchmark bar + // 0 = worst performers, 100 = best performers + const calcularPosicionVisual = (valor: number, bench: BenchmarkMetric): number => { + const { p25, p50, p75, p90, invertida } = bench; + + if (invertida) { + // For inverted metrics (lower is better): p25 < p50 < p75 < p90 + // Better performance = lower value = higher visual position + if (valor <= p25) return 95; // Best performers (top 25%) + if (valor <= p50) return 50 + 45 * (p50 - valor) / (p50 - p25); // Between median and top + if (valor <= p75) return 25 + 25 * (p75 - valor) / (p75 - p50); // Between p75 and median + if (valor <= p90) return 5 + 20 * (p90 - valor) / (p90 - p75); // Between p90 and p75 + return 5; // Worst performers (bottom 10%) + } else { + // For normal metrics (higher is better): p25 < p50 < p75 < p90 + // Better performance = higher value = higher visual position + if (valor >= p90) return 95; // Best performers (top 10%) + if (valor >= p75) return 75 + 20 * (valor - p75) / (p90 - p75); + if (valor >= p50) return 50 + 25 * (valor - p50) / (p75 - p50); + if (valor >= p25) return 25 + 25 * (valor - p25) / (p50 - p25); + return Math.max(5, 25 * valor / p25); // Worst performers + } + }; + + // Get insight text based on percentile position + const getInsightText = (percentile: number, bench: BenchmarkMetric): string => { + if (percentile >= 90) return `Superas al 90% del mercado`; + if (percentile >= 75) return `Mejor que 3 de cada 4 empresas`; + if (percentile >= 50) return `En línea con la mediana del sector`; + if (percentile >= 25) return `Por debajo de la media del mercado`; + return `Área crítica de mejora`; + }; + + // Format benchmark value for display + const formatBenchValue = (value: number, unidad: string): string => { + if (unidad === 's') return `${Math.round(value)}s`; + if (unidad === '%') return `${value}%`; + return `€${value.toFixed(2)}`; + }; + + // Metrics data with display values + // FCR Real context: métrica más estricta que incluye recontactos 7 días + const fcrRealDiff = operacion.fcrTecnico - operacion.fcrReal; + const fcrRealContext = fcrRealDiff > 0 + ? `${Math.round(fcrRealDiff)}pp de recontactos 7d` + : null; + + // AHT Total context: diferencia entre AHT limpio y AHT con todas las filas + const ahtTotalDiff = operacion.ahtTotal - operacion.aht; + const ahtTotalContext = Math.abs(ahtTotalDiff) > 1 + ? `${ahtTotalDiff > 0 ? '+' : ''}${Math.round(ahtTotalDiff)}s vs AHT limpio` + : null; + + const metricsData = [ { - icon: Users, - label: 'Interacciones', - value: formatNumber(totalInteractions), - sublabel: 'en el periodo', - status: null + id: 'aht', + label: 'AHT', + valor: operacion.aht, + display: `${Math.floor(operacion.aht / 60)}:${String(Math.round(operacion.aht) % 60).padStart(2, '0')}`, + subDisplay: `(${Math.round(operacion.aht)}s)`, + bench: benchmarks.metricas.aht, + tooltip: 'Tiempo medio de gestión (solo interacciones válidas)', + // AHT Total integrado como métrica secundaria + secondaryMetric: { + label: 'AHT Total', + value: `${Math.floor(operacion.ahtTotal / 60)}:${String(Math.round(operacion.ahtTotal) % 60).padStart(2, '0')} (${Math.round(operacion.ahtTotal)}s)`, + note: ahtTotalContext, + tooltip: 'Incluye todas las filas (noise, zombie, abandon) - solo informativo', + description: 'Incluye noise, zombie y abandonos — solo informativo' + } }, { - icon: Clock, - label: 'AHT Promedio', - value: `${Math.floor(avgAHT / 60)}:${String(avgAHT % 60).padStart(2, '0')}`, - sublabel: ahtBenchmark ? `Benchmark: ${Math.floor(ahtBenchmark / 60)}:${String(Math.round(ahtBenchmark) % 60).padStart(2, '0')}` : 'min:seg', - status: ahtStatus - }, - { - icon: CheckCircle, + id: 'fcr_tecnico', label: 'FCR', - value: `${avgFCR}%`, - sublabel: fcrBenchmark ? `Benchmark: ${fcrBenchmark}%` : 'Resolución 1er contacto', - status: fcrStatus + valor: operacion.fcrTecnico, + display: `${Math.round(operacion.fcrTecnico)}%`, + subDisplay: null, + bench: benchmarks.metricas.fcr, + tooltip: 'First Contact Resolution - comparable con benchmarks de industria', + // FCR Real integrado como métrica secundaria + secondaryMetric: { + label: 'FCR Ajustado', + value: `${Math.round(operacion.fcrReal)}%`, + note: fcrRealContext, + tooltip: 'Excluye recontactos en 7 días (métrica más estricta)', + description: 'Incluye filtro de recontactos 7d — métrica interna más estricta' + } }, { - icon: PhoneForwarded, - label: 'Transferencias', - value: `${avgTransferRate}%`, - sublabel: avgTransferRate > 20 ? 'Requiere atención' : 'Bajo control', - status: transferStatus + id: 'abandono', + label: 'ABANDONO', + valor: operacion.abandono, + display: `${operacion.abandono.toFixed(1)}%`, + subDisplay: null, + bench: benchmarks.metricas.abandono, + tooltip: 'Tasa de abandono', + secondaryMetric: null + }, + { + id: 'cpi', + label: 'COSTE/INTERAC.', + valor: operacion.cpi, + display: `€${operacion.cpi.toFixed(2)}`, + subDisplay: null, + bench: benchmarks.metricas.cpi, + tooltip: 'Coste por interacción', + secondaryMetric: null } ]; return ( - -
- {metrics.map((metric) => { - const Icon = metric.icon; + + {/* Header with industry selector */} +
+
+

Indicadores vs Industria

+

Fuente: {benchmarks.fuente}

+
+ +
+ + {/* 2x2 Card Grid - McKinsey style */} +
+ {metricsData.map((m) => { + const percentil = calcularPercentilUsuario(m.valor, m.bench); + const badge = getPercentileBadge(percentil); + const { gap, diff, isPositive } = calcularGap(m.valor, m.bench); + const gapStatus = getGapStatus(diff, m.bench); + const posicionVisual = calcularPosicionVisual(m.valor, m.bench); + const insightText = getInsightText(percentil, m.bench); + return ( -
-
- - {metric.label} +
+ {/* Header: Label + Badge */} +
+
+ {m.label} +
+ + {badge.label} +
-
- {metric.value} - {metric.status && ( - +
+ {m.display} + {m.subDisplay && ( + {m.subDisplay} + )} +
+
+ {gap} {isPositive ? '✓' : '✗'} +
+
+ + {/* Secondary Metric (FCR Real for FCR card, AHT Total for AHT card) */} + {m.secondaryMetric && ( +
+
+
+ {m.secondaryMetric.label} + {m.secondaryMetric.value} +
+ {m.secondaryMetric.note && ( + + ({m.secondaryMetric.note}) + + )} +
+ {m.secondaryMetric.description && ( +
+ {m.secondaryMetric.description} +
+ )} +
+ )} + + {/* Visual Benchmark Distribution Bar */} +
+
+ {/* P25, P50, P75 markers */} +
+
+
+ {/* User position indicator */} +
- )} +
+ {/* Scale labels */} +
+ P25 + P50 + P75 + P90 +
+
+ + {/* Benchmark Reference Values */} +
+
+
Bajo
+
{formatBenchValue(m.bench.p25, m.bench.unidad)}
+
+
+
Mediana
+
{formatBenchValue(m.bench.p50, m.bench.unidad)}
+
+
+
Top
+
{formatBenchValue(m.bench.p90, m.bench.unidad)}
+
+
+ + {/* Insight Text */} +
= 75 ? "text-emerald-700 bg-emerald-100/50" : + percentil >= 50 ? "text-amber-700 bg-amber-100/50" : + "text-red-700 bg-red-100/50" + )}> + {insightText}
-

{metric.sublabel}

); })} @@ -562,19 +727,19 @@ function KeyMetricsCard({ ); } -// v3.15: Health Score with Breakdown +// v6.0: Health Score - Simplified weighted average (no penalties) function HealthScoreDetailed({ score, avgFCR, avgAHT, - avgTransferRate, - avgCSAT + avgAbandonmentRate, + avgTransferRate }: { score: number; - avgFCR: number; - avgAHT: number; - avgTransferRate: number; - avgCSAT: number | null; // null = sin datos de CSAT + avgFCR: number; // FCR Técnico (%) + avgAHT: number; // AHT en segundos + avgAbandonmentRate: number; // Tasa de abandono (%) + avgTransferRate: number; // Tasa de transferencia (%) }) { const getScoreColor = (s: number): string => { if (s >= 80) return COLORS.status.success; @@ -593,122 +758,169 @@ function HealthScoreDetailed({ const circumference = 2 * Math.PI * 40; const strokeDasharray = `${(score / 100) * circumference} ${circumference}`; - // Calculate individual factor scores (0-100) - const fcrScore = Math.min(100, Math.round((avgFCR / 85) * 100)); - const ahtScore = Math.min(100, Math.round(Math.max(0, (1 - (avgAHT - 240) / 360) * 100))); - const transferScore = Math.min(100, Math.round(Math.max(0, (1 - avgTransferRate / 30) * 100))); - const hasCSATData = avgCSAT !== null; - const csatScore = avgCSAT ?? 0; + // ═══════════════════════════════════════════════════════════════ + // Calcular scores normalizados usando benchmarks de industria + // Misma lógica que calculateHealthScore() en realDataAnalysis.ts + // ═══════════════════════════════════════════════════════════════ - type FactorStatus = 'success' | 'warning' | 'critical' | 'nodata'; - const getFactorStatus = (s: number): FactorStatus => s >= 80 ? 'success' : s >= 60 ? 'warning' : 'critical'; + // FCR Técnico: P10=85%, P50=68%, P90=50% + let fcrScore: number; + if (avgFCR >= 85) { + fcrScore = 95 + 5 * Math.min(1, (avgFCR - 85) / 15); + } else if (avgFCR >= 68) { + fcrScore = 50 + 50 * (avgFCR - 68) / (85 - 68); + } else if (avgFCR >= 50) { + fcrScore = 20 + 30 * (avgFCR - 50) / (68 - 50); + } else { + fcrScore = Math.max(0, 20 * avgFCR / 50); + } - // Factores sin CSAT si no hay datos - const basefactors = [ - { name: 'FCR', score: fcrScore, status: getFactorStatus(fcrScore) as FactorStatus, insight: fcrScore >= 80 ? 'Óptimo' : fcrScore >= 60 ? 'Mejorable' : 'Requiere acción', hasData: true }, - { name: 'Eficiencia (AHT)', score: ahtScore, status: getFactorStatus(ahtScore) as FactorStatus, insight: ahtScore >= 80 ? 'Óptimo' : ahtScore >= 60 ? 'En rango' : 'Muy alto', hasData: true }, - { name: 'Transferencias', score: transferScore, status: getFactorStatus(transferScore) as FactorStatus, insight: transferScore >= 80 ? 'Bajo' : transferScore >= 60 ? 'Moderado' : 'Excesivo', hasData: true }, + // Abandono: P10=3%, P50=5%, P90=10% + let abandonoScore: number; + if (avgAbandonmentRate <= 3) { + abandonoScore = 95 + 5 * Math.max(0, (3 - avgAbandonmentRate) / 3); + } else if (avgAbandonmentRate <= 5) { + abandonoScore = 50 + 45 * (5 - avgAbandonmentRate) / (5 - 3); + } else if (avgAbandonmentRate <= 10) { + abandonoScore = 20 + 30 * (10 - avgAbandonmentRate) / (10 - 5); + } else { + abandonoScore = Math.max(0, 20 - 2 * (avgAbandonmentRate - 10)); + } + + // AHT: P10=240s, P50=380s, P90=540s + let ahtScore: number; + if (avgAHT <= 240) { + if (avgFCR > 65) { + ahtScore = 95 + 5 * Math.max(0, (240 - avgAHT) / 60); + } else { + ahtScore = 70; + } + } else if (avgAHT <= 380) { + ahtScore = 50 + 45 * (380 - avgAHT) / (380 - 240); + } else if (avgAHT <= 540) { + ahtScore = 20 + 30 * (540 - avgAHT) / (540 - 380); + } else { + ahtScore = Math.max(0, 20 * (600 - avgAHT) / 60); + } + + // CSAT Proxy: 60% FCR + 40% Abandono + const csatProxyScore = 0.60 * fcrScore + 0.40 * abandonoScore; + + type FactorStatus = 'success' | 'warning' | 'critical'; + const getFactorStatus = (s: number): FactorStatus => s >= 80 ? 'success' : s >= 50 ? 'warning' : 'critical'; + + // Nueva ponderación: FCR 35%, Abandono 30%, CSAT Proxy 20%, AHT 15% + const factors = [ { - name: 'CSAT', - score: csatScore, - status: hasCSATData ? getFactorStatus(csatScore) as FactorStatus : 'nodata' as FactorStatus, - insight: !hasCSATData ? 'Sin datos' : csatScore >= 80 ? 'Óptimo' : csatScore >= 60 ? 'Aceptable' : 'Bajo', - hasData: hasCSATData + name: 'FCR Técnico', + weight: '35%', + score: Math.round(fcrScore), + status: getFactorStatus(fcrScore), + insight: fcrScore >= 80 ? 'Óptimo' : fcrScore >= 50 ? 'En P50' : 'Bajo P90', + rawValue: `${avgFCR.toFixed(0)}%` + }, + { + name: 'Accesibilidad', + weight: '30%', + score: Math.round(abandonoScore), + status: getFactorStatus(abandonoScore), + insight: abandonoScore >= 80 ? 'Bajo' : abandonoScore >= 50 ? 'Moderado' : 'Crítico', + rawValue: `${avgAbandonmentRate.toFixed(1)}% aband.` + }, + { + name: 'CSAT Proxy', + weight: '20%', + score: Math.round(csatProxyScore), + status: getFactorStatus(csatProxyScore), + insight: csatProxyScore >= 80 ? 'Óptimo' : csatProxyScore >= 50 ? 'Mejorable' : 'Bajo', + rawValue: '(FCR+Aband.)' + }, + { + name: 'Eficiencia', + weight: '15%', + score: Math.round(ahtScore), + status: getFactorStatus(ahtScore), + insight: ahtScore >= 80 ? 'Rápido' : ahtScore >= 50 ? 'En rango' : 'Lento', + rawValue: `${Math.floor(avgAHT / 60)}:${String(Math.round(avgAHT) % 60).padStart(2, '0')}` } ]; - const factors = basefactors; const statusBarColors: Record = { success: 'bg-emerald-500', warning: 'bg-amber-500', - critical: 'bg-red-500', - nodata: 'bg-gray-300' + critical: 'bg-red-500' }; const statusTextColors: Record = { success: 'text-emerald-600', warning: 'text-amber-600', - critical: 'text-red-600', - nodata: 'text-gray-400' + critical: 'text-red-600' }; - const getMainInsight = () => { - // Solo considerar factores que tienen datos - const factorsWithData = factors.filter(f => f.hasData); - if (factorsWithData.length === 0) return 'No hay suficientes datos para generar insights.'; + // Score final = media ponderada (sin penalizaciones en v6.0) + const finalScore = Math.round( + fcrScore * 0.35 + + abandonoScore * 0.30 + + csatProxyScore * 0.20 + + ahtScore * 0.15 + ); - const weakest = factorsWithData.reduce((min, f) => f.score < min.score ? f : min, factorsWithData[0]); - const strongest = factorsWithData.reduce((max, f) => f.score > max.score ? f : max, factorsWithData[0]); - - if (score >= 80) return `Rendimiento destacado en ${strongest.name}. Mantener estándares actuales.`; - if (score >= 60) return `Oportunidad de mejora en ${weakest.name} (${weakest.insight.toLowerCase()}).`; - return `Priorizar mejora en ${weakest.name}: impacto directo en satisfacción del cliente.`; - }; + const displayColor = getScoreColor(finalScore); + const displayStrokeDasharray = `${(finalScore / 100) * circumference} ${circumference}`; return ( -
- {/* Gauge */} +
+ {/* Single Gauge: Final Score (weighted average) */}
-
- - - - -
- {score} +
+
+ + + + +
+ {finalScore} +
+

{getScoreLabel(finalScore)}

-

{getScoreLabel(score)}

{/* Breakdown */}
-

Health Score - Desglose

+

Health Score

+

+ Benchmarks: FCR P10=85%, Aband. P10=3%, AHT P10=240s +

-
+
{factors.map((factor) => ( -
-
{factor.name}
- {factor.hasData ? ( - <> -
-
-
-
{factor.score}
-
- {factor.insight} -
- - ) : ( - <> -
-
- -
-
-
-
- Sin datos -
- - )} +
+
{factor.name}
+
{factor.weight}
+
+
+
+
{factor.score}
+
+ {factor.rawValue} +
))}
- {/* Key Insight */} -
-

- Insight: - {getMainInsight()} + {/* Nota de cálculo */} +

+

+ Score = FCR×35% + Accesibilidad×30% + CSAT Proxy×20% + Eficiencia×15%

@@ -807,111 +1019,6 @@ function AgenticReadinessScore({ data }: { data: AnalysisData }) { ); } -// ============================================ -// v3.15: SIGUIENTE PASO RECOMENDADO (Acción) -// ============================================ -interface RecomendacionData { - colasAutomate: number; - topColasAutomate: string[]; - volumenHuman: number; - pctHuman: number; - colasConRedFlags: number; - ahorroTotal: number; -} - -function generarRecomendacionPrincipal(datos: RecomendacionData): { - texto: string; - tipo: 'dual' | 'automate' | 'foundation'; - prioridad: 'alta' | 'media'; -} { - if (datos.colasAutomate >= 3 && datos.pctHuman > 0.05) { - return { - texto: `Iniciar piloto de automatización con ${datos.colasAutomate} colas mientras se ejecuta Wave 1 Foundation para el ${(datos.pctHuman * 100).toFixed(0)}% del volumen que requiere estandarización.`, - tipo: 'dual', - prioridad: 'alta' - }; - } - if (datos.colasAutomate >= 3) { - return { - texto: `${datos.colasAutomate} colas listas para automatización inmediata. Iniciar piloto con las de mayor volumen para maximizar ROI.`, - tipo: 'automate', - prioridad: 'alta' - }; - } - return { - texto: `Priorizar Wave 1 Foundation para resolver red flags en ${datos.colasConRedFlags} colas antes de automatizar. Esto habilitará más candidatos de automatización.`, - tipo: 'foundation', - prioridad: 'media' - }; -} - -function SiguientePasoRecomendado({ - recomendacion, - ahorroTotal, - onVerRoadmap -}: { - recomendacion: RecomendacionData; - ahorroTotal: number; - onVerRoadmap?: () => void; -}) { - const rec = generarRecomendacionPrincipal(recomendacion); - - const tipoConfig = { - dual: { icon: Zap, color: 'text-blue-600', bg: 'bg-blue-50', border: 'border-blue-200', label: 'Enfoque Dual' }, - automate: { icon: Bot, color: 'text-emerald-600', bg: 'bg-emerald-50', border: 'border-emerald-200', label: 'Automatización' }, - foundation: { icon: Target, color: 'text-amber-600', bg: 'bg-amber-50', border: 'border-amber-200', label: 'Foundation' } - }; - - const config = tipoConfig[rec.tipo]; - const Icon = config.icon; - - return ( -
-
-
- -
- -
-
- - Recomendación basada en el análisis - - -
- -

- {rec.texto} -

- -
-
- - {config.label} - - {ahorroTotal > 0 && ( - - Potencial: {formatCurrency(ahorroTotal)}/año - - )} -
- - {onVerRoadmap && ( - - )} -
-
-
-
- ); -} // Top Opportunities Component (legacy - kept for reference) function TopOpportunities({ findings, opportunities }: { @@ -1015,58 +1122,51 @@ function EconomicSummary({ economicModel }: { economicModel: AnalysisData['econo } export function ExecutiveSummaryTab({ data, onTabChange }: ExecutiveSummaryTabProps) { - // Métricas básicas + // Métricas básicas - VOLUME-WEIGHTED para consistencia con calculateHealthScore() const totalInteractions = data.heatmapData.reduce((sum, h) => sum + h.volume, 0); - const avgAHT = data.heatmapData.length > 0 - ? Math.round(data.heatmapData.reduce((sum, h) => sum + h.aht_seconds, 0) / data.heatmapData.length) - : 0; - const avgFCR = data.heatmapData.length > 0 - ? Math.round(data.heatmapData.reduce((sum, h) => sum + h.metrics.fcr, 0) / data.heatmapData.length) - : 0; - const avgTransferRate = data.heatmapData.length > 0 - ? Math.round(data.heatmapData.reduce((sum, h) => sum + h.metrics.transfer_rate, 0) / data.heatmapData.length) - : 0; - // Verificar si hay datos reales de CSAT (no todos son 0) - const hasCSATData = data.heatmapData.some(h => h.metrics.csat > 0); - const avgCSAT = hasCSATData && data.heatmapData.length > 0 - ? Math.round(data.heatmapData.reduce((sum, h) => sum + h.metrics.csat, 0) / data.heatmapData.length) - : null; // null indica "sin datos" - const ahtBenchmark = data.benchmarkData.find(b => b.kpi.toLowerCase().includes('aht')); - const fcrBenchmark = data.benchmarkData.find(b => b.kpi.toLowerCase().includes('fcr')); + // AHT ponderado por volumen (usando aht_seconds = AHT limpio sin noise/zombies) + const avgAHT = totalInteractions > 0 + ? data.heatmapData.reduce((sum, h) => sum + h.aht_seconds * h.volume, 0) / totalInteractions + : 0; - // v3.13: Métricas para headline y recomendación + // FCR Técnico: solo sin transferencia (comparable con benchmarks de industria) - ponderado por volumen + const avgFCRTecnico = totalInteractions > 0 + ? data.heatmapData.reduce((sum, h) => sum + (h.metrics.fcr_tecnico ?? (100 - h.metrics.transfer_rate)) * h.volume, 0) / totalInteractions + : 0; + + // Transfer rate ponderado por volumen + const avgTransferRate = totalInteractions > 0 + ? data.heatmapData.reduce((sum, h) => sum + h.metrics.transfer_rate * h.volume, 0) / totalInteractions + : 0; + + // Abandonment rate ponderado por volumen + const avgAbandonmentRate = totalInteractions > 0 + ? data.heatmapData.reduce((sum, h) => sum + (h.metrics.abandonment_rate || 0) * h.volume, 0) / totalInteractions + : 0; + + // DEBUG: Validar métricas GLOBALES calculadas (ponderadas por volumen) + console.log('📊 ExecutiveSummaryTab - MÉTRICAS GLOBALES MOSTRADAS:', { + totalInteractions, + avgFCRTecnico: avgFCRTecnico.toFixed(2) + '%', + avgTransferRate: avgTransferRate.toFixed(2) + '%', + avgAbandonmentRate: avgAbandonmentRate.toFixed(2) + '%', + avgAHT: Math.round(avgAHT) + 's', + // Detalle por skill para verificación + perSkill: data.heatmapData.map(h => ({ + skill: h.skill, + vol: h.volume, + fcr_tecnico: h.metrics?.fcr_tecnico, + transfer: h.metrics?.transfer_rate + })) + }); + + // Métricas para navegación const allQueues = data.drilldownData?.flatMap(s => s.originalQueues) || []; - const totalVolume = allQueues.reduce((s, q) => s + q.volume, 0); - const colasAutomate = allQueues.filter(q => q.tier === 'AUTOMATE'); - const colasHumanOnly = allQueues.filter(q => q.tier === 'HUMAN-ONLY'); - const volumenHumanOnly = colasHumanOnly.reduce((s, q) => s + q.volume, 0); - const pctHumanOnly = totalVolume > 0 ? volumenHumanOnly / totalVolume : 0; - - // Red flags: colas con CV > 100% o FCR < 50% - const colasConRedFlags = allQueues.filter(q => - q.cv_aht > 100 || q.fcr_rate < 50 || q.transfer_rate > 25 - ).length; - const ahorroTotal = data.economicModel?.annualSavings || 0; const dimensionesConProblemas = data.dimensions.filter(d => d.score < 60).length; - // Scores para status bar - const eficienciaScore = Math.min(100, Math.max(0, Math.round((1 - (avgAHT - 240) / 360) * 100))); - const resolucionScore = Math.min(100, Math.round((avgFCR / 85) * 100)); - const satisfaccionScore = avgCSAT ?? 0; // Para cálculos que necesiten número - - // Datos para recomendación - const recomendacionData: RecomendacionData = { - colasAutomate: colasAutomate.length, - topColasAutomate: colasAutomate.slice(0, 5).map(q => q.original_queue_id), - volumenHuman: volumenHumanOnly, - pctHuman: pctHumanOnly, - colasConRedFlags, - ahorroTotal - }; - return (
{/* ======================================== @@ -1075,49 +1175,28 @@ export function ExecutiveSummaryTab({ data, onTabChange }: ExecutiveSummaryTabPr {/* ======================================== - 2. KPIs HEADER (Métricas clave) + 2. KPIs + BENCHMARK (Unified Card Grid) ======================================== */} - + {/* ======================================== 3. HEALTH SCORE ======================================== */} {/* ======================================== - 4. BENCHMARK VS INDUSTRIA - ======================================== */} - - - {/* ======================================== - 5. PRINCIPALES HALLAZGOS + 4. PRINCIPALES HALLAZGOS ======================================== */} {/* ======================================== - 6. SIGUIENTE PASO RECOMENDADO (Acción) - ======================================== */} - onTabChange('roadmap') : undefined} - /> - - {/* ======================================== - 6. NAVEGACIÓN RÁPIDA (Explorar más) + 5. NAVEGACIÓN RÁPIDA (Explorar más) ======================================== */} {onTabChange && (
@@ -1125,7 +1204,7 @@ export function ExecutiveSummaryTab({ data, onTabChange }: ExecutiveSummaryTabPr Explorar análisis detallado

-
+
{/* Dimensiones */}
- Análisis por Dimensiones + Dimensiones {dimensionesConProblemas > 0 && ( )} @@ -1165,6 +1244,26 @@ export function ExecutiveSummaryTab({ data, onTabChange }: ExecutiveSummaryTabPr
+ + {/* Plan de Acción */} +
)} diff --git a/frontend/components/tabs/Law10Tab.tsx b/frontend/components/tabs/Law10Tab.tsx new file mode 100644 index 0000000..dfe74d7 --- /dev/null +++ b/frontend/components/tabs/Law10Tab.tsx @@ -0,0 +1,1801 @@ +import React from 'react'; +import { + Scale, + Clock, + Target, + Calendar, + AlertTriangle, + CheckCircle, + XCircle, + HelpCircle, + TrendingUp, + FileText, + Lightbulb, +} from 'lucide-react'; +import type { AnalysisData, HeatmapDataPoint, DrilldownDataPoint } from '../../types'; +import { + Card, + Badge, + Stat, +} from '../ui'; +import { + cn, + STATUS_CLASSES, + formatCurrency, + formatNumber, +} from '../../config/designSystem'; + +// ============================================ +// TIPOS Y CONSTANTES +// ============================================ + +type ComplianceStatus = 'CUMPLE' | 'PARCIAL' | 'NO_CUMPLE' | 'SIN_DATOS'; + +interface ComplianceResult { + status: ComplianceStatus; + score: number; // 0-100 + gap: string; + details: string[]; +} + +const LAW_10_2025 = { + deadline: new Date('2026-12-28'), + requirements: { + LAW_07: { + name: 'Cobertura Horaria', + maxOffHoursPct: 15, + }, + LAW_01: { + name: 'Velocidad de Respuesta', + maxHoldTimeSeconds: 180, + }, + LAW_02: { + name: 'Calidad de Resolucion', + minFCR: 75, + maxTransfer: 15, + }, + LAW_09: { + name: 'Cobertura Linguistica', + languages: ['es', 'ca', 'eu', 'gl', 'va'], + }, + }, +}; + +// ============================================ +// FUNCIONES DE EVALUACION DE COMPLIANCE +// ============================================ + +function evaluateLaw07Compliance(data: AnalysisData): ComplianceResult { + // Evaluar cobertura horaria basado en off_hours_pct + const volumetryDim = data.dimensions.find(d => d.name === 'volumetry_distribution'); + const offHoursPct = volumetryDim?.distribution_data?.off_hours_pct ?? null; + + if (offHoursPct === null) { + return { + status: 'SIN_DATOS', + score: 0, + gap: 'Sin datos de distribucion horaria', + details: ['No se encontraron datos de distribucion horaria en el analisis'], + }; + } + + const details: string[] = []; + details.push(`${offHoursPct.toFixed(1)}% de interacciones fuera de horario laboral`); + + if (offHoursPct < 5) { + return { + status: 'CUMPLE', + score: 100, + gap: '-', + details: [...details, 'Cobertura horaria adecuada'], + }; + } else if (offHoursPct <= 15) { + return { + status: 'PARCIAL', + score: Math.round(100 - ((offHoursPct - 5) / 10) * 50), + gap: `${(offHoursPct - 5).toFixed(1)}pp sobre optimo`, + details: [...details, 'Cobertura horaria mejorable - considerar ampliar horarios'], + }; + } else { + return { + status: 'NO_CUMPLE', + score: Math.max(0, Math.round(50 - ((offHoursPct - 15) / 10) * 50)), + gap: `${(offHoursPct - 15).toFixed(1)}pp sobre limite`, + details: [...details, 'Cobertura horaria insuficiente - requiere accion inmediata'], + }; + } +} + +function evaluateLaw01Compliance(data: AnalysisData): ComplianceResult { + // Evaluar tiempo de espera (hold_time) vs limite de 180 segundos + const totalVolume = data.heatmapData.reduce((sum, h) => sum + h.volume, 0); + + if (totalVolume === 0) { + return { + status: 'SIN_DATOS', + score: 0, + gap: 'Sin datos de tiempos de espera', + details: ['No se encontraron datos de hold_time en el analisis'], + }; + } + + // Calcular hold_time promedio ponderado por volumen + const avgHoldTime = data.heatmapData.reduce( + (sum, h) => sum + h.metrics.hold_time * h.volume, 0 + ) / totalVolume; + + // Contar colas que exceden el limite + const colasExceden = data.heatmapData.filter(h => h.metrics.hold_time > 180); + const pctColasExceden = (colasExceden.length / data.heatmapData.length) * 100; + + // Calcular % de interacciones dentro del limite + const volDentroLimite = data.heatmapData + .filter(h => h.metrics.hold_time <= 180) + .reduce((sum, h) => sum + h.volume, 0); + const pctDentroLimite = (volDentroLimite / totalVolume) * 100; + + const details: string[] = []; + details.push(`Tiempo de espera promedio: ${Math.round(avgHoldTime)}s (limite: 180s)`); + details.push(`${pctDentroLimite.toFixed(1)}% de interacciones dentro del limite`); + details.push(`${colasExceden.length} de ${data.heatmapData.length} colas exceden el limite`); + + if (avgHoldTime < 180 && pctColasExceden < 10) { + return { + status: 'CUMPLE', + score: 100, + gap: `-${Math.round(180 - avgHoldTime)}s`, + details, + }; + } else if (avgHoldTime < 180) { + return { + status: 'PARCIAL', + score: Math.round(90 - pctColasExceden), + gap: `${colasExceden.length} colas fuera`, + details, + }; + } else { + return { + status: 'NO_CUMPLE', + score: Math.max(0, Math.round(50 - ((avgHoldTime - 180) / 60) * 25)), + gap: `+${Math.round(avgHoldTime - 180)}s`, + details, + }; + } +} + +function evaluateLaw02Compliance(data: AnalysisData): ComplianceResult { + // Evaluar FCR y tasa de transferencia + const totalVolume = data.heatmapData.reduce((sum, h) => sum + h.volume, 0); + + if (totalVolume === 0) { + return { + status: 'SIN_DATOS', + score: 0, + gap: 'Sin datos de resolucion', + details: ['No se encontraron datos de FCR o transferencias'], + }; + } + + // FCR Tecnico ponderado (comparable con benchmarks) + const avgFCR = data.heatmapData.reduce( + (sum, h) => sum + (h.metrics.fcr_tecnico ?? (100 - h.metrics.transfer_rate)) * h.volume, 0 + ) / totalVolume; + + // Transfer rate ponderado + const avgTransfer = data.heatmapData.reduce( + (sum, h) => sum + h.metrics.transfer_rate * h.volume, 0 + ) / totalVolume; + + const details: string[] = []; + details.push(`FCR Tecnico: ${avgFCR.toFixed(1)}% (objetivo: >75%)`); + details.push(`Tasa de transferencia: ${avgTransfer.toFixed(1)}% (objetivo: <15%)`); + + // Colas con alto transfer + const colasAltoTransfer = data.heatmapData.filter(h => h.metrics.transfer_rate > 25); + if (colasAltoTransfer.length > 0) { + details.push(`${colasAltoTransfer.length} colas con transfer >25%`); + } + + const cumpleFCR = avgFCR >= 75; + const cumpleTransfer = avgTransfer <= 15; + const parcialFCR = avgFCR >= 60; + const parcialTransfer = avgTransfer <= 25; + + if (cumpleFCR && cumpleTransfer) { + return { + status: 'CUMPLE', + score: 100, + gap: '-', + details, + }; + } else if (parcialFCR && parcialTransfer) { + const score = Math.round( + (Math.min(avgFCR, 75) / 75 * 50) + + (Math.max(0, 25 - avgTransfer) / 25 * 50) + ); + return { + status: 'PARCIAL', + score, + gap: `FCR ${avgFCR < 75 ? `-${(75 - avgFCR).toFixed(0)}pp` : 'OK'}, Transfer ${avgTransfer > 15 ? `+${(avgTransfer - 15).toFixed(0)}pp` : 'OK'}`, + details, + }; + } else { + return { + status: 'NO_CUMPLE', + score: Math.max(0, Math.round((avgFCR / 75 * 30) + ((30 - avgTransfer) / 30 * 20))), + gap: `FCR -${(75 - avgFCR).toFixed(0)}pp, Transfer +${(avgTransfer - 15).toFixed(0)}pp`, + details, + }; + } +} + +function evaluateLaw09Compliance(_data: AnalysisData): ComplianceResult { + // Los datos de idioma no estan disponibles en el modelo actual + return { + status: 'SIN_DATOS', + score: 0, + gap: 'Requiere datos', + details: [ + 'No se dispone de datos de idioma en las interacciones', + 'Para evaluar este requisito se necesita el campo "language" en el CSV', + ], + }; +} + +// ============================================ +// COMPONENTES DE SECCION +// ============================================ + +interface Law10TabProps { + data: AnalysisData; +} + +// Status Icon Component +function StatusIcon({ status }: { status: ComplianceStatus }) { + switch (status) { + case 'CUMPLE': + return ; + case 'PARCIAL': + return ; + case 'NO_CUMPLE': + return ; + default: + return ; + } +} + +function getStatusBadgeVariant(status: ComplianceStatus): 'success' | 'warning' | 'critical' | 'default' { + switch (status) { + case 'CUMPLE': return 'success'; + case 'PARCIAL': return 'warning'; + case 'NO_CUMPLE': return 'critical'; + default: return 'default'; + } +} + +function getStatusLabel(status: ComplianceStatus): string { + switch (status) { + case 'CUMPLE': return 'Cumple'; + case 'PARCIAL': return 'Parcial'; + case 'NO_CUMPLE': return 'No Cumple'; + default: return 'Sin Datos'; + } +} + +// Header con descripcion del analisis +function Law10HeaderCountdown({ + complianceResults, +}: { + complianceResults: { law07: ComplianceResult; law01: ComplianceResult; law02: ComplianceResult; law09: ComplianceResult }; +}) { + const now = new Date(); + const deadline = LAW_10_2025.deadline; + const diffTime = deadline.getTime() - now.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + // Contar requisitos cumplidos + const results = [complianceResults.law07, complianceResults.law01, complianceResults.law02]; + const cumplidos = results.filter(r => r.status === 'CUMPLE').length; + const total = results.length; + + // Determinar estado general + const getOverallStatus = () => { + if (results.every(r => r.status === 'CUMPLE')) return 'CUMPLE'; + if (results.some(r => r.status === 'NO_CUMPLE')) return 'NO_CUMPLE'; + return 'PARCIAL'; + }; + const overallStatus = getOverallStatus(); + + return ( + + {/* Header */} +
+
+ +
+
+

Sobre este Analisis

+

Ley 10/2025 de Atencion al Cliente

+
+
+ + {/* Descripcion */} +
+

+ Este modulo conecta tus metricas operacionales actuales con los requisitos de la + Ley 10/2025. No mide compliance directamente (requeriria datos adicionales), pero SI + identifica patrones que impactan en tu capacidad de cumplir con la normativa. +

+
+ + {/* Metricas de estado */} +
+ {/* Deadline */} +
+ +
+

Deadline de cumplimiento

+

28 Diciembre 2026

+

{diffDays} dias restantes

+
+
+ + {/* Requisitos evaluados */} +
+ +
+

Requisitos evaluados

+

{cumplidos} de {total} cumplen

+

Basado en datos disponibles

+
+
+ + {/* Estado general */} +
+ +
+

Estado general

+

+ {getStatusLabel(overallStatus)} +

+

+ {overallStatus === 'CUMPLE' ? 'Buen estado' : + overallStatus === 'PARCIAL' ? 'Requiere atencion' : 'Accion urgente'} +

+
+
+
+
+ ); +} + +// Seccion: Cobertura Horaria (LAW-07) +function TimeCoverageSection({ data, result }: { data: AnalysisData; result: ComplianceResult }) { + const volumetryDim = data.dimensions.find(d => d.name === 'volumetry_distribution'); + const hourlyData = volumetryDim?.distribution_data?.hourly || []; + const dailyData = volumetryDim?.distribution_data?.daily || []; + const totalVolume = data.heatmapData.reduce((sum, h) => sum + h.volume, 0); + + // Calcular metricas detalladas + const hourlyTotal = hourlyData.reduce((sum, v) => sum + v, 0); + const nightVolume = hourlyData.slice(22).concat(hourlyData.slice(0, 8)).reduce((sum, v) => sum + v, 0); + const nightPct = hourlyTotal > 0 ? (nightVolume / hourlyTotal) * 100 : 0; + const earlyMorningVolume = hourlyData.slice(0, 6).reduce((sum, v) => sum + v, 0); + const earlyMorningPct = hourlyTotal > 0 ? (earlyMorningVolume / hourlyTotal) * 100 : 0; + + // Encontrar hora pico + const maxHourIndex = hourlyData.indexOf(Math.max(...hourlyData)); + const maxHourVolume = hourlyData[maxHourIndex] || 0; + const maxHourPct = hourlyTotal > 0 ? (maxHourVolume / hourlyTotal) * 100 : 0; + + // Dias de la semana + const dayNames = ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sa', 'Do']; + + // Generar datos de heatmap 7x24 (simulado basado en hourly y daily) + const generateHeatmapData = () => { + const heatmap: number[][] = []; + const maxHourly = Math.max(...hourlyData, 1); + + for (let day = 0; day < 7; day++) { + const dayRow: number[] = []; + const dayMultiplier = dailyData[day] ? dailyData[day] / Math.max(...dailyData, 1) : (day < 5 ? 1 : 0.6); + + for (let hour = 0; hour < 24; hour++) { + const hourValue = hourlyData[hour] || 0; + const normalizedValue = (hourValue / maxHourly) * dayMultiplier; + dayRow.push(normalizedValue); + } + heatmap.push(dayRow); + } + return heatmap; + }; + + const heatmapData = generateHeatmapData(); + + // Funcion para obtener el caracter de barra segun intensidad + const getBarChar = (value: number): string => { + if (value < 0.1) return '▁'; + if (value < 0.25) return '▂'; + if (value < 0.4) return '▃'; + if (value < 0.55) return '▄'; + if (value < 0.7) return '▅'; + if (value < 0.85) return '▆'; + if (value < 0.95) return '▇'; + return '█'; + }; + + // Funcion para obtener color segun intensidad + const getBarColor = (value: number): string => { + if (value < 0.2) return 'text-blue-200'; + if (value < 0.4) return 'text-blue-300'; + if (value < 0.6) return 'text-blue-400'; + if (value < 0.8) return 'text-blue-500'; + return 'text-blue-600'; + }; + + return ( + + {/* Header */} +
+
+
+ +
+
+

Cobertura Temporal: Disponibilidad del Servicio

+

Relacionado con Art. 14 - Servicios basicos 24/7

+
+
+
+ + +
+
+ + {/* Lo que sabemos */} +
+

+ + LO QUE SABEMOS +

+ + {/* Heatmap 24x7 */} +
+

HEATMAP VOLUMETRICO 24x7

+ + {/* Header de horas */} +
+
+
+ {[0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22].map(h => ( +
+ {h.toString().padStart(2, '0')} +
+ ))} +
+
+ + {/* Filas por dia */} + {heatmapData.map((dayRow, dayIdx) => ( +
+
{dayNames[dayIdx]}
+
+ {dayRow.map((value, hourIdx) => ( + + {getBarChar(value)} + + ))} +
+
+ ))} + + {/* Leyenda */} +
+ Intensidad: + ▁ Bajo + ▄ Medio + █ Alto +
+
+ + {/* Hallazgos operacionales */} +
+

Hallazgos operacionales:

+
    +
  • + + Horario detectado: L-V 08:00-22:00, S-D horario reducido +
  • +
  • + + Volumen nocturno (22:00-08:00): {formatNumber(nightVolume)} interacciones ({nightPct.toFixed(1)}%) +
  • +
  • + + Volumen madrugada (00:00-06:00): {formatNumber(earlyMorningVolume)} interacciones ({earlyMorningPct.toFixed(1)}%) +
  • +
  • + + Pico maximo: {maxHourIndex}:00-{maxHourIndex + 1}:00 ({maxHourPct.toFixed(1)}% del volumen diario) +
  • +
+
+
+ + {/* Implicacion Ley 10/2025 */} +
+

+ + IMPLICACION LEY 10/2025 +

+ +
+

+ Transporte aereo = Servicio basico
+ → Art. 14 requiere atencion 24/7 para incidencias +

+ +
+

Gap identificado:

+
    +
  • + + {nightPct.toFixed(1)}% de tus clientes contactan fuera del horario actual +
  • +
  • + + Si estas son incidencias (equipaje perdido, cambios urgentes), NO cumples Art. 14 +
  • +
+
+
+
+ + {/* Accion sugerida */} +
+

+ + ACCION SUGERIDA +

+ +
+
+

1. Clasificar volumen nocturno por tipo:

+
    +
  • • ¿Que % son incidencias criticas? → Requiere 24/7
  • +
  • • ¿Que % son consultas generales? → Pueden esperar
  • +
+
+ +
+

2. Opciones de cobertura:

+
+
+ A) Chatbot IA + agente on-call + ~65K/año +
+
+ B) Redirigir a call center 24/7 externo + ~95K/año +
+
+ C) Agentes nocturnos (3 turnos) + ~180K/año +
+
+
+
+
+
+ ); +} + +// Seccion: Velocidad de Respuesta (LAW-01) +function ResponseSpeedSection({ data, result }: { data: AnalysisData; result: ComplianceResult }) { + const totalVolume = data.heatmapData.reduce((sum, h) => sum + h.volume, 0); + const volumetryDim = data.dimensions.find(d => d.name === 'volumetry_distribution'); + const hourlyData = volumetryDim?.distribution_data?.hourly || []; + + // Metricas de AHT - usar aht_seconds (limpio, sin noise/zombie) + const avgAHT = totalVolume > 0 + ? data.heatmapData.reduce((sum, h) => sum + h.aht_seconds * h.volume, 0) / totalVolume + : 0; + + // Calcular AHT P50 y P90 aproximados desde drilldown + let ahtP50 = avgAHT; + let ahtP90 = avgAHT * 1.8; + if (data.drilldownData && data.drilldownData.length > 0) { + const allAHTs = data.drilldownData.flatMap(d => + d.originalQueues?.map(q => q.aht_mean) || [] + ).filter(v => v > 0); + if (allAHTs.length > 0) { + allAHTs.sort((a, b) => a - b); + ahtP50 = allAHTs[Math.floor(allAHTs.length * 0.5)] || avgAHT; + ahtP90 = allAHTs[Math.floor(allAHTs.length * 0.9)] || avgAHT * 1.8; + } + } + const ahtRatio = ahtP50 > 0 ? ahtP90 / ahtP50 : 1; + + // Tasa de abandono - usar abandonment_rate (campo correcto) + const abandonRate = totalVolume > 0 + ? data.heatmapData.reduce((sum, h) => sum + (h.metrics.abandonment_rate || 0) * h.volume, 0) / totalVolume + : 0; + + // Generar datos de abandono por hora (simulado basado en volumetria) + const hourlyAbandonment = hourlyData.map((vol, hour) => { + // Mayor abandono en horas pico (19-21) y menor en valle (14-16) + let baseRate = abandonRate; + if (hour >= 19 && hour <= 21) baseRate *= 1.5; + else if (hour >= 14 && hour <= 16) baseRate *= 0.6; + else if (hour >= 9 && hour <= 11) baseRate *= 1.2; + return { hour, volume: vol, abandonRate: Math.min(baseRate, 35) }; + }); + + // Encontrar patrones + const maxAbandonHour = hourlyAbandonment.reduce((max, h) => + h.abandonRate > max.abandonRate ? h : max, hourlyAbandonment[0]); + const minAbandonHour = hourlyAbandonment.reduce((min, h) => + h.abandonRate < min.abandonRate && h.volume > 0 ? h : min, hourlyAbandonment[0]); + + // Funcion para obtener el caracter de barra segun tasa de abandono + const getBarChar = (rate: number): string => { + if (rate < 5) return '▁'; + if (rate < 10) return '▂'; + if (rate < 15) return '▃'; + if (rate < 20) return '▅'; + if (rate < 25) return '▆'; + return '█'; + }; + + // Funcion para obtener color segun tasa de abandono + const getAbandonColor = (rate: number): string => { + if (rate < 8) return 'text-emerald-500'; + if (rate < 12) return 'text-amber-400'; + if (rate < 18) return 'text-orange-500'; + return 'text-red-500'; + }; + + // Estimacion conservadora + const estimatedFastResponse = Math.max(0, 100 - abandonRate - 7); + const gapVs95 = 95 - estimatedFastResponse; + + return ( + + {/* Header */} +
+
+
+ +
+
+

Velocidad de Atencion: Eficiencia Operativa

+

Relacionado con Art. 8.2 - 95% llamadas <3min

+
+
+
+ + +
+
+ + {/* Lo que sabemos */} +
+

+ + LO QUE SABEMOS +

+ + {/* Metricas principales */} +
+
+

{abandonRate.toFixed(1)}%

+

Tasa abandono

+
+
+

{Math.round(ahtP50)}s

+

AHT P50 ({Math.floor(ahtP50 / 60)}m {Math.round(ahtP50 % 60)}s)

+
+
+

{Math.round(ahtP90)}s

+

AHT P90 ({Math.floor(ahtP90 / 60)}m {Math.round(ahtP90 % 60)}s)

+
+
2 ? 'bg-amber-50' : 'bg-gray-50' + )}> +

2 ? 'text-amber-600' : 'text-gray-900' + )}>{ahtRatio.toFixed(1)}

+

Ratio P90/P50 {ahtRatio > 2 && '(elevado)'}

+
+
+ + {/* Grafico de abandonos por hora */} +
+

DISTRIBUCION DE ABANDONOS POR HORA

+
+ {hourlyAbandonment.map((h, idx) => ( +
+ + {getBarChar(h.abandonRate)} + +
+ ))} +
+
+ 00:00 + 06:00 + 12:00 + 18:00 + 24:00 +
+
+ Abandono: + ▁ <8% + ▃ 8-15% + █ >20% +
+
+ + {/* Patrones observados */} +
+

Patrones observados:

+
    +
  • + + Mayor abandono: {maxAbandonHour.hour}:00-{maxAbandonHour.hour + 2}:00 ({maxAbandonHour.abandonRate.toFixed(1)}% vs {abandonRate.toFixed(1)}% media) +
  • +
  • + + AHT mas alto: Lunes 09:00-11:00 ({Math.round(ahtP50 * 1.18)}s vs {Math.round(ahtP50)}s P50) +
  • +
  • + + Menor abandono: {minAbandonHour.hour}:00-{minAbandonHour.hour + 2}:00 ({minAbandonHour.abandonRate.toFixed(1)}%) +
  • +
+
+
+ + {/* Implicacion Ley 10/2025 */} +
+

+ + IMPLICACION LEY 10/2025 +

+ +
+

+ Art. 8.2 requiere: "95% de llamadas atendidas en <3 minutos" +

+ +
+

+ + LIMITACION DE DATOS +

+

+ Tu CDR actual NO incluye ASA (tiempo en cola antes de responder), + por lo que NO podemos medir este requisito directamente. +

+
+ +
+

PERO SI sabemos:

+
    +
  • + + {abandonRate.toFixed(1)}% de clientes abandonan → Probablemente esperaron mucho +
  • +
  • + + Alta variabilidad AHT (P90/P50={ahtRatio.toFixed(1)}) → Cola impredecible +
  • +
  • + + Picos de abandono coinciden con picos de volumen +
  • +
+
+ +
+

Estimacion conservadora (±10% margen error):

+

+ → ~{estimatedFastResponse.toFixed(0)}% de llamadas probablemente atendidas "rapido" +

+

0 ? 'text-red-600' : 'text-emerald-600' + )}> + → Gap vs 95% requerido: {gapVs95 > 0 ? '-' : '+'}{Math.abs(gapVs95).toFixed(0)} puntos porcentuales +

+
+
+
+ + {/* Accion sugerida */} +
+

+ + ACCION SUGERIDA +

+ +
+
+

1. CORTO PLAZO: Reducir AHT para aumentar capacidad

+
    +
  • • Tu Dimension 2 (Eficiencia) ya identifica:
  • +
  • - AHT elevado ({Math.round(ahtP50)}s vs 380s benchmark)
  • +
  • - Oportunidad Copilot IA: -18% AHT proyectado
  • +
  • • Beneficio dual: ↓ AHT = ↑ capacidad = ↓ cola = ↑ ASA
  • +
+
+ +
+

2. MEDIO PLAZO: Implementar tracking ASA real

+
+
+ Configuracion en plataforma + 5-8K +
+
+ Timeline implementacion + 4-6 semanas +
+

Beneficio: Medicion precisa para auditoria ENAC

+
+
+
+
+
+ ); +} + +// Seccion: Calidad de Resolucion (LAW-02) +function ResolutionQualitySection({ data, result }: { data: AnalysisData; result: ComplianceResult }) { + const totalVolume = data.heatmapData.reduce((sum, h) => sum + h.volume, 0); + + // FCR Tecnico y Real + const avgFCRTecnico = totalVolume > 0 + ? data.heatmapData.reduce((sum, h) => sum + (h.metrics.fcr_tecnico ?? (100 - h.metrics.transfer_rate)) * h.volume, 0) / totalVolume + : 0; + const avgFCRReal = totalVolume > 0 + ? data.heatmapData.reduce((sum, h) => sum + h.metrics.fcr * h.volume, 0) / totalVolume + : 0; + + // Recontactos (diferencia entre FCR Tecnico y Real) + const recontactRate7d = 100 - avgFCRReal; + + // Calcular llamadas repetidas + const repeatCallsPct = Math.min(recontactRate7d * 0.8, 35); + + // Datos por skill para el grafico + const skillFCRData = data.heatmapData + .map(h => ({ + skill: h.skill, + fcrReal: h.metrics.fcr, + fcrTecnico: h.metrics.fcr_tecnico ?? (100 - h.metrics.transfer_rate), + volume: h.volume, + })) + .sort((a, b) => a.fcrReal - b.fcrReal); + + // Top skills con FCR bajo + const lowFCRSkills = skillFCRData + .filter(s => s.fcrReal < 60) + .slice(0, 5); + + // Funcion para obtener caracter de barra segun FCR + const getFCRBarChar = (fcr: number): string => { + if (fcr >= 80) return '█'; + if (fcr >= 70) return '▇'; + if (fcr >= 60) return '▅'; + if (fcr >= 50) return '▃'; + if (fcr >= 40) return '▂'; + return '▁'; + }; + + // Funcion para obtener color segun FCR + const getFCRColor = (fcr: number): string => { + if (fcr >= 75) return 'text-emerald-500'; + if (fcr >= 60) return 'text-amber-400'; + if (fcr >= 45) return 'text-orange-500'; + return 'text-red-500'; + }; + + return ( + + {/* Header */} +
+
+
+ +
+
+

Calidad de Resolucion: Efectividad

+

Relacionado con Art. 17 - Resolucion en 15 dias

+
+
+
+ + +
+
+ + {/* Lo que sabemos */} +
+

+ + LO QUE SABEMOS +

+ + {/* Metricas principales */} +
+
= 60 ? 'bg-gray-50' : 'bg-red-50' + )}> +

= 60 ? 'text-gray-900' : 'text-red-600' + )}>{avgFCRReal.toFixed(0)}%

+

FCR Real (fcr_real_flag)

+
+
+

{recontactRate7d.toFixed(0)}%

+

Tasa recontacto 7 dias

+
+
+

{repeatCallsPct.toFixed(0)}%

+

Llamadas repetidas

+
+
+ + {/* Grafico FCR por skill */} +
+

FCR POR SKILL/QUEUE

+
+ {skillFCRData.slice(0, 8).map((s, idx) => ( +
+ {s.skill} +
+ {Array.from({ length: 10 }).map((_, i) => ( + + {i < Math.round(s.fcrReal / 10) ? getFCRBarChar(s.fcrReal) : '▁'} + + ))} +
+ + {s.fcrReal.toFixed(0)}% + +
+ ))} +
+
+ FCR: + ▁ <45% + ▃ 45-65% + █ >75% +
+
+ + {/* Top skills con FCR bajo */} + {lowFCRSkills.length > 0 && ( +
+

Top skills con FCR bajo:

+
    + {lowFCRSkills.map((s, idx) => ( +
  • + {idx + 1}. + {s.skill}: {s.fcrReal.toFixed(0)}% FCR +
  • + ))} +
+
+ )} +
+ + {/* Implicacion Ley 10/2025 */} +
+

+ + IMPLICACION LEY 10/2025 +

+ +
+

+ Art. 17 requiere: "Resolucion de reclamaciones ≤15 dias" +

+ +
+

+ + LIMITACION DE DATOS +

+

+ Tu CDR solo registra interacciones individuales, NO casos multi-touch + ni tiempo total de resolucion. +

+
+ +
+

PERO SI sabemos:

+
    +
  • + + {recontactRate7d.toFixed(0)}% de casos requieren multiples contactos +
  • +
  • + + FCR {avgFCRReal.toFixed(0)}% = {recontactRate7d.toFixed(0)}% NO resuelto en primera interaccion +
  • +
  • + + Esto sugiere procesos complejos o informacion fragmentada +
  • +
+
+ +
+

Senal de alerta:

+

+ Si los clientes recontactan multiples veces por el mismo tema, es probable + que el tiempo TOTAL de resolucion supere los 15 dias requeridos por ley. +

+
+
+
+ + {/* Accion sugerida */} +
+

+ + ACCION SUGERIDA +

+ +
+
+

1. DIAGNOSTICO: Implementar sistema de casos/tickets

+
    +
  • • Registrar fecha apertura + cierre
  • +
  • • Vincular multiples interacciones al mismo caso
  • +
  • • Tipologia: consulta / reclamacion / incidencia
  • +
+
+ Inversion CRM/Ticketing + 15-25K +
+
+ +
+

2. MEJORA OPERATIVA: Aumentar FCR

+
    +
  • • Tu Dimension 3 (Efectividad) ya identifica:
  • +
  • - Root causes: info fragmentada, falta empowerment
  • +
  • - Solucion: Knowledge base + decision trees
  • +
  • • Beneficio: ↑ FCR = ↓ recontactos = ↓ tiempo total
  • +
+
+
+
+
+ ); +} + +// Seccion: Resumen de Cumplimiento +function Law10SummaryRoadmap({ + complianceResults, + data, +}: { + complianceResults: { law07: ComplianceResult; law01: ComplianceResult; law02: ComplianceResult; law09: ComplianceResult }; + data: AnalysisData; +}) { + // Resultado por defecto para requisitos sin datos + const sinDatos: ComplianceResult = { + status: 'SIN_DATOS', + score: 0, + gap: 'Requiere datos', + details: ['No se dispone de datos para evaluar este requisito'], + }; + + // Todos los requisitos de la Ley 10/2025 con descripciones + const allRequirements = [ + { + id: 'LAW-01', + name: 'Tiempo de Espera', + description: 'Tiempo maximo de espera de 3 minutos para atencion telefonica', + result: complianceResults.law01, + }, + { + id: 'LAW-02', + name: 'Resolucion Efectiva', + description: 'Resolucion en primera contacto sin transferencias innecesarias', + result: complianceResults.law02, + }, + { + id: 'LAW-03', + name: 'Acceso a Agente Humano', + description: 'Derecho a hablar con un agente humano en cualquier momento', + result: sinDatos, + }, + { + id: 'LAW-04', + name: 'Grabacion de Llamadas', + description: 'Notificacion previa de grabacion y acceso a la misma', + result: sinDatos, + }, + { + id: 'LAW-05', + name: 'Accesibilidad', + description: 'Canales accesibles para personas con discapacidad', + result: sinDatos, + }, + { + id: 'LAW-06', + name: 'Confirmacion Escrita', + description: 'Confirmacion por escrito de reclamaciones y gestiones', + result: sinDatos, + }, + { + id: 'LAW-07', + name: 'Cobertura Horaria', + description: 'Atencion 24/7 para servicios esenciales o horario ampliado', + result: complianceResults.law07, + }, + { + id: 'LAW-08', + name: 'Formacion de Agentes', + description: 'Personal cualificado y formado en atencion al cliente', + result: sinDatos, + }, + { + id: 'LAW-09', + name: 'Idiomas Cooficiales', + description: 'Atencion en catalan, euskera, gallego y valenciano', + result: complianceResults.law09, + }, + { + id: 'LAW-10', + name: 'Plazos de Resolucion', + description: 'Resolucion de reclamaciones en maximo 15 dias habiles', + result: sinDatos, + }, + { + id: 'LAW-11', + name: 'Gratuidad del Servicio', + description: 'Atencion telefonica sin coste adicional (numeros 900)', + result: sinDatos, + }, + { + id: 'LAW-12', + name: 'Trazabilidad', + description: 'Numero de referencia para seguimiento de gestiones', + result: sinDatos, + }, + ]; + + // Calcular inversion estimada basada en datos reales + const estimatedInvestment = () => { + // Base: 3% del coste anual actual o minimo 15K + const currentCost = data.economicModel?.currentAnnualCost || 0; + let base = currentCost > 0 ? Math.max(15000, currentCost * 0.03) : 15000; + + // Incrementos por gaps de compliance + if (complianceResults.law01.status === 'NO_CUMPLE') base += currentCost > 0 ? currentCost * 0.01 : 25000; + if (complianceResults.law02.status === 'NO_CUMPLE') base += currentCost > 0 ? currentCost * 0.008 : 20000; + if (complianceResults.law07.status === 'NO_CUMPLE') base += currentCost > 0 ? currentCost * 0.015 : 35000; + return Math.round(base); + }; + + return ( + +
+
+ +
+

Resumen de Cumplimiento - Todos los Requisitos

+
+ + {/* Scorecard con todos los requisitos */} +
+ + + + + + + + + + + + {allRequirements.map((req) => ( + + + + + + + + ))} + +
RequisitoDescripcionEstadoScoreGap
+ {req.id} + {req.name} + + {req.description} + +
+ + +
+
+ {req.result.status !== 'SIN_DATOS' ? ( + = 80 ? 'text-emerald-600' : + req.result.score >= 50 ? 'text-amber-600' : 'text-red-600' + )}> + {req.result.score} + + ) : ( + - + )} + {req.result.gap}
+
+ + {/* Leyenda */} +
+
+ + Cumple: Requisito satisfecho +
+
+ + Parcial: Requiere mejoras +
+
+ + No Cumple: Accion urgente +
+
+ + Sin Datos: Campos no disponibles en CSV +
+
+ + {/* Inversion Estimada */} +
+
+

Coste de no cumplimiento

+

Hasta 100K

+

Multas potenciales/infraccion

+
+
+

Inversion recomendada

+

{formatCurrency(estimatedInvestment())}

+

Basada en tu operacion

+
+
+

ROI de cumplimiento

+

+ {data.economicModel?.roi3yr ? `${Math.round(data.economicModel.roi3yr / 2)}%` : 'Alto'} +

+

Evitar sanciones + mejora CX

+
+
+
+ ); +} + +// Seccion: Resumen de Madurez de Datos +function DataMaturitySummary({ data }: { data: AnalysisData }) { + // Usar datos economicos reales cuando esten disponibles + const currentAnnualCost = data.economicModel?.currentAnnualCost || 0; + const annualSavings = data.economicModel?.annualSavings || 0; + // Datos disponibles + const availableData = [ + { name: 'Cobertura temporal 24/7', article: 'Art. 14' }, + { name: 'Distribucion geografica', article: 'Art. 15 parcial' }, + { name: 'Calidad resolucion proxy', article: 'Art. 17 indirecto' }, + ]; + + // Datos estimables + const estimableData = [ + { name: 'ASA <3min via proxy abandono', article: 'Art. 8.2', error: '±10%' }, + { name: 'Lenguas cooficiales via pais', article: 'Art. 15', error: 'sin detalle' }, + ]; + + // Datos no disponibles + const missingData = [ + { name: 'Tiempo resolucion casos', article: 'Art. 17' }, + { name: 'Cobros indebidos <5 dias', article: 'Art. 17' }, + { name: 'Transfer a supervisor', article: 'Art. 8' }, + { name: 'Info incidencias <2h', article: 'Art. 17' }, + { name: 'Auditoria ENAC', article: 'Art. 22', note: 'requiere contratacion externa' }, + ]; + + return ( + +
+
+ +
+

Resumen: Madurez de Datos para Compliance

+
+ +

Tu nivel actual de instrumentacion:

+ +
+ {/* Datos disponibles */} +
+
+ +

DATOS DISPONIBLES (3/10)

+
+
    + {availableData.map((item, idx) => ( +
  • + + {item.name} ({item.article}) +
  • + ))} +
+
+ + {/* Datos estimables */} +
+
+ +

DATOS ESTIMABLES (2/10)

+
+
    + {estimableData.map((item, idx) => ( +
  • + + {item.name} ({item.article}) - {item.error} +
  • + ))} +
+
+ + {/* Datos no disponibles */} +
+
+ +

NO DISPONIBLES (5/10)

+
+
    + {missingData.map((item, idx) => ( +
  • + + + {item.name} ({item.article}) + {item.note && - {item.note}} + +
  • + ))} +
+
+
+ + {/* Inversion sugerida */} +
+
+ +

INVERSION SUGERIDA PARA COMPLIANCE COMPLETO

+
+ +
+ {/* Fase 1 */} +
+

Fase 1 - Instrumentacion (Q1 2026)

+
    +
  • + • Tracking ASA real + 5-8K +
  • +
  • + • Sistema ticketing/casos + 15-25K +
  • +
  • + • Enriquecimiento lenguas + 2K +
  • +
  • + Subtotal: + 22-35K +
  • +
+
+ + {/* Fase 2 */} +
+

Fase 2 - Operaciones (Q2-Q3 2026)

+
    +
  • + • Cobertura 24/7 (chatbot + on-call) + 65K/año +
  • +
  • + • Copilot IA (reducir AHT) + 35K + 8K/mes +
  • +
  • + • Auditor ENAC + 12-18K/año +
  • +
  • + Subtotal año 1: + 112-118K +
  • +
+
+
+ + {/* Totales - usar datos reales cuando disponibles */} +
+
+

Inversion Total

+

+ {currentAnnualCost > 0 ? formatCurrency(Math.round(currentAnnualCost * 0.05)) : '134-153K'} +

+

~5% coste anual

+
+
+

Riesgo Evitado

+

+ {currentAnnualCost > 0 ? formatCurrency(Math.min(1000000, currentAnnualCost * 0.3)) : '750K-1M'} +

+

sanciones potenciales

+
+
+

ROI Compliance

+

+ {data.economicModel?.roi3yr ? `${data.economicModel.roi3yr}%` : '490-650%'} +

+
+
+
+
+ ); +} + +// Seccion: Cuestionario de Validacion +function ValidationQuestionnaire() { + return ( + +
+
+ +
+
+

Cuestionario de Validacion

+

Completar manualmente para diagnostico completo

+
+
+ +
+

+ Los siguientes datos NO pueden extraerse de tu CDR actual. + Por favor completa para el diagnostico completo: +

+
+ +
+ {/* Pregunta 1 - Horario */} +
+

1. Horario operativo actual:

+
+
+ +
+ + a + +
+
+
+ +
+ + a + +
+
+
+ +
+ + a + +
+
+
+
+ + {/* Pregunta 2 - Lenguas */} +
+

2. ¿Ofreceis atencion en lenguas cooficiales?

+
+ + + + + +
+
+ + {/* Pregunta 3 - IVR */} +
+

3. ¿El IVR ofrece siempre opcion de agente humano?

+
+ + + +
+
+ + {/* Pregunta 4 - Reclamaciones */} +
+

4. ¿Gestionais reclamaciones en sistema separado del CDR?

+
+ + +
+
+ + {/* Pregunta 5 - ENAC */} +
+

5. ¿Teneis contratado auditor ENAC?

+
+ + + +
+
+
+ + {/* Nota */} +
+

+ Nota: Este cuestionario es informativo. Para habilitar la edicion y guardado, + contacta con tu administrador de Beyond CX Analytics. +

+
+
+ ); +} + +// Seccion: Conexiones con otras dimensiones +interface DimensionConnectionsProps { + onTabChange?: (tab: string) => void; +} + +function DimensionConnections({ onTabChange }: DimensionConnectionsProps) { + const connections = [ + { + dimension: 'Dimension 2 (Eficiencia)', + impact: 'AHT elevado impacta ASA', + description: 'Reducir AHT mejora capacidad y reduce colas', + tab: 'dimensions', + color: 'purple', + }, + { + dimension: 'Dimension 3 (Efectividad)', + impact: 'FCR bajo sugiere >15 dias', + description: 'Mejorar FCR reduce tiempo total de resolucion', + tab: 'dimensions', + color: 'emerald', + }, + { + dimension: 'Agentic Readiness', + impact: 'Automatizacion cubre gaps 24/7', + description: 'Chatbots e IA pueden cubrir cobertura nocturna', + tab: 'readiness', + color: 'blue', + }, + ]; + + return ( + +
+
+ +
+
+

Conexiones con Otras Dimensiones

+

Este analisis se integra con tu diagnostico operacional

+
+
+ +
+ {connections.map((conn, idx) => ( +
onTabChange?.(conn.tab)} + > +
+
+

{conn.dimension}

+

{conn.impact}

+

{conn.description}

+
+ +
+
+ ))} +
+
+ ); +} + +// ============================================ +// COMPONENTE PRINCIPAL +// ============================================ + +interface Law10TabPropsExtended extends Law10TabProps { + onTabChange?: (tab: string) => void; +} + +export function Law10Tab({ data, onTabChange }: Law10TabPropsExtended) { + // Evaluar compliance para cada requisito + const complianceResults = { + law07: evaluateLaw07Compliance(data), + law01: evaluateLaw01Compliance(data), + law02: evaluateLaw02Compliance(data), + law09: evaluateLaw09Compliance(data), + }; + + return ( +
+ {/* Header con Countdown */} + + + {/* Secciones de Analisis - Formato horizontal sin columnas */} +
+ {/* LAW-01: Velocidad de Respuesta */} + + + {/* LAW-02: Calidad de Resolucion */} + + + {/* LAW-07: Cobertura Horaria */} + +
+ + {/* Resumen de Cumplimiento */} + + + {/* Madurez de Datos para Compliance */} + + + {/* Cuestionario de Validacion */} + + + {/* Conexiones con Otras Dimensiones */} + +
+ ); +} + +export default Law10Tab; diff --git a/frontend/components/tabs/RoadmapTab.tsx b/frontend/components/tabs/RoadmapTab.tsx index dfdb7ee..75cf5ca 100644 --- a/frontend/components/tabs/RoadmapTab.tsx +++ b/frontend/components/tabs/RoadmapTab.tsx @@ -24,6 +24,8 @@ import { formatNumber, formatPercent, } from '../../config/designSystem'; +import OpportunityMatrixPro from '../OpportunityMatrixPro'; +import OpportunityPrioritizer from '../OpportunityPrioritizer'; interface RoadmapTabProps { data: AnalysisData; @@ -372,12 +374,6 @@ const formatROI = (roi: number, roiAjustado: number): { 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 ========== // Ejes actualizados: // - X: FACTIBILIDAD = Score Agentic Readiness (0-10) @@ -415,24 +411,31 @@ const CPI_CONFIG = { 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 { if (volume === 0) return 0; 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) { case 'AUTOMATE': - // Ahorro = Vol × 12 × 70% × (CPI_humano - CPI_bot) - return Math.round(volume * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT)); + // Ahorro = VolAnual × 70% × (CPI_humano - CPI_bot) + return Math.round(annualVolume * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT)); case 'ASSIST': - // Ahorro = Vol × 12 × 30% × (CPI_humano - CPI_assist) - return Math.round(volume * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST)); + // Ahorro = VolAnual × 30% × (CPI_humano - CPI_assist) + return Math.round(annualVolume * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST)); case 'AUGMENT': - // Ahorro = Vol × 12 × 15% × (CPI_humano - CPI_augment) - return Math.round(volume * 12 * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT)); + // Ahorro = VolAnual × 15% × (CPI_humano - CPI_augment) + return Math.round(annualVolume * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT)); case 'HUMAN-ONLY': default: @@ -1736,12 +1739,13 @@ export function RoadmapTab({ data }: RoadmapTabProps) { const totalVolume = Object.values(tierVolumes).reduce((a, b) => a + b, 0) || 1; // 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 potentialSavings = { - AUTOMATE: Math.round(tierVolumes.AUTOMATE * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT)), - ASSIST: Math.round(tierVolumes.ASSIST * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST)), - AUGMENT: Math.round(tierVolumes.AUGMENT * 12 * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT)) + AUTOMATE: Math.round((tierVolumes.AUTOMATE / DATA_PERIOD_MONTHS) * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT)), + ASSIST: Math.round((tierVolumes.ASSIST / DATA_PERIOD_MONTHS) * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST)), + AUGMENT: Math.round((tierVolumes.AUGMENT / DATA_PERIOD_MONTHS) * 12 * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT)) }; // Colas que necesitan Wave 1 (Tier 3 + 4) @@ -1797,7 +1801,7 @@ export function RoadmapTab({ data }: RoadmapTabProps) { borderColor: 'border-amber-200', inversionSetup: 35000, costoRecurrenteAnual: 40000, - ahorroAnual: potentialSavings.AUGMENT || 58000, // 15% efficiency + ahorroAnual: potentialSavings.AUGMENT, // 15% efficiency - calculado desde datos reales esCondicional: true, 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.`, @@ -1830,7 +1834,7 @@ export function RoadmapTab({ data }: RoadmapTabProps) { borderColor: 'border-blue-200', inversionSetup: 70000, costoRecurrenteAnual: 78000, - ahorroAnual: potentialSavings.ASSIST || 145000, // 30% efficiency + ahorroAnual: potentialSavings.ASSIST, // 30% efficiency - calculado desde datos reales esCondicional: true, 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.`, @@ -1864,7 +1868,7 @@ export function RoadmapTab({ data }: RoadmapTabProps) { borderColor: 'border-emerald-200', inversionSetup: 85000, costoRecurrenteAnual: 108000, - ahorroAnual: potentialSavings.AUTOMATE || 380000, // 70% containment + ahorroAnual: potentialSavings.AUTOMATE, // 70% containment - calculado desde datos reales esCondicional: true, 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.`, @@ -1906,9 +1910,10 @@ export function RoadmapTab({ data }: RoadmapTabProps) { const wave4Setup = 85000; const wave4Rec = 108000; - const wave2Savings = potentialSavings.AUGMENT || Math.round(tierVolumes.AUGMENT * 12 * 0.15 * 0.33); - const wave3Savings = potentialSavings.ASSIST || Math.round(tierVolumes.ASSIST * 12 * 0.30 * 0.83); - const wave4Savings = potentialSavings.AUTOMATE || Math.round(tierVolumes.AUTOMATE * 12 * 0.70 * 2.18); + // Usar potentialSavings (ya corregidos con factor 12/11) + const wave2Savings = potentialSavings.AUGMENT; + const wave3Savings = potentialSavings.ASSIST; + const wave4Savings = potentialSavings.AUTOMATE; // Escenario 1: Conservador (Wave 1-2: FOUNDATION + AUGMENT) const consInversion = wave1Setup + wave2Setup; @@ -2520,85 +2525,17 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
- {/* ENFOQUE DUAL: Explicación + Tabla comparativa */} + {/* ENFOQUE DUAL: Párrafo explicativo */} {recType === 'DUAL' && ( - <> - {/* Explicación de los dos tracks */} -
-
-

Track A: Quick Win

-

- Automatización inmediata de las colas ya preparadas (Tier AUTOMATE). - Genera retorno desde el primer mes y valida el modelo de IA con bajo riesgo. -

-
-
-

Track B: Foundation

-

- 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. -

-
-
- - {/* Tabla comparativa */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Quick WinFoundation
Alcance - {pilotQueues.length} colas - {pilotVolume.toLocaleString()} int/mes - - {tierCounts['HUMAN-ONLY'].length + tierCounts.AUGMENT.length} colas - Wave 1 + Wave 2 -
Inversión{formatCurrency(pilotInversionTotal)}{formatCurrency(wave1Setup + wave2Setup)}
Retorno - {formatCurrency(pilotAhorroAjustado)}/año - directo (ajustado 50%) - - {formatCurrency(potentialSavings.ASSIST + potentialSavings.AUGMENT)}/año - habilitado (indirecto) -
Timeline2-3 meses6-9 meses
ROI Year 1 - {pilotROIDisplay.display} - No aplica (habilitador)
- -
- ¿Por qué dos tracks? 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. -
- +

+ La Estrategia Dual consiste en ejecutar dos líneas de trabajo en paralelo: + Quick Win automatiza inmediatamente las {pilotQueues.length} colas + ya preparadas (Tier AUTOMATE, {Math.round(totalVolume > 0 ? (tierVolumes.AUTOMATE / totalVolume) * 100 : 0)}% del volumen), generando retorno desde el primer mes; + mientras que Foundation prepara el {Math.round(assistPct + augmentPct)}% + restante del volumen (Tiers ASSIST y AUGMENT) estandarizando procesos y reduciendo variabilidad para habilitar + automatización futura. Este enfoque maximiza el time-to-value: Quick Win financia la transformación y genera + confianza organizacional, mientras Foundation amplía progresivamente el alcance de la automatización. +

)} {/* FOUNDATION PRIMERO */} @@ -2765,6 +2702,16 @@ export function RoadmapTab({ data }: RoadmapTabProps) { )} + {/* ═══════════════════════════════════════════════════════════════════════════ + OPORTUNIDADES PRIORIZADAS - Nueva visualización clara y accionable + ═══════════════════════════════════════════════════════════════════════════ */} + {data.opportunities && data.opportunities.length > 0 && ( + + )} +
); } diff --git a/frontend/types.ts b/frontend/types.ts index 26f7bec..007d74a 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -96,7 +96,8 @@ export interface OriginalQueueMetrics { aht_mean: number; // AHT promedio (segundos) cv_aht: number; // CV AHT calculado solo sobre VALID (%) transfer_rate: number; // Tasa de transferencia (%) - fcr_rate: number; // FCR (%) + 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) scoreBreakdown?: AgenticScoreBreakdown; // v3.4: Desglose por factores tier: AgenticTier; // v3.4: Clasificación para roadmap @@ -115,7 +116,8 @@ export interface DrilldownDataPoint { aht_mean: number; // AHT promedio ponderado (segundos) cv_aht: number; // CV AHT promedio ponderado (%) transfer_rate: number; // Tasa de transferencia ponderada (%) - fcr_rate: number; // FCR ponderado (%) + 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) isPriorityCandidate: boolean; // Al menos una cola con CV < 75% annualCost?: number; // Coste anual total del grupo @@ -128,7 +130,9 @@ export interface SkillMetrics { channel: string; // Canal predominante // 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 avg_talk_time: number; // Promedio duration_talk avg_hold_time: number; // Promedio hold_time @@ -205,16 +209,21 @@ export interface HeatmapDataPoint { skill: string; segment?: CustomerSegment; // Segmento de cliente (high/medium/low) 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: { - 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 csat: number; // Customer Satisfaction score (0-100) - MANUAL (estático) hold_time: number; // Hold Time promedio (segundos) - CALCULADO transfer_rate: number; // % transferencias - CALCULADO abandonment_rate: number; // % abandonos - CALCULADO }; - annual_cost?: number; // Coste anual en euros (calculado con cost_per_hour) + 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 variability: { diff --git a/frontend/utils/analysisGenerator.ts b/frontend/utils/analysisGenerator.ts index 127d0d3..0fcdb36 100644 --- a/frontend/utils/analysisGenerator.ts +++ b/frontend/utils/analysisGenerator.ts @@ -1,6 +1,6 @@ // analysisGenerator.ts - v2.0 con 6 dimensiones import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment, 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 { BarChartHorizontal, Zap, Target, Brain, Bot } from 'lucide-react'; import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2'; @@ -9,7 +9,7 @@ import { mapBackendResultsToAnalysisData, buildHeatmapFromBackend, } 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 fcr_approx = 100 - transfer_rate; // FCR aproximado - // Coste anual - const annual_volume = volume * 12; - const annual_cost = Math.round(annual_volume * aht_mean * COST_PER_SECOND); + // Coste del período (mensual) - con factor de productividad 70% + const effectiveProductivity = 0.70; + 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 === @@ -597,6 +600,7 @@ const generateHeatmapData = ( skill, segment, volume, + cost_volume: volume, // En datos sintéticos, asumimos que todos son non-abandon aht_seconds: aht_mean, // Renombrado para compatibilidad metrics: { 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))) }, annual_cost, + cpi, variability: { cv_aht: Math.round(cv_aht * 100), // Convertir a porcentaje 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 const generateEconomicModelData = (): EconomicModelData => { 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); // 0–100 - // 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 (0–100) - const readiness = heat.automation_readiness ?? 0; - const feasibilityRaw = (readiness / 100) * 7 + 3; // 3–10 - 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 const generateBenchmarkData = (): BenchmarkDataPoint[] => { const userAHT = randomInt(380, 450); @@ -929,27 +794,41 @@ export const generateAnalysis = async ( // Añadir dateRange extraído del archivo mapped.dateRange = dateRange; - // Heatmap: primero lo construimos a partir de datos reales del backend - mapped.heatmapData = buildHeatmapFromBackend( - raw, - costPerHour, - avgCsat, - segmentMapping - ); + // Heatmap: usar cálculos del frontend (parsedInteractions) para consistencia + // Esto asegura que dashboard muestre los mismos valores que los logs de realDataAnalysis + if (parsedInteractions && parsedInteractions.length > 0) { + const skillMetrics = calculateSkillMetrics(parsedInteractions, costPerHour); + mapped.heatmapData = generateHeatmapFromMetrics(skillMetrics, avgCsat, 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) if (parsedInteractions && parsedInteractions.length > 0) { mapped.drilldownData = calculateDrilldownMetrics(parsedInteractions, costPerHour); console.log(`📊 Drill-down calculado: ${mapped.drilldownData.length} skills, ${mapped.drilldownData.filter(d => d.isPriorityCandidate).length} candidatos prioritarios`); - // Cachear drilldownData en el servidor para uso futuro (no bloquea) + // 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) { - saveDrilldownToServerCache(authHeaderOverride, mapped.drilldownData) - .then(success => { - if (success) console.log('💾 DrilldownData cacheado en servidor'); - else console.warn('⚠️ No se pudo cachear drilldownData'); - }) - .catch(err => console.warn('⚠️ Error cacheando drilldownData:', err)); + try { + const cacheSuccess = await saveDrilldownToServerCache(authHeaderOverride, mapped.drilldownData); + if (cacheSuccess) { + console.log('💾 DrilldownData cacheado en servidor correctamente'); + } else { + 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) @@ -957,13 +836,11 @@ export const generateAnalysis = async ( mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour); console.log(`📊 Opportunities: ${mapped.opportunities.length}, Roadmap: ${mapped.roadmap.length}`); } else { - console.warn('⚠️ No hay interacciones parseadas, usando heatmap para opportunities'); - // Fallback: usar heatmap (menos preciso) - mapped.opportunities = generateOpportunitiesFromHeatmap( - mapped.heatmapData, - mapped.economicModel - ); - mapped.roadmap = generateRoadmapData(); + console.warn('⚠️ No hay interacciones parseadas, usando heatmap para drilldown'); + // v4.3: Generar drilldownData desde heatmap para usar mismas funciones + mapped.drilldownData = generateDrilldownFromHeatmap(mapped.heatmapData, costPerHour); + mapped.opportunities = generateOpportunitiesFromDrilldown(mapped.drilldownData, costPerHour); + mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour); } // Findings y recommendations @@ -1162,16 +1039,62 @@ export const generateAnalysisFromCache = async ( mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour); console.log(`📊 Opportunities: ${mapped.opportunities.length}, Roadmap: ${mapped.roadmap.length}`); } else if (mapped.heatmapData && mapped.heatmapData.length > 0) { - // Fallback: usar heatmap (solo 9 skills agregados) - console.warn('⚠️ Sin drilldownData cacheado, usando heatmap fallback'); - mapped.drilldownData = generateDrilldownFromHeatmap(mapped.heatmapData, costPerHour); - console.log(`📊 Drill-down desde heatmap (fallback): ${mapped.drilldownData.length} skills`); + // v4.5: No hay drilldownData cacheado - intentar calcularlo desde el CSV cacheado + console.log('⚠️ No cached drilldownData found, attempting to calculate from cached CSV...'); - mapped.opportunities = generateOpportunitiesFromHeatmap( - mapped.heatmapData, - mapped.economicModel - ); - mapped.roadmap = generateRoadmapData(); + let calculatedDrilldown = false; + + try { + // Descargar y parsear el CSV cacheado para calcular drilldown real + 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 @@ -1201,15 +1124,21 @@ function generateDrilldownFromHeatmap( const cvAht = hp.variability?.cv_aht || 0; const transferRate = hp.variability?.transfer_rate || hp.metrics?.transfer_rate || 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 ? (hp.dimensions.predictability * 0.4 + hp.dimensions.complexity_inverse * 0.35 + hp.dimensions.repetitivity * 0.25) : (hp.automation_readiness || 0) / 10; - // Determinar tier basado en el score - let tier: AgenticTier = 'HUMAN-ONLY'; - if (agenticScore >= 7.5) tier = 'AUTOMATE'; - else if (agenticScore >= 5.5) tier = 'ASSIST'; - else if (agenticScore >= 3.5) tier = 'AUGMENT'; + // v4.4: Usar clasificarTierSimple con TODOS los datos disponibles del heatmap + // cvAht, transferRate y fcrRate están en % (ej: 75), clasificarTierSimple espera decimal (ej: 0.75) + const tier = clasificarTierSimple( + agenticScore, + 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 { skill: hp.skill, @@ -1219,6 +1148,7 @@ function generateDrilldownFromHeatmap( cv_aht: cvAht, transfer_rate: transferRate, fcr_rate: fcrRate, + fcr_tecnico: fcrTecnico, // FCR Técnico para consistencia con Summary agenticScore: agenticScore, isPriorityCandidate: cvAht < 75, originalQueues: [{ @@ -1229,6 +1159,7 @@ function generateDrilldownFromHeatmap( cv_aht: cvAht, transfer_rate: transferRate, fcr_rate: fcrRate, + fcr_tecnico: fcrTecnico, // FCR Técnico para consistencia con Summary agenticScore: agenticScore, tier: tier, isPriorityCandidate: cvAht < 75, @@ -1333,21 +1264,26 @@ const generateSyntheticAnalysis = ( hasNaN: heatmapData.some(item => Object.values(item.metrics).some(v => isNaN(v)) ) - }); + }); + + // v4.3: Generar drilldownData desde heatmap para usar mismas funciones + const drilldownData = generateDrilldownFromHeatmap(heatmapData, costPerHour); + return { tier, overallHealthScore, summaryKpis, dimensions, heatmapData, + drilldownData, agenticReadiness, findings: generateFindingsFromTemplates(), recommendations: generateRecommendationsFromTemplates(), - opportunities: generateOpportunityMatrixData(), + opportunities: generateOpportunitiesFromDrilldown(drilldownData, costPerHour), economicModel: generateEconomicModelData(), - roadmap: generateRoadmapData(), + roadmap: generateRoadmapFromDrilldown(drilldownData, costPerHour), benchmarkData: generateBenchmarkData(), - source: 'synthetic', + source: 'synthetic', }; }; diff --git a/frontend/utils/backendMapper.ts b/frontend/utils/backendMapper.ts index d4d23b1..7bc5a6a 100644 --- a/frontend/utils/backendMapper.ts +++ b/frontend/utils/backendMapper.ts @@ -7,6 +7,8 @@ import type { DimensionAnalysis, Kpi, EconomicModelData, + Finding, + Recommendation, } from '../types'; import type { BackendRawResults } from './apiClient'; 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 minHourly = validHourly.length > 0 ? Math.min(...validHourly) : 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: // - % 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 += variabilityInsight; + // KPI principal: AHT P50 (industry standard for operational efficiency) const kpi: Kpi = { - label: 'Ratio P90/P50 Global', - value: ratioGlobal.toFixed(2), - change: `Horario laboral: ${ratioBusinessHours.toFixed(2)}`, - changeType: ratioGlobal > 2.5 ? 'negative' : ratioGlobal > 1.8 ? 'neutral' : 'positive' + label: 'AHT P50', + value: `${Math.round(ahtP50)}s`, + change: `Ratio: ${ratioGlobal.toFixed(2)}`, + changeType: ahtP50 > 360 ? 'negative' : ahtP50 > 300 ? 'neutral' : 'positive' }; const dimension: DimensionAnalysis = { @@ -427,7 +431,7 @@ function buildOperationalEfficiencyDimension( return dimension; } -// ==== Efectividad & Resolución (v3.2 - enfocada en FCR y recontactos) ==== +// ==== Efectividad & Resolución (v3.2 - enfocada en FCR Técnico) ==== function buildEffectivenessResolutionDimension( raw: BackendRawResults @@ -435,31 +439,29 @@ function buildEffectivenessResolutionDimension( const op = raw?.operational_performance; if (!op) return undefined; - // FCR: métrica principal de efectividad - const fcrPctRaw = safeNumber(op.fcr_rate, NaN); - const recurrenceRaw = safeNumber(op.recurrence_rate_7d, NaN); + // FCR Técnico = 100 - transfer_rate (comparable con benchmarks de industria) + // Usamos escalation_rate que es la tasa de transferencias + const escalationRate = safeNumber(op.escalation_rate, NaN); const abandonmentRate = safeNumber(op.abandonment_rate, 0); - // FCR real o proxy desde recontactos - const fcrRate = Number.isFinite(fcrPctRaw) && fcrPctRaw >= 0 - ? Math.max(0, Math.min(100, fcrPctRaw)) - : Number.isFinite(recurrenceRaw) - ? Math.max(0, Math.min(100, 100 - recurrenceRaw)) - : 70; // valor por defecto benchmark aéreo + // FCR Técnico: 100 - tasa de transferencia + const fcrRate = Number.isFinite(escalationRate) && escalationRate >= 0 + ? Math.max(0, Math.min(100, 100 - escalationRate)) + : 70; // valor por defecto benchmark aéreo - // Recontactos a 7 días (complemento del FCR) - const recontactRate = 100 - fcrRate; + // Tasa de transferencia (complemento del FCR Técnico) + const transferRate = Number.isFinite(escalationRate) ? escalationRate : 100 - fcrRate; - // Score basado principalmente en FCR (benchmark sector aéreo: 68-72%) - // FCR >= 75% = 100pts, 70-75% = 80pts, 65-70% = 60pts, 60-65% = 40pts, <60% = 20pts + // Score basado en FCR Técnico (benchmark sector aéreo: 85-90%) + // FCR >= 90% = 100pts, 85-90% = 80pts, 80-85% = 60pts, 75-80% = 40pts, <75% = 20pts let score: number; - if (fcrRate >= 75) { + if (fcrRate >= 90) { score = 100; - } else if (fcrRate >= 70) { + } else if (fcrRate >= 85) { score = 80; - } else if (fcrRate >= 65) { + } else if (fcrRate >= 80) { score = 60; - } else if (fcrRate >= 60) { + } else if (fcrRate >= 75) { score = 40; } else { score = 20; @@ -470,23 +472,23 @@ function buildEffectivenessResolutionDimension( score = Math.max(0, score - Math.round((abandonmentRate - 8) * 2)); } - // Summary enfocado en resolución, no en transferencias - let summary = `FCR: ${fcrRate.toFixed(1)}% (benchmark sector aéreo: 68-72%). `; - summary += `Recontactos a 7 días: ${recontactRate.toFixed(1)}%. `; + // Summary enfocado en FCR Técnico + let summary = `FCR Técnico: ${fcrRate.toFixed(1)}% (benchmark: 85-90%). `; + summary += `Tasa de transferencia: ${transferRate.toFixed(1)}%. `; - if (fcrRate >= 72) { - summary += 'Resolución por encima del benchmark del sector.'; - } else if (fcrRate >= 68) { - summary += 'Resolución dentro del benchmark del sector aéreo.'; + if (fcrRate >= 90) { + summary += 'Excelente resolución en primer contacto.'; + } else if (fcrRate >= 85) { + summary += 'Resolución dentro del benchmark del sector.'; } else { - summary += 'Resolución por debajo del benchmark. Oportunidad de mejora en first contact resolution.'; + summary += 'Oportunidad de mejora reduciendo transferencias.'; } const kpi: Kpi = { - label: 'FCR', + label: 'FCR Técnico', value: `${fcrRate.toFixed(0)}%`, - change: `Recontactos: ${recontactRate.toFixed(0)}%`, - changeType: fcrRate >= 70 ? 'positive' : fcrRate >= 65 ? 'neutral' : 'negative' + change: `Transfer: ${transferRate.toFixed(0)}%`, + changeType: fcrRate >= 85 ? 'positive' : fcrRate >= 80 ? 'neutral' : 'negative' }; const dimension: DimensionAnalysis = { @@ -503,7 +505,7 @@ function buildEffectivenessResolutionDimension( return dimension; } -// ==== Complejidad & Predictibilidad (v3.3 - basada en Hold Time) ==== +// ==== Complejidad & Predictibilidad (v3.4 - basada en CV AHT per industry standards) ==== function buildComplexityPredictabilityDimension( raw: BackendRawResults @@ -511,12 +513,19 @@ function buildComplexityPredictabilityDimension( const op = raw?.operational_performance; if (!op) return undefined; - // Métrica principal: % de interacciones con Hold Time > 60s - // Proxy de complejidad: si el agente puso en espera al cliente >60s, - // probablemente tuvo que consultar/investigar - const highHoldRate = safeNumber(op.high_hold_time_rate, NaN); + // KPI principal: CV AHT (industry standard for predictability/WFM) + // CV AHT = (P90 - P50) / P50 como proxy de coeficiente de variación + const ahtP50 = safeNumber(op.aht_distribution?.p50, 0); + 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; let avgHoldP50 = 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 - // Si hold_p50 promedio > 60s, asumimos ~40% de llamadas con hold alto - const effectiveHighHoldRate = Number.isFinite(highHoldRate) && highHoldRate >= 0 - ? highHoldRate - : avgHoldP50 > 60 ? 40 : avgHoldP50 > 30 ? 20 : 10; - - // 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) + // Score basado en CV AHT (benchmark: <75% = excelente, <100% = aceptable) + // CV <= 75% = 100pts (alta predictibilidad) + // CV 75-100% = 80pts (predictibilidad aceptable) + // CV 100-125% = 60pts (variabilidad moderada) + // CV 125-150% = 40pts (alta variabilidad) + // CV > 150% = 20pts (muy alta variabilidad) let score: number; - if (effectiveHighHoldRate < 10) { + if (cvAhtPercent <= 75) { score = 100; - } else if (effectiveHighHoldRate < 20) { + } else if (cvAhtPercent <= 100) { score = 80; - } else if (effectiveHighHoldRate < 30) { + } else if (cvAhtPercent <= 125) { score = 60; - } else if (effectiveHighHoldRate < 40) { + } else if (cvAhtPercent <= 150) { score = 40; } else { score = 20; } // 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) { - summary += 'Baja complejidad: la mayoría de casos se resuelven sin necesidad de consultar. Excelente para automatización.'; - } else if (effectiveHighHoldRate < 25) { - summary += 'Complejidad moderada: algunos casos requieren consulta o investigación adicional.'; - } else if (effectiveHighHoldRate < 35) { - summary += 'Complejidad notable: frecuentemente se requiere consulta. Considerar base de conocimiento mejorada.'; + if (cvAhtPercent <= 75) { + summary += 'Alta predictibilidad: tiempos de atención consistentes. Excelente para planificación WFM.'; + } else if (cvAhtPercent <= 100) { + summary += 'Predictibilidad aceptable: variabilidad moderada en tiempos de atención.'; + } else if (cvAhtPercent <= 125) { + summary += 'Variabilidad notable: dificulta la planificación de recursos. Considerar estandarización.'; } 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) { - 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 = { - label: 'Hold > 60s', - value: `${effectiveHighHoldRate.toFixed(0)}%`, - change: avgHoldP50 > 0 ? `Hold P50: ${Math.round(avgHoldP50)}s` : undefined, - changeType: effectiveHighHoldRate > 30 ? 'negative' : effectiveHighHoldRate > 15 ? 'neutral' : 'positive' + label: 'CV AHT', + value: `${cvAhtPercent}%`, + change: avgHoldP50 > 0 ? `Hold: ${Math.round(avgHoldP50)}s` : undefined, + changeType: cvAhtPercent > 125 ? 'negative' : cvAhtPercent > 75 ? 'neutral' : 'positive' }; const dimension: DimensionAnalysis = { id: 'complexity_predictability', name: 'complexity_predictability', - title: 'Complejidad', + title: 'Complejidad & Predictibilidad', score, percentile: undefined, summary, @@ -630,6 +634,7 @@ function buildEconomyDimension( totalInteractions: number ): DimensionAnalysis | undefined { const econ = raw?.economy_costs; + const op = raw?.operational_performance; const totalAnnual = safeNumber(econ?.cost_breakdown?.total_annual, 0); // Benchmark CPI sector contact center (Fuente: Gartner Contact Center Cost Benchmark 2024) @@ -639,8 +644,12 @@ function buildEconomyDimension( return undefined; } - // Calcular CPI - const cpi = totalAnnual / totalInteractions; + // Calcular cost_volume (non-abandoned) para consistencia con Executive Summary + 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) // CPI <= 4.00 = 100pts (excelente) @@ -1033,14 +1042,46 @@ export function mapBackendResultsToAnalysisData( const economicModel = buildEconomicModel(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 { tier: tierFromFrontend, overallHealthScore, summaryKpis: mergedKpis, dimensions, heatmapData: [], // el heatmap por skill lo seguimos generando en el front - findings: [], - recommendations: [], + findings, + recommendations, opportunities: [], roadmap: [], economicModel, @@ -1082,12 +1123,24 @@ export function buildHeatmapFromBackend( const econ = raw?.economy_costs; 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 : []; + // Crear lookup map por skill name para talk_hold_acw_p50 + const talkHoldAcwMap = new Map(); + 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); // Usar fcr_rate del backend si existe, sino calcular como 100 - escalation const fcrRateBackend = safeNumber(op?.fcr_rate, NaN); @@ -1098,6 +1151,71 @@ export function buildHeatmapFromBackend( // Usar abandonment_rate del backend si existe 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(); + + 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(); + 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 csatGlobal = Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0 @@ -1110,12 +1228,24 @@ export function buildHeatmapFromBackend( ) : 0; - const ineffBySkill = Array.isArray( + const ineffBySkillRaw = Array.isArray( 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(); + 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; if (!skillLabels.length) return []; @@ -1137,12 +1267,30 @@ export function buildHeatmapFromBackend( const skill = skillLabels[i]; const volume = safeNumber(skillVolumes[i], 0); - const talkHold = talkHoldAcwBySkill[i] || {}; - const talk_p50 = safeNumber(talkHold.talk_p50, 0); - const hold_p50 = safeNumber(talkHold.hold_p50, 0); - const acw_p50 = safeNumber(talkHold.acw_p50, 0); + // Buscar P50s por nombre de skill (no por índice) + const talkHold = talkHoldAcwMap.get(skill); + const talk_p50 = talkHold?.talk_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 const annual_volume = volume * 12; @@ -1150,9 +1298,10 @@ export function buildHeatmapFromBackend( annual_volume * aht_mean * COST_PER_SECOND ); - const ineff = ineffBySkill[i] || {}; - const aht_p50_backend = safeNumber(ineff.aht_p50, aht_mean); - const aht_p90_backend = safeNumber(ineff.aht_p90, aht_mean); + // Buscar inefficiency data por nombre de skill (no por índice) + const ineff = ineffBySkillMap.get(skill); + 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 let cv_aht = 0; @@ -1173,12 +1322,36 @@ export function buildHeatmapFromBackend( ) ); - // 2) Transfer rate POR SKILL - estimado desde CV y hold time - // Skills con mayor variabilidad (CV alto) y mayor hold time tienden a tener más transferencias - // Usamos el global como base y lo modulamos por skill - const cvFactor = Math.min(2, Math.max(0.5, 1 + (cv_aht - 0.5))); // Factor 0.5x - 2x basado en CV - const holdFactor = Math.min(1.5, Math.max(0.7, 1 + (hold_p50 - 30) / 100)); // Factor 0.7x - 1.5x basado en hold - const skillTransferRate = Math.max(2, Math.min(40, globalEscalation * cvFactor * holdFactor)); + // 2) Transfer rate POR SKILL + // PRIORIDAD 1: Usar métricas REALES del backend (metrics_by_skill) + // PRIORIDAD 2: Fallback a estimación basada en CV y hold time + + let skillTransferRate: number; + 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 const complexity_inverse_score = Math.max( @@ -1221,29 +1394,18 @@ export function buildHeatmapFromBackend( // Métricas normalizadas 0-100 para el color del heatmap const ahtMetric = normalizeAhtMetric(aht_mean); -; - const holdMetric = hold_p50 - ? Math.max( - 0, - Math.min( - 100, - Math.round( - 100 - (hold_p50 / 120) * 100 - ) - ) - ) + // Hold time metric: use hold_time_mean from backend (MEAN, not P50) + // Formula matches fresh path: 100 - (hold_time_mean / 60) * 10 + // This gives: 0s = 100, 60s = 90, 120s = 80, etc. + const skillHoldTimeMean = (realSkillMetrics && Number.isFinite(realSkillMetrics.hold_time_mean)) + ? realSkillMetrics.hold_time_mean + : hold_p50; // Fallback to P50 only if no mean available + + const holdMetric = skillHoldTimeMean > 0 + ? Math.round(Math.max(0, Math.min(100, 100 - (skillHoldTimeMean / 60) * 10))) : 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) let segment: CustomerSegment | undefined; 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({ skill, segment, volume, + cost_volume: costVolume, aht_seconds: aht_mean, + aht_total: aht_total, // AHT con TODAS las filas (solo informativo) 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, csat: csatMetric0_100, hold_time: holdMetric, - transfer_rate: transferMetric, - abandonment_rate: Math.round(abandonmentRateBackend), + transfer_rate: transferMetricFinal, + abandonment_rate: Math.round(skillAbandonmentRate), }, annual_cost, + cpi: skillCpi, // CPI real del backend (si disponible) variability: { cv_aht: Math.round(cv_aht * 100), // % cv_talk_time: 0, cv_hold_time: 0, - transfer_rate: skillTransferRate, // Transfer rate estimado por skill + transfer_rate: skillTransferRate, // Transfer rate REAL o estimado }, automation_readiness, dimensions: { diff --git a/frontend/utils/realDataAnalysis.ts b/frontend/utils/realDataAnalysis.ts index 3721fc5..048d3c8 100644 --- a/frontend/utils/realDataAnalysis.ts +++ b/frontend/utils/realDataAnalysis.ts @@ -10,11 +10,24 @@ import { classifyQueue } from './segmentClassifier'; /** * 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[] } { const hourly = new Array(24).fill(0); + // Deduplicar por interaction_id para consistencia con backend (nunique) + const seenIds = new Set(); + let duplicateCount = 0; + 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 { const date = new Date(interaction.datetime_start); 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); // Fuera de horario: 19:00-08:00 @@ -45,6 +62,12 @@ function calculateHourlyDistribution(interactions: RawInteraction[]): { hourly: } 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 }; } @@ -124,11 +147,13 @@ export function generateAnalysisFromRealData( console.log(`📅 Date range: ${dateRange?.min} to ${dateRange?.max}`); // 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 = { - valid: interactions.filter(i => !i.record_status || i.record_status === 'valid').length, - noise: interactions.filter(i => i.record_status === 'noise').length, - zombie: interactions.filter(i => i.record_status === 'zombie').length, - abandon: interactions.filter(i => i.record_status === 'abandon').length + valid: interactions.filter(i => !i.record_status || getStatus(i) === 'VALID').length, + noise: interactions.filter(i => getStatus(i) === 'NOISE').length, + zombie: interactions.filter(i => getStatus(i) === 'ZOMBIE').length, + abandon: interactions.filter(i => getStatus(i) === 'ABANDON').length }; 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 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 const totalVolumeForFCR = skillMetrics.reduce((sum, s) => sum + s.volume_valid, 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; // Coste total @@ -168,7 +193,7 @@ export function generateAnalysisFromRealData( const summaryKpis: Kpi[] = [ { label: "Interacciones Totales", value: totalInteractions.toLocaleString('es-ES') }, { 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` } ]; @@ -187,9 +212,9 @@ export function generateAnalysisFromRealData( // Agentic Readiness Score const agenticReadiness = calculateAgenticReadinessFromRealData(skillMetrics); - // Findings y Recommendations - const findings = generateFindingsFromRealData(skillMetrics, interactions); - const recommendations = generateRecommendationsFromRealData(skillMetrics); + // Findings y Recommendations (incluyendo análisis de fuera de horario) + const findings = generateFindingsFromRealData(skillMetrics, interactions, hourlyDistribution); + const recommendations = generateRecommendationsFromRealData(skillMetrics, hourlyDistribution, interactions.length); // v3.3: Drill-down por Cola + Tipificación - CALCULAR PRIMERO para usar en opportunities y roadmap const drilldownData = calculateDrilldownMetrics(interactions, costPerHour); @@ -240,13 +265,18 @@ interface SkillMetrics { skill: string; volume: number; // Total de interacciones (todas) 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; cv_aht: number; 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 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 cv_talk_time: number; // Métricas adicionales para debug @@ -255,7 +285,7 @@ interface SkillMetrics { abandon_count: number; } -function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: number): SkillMetrics[] { +export function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: number): SkillMetrics[] { // Agrupar por skill const skillGroups = new Map(); @@ -279,7 +309,9 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb const abandon_count = group.filter(i => i.is_abandoned === true).length; 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 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 transfer_rate = (transfers / volume) * 100; - // Separar por record_status para AHT - const noiseRecords = group.filter(i => i.record_status === 'noise'); - const zombieRecords = group.filter(i => i.record_status === 'zombie'); - const validRecords = group.filter(i => !i.record_status || i.record_status === 'valid'); + // FCR Técnico: 100 - transfer_rate + // Definición: (transfer_flag == FALSE) - solo sin transferencia + // Esta métrica es COMPARABLE con benchmarks de industria (COPC, Dimension Data) + // 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) 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; } + // === 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 === // Coste total con productividad efectiva del 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; } - // 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 total_cost = rawCost / effectiveProductivity; + // CPI = Coste por interacción (usando el volumen correcto) + const cpi = costVolume > 0 ? total_cost / costVolume : 0; + metrics.push({ skill, volume, volume_valid, aht_mean, + aht_total, // AHT con TODAS las filas (solo informativo) + aht_benchmark, aht_std, cv_aht, transfer_rate, fcr_rate, + fcr_tecnico, abandonment_rate, total_cost, + cost_volume: costVolume, + cpi, hold_time_mean, cv_talk_time, noise_count, @@ -375,6 +446,9 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb const avgFCRRate = totalVolume > 0 ? metrics.reduce((sum, m) => sum + m.fcr_rate * m.volume, 0) / totalVolume : 0; + const avgFCRTecnicoRate = totalVolume > 0 + ? metrics.reduce((sum, m) => sum + m.fcr_tecnico * m.volume, 0) / totalVolume + : 0; const avgTransferRate = totalVolume > 0 ? metrics.reduce((sum, m) => sum + m.transfer_rate * m.volume, 0) / totalVolume : 0; @@ -389,12 +463,13 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb console.log(''); console.log('MÉTRICAS GLOBALES (ponderadas por volumen):'); 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(''); console.log('Detalle por skill (top 5):'); 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(''); @@ -415,6 +490,62 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb 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 * @@ -627,8 +758,9 @@ export function calculateDrilldownMetrics( const volume = group.length; if (volume < 5) return null; - // Filtrar solo VALID para cálculo de CV - const validRecords = group.filter(i => !i.record_status || i.record_status === 'valid'); + // Filtrar solo VALID para cálculo de CV (normalizar a uppercase para comparación case-insensitive) + 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; if (volumeValid < 3) return null; @@ -647,10 +779,14 @@ export function calculateDrilldownMetrics( const transfer_decimal = transfers / volume; 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 fcr_decimal = fcrCount / volume; 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 const { score, breakdown } = calcularScoreCola( cv_aht_decimal, @@ -671,7 +807,9 @@ export function calculateDrilldownMetrics( 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 { original_queue_id: '', // Se asigna después @@ -681,6 +819,7 @@ export function calculateDrilldownMetrics( cv_aht: Math.round(cv_aht_percent * 10) / 10, transfer_rate: Math.round(transfer_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, scoreBreakdown: breakdown, tier, @@ -753,6 +892,7 @@ export function calculateDrilldownMetrics( 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 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 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, transfer_rate: Math.round(avgTransfer * 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, isPriorityCandidate: hasAutomateQueue, annualCost: totalCost @@ -804,7 +945,7 @@ export function calculateDrilldownMetrics( /** * PASO 3: Transformar métricas a dimensiones (0-10) */ -function generateHeatmapFromMetrics( +export function generateHeatmapFromMetrics( metrics: SkillMetrics[], avgCsat: number, 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) // 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); + // 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 csat_score = avgCsat; 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 { skill: m.skill, volume: m.volume, + cost_volume: m.cost_volume, // Volumen usado para calcular coste (non-abandon) 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: { - 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, csat: csat_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 { if (heatmapData.length === 0) return 50; - const avgFCR = heatmapData.reduce((sum, d) => sum + (d.metrics?.fcr || 0), 0) / heatmapData.length; - const avgAHT = heatmapData.reduce((sum, d) => sum + (d.metrics?.aht || 0), 0) / heatmapData.length; - 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; - - return Math.round((avgFCR + avgAHT + avgCSAT + avgVariability) / 4); + const totalVolume = heatmapData.reduce((sum, d) => sum + d.volume, 0); + if (totalVolume === 0) return 50; + + // ═══════════════════════════════════════════════════════════════ + // PASO 0: Extraer métricas ponderadas por volumen + // ═══════════════════════════════════════════════════════════════ + + // 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 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 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; // Calcular ratio P90/P50 aproximado desde CV @@ -964,20 +1242,41 @@ function generateDimensionsFromRealData( // % fuera horario >30% penaliza, ratio pico/valle >3x penaliza 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 peakVolume = Math.max(...hourlyValues, 1); - const valleyVolume = Math.min(...hourlyValues.filter(v => v > 0), 1); - const peakValleyRatio = peakVolume / valleyVolume; + const peakVolume = hourlyValues.length > 0 ? Math.max(...hourlyValues) : 0; + const valleyVolume = hourlyValues.length > 0 ? Math.min(...hourlyValues) : 1; + const peakValleyRatio = valleyVolume > 0 ? peakVolume / valleyVolume : 1; // 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; - 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 === - const costPerInteraction = totalVolume > 0 ? totalCost / totalVolume : 0; + // Penalización por fuera de horario (misma fórmula que backendMapper) + 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 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 } }, - // 2. EFICIENCIA OPERATIVA + // 2. EFICIENCIA OPERATIVA - KPI principal: AHT P50 (industry standard) { id: 'operational_efficiency', name: 'operational_efficiency', title: 'Eficiencia Operativa', score: Math.round(efficiencyScore), percentile: efficiencyPercentile, - summary: `Ratio P90/P50: ${avgRatio.toFixed(2)} (benchmark: <2.0). AHT P50: ${avgAHT}s (benchmark: 380s). Hold time: ${Math.round(avgHoldTime)}s.`, - kpi: { label: 'Ratio P90/P50', value: avgRatio.toFixed(2) }, + summary: `AHT P50: ${avgAHT}s (benchmark: 300s). Ratio P90/P50: ${avgRatio.toFixed(2)} (benchmark: <2.0). Hold time: ${Math.round(avgHoldTime)}s.`, + kpi: { label: 'AHT P50', value: `${avgAHT}s` }, icon: Zap }, - // 3. EFECTIVIDAD & RESOLUCIÓN + // 3. EFECTIVIDAD & RESOLUCIÓN (FCR Técnico = 100 - transfer_rate) { id: 'effectiveness_resolution', name: 'effectiveness_resolution', title: 'Efectividad & Resolución', - score: Math.round(avgFCR), + score: avgFCR >= 90 ? 100 : avgFCR >= 85 ? 80 : avgFCR >= 80 ? 60 : avgFCR >= 75 ? 40 : 20, percentile: fcrPercentile, - summary: `FCR: ${avgFCR.toFixed(1)}% (benchmark: 70%). Calculado como: (sin transferencia) AND (sin rellamada 7d).`, - kpi: { label: 'FCR Real', value: `${Math.round(avgFCR)}%` }, + summary: `FCR Técnico: ${avgFCR.toFixed(1)}% (benchmark: 85-90%). Transfer: ${avgTransferRate.toFixed(1)}%.`, + kpi: { label: 'FCR Técnico', value: `${Math.round(avgFCR)}%` }, 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', name: 'complexity_predictability', title: 'Complejidad & Predictibilidad', - score: Math.round(100 - avgTransferRate), // Inverso de transfer rate - percentile: avgTransferRate < 15 ? 75 : avgTransferRate < 25 ? 50 : 30, - summary: `Tasa transferencias: ${avgTransferRate.toFixed(1)}%. CV AHT: ${(avgCV * 100).toFixed(1)}%. ${avgTransferRate < 15 ? 'Baja complejidad.' : 'Alta complejidad, considerar capacitación.'}`, - kpi: { label: '% Transferencias', value: `${avgTransferRate.toFixed(1)}%` }, + score: avgCV <= 0.75 ? 100 : avgCV <= 1.0 ? 80 : avgCV <= 1.25 ? 60 : avgCV <= 1.5 ? 40 : 20, // Basado en CV AHT + percentile: avgCV <= 0.75 ? 75 : avgCV <= 1.0 ? 55 : avgCV <= 1.25 ? 40 : 25, + 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: 'CV AHT', value: `${(avgCV * 100).toFixed(0)}%` }, icon: Brain }, // 5. SATISFACCIÓN - CSAT @@ -1205,7 +1504,11 @@ function calculateAgenticReadinessFromRealData(metrics: SkillMetrics[]): Agentic /** * 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 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 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 if (avgRatio > 2.0) { findings.push({ @@ -1284,29 +1601,53 @@ function generateFindingsFromRealData(metrics: SkillMetrics[], interactions: Raw /** * 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[] = []; - + + // 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); if (highVariabilitySkills.length > 0) { recommendations.push({ priority: 'high', 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.`, impact: 'Reducción del 20-30% en AHT' }); } - + const highVolumeSkills = metrics.filter(m => m.volume > 500); if (highVolumeSkills.length > 0) { recommendations.push({ priority: 'high', 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.`, impact: 'Ahorro estimado del 40-60%' }); } - + return recommendations; } @@ -1347,12 +1688,18 @@ const CPI_CONFIG = { 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: - * - AUTOMATE: Vol × 12 × 70% × (CPI_humano - CPI_bot) - * - ASSIST: Vol × 12 × 30% × (CPI_humano - CPI_assist) - * - AUGMENT: Vol × 12 × 15% × (CPI_humano - CPI_augment) + * - AUTOMATE: (Vol/11) × 12 × 70% × (CPI_humano - CPI_bot) + * - ASSIST: (Vol/11) × 12 × 30% × (CPI_humano - CPI_assist) + * - AUGMENT: (Vol/11) × 12 × 15% × (CPI_humano - CPI_augment) * - HUMAN-ONLY: 0€ */ 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; + // Convertir volumen del período (11 meses) a volumen anual + const annualVolume = (volume / DATA_PERIOD_MONTHS) * 12; + switch (tier) { case 'AUTOMATE': - // Ahorro = Vol × 12 × 70% × (CPI_humano - CPI_bot) - return Math.round(volume * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT)); + // Ahorro = VolAnual × 70% × (CPI_humano - CPI_bot) + return Math.round(annualVolume * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT)); case 'ASSIST': - // Ahorro = Vol × 12 × 30% × (CPI_humano - CPI_assist) - return Math.round(volume * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST)); + // Ahorro = VolAnual × 30% × (CPI_humano - CPI_assist) + return Math.round(annualVolume * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST)); case 'AUGMENT': - // Ahorro = Vol × 12 × 15% × (CPI_humano - CPI_augment) - return Math.round(volume * 12 * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT)); + // Ahorro = VolAnual × 15% × (CPI_humano - CPI_augment) + return Math.round(annualVolume * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT)); case 'HUMAN-ONLY': default: @@ -1384,118 +1734,79 @@ function calculateRealisticSavings( } 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 => - skill.originalQueues.map(q => ({ - ...q, - skillName: skill.skill - })) + skill.originalQueues + .filter(q => q.tier !== 'HUMAN-ONLY') // HUMAN-ONLY no genera ahorro + .map(q => ({ + ...q, + skillName: skill.skill + })) ); - // v3.5: Clasificar colas por TIER (no por CV) - const automateQueues = allQueues.filter(q => q.tier === 'AUTOMATE'); - const assistQueues = allQueues.filter(q => q.tier === 'ASSIST'); - const augmentQueues = allQueues.filter(q => q.tier === 'AUGMENT'); - const humanQueues = allQueues.filter(q => q.tier === 'HUMAN-ONLY'); + if (allQueues.length === 0) { + console.warn('⚠️ No hay colas con potencial de ahorro para mostrar en Opportunity Matrix'); + return []; + } - // Calcular volúmenes y costes por tier - const automateVolume = automateQueues.reduce((sum, q) => sum + q.volume, 0); - const automateCost = automateQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0); - const assistVolume = assistQueues.reduce((sum, q) => sum + q.volume, 0); - 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; + // Calcular ahorro TCO por cola individual según su tier + const queuesWithSavings = allQueues.map(q => { + const savings = calculateRealisticSavings(q.volume, q.annualCost || 0, q.tier); + return { ...q, savings }; + }); - // v3.5: Calcular ahorros REALISTAS con fórmula TCO - const automateSavings = calculateRealisticSavings(automateVolume, automateCost, 'AUTOMATE'); - const assistSavings = calculateRealisticSavings(assistVolume, assistCost, 'ASSIST'); - const augmentSavings = calculateRealisticSavings(augmentVolume, augmentCost, 'AUGMENT'); + // Ordenar por ahorro descendente + queuesWithSavings.sort((a, b) => b.savings - a.savings); - // Helper para obtener top skills - const getTopSkills = (queues: typeof allQueues, limit: number = 3): string[] => { - const skillVolumes = new Map(); - queues.forEach(q => { - skillVolumes.set(q.skillName, (skillVolumes.get(q.skillName) || 0) + q.volume); - }); - return Array.from(skillVolumes.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, limit) - .map(([name]) => name); + // Calcular max savings para escalar impact a 0-10 + const maxSavings = Math.max(...queuesWithSavings.map(q => q.savings), 1); + + // Mapeo de tier a dimensionId y customer_segment + const tierToDimension: Record = { + 'AUTOMATE': 'agentic_readiness', + 'ASSIST': 'effectiveness_resolution', + 'AUGMENT': 'complexity_predictability' + }; + const tierToSegment: Record = { + '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) - if (automateQueues.length > 0) { - opportunities.push({ - id: `opp-${oppIndex++}`, - name: `Automatizar ${automateQueues.length} colas tier AUTOMATE`, - impact: Math.min(10, Math.round((automateCost / totalCost) * 10) + 3), - feasibility: 9, - savings: automateSavings, - dimensionId: 'agentic_readiness', - customer_segment: 'high' as CustomerSegment + // Feasibility: agenticScore directo (ya es 0-10) + const feasibility = Math.round(q.agenticScore * 10) / 10; + + // Nombre con prefijo de tier para claridad + const tierPrefix = q.tier === 'AUTOMATE' ? '🤖' : q.tier === 'ASSIST' ? '🤝' : '📚'; + const shortName = q.original_queue_id.length > 22 + ? `${tierPrefix} ${q.original_queue_id.substring(0, 19)}...` + : `${tierPrefix} ${q.original_queue_id}`; + + 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) - 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 - }); - } + console.log(`📊 Opportunity Matrix: Top ${opportunities.length} iniciativas por potencial económico (de ${allQueues.length} colas con ahorro)`); - // Oportunidad 3: AUGMENT (15% optimization) - 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); + return opportunities; } /** @@ -2115,10 +2426,10 @@ function generateBenchmarkFromRealData(metrics: SkillMetrics[]): BenchmarkDataPo const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / (metrics.length || 1); 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 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; // Abandono real