Compare commits
5 Commits
62454c6b6a
...
33d25871ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33d25871ae | ||
|
|
468248aaed | ||
|
|
b921ecf134 | ||
|
|
0f1bfd93cd | ||
|
|
88d7e4c10d |
103
CLAUDE.md
Normal file
103
CLAUDE.md
Normal 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
144
Dockerfile
Normal 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"]
|
||||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
@@ -23,12 +24,38 @@ router = APIRouter(
|
|||||||
tags=["cache"],
|
tags=["cache"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Directory for cache files
|
# Directory for cache files - use platform-appropriate default
|
||||||
CACHE_DIR = Path(os.getenv("CACHE_DIR", "/data/cache"))
|
def _get_default_cache_dir() -> Path:
|
||||||
|
"""Get a platform-appropriate default cache directory."""
|
||||||
|
env_cache_dir = os.getenv("CACHE_DIR")
|
||||||
|
if env_cache_dir:
|
||||||
|
return Path(env_cache_dir)
|
||||||
|
|
||||||
|
# On Windows, check if C:/data/cache exists (legacy location)
|
||||||
|
# Otherwise use a local .cache directory relative to the backend
|
||||||
|
# On Unix/Docker, use /data/cache
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# Check legacy location first (for backwards compatibility)
|
||||||
|
legacy_cache = Path("C:/data/cache")
|
||||||
|
if legacy_cache.exists():
|
||||||
|
return legacy_cache
|
||||||
|
# Fallback to local .cache directory in the backend folder
|
||||||
|
backend_dir = Path(__file__).parent.parent.parent
|
||||||
|
return backend_dir / ".cache"
|
||||||
|
else:
|
||||||
|
return Path("/data/cache")
|
||||||
|
|
||||||
|
CACHE_DIR = _get_default_cache_dir()
|
||||||
CACHED_FILE = CACHE_DIR / "cached_data.csv"
|
CACHED_FILE = CACHE_DIR / "cached_data.csv"
|
||||||
METADATA_FILE = CACHE_DIR / "metadata.json"
|
METADATA_FILE = CACHE_DIR / "metadata.json"
|
||||||
DRILLDOWN_FILE = CACHE_DIR / "drilldown_data.json"
|
DRILLDOWN_FILE = CACHE_DIR / "drilldown_data.json"
|
||||||
|
|
||||||
|
# Log cache directory on module load
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info(f"[Cache] Using cache directory: {CACHE_DIR}")
|
||||||
|
logger.info(f"[Cache] Drilldown file path: {DRILLDOWN_FILE}")
|
||||||
|
|
||||||
|
|
||||||
class CacheMetadata(BaseModel):
|
class CacheMetadata(BaseModel):
|
||||||
fileName: str
|
fileName: str
|
||||||
@@ -158,7 +185,11 @@ def get_cached_drilldown(current_user: str = Depends(get_current_user)):
|
|||||||
Get the cached drilldownData JSON.
|
Get the cached drilldownData JSON.
|
||||||
Returns the pre-calculated drilldown data for fast cache usage.
|
Returns the pre-calculated drilldown data for fast cache usage.
|
||||||
"""
|
"""
|
||||||
|
logger.info(f"[Cache] GET /drilldown - checking file: {DRILLDOWN_FILE}")
|
||||||
|
logger.info(f"[Cache] File exists: {DRILLDOWN_FILE.exists()}")
|
||||||
|
|
||||||
if not DRILLDOWN_FILE.exists():
|
if not DRILLDOWN_FILE.exists():
|
||||||
|
logger.warning(f"[Cache] Drilldown file not found at: {DRILLDOWN_FILE}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="No cached drilldown data found"
|
detail="No cached drilldown data found"
|
||||||
@@ -167,8 +198,10 @@ def get_cached_drilldown(current_user: str = Depends(get_current_user)):
|
|||||||
try:
|
try:
|
||||||
with open(DRILLDOWN_FILE, "r", encoding="utf-8") as f:
|
with open(DRILLDOWN_FILE, "r", encoding="utf-8") as f:
|
||||||
drilldown_data = json.load(f)
|
drilldown_data = json.load(f)
|
||||||
|
logger.info(f"[Cache] Loaded drilldown with {len(drilldown_data)} skills")
|
||||||
return JSONResponse(content={"success": True, "drilldownData": drilldown_data})
|
return JSONResponse(content={"success": True, "drilldownData": drilldown_data})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error(f"[Cache] Error reading drilldown: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=f"Error reading drilldown data: {str(e)}"
|
detail=f"Error reading drilldown data: {str(e)}"
|
||||||
@@ -185,16 +218,21 @@ async def save_cached_drilldown(
|
|||||||
Called by frontend after calculating drilldown from uploaded file.
|
Called by frontend after calculating drilldown from uploaded file.
|
||||||
Receives JSON as form field.
|
Receives JSON as form field.
|
||||||
"""
|
"""
|
||||||
|
logger.info(f"[Cache] POST /drilldown - saving to: {DRILLDOWN_FILE}")
|
||||||
|
logger.info(f"[Cache] Cache directory: {CACHE_DIR}")
|
||||||
ensure_cache_dir()
|
ensure_cache_dir()
|
||||||
|
logger.info(f"[Cache] Cache dir exists after ensure: {CACHE_DIR.exists()}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Parse and validate JSON
|
# Parse and validate JSON
|
||||||
drilldown_data = json.loads(drilldown_json)
|
drilldown_data = json.loads(drilldown_json)
|
||||||
|
logger.info(f"[Cache] Parsed drilldown JSON with {len(drilldown_data)} skills")
|
||||||
|
|
||||||
# Save to file
|
# Save to file
|
||||||
with open(DRILLDOWN_FILE, "w", encoding="utf-8") as f:
|
with open(DRILLDOWN_FILE, "w", encoding="utf-8") as f:
|
||||||
json.dump(drilldown_data, f)
|
json.dump(drilldown_data, f)
|
||||||
|
|
||||||
|
logger.info(f"[Cache] Drilldown saved successfully, file exists: {DRILLDOWN_FILE.exists()}")
|
||||||
return JSONResponse(content={
|
return JSONResponse(content={
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Cached drilldown data with {len(drilldown_data)} skills"
|
"message": f"Cached drilldown data with {len(drilldown_data)} skills"
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ app = FastAPI()
|
|||||||
|
|
||||||
origins = [
|
origins = [
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
|
"http://localhost:3001",
|
||||||
"http://127.0.0.1:3000",
|
"http://127.0.0.1:3000",
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
]
|
]
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"metrics": [
|
"metrics": [
|
||||||
"aht_distribution",
|
"aht_distribution",
|
||||||
"talk_hold_acw_p50_by_skill",
|
"talk_hold_acw_p50_by_skill",
|
||||||
|
"metrics_by_skill",
|
||||||
"fcr_rate",
|
"fcr_rate",
|
||||||
"escalation_rate",
|
"escalation_rate",
|
||||||
"abandonment_rate",
|
"abandonment_rate",
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ class EconomyCostMetrics:
|
|||||||
+ df["wrap_up_time"].fillna(0)
|
+ df["wrap_up_time"].fillna(0)
|
||||||
) # segundos
|
) # segundos
|
||||||
|
|
||||||
|
# Filtrar por record_status para cálculos de AHT/CPI
|
||||||
|
# Solo incluir registros VALID (excluir NOISE, ZOMBIE, ABANDON)
|
||||||
|
if "record_status" in df.columns:
|
||||||
|
df["record_status"] = df["record_status"].astype(str).str.strip().str.upper()
|
||||||
|
df["_is_valid_for_cost"] = df["record_status"] == "VALID"
|
||||||
|
else:
|
||||||
|
# Legacy data sin record_status: incluir todo
|
||||||
|
df["_is_valid_for_cost"] = True
|
||||||
|
|
||||||
self.df = df
|
self.df = df
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -115,12 +124,19 @@ class EconomyCostMetrics:
|
|||||||
"""
|
"""
|
||||||
CPI (Coste Por Interacción) por skill/canal.
|
CPI (Coste Por Interacción) por skill/canal.
|
||||||
|
|
||||||
CPI = Labor_cost_per_interaction + Overhead_variable
|
CPI = (Labor_cost_per_interaction + Overhead_variable) / EFFECTIVE_PRODUCTIVITY
|
||||||
|
|
||||||
- Labor_cost_per_interaction = (labor_cost_per_hour * AHT_hours)
|
- Labor_cost_per_interaction = (labor_cost_per_hour * AHT_hours)
|
||||||
- Overhead_variable = overhead_rate * Labor_cost_per_interaction
|
- Overhead_variable = overhead_rate * Labor_cost_per_interaction
|
||||||
|
- EFFECTIVE_PRODUCTIVITY = 0.70 (70% - accounts for non-productive time)
|
||||||
|
|
||||||
|
Excluye registros abandonados del cálculo de costes para consistencia
|
||||||
|
con el path del frontend (fresh CSV).
|
||||||
|
|
||||||
Si no hay config de costes -> devuelve DataFrame vacío.
|
Si no hay config de costes -> devuelve DataFrame vacío.
|
||||||
|
|
||||||
|
Incluye queue_skill y channel como columnas (no solo índice) para que
|
||||||
|
el frontend pueda hacer lookup por nombre de skill.
|
||||||
"""
|
"""
|
||||||
if not self._has_cost_config():
|
if not self._has_cost_config():
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
@@ -132,8 +148,22 @@ class EconomyCostMetrics:
|
|||||||
if df.empty:
|
if df.empty:
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
||||||
# AHT por skill/canal (en segundos)
|
# Filter out abandonments for cost calculation (consistency with frontend)
|
||||||
grouped = df.groupby(["queue_skill", "channel"])["handle_time"].mean()
|
if "is_abandoned" in df.columns:
|
||||||
|
df_cost = df[df["is_abandoned"] != True]
|
||||||
|
else:
|
||||||
|
df_cost = df
|
||||||
|
|
||||||
|
# Filtrar por record_status: solo VALID para cálculo de AHT
|
||||||
|
# Excluye NOISE, ZOMBIE, ABANDON
|
||||||
|
if "_is_valid_for_cost" in df_cost.columns:
|
||||||
|
df_cost = df_cost[df_cost["_is_valid_for_cost"] == True]
|
||||||
|
|
||||||
|
if df_cost.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# AHT por skill/canal (en segundos) - solo registros VALID
|
||||||
|
grouped = df_cost.groupby(["queue_skill", "channel"])["handle_time"].mean()
|
||||||
|
|
||||||
if grouped.empty:
|
if grouped.empty:
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
@@ -141,9 +171,14 @@ class EconomyCostMetrics:
|
|||||||
aht_sec = grouped
|
aht_sec = grouped
|
||||||
aht_hours = aht_sec / 3600.0
|
aht_hours = aht_sec / 3600.0
|
||||||
|
|
||||||
|
# Apply productivity factor (70% effectiveness)
|
||||||
|
# This accounts for non-productive agent time (breaks, training, etc.)
|
||||||
|
EFFECTIVE_PRODUCTIVITY = 0.70
|
||||||
|
|
||||||
labor_cost = cfg.labor_cost_per_hour * aht_hours
|
labor_cost = cfg.labor_cost_per_hour * aht_hours
|
||||||
overhead = labor_cost * cfg.overhead_rate
|
overhead = labor_cost * cfg.overhead_rate
|
||||||
cpi = labor_cost + overhead
|
raw_cpi = labor_cost + overhead
|
||||||
|
cpi = raw_cpi / EFFECTIVE_PRODUCTIVITY
|
||||||
|
|
||||||
out = pd.DataFrame(
|
out = pd.DataFrame(
|
||||||
{
|
{
|
||||||
@@ -154,7 +189,8 @@ class EconomyCostMetrics:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return out.sort_index()
|
# Reset index to include queue_skill and channel as columns for frontend lookup
|
||||||
|
return out.sort_index().reset_index()
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# KPI 2: coste anual por skill/canal
|
# KPI 2: coste anual por skill/canal
|
||||||
@@ -180,7 +216,9 @@ class EconomyCostMetrics:
|
|||||||
.rename("volume")
|
.rename("volume")
|
||||||
)
|
)
|
||||||
|
|
||||||
joined = cpi_table.join(volume, how="left").fillna({"volume": 0})
|
# Set index on cpi_table to match volume's MultiIndex for join
|
||||||
|
cpi_indexed = cpi_table.set_index(["queue_skill", "channel"])
|
||||||
|
joined = cpi_indexed.join(volume, how="left").fillna({"volume": 0})
|
||||||
joined["annual_cost"] = (joined["cpi_total"] * joined["volume"]).round(2)
|
joined["annual_cost"] = (joined["cpi_total"] * joined["volume"]).round(2)
|
||||||
|
|
||||||
return joined
|
return joined
|
||||||
@@ -216,7 +254,9 @@ class EconomyCostMetrics:
|
|||||||
.rename("volume")
|
.rename("volume")
|
||||||
)
|
)
|
||||||
|
|
||||||
joined = cpi_table.join(volume, how="left").fillna({"volume": 0})
|
# Set index on cpi_table to match volume's MultiIndex for join
|
||||||
|
cpi_indexed = cpi_table.set_index(["queue_skill", "channel"])
|
||||||
|
joined = cpi_indexed.join(volume, how="left").fillna({"volume": 0})
|
||||||
|
|
||||||
# Costes anuales de labor y overhead
|
# Costes anuales de labor y overhead
|
||||||
annual_labor = (joined["labor_cost"] * joined["volume"]).sum()
|
annual_labor = (joined["labor_cost"] * joined["volume"]).sum()
|
||||||
@@ -252,7 +292,7 @@ class EconomyCostMetrics:
|
|||||||
- Ineff_seconds = Delta * volume * 0.4
|
- Ineff_seconds = Delta * volume * 0.4
|
||||||
- Ineff_cost = LaborCPI_per_second * Ineff_seconds
|
- Ineff_cost = LaborCPI_per_second * Ineff_seconds
|
||||||
|
|
||||||
⚠️ Es un modelo aproximado para cuantificar "orden de magnitud".
|
NOTA: Es un modelo aproximado para cuantificar "orden de magnitud".
|
||||||
"""
|
"""
|
||||||
if not self._has_cost_config():
|
if not self._has_cost_config():
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
@@ -261,6 +301,12 @@ class EconomyCostMetrics:
|
|||||||
assert cfg is not None
|
assert cfg is not None
|
||||||
|
|
||||||
df = self.df.copy()
|
df = self.df.copy()
|
||||||
|
|
||||||
|
# Filtrar por record_status: solo VALID para cálculo de AHT
|
||||||
|
# Excluye NOISE, ZOMBIE, ABANDON
|
||||||
|
if "_is_valid_for_cost" in df.columns:
|
||||||
|
df = df[df["_is_valid_for_cost"] == True]
|
||||||
|
|
||||||
grouped = df.groupby(["queue_skill", "channel"])
|
grouped = df.groupby(["queue_skill", "channel"])
|
||||||
|
|
||||||
stats = grouped["handle_time"].agg(
|
stats = grouped["handle_time"].agg(
|
||||||
@@ -273,10 +319,14 @@ class EconomyCostMetrics:
|
|||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
||||||
# CPI para obtener coste/segundo de labor
|
# CPI para obtener coste/segundo de labor
|
||||||
cpi_table = self.cpi_by_skill_channel()
|
# cpi_by_skill_channel now returns with reset_index, so we need to set index for join
|
||||||
if cpi_table.empty:
|
cpi_table_raw = self.cpi_by_skill_channel()
|
||||||
|
if cpi_table_raw.empty:
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# Set queue_skill+channel as index for the join
|
||||||
|
cpi_table = cpi_table_raw.set_index(["queue_skill", "channel"])
|
||||||
|
|
||||||
merged = stats.join(cpi_table[["labor_cost"]], how="left")
|
merged = stats.join(cpi_table[["labor_cost"]], how="left")
|
||||||
merged = merged.fillna(0.0)
|
merged = merged.fillna(0.0)
|
||||||
|
|
||||||
@@ -297,7 +347,8 @@ class EconomyCostMetrics:
|
|||||||
merged["ineff_seconds"] = ineff_seconds.round(2)
|
merged["ineff_seconds"] = ineff_seconds.round(2)
|
||||||
merged["ineff_cost"] = ineff_cost
|
merged["ineff_cost"] = ineff_cost
|
||||||
|
|
||||||
return merged[["aht_p50", "aht_p90", "volume", "ineff_seconds", "ineff_cost"]]
|
# Reset index to include queue_skill and channel as columns for frontend lookup
|
||||||
|
return merged[["aht_p50", "aht_p90", "volume", "ineff_seconds", "ineff_cost"]].reset_index()
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# KPI 5: ahorro potencial anual por automatización
|
# KPI 5: ahorro potencial anual por automatización
|
||||||
@@ -419,7 +470,9 @@ class EconomyCostMetrics:
|
|||||||
.rename("volume")
|
.rename("volume")
|
||||||
)
|
)
|
||||||
|
|
||||||
joined = cpi_table.join(volume, how="left").fillna({"volume": 0})
|
# Set index on cpi_table to match volume's MultiIndex for join
|
||||||
|
cpi_indexed = cpi_table.set_index(["queue_skill", "channel"])
|
||||||
|
joined = cpi_indexed.join(volume, how="left").fillna({"volume": 0})
|
||||||
|
|
||||||
# CPI medio ponderado por canal
|
# CPI medio ponderado por canal
|
||||||
per_channel = (
|
per_channel = (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -87,14 +87,26 @@ class OperationalPerformanceMetrics:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# v3.0: Filtrar NOISE y ZOMBIE para cálculos de variabilidad
|
# v3.0: Filtrar NOISE y ZOMBIE para cálculos de variabilidad
|
||||||
# record_status: 'valid', 'noise', 'zombie', 'abandon'
|
# record_status: 'VALID', 'NOISE', 'ZOMBIE', 'ABANDON'
|
||||||
# Para AHT/CV solo usamos 'valid' (o sin status = legacy data)
|
# Para AHT/CV solo usamos 'VALID' (excluye noise, zombie, abandon)
|
||||||
if "record_status" in df.columns:
|
if "record_status" in df.columns:
|
||||||
df["record_status"] = df["record_status"].astype(str).str.strip().str.upper()
|
df["record_status"] = df["record_status"].astype(str).str.strip().str.upper()
|
||||||
# Crear máscara para registros válidos (para cálculos de CV/variabilidad)
|
# Crear máscara para registros válidos: SOLO "VALID"
|
||||||
df["_is_valid_for_cv"] = df["record_status"].isin(["VALID", "NAN", ""]) | df["record_status"].isna()
|
# Excluye explícitamente NOISE, ZOMBIE, ABANDON y cualquier otro valor
|
||||||
|
df["_is_valid_for_cv"] = df["record_status"] == "VALID"
|
||||||
|
|
||||||
|
# Log record_status breakdown for debugging
|
||||||
|
status_counts = df["record_status"].value_counts()
|
||||||
|
valid_count = int(df["_is_valid_for_cv"].sum())
|
||||||
|
print(f"[OperationalPerformance] Record status breakdown:")
|
||||||
|
print(f" Total rows: {len(df)}")
|
||||||
|
for status, count in status_counts.items():
|
||||||
|
print(f" - {status}: {count}")
|
||||||
|
print(f" VALID rows for AHT calculation: {valid_count}")
|
||||||
else:
|
else:
|
||||||
|
# Legacy data sin record_status: incluir todo
|
||||||
df["_is_valid_for_cv"] = True
|
df["_is_valid_for_cv"] = True
|
||||||
|
print(f"[OperationalPerformance] No record_status column - using all {len(df)} rows")
|
||||||
|
|
||||||
# Normalización básica
|
# Normalización básica
|
||||||
df["queue_skill"] = df["queue_skill"].astype(str).str.strip()
|
df["queue_skill"] = df["queue_skill"].astype(str).str.strip()
|
||||||
@@ -156,6 +168,9 @@ class OperationalPerformanceMetrics:
|
|||||||
def talk_hold_acw_p50_by_skill(self) -> pd.DataFrame:
|
def talk_hold_acw_p50_by_skill(self) -> pd.DataFrame:
|
||||||
"""
|
"""
|
||||||
P50 de talk_time, hold_time y wrap_up_time por skill.
|
P50 de talk_time, hold_time y wrap_up_time por skill.
|
||||||
|
|
||||||
|
Incluye queue_skill como columna (no solo índice) para que
|
||||||
|
el frontend pueda hacer lookup por nombre de skill.
|
||||||
"""
|
"""
|
||||||
df = self.df
|
df = self.df
|
||||||
|
|
||||||
@@ -173,7 +188,8 @@ class OperationalPerformanceMetrics:
|
|||||||
"acw_p50": grouped["wrap_up_time"].apply(lambda s: perc(s, 50)),
|
"acw_p50": grouped["wrap_up_time"].apply(lambda s: perc(s, 50)),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return result.round(2).sort_index()
|
# Reset index to include queue_skill as column for frontend lookup
|
||||||
|
return result.round(2).sort_index().reset_index()
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# FCR, escalación, abandono, reincidencia, repetición canal
|
# FCR, escalación, abandono, reincidencia, repetición canal
|
||||||
@@ -290,13 +306,17 @@ class OperationalPerformanceMetrics:
|
|||||||
|
|
||||||
def recurrence_rate_7d(self) -> float:
|
def recurrence_rate_7d(self) -> float:
|
||||||
"""
|
"""
|
||||||
% de clientes que vuelven a contactar en < 7 días.
|
% de clientes que vuelven a contactar en < 7 días para el MISMO skill.
|
||||||
|
|
||||||
Se basa en customer_id (o caller_id si no hay customer_id).
|
Se basa en customer_id (o caller_id si no hay customer_id) + queue_skill.
|
||||||
Calcula:
|
Calcula:
|
||||||
- Para cada cliente, ordena por datetime_start
|
- Para cada combinación cliente + skill, ordena por datetime_start
|
||||||
- Si hay dos contactos consecutivos separados < 7 días, cuenta como "recurrente"
|
- Si hay dos contactos consecutivos separados < 7 días (mismo cliente, mismo skill),
|
||||||
|
cuenta como "recurrente"
|
||||||
- Tasa = nº clientes recurrentes / nº total de clientes
|
- Tasa = nº clientes recurrentes / nº total de clientes
|
||||||
|
|
||||||
|
NOTA: Solo cuenta como recurrencia si el cliente llama por el MISMO skill.
|
||||||
|
Un cliente que llama a "Ventas" y luego a "Soporte" NO es recurrente.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
df = self.df.dropna(subset=["datetime_start"]).copy()
|
df = self.df.dropna(subset=["datetime_start"]).copy()
|
||||||
@@ -313,16 +333,17 @@ class OperationalPerformanceMetrics:
|
|||||||
if df.empty:
|
if df.empty:
|
||||||
return float("nan")
|
return float("nan")
|
||||||
|
|
||||||
# Ordenar por cliente + fecha
|
# Ordenar por cliente + skill + fecha
|
||||||
df = df.sort_values(["customer_id", "datetime_start"])
|
df = df.sort_values(["customer_id", "queue_skill", "datetime_start"])
|
||||||
|
|
||||||
# Diferencia de tiempo entre contactos consecutivos por cliente
|
# Diferencia de tiempo entre contactos consecutivos por cliente Y skill
|
||||||
df["delta"] = df.groupby("customer_id")["datetime_start"].diff()
|
# Esto asegura que solo contamos recontactos del mismo cliente para el mismo skill
|
||||||
|
df["delta"] = df.groupby(["customer_id", "queue_skill"])["datetime_start"].diff()
|
||||||
|
|
||||||
# Marcamos los contactos que ocurren a menos de 7 días del anterior
|
# Marcamos los contactos que ocurren a menos de 7 días del anterior (mismo skill)
|
||||||
recurrence_mask = df["delta"] < pd.Timedelta(days=7)
|
recurrence_mask = df["delta"] < pd.Timedelta(days=7)
|
||||||
|
|
||||||
# Nº de clientes que tienen al menos un contacto recurrente
|
# Nº de clientes que tienen al menos un contacto recurrente (para cualquier skill)
|
||||||
recurrent_customers = df.loc[recurrence_mask, "customer_id"].nunique()
|
recurrent_customers = df.loc[recurrence_mask, "customer_id"].nunique()
|
||||||
total_customers = df["customer_id"].nunique()
|
total_customers = df["customer_id"].nunique()
|
||||||
|
|
||||||
@@ -568,3 +589,128 @@ class OperationalPerformanceMetrics:
|
|||||||
ax.grid(axis="y", alpha=0.3)
|
ax.grid(axis="y", alpha=0.3)
|
||||||
|
|
||||||
return ax
|
return ax
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Métricas por skill (para consistencia frontend cached/fresh)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def metrics_by_skill(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Calcula métricas operacionales por skill:
|
||||||
|
- transfer_rate: % de interacciones con transfer_flag == True
|
||||||
|
- abandonment_rate: % de interacciones abandonadas
|
||||||
|
- fcr_tecnico: 100 - transfer_rate (sin transferencia)
|
||||||
|
- fcr_real: % sin transferencia Y sin recontacto 7d (si hay datos)
|
||||||
|
- volume: número de interacciones
|
||||||
|
|
||||||
|
Devuelve una lista de dicts, uno por skill, para que el frontend
|
||||||
|
tenga acceso a las métricas reales por skill (no estimadas).
|
||||||
|
"""
|
||||||
|
df = self.df
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Detectar columna de abandono
|
||||||
|
abandon_col = None
|
||||||
|
for col_name in ["is_abandoned", "abandoned_flag", "abandoned"]:
|
||||||
|
if col_name in df.columns:
|
||||||
|
abandon_col = col_name
|
||||||
|
break
|
||||||
|
|
||||||
|
# Detectar columna de repeat_call_7d para FCR real
|
||||||
|
repeat_col = None
|
||||||
|
for col_name in ["repeat_call_7d", "repeat_7d", "is_repeat_7d"]:
|
||||||
|
if col_name in df.columns:
|
||||||
|
repeat_col = col_name
|
||||||
|
break
|
||||||
|
|
||||||
|
for skill, group in df.groupby("queue_skill"):
|
||||||
|
total = len(group)
|
||||||
|
if total == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Transfer rate
|
||||||
|
if "transfer_flag" in group.columns:
|
||||||
|
transfer_count = group["transfer_flag"].sum()
|
||||||
|
transfer_rate = float(round(transfer_count / total * 100, 2))
|
||||||
|
else:
|
||||||
|
transfer_rate = 0.0
|
||||||
|
|
||||||
|
# FCR Técnico = 100 - transfer_rate
|
||||||
|
fcr_tecnico = float(round(100.0 - transfer_rate, 2))
|
||||||
|
|
||||||
|
# Abandonment rate
|
||||||
|
abandonment_rate = 0.0
|
||||||
|
if abandon_col:
|
||||||
|
col = group[abandon_col]
|
||||||
|
if col.dtype == "O":
|
||||||
|
abandon_mask = (
|
||||||
|
col.astype(str)
|
||||||
|
.str.strip()
|
||||||
|
.str.lower()
|
||||||
|
.isin(["true", "t", "1", "yes", "y", "si", "sí"])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
abandon_mask = pd.to_numeric(col, errors="coerce").fillna(0) > 0
|
||||||
|
abandoned = int(abandon_mask.sum())
|
||||||
|
abandonment_rate = float(round(abandoned / total * 100, 2))
|
||||||
|
|
||||||
|
# FCR Real (sin transferencia Y sin recontacto 7d)
|
||||||
|
fcr_real = fcr_tecnico # default to fcr_tecnico if no repeat data
|
||||||
|
if repeat_col and "transfer_flag" in group.columns:
|
||||||
|
repeat_data = group[repeat_col]
|
||||||
|
if repeat_data.dtype == "O":
|
||||||
|
repeat_mask = (
|
||||||
|
repeat_data.astype(str)
|
||||||
|
.str.strip()
|
||||||
|
.str.lower()
|
||||||
|
.isin(["true", "t", "1", "yes", "y", "si", "sí"])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
repeat_mask = pd.to_numeric(repeat_data, errors="coerce").fillna(0) > 0
|
||||||
|
|
||||||
|
# FCR Real: no transfer AND no repeat
|
||||||
|
fcr_real_mask = (~group["transfer_flag"]) & (~repeat_mask)
|
||||||
|
fcr_real_count = fcr_real_mask.sum()
|
||||||
|
fcr_real = float(round(fcr_real_count / total * 100, 2))
|
||||||
|
|
||||||
|
# AHT Mean (promedio de handle_time sobre registros válidos)
|
||||||
|
# Filtramos solo registros 'valid' (excluye noise/zombie) para consistencia
|
||||||
|
if "_is_valid_for_cv" in group.columns:
|
||||||
|
valid_records = group[group["_is_valid_for_cv"]]
|
||||||
|
else:
|
||||||
|
valid_records = group
|
||||||
|
|
||||||
|
if len(valid_records) > 0 and "handle_time" in valid_records.columns:
|
||||||
|
aht_mean = float(round(valid_records["handle_time"].mean(), 2))
|
||||||
|
else:
|
||||||
|
aht_mean = 0.0
|
||||||
|
|
||||||
|
# AHT Total (promedio de handle_time sobre TODOS los registros)
|
||||||
|
# Incluye NOISE, ZOMBIE, ABANDON - solo para información/comparación
|
||||||
|
if len(group) > 0 and "handle_time" in group.columns:
|
||||||
|
aht_total = float(round(group["handle_time"].mean(), 2))
|
||||||
|
else:
|
||||||
|
aht_total = 0.0
|
||||||
|
|
||||||
|
# Hold Time Mean (promedio de hold_time sobre registros válidos)
|
||||||
|
# Consistente con fresh path que usa MEAN, no P50
|
||||||
|
if len(valid_records) > 0 and "hold_time" in valid_records.columns:
|
||||||
|
hold_time_mean = float(round(valid_records["hold_time"].mean(), 2))
|
||||||
|
else:
|
||||||
|
hold_time_mean = 0.0
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"skill": str(skill),
|
||||||
|
"volume": int(total),
|
||||||
|
"transfer_rate": transfer_rate,
|
||||||
|
"abandonment_rate": abandonment_rate,
|
||||||
|
"fcr_tecnico": fcr_tecnico,
|
||||||
|
"fcr_real": fcr_real,
|
||||||
|
"aht_mean": aht_mean,
|
||||||
|
"aht_total": aht_total,
|
||||||
|
"hold_time_mean": hold_time_mean,
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { LayoutDashboard, Layers, Bot, Map } from 'lucide-react';
|
import { LayoutDashboard, Layers, Bot, Map, ShieldCheck, Info, Scale } from 'lucide-react';
|
||||||
import { formatDateMonthYear } from '../utils/formatters';
|
|
||||||
|
|
||||||
export type TabId = 'executive' | 'dimensions' | 'readiness' | 'roadmap';
|
export type TabId = 'executive' | 'dimensions' | 'readiness' | 'roadmap' | 'law10';
|
||||||
|
|
||||||
export interface TabConfig {
|
export interface TabConfig {
|
||||||
id: TabId;
|
id: TabId;
|
||||||
@@ -14,6 +13,7 @@ interface DashboardHeaderProps {
|
|||||||
title?: string;
|
title?: string;
|
||||||
activeTab: TabId;
|
activeTab: TabId;
|
||||||
onTabChange: (id: TabId) => void;
|
onTabChange: (id: TabId) => void;
|
||||||
|
onMetodologiaClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS: TabConfig[] = [
|
const TABS: TabConfig[] = [
|
||||||
@@ -21,20 +21,32 @@ const TABS: TabConfig[] = [
|
|||||||
{ id: 'dimensions', label: 'Dimensiones', icon: Layers },
|
{ id: 'dimensions', label: 'Dimensiones', icon: Layers },
|
||||||
{ id: 'readiness', label: 'Agentic Readiness', icon: Bot },
|
{ id: 'readiness', label: 'Agentic Readiness', icon: Bot },
|
||||||
{ id: 'roadmap', label: 'Roadmap', icon: Map },
|
{ id: 'roadmap', label: 'Roadmap', icon: Map },
|
||||||
|
{ id: 'law10', label: 'Ley 10/2025', icon: Scale },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function DashboardHeader({
|
export function DashboardHeader({
|
||||||
title = 'AIR EUROPA - Beyond CX Analytics',
|
title = 'AIR EUROPA - Beyond CX Analytics',
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange
|
onTabChange,
|
||||||
|
onMetodologiaClick
|
||||||
}: DashboardHeaderProps) {
|
}: DashboardHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
|
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
|
||||||
{/* Top row: Title and Date */}
|
{/* Top row: Title and Metodología Badge */}
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h1 className="text-base sm:text-xl font-bold text-slate-800 truncate">{title}</h1>
|
<h1 className="text-base sm:text-xl font-bold text-slate-800 truncate">{title}</h1>
|
||||||
<span className="text-xs sm:text-sm text-slate-500 flex-shrink-0">{formatDateMonthYear()}</span>
|
{onMetodologiaClick && (
|
||||||
|
<button
|
||||||
|
onClick={onMetodologiaClick}
|
||||||
|
className="inline-flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 bg-green-100 text-green-800 rounded-full text-[10px] sm:text-xs font-medium hover:bg-green-200 transition-colors cursor-pointer flex-shrink-0"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
||||||
|
<span className="hidden md:inline">Metodología de Transformación de Datos aplicada</span>
|
||||||
|
<span className="md:hidden">Metodología</span>
|
||||||
|
<Info className="w-2.5 h-2.5 sm:w-3 sm:h-3 opacity-60" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { ArrowLeft, ShieldCheck, Info } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
import { DashboardHeader, TabId } from './DashboardHeader';
|
import { DashboardHeader, TabId } from './DashboardHeader';
|
||||||
|
import { formatDateMonthYear } from '../utils/formatters';
|
||||||
import { ExecutiveSummaryTab } from './tabs/ExecutiveSummaryTab';
|
import { ExecutiveSummaryTab } from './tabs/ExecutiveSummaryTab';
|
||||||
import { DimensionAnalysisTab } from './tabs/DimensionAnalysisTab';
|
import { DimensionAnalysisTab } from './tabs/DimensionAnalysisTab';
|
||||||
import { AgenticReadinessTab } from './tabs/AgenticReadinessTab';
|
import { AgenticReadinessTab } from './tabs/AgenticReadinessTab';
|
||||||
import { RoadmapTab } from './tabs/RoadmapTab';
|
import { RoadmapTab } from './tabs/RoadmapTab';
|
||||||
|
import { Law10Tab } from './tabs/Law10Tab';
|
||||||
import { MetodologiaDrawer } from './MetodologiaDrawer';
|
import { MetodologiaDrawer } from './MetodologiaDrawer';
|
||||||
import type { AnalysisData } from '../types';
|
import type { AnalysisData } from '../types';
|
||||||
|
|
||||||
@@ -33,6 +35,8 @@ export function DashboardTabs({
|
|||||||
return <AgenticReadinessTab data={data} onTabChange={setActiveTab} />;
|
return <AgenticReadinessTab data={data} onTabChange={setActiveTab} />;
|
||||||
case 'roadmap':
|
case 'roadmap':
|
||||||
return <RoadmapTab data={data} />;
|
return <RoadmapTab data={data} />;
|
||||||
|
case 'law10':
|
||||||
|
return <Law10Tab data={data} />;
|
||||||
default:
|
default:
|
||||||
return <ExecutiveSummaryTab data={data} />;
|
return <ExecutiveSummaryTab data={data} />;
|
||||||
}
|
}
|
||||||
@@ -61,6 +65,7 @@ export function DashboardTabs({
|
|||||||
title={title}
|
title={title}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={setActiveTab}
|
onTabChange={setActiveTab}
|
||||||
|
onMetodologiaClick={() => setMetodologiaOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
@@ -84,23 +89,7 @@ export function DashboardTabs({
|
|||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-sm text-slate-500">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-sm text-slate-500">
|
||||||
<span className="hidden sm:inline">Beyond Diagnosis - Contact Center Analytics Platform</span>
|
<span className="hidden sm:inline">Beyond Diagnosis - Contact Center Analytics Platform</span>
|
||||||
<span className="sm:hidden text-xs">Beyond Diagnosis</span>
|
<span className="sm:hidden text-xs">Beyond Diagnosis</span>
|
||||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
<span className="text-xs sm:text-sm text-slate-400 italic">{formatDateMonthYear()}</span>
|
||||||
<span className="text-xs sm:text-sm">
|
|
||||||
{data.tier ? data.tier.toUpperCase() : 'GOLD'} |
|
|
||||||
{data.source === 'backend' ? 'Genesys' : data.source || 'synthetic'}
|
|
||||||
</span>
|
|
||||||
<span className="hidden sm:inline text-slate-300">|</span>
|
|
||||||
{/* Badge Metodología */}
|
|
||||||
<button
|
|
||||||
onClick={() => setMetodologiaOpen(true)}
|
|
||||||
className="inline-flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 bg-green-100 text-green-800 rounded-full text-[10px] sm:text-xs font-medium hover:bg-green-200 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<ShieldCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
|
||||||
<span className="hidden md:inline">Metodología de Transformación de Datos aplicada</span>
|
|
||||||
<span className="md:hidden">Metodología</span>
|
|
||||||
<Info className="w-2.5 h-2.5 sm:w-3 sm:h-3 opacity-60" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -304,6 +304,111 @@ function KPIRedefinitionSection({ kpis }: { kpis: DataSummary['kpis'] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CPICalculationSection({ totalCost, totalVolume, costPerHour = 20 }: { totalCost: number; totalVolume: number; costPerHour?: number }) {
|
||||||
|
// Productivity factor: agents are ~70% productive (rest is breaks, training, after-call work, etc.)
|
||||||
|
const effectiveProductivity = 0.70;
|
||||||
|
|
||||||
|
// CPI = Total Cost / Total Volume
|
||||||
|
// El coste total ya incluye: TODOS los registros (noise + zombie + valid) y el factor de productividad
|
||||||
|
const cpi = totalVolume > 0 ? totalCost / totalVolume : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-5 h-5 text-emerald-600" />
|
||||||
|
Coste por Interacción (CPI)
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
El CPI se calcula dividiendo el <strong>coste total</strong> entre el <strong>volumen de interacciones</strong>.
|
||||||
|
El coste total incluye <em>todas</em> las interacciones (noise, zombie y válidas) porque todas se facturan,
|
||||||
|
y aplica un factor de productividad del {(effectiveProductivity * 100).toFixed(0)}%.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Fórmula visual */}
|
||||||
|
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4 mb-4">
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<span className="text-xs text-emerald-700 uppercase tracking-wider font-medium">Fórmula de Cálculo</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-2 text-lg font-mono flex-wrap">
|
||||||
|
<span className="px-3 py-1 bg-white rounded border border-emerald-300">CPI</span>
|
||||||
|
<span className="text-emerald-600">=</span>
|
||||||
|
<span className="px-2 py-1 bg-blue-100 rounded text-blue-800 text-sm">Coste Total</span>
|
||||||
|
<span className="text-emerald-600">÷</span>
|
||||||
|
<span className="px-2 py-1 bg-amber-100 rounded text-amber-800 text-sm">Volumen Total</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-center text-emerald-600 mt-2">
|
||||||
|
El coste total usa (AHT segundos ÷ 3600) × coste/hora × volumen ÷ productividad
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cómo se calcula el coste total */}
|
||||||
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 mb-4">
|
||||||
|
<div className="text-sm font-semibold text-slate-700 mb-2">¿Cómo se calcula el Coste Total?</div>
|
||||||
|
<div className="bg-white rounded p-3 mb-3">
|
||||||
|
<div className="flex items-center justify-center gap-2 text-sm font-mono flex-wrap">
|
||||||
|
<span className="text-slate-600">Coste =</span>
|
||||||
|
<span className="px-2 py-1 bg-blue-100 rounded text-blue-800 text-xs">(AHT seg ÷ 3600)</span>
|
||||||
|
<span className="text-slate-400">×</span>
|
||||||
|
<span className="px-2 py-1 bg-amber-100 rounded text-amber-800 text-xs">€{costPerHour}/h</span>
|
||||||
|
<span className="text-slate-400">×</span>
|
||||||
|
<span className="px-2 py-1 bg-gray-100 rounded text-gray-800 text-xs">Volumen</span>
|
||||||
|
<span className="text-slate-400">÷</span>
|
||||||
|
<span className="px-2 py-1 bg-purple-100 rounded text-purple-800 text-xs">{(effectiveProductivity * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
El <strong>AHT</strong> está en segundos, se convierte a horas dividiendo por 3600.
|
||||||
|
Incluye todas las interacciones que generan coste (noise + zombie + válidas).
|
||||||
|
Solo se excluyen los abandonos porque no consumen tiempo de agente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Componentes del coste horario */}
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="text-sm font-semibold text-amber-800">Coste por Hora del Agente (Fully Loaded)</div>
|
||||||
|
<span className="text-xs bg-amber-200 text-amber-800 px-2 py-0.5 rounded-full font-medium">
|
||||||
|
Valor introducido: €{costPerHour.toFixed(2)}/h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-amber-700 mb-3">
|
||||||
|
Este valor fue configurado en la pantalla de entrada de datos y debe incluir todos los costes asociados al agente:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-amber-500">•</span>
|
||||||
|
<span className="text-amber-700">Salario bruto del agente</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-amber-500">•</span>
|
||||||
|
<span className="text-amber-700">Costes de seguridad social</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-amber-500">•</span>
|
||||||
|
<span className="text-amber-700">Licencias de software</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-amber-500">•</span>
|
||||||
|
<span className="text-amber-700">Infraestructura y puesto</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-amber-500">•</span>
|
||||||
|
<span className="text-amber-700">Supervisión y QA</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-amber-500">•</span>
|
||||||
|
<span className="text-amber-700">Formación y overhead</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-amber-600 mt-3 italic">
|
||||||
|
💡 Si necesita ajustar este valor, puede volver a la pantalla de entrada de datos y modificarlo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BeforeAfterSection({ kpis }: { kpis: DataSummary['kpis'] }) {
|
function BeforeAfterSection({ kpis }: { kpis: DataSummary['kpis'] }) {
|
||||||
const rows = [
|
const rows = [
|
||||||
{
|
{
|
||||||
@@ -528,6 +633,9 @@ function GuaranteesSection() {
|
|||||||
export function MetodologiaDrawer({ isOpen, onClose, data }: MetodologiaDrawerProps) {
|
export function MetodologiaDrawer({ isOpen, onClose, data }: MetodologiaDrawerProps) {
|
||||||
// Calcular datos del resumen desde AnalysisData
|
// Calcular datos del resumen desde AnalysisData
|
||||||
const totalRegistros = data.heatmapData?.reduce((sum, h) => sum + h.volume, 0) || 0;
|
const totalRegistros = data.heatmapData?.reduce((sum, h) => sum + h.volume, 0) || 0;
|
||||||
|
const totalCost = data.heatmapData?.reduce((sum, h) => sum + (h.annual_cost || 0), 0) || 0;
|
||||||
|
// cost_volume: volumen usado para calcular coste (non-abandon), fallback a volume si no existe
|
||||||
|
const totalCostVolume = data.heatmapData?.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0) || totalRegistros;
|
||||||
|
|
||||||
// Calcular meses de histórico desde dateRange
|
// Calcular meses de histórico desde dateRange
|
||||||
let mesesHistorico = 1;
|
let mesesHistorico = 1;
|
||||||
@@ -633,6 +741,11 @@ export function MetodologiaDrawer({ isOpen, onClose, data }: MetodologiaDrawerPr
|
|||||||
<SkillsMappingSection numSkillsNegocio={dataSummary.kpis.skillsNegocio} />
|
<SkillsMappingSection numSkillsNegocio={dataSummary.kpis.skillsNegocio} />
|
||||||
<TaxonomySection data={dataSummary.taxonomia} />
|
<TaxonomySection data={dataSummary.taxonomia} />
|
||||||
<KPIRedefinitionSection kpis={dataSummary.kpis} />
|
<KPIRedefinitionSection kpis={dataSummary.kpis} />
|
||||||
|
<CPICalculationSection
|
||||||
|
totalCost={totalCost}
|
||||||
|
totalVolume={totalCostVolume}
|
||||||
|
costPerHour={data.staticConfig?.cost_per_hour || 20}
|
||||||
|
/>
|
||||||
<BeforeAfterSection kpis={dataSummary.kpis} />
|
<BeforeAfterSection kpis={dataSummary.kpis} />
|
||||||
<GuaranteesSection />
|
<GuaranteesSection />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,13 +81,14 @@ const OpportunityMatrixPro: React.FC<OpportunityMatrixProProps> = ({ data, heatm
|
|||||||
};
|
};
|
||||||
}, [dataWithPriority]);
|
}, [dataWithPriority]);
|
||||||
|
|
||||||
// Dynamic title
|
// Dynamic title - v4.3: Top 10 iniciativas por potencial económico
|
||||||
const dynamicTitle = useMemo(() => {
|
const dynamicTitle = useMemo(() => {
|
||||||
const { quickWins } = portfolioSummary;
|
const totalQueues = dataWithPriority.length;
|
||||||
if (quickWins.count > 0) {
|
const totalSavings = portfolioSummary.totalSavings;
|
||||||
return `${quickWins.count} Quick Wins pueden generar €${(quickWins.savings / 1000).toFixed(0)}K en ahorros con implementación en Q1-Q2`;
|
if (totalQueues === 0) {
|
||||||
|
return 'No hay iniciativas con potencial de ahorro identificadas';
|
||||||
}
|
}
|
||||||
return `Portfolio de ${dataWithPriority.length} oportunidades identificadas con potencial de €${(portfolioSummary.totalSavings / 1000).toFixed(0)}K`;
|
return `Top ${totalQueues} iniciativas por potencial económico | Ahorro total: €${(totalSavings / 1000).toFixed(0)}K/año`;
|
||||||
}, [portfolioSummary, dataWithPriority]);
|
}, [portfolioSummary, dataWithPriority]);
|
||||||
|
|
||||||
const getQuadrantInfo = (impact: number, feasibility: number): QuadrantInfo => {
|
const getQuadrantInfo = (impact: number, feasibility: number): QuadrantInfo => {
|
||||||
@@ -160,21 +161,24 @@ const OpportunityMatrixPro: React.FC<OpportunityMatrixProProps> = ({ data, heatm
|
|||||||
<div id="opportunities" className="bg-white p-8 rounded-xl border border-slate-200 shadow-sm">
|
<div id="opportunities" className="bg-white p-8 rounded-xl border border-slate-200 shadow-sm">
|
||||||
{/* Header with Dynamic Title */}
|
{/* Header with Dynamic Title */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="font-bold text-2xl text-slate-800">Opportunity Matrix</h3>
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-bold text-2xl text-slate-800">Opportunity Matrix - Top 10 Iniciativas</h3>
|
||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
<HelpCircle size={18} className="text-slate-400 cursor-pointer" />
|
<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">
|
<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 className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-800"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<p className="text-base text-slate-700 font-medium leading-relaxed mb-1">
|
||||||
{dynamicTitle}
|
{dynamicTitle}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
Portfolio de Oportunidades | Análisis de {dataWithPriority.length} iniciativas identificadas
|
{dataWithPriority.length} iniciativas identificadas | Ahorro TCO según tier (AUTOMATE 70%, ASSIST 30%, AUGMENT 15%)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -217,33 +221,33 @@ const OpportunityMatrixPro: React.FC<OpportunityMatrixProProps> = ({ data, heatm
|
|||||||
<div className="relative w-full h-[500px] border-l-2 border-b-2 border-slate-400 rounded-bl-lg bg-gradient-to-tr from-slate-50 to-white">
|
<div className="relative w-full h-[500px] border-l-2 border-b-2 border-slate-400 rounded-bl-lg bg-gradient-to-tr from-slate-50 to-white">
|
||||||
{/* Y-axis Label */}
|
{/* Y-axis Label */}
|
||||||
<div className="absolute -left-20 top-1/2 -translate-y-1/2 -rotate-90 text-sm font-bold text-slate-700 flex items-center gap-2">
|
<div className="absolute -left-20 top-1/2 -translate-y-1/2 -rotate-90 text-sm font-bold text-slate-700 flex items-center gap-2">
|
||||||
<TrendingUp size={18} /> IMPACTO
|
<TrendingUp size={18} /> IMPACTO (Ahorro TCO)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* X-axis Label */}
|
{/* X-axis Label */}
|
||||||
<div className="absolute -bottom-14 left-1/2 -translate-x-1/2 text-sm font-bold text-slate-700 flex items-center gap-2">
|
<div className="absolute -bottom-14 left-1/2 -translate-x-1/2 text-sm font-bold text-slate-700 flex items-center gap-2">
|
||||||
<Zap size={18} /> FACTIBILIDAD
|
<Zap size={18} /> FACTIBILIDAD (Agentic Score)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Axis scale labels */}
|
{/* Axis scale labels */}
|
||||||
<div className="absolute -left-2 top-0 -translate-x-full text-xs text-slate-500 font-medium">
|
<div className="absolute -left-2 top-0 -translate-x-full text-xs text-slate-500 font-medium">
|
||||||
Muy Alto
|
Alto (10)
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -left-2 top-1/2 -translate-x-full -translate-y-1/2 text-xs text-slate-500 font-medium">
|
<div className="absolute -left-2 top-1/2 -translate-x-full -translate-y-1/2 text-xs text-slate-500 font-medium">
|
||||||
Medio
|
Medio (5)
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -left-2 bottom-0 -translate-x-full text-xs text-slate-500 font-medium">
|
<div className="absolute -left-2 bottom-0 -translate-x-full text-xs text-slate-500 font-medium">
|
||||||
Bajo
|
Bajo (1)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute left-0 -bottom-2 translate-y-full text-xs text-slate-500 font-medium">
|
<div className="absolute left-0 -bottom-2 translate-y-full text-xs text-slate-500 font-medium">
|
||||||
Muy Difícil
|
0
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute left-1/2 -bottom-2 -translate-x-1/2 translate-y-full text-xs text-slate-500 font-medium">
|
<div className="absolute left-1/2 -bottom-2 -translate-x-1/2 translate-y-full text-xs text-slate-500 font-medium">
|
||||||
Moderado
|
5
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute right-0 -bottom-2 translate-y-full text-xs text-slate-500 font-medium">
|
<div className="absolute right-0 -bottom-2 translate-y-full text-xs text-slate-500 font-medium">
|
||||||
Fácil
|
10
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quadrant Lines */}
|
{/* Quadrant Lines */}
|
||||||
@@ -364,22 +368,24 @@ const OpportunityMatrixPro: React.FC<OpportunityMatrixProProps> = ({ data, heatm
|
|||||||
|
|
||||||
{/* Enhanced Legend */}
|
{/* Enhanced Legend */}
|
||||||
<div className="mt-8 p-4 bg-slate-50 rounded-lg">
|
<div className="mt-8 p-4 bg-slate-50 rounded-lg">
|
||||||
<div className="flex flex-wrap items-center gap-6 text-xs">
|
<div className="flex flex-wrap items-center gap-4 text-xs">
|
||||||
<span className="font-semibold text-slate-700">Tamaño de burbuja = Ahorro potencial:</span>
|
<span className="font-semibold text-slate-700">Tier:</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-4 h-4 rounded-full bg-slate-400"></div>
|
<span>🤖</span>
|
||||||
<span className="text-slate-700">Pequeño (<€50K)</span>
|
<span className="text-emerald-600 font-medium">AUTOMATE</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-6 h-6 rounded-full bg-slate-400"></div>
|
<span>🤝</span>
|
||||||
<span className="text-slate-700">Medio (€50-150K)</span>
|
<span className="text-blue-600 font-medium">ASSIST</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-8 h-8 rounded-full bg-slate-400"></div>
|
<span>📚</span>
|
||||||
<span className="text-slate-700">Grande (>€150K)</span>
|
<span className="text-amber-600 font-medium">AUGMENT</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-4 text-slate-500">|</span>
|
<span className="text-slate-400">|</span>
|
||||||
<span className="font-semibold text-slate-700">Número = Prioridad estratégica</span>
|
<span className="font-semibold text-slate-700">Tamaño = Ahorro TCO</span>
|
||||||
|
<span className="text-slate-400">|</span>
|
||||||
|
<span className="font-semibold text-slate-700">Número = Ranking</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -447,10 +453,10 @@ const OpportunityMatrixPro: React.FC<OpportunityMatrixProProps> = ({ data, heatm
|
|||||||
|
|
||||||
{/* Methodology Footer */}
|
{/* Methodology Footer */}
|
||||||
<MethodologyFooter
|
<MethodologyFooter
|
||||||
sources="Análisis interno de procesos operacionales | Benchmarks de implementación: Gartner Magic Quadrant for CCaaS 2024, Forrester Wave Contact Center 2024"
|
sources="Agentic Readiness Score (5 factores ponderados) | Modelo TCO con CPI diferenciado por tier"
|
||||||
methodology="Impacto: Basado en % reducción de AHT, mejora de FCR, y reducción de costes operacionales | Factibilidad: Evaluación de complejidad técnica (40%), cambio organizacional (30%), inversión requerida (30%) | Priorización: Score = (Impacto/10) × (Factibilidad/10) × (Ahorro/Max Ahorro)"
|
methodology="Factibilidad = Agentic Score (0-10) | Impacto = Ahorro TCO anual según tier: AUTOMATE (Vol/11×12×70%×€2.18), ASSIST (×30%×€0.83), AUGMENT (×15%×€0.33)"
|
||||||
notes="Ahorros calculados en escenario conservador (base case) sin incluir upside potencial | ROI calculado a 3 años con tasa de descuento 10%"
|
notes="Top 10 iniciativas ordenadas por potencial económico | CPI: Humano €2.33, Bot €0.15, Assist €1.50, Augment €2.00"
|
||||||
lastUpdated="Enero 2025"
|
lastUpdated="Enero 2026"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
623
frontend/components/OpportunityPrioritizer.tsx
Normal file
623
frontend/components/OpportunityPrioritizer.tsx
Normal 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
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ChevronRight, TrendingUp, TrendingDown, Minus, AlertTriangle, Lightbulb, DollarSign } from 'lucide-react';
|
import { ChevronRight, TrendingUp, TrendingDown, Minus, AlertTriangle, Lightbulb, DollarSign, Clock } from 'lucide-react';
|
||||||
import type { AnalysisData, DimensionAnalysis, Finding, Recommendation, HeatmapDataPoint } from '../../types';
|
import type { AnalysisData, DimensionAnalysis, Finding, Recommendation, HeatmapDataPoint } from '../../types';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -20,7 +20,7 @@ interface DimensionAnalysisTabProps {
|
|||||||
data: AnalysisData;
|
data: AnalysisData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== ANÁLISIS CAUSAL CON IMPACTO ECONÓMICO ==========
|
// ========== HALLAZGO CLAVE CON IMPACTO ECONÓMICO ==========
|
||||||
|
|
||||||
interface CausalAnalysis {
|
interface CausalAnalysis {
|
||||||
finding: string;
|
finding: string;
|
||||||
@@ -34,20 +34,44 @@ interface CausalAnalysis {
|
|||||||
interface CausalAnalysisExtended extends CausalAnalysis {
|
interface CausalAnalysisExtended extends CausalAnalysis {
|
||||||
impactFormula?: string; // Explicación de cómo se calculó el impacto
|
impactFormula?: string; // Explicación de cómo se calculó el impacto
|
||||||
hasRealData: boolean; // True si hay datos reales para calcular
|
hasRealData: boolean; // True si hay datos reales para calcular
|
||||||
|
timeSavings?: string; // Ahorro de tiempo para dar credibilidad al impacto económico
|
||||||
}
|
}
|
||||||
|
|
||||||
// Genera análisis causal basado en dimensión y datos
|
// Genera hallazgo clave basado en dimensión y datos
|
||||||
function generateCausalAnalysis(
|
function generateCausalAnalysis(
|
||||||
dimension: DimensionAnalysis,
|
dimension: DimensionAnalysis,
|
||||||
heatmapData: HeatmapDataPoint[],
|
heatmapData: HeatmapDataPoint[],
|
||||||
economicModel: { currentAnnualCost: number }
|
economicModel: { currentAnnualCost: number },
|
||||||
|
staticConfig?: { cost_per_hour: number },
|
||||||
|
dateRange?: { min: string; max: string }
|
||||||
): CausalAnalysisExtended[] {
|
): CausalAnalysisExtended[] {
|
||||||
const analyses: CausalAnalysisExtended[] = [];
|
const analyses: CausalAnalysisExtended[] = [];
|
||||||
const totalVolume = heatmapData.reduce((sum, h) => sum + h.volume, 0);
|
const totalVolume = heatmapData.reduce((sum, h) => sum + h.volume, 0);
|
||||||
|
|
||||||
// v3.11: CPI basado en modelo TCO (€2.33/interacción)
|
// Coste horario del agente desde config (default €20 si no está definido)
|
||||||
|
const HOURLY_COST = staticConfig?.cost_per_hour ?? 20;
|
||||||
|
|
||||||
|
// Calcular factor de anualización basado en el período de datos
|
||||||
|
// Si tenemos dateRange, calculamos cuántos días cubre y extrapolamos a año
|
||||||
|
let annualizationFactor = 1; // Por defecto, asumimos que los datos ya son anuales
|
||||||
|
if (dateRange?.min && dateRange?.max) {
|
||||||
|
const startDate = new Date(dateRange.min);
|
||||||
|
const endDate = new Date(dateRange.max);
|
||||||
|
const daysCovered = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1);
|
||||||
|
annualizationFactor = 365 / daysCovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v3.11: CPI consistente con Executive Summary
|
||||||
const CPI_TCO = 2.33;
|
const CPI_TCO = 2.33;
|
||||||
const CPI = totalVolume > 0 ? economicModel.currentAnnualCost / (totalVolume * 12) : CPI_TCO;
|
// Usar CPI pre-calculado de heatmapData si existe, sino calcular desde annual_cost/cost_volume
|
||||||
|
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
|
||||||
|
const totalAnnualCost = heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0);
|
||||||
|
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
|
||||||
|
const CPI = hasCpiField
|
||||||
|
? (totalCostVolume > 0
|
||||||
|
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
|
||||||
|
: CPI_TCO)
|
||||||
|
: (totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : CPI_TCO);
|
||||||
|
|
||||||
// Calcular métricas agregadas
|
// Calcular métricas agregadas
|
||||||
const avgCVAHT = totalVolume > 0
|
const avgCVAHT = totalVolume > 0
|
||||||
@@ -56,8 +80,10 @@ function generateCausalAnalysis(
|
|||||||
const avgTransferRate = totalVolume > 0
|
const avgTransferRate = totalVolume > 0
|
||||||
? heatmapData.reduce((sum, h) => sum + (h.variability?.transfer_rate || 0) * h.volume, 0) / totalVolume
|
? heatmapData.reduce((sum, h) => sum + (h.variability?.transfer_rate || 0) * h.volume, 0) / totalVolume
|
||||||
: 0;
|
: 0;
|
||||||
|
// Usar FCR Técnico (100 - transfer_rate) en lugar de FCR Real (con filtro recontacto 7d)
|
||||||
|
// FCR Técnico es más comparable con benchmarks de industria
|
||||||
const avgFCR = totalVolume > 0
|
const avgFCR = totalVolume > 0
|
||||||
? heatmapData.reduce((sum, h) => sum + h.metrics.fcr * h.volume, 0) / totalVolume
|
? heatmapData.reduce((sum, h) => sum + (h.metrics.fcr_tecnico ?? (100 - h.metrics.transfer_rate)) * h.volume, 0) / totalVolume
|
||||||
: 0;
|
: 0;
|
||||||
const avgAHT = totalVolume > 0
|
const avgAHT = totalVolume > 0
|
||||||
? heatmapData.reduce((sum, h) => sum + h.aht_seconds * h.volume, 0) / totalVolume
|
? heatmapData.reduce((sum, h) => sum + h.aht_seconds * h.volume, 0) / totalVolume
|
||||||
@@ -71,77 +97,112 @@ function generateCausalAnalysis(
|
|||||||
|
|
||||||
// Skills con problemas específicos
|
// Skills con problemas específicos
|
||||||
const skillsHighCV = heatmapData.filter(h => (h.variability?.cv_aht || 0) > 100);
|
const skillsHighCV = heatmapData.filter(h => (h.variability?.cv_aht || 0) > 100);
|
||||||
const skillsLowFCR = heatmapData.filter(h => h.metrics.fcr < 50);
|
// Usar FCR Técnico para identificar skills con bajo FCR
|
||||||
|
const skillsLowFCR = heatmapData.filter(h => (h.metrics.fcr_tecnico ?? (100 - h.metrics.transfer_rate)) < 50);
|
||||||
const skillsHighTransfer = heatmapData.filter(h => (h.variability?.transfer_rate || 0) > 20);
|
const skillsHighTransfer = heatmapData.filter(h => (h.variability?.transfer_rate || 0) > 20);
|
||||||
|
|
||||||
|
// Parsear P50 AHT del KPI del header para consistencia visual
|
||||||
|
// El KPI puede ser "345s (P50)" o similar
|
||||||
|
const parseKpiAhtSeconds = (kpiValue: string): number | null => {
|
||||||
|
const match = kpiValue.match(/(\d+)s/);
|
||||||
|
return match ? parseInt(match[1], 10) : null;
|
||||||
|
};
|
||||||
|
|
||||||
switch (dimension.name) {
|
switch (dimension.name) {
|
||||||
case 'operational_efficiency':
|
case 'operational_efficiency':
|
||||||
// Análisis de variabilidad AHT
|
// Obtener P50 AHT del header para mostrar valor consistente
|
||||||
if (avgCVAHT > 80) {
|
const p50Aht = parseKpiAhtSeconds(dimension.kpi.value) ?? avgAHT;
|
||||||
const inefficiencyPct = Math.min(0.15, (avgCVAHT - 60) / 200);
|
|
||||||
const inefficiencyCost = Math.round(economicModel.currentAnnualCost * inefficiencyPct);
|
// Eficiencia Operativa: enfocada en AHT (valor absoluto)
|
||||||
|
// CV AHT se analiza en Complejidad & Predictibilidad (best practice)
|
||||||
|
const hasHighAHT = p50Aht > 300; // 5:00 benchmark
|
||||||
|
const ahtBenchmark = 300; // 5:00 objetivo
|
||||||
|
|
||||||
|
if (hasHighAHT) {
|
||||||
|
// Calcular impacto económico por AHT excesivo
|
||||||
|
const excessSeconds = p50Aht - ahtBenchmark;
|
||||||
|
const annualVolume = Math.round(totalVolume * annualizationFactor);
|
||||||
|
const excessHours = Math.round((excessSeconds / 3600) * annualVolume);
|
||||||
|
const ahtExcessCost = Math.round(excessHours * HOURLY_COST);
|
||||||
|
|
||||||
|
// Estimar ahorro con solución Copilot (25-30% reducción AHT)
|
||||||
|
const copilotSavings = Math.round(ahtExcessCost * 0.28);
|
||||||
|
|
||||||
|
// Causa basada en AHT elevado
|
||||||
|
const cause = 'Agentes dedican tiempo excesivo a búsqueda manual de información, navegación entre sistemas y tareas repetitivas.';
|
||||||
|
|
||||||
analyses.push({
|
analyses.push({
|
||||||
finding: `Variabilidad AHT elevada: CV ${avgCVAHT.toFixed(0)}% (benchmark: <60%)`,
|
finding: `AHT elevado: P50 ${Math.floor(p50Aht / 60)}:${String(Math.round(p50Aht) % 60).padStart(2, '0')} (benchmark: 5:00)`,
|
||||||
probableCause: skillsHighCV.length > 0
|
probableCause: cause,
|
||||||
? `Falta de scripts estandarizados en ${skillsHighCV.slice(0, 3).map(s => s.skill).join(', ')}. Agentes manejan casos similares de formas muy diferentes.`
|
economicImpact: ahtExcessCost,
|
||||||
: 'Procesos no documentados y falta de guías de atención claras.',
|
impactFormula: `${excessHours.toLocaleString()}h × €${HOURLY_COST}/h`,
|
||||||
economicImpact: inefficiencyCost,
|
timeSavings: `${excessHours.toLocaleString()} horas/año en exceso de AHT`,
|
||||||
impactFormula: `Coste anual × ${(inefficiencyPct * 100).toFixed(1)}% ineficiencia = €${(economicModel.currentAnnualCost/1000).toFixed(0)}K × ${(inefficiencyPct * 100).toFixed(1)}%`,
|
recommendation: `Desplegar Copilot IA para agentes: (1) Auto-búsqueda en KB; (2) Sugerencias contextuales en tiempo real; (3) Scripts guiados para casos frecuentes. Reducción esperada: 20-30% AHT. Ahorro: ${formatCurrency(copilotSavings)}/año.`,
|
||||||
recommendation: 'Crear playbooks por tipología de consulta y certificar agentes en procesos estándar.',
|
severity: p50Aht > 420 ? 'critical' : 'warning',
|
||||||
severity: avgCVAHT > 120 ? 'critical' : 'warning',
|
|
||||||
hasRealData: true
|
hasRealData: true
|
||||||
});
|
});
|
||||||
}
|
} else {
|
||||||
|
// AHT dentro de benchmark - mostrar estado positivo
|
||||||
// Análisis de AHT absoluto
|
|
||||||
if (avgAHT > 420) {
|
|
||||||
const excessSeconds = avgAHT - 360;
|
|
||||||
const excessCost = Math.round((excessSeconds / 3600) * totalVolume * 12 * 25);
|
|
||||||
analyses.push({
|
analyses.push({
|
||||||
finding: `AHT elevado: ${Math.floor(avgAHT / 60)}:${String(Math.round(avgAHT) % 60).padStart(2, '0')} (benchmark: 6:00)`,
|
finding: `AHT dentro de benchmark: P50 ${Math.floor(p50Aht / 60)}:${String(Math.round(p50Aht) % 60).padStart(2, '0')} (benchmark: 5:00)`,
|
||||||
probableCause: 'Sistemas de información fragmentados, búsquedas manuales excesivas, o falta de herramientas de asistencia al agente.',
|
probableCause: 'Tiempos de gestión eficientes. Procesos operativos optimizados.',
|
||||||
economicImpact: excessCost,
|
economicImpact: 0,
|
||||||
impactFormula: `Exceso ${Math.round(excessSeconds)}s × ${totalVolume.toLocaleString()} int/mes × 12 × €25/h`,
|
impactFormula: 'Sin exceso de coste por AHT',
|
||||||
recommendation: 'Implementar vista unificada de cliente y herramientas de sugerencia automática.',
|
timeSavings: 'Operación eficiente',
|
||||||
severity: avgAHT > 540 ? 'critical' : 'warning',
|
recommendation: 'Mantener nivel actual. Considerar Copilot para mejora continua y reducción adicional de tiempos en casos complejos.',
|
||||||
|
severity: 'info',
|
||||||
hasRealData: true
|
hasRealData: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'effectiveness_resolution':
|
case 'effectiveness_resolution':
|
||||||
// Análisis de FCR
|
// Análisis principal: FCR Técnico y tasa de transferencias
|
||||||
|
const annualVolumeEff = Math.round(totalVolume * annualizationFactor);
|
||||||
|
const transferCount = Math.round(annualVolumeEff * (avgTransferRate / 100));
|
||||||
|
|
||||||
|
// Calcular impacto económico de transferencias
|
||||||
|
const transferCostTotal = Math.round(transferCount * CPI_TCO * 0.5);
|
||||||
|
|
||||||
|
// Potencial de mejora con IA
|
||||||
|
const improvementPotential = avgFCR < 90 ? Math.round((90 - avgFCR) / 100 * annualVolumeEff) : 0;
|
||||||
|
const potentialSavingsEff = Math.round(improvementPotential * CPI_TCO * 0.3);
|
||||||
|
|
||||||
|
// Determinar severidad basada en FCR
|
||||||
|
const effSeverity = avgFCR < 70 ? 'critical' : avgFCR < 85 ? 'warning' : 'info';
|
||||||
|
|
||||||
|
// Construir causa basada en datos
|
||||||
|
let effCause = '';
|
||||||
if (avgFCR < 70) {
|
if (avgFCR < 70) {
|
||||||
const recontactRate = (100 - avgFCR) / 100;
|
effCause = skillsLowFCR.length > 0
|
||||||
const recontactCost = Math.round(totalVolume * 12 * recontactRate * CPI_TCO);
|
? `Alta tasa de transferencias (${avgTransferRate.toFixed(0)}%) indica falta de herramientas o autoridad. Crítico en ${skillsLowFCR.slice(0, 2).map(s => s.skill).join(', ')}.`
|
||||||
analyses.push({
|
: `Transferencias elevadas (${avgTransferRate.toFixed(0)}%): agentes sin información contextual o sin autoridad para resolver.`;
|
||||||
finding: `FCR bajo: ${avgFCR.toFixed(0)}% (benchmark: >75%)`,
|
} else if (avgFCR < 85) {
|
||||||
probableCause: skillsLowFCR.length > 0
|
effCause = `Transferencias del ${avgTransferRate.toFixed(0)}% indican oportunidad de mejora con asistencia IA para casos complejos.`;
|
||||||
? `Agentes sin autonomía para resolver en ${skillsLowFCR.slice(0, 2).map(s => s.skill).join(', ')}. Políticas de escalado excesivamente restrictivas.`
|
} else {
|
||||||
: 'Falta de información completa en primer contacto o limitaciones de autoridad del agente.',
|
effCause = `FCR Técnico en nivel óptimo. Transferencias del ${avgTransferRate.toFixed(0)}% principalmente en casos que requieren escalación legítima.`;
|
||||||
economicImpact: recontactCost,
|
}
|
||||||
impactFormula: `${totalVolume.toLocaleString()} int × 12 × ${(recontactRate * 100).toFixed(0)}% recontactos × €${CPI_TCO}/int`,
|
|
||||||
recommendation: 'Empoderar agentes con mayor autoridad de resolución y crear Knowledge Base contextual.',
|
// Construir recomendación
|
||||||
severity: avgFCR < 50 ? 'critical' : 'warning',
|
let effRecommendation = '';
|
||||||
hasRealData: true
|
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({
|
analyses.push({
|
||||||
finding: `Tasa de transferencias: ${avgTransferRate.toFixed(1)}% (benchmark: <10%)`,
|
finding: `FCR Técnico: ${avgFCR.toFixed(0)}% | Transferencias: ${avgTransferRate.toFixed(0)}% (benchmark: FCR >85%, Transfer <10%)`,
|
||||||
probableCause: skillsHighTransfer.length > 0
|
probableCause: effCause,
|
||||||
? `Routing inicial incorrecto hacia ${skillsHighTransfer.slice(0, 2).map(s => s.skill).join(', ')}. IVR no identifica correctamente la intención del cliente.`
|
economicImpact: transferCostTotal,
|
||||||
: 'Reglas de enrutamiento desactualizadas o skills mal definidos.',
|
impactFormula: `${transferCount.toLocaleString()} transferencias/año × €${CPI_TCO}/int × 50% coste adicional`,
|
||||||
economicImpact: transferCost,
|
timeSavings: `${transferCount.toLocaleString()} transferencias/año (${avgTransferRate.toFixed(0)}% del volumen)`,
|
||||||
impactFormula: `${totalVolume.toLocaleString()} int × 12 × ${avgTransferRate.toFixed(1)}% × €${CPI_TCO} × 50% coste adicional`,
|
recommendation: effRecommendation,
|
||||||
recommendation: 'Revisar árbol de IVR, actualizar reglas de ACD y capacitar agentes en resolución integral.',
|
severity: effSeverity,
|
||||||
severity: avgTransferRate > 25 ? 'critical' : 'warning',
|
|
||||||
hasRealData: true
|
hasRealData: true
|
||||||
});
|
});
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'volumetry_distribution':
|
case 'volumetry_distribution':
|
||||||
@@ -149,13 +210,16 @@ function generateCausalAnalysis(
|
|||||||
const topSkill = [...heatmapData].sort((a, b) => b.volume - a.volume)[0];
|
const topSkill = [...heatmapData].sort((a, b) => b.volume - a.volume)[0];
|
||||||
const topSkillPct = topSkill ? (topSkill.volume / totalVolume) * 100 : 0;
|
const topSkillPct = topSkill ? (topSkill.volume / totalVolume) * 100 : 0;
|
||||||
if (topSkillPct > 40 && topSkill) {
|
if (topSkillPct > 40 && topSkill) {
|
||||||
const deflectionPotential = Math.round(topSkill.volume * 12 * CPI_TCO * 0.20);
|
const annualTopSkillVolume = Math.round(topSkill.volume * annualizationFactor);
|
||||||
|
const deflectionPotential = Math.round(annualTopSkillVolume * CPI_TCO * 0.20);
|
||||||
|
const interactionsDeflectable = Math.round(annualTopSkillVolume * 0.20);
|
||||||
analyses.push({
|
analyses.push({
|
||||||
finding: `Concentración de volumen: ${topSkill.skill} representa ${topSkillPct.toFixed(0)}% del total`,
|
finding: `Concentración de volumen: ${topSkill.skill} representa ${topSkillPct.toFixed(0)}% del total`,
|
||||||
probableCause: 'Dependencia excesiva de un skill puede indicar oportunidad de autoservicio o automatización parcial.',
|
probableCause: `Alta concentración en un skill indica consultas repetitivas con potencial de automatización.`,
|
||||||
economicImpact: deflectionPotential,
|
economicImpact: deflectionPotential,
|
||||||
impactFormula: `${topSkill.volume.toLocaleString()} int × 12 × €${CPI_TCO} × 20% deflexión potencial`,
|
impactFormula: `${topSkill.volume.toLocaleString()} int × anualización × €${CPI_TCO} × 20% deflexión potencial`,
|
||||||
recommendation: `Analizar top consultas de ${topSkill.skill} para identificar candidatas a deflexión digital o FAQ automatizado.`,
|
timeSavings: `${annualTopSkillVolume.toLocaleString()} interacciones/año en ${topSkill.skill} (${interactionsDeflectable.toLocaleString()} automatizables)`,
|
||||||
|
recommendation: `Analizar tipologías de ${topSkill.skill} para deflexión a autoservicio o agente virtual. Potencial: ${formatCurrency(deflectionPotential)}/año.`,
|
||||||
severity: 'info',
|
severity: 'info',
|
||||||
hasRealData: true
|
hasRealData: true
|
||||||
});
|
});
|
||||||
@@ -163,65 +227,102 @@ function generateCausalAnalysis(
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'complexity_predictability':
|
case 'complexity_predictability':
|
||||||
// v3.11: Análisis de complejidad basado en hold time y CV
|
// KPI principal: CV AHT (predictability metric per industry standards)
|
||||||
if (avgHoldTime > 45) {
|
// Siempre mostrar análisis de CV AHT ya que es el KPI de esta dimensión
|
||||||
const excessHold = avgHoldTime - 30;
|
const cvBenchmark = 75; // Best practice: CV AHT < 75%
|
||||||
const holdCost = Math.round((excessHold / 3600) * totalVolume * 12 * 25);
|
|
||||||
|
if (avgCVAHT > cvBenchmark) {
|
||||||
|
const staffingCost = Math.round(economicModel.currentAnnualCost * 0.03);
|
||||||
|
const staffingHours = Math.round(staffingCost / HOURLY_COST);
|
||||||
|
const standardizationSavings = Math.round(staffingCost * 0.50);
|
||||||
|
|
||||||
|
// Determinar severidad basada en CV AHT
|
||||||
|
const cvSeverity = avgCVAHT > 125 ? 'critical' : avgCVAHT > 100 ? 'warning' : 'warning';
|
||||||
|
|
||||||
|
// Causa dinámica basada en nivel de variabilidad
|
||||||
|
const cvCause = avgCVAHT > 125
|
||||||
|
? 'Dispersión extrema en tiempos de atención impide planificación efectiva de recursos. Probable falta de scripts o procesos estandarizados.'
|
||||||
|
: 'Variabilidad moderada en tiempos indica oportunidad de estandarización para mejorar planificación WFM.';
|
||||||
|
|
||||||
analyses.push({
|
analyses.push({
|
||||||
finding: `Hold time elevado: ${avgHoldTime.toFixed(0)}s promedio (benchmark: <30s)`,
|
finding: `CV AHT elevado: ${avgCVAHT.toFixed(0)}% (benchmark: <${cvBenchmark}%)`,
|
||||||
probableCause: 'Consultas complejas requieren búsqueda de información durante la llamada. Posible falta de acceso rápido a datos o sistemas.',
|
probableCause: cvCause,
|
||||||
economicImpact: holdCost,
|
economicImpact: staffingCost,
|
||||||
impactFormula: `Exceso ${Math.round(excessHold)}s × ${totalVolume.toLocaleString()} int × 12 × €25/h`,
|
impactFormula: `~3% del coste operativo por ineficiencia de staffing`,
|
||||||
recommendation: 'Implementar acceso contextual a información del cliente y reducir sistemas fragmentados.',
|
timeSavings: `~${staffingHours.toLocaleString()} horas/año en sobre/subdimensionamiento`,
|
||||||
severity: avgHoldTime > 60 ? 'critical' : 'warning',
|
recommendation: `Implementar scripts guiados por IA que estandaricen la atención. Reducción esperada: -50% variabilidad. Ahorro: ${formatCurrency(standardizationSavings)}/año.`,
|
||||||
|
severity: cvSeverity,
|
||||||
|
hasRealData: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// CV AHT dentro de benchmark - mostrar estado positivo
|
||||||
|
analyses.push({
|
||||||
|
finding: `CV AHT dentro de benchmark: ${avgCVAHT.toFixed(0)}% (benchmark: <${cvBenchmark}%)`,
|
||||||
|
probableCause: 'Tiempos de atención consistentes. Buena estandarización de procesos.',
|
||||||
|
economicImpact: 0,
|
||||||
|
impactFormula: 'Sin impacto por variabilidad',
|
||||||
|
timeSavings: 'Planificación WFM eficiente',
|
||||||
|
recommendation: 'Mantener nivel actual. Analizar casos atípicos para identificar oportunidades de mejora continua.',
|
||||||
|
severity: 'info',
|
||||||
hasRealData: true
|
hasRealData: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (avgCVAHT > 100) {
|
// Análisis secundario: Hold Time (proxy de complejidad)
|
||||||
|
if (avgHoldTime > 45) {
|
||||||
|
const excessHold = avgHoldTime - 30;
|
||||||
|
const annualVolumeHold = Math.round(totalVolume * annualizationFactor);
|
||||||
|
const excessHoldHours = Math.round((excessHold / 3600) * annualVolumeHold);
|
||||||
|
const holdCost = Math.round(excessHoldHours * HOURLY_COST);
|
||||||
|
const searchCopilotSavings = Math.round(holdCost * 0.60);
|
||||||
analyses.push({
|
analyses.push({
|
||||||
finding: `Alta impredecibilidad: CV AHT ${avgCVAHT.toFixed(0)}% (benchmark: <75%)`,
|
finding: `Hold time elevado: ${avgHoldTime.toFixed(0)}s promedio (benchmark: <30s)`,
|
||||||
probableCause: 'Procesos con alta variabilidad dificultan la planificación de recursos y el staffing.',
|
probableCause: 'Agentes ponen cliente en espera para buscar información. Sistemas no presentan datos de forma contextual.',
|
||||||
economicImpact: Math.round(economicModel.currentAnnualCost * 0.03),
|
economicImpact: holdCost,
|
||||||
impactFormula: `~3% del coste operativo por ineficiencia de staffing`,
|
impactFormula: `Exceso ${Math.round(excessHold)}s × ${totalVolume.toLocaleString()} int × anualización × €${HOURLY_COST}/h`,
|
||||||
recommendation: 'Segmentar procesos por complejidad y estandarizar los más frecuentes.',
|
timeSavings: `${excessHoldHours.toLocaleString()} horas/año de cliente en espera`,
|
||||||
severity: 'warning',
|
recommendation: `Desplegar vista 360° con contexto automático: historial, productos y acciones sugeridas visibles al contestar. Reducción esperada: -60% hold time. Ahorro: ${formatCurrency(searchCopilotSavings)}/año.`,
|
||||||
|
severity: avgHoldTime > 60 ? 'critical' : 'warning',
|
||||||
hasRealData: true
|
hasRealData: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'customer_satisfaction':
|
case 'customer_satisfaction':
|
||||||
// v3.11: Solo generar análisis si hay datos de CSAT reales
|
// Solo generar análisis si hay datos de CSAT reales
|
||||||
if (avgCSAT > 0) {
|
if (avgCSAT > 0) {
|
||||||
if (avgCSAT < 70) {
|
if (avgCSAT < 70) {
|
||||||
// Estimación conservadora: impacto en retención
|
const annualVolumeCsat = Math.round(totalVolume * annualizationFactor);
|
||||||
const churnRisk = Math.round(totalVolume * 12 * 0.02 * 50); // 2% churn × €50 valor medio
|
const customersAtRisk = Math.round(annualVolumeCsat * 0.02);
|
||||||
|
const churnRisk = Math.round(customersAtRisk * 50);
|
||||||
analyses.push({
|
analyses.push({
|
||||||
finding: `CSAT por debajo del objetivo: ${avgCSAT.toFixed(0)}% (benchmark: >80%)`,
|
finding: `CSAT por debajo del objetivo: ${avgCSAT.toFixed(0)}% (benchmark: >80%)`,
|
||||||
probableCause: 'Experiencia del cliente subóptima puede estar relacionada con tiempos de espera, resolución incompleta, o trato del agente.',
|
probableCause: 'Clientes insatisfechos por esperas, falta de resolución o experiencia de atención deficiente.',
|
||||||
economicImpact: churnRisk,
|
economicImpact: churnRisk,
|
||||||
impactFormula: `${totalVolume.toLocaleString()} clientes × 12 × 2% riesgo churn × €50 valor`,
|
impactFormula: `${totalVolume.toLocaleString()} clientes × anualización × 2% riesgo churn × €50 valor`,
|
||||||
recommendation: 'Implementar programa de voz del cliente (VoC) y cerrar loop de feedback.',
|
timeSavings: `${customersAtRisk.toLocaleString()} clientes/año en riesgo de fuga`,
|
||||||
|
recommendation: `Implementar programa VoC: encuestas post-contacto + análisis de causas raíz + acción correctiva en 48h. Objetivo: CSAT >80%.`,
|
||||||
severity: avgCSAT < 50 ? 'critical' : 'warning',
|
severity: avgCSAT < 50 ? 'critical' : 'warning',
|
||||||
hasRealData: true
|
hasRealData: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Si no hay CSAT, no generamos análisis falso
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'economy_cpi':
|
case 'economy_cpi':
|
||||||
// Análisis de CPI
|
// Análisis de CPI
|
||||||
if (CPI > 3.5) {
|
if (CPI > 3.5) {
|
||||||
const excessCPI = CPI - CPI_TCO;
|
const excessCPI = CPI - CPI_TCO;
|
||||||
const potentialSavings = Math.round(totalVolume * 12 * excessCPI);
|
const annualVolumeCpi = Math.round(totalVolume * annualizationFactor);
|
||||||
|
const potentialSavings = Math.round(annualVolumeCpi * excessCPI);
|
||||||
|
const excessHours = Math.round(potentialSavings / HOURLY_COST);
|
||||||
analyses.push({
|
analyses.push({
|
||||||
finding: `CPI por encima del benchmark: €${CPI.toFixed(2)} (objetivo: €${CPI_TCO})`,
|
finding: `CPI por encima del benchmark: €${CPI.toFixed(2)} (objetivo: €${CPI_TCO})`,
|
||||||
probableCause: 'Combinación de AHT alto, baja productividad efectiva, o costes de personal por encima del mercado.',
|
probableCause: 'Coste por interacción elevado por AHT alto, baja ocupación o estructura de costes ineficiente.',
|
||||||
economicImpact: potentialSavings,
|
economicImpact: potentialSavings,
|
||||||
impactFormula: `${totalVolume.toLocaleString()} int × 12 × €${excessCPI.toFixed(2)} exceso CPI`,
|
impactFormula: `${totalVolume.toLocaleString()} int × anualización × €${excessCPI.toFixed(2)} exceso CPI`,
|
||||||
recommendation: 'Revisar mix de canales, optimizar procesos para reducir AHT y evaluar modelo de staffing.',
|
timeSavings: `€${excessCPI.toFixed(2)} exceso/int × ${annualVolumeCpi.toLocaleString()} int = ${excessHours.toLocaleString()}h equivalentes`,
|
||||||
|
recommendation: `Optimizar mix de canales + reducir AHT con automatización + revisar modelo de staffing. Objetivo: CPI <€${CPI_TCO}.`,
|
||||||
severity: CPI > 5 ? 'critical' : 'warning',
|
severity: CPI > 5 ? 'critical' : 'warning',
|
||||||
hasRealData: true
|
hasRealData: true
|
||||||
});
|
});
|
||||||
@@ -362,11 +463,11 @@ function DimensionCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Análisis Causal Completo - Solo si hay datos */}
|
{/* Hallazgo Clave - Solo si hay datos */}
|
||||||
{dimension.score >= 0 && causalAnalyses.length > 0 && (
|
{dimension.score >= 0 && causalAnalyses.length > 0 && (
|
||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||||
Análisis Causal
|
Hallazgo Clave
|
||||||
</h4>
|
</h4>
|
||||||
{causalAnalyses.map((analysis, idx) => {
|
{causalAnalyses.map((analysis, idx) => {
|
||||||
const config = getSeverityConfig(analysis.severity);
|
const config = getSeverityConfig(analysis.severity);
|
||||||
@@ -395,10 +496,18 @@ function DimensionCard({
|
|||||||
<span className="text-xs font-bold text-red-600">
|
<span className="text-xs font-bold text-red-600">
|
||||||
{formatCurrency(analysis.economicImpact)}
|
{formatCurrency(analysis.economicImpact)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500">impacto anual estimado</span>
|
<span className="text-xs text-gray-500">impacto anual (coste del problema)</span>
|
||||||
<span className="text-xs text-gray-400">i</span>
|
<span className="text-xs text-gray-400">i</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ahorro de tiempo - da credibilidad al cálculo económico */}
|
||||||
|
{analysis.timeSavings && (
|
||||||
|
<div className="ml-6 mb-2 flex items-center gap-2">
|
||||||
|
<Clock className="w-3 h-3 text-blue-500" />
|
||||||
|
<span className="text-xs text-blue-700">{analysis.timeSavings}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Recomendación inline */}
|
{/* Recomendación inline */}
|
||||||
<div className="ml-6 p-2 bg-white rounded border border-gray-200">
|
<div className="ml-6 p-2 bg-white rounded border border-gray-200">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -412,7 +521,7 @@ function DimensionCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fallback: Hallazgos originales si no hay análisis causal - Solo si hay datos */}
|
{/* Fallback: Hallazgos originales si no hay hallazgo clave - Solo si hay datos */}
|
||||||
{dimension.score >= 0 && causalAnalyses.length === 0 && findings.length > 0 && (
|
{dimension.score >= 0 && causalAnalyses.length === 0 && findings.length > 0 && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
||||||
@@ -445,7 +554,7 @@ function DimensionCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recommendations Preview - Solo si no hay análisis causal y hay datos */}
|
{/* Recommendations Preview - Solo si no hay hallazgo clave y hay datos */}
|
||||||
{dimension.score >= 0 && causalAnalyses.length === 0 && recommendations.length > 0 && (
|
{dimension.score >= 0 && causalAnalyses.length === 0 && recommendations.length > 0 && (
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<div className="p-3 bg-blue-50 rounded-lg border border-blue-100">
|
<div className="p-3 bg-blue-50 rounded-lg border border-blue-100">
|
||||||
@@ -473,9 +582,9 @@ export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
|
|||||||
const getRecommendationsForDimension = (dimensionId: string) =>
|
const getRecommendationsForDimension = (dimensionId: string) =>
|
||||||
data.recommendations.filter(r => r.dimensionId === dimensionId);
|
data.recommendations.filter(r => r.dimensionId === dimensionId);
|
||||||
|
|
||||||
// Generar análisis causal para cada dimensión
|
// Generar hallazgo clave para cada dimensión
|
||||||
const getCausalAnalysisForDimension = (dimension: DimensionAnalysis) =>
|
const getCausalAnalysisForDimension = (dimension: DimensionAnalysis) =>
|
||||||
generateCausalAnalysis(dimension, data.heatmapData, data.economicModel);
|
generateCausalAnalysis(dimension, data.heatmapData, data.economicModel, data.staticConfig, data.dateRange);
|
||||||
|
|
||||||
// Calcular impacto total de todas las dimensiones con datos
|
// Calcular impacto total de todas las dimensiones con datos
|
||||||
const impactoTotal = coreDimensions
|
const impactoTotal = coreDimensions
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1533
frontend/components/tabs/Law10Tab.tsx
Normal file
1533
frontend/components/tabs/Law10Tab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,8 @@ import {
|
|||||||
formatNumber,
|
formatNumber,
|
||||||
formatPercent,
|
formatPercent,
|
||||||
} from '../../config/designSystem';
|
} from '../../config/designSystem';
|
||||||
|
import OpportunityMatrixPro from '../OpportunityMatrixPro';
|
||||||
|
import OpportunityPrioritizer from '../OpportunityPrioritizer';
|
||||||
|
|
||||||
interface RoadmapTabProps {
|
interface RoadmapTabProps {
|
||||||
data: AnalysisData;
|
data: AnalysisData;
|
||||||
@@ -372,12 +374,6 @@ const formatROI = (roi: number, roiAjustado: number): {
|
|||||||
return { text: roiDisplay, showAjustado, isHighWarning };
|
return { text: roiDisplay, showAjustado, isHighWarning };
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (value: number): string => {
|
|
||||||
if (value >= 1000000) return `€${(value / 1000000).toFixed(1)}M`;
|
|
||||||
if (value >= 1000) return `€${Math.round(value / 1000)}K`;
|
|
||||||
return `€${value.toLocaleString()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========== COMPONENTE: MAPA DE OPORTUNIDADES v3.5 ==========
|
// ========== COMPONENTE: MAPA DE OPORTUNIDADES v3.5 ==========
|
||||||
// Ejes actualizados:
|
// Ejes actualizados:
|
||||||
// - X: FACTIBILIDAD = Score Agentic Readiness (0-10)
|
// - X: FACTIBILIDAD = Score Agentic Readiness (0-10)
|
||||||
@@ -415,24 +411,31 @@ const CPI_CONFIG = {
|
|||||||
RATE_AUGMENT: 0.15 // 15% mejora en optimización
|
RATE_AUGMENT: 0.15 // 15% mejora en optimización
|
||||||
};
|
};
|
||||||
|
|
||||||
// v3.6: Calcular ahorro TCO realista con fórmula explícita
|
// Período de datos: el volumen corresponde a 11 meses, no es mensual
|
||||||
|
const DATA_PERIOD_MONTHS = 11;
|
||||||
|
|
||||||
|
// v4.2: Calcular ahorro TCO realista con fórmula explícita
|
||||||
|
// IMPORTANTE: El volumen es de 11 meses, se convierte a anual: (Vol/11) × 12
|
||||||
function calculateTCOSavings(volume: number, tier: AgenticTier): number {
|
function calculateTCOSavings(volume: number, tier: AgenticTier): number {
|
||||||
if (volume === 0) return 0;
|
if (volume === 0) return 0;
|
||||||
|
|
||||||
const { CPI_HUMANO, CPI_BOT, CPI_ASSIST, CPI_AUGMENT, RATE_AUTOMATE, RATE_ASSIST, RATE_AUGMENT } = CPI_CONFIG;
|
const { CPI_HUMANO, CPI_BOT, CPI_ASSIST, CPI_AUGMENT, RATE_AUTOMATE, RATE_ASSIST, RATE_AUGMENT } = CPI_CONFIG;
|
||||||
|
|
||||||
|
// Convertir volumen del período (11 meses) a volumen anual
|
||||||
|
const annualVolume = (volume / DATA_PERIOD_MONTHS) * 12;
|
||||||
|
|
||||||
switch (tier) {
|
switch (tier) {
|
||||||
case 'AUTOMATE':
|
case 'AUTOMATE':
|
||||||
// Ahorro = Vol × 12 × 70% × (CPI_humano - CPI_bot)
|
// Ahorro = VolAnual × 70% × (CPI_humano - CPI_bot)
|
||||||
return Math.round(volume * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT));
|
return Math.round(annualVolume * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT));
|
||||||
|
|
||||||
case 'ASSIST':
|
case 'ASSIST':
|
||||||
// Ahorro = Vol × 12 × 30% × (CPI_humano - CPI_assist)
|
// Ahorro = VolAnual × 30% × (CPI_humano - CPI_assist)
|
||||||
return Math.round(volume * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST));
|
return Math.round(annualVolume * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST));
|
||||||
|
|
||||||
case 'AUGMENT':
|
case 'AUGMENT':
|
||||||
// Ahorro = Vol × 12 × 15% × (CPI_humano - CPI_augment)
|
// Ahorro = VolAnual × 15% × (CPI_humano - CPI_augment)
|
||||||
return Math.round(volume * 12 * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT));
|
return Math.round(annualVolume * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT));
|
||||||
|
|
||||||
case 'HUMAN-ONLY':
|
case 'HUMAN-ONLY':
|
||||||
default:
|
default:
|
||||||
@@ -1736,12 +1739,13 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
|||||||
const totalVolume = Object.values(tierVolumes).reduce((a, b) => a + b, 0) || 1;
|
const totalVolume = Object.values(tierVolumes).reduce((a, b) => a + b, 0) || 1;
|
||||||
|
|
||||||
// Calcular ahorros potenciales por tier usando fórmula TCO
|
// Calcular ahorros potenciales por tier usando fórmula TCO
|
||||||
|
// IMPORTANTE: El volumen es de 11 meses, se convierte a anual: (Vol/11) × 12
|
||||||
const { CPI_HUMANO, CPI_BOT, CPI_ASSIST, CPI_AUGMENT, RATE_AUTOMATE, RATE_ASSIST, RATE_AUGMENT } = CPI_CONFIG;
|
const { CPI_HUMANO, CPI_BOT, CPI_ASSIST, CPI_AUGMENT, RATE_AUTOMATE, RATE_ASSIST, RATE_AUGMENT } = CPI_CONFIG;
|
||||||
|
|
||||||
const potentialSavings = {
|
const potentialSavings = {
|
||||||
AUTOMATE: Math.round(tierVolumes.AUTOMATE * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT)),
|
AUTOMATE: Math.round((tierVolumes.AUTOMATE / DATA_PERIOD_MONTHS) * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT)),
|
||||||
ASSIST: Math.round(tierVolumes.ASSIST * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST)),
|
ASSIST: Math.round((tierVolumes.ASSIST / DATA_PERIOD_MONTHS) * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST)),
|
||||||
AUGMENT: Math.round(tierVolumes.AUGMENT * 12 * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT))
|
AUGMENT: Math.round((tierVolumes.AUGMENT / DATA_PERIOD_MONTHS) * 12 * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT))
|
||||||
};
|
};
|
||||||
|
|
||||||
// Colas que necesitan Wave 1 (Tier 3 + 4)
|
// Colas que necesitan Wave 1 (Tier 3 + 4)
|
||||||
@@ -1797,7 +1801,7 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
|||||||
borderColor: 'border-amber-200',
|
borderColor: 'border-amber-200',
|
||||||
inversionSetup: 35000,
|
inversionSetup: 35000,
|
||||||
costoRecurrenteAnual: 40000,
|
costoRecurrenteAnual: 40000,
|
||||||
ahorroAnual: potentialSavings.AUGMENT || 58000, // 15% efficiency
|
ahorroAnual: potentialSavings.AUGMENT, // 15% efficiency - calculado desde datos reales
|
||||||
esCondicional: true,
|
esCondicional: true,
|
||||||
condicion: 'Requiere CV ≤75% post-Wave 1 en colas target',
|
condicion: 'Requiere CV ≤75% post-Wave 1 en colas target',
|
||||||
porQueNecesario: `Implementar herramientas de soporte para colas Tier 3 (Score 3.5-5.5). Objetivo: elevar score a ≥5.5 para habilitar Wave 3. Foco en ${tierCounts.AUGMENT.length} colas con ${tierVolumes.AUGMENT.toLocaleString()} int/mes.`,
|
porQueNecesario: `Implementar herramientas de soporte para colas Tier 3 (Score 3.5-5.5). Objetivo: elevar score a ≥5.5 para habilitar Wave 3. Foco en ${tierCounts.AUGMENT.length} colas con ${tierVolumes.AUGMENT.toLocaleString()} int/mes.`,
|
||||||
@@ -1830,7 +1834,7 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
|||||||
borderColor: 'border-blue-200',
|
borderColor: 'border-blue-200',
|
||||||
inversionSetup: 70000,
|
inversionSetup: 70000,
|
||||||
costoRecurrenteAnual: 78000,
|
costoRecurrenteAnual: 78000,
|
||||||
ahorroAnual: potentialSavings.ASSIST || 145000, // 30% efficiency
|
ahorroAnual: potentialSavings.ASSIST, // 30% efficiency - calculado desde datos reales
|
||||||
esCondicional: true,
|
esCondicional: true,
|
||||||
condicion: 'Requiere Score ≥5.5 Y CV ≤90% Y Transfer ≤30%',
|
condicion: 'Requiere Score ≥5.5 Y CV ≤90% Y Transfer ≤30%',
|
||||||
porQueNecesario: `Copilot IA para agentes en colas Tier 2. Sugerencias en tiempo real, autocompletado, next-best-action. Objetivo: elevar score a ≥7.5 para Wave 4. Target: ${tierCounts.ASSIST.length} colas con ${tierVolumes.ASSIST.toLocaleString()} int/mes.`,
|
porQueNecesario: `Copilot IA para agentes en colas Tier 2. Sugerencias en tiempo real, autocompletado, next-best-action. Objetivo: elevar score a ≥7.5 para Wave 4. Target: ${tierCounts.ASSIST.length} colas con ${tierVolumes.ASSIST.toLocaleString()} int/mes.`,
|
||||||
@@ -1864,7 +1868,7 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
|||||||
borderColor: 'border-emerald-200',
|
borderColor: 'border-emerald-200',
|
||||||
inversionSetup: 85000,
|
inversionSetup: 85000,
|
||||||
costoRecurrenteAnual: 108000,
|
costoRecurrenteAnual: 108000,
|
||||||
ahorroAnual: potentialSavings.AUTOMATE || 380000, // 70% containment
|
ahorroAnual: potentialSavings.AUTOMATE, // 70% containment - calculado desde datos reales
|
||||||
esCondicional: true,
|
esCondicional: true,
|
||||||
condicion: 'Requiere Score ≥7.5 Y CV ≤75% Y Transfer ≤20% Y FCR ≥50%',
|
condicion: 'Requiere Score ≥7.5 Y CV ≤75% Y Transfer ≤20% Y FCR ≥50%',
|
||||||
porQueNecesario: `Automatización end-to-end para colas Tier 1. Voicebot/Chatbot transaccional con 70% contención. Solo viable con procesos maduros. Target actual: ${tierCounts.AUTOMATE.length} colas con ${tierVolumes.AUTOMATE.toLocaleString()} int/mes.`,
|
porQueNecesario: `Automatización end-to-end para colas Tier 1. Voicebot/Chatbot transaccional con 70% contención. Solo viable con procesos maduros. Target actual: ${tierCounts.AUTOMATE.length} colas con ${tierVolumes.AUTOMATE.toLocaleString()} int/mes.`,
|
||||||
@@ -1906,9 +1910,10 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
|||||||
const wave4Setup = 85000;
|
const wave4Setup = 85000;
|
||||||
const wave4Rec = 108000;
|
const wave4Rec = 108000;
|
||||||
|
|
||||||
const wave2Savings = potentialSavings.AUGMENT || Math.round(tierVolumes.AUGMENT * 12 * 0.15 * 0.33);
|
// Usar potentialSavings (ya corregidos con factor 12/11)
|
||||||
const wave3Savings = potentialSavings.ASSIST || Math.round(tierVolumes.ASSIST * 12 * 0.30 * 0.83);
|
const wave2Savings = potentialSavings.AUGMENT;
|
||||||
const wave4Savings = potentialSavings.AUTOMATE || Math.round(tierVolumes.AUTOMATE * 12 * 0.70 * 2.18);
|
const wave3Savings = potentialSavings.ASSIST;
|
||||||
|
const wave4Savings = potentialSavings.AUTOMATE;
|
||||||
|
|
||||||
// Escenario 1: Conservador (Wave 1-2: FOUNDATION + AUGMENT)
|
// Escenario 1: Conservador (Wave 1-2: FOUNDATION + AUGMENT)
|
||||||
const consInversion = wave1Setup + wave2Setup;
|
const consInversion = wave1Setup + wave2Setup;
|
||||||
@@ -2520,85 +2525,17 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
{/* ENFOQUE DUAL: Explicación + Tabla comparativa */}
|
{/* ENFOQUE DUAL: Párrafo explicativo */}
|
||||||
{recType === 'DUAL' && (
|
{recType === 'DUAL' && (
|
||||||
<>
|
<p className="text-sm text-gray-600 leading-relaxed">
|
||||||
{/* Explicación de los dos tracks */}
|
La Estrategia Dual consiste en ejecutar dos líneas de trabajo en paralelo:
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<strong className="text-gray-800"> Quick Win</strong> automatiza inmediatamente las {pilotQueues.length} colas
|
||||||
<div className="p-3 bg-gray-50 rounded-lg">
|
ya preparadas (Tier AUTOMATE, {Math.round(totalVolume > 0 ? (tierVolumes.AUTOMATE / totalVolume) * 100 : 0)}% del volumen), generando retorno desde el primer mes;
|
||||||
<p className="font-semibold text-gray-800 mb-1">Track A: Quick Win</p>
|
mientras que <strong className="text-gray-800">Foundation</strong> prepara el {Math.round(assistPct + augmentPct)}%
|
||||||
<p className="text-xs text-gray-600">
|
restante del volumen (Tiers ASSIST y AUGMENT) estandarizando procesos y reduciendo variabilidad para habilitar
|
||||||
Automatización inmediata de las colas ya preparadas (Tier AUTOMATE).
|
automatización futura. Este enfoque maximiza el time-to-value: Quick Win financia la transformación y genera
|
||||||
Genera retorno desde el primer mes y valida el modelo de IA con bajo riesgo.
|
confianza organizacional, mientras Foundation amplía progresivamente el alcance de la automatización.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-gray-50 rounded-lg">
|
|
||||||
<p className="font-semibold text-gray-800 mb-1">Track B: Foundation</p>
|
|
||||||
<p className="text-xs text-gray-600">
|
|
||||||
Preparación de las colas que aún no están listas (Tier 3-4).
|
|
||||||
Estandariza procesos y reduce variabilidad para habilitar automatización futura.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabla comparativa */}
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-gray-200">
|
|
||||||
<th className="text-left py-2 font-medium text-gray-500"></th>
|
|
||||||
<th className="text-center py-2 font-medium text-gray-700">Quick Win</th>
|
|
||||||
<th className="text-center py-2 font-medium text-gray-700">Foundation</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="text-gray-600">
|
|
||||||
<tr className="border-b border-gray-100">
|
|
||||||
<td className="py-2 text-gray-500 text-xs">Alcance</td>
|
|
||||||
<td className="py-2 text-center">
|
|
||||||
<span className="font-medium text-gray-800">{pilotQueues.length} colas</span>
|
|
||||||
<span className="text-xs text-gray-400 block">{pilotVolume.toLocaleString()} int/mes</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 text-center">
|
|
||||||
<span className="font-medium text-gray-800">{tierCounts['HUMAN-ONLY'].length + tierCounts.AUGMENT.length} colas</span>
|
|
||||||
<span className="text-xs text-gray-400 block">Wave 1 + Wave 2</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="border-b border-gray-100">
|
|
||||||
<td className="py-2 text-gray-500 text-xs">Inversión</td>
|
|
||||||
<td className="py-2 text-center font-medium text-gray-800">{formatCurrency(pilotInversionTotal)}</td>
|
|
||||||
<td className="py-2 text-center font-medium text-gray-800">{formatCurrency(wave1Setup + wave2Setup)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="border-b border-gray-100">
|
|
||||||
<td className="py-2 text-gray-500 text-xs">Retorno</td>
|
|
||||||
<td className="py-2 text-center">
|
|
||||||
<span className="font-medium text-gray-800">{formatCurrency(pilotAhorroAjustado)}/año</span>
|
|
||||||
<span className="text-xs text-gray-400 block">directo (ajustado 50%)</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 text-center">
|
|
||||||
<span className="font-medium text-gray-800">{formatCurrency(potentialSavings.ASSIST + potentialSavings.AUGMENT)}/año</span>
|
|
||||||
<span className="text-xs text-gray-400 block">habilitado (indirecto)</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="border-b border-gray-100">
|
|
||||||
<td className="py-2 text-gray-500 text-xs">Timeline</td>
|
|
||||||
<td className="py-2 text-center text-gray-800">2-3 meses</td>
|
|
||||||
<td className="py-2 text-center text-gray-800">6-9 meses</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td className="py-2 text-gray-500 text-xs">ROI Year 1</td>
|
|
||||||
<td className="py-2 text-center">
|
|
||||||
<span className="font-semibold text-gray-900">{pilotROIDisplay.display}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 text-center text-gray-500 text-xs">No aplica (habilitador)</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div className="text-xs text-gray-500 border-t border-gray-100 pt-3">
|
|
||||||
<strong className="text-gray-700">¿Por qué dos tracks?</strong> Quick Win genera caja y confianza desde el inicio.
|
|
||||||
Foundation prepara el {Math.round(assistPct + augmentPct)}% restante del volumen para fases posteriores.
|
|
||||||
Ejecutarlos en paralelo acelera el time-to-value total.
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* FOUNDATION PRIMERO */}
|
{/* FOUNDATION PRIMERO */}
|
||||||
@@ -2765,6 +2702,16 @@ export function RoadmapTab({ data }: RoadmapTabProps) {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
OPORTUNIDADES PRIORIZADAS - Nueva visualización clara y accionable
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ */}
|
||||||
|
{data.opportunities && data.opportunities.length > 0 && (
|
||||||
|
<OpportunityPrioritizer
|
||||||
|
opportunities={data.opportunities}
|
||||||
|
drilldownData={data.drilldownData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ export interface OriginalQueueMetrics {
|
|||||||
aht_mean: number; // AHT promedio (segundos)
|
aht_mean: number; // AHT promedio (segundos)
|
||||||
cv_aht: number; // CV AHT calculado solo sobre VALID (%)
|
cv_aht: number; // CV AHT calculado solo sobre VALID (%)
|
||||||
transfer_rate: number; // Tasa de transferencia (%)
|
transfer_rate: number; // Tasa de transferencia (%)
|
||||||
fcr_rate: number; // FCR (%)
|
fcr_rate: number; // FCR Real (%) - usa fcr_real_flag, incluye filtro recontacto 7d
|
||||||
|
fcr_tecnico: number; // FCR Técnico (%) = 100 - transfer_rate, comparable con benchmarks
|
||||||
agenticScore: number; // Score de automatización (0-10)
|
agenticScore: number; // Score de automatización (0-10)
|
||||||
scoreBreakdown?: AgenticScoreBreakdown; // v3.4: Desglose por factores
|
scoreBreakdown?: AgenticScoreBreakdown; // v3.4: Desglose por factores
|
||||||
tier: AgenticTier; // v3.4: Clasificación para roadmap
|
tier: AgenticTier; // v3.4: Clasificación para roadmap
|
||||||
@@ -115,7 +116,8 @@ export interface DrilldownDataPoint {
|
|||||||
aht_mean: number; // AHT promedio ponderado (segundos)
|
aht_mean: number; // AHT promedio ponderado (segundos)
|
||||||
cv_aht: number; // CV AHT promedio ponderado (%)
|
cv_aht: number; // CV AHT promedio ponderado (%)
|
||||||
transfer_rate: number; // Tasa de transferencia ponderada (%)
|
transfer_rate: number; // Tasa de transferencia ponderada (%)
|
||||||
fcr_rate: number; // FCR ponderado (%)
|
fcr_rate: number; // FCR Real ponderado (%) - usa fcr_real_flag
|
||||||
|
fcr_tecnico: number; // FCR Técnico ponderado (%) = 100 - transfer_rate
|
||||||
agenticScore: number; // Score de automatización promedio (0-10)
|
agenticScore: number; // Score de automatización promedio (0-10)
|
||||||
isPriorityCandidate: boolean; // Al menos una cola con CV < 75%
|
isPriorityCandidate: boolean; // Al menos una cola con CV < 75%
|
||||||
annualCost?: number; // Coste anual total del grupo
|
annualCost?: number; // Coste anual total del grupo
|
||||||
@@ -128,7 +130,9 @@ export interface SkillMetrics {
|
|||||||
channel: string; // Canal predominante
|
channel: string; // Canal predominante
|
||||||
|
|
||||||
// Métricas de rendimiento (calculadas)
|
// Métricas de rendimiento (calculadas)
|
||||||
fcr: number; // FCR aproximado: 100% - transfer_rate
|
fcr: number; // FCR Real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE) - sin recontacto 7 días
|
||||||
|
fcr_tecnico: number; // FCR Técnico: 100% - transfer_rate (comparable con benchmarks de industria)
|
||||||
|
fcr_real: number; // Alias de fcr - FCR Real con filtro de recontacto 7 días
|
||||||
aht: number; // AHT = duration_talk + hold_time + wrap_up_time
|
aht: number; // AHT = duration_talk + hold_time + wrap_up_time
|
||||||
avg_talk_time: number; // Promedio duration_talk
|
avg_talk_time: number; // Promedio duration_talk
|
||||||
avg_hold_time: number; // Promedio hold_time
|
avg_hold_time: number; // Promedio hold_time
|
||||||
@@ -205,16 +209,21 @@ export interface HeatmapDataPoint {
|
|||||||
skill: string;
|
skill: string;
|
||||||
segment?: CustomerSegment; // Segmento de cliente (high/medium/low)
|
segment?: CustomerSegment; // Segmento de cliente (high/medium/low)
|
||||||
volume: number; // Volumen mensual de interacciones
|
volume: number; // Volumen mensual de interacciones
|
||||||
aht_seconds: number; // AHT en segundos (para cálculo de coste)
|
cost_volume?: number; // Volumen usado para calcular coste (non-abandon)
|
||||||
|
aht_seconds: number; // AHT "limpio" en segundos (solo valid, excluye noise/zombie/abandon) - para métricas de calidad
|
||||||
|
aht_total?: number; // AHT "total" en segundos (ALL rows incluyendo noise/zombie/abandon) - solo informativo
|
||||||
|
aht_benchmark?: number; // AHT "tradicional" en segundos (incluye noise, excluye zombie/abandon) - para comparación con benchmarks de industria
|
||||||
metrics: {
|
metrics: {
|
||||||
fcr: number; // First Contact Resolution score (0-100) - CALCULADO
|
fcr: number; // FCR Real: sin transferencia Y sin recontacto 7 días (0-100) - CALCULADO
|
||||||
|
fcr_tecnico?: number; // FCR Técnico: sin transferencia (comparable con benchmarks industria)
|
||||||
aht: number; // Average Handle Time score (0-100, donde 100 es óptimo) - CALCULADO
|
aht: number; // Average Handle Time score (0-100, donde 100 es óptimo) - CALCULADO
|
||||||
csat: number; // Customer Satisfaction score (0-100) - MANUAL (estático)
|
csat: number; // Customer Satisfaction score (0-100) - MANUAL (estático)
|
||||||
hold_time: number; // Hold Time promedio (segundos) - CALCULADO
|
hold_time: number; // Hold Time promedio (segundos) - CALCULADO
|
||||||
transfer_rate: number; // % transferencias - CALCULADO
|
transfer_rate: number; // % transferencias - CALCULADO
|
||||||
abandonment_rate: number; // % abandonos - CALCULADO
|
abandonment_rate: number; // % abandonos - CALCULADO
|
||||||
};
|
};
|
||||||
annual_cost?: number; // Coste anual en euros (calculado con cost_per_hour)
|
annual_cost?: number; // Coste total del período (calculado con cost_per_hour)
|
||||||
|
cpi?: number; // Coste por interacción = total_cost / cost_volume
|
||||||
|
|
||||||
// v2.0: Métricas de variabilidad interna
|
// v2.0: Métricas de variabilidad interna
|
||||||
variability: {
|
variability: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// analysisGenerator.ts - v2.0 con 6 dimensiones
|
// analysisGenerator.ts - v2.0 con 6 dimensiones
|
||||||
import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment, RawInteraction, DrilldownDataPoint, AgenticTier } from '../types';
|
import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment, RawInteraction, DrilldownDataPoint, AgenticTier } from '../types';
|
||||||
import { generateAnalysisFromRealData, calculateDrilldownMetrics, generateOpportunitiesFromDrilldown, generateRoadmapFromDrilldown } from './realDataAnalysis';
|
import { generateAnalysisFromRealData, calculateDrilldownMetrics, generateOpportunitiesFromDrilldown, generateRoadmapFromDrilldown, calculateSkillMetrics, generateHeatmapFromMetrics, clasificarTierSimple } from './realDataAnalysis';
|
||||||
import { RoadmapPhase } from '../types';
|
import { RoadmapPhase } from '../types';
|
||||||
import { BarChartHorizontal, Zap, Target, Brain, Bot } from 'lucide-react';
|
import { BarChartHorizontal, Zap, Target, Brain, Bot } from 'lucide-react';
|
||||||
import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2';
|
import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2';
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
mapBackendResultsToAnalysisData,
|
mapBackendResultsToAnalysisData,
|
||||||
buildHeatmapFromBackend,
|
buildHeatmapFromBackend,
|
||||||
} from './backendMapper';
|
} from './backendMapper';
|
||||||
import { saveFileToServerCache, saveDrilldownToServerCache, getCachedDrilldown } from './serverCache';
|
import { saveFileToServerCache, saveDrilldownToServerCache, getCachedDrilldown, downloadCachedFile } from './serverCache';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -532,9 +532,12 @@ const generateHeatmapData = (
|
|||||||
const transfer_rate = randomInt(5, 35); // %
|
const transfer_rate = randomInt(5, 35); // %
|
||||||
const fcr_approx = 100 - transfer_rate; // FCR aproximado
|
const fcr_approx = 100 - transfer_rate; // FCR aproximado
|
||||||
|
|
||||||
// Coste anual
|
// Coste del período (mensual) - con factor de productividad 70%
|
||||||
const annual_volume = volume * 12;
|
const effectiveProductivity = 0.70;
|
||||||
const annual_cost = Math.round(annual_volume * aht_mean * COST_PER_SECOND);
|
const period_cost = Math.round((aht_mean / 3600) * costPerHour * volume / effectiveProductivity);
|
||||||
|
const annual_cost = period_cost; // Renombrado por compatibilidad, pero es coste mensual
|
||||||
|
// CPI = coste por interacción
|
||||||
|
const cpi = volume > 0 ? period_cost / volume : 0;
|
||||||
|
|
||||||
// === NUEVA LÓGICA: 3 DIMENSIONES ===
|
// === NUEVA LÓGICA: 3 DIMENSIONES ===
|
||||||
|
|
||||||
@@ -597,6 +600,7 @@ const generateHeatmapData = (
|
|||||||
skill,
|
skill,
|
||||||
segment,
|
segment,
|
||||||
volume,
|
volume,
|
||||||
|
cost_volume: volume, // En datos sintéticos, asumimos que todos son non-abandon
|
||||||
aht_seconds: aht_mean, // Renombrado para compatibilidad
|
aht_seconds: aht_mean, // Renombrado para compatibilidad
|
||||||
metrics: {
|
metrics: {
|
||||||
fcr: isNaN(fcr_approx) ? 0 : Math.max(0, Math.min(100, Math.round(fcr_approx))),
|
fcr: isNaN(fcr_approx) ? 0 : Math.max(0, Math.min(100, Math.round(fcr_approx))),
|
||||||
@@ -606,6 +610,7 @@ const generateHeatmapData = (
|
|||||||
transfer_rate: isNaN(transfer_rate) ? 0 : Math.max(0, Math.min(100, Math.round(transfer_rate * 100)))
|
transfer_rate: isNaN(transfer_rate) ? 0 : Math.max(0, Math.min(100, Math.round(transfer_rate * 100)))
|
||||||
},
|
},
|
||||||
annual_cost,
|
annual_cost,
|
||||||
|
cpi,
|
||||||
variability: {
|
variability: {
|
||||||
cv_aht: Math.round(cv_aht * 100), // Convertir a porcentaje
|
cv_aht: Math.round(cv_aht * 100), // Convertir a porcentaje
|
||||||
cv_talk_time: 0, // Deprecado en v2.1
|
cv_talk_time: 0, // Deprecado en v2.1
|
||||||
@@ -624,29 +629,6 @@ const generateHeatmapData = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// v3.0: Oportunidades con nuevas dimensiones
|
|
||||||
const generateOpportunityMatrixData = (): Opportunity[] => {
|
|
||||||
const opportunities = [
|
|
||||||
{ id: 'opp1', name: 'Automatizar consulta de pedidos', savings: 85000, dimensionId: 'agentic_readiness', customer_segment: 'medium' as CustomerSegment },
|
|
||||||
{ id: 'opp2', name: 'Implementar Knowledge Base dinámica', savings: 45000, dimensionId: 'operational_efficiency', customer_segment: 'high' as CustomerSegment },
|
|
||||||
{ id: 'opp3', name: 'Chatbot de triaje inicial', savings: 120000, dimensionId: 'effectiveness_resolution', customer_segment: 'medium' as CustomerSegment },
|
|
||||||
{ id: 'opp4', name: 'Reducir complejidad en colas críticas', savings: 30000, dimensionId: 'complexity_predictability', customer_segment: 'high' as CustomerSegment },
|
|
||||||
{ id: 'opp5', name: 'Cobertura 24/7 con agentes virtuales', savings: 65000, dimensionId: 'volumetry_distribution', customer_segment: 'low' as CustomerSegment },
|
|
||||||
];
|
|
||||||
return opportunities.map(opp => ({ ...opp, impact: randomInt(3, 10), feasibility: randomInt(2, 9) }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// v3.0: Roadmap con nuevas dimensiones
|
|
||||||
const generateRoadmapData = (): RoadmapInitiative[] => {
|
|
||||||
return [
|
|
||||||
{ id: 'r1', name: 'Chatbot de estado de pedido', phase: RoadmapPhase.Automate, timeline: 'Q1 2025', investment: 25000, resources: ['1x Bot Developer', 'API Access'], dimensionId: 'agentic_readiness', risk: 'low' },
|
|
||||||
{ id: 'r2', name: 'Implementar Knowledge Base dinámica', phase: RoadmapPhase.Assist, timeline: 'Q1 2025', investment: 15000, resources: ['1x PM', 'Content Team'], dimensionId: 'operational_efficiency', risk: 'low' },
|
|
||||||
{ id: 'r3', name: 'Agent Assist para sugerencias en real-time', phase: RoadmapPhase.Assist, timeline: 'Q2 2025', investment: 45000, resources: ['2x AI Devs', 'QA Team'], dimensionId: 'effectiveness_resolution', risk: 'medium' },
|
|
||||||
{ id: 'r4', name: 'Estandarización de procesos complejos', phase: RoadmapPhase.Augment, timeline: 'Q3 2025', investment: 30000, resources: ['Process Analyst', 'Training Team'], dimensionId: 'complexity_predictability', risk: 'medium' },
|
|
||||||
{ id: 'r5', name: 'Cobertura 24/7 con agentes virtuales', phase: RoadmapPhase.Augment, timeline: 'Q4 2025', investment: 75000, resources: ['Lead AI Engineer', 'Data Scientist'], dimensionId: 'volumetry_distribution', risk: 'high' },
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
// v2.0: Añadir NPV y costBreakdown
|
// v2.0: Añadir NPV y costBreakdown
|
||||||
const generateEconomicModelData = (): EconomicModelData => {
|
const generateEconomicModelData = (): EconomicModelData => {
|
||||||
const currentAnnualCost = randomInt(800000, 2500000);
|
const currentAnnualCost = randomInt(800000, 2500000);
|
||||||
@@ -691,123 +673,6 @@ const generateEconomicModelData = (): EconomicModelData => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// v2.x: Generar Opportunity Matrix a partir de datos REALES (heatmap + modelo económico)
|
|
||||||
const generateOpportunitiesFromHeatmap = (
|
|
||||||
heatmapData: HeatmapDataPoint[],
|
|
||||||
economicModel?: EconomicModelData
|
|
||||||
): Opportunity[] => {
|
|
||||||
if (!heatmapData || heatmapData.length === 0) return [];
|
|
||||||
|
|
||||||
// Ahorro anual total calculado por el backend (si existe)
|
|
||||||
const globalSavings = economicModel?.annualSavings ?? 0;
|
|
||||||
|
|
||||||
// 1) Calculamos un "peso" por skill en función de:
|
|
||||||
// - coste anual
|
|
||||||
// - ineficiencia (FCR bajo)
|
|
||||||
// - readiness (facilidad para automatizar)
|
|
||||||
const scored = heatmapData.map((h) => {
|
|
||||||
const annualCost = h.annual_cost ?? 0;
|
|
||||||
const readiness = h.automation_readiness ?? 0;
|
|
||||||
const fcrScore = h.metrics?.fcr ?? 0;
|
|
||||||
|
|
||||||
// FCR bajo => más ineficiencia
|
|
||||||
const ineffPenalty = Math.max(0, 100 - fcrScore); // 0–100
|
|
||||||
// Peso base: coste alto + ineficiencia alta + readiness alto
|
|
||||||
const baseWeight =
|
|
||||||
annualCost *
|
|
||||||
(1 + ineffPenalty / 100) *
|
|
||||||
(0.3 + 0.7 * (readiness / 100));
|
|
||||||
|
|
||||||
const weight = !Number.isFinite(baseWeight) || baseWeight < 0 ? 0 : baseWeight;
|
|
||||||
|
|
||||||
return { heat: h, weight };
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalWeight =
|
|
||||||
scored.reduce((sum, s) => sum + s.weight, 0) || 1;
|
|
||||||
|
|
||||||
// 2) Asignamos "savings" (ahorro potencial) por skill
|
|
||||||
const opportunitiesWithSavings = scored.map((s) => {
|
|
||||||
const { heat } = s;
|
|
||||||
const annualCost = heat.annual_cost ?? 0;
|
|
||||||
|
|
||||||
// Si el backend nos da un ahorro anual total, lo distribuimos proporcionalmente
|
|
||||||
const savings =
|
|
||||||
globalSavings > 0 && totalWeight > 0
|
|
||||||
? (globalSavings * s.weight) / totalWeight
|
|
||||||
: // Si no hay dato de ahorro global, suponemos un 20% del coste anual
|
|
||||||
annualCost * 0.2;
|
|
||||||
|
|
||||||
return {
|
|
||||||
heat,
|
|
||||||
savings: Math.max(0, savings),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxSavings =
|
|
||||||
opportunitiesWithSavings.reduce(
|
|
||||||
(max, s) => (s.savings > max ? s.savings : max),
|
|
||||||
0
|
|
||||||
) || 1;
|
|
||||||
|
|
||||||
// 3) Construimos cada oportunidad
|
|
||||||
return opportunitiesWithSavings.map((item, index) => {
|
|
||||||
const { heat, savings } = item;
|
|
||||||
const skillName = heat.skill || `Skill ${index + 1}`;
|
|
||||||
|
|
||||||
// Impacto: relativo al mayor ahorro
|
|
||||||
const impactRaw = (savings / maxSavings) * 10;
|
|
||||||
const impact = Math.max(
|
|
||||||
3,
|
|
||||||
Math.min(10, Math.round(impactRaw))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Factibilidad base: a partir del automation_readiness (0–100)
|
|
||||||
const readiness = heat.automation_readiness ?? 0;
|
|
||||||
const feasibilityRaw = (readiness / 100) * 7 + 3; // 3–10
|
|
||||||
const feasibility = Math.max(
|
|
||||||
3,
|
|
||||||
Math.min(10, Math.round(feasibilityRaw))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Dimensión a la que lo vinculamos
|
|
||||||
const dimensionId =
|
|
||||||
readiness >= 70
|
|
||||||
? 'agentic_readiness'
|
|
||||||
: readiness >= 40
|
|
||||||
? 'effectiveness_resolution'
|
|
||||||
: 'complexity_predictability';
|
|
||||||
|
|
||||||
// Segmento de cliente (high/medium/low) si lo tenemos
|
|
||||||
const customer_segment = heat.segment;
|
|
||||||
|
|
||||||
// Nombre legible que incluye el skill -> esto ayuda a
|
|
||||||
// OpportunityMatrixPro a encontrar el skill en el heatmap
|
|
||||||
const namePrefix =
|
|
||||||
readiness >= 70
|
|
||||||
? 'Automatizar '
|
|
||||||
: readiness >= 40
|
|
||||||
? 'Asistir con IA en '
|
|
||||||
: 'Optimizar procesos en ';
|
|
||||||
|
|
||||||
const idSlug = skillName
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '_')
|
|
||||||
.replace(/^_+|_+$/g, '');
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `opp_${index + 1}_${idSlug}`,
|
|
||||||
name: `${namePrefix}${skillName}`,
|
|
||||||
impact,
|
|
||||||
feasibility,
|
|
||||||
savings: Math.round(savings),
|
|
||||||
dimensionId,
|
|
||||||
customer_segment,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// v2.0: Añadir percentiles múltiples
|
// v2.0: Añadir percentiles múltiples
|
||||||
const generateBenchmarkData = (): BenchmarkDataPoint[] => {
|
const generateBenchmarkData = (): BenchmarkDataPoint[] => {
|
||||||
const userAHT = randomInt(380, 450);
|
const userAHT = randomInt(380, 450);
|
||||||
@@ -929,27 +794,41 @@ export const generateAnalysis = async (
|
|||||||
// Añadir dateRange extraído del archivo
|
// Añadir dateRange extraído del archivo
|
||||||
mapped.dateRange = dateRange;
|
mapped.dateRange = dateRange;
|
||||||
|
|
||||||
// Heatmap: primero lo construimos a partir de datos reales del backend
|
// Heatmap: usar cálculos del frontend (parsedInteractions) para consistencia
|
||||||
|
// 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(
|
mapped.heatmapData = buildHeatmapFromBackend(
|
||||||
raw,
|
raw,
|
||||||
costPerHour,
|
costPerHour,
|
||||||
avgCsat,
|
avgCsat,
|
||||||
segmentMapping
|
segmentMapping
|
||||||
);
|
);
|
||||||
|
console.log('📊 Heatmap generado desde backend (fallback - sin parsedInteractions)');
|
||||||
|
}
|
||||||
|
|
||||||
// v3.5: Calcular drilldownData PRIMERO (necesario para opportunities y roadmap)
|
// v3.5: Calcular drilldownData PRIMERO (necesario para opportunities y roadmap)
|
||||||
if (parsedInteractions && parsedInteractions.length > 0) {
|
if (parsedInteractions && parsedInteractions.length > 0) {
|
||||||
mapped.drilldownData = calculateDrilldownMetrics(parsedInteractions, costPerHour);
|
mapped.drilldownData = calculateDrilldownMetrics(parsedInteractions, costPerHour);
|
||||||
console.log(`📊 Drill-down calculado: ${mapped.drilldownData.length} skills, ${mapped.drilldownData.filter(d => d.isPriorityCandidate).length} candidatos prioritarios`);
|
console.log(`📊 Drill-down calculado: ${mapped.drilldownData.length} skills, ${mapped.drilldownData.filter(d => d.isPriorityCandidate).length} candidatos prioritarios`);
|
||||||
|
|
||||||
// Cachear drilldownData en el servidor para uso futuro (no bloquea)
|
// v4.4: Cachear drilldownData en el servidor ANTES de retornar (fix: era fire-and-forget)
|
||||||
|
// Esto asegura que el cache esté disponible cuando el usuario haga "Usar Cache"
|
||||||
if (authHeaderOverride && mapped.drilldownData.length > 0) {
|
if (authHeaderOverride && mapped.drilldownData.length > 0) {
|
||||||
saveDrilldownToServerCache(authHeaderOverride, mapped.drilldownData)
|
try {
|
||||||
.then(success => {
|
const cacheSuccess = await saveDrilldownToServerCache(authHeaderOverride, mapped.drilldownData);
|
||||||
if (success) console.log('💾 DrilldownData cacheado en servidor');
|
if (cacheSuccess) {
|
||||||
else console.warn('⚠️ No se pudo cachear drilldownData');
|
console.log('💾 DrilldownData cacheado en servidor correctamente');
|
||||||
})
|
} else {
|
||||||
.catch(err => console.warn('⚠️ Error cacheando drilldownData:', err));
|
console.warn('⚠️ No se pudo cachear drilldownData - fallback a heatmap en próximo uso');
|
||||||
|
}
|
||||||
|
} catch (cacheErr) {
|
||||||
|
console.warn('⚠️ Error cacheando drilldownData:', cacheErr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usar oportunidades y roadmap basados en drilldownData (datos reales)
|
// Usar oportunidades y roadmap basados en drilldownData (datos reales)
|
||||||
@@ -957,13 +836,11 @@ export const generateAnalysis = async (
|
|||||||
mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour);
|
mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour);
|
||||||
console.log(`📊 Opportunities: ${mapped.opportunities.length}, Roadmap: ${mapped.roadmap.length}`);
|
console.log(`📊 Opportunities: ${mapped.opportunities.length}, Roadmap: ${mapped.roadmap.length}`);
|
||||||
} else {
|
} else {
|
||||||
console.warn('⚠️ No hay interacciones parseadas, usando heatmap para opportunities');
|
console.warn('⚠️ No hay interacciones parseadas, usando heatmap para drilldown');
|
||||||
// Fallback: usar heatmap (menos preciso)
|
// v4.3: Generar drilldownData desde heatmap para usar mismas funciones
|
||||||
mapped.opportunities = generateOpportunitiesFromHeatmap(
|
mapped.drilldownData = generateDrilldownFromHeatmap(mapped.heatmapData, costPerHour);
|
||||||
mapped.heatmapData,
|
mapped.opportunities = generateOpportunitiesFromDrilldown(mapped.drilldownData, costPerHour);
|
||||||
mapped.economicModel
|
mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour);
|
||||||
);
|
|
||||||
mapped.roadmap = generateRoadmapData();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Findings y recommendations
|
// Findings y recommendations
|
||||||
@@ -1162,16 +1039,62 @@ export const generateAnalysisFromCache = async (
|
|||||||
mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour);
|
mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour);
|
||||||
console.log(`📊 Opportunities: ${mapped.opportunities.length}, Roadmap: ${mapped.roadmap.length}`);
|
console.log(`📊 Opportunities: ${mapped.opportunities.length}, Roadmap: ${mapped.roadmap.length}`);
|
||||||
} else if (mapped.heatmapData && mapped.heatmapData.length > 0) {
|
} else if (mapped.heatmapData && mapped.heatmapData.length > 0) {
|
||||||
// Fallback: usar heatmap (solo 9 skills agregados)
|
// v4.5: No hay drilldownData cacheado - intentar calcularlo desde el CSV cacheado
|
||||||
console.warn('⚠️ Sin drilldownData cacheado, usando heatmap fallback');
|
console.log('⚠️ No cached drilldownData found, attempting to calculate from cached CSV...');
|
||||||
mapped.drilldownData = generateDrilldownFromHeatmap(mapped.heatmapData, costPerHour);
|
|
||||||
console.log(`📊 Drill-down desde heatmap (fallback): ${mapped.drilldownData.length} skills`);
|
|
||||||
|
|
||||||
mapped.opportunities = generateOpportunitiesFromHeatmap(
|
let calculatedDrilldown = false;
|
||||||
mapped.heatmapData,
|
|
||||||
mapped.economicModel
|
try {
|
||||||
);
|
// Descargar y parsear el CSV cacheado para calcular drilldown real
|
||||||
mapped.roadmap = generateRoadmapData();
|
const cachedFile = await downloadCachedFile(authHeaderOverride);
|
||||||
|
if (cachedFile) {
|
||||||
|
console.log(`📥 Downloaded cached CSV: ${(cachedFile.size / 1024 / 1024).toFixed(2)} MB`);
|
||||||
|
|
||||||
|
const { parseFile } = await import('./fileParser');
|
||||||
|
const parsedInteractions = await parseFile(cachedFile);
|
||||||
|
|
||||||
|
if (parsedInteractions && parsedInteractions.length > 0) {
|
||||||
|
console.log(`📊 Parsed ${parsedInteractions.length} interactions from cached CSV`);
|
||||||
|
|
||||||
|
// Calcular drilldown real desde interacciones
|
||||||
|
mapped.drilldownData = calculateDrilldownMetrics(parsedInteractions, costPerHour);
|
||||||
|
console.log(`📊 Calculated drilldown: ${mapped.drilldownData.length} skills`);
|
||||||
|
|
||||||
|
// Guardar drilldown en cache para próximo uso
|
||||||
|
try {
|
||||||
|
const saveSuccess = await saveDrilldownToServerCache(authHeaderOverride, mapped.drilldownData);
|
||||||
|
if (saveSuccess) {
|
||||||
|
console.log('💾 DrilldownData saved to cache for future use');
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ Failed to save drilldownData to cache');
|
||||||
|
}
|
||||||
|
} catch (saveErr) {
|
||||||
|
console.warn('⚠️ Error saving drilldownData to cache:', saveErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
calculatedDrilldown = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (csvErr) {
|
||||||
|
console.warn('⚠️ Could not calculate drilldown from cached CSV:', csvErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!calculatedDrilldown) {
|
||||||
|
// Fallback final: usar heatmap (datos aproximados)
|
||||||
|
console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
console.warn('⚠️ FALLBACK ACTIVO: No hay drilldownData cacheado');
|
||||||
|
console.warn(' Causa probable: El CSV no se subió correctamente o la caché expiró');
|
||||||
|
console.warn(' Consecuencia: Usando datos agregados del heatmap (menos precisos)');
|
||||||
|
console.warn(' Solución: Vuelva a subir el archivo CSV para obtener datos completos');
|
||||||
|
console.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
|
||||||
|
mapped.drilldownData = generateDrilldownFromHeatmap(mapped.heatmapData, costPerHour);
|
||||||
|
console.log(`📊 Drill-down desde heatmap (fallback): ${mapped.drilldownData.length} skills agregados`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usar mismas funciones que ruta fresh para consistencia
|
||||||
|
mapped.opportunities = generateOpportunitiesFromDrilldown(mapped.drilldownData, costPerHour);
|
||||||
|
mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Findings y recommendations
|
// Findings y recommendations
|
||||||
@@ -1201,15 +1124,21 @@ function generateDrilldownFromHeatmap(
|
|||||||
const cvAht = hp.variability?.cv_aht || 0;
|
const cvAht = hp.variability?.cv_aht || 0;
|
||||||
const transferRate = hp.variability?.transfer_rate || hp.metrics?.transfer_rate || 0;
|
const transferRate = hp.variability?.transfer_rate || hp.metrics?.transfer_rate || 0;
|
||||||
const fcrRate = hp.metrics?.fcr || 0;
|
const fcrRate = hp.metrics?.fcr || 0;
|
||||||
|
// FCR Técnico: usar el campo si existe, sino calcular como 100 - transfer_rate
|
||||||
|
const fcrTecnico = hp.metrics?.fcr_tecnico ?? (100 - transferRate);
|
||||||
const agenticScore = hp.dimensions
|
const agenticScore = hp.dimensions
|
||||||
? (hp.dimensions.predictability * 0.4 + hp.dimensions.complexity_inverse * 0.35 + hp.dimensions.repetitivity * 0.25)
|
? (hp.dimensions.predictability * 0.4 + hp.dimensions.complexity_inverse * 0.35 + hp.dimensions.repetitivity * 0.25)
|
||||||
: (hp.automation_readiness || 0) / 10;
|
: (hp.automation_readiness || 0) / 10;
|
||||||
|
|
||||||
// Determinar tier basado en el score
|
// v4.4: Usar clasificarTierSimple con TODOS los datos disponibles del heatmap
|
||||||
let tier: AgenticTier = 'HUMAN-ONLY';
|
// cvAht, transferRate y fcrRate están en % (ej: 75), clasificarTierSimple espera decimal (ej: 0.75)
|
||||||
if (agenticScore >= 7.5) tier = 'AUTOMATE';
|
const tier = clasificarTierSimple(
|
||||||
else if (agenticScore >= 5.5) tier = 'ASSIST';
|
agenticScore,
|
||||||
else if (agenticScore >= 3.5) tier = 'AUGMENT';
|
cvAht / 100, // CV como decimal
|
||||||
|
transferRate / 100, // Transfer como decimal
|
||||||
|
fcrRate / 100, // FCR como decimal (nuevo en v4.4)
|
||||||
|
hp.volume // Volumen para red flag check (nuevo en v4.4)
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
skill: hp.skill,
|
skill: hp.skill,
|
||||||
@@ -1219,6 +1148,7 @@ function generateDrilldownFromHeatmap(
|
|||||||
cv_aht: cvAht,
|
cv_aht: cvAht,
|
||||||
transfer_rate: transferRate,
|
transfer_rate: transferRate,
|
||||||
fcr_rate: fcrRate,
|
fcr_rate: fcrRate,
|
||||||
|
fcr_tecnico: fcrTecnico, // FCR Técnico para consistencia con Summary
|
||||||
agenticScore: agenticScore,
|
agenticScore: agenticScore,
|
||||||
isPriorityCandidate: cvAht < 75,
|
isPriorityCandidate: cvAht < 75,
|
||||||
originalQueues: [{
|
originalQueues: [{
|
||||||
@@ -1229,6 +1159,7 @@ function generateDrilldownFromHeatmap(
|
|||||||
cv_aht: cvAht,
|
cv_aht: cvAht,
|
||||||
transfer_rate: transferRate,
|
transfer_rate: transferRate,
|
||||||
fcr_rate: fcrRate,
|
fcr_rate: fcrRate,
|
||||||
|
fcr_tecnico: fcrTecnico, // FCR Técnico para consistencia con Summary
|
||||||
agenticScore: agenticScore,
|
agenticScore: agenticScore,
|
||||||
tier: tier,
|
tier: tier,
|
||||||
isPriorityCandidate: cvAht < 75,
|
isPriorityCandidate: cvAht < 75,
|
||||||
@@ -1334,18 +1265,23 @@ const generateSyntheticAnalysis = (
|
|||||||
Object.values(item.metrics).some(v => isNaN(v))
|
Object.values(item.metrics).some(v => isNaN(v))
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v4.3: Generar drilldownData desde heatmap para usar mismas funciones
|
||||||
|
const drilldownData = generateDrilldownFromHeatmap(heatmapData, costPerHour);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tier,
|
tier,
|
||||||
overallHealthScore,
|
overallHealthScore,
|
||||||
summaryKpis,
|
summaryKpis,
|
||||||
dimensions,
|
dimensions,
|
||||||
heatmapData,
|
heatmapData,
|
||||||
|
drilldownData,
|
||||||
agenticReadiness,
|
agenticReadiness,
|
||||||
findings: generateFindingsFromTemplates(),
|
findings: generateFindingsFromTemplates(),
|
||||||
recommendations: generateRecommendationsFromTemplates(),
|
recommendations: generateRecommendationsFromTemplates(),
|
||||||
opportunities: generateOpportunityMatrixData(),
|
opportunities: generateOpportunitiesFromDrilldown(drilldownData, costPerHour),
|
||||||
economicModel: generateEconomicModelData(),
|
economicModel: generateEconomicModelData(),
|
||||||
roadmap: generateRoadmapData(),
|
roadmap: generateRoadmapFromDrilldown(drilldownData, costPerHour),
|
||||||
benchmarkData: generateBenchmarkData(),
|
benchmarkData: generateBenchmarkData(),
|
||||||
source: 'synthetic',
|
source: 'synthetic',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
DimensionAnalysis,
|
DimensionAnalysis,
|
||||||
Kpi,
|
Kpi,
|
||||||
EconomicModelData,
|
EconomicModelData,
|
||||||
|
Finding,
|
||||||
|
Recommendation,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { BackendRawResults } from './apiClient';
|
import type { BackendRawResults } from './apiClient';
|
||||||
import { BarChartHorizontal, Zap, Target, Brain, Bot, Smile, DollarSign } from 'lucide-react';
|
import { BarChartHorizontal, Zap, Target, Brain, Bot, Smile, DollarSign } from 'lucide-react';
|
||||||
@@ -290,6 +292,7 @@ function buildVolumetryDimension(
|
|||||||
const maxHourly = validHourly.length > 0 ? Math.max(...validHourly) : 0;
|
const maxHourly = validHourly.length > 0 ? Math.max(...validHourly) : 0;
|
||||||
const minHourly = validHourly.length > 0 ? Math.min(...validHourly) : 1;
|
const minHourly = validHourly.length > 0 ? Math.min(...validHourly) : 1;
|
||||||
const peakValleyRatio = minHourly > 0 ? maxHourly / minHourly : 1;
|
const peakValleyRatio = minHourly > 0 ? maxHourly / minHourly : 1;
|
||||||
|
console.log(`⏰ Hourly distribution (backend path): total=${totalVolume}, peak=${maxHourly}, valley=${minHourly}, ratio=${peakValleyRatio.toFixed(2)}`);
|
||||||
|
|
||||||
// Score basado en:
|
// Score basado en:
|
||||||
// - % fuera de horario (>30% penaliza)
|
// - % fuera de horario (>30% penaliza)
|
||||||
@@ -406,11 +409,12 @@ function buildOperationalEfficiencyDimension(
|
|||||||
summary += `AHT Horario Laboral (8-19h): ${ahtBusinessHours}s (P50), ratio ${ratioBusinessHours.toFixed(2)}. `;
|
summary += `AHT Horario Laboral (8-19h): ${ahtBusinessHours}s (P50), ratio ${ratioBusinessHours.toFixed(2)}. `;
|
||||||
summary += variabilityInsight;
|
summary += variabilityInsight;
|
||||||
|
|
||||||
|
// KPI principal: AHT P50 (industry standard for operational efficiency)
|
||||||
const kpi: Kpi = {
|
const kpi: Kpi = {
|
||||||
label: 'Ratio P90/P50 Global',
|
label: 'AHT P50',
|
||||||
value: ratioGlobal.toFixed(2),
|
value: `${Math.round(ahtP50)}s`,
|
||||||
change: `Horario laboral: ${ratioBusinessHours.toFixed(2)}`,
|
change: `Ratio: ${ratioGlobal.toFixed(2)}`,
|
||||||
changeType: ratioGlobal > 2.5 ? 'negative' : ratioGlobal > 1.8 ? 'neutral' : 'positive'
|
changeType: ahtP50 > 360 ? 'negative' : ahtP50 > 300 ? 'neutral' : 'positive'
|
||||||
};
|
};
|
||||||
|
|
||||||
const dimension: DimensionAnalysis = {
|
const dimension: DimensionAnalysis = {
|
||||||
@@ -427,7 +431,7 @@ function buildOperationalEfficiencyDimension(
|
|||||||
return dimension;
|
return dimension;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==== Efectividad & Resolución (v3.2 - enfocada en FCR y recontactos) ====
|
// ==== Efectividad & Resolución (v3.2 - enfocada en FCR Técnico) ====
|
||||||
|
|
||||||
function buildEffectivenessResolutionDimension(
|
function buildEffectivenessResolutionDimension(
|
||||||
raw: BackendRawResults
|
raw: BackendRawResults
|
||||||
@@ -435,31 +439,29 @@ function buildEffectivenessResolutionDimension(
|
|||||||
const op = raw?.operational_performance;
|
const op = raw?.operational_performance;
|
||||||
if (!op) return undefined;
|
if (!op) return undefined;
|
||||||
|
|
||||||
// FCR: métrica principal de efectividad
|
// FCR Técnico = 100 - transfer_rate (comparable con benchmarks de industria)
|
||||||
const fcrPctRaw = safeNumber(op.fcr_rate, NaN);
|
// Usamos escalation_rate que es la tasa de transferencias
|
||||||
const recurrenceRaw = safeNumber(op.recurrence_rate_7d, NaN);
|
const escalationRate = safeNumber(op.escalation_rate, NaN);
|
||||||
const abandonmentRate = safeNumber(op.abandonment_rate, 0);
|
const abandonmentRate = safeNumber(op.abandonment_rate, 0);
|
||||||
|
|
||||||
// FCR real o proxy desde recontactos
|
// FCR Técnico: 100 - tasa de transferencia
|
||||||
const fcrRate = Number.isFinite(fcrPctRaw) && fcrPctRaw >= 0
|
const fcrRate = Number.isFinite(escalationRate) && escalationRate >= 0
|
||||||
? Math.max(0, Math.min(100, fcrPctRaw))
|
? Math.max(0, Math.min(100, 100 - escalationRate))
|
||||||
: Number.isFinite(recurrenceRaw)
|
|
||||||
? Math.max(0, Math.min(100, 100 - recurrenceRaw))
|
|
||||||
: 70; // valor por defecto benchmark aéreo
|
: 70; // valor por defecto benchmark aéreo
|
||||||
|
|
||||||
// Recontactos a 7 días (complemento del FCR)
|
// Tasa de transferencia (complemento del FCR Técnico)
|
||||||
const recontactRate = 100 - fcrRate;
|
const transferRate = Number.isFinite(escalationRate) ? escalationRate : 100 - fcrRate;
|
||||||
|
|
||||||
// Score basado principalmente en FCR (benchmark sector aéreo: 68-72%)
|
// Score basado en FCR Técnico (benchmark sector aéreo: 85-90%)
|
||||||
// FCR >= 75% = 100pts, 70-75% = 80pts, 65-70% = 60pts, 60-65% = 40pts, <60% = 20pts
|
// FCR >= 90% = 100pts, 85-90% = 80pts, 80-85% = 60pts, 75-80% = 40pts, <75% = 20pts
|
||||||
let score: number;
|
let score: number;
|
||||||
if (fcrRate >= 75) {
|
if (fcrRate >= 90) {
|
||||||
score = 100;
|
score = 100;
|
||||||
} else if (fcrRate >= 70) {
|
} else if (fcrRate >= 85) {
|
||||||
score = 80;
|
score = 80;
|
||||||
} else if (fcrRate >= 65) {
|
} else if (fcrRate >= 80) {
|
||||||
score = 60;
|
score = 60;
|
||||||
} else if (fcrRate >= 60) {
|
} else if (fcrRate >= 75) {
|
||||||
score = 40;
|
score = 40;
|
||||||
} else {
|
} else {
|
||||||
score = 20;
|
score = 20;
|
||||||
@@ -470,23 +472,23 @@ function buildEffectivenessResolutionDimension(
|
|||||||
score = Math.max(0, score - Math.round((abandonmentRate - 8) * 2));
|
score = Math.max(0, score - Math.round((abandonmentRate - 8) * 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary enfocado en resolución, no en transferencias
|
// Summary enfocado en FCR Técnico
|
||||||
let summary = `FCR: ${fcrRate.toFixed(1)}% (benchmark sector aéreo: 68-72%). `;
|
let summary = `FCR Técnico: ${fcrRate.toFixed(1)}% (benchmark: 85-90%). `;
|
||||||
summary += `Recontactos a 7 días: ${recontactRate.toFixed(1)}%. `;
|
summary += `Tasa de transferencia: ${transferRate.toFixed(1)}%. `;
|
||||||
|
|
||||||
if (fcrRate >= 72) {
|
if (fcrRate >= 90) {
|
||||||
summary += 'Resolución por encima del benchmark del sector.';
|
summary += 'Excelente resolución en primer contacto.';
|
||||||
} else if (fcrRate >= 68) {
|
} else if (fcrRate >= 85) {
|
||||||
summary += 'Resolución dentro del benchmark del sector aéreo.';
|
summary += 'Resolución dentro del benchmark del sector.';
|
||||||
} else {
|
} else {
|
||||||
summary += 'Resolución por debajo del benchmark. Oportunidad de mejora en first contact resolution.';
|
summary += 'Oportunidad de mejora reduciendo transferencias.';
|
||||||
}
|
}
|
||||||
|
|
||||||
const kpi: Kpi = {
|
const kpi: Kpi = {
|
||||||
label: 'FCR',
|
label: 'FCR Técnico',
|
||||||
value: `${fcrRate.toFixed(0)}%`,
|
value: `${fcrRate.toFixed(0)}%`,
|
||||||
change: `Recontactos: ${recontactRate.toFixed(0)}%`,
|
change: `Transfer: ${transferRate.toFixed(0)}%`,
|
||||||
changeType: fcrRate >= 70 ? 'positive' : fcrRate >= 65 ? 'neutral' : 'negative'
|
changeType: fcrRate >= 85 ? 'positive' : fcrRate >= 80 ? 'neutral' : 'negative'
|
||||||
};
|
};
|
||||||
|
|
||||||
const dimension: DimensionAnalysis = {
|
const dimension: DimensionAnalysis = {
|
||||||
@@ -503,7 +505,7 @@ function buildEffectivenessResolutionDimension(
|
|||||||
return dimension;
|
return dimension;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==== Complejidad & Predictibilidad (v3.3 - basada en Hold Time) ====
|
// ==== Complejidad & Predictibilidad (v3.4 - basada en CV AHT per industry standards) ====
|
||||||
|
|
||||||
function buildComplexityPredictabilityDimension(
|
function buildComplexityPredictabilityDimension(
|
||||||
raw: BackendRawResults
|
raw: BackendRawResults
|
||||||
@@ -511,12 +513,19 @@ function buildComplexityPredictabilityDimension(
|
|||||||
const op = raw?.operational_performance;
|
const op = raw?.operational_performance;
|
||||||
if (!op) return undefined;
|
if (!op) return undefined;
|
||||||
|
|
||||||
// Métrica principal: % de interacciones con Hold Time > 60s
|
// KPI principal: CV AHT (industry standard for predictability/WFM)
|
||||||
// Proxy de complejidad: si el agente puso en espera al cliente >60s,
|
// CV AHT = (P90 - P50) / P50 como proxy de coeficiente de variación
|
||||||
// probablemente tuvo que consultar/investigar
|
const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
|
||||||
const highHoldRate = safeNumber(op.high_hold_time_rate, NaN);
|
const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
|
||||||
|
|
||||||
// Si no hay datos de hold time, usar fallback del P50 de hold
|
// Calcular CV AHT como (P90-P50)/P50 (proxy del coeficiente de variación real)
|
||||||
|
let cvAht = 0;
|
||||||
|
if (ahtP50 > 0 && ahtP90 > 0) {
|
||||||
|
cvAht = (ahtP90 - ahtP50) / ahtP50;
|
||||||
|
}
|
||||||
|
const cvAhtPercent = Math.round(cvAht * 100);
|
||||||
|
|
||||||
|
// Hold Time como métrica secundaria de complejidad
|
||||||
const talkHoldAcw = op.talk_hold_acw_p50_by_skill;
|
const talkHoldAcw = op.talk_hold_acw_p50_by_skill;
|
||||||
let avgHoldP50 = 0;
|
let avgHoldP50 = 0;
|
||||||
if (Array.isArray(talkHoldAcw) && talkHoldAcw.length > 0) {
|
if (Array.isArray(talkHoldAcw) && talkHoldAcw.length > 0) {
|
||||||
@@ -526,60 +535,55 @@ function buildComplexityPredictabilityDimension(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si no tenemos high_hold_time_rate del backend, estimamos desde hold_p50
|
// Score basado en CV AHT (benchmark: <75% = excelente, <100% = aceptable)
|
||||||
// Si hold_p50 promedio > 60s, asumimos ~40% de llamadas con hold alto
|
// CV <= 75% = 100pts (alta predictibilidad)
|
||||||
const effectiveHighHoldRate = Number.isFinite(highHoldRate) && highHoldRate >= 0
|
// CV 75-100% = 80pts (predictibilidad aceptable)
|
||||||
? highHoldRate
|
// CV 100-125% = 60pts (variabilidad moderada)
|
||||||
: avgHoldP50 > 60 ? 40 : avgHoldP50 > 30 ? 20 : 10;
|
// CV 125-150% = 40pts (alta variabilidad)
|
||||||
|
// CV > 150% = 20pts (muy alta variabilidad)
|
||||||
// Score: menor % de Hold alto = menor complejidad = mejor score
|
|
||||||
// <10% = 100pts (muy baja complejidad)
|
|
||||||
// 10-20% = 80pts (baja complejidad)
|
|
||||||
// 20-30% = 60pts (complejidad moderada)
|
|
||||||
// 30-40% = 40pts (alta complejidad)
|
|
||||||
// >40% = 20pts (muy alta complejidad)
|
|
||||||
let score: number;
|
let score: number;
|
||||||
if (effectiveHighHoldRate < 10) {
|
if (cvAhtPercent <= 75) {
|
||||||
score = 100;
|
score = 100;
|
||||||
} else if (effectiveHighHoldRate < 20) {
|
} else if (cvAhtPercent <= 100) {
|
||||||
score = 80;
|
score = 80;
|
||||||
} else if (effectiveHighHoldRate < 30) {
|
} else if (cvAhtPercent <= 125) {
|
||||||
score = 60;
|
score = 60;
|
||||||
} else if (effectiveHighHoldRate < 40) {
|
} else if (cvAhtPercent <= 150) {
|
||||||
score = 40;
|
score = 40;
|
||||||
} else {
|
} else {
|
||||||
score = 20;
|
score = 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary descriptivo
|
// Summary descriptivo
|
||||||
let summary = `${effectiveHighHoldRate.toFixed(1)}% de interacciones con Hold Time > 60s (proxy de consulta/investigación). `;
|
let summary = `CV AHT: ${cvAhtPercent}% (benchmark: <75%). `;
|
||||||
|
|
||||||
if (effectiveHighHoldRate < 15) {
|
if (cvAhtPercent <= 75) {
|
||||||
summary += 'Baja complejidad: la mayoría de casos se resuelven sin necesidad de consultar. Excelente para automatización.';
|
summary += 'Alta predictibilidad: tiempos de atención consistentes. Excelente para planificación WFM.';
|
||||||
} else if (effectiveHighHoldRate < 25) {
|
} else if (cvAhtPercent <= 100) {
|
||||||
summary += 'Complejidad moderada: algunos casos requieren consulta o investigación adicional.';
|
summary += 'Predictibilidad aceptable: variabilidad moderada en tiempos de atención.';
|
||||||
} else if (effectiveHighHoldRate < 35) {
|
} else if (cvAhtPercent <= 125) {
|
||||||
summary += 'Complejidad notable: frecuentemente se requiere consulta. Considerar base de conocimiento mejorada.';
|
summary += 'Variabilidad notable: dificulta la planificación de recursos. Considerar estandarización.';
|
||||||
} else {
|
} else {
|
||||||
summary += 'Alta complejidad: muchos casos requieren investigación. Priorizar documentación y herramientas de soporte.';
|
summary += 'Alta variabilidad: tiempos muy dispersos. Priorizar scripts guiados y estandarización.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Añadir info de Hold P50 promedio si está disponible
|
// Añadir info de Hold P50 promedio si está disponible (proxy de complejidad)
|
||||||
if (avgHoldP50 > 0) {
|
if (avgHoldP50 > 0) {
|
||||||
summary += ` Hold Time P50 promedio: ${Math.round(avgHoldP50)}s.`;
|
summary += ` Hold Time P50: ${Math.round(avgHoldP50)}s.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KPI principal: CV AHT (predictability metric per industry standards)
|
||||||
const kpi: Kpi = {
|
const kpi: Kpi = {
|
||||||
label: 'Hold > 60s',
|
label: 'CV AHT',
|
||||||
value: `${effectiveHighHoldRate.toFixed(0)}%`,
|
value: `${cvAhtPercent}%`,
|
||||||
change: avgHoldP50 > 0 ? `Hold P50: ${Math.round(avgHoldP50)}s` : undefined,
|
change: avgHoldP50 > 0 ? `Hold: ${Math.round(avgHoldP50)}s` : undefined,
|
||||||
changeType: effectiveHighHoldRate > 30 ? 'negative' : effectiveHighHoldRate > 15 ? 'neutral' : 'positive'
|
changeType: cvAhtPercent > 125 ? 'negative' : cvAhtPercent > 75 ? 'neutral' : 'positive'
|
||||||
};
|
};
|
||||||
|
|
||||||
const dimension: DimensionAnalysis = {
|
const dimension: DimensionAnalysis = {
|
||||||
id: 'complexity_predictability',
|
id: 'complexity_predictability',
|
||||||
name: 'complexity_predictability',
|
name: 'complexity_predictability',
|
||||||
title: 'Complejidad',
|
title: 'Complejidad & Predictibilidad',
|
||||||
score,
|
score,
|
||||||
percentile: undefined,
|
percentile: undefined,
|
||||||
summary,
|
summary,
|
||||||
@@ -630,6 +634,7 @@ function buildEconomyDimension(
|
|||||||
totalInteractions: number
|
totalInteractions: number
|
||||||
): DimensionAnalysis | undefined {
|
): DimensionAnalysis | undefined {
|
||||||
const econ = raw?.economy_costs;
|
const econ = raw?.economy_costs;
|
||||||
|
const op = raw?.operational_performance;
|
||||||
const totalAnnual = safeNumber(econ?.cost_breakdown?.total_annual, 0);
|
const totalAnnual = safeNumber(econ?.cost_breakdown?.total_annual, 0);
|
||||||
|
|
||||||
// Benchmark CPI sector contact center (Fuente: Gartner Contact Center Cost Benchmark 2024)
|
// Benchmark CPI sector contact center (Fuente: Gartner Contact Center Cost Benchmark 2024)
|
||||||
@@ -639,8 +644,12 @@ function buildEconomyDimension(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calcular CPI
|
// Calcular cost_volume (non-abandoned) para consistencia con Executive Summary
|
||||||
const cpi = totalAnnual / totalInteractions;
|
const abandonmentRate = safeNumber(op?.abandonment_rate, 0) / 100;
|
||||||
|
const costVolume = Math.round(totalInteractions * (1 - abandonmentRate));
|
||||||
|
|
||||||
|
// Calcular CPI usando cost_volume (non-abandoned) como denominador
|
||||||
|
const cpi = costVolume > 0 ? totalAnnual / costVolume : totalAnnual / totalInteractions;
|
||||||
|
|
||||||
// Score basado en comparación con benchmark (€5.00)
|
// Score basado en comparación con benchmark (€5.00)
|
||||||
// CPI <= 4.00 = 100pts (excelente)
|
// CPI <= 4.00 = 100pts (excelente)
|
||||||
@@ -1033,14 +1042,46 @@ export function mapBackendResultsToAnalysisData(
|
|||||||
const economicModel = buildEconomicModel(raw);
|
const economicModel = buildEconomicModel(raw);
|
||||||
const benchmarkData = buildBenchmarkData(raw);
|
const benchmarkData = buildBenchmarkData(raw);
|
||||||
|
|
||||||
|
// Generar findings y recommendations basados en volumetría
|
||||||
|
const findings: Finding[] = [];
|
||||||
|
const recommendations: Recommendation[] = [];
|
||||||
|
|
||||||
|
// Extraer offHoursPct de la dimensión de volumetría
|
||||||
|
const offHoursPct = volumetryDimension?.distribution_data?.off_hours_pct ?? 0;
|
||||||
|
const offHoursPctValue = offHoursPct * 100; // Convertir de 0-1 a 0-100
|
||||||
|
|
||||||
|
if (offHoursPctValue > 20) {
|
||||||
|
const offHoursVolume = Math.round(totalVolume * offHoursPctValue / 100);
|
||||||
|
findings.push({
|
||||||
|
type: offHoursPctValue > 30 ? 'critical' : 'warning',
|
||||||
|
title: 'Alto Volumen Fuera de Horario',
|
||||||
|
text: `${offHoursPctValue.toFixed(0)}% de interacciones fuera de horario (8-19h)`,
|
||||||
|
dimensionId: 'volumetry_distribution',
|
||||||
|
description: `${offHoursVolume.toLocaleString()} interacciones (${offHoursPctValue.toFixed(1)}%) ocurren fuera de horario laboral. Oportunidad ideal para implementar agentes virtuales 24/7.`,
|
||||||
|
impact: offHoursPctValue > 30 ? 'high' : 'medium'
|
||||||
|
});
|
||||||
|
|
||||||
|
const estimatedContainment = offHoursPctValue > 30 ? 60 : 45;
|
||||||
|
const estimatedSavings = Math.round(offHoursVolume * estimatedContainment / 100);
|
||||||
|
recommendations.push({
|
||||||
|
priority: 'high',
|
||||||
|
title: 'Implementar Agente Virtual 24/7',
|
||||||
|
text: `Desplegar agente virtual para atender ${offHoursPctValue.toFixed(0)}% de interacciones fuera de horario`,
|
||||||
|
description: `${offHoursVolume.toLocaleString()} interacciones ocurren fuera de horario laboral (19:00-08:00). Un agente virtual puede resolver ~${estimatedContainment}% de estas consultas automáticamente.`,
|
||||||
|
dimensionId: 'volumetry_distribution',
|
||||||
|
impact: `Potencial de contención: ${estimatedSavings.toLocaleString()} interacciones/período`,
|
||||||
|
timeline: '1-3 meses'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tier: tierFromFrontend,
|
tier: tierFromFrontend,
|
||||||
overallHealthScore,
|
overallHealthScore,
|
||||||
summaryKpis: mergedKpis,
|
summaryKpis: mergedKpis,
|
||||||
dimensions,
|
dimensions,
|
||||||
heatmapData: [], // el heatmap por skill lo seguimos generando en el front
|
heatmapData: [], // el heatmap por skill lo seguimos generando en el front
|
||||||
findings: [],
|
findings,
|
||||||
recommendations: [],
|
recommendations,
|
||||||
opportunities: [],
|
opportunities: [],
|
||||||
roadmap: [],
|
roadmap: [],
|
||||||
economicModel,
|
economicModel,
|
||||||
@@ -1082,12 +1123,24 @@ export function buildHeatmapFromBackend(
|
|||||||
const econ = raw?.economy_costs;
|
const econ = raw?.economy_costs;
|
||||||
const cs = raw?.customer_satisfaction;
|
const cs = raw?.customer_satisfaction;
|
||||||
|
|
||||||
const talkHoldAcwBySkill = Array.isArray(
|
const talkHoldAcwBySkillRaw = Array.isArray(
|
||||||
op?.talk_hold_acw_p50_by_skill
|
op?.talk_hold_acw_p50_by_skill
|
||||||
)
|
)
|
||||||
? op.talk_hold_acw_p50_by_skill
|
? op.talk_hold_acw_p50_by_skill
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// Crear lookup map por skill name para talk_hold_acw_p50
|
||||||
|
const talkHoldAcwMap = new Map<string, { talk_p50: number; hold_p50: number; acw_p50: number }>();
|
||||||
|
for (const item of talkHoldAcwBySkillRaw) {
|
||||||
|
if (item?.queue_skill) {
|
||||||
|
talkHoldAcwMap.set(String(item.queue_skill), {
|
||||||
|
talk_p50: safeNumber(item.talk_p50, 0),
|
||||||
|
hold_p50: safeNumber(item.hold_p50, 0),
|
||||||
|
acw_p50: safeNumber(item.acw_p50, 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const globalEscalation = safeNumber(op?.escalation_rate, 0);
|
const globalEscalation = safeNumber(op?.escalation_rate, 0);
|
||||||
// Usar fcr_rate del backend si existe, sino calcular como 100 - escalation
|
// Usar fcr_rate del backend si existe, sino calcular como 100 - escalation
|
||||||
const fcrRateBackend = safeNumber(op?.fcr_rate, NaN);
|
const fcrRateBackend = safeNumber(op?.fcr_rate, NaN);
|
||||||
@@ -1098,6 +1151,71 @@ export function buildHeatmapFromBackend(
|
|||||||
// Usar abandonment_rate del backend si existe
|
// Usar abandonment_rate del backend si existe
|
||||||
const abandonmentRateBackend = safeNumber(op?.abandonment_rate, 0);
|
const abandonmentRateBackend = safeNumber(op?.abandonment_rate, 0);
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// NUEVO: Métricas REALES por skill (transfer, abandonment, FCR)
|
||||||
|
// Esto elimina la estimación de transfer rate basada en CV y hold time
|
||||||
|
// ========================================================================
|
||||||
|
const metricsBySkillRaw = Array.isArray(op?.metrics_by_skill)
|
||||||
|
? op.metrics_by_skill
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Crear lookup por nombre de skill para acceso O(1)
|
||||||
|
const metricsBySkillMap = new Map<string, {
|
||||||
|
transfer_rate: number;
|
||||||
|
abandonment_rate: number;
|
||||||
|
fcr_tecnico: number;
|
||||||
|
fcr_real: number;
|
||||||
|
aht_mean: number; // AHT promedio del backend (solo VALID - consistente con fresh path)
|
||||||
|
aht_total: number; // AHT total (ALL rows incluyendo NOISE/ZOMBIE/ABANDON) - solo informativo
|
||||||
|
hold_time_mean: number; // Hold time promedio (consistente con fresh path - MEAN, no P50)
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const m of metricsBySkillRaw) {
|
||||||
|
if (m?.skill) {
|
||||||
|
metricsBySkillMap.set(String(m.skill), {
|
||||||
|
transfer_rate: safeNumber(m.transfer_rate, NaN),
|
||||||
|
abandonment_rate: safeNumber(m.abandonment_rate, NaN),
|
||||||
|
fcr_tecnico: safeNumber(m.fcr_tecnico, NaN),
|
||||||
|
fcr_real: safeNumber(m.fcr_real, NaN),
|
||||||
|
aht_mean: safeNumber(m.aht_mean, NaN), // AHT promedio (solo VALID)
|
||||||
|
aht_total: safeNumber(m.aht_total, NaN), // AHT total (ALL rows)
|
||||||
|
hold_time_mean: safeNumber(m.hold_time_mean, NaN), // Hold time promedio (MEAN)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRealMetricsBySkill = metricsBySkillMap.size > 0;
|
||||||
|
if (hasRealMetricsBySkill) {
|
||||||
|
console.log('✅ Usando métricas REALES por skill del backend:', metricsBySkillMap.size, 'skills');
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ No hay metrics_by_skill del backend, usando estimación basada en CV/hold');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// NUEVO: CPI por skill desde cpi_by_skill_channel
|
||||||
|
// Esto permite que el cached path tenga CPI real como el fresh path
|
||||||
|
// ========================================================================
|
||||||
|
const cpiBySkillRaw = Array.isArray(econ?.cpi_by_skill_channel)
|
||||||
|
? econ.cpi_by_skill_channel
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Crear lookup por nombre de skill para CPI
|
||||||
|
const cpiBySkillMap = new Map<string, number>();
|
||||||
|
for (const item of cpiBySkillRaw) {
|
||||||
|
if (item?.queue_skill || item?.skill) {
|
||||||
|
const skillKey = String(item.queue_skill ?? item.skill);
|
||||||
|
const cpiValue = safeNumber(item.cpi_total ?? item.cpi, NaN);
|
||||||
|
if (Number.isFinite(cpiValue)) {
|
||||||
|
cpiBySkillMap.set(skillKey, cpiValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCpiBySkill = cpiBySkillMap.size > 0;
|
||||||
|
if (hasCpiBySkill) {
|
||||||
|
console.log('✅ Usando CPI por skill del backend:', cpiBySkillMap.size, 'skills');
|
||||||
|
}
|
||||||
|
|
||||||
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
|
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
|
||||||
const csatGlobal =
|
const csatGlobal =
|
||||||
Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0
|
Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0
|
||||||
@@ -1110,12 +1228,24 @@ export function buildHeatmapFromBackend(
|
|||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const ineffBySkill = Array.isArray(
|
const ineffBySkillRaw = Array.isArray(
|
||||||
econ?.inefficiency_cost_by_skill_channel
|
econ?.inefficiency_cost_by_skill_channel
|
||||||
)
|
)
|
||||||
? econ.inefficiency_cost_by_skill_channel
|
? econ.inefficiency_cost_by_skill_channel
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// Crear lookup map por skill name para inefficiency data
|
||||||
|
const ineffBySkillMap = new Map<string, { aht_p50: number; aht_p90: number; volume: number }>();
|
||||||
|
for (const item of ineffBySkillRaw) {
|
||||||
|
if (item?.queue_skill) {
|
||||||
|
ineffBySkillMap.set(String(item.queue_skill), {
|
||||||
|
aht_p50: safeNumber(item.aht_p50, 0),
|
||||||
|
aht_p90: safeNumber(item.aht_p90, 0),
|
||||||
|
volume: safeNumber(item.volume, 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const COST_PER_SECOND = costPerHour / 3600;
|
const COST_PER_SECOND = costPerHour / 3600;
|
||||||
|
|
||||||
if (!skillLabels.length) return [];
|
if (!skillLabels.length) return [];
|
||||||
@@ -1137,12 +1267,30 @@ export function buildHeatmapFromBackend(
|
|||||||
const skill = skillLabels[i];
|
const skill = skillLabels[i];
|
||||||
const volume = safeNumber(skillVolumes[i], 0);
|
const volume = safeNumber(skillVolumes[i], 0);
|
||||||
|
|
||||||
const talkHold = talkHoldAcwBySkill[i] || {};
|
// Buscar P50s por nombre de skill (no por índice)
|
||||||
const talk_p50 = safeNumber(talkHold.talk_p50, 0);
|
const talkHold = talkHoldAcwMap.get(skill);
|
||||||
const hold_p50 = safeNumber(talkHold.hold_p50, 0);
|
const talk_p50 = talkHold?.talk_p50 ?? 0;
|
||||||
const acw_p50 = safeNumber(talkHold.acw_p50, 0);
|
const hold_p50 = talkHold?.hold_p50 ?? 0;
|
||||||
|
const acw_p50 = talkHold?.acw_p50 ?? 0;
|
||||||
|
|
||||||
const aht_mean = talk_p50 + hold_p50 + acw_p50;
|
// Buscar métricas REALES del backend (metrics_by_skill)
|
||||||
|
const realSkillMetrics = metricsBySkillMap.get(skill);
|
||||||
|
|
||||||
|
// AHT: Use ONLY aht_mean from backend metrics_by_skill
|
||||||
|
// NEVER use P50 sum as fallback - it's mathematically different from mean AHT
|
||||||
|
const aht_mean = (realSkillMetrics && Number.isFinite(realSkillMetrics.aht_mean) && realSkillMetrics.aht_mean > 0)
|
||||||
|
? realSkillMetrics.aht_mean
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// AHT Total: AHT calculado con TODAS las filas (incluye NOISE/ZOMBIE/ABANDON)
|
||||||
|
// Solo para información/comparación - no se usa en cálculos
|
||||||
|
const aht_total = (realSkillMetrics && Number.isFinite(realSkillMetrics.aht_total) && realSkillMetrics.aht_total > 0)
|
||||||
|
? realSkillMetrics.aht_total
|
||||||
|
: aht_mean; // fallback to aht_mean if not available
|
||||||
|
|
||||||
|
if (aht_mean === 0) {
|
||||||
|
console.warn(`⚠️ No aht_mean for skill ${skill} - data may be incomplete`);
|
||||||
|
}
|
||||||
|
|
||||||
// Coste anual aproximado
|
// Coste anual aproximado
|
||||||
const annual_volume = volume * 12;
|
const annual_volume = volume * 12;
|
||||||
@@ -1150,9 +1298,10 @@ export function buildHeatmapFromBackend(
|
|||||||
annual_volume * aht_mean * COST_PER_SECOND
|
annual_volume * aht_mean * COST_PER_SECOND
|
||||||
);
|
);
|
||||||
|
|
||||||
const ineff = ineffBySkill[i] || {};
|
// Buscar inefficiency data por nombre de skill (no por índice)
|
||||||
const aht_p50_backend = safeNumber(ineff.aht_p50, aht_mean);
|
const ineff = ineffBySkillMap.get(skill);
|
||||||
const aht_p90_backend = safeNumber(ineff.aht_p90, aht_mean);
|
const aht_p50_backend = ineff?.aht_p50 ?? aht_mean;
|
||||||
|
const aht_p90_backend = ineff?.aht_p90 ?? aht_mean;
|
||||||
|
|
||||||
// Variabilidad proxy: aproximamos CV a partir de P90-P50
|
// Variabilidad proxy: aproximamos CV a partir de P90-P50
|
||||||
let cv_aht = 0;
|
let cv_aht = 0;
|
||||||
@@ -1173,12 +1322,36 @@ export function buildHeatmapFromBackend(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2) Transfer rate POR SKILL - estimado desde CV y hold time
|
// 2) Transfer rate POR SKILL
|
||||||
// Skills con mayor variabilidad (CV alto) y mayor hold time tienden a tener más transferencias
|
// PRIORIDAD 1: Usar métricas REALES del backend (metrics_by_skill)
|
||||||
// Usamos el global como base y lo modulamos por skill
|
// PRIORIDAD 2: Fallback a estimación basada en CV y hold time
|
||||||
const cvFactor = Math.min(2, Math.max(0.5, 1 + (cv_aht - 0.5))); // Factor 0.5x - 2x basado en CV
|
|
||||||
const holdFactor = Math.min(1.5, Math.max(0.7, 1 + (hold_p50 - 30) / 100)); // Factor 0.7x - 1.5x basado en hold
|
let skillTransferRate: number;
|
||||||
const skillTransferRate = Math.max(2, Math.min(40, globalEscalation * cvFactor * holdFactor));
|
let skillAbandonmentRate: number;
|
||||||
|
let skillFcrTecnico: number;
|
||||||
|
let skillFcrReal: number;
|
||||||
|
|
||||||
|
if (realSkillMetrics && Number.isFinite(realSkillMetrics.transfer_rate)) {
|
||||||
|
// Usar métricas REALES del backend
|
||||||
|
skillTransferRate = realSkillMetrics.transfer_rate;
|
||||||
|
skillAbandonmentRate = Number.isFinite(realSkillMetrics.abandonment_rate)
|
||||||
|
? realSkillMetrics.abandonment_rate
|
||||||
|
: abandonmentRateBackend;
|
||||||
|
skillFcrTecnico = Number.isFinite(realSkillMetrics.fcr_tecnico)
|
||||||
|
? realSkillMetrics.fcr_tecnico
|
||||||
|
: 100 - skillTransferRate;
|
||||||
|
skillFcrReal = Number.isFinite(realSkillMetrics.fcr_real)
|
||||||
|
? realSkillMetrics.fcr_real
|
||||||
|
: skillFcrTecnico;
|
||||||
|
} else {
|
||||||
|
// NO usar estimación - usar valores globales del backend directamente
|
||||||
|
// Esto asegura consistencia con el fresh path que usa valores directos del CSV
|
||||||
|
skillTransferRate = globalEscalation; // Usar tasa global, sin estimación
|
||||||
|
skillAbandonmentRate = abandonmentRateBackend;
|
||||||
|
skillFcrTecnico = 100 - skillTransferRate;
|
||||||
|
skillFcrReal = globalFcrPct;
|
||||||
|
console.warn(`⚠️ No metrics_by_skill for skill ${skill} - using global rates`);
|
||||||
|
}
|
||||||
|
|
||||||
// Complejidad inversa basada en transfer rate del skill
|
// Complejidad inversa basada en transfer rate del skill
|
||||||
const complexity_inverse_score = Math.max(
|
const complexity_inverse_score = Math.max(
|
||||||
@@ -1221,29 +1394,18 @@ export function buildHeatmapFromBackend(
|
|||||||
|
|
||||||
// Métricas normalizadas 0-100 para el color del heatmap
|
// Métricas normalizadas 0-100 para el color del heatmap
|
||||||
const ahtMetric = normalizeAhtMetric(aht_mean);
|
const ahtMetric = normalizeAhtMetric(aht_mean);
|
||||||
;
|
|
||||||
|
|
||||||
const holdMetric = hold_p50
|
// Hold time metric: use hold_time_mean from backend (MEAN, not P50)
|
||||||
? Math.max(
|
// Formula matches fresh path: 100 - (hold_time_mean / 60) * 10
|
||||||
0,
|
// This gives: 0s = 100, 60s = 90, 120s = 80, etc.
|
||||||
Math.min(
|
const skillHoldTimeMean = (realSkillMetrics && Number.isFinite(realSkillMetrics.hold_time_mean))
|
||||||
100,
|
? realSkillMetrics.hold_time_mean
|
||||||
Math.round(
|
: hold_p50; // Fallback to P50 only if no mean available
|
||||||
100 - (hold_p50 / 120) * 100
|
|
||||||
)
|
const holdMetric = skillHoldTimeMean > 0
|
||||||
)
|
? Math.round(Math.max(0, Math.min(100, 100 - (skillHoldTimeMean / 60) * 10)))
|
||||||
)
|
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Transfer rate es el % real de transferencias POR SKILL
|
|
||||||
const transferMetric = Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(
|
|
||||||
100,
|
|
||||||
Math.round(skillTransferRate)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clasificación por segmento (si nos pasan mapeo)
|
// Clasificación por segmento (si nos pasan mapeo)
|
||||||
let segment: CustomerSegment | undefined;
|
let segment: CustomerSegment | undefined;
|
||||||
if (segmentMapping) {
|
if (segmentMapping) {
|
||||||
@@ -1265,25 +1427,41 @@ export function buildHeatmapFromBackend(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Métricas de transferencia y FCR (ahora usando valores REALES cuando disponibles)
|
||||||
|
const transferMetricFinal = Math.max(0, Math.min(100, Math.round(skillTransferRate)));
|
||||||
|
|
||||||
|
// CPI should be extracted from cpi_by_skill_channel using cpi_total field
|
||||||
|
const skillCpiRaw = cpiBySkillMap.get(skill);
|
||||||
|
// Only use if it's a valid number
|
||||||
|
const skillCpi = (Number.isFinite(skillCpiRaw) && skillCpiRaw > 0) ? skillCpiRaw : undefined;
|
||||||
|
|
||||||
|
// cost_volume: volumen sin abandonos (para cálculo de CPI consistente)
|
||||||
|
// Si tenemos abandonment_rate, restamos los abandonos
|
||||||
|
const costVolume = Math.round(volume * (1 - skillAbandonmentRate / 100));
|
||||||
|
|
||||||
heatmap.push({
|
heatmap.push({
|
||||||
skill,
|
skill,
|
||||||
segment,
|
segment,
|
||||||
volume,
|
volume,
|
||||||
|
cost_volume: costVolume,
|
||||||
aht_seconds: aht_mean,
|
aht_seconds: aht_mean,
|
||||||
|
aht_total: aht_total, // AHT con TODAS las filas (solo informativo)
|
||||||
metrics: {
|
metrics: {
|
||||||
fcr: Math.round(globalFcrPct),
|
fcr: Math.round(skillFcrReal), // FCR Real (sin transfer Y sin recontacto 7d)
|
||||||
|
fcr_tecnico: Math.round(skillFcrTecnico), // FCR Técnico (comparable con benchmarks)
|
||||||
aht: ahtMetric,
|
aht: ahtMetric,
|
||||||
csat: csatMetric0_100,
|
csat: csatMetric0_100,
|
||||||
hold_time: holdMetric,
|
hold_time: holdMetric,
|
||||||
transfer_rate: transferMetric,
|
transfer_rate: transferMetricFinal,
|
||||||
abandonment_rate: Math.round(abandonmentRateBackend),
|
abandonment_rate: Math.round(skillAbandonmentRate),
|
||||||
},
|
},
|
||||||
annual_cost,
|
annual_cost,
|
||||||
|
cpi: skillCpi, // CPI real del backend (si disponible)
|
||||||
variability: {
|
variability: {
|
||||||
cv_aht: Math.round(cv_aht * 100), // %
|
cv_aht: Math.round(cv_aht * 100), // %
|
||||||
cv_talk_time: 0,
|
cv_talk_time: 0,
|
||||||
cv_hold_time: 0,
|
cv_hold_time: 0,
|
||||||
transfer_rate: skillTransferRate, // Transfer rate estimado por skill
|
transfer_rate: skillTransferRate, // Transfer rate REAL o estimado
|
||||||
},
|
},
|
||||||
automation_readiness,
|
automation_readiness,
|
||||||
dimensions: {
|
dimensions: {
|
||||||
|
|||||||
@@ -10,11 +10,24 @@ import { classifyQueue } from './segmentClassifier';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcular distribución horaria desde interacciones
|
* Calcular distribución horaria desde interacciones
|
||||||
|
* NOTA: Usa interaction_id únicos para consistencia con backend (aggfunc="nunique")
|
||||||
*/
|
*/
|
||||||
function calculateHourlyDistribution(interactions: RawInteraction[]): { hourly: number[]; off_hours_pct: number; peak_hours: number[] } {
|
function calculateHourlyDistribution(interactions: RawInteraction[]): { hourly: number[]; off_hours_pct: number; peak_hours: number[] } {
|
||||||
const hourly = new Array(24).fill(0);
|
const hourly = new Array(24).fill(0);
|
||||||
|
|
||||||
|
// Deduplicar por interaction_id para consistencia con backend (nunique)
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
let duplicateCount = 0;
|
||||||
|
|
||||||
for (const interaction of interactions) {
|
for (const interaction of interactions) {
|
||||||
|
// Saltar duplicados de interaction_id
|
||||||
|
const id = interaction.interaction_id;
|
||||||
|
if (id && seenIds.has(id)) {
|
||||||
|
duplicateCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (id) seenIds.add(id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const date = new Date(interaction.datetime_start);
|
const date = new Date(interaction.datetime_start);
|
||||||
if (!isNaN(date.getTime())) {
|
if (!isNaN(date.getTime())) {
|
||||||
@@ -26,6 +39,10 @@ function calculateHourlyDistribution(interactions: RawInteraction[]): { hourly:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (duplicateCount > 0) {
|
||||||
|
console.log(`⏰ calculateHourlyDistribution: ${duplicateCount} interaction_ids duplicados ignorados`);
|
||||||
|
}
|
||||||
|
|
||||||
const total = hourly.reduce((a, b) => a + b, 0);
|
const total = hourly.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
// Fuera de horario: 19:00-08:00
|
// Fuera de horario: 19:00-08:00
|
||||||
@@ -45,6 +62,12 @@ function calculateHourlyDistribution(interactions: RawInteraction[]): { hourly:
|
|||||||
}
|
}
|
||||||
const peak_hours = [peakStart, peakStart + 1, peakStart + 2];
|
const peak_hours = [peakStart, peakStart + 1, peakStart + 2];
|
||||||
|
|
||||||
|
// Log para debugging
|
||||||
|
const hourlyNonZero = hourly.filter(v => v > 0);
|
||||||
|
const peakVolume = Math.max(...hourlyNonZero, 1);
|
||||||
|
const valleyVolume = Math.min(...hourlyNonZero.filter(v => v > 0), 1);
|
||||||
|
console.log(`⏰ Hourly distribution: total=${total}, peak=${peakVolume}, valley=${valleyVolume}, ratio=${(peakVolume/valleyVolume).toFixed(2)}`);
|
||||||
|
|
||||||
return { hourly, off_hours_pct, peak_hours };
|
return { hourly, off_hours_pct, peak_hours };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,11 +147,13 @@ export function generateAnalysisFromRealData(
|
|||||||
console.log(`📅 Date range: ${dateRange?.min} to ${dateRange?.max}`);
|
console.log(`📅 Date range: ${dateRange?.min} to ${dateRange?.max}`);
|
||||||
|
|
||||||
// PASO 1: Analizar record_status (ya no filtramos, el filtrado se hace internamente en calculateSkillMetrics)
|
// PASO 1: Analizar record_status (ya no filtramos, el filtrado se hace internamente en calculateSkillMetrics)
|
||||||
|
// Normalizar a uppercase para comparación case-insensitive
|
||||||
|
const getStatus = (i: RawInteraction) => (i.record_status || '').toString().toUpperCase().trim();
|
||||||
const statusCounts = {
|
const statusCounts = {
|
||||||
valid: interactions.filter(i => !i.record_status || i.record_status === 'valid').length,
|
valid: interactions.filter(i => !i.record_status || getStatus(i) === 'VALID').length,
|
||||||
noise: interactions.filter(i => i.record_status === 'noise').length,
|
noise: interactions.filter(i => getStatus(i) === 'NOISE').length,
|
||||||
zombie: interactions.filter(i => i.record_status === 'zombie').length,
|
zombie: interactions.filter(i => getStatus(i) === 'ZOMBIE').length,
|
||||||
abandon: interactions.filter(i => i.record_status === 'abandon').length
|
abandon: interactions.filter(i => getStatus(i) === 'ABANDON').length
|
||||||
};
|
};
|
||||||
console.log(`📊 Record status breakdown:`, statusCounts);
|
console.log(`📊 Record status breakdown:`, statusCounts);
|
||||||
|
|
||||||
@@ -154,11 +179,11 @@ export function generateAnalysisFromRealData(
|
|||||||
const totalWeightedAHT = skillMetrics.reduce((sum, s) => sum + (s.aht_mean * s.volume_valid), 0);
|
const totalWeightedAHT = skillMetrics.reduce((sum, s) => sum + (s.aht_mean * s.volume_valid), 0);
|
||||||
const avgAHT = totalValidInteractions > 0 ? Math.round(totalWeightedAHT / totalValidInteractions) : 0;
|
const avgAHT = totalValidInteractions > 0 ? Math.round(totalWeightedAHT / totalValidInteractions) : 0;
|
||||||
|
|
||||||
// FCR Real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE)
|
// FCR Técnico: 100 - transfer_rate (comparable con benchmarks de industria)
|
||||||
// Ponderado por volumen de cada skill
|
// Ponderado por volumen de cada skill
|
||||||
const totalVolumeForFCR = skillMetrics.reduce((sum, s) => sum + s.volume_valid, 0);
|
const totalVolumeForFCR = skillMetrics.reduce((sum, s) => sum + s.volume_valid, 0);
|
||||||
const avgFCR = totalVolumeForFCR > 0
|
const avgFCR = totalVolumeForFCR > 0
|
||||||
? Math.round(skillMetrics.reduce((sum, s) => sum + (s.fcr_rate * s.volume_valid), 0) / totalVolumeForFCR)
|
? Math.round(skillMetrics.reduce((sum, s) => sum + (s.fcr_tecnico * s.volume_valid), 0) / totalVolumeForFCR)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Coste total
|
// Coste total
|
||||||
@@ -168,7 +193,7 @@ export function generateAnalysisFromRealData(
|
|||||||
const summaryKpis: Kpi[] = [
|
const summaryKpis: Kpi[] = [
|
||||||
{ label: "Interacciones Totales", value: totalInteractions.toLocaleString('es-ES') },
|
{ label: "Interacciones Totales", value: totalInteractions.toLocaleString('es-ES') },
|
||||||
{ label: "AHT Promedio", value: `${avgAHT}s` },
|
{ label: "AHT Promedio", value: `${avgAHT}s` },
|
||||||
{ label: "Tasa FCR", value: `${avgFCR}%` },
|
{ label: "FCR Técnico", value: `${avgFCR}%` },
|
||||||
{ label: "CSAT", value: `${(avgCsat / 20).toFixed(1)}/5` }
|
{ label: "CSAT", value: `${(avgCsat / 20).toFixed(1)}/5` }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -187,9 +212,9 @@ export function generateAnalysisFromRealData(
|
|||||||
// Agentic Readiness Score
|
// Agentic Readiness Score
|
||||||
const agenticReadiness = calculateAgenticReadinessFromRealData(skillMetrics);
|
const agenticReadiness = calculateAgenticReadinessFromRealData(skillMetrics);
|
||||||
|
|
||||||
// Findings y Recommendations
|
// Findings y Recommendations (incluyendo análisis de fuera de horario)
|
||||||
const findings = generateFindingsFromRealData(skillMetrics, interactions);
|
const findings = generateFindingsFromRealData(skillMetrics, interactions, hourlyDistribution);
|
||||||
const recommendations = generateRecommendationsFromRealData(skillMetrics);
|
const recommendations = generateRecommendationsFromRealData(skillMetrics, hourlyDistribution, interactions.length);
|
||||||
|
|
||||||
// v3.3: Drill-down por Cola + Tipificación - CALCULAR PRIMERO para usar en opportunities y roadmap
|
// v3.3: Drill-down por Cola + Tipificación - CALCULAR PRIMERO para usar en opportunities y roadmap
|
||||||
const drilldownData = calculateDrilldownMetrics(interactions, costPerHour);
|
const drilldownData = calculateDrilldownMetrics(interactions, costPerHour);
|
||||||
@@ -240,13 +265,18 @@ interface SkillMetrics {
|
|||||||
skill: string;
|
skill: string;
|
||||||
volume: number; // Total de interacciones (todas)
|
volume: number; // Total de interacciones (todas)
|
||||||
volume_valid: number; // Interacciones válidas para AHT (valid + abandon)
|
volume_valid: number; // Interacciones válidas para AHT (valid + abandon)
|
||||||
aht_mean: number; // AHT calculado solo sobre valid (sin noise/zombie/abandon)
|
aht_mean: number; // AHT "limpio" calculado solo sobre valid (sin noise/zombie/abandon) - para métricas de calidad, CV
|
||||||
|
aht_total: number; // AHT "total" calculado con TODAS las filas (noise/zombie/abandon incluidas) - solo informativo
|
||||||
|
aht_benchmark: number; // AHT "tradicional" (incluye noise, excluye zombie/abandon) - para comparación con benchmarks de industria
|
||||||
aht_std: number;
|
aht_std: number;
|
||||||
cv_aht: number;
|
cv_aht: number;
|
||||||
transfer_rate: number; // Calculado sobre valid + abandon
|
transfer_rate: number; // Calculado sobre valid + abandon
|
||||||
fcr_rate: number; // FCR real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE)
|
fcr_rate: number; // FCR Real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE) - sin recontacto 7 días
|
||||||
|
fcr_tecnico: number; // FCR Técnico: (transfer_flag == FALSE) - solo sin transferencia, comparable con benchmarks de industria
|
||||||
abandonment_rate: number; // % de abandonos sobre total
|
abandonment_rate: number; // % de abandonos sobre total
|
||||||
total_cost: number; // Coste total (todas las interacciones excepto abandon)
|
total_cost: number; // Coste total (todas las interacciones excepto abandon)
|
||||||
|
cost_volume: number; // Volumen usado para calcular coste (non-abandon)
|
||||||
|
cpi: number; // Coste por interacción = total_cost / cost_volume
|
||||||
hold_time_mean: number; // Calculado sobre valid
|
hold_time_mean: number; // Calculado sobre valid
|
||||||
cv_talk_time: number;
|
cv_talk_time: number;
|
||||||
// Métricas adicionales para debug
|
// Métricas adicionales para debug
|
||||||
@@ -255,7 +285,7 @@ interface SkillMetrics {
|
|||||||
abandon_count: number;
|
abandon_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: number): SkillMetrics[] {
|
export function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: number): SkillMetrics[] {
|
||||||
// Agrupar por skill
|
// Agrupar por skill
|
||||||
const skillGroups = new Map<string, RawInteraction[]>();
|
const skillGroups = new Map<string, RawInteraction[]>();
|
||||||
|
|
||||||
@@ -279,7 +309,9 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb
|
|||||||
const abandon_count = group.filter(i => i.is_abandoned === true).length;
|
const abandon_count = group.filter(i => i.is_abandoned === true).length;
|
||||||
const abandonment_rate = (abandon_count / volume) * 100;
|
const abandonment_rate = (abandon_count / volume) * 100;
|
||||||
|
|
||||||
// FCR: DIRECTO del campo fcr_real_flag del CSV
|
// FCR Real: DIRECTO del campo fcr_real_flag del CSV
|
||||||
|
// Definición: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE)
|
||||||
|
// Esta es la métrica MÁS ESTRICTA - sin transferencia Y sin recontacto en 7 días
|
||||||
const fcrTrueCount = group.filter(i => i.fcr_real_flag === true).length;
|
const fcrTrueCount = group.filter(i => i.fcr_real_flag === true).length;
|
||||||
const fcr_rate = (fcrTrueCount / volume) * 100;
|
const fcr_rate = (fcrTrueCount / volume) * 100;
|
||||||
|
|
||||||
@@ -287,10 +319,17 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb
|
|||||||
const transfers = group.filter(i => i.transfer_flag === true).length;
|
const transfers = group.filter(i => i.transfer_flag === true).length;
|
||||||
const transfer_rate = (transfers / volume) * 100;
|
const transfer_rate = (transfers / volume) * 100;
|
||||||
|
|
||||||
// Separar por record_status para AHT
|
// FCR Técnico: 100 - transfer_rate
|
||||||
const noiseRecords = group.filter(i => i.record_status === 'noise');
|
// Definición: (transfer_flag == FALSE) - solo sin transferencia
|
||||||
const zombieRecords = group.filter(i => i.record_status === 'zombie');
|
// Esta métrica es COMPARABLE con benchmarks de industria (COPC, Dimension Data)
|
||||||
const validRecords = group.filter(i => !i.record_status || i.record_status === 'valid');
|
// Los benchmarks de industria (~70%) miden FCR sin transferencia, NO sin recontacto
|
||||||
|
const fcr_tecnico = 100 - transfer_rate;
|
||||||
|
|
||||||
|
// Separar por record_status para AHT (normalizar a uppercase para comparación case-insensitive)
|
||||||
|
const getStatus = (i: RawInteraction) => (i.record_status || '').toString().toUpperCase().trim();
|
||||||
|
const noiseRecords = group.filter(i => getStatus(i) === 'NOISE');
|
||||||
|
const zombieRecords = group.filter(i => getStatus(i) === 'ZOMBIE');
|
||||||
|
const validRecords = group.filter(i => !i.record_status || getStatus(i) === 'VALID');
|
||||||
// Registros que generan coste (todo excepto abandonos)
|
// Registros que generan coste (todo excepto abandonos)
|
||||||
const nonAbandonRecords = group.filter(i => i.is_abandoned !== true);
|
const nonAbandonRecords = group.filter(i => i.is_abandoned !== true);
|
||||||
|
|
||||||
@@ -325,6 +364,30 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb
|
|||||||
hold_time_mean = ahtRecords.reduce((sum, i) => sum + i.hold_time, 0) / volume_valid;
|
hold_time_mean = ahtRecords.reduce((sum, i) => sum + i.hold_time, 0) / volume_valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === AHT BENCHMARK: para comparación con benchmarks de industria ===
|
||||||
|
// Incluye NOISE (llamadas cortas son trabajo real), excluye ZOMBIE (errores) y ABANDON (sin handle time)
|
||||||
|
// Los benchmarks de industria (COPC, Dimension Data) NO filtran llamadas cortas
|
||||||
|
const benchmarkRecords = group.filter(i =>
|
||||||
|
getStatus(i) !== 'ZOMBIE' &&
|
||||||
|
getStatus(i) !== 'ABANDON' &&
|
||||||
|
i.is_abandoned !== true
|
||||||
|
);
|
||||||
|
const volume_benchmark = benchmarkRecords.length;
|
||||||
|
|
||||||
|
let aht_benchmark = aht_mean; // Fallback al AHT limpio si no hay registros benchmark
|
||||||
|
if (volume_benchmark > 0) {
|
||||||
|
const benchmarkAhts = benchmarkRecords.map(i => i.duration_talk + i.hold_time + i.wrap_up_time);
|
||||||
|
aht_benchmark = benchmarkAhts.reduce((sum, v) => sum + v, 0) / volume_benchmark;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === AHT TOTAL: calculado con TODAS las filas (solo informativo) ===
|
||||||
|
// Incluye NOISE, ZOMBIE, ABANDON - para comparación con AHT limpio
|
||||||
|
let aht_total = 0;
|
||||||
|
if (volume > 0) {
|
||||||
|
const allAhts = group.map(i => i.duration_talk + i.hold_time + i.wrap_up_time);
|
||||||
|
aht_total = allAhts.reduce((sum, v) => sum + v, 0) / volume;
|
||||||
|
}
|
||||||
|
|
||||||
// === CÁLCULOS FINANCIEROS: usar TODAS las interacciones ===
|
// === CÁLCULOS FINANCIEROS: usar TODAS las interacciones ===
|
||||||
// Coste total con productividad efectiva del 70%
|
// Coste total con productividad efectiva del 70%
|
||||||
const effectiveProductivity = 0.70;
|
const effectiveProductivity = 0.70;
|
||||||
@@ -342,21 +405,29 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb
|
|||||||
aht_for_cost = costAhts.reduce((sum, v) => sum + v, 0) / costVolume;
|
aht_for_cost = costAhts.reduce((sum, v) => sum + v, 0) / costVolume;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coste Real = (Volumen × AHT × Coste/hora) / Productividad Efectiva
|
// Coste Real = (AHT en horas × Coste/hora × Volumen) / Productividad Efectiva
|
||||||
const rawCost = (aht_for_cost / 3600) * costPerHour * costVolume;
|
const rawCost = (aht_for_cost / 3600) * costPerHour * costVolume;
|
||||||
const total_cost = rawCost / effectiveProductivity;
|
const total_cost = rawCost / effectiveProductivity;
|
||||||
|
|
||||||
|
// CPI = Coste por interacción (usando el volumen correcto)
|
||||||
|
const cpi = costVolume > 0 ? total_cost / costVolume : 0;
|
||||||
|
|
||||||
metrics.push({
|
metrics.push({
|
||||||
skill,
|
skill,
|
||||||
volume,
|
volume,
|
||||||
volume_valid,
|
volume_valid,
|
||||||
aht_mean,
|
aht_mean,
|
||||||
|
aht_total, // AHT con TODAS las filas (solo informativo)
|
||||||
|
aht_benchmark,
|
||||||
aht_std,
|
aht_std,
|
||||||
cv_aht,
|
cv_aht,
|
||||||
transfer_rate,
|
transfer_rate,
|
||||||
fcr_rate,
|
fcr_rate,
|
||||||
|
fcr_tecnico,
|
||||||
abandonment_rate,
|
abandonment_rate,
|
||||||
total_cost,
|
total_cost,
|
||||||
|
cost_volume: costVolume,
|
||||||
|
cpi,
|
||||||
hold_time_mean,
|
hold_time_mean,
|
||||||
cv_talk_time,
|
cv_talk_time,
|
||||||
noise_count,
|
noise_count,
|
||||||
@@ -375,6 +446,9 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb
|
|||||||
const avgFCRRate = totalVolume > 0
|
const avgFCRRate = totalVolume > 0
|
||||||
? metrics.reduce((sum, m) => sum + m.fcr_rate * m.volume, 0) / totalVolume
|
? metrics.reduce((sum, m) => sum + m.fcr_rate * m.volume, 0) / totalVolume
|
||||||
: 0;
|
: 0;
|
||||||
|
const avgFCRTecnicoRate = totalVolume > 0
|
||||||
|
? metrics.reduce((sum, m) => sum + m.fcr_tecnico * m.volume, 0) / totalVolume
|
||||||
|
: 0;
|
||||||
const avgTransferRate = totalVolume > 0
|
const avgTransferRate = totalVolume > 0
|
||||||
? metrics.reduce((sum, m) => sum + m.transfer_rate * m.volume, 0) / totalVolume
|
? metrics.reduce((sum, m) => sum + m.transfer_rate * m.volume, 0) / totalVolume
|
||||||
: 0;
|
: 0;
|
||||||
@@ -389,12 +463,13 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb
|
|||||||
console.log('');
|
console.log('');
|
||||||
console.log('MÉTRICAS GLOBALES (ponderadas por volumen):');
|
console.log('MÉTRICAS GLOBALES (ponderadas por volumen):');
|
||||||
console.log(` Abandonment Rate: ${globalAbandonRate.toFixed(2)}%`);
|
console.log(` Abandonment Rate: ${globalAbandonRate.toFixed(2)}%`);
|
||||||
console.log(` FCR Rate (fcr_real_flag=TRUE): ${avgFCRRate.toFixed(2)}%`);
|
console.log(` FCR Real (sin transfer + sin recontacto 7d): ${avgFCRRate.toFixed(2)}%`);
|
||||||
|
console.log(` FCR Técnico (solo sin transfer, comparable con benchmarks): ${avgFCRTecnicoRate.toFixed(2)}%`);
|
||||||
console.log(` Transfer Rate: ${avgTransferRate.toFixed(2)}%`);
|
console.log(` Transfer Rate: ${avgTransferRate.toFixed(2)}%`);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Detalle por skill (top 5):');
|
console.log('Detalle por skill (top 5):');
|
||||||
metrics.slice(0, 5).forEach(m => {
|
metrics.slice(0, 5).forEach(m => {
|
||||||
console.log(` ${m.skill}: vol=${m.volume}, abandon=${m.abandon_count} (${m.abandonment_rate.toFixed(1)}%), FCR=${m.fcr_rate.toFixed(1)}%, transfer=${m.transfer_rate.toFixed(1)}%`);
|
console.log(` ${m.skill}: vol=${m.volume}, abandon=${m.abandon_count} (${m.abandonment_rate.toFixed(1)}%), FCR Real=${m.fcr_rate.toFixed(1)}%, FCR Técnico=${m.fcr_tecnico.toFixed(1)}%, transfer=${m.transfer_rate.toFixed(1)}%`);
|
||||||
});
|
});
|
||||||
console.log('═══════════════════════════════════════════════════════════════');
|
console.log('═══════════════════════════════════════════════════════════════');
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -415,6 +490,62 @@ function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: numb
|
|||||||
return metrics.sort((a, b) => b.volume - a.volume); // Ordenar por volumen descendente
|
return metrics.sort((a, b) => b.volume - a.volume); // Ordenar por volumen descendente
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v4.4: Clasificar tier de automatización con datos del heatmap
|
||||||
|
*
|
||||||
|
* Esta función replica la lógica de clasificarTier() usando los datos
|
||||||
|
* disponibles en el heatmap. Acepta parámetros opcionales (fcr, volume)
|
||||||
|
* para mayor precisión cuando están disponibles.
|
||||||
|
*
|
||||||
|
* Se usa en generateDrilldownFromHeatmap() de analysisGenerator.ts para
|
||||||
|
* asegurar consistencia entre la ruta fresh (datos completos) y la ruta
|
||||||
|
* cached (datos del heatmap).
|
||||||
|
*
|
||||||
|
* @param score - Agentic Readiness Score (0-10)
|
||||||
|
* @param cv - Coeficiente de Variación del AHT como decimal (0.75 = 75%)
|
||||||
|
* @param transfer - Tasa de transferencia como decimal (0.20 = 20%)
|
||||||
|
* @param fcr - FCR rate como decimal (0.80 = 80%), opcional
|
||||||
|
* @param volume - Volumen mensual de interacciones, opcional
|
||||||
|
* @returns AgenticTier ('AUTOMATE' | 'ASSIST' | 'AUGMENT' | 'HUMAN-ONLY')
|
||||||
|
*/
|
||||||
|
export function clasificarTierSimple(
|
||||||
|
score: number,
|
||||||
|
cv: number, // CV como decimal (0.75 = 75%)
|
||||||
|
transfer: number, // Transfer como decimal (0.20 = 20%)
|
||||||
|
fcr?: number, // FCR como decimal (0.80 = 80%)
|
||||||
|
volume?: number // Volumen mensual
|
||||||
|
): import('../types').AgenticTier {
|
||||||
|
// RED FLAGS críticos - mismos que clasificarTier() completa
|
||||||
|
// CV > 120% o Transfer > 50% son red flags absolutos
|
||||||
|
if (cv > 1.20 || transfer > 0.50) {
|
||||||
|
return 'HUMAN-ONLY';
|
||||||
|
}
|
||||||
|
// Volume < 50/mes es red flag si tenemos el dato
|
||||||
|
if (volume !== undefined && volume < 50) {
|
||||||
|
return 'HUMAN-ONLY';
|
||||||
|
}
|
||||||
|
|
||||||
|
// TIER 1: AUTOMATE - requiere métricas óptimas
|
||||||
|
// Mismo criterio que clasificarTier(): score >= 7.5, cv <= 0.75, transfer <= 0.20, fcr >= 0.50
|
||||||
|
const fcrOk = fcr === undefined || fcr >= 0.50; // Si no tenemos FCR, asumimos OK
|
||||||
|
if (score >= 7.5 && cv <= 0.75 && transfer <= 0.20 && fcrOk) {
|
||||||
|
return 'AUTOMATE';
|
||||||
|
}
|
||||||
|
|
||||||
|
// TIER 2: ASSIST - apto para copilot/asistencia
|
||||||
|
if (score >= 5.5 && cv <= 0.90 && transfer <= 0.30) {
|
||||||
|
return 'ASSIST';
|
||||||
|
}
|
||||||
|
|
||||||
|
// TIER 3: AUGMENT - requiere optimización previa
|
||||||
|
if (score >= 3.5) {
|
||||||
|
return 'AUGMENT';
|
||||||
|
}
|
||||||
|
|
||||||
|
// TIER 4: HUMAN-ONLY - proceso complejo
|
||||||
|
return 'HUMAN-ONLY';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v3.4: Calcular métricas drill-down con nueva fórmula de Agentic Readiness Score
|
* v3.4: Calcular métricas drill-down con nueva fórmula de Agentic Readiness Score
|
||||||
*
|
*
|
||||||
@@ -627,8 +758,9 @@ export function calculateDrilldownMetrics(
|
|||||||
const volume = group.length;
|
const volume = group.length;
|
||||||
if (volume < 5) return null;
|
if (volume < 5) return null;
|
||||||
|
|
||||||
// Filtrar solo VALID para cálculo de CV
|
// Filtrar solo VALID para cálculo de CV (normalizar a uppercase para comparación case-insensitive)
|
||||||
const validRecords = group.filter(i => !i.record_status || i.record_status === 'valid');
|
const getStatus = (i: RawInteraction) => (i.record_status || '').toString().toUpperCase().trim();
|
||||||
|
const validRecords = group.filter(i => !i.record_status || getStatus(i) === 'VALID');
|
||||||
const volumeValid = validRecords.length;
|
const volumeValid = validRecords.length;
|
||||||
if (volumeValid < 3) return null;
|
if (volumeValid < 3) return null;
|
||||||
|
|
||||||
@@ -647,10 +779,14 @@ export function calculateDrilldownMetrics(
|
|||||||
const transfer_decimal = transfers / volume;
|
const transfer_decimal = transfers / volume;
|
||||||
const transfer_percent = transfer_decimal * 100;
|
const transfer_percent = transfer_decimal * 100;
|
||||||
|
|
||||||
|
// FCR Real: usa fcr_real_flag del CSV (sin transferencia Y sin recontacto 7d)
|
||||||
const fcrCount = group.filter(i => i.fcr_real_flag === true).length;
|
const fcrCount = group.filter(i => i.fcr_real_flag === true).length;
|
||||||
const fcr_decimal = fcrCount / volume;
|
const fcr_decimal = fcrCount / volume;
|
||||||
const fcr_percent = fcr_decimal * 100;
|
const fcr_percent = fcr_decimal * 100;
|
||||||
|
|
||||||
|
// FCR Técnico: 100 - transfer_rate (comparable con benchmarks de industria)
|
||||||
|
const fcr_tecnico_percent = 100 - transfer_percent;
|
||||||
|
|
||||||
// Calcular score con nueva fórmula v3.4
|
// Calcular score con nueva fórmula v3.4
|
||||||
const { score, breakdown } = calcularScoreCola(
|
const { score, breakdown } = calcularScoreCola(
|
||||||
cv_aht_decimal,
|
cv_aht_decimal,
|
||||||
@@ -671,7 +807,9 @@ export function calculateDrilldownMetrics(
|
|||||||
validPct
|
validPct
|
||||||
);
|
);
|
||||||
|
|
||||||
const annualCost = Math.round((aht_mean / 3600) * costPerHour * volume / effectiveProductivity);
|
// v4.2: Convertir volumen de 11 meses a anual para el coste
|
||||||
|
const annualVolume = (volume / 11) * 12; // 11 meses → anual
|
||||||
|
const annualCost = Math.round((aht_mean / 3600) * costPerHour * annualVolume / effectiveProductivity);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
original_queue_id: '', // Se asigna después
|
original_queue_id: '', // Se asigna después
|
||||||
@@ -681,6 +819,7 @@ export function calculateDrilldownMetrics(
|
|||||||
cv_aht: Math.round(cv_aht_percent * 10) / 10,
|
cv_aht: Math.round(cv_aht_percent * 10) / 10,
|
||||||
transfer_rate: Math.round(transfer_percent * 10) / 10,
|
transfer_rate: Math.round(transfer_percent * 10) / 10,
|
||||||
fcr_rate: Math.round(fcr_percent * 10) / 10,
|
fcr_rate: Math.round(fcr_percent * 10) / 10,
|
||||||
|
fcr_tecnico: Math.round(fcr_tecnico_percent * 10) / 10, // FCR Técnico para consistencia con Summary
|
||||||
agenticScore: score,
|
agenticScore: score,
|
||||||
scoreBreakdown: breakdown,
|
scoreBreakdown: breakdown,
|
||||||
tier,
|
tier,
|
||||||
@@ -753,6 +892,7 @@ export function calculateDrilldownMetrics(
|
|||||||
const avgCv = originalQueues.reduce((sum, q) => sum + q.cv_aht * q.volume, 0) / totalVolume;
|
const avgCv = originalQueues.reduce((sum, q) => sum + q.cv_aht * q.volume, 0) / totalVolume;
|
||||||
const avgTransfer = originalQueues.reduce((sum, q) => sum + q.transfer_rate * q.volume, 0) / totalVolume;
|
const avgTransfer = originalQueues.reduce((sum, q) => sum + q.transfer_rate * q.volume, 0) / totalVolume;
|
||||||
const avgFcr = originalQueues.reduce((sum, q) => sum + q.fcr_rate * q.volume, 0) / totalVolume;
|
const avgFcr = originalQueues.reduce((sum, q) => sum + q.fcr_rate * q.volume, 0) / totalVolume;
|
||||||
|
const avgFcrTecnico = originalQueues.reduce((sum, q) => sum + q.fcr_tecnico * q.volume, 0) / totalVolume;
|
||||||
|
|
||||||
// Score global ponderado por volumen
|
// Score global ponderado por volumen
|
||||||
const avgScore = originalQueues.reduce((sum, q) => sum + q.agenticScore * q.volume, 0) / totalVolume;
|
const avgScore = originalQueues.reduce((sum, q) => sum + q.agenticScore * q.volume, 0) / totalVolume;
|
||||||
@@ -775,6 +915,7 @@ export function calculateDrilldownMetrics(
|
|||||||
cv_aht: Math.round(avgCv * 10) / 10,
|
cv_aht: Math.round(avgCv * 10) / 10,
|
||||||
transfer_rate: Math.round(avgTransfer * 10) / 10,
|
transfer_rate: Math.round(avgTransfer * 10) / 10,
|
||||||
fcr_rate: Math.round(avgFcr * 10) / 10,
|
fcr_rate: Math.round(avgFcr * 10) / 10,
|
||||||
|
fcr_tecnico: Math.round(avgFcrTecnico * 10) / 10, // FCR Técnico para consistencia
|
||||||
agenticScore: Math.round(avgScore * 10) / 10,
|
agenticScore: Math.round(avgScore * 10) / 10,
|
||||||
isPriorityCandidate: hasAutomateQueue,
|
isPriorityCandidate: hasAutomateQueue,
|
||||||
annualCost: totalCost
|
annualCost: totalCost
|
||||||
@@ -804,7 +945,7 @@ export function calculateDrilldownMetrics(
|
|||||||
/**
|
/**
|
||||||
* PASO 3: Transformar métricas a dimensiones (0-10)
|
* PASO 3: Transformar métricas a dimensiones (0-10)
|
||||||
*/
|
*/
|
||||||
function generateHeatmapFromMetrics(
|
export function generateHeatmapFromMetrics(
|
||||||
metrics: SkillMetrics[],
|
metrics: SkillMetrics[],
|
||||||
avgCsat: number,
|
avgCsat: number,
|
||||||
segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] }
|
segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] }
|
||||||
@@ -858,8 +999,10 @@ function generateHeatmapFromMetrics(
|
|||||||
|
|
||||||
// Scores de performance (normalizados 0-100)
|
// Scores de performance (normalizados 0-100)
|
||||||
// FCR Real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE)
|
// FCR Real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE)
|
||||||
// Usamos el fcr_rate calculado correctamente
|
// Esta es la métrica más estricta - sin transferencia Y sin recontacto en 7 días
|
||||||
const fcr_score = Math.round(m.fcr_rate);
|
const fcr_score = Math.round(m.fcr_rate);
|
||||||
|
// FCR Técnico: solo sin transferencia (comparable con benchmarks de industria COPC, Dimension Data)
|
||||||
|
const fcr_tecnico_score = Math.round(m.fcr_tecnico);
|
||||||
const aht_score = Math.round(Math.max(0, Math.min(100, 100 - ((m.aht_mean - 240) / 310) * 100)));
|
const aht_score = Math.round(Math.max(0, Math.min(100, 100 - ((m.aht_mean - 240) / 310) * 100)));
|
||||||
const csat_score = avgCsat;
|
const csat_score = avgCsat;
|
||||||
const hold_time_score = Math.round(Math.max(0, Math.min(100, 100 - (m.hold_time_mean / 60) * 10)));
|
const hold_time_score = Math.round(Math.max(0, Math.min(100, 100 - (m.hold_time_mean / 60) * 10)));
|
||||||
@@ -871,9 +1014,15 @@ function generateHeatmapFromMetrics(
|
|||||||
return {
|
return {
|
||||||
skill: m.skill,
|
skill: m.skill,
|
||||||
volume: m.volume,
|
volume: m.volume,
|
||||||
|
cost_volume: m.cost_volume, // Volumen usado para calcular coste (non-abandon)
|
||||||
aht_seconds: Math.round(m.aht_mean),
|
aht_seconds: Math.round(m.aht_mean),
|
||||||
|
aht_total: Math.round(m.aht_total), // AHT con TODAS las filas (solo informativo)
|
||||||
|
aht_benchmark: Math.round(m.aht_benchmark), // AHT tradicional para comparación con benchmarks de industria
|
||||||
|
annual_cost: Math.round(m.total_cost), // Coste calculado con TODOS los registros (noise + zombie + valid)
|
||||||
|
cpi: m.cpi, // Coste por interacción (calculado correctamente)
|
||||||
metrics: {
|
metrics: {
|
||||||
fcr: fcr_score,
|
fcr: fcr_score, // FCR Real (más estricto, con filtro de recontacto 7d)
|
||||||
|
fcr_tecnico: fcr_tecnico_score, // FCR Técnico (comparable con benchmarks industria)
|
||||||
aht: aht_score,
|
aht: aht_score,
|
||||||
csat: csat_score,
|
csat: csat_score,
|
||||||
hold_time: hold_time_score,
|
hold_time: hold_time_score,
|
||||||
@@ -912,17 +1061,146 @@ function generateHeatmapFromMetrics(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcular Health Score global
|
* Calcular Health Score global - Nueva fórmula basada en benchmarks de industria
|
||||||
|
*
|
||||||
|
* PASO 1: Normalización de componentes usando percentiles de industria
|
||||||
|
* PASO 2: Ponderación (FCR 35%, Abandono 30%, CSAT Proxy 20%, AHT 15%)
|
||||||
|
* PASO 3: Penalizaciones por umbrales críticos
|
||||||
|
*
|
||||||
|
* Benchmarks de industria (Cross-Industry):
|
||||||
|
* - FCR Técnico: P10=85%, P50=68%, P90=50%
|
||||||
|
* - Abandono: P10=3%, P50=5%, P90=10%
|
||||||
|
* - AHT: P10=240s, P50=380s, P90=540s
|
||||||
*/
|
*/
|
||||||
function calculateHealthScore(heatmapData: HeatmapDataPoint[]): number {
|
function calculateHealthScore(heatmapData: HeatmapDataPoint[]): number {
|
||||||
if (heatmapData.length === 0) return 50;
|
if (heatmapData.length === 0) return 50;
|
||||||
|
|
||||||
const avgFCR = heatmapData.reduce((sum, d) => sum + (d.metrics?.fcr || 0), 0) / heatmapData.length;
|
const totalVolume = heatmapData.reduce((sum, d) => sum + d.volume, 0);
|
||||||
const avgAHT = heatmapData.reduce((sum, d) => sum + (d.metrics?.aht || 0), 0) / heatmapData.length;
|
if (totalVolume === 0) return 50;
|
||||||
const avgCSAT = heatmapData.reduce((sum, d) => sum + (d.metrics?.csat || 0), 0) / heatmapData.length;
|
|
||||||
const avgVariability = heatmapData.reduce((sum, d) => sum + (100 - (d.variability?.cv_aht || 0)), 0) / heatmapData.length;
|
|
||||||
|
|
||||||
return Math.round((avgFCR + avgAHT + avgCSAT + avgVariability) / 4);
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// PASO 0: Extraer métricas ponderadas por volumen
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// FCR Técnico (%)
|
||||||
|
const fcrTecnico = heatmapData.reduce((sum, d) =>
|
||||||
|
sum + (d.metrics?.fcr_tecnico ?? (100 - d.metrics.transfer_rate)) * d.volume, 0) / totalVolume;
|
||||||
|
|
||||||
|
// Abandono (%)
|
||||||
|
const abandono = heatmapData.reduce((sum, d) =>
|
||||||
|
sum + (d.metrics?.abandonment_rate || 0) * d.volume, 0) / totalVolume;
|
||||||
|
|
||||||
|
// AHT (segundos) - usar aht_seconds (AHT limpio sin noise/zombies)
|
||||||
|
const aht = heatmapData.reduce((sum, d) =>
|
||||||
|
sum + d.aht_seconds * d.volume, 0) / totalVolume;
|
||||||
|
|
||||||
|
// Transferencia (%)
|
||||||
|
const transferencia = heatmapData.reduce((sum, d) =>
|
||||||
|
sum + (d.metrics?.transfer_rate || 0) * d.volume, 0) / totalVolume;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// PASO 1: Normalización de componentes (0-100 score)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// FCR Técnico: P10=85%, P50=68%, P90=50%
|
||||||
|
// Más alto = mejor
|
||||||
|
let fcrScore: number;
|
||||||
|
if (fcrTecnico >= 85) {
|
||||||
|
fcrScore = 95 + 5 * Math.min(1, (fcrTecnico - 85) / 15); // 95-100
|
||||||
|
} else if (fcrTecnico >= 68) {
|
||||||
|
fcrScore = 50 + 50 * (fcrTecnico - 68) / (85 - 68); // 50-100
|
||||||
|
} else if (fcrTecnico >= 50) {
|
||||||
|
fcrScore = 20 + 30 * (fcrTecnico - 50) / (68 - 50); // 20-50
|
||||||
|
} else {
|
||||||
|
fcrScore = Math.max(0, 20 * fcrTecnico / 50); // 0-20
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abandono: P10=3%, P50=5%, P90=10%
|
||||||
|
// Más bajo = mejor (invertido)
|
||||||
|
let abandonoScore: number;
|
||||||
|
if (abandono <= 3) {
|
||||||
|
abandonoScore = 95 + 5 * Math.max(0, (3 - abandono) / 3); // 95-100
|
||||||
|
} else if (abandono <= 5) {
|
||||||
|
abandonoScore = 50 + 45 * (5 - abandono) / (5 - 3); // 50-95
|
||||||
|
} else if (abandono <= 10) {
|
||||||
|
abandonoScore = 20 + 30 * (10 - abandono) / (10 - 5); // 20-50
|
||||||
|
} else {
|
||||||
|
// Por encima de P90 (crítico): penalización fuerte
|
||||||
|
abandonoScore = Math.max(0, 20 - 2 * (abandono - 10)); // 0-20, decrece rápido
|
||||||
|
}
|
||||||
|
|
||||||
|
// AHT: P10=240s, P50=380s, P90=540s
|
||||||
|
// Más bajo = mejor (invertido)
|
||||||
|
// PERO: Si FCR es bajo, AHT bajo puede indicar llamadas rushed (mala calidad)
|
||||||
|
let ahtScore: number;
|
||||||
|
if (aht <= 240) {
|
||||||
|
// Por debajo de P10 (excelente eficiencia)
|
||||||
|
// Si FCR > 65%, es genuinamente eficiente; si no, puede ser rushed
|
||||||
|
if (fcrTecnico > 65) {
|
||||||
|
ahtScore = 95 + 5 * Math.max(0, (240 - aht) / 60); // 95-100
|
||||||
|
} else {
|
||||||
|
ahtScore = 70; // Cap score si FCR es bajo (posible rushed calls)
|
||||||
|
}
|
||||||
|
} else if (aht <= 380) {
|
||||||
|
ahtScore = 50 + 45 * (380 - aht) / (380 - 240); // 50-95
|
||||||
|
} else if (aht <= 540) {
|
||||||
|
ahtScore = 20 + 30 * (540 - aht) / (540 - 380); // 20-50
|
||||||
|
} else {
|
||||||
|
ahtScore = Math.max(0, 20 * (600 - aht) / 60); // 0-20
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSAT Proxy: Calculado desde FCR + Abandono
|
||||||
|
// Sin datos reales de CSAT, usamos proxy
|
||||||
|
const csatProxy = 0.60 * fcrScore + 0.40 * abandonoScore;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// PASO 2: Aplicar pesos
|
||||||
|
// FCR 35% + Abandono 30% + CSAT Proxy 20% + AHT 15%
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const subtotal = (
|
||||||
|
fcrScore * 0.35 +
|
||||||
|
abandonoScore * 0.30 +
|
||||||
|
csatProxy * 0.20 +
|
||||||
|
ahtScore * 0.15
|
||||||
|
);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// PASO 3: Calcular penalizaciones
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
let penalties = 0;
|
||||||
|
|
||||||
|
// Penalización por abandono crítico (>10%)
|
||||||
|
if (abandono > 10) {
|
||||||
|
penalties += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Penalización por transferencia alta (>20%)
|
||||||
|
if (transferencia > 20) {
|
||||||
|
penalties += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Penalización combo: Abandono alto + FCR bajo
|
||||||
|
// Indica problemas sistémicos de capacidad Y resolución
|
||||||
|
if (abandono > 8 && fcrTecnico < 78) {
|
||||||
|
penalties += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// PASO 4: Score final
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const finalScore = Math.max(0, Math.min(100, subtotal - penalties));
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log('📊 Health Score Calculation:', {
|
||||||
|
inputs: { fcrTecnico: fcrTecnico.toFixed(1), abandono: abandono.toFixed(1), aht: Math.round(aht), transferencia: transferencia.toFixed(1) },
|
||||||
|
scores: { fcrScore: fcrScore.toFixed(1), abandonoScore: abandonoScore.toFixed(1), ahtScore: ahtScore.toFixed(1), csatProxy: csatProxy.toFixed(1) },
|
||||||
|
weighted: { subtotal: subtotal.toFixed(1), penalties, final: Math.round(finalScore) }
|
||||||
|
});
|
||||||
|
|
||||||
|
return Math.round(finalScore);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -942,10 +1220,10 @@ function generateDimensionsFromRealData(
|
|||||||
const avgHoldTime = metrics.reduce((sum, m) => sum + m.hold_time_mean, 0) / metrics.length;
|
const avgHoldTime = metrics.reduce((sum, m) => sum + m.hold_time_mean, 0) / metrics.length;
|
||||||
const totalCost = metrics.reduce((sum, m) => sum + m.total_cost, 0);
|
const totalCost = metrics.reduce((sum, m) => sum + m.total_cost, 0);
|
||||||
|
|
||||||
// FCR real (ponderado por volumen)
|
// FCR Técnico (100 - transfer_rate, ponderado por volumen) - comparable con benchmarks
|
||||||
const totalVolumeForFCR = metrics.reduce((sum, m) => sum + m.volume_valid, 0);
|
const totalVolumeForFCR = metrics.reduce((sum, m) => sum + m.volume_valid, 0);
|
||||||
const avgFCR = totalVolumeForFCR > 0
|
const avgFCR = totalVolumeForFCR > 0
|
||||||
? metrics.reduce((sum, m) => sum + (m.fcr_rate * m.volume_valid), 0) / totalVolumeForFCR
|
? metrics.reduce((sum, m) => sum + (m.fcr_tecnico * m.volume_valid), 0) / totalVolumeForFCR
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Calcular ratio P90/P50 aproximado desde CV
|
// Calcular ratio P90/P50 aproximado desde CV
|
||||||
@@ -964,20 +1242,41 @@ function generateDimensionsFromRealData(
|
|||||||
// % fuera horario >30% penaliza, ratio pico/valle >3x penaliza
|
// % fuera horario >30% penaliza, ratio pico/valle >3x penaliza
|
||||||
const offHoursPct = hourlyDistribution.off_hours_pct;
|
const offHoursPct = hourlyDistribution.off_hours_pct;
|
||||||
|
|
||||||
// Calcular ratio pico/valle
|
// Calcular ratio pico/valle (consistente con backendMapper.ts)
|
||||||
const hourlyValues = hourlyDistribution.hourly.filter(v => v > 0);
|
const hourlyValues = hourlyDistribution.hourly.filter(v => v > 0);
|
||||||
const peakVolume = Math.max(...hourlyValues, 1);
|
const peakVolume = hourlyValues.length > 0 ? Math.max(...hourlyValues) : 0;
|
||||||
const valleyVolume = Math.min(...hourlyValues.filter(v => v > 0), 1);
|
const valleyVolume = hourlyValues.length > 0 ? Math.min(...hourlyValues) : 1;
|
||||||
const peakValleyRatio = peakVolume / valleyVolume;
|
const peakValleyRatio = valleyVolume > 0 ? peakVolume / valleyVolume : 1;
|
||||||
|
|
||||||
// Score volumetría: 100 base, penalizar por fuera de horario y ratio pico/valle
|
// Score volumetría: 100 base, penalizar por fuera de horario y ratio pico/valle
|
||||||
|
// NOTA: Fórmulas sincronizadas con backendMapper.ts buildVolumetryDimension()
|
||||||
let volumetryScore = 100;
|
let volumetryScore = 100;
|
||||||
if (offHoursPct > 30) volumetryScore -= (offHoursPct - 30) * 1.5; // Penalizar por % fuera horario
|
|
||||||
if (peakValleyRatio > 3) volumetryScore -= (peakValleyRatio - 3) * 10; // Penalizar por ratio pico/valle
|
|
||||||
volumetryScore = Math.max(20, Math.min(100, Math.round(volumetryScore)));
|
|
||||||
|
|
||||||
// === CPI: Coste por interacción ===
|
// Penalización por fuera de horario (misma fórmula que backendMapper)
|
||||||
const costPerInteraction = totalVolume > 0 ? totalCost / totalVolume : 0;
|
if (offHoursPct > 30) {
|
||||||
|
volumetryScore -= Math.min(40, (offHoursPct - 30) * 2); // -2 pts por cada % sobre 30%
|
||||||
|
} else if (offHoursPct > 20) {
|
||||||
|
volumetryScore -= (offHoursPct - 20); // -1 pt por cada % entre 20-30%
|
||||||
|
}
|
||||||
|
|
||||||
|
// Penalización por ratio pico/valle alto (misma fórmula que backendMapper)
|
||||||
|
if (peakValleyRatio > 5) {
|
||||||
|
volumetryScore -= 30;
|
||||||
|
} else if (peakValleyRatio > 3) {
|
||||||
|
volumetryScore -= 20;
|
||||||
|
} else if (peakValleyRatio > 2) {
|
||||||
|
volumetryScore -= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
volumetryScore = Math.max(0, Math.min(100, Math.round(volumetryScore)));
|
||||||
|
|
||||||
|
// === CPI: Coste por interacción (consistente con Executive Summary) ===
|
||||||
|
// Usar cost_volume (non-abandon) como denominador, igual que heatmapData
|
||||||
|
const totalCostVolume = metrics.reduce((sum, m) => sum + m.cost_volume, 0);
|
||||||
|
// Usar CPI pre-calculado si disponible, sino calcular desde total_cost / cost_volume
|
||||||
|
const costPerInteraction = totalCostVolume > 0
|
||||||
|
? metrics.reduce((sum, m) => sum + (m.cpi * m.cost_volume), 0) / totalCostVolume
|
||||||
|
: (totalCost / totalVolume);
|
||||||
|
|
||||||
// Calcular Agentic Score
|
// Calcular Agentic Score
|
||||||
const predictability = Math.max(0, Math.min(10, 10 - ((avgCV - 0.3) / 1.2 * 10)));
|
const predictability = Math.max(0, Math.min(10, 10 - ((avgCV - 0.3) / 1.2 * 10)));
|
||||||
@@ -1008,37 +1307,37 @@ function generateDimensionsFromRealData(
|
|||||||
peak_hours: hourlyDistribution.peak_hours
|
peak_hours: hourlyDistribution.peak_hours
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 2. EFICIENCIA OPERATIVA
|
// 2. EFICIENCIA OPERATIVA - KPI principal: AHT P50 (industry standard)
|
||||||
{
|
{
|
||||||
id: 'operational_efficiency',
|
id: 'operational_efficiency',
|
||||||
name: 'operational_efficiency',
|
name: 'operational_efficiency',
|
||||||
title: 'Eficiencia Operativa',
|
title: 'Eficiencia Operativa',
|
||||||
score: Math.round(efficiencyScore),
|
score: Math.round(efficiencyScore),
|
||||||
percentile: efficiencyPercentile,
|
percentile: efficiencyPercentile,
|
||||||
summary: `Ratio P90/P50: ${avgRatio.toFixed(2)} (benchmark: <2.0). AHT P50: ${avgAHT}s (benchmark: 380s). Hold time: ${Math.round(avgHoldTime)}s.`,
|
summary: `AHT P50: ${avgAHT}s (benchmark: 300s). Ratio P90/P50: ${avgRatio.toFixed(2)} (benchmark: <2.0). Hold time: ${Math.round(avgHoldTime)}s.`,
|
||||||
kpi: { label: 'Ratio P90/P50', value: avgRatio.toFixed(2) },
|
kpi: { label: 'AHT P50', value: `${avgAHT}s` },
|
||||||
icon: Zap
|
icon: Zap
|
||||||
},
|
},
|
||||||
// 3. EFECTIVIDAD & RESOLUCIÓN
|
// 3. EFECTIVIDAD & RESOLUCIÓN (FCR Técnico = 100 - transfer_rate)
|
||||||
{
|
{
|
||||||
id: 'effectiveness_resolution',
|
id: 'effectiveness_resolution',
|
||||||
name: 'effectiveness_resolution',
|
name: 'effectiveness_resolution',
|
||||||
title: 'Efectividad & Resolución',
|
title: 'Efectividad & Resolución',
|
||||||
score: Math.round(avgFCR),
|
score: avgFCR >= 90 ? 100 : avgFCR >= 85 ? 80 : avgFCR >= 80 ? 60 : avgFCR >= 75 ? 40 : 20,
|
||||||
percentile: fcrPercentile,
|
percentile: fcrPercentile,
|
||||||
summary: `FCR: ${avgFCR.toFixed(1)}% (benchmark: 70%). Calculado como: (sin transferencia) AND (sin rellamada 7d).`,
|
summary: `FCR Técnico: ${avgFCR.toFixed(1)}% (benchmark: 85-90%). Transfer: ${avgTransferRate.toFixed(1)}%.`,
|
||||||
kpi: { label: 'FCR Real', value: `${Math.round(avgFCR)}%` },
|
kpi: { label: 'FCR Técnico', value: `${Math.round(avgFCR)}%` },
|
||||||
icon: Target
|
icon: Target
|
||||||
},
|
},
|
||||||
// 4. COMPLEJIDAD & PREDICTIBILIDAD - Usar % transferencias como métrica principal
|
// 4. COMPLEJIDAD & PREDICTIBILIDAD - KPI principal: CV AHT (industry standard for predictability)
|
||||||
{
|
{
|
||||||
id: 'complexity_predictability',
|
id: 'complexity_predictability',
|
||||||
name: 'complexity_predictability',
|
name: 'complexity_predictability',
|
||||||
title: 'Complejidad & Predictibilidad',
|
title: 'Complejidad & Predictibilidad',
|
||||||
score: Math.round(100 - avgTransferRate), // Inverso de transfer rate
|
score: avgCV <= 0.75 ? 100 : avgCV <= 1.0 ? 80 : avgCV <= 1.25 ? 60 : avgCV <= 1.5 ? 40 : 20, // Basado en CV AHT
|
||||||
percentile: avgTransferRate < 15 ? 75 : avgTransferRate < 25 ? 50 : 30,
|
percentile: avgCV <= 0.75 ? 75 : avgCV <= 1.0 ? 55 : avgCV <= 1.25 ? 40 : 25,
|
||||||
summary: `Tasa transferencias: ${avgTransferRate.toFixed(1)}%. CV AHT: ${(avgCV * 100).toFixed(1)}%. ${avgTransferRate < 15 ? 'Baja complejidad.' : 'Alta complejidad, considerar capacitación.'}`,
|
summary: `CV AHT: ${(avgCV * 100).toFixed(0)}% (benchmark: <75%). Hold time: ${Math.round(avgHoldTime)}s. ${avgCV <= 0.75 ? 'Alta predictibilidad para WFM.' : avgCV <= 1.0 ? 'Predictibilidad aceptable.' : 'Alta variabilidad, dificulta planificación.'}`,
|
||||||
kpi: { label: '% Transferencias', value: `${avgTransferRate.toFixed(1)}%` },
|
kpi: { label: 'CV AHT', value: `${(avgCV * 100).toFixed(0)}%` },
|
||||||
icon: Brain
|
icon: Brain
|
||||||
},
|
},
|
||||||
// 5. SATISFACCIÓN - CSAT
|
// 5. SATISFACCIÓN - CSAT
|
||||||
@@ -1205,7 +1504,11 @@ function calculateAgenticReadinessFromRealData(metrics: SkillMetrics[]): Agentic
|
|||||||
/**
|
/**
|
||||||
* Generar findings desde datos reales - SOLO datos calculados del dataset
|
* Generar findings desde datos reales - SOLO datos calculados del dataset
|
||||||
*/
|
*/
|
||||||
function generateFindingsFromRealData(metrics: SkillMetrics[], interactions: RawInteraction[]): Finding[] {
|
function generateFindingsFromRealData(
|
||||||
|
metrics: SkillMetrics[],
|
||||||
|
interactions: RawInteraction[],
|
||||||
|
hourlyDistribution?: { hourly: number[]; off_hours_pct: number; peak_hours: number[] }
|
||||||
|
): Finding[] {
|
||||||
const findings: Finding[] = [];
|
const findings: Finding[] = [];
|
||||||
const totalVolume = interactions.length;
|
const totalVolume = interactions.length;
|
||||||
|
|
||||||
@@ -1218,6 +1521,20 @@ function generateFindingsFromRealData(metrics: SkillMetrics[], interactions: Raw
|
|||||||
const totalAbandoned = metrics.reduce((sum, m) => sum + m.abandon_count, 0);
|
const totalAbandoned = metrics.reduce((sum, m) => sum + m.abandon_count, 0);
|
||||||
const abandonRate = totalVolume > 0 ? (totalAbandoned / totalVolume) * 100 : 0;
|
const abandonRate = totalVolume > 0 ? (totalAbandoned / totalVolume) * 100 : 0;
|
||||||
|
|
||||||
|
// Finding 0: Alto volumen fuera de horario - oportunidad para agente virtual
|
||||||
|
const offHoursPct = hourlyDistribution?.off_hours_pct ?? 0;
|
||||||
|
if (offHoursPct > 20) {
|
||||||
|
const offHoursVolume = Math.round(totalVolume * offHoursPct / 100);
|
||||||
|
findings.push({
|
||||||
|
type: offHoursPct > 30 ? 'critical' : 'warning',
|
||||||
|
title: 'Alto Volumen Fuera de Horario',
|
||||||
|
text: `${offHoursPct.toFixed(0)}% de interacciones fuera de horario (8-19h)`,
|
||||||
|
dimensionId: 'volumetry_distribution',
|
||||||
|
description: `${offHoursVolume.toLocaleString()} interacciones (${offHoursPct.toFixed(1)}%) ocurren fuera de horario laboral. Oportunidad ideal para implementar agentes virtuales 24/7.`,
|
||||||
|
impact: offHoursPct > 30 ? 'high' : 'medium'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Finding 1: Ratio P90/P50 si está fuera de benchmark
|
// Finding 1: Ratio P90/P50 si está fuera de benchmark
|
||||||
if (avgRatio > 2.0) {
|
if (avgRatio > 2.0) {
|
||||||
findings.push({
|
findings.push({
|
||||||
@@ -1284,14 +1601,37 @@ function generateFindingsFromRealData(metrics: SkillMetrics[], interactions: Raw
|
|||||||
/**
|
/**
|
||||||
* Generar recomendaciones desde datos reales
|
* Generar recomendaciones desde datos reales
|
||||||
*/
|
*/
|
||||||
function generateRecommendationsFromRealData(metrics: SkillMetrics[]): Recommendation[] {
|
function generateRecommendationsFromRealData(
|
||||||
|
metrics: SkillMetrics[],
|
||||||
|
hourlyDistribution?: { hourly: number[]; off_hours_pct: number; peak_hours: number[] },
|
||||||
|
totalVolume?: number
|
||||||
|
): Recommendation[] {
|
||||||
const recommendations: Recommendation[] = [];
|
const recommendations: Recommendation[] = [];
|
||||||
|
|
||||||
|
// Recomendación prioritaria: Agente virtual para fuera de horario
|
||||||
|
const offHoursPct = hourlyDistribution?.off_hours_pct ?? 0;
|
||||||
|
const volume = totalVolume ?? metrics.reduce((sum, m) => sum + m.volume, 0);
|
||||||
|
if (offHoursPct > 20) {
|
||||||
|
const offHoursVolume = Math.round(volume * offHoursPct / 100);
|
||||||
|
const estimatedContainment = offHoursPct > 30 ? 60 : 45; // % que puede resolver el bot
|
||||||
|
const estimatedSavings = Math.round(offHoursVolume * estimatedContainment / 100);
|
||||||
|
recommendations.push({
|
||||||
|
priority: 'high',
|
||||||
|
title: 'Implementar Agente Virtual 24/7',
|
||||||
|
text: `Desplegar agente virtual para atender ${offHoursPct.toFixed(0)}% de interacciones fuera de horario`,
|
||||||
|
description: `${offHoursVolume.toLocaleString()} interacciones ocurren fuera de horario laboral (19:00-08:00). Un agente virtual puede resolver ~${estimatedContainment}% de estas consultas automáticamente, liberando recursos humanos y mejorando la experiencia del cliente con atención inmediata 24/7.`,
|
||||||
|
dimensionId: 'volumetry_distribution',
|
||||||
|
impact: `Potencial de contención: ${estimatedSavings.toLocaleString()} interacciones/período`,
|
||||||
|
timeline: '1-3 meses'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const highVariabilitySkills = metrics.filter(m => m.cv_aht > 0.45);
|
const highVariabilitySkills = metrics.filter(m => m.cv_aht > 0.45);
|
||||||
if (highVariabilitySkills.length > 0) {
|
if (highVariabilitySkills.length > 0) {
|
||||||
recommendations.push({
|
recommendations.push({
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
title: 'Estandarizar Procesos',
|
title: 'Estandarizar Procesos',
|
||||||
|
text: `Crear guías y scripts para los ${highVariabilitySkills.length} skills con alta variabilidad`,
|
||||||
description: `Crear guías y scripts para los ${highVariabilitySkills.length} skills con alta variabilidad.`,
|
description: `Crear guías y scripts para los ${highVariabilitySkills.length} skills con alta variabilidad.`,
|
||||||
impact: 'Reducción del 20-30% en AHT'
|
impact: 'Reducción del 20-30% en AHT'
|
||||||
});
|
});
|
||||||
@@ -1302,6 +1642,7 @@ function generateRecommendationsFromRealData(metrics: SkillMetrics[]): Recommend
|
|||||||
recommendations.push({
|
recommendations.push({
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
title: 'Automatizar Skills de Alto Volumen',
|
title: 'Automatizar Skills de Alto Volumen',
|
||||||
|
text: `Implementar bots para los ${highVolumeSkills.length} skills con > 500 interacciones`,
|
||||||
description: `Implementar bots para los ${highVolumeSkills.length} skills con > 500 interacciones.`,
|
description: `Implementar bots para los ${highVolumeSkills.length} skills con > 500 interacciones.`,
|
||||||
impact: 'Ahorro estimado del 40-60%'
|
impact: 'Ahorro estimado del 40-60%'
|
||||||
});
|
});
|
||||||
@@ -1347,12 +1688,18 @@ const CPI_CONFIG = {
|
|||||||
RATE_AUGMENT: 0.15 // 15% mejora en optimización
|
RATE_AUGMENT: 0.15 // 15% mejora en optimización
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Período de datos: el volumen en los datos corresponde a 11 meses, no es mensual
|
||||||
|
const DATA_PERIOD_MONTHS = 11;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v3.6: Calcular ahorro TCO realista usando fórmula explícita con CPI fijos
|
* v4.2: Calcular ahorro TCO realista usando fórmula explícita con CPI fijos
|
||||||
|
* IMPORTANTE: El volumen de los datos corresponde a 11 meses, por lo que:
|
||||||
|
* - Primero calculamos volumen mensual: Vol / 11
|
||||||
|
* - Luego anualizamos: × 12
|
||||||
* Fórmulas:
|
* Fórmulas:
|
||||||
* - AUTOMATE: Vol × 12 × 70% × (CPI_humano - CPI_bot)
|
* - AUTOMATE: (Vol/11) × 12 × 70% × (CPI_humano - CPI_bot)
|
||||||
* - ASSIST: Vol × 12 × 30% × (CPI_humano - CPI_assist)
|
* - ASSIST: (Vol/11) × 12 × 30% × (CPI_humano - CPI_assist)
|
||||||
* - AUGMENT: Vol × 12 × 15% × (CPI_humano - CPI_augment)
|
* - AUGMENT: (Vol/11) × 12 × 15% × (CPI_humano - CPI_augment)
|
||||||
* - HUMAN-ONLY: 0€
|
* - HUMAN-ONLY: 0€
|
||||||
*/
|
*/
|
||||||
function calculateRealisticSavings(
|
function calculateRealisticSavings(
|
||||||
@@ -1364,18 +1711,21 @@ function calculateRealisticSavings(
|
|||||||
|
|
||||||
const { CPI_HUMANO, CPI_BOT, CPI_ASSIST, CPI_AUGMENT, RATE_AUTOMATE, RATE_ASSIST, RATE_AUGMENT } = CPI_CONFIG;
|
const { CPI_HUMANO, CPI_BOT, CPI_ASSIST, CPI_AUGMENT, RATE_AUTOMATE, RATE_ASSIST, RATE_AUGMENT } = CPI_CONFIG;
|
||||||
|
|
||||||
|
// Convertir volumen del período (11 meses) a volumen anual
|
||||||
|
const annualVolume = (volume / DATA_PERIOD_MONTHS) * 12;
|
||||||
|
|
||||||
switch (tier) {
|
switch (tier) {
|
||||||
case 'AUTOMATE':
|
case 'AUTOMATE':
|
||||||
// Ahorro = Vol × 12 × 70% × (CPI_humano - CPI_bot)
|
// Ahorro = VolAnual × 70% × (CPI_humano - CPI_bot)
|
||||||
return Math.round(volume * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT));
|
return Math.round(annualVolume * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT));
|
||||||
|
|
||||||
case 'ASSIST':
|
case 'ASSIST':
|
||||||
// Ahorro = Vol × 12 × 30% × (CPI_humano - CPI_assist)
|
// Ahorro = VolAnual × 30% × (CPI_humano - CPI_assist)
|
||||||
return Math.round(volume * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST));
|
return Math.round(annualVolume * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST));
|
||||||
|
|
||||||
case 'AUGMENT':
|
case 'AUGMENT':
|
||||||
// Ahorro = Vol × 12 × 15% × (CPI_humano - CPI_augment)
|
// Ahorro = VolAnual × 15% × (CPI_humano - CPI_augment)
|
||||||
return Math.round(volume * 12 * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT));
|
return Math.round(annualVolume * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT));
|
||||||
|
|
||||||
case 'HUMAN-ONLY':
|
case 'HUMAN-ONLY':
|
||||||
default:
|
default:
|
||||||
@@ -1384,118 +1734,79 @@ function calculateRealisticSavings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function generateOpportunitiesFromDrilldown(drilldownData: DrilldownDataPoint[], costPerHour: number): Opportunity[] {
|
export function generateOpportunitiesFromDrilldown(drilldownData: DrilldownDataPoint[], costPerHour: number): Opportunity[] {
|
||||||
const opportunities: Opportunity[] = [];
|
// v4.3: Top 10 iniciativas por potencial económico (todos los tiers, no solo AUTOMATE)
|
||||||
|
// Cada cola = 1 burbuja con su score real y ahorro TCO real según su tier
|
||||||
|
|
||||||
// Extraer todas las colas usando el nuevo sistema de Tiers
|
// Extraer todas las colas con su skill padre (excluir HUMAN-ONLY, no tienen ahorro)
|
||||||
const allQueues = drilldownData.flatMap(skill =>
|
const allQueues = drilldownData.flatMap(skill =>
|
||||||
skill.originalQueues.map(q => ({
|
skill.originalQueues
|
||||||
|
.filter(q => q.tier !== 'HUMAN-ONLY') // HUMAN-ONLY no genera ahorro
|
||||||
|
.map(q => ({
|
||||||
...q,
|
...q,
|
||||||
skillName: skill.skill
|
skillName: skill.skill
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
// v3.5: Clasificar colas por TIER (no por CV)
|
if (allQueues.length === 0) {
|
||||||
const automateQueues = allQueues.filter(q => q.tier === 'AUTOMATE');
|
console.warn('⚠️ No hay colas con potencial de ahorro para mostrar en Opportunity Matrix');
|
||||||
const assistQueues = allQueues.filter(q => q.tier === 'ASSIST');
|
return [];
|
||||||
const augmentQueues = allQueues.filter(q => q.tier === 'AUGMENT');
|
}
|
||||||
const humanQueues = allQueues.filter(q => q.tier === 'HUMAN-ONLY');
|
|
||||||
|
|
||||||
// Calcular volúmenes y costes por tier
|
// Calcular ahorro TCO por cola individual según su tier
|
||||||
const automateVolume = automateQueues.reduce((sum, q) => sum + q.volume, 0);
|
const queuesWithSavings = allQueues.map(q => {
|
||||||
const automateCost = automateQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0);
|
const savings = calculateRealisticSavings(q.volume, q.annualCost || 0, q.tier);
|
||||||
const assistVolume = assistQueues.reduce((sum, q) => sum + q.volume, 0);
|
return { ...q, savings };
|
||||||
const assistCost = assistQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0);
|
|
||||||
const augmentVolume = augmentQueues.reduce((sum, q) => sum + q.volume, 0);
|
|
||||||
const augmentCost = augmentQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0);
|
|
||||||
const totalCost = automateCost + assistCost + augmentCost;
|
|
||||||
|
|
||||||
// v3.5: Calcular ahorros REALISTAS con fórmula TCO
|
|
||||||
const automateSavings = calculateRealisticSavings(automateVolume, automateCost, 'AUTOMATE');
|
|
||||||
const assistSavings = calculateRealisticSavings(assistVolume, assistCost, 'ASSIST');
|
|
||||||
const augmentSavings = calculateRealisticSavings(augmentVolume, augmentCost, 'AUGMENT');
|
|
||||||
|
|
||||||
// Helper para obtener top skills
|
|
||||||
const getTopSkills = (queues: typeof allQueues, limit: number = 3): string[] => {
|
|
||||||
const skillVolumes = new Map<string, number>();
|
|
||||||
queues.forEach(q => {
|
|
||||||
skillVolumes.set(q.skillName, (skillVolumes.get(q.skillName) || 0) + q.volume);
|
|
||||||
});
|
});
|
||||||
return Array.from(skillVolumes.entries())
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
// Ordenar por ahorro descendente
|
||||||
.slice(0, limit)
|
queuesWithSavings.sort((a, b) => b.savings - a.savings);
|
||||||
.map(([name]) => name);
|
|
||||||
|
// Calcular max savings para escalar impact a 0-10
|
||||||
|
const maxSavings = Math.max(...queuesWithSavings.map(q => q.savings), 1);
|
||||||
|
|
||||||
|
// Mapeo de tier a dimensionId y customer_segment
|
||||||
|
const tierToDimension: Record<string, string> = {
|
||||||
|
'AUTOMATE': 'agentic_readiness',
|
||||||
|
'ASSIST': 'effectiveness_resolution',
|
||||||
|
'AUGMENT': 'complexity_predictability'
|
||||||
|
};
|
||||||
|
const tierToSegment: Record<string, CustomerSegment> = {
|
||||||
|
'AUTOMATE': 'high',
|
||||||
|
'ASSIST': 'medium',
|
||||||
|
'AUGMENT': 'low'
|
||||||
};
|
};
|
||||||
|
|
||||||
let oppIndex = 1;
|
// Generar oportunidades individuales (TOP 10 por potencial económico)
|
||||||
|
const opportunities: Opportunity[] = queuesWithSavings
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((q, idx) => {
|
||||||
|
// Impact: ahorro escalado a 0-10
|
||||||
|
const impactRaw = (q.savings / maxSavings) * 10;
|
||||||
|
const impact = Math.max(1, Math.min(10, Math.round(impactRaw * 10) / 10));
|
||||||
|
|
||||||
// Oportunidad 1: AUTOMATE (70% containment)
|
// Feasibility: agenticScore directo (ya es 0-10)
|
||||||
if (automateQueues.length > 0) {
|
const feasibility = Math.round(q.agenticScore * 10) / 10;
|
||||||
opportunities.push({
|
|
||||||
id: `opp-${oppIndex++}`,
|
// Nombre con prefijo de tier para claridad
|
||||||
name: `Automatizar ${automateQueues.length} colas tier AUTOMATE`,
|
const tierPrefix = q.tier === 'AUTOMATE' ? '🤖' : q.tier === 'ASSIST' ? '🤝' : '📚';
|
||||||
impact: Math.min(10, Math.round((automateCost / totalCost) * 10) + 3),
|
const shortName = q.original_queue_id.length > 22
|
||||||
feasibility: 9,
|
? `${tierPrefix} ${q.original_queue_id.substring(0, 19)}...`
|
||||||
savings: automateSavings,
|
: `${tierPrefix} ${q.original_queue_id}`;
|
||||||
dimensionId: 'agentic_readiness',
|
|
||||||
customer_segment: 'high' as CustomerSegment
|
return {
|
||||||
|
id: `opp-${q.tier.toLowerCase()}-${idx + 1}`,
|
||||||
|
name: shortName,
|
||||||
|
impact,
|
||||||
|
feasibility,
|
||||||
|
savings: q.savings,
|
||||||
|
dimensionId: tierToDimension[q.tier] || 'agentic_readiness',
|
||||||
|
customer_segment: tierToSegment[q.tier] || 'medium'
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Oportunidad 2: ASSIST (30% efficiency)
|
console.log(`📊 Opportunity Matrix: Top ${opportunities.length} iniciativas por potencial económico (de ${allQueues.length} colas con ahorro)`);
|
||||||
if (assistQueues.length > 0) {
|
|
||||||
opportunities.push({
|
|
||||||
id: `opp-${oppIndex++}`,
|
|
||||||
name: `Copilot IA en ${assistQueues.length} colas tier ASSIST`,
|
|
||||||
impact: Math.min(10, Math.round((assistCost / totalCost) * 10) + 2),
|
|
||||||
feasibility: 7,
|
|
||||||
savings: assistSavings,
|
|
||||||
dimensionId: 'effectiveness_resolution',
|
|
||||||
customer_segment: 'medium' as CustomerSegment
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Oportunidad 3: AUGMENT (15% optimization)
|
return opportunities;
|
||||||
if (augmentQueues.length > 0) {
|
|
||||||
opportunities.push({
|
|
||||||
id: `opp-${oppIndex++}`,
|
|
||||||
name: `Optimizar ${augmentQueues.length} colas tier AUGMENT`,
|
|
||||||
impact: Math.min(10, Math.round((augmentCost / totalCost) * 10) + 1),
|
|
||||||
feasibility: 5,
|
|
||||||
savings: augmentSavings,
|
|
||||||
dimensionId: 'complexity_predictability',
|
|
||||||
customer_segment: 'medium' as CustomerSegment
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Oportunidades específicas por skill con alto volumen
|
|
||||||
const skillsWithHighVolume = drilldownData
|
|
||||||
.filter(s => s.volume > 10000)
|
|
||||||
.sort((a, b) => b.volume - a.volume)
|
|
||||||
.slice(0, 3);
|
|
||||||
|
|
||||||
for (const skill of skillsWithHighVolume) {
|
|
||||||
const autoQueues = skill.originalQueues.filter(q => q.tier === 'AUTOMATE');
|
|
||||||
if (autoQueues.length > 0) {
|
|
||||||
const skillVolume = autoQueues.reduce((sum, q) => sum + q.volume, 0);
|
|
||||||
const skillCost = autoQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0);
|
|
||||||
const savings = calculateRealisticSavings(skillVolume, skillCost, 'AUTOMATE');
|
|
||||||
|
|
||||||
opportunities.push({
|
|
||||||
id: `opp-${oppIndex++}`,
|
|
||||||
name: `Quick win: ${skill.skill}`,
|
|
||||||
impact: Math.min(8, Math.round(skillVolume / 30000) + 3),
|
|
||||||
feasibility: 8,
|
|
||||||
savings,
|
|
||||||
dimensionId: 'operational_efficiency',
|
|
||||||
customer_segment: 'high' as CustomerSegment
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ordenar por ahorro (ya es realista)
|
|
||||||
opportunities.sort((a, b) => b.savings - a.savings);
|
|
||||||
|
|
||||||
return opportunities.slice(0, 8);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2115,10 +2426,10 @@ function generateBenchmarkFromRealData(metrics: SkillMetrics[]): BenchmarkDataPo
|
|||||||
const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / (metrics.length || 1);
|
const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / (metrics.length || 1);
|
||||||
const avgRatio = 1 + avgCV * 1.5; // Ratio P90/P50 aproximado
|
const avgRatio = 1 + avgCV * 1.5; // Ratio P90/P50 aproximado
|
||||||
|
|
||||||
// FCR Real: ponderado por volumen
|
// FCR Técnico: 100 - transfer_rate (ponderado por volumen)
|
||||||
const totalVolume = metrics.reduce((sum, m) => sum + m.volume_valid, 0);
|
const totalVolume = metrics.reduce((sum, m) => sum + m.volume_valid, 0);
|
||||||
const avgFCR = totalVolume > 0
|
const avgFCR = totalVolume > 0
|
||||||
? metrics.reduce((sum, m) => sum + (m.fcr_rate * m.volume_valid), 0) / totalVolume
|
? metrics.reduce((sum, m) => sum + (m.fcr_tecnico * m.volume_valid), 0) / totalVolume
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Abandono real
|
// Abandono real
|
||||||
|
|||||||
Reference in New Issue
Block a user