Compare commits

..

18 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

103
CLAUDE.md Normal file
View File

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

144
Dockerfile Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeft, ShieldCheck, Info } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { DashboardHeader, TabId } from './DashboardHeader'; import { DashboardHeader, TabId } from './DashboardHeader';
import { formatDateMonthYear } from '../utils/formatters';
import { ExecutiveSummaryTab } from './tabs/ExecutiveSummaryTab'; import { ExecutiveSummaryTab } from './tabs/ExecutiveSummaryTab';
import { DimensionAnalysisTab } from './tabs/DimensionAnalysisTab'; import { DimensionAnalysisTab } from './tabs/DimensionAnalysisTab';
import { AgenticReadinessTab } from './tabs/AgenticReadinessTab'; import { AgenticReadinessTab } from './tabs/AgenticReadinessTab';
import { RoadmapTab } from './tabs/RoadmapTab'; import { RoadmapTab } from './tabs/RoadmapTab';
import { Law10Tab } from './tabs/Law10Tab';
import { MetodologiaDrawer } from './MetodologiaDrawer'; import { MetodologiaDrawer } from './MetodologiaDrawer';
import type { AnalysisData } from '../types'; import type { AnalysisData } from '../types';
@@ -33,6 +35,8 @@ export function DashboardTabs({
return <AgenticReadinessTab data={data} onTabChange={setActiveTab} />; return <AgenticReadinessTab data={data} onTabChange={setActiveTab} />;
case 'roadmap': case 'roadmap':
return <RoadmapTab data={data} />; return <RoadmapTab data={data} />;
case 'law10':
return <Law10Tab data={data} />;
default: default:
return <ExecutiveSummaryTab data={data} />; return <ExecutiveSummaryTab data={data} />;
} }
@@ -61,6 +65,7 @@ export function DashboardTabs({
title={title} title={title}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={setActiveTab}
onMetodologiaClick={() => setMetodologiaOpen(true)}
/> />
{/* Tab Content */} {/* Tab Content */}
@@ -84,23 +89,7 @@ export function DashboardTabs({
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-sm text-slate-500"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-sm text-slate-500">
<span className="hidden sm:inline">Beyond Diagnosis - Contact Center Analytics Platform</span> <span className="hidden sm:inline">Beyond Diagnosis - Contact Center Analytics Platform</span>
<span className="sm:hidden text-xs">Beyond Diagnosis</span> <span className="sm:hidden text-xs">Beyond Diagnosis</span>
<div className="flex flex-wrap items-center gap-2 sm:gap-3"> <span className="text-xs sm:text-sm text-slate-400 italic">{formatDateMonthYear()}</span>
<span className="text-xs sm:text-sm">
{data.tier ? data.tier.toUpperCase() : 'GOLD'} |
{data.source === 'backend' ? 'Genesys' : data.source || 'synthetic'}
</span>
<span className="hidden sm:inline text-slate-300">|</span>
{/* Badge Metodología */}
<button
onClick={() => setMetodologiaOpen(true)}
className="inline-flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 bg-green-100 text-green-800 rounded-full text-[10px] sm:text-xs font-medium hover:bg-green-200 transition-colors cursor-pointer"
>
<ShieldCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="hidden md:inline">Metodología de Transformación de Datos aplicada</span>
<span className="md:hidden">Metodología</span>
<Info className="w-2.5 h-2.5 sm:w-3 sm:h-3 opacity-60" />
</button>
</div>
</div> </div>
</div> </div>
</footer> </footer>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ChevronRight, TrendingUp, TrendingDown, Minus, AlertTriangle, Lightbulb, DollarSign } from 'lucide-react'; import { ChevronRight, TrendingUp, TrendingDown, Minus, AlertTriangle, Lightbulb, DollarSign, Clock } from 'lucide-react';
import type { AnalysisData, DimensionAnalysis, Finding, Recommendation, HeatmapDataPoint } from '../../types'; import type { AnalysisData, DimensionAnalysis, Finding, Recommendation, HeatmapDataPoint } from '../../types';
import { import {
Card, Card,
@@ -20,7 +20,7 @@ interface DimensionAnalysisTabProps {
data: AnalysisData; data: AnalysisData;
} }
// ========== ANÁLISIS CAUSAL CON IMPACTO ECONÓMICO ========== // ========== HALLAZGO CLAVE CON IMPACTO ECONÓMICO ==========
interface CausalAnalysis { interface CausalAnalysis {
finding: string; finding: string;
@@ -34,30 +34,57 @@ 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 CPI_TCO = 2.33; const HOURLY_COST = staticConfig?.cost_per_hour ?? 20;
const CPI = totalVolume > 0 ? economicModel.currentAnnualCost / (totalVolume * 12) : CPI_TCO;
// Calcular factor de anualización basado en el período de datos
// Si tenemos dateRange, calculamos cuántos días cubre y extrapolamos a año
let annualizationFactor = 1; // Por defecto, asumimos que los datos ya son anuales
if (dateRange?.min && dateRange?.max) {
const startDate = new Date(dateRange.min);
const endDate = new Date(dateRange.max);
const daysCovered = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1);
annualizationFactor = 365 / daysCovered;
}
// v3.11: CPI consistente con Executive Summary - benchmark aerolíneas p50
const CPI_TCO = 3.50; // Benchmark aerolíneas (p50) para cálculos de impacto
// Usar CPI pre-calculado de heatmapData si existe, sino calcular desde annual_cost/cost_volume
// IMPORTANTE: Mismo cálculo que ExecutiveSummaryTab para consistencia
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
const totalAnnualCost = heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0);
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
const CPI = hasCpiField
? (totalCostVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
: 0)
: (totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : 0);
// Calcular métricas agregadas // Calcular métricas agregadas
const avgCVAHT = totalVolume > 0 const avgCVAHT = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.variability?.cv_aht || 0) * h.volume, 0) / totalVolume ? heatmapData.reduce((sum, h) => sum + (h.variability?.cv_aht || 0) * h.volume, 0) / totalVolume
: 0; : 0;
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.metrics.transfer_rate * 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 +98,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 skillsHighTransfer = heatmapData.filter(h => (h.variability?.transfer_rate || 0) > 20); const skillsLowFCR = heatmapData.filter(h => (h.metrics.fcr_tecnico ?? (100 - h.metrics.transfer_rate)) < 50);
const skillsHighTransfer = heatmapData.filter(h => h.metrics.transfer_rate > 20);
// Parsear P50 AHT del KPI del header para consistencia visual
// El KPI puede ser "345s (P50)" o similar
const parseKpiAhtSeconds = (kpiValue: string): number | null => {
const match = kpiValue.match(/(\d+)s/);
return match ? parseInt(match[1], 10) : null;
};
switch (dimension.name) { switch (dimension.name) {
case 'operational_efficiency': case 'operational_efficiency':
// Análisis de variabilidad AHT // Obtener P50 AHT del header para mostrar valor consistente
if (avgCVAHT > 80) { const p50Aht = parseKpiAhtSeconds(dimension.kpi.value) ?? avgAHT;
const inefficiencyPct = Math.min(0.15, (avgCVAHT - 60) / 200);
const inefficiencyCost = Math.round(economicModel.currentAnnualCost * inefficiencyPct); // Eficiencia Operativa: enfocada en AHT (valor absoluto)
// CV AHT se analiza en Complejidad & Predictibilidad (best practice)
const hasHighAHT = p50Aht > 300; // 5:00 benchmark
const ahtBenchmark = 300; // 5:00 objetivo
if (hasHighAHT) {
// Calcular impacto económico por AHT excesivo
const excessSeconds = p50Aht - ahtBenchmark;
const annualVolume = Math.round(totalVolume * annualizationFactor);
const excessHours = Math.round((excessSeconds / 3600) * annualVolume);
const ahtExcessCost = Math.round(excessHours * HOURLY_COST);
// Estimar ahorro con solución Copilot (25-30% reducción AHT)
const copilotSavings = Math.round(ahtExcessCost * 0.28);
// Causa basada en AHT elevado
const cause = 'Agentes dedican tiempo excesivo a búsqueda manual de información, navegación entre sistemas y tareas repetitivas.';
analyses.push({ analyses.push({
finding: `Variabilidad AHT elevada: CV ${avgCVAHT.toFixed(0)}% (benchmark: <60%)`, finding: `AHT elevado: P50 ${Math.floor(p50Aht / 60)}:${String(Math.round(p50Aht) % 60).padStart(2, '0')} (benchmark: 5:00)`,
probableCause: skillsHighCV.length > 0 probableCause: cause,
? `Falta de scripts estandarizados en ${skillsHighCV.slice(0, 3).map(s => s.skill).join(', ')}. Agentes manejan casos similares de formas muy diferentes.` economicImpact: ahtExcessCost,
: 'Procesos no documentados y falta de guías de atención claras.', impactFormula: `${excessHours.toLocaleString()}h ×${HOURLY_COST}/h`,
economicImpact: inefficiencyCost, timeSavings: `${excessHours.toLocaleString()} horas/año en exceso de AHT`,
impactFormula: `Coste anual × ${(inefficiencyPct * 100).toFixed(1)}% ineficiencia = €${(economicModel.currentAnnualCost/1000).toFixed(0)}K × ${(inefficiencyPct * 100).toFixed(1)}%`, recommendation: `Desplegar Copilot IA para agentes: (1) Auto-búsqueda en KB; (2) Sugerencias contextuales en tiempo real; (3) Scripts guiados para casos frecuentes. Reducción esperada: 20-30% AHT. Ahorro: ${formatCurrency(copilotSavings)}/año.`,
recommendation: 'Crear playbooks por tipología de consulta y certificar agentes en procesos estándar.', severity: p50Aht > 420 ? 'critical' : 'warning',
severity: avgCVAHT > 120 ? 'critical' : 'warning',
hasRealData: true hasRealData: true
}); });
} } else {
// AHT dentro de benchmark - mostrar estado positivo
// Análisis de AHT absoluto
if (avgAHT > 420) {
const excessSeconds = avgAHT - 360;
const excessCost = Math.round((excessSeconds / 3600) * totalVolume * 12 * 25);
analyses.push({ analyses.push({
finding: `AHT elevado: ${Math.floor(avgAHT / 60)}:${String(Math.round(avgAHT) % 60).padStart(2, '0')} (benchmark: 6:00)`, finding: `AHT dentro de benchmark: P50 ${Math.floor(p50Aht / 60)}:${String(Math.round(p50Aht) % 60).padStart(2, '0')} (benchmark: 5:00)`,
probableCause: 'Sistemas de información fragmentados, búsquedas manuales excesivas, o falta de herramientas de asistencia al agente.', probableCause: 'Tiempos de gestión eficientes. Procesos operativos optimizados.',
economicImpact: excessCost, economicImpact: 0,
impactFormula: `Exceso ${Math.round(excessSeconds)}s × ${totalVolume.toLocaleString()} int/mes × 12 × €25/h`, impactFormula: 'Sin exceso de coste por AHT',
recommendation: 'Implementar vista unificada de cliente y herramientas de sugerencia automática.', timeSavings: 'Operación eficiente',
severity: avgAHT > 540 ? 'critical' : 'warning', recommendation: 'Mantener nivel actual. Considerar Copilot para mejora continua y reducción adicional de tiempos en casos complejos.',
severity: 'info',
hasRealData: true hasRealData: true
}); });
} }
break; break;
case 'effectiveness_resolution': case 'effectiveness_resolution':
// Análisis de FCR // Análisis principal: FCR Técnico y tasa de transferencias
const annualVolumeEff = Math.round(totalVolume * annualizationFactor);
const transferCount = Math.round(annualVolumeEff * (avgTransferRate / 100));
// Calcular impacto económico de transferencias
const transferCostTotal = Math.round(transferCount * CPI_TCO * 0.5);
// Potencial de mejora con IA
const improvementPotential = avgFCR < 90 ? Math.round((90 - avgFCR) / 100 * annualVolumeEff) : 0;
const potentialSavingsEff = Math.round(improvementPotential * CPI_TCO * 0.3);
// Determinar severidad basada en FCR
const effSeverity = avgFCR < 70 ? 'critical' : avgFCR < 85 ? 'warning' : 'info';
// Construir causa basada en datos
let effCause = '';
if (avgFCR < 70) { if (avgFCR < 70) {
const recontactRate = (100 - avgFCR) / 100; effCause = skillsLowFCR.length > 0
const recontactCost = Math.round(totalVolume * 12 * recontactRate * CPI_TCO); ? `Alta tasa de transferencias (${avgTransferRate.toFixed(0)}%) indica falta de herramientas o autoridad. Crítico en ${skillsLowFCR.slice(0, 2).map(s => s.skill).join(', ')}.`
analyses.push({ : `Transferencias elevadas (${avgTransferRate.toFixed(0)}%): agentes sin información contextual o sin autoridad para resolver.`;
finding: `FCR bajo: ${avgFCR.toFixed(0)}% (benchmark: >75%)`, } else if (avgFCR < 85) {
probableCause: skillsLowFCR.length > 0 effCause = `Transferencias del ${avgTransferRate.toFixed(0)}% indican oportunidad de mejora con asistencia IA para casos complejos.`;
? `Agentes sin autonomía para resolver en ${skillsLowFCR.slice(0, 2).map(s => s.skill).join(', ')}. Políticas de escalado excesivamente restrictivas.` } else {
: 'Falta de información completa en primer contacto o limitaciones de autoridad del agente.', effCause = `FCR Técnico en nivel óptimo. Transferencias del ${avgTransferRate.toFixed(0)}% principalmente en casos que requieren escalación legítima.`;
economicImpact: recontactCost,
impactFormula: `${totalVolume.toLocaleString()} int × 12 × ${(recontactRate * 100).toFixed(0)}% recontactos ×${CPI_TCO}/int`,
recommendation: 'Empoderar agentes con mayor autoridad de resolución y crear Knowledge Base contextual.',
severity: avgFCR < 50 ? 'critical' : 'warning',
hasRealData: true
});
} }
// Análisis de transferencias // Construir recomendación
if (avgTransferRate > 15) { let effRecommendation = '';
const transferCost = Math.round(totalVolume * 12 * (avgTransferRate / 100) * CPI_TCO * 0.5); if (avgFCR < 70) {
analyses.push({ effRecommendation = `Desplegar Knowledge Copilot con búsqueda inteligente en KB + Guided Resolution Copilot para casos complejos. Objetivo: FCR >85%. Potencial ahorro: ${formatCurrency(potentialSavingsEff)}/año.`;
finding: `Tasa de transferencias: ${avgTransferRate.toFixed(1)}% (benchmark: <10%)`, } else if (avgFCR < 85) {
probableCause: skillsHighTransfer.length > 0 effRecommendation = `Implementar Copilot de asistencia en tiempo real: sugerencias contextuales + conexión con expertos virtuales para reducir transferencias. Objetivo: FCR >90%.`;
? `Routing inicial incorrecto hacia ${skillsHighTransfer.slice(0, 2).map(s => s.skill).join(', ')}. IVR no identifica correctamente la intención del cliente.` } else {
: 'Reglas de enrutamiento desactualizadas o skills mal definidos.', effRecommendation = `Mantener nivel actual. Considerar IA para análisis de transferencias legítimas y optimización de enrutamiento predictivo.`;
economicImpact: transferCost,
impactFormula: `${totalVolume.toLocaleString()} int × 12 × ${avgTransferRate.toFixed(1)}% ×${CPI_TCO} × 50% coste adicional`,
recommendation: 'Revisar árbol de IVR, actualizar reglas de ACD y capacitar agentes en resolución integral.',
severity: avgTransferRate > 25 ? 'critical' : 'warning',
hasRealData: true
});
} }
analyses.push({
finding: `FCR Técnico: ${avgFCR.toFixed(0)}% | Transferencias: ${avgTransferRate.toFixed(0)}% (benchmark: FCR >85%, Transfer <10%)`,
probableCause: effCause,
economicImpact: transferCostTotal,
impactFormula: `${transferCount.toLocaleString()} transferencias/año ×${CPI_TCO}/int × 50% coste adicional`,
timeSavings: `${transferCount.toLocaleString()} transferencias/año (${avgTransferRate.toFixed(0)}% del volumen)`,
recommendation: effRecommendation,
severity: effSeverity,
hasRealData: true
});
break; break;
case 'volumetry_distribution': case 'volumetry_distribution':
@@ -149,13 +211,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 +228,103 @@ 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':
case 'economy_costs': // También manejar el ID del backend
// 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 +465,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 +498,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 +523,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 +556,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">
@@ -463,6 +574,29 @@ function DimensionCard({
// ========== v3.16: COMPONENTE PRINCIPAL ========== // ========== v3.16: COMPONENTE PRINCIPAL ==========
export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) { export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
// DEBUG: Verificar CPI en dimensión vs heatmapData
const economyDim = data.dimensions.find(d =>
d.id === 'economy_costs' || d.name === 'economy_costs' ||
d.id === 'economy_cpi' || d.name === 'economy_cpi'
);
const heatmapData = data.heatmapData;
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
const calculatedCPI = hasCpiField
? (totalCostVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
: 0)
: (totalCostVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0) / totalCostVolume
: 0);
console.log('🔍 DimensionAnalysisTab DEBUG:');
console.log(' - economyDim found:', !!economyDim, economyDim?.id || economyDim?.name);
console.log(' - economyDim.kpi.value:', economyDim?.kpi?.value);
console.log(' - calculatedCPI from heatmapData:', `${calculatedCPI.toFixed(2)}`);
console.log(' - hasCpiField:', hasCpiField);
console.log(' - MATCH:', economyDim?.kpi?.value === `${calculatedCPI.toFixed(2)}`);
// Filter out agentic_readiness (has its own tab) // Filter out agentic_readiness (has its own tab)
const coreDimensions = data.dimensions.filter(d => d.name !== 'agentic_readiness'); const coreDimensions = data.dimensions.filter(d => d.name !== 'agentic_readiness');
@@ -473,9 +607,9 @@ export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
const getRecommendationsForDimension = (dimensionId: string) => const getRecommendationsForDimension = (dimensionId: string) =>
data.recommendations.filter(r => r.dimensionId === dimensionId); data.recommendations.filter(r => r.dimensionId === dimensionId);
// Generar análisis causal para cada dimensión // Generar hallazgo clave para cada dimensión
const getCausalAnalysisForDimension = (dimension: DimensionAnalysis) => const getCausalAnalysisForDimension = (dimension: DimensionAnalysis) =>
generateCausalAnalysis(dimension, data.heatmapData, data.economicModel); generateCausalAnalysis(dimension, data.heatmapData, data.economicModel, data.staticConfig, data.dateRange);
// Calcular impacto total de todas las dimensiones con datos // Calcular impacto total de todas las dimensiones con datos
const impactoTotal = coreDimensions const impactoTotal = coreDimensions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
// analysisGenerator.ts - v2.0 con 6 dimensiones // analysisGenerator.ts - v2.0 con 6 dimensiones
import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment, RawInteraction, DrilldownDataPoint, AgenticTier } from '../types'; import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment, RawInteraction, DrilldownDataPoint, AgenticTier } from '../types';
import { generateAnalysisFromRealData, calculateDrilldownMetrics, generateOpportunitiesFromDrilldown, generateRoadmapFromDrilldown } from './realDataAnalysis'; import { generateAnalysisFromRealData, calculateDrilldownMetrics, generateOpportunitiesFromDrilldown, generateRoadmapFromDrilldown, calculateSkillMetrics, generateHeatmapFromMetrics, clasificarTierSimple } from './realDataAnalysis';
import { RoadmapPhase } from '../types'; import { RoadmapPhase } from '../types';
import { BarChartHorizontal, Zap, Target, Brain, Bot } from 'lucide-react'; import { BarChartHorizontal, Zap, Target, Brain, Bot } from 'lucide-react';
import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2'; import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2';
@@ -9,7 +9,7 @@ import {
mapBackendResultsToAnalysisData, mapBackendResultsToAnalysisData,
buildHeatmapFromBackend, buildHeatmapFromBackend,
} from './backendMapper'; } from './backendMapper';
import { saveFileToServerCache, saveDrilldownToServerCache, getCachedDrilldown } from './serverCache'; import { saveFileToServerCache, saveDrilldownToServerCache, getCachedDrilldown, downloadCachedFile } from './serverCache';
@@ -532,9 +532,12 @@ const generateHeatmapData = (
const transfer_rate = randomInt(5, 35); // % const transfer_rate = randomInt(5, 35); // %
const fcr_approx = 100 - transfer_rate; // FCR aproximado const fcr_approx = 100 - transfer_rate; // FCR aproximado
// Coste anual // Coste del período (mensual) - con factor de productividad 70%
const annual_volume = volume * 12; const effectiveProductivity = 0.70;
const annual_cost = Math.round(annual_volume * aht_mean * COST_PER_SECOND); const period_cost = Math.round((aht_mean / 3600) * costPerHour * volume / effectiveProductivity);
const annual_cost = period_cost; // Renombrado por compatibilidad, pero es coste mensual
// CPI = coste por interacción
const cpi = volume > 0 ? period_cost / volume : 0;
// === NUEVA LÓGICA: 3 DIMENSIONES === // === NUEVA LÓGICA: 3 DIMENSIONES ===
@@ -597,6 +600,7 @@ const generateHeatmapData = (
skill, skill,
segment, segment,
volume, volume,
cost_volume: volume, // En datos sintéticos, asumimos que todos son non-abandon
aht_seconds: aht_mean, // Renombrado para compatibilidad aht_seconds: aht_mean, // Renombrado para compatibilidad
metrics: { metrics: {
fcr: isNaN(fcr_approx) ? 0 : Math.max(0, Math.min(100, Math.round(fcr_approx))), fcr: isNaN(fcr_approx) ? 0 : Math.max(0, Math.min(100, Math.round(fcr_approx))),
@@ -606,6 +610,7 @@ const generateHeatmapData = (
transfer_rate: isNaN(transfer_rate) ? 0 : Math.max(0, Math.min(100, Math.round(transfer_rate * 100))) transfer_rate: isNaN(transfer_rate) ? 0 : Math.max(0, Math.min(100, Math.round(transfer_rate * 100)))
}, },
annual_cost, annual_cost,
cpi,
variability: { variability: {
cv_aht: Math.round(cv_aht * 100), // Convertir a porcentaje cv_aht: Math.round(cv_aht * 100), // Convertir a porcentaje
cv_talk_time: 0, // Deprecado en v2.1 cv_talk_time: 0, // Deprecado en v2.1
@@ -624,29 +629,6 @@ const generateHeatmapData = (
}); });
}; };
// v3.0: Oportunidades con nuevas dimensiones
const generateOpportunityMatrixData = (): Opportunity[] => {
const opportunities = [
{ id: 'opp1', name: 'Automatizar consulta de pedidos', savings: 85000, dimensionId: 'agentic_readiness', customer_segment: 'medium' as CustomerSegment },
{ id: 'opp2', name: 'Implementar Knowledge Base dinámica', savings: 45000, dimensionId: 'operational_efficiency', customer_segment: 'high' as CustomerSegment },
{ id: 'opp3', name: 'Chatbot de triaje inicial', savings: 120000, dimensionId: 'effectiveness_resolution', customer_segment: 'medium' as CustomerSegment },
{ id: 'opp4', name: 'Reducir complejidad en colas críticas', savings: 30000, dimensionId: 'complexity_predictability', customer_segment: 'high' as CustomerSegment },
{ id: 'opp5', name: 'Cobertura 24/7 con agentes virtuales', savings: 65000, dimensionId: 'volumetry_distribution', customer_segment: 'low' as CustomerSegment },
];
return opportunities.map(opp => ({ ...opp, impact: randomInt(3, 10), feasibility: randomInt(2, 9) }));
};
// v3.0: Roadmap con nuevas dimensiones
const generateRoadmapData = (): RoadmapInitiative[] => {
return [
{ id: 'r1', name: 'Chatbot de estado de pedido', phase: RoadmapPhase.Automate, timeline: 'Q1 2025', investment: 25000, resources: ['1x Bot Developer', 'API Access'], dimensionId: 'agentic_readiness', risk: 'low' },
{ id: 'r2', name: 'Implementar Knowledge Base dinámica', phase: RoadmapPhase.Assist, timeline: 'Q1 2025', investment: 15000, resources: ['1x PM', 'Content Team'], dimensionId: 'operational_efficiency', risk: 'low' },
{ id: 'r3', name: 'Agent Assist para sugerencias en real-time', phase: RoadmapPhase.Assist, timeline: 'Q2 2025', investment: 45000, resources: ['2x AI Devs', 'QA Team'], dimensionId: 'effectiveness_resolution', risk: 'medium' },
{ id: 'r4', name: 'Estandarización de procesos complejos', phase: RoadmapPhase.Augment, timeline: 'Q3 2025', investment: 30000, resources: ['Process Analyst', 'Training Team'], dimensionId: 'complexity_predictability', risk: 'medium' },
{ id: 'r5', name: 'Cobertura 24/7 con agentes virtuales', phase: RoadmapPhase.Augment, timeline: 'Q4 2025', investment: 75000, resources: ['Lead AI Engineer', 'Data Scientist'], dimensionId: 'volumetry_distribution', risk: 'high' },
];
};
// v2.0: Añadir NPV y costBreakdown // v2.0: Añadir NPV y costBreakdown
const generateEconomicModelData = (): EconomicModelData => { const generateEconomicModelData = (): EconomicModelData => {
const currentAnnualCost = randomInt(800000, 2500000); const currentAnnualCost = randomInt(800000, 2500000);
@@ -691,123 +673,6 @@ const generateEconomicModelData = (): EconomicModelData => {
}; };
}; };
// v2.x: Generar Opportunity Matrix a partir de datos REALES (heatmap + modelo económico)
const generateOpportunitiesFromHeatmap = (
heatmapData: HeatmapDataPoint[],
economicModel?: EconomicModelData
): Opportunity[] => {
if (!heatmapData || heatmapData.length === 0) return [];
// Ahorro anual total calculado por el backend (si existe)
const globalSavings = economicModel?.annualSavings ?? 0;
// 1) Calculamos un "peso" por skill en función de:
// - coste anual
// - ineficiencia (FCR bajo)
// - readiness (facilidad para automatizar)
const scored = heatmapData.map((h) => {
const annualCost = h.annual_cost ?? 0;
const readiness = h.automation_readiness ?? 0;
const fcrScore = h.metrics?.fcr ?? 0;
// FCR bajo => más ineficiencia
const ineffPenalty = Math.max(0, 100 - fcrScore); // 0100
// Peso base: coste alto + ineficiencia alta + readiness alto
const baseWeight =
annualCost *
(1 + ineffPenalty / 100) *
(0.3 + 0.7 * (readiness / 100));
const weight = !Number.isFinite(baseWeight) || baseWeight < 0 ? 0 : baseWeight;
return { heat: h, weight };
});
const totalWeight =
scored.reduce((sum, s) => sum + s.weight, 0) || 1;
// 2) Asignamos "savings" (ahorro potencial) por skill
const opportunitiesWithSavings = scored.map((s) => {
const { heat } = s;
const annualCost = heat.annual_cost ?? 0;
// Si el backend nos da un ahorro anual total, lo distribuimos proporcionalmente
const savings =
globalSavings > 0 && totalWeight > 0
? (globalSavings * s.weight) / totalWeight
: // Si no hay dato de ahorro global, suponemos un 20% del coste anual
annualCost * 0.2;
return {
heat,
savings: Math.max(0, savings),
};
});
const maxSavings =
opportunitiesWithSavings.reduce(
(max, s) => (s.savings > max ? s.savings : max),
0
) || 1;
// 3) Construimos cada oportunidad
return opportunitiesWithSavings.map((item, index) => {
const { heat, savings } = item;
const skillName = heat.skill || `Skill ${index + 1}`;
// Impacto: relativo al mayor ahorro
const impactRaw = (savings / maxSavings) * 10;
const impact = Math.max(
3,
Math.min(10, Math.round(impactRaw))
);
// Factibilidad base: a partir del automation_readiness (0100)
const readiness = heat.automation_readiness ?? 0;
const feasibilityRaw = (readiness / 100) * 7 + 3; // 310
const feasibility = Math.max(
3,
Math.min(10, Math.round(feasibilityRaw))
);
// Dimensión a la que lo vinculamos
const dimensionId =
readiness >= 70
? 'agentic_readiness'
: readiness >= 40
? 'effectiveness_resolution'
: 'complexity_predictability';
// Segmento de cliente (high/medium/low) si lo tenemos
const customer_segment = heat.segment;
// Nombre legible que incluye el skill -> esto ayuda a
// OpportunityMatrixPro a encontrar el skill en el heatmap
const namePrefix =
readiness >= 70
? 'Automatizar '
: readiness >= 40
? 'Asistir con IA en '
: 'Optimizar procesos en ';
const idSlug = skillName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return {
id: `opp_${index + 1}_${idSlug}`,
name: `${namePrefix}${skillName}`,
impact,
feasibility,
savings: Math.round(savings),
dimensionId,
customer_segment,
};
});
};
// v2.0: Añadir percentiles múltiples // v2.0: Añadir percentiles múltiples
const generateBenchmarkData = (): BenchmarkDataPoint[] => { const generateBenchmarkData = (): BenchmarkDataPoint[] => {
const userAHT = randomInt(380, 450); const userAHT = randomInt(380, 450);
@@ -929,27 +794,95 @@ export const generateAnalysis = async (
// Añadir dateRange extraído del archivo // Añadir dateRange extraído del archivo
mapped.dateRange = dateRange; mapped.dateRange = dateRange;
// Heatmap: primero lo construimos a partir de datos reales del backend // Heatmap: usar cálculos del frontend (parsedInteractions) para consistencia
mapped.heatmapData = buildHeatmapFromBackend( // Esto asegura que dashboard muestre los mismos valores que los logs de realDataAnalysis
raw, if (parsedInteractions && parsedInteractions.length > 0) {
costPerHour, const skillMetrics = calculateSkillMetrics(parsedInteractions, costPerHour);
avgCsat, mapped.heatmapData = generateHeatmapFromMetrics(skillMetrics, avgCsat, segmentMapping);
segmentMapping console.log('📊 Heatmap generado desde frontend (parsedInteractions) - métricas consistentes');
); } else {
// Fallback: usar backend si no hay parsedInteractions
mapped.heatmapData = buildHeatmapFromBackend(
raw,
costPerHour,
avgCsat,
segmentMapping
);
console.log('📊 Heatmap generado desde backend (fallback - sin parsedInteractions)');
}
// v4.5: SINCRONIZAR CPI de dimensión economía con heatmapData para consistencia entre tabs
// El heatmapData contiene el CPI calculado correctamente (con cost_volume ponderado)
// La dimensión economía fue calculada en mapBackendResultsToAnalysisData con otra fórmula
// Actualizamos la dimensión para que muestre el mismo valor que Executive Summary
if (mapped.heatmapData && mapped.heatmapData.length > 0) {
const heatmapData = mapped.heatmapData;
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
let globalCPI: number;
if (hasCpiField) {
// CPI real disponible: promedio ponderado por cost_volume
globalCPI = totalCostVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
: 0;
} else {
// Fallback: annual_cost / cost_volume
const totalAnnualCost = heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0);
globalCPI = totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : 0;
}
// Actualizar la dimensión de economía con el CPI calculado desde heatmap
// Buscar tanto economy_costs (backend) como economy_cpi (frontend fallback)
const economyDimIdx = mapped.dimensions.findIndex(d =>
d.id === 'economy_costs' || d.name === 'economy_costs' ||
d.id === 'economy_cpi' || d.name === 'economy_cpi'
);
if (economyDimIdx >= 0 && globalCPI > 0) {
// Usar benchmark de aerolíneas (€3.50) para consistencia con ExecutiveSummaryTab
// Percentiles: p25=2.20, p50=3.50, p75=4.50, p90=5.50
const CPI_BENCHMARK = 3.50;
const cpiDiff = globalCPI - CPI_BENCHMARK;
// Para CPI invertido: menor es mejor
const cpiStatus = cpiDiff <= 0 ? 'positive' : cpiDiff <= 0.5 ? 'neutral' : 'negative';
// Calcular score basado en percentiles aerolíneas
let newScore: number;
if (globalCPI <= 2.20) newScore = 100;
else if (globalCPI <= 3.50) newScore = 80;
else if (globalCPI <= 4.50) newScore = 60;
else if (globalCPI <= 5.50) newScore = 40;
else newScore = 20;
mapped.dimensions[economyDimIdx].score = newScore;
mapped.dimensions[economyDimIdx].kpi = {
label: 'Coste por Interacción',
value: `${globalCPI.toFixed(2)}`,
change: `vs benchmark €${CPI_BENCHMARK.toFixed(2)}`,
changeType: cpiStatus as 'positive' | 'neutral' | 'negative'
};
console.log(`💰 CPI sincronizado: €${globalCPI.toFixed(2)}, score: ${newScore}`);
}
}
// v3.5: Calcular drilldownData PRIMERO (necesario para opportunities y roadmap) // 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 +890,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
@@ -1143,6 +1074,78 @@ export const generateAnalysisFromCache = async (
); );
console.log('📊 Heatmap data points:', mapped.heatmapData?.length || 0); console.log('📊 Heatmap data points:', mapped.heatmapData?.length || 0);
// v4.6: SINCRONIZAR CPI de dimensión economía con heatmapData para consistencia entre tabs
// (Mismo fix que en generateAnalysis - necesario para path de cache)
if (mapped.heatmapData && mapped.heatmapData.length > 0) {
const heatmapData = mapped.heatmapData;
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
// DEBUG: Log CPI calculation details
console.log('🔍 CPI SYNC DEBUG (cache):');
console.log(' - heatmapData length:', heatmapData.length);
console.log(' - hasCpiField:', hasCpiField);
console.log(' - totalCostVolume:', totalCostVolume);
if (hasCpiField) {
console.log(' - Sample CPIs:', heatmapData.slice(0, 3).map(h => ({ skill: h.skill, cpi: h.cpi, cost_volume: h.cost_volume })));
}
let globalCPI: number;
if (hasCpiField) {
globalCPI = totalCostVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
: 0;
} else {
const totalAnnualCost = heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0);
console.log(' - totalAnnualCost (fallback):', totalAnnualCost);
globalCPI = totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : 0;
}
console.log(' - globalCPI calculated:', globalCPI.toFixed(4));
// Buscar tanto economy_costs (backend) como economy_cpi (frontend fallback)
const dimensionIds = mapped.dimensions.map(d => ({ id: d.id, name: d.name }));
console.log(' - Available dimensions:', dimensionIds);
const economyDimIdx = mapped.dimensions.findIndex(d =>
d.id === 'economy_costs' || d.name === 'economy_costs' ||
d.id === 'economy_cpi' || d.name === 'economy_cpi'
);
console.log(' - economyDimIdx:', economyDimIdx);
if (economyDimIdx >= 0 && globalCPI > 0) {
const oldKpi = mapped.dimensions[economyDimIdx].kpi;
console.log(' - OLD KPI value:', oldKpi?.value);
// Usar benchmark de aerolíneas (€3.50) para consistencia con ExecutiveSummaryTab
// Percentiles: p25=2.20, p50=3.50, p75=4.50, p90=5.50
const CPI_BENCHMARK = 3.50;
const cpiDiff = globalCPI - CPI_BENCHMARK;
// Para CPI invertido: menor es mejor
const cpiStatus = cpiDiff <= 0 ? 'positive' : cpiDiff <= 0.5 ? 'neutral' : 'negative';
// Calcular score basado en percentiles aerolíneas
let newScore: number;
if (globalCPI <= 2.20) newScore = 100;
else if (globalCPI <= 3.50) newScore = 80;
else if (globalCPI <= 4.50) newScore = 60;
else if (globalCPI <= 5.50) newScore = 40;
else newScore = 20;
mapped.dimensions[economyDimIdx].score = newScore;
mapped.dimensions[economyDimIdx].kpi = {
label: 'Coste por Interacción',
value: `${globalCPI.toFixed(2)}`,
change: `vs benchmark €${CPI_BENCHMARK.toFixed(2)}`,
changeType: cpiStatus as 'positive' | 'neutral' | 'negative'
};
console.log(' - NEW KPI value:', mapped.dimensions[economyDimIdx].kpi.value);
console.log(' - NEW score:', newScore);
console.log(`💰 CPI sincronizado (cache): €${globalCPI.toFixed(2)}`);
} else {
console.warn('⚠️ CPI sync skipped: economyDimIdx=', economyDimIdx, 'globalCPI=', globalCPI);
}
}
// === DrilldownData: usar cacheado (rápido) o fallback a heatmap === // === DrilldownData: usar cacheado (rápido) o fallback a heatmap ===
if (cachedDrilldownData && cachedDrilldownData.length > 0) { if (cachedDrilldownData && cachedDrilldownData.length > 0) {
// Usar drilldownData cacheado directamente (ya calculado al subir archivo) // Usar drilldownData cacheado directamente (ya calculado al subir archivo)
@@ -1162,16 +1165,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 +1250,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 +1274,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 +1285,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 +1391,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',
}; };

View File

@@ -7,6 +7,8 @@ import type {
DimensionAnalysis, DimensionAnalysis,
Kpi, Kpi,
EconomicModelData, EconomicModelData,
Finding,
Recommendation,
} from '../types'; } from '../types';
import type { BackendRawResults } from './apiClient'; import type { BackendRawResults } from './apiClient';
import { BarChartHorizontal, Zap, Target, Brain, Bot, Smile, DollarSign } from 'lucide-react'; import { BarChartHorizontal, Zap, Target, Brain, Bot, Smile, DollarSign } from 'lucide-react';
@@ -290,6 +292,7 @@ function buildVolumetryDimension(
const maxHourly = validHourly.length > 0 ? Math.max(...validHourly) : 0; const maxHourly = validHourly.length > 0 ? Math.max(...validHourly) : 0;
const minHourly = validHourly.length > 0 ? Math.min(...validHourly) : 1; const minHourly = validHourly.length > 0 ? Math.min(...validHourly) : 1;
const peakValleyRatio = minHourly > 0 ? maxHourly / minHourly : 1; const peakValleyRatio = minHourly > 0 ? maxHourly / minHourly : 1;
console.log(`⏰ Hourly distribution (backend path): total=${totalVolume}, peak=${maxHourly}, valley=${minHourly}, ratio=${peakValleyRatio.toFixed(2)}`);
// Score basado en: // Score basado en:
// - % fuera de horario (>30% penaliza) // - % fuera de horario (>30% penaliza)
@@ -406,11 +409,12 @@ function buildOperationalEfficiencyDimension(
summary += `AHT Horario Laboral (8-19h): ${ahtBusinessHours}s (P50), ratio ${ratioBusinessHours.toFixed(2)}. `; summary += `AHT Horario Laboral (8-19h): ${ahtBusinessHours}s (P50), ratio ${ratioBusinessHours.toFixed(2)}. `;
summary += variabilityInsight; summary += variabilityInsight;
// KPI principal: AHT P50 (industry standard for operational efficiency)
const kpi: Kpi = { const kpi: Kpi = {
label: 'Ratio P90/P50 Global', label: 'AHT P50',
value: ratioGlobal.toFixed(2), value: `${Math.round(ahtP50)}s`,
change: `Horario laboral: ${ratioBusinessHours.toFixed(2)}`, change: `Ratio: ${ratioGlobal.toFixed(2)}`,
changeType: ratioGlobal > 2.5 ? 'negative' : ratioGlobal > 1.8 ? 'neutral' : 'positive' changeType: ahtP50 > 360 ? 'negative' : ahtP50 > 300 ? 'neutral' : 'positive'
}; };
const dimension: DimensionAnalysis = { const dimension: DimensionAnalysis = {
@@ -427,7 +431,7 @@ function buildOperationalEfficiencyDimension(
return dimension; return dimension;
} }
// ==== Efectividad & Resolución (v3.2 - enfocada en FCR y recontactos) ==== // ==== Efectividad & Resolución (v3.2 - enfocada en FCR Técnico) ====
function buildEffectivenessResolutionDimension( function buildEffectivenessResolutionDimension(
raw: BackendRawResults raw: BackendRawResults
@@ -435,31 +439,29 @@ function buildEffectivenessResolutionDimension(
const op = raw?.operational_performance; const op = raw?.operational_performance;
if (!op) return undefined; if (!op) return undefined;
// FCR: métrica principal de efectividad // FCR Técnico = 100 - transfer_rate (comparable con benchmarks de industria)
const fcrPctRaw = safeNumber(op.fcr_rate, NaN); // Usamos escalation_rate que es la tasa de transferencias
const recurrenceRaw = safeNumber(op.recurrence_rate_7d, NaN); const escalationRate = safeNumber(op.escalation_rate, NaN);
const abandonmentRate = safeNumber(op.abandonment_rate, 0); const abandonmentRate = safeNumber(op.abandonment_rate, 0);
// FCR real o proxy desde recontactos // FCR Técnico: 100 - tasa de transferencia
const fcrRate = Number.isFinite(fcrPctRaw) && fcrPctRaw >= 0 const fcrRate = Number.isFinite(escalationRate) && escalationRate >= 0
? Math.max(0, Math.min(100, fcrPctRaw)) ? Math.max(0, Math.min(100, 100 - escalationRate))
: Number.isFinite(recurrenceRaw) : 70; // valor por defecto benchmark aéreo
? Math.max(0, Math.min(100, 100 - recurrenceRaw))
: 70; // valor por defecto benchmark aéreo
// Recontactos a 7 días (complemento del FCR) // Tasa de transferencia (complemento del FCR Técnico)
const recontactRate = 100 - fcrRate; const transferRate = Number.isFinite(escalationRate) ? escalationRate : 100 - fcrRate;
// Score basado principalmente en FCR (benchmark sector aéreo: 68-72%) // Score basado en FCR Técnico (benchmark sector aéreo: 85-90%)
// FCR >= 75% = 100pts, 70-75% = 80pts, 65-70% = 60pts, 60-65% = 40pts, <60% = 20pts // FCR >= 90% = 100pts, 85-90% = 80pts, 80-85% = 60pts, 75-80% = 40pts, <75% = 20pts
let score: number; let score: number;
if (fcrRate >= 75) { if (fcrRate >= 90) {
score = 100; score = 100;
} else if (fcrRate >= 70) { } else if (fcrRate >= 85) {
score = 80; score = 80;
} else if (fcrRate >= 65) { } else if (fcrRate >= 80) {
score = 60; score = 60;
} else if (fcrRate >= 60) { } else if (fcrRate >= 75) {
score = 40; score = 40;
} else { } else {
score = 20; score = 20;
@@ -470,23 +472,23 @@ function buildEffectivenessResolutionDimension(
score = Math.max(0, score - Math.round((abandonmentRate - 8) * 2)); score = Math.max(0, score - Math.round((abandonmentRate - 8) * 2));
} }
// Summary enfocado en resolución, no en transferencias // Summary enfocado en FCR Técnico
let summary = `FCR: ${fcrRate.toFixed(1)}% (benchmark sector aéreo: 68-72%). `; let summary = `FCR Técnico: ${fcrRate.toFixed(1)}% (benchmark: 85-90%). `;
summary += `Recontactos a 7 días: ${recontactRate.toFixed(1)}%. `; summary += `Tasa de transferencia: ${transferRate.toFixed(1)}%. `;
if (fcrRate >= 72) { if (fcrRate >= 90) {
summary += 'Resolución por encima del benchmark del sector.'; summary += 'Excelente resolución en primer contacto.';
} else if (fcrRate >= 68) { } else if (fcrRate >= 85) {
summary += 'Resolución dentro del benchmark del sector aéreo.'; summary += 'Resolución dentro del benchmark del sector.';
} else { } else {
summary += 'Resolución por debajo del benchmark. Oportunidad de mejora en first contact resolution.'; summary += 'Oportunidad de mejora reduciendo transferencias.';
} }
const kpi: Kpi = { const kpi: Kpi = {
label: 'FCR', label: 'FCR Técnico',
value: `${fcrRate.toFixed(0)}%`, value: `${fcrRate.toFixed(0)}%`,
change: `Recontactos: ${recontactRate.toFixed(0)}%`, change: `Transfer: ${transferRate.toFixed(0)}%`,
changeType: fcrRate >= 70 ? 'positive' : fcrRate >= 65 ? 'neutral' : 'negative' changeType: fcrRate >= 85 ? 'positive' : fcrRate >= 80 ? 'neutral' : 'negative'
}; };
const dimension: DimensionAnalysis = { const dimension: DimensionAnalysis = {
@@ -503,7 +505,7 @@ function buildEffectivenessResolutionDimension(
return dimension; return dimension;
} }
// ==== Complejidad & Predictibilidad (v3.3 - basada en Hold Time) ==== // ==== Complejidad & Predictibilidad (v3.4 - basada en CV AHT per industry standards) ====
function buildComplexityPredictabilityDimension( function buildComplexityPredictabilityDimension(
raw: BackendRawResults raw: BackendRawResults
@@ -511,12 +513,19 @@ function buildComplexityPredictabilityDimension(
const op = raw?.operational_performance; const op = raw?.operational_performance;
if (!op) return undefined; if (!op) return undefined;
// Métrica principal: % de interacciones con Hold Time > 60s // KPI principal: CV AHT (industry standard for predictability/WFM)
// Proxy de complejidad: si el agente puso en espera al cliente >60s, // CV AHT = (P90 - P50) / P50 como proxy de coeficiente de variación
// probablemente tuvo que consultar/investigar const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
const highHoldRate = safeNumber(op.high_hold_time_rate, NaN); const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
// Si no hay datos de hold time, usar fallback del P50 de hold // Calcular CV AHT como (P90-P50)/P50 (proxy del coeficiente de variación real)
let cvAht = 0;
if (ahtP50 > 0 && ahtP90 > 0) {
cvAht = (ahtP90 - ahtP50) / ahtP50;
}
const cvAhtPercent = Math.round(cvAht * 100);
// Hold Time como métrica secundaria de complejidad
const talkHoldAcw = op.talk_hold_acw_p50_by_skill; const talkHoldAcw = op.talk_hold_acw_p50_by_skill;
let avgHoldP50 = 0; let avgHoldP50 = 0;
if (Array.isArray(talkHoldAcw) && talkHoldAcw.length > 0) { if (Array.isArray(talkHoldAcw) && talkHoldAcw.length > 0) {
@@ -526,60 +535,55 @@ function buildComplexityPredictabilityDimension(
} }
} }
// Si no tenemos high_hold_time_rate del backend, estimamos desde hold_p50 // Score basado en CV AHT (benchmark: <75% = excelente, <100% = aceptable)
// Si hold_p50 promedio > 60s, asumimos ~40% de llamadas con hold alto // CV <= 75% = 100pts (alta predictibilidad)
const effectiveHighHoldRate = Number.isFinite(highHoldRate) && highHoldRate >= 0 // CV 75-100% = 80pts (predictibilidad aceptable)
? highHoldRate // CV 100-125% = 60pts (variabilidad moderada)
: avgHoldP50 > 60 ? 40 : avgHoldP50 > 30 ? 20 : 10; // CV 125-150% = 40pts (alta variabilidad)
// CV > 150% = 20pts (muy alta variabilidad)
// Score: menor % de Hold alto = menor complejidad = mejor score
// <10% = 100pts (muy baja complejidad)
// 10-20% = 80pts (baja complejidad)
// 20-30% = 60pts (complejidad moderada)
// 30-40% = 40pts (alta complejidad)
// >40% = 20pts (muy alta complejidad)
let score: number; let score: number;
if (effectiveHighHoldRate < 10) { if (cvAhtPercent <= 75) {
score = 100; score = 100;
} else if (effectiveHighHoldRate < 20) { } else if (cvAhtPercent <= 100) {
score = 80; score = 80;
} else if (effectiveHighHoldRate < 30) { } else if (cvAhtPercent <= 125) {
score = 60; score = 60;
} else if (effectiveHighHoldRate < 40) { } else if (cvAhtPercent <= 150) {
score = 40; score = 40;
} else { } else {
score = 20; score = 20;
} }
// Summary descriptivo // Summary descriptivo
let summary = `${effectiveHighHoldRate.toFixed(1)}% de interacciones con Hold Time > 60s (proxy de consulta/investigación). `; let summary = `CV AHT: ${cvAhtPercent}% (benchmark: <75%). `;
if (effectiveHighHoldRate < 15) { if (cvAhtPercent <= 75) {
summary += 'Baja complejidad: la mayoría de casos se resuelven sin necesidad de consultar. Excelente para automatización.'; summary += 'Alta predictibilidad: tiempos de atención consistentes. Excelente para planificación WFM.';
} else if (effectiveHighHoldRate < 25) { } else if (cvAhtPercent <= 100) {
summary += 'Complejidad moderada: algunos casos requieren consulta o investigación adicional.'; summary += 'Predictibilidad aceptable: variabilidad moderada en tiempos de atención.';
} else if (effectiveHighHoldRate < 35) { } else if (cvAhtPercent <= 125) {
summary += 'Complejidad notable: frecuentemente se requiere consulta. Considerar base de conocimiento mejorada.'; summary += 'Variabilidad notable: dificulta la planificación de recursos. Considerar estandarización.';
} else { } else {
summary += 'Alta complejidad: muchos casos requieren investigación. Priorizar documentación y herramientas de soporte.'; summary += 'Alta variabilidad: tiempos muy dispersos. Priorizar scripts guiados y estandarización.';
} }
// Añadir info de Hold P50 promedio si está disponible // Añadir info de Hold P50 promedio si está disponible (proxy de complejidad)
if (avgHoldP50 > 0) { if (avgHoldP50 > 0) {
summary += ` Hold Time P50 promedio: ${Math.round(avgHoldP50)}s.`; summary += ` Hold Time P50: ${Math.round(avgHoldP50)}s.`;
} }
// KPI principal: CV AHT (predictability metric per industry standards)
const kpi: Kpi = { const kpi: Kpi = {
label: 'Hold > 60s', label: 'CV AHT',
value: `${effectiveHighHoldRate.toFixed(0)}%`, value: `${cvAhtPercent}%`,
change: avgHoldP50 > 0 ? `Hold P50: ${Math.round(avgHoldP50)}s` : undefined, change: avgHoldP50 > 0 ? `Hold: ${Math.round(avgHoldP50)}s` : undefined,
changeType: effectiveHighHoldRate > 30 ? 'negative' : effectiveHighHoldRate > 15 ? 'neutral' : 'positive' changeType: cvAhtPercent > 125 ? 'negative' : cvAhtPercent > 75 ? 'neutral' : 'positive'
}; };
const dimension: DimensionAnalysis = { const dimension: DimensionAnalysis = {
id: 'complexity_predictability', id: 'complexity_predictability',
name: 'complexity_predictability', name: 'complexity_predictability',
title: 'Complejidad', title: 'Complejidad & Predictibilidad',
score, score,
percentile: undefined, percentile: undefined,
summary, summary,
@@ -630,32 +634,38 @@ 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 aerolíneas (consistente con ExecutiveSummaryTab)
const CPI_BENCHMARK = 5.00; // p25: 2.20, p50: 3.50, p75: 4.50, p90: 5.50
const CPI_BENCHMARK = 3.50; // p50 aerolíneas
if (totalAnnual <= 0 || totalInteractions <= 0) { if (totalAnnual <= 0 || totalInteractions <= 0) {
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));
// Score basado en comparación con benchmark (€5.00) // Calcular CPI usando cost_volume (non-abandoned) como denominador
// CPI <= 4.00 = 100pts (excelente) const cpi = costVolume > 0 ? totalAnnual / costVolume : totalAnnual / totalInteractions;
// CPI 4.00-5.00 = 80pts (en benchmark)
// CPI 5.00-6.00 = 60pts (por encima) // Score basado en percentiles de aerolíneas (CPI invertido: menor = mejor)
// CPI 6.00-7.00 = 40pts (alto) // CPI <= 2.20 (p25) = 100pts (excelente, top 25%)
// CPI > 7.00 = 20pts (crítico) // CPI 2.20-3.50 (p25-p50) = 80pts (bueno, top 50%)
// CPI 3.50-4.50 (p50-p75) = 60pts (promedio)
// CPI 4.50-5.50 (p75-p90) = 40pts (por debajo)
// CPI > 5.50 (>p90) = 20pts (crítico)
let score: number; let score: number;
if (cpi <= 4.00) { if (cpi <= 2.20) {
score = 100; score = 100;
} else if (cpi <= 5.00) { } else if (cpi <= 3.50) {
score = 80; score = 80;
} else if (cpi <= 6.00) { } else if (cpi <= 4.50) {
score = 60; score = 60;
} else if (cpi <= 7.00) { } else if (cpi <= 5.50) {
score = 40; score = 40;
} else { } else {
score = 20; score = 20;
@@ -667,7 +677,7 @@ function buildEconomyDimension(
let summary = `Coste por interacción: €${cpi.toFixed(2)} vs benchmark €${CPI_BENCHMARK.toFixed(2)}. `; let summary = `Coste por interacción: €${cpi.toFixed(2)} vs benchmark €${CPI_BENCHMARK.toFixed(2)}. `;
if (cpi <= CPI_BENCHMARK) { if (cpi <= CPI_BENCHMARK) {
summary += 'Eficiencia de costes óptima, por debajo del benchmark del sector.'; summary += 'Eficiencia de costes óptima, por debajo del benchmark del sector.';
} else if (cpi <= 6.00) { } else if (cpi <= 4.50) {
summary += 'Coste ligeramente por encima del benchmark, oportunidad de optimización.'; summary += 'Coste ligeramente por encima del benchmark, oportunidad de optimización.';
} else { } else {
summary += 'Coste elevado respecto al sector. Priorizar iniciativas de eficiencia.'; summary += 'Coste elevado respecto al sector. Priorizar iniciativas de eficiencia.';
@@ -1033,14 +1043,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 +1124,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 +1152,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 +1229,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 +1268,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 +1299,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 +1323,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 +1395,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 +1428,41 @@ export function buildHeatmapFromBackend(
} }
} }
// Métricas de transferencia y FCR (ahora usando valores REALES cuando disponibles)
const transferMetricFinal = Math.max(0, Math.min(100, Math.round(skillTransferRate)));
// CPI should be extracted from cpi_by_skill_channel using cpi_total field
const skillCpiRaw = cpiBySkillMap.get(skill);
// Only use if it's a valid number
const skillCpi = (Number.isFinite(skillCpiRaw) && skillCpiRaw > 0) ? skillCpiRaw : undefined;
// cost_volume: volumen sin abandonos (para cálculo de CPI consistente)
// Si tenemos abandonment_rate, restamos los abandonos
const costVolume = Math.round(volume * (1 - skillAbandonmentRate / 100));
heatmap.push({ 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: {

File diff suppressed because it is too large Load Diff