Compare commits

...

36 Commits

Author SHA1 Message Date
675573a472 Merge pull request 'corregido la guarrada' (#2) from desarrollo into main
Reviewed-on: #2
2026-01-28 15:48:44 +00:00
148c86563b Update backend/beyond_api/security.py 2026-01-28 15:48:29 +00:00
d3af112bdf revert 4b0f93540f
revert Update backend/beyond_api/security.py
2026-01-28 15:47:51 +00:00
4b0f93540f Update backend/beyond_api/security.py 2026-01-28 15:47:02 +00:00
3caea45730 Merge pull request '-Guarrada urgente pra entregar usaurio distinto.' (#1) from desarrollo into main
Reviewed-on: #1
2026-01-28 15:31:01 +00:00
88a7edba5c revert 2f6ad3b52c
revert Update backend/beyond_api/security.py
2026-01-28 15:30:05 +00:00
5b98143117 revert 3ed079542a
revert Update backend/beyond_api/security.py
2026-01-28 15:29:49 +00:00
b488c1bff6 Update backend/beyond_api/security.py 2026-01-28 15:26:29 +00:00
3ed079542a Update backend/beyond_api/security.py 2026-01-28 15:23:12 +00:00
2f6ad3b52c Update backend/beyond_api/security.py 2026-01-28 15:22:33 +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
Susana
62454c6b6a Commit inicial 2026-01-18 19:15:34 +00:00
Susana
522b4b6caa feat: Mejorar visualización del resumen ejecutivo
- KPIs compactos en una sola tarjeta horizontal (4 métricas)
- Health Score con desglose de factores (FCR, AHT, Transferencias, CSAT)
- Barras de progreso por cada factor con estado y insight
- Insight contextual según el score
- Diseño más profesional y menos espacio vacío

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:03:47 +00:00
Susana
806e32429d feat: Simplificar página de entrada de datos
- Cabecera estilo dashboard (AIR EUROPA + fecha)
- Eliminar selección de tier (usar gold por defecto)
- Campos manuales vacíos por defecto
- Solo opción de subir archivo CSV/Excel
- Eliminar tabla de campos, plantilla y datos sintéticos

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:56:20 +00:00
Susana
8082a14e1b fix: Corregir cálculo de transfer_rate en métricas
- transfer_rate ahora muestra el % real de transferencias
- FCR = 100 - transfer_rate (resolución en primer contacto)
- Antes ambos mostraban el mismo valor (FCR)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:48:43 +00:00
Susana
7e24f4eb31 feat: Rediseño dashboard con 4 pestañas estilo McKinsey
- Nueva estructura de tabs: Resumen, Dimensiones, Agentic Readiness, Roadmap
- Componentes de visualización McKinsey:
  - BulletChart: actual vs benchmark con rangos de color
  - WaterfallChart: impacto económico con costes y ahorros
  - OpportunityTreemap: priorización por volumen y readiness
- 5 dimensiones actualizadas (sin satisfaction ni economy)
- Header sticky con navegación animada
- Integración completa con datos existentes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:41:20 +00:00
igferne
fdfb520710 Actualización de límites NGINX 2026-01-08 17:19:09 +01:00
igferne
5a1fb1e767 Refactor Recurrence 7d 2026-01-08 17:08:47 +01:00
igferne
d8fecb1cb7 Installer url repo fix 2026-01-07 15:57:29 +01:00
igferne
1315417c53 Despliegue con token git 2026-01-07 14:06:17 +01:00
igferne
49b2677783 Readme 2026-01-07 13:45:40 +01:00
43 changed files with 19349 additions and 1608 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`)

151
CLEANUP_PLAN.md Normal file
View File

@@ -0,0 +1,151 @@
# Code Cleanup Plan - Beyond Diagnosis
## Summary
After analyzing all project files, I've identified the following issues to clean up:
---
## 1. UNUSED COMPONENT FILES (25 files)
These components form orphaned chains - they are not imported anywhere in the active codebase. The main app flow is:
- `App.tsx``SinglePageDataRequestIntegrated``DashboardTabs` → Tab components
### DashboardEnhanced Chain (5 files)
Files only used by `DashboardEnhanced.tsx` which itself is never imported:
- `components/DashboardEnhanced.tsx`
- `components/DashboardNavigation.tsx`
- `components/HeatmapEnhanced.tsx`
- `components/OpportunityMatrixEnhanced.tsx`
- `components/EconomicModelEnhanced.tsx`
### DashboardReorganized Chain (12 files)
Files only used by `DashboardReorganized.tsx` which itself is never imported:
- `components/DashboardReorganized.tsx`
- `components/HeatmapPro.tsx`
- `components/OpportunityMatrixPro.tsx`
- `components/RoadmapPro.tsx`
- `components/EconomicModelPro.tsx`
- `components/BenchmarkReportPro.tsx`
- `components/VariabilityHeatmap.tsx`
- `components/AgenticReadinessBreakdown.tsx`
- `components/HourlyDistributionChart.tsx`
### Shared but now orphaned (3 files)
Used only by the orphaned DashboardEnhanced and DashboardReorganized:
- `components/HealthScoreGaugeEnhanced.tsx`
- `components/DimensionCard.tsx`
- `components/BadgePill.tsx`
### Completely orphaned (5 files)
Not imported anywhere at all:
- `components/DataUploader.tsx`
- `components/DataUploaderEnhanced.tsx`
- `components/Roadmap.tsx` (different from RoadmapTab.tsx which IS used)
- `components/BenchmarkReport.tsx`
- `components/ProgressStepper.tsx`
- `components/TierSelectorEnhanced.tsx`
- `components/DimensionDetailView.tsx`
- `components/TopOpportunitiesCard.tsx`
---
## 2. DUPLICATE IMPORTS (1 issue)
### RoadmapTab.tsx (lines 4-5)
`AlertCircle` is imported twice from lucide-react.
**Before:**
```tsx
import {
Clock, DollarSign, TrendingUp, AlertTriangle, CheckCircle,
ArrowRight, Info, Users, Target, Zap, Shield, AlertCircle,
ChevronDown, ChevronUp, BookOpen, Bot, Settings, Rocket
} from 'lucide-react';
```
Note: `AlertCircle` appears on line 5
**Fix:** Remove duplicate import
---
## 3. DUPLICATE FUNCTIONS (1 issue)
### formatDate function
Duplicated in two active files:
- `SinglePageDataRequestIntegrated.tsx` (lines 14-21)
- `DashboardHeader.tsx` (lines 25-32)
**Recommendation:** Create a shared utility function in `utils/formatters.ts` and import from there.
---
## 4. SHADOWED TYPES (1 issue)
### realDataAnalysis.ts
Has a local `SkillMetrics` interface (lines 235-252) that shadows the one imported from `types.ts`.
**Recommendation:** Remove local interface and use the imported one, or rename to avoid confusion.
---
## 5. UNUSED IMPORTS IN FILES (Minor)
Several files have console.log debug statements that could be removed for production:
- `HeatmapPro.tsx` - multiple debug console.logs
- `OpportunityMatrixPro.tsx` - debug console.logs
---
## Action Plan
### Phase 1: Safe Fixes (No functionality change)
1. Fix duplicate import in RoadmapTab.tsx
2. Consolidate formatDate function to shared utility
### Phase 2: Dead Code Removal (Files to delete)
Delete all 25 unused component files listed above.
### Phase 3: Type Cleanup
Fix shadowed SkillMetrics type in realDataAnalysis.ts
---
## Files to Keep (Active codebase)
### App Entry
- `App.tsx`
- `index.tsx`
### Components (Active)
- `SinglePageDataRequestIntegrated.tsx`
- `DashboardTabs.tsx`
- `DashboardHeader.tsx`
- `DataInputRedesigned.tsx`
- `LoginPage.tsx`
- `ErrorBoundary.tsx`
- `MethodologyFooter.tsx`
- `MetodologiaDrawer.tsx`
- `tabs/ExecutiveSummaryTab.tsx`
- `tabs/DimensionAnalysisTab.tsx`
- `tabs/AgenticReadinessTab.tsx`
- `tabs/RoadmapTab.tsx`
- `charts/WaterfallChart.tsx`
### Utils (Active)
- `apiClient.ts`
- `AuthContext.tsx`
- `analysisGenerator.ts`
- `backendMapper.ts`
- `realDataAnalysis.ts`
- `fileParser.ts`
- `syntheticDataGenerator.ts`
- `dataTransformation.ts`
- `segmentClassifier.ts`
- `agenticReadinessV2.ts`
### Config (Active)
- `types.ts`
- `constants.ts`
- `styles/colors.ts`
- `config/skillsConsolidation.ts`

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

192
README.md Normal file
View File

@@ -0,0 +1,192 @@
# Beyond Diagnosis
Beyond Diagnosis es una aplicación de análisis de operaciones de contact center.
Permite subir un CSV con interacciones y genera:
- Análisis de volumetría por canal y skill
- Métricas operativas (AHT, escalaciones, recurrencia, etc.)
- CSAT global y métricas de satisfacción
- Modelo económico (coste anual, ahorro potencial, etc.)
- Matriz de oportunidades y roadmap basados en datos reales
- Cálculo de *agentic readiness* para priorizar iniciativas de automatización
La arquitectura está compuesta por:
- **Frontend** (React + Vite)
- **Backend** (FastAPI + Python)
- **Nginx** como proxy inverso y terminación TLS
- **Docker Compose** para orquestar los tres servicios
En producción, la aplicación se sirve en **HTTPS (443)** con certificados de **Lets Encrypt**.
---
## Requisitos
Para instalación manual o con el script:
- Servidor **Ubuntu** reciente (20.04 o superior recomendado)
- Dominio apuntando al servidor (ej: `app.cliente.com`)
- Puertos **80** y **443** accesibles desde Internet (para Lets Encrypt)
- Usuario con permisos de `sudo`
> El script de instalación se encarga de instalar Docker, docker compose plugin y certbot si no están presentes.
---
## Instalación con script (recomendada)
### 1. Copiar el script al servidor
Conéctate al servidor por SSH y crea el fichero:
```bash
nano install_beyond.sh
```
Pega dentro el contenido del script de instalación que has preparado (el que:
- Instala Docker y dependencias
- Pide dominio, email, usuario y contraseña
- Clona/actualiza el repo en `/opt/beyonddiagnosis`
- Solicita el certificado de Lets Encrypt
- Genera la configuración de Nginx con SSL
- Lanza `docker compose build` + `docker compose up -d`
).
Guarda (`Ctrl + O`, Enter) y sal (`Ctrl + X`).
Hazlo ejecutable:
```bash
chmod +x install_beyond.sh
```
### 2. Ejecutar el instalador
Ejecuta el script como root (o con sudo):
```bash
sudo ./install_beyond.sh
```
El script te pedirá:
- **Dominio** de la aplicación (ej. `app.cliente.com`)
- **Email** para Lets Encrypt (avisos de renovación)
- **Usuario** de acceso (Basic Auth / login)
- **Contraseña** de acceso
- **URL del repositorio Git** (por defecto usará la que se haya dejado en el script)
Te mostrará un resumen y te preguntará si quieres continuar.
A partir de ahí, el proceso es **desatendido**, pero irá indicando cada paso:
- Instalación de Docker + docker compose plugin + certbot
- Descarga o actualización del repositorio en `/opt/beyonddiagnosis`
- Sustitución de credenciales en `docker-compose.yml`
- Obtención del certificado de Lets Encrypt para el dominio indicado
- Generación de `nginx/conf.d/beyond.conf` con configuración HTTPS
- Construcción de imágenes y arranque de contenedores con `docker compose up -d`
### 3. Acceso a la aplicación
Una vez finalizado:
- La aplicación estará disponible en:
**https://TU_DOMINIO**
- Inicia sesión con el **usuario** y **contraseña** que has introducido durante la instalación.
---
## Estructura de la instalación
Por defecto, el script instala todo en:
```text
/opt/beyonddiagnosis
├── backend/ # Código del backend (FastAPI)
├── frontend/ # Código del frontend (React + Vite)
├── nginx/
│ └── conf.d/
│ └── beyond.conf # Configuración nginx para este dominio
└── docker-compose.yml # Orquestación de backend, frontend y nginx
```
Servicios en Docker:
- `backend` → FastAPI en el puerto 8000 interno
- `frontend` → React en el puerto 4173 interno
- `nginx` → expone 80/443 y hace de proxy:
- `/` → frontend
- `/api/` → backend
Los certificados de Lets Encrypt se almacenan en:
```text
/etc/letsencrypt/live/TU_DOMINIO/
```
y se montan en el contenedor de Nginx como volumen de solo lectura.
---
## Actualización de la aplicación
Para desplegar una nueva versión del código:
```bash
cd /opt/beyonddiagnosis
sudo git pull
sudo docker compose build
sudo docker compose up -d
```
Esto:
- Actualiza el código desde el repositorio
- Reconstruye las imágenes
- Levanta los contenedores con la nueva versión sin perder datos de configuración ni certificados.
---
## Gestión de la aplicación
Desde `/opt/beyonddiagnosis`:
- Ver estado de los contenedores:
```bash
docker compose ps
```
- Ver logs en tiempo real:
```bash
docker compose logs -f
```
- Parar la aplicación:
```bash
docker compose down
```
---
## Uso básico
1. Accede a `https://TU_DOMINIO`.
2. Inicia sesión con las credenciales configuradas en la instalación.
3. Sube un fichero CSV con las columnas esperadas (canal, skill, tiempos, etc.).
4. La aplicación enviará el fichero al backend, que:
- Calcula métricas de volumetría, rendimiento, satisfacción y costes.
- Devuelve un JSON estructurado con el análisis.
5. El frontend muestra:
- Dashboard de métricas clave
- Dimensiones (volumetría, performance, satisfacción, economía, eficiencia…)
- Heatmap por skill
- Oportunidades y roadmap basado en datos reales.
Este README junto con el script de instalación permiten desplegar la aplicación de forma rápida y homogénea en un servidor por cliente.

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
import json import json
import math import math
@@ -12,6 +13,10 @@ from fastapi.responses import JSONResponse
from beyond_api.security import get_current_user from beyond_api.security import get_current_user
from beyond_api.services.analysis_service import run_analysis_collect_json from beyond_api.services.analysis_service import run_analysis_collect_json
# Cache paths - same as in cache.py
CACHE_DIR = Path(os.getenv("CACHE_DIR", "/data/cache"))
CACHED_FILE = CACHE_DIR / "cached_data.csv"
router = APIRouter( router = APIRouter(
prefix="", prefix="",
tags=["analysis"], tags=["analysis"],
@@ -117,3 +122,100 @@ async def analysis_endpoint(
"results": safe_results, "results": safe_results,
} }
) )
def extract_date_range_from_csv(file_path: Path) -> dict:
"""Extrae el rango de fechas del CSV."""
import pandas as pd
try:
# Leer solo la columna de fecha para eficiencia
df = pd.read_csv(file_path, usecols=['datetime_start'], parse_dates=['datetime_start'])
if 'datetime_start' in df.columns and len(df) > 0:
min_date = df['datetime_start'].min()
max_date = df['datetime_start'].max()
return {
"min": min_date.strftime('%Y-%m-%d') if pd.notna(min_date) else None,
"max": max_date.strftime('%Y-%m-%d') if pd.notna(max_date) else None,
}
except Exception as e:
print(f"Error extracting date range: {e}")
return {"min": None, "max": None}
def count_unique_queues_from_csv(file_path: Path) -> int:
"""Cuenta las colas únicas en el CSV."""
import pandas as pd
try:
df = pd.read_csv(file_path, usecols=['queue_skill'])
if 'queue_skill' in df.columns:
return df['queue_skill'].nunique()
except Exception as e:
print(f"Error counting queues: {e}")
return 0
@router.post("/analysis/cached")
async def analysis_cached_endpoint(
economy_json: Optional[str] = Form(default=None),
analysis: Literal["basic", "premium"] = Form(default="premium"),
current_user: str = Depends(get_current_user),
):
"""
Ejecuta el pipeline sobre el archivo CSV cacheado en el servidor.
Útil para re-analizar sin tener que subir el archivo de nuevo.
"""
# Validar que existe el archivo cacheado
if not CACHED_FILE.exists():
raise HTTPException(
status_code=404,
detail="No hay archivo cacheado en el servidor. Sube un archivo primero.",
)
# Validar `analysis`
if analysis not in {"basic", "premium"}:
raise HTTPException(
status_code=400,
detail="analysis debe ser 'basic' o 'premium'.",
)
# Parseo de economía (si viene)
economy_data = None
if economy_json:
try:
economy_data = json.loads(economy_json)
except json.JSONDecodeError:
raise HTTPException(
status_code=400,
detail="economy_json no es un JSON válido.",
)
# Extraer metadatos del CSV
date_range = extract_date_range_from_csv(CACHED_FILE)
unique_queues = count_unique_queues_from_csv(CACHED_FILE)
try:
# Ejecutar el análisis sobre el archivo cacheado
results_json = run_analysis_collect_json(
input_path=CACHED_FILE,
economy_data=economy_data,
analysis=analysis,
company_folder=None,
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Error ejecutando análisis: {str(e)}",
)
# Limpiar NaN/inf para que el JSON sea válido
safe_results = sanitize_for_json(results_json)
return JSONResponse(
content={
"user": current_user,
"results": safe_results,
"source": "cached",
"dateRange": date_range,
"uniqueQueues": unique_queues,
}
)

View File

@@ -0,0 +1,288 @@
# beyond_api/api/cache.py
"""
Server-side cache for CSV files.
Stores the uploaded CSV file and metadata for later re-analysis.
"""
from __future__ import annotations
import json
import os
import shutil
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from beyond_api.security import get_current_user
router = APIRouter(
prefix="/cache",
tags=["cache"],
)
# Directory for cache files - use platform-appropriate default
def _get_default_cache_dir() -> Path:
"""Get a platform-appropriate default cache directory."""
env_cache_dir = os.getenv("CACHE_DIR")
if env_cache_dir:
return Path(env_cache_dir)
# On Windows, check if C:/data/cache exists (legacy location)
# Otherwise use a local .cache directory relative to the backend
# On Unix/Docker, use /data/cache
if sys.platform == "win32":
# Check legacy location first (for backwards compatibility)
legacy_cache = Path("C:/data/cache")
if legacy_cache.exists():
return legacy_cache
# Fallback to local .cache directory in the backend folder
backend_dir = Path(__file__).parent.parent.parent
return backend_dir / ".cache"
else:
return Path("/data/cache")
CACHE_DIR = _get_default_cache_dir()
CACHED_FILE = CACHE_DIR / "cached_data.csv"
METADATA_FILE = CACHE_DIR / "metadata.json"
DRILLDOWN_FILE = CACHE_DIR / "drilldown_data.json"
# Log cache directory on module load
import logging
logger = logging.getLogger(__name__)
logger.info(f"[Cache] Using cache directory: {CACHE_DIR}")
logger.info(f"[Cache] Drilldown file path: {DRILLDOWN_FILE}")
class CacheMetadata(BaseModel):
fileName: str
fileSize: int
recordCount: int
cachedAt: str
costPerHour: float
def ensure_cache_dir():
"""Create cache directory if it doesn't exist."""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
def count_csv_records(file_path: Path) -> int:
"""Count records in CSV file (excluding header)."""
try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
# Count lines minus header
return sum(1 for _ in f) - 1
except Exception:
return 0
@router.get("/check")
def check_cache(current_user: str = Depends(get_current_user)):
"""
Check if there's cached data available.
Returns metadata if cache exists, null otherwise.
"""
if not METADATA_FILE.exists() or not CACHED_FILE.exists():
return JSONResponse(content={"exists": False, "metadata": None})
try:
with open(METADATA_FILE, "r") as f:
metadata = json.load(f)
return JSONResponse(content={"exists": True, "metadata": metadata})
except Exception as e:
return JSONResponse(content={"exists": False, "metadata": None, "error": str(e)})
@router.get("/file")
def get_cached_file_path(current_user: str = Depends(get_current_user)):
"""
Returns the path to the cached CSV file for internal use.
"""
if not CACHED_FILE.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No cached file found"
)
return JSONResponse(content={"path": str(CACHED_FILE)})
@router.get("/download")
def download_cached_file(current_user: str = Depends(get_current_user)):
"""
Download the cached CSV file for frontend parsing.
Returns the file as a streaming response.
"""
from fastapi.responses import FileResponse
if not CACHED_FILE.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No cached file found"
)
return FileResponse(
path=CACHED_FILE,
media_type="text/csv",
filename="cached_data.csv"
)
@router.post("/file")
async def save_cached_file(
csv_file: UploadFile = File(...),
fileName: str = Form(...),
fileSize: int = Form(...),
costPerHour: float = Form(...),
current_user: str = Depends(get_current_user)
):
"""
Save uploaded CSV file to server cache.
"""
ensure_cache_dir()
try:
# Save the CSV file
with open(CACHED_FILE, "wb") as f:
while True:
chunk = await csv_file.read(1024 * 1024) # 1 MB chunks
if not chunk:
break
f.write(chunk)
# Count records
record_count = count_csv_records(CACHED_FILE)
# Save metadata
metadata = {
"fileName": fileName,
"fileSize": fileSize,
"recordCount": record_count,
"cachedAt": datetime.now().isoformat(),
"costPerHour": costPerHour,
}
with open(METADATA_FILE, "w") as f:
json.dump(metadata, f)
return JSONResponse(content={
"success": True,
"message": f"Cached file with {record_count} records",
"metadata": metadata
})
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error saving cache: {str(e)}"
)
@router.get("/drilldown")
def get_cached_drilldown(current_user: str = Depends(get_current_user)):
"""
Get the cached drilldownData JSON.
Returns the pre-calculated drilldown data for fast cache usage.
"""
logger.info(f"[Cache] GET /drilldown - checking file: {DRILLDOWN_FILE}")
logger.info(f"[Cache] File exists: {DRILLDOWN_FILE.exists()}")
if not DRILLDOWN_FILE.exists():
logger.warning(f"[Cache] Drilldown file not found at: {DRILLDOWN_FILE}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No cached drilldown data found"
)
try:
with open(DRILLDOWN_FILE, "r", encoding="utf-8") as f:
drilldown_data = json.load(f)
logger.info(f"[Cache] Loaded drilldown with {len(drilldown_data)} skills")
return JSONResponse(content={"success": True, "drilldownData": drilldown_data})
except Exception as e:
logger.error(f"[Cache] Error reading drilldown: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error reading drilldown data: {str(e)}"
)
@router.post("/drilldown")
async def save_cached_drilldown(
drilldown_json: str = Form(...),
current_user: str = Depends(get_current_user)
):
"""
Save drilldownData JSON to server cache.
Called by frontend after calculating drilldown from uploaded file.
Receives JSON as form field.
"""
logger.info(f"[Cache] POST /drilldown - saving to: {DRILLDOWN_FILE}")
logger.info(f"[Cache] Cache directory: {CACHE_DIR}")
ensure_cache_dir()
logger.info(f"[Cache] Cache dir exists after ensure: {CACHE_DIR.exists()}")
try:
# Parse and validate JSON
drilldown_data = json.loads(drilldown_json)
logger.info(f"[Cache] Parsed drilldown JSON with {len(drilldown_data)} skills")
# Save to file
with open(DRILLDOWN_FILE, "w", encoding="utf-8") as f:
json.dump(drilldown_data, f)
logger.info(f"[Cache] Drilldown saved successfully, file exists: {DRILLDOWN_FILE.exists()}")
return JSONResponse(content={
"success": True,
"message": f"Cached drilldown data with {len(drilldown_data)} skills"
})
except json.JSONDecodeError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid JSON: {str(e)}"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error saving drilldown data: {str(e)}"
)
@router.delete("/file")
def clear_cache(current_user: str = Depends(get_current_user)):
"""
Clear the server-side cache (CSV, metadata, and drilldown data).
"""
try:
if CACHED_FILE.exists():
CACHED_FILE.unlink()
if METADATA_FILE.exists():
METADATA_FILE.unlink()
if DRILLDOWN_FILE.exists():
DRILLDOWN_FILE.unlink()
return JSONResponse(content={"success": True, "message": "Cache cleared"})
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error clearing cache: {str(e)}"
)
# Keep old endpoints for backwards compatibility but mark as deprecated
@router.get("/interactions")
def get_cached_interactions_deprecated(current_user: str = Depends(get_current_user)):
"""DEPRECATED: Use /cache/file instead."""
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail="This endpoint is deprecated. Use /cache/file with re-analysis instead."
)
@router.post("/interactions")
def save_cached_interactions_deprecated(current_user: str = Depends(get_current_user)):
"""DEPRECATED: Use /cache/file instead."""
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail="This endpoint is deprecated. Use /cache/file instead."
)

View File

@@ -4,7 +4,8 @@ from fastapi.middleware.cors import CORSMiddleware
# importa tus routers # importa tus routers
from beyond_api.api.analysis import router as analysis_router from beyond_api.api.analysis import router as analysis_router
from beyond_api.api.auth import router as auth_router # 👈 nuevo from beyond_api.api.auth import router as auth_router
from beyond_api.api.cache import router as cache_router
def setup_basic_logging() -> None: def setup_basic_logging() -> None:
logging.basicConfig( logging.basicConfig(
@@ -18,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(
@@ -30,4 +33,5 @@ app.add_middleware(
) )
app.include_router(analysis_router) app.include_router(analysis_router)
app.include_router(auth_router) # 👈 registrar el router de auth app.include_router(auth_router)
app.include_router(cache_router)

View File

@@ -5,26 +5,40 @@ import secrets
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasic, HTTPBasicCredentials
security = HTTPBasic() # auto_error=False para que no dispare el popup nativo del navegador automáticamente
security = HTTPBasic(auto_error=False)
# En producción: export BASIC_AUTH_USERNAME y BASIC_AUTH_PASSWORD. # En producción: export BASIC_AUTH_USERNAME y BASIC_AUTH_PASSWORD.
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 = Depends(security)) -> str: def get_current_user(credentials: HTTPBasicCredentials | None = Depends(security)) -> str:
""" """
Valida el usuario/contraseña vía HTTP Basic. Valida el usuario/contraseña vía HTTP Basic.
NO envía WWW-Authenticate para evitar el popup nativo del navegador
(el frontend tiene su propio formulario de login).
""" """
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales requeridas",
)
correct_username = secrets.compare_digest(credentials.username, BASIC_USER) correct_username = secrets.compare_digest(credentials.username, BASIC_USER)
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):
# Importante devolver el header WWW-Authenticate para que el navegador saque el prompt # Guarrada maxima, yo no he sido
raise HTTPException( correct_username = secrets.compare_digest(credentials.username, INT_USER)
status_code=status.HTTP_401_UNAUTHORIZED, correct_password = secrets.compare_digest(credentials.password, INT_PASS)
detail="Credenciales incorrectas", if not (correct_username and correct_password):
headers={"WWW-Authenticate": "Basic"}, raise HTTPException(
) status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales incorrectas",
)
return credentials.username return credentials.username

View File

@@ -506,11 +506,10 @@ def score_roi(annual_savings: Any) -> Dict[str, Any]:
def classify_agentic_score(score: Optional[float]) -> Dict[str, Any]: def classify_agentic_score(score: Optional[float]) -> Dict[str, Any]:
""" """
Clasificación final: Clasificación final (alineada con frontend):
- 810: AUTOMATE 🤖 - ≥6: COPILOT 🤖 (Listo para Copilot)
- 57.99: ASSIST 🤝 - 45.99: OPTIMIZE 🔧 (Optimizar Primero)
- 34.99: AUGMENT 🧠 - <4: HUMAN 👤 (Requiere Gestión Humana)
- 02.99: HUMAN_ONLY 👤
Si score es None (ninguna dimensión disponible), devuelve NO_DATA. Si score es None (ninguna dimensión disponible), devuelve NO_DATA.
""" """
@@ -524,33 +523,26 @@ def classify_agentic_score(score: Optional[float]) -> Dict[str, Any]:
), ),
} }
if score >= 8.0: if score >= 6.0:
label = "AUTOMATE" label = "COPILOT"
emoji = "🤖" emoji = "🤖"
description = ( description = (
"Alta repetitividad, alta predictibilidad y ROI elevado. " "Listo para Copilot. Procesos con predictibilidad y simplicidad "
"Candidato a automatización completa (chatbot/IVR inteligente)." "suficientes para asistencia IA (sugerencias en tiempo real, autocompletado)."
) )
elif score >= 5.0: elif score >= 4.0:
label = "ASSIST" label = "OPTIMIZE"
emoji = "🤝" emoji = "🔧"
description = ( description = (
"Complejidad media o ROI limitado. Recomendado enfoque de copilot " "Optimizar primero. Estandarizar procesos y reducir variabilidad "
"para agentes (sugerencias en tiempo real, autocompletado, etc.)." "antes de implementar asistencia IA."
)
elif score >= 3.0:
label = "AUGMENT"
emoji = "🧠"
description = (
"Alta complejidad o bajo volumen. Mejor usar herramientas de apoyo "
"(knowledge base, guías dinámicas, scripts)."
) )
else: else:
label = "HUMAN_ONLY" label = "HUMAN"
emoji = "👤" emoji = "👤"
description = ( description = (
"Procesos de muy bajo volumen o extremadamente complejos. Mejor " "Requiere gestión humana. Procesos complejos o variables que "
"mantener operación 100% humana de momento." "necesitan intervención humana antes de considerar automatización."
) )
return { return {

View File

@@ -20,9 +20,11 @@
"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",
"high_hold_time_rate",
"recurrence_rate_7d", "recurrence_rate_7d",
"repeat_channel_rate", "repeat_channel_rate",
"occupancy_rate", "occupancy_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
@@ -86,6 +86,28 @@ class OperationalPerformanceMetrics:
+ df["wrap_up_time"].fillna(0) + df["wrap_up_time"].fillna(0)
) )
# v3.0: Filtrar NOISE y ZOMBIE para cálculos de variabilidad
# record_status: 'VALID', 'NOISE', 'ZOMBIE', 'ABANDON'
# Para AHT/CV solo usamos 'VALID' (excluye noise, zombie, abandon)
if "record_status" in df.columns:
df["record_status"] = df["record_status"].astype(str).str.strip().str.upper()
# Crear máscara para registros válidos: SOLO "VALID"
# Excluye explícitamente NOISE, ZOMBIE, ABANDON y cualquier otro valor
df["_is_valid_for_cv"] = df["record_status"] == "VALID"
# Log record_status breakdown for debugging
status_counts = df["record_status"].value_counts()
valid_count = int(df["_is_valid_for_cv"].sum())
print(f"[OperationalPerformance] Record status breakdown:")
print(f" Total rows: {len(df)}")
for status, count in status_counts.items():
print(f" - {status}: {count}")
print(f" VALID rows for AHT calculation: {valid_count}")
else:
# Legacy data sin record_status: incluir todo
df["_is_valid_for_cv"] = True
print(f"[OperationalPerformance] No record_status column - using all {len(df)} rows")
# Normalización básica # Normalización básica
df["queue_skill"] = df["queue_skill"].astype(str).str.strip() df["queue_skill"] = df["queue_skill"].astype(str).str.strip()
df["channel"] = df["channel"].astype(str).str.strip() df["channel"] = df["channel"].astype(str).str.strip()
@@ -121,8 +143,13 @@ class OperationalPerformanceMetrics:
def aht_distribution(self) -> Dict[str, float]: def aht_distribution(self) -> Dict[str, float]:
""" """
Devuelve P10, P50, P90 del AHT y el ratio P90/P50 como medida de variabilidad. Devuelve P10, P50, P90 del AHT y el ratio P90/P50 como medida de variabilidad.
v3.0: Filtra NOISE y ZOMBIE para el cálculo de variabilidad.
Solo usa registros con record_status='valid' o sin status (legacy).
""" """
ht = self.df["handle_time"].dropna().astype(float) # Filtrar solo registros válidos para cálculo de variabilidad
df_valid = self.df[self.df["_is_valid_for_cv"] == True]
ht = df_valid["handle_time"].dropna().astype(float)
if ht.empty: if ht.empty:
return {} return {}
@@ -141,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
@@ -158,63 +188,53 @@ 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
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def fcr_rate(self) -> float: def fcr_rate(self) -> float:
""" """
FCR proxy = 100 - escalation_rate. FCR (First Contact Resolution).
Usamos la métrica de escalación ya calculada a partir de transfer_flag. Prioridad 1: Usar fcr_real_flag del CSV si existe
Si no se puede calcular escalation_rate, intentamos derivarlo Prioridad 2: Calcular como 100 - escalation_rate
directamente de la columna transfer_flag. Si todo falla, devolvemos NaN.
""" """
df = self.df
total = len(df)
if total == 0:
return float("nan")
# Prioridad 1: Usar fcr_real_flag si existe
if "fcr_real_flag" in df.columns:
col = df["fcr_real_flag"]
# Normalizar a booleano
if col.dtype == "O":
fcr_mask = (
col.astype(str)
.str.strip()
.str.lower()
.isin(["true", "t", "1", "yes", "y", "si", ""])
)
else:
fcr_mask = pd.to_numeric(col, errors="coerce").fillna(0) > 0
fcr_count = int(fcr_mask.sum())
fcr = (fcr_count / total) * 100.0
return float(max(0.0, min(100.0, round(fcr, 2))))
# Prioridad 2: Fallback a 100 - escalation_rate
try: try:
esc = self.escalation_rate() esc = self.escalation_rate()
except Exception: except Exception:
esc = float("nan") esc = float("nan")
# Si escalation_rate es válido, usamos el proxy simple
if esc is not None and not math.isnan(esc): if esc is not None and not math.isnan(esc):
fcr = 100.0 - esc fcr = 100.0 - esc
return float(max(0.0, min(100.0, round(fcr, 2)))) return float(max(0.0, min(100.0, round(fcr, 2))))
# Fallback: calcular directamente desde transfer_flag return float("nan")
df = self.df
if "transfer_flag" not in df.columns or len(df) == 0:
return float("nan")
col = df["transfer_flag"]
# Normalizar a booleano: TRUE/FALSE, 1/0, etc.
if col.dtype == "O":
col_norm = (
col.astype(str)
.str.strip()
.str.lower()
.map({
"true": True,
"t": True,
"1": True,
"yes": True,
"y": True,
})
).fillna(False)
transfer_mask = col_norm
else:
transfer_mask = pd.to_numeric(col, errors="coerce").fillna(0) > 0
total = len(df)
transfers = int(transfer_mask.sum())
esc_rate = transfers / total if total > 0 else float("nan")
if math.isnan(esc_rate):
return float("nan")
fcr = 100.0 - esc_rate * 100.0
return float(max(0.0, min(100.0, round(fcr, 2))))
def escalation_rate(self) -> float: def escalation_rate(self) -> float:
@@ -233,52 +253,106 @@ class OperationalPerformanceMetrics:
""" """
% de interacciones abandonadas. % de interacciones abandonadas.
Definido como % de filas con abandoned_flag == True. Busca en orden: is_abandoned, abandoned_flag, abandoned
Si la columna no existe, devuelve NaN. Si ninguna columna existe, devuelve NaN.
""" """
df = self.df df = self.df
if "abandoned_flag" not in df.columns:
return float("nan")
total = len(df) total = len(df)
if total == 0: if total == 0:
return float("nan") return float("nan")
abandoned = df["abandoned_flag"].sum() # Buscar columna de abandono en orden de prioridad
abandon_col = None
for col_name in ["is_abandoned", "abandoned_flag", "abandoned"]:
if col_name in df.columns:
abandon_col = col_name
break
if abandon_col is None:
return float("nan")
col = df[abandon_col]
# Normalizar a booleano
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())
return float(round(abandoned / total * 100, 2)) return float(round(abandoned / total * 100, 2))
def high_hold_time_rate(self, threshold_seconds: float = 60.0) -> float:
"""
% de interacciones con hold_time > threshold (por defecto 60s).
Proxy de complejidad: si el agente tuvo que poner en espera al cliente
más de 60 segundos, probablemente tuvo que consultar/investigar.
"""
df = self.df
total = len(df)
if total == 0:
return float("nan")
hold_times = df["hold_time"].fillna(0)
high_hold_count = (hold_times > threshold_seconds).sum()
return float(round(high_hold_count / total * 100, 2))
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()
if df["customer_id"].isna().all():
# Normalizar identificador de cliente
if "customer_id" not in df.columns:
if "caller_id" in df.columns:
df["customer_id"] = df["caller_id"]
else:
# No hay identificador de cliente -> no se puede calcular
return float("nan")
df = df.dropna(subset=["customer_id"])
if df.empty:
return float("nan") return float("nan")
customers = df["customer_id"].dropna().unique() # Ordenar por cliente + skill + fecha
if len(customers) == 0: df = df.sort_values(["customer_id", "queue_skill", "datetime_start"])
# Diferencia de tiempo entre contactos consecutivos por cliente Y skill
# Esto asegura que solo contamos recontactos del mismo cliente para el mismo skill
df["delta"] = df.groupby(["customer_id", "queue_skill"])["datetime_start"].diff()
# Marcamos los contactos que ocurren a menos de 7 días del anterior (mismo skill)
recurrence_mask = df["delta"] < pd.Timedelta(days=7)
# Nº de clientes que tienen al menos un contacto recurrente (para cualquier skill)
recurrent_customers = df.loc[recurrence_mask, "customer_id"].nunique()
total_customers = df["customer_id"].nunique()
if total_customers == 0:
return float("nan") return float("nan")
recurrent_customers = 0 rate = recurrent_customers / total_customers * 100.0
return float(round(rate, 2))
for cust in customers:
sub = df[df["customer_id"] == cust].sort_values("datetime_start")
if len(sub) < 2:
continue
deltas = sub["datetime_start"].diff().dropna()
if (deltas < pd.Timedelta(days=7)).any():
recurrent_customers += 1
if len(customers) == 0:
return float("nan")
return float(round(recurrent_customers / len(customers) * 100, 2))
def repeat_channel_rate(self) -> float: def repeat_channel_rate(self) -> float:
""" """
@@ -515,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

42
deploy.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
# Script para reconstruir y desplegar los contenedores de Beyond Diagnosis
# Ejecutar con: sudo ./deploy.sh
set -e
echo "=========================================="
echo " Beyond Diagnosis - Deploy Script"
echo "=========================================="
cd /opt/beyonddiagnosis
echo ""
echo "[1/4] Deteniendo contenedores actuales..."
docker compose down
echo ""
echo "[2/4] Reconstruyendo contenedor del frontend (con cambios)..."
docker compose build --no-cache frontend
echo ""
echo "[3/4] Reconstruyendo contenedor del backend (si hay cambios)..."
docker compose build backend
echo ""
echo "[4/4] Iniciando todos los contenedores..."
docker compose up -d
echo ""
echo "=========================================="
echo " Deploy completado!"
echo "=========================================="
echo ""
echo "Verificando estado de contenedores:"
docker compose ps
echo ""
echo "Logs del frontend (últimas 20 líneas):"
docker compose logs --tail=20 frontend
echo ""
echo "La aplicación está disponible en: https://diag.yourcompany.com"

View File

@@ -7,8 +7,11 @@ services:
container_name: beyond-backend container_name: beyond-backend
environment: environment:
# credenciales del API (las mismas que usas ahora) # credenciales del API (las mismas que usas ahora)
BASIC_AUTH_USERNAME: admin BASIC_AUTH_USERNAME: "beyond"
BASIC_AUTH_PASSWORD: admin BASIC_AUTH_PASSWORD: "beyond2026"
CACHE_DIR: "/data/cache"
volumes:
- cache-data:/data/cache
expose: expose:
- "8000" - "8000"
networks: networks:
@@ -34,11 +37,17 @@ services:
- frontend - frontend
ports: ports:
- "80:80" - "80:80"
- "443:443"
volumes: volumes:
- /etc/letsencrypt:/etc/letsencrypt:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro
networks: networks:
- beyond-net - beyond-net
volumes:
cache-data:
driver: local
networks: networks:
beyond-net: beyond-net:
driver: bridge driver: bridge

View File

@@ -0,0 +1,94 @@
import { motion } from 'framer-motion';
import { LayoutDashboard, Layers, Bot, Map, ShieldCheck, Info, Scale } from 'lucide-react';
export type TabId = 'executive' | 'dimensions' | 'readiness' | 'roadmap' | 'law10';
export interface TabConfig {
id: TabId;
label: string;
icon: React.ElementType;
}
interface DashboardHeaderProps {
title?: string;
activeTab: TabId;
onTabChange: (id: TabId) => void;
onMetodologiaClick?: () => void;
}
const TABS: TabConfig[] = [
{ id: 'executive', label: 'Resumen', icon: LayoutDashboard },
{ id: 'dimensions', label: 'Dimensiones', icon: Layers },
{ id: 'readiness', label: 'Agentic Readiness', icon: Bot },
{ id: 'roadmap', label: 'Roadmap', icon: Map },
{ id: 'law10', label: 'Ley 10/2025', icon: Scale },
];
export function DashboardHeader({
title = 'AIR EUROPA - Beyond CX Analytics',
activeTab,
onTabChange,
onMetodologiaClick
}: DashboardHeaderProps) {
return (
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
{/* Top row: Title and Metodología Badge */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
<div className="flex items-center justify-between gap-2">
<h1 className="text-base sm:text-xl font-bold text-slate-800 truncate">{title}</h1>
{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>
{/* Tab Navigation */}
<nav className="max-w-7xl mx-auto px-2 sm:px-6 overflow-x-auto">
<div className="flex space-x-1">
{TABS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
relative flex items-center gap-2 px-4 py-3 text-sm font-medium
transition-colors duration-200
${isActive
? 'text-[#6D84E3]'
: 'text-slate-500 hover:text-slate-700'
}
`}
>
<Icon className="w-4 h-4" />
<span className="hidden sm:inline">{tab.label}</span>
{/* Active indicator */}
{isActive && (
<motion.div
layoutId="activeTab"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#6D84E3]"
initial={false}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
/>
)}
</button>
);
})}
</div>
</nav>
</header>
);
}
export default DashboardHeader;

View File

@@ -0,0 +1,107 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeft } from 'lucide-react';
import { DashboardHeader, TabId } from './DashboardHeader';
import { formatDateMonthYear } from '../utils/formatters';
import { ExecutiveSummaryTab } from './tabs/ExecutiveSummaryTab';
import { DimensionAnalysisTab } from './tabs/DimensionAnalysisTab';
import { AgenticReadinessTab } from './tabs/AgenticReadinessTab';
import { RoadmapTab } from './tabs/RoadmapTab';
import { Law10Tab } from './tabs/Law10Tab';
import { MetodologiaDrawer } from './MetodologiaDrawer';
import type { AnalysisData } from '../types';
interface DashboardTabsProps {
data: AnalysisData;
title?: string;
onBack?: () => void;
}
export function DashboardTabs({
data,
title = 'AIR EUROPA - Beyond CX Analytics',
onBack
}: DashboardTabsProps) {
const [activeTab, setActiveTab] = useState<TabId>('executive');
const [metodologiaOpen, setMetodologiaOpen] = useState(false);
const renderTabContent = () => {
switch (activeTab) {
case 'executive':
return <ExecutiveSummaryTab data={data} onTabChange={setActiveTab} />;
case 'dimensions':
return <DimensionAnalysisTab data={data} />;
case 'readiness':
return <AgenticReadinessTab data={data} onTabChange={setActiveTab} />;
case 'roadmap':
return <RoadmapTab data={data} />;
case 'law10':
return <Law10Tab data={data} />;
default:
return <ExecutiveSummaryTab data={data} />;
}
};
return (
<div className="min-h-screen bg-slate-50">
{/* Back button */}
{onBack && (
<div className="bg-white border-b border-slate-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-2">
<button
onClick={onBack}
className="flex items-center gap-2 text-sm text-slate-600 hover:text-slate-800 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span className="hidden sm:inline">Volver al formulario</span>
<span className="sm:hidden">Volver</span>
</button>
</div>
</div>
)}
{/* Sticky Header with Tabs */}
<DashboardHeader
title={title}
activeTab={activeTab}
onTabChange={setActiveTab}
onMetodologiaClick={() => setMetodologiaOpen(true)}
/>
{/* Tab Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-4 sm:py-6">
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{renderTabContent()}
</motion.div>
</AnimatePresence>
</main>
{/* Footer */}
<footer className="border-t border-slate-200 bg-white mt-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-sm text-slate-500">
<span className="hidden sm:inline">Beyond Diagnosis - Contact Center Analytics Platform</span>
<span className="sm:hidden text-xs">Beyond Diagnosis</span>
<span className="text-xs sm:text-sm text-slate-400 italic">{formatDateMonthYear()}</span>
</div>
</div>
</footer>
{/* Drawer de Metodología */}
<MetodologiaDrawer
isOpen={metodologiaOpen}
onClose={() => setMetodologiaOpen(false)}
data={data}
/>
</div>
);
}
export default DashboardTabs;

View File

@@ -1,15 +1,21 @@
// components/DataInputRedesigned.tsx // components/DataInputRedesigned.tsx
// Interfaz de entrada de datos rediseñada y organizada // Interfaz de entrada de datos simplificada
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { import {
Download, CheckCircle, AlertCircle, FileText, Database, AlertCircle, FileText, Database,
UploadCloud, File, Sheet, Loader2, Sparkles, Table, UploadCloud, File, Loader2, Info, X,
Info, ExternalLink, X HardDrive, Trash2, RefreshCw, Server
} from 'lucide-react'; } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { checkServerCache, clearServerCache, ServerCacheMetadata } from '../utils/serverCache';
import { useAuth } from '../utils/AuthContext';
interface CacheInfo extends ServerCacheMetadata {
// Using server cache metadata structure
}
interface DataInputRedesignedProps { interface DataInputRedesignedProps {
onAnalyze: (config: { onAnalyze: (config: {
@@ -23,59 +29,101 @@ interface DataInputRedesignedProps {
file?: File; file?: File;
sheetUrl?: string; sheetUrl?: string;
useSynthetic?: boolean; useSynthetic?: boolean;
useCache?: boolean;
}) => void; }) => void;
isAnalyzing: boolean; isAnalyzing: boolean;
} }
const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
onAnalyze, onAnalyze,
isAnalyzing isAnalyzing
}) => { }) => {
// Estados para datos manuales const { authHeader } = useAuth();
const [costPerHour, setCostPerHour] = useState<number>(20);
const [avgCsat, setAvgCsat] = useState<number>(85); // Estados para datos manuales - valores vacíos por defecto
const [costPerHour, setCostPerHour] = useState<string>('');
const [avgCsat, setAvgCsat] = useState<string>('');
// Estados para mapeo de segmentación // Estados para mapeo de segmentación
const [highValueQueues, setHighValueQueues] = useState<string>(''); const [highValueQueues, setHighValueQueues] = useState<string>('');
const [mediumValueQueues, setMediumValueQueues] = useState<string>(''); const [mediumValueQueues, setMediumValueQueues] = useState<string>('');
const [lowValueQueues, setLowValueQueues] = useState<string>(''); const [lowValueQueues, setLowValueQueues] = useState<string>('');
// Estados para carga de datos // Estados para carga de datos
const [uploadMethod, setUploadMethod] = useState<'file' | 'url' | 'synthetic' | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [sheetUrl, setSheetUrl] = useState<string>('');
const [isGenerating, setIsGenerating] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
// Campos CSV requeridos // Estado para caché del servidor
const csvFields = [ const [cacheInfo, setCacheInfo] = useState<CacheInfo | null>(null);
{ name: 'interaction_id', type: 'String único', example: 'call_8842910', required: true }, const [checkingCache, setCheckingCache] = useState(true);
{ name: 'datetime_start', type: 'DateTime', example: '2024-10-01 09:15:22', required: true },
{ name: 'queue_skill', type: 'String', example: 'Soporte_Nivel1, Ventas', required: true }, // Verificar caché del servidor al cargar
{ name: 'channel', type: 'String', example: 'Voice, Chat, WhatsApp', required: true }, useEffect(() => {
{ name: 'duration_talk', type: 'Segundos', example: '345', required: true }, const checkCache = async () => {
{ name: 'hold_time', type: 'Segundos', example: '45', required: true }, console.log('[DataInput] Checking server cache, authHeader:', authHeader ? 'present' : 'null');
{ name: 'wrap_up_time', type: 'Segundos', example: '30', required: true }, if (!authHeader) {
{ name: 'agent_id', type: 'String', example: 'Agente_045', required: true }, console.log('[DataInput] No authHeader, skipping cache check');
{ name: 'transfer_flag', type: 'Boolean', example: 'TRUE / FALSE', required: true }, setCheckingCache(false);
{ name: 'caller_id', type: 'String (hash)', example: 'Hash_99283', required: false }, return;
{ name: 'csat_score', type: 'Float', example: '4', required: false } }
];
try {
const handleDownloadTemplate = () => { setCheckingCache(true);
const headers = csvFields.map(f => f.name).join(','); console.log('[DataInput] Calling checkServerCache...');
const exampleRow = csvFields.map(f => f.example).join(','); const { exists, metadata } = await checkServerCache(authHeader);
const csvContent = `${headers}\n${exampleRow}\n`; console.log('[DataInput] Cache check result:', { exists, metadata });
if (exists && metadata) {
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); setCacheInfo(metadata);
const link = document.createElement('a'); console.log('[DataInput] Cache info set:', metadata);
link.href = URL.createObjectURL(blob); // Auto-rellenar coste si hay en caché
link.download = 'plantilla_beyond_diagnostic.csv'; if (metadata.costPerHour > 0 && !costPerHour) {
link.click(); setCostPerHour(metadata.costPerHour.toString());
}
toast.success('Plantilla CSV descargada', { icon: '📥' }); } else {
console.log('[DataInput] No cache found on server');
}
} catch (error) {
console.error('[DataInput] Error checking server cache:', error);
} finally {
setCheckingCache(false);
}
};
checkCache();
}, [authHeader]);
const handleClearCache = async () => {
if (!authHeader) return;
try {
const success = await clearServerCache(authHeader);
if (success) {
setCacheInfo(null);
toast.success('Caché del servidor limpiada', { icon: '🗑️' });
} else {
toast.error('Error limpiando caché del servidor');
}
} catch (error) {
toast.error('Error limpiando caché');
}
}; };
const handleUseCache = () => {
if (!cacheInfo) return;
const segmentMapping = (highValueQueues || mediumValueQueues || lowValueQueues) ? {
high_value_queues: (highValueQueues || '').split(',').map(q => q.trim()).filter(q => q),
medium_value_queues: (mediumValueQueues || '').split(',').map(q => q.trim()).filter(q => q),
low_value_queues: (lowValueQueues || '').split(',').map(q => q.trim()).filter(q => q)
} : undefined;
onAnalyze({
costPerHour: parseFloat(costPerHour) || cacheInfo.costPerHour,
avgCsat: parseFloat(avgCsat) || 0,
segmentMapping,
useCache: true
});
};
const handleFileChange = (selectedFile: File | null) => { const handleFileChange = (selectedFile: File | null) => {
if (selectedFile) { if (selectedFile) {
const allowedTypes = [ const allowedTypes = [
@@ -83,456 +131,345 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
'application/vnd.ms-excel', 'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
]; ];
if (allowedTypes.includes(selectedFile.type) || if (allowedTypes.includes(selectedFile.type) ||
selectedFile.name.endsWith('.csv') || selectedFile.name.endsWith('.csv') ||
selectedFile.name.endsWith('.xlsx') || selectedFile.name.endsWith('.xlsx') ||
selectedFile.name.endsWith('.xls')) { selectedFile.name.endsWith('.xls')) {
setFile(selectedFile); setFile(selectedFile);
setUploadMethod('file');
toast.success(`Archivo "${selectedFile.name}" cargado`, { icon: '📄' }); toast.success(`Archivo "${selectedFile.name}" cargado`, { icon: '📄' });
} else { } else {
toast.error('Tipo de archivo no válido. Sube un CSV.', { icon: '❌' }); toast.error('Tipo de archivo no válido. Sube un CSV o Excel.', { icon: '❌' });
} }
} }
}; };
const onDragOver = (e: React.DragEvent) => { const onDragOver = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragging(true); setIsDragging(true);
}; };
const onDragLeave = (e: React.DragEvent) => { const onDragLeave = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragging(false); setIsDragging(false);
}; };
const onDrop = (e: React.DragEvent) => { const onDrop = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragging(false); setIsDragging(false);
const droppedFile = e.dataTransfer.files[0]; const droppedFile = e.dataTransfer.files[0];
if (droppedFile) { if (droppedFile) {
handleFileChange(droppedFile); handleFileChange(droppedFile);
} }
}; };
const handleGenerateSynthetic = () => { const canAnalyze = file !== null && costPerHour !== '' && parseFloat(costPerHour) > 0;
setIsGenerating(true);
setTimeout(() => { const handleSubmit = () => {
setUploadMethod('synthetic'); // Preparar segment_mapping
setIsGenerating(false); const segmentMapping = (highValueQueues || mediumValueQueues || lowValueQueues) ? {
toast.success('Datos sintéticos generados para demo', { icon: '✨' }); high_value_queues: (highValueQueues || '').split(',').map(q => q.trim()).filter(q => q),
}, 1500); medium_value_queues: (mediumValueQueues || '').split(',').map(q => q.trim()).filter(q => q),
low_value_queues: (lowValueQueues || '').split(',').map(q => q.trim()).filter(q => q)
} : undefined;
onAnalyze({
costPerHour: parseFloat(costPerHour) || 0,
avgCsat: parseFloat(avgCsat) || 0,
segmentMapping,
file: file || undefined,
useSynthetic: false
});
}; };
const handleSheetUrlSubmit = () => {
if (sheetUrl.trim()) {
setUploadMethod('url');
toast.success('URL de Google Sheets conectada', { icon: '🔗' });
} else {
toast.error('Introduce una URL válida', { icon: '❌' });
}
};
const canAnalyze = uploadMethod !== null && costPerHour > 0;
return ( return (
<div className="space-y-8"> <div className="space-y-6">
{/* Sección 1: Datos Manuales */} {/* Sección 1: Datos Manuales */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
className="bg-white rounded-xl shadow-lg p-8 border-2 border-slate-200" className="bg-white rounded-lg shadow-sm p-4 sm:p-6 border border-slate-200"
> >
<div className="mb-6"> <div className="mb-6">
<h2 className="text-2xl font-bold text-slate-900 mb-2 flex items-center gap-2"> <h2 className="text-lg font-semibold text-slate-800 mb-1 flex items-center gap-2">
<Database size={24} className="text-[#6D84E3]" /> <Database size={20} className="text-[#6D84E3]" />
1. Datos Manuales Configuración Manual
</h2> </h2>
<p className="text-slate-600 text-sm"> <p className="text-slate-500 text-sm">
Introduce los parámetros de configuración para tu análisis Introduce los parámetros de configuración para tu análisis
</p> </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
{/* Coste por Hora */} {/* Coste por Hora */}
<div> <div>
<label className="block text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2"> <label className="block text-sm font-medium text-slate-700 mb-2 flex items-center gap-2">
Coste por Hora Agente (Fully Loaded) Coste por Hora Agente (Fully Loaded)
<span className="inline-flex items-center gap-1 text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full font-semibold"> <span className="inline-flex items-center gap-1 text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full font-medium">
<AlertCircle size={10} /> <AlertCircle size={10} />
Obligatorio Obligatorio
</span> </span>
</label> </label>
<div className="relative"> <div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 font-semibold text-lg"></span> <span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500"></span>
<input <input
type="number" type="number"
value={costPerHour} value={costPerHour}
onChange={(e) => setCostPerHour(parseFloat(e.target.value) || 0)} onChange={(e) => setCostPerHour(e.target.value)}
min="0" min="0"
step="0.5" step="0.5"
className="w-full pl-10 pr-20 py-3 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition text-lg font-semibold" className="w-full pl-8 pr-16 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition"
placeholder="20" placeholder="Ej: 20"
/> />
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-xs text-slate-500 font-medium">/hora</span> <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">/hora</span>
</div> </div>
<p className="text-xs text-slate-500 mt-1 flex items-start gap-1"> <p className="text-xs text-slate-500 mt-1">
<Info size={12} className="mt-0.5 flex-shrink-0" />
<span>Tipo: <strong>Número (decimal)</strong> | Ejemplo: <code className="bg-slate-100 px-1 rounded">20</code></span>
</p>
<p className="text-xs text-slate-600 mt-1">
Incluye salario, cargas sociales, infraestructura, etc. Incluye salario, cargas sociales, infraestructura, etc.
</p> </p>
</div> </div>
{/* CSAT Promedio */} {/* CSAT Promedio */}
<div> <div>
<label className="block text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2"> <label className="block text-sm font-medium text-slate-700 mb-2 flex items-center gap-2">
CSAT Promedio CSAT Promedio
<span className="inline-flex items-center gap-1 text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-semibold"> <span className="text-xs text-slate-400">(Opcional)</span>
<CheckCircle size={10} />
Opcional
</span>
</label> </label>
<div className="relative"> <div className="relative">
<input <input
type="number" type="number"
value={avgCsat} value={avgCsat}
onChange={(e) => setAvgCsat(parseFloat(e.target.value) || 0)} onChange={(e) => setAvgCsat(e.target.value)}
min="0" min="0"
max="100" max="100"
step="1" step="1"
className="w-full pr-16 py-3 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition text-lg font-semibold" className="w-full pr-12 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition"
placeholder="85" placeholder="Ej: 85"
/> />
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-xs text-slate-500 font-medium">/ 100</span> <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">/ 100</span>
</div> </div>
<p className="text-xs text-slate-500 mt-1 flex items-start gap-1"> <p className="text-xs text-slate-500 mt-1">
<Info size={12} className="mt-0.5 flex-shrink-0" />
<span>Tipo: <strong>Número (0-100)</strong> | Ejemplo: <code className="bg-slate-100 px-1 rounded">85</code></span>
</p>
<p className="text-xs text-slate-600 mt-1">
Puntuación promedio de satisfacción del cliente Puntuación promedio de satisfacción del cliente
</p> </p>
</div> </div>
{/* Segmentación por Cola/Skill */} {/* Segmentación por Cola/Skill */}
<div className="col-span-2"> <div className="col-span-1 md:col-span-2">
<div className="mb-4"> <div className="mb-3">
<h4 className="font-semibold text-slate-900 mb-2 flex items-center gap-2"> <h4 className="font-medium text-slate-700 mb-1 flex items-center gap-2">
<Database size={18} className="text-[#6D84E3]" />
Segmentación de Clientes por Cola/Skill Segmentación de Clientes por Cola/Skill
<span className="inline-flex items-center gap-1 text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-semibold"> <span className="text-xs text-slate-400">(Opcional)</span>
<CheckCircle size={10} />
Opcional
</span>
</h4> </h4>
<p className="text-sm text-slate-600"> <p className="text-sm text-slate-500">
Identifica qué colas/skills corresponden a cada segmento de cliente. Separa múltiples colas con comas. Identifica qué colas corresponden a cada segmento. Separa múltiples colas con comas.
</p> </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
{/* High Value */}
<div> <div>
<label className="block text-sm font-semibold text-slate-700 mb-2"> <label className="block text-sm font-medium text-slate-700 mb-1">
🟢 Clientes Alto Valor (High) Alto Valor
</label> </label>
<input <input
type="text" type="text"
value={highValueQueues} value={highValueQueues}
onChange={(e) => setHighValueQueues(e.target.value)} onChange={(e) => setHighValueQueues(e.target.value)}
placeholder="VIP, Premium, Enterprise" placeholder="VIP, Premium, Enterprise"
className="w-full px-4 py-3 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition" className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition text-sm"
/> />
<p className="text-xs text-slate-500 mt-1">
Colas para clientes de alto valor
</p>
</div> </div>
{/* Medium Value */}
<div> <div>
<label className="block text-sm font-semibold text-slate-700 mb-2"> <label className="block text-sm font-medium text-slate-700 mb-1">
🟡 Clientes Valor Medio (Medium) Valor Medio
</label> </label>
<input <input
type="text" type="text"
value={mediumValueQueues} value={mediumValueQueues}
onChange={(e) => setMediumValueQueues(e.target.value)} onChange={(e) => setMediumValueQueues(e.target.value)}
placeholder="Soporte_General, Ventas" placeholder="Soporte_General, Ventas"
className="w-full px-4 py-3 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition" className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition text-sm"
/> />
<p className="text-xs text-slate-500 mt-1">
Colas para clientes estándar
</p>
</div> </div>
{/* Low Value */}
<div> <div>
<label className="block text-sm font-semibold text-slate-700 mb-2"> <label className="block text-sm font-medium text-slate-700 mb-1">
🔴 Clientes Bajo Valor (Low) Bajo Valor
</label> </label>
<input <input
type="text" type="text"
value={lowValueQueues} value={lowValueQueues}
onChange={(e) => setLowValueQueues(e.target.value)} onChange={(e) => setLowValueQueues(e.target.value)}
placeholder="Basico, Trial, Freemium" placeholder="Basico, Trial, Freemium"
className="w-full px-4 py-3 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition" className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition text-sm"
/> />
<p className="text-xs text-slate-500 mt-1">
Colas para clientes de bajo valor
</p>
</div> </div>
</div> </div>
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg"> <p className="text-xs text-slate-500 mt-2 flex items-start gap-1">
<p className="text-xs text-blue-800 flex items-start gap-2"> <Info size={12} className="mt-0.5 flex-shrink-0" />
<Info size={14} className="mt-0.5 flex-shrink-0" /> Las colas no mapeadas se clasificarán como "Valor Medio" por defecto.
<span> </p>
<strong>Nota:</strong> Las colas no mapeadas se clasificarán automáticamente como "Medium". </div>
El matching es flexible (no distingue mayúsculas y permite coincidencias parciales). </div>
</span> </motion.div>
{/* Sección 2: Datos en Caché del Servidor (si hay) */}
{cacheInfo && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="bg-emerald-50 rounded-lg shadow-sm p-4 sm:p-6 border-2 border-emerald-300"
>
<div className="flex items-start justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-emerald-800 flex items-center gap-2">
<Server size={20} className="text-emerald-600" />
Datos en Caché
</h2>
</div>
<button
onClick={handleClearCache}
className="p-2 text-emerald-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition"
title="Limpiar caché"
>
<Trash2 size={18} />
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-4 mb-4">
<div className="bg-white rounded-lg p-3 border border-emerald-200">
<p className="text-xs text-emerald-600 font-medium">Archivo</p>
<p className="text-sm font-semibold text-slate-800 truncate" title={cacheInfo.fileName}>
{cacheInfo.fileName}
</p>
</div>
<div className="bg-white rounded-lg p-3 border border-emerald-200">
<p className="text-xs text-emerald-600 font-medium">Registros</p>
<p className="text-sm font-semibold text-slate-800">
{cacheInfo.recordCount.toLocaleString()}
</p>
</div>
<div className="bg-white rounded-lg p-3 border border-emerald-200">
<p className="text-xs text-emerald-600 font-medium">Tamaño Original</p>
<p className="text-sm font-semibold text-slate-800">
{(cacheInfo.fileSize / (1024 * 1024)).toFixed(1)} MB
</p>
</div>
<div className="bg-white rounded-lg p-3 border border-emerald-200">
<p className="text-xs text-emerald-600 font-medium">Guardado</p>
<p className="text-sm font-semibold text-slate-800">
{new Date(cacheInfo.cachedAt).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
</p> </p>
</div> </div>
</div> </div>
</div>
</motion.div> <button
onClick={handleUseCache}
{/* Sección 2: Datos CSV */} disabled={isAnalyzing || !costPerHour || parseFloat(costPerHour) <= 0}
className={clsx(
'w-full py-3 rounded-lg font-semibold flex items-center justify-center gap-2 transition-all',
(!isAnalyzing && costPerHour && parseFloat(costPerHour) > 0)
? 'bg-emerald-600 text-white hover:bg-emerald-700'
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
)}
>
{isAnalyzing ? (
<>
<Loader2 size={20} className="animate-spin" />
Analizando...
</>
) : (
<>
<RefreshCw size={20} />
Usar Datos en Caché
</>
)}
</button>
{(!costPerHour || parseFloat(costPerHour) <= 0) && (
<p className="text-xs text-amber-600 mt-2 text-center">
Introduce el coste por hora arriba para continuar
</p>
)}
</motion.div>
)}
{/* Sección 3: Subir Archivo */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }} transition={{ delay: cacheInfo ? 0.25 : 0.2 }}
className="bg-white rounded-xl shadow-lg p-8 border-2 border-slate-200" className="bg-white rounded-lg shadow-sm p-4 sm:p-6 border border-slate-200"
> >
<div className="mb-6"> <div className="mb-4">
<h2 className="text-2xl font-bold text-slate-900 mb-2 flex items-center gap-2"> <h2 className="text-lg font-semibold text-slate-800 mb-1 flex items-center gap-2">
<Table size={24} className="text-[#6D84E3]" /> <UploadCloud size={20} className="text-[#6D84E3]" />
2. Datos CSV (Raw Data de ACD) {cacheInfo ? 'Subir Nuevo Archivo' : 'Datos CSV'}
</h2> </h2>
<p className="text-slate-600 text-sm"> <p className="text-slate-500 text-sm">
Exporta estos campos desde tu sistema ACD/CTI (Genesys, Avaya, Talkdesk, Zendesk, etc.) {cacheInfo ? 'O sube un archivo diferente para analizar' : 'Sube el archivo exportado desde tu sistema ACD/CTI'}
</p> </p>
</div> </div>
{/* Tabla de campos requeridos */}
<div className="mb-6 overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead className="bg-slate-50">
<tr>
<th className="p-3 text-left font-semibold text-slate-700 border-b-2 border-slate-300">Campo</th>
<th className="p-3 text-left font-semibold text-slate-700 border-b-2 border-slate-300">Tipo</th>
<th className="p-3 text-left font-semibold text-slate-700 border-b-2 border-slate-300">Ejemplo</th>
<th className="p-3 text-center font-semibold text-slate-700 border-b-2 border-slate-300">Obligatorio</th>
</tr>
</thead>
<tbody>
{csvFields.map((field, index) => (
<tr key={field.name} className={clsx(
'border-b border-slate-200',
index % 2 === 0 ? 'bg-white' : 'bg-slate-50'
)}>
<td className="p-3 font-mono text-sm font-semibold text-slate-900">{field.name}</td>
<td className="p-3 text-slate-700">{field.type}</td>
<td className="p-3 font-mono text-xs text-slate-600">{field.example}</td>
<td className="p-3 text-center">
{field.required ? (
<span className="inline-flex items-center gap-1 text-xs bg-red-100 text-red-700 px-2 py-1 rounded-full font-semibold">
<AlertCircle size={10} />
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full font-semibold">
<CheckCircle size={10} />
No
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Botón de descarga de plantilla */}
<div className="mb-6">
<button
onClick={handleDownloadTemplate}
className="inline-flex items-center gap-2 px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5a6fc9] transition font-semibold"
>
<Download size={18} />
Descargar Plantilla CSV
</button>
<p className="text-xs text-slate-500 mt-2">
Descarga una plantilla con la estructura exacta de campos requeridos
</p>
</div>
{/* Opciones de carga */}
<div className="space-y-4">
<h3 className="font-semibold text-slate-900 mb-3">Elige cómo proporcionar tus datos:</h3>
{/* Opción 1: Subir archivo */}
<div className={clsx(
'border-2 rounded-lg p-4 transition-all',
uploadMethod === 'file' ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300'
)}>
<div className="flex items-start gap-3">
<input
type="radio"
name="uploadMethod"
checked={uploadMethod === 'file'}
onChange={() => setUploadMethod('file')}
className="mt-1"
/>
<div className="flex-1">
<h4 className="font-semibold text-slate-900 mb-2 flex items-center gap-2">
<UploadCloud size={18} className="text-[#6D84E3]" />
Subir Archivo CSV
</h4>
{uploadMethod === 'file' && (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={clsx(
'border-2 border-dashed rounded-lg p-6 text-center transition-all',
isDragging ? 'border-[#6D84E3] bg-blue-100' : 'border-slate-300 bg-slate-50'
)}
>
{file ? (
<div className="flex items-center justify-center gap-3">
<File size={24} className="text-green-600" />
<div className="text-left">
<p className="font-semibold text-slate-900">{file.name}</p>
<p className="text-xs text-slate-500">{(file.size / 1024).toFixed(1)} KB</p>
</div>
<button
onClick={() => setFile(null)}
className="ml-auto p-1 hover:bg-slate-200 rounded"
>
<X size={18} />
</button>
</div>
) : (
<>
<UploadCloud size={32} className="mx-auto text-slate-400 mb-2" />
<p className="text-sm text-slate-600 mb-2">
Arrastra tu archivo aquí o haz click para seleccionar
</p>
<input
type="file"
accept=".csv,.xlsx,.xls"
onChange={(e) => handleFileChange(e.target.files?.[0] || null)}
className="hidden"
id="file-upload"
/>
<label
htmlFor="file-upload"
className="inline-block px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5a6fc9] transition cursor-pointer"
>
Seleccionar Archivo
</label>
</>
)}
</div>
)}
</div>
</div>
</div>
{/* Opción 2: URL Google Sheets {/* Zona de subida */}
<div className={clsx( <div
'border-2 rounded-lg p-4 transition-all', onDragOver={onDragOver}
uploadMethod === 'url' ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300' onDragLeave={onDragLeave}
)}> onDrop={onDrop}
<div className="flex items-start gap-3"> className={clsx(
<input 'border-2 border-dashed rounded-lg p-8 text-center transition-all cursor-pointer',
type="radio" isDragging ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300 bg-slate-50 hover:border-slate-400'
name="uploadMethod" )}
checked={uploadMethod === 'url'} >
onChange={() => setUploadMethod('url')} {file ? (
className="mt-1" <div className="flex items-center justify-center gap-3">
/> <File size={24} className="text-emerald-600" />
<div className="flex-1"> <div className="text-left">
<h4 className="font-semibold text-slate-900 mb-2 flex items-center gap-2"> <p className="font-medium text-slate-800">{file.name}</p>
<Sheet size={18} className="text-[#6D84E3]" /> <p className="text-xs text-slate-500">{(file.size / 1024).toFixed(1)} KB</p>
Conectar Google Sheets
</h4>
{uploadMethod === 'url' && (
<div className="flex gap-2">
<input
type="url"
value={sheetUrl}
onChange={(e) => setSheetUrl(e.target.value)}
placeholder="https://docs.google.com/spreadsheets/d/..."
className="flex-1 px-4 py-2 border-2 border-slate-300 rounded-lg focus:ring-2 focus:ring-[#6D84E3] focus:border-[#6D84E3] transition"
/>
<button
onClick={handleSheetUrlSubmit}
className="px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5a6fc9] transition font-semibold"
>
<ExternalLink size={18} />
</button>
</div>
)}
</div> </div>
<button
onClick={(e) => {
e.stopPropagation();
setFile(null);
}}
className="ml-4 p-1.5 hover:bg-slate-200 rounded-full transition"
>
<X size={18} className="text-slate-500" />
</button>
</div> </div>
</div> ) : (
*/} <>
<UploadCloud size={40} className="mx-auto text-slate-400 mb-3" />
{/* Opción 3: Datos sintéticos */} <p className="text-slate-600 mb-2">
<div className={clsx( Arrastra tu archivo aquí o haz click para seleccionar
'border-2 rounded-lg p-4 transition-all', </p>
uploadMethod === 'synthetic' ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300' <p className="text-xs text-slate-400 mb-4">
)}> Formatos aceptados: CSV, Excel (.xlsx, .xls)
<div className="flex items-start gap-3"> </p>
<input <input
type="radio" type="file"
name="uploadMethod" accept=".csv,.xlsx,.xls"
checked={uploadMethod === 'synthetic'} onChange={(e) => handleFileChange(e.target.files?.[0] || null)}
onChange={() => setUploadMethod('synthetic')} className="hidden"
className="mt-1" id="file-upload"
/> />
<div className="flex-1"> <label
<h4 className="font-semibold text-slate-900 mb-2 flex items-center gap-2"> htmlFor="file-upload"
<Sparkles size={18} className="text-[#6D84E3]" /> className="inline-block px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5a6fc9] transition cursor-pointer font-medium"
Generar Datos Sintéticos (Demo) >
</h4> Seleccionar Archivo
</label>
{uploadMethod === 'synthetic' && ( </>
<button )}
onClick={handleGenerateSynthetic}
disabled={isGenerating}
className="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg hover:from-purple-700 hover:to-pink-700 transition font-semibold disabled:opacity-50"
>
{isGenerating ? (
<>
<Loader2 size={18} className="animate-spin" />
Generando...
</>
) : (
<>
<Sparkles size={18} />
Generar Datos de Prueba
</>
)}
</button>
)}
</div>
</div>
</div>
</div> </div>
</motion.div> </motion.div>
{/* Botón de análisis */} {/* Botón de análisis */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@@ -541,40 +478,23 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
className="flex justify-center" className="flex justify-center"
> >
<button <button
onClick={() => { onClick={handleSubmit}
// Preparar segment_mapping
const segmentMapping = (highValueQueues || mediumValueQueues || lowValueQueues) ? {
high_value_queues: (highValueQueues || '').split(',').map(q => q.trim()).filter(q => q),
medium_value_queues: (mediumValueQueues || '').split(',').map(q => q.trim()).filter(q => q),
low_value_queues: (lowValueQueues || '').split(',').map(q => q.trim()).filter(q => q)
} : undefined;
// Llamar a onAnalyze con todos los datos
onAnalyze({
costPerHour,
avgCsat,
segmentMapping,
file: uploadMethod === 'file' ? file || undefined : undefined,
sheetUrl: uploadMethod === 'url' ? sheetUrl : undefined,
useSynthetic: uploadMethod === 'synthetic'
});
}}
disabled={!canAnalyze || isAnalyzing} disabled={!canAnalyze || isAnalyzing}
className={clsx( className={clsx(
'px-8 py-4 rounded-xl font-bold text-lg transition-all flex items-center gap-3', 'px-8 py-3 rounded-lg font-semibold text-lg transition-all flex items-center gap-3',
canAnalyze && !isAnalyzing canAnalyze && !isAnalyzing
? 'bg-gradient-to-r from-[#6D84E3] to-[#5a6fc9] text-white hover:scale-105 shadow-lg' ? 'bg-[#6D84E3] text-white hover:bg-[#5a6fc9] shadow-lg hover:shadow-xl'
: 'bg-slate-300 text-slate-500 cursor-not-allowed' : 'bg-slate-200 text-slate-400 cursor-not-allowed'
)} )}
> >
{isAnalyzing ? ( {isAnalyzing ? (
<> <>
<Loader2 size={24} className="animate-spin" /> <Loader2 size={22} className="animate-spin" />
Analizando... Analizando...
</> </>
) : ( ) : (
<> <>
<FileText size={24} /> <FileText size={22} />
Generar Análisis Generar Análisis
</> </>
)} )}

View File

@@ -0,0 +1,775 @@
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
X, ShieldCheck, Database, RefreshCw, Tag, BarChart3,
ArrowRight, BadgeCheck, Download, ArrowLeftRight, Layers
} from 'lucide-react';
import type { AnalysisData, HeatmapDataPoint } from '../types';
interface MetodologiaDrawerProps {
isOpen: boolean;
onClose: () => void;
data: AnalysisData;
}
interface DataSummary {
totalRegistros: number;
mesesHistorico: number;
periodo: string;
fuente: string;
taxonomia: {
valid: number;
noise: number;
zombie: number;
abandon: number;
};
kpis: {
fcrTecnico: number;
fcrReal: number;
abandonoTradicional: number;
abandonoReal: number;
ahtLimpio: number;
skillsTecnicos: number;
skillsNegocio: number;
};
}
// ========== SUBSECCIONES ==========
function DataSummarySection({ data }: { data: DataSummary }) {
return (
<div className="bg-slate-50 rounded-lg p-5">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Database className="w-5 h-5 text-blue-600" />
Datos Procesados
</h3>
<div className="grid grid-cols-3 gap-4">
<div className="bg-white rounded-lg p-4 text-center shadow-sm">
<div className="text-3xl font-bold text-blue-600">
{data.totalRegistros.toLocaleString('es-ES')}
</div>
<div className="text-sm text-gray-600">Registros analizados</div>
</div>
<div className="bg-white rounded-lg p-4 text-center shadow-sm">
<div className="text-3xl font-bold text-blue-600">
{data.mesesHistorico}
</div>
<div className="text-sm text-gray-600">Meses de histórico</div>
</div>
<div className="bg-white rounded-lg p-4 text-center shadow-sm">
<div className="text-2xl font-bold text-blue-600">
{data.fuente}
</div>
<div className="text-sm text-gray-600">Sistema origen</div>
</div>
</div>
<p className="text-xs text-slate-500 mt-3 text-center">
Periodo: {data.periodo}
</p>
</div>
);
}
function PipelineSection() {
const steps = [
{
layer: 'Layer 0',
name: 'Raw Data',
desc: 'Ingesta y Normalización',
color: 'bg-gray-100 border-gray-300'
},
{
layer: 'Layer 1',
name: 'Trusted Data',
desc: 'Higiene y Clasificación',
color: 'bg-yellow-50 border-yellow-300'
},
{
layer: 'Layer 2',
name: 'Business Insights',
desc: 'Enriquecimiento',
color: 'bg-green-50 border-green-300'
},
{
layer: 'Output',
name: 'Dashboard',
desc: 'Visualización',
color: 'bg-blue-50 border-blue-300'
}
];
return (
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<RefreshCw className="w-5 h-5 text-purple-600" />
Pipeline de Transformación
</h3>
<div className="flex items-center justify-between">
{steps.map((step, index) => (
<React.Fragment key={step.layer}>
<div className={`flex-1 p-3 rounded-lg border-2 ${step.color} text-center`}>
<div className="text-[10px] text-gray-500 uppercase">{step.layer}</div>
<div className="font-semibold text-sm">{step.name}</div>
<div className="text-[10px] text-gray-600 mt-1">{step.desc}</div>
</div>
{index < steps.length - 1 && (
<ArrowRight className="w-5 h-5 text-gray-400 mx-1 flex-shrink-0" />
)}
</React.Fragment>
))}
</div>
<p className="text-xs text-gray-500 mt-3 italic">
Arquitectura modular de 3 capas para garantizar trazabilidad y escalabilidad.
</p>
</div>
);
}
function TaxonomySection({ data }: { data: DataSummary['taxonomia'] }) {
const rows = [
{
status: 'VALID',
pct: data.valid,
def: 'Duración 10s - 3h. Interacciones reales.',
costes: true,
aht: true,
bgClass: 'bg-green-100 text-green-800'
},
{
status: 'NOISE',
pct: data.noise,
def: 'Duración <10s (no abandono). Ruido técnico.',
costes: true,
aht: false,
bgClass: 'bg-yellow-100 text-yellow-800'
},
{
status: 'ZOMBIE',
pct: data.zombie,
def: 'Duración >3h. Error de sistema.',
costes: true,
aht: false,
bgClass: 'bg-red-100 text-red-800'
},
{
status: 'ABANDON',
pct: data.abandon,
def: 'Desconexión externa + Talk ≤5s.',
costes: false,
aht: false,
bgClass: 'bg-gray-100 text-gray-800'
}
];
return (
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Tag className="w-5 h-5 text-orange-600" />
Taxonomía de Calidad de Datos
</h3>
<p className="text-sm text-gray-600 mb-4">
En lugar de eliminar registros, aplicamos "Soft Delete" con etiquetado de calidad
para permitir doble visión: financiera (todos los costes) y operativa (KPIs limpios).
</p>
<div className="overflow-hidden rounded-lg border border-slate-200">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left font-semibold">Estado</th>
<th className="px-3 py-2 text-right font-semibold">%</th>
<th className="px-3 py-2 text-left font-semibold">Definición</th>
<th className="px-3 py-2 text-center font-semibold">Costes</th>
<th className="px-3 py-2 text-center font-semibold">AHT</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rows.map((row, idx) => (
<tr key={row.status} className={idx % 2 === 1 ? 'bg-gray-50' : ''}>
<td className="px-3 py-2">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${row.bgClass}`}>
{row.status}
</span>
</td>
<td className="px-3 py-2 text-right font-semibold">{row.pct.toFixed(1)}%</td>
<td className="px-3 py-2 text-xs text-gray-600">{row.def}</td>
<td className="px-3 py-2 text-center">
{row.costes ? (
<span className="text-green-600"> Suma</span>
) : (
<span className="text-red-600"> No</span>
)}
</td>
<td className="px-3 py-2 text-center">
{row.aht ? (
<span className="text-green-600"> Promedio</span>
) : (
<span className="text-red-600"> Excluye</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function KPIRedefinitionSection({ kpis }: { kpis: DataSummary['kpis'] }) {
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-indigo-600" />
KPIs Redefinidos
</h3>
<p className="text-sm text-gray-600 mb-4">
Hemos redefinido los KPIs para eliminar los "puntos ciegos" de las métricas tradicionales.
</p>
<div className="space-y-3">
{/* FCR */}
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex justify-between items-start">
<div>
<h4 className="font-semibold text-red-800">FCR Real vs FCR Técnico</h4>
<p className="text-xs text-red-700 mt-1">
El hallazgo más crítico del diagnóstico.
</p>
</div>
<span className="text-2xl font-bold text-red-600">{kpis.fcrReal}%</span>
</div>
<div className="mt-3 text-xs">
<div className="flex justify-between py-1 border-b border-red-200">
<span className="text-gray-600">FCR Técnico (sin transferencia):</span>
<span className="font-medium">~{kpis.fcrTecnico}%</span>
</div>
<div className="flex justify-between py-1">
<span className="text-gray-600">FCR Real (sin recontacto 7 días):</span>
<span className="font-medium text-red-600">{kpis.fcrReal}%</span>
</div>
</div>
<p className="text-[10px] text-red-600 mt-2 italic">
💡 ~{kpis.fcrTecnico - kpis.fcrReal}% de "casos resueltos" generan segunda llamada, disparando costes ocultos.
</p>
</div>
{/* Abandono */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex justify-between items-start">
<div>
<h4 className="font-semibold text-yellow-800">Tasa de Abandono Real</h4>
<p className="text-xs text-yellow-700 mt-1">
Fórmula: Desconexión Externa + Talk 5 segundos
</p>
</div>
<span className="text-2xl font-bold text-yellow-600">{kpis.abandonoReal.toFixed(1)}%</span>
</div>
<p className="text-[10px] text-yellow-600 mt-2 italic">
💡 El umbral de 5s captura al cliente que cuelga al escuchar la locución o en el timbre.
</p>
</div>
{/* AHT */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex justify-between items-start">
<div>
<h4 className="font-semibold text-blue-800">AHT Limpio</h4>
<p className="text-xs text-blue-700 mt-1">
Excluye NOISE (&lt;10s) y ZOMBIE (&gt;3h) del promedio.
</p>
</div>
<span className="text-2xl font-bold text-blue-600">{formatTime(kpis.ahtLimpio)}</span>
</div>
<p className="text-[10px] text-blue-600 mt-2 italic">
💡 El AHT sin filtrar estaba distorsionado por errores de sistema.
</p>
</div>
</div>
</div>
);
}
function CPICalculationSection({ totalCost, totalVolume, costPerHour = 20 }: { totalCost: number; totalVolume: number; costPerHour?: number }) {
// Productivity factor: agents are ~70% productive (rest is breaks, training, after-call work, etc.)
const effectiveProductivity = 0.70;
// CPI = Total Cost / Total Volume
// El coste total ya incluye: TODOS los registros (noise + zombie + valid) y el factor de productividad
const cpi = totalVolume > 0 ? totalCost / totalVolume : 0;
return (
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-emerald-600" />
Coste por Interacción (CPI)
</h3>
<p className="text-sm text-gray-600 mb-4">
El CPI se calcula dividiendo el <strong>coste total</strong> entre el <strong>volumen de interacciones</strong>.
El coste total incluye <em>todas</em> las interacciones (noise, zombie y válidas) porque todas se facturan,
y aplica un factor de productividad del {(effectiveProductivity * 100).toFixed(0)}%.
</p>
{/* Fórmula visual */}
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4 mb-4">
<div className="text-center mb-3">
<span className="text-xs text-emerald-700 uppercase tracking-wider font-medium">Fórmula de Cálculo</span>
</div>
<div className="flex items-center justify-center gap-2 text-lg font-mono flex-wrap">
<span className="px-3 py-1 bg-white rounded border border-emerald-300">CPI</span>
<span className="text-emerald-600">=</span>
<span className="px-2 py-1 bg-blue-100 rounded text-blue-800 text-sm">Coste Total</span>
<span className="text-emerald-600">÷</span>
<span className="px-2 py-1 bg-amber-100 rounded text-amber-800 text-sm">Volumen Total</span>
</div>
<p className="text-[10px] text-center text-emerald-600 mt-2">
El coste total usa (AHT segundos ÷ 3600) × coste/hora × volumen ÷ productividad
</p>
</div>
{/* Cómo se calcula el coste total */}
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 mb-4">
<div className="text-sm font-semibold text-slate-700 mb-2">¿Cómo se calcula el Coste Total?</div>
<div className="bg-white rounded p-3 mb-3">
<div className="flex items-center justify-center gap-2 text-sm font-mono flex-wrap">
<span className="text-slate-600">Coste =</span>
<span className="px-2 py-1 bg-blue-100 rounded text-blue-800 text-xs">(AHT seg ÷ 3600)</span>
<span className="text-slate-400">×</span>
<span className="px-2 py-1 bg-amber-100 rounded text-amber-800 text-xs">{costPerHour}/h</span>
<span className="text-slate-400">×</span>
<span className="px-2 py-1 bg-gray-100 rounded text-gray-800 text-xs">Volumen</span>
<span className="text-slate-400">÷</span>
<span className="px-2 py-1 bg-purple-100 rounded text-purple-800 text-xs">{(effectiveProductivity * 100).toFixed(0)}%</span>
</div>
</div>
<p className="text-xs text-slate-600">
El <strong>AHT</strong> está en segundos, se convierte a horas dividiendo por 3600.
Incluye todas las interacciones que generan coste (noise + zombie + válidas).
Solo se excluyen los abandonos porque no consumen tiempo de agente.
</p>
</div>
{/* Componentes del coste horario */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-semibold text-amber-800">Coste por Hora del Agente (Fully Loaded)</div>
<span className="text-xs bg-amber-200 text-amber-800 px-2 py-0.5 rounded-full font-medium">
Valor introducido: {costPerHour.toFixed(2)}/h
</span>
</div>
<p className="text-xs text-amber-700 mb-3">
Este valor fue configurado en la pantalla de entrada de datos y debe incluir todos los costes asociados al agente:
</p>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-2">
<span className="text-amber-500"></span>
<span className="text-amber-700">Salario bruto del agente</span>
</div>
<div className="flex items-center gap-2">
<span className="text-amber-500"></span>
<span className="text-amber-700">Costes de seguridad social</span>
</div>
<div className="flex items-center gap-2">
<span className="text-amber-500"></span>
<span className="text-amber-700">Licencias de software</span>
</div>
<div className="flex items-center gap-2">
<span className="text-amber-500"></span>
<span className="text-amber-700">Infraestructura y puesto</span>
</div>
<div className="flex items-center gap-2">
<span className="text-amber-500"></span>
<span className="text-amber-700">Supervisión y QA</span>
</div>
<div className="flex items-center gap-2">
<span className="text-amber-500"></span>
<span className="text-amber-700">Formación y overhead</span>
</div>
</div>
<p className="text-[10px] text-amber-600 mt-3 italic">
💡 Si necesita ajustar este valor, puede volver a la pantalla de entrada de datos y modificarlo.
</p>
</div>
</div>
);
}
function BeforeAfterSection({ kpis }: { kpis: DataSummary['kpis'] }) {
const rows = [
{
metric: 'FCR',
tradicional: `${kpis.fcrTecnico}%`,
beyond: `${kpis.fcrReal}%`,
beyondClass: 'text-red-600',
impacto: 'Revela demanda fallida oculta'
},
{
metric: 'Abandono',
tradicional: `~${kpis.abandonoTradicional}%`,
beyond: `${kpis.abandonoReal.toFixed(1)}%`,
beyondClass: 'text-yellow-600',
impacto: 'Detecta frustración cliente real'
},
{
metric: 'Skills',
tradicional: `${kpis.skillsTecnicos} técnicos`,
beyond: `${kpis.skillsNegocio} líneas negocio`,
beyondClass: 'text-blue-600',
impacto: 'Visión ejecutiva accionable'
},
{
metric: 'AHT',
tradicional: 'Distorsionado',
beyond: 'Limpio',
beyondClass: 'text-green-600',
impacto: 'KPIs reflejan desempeño real'
}
];
return (
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<ArrowLeftRight className="w-5 h-5 text-teal-600" />
Impacto de la Transformación
</h3>
<div className="overflow-hidden rounded-lg border border-slate-200">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left font-semibold">Métrica</th>
<th className="px-3 py-2 text-center font-semibold">Visión Tradicional</th>
<th className="px-3 py-2 text-center font-semibold">Visión Beyond</th>
<th className="px-3 py-2 text-left font-semibold">Impacto</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rows.map((row, idx) => (
<tr key={row.metric} className={idx % 2 === 1 ? 'bg-gray-50' : ''}>
<td className="px-3 py-2 font-medium">{row.metric}</td>
<td className="px-3 py-2 text-center">{row.tradicional}</td>
<td className={`px-3 py-2 text-center font-semibold ${row.beyondClass}`}>{row.beyond}</td>
<td className="px-3 py-2 text-xs text-gray-600">{row.impacto}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 p-3 bg-indigo-50 border border-indigo-200 rounded-lg">
<p className="text-xs text-indigo-800">
<strong>💡 Sin esta transformación,</strong> las decisiones de automatización
se basarían en datos incorrectos, generando inversiones en los procesos equivocados.
</p>
</div>
</div>
);
}
function SkillsMappingSection({ numSkillsNegocio }: { numSkillsNegocio: number }) {
const mappings = [
{
lineaNegocio: 'Baggage & Handling',
keywords: 'HANDLING, EQUIPAJE, AHL (Lost & Found), DPR (Daños)',
color: 'bg-amber-100 text-amber-800'
},
{
lineaNegocio: 'Sales & Booking',
keywords: 'COMPRA, VENTA, RESERVA, PAGO',
color: 'bg-blue-100 text-blue-800'
},
{
lineaNegocio: 'Loyalty (SUMA)',
keywords: 'SUMA (Programa de Fidelización)',
color: 'bg-purple-100 text-purple-800'
},
{
lineaNegocio: 'B2B & Agencies',
keywords: 'AGENCIAS, AAVV, EMPRESAS, AVORIS, TOUROPERACION',
color: 'bg-cyan-100 text-cyan-800'
},
{
lineaNegocio: 'Changes & Post-Sales',
keywords: 'MODIFICACION, CAMBIO, POSTVENTA, REFUND, REEMBOLSO',
color: 'bg-orange-100 text-orange-800'
},
{
lineaNegocio: 'Digital Support',
keywords: 'WEB (Soporte a navegación)',
color: 'bg-indigo-100 text-indigo-800'
},
{
lineaNegocio: 'Customer Service',
keywords: 'ATENCION, INFO, OTROS, GENERAL, PREMIUM',
color: 'bg-green-100 text-green-800'
},
{
lineaNegocio: 'Internal / Backoffice',
keywords: 'COORD, BO_, HELPDESK, BACKOFFICE',
color: 'bg-slate-100 text-slate-800'
}
];
return (
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Layers className="w-5 h-5 text-violet-600" />
Mapeo de Skills a Líneas de Negocio
</h3>
{/* Resumen del mapeo */}
<div className="bg-violet-50 border border-violet-200 rounded-lg p-4 mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-violet-800">Simplificación aplicada</span>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-violet-600">980</span>
<ArrowRight className="w-4 h-4 text-violet-400" />
<span className="text-2xl font-bold text-violet-600">{numSkillsNegocio}</span>
</div>
</div>
<p className="text-xs text-violet-700">
Se redujo la complejidad de <strong>980 skills técnicos</strong> a <strong>{numSkillsNegocio} Líneas de Negocio</strong>.
Esta simplificación es vital para la visualización ejecutiva y la toma de decisiones estratégicas.
</p>
</div>
{/* Tabla de mapeo */}
<div className="overflow-hidden rounded-lg border border-slate-200">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left font-semibold">Línea de Negocio</th>
<th className="px-3 py-2 text-left font-semibold">Keywords Detectadas (Lógica Fuzzy)</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{mappings.map((m, idx) => (
<tr key={m.lineaNegocio} className={idx % 2 === 1 ? 'bg-gray-50' : ''}>
<td className="px-3 py-2">
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${m.color}`}>
{m.lineaNegocio}
</span>
</td>
<td className="px-3 py-2 text-xs text-gray-600 font-mono">
{m.keywords}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-gray-500 mt-3 italic">
💡 El mapeo utiliza lógica fuzzy para clasificar automáticamente cada skill técnico
según las keywords detectadas en su nombre. Los skills no clasificados se asignan a "Customer Service".
</p>
</div>
);
}
function GuaranteesSection() {
const guarantees = [
{
icon: '✓',
title: '100% Trazabilidad',
desc: 'Todos los registros conservados (soft delete)'
},
{
icon: '✓',
title: 'Fórmulas Documentadas',
desc: 'Cada KPI tiene metodología auditable'
},
{
icon: '✓',
title: 'Reconciliación Financiera',
desc: 'Dataset original disponible para auditoría'
},
{
icon: '✓',
title: 'Metodología Replicable',
desc: 'Proceso reproducible para actualizaciones'
}
];
return (
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<BadgeCheck className="w-5 h-5 text-green-600" />
Garantías de Calidad
</h3>
<div className="grid grid-cols-2 gap-3">
{guarantees.map((item, i) => (
<div key={i} className="flex items-start gap-3 p-3 bg-green-50 rounded-lg">
<span className="text-green-600 font-bold text-lg">{item.icon}</span>
<div>
<div className="font-medium text-green-800 text-sm">{item.title}</div>
<div className="text-xs text-green-700">{item.desc}</div>
</div>
</div>
))}
</div>
</div>
);
}
// ========== COMPONENTE PRINCIPAL ==========
export function MetodologiaDrawer({ isOpen, onClose, data }: MetodologiaDrawerProps) {
// Calcular datos del resumen desde AnalysisData
const totalRegistros = data.heatmapData?.reduce((sum, h) => sum + h.volume, 0) || 0;
const totalCost = data.heatmapData?.reduce((sum, h) => sum + (h.annual_cost || 0), 0) || 0;
// cost_volume: volumen usado para calcular coste (non-abandon), fallback a volume si no existe
const totalCostVolume = data.heatmapData?.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0) || totalRegistros;
// Calcular meses de histórico desde dateRange
let mesesHistorico = 1;
if (data.dateRange?.min && data.dateRange?.max) {
const minDate = new Date(data.dateRange.min);
const maxDate = new Date(data.dateRange.max);
mesesHistorico = Math.max(1, Math.round((maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24 * 30)));
}
// Calcular FCR promedio
const avgFCR = data.heatmapData?.length > 0
? Math.round(data.heatmapData.reduce((sum, h) => sum + (h.metrics?.fcr || 0), 0) / data.heatmapData.length)
: 46;
// Calcular abandono promedio
const avgAbandonment = data.heatmapData?.length > 0
? data.heatmapData.reduce((sum, h) => sum + (h.metrics?.abandonment_rate || 0), 0) / data.heatmapData.length
: 11;
// Calcular AHT promedio
const avgAHT = data.heatmapData?.length > 0
? Math.round(data.heatmapData.reduce((sum, h) => sum + (h.aht_seconds || 0), 0) / data.heatmapData.length)
: 289;
const dataSummary: DataSummary = {
totalRegistros,
mesesHistorico,
periodo: data.dateRange
? `${data.dateRange.min} - ${data.dateRange.max}`
: 'Enero - Diciembre 2025',
fuente: data.source === 'backend' ? 'Genesys Cloud CX' : 'Dataset cargado',
taxonomia: {
valid: 94.2,
noise: 3.1,
zombie: 0.8,
abandon: 1.9
},
kpis: {
fcrTecnico: Math.min(87, avgFCR + 30),
fcrReal: avgFCR,
abandonoTradicional: 0,
abandonoReal: avgAbandonment,
ahtLimpio: avgAHT,
skillsTecnicos: 980,
skillsNegocio: data.heatmapData?.length || 9
}
};
const handleDownloadPDF = () => {
// Por ahora, abrir una URL placeholder o mostrar alert
alert('Funcionalidad de descarga PDF en desarrollo. El documento estará disponible próximamente.');
// En producción: window.open('/documents/Beyond_Diagnostic_Protocolo_Datos.pdf', '_blank');
};
const formatDate = (): string => {
const now = new Date();
const months = [
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
];
return `${months[now.getMonth()]} ${now.getFullYear()}`;
};
return (
<AnimatePresence>
{isOpen && (
<>
{/* Overlay */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 z-40"
onClick={onClose}
/>
{/* Drawer */}
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
className="fixed right-0 top-0 h-full w-full max-w-2xl bg-white shadow-xl z-50 overflow-hidden flex flex-col"
>
{/* Header */}
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-center flex-shrink-0">
<div className="flex items-center gap-2">
<ShieldCheck className="text-green-600 w-6 h-6" />
<h2 className="text-lg font-bold text-slate-800">Metodología de Transformación de Datos</h2>
</div>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 p-1 rounded-lg hover:bg-slate-100 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body - Scrollable */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
<DataSummarySection data={dataSummary} />
<PipelineSection />
<SkillsMappingSection numSkillsNegocio={dataSummary.kpis.skillsNegocio} />
<TaxonomySection data={dataSummary.taxonomia} />
<KPIRedefinitionSection kpis={dataSummary.kpis} />
<CPICalculationSection
totalCost={totalCost}
totalVolume={totalCostVolume}
costPerHour={data.staticConfig?.cost_per_hour || 20}
/>
<BeforeAfterSection kpis={dataSummary.kpis} />
<GuaranteesSection />
</div>
{/* Footer */}
<div className="sticky bottom-0 bg-gray-50 border-t border-slate-200 px-6 py-4 flex-shrink-0">
<div className="flex justify-between items-center">
<button
onClick={handleDownloadPDF}
className="flex items-center gap-2 px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5A70C7] transition-colors text-sm font-medium"
>
<Download className="w-4 h-4" />
Descargar Protocolo Completo (PDF)
</button>
<span className="text-xs text-gray-500">
Beyond Diagnosis - Data Strategy Unit Certificado: {formatDate()}
</span>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}
export default MetodologiaDrawer;

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;

View File

@@ -1,29 +1,22 @@
// components/SinglePageDataRequestIntegrated.tsx // components/SinglePageDataRequestIntegrated.tsx
// Versión integrada con DataInputRedesigned + Dashboard actual // Versión simplificada con cabecera estilo dashboard
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { TierKey, AnalysisData } from '../types'; import { TierKey, AnalysisData } from '../types';
import TierSelectorEnhanced from './TierSelectorEnhanced';
import DataInputRedesigned from './DataInputRedesigned'; import DataInputRedesigned from './DataInputRedesigned';
import DashboardReorganized from './DashboardReorganized'; import DashboardTabs from './DashboardTabs';
import { generateAnalysis } from '../utils/analysisGenerator'; import { generateAnalysis, generateAnalysisFromCache } from '../utils/analysisGenerator';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useAuth } from '../utils/AuthContext'; import { useAuth } from '../utils/AuthContext';
import { formatDateMonthYear } from '../utils/formatters';
const SinglePageDataRequestIntegrated: React.FC = () => { const SinglePageDataRequestIntegrated: React.FC = () => {
const [selectedTier, setSelectedTier] = useState<TierKey>('silver');
const [view, setView] = useState<'form' | 'dashboard'>('form'); const [view, setView] = useState<'form' | 'dashboard'>('form');
const [analysisData, setAnalysisData] = useState<AnalysisData | null>(null); const [analysisData, setAnalysisData] = useState<AnalysisData | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false);
const handleTierSelect = (tier: TierKey) => {
setSelectedTier(tier);
};
const { authHeader, logout } = useAuth(); const { authHeader, logout } = useAuth();
const handleAnalyze = (config: { const handleAnalyze = (config: {
costPerHour: number; costPerHour: number;
@@ -36,53 +29,66 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
file?: File; file?: File;
sheetUrl?: string; sheetUrl?: string;
useSynthetic?: boolean; useSynthetic?: boolean;
useCache?: boolean;
}) => { }) => {
console.log('🚀 handleAnalyze called with config:', config); // Validar que hay archivo o caché
console.log('🎯 Selected tier:', selectedTier); if (!config.file && !config.useCache) {
console.log('📄 File:', config.file); toast.error('Por favor, sube un archivo CSV o Excel.');
console.log('🔗 Sheet URL:', config.sheetUrl);
console.log('✨ Use Synthetic:', config.useSynthetic);
// Validar que hay datos
if (!config.file && !config.sheetUrl && !config.useSynthetic) {
toast.error('Por favor, sube un archivo, introduce una URL o genera datos sintéticos.');
return; return;
} }
// 🔐 Si usamos CSV real, exigir estar logado // Validar coste por hora
if (config.file && !config.useSynthetic && !authHeader) { if (!config.costPerHour || config.costPerHour <= 0) {
toast.error('Debes iniciar sesión para analizar datos reales.'); toast.error('Por favor, introduce el coste por hora del agente.');
return; return;
} }
// Exigir estar logado para analizar
if (!authHeader) {
toast.error('Debes iniciar sesión para analizar datos.');
return;
}
setIsAnalyzing(true); setIsAnalyzing(true);
toast.loading('Generando análisis...', { id: 'analyzing' }); const loadingMsg = config.useCache ? 'Cargando desde caché...' : 'Generando análisis...';
toast.loading(loadingMsg, { id: 'analyzing' });
setTimeout(async () => { setTimeout(async () => {
console.log('⏰ Generating analysis...');
try { try {
const data = await generateAnalysis( let data: AnalysisData;
selectedTier,
config.costPerHour, if (config.useCache) {
config.avgCsat, // Usar datos desde caché
config.segmentMapping, data = await generateAnalysisFromCache(
config.file, 'gold' as TierKey,
config.sheetUrl, config.costPerHour,
config.useSynthetic, config.avgCsat || 0,
authHeader || undefined config.segmentMapping,
); authHeader || undefined
console.log('✅ Analysis generated successfully'); );
} else {
// Usar tier 'gold' por defecto
data = await generateAnalysis(
'gold' as TierKey,
config.costPerHour,
config.avgCsat || 0,
config.segmentMapping,
config.file,
config.sheetUrl,
false, // No usar sintético
authHeader || undefined
);
}
setAnalysisData(data); setAnalysisData(data);
setIsAnalyzing(false); setIsAnalyzing(false);
toast.dismiss('analyzing'); toast.dismiss('analyzing');
toast.success('¡Análisis completado!', { icon: '🎉' }); toast.success(config.useCache ? '¡Datos cargados desde caché!' : '¡Análisis completado!', { icon: '🎉' });
setView('dashboard'); setView('dashboard');
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (error) { } catch (error) {
console.error('Error generating analysis:', error); console.error('Error generating analysis:', error);
setIsAnalyzing(false); setIsAnalyzing(false);
toast.dismiss('analyzing'); toast.dismiss('analyzing');
@@ -95,7 +101,7 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
toast.error('Error al generar el análisis: ' + msg); toast.error('Error al generar el análisis: ' + msg);
} }
} }
}, 1500); }, 500);
}; };
const handleBackToForm = () => { const handleBackToForm = () => {
@@ -106,14 +112,10 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
// Dashboard view // Dashboard view
if (view === 'dashboard' && analysisData) { if (view === 'dashboard' && analysisData) {
console.log('📊 Rendering dashboard with data:', analysisData);
console.log('📊 Heatmap data length:', analysisData.heatmapData?.length);
console.log('📊 Dimensions length:', analysisData.dimensions?.length);
try { try {
return <DashboardReorganized analysisData={analysisData} onBack={handleBackToForm} />; return <DashboardTabs data={analysisData} onBack={handleBackToForm} />;
} catch (error) { } catch (error) {
console.error('Error rendering dashboard:', error); console.error('Error rendering dashboard:', error);
return ( return (
<div className="min-h-screen bg-red-50 p-8"> <div className="min-h-screen bg-red-50 p-8">
<div className="max-w-2xl mx-auto bg-white rounded-xl shadow-lg p-6"> <div className="max-w-2xl mx-auto bg-white rounded-xl shadow-lg p-6">
@@ -135,57 +137,35 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
return ( return (
<> <>
<Toaster position="top-right" /> <Toaster position="top-right" />
<div className="w-full min-h-screen bg-gradient-to-br from-slate-50 via-[#E8EBFA] to-slate-100 font-sans">
<div className="w-full max-w-7xl mx-auto p-6 space-y-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center"
>
<h1 className="text-4xl md:text-5xl font-bold text-slate-900 mb-3">
Beyond Diagnostic
</h1>
<p className="text-lg text-slate-600">
Análisis de Readiness Agéntico para Contact Centers
</p>
<button
onClick={logout}
className="text-xs text-slate-500 hover:text-slate-800 underline mt-1"
>
Cerrar sesión
</button>
</motion.div>
{/* Tier Selection */} <div className="min-h-screen bg-slate-50">
<motion.div {/* Header estilo dashboard */}
initial={{ opacity: 0, y: 20 }} <header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
animate={{ opacity: 1, y: 0 }} <div className="max-w-7xl mx-auto px-6 py-4">
transition={{ delay: 0.1 }} <div className="flex items-center justify-between">
className="bg-white rounded-xl shadow-lg p-8" <h1 className="text-xl font-bold text-slate-800">
> AIR EUROPA - Beyond CX Analytics
<div className="mb-8"> </h1>
<h2 className="text-3xl font-bold text-slate-900 mb-2"> <div className="flex items-center gap-4">
Selecciona tu Tier de Análisis <span className="text-sm text-slate-500">{formatDateMonthYear()}</span>
</h2> <button
<p className="text-slate-600"> onClick={logout}
Elige el nivel de profundidad que necesitas para tu diagnóstico className="text-xs text-slate-500 hover:text-slate-800 underline"
</p> >
Cerrar sesión
</button>
</div>
</div> </div>
</div>
</header>
<TierSelectorEnhanced {/* Contenido principal */}
selectedTier={selectedTier} <main className="max-w-7xl mx-auto px-6 py-6">
onSelectTier={handleTierSelect}
/>
</motion.div>
{/* Data Input - Using redesigned component */}
<DataInputRedesigned <DataInputRedesigned
onAnalyze={handleAnalyze} onAnalyze={handleAnalyze}
isAnalyzing={isAnalyzing} isAnalyzing={isAnalyzing}
/> />
</div> </main>
</div> </div>
</> </>
); );

View File

@@ -0,0 +1,159 @@
import { useMemo } from 'react';
export interface BulletChartProps {
label: string;
actual: number;
target: number;
ranges: [number, number, number]; // [poor, satisfactory, good/max]
unit?: string;
percentile?: number;
inverse?: boolean; // true if lower is better (e.g., AHT)
formatValue?: (value: number) => string;
}
export function BulletChart({
label,
actual,
target,
ranges,
unit = '',
percentile,
inverse = false,
formatValue = (v) => v.toLocaleString()
}: BulletChartProps) {
const [poor, satisfactory, max] = ranges;
const { actualPercent, targetPercent, rangePercents, performance } = useMemo(() => {
const actualPct = Math.min((actual / max) * 100, 100);
const targetPct = Math.min((target / max) * 100, 100);
const poorPct = (poor / max) * 100;
const satPct = (satisfactory / max) * 100;
// Determine performance level
let perf: 'poor' | 'satisfactory' | 'good';
if (inverse) {
// Lower is better (e.g., AHT, hold time)
if (actual <= satisfactory) perf = 'good';
else if (actual <= poor) perf = 'satisfactory';
else perf = 'poor';
} else {
// Higher is better (e.g., FCR, CSAT)
if (actual >= satisfactory) perf = 'good';
else if (actual >= poor) perf = 'satisfactory';
else perf = 'poor';
}
return {
actualPercent: actualPct,
targetPercent: targetPct,
rangePercents: { poor: poorPct, satisfactory: satPct },
performance: perf
};
}, [actual, target, ranges, inverse, poor, satisfactory, max]);
const performanceColors = {
poor: 'bg-red-500',
satisfactory: 'bg-amber-500',
good: 'bg-emerald-500'
};
const performanceLabels = {
poor: 'Crítico',
satisfactory: 'Aceptable',
good: 'Óptimo'
};
return (
<div className="bg-white rounded-lg p-4 border border-slate-200">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-800">{label}</span>
{percentile !== undefined && (
<span className="text-xs px-2 py-0.5 bg-slate-100 text-slate-600 rounded-full">
P{percentile}
</span>
)}
</div>
<span className={`text-xs px-2 py-1 rounded-full ${
performance === 'good' ? 'bg-emerald-100 text-emerald-700' :
performance === 'satisfactory' ? 'bg-amber-100 text-amber-700' :
'bg-red-100 text-red-700'
}`}>
{performanceLabels[performance]}
</span>
</div>
{/* Bullet Chart */}
<div className="relative h-8 mb-2">
{/* Background ranges */}
<div className="absolute inset-0 flex rounded overflow-hidden">
{inverse ? (
// Inverse: green on left, red on right
<>
<div
className="h-full bg-emerald-100"
style={{ width: `${rangePercents.satisfactory}%` }}
/>
<div
className="h-full bg-amber-100"
style={{ width: `${rangePercents.poor - rangePercents.satisfactory}%` }}
/>
<div
className="h-full bg-red-100"
style={{ width: `${100 - rangePercents.poor}%` }}
/>
</>
) : (
// Normal: red on left, green on right
<>
<div
className="h-full bg-red-100"
style={{ width: `${rangePercents.poor}%` }}
/>
<div
className="h-full bg-amber-100"
style={{ width: `${rangePercents.satisfactory - rangePercents.poor}%` }}
/>
<div
className="h-full bg-emerald-100"
style={{ width: `${100 - rangePercents.satisfactory}%` }}
/>
</>
)}
</div>
{/* Actual value bar */}
<div
className={`absolute top-1/2 -translate-y-1/2 h-4 rounded ${performanceColors[performance]}`}
style={{ width: `${actualPercent}%`, minWidth: '4px' }}
/>
{/* Target marker */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-slate-800"
style={{ left: `${targetPercent}%` }}
>
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-0 h-0 border-l-[4px] border-r-[4px] border-t-[6px] border-l-transparent border-r-transparent border-t-slate-800" />
</div>
</div>
{/* Values */}
<div className="flex items-center justify-between text-sm">
<div>
<span className="font-bold text-slate-800">{formatValue(actual)}</span>
<span className="text-slate-500">{unit}</span>
<span className="text-slate-400 ml-1">actual</span>
</div>
<div className="text-slate-500">
<span className="text-slate-600">{formatValue(target)}</span>
<span>{unit}</span>
<span className="ml-1">benchmark</span>
</div>
</div>
</div>
);
}
export default BulletChart;

View File

@@ -0,0 +1,214 @@
import { Treemap, ResponsiveContainer, Tooltip } from 'recharts';
export type ReadinessCategory = 'automate_now' | 'assist_copilot' | 'optimize_first';
export interface TreemapData {
name: string;
value: number; // Savings potential (determines size)
category: ReadinessCategory;
skill: string;
score: number; // Agentic readiness score 0-10
volume?: number;
}
export interface OpportunityTreemapProps {
data: TreemapData[];
title?: string;
height?: number;
onItemClick?: (item: TreemapData) => void;
}
const CATEGORY_COLORS: Record<ReadinessCategory, string> = {
automate_now: '#059669', // emerald-600
assist_copilot: '#6D84E3', // primary blue
optimize_first: '#D97706' // amber-600
};
const CATEGORY_LABELS: Record<ReadinessCategory, string> = {
automate_now: 'Automatizar Ahora',
assist_copilot: 'Asistir con Copilot',
optimize_first: 'Optimizar Primero'
};
interface TreemapContentProps {
x: number;
y: number;
width: number;
height: number;
name: string;
category: ReadinessCategory;
score: number;
value: number;
}
const CustomizedContent = ({
x,
y,
width,
height,
name,
category,
score,
value
}: TreemapContentProps) => {
const showLabel = width > 60 && height > 40;
const showScore = width > 80 && height > 55;
const showValue = width > 100 && height > 70;
const baseColor = CATEGORY_COLORS[category] || '#94A3B8';
return (
<g>
<rect
x={x}
y={y}
width={width}
height={height}
style={{
fill: baseColor,
stroke: '#fff',
strokeWidth: 2,
opacity: 0.85 + (score / 10) * 0.15 // Higher score = more opaque
}}
rx={4}
/>
{showLabel && (
<text
x={x + width / 2}
y={y + height / 2 - (showScore ? 8 : 0)}
textAnchor="middle"
dominantBaseline="middle"
style={{
fontSize: Math.min(12, width / 8),
fontWeight: 600,
fill: '#fff',
textShadow: '0 1px 2px rgba(0,0,0,0.3)'
}}
>
{name.length > 15 && width < 120 ? `${name.slice(0, 12)}...` : name}
</text>
)}
{showScore && (
<text
x={x + width / 2}
y={y + height / 2 + 10}
textAnchor="middle"
dominantBaseline="middle"
style={{
fontSize: 10,
fill: 'rgba(255,255,255,0.9)'
}}
>
Score: {score.toFixed(1)}
</text>
)}
{showValue && (
<text
x={x + width / 2}
y={y + height / 2 + 24}
textAnchor="middle"
dominantBaseline="middle"
style={{
fontSize: 9,
fill: 'rgba(255,255,255,0.8)'
}}
>
{(value / 1000).toFixed(0)}K
</text>
)}
</g>
);
};
interface TooltipPayload {
payload: TreemapData;
}
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-white px-3 py-2 shadow-lg rounded-lg border border-slate-200">
<p className="font-semibold text-slate-800">{data.name}</p>
<p className="text-xs text-slate-500 mb-2">{data.skill}</p>
<div className="space-y-1 text-sm">
<div className="flex justify-between gap-4">
<span className="text-slate-600">Readiness Score:</span>
<span className="font-medium">{data.score.toFixed(1)}/10</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-slate-600">Ahorro Potencial:</span>
<span className="font-medium text-emerald-600">{data.value.toLocaleString()}</span>
</div>
{data.volume && (
<div className="flex justify-between gap-4">
<span className="text-slate-600">Volumen:</span>
<span className="font-medium">{data.volume.toLocaleString()}/mes</span>
</div>
)}
<div className="flex justify-between gap-4">
<span className="text-slate-600">Categoría:</span>
<span
className="font-medium"
style={{ color: CATEGORY_COLORS[data.category] }}
>
{CATEGORY_LABELS[data.category]}
</span>
</div>
</div>
</div>
);
}
return null;
};
export function OpportunityTreemap({
data,
title,
height = 350,
onItemClick
}: OpportunityTreemapProps) {
// Group data by category for treemap
const treemapData = data.map(item => ({
...item,
size: item.value
}));
return (
<div className="bg-white rounded-lg p-4 border border-slate-200">
{title && (
<h3 className="font-semibold text-slate-800 mb-4">{title}</h3>
)}
<ResponsiveContainer width="100%" height={height}>
<Treemap
data={treemapData}
dataKey="size"
aspectRatio={4 / 3}
stroke="#fff"
content={<CustomizedContent x={0} y={0} width={0} height={0} name="" category="automate_now" score={0} value={0} />}
onClick={onItemClick ? (node) => onItemClick(node as unknown as TreemapData) : undefined}
>
<Tooltip content={<CustomTooltip />} />
</Treemap>
</ResponsiveContainer>
{/* Legend */}
<div className="flex items-center justify-center gap-6 mt-4 text-xs">
{Object.entries(CATEGORY_COLORS).map(([category, color]) => (
<div key={category} className="flex items-center gap-1.5">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: color }}
/>
<span className="text-slate-600">
{CATEGORY_LABELS[category as ReadinessCategory]}
</span>
</div>
))}
</div>
</div>
);
}
export default OpportunityTreemap;

View File

@@ -0,0 +1,197 @@
import {
ComposedChart,
Bar,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
LabelList
} from 'recharts';
export interface WaterfallDataPoint {
label: string;
value: number;
cumulative: number;
type: 'initial' | 'increase' | 'decrease' | 'total';
}
export interface WaterfallChartProps {
data: WaterfallDataPoint[];
title?: string;
height?: number;
formatValue?: (value: number) => string;
}
interface ProcessedDataPoint {
label: string;
value: number;
cumulative: number;
type: 'initial' | 'increase' | 'decrease' | 'total';
start: number;
end: number;
displayValue: number;
}
export function WaterfallChart({
data,
title,
height = 300,
formatValue = (v) => `${Math.abs(v).toLocaleString()}`
}: WaterfallChartProps) {
// Process data for waterfall visualization
const processedData: ProcessedDataPoint[] = data.map((item) => {
let start: number;
let end: number;
if (item.type === 'initial' || item.type === 'total') {
start = 0;
end = item.cumulative;
} else if (item.type === 'decrease') {
// Savings: bar goes down from previous cumulative
start = item.cumulative;
end = item.cumulative - item.value;
} else {
// Increase: bar goes up from previous cumulative
start = item.cumulative - item.value;
end = item.cumulative;
}
return {
...item,
start: Math.min(start, end),
end: Math.max(start, end),
displayValue: Math.abs(item.value)
};
});
const getBarColor = (type: string): string => {
switch (type) {
case 'initial':
return '#64748B'; // slate-500
case 'decrease':
return '#059669'; // emerald-600 (savings)
case 'increase':
return '#DC2626'; // red-600 (costs)
case 'total':
return '#6D84E3'; // primary blue
default:
return '#94A3B8';
}
};
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: ProcessedDataPoint }> }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-white px-3 py-2 shadow-lg rounded-lg border border-slate-200">
<p className="font-medium text-slate-800">{data.label}</p>
<p className={`text-sm ${
data.type === 'decrease' ? 'text-emerald-600' :
data.type === 'increase' ? 'text-red-600' :
'text-slate-600'
}`}>
{data.type === 'decrease' ? '-' : data.type === 'increase' ? '+' : ''}
{formatValue(data.value)}
</p>
{data.type !== 'initial' && data.type !== 'total' && (
<p className="text-xs text-slate-500">
Acumulado: {formatValue(data.cumulative)}
</p>
)}
</div>
);
}
return null;
};
// Find min/max for Y axis - always start from 0
const allValues = processedData.flatMap(d => [d.start, d.end]);
const minValue = 0; // Always start from 0, not negative
const maxValue = Math.max(...allValues);
const padding = maxValue * 0.1;
return (
<div className="bg-white rounded-lg p-4 border border-slate-200">
{title && (
<h3 className="font-semibold text-slate-800 mb-4">{title}</h3>
)}
<ResponsiveContainer width="100%" height={height}>
<ComposedChart
data={processedData}
margin={{ top: 20, right: 20, left: 20, bottom: 60 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="#E2E8F0"
vertical={false}
/>
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: '#64748B' }}
tickLine={false}
axisLine={{ stroke: '#E2E8F0' }}
angle={-45}
textAnchor="end"
height={80}
interval={0}
/>
<YAxis
domain={[minValue - padding, maxValue + padding]}
tick={{ fontSize: 11, fill: '#64748B' }}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${(value / 1000).toFixed(0)}K`}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine y={0} stroke="#94A3B8" strokeWidth={1} />
{/* Invisible bar for spacing (from 0 to start) */}
<Bar dataKey="start" stackId="waterfall" fill="transparent" />
{/* Visible bar (the actual segment) */}
<Bar
dataKey="displayValue"
stackId="waterfall"
radius={[4, 4, 0, 0]}
>
{processedData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={getBarColor(entry.type)} />
))}
<LabelList
dataKey="displayValue"
position="top"
formatter={(value: number) => formatValue(value)}
style={{ fontSize: 10, fill: '#475569' }}
/>
</Bar>
</ComposedChart>
</ResponsiveContainer>
{/* Legend */}
<div className="flex items-center justify-center gap-6 mt-4 text-xs">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-slate-500" />
<span className="text-slate-600">Coste Base</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-emerald-600" />
<span className="text-slate-600">Ahorro</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-red-600" />
<span className="text-slate-600">Inversión</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-[#6D84E3]" />
<span className="text-slate-600">Total</span>
</div>
</div>
</div>
);
}
export default WaterfallChart;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,654 @@
import React from 'react';
import { motion } from 'framer-motion';
import { ChevronRight, TrendingUp, TrendingDown, Minus, AlertTriangle, Lightbulb, DollarSign, Clock } from 'lucide-react';
import type { AnalysisData, DimensionAnalysis, Finding, Recommendation, HeatmapDataPoint } from '../../types';
import {
Card,
Badge,
} from '../ui';
import {
cn,
COLORS,
STATUS_CLASSES,
getStatusFromScore,
formatCurrency,
formatNumber,
formatPercent,
} from '../../config/designSystem';
interface DimensionAnalysisTabProps {
data: AnalysisData;
}
// ========== HALLAZGO CLAVE CON IMPACTO ECONÓMICO ==========
interface CausalAnalysis {
finding: string;
probableCause: string;
economicImpact: number;
recommendation: string;
severity: 'critical' | 'warning' | 'info';
}
// v3.11: Interfaz extendida para incluir fórmula de cálculo
interface CausalAnalysisExtended extends CausalAnalysis {
impactFormula?: string; // Explicación de cómo se calculó el impacto
hasRealData: boolean; // True si hay datos reales para calcular
timeSavings?: string; // Ahorro de tiempo para dar credibilidad al impacto económico
}
// Genera hallazgo clave basado en dimensión y datos
function generateCausalAnalysis(
dimension: DimensionAnalysis,
heatmapData: HeatmapDataPoint[],
economicModel: { currentAnnualCost: number },
staticConfig?: { cost_per_hour: number },
dateRange?: { min: string; max: string }
): CausalAnalysisExtended[] {
const analyses: CausalAnalysisExtended[] = [];
const totalVolume = heatmapData.reduce((sum, h) => sum + h.volume, 0);
// Coste horario del agente desde config (default €20 si no está definido)
const HOURLY_COST = staticConfig?.cost_per_hour ?? 20;
// Calcular factor de anualización basado en el período de datos
// Si tenemos dateRange, calculamos cuántos días cubre y extrapolamos a año
let annualizationFactor = 1; // Por defecto, asumimos que los datos ya son anuales
if (dateRange?.min && dateRange?.max) {
const startDate = new Date(dateRange.min);
const endDate = new Date(dateRange.max);
const daysCovered = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1);
annualizationFactor = 365 / daysCovered;
}
// v3.11: CPI consistente con Executive Summary - benchmark aerolíneas p50
const CPI_TCO = 3.50; // Benchmark aerolíneas (p50) para cálculos de impacto
// Usar CPI pre-calculado de heatmapData si existe, sino calcular desde annual_cost/cost_volume
// IMPORTANTE: Mismo cálculo que ExecutiveSummaryTab para consistencia
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
const totalAnnualCost = heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0);
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
const CPI = hasCpiField
? (totalCostVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
: 0)
: (totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : 0);
// Calcular métricas agregadas
const avgCVAHT = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.variability?.cv_aht || 0) * h.volume, 0) / totalVolume
: 0;
const avgTransferRate = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + h.metrics.transfer_rate * h.volume, 0) / totalVolume
: 0;
// Usar FCR Técnico (100 - transfer_rate) en lugar de FCR Real (con filtro recontacto 7d)
// FCR Técnico es más comparable con benchmarks de industria
const avgFCR = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.metrics.fcr_tecnico ?? (100 - h.metrics.transfer_rate)) * h.volume, 0) / totalVolume
: 0;
const avgAHT = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + h.aht_seconds * h.volume, 0) / totalVolume
: 0;
const avgCSAT = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.metrics?.csat || 0) * h.volume, 0) / totalVolume
: 0;
const avgHoldTime = totalVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.metrics?.hold_time || 0) * h.volume, 0) / totalVolume
: 0;
// Skills con problemas específicos
const skillsHighCV = heatmapData.filter(h => (h.variability?.cv_aht || 0) > 100);
// Usar FCR Técnico para identificar skills con bajo FCR
const skillsLowFCR = heatmapData.filter(h => (h.metrics.fcr_tecnico ?? (100 - h.metrics.transfer_rate)) < 50);
const skillsHighTransfer = heatmapData.filter(h => h.metrics.transfer_rate > 20);
// Parsear P50 AHT del KPI del header para consistencia visual
// El KPI puede ser "345s (P50)" o similar
const parseKpiAhtSeconds = (kpiValue: string): number | null => {
const match = kpiValue.match(/(\d+)s/);
return match ? parseInt(match[1], 10) : null;
};
switch (dimension.name) {
case 'operational_efficiency':
// Obtener P50 AHT del header para mostrar valor consistente
const p50Aht = parseKpiAhtSeconds(dimension.kpi.value) ?? avgAHT;
// Eficiencia Operativa: enfocada en AHT (valor absoluto)
// CV AHT se analiza en Complejidad & Predictibilidad (best practice)
const hasHighAHT = p50Aht > 300; // 5:00 benchmark
const ahtBenchmark = 300; // 5:00 objetivo
if (hasHighAHT) {
// Calcular impacto económico por AHT excesivo
const excessSeconds = p50Aht - ahtBenchmark;
const annualVolume = Math.round(totalVolume * annualizationFactor);
const excessHours = Math.round((excessSeconds / 3600) * annualVolume);
const ahtExcessCost = Math.round(excessHours * HOURLY_COST);
// Estimar ahorro con solución Copilot (25-30% reducción AHT)
const copilotSavings = Math.round(ahtExcessCost * 0.28);
// Causa basada en AHT elevado
const cause = 'Agentes dedican tiempo excesivo a búsqueda manual de información, navegación entre sistemas y tareas repetitivas.';
analyses.push({
finding: `AHT elevado: P50 ${Math.floor(p50Aht / 60)}:${String(Math.round(p50Aht) % 60).padStart(2, '0')} (benchmark: 5:00)`,
probableCause: cause,
economicImpact: ahtExcessCost,
impactFormula: `${excessHours.toLocaleString()}h ×${HOURLY_COST}/h`,
timeSavings: `${excessHours.toLocaleString()} horas/año en exceso de AHT`,
recommendation: `Desplegar Copilot IA para agentes: (1) Auto-búsqueda en KB; (2) Sugerencias contextuales en tiempo real; (3) Scripts guiados para casos frecuentes. Reducción esperada: 20-30% AHT. Ahorro: ${formatCurrency(copilotSavings)}/año.`,
severity: p50Aht > 420 ? 'critical' : 'warning',
hasRealData: true
});
} else {
// AHT dentro de benchmark - mostrar estado positivo
analyses.push({
finding: `AHT dentro de benchmark: P50 ${Math.floor(p50Aht / 60)}:${String(Math.round(p50Aht) % 60).padStart(2, '0')} (benchmark: 5:00)`,
probableCause: 'Tiempos de gestión eficientes. Procesos operativos optimizados.',
economicImpact: 0,
impactFormula: 'Sin exceso de coste por AHT',
timeSavings: 'Operación eficiente',
recommendation: 'Mantener nivel actual. Considerar Copilot para mejora continua y reducción adicional de tiempos en casos complejos.',
severity: 'info',
hasRealData: true
});
}
break;
case 'effectiveness_resolution':
// Análisis 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) {
effCause = skillsLowFCR.length > 0
? `Alta tasa de transferencias (${avgTransferRate.toFixed(0)}%) indica falta de herramientas o autoridad. Crítico en ${skillsLowFCR.slice(0, 2).map(s => s.skill).join(', ')}.`
: `Transferencias elevadas (${avgTransferRate.toFixed(0)}%): agentes sin información contextual o sin autoridad para resolver.`;
} else if (avgFCR < 85) {
effCause = `Transferencias del ${avgTransferRate.toFixed(0)}% indican oportunidad de mejora con asistencia IA para casos complejos.`;
} else {
effCause = `FCR Técnico en nivel óptimo. Transferencias del ${avgTransferRate.toFixed(0)}% principalmente en casos que requieren escalación legítima.`;
}
// Construir recomendación
let effRecommendation = '';
if (avgFCR < 70) {
effRecommendation = `Desplegar Knowledge Copilot con búsqueda inteligente en KB + Guided Resolution Copilot para casos complejos. Objetivo: FCR >85%. Potencial ahorro: ${formatCurrency(potentialSavingsEff)}/año.`;
} else if (avgFCR < 85) {
effRecommendation = `Implementar Copilot de asistencia en tiempo real: sugerencias contextuales + conexión con expertos virtuales para reducir transferencias. Objetivo: FCR >90%.`;
} else {
effRecommendation = `Mantener nivel actual. Considerar IA para análisis de transferencias legítimas y optimización de enrutamiento predictivo.`;
}
analyses.push({
finding: `FCR Técnico: ${avgFCR.toFixed(0)}% | Transferencias: ${avgTransferRate.toFixed(0)}% (benchmark: FCR >85%, Transfer <10%)`,
probableCause: effCause,
economicImpact: transferCostTotal,
impactFormula: `${transferCount.toLocaleString()} transferencias/año ×${CPI_TCO}/int × 50% coste adicional`,
timeSavings: `${transferCount.toLocaleString()} transferencias/año (${avgTransferRate.toFixed(0)}% del volumen)`,
recommendation: effRecommendation,
severity: effSeverity,
hasRealData: true
});
break;
case 'volumetry_distribution':
// Análisis de concentración de volumen
const topSkill = [...heatmapData].sort((a, b) => b.volume - a.volume)[0];
const topSkillPct = topSkill ? (topSkill.volume / totalVolume) * 100 : 0;
if (topSkillPct > 40 && topSkill) {
const annualTopSkillVolume = Math.round(topSkill.volume * annualizationFactor);
const deflectionPotential = Math.round(annualTopSkillVolume * CPI_TCO * 0.20);
const interactionsDeflectable = Math.round(annualTopSkillVolume * 0.20);
analyses.push({
finding: `Concentración de volumen: ${topSkill.skill} representa ${topSkillPct.toFixed(0)}% del total`,
probableCause: `Alta concentración en un skill indica consultas repetitivas con potencial de automatización.`,
economicImpact: deflectionPotential,
impactFormula: `${topSkill.volume.toLocaleString()} int × anualización ×${CPI_TCO} × 20% deflexión potencial`,
timeSavings: `${annualTopSkillVolume.toLocaleString()} interacciones/año en ${topSkill.skill} (${interactionsDeflectable.toLocaleString()} automatizables)`,
recommendation: `Analizar tipologías de ${topSkill.skill} para deflexión a autoservicio o agente virtual. Potencial: ${formatCurrency(deflectionPotential)}/año.`,
severity: 'info',
hasRealData: true
});
}
break;
case 'complexity_predictability':
// KPI principal: CV AHT (predictability metric per industry standards)
// Siempre mostrar análisis de CV AHT ya que es el KPI de esta dimensión
const cvBenchmark = 75; // Best practice: CV AHT < 75%
if (avgCVAHT > cvBenchmark) {
const staffingCost = Math.round(economicModel.currentAnnualCost * 0.03);
const staffingHours = Math.round(staffingCost / HOURLY_COST);
const standardizationSavings = Math.round(staffingCost * 0.50);
// Determinar severidad basada en CV AHT
const cvSeverity = avgCVAHT > 125 ? 'critical' : avgCVAHT > 100 ? 'warning' : 'warning';
// Causa dinámica basada en nivel de variabilidad
const cvCause = avgCVAHT > 125
? 'Dispersión extrema en tiempos de atención impide planificación efectiva de recursos. Probable falta de scripts o procesos estandarizados.'
: 'Variabilidad moderada en tiempos indica oportunidad de estandarización para mejorar planificación WFM.';
analyses.push({
finding: `CV AHT elevado: ${avgCVAHT.toFixed(0)}% (benchmark: <${cvBenchmark}%)`,
probableCause: cvCause,
economicImpact: staffingCost,
impactFormula: `~3% del coste operativo por ineficiencia de staffing`,
timeSavings: `~${staffingHours.toLocaleString()} horas/año en sobre/subdimensionamiento`,
recommendation: `Implementar scripts guiados por IA que estandaricen la atención. Reducción esperada: -50% variabilidad. Ahorro: ${formatCurrency(standardizationSavings)}/año.`,
severity: cvSeverity,
hasRealData: true
});
} else {
// CV AHT dentro de benchmark - mostrar estado positivo
analyses.push({
finding: `CV AHT dentro de benchmark: ${avgCVAHT.toFixed(0)}% (benchmark: <${cvBenchmark}%)`,
probableCause: 'Tiempos de atención consistentes. Buena estandarización de procesos.',
economicImpact: 0,
impactFormula: 'Sin impacto por variabilidad',
timeSavings: 'Planificación WFM eficiente',
recommendation: 'Mantener nivel actual. Analizar casos atípicos para identificar oportunidades de mejora continua.',
severity: 'info',
hasRealData: true
});
}
// Análisis secundario: Hold Time (proxy de complejidad)
if (avgHoldTime > 45) {
const excessHold = avgHoldTime - 30;
const annualVolumeHold = Math.round(totalVolume * annualizationFactor);
const excessHoldHours = Math.round((excessHold / 3600) * annualVolumeHold);
const holdCost = Math.round(excessHoldHours * HOURLY_COST);
const searchCopilotSavings = Math.round(holdCost * 0.60);
analyses.push({
finding: `Hold time elevado: ${avgHoldTime.toFixed(0)}s promedio (benchmark: <30s)`,
probableCause: 'Agentes ponen cliente en espera para buscar información. Sistemas no presentan datos de forma contextual.',
economicImpact: holdCost,
impactFormula: `Exceso ${Math.round(excessHold)}s × ${totalVolume.toLocaleString()} int × anualización ×${HOURLY_COST}/h`,
timeSavings: `${excessHoldHours.toLocaleString()} horas/año de cliente en espera`,
recommendation: `Desplegar vista 360° con contexto automático: historial, productos y acciones sugeridas visibles al contestar. Reducción esperada: -60% hold time. Ahorro: ${formatCurrency(searchCopilotSavings)}/año.`,
severity: avgHoldTime > 60 ? 'critical' : 'warning',
hasRealData: true
});
}
break;
case 'customer_satisfaction':
// Solo generar análisis si hay datos de CSAT reales
if (avgCSAT > 0) {
if (avgCSAT < 70) {
const annualVolumeCsat = Math.round(totalVolume * annualizationFactor);
const customersAtRisk = Math.round(annualVolumeCsat * 0.02);
const churnRisk = Math.round(customersAtRisk * 50);
analyses.push({
finding: `CSAT por debajo del objetivo: ${avgCSAT.toFixed(0)}% (benchmark: >80%)`,
probableCause: 'Clientes insatisfechos por esperas, falta de resolución o experiencia de atención deficiente.',
economicImpact: churnRisk,
impactFormula: `${totalVolume.toLocaleString()} clientes × anualización × 2% riesgo churn × €50 valor`,
timeSavings: `${customersAtRisk.toLocaleString()} clientes/año en riesgo de fuga`,
recommendation: `Implementar programa VoC: encuestas post-contacto + análisis de causas raíz + acción correctiva en 48h. Objetivo: CSAT >80%.`,
severity: avgCSAT < 50 ? 'critical' : 'warning',
hasRealData: true
});
}
}
break;
case 'economy_cpi':
case 'economy_costs': // También manejar el ID del backend
// Análisis de CPI
if (CPI > 3.5) {
const excessCPI = CPI - CPI_TCO;
const annualVolumeCpi = Math.round(totalVolume * annualizationFactor);
const potentialSavings = Math.round(annualVolumeCpi * excessCPI);
const excessHours = Math.round(potentialSavings / HOURLY_COST);
analyses.push({
finding: `CPI por encima del benchmark: €${CPI.toFixed(2)} (objetivo: €${CPI_TCO})`,
probableCause: 'Coste por interacción elevado por AHT alto, baja ocupación o estructura de costes ineficiente.',
economicImpact: potentialSavings,
impactFormula: `${totalVolume.toLocaleString()} int × anualización ×${excessCPI.toFixed(2)} exceso CPI`,
timeSavings: `${excessCPI.toFixed(2)} exceso/int × ${annualVolumeCpi.toLocaleString()} int = ${excessHours.toLocaleString()}h equivalentes`,
recommendation: `Optimizar mix de canales + reducir AHT con automatización + revisar modelo de staffing. Objetivo: CPI <€${CPI_TCO}.`,
severity: CPI > 5 ? 'critical' : 'warning',
hasRealData: true
});
}
break;
}
// v3.11: NO generar fallback con impacto económico falso
// Si no hay análisis específico, simplemente retornar array vacío
// La UI mostrará "Sin hallazgos críticos" en lugar de un impacto inventado
return analyses;
}
// Formateador de moneda (usa la función importada de designSystem)
// v3.15: Dimension Card Component - con diseño McKinsey
function DimensionCard({
dimension,
findings,
recommendations,
causalAnalyses,
delay = 0
}: {
dimension: DimensionAnalysis;
findings: Finding[];
recommendations: Recommendation[];
causalAnalyses: CausalAnalysisExtended[];
delay?: number;
}) {
const Icon = dimension.icon;
const getScoreVariant = (score: number): 'success' | 'warning' | 'critical' | 'default' => {
if (score < 0) return 'default'; // N/A
if (score >= 70) return 'success';
if (score >= 40) return 'warning';
return 'critical';
};
const getScoreLabel = (score: number): string => {
if (score < 0) return 'N/A';
if (score >= 80) return 'Óptimo';
if (score >= 60) return 'Aceptable';
if (score >= 40) return 'Mejorable';
return 'Crítico';
};
const getSeverityConfig = (severity: string) => {
if (severity === 'critical') return STATUS_CLASSES.critical;
if (severity === 'warning') return STATUS_CLASSES.warning;
return STATUS_CLASSES.info;
};
// Get KPI trend icon
const TrendIcon = dimension.kpi.changeType === 'positive' ? TrendingUp :
dimension.kpi.changeType === 'negative' ? TrendingDown : Minus;
const trendColor = dimension.kpi.changeType === 'positive' ? 'text-emerald-600' :
dimension.kpi.changeType === 'negative' ? 'text-red-600' : 'text-gray-500';
// Calcular impacto total de esta dimensión
const totalImpact = causalAnalyses.reduce((sum, a) => sum + a.economicImpact, 0);
const scoreVariant = getScoreVariant(dimension.score);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay }}
className="bg-white rounded-lg border border-gray-200 overflow-hidden"
>
{/* Header */}
<div className="p-4 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-50">
<Icon className="w-5 h-5 text-blue-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900">{dimension.title}</h3>
<p className="text-xs text-gray-500 mt-0.5 max-w-xs">{dimension.summary}</p>
</div>
</div>
<div className="text-right">
<Badge
label={dimension.score >= 0 ? `${dimension.score} ${getScoreLabel(dimension.score)}` : '— N/A'}
variant={scoreVariant}
size="md"
/>
{totalImpact > 0 && (
<p className="text-xs text-red-600 font-medium mt-1">
Impacto: {formatCurrency(totalImpact)}
</p>
)}
</div>
</div>
</div>
{/* KPI Highlight */}
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">{dimension.kpi.label}</span>
<div className="flex items-center gap-2">
<span className="font-bold text-gray-900">{dimension.kpi.value}</span>
{dimension.kpi.change && (
<div className={cn('flex items-center gap-1 text-xs', trendColor)}>
<TrendIcon className="w-3 h-3" />
<span>{dimension.kpi.change}</span>
</div>
)}
</div>
</div>
{dimension.percentile && (
<div className="mt-2">
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>Percentil</span>
<span>P{dimension.percentile}</span>
</div>
<div className="h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-600 rounded-full"
style={{ width: `${dimension.percentile}%` }}
/>
</div>
</div>
)}
</div>
{/* Si no hay datos para esta dimensión (score < 0 = N/A) */}
{dimension.score < 0 && (
<div className="p-4">
<div className="p-3 bg-gray-50 rounded-lg border border-gray-200">
<p className="text-sm text-gray-500 italic flex items-center gap-2">
<Minus className="w-4 h-4" />
Sin datos disponibles para esta dimensión.
</p>
</div>
</div>
)}
{/* Hallazgo Clave - Solo si hay datos */}
{dimension.score >= 0 && causalAnalyses.length > 0 && (
<div className="p-4 space-y-3">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Hallazgo Clave
</h4>
{causalAnalyses.map((analysis, idx) => {
const config = getSeverityConfig(analysis.severity);
return (
<div key={idx} className={cn('p-3 rounded-lg border', config.bg, config.border)}>
{/* Hallazgo */}
<div className="flex items-start gap-2 mb-2">
<AlertTriangle className={cn('w-4 h-4 mt-0.5 flex-shrink-0', config.text)} />
<div>
<p className={cn('text-sm font-medium', config.text)}>{analysis.finding}</p>
</div>
</div>
{/* Causa probable */}
<div className="ml-6 mb-2">
<p className="text-xs text-gray-500 font-medium mb-0.5">Causa probable:</p>
<p className="text-xs text-gray-700">{analysis.probableCause}</p>
</div>
{/* Impacto económico */}
<div
className="ml-6 mb-2 flex items-center gap-2 cursor-help"
title={analysis.impactFormula || 'Impacto estimado basado en métricas operativas'}
>
<DollarSign className="w-3 h-3 text-red-500" />
<span className="text-xs font-bold text-red-600">
{formatCurrency(analysis.economicImpact)}
</span>
<span className="text-xs text-gray-500">impacto anual (coste del problema)</span>
<span className="text-xs text-gray-400">i</span>
</div>
{/* Ahorro de tiempo - da credibilidad al cálculo económico */}
{analysis.timeSavings && (
<div className="ml-6 mb-2 flex items-center gap-2">
<Clock className="w-3 h-3 text-blue-500" />
<span className="text-xs text-blue-700">{analysis.timeSavings}</span>
</div>
)}
{/* Recomendación inline */}
<div className="ml-6 p-2 bg-white rounded border border-gray-200">
<div className="flex items-start gap-2">
<Lightbulb className="w-3 h-3 text-blue-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-gray-600">{analysis.recommendation}</p>
</div>
</div>
</div>
);
})}
</div>
)}
{/* Fallback: Hallazgos originales si no hay hallazgo clave - Solo si hay datos */}
{dimension.score >= 0 && causalAnalyses.length === 0 && findings.length > 0 && (
<div className="p-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
Hallazgos Clave
</h4>
<ul className="space-y-2">
{findings.slice(0, 3).map((finding, idx) => (
<li key={idx} className="flex items-start gap-2 text-sm">
<ChevronRight className={cn('w-4 h-4 mt-0.5 flex-shrink-0',
finding.type === 'critical' ? 'text-red-500' :
finding.type === 'warning' ? 'text-amber-500' :
'text-blue-600'
)} />
<span className="text-gray-700">{finding.text}</span>
</li>
))}
</ul>
</div>
)}
{/* Si no hay análisis ni hallazgos pero sí hay datos */}
{dimension.score >= 0 && causalAnalyses.length === 0 && findings.length === 0 && (
<div className="p-4">
<div className={cn('p-3 rounded-lg border', STATUS_CLASSES.success.bg, STATUS_CLASSES.success.border)}>
<p className={cn('text-sm flex items-center gap-2', STATUS_CLASSES.success.text)}>
<ChevronRight className="w-4 h-4" />
Métricas dentro de rangos aceptables. Sin hallazgos críticos.
</p>
</div>
</div>
)}
{/* Recommendations Preview - Solo si no hay hallazgo clave y hay datos */}
{dimension.score >= 0 && causalAnalyses.length === 0 && recommendations.length > 0 && (
<div className="px-4 pb-4">
<div className="p-3 bg-blue-50 rounded-lg border border-blue-100">
<div className="flex items-start gap-2">
<span className="text-xs font-semibold text-blue-600">Recomendación:</span>
<span className="text-xs text-gray-600">{recommendations[0].text}</span>
</div>
</div>
</div>
)}
</motion.div>
);
}
// ========== v3.16: COMPONENTE PRINCIPAL ==========
export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
// DEBUG: Verificar CPI en dimensión vs heatmapData
const economyDim = data.dimensions.find(d =>
d.id === 'economy_costs' || d.name === 'economy_costs' ||
d.id === 'economy_cpi' || d.name === 'economy_cpi'
);
const heatmapData = data.heatmapData;
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
const calculatedCPI = hasCpiField
? (totalCostVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
: 0)
: (totalCostVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0) / totalCostVolume
: 0);
console.log('🔍 DimensionAnalysisTab DEBUG:');
console.log(' - economyDim found:', !!economyDim, economyDim?.id || economyDim?.name);
console.log(' - economyDim.kpi.value:', economyDim?.kpi?.value);
console.log(' - calculatedCPI from heatmapData:', `${calculatedCPI.toFixed(2)}`);
console.log(' - hasCpiField:', hasCpiField);
console.log(' - MATCH:', economyDim?.kpi?.value === `${calculatedCPI.toFixed(2)}`);
// Filter out agentic_readiness (has its own tab)
const coreDimensions = data.dimensions.filter(d => d.name !== 'agentic_readiness');
// Group findings and recommendations by dimension
const getFindingsForDimension = (dimensionId: string) =>
data.findings.filter(f => f.dimensionId === dimensionId);
const getRecommendationsForDimension = (dimensionId: string) =>
data.recommendations.filter(r => r.dimensionId === dimensionId);
// Generar hallazgo clave para cada dimensión
const getCausalAnalysisForDimension = (dimension: DimensionAnalysis) =>
generateCausalAnalysis(dimension, data.heatmapData, data.economicModel, data.staticConfig, data.dateRange);
// Calcular impacto total de todas las dimensiones con datos
const impactoTotal = coreDimensions
.filter(d => d.score !== null && d.score !== undefined)
.reduce((total, dimension) => {
const analyses = getCausalAnalysisForDimension(dimension);
return total + analyses.reduce((sum, a) => sum + a.economicImpact, 0);
}, 0);
// v3.16: Contar dimensiones por estado para el header
const conDatos = coreDimensions.filter(d => d.score !== null && d.score !== undefined && d.score >= 0);
const sinDatos = coreDimensions.filter(d => d.score === null || d.score === undefined || d.score < 0);
return (
<div className="space-y-6">
{/* v3.16: Header simplificado - solo título y subtítulo */}
<div className="mb-2">
<h2 className="text-lg font-bold text-gray-900">Diagnóstico por Dimensión</h2>
<p className="text-sm text-gray-500">
{coreDimensions.length} dimensiones analizadas
{sinDatos.length > 0 && ` (${sinDatos.length} sin datos)`}
</p>
</div>
{/* v3.16: Grid simple con todas las dimensiones sin agrupación */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{coreDimensions.map((dimension, idx) => (
<DimensionCard
key={dimension.id}
dimension={dimension}
findings={getFindingsForDimension(dimension.id)}
recommendations={getRecommendationsForDimension(dimension.id)}
causalAnalyses={getCausalAnalysisForDimension(dimension)}
delay={idx * 0.05}
/>
))}
</div>
</div>
);
}
export default DimensionAnalysisTab;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,595 @@
/**
* v3.15: Componentes UI McKinsey
*
* Componentes base reutilizables que implementan el sistema de diseño.
* Usar estos componentes en lugar de crear estilos ad-hoc.
*/
import React from 'react';
import {
TrendingUp,
TrendingDown,
Minus,
ChevronRight,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import {
cn,
CARD_BASE,
SECTION_HEADER,
BADGE_BASE,
BADGE_SIZES,
METRIC_BASE,
STATUS_CLASSES,
TIER_CLASSES,
SPACING,
} from '../../config/designSystem';
// ============================================
// CARD
// ============================================
interface CardProps {
children: React.ReactNode;
variant?: 'default' | 'highlight' | 'muted';
padding?: 'sm' | 'md' | 'lg' | 'none';
className?: string;
}
export function Card({
children,
variant = 'default',
padding = 'md',
className,
}: CardProps) {
return (
<div
className={cn(
CARD_BASE,
variant === 'highlight' && 'bg-gray-50 border-gray-300',
variant === 'muted' && 'bg-gray-50 border-gray-100',
padding !== 'none' && SPACING.card[padding],
className
)}
>
{children}
</div>
);
}
// Card con indicador de status (borde superior)
interface StatusCardProps extends CardProps {
status: 'critical' | 'warning' | 'success' | 'info' | 'neutral';
}
export function StatusCard({
status,
children,
className,
...props
}: StatusCardProps) {
const statusClasses = STATUS_CLASSES[status];
return (
<Card
className={cn(
'border-t-2',
statusClasses.borderTop,
className
)}
{...props}
>
{children}
</Card>
);
}
// ============================================
// SECTION HEADER
// ============================================
interface SectionHeaderProps {
title: string;
subtitle?: string;
badge?: BadgeProps;
action?: React.ReactNode;
level?: 2 | 3 | 4;
className?: string;
noBorder?: boolean;
}
export function SectionHeader({
title,
subtitle,
badge,
action,
level = 2,
className,
noBorder = false,
}: SectionHeaderProps) {
const Tag = `h${level}` as keyof JSX.IntrinsicElements;
const titleClass = level === 2
? SECTION_HEADER.title.h2
: level === 3
? SECTION_HEADER.title.h3
: SECTION_HEADER.title.h4;
return (
<div className={cn(
SECTION_HEADER.wrapper,
noBorder && 'border-b-0 pb-0 mb-2',
className
)}>
<div>
<div className="flex items-center gap-3">
<Tag className={titleClass}>{title}</Tag>
{badge && <Badge {...badge} />}
</div>
{subtitle && (
<p className={SECTION_HEADER.subtitle}>{subtitle}</p>
)}
</div>
{action && <div className="flex-shrink-0">{action}</div>}
</div>
);
}
// ============================================
// BADGE
// ============================================
interface BadgeProps {
label: string | number;
variant?: 'default' | 'success' | 'warning' | 'critical' | 'info';
size?: 'sm' | 'md';
className?: string;
}
export function Badge({
label,
variant = 'default',
size = 'sm',
className,
}: BadgeProps) {
const variantClasses = {
default: 'bg-gray-100 text-gray-700',
success: 'bg-emerald-50 text-emerald-700',
warning: 'bg-amber-50 text-amber-700',
critical: 'bg-red-50 text-red-700',
info: 'bg-blue-50 text-blue-700',
};
return (
<span
className={cn(
BADGE_BASE,
BADGE_SIZES[size],
variantClasses[variant],
className
)}
>
{label}
</span>
);
}
// Badge para Tiers
interface TierBadgeProps {
tier: 'AUTOMATE' | 'ASSIST' | 'AUGMENT' | 'HUMAN-ONLY';
size?: 'sm' | 'md';
className?: string;
}
export function TierBadge({ tier, size = 'sm', className }: TierBadgeProps) {
const tierClasses = TIER_CLASSES[tier];
return (
<span
className={cn(
BADGE_BASE,
BADGE_SIZES[size],
tierClasses.bg,
tierClasses.text,
className
)}
>
{tier}
</span>
);
}
// ============================================
// METRIC
// ============================================
interface MetricProps {
label: string;
value: string | number;
unit?: string;
status?: 'success' | 'warning' | 'critical';
comparison?: string;
trend?: 'up' | 'down' | 'neutral';
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
}
export function Metric({
label,
value,
unit,
status,
comparison,
trend,
size = 'md',
className,
}: MetricProps) {
const valueColorClass = !status
? 'text-gray-900'
: status === 'success'
? 'text-emerald-600'
: status === 'warning'
? 'text-amber-600'
: 'text-red-600';
return (
<div className={cn('flex flex-col', className)}>
<span className={METRIC_BASE.label}>{label}</span>
<div className="flex items-baseline gap-1 mt-1">
<span className={cn(METRIC_BASE.value[size], valueColorClass)}>
{value}
</span>
{unit && <span className={METRIC_BASE.unit}>{unit}</span>}
{trend && <TrendIndicator direction={trend} />}
</div>
{comparison && (
<span className={METRIC_BASE.comparison}>{comparison}</span>
)}
</div>
);
}
// Indicador de tendencia
function TrendIndicator({ direction }: { direction: 'up' | 'down' | 'neutral' }) {
if (direction === 'up') {
return <TrendingUp className="w-4 h-4 text-emerald-500" />;
}
if (direction === 'down') {
return <TrendingDown className="w-4 h-4 text-red-500" />;
}
return <Minus className="w-4 h-4 text-gray-400" />;
}
// ============================================
// KPI CARD (Metric in a card)
// ============================================
interface KPICardProps extends MetricProps {
icon?: React.ReactNode;
}
export function KPICard({ icon, ...metricProps }: KPICardProps) {
return (
<Card padding="md" className="flex items-start gap-3">
{icon && (
<div className="p-2 bg-gray-100 rounded-lg flex-shrink-0">
{icon}
</div>
)}
<Metric {...metricProps} />
</Card>
);
}
// ============================================
// STAT (inline stat for summaries)
// ============================================
interface StatProps {
value: string | number;
label: string;
status?: 'success' | 'warning' | 'critical';
className?: string;
}
export function Stat({ value, label, status, className }: StatProps) {
const statusClasses = STATUS_CLASSES[status || 'neutral'];
return (
<div className={cn(
'p-3 rounded-lg border',
status ? statusClasses.bg : 'bg-gray-50',
status ? statusClasses.border : 'border-gray-200',
className
)}>
<p className={cn(
'text-2xl font-bold',
status ? statusClasses.text : 'text-gray-700'
)}>
{value}
</p>
<p className="text-xs text-gray-500 font-medium">{label}</p>
</div>
);
}
// ============================================
// DIVIDER
// ============================================
export function Divider({ className }: { className?: string }) {
return <hr className={cn('border-gray-200 my-4', className)} />;
}
// ============================================
// COLLAPSIBLE SECTION
// ============================================
interface CollapsibleProps {
title: string;
subtitle?: string;
badge?: BadgeProps;
defaultOpen?: boolean;
children: React.ReactNode;
className?: string;
}
export function Collapsible({
title,
subtitle,
badge,
defaultOpen = false,
children,
className,
}: CollapsibleProps) {
const [isOpen, setIsOpen] = React.useState(defaultOpen);
return (
<div className={cn('border border-gray-200 rounded-lg overflow-hidden', className)}>
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full px-4 py-3 flex items-center justify-between bg-gray-50 hover:bg-gray-100 transition-colors"
>
<div className="flex items-center gap-3">
<span className="font-semibold text-gray-800">{title}</span>
{badge && <Badge {...badge} />}
</div>
<div className="flex items-center gap-2 text-gray-400">
{subtitle && <span className="text-xs">{subtitle}</span>}
{isOpen ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</div>
</button>
{isOpen && (
<div className="p-4 border-t border-gray-200 bg-white">
{children}
</div>
)}
</div>
);
}
// ============================================
// DISTRIBUTION BAR
// ============================================
interface DistributionBarProps {
segments: Array<{
value: number;
color: string;
label?: string;
}>;
total?: number;
height?: 'sm' | 'md' | 'lg';
showLabels?: boolean;
className?: string;
}
export function DistributionBar({
segments,
total,
height = 'md',
showLabels = false,
className,
}: DistributionBarProps) {
const computedTotal = total || segments.reduce((sum, s) => sum + s.value, 0);
const heightClass = height === 'sm' ? 'h-2' : height === 'md' ? 'h-3' : 'h-4';
return (
<div className={cn('w-full', className)}>
<div className={cn('flex rounded-full overflow-hidden bg-gray-100', heightClass)}>
{segments.map((segment, idx) => {
const pct = computedTotal > 0 ? (segment.value / computedTotal) * 100 : 0;
if (pct <= 0) return null;
return (
<div
key={idx}
className={cn('flex items-center justify-center transition-all', segment.color)}
style={{ width: `${pct}%` }}
title={segment.label || `${pct.toFixed(0)}%`}
>
{showLabels && pct >= 10 && (
<span className="text-[9px] text-white font-bold">
{pct.toFixed(0)}%
</span>
)}
</div>
);
})}
</div>
</div>
);
}
// ============================================
// TABLE COMPONENTS
// ============================================
export function Table({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className="overflow-x-auto">
<table className={cn('w-full text-sm text-left', className)}>
{children}
</table>
</div>
);
}
export function Thead({ children }: { children: React.ReactNode }) {
return (
<thead className="text-xs text-gray-500 uppercase tracking-wide bg-gray-50">
{children}
</thead>
);
}
export function Th({
children,
align = 'left',
className,
}: {
children: React.ReactNode;
align?: 'left' | 'right' | 'center';
className?: string;
}) {
return (
<th
className={cn(
'px-4 py-3 font-medium',
align === 'right' && 'text-right',
align === 'center' && 'text-center',
className
)}
>
{children}
</th>
);
}
export function Tbody({ children }: { children: React.ReactNode }) {
return <tbody className="divide-y divide-gray-100">{children}</tbody>;
}
export function Tr({
children,
highlighted,
className,
}: {
children: React.ReactNode;
highlighted?: boolean;
className?: string;
}) {
return (
<tr
className={cn(
'hover:bg-gray-50 transition-colors',
highlighted && 'bg-blue-50',
className
)}
>
{children}
</tr>
);
}
export function Td({
children,
align = 'left',
className,
}: {
children: React.ReactNode;
align?: 'left' | 'right' | 'center';
className?: string;
}) {
return (
<td
className={cn(
'px-4 py-3 text-gray-700',
align === 'right' && 'text-right',
align === 'center' && 'text-center',
className
)}
>
{children}
</td>
);
}
// ============================================
// EMPTY STATE
// ============================================
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
}
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
{icon && <div className="text-gray-300 mb-4">{icon}</div>}
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
{description && (
<p className="text-sm text-gray-500 mt-1 max-w-sm">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
);
}
// ============================================
// BUTTON
// ============================================
interface ButtonProps {
children: React.ReactNode;
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md';
onClick?: () => void;
disabled?: boolean;
className?: string;
}
export function Button({
children,
variant = 'primary',
size = 'md',
onClick,
disabled,
className,
}: ButtonProps) {
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors';
const variantClasses = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-300',
secondary: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 disabled:bg-gray-100',
ghost: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100',
};
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
};
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(baseClasses, variantClasses[variant], sizeClasses[size], className)}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,268 @@
/**
* v3.15: Sistema de Diseño McKinsey
*
* Principios:
* 1. Minimalismo funcional: Cada elemento debe tener un propósito
* 2. Jerarquía clara: El ojo sabe dónde ir primero
* 3. Datos como protagonistas: Los números destacan, no los adornos
* 4. Color con significado: Solo para indicar status, no para decorar
* 5. Espacio en blanco: Respira, no satura
* 6. Consistencia absoluta: Mismo patrón en todas partes
*/
// ============================================
// PALETA DE COLORES (restringida)
// ============================================
export const COLORS = {
// Colores base
text: {
primary: '#1a1a1a', // Títulos, valores importantes
secondary: '#4a4a4a', // Texto normal
muted: '#6b7280', // Labels, texto secundario
inverse: '#ffffff', // Texto sobre fondos oscuros
},
// Fondos
background: {
page: '#f9fafb', // Fondo de página
card: '#ffffff', // Fondo de cards
subtle: '#f3f4f6', // Fondos de secciones
hover: '#f9fafb', // Hover states
},
// Bordes
border: {
light: '#e5e7eb', // Bordes sutiles
medium: '#d1d5db', // Bordes más visibles
},
// Semánticos (ÚNICOS colores con significado)
status: {
critical: '#dc2626', // Rojo - Requiere acción
warning: '#f59e0b', // Ámbar - Atención
success: '#10b981', // Verde - Óptimo
info: '#3b82f6', // Azul - Informativo/Habilitador
neutral: '#6b7280', // Gris - Sin datos/NA
},
// Tiers de automatización
tier: {
automate: '#10b981', // Verde
assist: '#06b6d4', // Cyan
augment: '#f59e0b', // Ámbar
human: '#6b7280', // Gris
},
// Acento (usar con moderación)
accent: {
primary: '#2563eb', // Azul corporativo - CTAs, links
primaryHover: '#1d4ed8',
}
};
// Mapeo de colores para clases Tailwind
export const STATUS_CLASSES = {
critical: {
text: 'text-red-600',
bg: 'bg-red-50',
border: 'border-red-200',
borderTop: 'border-t-red-500',
},
warning: {
text: 'text-amber-600',
bg: 'bg-amber-50',
border: 'border-amber-200',
borderTop: 'border-t-amber-500',
},
success: {
text: 'text-emerald-600',
bg: 'bg-emerald-50',
border: 'border-emerald-200',
borderTop: 'border-t-emerald-500',
},
info: {
text: 'text-blue-600',
bg: 'bg-blue-50',
border: 'border-blue-200',
borderTop: 'border-t-blue-500',
},
neutral: {
text: 'text-gray-500',
bg: 'bg-gray-50',
border: 'border-gray-200',
borderTop: 'border-t-gray-400',
},
};
export const TIER_CLASSES = {
AUTOMATE: {
text: 'text-emerald-600',
bg: 'bg-emerald-50',
border: 'border-emerald-200',
fill: '#10b981',
},
ASSIST: {
text: 'text-cyan-600',
bg: 'bg-cyan-50',
border: 'border-cyan-200',
fill: '#06b6d4',
},
AUGMENT: {
text: 'text-amber-600',
bg: 'bg-amber-50',
border: 'border-amber-200',
fill: '#f59e0b',
},
'HUMAN-ONLY': {
text: 'text-gray-500',
bg: 'bg-gray-50',
border: 'border-gray-200',
fill: '#6b7280',
},
};
// ============================================
// TIPOGRAFÍA
// ============================================
export const TYPOGRAPHY = {
// Tamaños (escala restringida)
fontSize: {
xs: 'text-xs', // 12px - Footnotes, badges
sm: 'text-sm', // 14px - Labels, texto secundario
base: 'text-base', // 16px - Texto normal
lg: 'text-lg', // 18px - Subtítulos
xl: 'text-xl', // 20px - Títulos de sección
'2xl': 'text-2xl', // 24px - Títulos de página
'3xl': 'text-3xl', // 32px - Métricas grandes
'4xl': 'text-4xl', // 40px - KPIs hero
},
// Pesos
fontWeight: {
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold',
},
};
// ============================================
// ESPACIADO
// ============================================
export const SPACING = {
// Padding de cards
card: {
sm: 'p-4', // Cards compactas
md: 'p-5', // Cards normales (changed from p-6)
lg: 'p-6', // Cards destacadas
},
// Gaps entre secciones
section: {
sm: 'space-y-4', // Entre elementos dentro de sección
md: 'space-y-6', // Entre secciones
lg: 'space-y-8', // Entre bloques principales
},
// Grid gaps
grid: {
sm: 'gap-3',
md: 'gap-4',
lg: 'gap-6',
}
};
// ============================================
// COMPONENTES BASE (clases)
// ============================================
// Card base
export const CARD_BASE = 'bg-white rounded-lg border border-gray-200';
// Section header
export const SECTION_HEADER = {
wrapper: 'flex items-start justify-between pb-3 mb-4 border-b border-gray-200',
title: {
h2: 'text-lg font-semibold text-gray-900',
h3: 'text-base font-semibold text-gray-900',
h4: 'text-sm font-medium text-gray-800',
},
subtitle: 'text-sm text-gray-500 mt-0.5',
};
// Badge
export const BADGE_BASE = 'inline-flex items-center font-medium rounded-md';
export const BADGE_SIZES = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-1 text-sm',
};
// Metric
export const METRIC_BASE = {
label: 'text-xs font-medium text-gray-500 uppercase tracking-wide',
value: {
sm: 'text-lg font-semibold',
md: 'text-2xl font-semibold',
lg: 'text-3xl font-semibold',
xl: 'text-4xl font-bold',
},
unit: 'text-sm text-gray-500',
comparison: 'text-xs text-gray-400',
};
// Table
export const TABLE_CLASSES = {
wrapper: 'overflow-x-auto',
table: 'w-full text-sm text-left',
thead: 'text-xs text-gray-500 uppercase tracking-wide bg-gray-50',
th: 'px-4 py-3 font-medium',
tbody: 'divide-y divide-gray-100',
tr: 'hover:bg-gray-50 transition-colors',
td: 'px-4 py-3 text-gray-700',
};
// ============================================
// HELPERS
// ============================================
/**
* Obtiene las clases de status basado en score
*/
export function getStatusFromScore(score: number | null | undefined): keyof typeof STATUS_CLASSES {
if (score === null || score === undefined) return 'neutral';
if (score < 40) return 'critical';
if (score < 70) return 'warning';
return 'success';
}
/**
* Formatea moneda de forma consistente
*/
export function formatCurrency(value: number): string {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${Math.round(value / 1000)}K`;
return `${value.toLocaleString()}`;
}
/**
* Formatea número grande
*/
export function formatNumber(value: number): string {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${Math.round(value / 1000)}K`;
return value.toLocaleString();
}
/**
* Formatea porcentaje
*/
export function formatPercent(value: number, decimals = 0): string {
return `${value.toFixed(decimals)}%`;
}
/**
* Combina clases de forma segura (simple cn helper)
*/
export function cn(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(' ');
}

View File

@@ -6,17 +6,17 @@ export const TIERS: TiersData = {
name: 'Análisis GOLD', name: 'Análisis GOLD',
price: 4900, price: 4900,
color: 'bg-yellow-500', color: 'bg-yellow-500',
description: '6 dimensiones completas con algoritmo Agentic Readiness avanzado', description: '5 dimensiones completas con Agentic Readiness avanzado',
requirements: 'CCaaS moderno (Genesys, Five9, NICE, Talkdesk)', requirements: 'CCaaS moderno (Genesys, Five9, NICE, Talkdesk)',
timeline: '3-4 semanas', timeline: '3-4 semanas',
features: [ features: [
'6 dimensiones completas', '5 dimensiones: Volumetría, Eficiencia, Efectividad, Complejidad, Agentic Readiness',
'Algoritmo Agentic Readiness avanzado (6 sub-factores)', 'Agentic Readiness Score 0-10 por cola',
'Análisis de distribución horaria', 'Análisis de distribución horaria y semanal',
'Segmentación de clientes (opcional)', 'Métricas P10/P50/P90 por cola',
'Benchmark con percentiles múltiples (P25, P50, P75, P90)', 'FCR proxy y tasa de transferencias',
'Análisis de variabilidad y predictibilidad',
'Roadmap ejecutable con 3 waves', 'Roadmap ejecutable con 3 waves',
'Modelo económico con NPV y análisis de sensibilidad',
'Sesión de presentación incluida' 'Sesión de presentación incluida'
] ]
}, },
@@ -24,15 +24,14 @@ export const TIERS: TiersData = {
name: 'Análisis SILVER', name: 'Análisis SILVER',
price: 3500, price: 3500,
color: 'bg-gray-400', color: 'bg-gray-400',
description: '4 dimensiones core con Agentic Readiness simplificado', description: '5 dimensiones con Agentic Readiness simplificado',
requirements: 'Sistema ACD/PBX con reporting básico', requirements: 'Sistema ACD/PBX con reporting básico',
timeline: '2-3 semanas', timeline: '2-3 semanas',
features: [ features: [
'4 dimensiones (Volumetría, Rendimiento, Economía, Agentic Readiness)', '5 dimensiones completas',
'Algoritmo Agentic Readiness simplificado (3 sub-factores)', 'Agentic Readiness simplificado (4 sub-factores)',
'Roadmap de implementación', 'Roadmap de implementación',
'Opportunity Matrix', 'Opportunity Matrix',
'Economic Model básico',
'Dashboard interactivo' 'Dashboard interactivo'
] ]
}, },
@@ -40,15 +39,14 @@ export const TIERS: TiersData = {
name: 'Análisis EXPRESS', name: 'Análisis EXPRESS',
price: 1950, price: 1950,
color: 'bg-orange-600', color: 'bg-orange-600',
description: '3 dimensiones fundamentales sin Agentic Readiness', description: '4 dimensiones fundamentales sin Agentic Readiness detallado',
requirements: 'Exportación básica de reportes', requirements: 'Exportación básica de reportes',
timeline: '1-2 semanas', timeline: '1-2 semanas',
features: [ features: [
'3 dimensiones core (Volumetría, Rendimiento, Economía)', '4 dimensiones core (Volumetría, Eficiencia, Efectividad, Complejidad)',
'Agentic Readiness básico',
'Roadmap cualitativo', 'Roadmap cualitativo',
'Análisis básico', 'Recomendaciones estratégicas'
'Recomendaciones estratégicas',
'Reporte ejecutivo'
] ]
} }
}; };
@@ -136,14 +134,13 @@ export const DATA_REQUIREMENTS: DataRequirementsData = {
} }
}; };
// v2.0: Dimensiones actualizadas (6 en lugar de 8) // v3.0: 5 dimensiones viables
export const DIMENSION_NAMES = { export const DIMENSION_NAMES = {
volumetry_distribution: 'Volumetría y Distribución Horaria', volumetry_distribution: 'Volumetría & Distribución',
performance: 'Rendimiento', operational_efficiency: 'Eficiencia Operativa',
satisfaction: 'Satisfacción', effectiveness_resolution: 'Efectividad & Resolución',
economy: 'Economía', complexity_predictability: 'Complejidad & Predictibilidad',
efficiency: 'Eficiencia', // Fusiona Eficiencia + Efectividad agentic_readiness: 'Agentic Readiness'
benchmark: 'Benchmark'
}; };
// v2.0: Ponderaciones para Agentic Readiness Score // v2.0: Ponderaciones para Agentic Readiness Score

View File

@@ -60,7 +60,67 @@ export interface RawInteraction {
wrap_up_time: number; // Tiempo ACW post-llamada (segundos) wrap_up_time: number; // Tiempo ACW post-llamada (segundos)
agent_id: string; // ID agente (anónimo/hash) agent_id: string; // ID agente (anónimo/hash)
transfer_flag: boolean; // Indicador de transferencia transfer_flag: boolean; // Indicador de transferencia
repeat_call_7d?: boolean; // True si el cliente llamó en los últimos 7 días (para FCR)
caller_id?: string; // ID cliente (opcional, hash/anónimo) caller_id?: string; // ID cliente (opcional, hash/anónimo)
disconnection_type?: string; // Tipo de desconexión (Externo/Interno/etc.)
total_conversation?: number; // Conversación total en segundos (null/0 = abandono)
is_abandoned?: boolean; // Flag directo de abandono del CSV
record_status?: 'valid' | 'noise' | 'zombie' | 'abandon'; // Estado del registro para filtrado
fcr_real_flag?: boolean; // FCR pre-calculado en el CSV (TRUE = resuelto en primer contacto)
// v3.0: Campos para drill-down (jerarquía de 2 niveles)
original_queue_id?: string; // Nombre real de la cola en centralita (nivel operativo)
linea_negocio?: string; // Línea de negocio (business_unit) - 9 categorías C-Level
// queue_skill ya existe arriba como nivel estratégico
}
// Tipo para filtrado por record_status
export type RecordStatus = 'valid' | 'noise' | 'zombie' | 'abandon';
// v3.4: Tier de clasificación para roadmap
export type AgenticTier = 'AUTOMATE' | 'ASSIST' | 'AUGMENT' | 'HUMAN-ONLY';
// v3.4: Desglose del score por factores
export interface AgenticScoreBreakdown {
predictibilidad: number; // 30% - basado en CV AHT
resolutividad: number; // 25% - FCR (60%) + Transfer (40%)
volumen: number; // 25% - basado en volumen mensual
calidadDatos: number; // 10% - % registros válidos
simplicidad: number; // 10% - basado en AHT
}
// v3.4: Métricas por cola individual (original_queue_id - nivel operativo)
export interface OriginalQueueMetrics {
original_queue_id: string; // Nombre real de la cola en centralita
volume: number; // Total de interacciones
volumeValid: number; // Sin NOISE/ZOMBIE (para cálculo CV)
aht_mean: number; // AHT promedio (segundos)
cv_aht: number; // CV AHT calculado solo sobre VALID (%)
transfer_rate: number; // Tasa de transferencia (%)
fcr_rate: number; // FCR Real (%) - usa fcr_real_flag, incluye filtro recontacto 7d
fcr_tecnico: number; // FCR Técnico (%) = 100 - transfer_rate, comparable con benchmarks
agenticScore: number; // Score de automatización (0-10)
scoreBreakdown?: AgenticScoreBreakdown; // v3.4: Desglose por factores
tier: AgenticTier; // v3.4: Clasificación para roadmap
tierMotivo?: string; // v3.4: Motivo de la clasificación
isPriorityCandidate: boolean; // Tier 1 (AUTOMATE)
annualCost?: number; // Coste anual estimado
}
// v3.1: Tipo para drill-down - Nivel 1: queue_skill (estratégico)
export interface DrilldownDataPoint {
skill: string; // queue_skill (categoría estratégica)
originalQueues: OriginalQueueMetrics[]; // Colas reales de centralita (nivel 2)
// Métricas agregadas del grupo
volume: number; // Total de interacciones del grupo
volumeValid: number; // Sin NOISE/ZOMBIE
aht_mean: number; // AHT promedio ponderado (segundos)
cv_aht: number; // CV AHT promedio ponderado (%)
transfer_rate: number; // Tasa de transferencia ponderada (%)
fcr_rate: number; // FCR Real ponderado (%) - usa fcr_real_flag
fcr_tecnico: number; // FCR Técnico ponderado (%) = 100 - transfer_rate
agenticScore: number; // Score de automatización promedio (0-10)
isPriorityCandidate: boolean; // Al menos una cola con CV < 75%
annualCost?: number; // Coste anual total del grupo
} }
// Métricas calculadas por skill // Métricas calculadas por skill
@@ -68,27 +128,30 @@ export interface SkillMetrics {
skill: string; skill: string;
volume: number; // Total de interacciones volume: number; // Total de interacciones
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
avg_wrap_up: number; // Promedio wrap_up_time avg_wrap_up: number; // Promedio wrap_up_time
transfer_rate: number; // % con transfer_flag = true transfer_rate: number; // % con transfer_flag = true
abandonment_rate: number; // % abandonos (desconexión externa + sin conversación)
// Métricas de variabilidad // Métricas de variabilidad
cv_aht: number; // Coeficiente de variación AHT (%) cv_aht: number; // Coeficiente de variación AHT (%)
cv_talk_time: number; // CV de duration_talk (proxy de variabilidad input) cv_talk_time: number; // CV de duration_talk (proxy de variabilidad input)
cv_hold_time: number; // CV de hold_time cv_hold_time: number; // CV de hold_time
// Distribución temporal // Distribución temporal
hourly_distribution: number[]; // 24 valores (0-23h) hourly_distribution: number[]; // 24 valores (0-23h)
off_hours_pct: number; // % llamadas fuera de horario (19:00-08:00) off_hours_pct: number; // % llamadas fuera de horario (19:00-08:00)
// Coste // Coste
annual_cost: number; // Volumen × AHT × cost_per_hour × 12 annual_cost: number; // Volumen × AHT × cost_per_hour × 12
// Outliers y complejidad // Outliers y complejidad
outlier_rate: number; // % casos con AHT > P90 outlier_rate: number; // % casos con AHT > P90
} }
@@ -102,14 +165,15 @@ export interface Kpi {
changeType?: 'positive' | 'negative' | 'neutral'; changeType?: 'positive' | 'negative' | 'neutral';
} }
// v2.0: Dimensiones reducidas de 8 a 6 // v4.0: 7 dimensiones viables
export type DimensionName = export type DimensionName =
| 'volumetry_distribution' // Volumetría y Distribución Horaria (fusión + ampliación) | 'volumetry_distribution' // Volumetría & Distribución
| 'performance' // Rendimiento | 'operational_efficiency' // Eficiencia Operativa
| 'satisfaction' // Satisfacción | 'effectiveness_resolution' // Efectividad & Resolución
| 'economy' // Economía | 'complexity_predictability' // Complejidad & Predictibilidad
| 'efficiency' // Eficiencia (fusiona efficiency + effectiveness) | 'customer_satisfaction' // Satisfacción del Cliente (CSAT)
| 'benchmark'; // Benchmark | 'economy_cpi' // Economía Operacional (CPI)
| 'agentic_readiness'; // Agentic Readiness
export interface SubFactor { export interface SubFactor {
name: string; name: string;
@@ -145,15 +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
}; };
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: {
@@ -186,11 +256,14 @@ export interface Opportunity {
customer_segment?: CustomerSegment; // v2.0: Nuevo campo opcional customer_segment?: CustomerSegment; // v2.0: Nuevo campo opcional
} }
export enum RoadmapPhase { // Usar objeto const en lugar de enum para evitar problemas de tree-shaking con Vite
Automate = 'Automate', export const RoadmapPhase = {
Assist = 'Assist', Automate: 'Automate',
Augment = 'Augment' Assist: 'Assist',
} Augment: 'Augment'
} as const;
export type RoadmapPhase = typeof RoadmapPhase[keyof typeof RoadmapPhase];
export interface RoadmapInitiative { export interface RoadmapInitiative {
id: string; id: string;
@@ -201,6 +274,15 @@ export interface RoadmapInitiative {
resources: string[]; resources: string[];
dimensionId: string; dimensionId: string;
risk?: 'high' | 'medium' | 'low'; // v2.0: Nuevo campo risk?: 'high' | 'medium' | 'low'; // v2.0: Nuevo campo
// v2.1: Campos para trazabilidad
skillsImpacted?: string[]; // Skills que impacta
savingsDetail?: string; // Detalle del cálculo de ahorro
estimatedSavings?: number; // Ahorro estimado €
resourceHours?: number; // Horas estimadas de recursos
// v3.0: Campos mejorados conectados con skills reales
volumeImpacted?: number; // Volumen de interacciones impactadas
kpiObjective?: string; // Objetivo KPI específico
rationale?: string; // Justificación de la iniciativa
} }
export interface Finding { export interface Finding {
@@ -271,4 +353,6 @@ export interface AnalysisData {
agenticReadiness?: AgenticReadinessResult; // v2.0: Nuevo campo agenticReadiness?: AgenticReadinessResult; // v2.0: Nuevo campo
staticConfig?: StaticConfig; // v2.0: Configuración estática usada staticConfig?: StaticConfig; // v2.0: Configuración estática usada
source?: AnalysisSource; source?: AnalysisSource;
dateRange?: { min: string; max: string }; // v2.1: Periodo analizado
drilldownData?: DrilldownDataPoint[]; // v3.0: Drill-down Cola + Tipificación
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

241
frontend/utils/dataCache.ts Normal file
View File

@@ -0,0 +1,241 @@
/**
* dataCache.ts - Sistema de caché para datos de análisis
*
* Usa IndexedDB para persistir los datos parseados entre rebuilds.
* El CSV de 500MB parseado a JSON es mucho más pequeño (~10-50MB).
*/
import { RawInteraction, AnalysisData } from '../types';
const DB_NAME = 'BeyondDiagnosisCache';
const DB_VERSION = 1;
const STORE_RAW = 'rawInteractions';
const STORE_ANALYSIS = 'analysisData';
const STORE_META = 'metadata';
interface CacheMetadata {
id: string;
fileName: string;
fileSize: number;
recordCount: number;
cachedAt: string;
costPerHour: number;
}
// Abrir conexión a IndexedDB
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Store para interacciones raw
if (!db.objectStoreNames.contains(STORE_RAW)) {
db.createObjectStore(STORE_RAW, { keyPath: 'id' });
}
// Store para datos de análisis
if (!db.objectStoreNames.contains(STORE_ANALYSIS)) {
db.createObjectStore(STORE_ANALYSIS, { keyPath: 'id' });
}
// Store para metadata
if (!db.objectStoreNames.contains(STORE_META)) {
db.createObjectStore(STORE_META, { keyPath: 'id' });
}
};
});
}
/**
* Guardar interacciones parseadas en caché
*/
export async function cacheRawInteractions(
interactions: RawInteraction[],
fileName: string,
fileSize: number,
costPerHour: number
): Promise<void> {
try {
// Validar que es un array antes de cachear
if (!Array.isArray(interactions)) {
console.error('[Cache] No se puede cachear: interactions no es un array');
return;
}
if (interactions.length === 0) {
console.warn('[Cache] No se cachea: array vacío');
return;
}
const db = await openDB();
// Guardar metadata
const metadata: CacheMetadata = {
id: 'current',
fileName,
fileSize,
recordCount: interactions.length,
cachedAt: new Date().toISOString(),
costPerHour
};
const metaTx = db.transaction(STORE_META, 'readwrite');
metaTx.objectStore(STORE_META).put(metadata);
// Guardar interacciones (en chunks para archivos grandes)
const rawTx = db.transaction(STORE_RAW, 'readwrite');
const store = rawTx.objectStore(STORE_RAW);
// Limpiar datos anteriores
store.clear();
// Guardar como un solo objeto (más eficiente para lectura)
// Aseguramos que guardamos el array directamente
const dataToStore = { id: 'interactions', data: [...interactions] };
store.put(dataToStore);
await new Promise((resolve, reject) => {
rawTx.oncomplete = resolve;
rawTx.onerror = () => reject(rawTx.error);
});
console.log(`[Cache] Guardadas ${interactions.length} interacciones en caché (verificado: Array)`);
} catch (error) {
console.error('[Cache] Error guardando en caché:', error);
}
}
/**
* Guardar resultado de análisis en caché
*/
export async function cacheAnalysisData(data: AnalysisData): Promise<void> {
try {
const db = await openDB();
const tx = db.transaction(STORE_ANALYSIS, 'readwrite');
tx.objectStore(STORE_ANALYSIS).put({ id: 'analysis', data });
await new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
console.log('[Cache] Análisis guardado en caché');
} catch (error) {
console.error('[Cache] Error guardando análisis:', error);
}
}
/**
* Obtener metadata de caché (para mostrar info al usuario)
*/
export async function getCacheMetadata(): Promise<CacheMetadata | null> {
try {
const db = await openDB();
const tx = db.transaction(STORE_META, 'readonly');
const request = tx.objectStore(STORE_META).get('current');
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
} catch (error) {
console.error('[Cache] Error leyendo metadata:', error);
return null;
}
}
/**
* Obtener interacciones cacheadas
*/
export async function getCachedInteractions(): Promise<RawInteraction[] | null> {
try {
const db = await openDB();
const tx = db.transaction(STORE_RAW, 'readonly');
const request = tx.objectStore(STORE_RAW).get('interactions');
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const result = request.result;
const data = result?.data;
// Validar que es un array
if (!data) {
console.log('[Cache] No hay datos en caché');
resolve(null);
return;
}
if (!Array.isArray(data)) {
console.error('[Cache] Datos en caché no son un array:', typeof data);
resolve(null);
return;
}
console.log(`[Cache] Recuperadas ${data.length} interacciones`);
resolve(data);
};
request.onerror = () => reject(request.error);
});
} catch (error) {
console.error('[Cache] Error leyendo interacciones:', error);
return null;
}
}
/**
* Obtener análisis cacheado
*/
export async function getCachedAnalysis(): Promise<AnalysisData | null> {
try {
const db = await openDB();
const tx = db.transaction(STORE_ANALYSIS, 'readonly');
const request = tx.objectStore(STORE_ANALYSIS).get('analysis');
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const result = request.result;
resolve(result?.data || null);
};
request.onerror = () => reject(request.error);
});
} catch (error) {
console.error('[Cache] Error leyendo análisis:', error);
return null;
}
}
/**
* Limpiar toda la caché
*/
export async function clearCache(): Promise<void> {
try {
const db = await openDB();
const tx = db.transaction([STORE_RAW, STORE_ANALYSIS, STORE_META], 'readwrite');
tx.objectStore(STORE_RAW).clear();
tx.objectStore(STORE_ANALYSIS).clear();
tx.objectStore(STORE_META).clear();
await new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
console.log('[Cache] Caché limpiada');
} catch (error) {
console.error('[Cache] Error limpiando caché:', error);
}
}
/**
* Verificar si hay datos en caché
*/
export async function hasCachedData(): Promise<boolean> {
const metadata = await getCacheMetadata();
return metadata !== null;
}

View File

@@ -5,134 +5,320 @@
import { RawInteraction } from '../types'; import { RawInteraction } from '../types';
/**
* Helper: Parsear valor booleano de CSV (TRUE/FALSE, true/false, 1/0, yes/no, etc.)
*/
function parseBoolean(value: any): boolean {
if (value === undefined || value === null || value === '') {
return false;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
const strVal = String(value).toLowerCase().trim();
return strVal === 'true' || strVal === '1' || strVal === 'yes' || strVal === 'si' || strVal === 'sí' || strVal === 'y' || strVal === 's';
}
/**
* Helper: Obtener valor de columna buscando múltiples variaciones del nombre
*/
function getColumnValue(row: any, ...columnNames: string[]): string {
for (const name of columnNames) {
if (row[name] !== undefined && row[name] !== null && row[name] !== '') {
return String(row[name]);
}
}
return '';
}
/** /**
* Parsear archivo CSV a array de objetos * Parsear archivo CSV a array de objetos
*/ */
export async function parseCSV(file: File): Promise<RawInteraction[]> { export async function parseCSV(file: File): Promise<RawInteraction[]> {
const text = await file.text(); const text = await file.text();
const lines = text.split('\n').filter(line => line.trim()); const lines = text.split('\n').filter(line => line.trim());
if (lines.length < 2) { if (lines.length < 2) {
throw new Error('El archivo CSV está vacío o no tiene datos'); throw new Error('El archivo CSV está vacío o no tiene datos');
} }
// Parsear headers // Parsear headers
const headers = lines[0].split(',').map(h => h.trim()); const headers = lines[0].split(',').map(h => h.trim());
console.log('📋 Todos los headers del CSV:', headers);
// Validar headers requeridos
const requiredFields = [ // Verificar campos clave
'interaction_id', const keyFields = ['is_abandoned', 'fcr_real_flag', 'repeat_call_7d', 'transfer_flag', 'record_status'];
'datetime_start', const foundKeyFields = keyFields.filter(f => headers.includes(f));
'queue_skill', const missingKeyFields = keyFields.filter(f => !headers.includes(f));
'channel', console.log('✅ Campos clave encontrados:', foundKeyFields);
'duration_talk', console.log('⚠️ Campos clave NO encontrados:', missingKeyFields.length > 0 ? missingKeyFields : 'TODOS PRESENTES');
'hold_time',
'wrap_up_time', // Debug: Mostrar las primeras 5 filas con valores crudos de campos booleanos
'agent_id', console.log('📋 VALORES CRUDOS DE CAMPOS BOOLEANOS (primeras 5 filas):');
'transfer_flag' for (let rowNum = 1; rowNum <= Math.min(5, lines.length - 1); rowNum++) {
const rawValues = lines[rowNum].split(',').map(v => v.trim());
const rowData: Record<string, string> = {};
headers.forEach((header, idx) => {
rowData[header] = rawValues[idx] || '';
});
console.log(` Fila ${rowNum}:`, {
is_abandoned: rowData.is_abandoned,
fcr_real_flag: rowData.fcr_real_flag,
repeat_call_7d: rowData.repeat_call_7d,
transfer_flag: rowData.transfer_flag,
record_status: rowData.record_status
});
}
// Validar headers requeridos (con variantes aceptadas)
// v3.1: queue_skill (estratégico) y original_queue_id (operativo) son campos separados
const requiredFieldsWithVariants: { field: string; variants: string[] }[] = [
{ field: 'interaction_id', variants: ['interaction_id', 'Interaction_ID', 'Interaction ID'] },
{ field: 'datetime_start', variants: ['datetime_start', 'Datetime_Start', 'Datetime Start'] },
{ field: 'queue_skill', variants: ['queue_skill', 'Queue_Skill', 'Queue Skill', 'Skill'] },
{ field: 'original_queue_id', variants: ['original_queue_id', 'Original_Queue_ID', 'Original Queue ID', 'Cola'] },
{ field: 'channel', variants: ['channel', 'Channel'] },
{ field: 'duration_talk', variants: ['duration_talk', 'Duration_Talk', 'Duration Talk'] },
{ field: 'hold_time', variants: ['hold_time', 'Hold_Time', 'Hold Time'] },
{ field: 'wrap_up_time', variants: ['wrap_up_time', 'Wrap_Up_Time', 'Wrap Up Time'] },
{ field: 'agent_id', variants: ['agent_id', 'Agent_ID', 'Agent ID'] },
{ field: 'transfer_flag', variants: ['transfer_flag', 'Transfer_Flag', 'Transfer Flag'] }
]; ];
const missingFields = requiredFields.filter(field => !headers.includes(field)); const missingFields = requiredFieldsWithVariants
.filter(({ variants }) => !variants.some(v => headers.includes(v)))
.map(({ field }) => field);
if (missingFields.length > 0) { if (missingFields.length > 0) {
throw new Error(`Faltan campos requeridos: ${missingFields.join(', ')}`); throw new Error(`Faltan campos requeridos: ${missingFields.join(', ')}`);
} }
// Parsear filas // Parsear filas
const interactions: RawInteraction[] = []; const interactions: RawInteraction[] = [];
// Contadores para debug
let abandonedTrueCount = 0;
let abandonedFalseCount = 0;
let fcrTrueCount = 0;
let fcrFalseCount = 0;
let repeatTrueCount = 0;
let repeatFalseCount = 0;
let transferTrueCount = 0;
let transferFalseCount = 0;
for (let i = 1; i < lines.length; i++) { for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim()); const values = lines[i].split(',').map(v => v.trim());
if (values.length !== headers.length) { if (values.length !== headers.length) {
console.warn(`Fila ${i + 1} tiene número incorrecto de columnas, saltando...`); console.warn(`Fila ${i + 1} tiene ${values.length} columnas, esperado ${headers.length}, saltando...`);
continue; continue;
} }
const row: any = {}; const row: any = {};
headers.forEach((header, index) => { headers.forEach((header, index) => {
row[header] = values[index]; row[header] = values[index];
}); });
try { try {
// === PARSING SIMPLE Y DIRECTO ===
// is_abandoned: valor directo del CSV
const isAbandonedRaw = getColumnValue(row, 'is_abandoned', 'Is_Abandoned', 'Is Abandoned', 'abandoned');
const isAbandoned = parseBoolean(isAbandonedRaw);
if (isAbandoned) abandonedTrueCount++; else abandonedFalseCount++;
// fcr_real_flag: valor directo del CSV
const fcrRealRaw = getColumnValue(row, 'fcr_real_flag', 'FCR_Real_Flag', 'FCR Real Flag', 'fcr_flag', 'fcr');
const fcrRealFlag = parseBoolean(fcrRealRaw);
if (fcrRealFlag) fcrTrueCount++; else fcrFalseCount++;
// repeat_call_7d: valor directo del CSV
const repeatRaw = getColumnValue(row, 'repeat_call_7d', 'Repeat_Call_7d', 'Repeat Call 7d', 'repeat_call', 'rellamada', 'Rellamada');
const repeatCall7d = parseBoolean(repeatRaw);
if (repeatCall7d) repeatTrueCount++; else repeatFalseCount++;
// transfer_flag: valor directo del CSV
const transferRaw = getColumnValue(row, 'transfer_flag', 'Transfer_Flag', 'Transfer Flag');
const transferFlag = parseBoolean(transferRaw);
if (transferFlag) transferTrueCount++; else transferFalseCount++;
// record_status: valor directo, normalizado a lowercase
const recordStatusRaw = getColumnValue(row, 'record_status', 'Record_Status', 'Record Status').toLowerCase().trim();
const validStatuses = ['valid', 'noise', 'zombie', 'abandon'];
const recordStatus = validStatuses.includes(recordStatusRaw)
? recordStatusRaw as 'valid' | 'noise' | 'zombie' | 'abandon'
: undefined;
// v3.0: Parsear campos para drill-down
// business_unit = Línea de Negocio (9 categorías C-Level)
// queue_skill ya se usa como skill técnico (980 skills granulares)
const lineaNegocio = getColumnValue(row, 'business_unit', 'Business_Unit', 'BusinessUnit', 'linea_negocio', 'Linea_Negocio', 'business_line');
// v3.1: Parsear ambos niveles de jerarquía
const queueSkill = getColumnValue(row, 'queue_skill', 'Queue_Skill', 'Queue Skill', 'Skill');
const originalQueueId = getColumnValue(row, 'original_queue_id', 'Original_Queue_ID', 'Original Queue ID', 'Cola');
const interaction: RawInteraction = { const interaction: RawInteraction = {
interaction_id: row.interaction_id, interaction_id: row.interaction_id,
datetime_start: row.datetime_start, datetime_start: row.datetime_start,
queue_skill: row.queue_skill, queue_skill: queueSkill,
original_queue_id: originalQueueId || undefined,
channel: row.channel, channel: row.channel,
duration_talk: isNaN(parseFloat(row.duration_talk)) ? 0 : parseFloat(row.duration_talk), duration_talk: isNaN(parseFloat(row.duration_talk)) ? 0 : parseFloat(row.duration_talk),
hold_time: isNaN(parseFloat(row.hold_time)) ? 0 : parseFloat(row.hold_time), hold_time: isNaN(parseFloat(row.hold_time)) ? 0 : parseFloat(row.hold_time),
wrap_up_time: isNaN(parseFloat(row.wrap_up_time)) ? 0 : parseFloat(row.wrap_up_time), wrap_up_time: isNaN(parseFloat(row.wrap_up_time)) ? 0 : parseFloat(row.wrap_up_time),
agent_id: row.agent_id, agent_id: row.agent_id,
transfer_flag: row.transfer_flag?.toLowerCase() === 'true' || row.transfer_flag === '1', transfer_flag: transferFlag,
caller_id: row.caller_id || undefined repeat_call_7d: repeatCall7d,
caller_id: row.caller_id || undefined,
is_abandoned: isAbandoned,
record_status: recordStatus,
fcr_real_flag: fcrRealFlag,
linea_negocio: lineaNegocio || undefined
}; };
interactions.push(interaction); interactions.push(interaction);
} catch (error) { } catch (error) {
console.warn(`Error parseando fila ${i + 1}:`, error); console.warn(`Error parseando fila ${i + 1}:`, error);
} }
} }
// === DEBUG SUMMARY ===
const total = interactions.length;
console.log('');
console.log('═══════════════════════════════════════════════════════════════');
console.log('📊 RESUMEN DE PARSING CSV - VALORES BOOLEANOS');
console.log('═══════════════════════════════════════════════════════════════');
console.log(`Total registros parseados: ${total}`);
console.log('');
console.log(`is_abandoned:`);
console.log(` TRUE: ${abandonedTrueCount} (${((abandonedTrueCount/total)*100).toFixed(1)}%)`);
console.log(` FALSE: ${abandonedFalseCount} (${((abandonedFalseCount/total)*100).toFixed(1)}%)`);
console.log('');
console.log(`fcr_real_flag:`);
console.log(` TRUE: ${fcrTrueCount} (${((fcrTrueCount/total)*100).toFixed(1)}%)`);
console.log(` FALSE: ${fcrFalseCount} (${((fcrFalseCount/total)*100).toFixed(1)}%)`);
console.log('');
console.log(`repeat_call_7d:`);
console.log(` TRUE: ${repeatTrueCount} (${((repeatTrueCount/total)*100).toFixed(1)}%)`);
console.log(` FALSE: ${repeatFalseCount} (${((repeatFalseCount/total)*100).toFixed(1)}%)`);
console.log('');
console.log(`transfer_flag:`);
console.log(` TRUE: ${transferTrueCount} (${((transferTrueCount/total)*100).toFixed(1)}%)`);
console.log(` FALSE: ${transferFalseCount} (${((transferFalseCount/total)*100).toFixed(1)}%)`);
console.log('');
// Calcular métricas esperadas
const expectedAbandonRate = (abandonedTrueCount / total) * 100;
const expectedFCR_fromFlag = (fcrTrueCount / total) * 100;
const expectedFCR_calculated = ((total - transferTrueCount - repeatTrueCount +
interactions.filter(i => i.transfer_flag && i.repeat_call_7d).length) / total) * 100;
console.log('📈 MÉTRICAS ESPERADAS:');
console.log(` Abandonment Rate (is_abandoned=TRUE): ${expectedAbandonRate.toFixed(1)}%`);
console.log(` FCR (fcr_real_flag=TRUE): ${expectedFCR_fromFlag.toFixed(1)}%`);
console.log(` FCR calculado (no transfer AND no repeat): ~${expectedFCR_calculated.toFixed(1)}%`);
console.log('═══════════════════════════════════════════════════════════════');
console.log('');
return interactions; return interactions;
} }
/** /**
* Parsear archivo Excel a array de objetos * Parsear archivo Excel a array de objetos
* Usa la librería xlsx que ya está instalada
*/ */
export async function parseExcel(file: File): Promise<RawInteraction[]> { export async function parseExcel(file: File): Promise<RawInteraction[]> {
// Importar xlsx dinámicamente
const XLSX = await import('xlsx'); const XLSX = await import('xlsx');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
try { try {
const data = e.target?.result; const data = e.target?.result;
const workbook = XLSX.read(data, { type: 'binary' }); const workbook = XLSX.read(data, { type: 'binary' });
// Usar la primera hoja
const firstSheetName = workbook.SheetNames[0]; const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName]; const worksheet = workbook.Sheets[firstSheetName];
// Convertir a JSON
const jsonData = XLSX.utils.sheet_to_json(worksheet); const jsonData = XLSX.utils.sheet_to_json(worksheet);
if (jsonData.length === 0) { if (jsonData.length === 0) {
reject(new Error('El archivo Excel está vacío')); reject(new Error('El archivo Excel está vacío'));
return; return;
} }
// Validar y transformar a RawInteraction[]
const interactions: RawInteraction[] = []; const interactions: RawInteraction[] = [];
// Contadores para debug
let abandonedTrueCount = 0;
let fcrTrueCount = 0;
let repeatTrueCount = 0;
let transferTrueCount = 0;
for (let i = 0; i < jsonData.length; i++) { for (let i = 0; i < jsonData.length; i++) {
const row: any = jsonData[i]; const row: any = jsonData[i];
try {
const durationStr = row.duration_talk || row.Duration_Talk || row['Duration Talk'] || '0';
const holdStr = row.hold_time || row.Hold_Time || row['Hold Time'] || '0';
const wrapStr = row.wrap_up_time || row.Wrap_Up_Time || row['Wrap Up Time'] || '0';
const durationTalkVal = isNaN(parseFloat(durationStr)) ? 0 : parseFloat(durationStr); try {
const holdTimeVal = isNaN(parseFloat(holdStr)) ? 0 : parseFloat(holdStr); // === PARSING SIMPLE Y DIRECTO ===
const wrapUpTimeVal = isNaN(parseFloat(wrapStr)) ? 0 : parseFloat(wrapStr);
// is_abandoned
const isAbandonedRaw = getColumnValue(row, 'is_abandoned', 'Is_Abandoned', 'Is Abandoned', 'abandoned');
const isAbandoned = parseBoolean(isAbandonedRaw);
if (isAbandoned) abandonedTrueCount++;
// fcr_real_flag
const fcrRealRaw = getColumnValue(row, 'fcr_real_flag', 'FCR_Real_Flag', 'FCR Real Flag', 'fcr_flag', 'fcr');
const fcrRealFlag = parseBoolean(fcrRealRaw);
if (fcrRealFlag) fcrTrueCount++;
// repeat_call_7d
const repeatRaw = getColumnValue(row, 'repeat_call_7d', 'Repeat_Call_7d', 'Repeat Call 7d', 'repeat_call', 'rellamada');
const repeatCall7d = parseBoolean(repeatRaw);
if (repeatCall7d) repeatTrueCount++;
// transfer_flag
const transferRaw = getColumnValue(row, 'transfer_flag', 'Transfer_Flag', 'Transfer Flag');
const transferFlag = parseBoolean(transferRaw);
if (transferFlag) transferTrueCount++;
// record_status
const recordStatusRaw = getColumnValue(row, 'record_status', 'Record_Status', 'Record Status').toLowerCase().trim();
const validStatuses = ['valid', 'noise', 'zombie', 'abandon'];
const recordStatus = validStatuses.includes(recordStatusRaw)
? recordStatusRaw as 'valid' | 'noise' | 'zombie' | 'abandon'
: undefined;
const durationTalkVal = parseFloat(getColumnValue(row, 'duration_talk', 'Duration_Talk', 'Duration Talk') || '0');
const holdTimeVal = parseFloat(getColumnValue(row, 'hold_time', 'Hold_Time', 'Hold Time') || '0');
const wrapUpTimeVal = parseFloat(getColumnValue(row, 'wrap_up_time', 'Wrap_Up_Time', 'Wrap Up Time') || '0');
// v3.0: Parsear campos para drill-down
// business_unit = Línea de Negocio (9 categorías C-Level)
const lineaNegocio = getColumnValue(row, 'business_unit', 'Business_Unit', 'BusinessUnit', 'linea_negocio', 'Linea_Negocio', 'business_line');
const interaction: RawInteraction = { const interaction: RawInteraction = {
interaction_id: String(row.interaction_id || row.Interaction_ID || row['Interaction ID'] || ''), interaction_id: String(getColumnValue(row, 'interaction_id', 'Interaction_ID', 'Interaction ID') || ''),
datetime_start: String(row.datetime_start || row.Datetime_Start || row['Datetime Start'] || row['Fecha/Hora de apertura'] || ''), datetime_start: String(getColumnValue(row, 'datetime_start', 'Datetime_Start', 'Datetime Start', 'Fecha/Hora de apertura') || ''),
queue_skill: String(row.queue_skill || row.Queue_Skill || row['Queue Skill'] || row.Subtipo || row.Tipo || ''), queue_skill: String(getColumnValue(row, 'queue_skill', 'Queue_Skill', 'Queue Skill', 'Skill', 'Subtipo', 'Tipo') || ''),
channel: String(row.channel || row.Channel || row['Origen del caso'] || 'Unknown'), original_queue_id: String(getColumnValue(row, 'original_queue_id', 'Original_Queue_ID', 'Original Queue ID', 'Cola') || '') || undefined,
channel: String(getColumnValue(row, 'channel', 'Channel', 'Origen del caso') || 'Unknown'),
duration_talk: isNaN(durationTalkVal) ? 0 : durationTalkVal, duration_talk: isNaN(durationTalkVal) ? 0 : durationTalkVal,
hold_time: isNaN(holdTimeVal) ? 0 : holdTimeVal, hold_time: isNaN(holdTimeVal) ? 0 : holdTimeVal,
wrap_up_time: isNaN(wrapUpTimeVal) ? 0 : wrapUpTimeVal, wrap_up_time: isNaN(wrapUpTimeVal) ? 0 : wrapUpTimeVal,
agent_id: String(row.agent_id || row.Agent_ID || row['Agent ID'] || row['Propietario del caso'] || 'Unknown'), agent_id: String(getColumnValue(row, 'agent_id', 'Agent_ID', 'Agent ID', 'Propietario del caso') || 'Unknown'),
transfer_flag: Boolean(row.transfer_flag || row.Transfer_Flag || row['Transfer Flag'] || false), transfer_flag: transferFlag,
caller_id: row.caller_id || row.Caller_ID || row['Caller ID'] || undefined repeat_call_7d: repeatCall7d,
caller_id: getColumnValue(row, 'caller_id', 'Caller_ID', 'Caller ID') || undefined,
is_abandoned: isAbandoned,
record_status: recordStatus,
fcr_real_flag: fcrRealFlag,
linea_negocio: lineaNegocio || undefined
}; };
// Validar que tiene datos mínimos
if (interaction.interaction_id && interaction.queue_skill) { if (interaction.interaction_id && interaction.queue_skill) {
interactions.push(interaction); interactions.push(interaction);
} }
@@ -140,22 +326,32 @@ export async function parseExcel(file: File): Promise<RawInteraction[]> {
console.warn(`Error parseando fila ${i + 1}:`, error); console.warn(`Error parseando fila ${i + 1}:`, error);
} }
} }
// Debug summary
const total = interactions.length;
console.log('📊 Excel Parsing Summary:', {
total,
is_abandoned_TRUE: `${abandonedTrueCount} (${((abandonedTrueCount/total)*100).toFixed(1)}%)`,
fcr_real_flag_TRUE: `${fcrTrueCount} (${((fcrTrueCount/total)*100).toFixed(1)}%)`,
repeat_call_7d_TRUE: `${repeatTrueCount} (${((repeatTrueCount/total)*100).toFixed(1)}%)`,
transfer_flag_TRUE: `${transferTrueCount} (${((transferTrueCount/total)*100).toFixed(1)}%)`
});
if (interactions.length === 0) { if (interactions.length === 0) {
reject(new Error('No se pudieron parsear datos válidos del Excel')); reject(new Error('No se pudieron parsear datos válidos del Excel'));
return; return;
} }
resolve(interactions); resolve(interactions);
} catch (error) { } catch (error) {
reject(error); reject(error);
} }
}; };
reader.onerror = () => { reader.onerror = () => {
reject(new Error('Error leyendo el archivo')); reject(new Error('Error leyendo el archivo'));
}; };
reader.readAsBinaryString(file); reader.readAsBinaryString(file);
}); });
} }
@@ -165,7 +361,7 @@ export async function parseExcel(file: File): Promise<RawInteraction[]> {
*/ */
export async function parseFile(file: File): Promise<RawInteraction[]> { export async function parseFile(file: File): Promise<RawInteraction[]> {
const fileName = file.name.toLowerCase(); const fileName = file.name.toLowerCase();
if (fileName.endsWith('.csv')) { if (fileName.endsWith('.csv')) {
return parseCSV(file); return parseCSV(file);
} else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) { } else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) {
@@ -193,7 +389,7 @@ export function validateInteractions(interactions: RawInteraction[]): {
} { } {
const errors: string[] = []; const errors: string[] = [];
const warnings: string[] = []; const warnings: string[] = [];
if (interactions.length === 0) { if (interactions.length === 0) {
errors.push('No hay interacciones para validar'); errors.push('No hay interacciones para validar');
return { return {
@@ -203,39 +399,47 @@ export function validateInteractions(interactions: RawInteraction[]): {
stats: { total: 0, valid: 0, invalid: 0, skills: 0, agents: 0, dateRange: null } stats: { total: 0, valid: 0, invalid: 0, skills: 0, agents: 0, dateRange: null }
}; };
} }
// Validar período mínimo (3 meses recomendado) // Validar período mínimo (3 meses recomendado)
const dates = interactions let minTime = Infinity;
.map(i => new Date(i.datetime_start)) let maxTime = -Infinity;
.filter(d => !isNaN(d.getTime())); let validDatesCount = 0;
if (dates.length > 0) { for (const interaction of interactions) {
const minDate = new Date(Math.min(...dates.map(d => d.getTime()))); const date = new Date(interaction.datetime_start);
const maxDate = new Date(Math.max(...dates.map(d => d.getTime()))); const time = date.getTime();
const monthsDiff = (maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24 * 30); if (!isNaN(time)) {
validDatesCount++;
if (time < minTime) minTime = time;
if (time > maxTime) maxTime = time;
}
}
if (validDatesCount > 0) {
const monthsDiff = (maxTime - minTime) / (1000 * 60 * 60 * 24 * 30);
if (monthsDiff < 3) { if (monthsDiff < 3) {
warnings.push(`Período de datos: ${monthsDiff.toFixed(1)} meses. Se recomiendan al menos 3 meses para análisis robusto.`); warnings.push(`Período de datos: ${monthsDiff.toFixed(1)} meses. Se recomiendan al menos 3 meses para análisis robusto.`);
} }
} }
// Contar skills y agentes únicos // Contar skills y agentes únicos
const uniqueSkills = new Set(interactions.map(i => i.queue_skill)).size; const uniqueSkills = new Set(interactions.map(i => i.queue_skill)).size;
const uniqueAgents = new Set(interactions.map(i => i.agent_id)).size; const uniqueAgents = new Set(interactions.map(i => i.agent_id)).size;
if (uniqueSkills < 3) { if (uniqueSkills < 3) {
warnings.push(`Solo ${uniqueSkills} skills detectados. Se recomienda tener al menos 3 para análisis comparativo.`); warnings.push(`Solo ${uniqueSkills} skills detectados. Se recomienda tener al menos 3 para análisis comparativo.`);
} }
// Validar datos de tiempo // Validar datos de tiempo
const invalidTimes = interactions.filter(i => const invalidTimes = interactions.filter(i =>
i.duration_talk < 0 || i.hold_time < 0 || i.wrap_up_time < 0 i.duration_talk < 0 || i.hold_time < 0 || i.wrap_up_time < 0
).length; ).length;
if (invalidTimes > 0) { if (invalidTimes > 0) {
warnings.push(`${invalidTimes} interacciones tienen tiempos negativos (serán filtradas).`); warnings.push(`${invalidTimes} interacciones tienen tiempos negativos (serán filtradas).`);
} }
return { return {
valid: errors.length === 0, valid: errors.length === 0,
errors, errors,
@@ -246,9 +450,9 @@ export function validateInteractions(interactions: RawInteraction[]): {
invalid: invalidTimes, invalid: invalidTimes,
skills: uniqueSkills, skills: uniqueSkills,
agents: uniqueAgents, agents: uniqueAgents,
dateRange: dates.length > 0 ? { dateRange: validDatesCount > 0 ? {
min: new Date(Math.min(...dates.map(d => d.getTime()))).toISOString().split('T')[0], min: new Date(minTime).toISOString().split('T')[0],
max: new Date(Math.max(...dates.map(d => d.getTime()))).toISOString().split('T')[0] max: new Date(maxTime).toISOString().split('T')[0]
} : null } : null
} }
}; };

View File

@@ -0,0 +1,15 @@
// utils/formatters.ts
// Shared formatting utilities
/**
* Formats the current date as "Month Year" in Spanish
* Example: "Enero 2025"
*/
export const formatDateMonthYear = (): string => {
const now = new Date();
const months = [
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
];
return `${months[now.getMonth()]} ${now.getFullYear()}`;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,260 @@
/**
* serverCache.ts - Server-side cache for CSV files
*
* Uses backend API to store/retrieve cached CSV files.
* Works across browsers and computers (as long as they access the same server).
*/
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
export interface ServerCacheMetadata {
fileName: string;
fileSize: number;
recordCount: number;
cachedAt: string;
costPerHour: number;
}
/**
* Check if server has cached data
*/
export async function checkServerCache(authHeader: string): Promise<{
exists: boolean;
metadata: ServerCacheMetadata | null;
}> {
const url = `${API_BASE_URL}/cache/check`;
console.log('[ServerCache] Checking cache at:', url);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: authHeader,
},
});
console.log('[ServerCache] Response status:', response.status);
if (!response.ok) {
const text = await response.text();
console.error('[ServerCache] Error checking cache:', response.status, text);
return { exists: false, metadata: null };
}
const data = await response.json();
console.log('[ServerCache] Response data:', data);
return {
exists: data.exists || false,
metadata: data.metadata || null,
};
} catch (error) {
console.error('[ServerCache] Error checking cache:', error);
return { exists: false, metadata: null };
}
}
/**
* Save CSV file to server cache using FormData
* This sends the actual file, not parsed JSON data
*/
export async function saveFileToServerCache(
authHeader: string,
file: File,
costPerHour: number
): Promise<boolean> {
const url = `${API_BASE_URL}/cache/file`;
console.log(`[ServerCache] Saving file "${file.name}" (${(file.size / 1024 / 1024).toFixed(2)} MB) to server at:`, url);
try {
const formData = new FormData();
formData.append('csv_file', file);
formData.append('fileName', file.name);
formData.append('fileSize', file.size.toString());
formData.append('costPerHour', costPerHour.toString());
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: authHeader,
// Note: Don't set Content-Type - browser sets it automatically with boundary for FormData
},
body: formData,
});
console.log('[ServerCache] Save response status:', response.status);
if (!response.ok) {
const text = await response.text();
console.error('[ServerCache] Error saving cache:', response.status, text);
return false;
}
const data = await response.json();
console.log('[ServerCache] Save success:', data);
return true;
} catch (error) {
console.error('[ServerCache] Error saving cache:', error);
return false;
}
}
/**
* Download the cached CSV file from the server
* Returns a File object that can be parsed locally
*/
export async function downloadCachedFile(authHeader: string): Promise<File | null> {
const url = `${API_BASE_URL}/cache/download`;
console.log('[ServerCache] Downloading cached file from:', url);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: authHeader,
},
});
console.log('[ServerCache] Download response status:', response.status);
if (response.status === 404) {
console.error('[ServerCache] No cached file found');
return null;
}
if (!response.ok) {
const text = await response.text();
console.error('[ServerCache] Error downloading cached file:', response.status, text);
return null;
}
// Get the blob and create a File object
const blob = await response.blob();
const file = new File([blob], 'cached_data.csv', { type: 'text/csv' });
console.log(`[ServerCache] Downloaded file: ${(file.size / 1024 / 1024).toFixed(2)} MB`);
return file;
} catch (error) {
console.error('[ServerCache] Error downloading cached file:', error);
return null;
}
}
/**
* Save drilldownData JSON to server cache
* Called after calculating drilldown from uploaded file
*/
export async function saveDrilldownToServerCache(
authHeader: string,
drilldownData: any[]
): Promise<boolean> {
const url = `${API_BASE_URL}/cache/drilldown`;
console.log(`[ServerCache] Saving drilldownData (${drilldownData.length} skills) to server`);
try {
const formData = new FormData();
formData.append('drilldown_json', JSON.stringify(drilldownData));
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: authHeader,
},
body: formData,
});
console.log('[ServerCache] Save drilldown response status:', response.status);
if (!response.ok) {
const text = await response.text();
console.error('[ServerCache] Error saving drilldown:', response.status, text);
return false;
}
const data = await response.json();
console.log('[ServerCache] Drilldown save success:', data);
return true;
} catch (error) {
console.error('[ServerCache] Error saving drilldown:', error);
return false;
}
}
/**
* Get cached drilldownData from server
* Returns the pre-calculated drilldown data for fast cache usage
*/
export async function getCachedDrilldown(authHeader: string): Promise<any[] | null> {
const url = `${API_BASE_URL}/cache/drilldown`;
console.log('[ServerCache] Getting cached drilldown from:', url);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: authHeader,
},
});
console.log('[ServerCache] Get drilldown response status:', response.status);
if (response.status === 404) {
console.log('[ServerCache] No cached drilldown found');
return null;
}
if (!response.ok) {
const text = await response.text();
console.error('[ServerCache] Error getting drilldown:', response.status, text);
return null;
}
const data = await response.json();
console.log(`[ServerCache] Got cached drilldown: ${data.drilldownData?.length || 0} skills`);
return data.drilldownData || null;
} catch (error) {
console.error('[ServerCache] Error getting drilldown:', error);
return null;
}
}
/**
* Clear server cache
*/
export async function clearServerCache(authHeader: string): Promise<boolean> {
const url = `${API_BASE_URL}/cache/file`;
console.log('[ServerCache] Clearing cache at:', url);
try {
const response = await fetch(url, {
method: 'DELETE',
headers: {
Authorization: authHeader,
},
});
console.log('[ServerCache] Clear response status:', response.status);
if (!response.ok) {
const text = await response.text();
console.error('[ServerCache] Error clearing cache:', response.status, text);
return false;
}
console.log('[ServerCache] Cache cleared');
return true;
} catch (error) {
console.error('[ServerCache] Error clearing cache:', error);
return false;
}
}
// Legacy exports - kept for backwards compatibility during transition
// These will throw errors if called since the backend endpoints are deprecated
export async function saveServerCache(): Promise<boolean> {
console.error('[ServerCache] saveServerCache is deprecated - use saveFileToServerCache instead');
return false;
}
export async function getServerCachedInteractions(): Promise<null> {
console.error('[ServerCache] getServerCachedInteractions is deprecated - use cached file analysis instead');
return null;
}

View File

@@ -4,7 +4,7 @@ set -euo pipefail
############################################### ###############################################
# CONFIGURACIÓN BÁSICA EDITA ESTO # CONFIGURACIÓN BÁSICA EDITA ESTO
############################################### ###############################################
# TODO: pon aquí la URL real de tu repo # TODO: pon aquí la URL real de tu repo (sin credenciales)
REPO_URL_DEFAULT="https://github.com/igferne/Beyond-Diagnosis.git" REPO_URL_DEFAULT="https://github.com/igferne/Beyond-Diagnosis.git"
INSTALL_DIR="/opt/beyonddiagnosis" INSTALL_DIR="/opt/beyonddiagnosis"
@@ -57,15 +57,44 @@ if [ -z "$API_PASS" ]; then
exit 1 exit 1
fi fi
read -rp "URL del repositorio Git [$REPO_URL_DEFAULT]: " REPO_URL echo
read -rp "URL del repositorio Git (HTTPS, sin credenciales) [$REPO_URL_DEFAULT]: " REPO_URL
REPO_URL=${REPO_URL:-$REPO_URL_DEFAULT} REPO_URL=${REPO_URL:-$REPO_URL_DEFAULT}
echo
read -rp "¿El repositorio es PRIVADO en GitHub y necesitas token? [s/N]: " IS_PRIVATE
IS_PRIVATE=${IS_PRIVATE:-N}
GIT_CLONE_URL="$REPO_URL"
if [[ "$IS_PRIVATE" =~ ^[sS]$ ]]; then
echo "Introduce un Personal Access Token (PAT) de GitHub con permiso de lectura del repo."
read -rsp "GitHub PAT: " GITHUB_TOKEN
echo
if [ -z "$GITHUB_TOKEN" ]; then
echo "El token no puede estar vacío si el repo es privado."
exit 1
fi
# Construimos una URL del tipo: https://TOKEN@github.com/usuario/repo.git
if [[ "$REPO_URL" =~ ^https:// ]]; then
GIT_CLONE_URL="https://${GITHUB_TOKEN}@${REPO_URL#https://}"
else
echo "La URL del repositorio debe empezar por https:// para usar el token."
exit 1
fi
fi
echo echo
echo "Resumen de configuración:" echo "Resumen de configuración:"
echo " Dominio: $DOMAIN" echo " Dominio: $DOMAIN"
echo " Email Let'sEnc: $EMAIL" echo " Email Let'sEnc: $EMAIL"
echo " Usuario API: $API_USER" echo " Usuario API: $API_USER"
echo " Repo: $REPO_URL" echo " Repo (visible): $REPO_URL"
if [[ "$IS_PRIVATE" =~ ^[sS]$ ]]; then
echo " Repo privado: Sí (se usará un PAT sólo para el clon inicial)"
else
echo " Repo privado: No"
fi
echo echo
read -rp "¿Continuar con la instalación? [s/N]: " CONFIRM read -rp "¿Continuar con la instalación? [s/N]: " CONFIRM
@@ -137,7 +166,8 @@ if [ -d "$INSTALL_DIR/.git" ]; then
git -C "$INSTALL_DIR" pull --ff-only git -C "$INSTALL_DIR" pull --ff-only
else else
rm -rf "$INSTALL_DIR" rm -rf "$INSTALL_DIR"
git clone "$REPO_URL" "$INSTALL_DIR" echo "Clonando repositorio..."
git clone "$GIT_CLONE_URL" "$INSTALL_DIR"
fi fi
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
@@ -206,12 +236,15 @@ server {
listen 80; listen 80;
server_name $DOMAIN; server_name $DOMAIN;
return 301 https://\$host\$request_uri; return 301 https://\$host\$request_uri;
client_max_body_size 1024M;
} }
server { server {
listen 443 ssl; listen 443 ssl;
server_name $DOMAIN; server_name $DOMAIN;
client_max_body_size 1024M;
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;
@@ -235,6 +268,11 @@ server {
proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header Upgrade \$http_upgrade; proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
send_timeout 600s;
} }
} }
EOF EOF

43
nginx/conf.d/beyond.conf Normal file
View File

@@ -0,0 +1,43 @@
server {
listen 80;
server_name ae-analytics.beyondcx.ai;
return 301 https://$host$request_uri;
client_max_body_size 1024M;
}
server {
listen 443 ssl;
server_name ae-analytics.beyondcx.ai;
client_max_body_size 1024M;
ssl_certificate /etc/letsencrypt/live/ae-analytics.beyondcx.ai/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ae-analytics.beyondcx.ai/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# FRONTEND (React)
location / {
proxy_pass http://frontend:4173/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# BACKEND (FastAPI)
location /api/ {
proxy_pass http://backend:8000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
send_timeout 600s;
}
}

View File

@@ -23,5 +23,10 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
send_timeout 600s;
} }
} }