Compare commits

...

18 Commits

Author SHA1 Message Date
148c86563b Update backend/beyond_api/security.py 2026-01-28 15:48:29 +00:00
b488c1bff6 Update backend/beyond_api/security.py 2026-01-28 15:26:29 +00:00
sujucu70
152b5c0628 fix: Use airlines benchmark (€3.50) for CPI economic impact calculation
Changed CPI_TCO from €2.33 to €3.50 to match the airlines p50 benchmark
used in the rest of the dashboard. This ensures consistent impact
calculations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 14:09:12 +01:00
sujucu70
eb804d7fb0 fix: Consistent CPI score calculation using airlines benchmarks
Updates economy dimension score to use airlines benchmark percentiles:
- p25 (€2.20) = 100 points
- p50 (€3.50) = 80 points
- p75 (€4.50) = 60 points
- p90 (€5.50) = 40 points
- >p90 = 20 points

Applies to: backendMapper.ts, realDataAnalysis.ts, analysisGenerator.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 14:02:25 +01:00
sujucu70
c9f6db9882 fix: Use airlines CPI benchmark (€3.50) for consistency
Changes CPI_BENCHMARK from €5.00 to €3.50 to match the airlines
industry benchmark used in ExecutiveSummaryTab.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 13:53:24 +01:00
sujucu70
a48aca0a26 debug: Add CPI comparison logging in both tabs
Logs CPI values in both ExecutiveSummaryTab and DimensionAnalysisTab
to identify where the mismatch occurs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:51:08 +01:00
sujucu70
20e9d213bb debug: Add detailed CPI sync logging for cache path
Adds console logs to trace CPI calculation and sync process.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:49:14 +01:00
sujucu70
c5c88f6f21 fix: Handle both economy_cpi and economy_costs dimension IDs
- CPI sync now searches for both IDs (backend uses economy_costs,
  frontend fallback uses economy_cpi)
- DimensionAnalysisTab causal analysis recognizes both IDs
- Ensures consistency across fresh data, cached data, and fallback paths

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:44:51 +01:00
sujucu70
cbea968776 fix: Sync CPI in economy dimension with heatmapData for cached data
Ensures CPI consistency between Executive Summary and Dimensions tabs
when using cached/backend data path. After heatmapData is built,
recalculates global CPI using weighted average and updates economy
dimension KPI.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:36:55 +01:00
sujucu70
820e8b4887 fix: Centralize CPI calculation for fresh data consistency
- Calculate CPI once in main function from heatmapData
- Pass globalCPI to generateDimensionsFromRealData
- This ensures dimension.kpi.value matches ExecutiveSummaryTab's calculation
- Both now use identical formula: weighted avg of (cpi * cost_volume) / total_cost_volume

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:32:40 +01:00
sujucu70
728ba5772e fix: Unify CPI calculation between dimensions and executive summary
- realDataAnalysis.ts now uses identical CPI calculation as ExecutiveSummaryTab
- Added hasCpiField check for consistent fallback behavior
- Uses same formula: weighted average of (cpi * cost_volume) / total_cost_volume
- Falls back to totalAnnualCost / totalCostVolume when no CPI field exists

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:22:10 +01:00
sujucu70
5df79d436f fix: Consistent CPI calculations and correct benchmark data
1. DimensionAnalysisTab: Changed CPI fallback from 2.33 to 0 to match
   ExecutiveSummaryTab calculation

2. ExecutiveSummaryTab: Fixed benchmark data for inverted metrics (CPI, Abandono)
   - Values must be in ASCENDING order (p25 < p50 < p75 < p90)
   - p25 = best performers (lowest CPI/abandono)
   - p90 = worst performers (highest CPI/abandono)
   - This fixes the visual comparison and gap calculation

Before: cpi { p25: 4.50, p50: 3.50, p75: 2.80, p90: 2.20 } (DESCENDING - wrong)
After:  cpi { p25: 2.20, p50: 3.50, p75: 4.50, p90: 5.50 } (ASCENDING - correct)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:09:40 +01:00
sujucu70
0063d299c9 fix: Consistent KPI calculations across tabs
- DimensionAnalysisTab now uses h.metrics.transfer_rate instead of
  h.variability?.transfer_rate for consistency with ExecutiveSummaryTab
  and Law10Tab
- Both fields should have the same value, but using metrics.transfer_rate
  ensures consistency across all tabs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:03:50 +01:00
sujucu70
33d25871ae fix: Restore TrendingUp import used by DataMaturitySummary
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:35:04 +01:00
sujucu70
468248aaed fix: Restore FileText import used by Law10SummaryRoadmap
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:32:16 +01:00
sujucu70
b921ecf134 refactor: Remove ValidationQuestionnaire and DimensionConnections from Law10Tab
- Removed ValidationQuestionnaire section (manual input form)
- Removed DimensionConnections section (links to other tabs)
- Removed unused imports (FileText, TrendingUp)
- Removed onTabChange prop from Law10Tab component
- Updated DashboardTabs.tsx to not pass onTabChange to Law10Tab

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:24:49 +01:00
sujucu70
0f1bfd93cd feat: Add unified Dockerfile for Render deployment
- Single Dockerfile at root for full-stack deployment
- Multi-stage build: frontend (Node) + backend (Python)
- Nginx serves frontend and proxies /api to backend
- Supervisor manages both nginx and uvicorn processes
- Supports Render's PORT environment variable

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:04:06 +01:00
sujucu70
88d7e4c10d feat: Add Law 10/2025 compliance analysis tab
- Add new Law10Tab with compliance analysis for Spanish Law 10/2025
- Sections: LAW-01 (Response Speed), LAW-02 (Resolution Quality), LAW-07 (Time Coverage)
- Add Data Maturity Summary showing available/estimable/missing data
- Add Validation Questionnaire for manual data input
- Add Dimension Connections linking to other analysis tabs
- Fix KPI consistency: use correct field names (abandonment_rate, aht_seconds)
- Fix cache directory path for Windows compatibility
- Update economic calculations to use actual economicModel data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:58:26 +01:00
22 changed files with 5644 additions and 1326 deletions

103
CLAUDE.md Normal file
View File

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

144
Dockerfile Normal file
View File

@@ -0,0 +1,144 @@
# Unified Dockerfile for Render deployment
# Builds both frontend and backend, serves via nginx
# ============================================
# Stage 1: Build Frontend
# ============================================
FROM node:20-alpine AS frontend-build
WORKDIR /app/frontend
# Copy package files
COPY frontend/package*.json ./
# Install dependencies
RUN npm install
# Copy frontend source
COPY frontend/ .
# Build with API pointing to /api
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build
# ============================================
# Stage 2: Build Backend
# ============================================
FROM python:3.11-slim AS backend-build
WORKDIR /app/backend
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Copy and install Python dependencies
COPY backend/pyproject.toml ./
RUN pip install --upgrade pip && pip install .
# Copy backend code
COPY backend/ .
# ============================================
# Stage 3: Final Image with Nginx
# ============================================
FROM python:3.11-slim
# Install nginx, supervisor, and bash
RUN apt-get update && apt-get install -y --no-install-recommends \
nginx \
supervisor \
bash \
&& rm -rf /var/lib/apt/lists/*
# Copy Python packages from backend-build
COPY --from=backend-build /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=backend-build /usr/local/bin /usr/local/bin
# Copy backend code
WORKDIR /app/backend
COPY --from=backend-build /app/backend .
# Copy frontend build
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html
# Create cache directory
RUN mkdir -p /data/cache && chmod 777 /data/cache
# Nginx configuration
RUN rm /etc/nginx/sites-enabled/default
COPY <<'NGINX' /etc/nginx/conf.d/default.conf
server {
listen 80;
server_name _;
# Frontend static files
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# API proxy to backend
location /api/ {
proxy_pass http://127.0.0.1:8000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
NGINX
# Supervisor configuration
COPY <<'SUPERVISOR' /etc/supervisor/conf.d/supervisord.conf
[supervisord]
nodaemon=true
user=root
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:backend]
command=python -m uvicorn beyond_api.main:app --host 127.0.0.1 --port 8000
directory=/app/backend
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
SUPERVISOR
# Environment variables
ENV BASIC_AUTH_USERNAME=beyond
ENV BASIC_AUTH_PASSWORD=beyond2026
ENV CACHE_DIR=/data/cache
ENV PYTHONUNBUFFERED=1
# Render uses PORT environment variable (default 10000)
ENV PORT=10000
EXPOSE 10000
# Start script that configures nginx to use $PORT
COPY <<'STARTSCRIPT' /start.sh
#!/bin/bash
# Replace port 80 with $PORT in nginx config
sed -i "s/listen 80/listen $PORT/" /etc/nginx/conf.d/default.conf
# Start supervisor
exec supervisord -c /etc/supervisor/conf.d/supervisord.conf
STARTSCRIPT
RUN chmod +x /start.sh
CMD ["/start.sh"]

View File

@@ -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"

View File

@@ -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(

View File

@@ -12,6 +12,9 @@ security = HTTPBasic(auto_error=False)
BASIC_USER = os.getenv("BASIC_AUTH_USERNAME", "beyond")
BASIC_PASS = os.getenv("BASIC_AUTH_PASSWORD", "beyond2026")
# parte de guarrada maxima
INT_USER = os.getenv("INT_AUTH_USERNAME", "beyond")
INT_PASS = os.getenv("INT_AUTH_PASSWORD", "beyond2026")
def get_current_user(credentials: HTTPBasicCredentials | None = Depends(security)) -> str:
"""
@@ -28,6 +31,10 @@ def get_current_user(credentials: HTTPBasicCredentials | None = Depends(security
correct_username = secrets.compare_digest(credentials.username, BASIC_USER)
correct_password = secrets.compare_digest(credentials.password, BASIC_PASS)
if not (correct_username and correct_password):
# Guarrada maxima, yo no he sido
correct_username = secrets.compare_digest(credentials.username, INT_USER)
correct_password = secrets.compare_digest(credentials.password, INT_PASS)
if not (correct_username and correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,

View File

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

View File

@@ -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 = (

View File

@@ -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", ""])
)
else:
abandon_mask = pd.to_numeric(col, errors="coerce").fillna(0) > 0
abandoned = int(abandon_mask.sum())
abandonment_rate = float(round(abandoned / total * 100, 2))
# FCR Real (sin transferencia Y sin recontacto 7d)
fcr_real = fcr_tecnico # default to fcr_tecnico if no repeat data
if repeat_col and "transfer_flag" in group.columns:
repeat_data = group[repeat_col]
if repeat_data.dtype == "O":
repeat_mask = (
repeat_data.astype(str)
.str.strip()
.str.lower()
.isin(["true", "t", "1", "yes", "y", "si", ""])
)
else:
repeat_mask = pd.to_numeric(repeat_data, errors="coerce").fillna(0) > 0
# FCR Real: no transfer AND no repeat
fcr_real_mask = (~group["transfer_flag"]) & (~repeat_mask)
fcr_real_count = fcr_real_mask.sum()
fcr_real = float(round(fcr_real_count / total * 100, 2))
# AHT Mean (promedio de handle_time sobre registros válidos)
# Filtramos solo registros 'valid' (excluye noise/zombie) para consistencia
if "_is_valid_for_cv" in group.columns:
valid_records = group[group["_is_valid_for_cv"]]
else:
valid_records = group
if len(valid_records) > 0 and "handle_time" in valid_records.columns:
aht_mean = float(round(valid_records["handle_time"].mean(), 2))
else:
aht_mean = 0.0
# AHT Total (promedio de handle_time sobre TODOS los registros)
# Incluye NOISE, ZOMBIE, ABANDON - solo para información/comparación
if len(group) > 0 and "handle_time" in group.columns:
aht_total = float(round(group["handle_time"].mean(), 2))
else:
aht_total = 0.0
# Hold Time Mean (promedio de hold_time sobre registros válidos)
# Consistente con fresh path que usa MEAN, no P50
if len(valid_records) > 0 and "hold_time" in valid_records.columns:
hold_time_mean = float(round(valid_records["hold_time"].mean(), 2))
else:
hold_time_mean = 0.0
results.append({
"skill": str(skill),
"volume": int(total),
"transfer_rate": transfer_rate,
"abandonment_rate": abandonment_rate,
"fcr_tecnico": fcr_tecnico,
"fcr_real": fcr_real,
"aht_mean": aht_mean,
"aht_total": aht_total,
"hold_time_mean": hold_time_mean,
})
return results

View File

@@ -1,8 +1,7 @@
import { motion } from 'framer-motion';
import { 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 (
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
{/* Top row: Title and Date */}
{/* Top row: Title and Metodología Badge */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
<div className="flex items-center justify-between gap-2">
<h1 className="text-base sm:text-xl font-bold text-slate-800 truncate">{title}</h1>
<span className="text-xs sm:text-sm text-slate-500 flex-shrink-0">{formatDateMonthYear()}</span>
{onMetodologiaClick && (
<button
onClick={onMetodologiaClick}
className="inline-flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 bg-green-100 text-green-800 rounded-full text-[10px] sm:text-xs font-medium hover:bg-green-200 transition-colors cursor-pointer flex-shrink-0"
>
<ShieldCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="hidden md:inline">Metodología de Transformación de Datos aplicada</span>
<span className="md:hidden">Metodología</span>
<Info className="w-2.5 h-2.5 sm:w-3 sm:h-3 opacity-60" />
</button>
)}
</div>
</div>

View File

@@ -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 <AgenticReadinessTab data={data} onTabChange={setActiveTab} />;
case 'roadmap':
return <RoadmapTab data={data} />;
case 'law10':
return <Law10Tab data={data} />;
default:
return <ExecutiveSummaryTab data={data} />;
}
@@ -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({
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-sm text-slate-500">
<span className="hidden sm:inline">Beyond Diagnosis - Contact Center Analytics Platform</span>
<span className="sm:hidden text-xs">Beyond Diagnosis</span>
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm">
{data.tier ? data.tier.toUpperCase() : 'GOLD'} |
{data.source === 'backend' ? 'Genesys' : data.source || 'synthetic'}
</span>
<span className="hidden sm:inline text-slate-300">|</span>
{/* Badge Metodología */}
<button
onClick={() => setMetodologiaOpen(true)}
className="inline-flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 bg-green-100 text-green-800 rounded-full text-[10px] sm:text-xs font-medium hover:bg-green-200 transition-colors cursor-pointer"
>
<ShieldCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="hidden md:inline">Metodología de Transformación de Datos aplicada</span>
<span className="md:hidden">Metodología</span>
<Info className="w-2.5 h-2.5 sm:w-3 sm:h-3 opacity-60" />
</button>
</div>
<span className="text-xs sm:text-sm text-slate-400 italic">{formatDateMonthYear()}</span>
</div>
</div>
</footer>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { 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,30 +34,57 @@ 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)
const CPI_TCO = 2.33;
const CPI = totalVolume > 0 ? economicModel.currentAnnualCost / (totalVolume * 12) : CPI_TCO;
// 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 - benchmark aerolíneas p50
const CPI_TCO = 3.50; // Benchmark aerolíneas (p50) para cálculos de impacto
// Usar CPI pre-calculado de heatmapData si existe, sino calcular desde annual_cost/cost_volume
// IMPORTANTE: Mismo cálculo que ExecutiveSummaryTab para consistencia
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
: 0)
: (totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : 0);
// Calcular métricas agregadas
const avgCVAHT = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.variability?.cv_aht || 0) * h.volume, 0) / totalVolume
: 0;
const avgTransferRate = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.variability?.transfer_rate || 0) * h.volume, 0) / totalVolume
? heatmapData.reduce((sum, h) => sum + h.metrics.transfer_rate * 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 +98,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);
const skillsHighTransfer = heatmapData.filter(h => (h.variability?.transfer_rate || 0) > 20);
// 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.metrics.transfer_rate > 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.`;
}
// 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.`;
}
// 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',
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 +211,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 +228,103 @@ 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':
case 'economy_costs': // También manejar el ID del backend
// 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 +465,11 @@ function DimensionCard({
</div>
)}
{/* Análisis Causal Completo - Solo si hay datos */}
{/* Hallazgo Clave - Solo si hay datos */}
{dimension.score >= 0 && causalAnalyses.length > 0 && (
<div className="p-4 space-y-3">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Análisis Causal
Hallazgo Clave
</h4>
{causalAnalyses.map((analysis, idx) => {
const config = getSeverityConfig(analysis.severity);
@@ -395,10 +498,18 @@ function DimensionCard({
<span className="text-xs font-bold text-red-600">
{formatCurrency(analysis.economicImpact)}
</span>
<span className="text-xs text-gray-500">impacto anual estimado</span>
<span className="text-xs text-gray-500">impacto anual (coste del problema)</span>
<span className="text-xs text-gray-400">i</span>
</div>
{/* Ahorro de tiempo - da credibilidad al cálculo económico */}
{analysis.timeSavings && (
<div className="ml-6 mb-2 flex items-center gap-2">
<Clock className="w-3 h-3 text-blue-500" />
<span className="text-xs text-blue-700">{analysis.timeSavings}</span>
</div>
)}
{/* Recomendación inline */}
<div className="ml-6 p-2 bg-white rounded border border-gray-200">
<div className="flex items-start gap-2">
@@ -412,7 +523,7 @@ function DimensionCard({
</div>
)}
{/* Fallback: Hallazgos originales si no hay análisis causal - Solo si hay datos */}
{/* Fallback: Hallazgos originales si no hay hallazgo clave - Solo si hay datos */}
{dimension.score >= 0 && causalAnalyses.length === 0 && findings.length > 0 && (
<div className="p-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
@@ -445,7 +556,7 @@ function DimensionCard({
</div>
)}
{/* Recommendations Preview - Solo si no hay análisis causal y hay datos */}
{/* Recommendations Preview - Solo si no hay hallazgo clave y hay datos */}
{dimension.score >= 0 && causalAnalyses.length === 0 && recommendations.length > 0 && (
<div className="px-4 pb-4">
<div className="p-3 bg-blue-50 rounded-lg border border-blue-100">
@@ -463,6 +574,29 @@ function DimensionCard({
// ========== v3.16: COMPONENTE PRINCIPAL ==========
export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
// DEBUG: Verificar CPI en dimensión vs heatmapData
const economyDim = data.dimensions.find(d =>
d.id === 'economy_costs' || d.name === 'economy_costs' ||
d.id === 'economy_cpi' || d.name === 'economy_cpi'
);
const heatmapData = data.heatmapData;
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
const calculatedCPI = hasCpiField
? (totalCostVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
: 0)
: (totalCostVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0) / totalCostVolume
: 0);
console.log('🔍 DimensionAnalysisTab DEBUG:');
console.log(' - economyDim found:', !!economyDim, economyDim?.id || economyDim?.name);
console.log(' - economyDim.kpi.value:', economyDim?.kpi?.value);
console.log(' - calculatedCPI from heatmapData:', `${calculatedCPI.toFixed(2)}`);
console.log(' - hasCpiField:', hasCpiField);
console.log(' - MATCH:', economyDim?.kpi?.value === `${calculatedCPI.toFixed(2)}`);
// Filter out agentic_readiness (has its own tab)
const coreDimensions = data.dimensions.filter(d => d.name !== 'agentic_readiness');
@@ -473,9 +607,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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,8 @@ import {
formatNumber,
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) {
</div>
<div className="p-4 space-y-4">
{/* ENFOQUE DUAL: Explicación + Tabla comparativa */}
{/* ENFOQUE DUAL: Párrafo explicativo */}
{recType === 'DUAL' && (
<>
{/* Explicación de los dos tracks */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="p-3 bg-gray-50 rounded-lg">
<p className="font-semibold text-gray-800 mb-1">Track A: Quick Win</p>
<p className="text-xs text-gray-600">
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.
<p className="text-sm text-gray-600 leading-relaxed">
La Estrategia Dual consiste en ejecutar dos líneas de trabajo en paralelo:
<strong className="text-gray-800"> Quick Win</strong> 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 <strong className="text-gray-800">Foundation</strong> 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.
</p>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<p className="font-semibold text-gray-800 mb-1">Track B: Foundation</p>
<p className="text-xs text-gray-600">
Preparación de las colas que aún no están listas (Tier 3-4).
Estandariza procesos y reduce variabilidad para habilitar automatización futura.
</p>
</div>
</div>
{/* Tabla comparativa */}
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 font-medium text-gray-500"></th>
<th className="text-center py-2 font-medium text-gray-700">Quick Win</th>
<th className="text-center py-2 font-medium text-gray-700">Foundation</th>
</tr>
</thead>
<tbody className="text-gray-600">
<tr className="border-b border-gray-100">
<td className="py-2 text-gray-500 text-xs">Alcance</td>
<td className="py-2 text-center">
<span className="font-medium text-gray-800">{pilotQueues.length} colas</span>
<span className="text-xs text-gray-400 block">{pilotVolume.toLocaleString()} int/mes</span>
</td>
<td className="py-2 text-center">
<span className="font-medium text-gray-800">{tierCounts['HUMAN-ONLY'].length + tierCounts.AUGMENT.length} colas</span>
<span className="text-xs text-gray-400 block">Wave 1 + Wave 2</span>
</td>
</tr>
<tr className="border-b border-gray-100">
<td className="py-2 text-gray-500 text-xs">Inversión</td>
<td className="py-2 text-center font-medium text-gray-800">{formatCurrency(pilotInversionTotal)}</td>
<td className="py-2 text-center font-medium text-gray-800">{formatCurrency(wave1Setup + wave2Setup)}</td>
</tr>
<tr className="border-b border-gray-100">
<td className="py-2 text-gray-500 text-xs">Retorno</td>
<td className="py-2 text-center">
<span className="font-medium text-gray-800">{formatCurrency(pilotAhorroAjustado)}/año</span>
<span className="text-xs text-gray-400 block">directo (ajustado 50%)</span>
</td>
<td className="py-2 text-center">
<span className="font-medium text-gray-800">{formatCurrency(potentialSavings.ASSIST + potentialSavings.AUGMENT)}/año</span>
<span className="text-xs text-gray-400 block">habilitado (indirecto)</span>
</td>
</tr>
<tr className="border-b border-gray-100">
<td className="py-2 text-gray-500 text-xs">Timeline</td>
<td className="py-2 text-center text-gray-800">2-3 meses</td>
<td className="py-2 text-center text-gray-800">6-9 meses</td>
</tr>
<tr>
<td className="py-2 text-gray-500 text-xs">ROI Year 1</td>
<td className="py-2 text-center">
<span className="font-semibold text-gray-900">{pilotROIDisplay.display}</span>
</td>
<td className="py-2 text-center text-gray-500 text-xs">No aplica (habilitador)</td>
</tr>
</tbody>
</table>
<div className="text-xs text-gray-500 border-t border-gray-100 pt-3">
<strong className="text-gray-700">¿Por qué dos tracks?</strong> Quick Win genera caja y confianza desde el inicio.
Foundation prepara el {Math.round(assistPct + augmentPct)}% restante del volumen para fases posteriores.
Ejecutarlos en paralelo acelera el time-to-value total.
</div>
</>
)}
{/* FOUNDATION PRIMERO */}
@@ -2765,6 +2702,16 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
)}
</Card>
{/* ═══════════════════════════════════════════════════════════════════════════
OPORTUNIDADES PRIORIZADAS - Nueva visualización clara y accionable
═══════════════════════════════════════════════════════════════════════════ */}
{data.opportunities && data.opportunities.length > 0 && (
<OpportunityPrioritizer
opportunities={data.opportunities}
drilldownData={data.drilldownData}
/>
)}
</div>
);
}

View File

@@ -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: {

View File

@@ -1,6 +1,6 @@
// analysisGenerator.ts - v2.0 con 6 dimensiones
import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment, 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); // 0100
// Peso base: coste alto + ineficiencia alta + readiness alto
const baseWeight =
annualCost *
(1 + ineffPenalty / 100) *
(0.3 + 0.7 * (readiness / 100));
const weight = !Number.isFinite(baseWeight) || baseWeight < 0 ? 0 : baseWeight;
return { heat: h, weight };
});
const totalWeight =
scored.reduce((sum, s) => sum + s.weight, 0) || 1;
// 2) Asignamos "savings" (ahorro potencial) por skill
const opportunitiesWithSavings = scored.map((s) => {
const { heat } = s;
const annualCost = heat.annual_cost ?? 0;
// Si el backend nos da un ahorro anual total, lo distribuimos proporcionalmente
const savings =
globalSavings > 0 && totalWeight > 0
? (globalSavings * s.weight) / totalWeight
: // Si no hay dato de ahorro global, suponemos un 20% del coste anual
annualCost * 0.2;
return {
heat,
savings: Math.max(0, savings),
};
});
const maxSavings =
opportunitiesWithSavings.reduce(
(max, s) => (s.savings > max ? s.savings : max),
0
) || 1;
// 3) Construimos cada oportunidad
return opportunitiesWithSavings.map((item, index) => {
const { heat, savings } = item;
const skillName = heat.skill || `Skill ${index + 1}`;
// Impacto: relativo al mayor ahorro
const impactRaw = (savings / maxSavings) * 10;
const impact = Math.max(
3,
Math.min(10, Math.round(impactRaw))
);
// Factibilidad base: a partir del automation_readiness (0100)
const readiness = heat.automation_readiness ?? 0;
const feasibilityRaw = (readiness / 100) * 7 + 3; // 310
const feasibility = Math.max(
3,
Math.min(10, Math.round(feasibilityRaw))
);
// Dimensión a la que lo vinculamos
const dimensionId =
readiness >= 70
? 'agentic_readiness'
: readiness >= 40
? 'effectiveness_resolution'
: 'complexity_predictability';
// Segmento de cliente (high/medium/low) si lo tenemos
const customer_segment = heat.segment;
// Nombre legible que incluye el skill -> esto ayuda a
// OpportunityMatrixPro a encontrar el skill en el heatmap
const namePrefix =
readiness >= 70
? 'Automatizar '
: readiness >= 40
? 'Asistir con IA en '
: 'Optimizar procesos en ';
const idSlug = skillName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return {
id: `opp_${index + 1}_${idSlug}`,
name: `${namePrefix}${skillName}`,
impact,
feasibility,
savings: Math.round(savings),
dimensionId,
customer_segment,
};
});
};
// v2.0: Añadir percentiles múltiples
const generateBenchmarkData = (): BenchmarkDataPoint[] => {
const userAHT = randomInt(380, 450);
@@ -929,27 +794,95 @@ 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
// 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)');
}
// v4.5: SINCRONIZAR CPI de dimensión economía con heatmapData para consistencia entre tabs
// El heatmapData contiene el CPI calculado correctamente (con cost_volume ponderado)
// La dimensión economía fue calculada en mapBackendResultsToAnalysisData con otra fórmula
// Actualizamos la dimensión para que muestre el mismo valor que Executive Summary
if (mapped.heatmapData && mapped.heatmapData.length > 0) {
const heatmapData = mapped.heatmapData;
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
let globalCPI: number;
if (hasCpiField) {
// CPI real disponible: promedio ponderado por cost_volume
globalCPI = totalCostVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
: 0;
} else {
// Fallback: annual_cost / cost_volume
const totalAnnualCost = heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0);
globalCPI = totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : 0;
}
// Actualizar la dimensión de economía con el CPI calculado desde heatmap
// Buscar tanto economy_costs (backend) como economy_cpi (frontend fallback)
const economyDimIdx = mapped.dimensions.findIndex(d =>
d.id === 'economy_costs' || d.name === 'economy_costs' ||
d.id === 'economy_cpi' || d.name === 'economy_cpi'
);
if (economyDimIdx >= 0 && globalCPI > 0) {
// Usar benchmark de aerolíneas (€3.50) para consistencia con ExecutiveSummaryTab
// Percentiles: p25=2.20, p50=3.50, p75=4.50, p90=5.50
const CPI_BENCHMARK = 3.50;
const cpiDiff = globalCPI - CPI_BENCHMARK;
// Para CPI invertido: menor es mejor
const cpiStatus = cpiDiff <= 0 ? 'positive' : cpiDiff <= 0.5 ? 'neutral' : 'negative';
// Calcular score basado en percentiles aerolíneas
let newScore: number;
if (globalCPI <= 2.20) newScore = 100;
else if (globalCPI <= 3.50) newScore = 80;
else if (globalCPI <= 4.50) newScore = 60;
else if (globalCPI <= 5.50) newScore = 40;
else newScore = 20;
mapped.dimensions[economyDimIdx].score = newScore;
mapped.dimensions[economyDimIdx].kpi = {
label: 'Coste por Interacción',
value: `${globalCPI.toFixed(2)}`,
change: `vs benchmark €${CPI_BENCHMARK.toFixed(2)}`,
changeType: cpiStatus as 'positive' | 'neutral' | 'negative'
};
console.log(`💰 CPI sincronizado: €${globalCPI.toFixed(2)}, score: ${newScore}`);
}
}
// 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 +890,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
@@ -1143,6 +1074,78 @@ export const generateAnalysisFromCache = async (
);
console.log('📊 Heatmap data points:', mapped.heatmapData?.length || 0);
// v4.6: SINCRONIZAR CPI de dimensión economía con heatmapData para consistencia entre tabs
// (Mismo fix que en generateAnalysis - necesario para path de cache)
if (mapped.heatmapData && mapped.heatmapData.length > 0) {
const heatmapData = mapped.heatmapData;
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
// DEBUG: Log CPI calculation details
console.log('🔍 CPI SYNC DEBUG (cache):');
console.log(' - heatmapData length:', heatmapData.length);
console.log(' - hasCpiField:', hasCpiField);
console.log(' - totalCostVolume:', totalCostVolume);
if (hasCpiField) {
console.log(' - Sample CPIs:', heatmapData.slice(0, 3).map(h => ({ skill: h.skill, cpi: h.cpi, cost_volume: h.cost_volume })));
}
let globalCPI: number;
if (hasCpiField) {
globalCPI = totalCostVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
: 0;
} else {
const totalAnnualCost = heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0);
console.log(' - totalAnnualCost (fallback):', totalAnnualCost);
globalCPI = totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : 0;
}
console.log(' - globalCPI calculated:', globalCPI.toFixed(4));
// Buscar tanto economy_costs (backend) como economy_cpi (frontend fallback)
const dimensionIds = mapped.dimensions.map(d => ({ id: d.id, name: d.name }));
console.log(' - Available dimensions:', dimensionIds);
const economyDimIdx = mapped.dimensions.findIndex(d =>
d.id === 'economy_costs' || d.name === 'economy_costs' ||
d.id === 'economy_cpi' || d.name === 'economy_cpi'
);
console.log(' - economyDimIdx:', economyDimIdx);
if (economyDimIdx >= 0 && globalCPI > 0) {
const oldKpi = mapped.dimensions[economyDimIdx].kpi;
console.log(' - OLD KPI value:', oldKpi?.value);
// Usar benchmark de aerolíneas (€3.50) para consistencia con ExecutiveSummaryTab
// Percentiles: p25=2.20, p50=3.50, p75=4.50, p90=5.50
const CPI_BENCHMARK = 3.50;
const cpiDiff = globalCPI - CPI_BENCHMARK;
// Para CPI invertido: menor es mejor
const cpiStatus = cpiDiff <= 0 ? 'positive' : cpiDiff <= 0.5 ? 'neutral' : 'negative';
// Calcular score basado en percentiles aerolíneas
let newScore: number;
if (globalCPI <= 2.20) newScore = 100;
else if (globalCPI <= 3.50) newScore = 80;
else if (globalCPI <= 4.50) newScore = 60;
else if (globalCPI <= 5.50) newScore = 40;
else newScore = 20;
mapped.dimensions[economyDimIdx].score = newScore;
mapped.dimensions[economyDimIdx].kpi = {
label: 'Coste por Interacción',
value: `${globalCPI.toFixed(2)}`,
change: `vs benchmark €${CPI_BENCHMARK.toFixed(2)}`,
changeType: cpiStatus as 'positive' | 'neutral' | 'negative'
};
console.log(' - NEW KPI value:', mapped.dimensions[economyDimIdx].kpi.value);
console.log(' - NEW score:', newScore);
console.log(`💰 CPI sincronizado (cache): €${globalCPI.toFixed(2)}`);
} else {
console.warn('⚠️ CPI sync skipped: economyDimIdx=', economyDimIdx, 'globalCPI=', globalCPI);
}
}
// === DrilldownData: usar cacheado (rápido) o fallback a heatmap ===
if (cachedDrilldownData && cachedDrilldownData.length > 0) {
// Usar drilldownData cacheado directamente (ya calculado al subir archivo)
@@ -1162,16 +1165,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 +1250,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 +1274,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 +1285,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,
@@ -1334,18 +1391,23 @@ const generateSyntheticAnalysis = (
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',
};

View File

@@ -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))
// 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,32 +634,38 @@ 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)
const CPI_BENCHMARK = 5.00;
// Benchmark CPI aerolíneas (consistente con ExecutiveSummaryTab)
// p25: 2.20, p50: 3.50, p75: 4.50, p90: 5.50
const CPI_BENCHMARK = 3.50; // p50 aerolíneas
if (totalAnnual <= 0 || totalInteractions <= 0) {
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));
// Score basado en comparación con benchmark (€5.00)
// CPI <= 4.00 = 100pts (excelente)
// CPI 4.00-5.00 = 80pts (en benchmark)
// CPI 5.00-6.00 = 60pts (por encima)
// CPI 6.00-7.00 = 40pts (alto)
// CPI > 7.00 = 20pts (crítico)
// Calcular CPI usando cost_volume (non-abandoned) como denominador
const cpi = costVolume > 0 ? totalAnnual / costVolume : totalAnnual / totalInteractions;
// Score basado en percentiles de aerolíneas (CPI invertido: menor = mejor)
// CPI <= 2.20 (p25) = 100pts (excelente, top 25%)
// CPI 2.20-3.50 (p25-p50) = 80pts (bueno, top 50%)
// CPI 3.50-4.50 (p50-p75) = 60pts (promedio)
// CPI 4.50-5.50 (p75-p90) = 40pts (por debajo)
// CPI > 5.50 (>p90) = 20pts (crítico)
let score: number;
if (cpi <= 4.00) {
if (cpi <= 2.20) {
score = 100;
} else if (cpi <= 5.00) {
} else if (cpi <= 3.50) {
score = 80;
} else if (cpi <= 6.00) {
} else if (cpi <= 4.50) {
score = 60;
} else if (cpi <= 7.00) {
} else if (cpi <= 5.50) {
score = 40;
} else {
score = 20;
@@ -667,7 +677,7 @@ function buildEconomyDimension(
let summary = `Coste por interacción: €${cpi.toFixed(2)} vs benchmark €${CPI_BENCHMARK.toFixed(2)}. `;
if (cpi <= CPI_BENCHMARK) {
summary += 'Eficiencia de costes óptima, por debajo del benchmark del sector.';
} else if (cpi <= 6.00) {
} else if (cpi <= 4.50) {
summary += 'Coste ligeramente por encima del benchmark, oportunidad de optimización.';
} else {
summary += 'Coste elevado respecto al sector. Priorizar iniciativas de eficiencia.';
@@ -1033,14 +1043,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 +1124,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<string, { talk_p50: number; hold_p50: number; acw_p50: number }>();
for (const item of talkHoldAcwBySkillRaw) {
if (item?.queue_skill) {
talkHoldAcwMap.set(String(item.queue_skill), {
talk_p50: safeNumber(item.talk_p50, 0),
hold_p50: safeNumber(item.hold_p50, 0),
acw_p50: safeNumber(item.acw_p50, 0),
});
}
}
const globalEscalation = safeNumber(op?.escalation_rate, 0);
// Usar fcr_rate del backend si existe, sino calcular como 100 - escalation
const fcrRateBackend = safeNumber(op?.fcr_rate, NaN);
@@ -1098,6 +1152,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<string, {
transfer_rate: number;
abandonment_rate: number;
fcr_tecnico: number;
fcr_real: number;
aht_mean: number; // AHT promedio del backend (solo VALID - consistente con fresh path)
aht_total: number; // AHT total (ALL rows incluyendo NOISE/ZOMBIE/ABANDON) - solo informativo
hold_time_mean: number; // Hold time promedio (consistente con fresh path - MEAN, no P50)
}>();
for (const m of metricsBySkillRaw) {
if (m?.skill) {
metricsBySkillMap.set(String(m.skill), {
transfer_rate: safeNumber(m.transfer_rate, NaN),
abandonment_rate: safeNumber(m.abandonment_rate, NaN),
fcr_tecnico: safeNumber(m.fcr_tecnico, NaN),
fcr_real: safeNumber(m.fcr_real, NaN),
aht_mean: safeNumber(m.aht_mean, NaN), // AHT promedio (solo VALID)
aht_total: safeNumber(m.aht_total, NaN), // AHT total (ALL rows)
hold_time_mean: safeNumber(m.hold_time_mean, NaN), // Hold time promedio (MEAN)
});
}
}
const hasRealMetricsBySkill = metricsBySkillMap.size > 0;
if (hasRealMetricsBySkill) {
console.log('✅ Usando métricas REALES por skill del backend:', metricsBySkillMap.size, 'skills');
} else {
console.warn('⚠️ No hay metrics_by_skill del backend, usando estimación basada en CV/hold');
}
// ========================================================================
// NUEVO: CPI por skill desde cpi_by_skill_channel
// Esto permite que el cached path tenga CPI real como el fresh path
// ========================================================================
const cpiBySkillRaw = Array.isArray(econ?.cpi_by_skill_channel)
? econ.cpi_by_skill_channel
: [];
// Crear lookup por nombre de skill para CPI
const cpiBySkillMap = new Map<string, number>();
for (const item of cpiBySkillRaw) {
if (item?.queue_skill || item?.skill) {
const skillKey = String(item.queue_skill ?? item.skill);
const cpiValue = safeNumber(item.cpi_total ?? item.cpi, NaN);
if (Number.isFinite(cpiValue)) {
cpiBySkillMap.set(skillKey, cpiValue);
}
}
}
const hasCpiBySkill = cpiBySkillMap.size > 0;
if (hasCpiBySkill) {
console.log('✅ Usando CPI por skill del backend:', cpiBySkillMap.size, 'skills');
}
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
const csatGlobal =
Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0
@@ -1110,12 +1229,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<string, { aht_p50: number; aht_p90: number; volume: number }>();
for (const item of ineffBySkillRaw) {
if (item?.queue_skill) {
ineffBySkillMap.set(String(item.queue_skill), {
aht_p50: safeNumber(item.aht_p50, 0),
aht_p90: safeNumber(item.aht_p90, 0),
volume: safeNumber(item.volume, 0),
});
}
}
const COST_PER_SECOND = costPerHour / 3600;
if (!skillLabels.length) return [];
@@ -1137,12 +1268,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 +1299,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 +1323,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 +1395,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 +1428,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: {

File diff suppressed because it is too large Load Diff