Compare commits
28 Commits
7151faf976
...
desarrollo
| Author | SHA1 | Date | |
|---|---|---|---|
| 148c86563b | |||
| b488c1bff6 | |||
|
|
152b5c0628 | ||
|
|
eb804d7fb0 | ||
|
|
c9f6db9882 | ||
|
|
a48aca0a26 | ||
|
|
20e9d213bb | ||
|
|
c5c88f6f21 | ||
|
|
cbea968776 | ||
|
|
820e8b4887 | ||
|
|
728ba5772e | ||
|
|
5df79d436f | ||
|
|
0063d299c9 | ||
|
|
33d25871ae | ||
|
|
468248aaed | ||
|
|
b921ecf134 | ||
|
|
0f1bfd93cd | ||
|
|
88d7e4c10d | ||
|
|
62454c6b6a | ||
|
|
522b4b6caa | ||
|
|
806e32429d | ||
|
|
8082a14e1b | ||
|
|
7e24f4eb31 | ||
|
|
fdfb520710 | ||
|
|
5a1fb1e767 | ||
|
|
d8fecb1cb7 | ||
|
|
1315417c53 | ||
|
|
49b2677783 |
103
CLAUDE.md
Normal file
103
CLAUDE.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# CLAUDE.md - Beyond CX Analytics
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Beyond CX Analytics is a Contact Center Analytics Platform that analyzes operational data and provides AI-assisted insights. The application processes CSV data from contact centers to generate volumetry analysis, performance metrics, CSAT scores, economic models, and automation readiness scoring.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
**Frontend:** React 19 + TypeScript + Vite
|
||||||
|
**Backend:** Python 3.11 + FastAPI
|
||||||
|
**Infrastructure:** Docker Compose + Nginx
|
||||||
|
**Charts:** Recharts
|
||||||
|
**UI Components:** Radix UI + Lucide React
|
||||||
|
**Data Processing:** Pandas, NumPy
|
||||||
|
**AI Integration:** OpenAI API
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
BeyondCXAnalytics_AE/
|
||||||
|
├── backend/
|
||||||
|
│ ├── beyond_api/ # FastAPI REST API
|
||||||
|
│ ├── beyond_metrics/ # Core metrics calculation library
|
||||||
|
│ ├── beyond_flows/ # AI agents and scoring engines
|
||||||
|
│ └── tests/ # pytest test suite
|
||||||
|
├── frontend/
|
||||||
|
│ ├── components/ # React components
|
||||||
|
│ ├── utils/ # Utility functions and API client
|
||||||
|
│ └── styles/ # CSS and color definitions
|
||||||
|
├── nginx/ # Reverse proxy configuration
|
||||||
|
└── docker-compose.yml # Service orchestration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install # Install dependencies
|
||||||
|
npm run dev # Start dev server (port 3000)
|
||||||
|
npm run build # Production build
|
||||||
|
npm run preview # Preview production build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install . # Install from pyproject.toml
|
||||||
|
python -m pytest tests/ # Run tests
|
||||||
|
uvicorn beyond_api.main:app --reload # Start dev server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
```bash
|
||||||
|
docker compose build # Build all services
|
||||||
|
docker compose up -d # Start all services
|
||||||
|
docker compose down # Stop all services
|
||||||
|
docker compose logs -f # Stream logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
```bash
|
||||||
|
./deploy.sh # Redeploy containers
|
||||||
|
sudo ./install_beyond.sh # Full server installation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Entry Points
|
||||||
|
|
||||||
|
| Component | File |
|
||||||
|
|-----------|------|
|
||||||
|
| Frontend App | `frontend/App.tsx` |
|
||||||
|
| Backend API | `backend/beyond_api/main.py` |
|
||||||
|
| Main Endpoint | `POST /analysis` |
|
||||||
|
| Metrics Engine | `backend/beyond_metrics/agent.py` |
|
||||||
|
| AI Agents | `backend/beyond_flows/agents/` |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **4 Analytics Dimensions:** Volumetry, Operational Performance, Satisfaction/Experience, Economy/Cost
|
||||||
|
- **Data Flow:** CSV Upload → FastAPI → Metrics Pipeline → AI Agents → JSON Response → React Dashboard
|
||||||
|
- **Authentication:** Basic Auth middleware
|
||||||
|
|
||||||
|
## Code Style Notes
|
||||||
|
|
||||||
|
- Documentation and comments are in **Spanish**
|
||||||
|
- Follow existing patterns when adding new components
|
||||||
|
- Frontend uses functional components with hooks
|
||||||
|
- Backend follows FastAPI conventions with Pydantic models
|
||||||
|
|
||||||
|
## Git Workflow
|
||||||
|
|
||||||
|
- **Main branch:** `main`
|
||||||
|
- **Development branch:** `desarrollo`
|
||||||
|
- Create feature branches from `desarrollo`
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Backend expects:
|
||||||
|
- `OPENAI_API_KEY` - For AI-powered analysis
|
||||||
|
- `BASIC_AUTH_USER` / `BASIC_AUTH_PASS` - API authentication
|
||||||
|
|
||||||
|
Frontend expects:
|
||||||
|
- `VITE_API_BASE_URL` - API endpoint (default: `/api`)
|
||||||
151
CLEANUP_PLAN.md
Normal file
151
CLEANUP_PLAN.md
Normal 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
144
Dockerfile
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Unified Dockerfile for Render deployment
|
||||||
|
# Builds both frontend and backend, serves via nginx
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Stage 1: Build Frontend
|
||||||
|
# ============================================
|
||||||
|
FROM node:20-alpine AS frontend-build
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy frontend source
|
||||||
|
COPY frontend/ .
|
||||||
|
|
||||||
|
# Build with API pointing to /api
|
||||||
|
ARG VITE_API_BASE_URL=/api
|
||||||
|
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Stage 2: Build Backend
|
||||||
|
# ============================================
|
||||||
|
FROM python:3.11-slim AS backend-build
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy and install Python dependencies
|
||||||
|
COPY backend/pyproject.toml ./
|
||||||
|
RUN pip install --upgrade pip && pip install .
|
||||||
|
|
||||||
|
# Copy backend code
|
||||||
|
COPY backend/ .
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Stage 3: Final Image with Nginx
|
||||||
|
# ============================================
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Install nginx, supervisor, and bash
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
nginx \
|
||||||
|
supervisor \
|
||||||
|
bash \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy Python packages from backend-build
|
||||||
|
COPY --from=backend-build /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||||
|
COPY --from=backend-build /usr/local/bin /usr/local/bin
|
||||||
|
|
||||||
|
# Copy backend code
|
||||||
|
WORKDIR /app/backend
|
||||||
|
COPY --from=backend-build /app/backend .
|
||||||
|
|
||||||
|
# Copy frontend build
|
||||||
|
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Create cache directory
|
||||||
|
RUN mkdir -p /data/cache && chmod 777 /data/cache
|
||||||
|
|
||||||
|
# Nginx configuration
|
||||||
|
RUN rm /etc/nginx/sites-enabled/default
|
||||||
|
COPY <<'NGINX' /etc/nginx/conf.d/default.conf
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Frontend static files
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API proxy to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
|
||||||
|
# Supervisor configuration
|
||||||
|
COPY <<'SUPERVISOR' /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
|
||||||
|
[program:nginx]
|
||||||
|
command=nginx -g "daemon off;"
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:backend]
|
||||||
|
command=python -m uvicorn beyond_api.main:app --host 127.0.0.1 --port 8000
|
||||||
|
directory=/app/backend
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
SUPERVISOR
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
ENV BASIC_AUTH_USERNAME=beyond
|
||||||
|
ENV BASIC_AUTH_PASSWORD=beyond2026
|
||||||
|
ENV CACHE_DIR=/data/cache
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Render uses PORT environment variable (default 10000)
|
||||||
|
ENV PORT=10000
|
||||||
|
EXPOSE 10000
|
||||||
|
|
||||||
|
# Start script that configures nginx to use $PORT
|
||||||
|
COPY <<'STARTSCRIPT' /start.sh
|
||||||
|
#!/bin/bash
|
||||||
|
# Replace port 80 with $PORT in nginx config
|
||||||
|
sed -i "s/listen 80/listen $PORT/" /etc/nginx/conf.d/default.conf
|
||||||
|
# Start supervisor
|
||||||
|
exec supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
STARTSCRIPT
|
||||||
|
|
||||||
|
RUN chmod +x /start.sh
|
||||||
|
|
||||||
|
CMD ["/start.sh"]
|
||||||
192
README.md
Normal file
192
README.md
Normal 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 **Let’s 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 Let’s 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 Let’s 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 Let’s 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 Let’s 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 Let’s 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.
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
288
backend/beyond_api/api/cache.py
Normal file
288
backend/beyond_api/api/cache.py
Normal 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."
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
correct_username = secrets.compare_digest(credentials.username, INT_USER)
|
||||||
|
correct_password = secrets.compare_digest(credentials.password, INT_PASS)
|
||||||
|
if not (correct_username and correct_password):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Credenciales incorrectas",
|
detail="Credenciales incorrectas",
|
||||||
headers={"WWW-Authenticate": "Basic"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return credentials.username
|
return credentials.username
|
||||||
|
|||||||
@@ -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):
|
||||||
- 8–10: AUTOMATE 🤖
|
- ≥6: COPILOT 🤖 (Listo para Copilot)
|
||||||
- 5–7.99: ASSIST 🤝
|
- 4–5.99: OPTIMIZE 🔧 (Optimizar Primero)
|
||||||
- 3–4.99: AUGMENT 🧠
|
- <4: HUMAN 👤 (Requiere Gestión Humana)
|
||||||
- 0–2.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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ class EconomyCostMetrics:
|
|||||||
+ df["wrap_up_time"].fillna(0)
|
+ df["wrap_up_time"].fillna(0)
|
||||||
) # segundos
|
) # segundos
|
||||||
|
|
||||||
|
# Filtrar por record_status para cálculos de AHT/CPI
|
||||||
|
# Solo incluir registros VALID (excluir NOISE, ZOMBIE, ABANDON)
|
||||||
|
if "record_status" in df.columns:
|
||||||
|
df["record_status"] = df["record_status"].astype(str).str.strip().str.upper()
|
||||||
|
df["_is_valid_for_cost"] = df["record_status"] == "VALID"
|
||||||
|
else:
|
||||||
|
# Legacy data sin record_status: incluir todo
|
||||||
|
df["_is_valid_for_cost"] = True
|
||||||
|
|
||||||
self.df = df
|
self.df = df
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -115,12 +124,19 @@ class EconomyCostMetrics:
|
|||||||
"""
|
"""
|
||||||
CPI (Coste Por Interacción) por skill/canal.
|
CPI (Coste Por Interacción) por skill/canal.
|
||||||
|
|
||||||
CPI = Labor_cost_per_interaction + Overhead_variable
|
CPI = (Labor_cost_per_interaction + Overhead_variable) / EFFECTIVE_PRODUCTIVITY
|
||||||
|
|
||||||
- Labor_cost_per_interaction = (labor_cost_per_hour * AHT_hours)
|
- Labor_cost_per_interaction = (labor_cost_per_hour * AHT_hours)
|
||||||
- Overhead_variable = overhead_rate * Labor_cost_per_interaction
|
- Overhead_variable = overhead_rate * Labor_cost_per_interaction
|
||||||
|
- EFFECTIVE_PRODUCTIVITY = 0.70 (70% - accounts for non-productive time)
|
||||||
|
|
||||||
|
Excluye registros abandonados del cálculo de costes para consistencia
|
||||||
|
con el path del frontend (fresh CSV).
|
||||||
|
|
||||||
Si no hay config de costes -> devuelve DataFrame vacío.
|
Si no hay config de costes -> devuelve DataFrame vacío.
|
||||||
|
|
||||||
|
Incluye queue_skill y channel como columnas (no solo índice) para que
|
||||||
|
el frontend pueda hacer lookup por nombre de skill.
|
||||||
"""
|
"""
|
||||||
if not self._has_cost_config():
|
if not self._has_cost_config():
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
@@ -132,8 +148,22 @@ class EconomyCostMetrics:
|
|||||||
if df.empty:
|
if df.empty:
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
||||||
# AHT por skill/canal (en segundos)
|
# Filter out abandonments for cost calculation (consistency with frontend)
|
||||||
grouped = df.groupby(["queue_skill", "channel"])["handle_time"].mean()
|
if "is_abandoned" in df.columns:
|
||||||
|
df_cost = df[df["is_abandoned"] != True]
|
||||||
|
else:
|
||||||
|
df_cost = df
|
||||||
|
|
||||||
|
# Filtrar por record_status: solo VALID para cálculo de AHT
|
||||||
|
# Excluye NOISE, ZOMBIE, ABANDON
|
||||||
|
if "_is_valid_for_cost" in df_cost.columns:
|
||||||
|
df_cost = df_cost[df_cost["_is_valid_for_cost"] == True]
|
||||||
|
|
||||||
|
if df_cost.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# AHT por skill/canal (en segundos) - solo registros VALID
|
||||||
|
grouped = df_cost.groupby(["queue_skill", "channel"])["handle_time"].mean()
|
||||||
|
|
||||||
if grouped.empty:
|
if grouped.empty:
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
@@ -141,9 +171,14 @@ class EconomyCostMetrics:
|
|||||||
aht_sec = grouped
|
aht_sec = grouped
|
||||||
aht_hours = aht_sec / 3600.0
|
aht_hours = aht_sec / 3600.0
|
||||||
|
|
||||||
|
# Apply productivity factor (70% effectiveness)
|
||||||
|
# This accounts for non-productive agent time (breaks, training, etc.)
|
||||||
|
EFFECTIVE_PRODUCTIVITY = 0.70
|
||||||
|
|
||||||
labor_cost = cfg.labor_cost_per_hour * aht_hours
|
labor_cost = cfg.labor_cost_per_hour * aht_hours
|
||||||
overhead = labor_cost * cfg.overhead_rate
|
overhead = labor_cost * cfg.overhead_rate
|
||||||
cpi = labor_cost + overhead
|
raw_cpi = labor_cost + overhead
|
||||||
|
cpi = raw_cpi / EFFECTIVE_PRODUCTIVITY
|
||||||
|
|
||||||
out = pd.DataFrame(
|
out = pd.DataFrame(
|
||||||
{
|
{
|
||||||
@@ -154,7 +189,8 @@ class EconomyCostMetrics:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return out.sort_index()
|
# Reset index to include queue_skill and channel as columns for frontend lookup
|
||||||
|
return out.sort_index().reset_index()
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# KPI 2: coste anual por skill/canal
|
# KPI 2: coste anual por skill/canal
|
||||||
@@ -180,7 +216,9 @@ class EconomyCostMetrics:
|
|||||||
.rename("volume")
|
.rename("volume")
|
||||||
)
|
)
|
||||||
|
|
||||||
joined = cpi_table.join(volume, how="left").fillna({"volume": 0})
|
# Set index on cpi_table to match volume's MultiIndex for join
|
||||||
|
cpi_indexed = cpi_table.set_index(["queue_skill", "channel"])
|
||||||
|
joined = cpi_indexed.join(volume, how="left").fillna({"volume": 0})
|
||||||
joined["annual_cost"] = (joined["cpi_total"] * joined["volume"]).round(2)
|
joined["annual_cost"] = (joined["cpi_total"] * joined["volume"]).round(2)
|
||||||
|
|
||||||
return joined
|
return joined
|
||||||
@@ -216,7 +254,9 @@ class EconomyCostMetrics:
|
|||||||
.rename("volume")
|
.rename("volume")
|
||||||
)
|
)
|
||||||
|
|
||||||
joined = cpi_table.join(volume, how="left").fillna({"volume": 0})
|
# Set index on cpi_table to match volume's MultiIndex for join
|
||||||
|
cpi_indexed = cpi_table.set_index(["queue_skill", "channel"])
|
||||||
|
joined = cpi_indexed.join(volume, how="left").fillna({"volume": 0})
|
||||||
|
|
||||||
# Costes anuales de labor y overhead
|
# Costes anuales de labor y overhead
|
||||||
annual_labor = (joined["labor_cost"] * joined["volume"]).sum()
|
annual_labor = (joined["labor_cost"] * joined["volume"]).sum()
|
||||||
@@ -252,7 +292,7 @@ class EconomyCostMetrics:
|
|||||||
- Ineff_seconds = Delta * volume * 0.4
|
- Ineff_seconds = Delta * volume * 0.4
|
||||||
- Ineff_cost = LaborCPI_per_second * Ineff_seconds
|
- Ineff_cost = LaborCPI_per_second * Ineff_seconds
|
||||||
|
|
||||||
⚠️ Es un modelo aproximado para cuantificar "orden de magnitud".
|
NOTA: Es un modelo aproximado para cuantificar "orden de magnitud".
|
||||||
"""
|
"""
|
||||||
if not self._has_cost_config():
|
if not self._has_cost_config():
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
@@ -261,6 +301,12 @@ class EconomyCostMetrics:
|
|||||||
assert cfg is not None
|
assert cfg is not None
|
||||||
|
|
||||||
df = self.df.copy()
|
df = self.df.copy()
|
||||||
|
|
||||||
|
# Filtrar por record_status: solo VALID para cálculo de AHT
|
||||||
|
# Excluye NOISE, ZOMBIE, ABANDON
|
||||||
|
if "_is_valid_for_cost" in df.columns:
|
||||||
|
df = df[df["_is_valid_for_cost"] == True]
|
||||||
|
|
||||||
grouped = df.groupby(["queue_skill", "channel"])
|
grouped = df.groupby(["queue_skill", "channel"])
|
||||||
|
|
||||||
stats = grouped["handle_time"].agg(
|
stats = grouped["handle_time"].agg(
|
||||||
@@ -273,10 +319,14 @@ class EconomyCostMetrics:
|
|||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
||||||
# CPI para obtener coste/segundo de labor
|
# CPI para obtener coste/segundo de labor
|
||||||
cpi_table = self.cpi_by_skill_channel()
|
# cpi_by_skill_channel now returns with reset_index, so we need to set index for join
|
||||||
if cpi_table.empty:
|
cpi_table_raw = self.cpi_by_skill_channel()
|
||||||
|
if cpi_table_raw.empty:
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# Set queue_skill+channel as index for the join
|
||||||
|
cpi_table = cpi_table_raw.set_index(["queue_skill", "channel"])
|
||||||
|
|
||||||
merged = stats.join(cpi_table[["labor_cost"]], how="left")
|
merged = stats.join(cpi_table[["labor_cost"]], how="left")
|
||||||
merged = merged.fillna(0.0)
|
merged = merged.fillna(0.0)
|
||||||
|
|
||||||
@@ -297,7 +347,8 @@ class EconomyCostMetrics:
|
|||||||
merged["ineff_seconds"] = ineff_seconds.round(2)
|
merged["ineff_seconds"] = ineff_seconds.round(2)
|
||||||
merged["ineff_cost"] = ineff_cost
|
merged["ineff_cost"] = ineff_cost
|
||||||
|
|
||||||
return merged[["aht_p50", "aht_p90", "volume", "ineff_seconds", "ineff_cost"]]
|
# Reset index to include queue_skill and channel as columns for frontend lookup
|
||||||
|
return merged[["aht_p50", "aht_p90", "volume", "ineff_seconds", "ineff_cost"]].reset_index()
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# KPI 5: ahorro potencial anual por automatización
|
# KPI 5: ahorro potencial anual por automatización
|
||||||
@@ -419,7 +470,9 @@ class EconomyCostMetrics:
|
|||||||
.rename("volume")
|
.rename("volume")
|
||||||
)
|
)
|
||||||
|
|
||||||
joined = cpi_table.join(volume, how="left").fillna({"volume": 0})
|
# Set index on cpi_table to match volume's MultiIndex for join
|
||||||
|
cpi_indexed = cpi_table.set_index(["queue_skill", "channel"])
|
||||||
|
joined = cpi_indexed.join(volume, how="left").fillna({"volume": 0})
|
||||||
|
|
||||||
# CPI medio ponderado por canal
|
# CPI medio ponderado por canal
|
||||||
per_channel = (
|
per_channel = (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -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,64 +188,54 @@ 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", "sí"])
|
||||||
|
)
|
||||||
|
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
|
|
||||||
df = self.df
|
|
||||||
if "transfer_flag" not in df.columns or len(df) == 0:
|
|
||||||
return float("nan")
|
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", "sí"])
|
||||||
|
)
|
||||||
|
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")
|
return float("nan")
|
||||||
|
|
||||||
customers = df["customer_id"].dropna().unique()
|
df = df.dropna(subset=["customer_id"])
|
||||||
if len(customers) == 0:
|
if df.empty:
|
||||||
return float("nan")
|
return float("nan")
|
||||||
|
|
||||||
recurrent_customers = 0
|
# Ordenar por cliente + skill + fecha
|
||||||
|
df = df.sort_values(["customer_id", "queue_skill", "datetime_start"])
|
||||||
|
|
||||||
for cust in customers:
|
# Diferencia de tiempo entre contactos consecutivos por cliente Y skill
|
||||||
sub = df[df["customer_id"] == cust].sort_values("datetime_start")
|
# Esto asegura que solo contamos recontactos del mismo cliente para el mismo skill
|
||||||
if len(sub) < 2:
|
df["delta"] = df.groupby(["customer_id", "queue_skill"])["datetime_start"].diff()
|
||||||
continue
|
|
||||||
deltas = sub["datetime_start"].diff().dropna()
|
|
||||||
if (deltas < pd.Timedelta(days=7)).any():
|
|
||||||
recurrent_customers += 1
|
|
||||||
|
|
||||||
if len(customers) == 0:
|
# 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")
|
||||||
|
|
||||||
return float(round(recurrent_customers / len(customers) * 100, 2))
|
rate = recurrent_customers / total_customers * 100.0
|
||||||
|
return float(round(rate, 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", "sí"])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
abandon_mask = pd.to_numeric(col, errors="coerce").fillna(0) > 0
|
||||||
|
abandoned = int(abandon_mask.sum())
|
||||||
|
abandonment_rate = float(round(abandoned / total * 100, 2))
|
||||||
|
|
||||||
|
# FCR Real (sin transferencia Y sin recontacto 7d)
|
||||||
|
fcr_real = fcr_tecnico # default to fcr_tecnico if no repeat data
|
||||||
|
if repeat_col and "transfer_flag" in group.columns:
|
||||||
|
repeat_data = group[repeat_col]
|
||||||
|
if repeat_data.dtype == "O":
|
||||||
|
repeat_mask = (
|
||||||
|
repeat_data.astype(str)
|
||||||
|
.str.strip()
|
||||||
|
.str.lower()
|
||||||
|
.isin(["true", "t", "1", "yes", "y", "si", "sí"])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
repeat_mask = pd.to_numeric(repeat_data, errors="coerce").fillna(0) > 0
|
||||||
|
|
||||||
|
# FCR Real: no transfer AND no repeat
|
||||||
|
fcr_real_mask = (~group["transfer_flag"]) & (~repeat_mask)
|
||||||
|
fcr_real_count = fcr_real_mask.sum()
|
||||||
|
fcr_real = float(round(fcr_real_count / total * 100, 2))
|
||||||
|
|
||||||
|
# AHT Mean (promedio de handle_time sobre registros válidos)
|
||||||
|
# Filtramos solo registros 'valid' (excluye noise/zombie) para consistencia
|
||||||
|
if "_is_valid_for_cv" in group.columns:
|
||||||
|
valid_records = group[group["_is_valid_for_cv"]]
|
||||||
|
else:
|
||||||
|
valid_records = group
|
||||||
|
|
||||||
|
if len(valid_records) > 0 and "handle_time" in valid_records.columns:
|
||||||
|
aht_mean = float(round(valid_records["handle_time"].mean(), 2))
|
||||||
|
else:
|
||||||
|
aht_mean = 0.0
|
||||||
|
|
||||||
|
# AHT Total (promedio de handle_time sobre TODOS los registros)
|
||||||
|
# Incluye NOISE, ZOMBIE, ABANDON - solo para información/comparación
|
||||||
|
if len(group) > 0 and "handle_time" in group.columns:
|
||||||
|
aht_total = float(round(group["handle_time"].mean(), 2))
|
||||||
|
else:
|
||||||
|
aht_total = 0.0
|
||||||
|
|
||||||
|
# Hold Time Mean (promedio de hold_time sobre registros válidos)
|
||||||
|
# Consistente con fresh path que usa MEAN, no P50
|
||||||
|
if len(valid_records) > 0 and "hold_time" in valid_records.columns:
|
||||||
|
hold_time_mean = float(round(valid_records["hold_time"].mean(), 2))
|
||||||
|
else:
|
||||||
|
hold_time_mean = 0.0
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"skill": str(skill),
|
||||||
|
"volume": int(total),
|
||||||
|
"transfer_rate": transfer_rate,
|
||||||
|
"abandonment_rate": abandonment_rate,
|
||||||
|
"fcr_tecnico": fcr_tecnico,
|
||||||
|
"fcr_real": fcr_real,
|
||||||
|
"aht_mean": aht_mean,
|
||||||
|
"aht_total": aht_total,
|
||||||
|
"hold_time_mean": hold_time_mean,
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|||||||
42
deploy.sh
Executable file
42
deploy.sh
Executable 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"
|
||||||
@@ -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
|
||||||
|
|||||||
94
frontend/components/DashboardHeader.tsx
Normal file
94
frontend/components/DashboardHeader.tsx
Normal 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;
|
||||||
107
frontend/components/DashboardTabs.tsx
Normal file
107
frontend/components/DashboardTabs.tsx
Normal 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;
|
||||||
@@ -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,6 +29,7 @@ interface DataInputRedesignedProps {
|
|||||||
file?: File;
|
file?: File;
|
||||||
sheetUrl?: string;
|
sheetUrl?: string;
|
||||||
useSynthetic?: boolean;
|
useSynthetic?: boolean;
|
||||||
|
useCache?: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
isAnalyzing: boolean;
|
isAnalyzing: boolean;
|
||||||
}
|
}
|
||||||
@@ -31,9 +38,11 @@ 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>('');
|
||||||
@@ -41,39 +50,78 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
|||||||
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 },
|
|
||||||
{ name: 'channel', type: 'String', example: 'Voice, Chat, WhatsApp', required: true },
|
|
||||||
{ name: 'duration_talk', type: 'Segundos', example: '345', required: true },
|
|
||||||
{ name: 'hold_time', type: 'Segundos', example: '45', required: true },
|
|
||||||
{ name: 'wrap_up_time', type: 'Segundos', example: '30', required: true },
|
|
||||||
{ name: 'agent_id', type: 'String', example: 'Agente_045', required: true },
|
|
||||||
{ name: 'transfer_flag', type: 'Boolean', example: 'TRUE / FALSE', required: true },
|
|
||||||
{ name: 'caller_id', type: 'String (hash)', example: 'Hash_99283', required: false },
|
|
||||||
{ name: 'csat_score', type: 'Float', example: '4', required: false }
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleDownloadTemplate = () => {
|
// Verificar caché del servidor al cargar
|
||||||
const headers = csvFields.map(f => f.name).join(',');
|
useEffect(() => {
|
||||||
const exampleRow = csvFields.map(f => f.example).join(',');
|
const checkCache = async () => {
|
||||||
const csvContent = `${headers}\n${exampleRow}\n`;
|
console.log('[DataInput] Checking server cache, authHeader:', authHeader ? 'present' : 'null');
|
||||||
|
if (!authHeader) {
|
||||||
|
console.log('[DataInput] No authHeader, skipping cache check');
|
||||||
|
setCheckingCache(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
try {
|
||||||
const link = document.createElement('a');
|
setCheckingCache(true);
|
||||||
link.href = URL.createObjectURL(blob);
|
console.log('[DataInput] Calling checkServerCache...');
|
||||||
link.download = 'plantilla_beyond_diagnostic.csv';
|
const { exists, metadata } = await checkServerCache(authHeader);
|
||||||
link.click();
|
console.log('[DataInput] Cache check result:', { exists, metadata });
|
||||||
|
if (exists && metadata) {
|
||||||
|
setCacheInfo(metadata);
|
||||||
|
console.log('[DataInput] Cache info set:', metadata);
|
||||||
|
// Auto-rellenar coste si hay en caché
|
||||||
|
if (metadata.costPerHour > 0 && !costPerHour) {
|
||||||
|
setCostPerHour(metadata.costPerHour.toString());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[DataInput] No cache found on server');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DataInput] Error checking server cache:', error);
|
||||||
|
} finally {
|
||||||
|
setCheckingCache(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkCache();
|
||||||
|
}, [authHeader]);
|
||||||
|
|
||||||
toast.success('Plantilla CSV descargada', { icon: '📥' });
|
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) => {
|
||||||
@@ -88,10 +136,9 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
|||||||
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: '❌' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -119,313 +166,292 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateSynthetic = () => {
|
const canAnalyze = file !== null && costPerHour !== '' && parseFloat(costPerHour) > 0;
|
||||||
setIsGenerating(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setUploadMethod('synthetic');
|
|
||||||
setIsGenerating(false);
|
|
||||||
toast.success('Datos sintéticos generados para demo', { icon: '✨' });
|
|
||||||
}, 1500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSheetUrlSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (sheetUrl.trim()) {
|
// Preparar segment_mapping
|
||||||
setUploadMethod('url');
|
const segmentMapping = (highValueQueues || mediumValueQueues || lowValueQueues) ? {
|
||||||
toast.success('URL de Google Sheets conectada', { icon: '🔗' });
|
high_value_queues: (highValueQueues || '').split(',').map(q => q.trim()).filter(q => q),
|
||||||
} else {
|
medium_value_queues: (mediumValueQueues || '').split(',').map(q => q.trim()).filter(q => q),
|
||||||
toast.error('Introduce una URL válida', { icon: '❌' });
|
low_value_queues: (lowValueQueues || '').split(',').map(q => q.trim()).filter(q => q)
|
||||||
}
|
} : undefined;
|
||||||
};
|
|
||||||
|
|
||||||
const canAnalyze = uploadMethod !== null && costPerHour > 0;
|
onAnalyze({
|
||||||
|
costPerHour: parseFloat(costPerHour) || 0,
|
||||||
|
avgCsat: parseFloat(avgCsat) || 0,
|
||||||
|
segmentMapping,
|
||||||
|
file: file || undefined,
|
||||||
|
useSynthetic: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
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>
|
|
||||||
<strong>Nota:</strong> Las colas no mapeadas se clasificarán automáticamente como "Medium".
|
|
||||||
El matching es flexible (no distingue mayúsculas y permite coincidencias parciales).
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Sección 2: Datos CSV */}
|
{/* Sección 2: Datos en Caché del Servidor (si hay) */}
|
||||||
|
{cacheInfo && (
|
||||||
<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: 0.15 }}
|
||||||
className="bg-white rounded-xl shadow-lg p-8 border-2 border-slate-200"
|
className="bg-emerald-50 rounded-lg shadow-sm p-4 sm:p-6 border-2 border-emerald-300"
|
||||||
>
|
>
|
||||||
<div className="mb-6">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<h2 className="text-2xl font-bold text-slate-900 mb-2 flex items-center gap-2">
|
<div>
|
||||||
<Table size={24} className="text-[#6D84E3]" />
|
<h2 className="text-lg font-semibold text-emerald-800 flex items-center gap-2">
|
||||||
2. Datos CSV (Raw Data de ACD)
|
<Server size={20} className="text-emerald-600" />
|
||||||
|
Datos en Caché
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-slate-600 text-sm">
|
|
||||||
Exporta estos campos desde tu sistema ACD/CTI (Genesys, Avaya, Talkdesk, Zendesk, etc.)
|
|
||||||
</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} />
|
|
||||||
Sí
|
|
||||||
</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
|
<button
|
||||||
onClick={handleDownloadTemplate}
|
onClick={handleClearCache}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5a6fc9] transition font-semibold"
|
className="p-2 text-emerald-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition"
|
||||||
|
title="Limpiar caché"
|
||||||
>
|
>
|
||||||
<Download size={18} />
|
<Trash2 size={18} />
|
||||||
Descargar Plantilla CSV
|
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xs text-slate-500 mt-2">
|
</div>
|
||||||
Descarga una plantilla con la estructura exacta de campos requeridos
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleUseCache}
|
||||||
|
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
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: cacheInfo ? 0.25 : 0.2 }}
|
||||||
|
className="bg-white rounded-lg shadow-sm p-4 sm:p-6 border border-slate-200"
|
||||||
|
>
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-800 mb-1 flex items-center gap-2">
|
||||||
|
<UploadCloud size={20} className="text-[#6D84E3]" />
|
||||||
|
{cacheInfo ? 'Subir Nuevo Archivo' : 'Datos CSV'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-500 text-sm">
|
||||||
|
{cacheInfo ? 'O sube un archivo diferente para analizar' : 'Sube el archivo exportado desde tu sistema ACD/CTI'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Opciones de carga */}
|
{/* Zona de subida */}
|
||||||
<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
|
<div
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'border-2 border-dashed rounded-lg p-6 text-center transition-all',
|
'border-2 border-dashed rounded-lg p-8 text-center transition-all cursor-pointer',
|
||||||
isDragging ? 'border-[#6D84E3] bg-blue-100' : 'border-slate-300 bg-slate-50'
|
isDragging ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300 bg-slate-50 hover:border-slate-400'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{file ? (
|
{file ? (
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<File size={24} className="text-green-600" />
|
<File size={24} className="text-emerald-600" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-semibold text-slate-900">{file.name}</p>
|
<p className="font-medium text-slate-800">{file.name}</p>
|
||||||
<p className="text-xs text-slate-500">{(file.size / 1024).toFixed(1)} KB</p>
|
<p className="text-xs text-slate-500">{(file.size / 1024).toFixed(1)} KB</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFile(null)}
|
onClick={(e) => {
|
||||||
className="ml-auto p-1 hover:bg-slate-200 rounded"
|
e.stopPropagation();
|
||||||
|
setFile(null);
|
||||||
|
}}
|
||||||
|
className="ml-4 p-1.5 hover:bg-slate-200 rounded-full transition"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} className="text-slate-500" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<UploadCloud size={32} className="mx-auto text-slate-400 mb-2" />
|
<UploadCloud size={40} className="mx-auto text-slate-400 mb-3" />
|
||||||
<p className="text-sm text-slate-600 mb-2">
|
<p className="text-slate-600 mb-2">
|
||||||
Arrastra tu archivo aquí o haz click para seleccionar
|
Arrastra tu archivo aquí o haz click para seleccionar
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mb-4">
|
||||||
|
Formatos aceptados: CSV, Excel (.xlsx, .xls)
|
||||||
|
</p>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".csv,.xlsx,.xls"
|
accept=".csv,.xlsx,.xls"
|
||||||
@@ -435,102 +461,13 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
|||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor="file-upload"
|
htmlFor="file-upload"
|
||||||
className="inline-block px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5a6fc9] transition cursor-pointer"
|
className="inline-block px-4 py-2 bg-[#6D84E3] text-white rounded-lg hover:bg-[#5a6fc9] transition cursor-pointer font-medium"
|
||||||
>
|
>
|
||||||
Seleccionar Archivo
|
Seleccionar Archivo
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Opción 2: URL Google Sheets
|
|
||||||
<div className={clsx(
|
|
||||||
'border-2 rounded-lg p-4 transition-all',
|
|
||||||
uploadMethod === 'url' ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300'
|
|
||||||
)}>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="uploadMethod"
|
|
||||||
checked={uploadMethod === 'url'}
|
|
||||||
onChange={() => setUploadMethod('url')}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="font-semibold text-slate-900 mb-2 flex items-center gap-2">
|
|
||||||
<Sheet size={18} className="text-[#6D84E3]" />
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
*/}
|
|
||||||
|
|
||||||
{/* Opción 3: Datos sintéticos */}
|
|
||||||
<div className={clsx(
|
|
||||||
'border-2 rounded-lg p-4 transition-all',
|
|
||||||
uploadMethod === 'synthetic' ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300'
|
|
||||||
)}>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="uploadMethod"
|
|
||||||
checked={uploadMethod === 'synthetic'}
|
|
||||||
onChange={() => setUploadMethod('synthetic')}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="font-semibold text-slate-900 mb-2 flex items-center gap-2">
|
|
||||||
<Sparkles size={18} className="text-[#6D84E3]" />
|
|
||||||
Generar Datos Sintéticos (Demo)
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Botón de análisis */}
|
{/* Botón de análisis */}
|
||||||
@@ -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
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
775
frontend/components/MetodologiaDrawer.tsx
Normal file
775
frontend/components/MetodologiaDrawer.tsx
Normal 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 (<10s) y ZOMBIE (>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;
|
||||||
@@ -81,13 +81,14 @@ const OpportunityMatrixPro: React.FC<OpportunityMatrixProProps> = ({ data, heatm
|
|||||||
};
|
};
|
||||||
}, [dataWithPriority]);
|
}, [dataWithPriority]);
|
||||||
|
|
||||||
// Dynamic title
|
// Dynamic title - v4.3: Top 10 iniciativas por potencial económico
|
||||||
const dynamicTitle = useMemo(() => {
|
const dynamicTitle = useMemo(() => {
|
||||||
const { quickWins } = portfolioSummary;
|
const totalQueues = dataWithPriority.length;
|
||||||
if (quickWins.count > 0) {
|
const totalSavings = portfolioSummary.totalSavings;
|
||||||
return `${quickWins.count} Quick Wins pueden generar €${(quickWins.savings / 1000).toFixed(0)}K en ahorros con implementación en Q1-Q2`;
|
if (totalQueues === 0) {
|
||||||
|
return 'No hay iniciativas con potencial de ahorro identificadas';
|
||||||
}
|
}
|
||||||
return `Portfolio de ${dataWithPriority.length} oportunidades identificadas con potencial de €${(portfolioSummary.totalSavings / 1000).toFixed(0)}K`;
|
return `Top ${totalQueues} iniciativas por potencial económico | Ahorro total: €${(totalSavings / 1000).toFixed(0)}K/año`;
|
||||||
}, [portfolioSummary, dataWithPriority]);
|
}, [portfolioSummary, dataWithPriority]);
|
||||||
|
|
||||||
const getQuadrantInfo = (impact: number, feasibility: number): QuadrantInfo => {
|
const getQuadrantInfo = (impact: number, feasibility: number): QuadrantInfo => {
|
||||||
@@ -160,21 +161,24 @@ const OpportunityMatrixPro: React.FC<OpportunityMatrixProProps> = ({ data, heatm
|
|||||||
<div id="opportunities" className="bg-white p-8 rounded-xl border border-slate-200 shadow-sm">
|
<div id="opportunities" className="bg-white p-8 rounded-xl border border-slate-200 shadow-sm">
|
||||||
{/* Header with Dynamic Title */}
|
{/* Header with Dynamic Title */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="font-bold text-2xl text-slate-800">Opportunity Matrix</h3>
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-bold text-2xl text-slate-800">Opportunity Matrix - Top 10 Iniciativas</h3>
|
||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
<HelpCircle size={18} className="text-slate-400 cursor-pointer" />
|
<HelpCircle size={18} className="text-slate-400 cursor-pointer" />
|
||||||
<div className="absolute bottom-full mb-2 w-80 bg-slate-800 text-white text-xs rounded py-2 px-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none z-10">
|
<div className="absolute bottom-full mb-2 w-80 bg-slate-800 text-white text-xs rounded py-2 px-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none z-10">
|
||||||
Prioriza iniciativas basadas en Impacto vs. Factibilidad. El tamaño de la burbuja representa el ahorro potencial. Los números indican la priorización estratégica. Click para ver detalles completos.
|
Top 10 colas por potencial económico (todos los tiers). Eje X = Factibilidad (Agentic Score), Eje Y = Impacto (Ahorro TCO). Tamaño = Ahorro potencial. 🤖=AUTOMATE, 🤝=ASSIST, 📚=AUGMENT.
|
||||||
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-800"></div>
|
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-x-4 border-x-transparent border-t-4 border-t-slate-800"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 italic">Priorizadas por potencial de ahorro TCO (🤖 AUTOMATE, 🤝 ASSIST, 📚 AUGMENT)</p>
|
||||||
|
</div>
|
||||||
<p className="text-base text-slate-700 font-medium leading-relaxed mb-1">
|
<p className="text-base text-slate-700 font-medium leading-relaxed mb-1">
|
||||||
{dynamicTitle}
|
{dynamicTitle}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
Portfolio de Oportunidades | Análisis de {dataWithPriority.length} iniciativas identificadas
|
{dataWithPriority.length} iniciativas identificadas | Ahorro TCO según tier (AUTOMATE 70%, ASSIST 30%, AUGMENT 15%)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -217,33 +221,33 @@ const OpportunityMatrixPro: React.FC<OpportunityMatrixProProps> = ({ data, heatm
|
|||||||
<div className="relative w-full h-[500px] border-l-2 border-b-2 border-slate-400 rounded-bl-lg bg-gradient-to-tr from-slate-50 to-white">
|
<div className="relative w-full h-[500px] border-l-2 border-b-2 border-slate-400 rounded-bl-lg bg-gradient-to-tr from-slate-50 to-white">
|
||||||
{/* Y-axis Label */}
|
{/* Y-axis Label */}
|
||||||
<div className="absolute -left-20 top-1/2 -translate-y-1/2 -rotate-90 text-sm font-bold text-slate-700 flex items-center gap-2">
|
<div className="absolute -left-20 top-1/2 -translate-y-1/2 -rotate-90 text-sm font-bold text-slate-700 flex items-center gap-2">
|
||||||
<TrendingUp size={18} /> IMPACTO
|
<TrendingUp size={18} /> IMPACTO (Ahorro TCO)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* X-axis Label */}
|
{/* X-axis Label */}
|
||||||
<div className="absolute -bottom-14 left-1/2 -translate-x-1/2 text-sm font-bold text-slate-700 flex items-center gap-2">
|
<div className="absolute -bottom-14 left-1/2 -translate-x-1/2 text-sm font-bold text-slate-700 flex items-center gap-2">
|
||||||
<Zap size={18} /> FACTIBILIDAD
|
<Zap size={18} /> FACTIBILIDAD (Agentic Score)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Axis scale labels */}
|
{/* Axis scale labels */}
|
||||||
<div className="absolute -left-2 top-0 -translate-x-full text-xs text-slate-500 font-medium">
|
<div className="absolute -left-2 top-0 -translate-x-full text-xs text-slate-500 font-medium">
|
||||||
Muy Alto
|
Alto (10)
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -left-2 top-1/2 -translate-x-full -translate-y-1/2 text-xs text-slate-500 font-medium">
|
<div className="absolute -left-2 top-1/2 -translate-x-full -translate-y-1/2 text-xs text-slate-500 font-medium">
|
||||||
Medio
|
Medio (5)
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -left-2 bottom-0 -translate-x-full text-xs text-slate-500 font-medium">
|
<div className="absolute -left-2 bottom-0 -translate-x-full text-xs text-slate-500 font-medium">
|
||||||
Bajo
|
Bajo (1)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute left-0 -bottom-2 translate-y-full text-xs text-slate-500 font-medium">
|
<div className="absolute left-0 -bottom-2 translate-y-full text-xs text-slate-500 font-medium">
|
||||||
Muy Difícil
|
0
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute left-1/2 -bottom-2 -translate-x-1/2 translate-y-full text-xs text-slate-500 font-medium">
|
<div className="absolute left-1/2 -bottom-2 -translate-x-1/2 translate-y-full text-xs text-slate-500 font-medium">
|
||||||
Moderado
|
5
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute right-0 -bottom-2 translate-y-full text-xs text-slate-500 font-medium">
|
<div className="absolute right-0 -bottom-2 translate-y-full text-xs text-slate-500 font-medium">
|
||||||
Fácil
|
10
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quadrant Lines */}
|
{/* Quadrant Lines */}
|
||||||
@@ -364,22 +368,24 @@ const OpportunityMatrixPro: React.FC<OpportunityMatrixProProps> = ({ data, heatm
|
|||||||
|
|
||||||
{/* Enhanced Legend */}
|
{/* Enhanced Legend */}
|
||||||
<div className="mt-8 p-4 bg-slate-50 rounded-lg">
|
<div className="mt-8 p-4 bg-slate-50 rounded-lg">
|
||||||
<div className="flex flex-wrap items-center gap-6 text-xs">
|
<div className="flex flex-wrap items-center gap-4 text-xs">
|
||||||
<span className="font-semibold text-slate-700">Tamaño de burbuja = Ahorro potencial:</span>
|
<span className="font-semibold text-slate-700">Tier:</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-4 h-4 rounded-full bg-slate-400"></div>
|
<span>🤖</span>
|
||||||
<span className="text-slate-700">Pequeño (<€50K)</span>
|
<span className="text-emerald-600 font-medium">AUTOMATE</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-6 h-6 rounded-full bg-slate-400"></div>
|
<span>🤝</span>
|
||||||
<span className="text-slate-700">Medio (€50-150K)</span>
|
<span className="text-blue-600 font-medium">ASSIST</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-8 h-8 rounded-full bg-slate-400"></div>
|
<span>📚</span>
|
||||||
<span className="text-slate-700">Grande (>€150K)</span>
|
<span className="text-amber-600 font-medium">AUGMENT</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-4 text-slate-500">|</span>
|
<span className="text-slate-400">|</span>
|
||||||
<span className="font-semibold text-slate-700">Número = Prioridad estratégica</span>
|
<span className="font-semibold text-slate-700">Tamaño = Ahorro TCO</span>
|
||||||
|
<span className="text-slate-400">|</span>
|
||||||
|
<span className="font-semibold text-slate-700">Número = Ranking</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -447,10 +453,10 @@ const OpportunityMatrixPro: React.FC<OpportunityMatrixProProps> = ({ data, heatm
|
|||||||
|
|
||||||
{/* Methodology Footer */}
|
{/* Methodology Footer */}
|
||||||
<MethodologyFooter
|
<MethodologyFooter
|
||||||
sources="Análisis interno de procesos operacionales | Benchmarks de implementación: Gartner Magic Quadrant for CCaaS 2024, Forrester Wave Contact Center 2024"
|
sources="Agentic Readiness Score (5 factores ponderados) | Modelo TCO con CPI diferenciado por tier"
|
||||||
methodology="Impacto: Basado en % reducción de AHT, mejora de FCR, y reducción de costes operacionales | Factibilidad: Evaluación de complejidad técnica (40%), cambio organizacional (30%), inversión requerida (30%) | Priorización: Score = (Impacto/10) × (Factibilidad/10) × (Ahorro/Max Ahorro)"
|
methodology="Factibilidad = Agentic Score (0-10) | Impacto = Ahorro TCO anual según tier: AUTOMATE (Vol/11×12×70%×€2.18), ASSIST (×30%×€0.83), AUGMENT (×15%×€0.33)"
|
||||||
notes="Ahorros calculados en escenario conservador (base case) sin incluir upside potencial | ROI calculado a 3 años con tasa de descuento 10%"
|
notes="Top 10 iniciativas ordenadas por potencial económico | CPI: Humano €2.33, Bot €0.15, Assist €1.50, Augment €2.00"
|
||||||
lastUpdated="Enero 2025"
|
lastUpdated="Enero 2026"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
623
frontend/components/OpportunityPrioritizer.tsx
Normal file
623
frontend/components/OpportunityPrioritizer.tsx
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
/**
|
||||||
|
* OpportunityPrioritizer - v1.0
|
||||||
|
*
|
||||||
|
* Redesigned Opportunity Matrix that clearly shows:
|
||||||
|
* 1. WHERE are the opportunities (ranked list with context)
|
||||||
|
* 2. WHERE to START (highlighted #1 with full justification)
|
||||||
|
* 3. WHY this prioritization (tier-based rationale + metrics)
|
||||||
|
*
|
||||||
|
* Design principles:
|
||||||
|
* - Scannable in 5 seconds (executive summary)
|
||||||
|
* - Actionable in 30 seconds (clear next steps)
|
||||||
|
* - Deep-dive available (expandable details)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Opportunity, DrilldownDataPoint, AgenticTier } from '../types';
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
TrendingUp,
|
||||||
|
Zap,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
Bot,
|
||||||
|
Headphones,
|
||||||
|
BookOpen,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
ArrowRight,
|
||||||
|
Info,
|
||||||
|
Target,
|
||||||
|
DollarSign,
|
||||||
|
BarChart3,
|
||||||
|
Sparkles
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface OpportunityPrioritizerProps {
|
||||||
|
opportunities: Opportunity[];
|
||||||
|
drilldownData?: DrilldownDataPoint[];
|
||||||
|
costPerHour?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnrichedOpportunity extends Opportunity {
|
||||||
|
rank: number;
|
||||||
|
tier: AgenticTier;
|
||||||
|
volume: number;
|
||||||
|
cv_aht: number;
|
||||||
|
transfer_rate: number;
|
||||||
|
fcr_rate: number;
|
||||||
|
agenticScore: number;
|
||||||
|
timelineMonths: number;
|
||||||
|
effortLevel: 'low' | 'medium' | 'high';
|
||||||
|
riskLevel: 'low' | 'medium' | 'high';
|
||||||
|
whyPrioritized: string[];
|
||||||
|
nextSteps: string[];
|
||||||
|
annualCost?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier configuration
|
||||||
|
const TIER_CONFIG: Record<AgenticTier, {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
bgColor: string;
|
||||||
|
borderColor: string;
|
||||||
|
savingsRate: string;
|
||||||
|
timeline: string;
|
||||||
|
description: string;
|
||||||
|
}> = {
|
||||||
|
'AUTOMATE': {
|
||||||
|
icon: <Bot size={18} />,
|
||||||
|
label: 'Automatizar',
|
||||||
|
color: 'text-emerald-700',
|
||||||
|
bgColor: 'bg-emerald-50',
|
||||||
|
borderColor: 'border-emerald-300',
|
||||||
|
savingsRate: '70%',
|
||||||
|
timeline: '3-6 meses',
|
||||||
|
description: 'Automatización completa con agentes IA'
|
||||||
|
},
|
||||||
|
'ASSIST': {
|
||||||
|
icon: <Headphones size={18} />,
|
||||||
|
label: 'Asistir',
|
||||||
|
color: 'text-blue-700',
|
||||||
|
bgColor: 'bg-blue-50',
|
||||||
|
borderColor: 'border-blue-300',
|
||||||
|
savingsRate: '30%',
|
||||||
|
timeline: '6-9 meses',
|
||||||
|
description: 'Copilot IA para agentes humanos'
|
||||||
|
},
|
||||||
|
'AUGMENT': {
|
||||||
|
icon: <BookOpen size={18} />,
|
||||||
|
label: 'Optimizar',
|
||||||
|
color: 'text-amber-700',
|
||||||
|
bgColor: 'bg-amber-50',
|
||||||
|
borderColor: 'border-amber-300',
|
||||||
|
savingsRate: '15%',
|
||||||
|
timeline: '9-12 meses',
|
||||||
|
description: 'Estandarización y mejora de procesos'
|
||||||
|
},
|
||||||
|
'HUMAN-ONLY': {
|
||||||
|
icon: <Users size={18} />,
|
||||||
|
label: 'Humano',
|
||||||
|
color: 'text-slate-600',
|
||||||
|
bgColor: 'bg-slate-50',
|
||||||
|
borderColor: 'border-slate-300',
|
||||||
|
savingsRate: '0%',
|
||||||
|
timeline: 'N/A',
|
||||||
|
description: 'Requiere intervención humana'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const OpportunityPrioritizer: React.FC<OpportunityPrioritizerProps> = ({
|
||||||
|
opportunities,
|
||||||
|
drilldownData,
|
||||||
|
costPerHour = 20
|
||||||
|
}) => {
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [showAllOpportunities, setShowAllOpportunities] = useState(false);
|
||||||
|
|
||||||
|
// Enrich opportunities with drilldown data
|
||||||
|
const enrichedOpportunities = useMemo((): EnrichedOpportunity[] => {
|
||||||
|
if (!opportunities || opportunities.length === 0) return [];
|
||||||
|
|
||||||
|
// Create a lookup map from drilldown data
|
||||||
|
const queueLookup = new Map<string, {
|
||||||
|
tier: AgenticTier;
|
||||||
|
volume: number;
|
||||||
|
cv_aht: number;
|
||||||
|
transfer_rate: number;
|
||||||
|
fcr_rate: number;
|
||||||
|
agenticScore: number;
|
||||||
|
annualCost?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
if (drilldownData) {
|
||||||
|
drilldownData.forEach(skill => {
|
||||||
|
skill.originalQueues?.forEach(q => {
|
||||||
|
queueLookup.set(q.original_queue_id.toLowerCase(), {
|
||||||
|
tier: q.tier || 'HUMAN-ONLY',
|
||||||
|
volume: q.volume,
|
||||||
|
cv_aht: q.cv_aht,
|
||||||
|
transfer_rate: q.transfer_rate,
|
||||||
|
fcr_rate: q.fcr_rate,
|
||||||
|
agenticScore: q.agenticScore,
|
||||||
|
annualCost: q.annualCost
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return opportunities.map((opp, index) => {
|
||||||
|
// Extract queue name (remove tier emoji prefix)
|
||||||
|
const cleanName = opp.name.replace(/^[^\w\s]+\s*/, '').toLowerCase();
|
||||||
|
const lookupData = queueLookup.get(cleanName);
|
||||||
|
|
||||||
|
// Determine tier from emoji prefix or lookup
|
||||||
|
let tier: AgenticTier = 'ASSIST';
|
||||||
|
if (opp.name.startsWith('🤖')) tier = 'AUTOMATE';
|
||||||
|
else if (opp.name.startsWith('🤝')) tier = 'ASSIST';
|
||||||
|
else if (opp.name.startsWith('📚')) tier = 'AUGMENT';
|
||||||
|
else if (lookupData) tier = lookupData.tier;
|
||||||
|
|
||||||
|
// Calculate effort and risk based on metrics
|
||||||
|
const cv = lookupData?.cv_aht || 50;
|
||||||
|
const transfer = lookupData?.transfer_rate || 15;
|
||||||
|
const effortLevel: 'low' | 'medium' | 'high' =
|
||||||
|
tier === 'AUTOMATE' && cv < 60 ? 'low' :
|
||||||
|
tier === 'ASSIST' || cv < 80 ? 'medium' : 'high';
|
||||||
|
|
||||||
|
const riskLevel: 'low' | 'medium' | 'high' =
|
||||||
|
cv < 50 && transfer < 15 ? 'low' :
|
||||||
|
cv < 80 && transfer < 30 ? 'medium' : 'high';
|
||||||
|
|
||||||
|
// Timeline based on tier
|
||||||
|
const timelineMonths = tier === 'AUTOMATE' ? 4 : tier === 'ASSIST' ? 7 : 10;
|
||||||
|
|
||||||
|
// Generate "why" explanation
|
||||||
|
const whyPrioritized: string[] = [];
|
||||||
|
if (opp.savings > 50000) whyPrioritized.push(`Alto ahorro potencial (€${(opp.savings / 1000).toFixed(0)}K/año)`);
|
||||||
|
if (lookupData?.volume && lookupData.volume > 1000) whyPrioritized.push(`Alto volumen (${lookupData.volume.toLocaleString()} interacciones)`);
|
||||||
|
if (tier === 'AUTOMATE') whyPrioritized.push('Proceso altamente predecible y repetitivo');
|
||||||
|
if (cv < 60) whyPrioritized.push('Baja variabilidad en tiempos de gestión');
|
||||||
|
if (transfer < 15) whyPrioritized.push('Baja tasa de transferencias');
|
||||||
|
if (opp.feasibility >= 7) whyPrioritized.push('Alta factibilidad técnica');
|
||||||
|
|
||||||
|
// Generate next steps
|
||||||
|
const nextSteps: string[] = [];
|
||||||
|
if (tier === 'AUTOMATE') {
|
||||||
|
nextSteps.push('Definir flujos conversacionales principales');
|
||||||
|
nextSteps.push('Identificar integraciones necesarias (CRM, APIs)');
|
||||||
|
nextSteps.push('Crear piloto con 10% del volumen');
|
||||||
|
} else if (tier === 'ASSIST') {
|
||||||
|
nextSteps.push('Mapear puntos de fricción del agente');
|
||||||
|
nextSteps.push('Diseñar sugerencias contextuales');
|
||||||
|
nextSteps.push('Piloto con equipo seleccionado');
|
||||||
|
} else {
|
||||||
|
nextSteps.push('Analizar causa raíz de variabilidad');
|
||||||
|
nextSteps.push('Estandarizar procesos y scripts');
|
||||||
|
nextSteps.push('Capacitar equipo en mejores prácticas');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...opp,
|
||||||
|
rank: index + 1,
|
||||||
|
tier,
|
||||||
|
volume: lookupData?.volume || Math.round(opp.savings / 10),
|
||||||
|
cv_aht: cv,
|
||||||
|
transfer_rate: transfer,
|
||||||
|
fcr_rate: lookupData?.fcr_rate || 75,
|
||||||
|
agenticScore: lookupData?.agenticScore || opp.feasibility,
|
||||||
|
timelineMonths,
|
||||||
|
effortLevel,
|
||||||
|
riskLevel,
|
||||||
|
whyPrioritized,
|
||||||
|
nextSteps,
|
||||||
|
annualCost: lookupData?.annualCost
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [opportunities, drilldownData]);
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const totalSavings = enrichedOpportunities.reduce((sum, o) => sum + o.savings, 0);
|
||||||
|
const byTier = {
|
||||||
|
AUTOMATE: enrichedOpportunities.filter(o => o.tier === 'AUTOMATE'),
|
||||||
|
ASSIST: enrichedOpportunities.filter(o => o.tier === 'ASSIST'),
|
||||||
|
AUGMENT: enrichedOpportunities.filter(o => o.tier === 'AUGMENT')
|
||||||
|
};
|
||||||
|
const quickWins = enrichedOpportunities.filter(o => o.tier === 'AUTOMATE' && o.effortLevel === 'low');
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSavings,
|
||||||
|
totalVolume: enrichedOpportunities.reduce((sum, o) => sum + o.volume, 0),
|
||||||
|
byTier,
|
||||||
|
quickWinsCount: quickWins.length,
|
||||||
|
quickWinsSavings: quickWins.reduce((sum, o) => sum + o.savings, 0)
|
||||||
|
};
|
||||||
|
}, [enrichedOpportunities]);
|
||||||
|
|
||||||
|
const displayedOpportunities = showAllOpportunities
|
||||||
|
? enrichedOpportunities
|
||||||
|
: enrichedOpportunities.slice(0, 5);
|
||||||
|
|
||||||
|
const topOpportunity = enrichedOpportunities[0];
|
||||||
|
|
||||||
|
if (!enrichedOpportunities.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-8 rounded-xl border border-slate-200 text-center">
|
||||||
|
<AlertTriangle className="mx-auto mb-4 text-amber-500" size={48} />
|
||||||
|
<h3 className="text-lg font-semibold text-slate-700">No hay oportunidades identificadas</h3>
|
||||||
|
<p className="text-slate-500 mt-2">Los datos actuales no muestran oportunidades de automatización viables.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||||
|
{/* Header - matching app's visual style */}
|
||||||
|
<div className="p-6 border-b border-slate-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Oportunidades Priorizadas</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{enrichedOpportunities.length} iniciativas ordenadas por potencial de ahorro y factibilidad
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Executive Summary - Answer "Where are opportunities?" in 5 seconds */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-6 bg-slate-50 border-b border-slate-200">
|
||||||
|
<div className="bg-white rounded-lg p-4 border border-slate-200 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 text-slate-500 text-xs mb-1">
|
||||||
|
<DollarSign size={14} />
|
||||||
|
<span>Ahorro Total Identificado</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-slate-800">
|
||||||
|
€{(summary.totalSavings / 1000).toFixed(0)}K
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">anuales</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-emerald-50 rounded-lg p-4 border border-emerald-200 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 text-emerald-600 text-xs mb-1">
|
||||||
|
<Bot size={14} />
|
||||||
|
<span>Quick Wins (AUTOMATE)</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-emerald-700">
|
||||||
|
{summary.byTier.AUTOMATE.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-emerald-600">
|
||||||
|
€{(summary.byTier.AUTOMATE.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K en 3-6 meses
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 text-blue-600 text-xs mb-1">
|
||||||
|
<Headphones size={14} />
|
||||||
|
<span>Asistencia (ASSIST)</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-blue-700">
|
||||||
|
{summary.byTier.ASSIST.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-blue-600">
|
||||||
|
€{(summary.byTier.ASSIST.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K en 6-9 meses
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-amber-50 rounded-lg p-4 border border-amber-200 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 text-amber-600 text-xs mb-1">
|
||||||
|
<BookOpen size={14} />
|
||||||
|
<span>Optimización (AUGMENT)</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-amber-700">
|
||||||
|
{summary.byTier.AUGMENT.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-amber-600">
|
||||||
|
€{(summary.byTier.AUGMENT.reduce((s, o) => s + o.savings, 0) / 1000).toFixed(0)}K en 9-12 meses
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* START HERE - Answer "Where do I start?" */}
|
||||||
|
{topOpportunity && (
|
||||||
|
<div className="p-6 bg-gradient-to-r from-emerald-50 to-green-50 border-b-2 border-emerald-200">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Sparkles className="text-emerald-600" size={20} />
|
||||||
|
<span className="text-emerald-800 font-bold text-lg">EMPIEZA AQUÍ</span>
|
||||||
|
<span className="bg-emerald-600 text-white text-xs px-2 py-0.5 rounded-full">Prioridad #1</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border-2 border-emerald-300 p-6 shadow-lg">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-start gap-6">
|
||||||
|
{/* Left: Main info */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className={`p-2 rounded-lg ${TIER_CONFIG[topOpportunity.tier].bgColor}`}>
|
||||||
|
{TIER_CONFIG[topOpportunity.tier].icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-slate-800">
|
||||||
|
{topOpportunity.name.replace(/^[^\w\s]+\s*/, '')}
|
||||||
|
</h3>
|
||||||
|
<span className={`text-sm font-medium ${TIER_CONFIG[topOpportunity.tier].color}`}>
|
||||||
|
{TIER_CONFIG[topOpportunity.tier].label} • {TIER_CONFIG[topOpportunity.tier].description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key metrics */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div className="bg-green-50 rounded-lg p-3">
|
||||||
|
<div className="text-xs text-green-600 mb-1">Ahorro Anual</div>
|
||||||
|
<div className="text-xl font-bold text-green-700">
|
||||||
|
€{(topOpportunity.savings / 1000).toFixed(0)}K
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 rounded-lg p-3">
|
||||||
|
<div className="text-xs text-slate-500 mb-1">Volumen</div>
|
||||||
|
<div className="text-xl font-bold text-slate-700">
|
||||||
|
{topOpportunity.volume.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 rounded-lg p-3">
|
||||||
|
<div className="text-xs text-slate-500 mb-1">Timeline</div>
|
||||||
|
<div className="text-xl font-bold text-slate-700">
|
||||||
|
{topOpportunity.timelineMonths} meses
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 rounded-lg p-3">
|
||||||
|
<div className="text-xs text-slate-500 mb-1">Agentic Score</div>
|
||||||
|
<div className="text-xl font-bold text-slate-700">
|
||||||
|
{topOpportunity.agenticScore.toFixed(1)}/10
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Why this is #1 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2">
|
||||||
|
<Info size={14} />
|
||||||
|
¿Por qué es la prioridad #1?
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{topOpportunity.whyPrioritized.slice(0, 4).map((reason, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<CheckCircle2 size={14} className="text-emerald-500 flex-shrink-0" />
|
||||||
|
{reason}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Next steps */}
|
||||||
|
<div className="lg:w-80 bg-emerald-50 rounded-lg p-4 border border-emerald-200">
|
||||||
|
<h4 className="text-sm font-semibold text-emerald-800 mb-3 flex items-center gap-2">
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
Próximos Pasos
|
||||||
|
</h4>
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{topOpportunity.nextSteps.map((step, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm text-emerald-700">
|
||||||
|
<span className="bg-emerald-600 text-white w-5 h-5 rounded-full flex items-center justify-center text-xs flex-shrink-0 mt-0.5">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
{step}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<button className="mt-4 w-full bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2 px-4 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||||
|
Ver Detalle Completo
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Full Opportunity List - Answer "What else?" */}
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2">
|
||||||
|
<BarChart3 size={20} />
|
||||||
|
Todas las Oportunidades Priorizadas
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{displayedOpportunities.slice(1).map((opp) => (
|
||||||
|
<motion.div
|
||||||
|
key={opp.id}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className={`border rounded-lg overflow-hidden transition-all ${
|
||||||
|
expandedId === opp.id ? 'border-blue-300 shadow-md' : 'border-slate-200 hover:border-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Collapsed view */}
|
||||||
|
<div
|
||||||
|
className="p-4 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||||
|
onClick={() => setExpandedId(expandedId === opp.id ? null : opp.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Rank */}
|
||||||
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-lg ${
|
||||||
|
opp.rank <= 3 ? 'bg-emerald-100 text-emerald-700' :
|
||||||
|
opp.rank <= 6 ? 'bg-blue-100 text-blue-700' :
|
||||||
|
'bg-slate-100 text-slate-600'
|
||||||
|
}`}>
|
||||||
|
#{opp.rank}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tier icon and name */}
|
||||||
|
<div className={`p-2 rounded-lg ${TIER_CONFIG[opp.tier].bgColor}`}>
|
||||||
|
{TIER_CONFIG[opp.tier].icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-semibold text-slate-800 truncate">
|
||||||
|
{opp.name.replace(/^[^\w\s]+\s*/, '')}
|
||||||
|
</h4>
|
||||||
|
<span className={`text-xs ${TIER_CONFIG[opp.tier].color}`}>
|
||||||
|
{TIER_CONFIG[opp.tier].label} • {TIER_CONFIG[opp.tier].timeline}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick stats */}
|
||||||
|
<div className="hidden md:flex items-center gap-6">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-slate-500">Ahorro</div>
|
||||||
|
<div className="font-bold text-green-600">€{(opp.savings / 1000).toFixed(0)}K</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-slate-500">Volumen</div>
|
||||||
|
<div className="font-semibold text-slate-700">{opp.volume.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-slate-500">Score</div>
|
||||||
|
<div className="font-semibold text-slate-700">{opp.agenticScore.toFixed(1)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual bar: Value vs Effort */}
|
||||||
|
<div className="hidden lg:block w-32">
|
||||||
|
<div className="text-xs text-slate-500 mb-1">Valor / Esfuerzo</div>
|
||||||
|
<div className="flex h-2 rounded-full overflow-hidden bg-slate-100">
|
||||||
|
<div
|
||||||
|
className="bg-emerald-500 transition-all"
|
||||||
|
style={{ width: `${Math.min(100, opp.impact * 10)}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="bg-amber-400 transition-all"
|
||||||
|
style={{ width: `${Math.min(100 - opp.impact * 10, (10 - opp.feasibility) * 10)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-400 mt-0.5">
|
||||||
|
<span>Valor</span>
|
||||||
|
<span>Esfuerzo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expand icon */}
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: expandedId === opp.id ? 90 : 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<ChevronRight className="text-slate-400" size={20} />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded details */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{expandedId === opp.id && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="p-4 bg-slate-50 border-t border-slate-200">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Why prioritized */}
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-semibold text-slate-700 mb-2">¿Por qué esta posición?</h5>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{opp.whyPrioritized.map((reason, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<CheckCircle2 size={12} className="text-emerald-500 flex-shrink-0" />
|
||||||
|
{reason}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics */}
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-semibold text-slate-700 mb-2">Métricas Clave</h5>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="bg-white rounded p-2 border border-slate-200">
|
||||||
|
<div className="text-xs text-slate-500">CV AHT</div>
|
||||||
|
<div className="font-semibold text-slate-700">{opp.cv_aht.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded p-2 border border-slate-200">
|
||||||
|
<div className="text-xs text-slate-500">Transfer Rate</div>
|
||||||
|
<div className="font-semibold text-slate-700">{opp.transfer_rate.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded p-2 border border-slate-200">
|
||||||
|
<div className="text-xs text-slate-500">FCR</div>
|
||||||
|
<div className="font-semibold text-slate-700">{opp.fcr_rate.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded p-2 border border-slate-200">
|
||||||
|
<div className="text-xs text-slate-500">Riesgo</div>
|
||||||
|
<div className={`font-semibold ${
|
||||||
|
opp.riskLevel === 'low' ? 'text-emerald-600' :
|
||||||
|
opp.riskLevel === 'medium' ? 'text-amber-600' : 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{opp.riskLevel === 'low' ? 'Bajo' : opp.riskLevel === 'medium' ? 'Medio' : 'Alto'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next steps */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-slate-200">
|
||||||
|
<h5 className="text-sm font-semibold text-slate-700 mb-2">Próximos Pasos</h5>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{opp.nextSteps.map((step, i) => (
|
||||||
|
<span key={i} className="bg-white border border-slate-200 rounded-full px-3 py-1 text-xs text-slate-600">
|
||||||
|
{i + 1}. {step}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show more button */}
|
||||||
|
{enrichedOpportunities.length > 5 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAllOpportunities(!showAllOpportunities)}
|
||||||
|
className="mt-4 w-full py-3 border border-slate-200 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{showAllOpportunities ? (
|
||||||
|
<>
|
||||||
|
<ChevronDown size={16} className="rotate-180" />
|
||||||
|
Mostrar menos
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
Ver {enrichedOpportunities.length - 5} oportunidades más
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Methodology note */}
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 text-xs text-slate-500">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Info size={14} className="flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<strong>Metodología de priorización:</strong> Las oportunidades se ordenan por potencial de ahorro TCO (volumen × tasa de contención × diferencial CPI).
|
||||||
|
La clasificación de tier (AUTOMATE/ASSIST/AUGMENT) se basa en el Agentic Readiness Score considerando predictibilidad (CV AHT),
|
||||||
|
resolutividad (FCR + Transfer), volumen, calidad de datos y simplicidad del proceso.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OpportunityPrioritizer;
|
||||||
@@ -1,30 +1,23 @@
|
|||||||
// 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;
|
||||||
avgCsat: number;
|
avgCsat: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exigir estar logado para analizar
|
||||||
|
if (!authHeader) {
|
||||||
|
toast.error('Debes iniciar sesión para analizar datos.');
|
||||||
return;
|
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,
|
|
||||||
|
if (config.useCache) {
|
||||||
|
// Usar datos desde caché
|
||||||
|
data = await generateAnalysisFromCache(
|
||||||
|
'gold' as TierKey,
|
||||||
config.costPerHour,
|
config.costPerHour,
|
||||||
config.avgCsat,
|
config.avgCsat || 0,
|
||||||
|
config.segmentMapping,
|
||||||
|
authHeader || undefined
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Usar tier 'gold' por defecto
|
||||||
|
data = await generateAnalysis(
|
||||||
|
'gold' as TierKey,
|
||||||
|
config.costPerHour,
|
||||||
|
config.avgCsat || 0,
|
||||||
config.segmentMapping,
|
config.segmentMapping,
|
||||||
config.file,
|
config.file,
|
||||||
config.sheetUrl,
|
config.sheetUrl,
|
||||||
config.useSynthetic,
|
false, // No usar sintético
|
||||||
authHeader || undefined
|
authHeader || undefined
|
||||||
);
|
);
|
||||||
console.log('✅ Analysis generated successfully');
|
}
|
||||||
|
|
||||||
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">
|
||||||
@@ -136,56 +138,34 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<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="min-h-screen bg-slate-50">
|
||||||
<div className="w-full max-w-7xl mx-auto p-6 space-y-8">
|
{/* Header estilo dashboard */}
|
||||||
{/* Header */}
|
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
|
||||||
<motion.div
|
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||||
initial={{ opacity: 0, y: -20 }}
|
<div className="flex items-center justify-between">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<h1 className="text-xl font-bold text-slate-800">
|
||||||
className="text-center"
|
AIR EUROPA - Beyond CX Analytics
|
||||||
>
|
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-900 mb-3">
|
|
||||||
Beyond Diagnostic
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-slate-600">
|
<div className="flex items-center gap-4">
|
||||||
Análisis de Readiness Agéntico para Contact Centers
|
<span className="text-sm text-slate-500">{formatDateMonthYear()}</span>
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="text-xs text-slate-500 hover:text-slate-800 underline mt-1"
|
className="text-xs text-slate-500 hover:text-slate-800 underline"
|
||||||
>
|
>
|
||||||
Cerrar sesión
|
Cerrar sesión
|
||||||
</button>
|
</button>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Tier Selection */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.1 }}
|
|
||||||
className="bg-white rounded-xl shadow-lg p-8"
|
|
||||||
>
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-3xl font-bold text-slate-900 mb-2">
|
|
||||||
Selecciona tu Tier de Análisis
|
|
||||||
</h2>
|
|
||||||
<p className="text-slate-600">
|
|
||||||
Elige el nivel de profundidad que necesitas para tu diagnóstico
|
|
||||||
</p>
|
|
||||||
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
159
frontend/components/charts/BulletChart.tsx
Normal file
159
frontend/components/charts/BulletChart.tsx
Normal 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;
|
||||||
214
frontend/components/charts/OpportunityTreemap.tsx
Normal file
214
frontend/components/charts/OpportunityTreemap.tsx
Normal 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;
|
||||||
197
frontend/components/charts/WaterfallChart.tsx
Normal file
197
frontend/components/charts/WaterfallChart.tsx
Normal 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;
|
||||||
3721
frontend/components/tabs/AgenticReadinessTab.tsx
Normal file
3721
frontend/components/tabs/AgenticReadinessTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
654
frontend/components/tabs/DimensionAnalysisTab.tsx
Normal file
654
frontend/components/tabs/DimensionAnalysisTab.tsx
Normal 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;
|
||||||
1277
frontend/components/tabs/ExecutiveSummaryTab.tsx
Normal file
1277
frontend/components/tabs/ExecutiveSummaryTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1533
frontend/components/tabs/Law10Tab.tsx
Normal file
1533
frontend/components/tabs/Law10Tab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
2719
frontend/components/tabs/RoadmapTab.tsx
Normal file
2719
frontend/components/tabs/RoadmapTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
595
frontend/components/ui/index.tsx
Normal file
595
frontend/components/ui/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
268
frontend/config/designSystem.ts
Normal file
268
frontend/config/designSystem.ts
Normal 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(' ');
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -70,12 +130,15 @@ export interface SkillMetrics {
|
|||||||
channel: string; // Canal predominante
|
channel: string; // Canal predominante
|
||||||
|
|
||||||
// Métricas de rendimiento (calculadas)
|
// Métricas de rendimiento (calculadas)
|
||||||
fcr: number; // FCR aproximado: 100% - transfer_rate
|
fcr: number; // FCR Real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE) - sin recontacto 7 días
|
||||||
|
fcr_tecnico: number; // FCR Técnico: 100% - transfer_rate (comparable con benchmarks de industria)
|
||||||
|
fcr_real: number; // Alias de fcr - FCR Real con filtro de recontacto 7 días
|
||||||
aht: number; // AHT = duration_talk + hold_time + wrap_up_time
|
aht: number; // AHT = duration_talk + hold_time + wrap_up_time
|
||||||
avg_talk_time: number; // Promedio duration_talk
|
avg_talk_time: number; // Promedio duration_talk
|
||||||
avg_hold_time: number; // Promedio hold_time
|
avg_hold_time: number; // Promedio hold_time
|
||||||
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 (%)
|
||||||
@@ -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
241
frontend/utils/dataCache.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -5,6 +5,35 @@
|
|||||||
|
|
||||||
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
|
||||||
*/
|
*/
|
||||||
@@ -18,21 +47,51 @@ export async function parseCSV(file: File): Promise<RawInteraction[]> {
|
|||||||
|
|
||||||
// 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
|
// Verificar campos clave
|
||||||
const requiredFields = [
|
const keyFields = ['is_abandoned', 'fcr_real_flag', 'repeat_call_7d', 'transfer_flag', 'record_status'];
|
||||||
'interaction_id',
|
const foundKeyFields = keyFields.filter(f => headers.includes(f));
|
||||||
'datetime_start',
|
const missingKeyFields = keyFields.filter(f => !headers.includes(f));
|
||||||
'queue_skill',
|
console.log('✅ Campos clave encontrados:', foundKeyFields);
|
||||||
'channel',
|
console.log('⚠️ Campos clave NO encontrados:', missingKeyFields.length > 0 ? missingKeyFields : 'TODOS PRESENTES');
|
||||||
'duration_talk',
|
|
||||||
'hold_time',
|
// Debug: Mostrar las primeras 5 filas con valores crudos de campos booleanos
|
||||||
'wrap_up_time',
|
console.log('📋 VALORES CRUDOS DE CAMPOS BOOLEANOS (primeras 5 filas):');
|
||||||
'agent_id',
|
for (let rowNum = 1; rowNum <= Math.min(5, lines.length - 1); rowNum++) {
|
||||||
'transfer_flag'
|
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(', ')}`);
|
||||||
}
|
}
|
||||||
@@ -40,11 +99,21 @@ export async function parseCSV(file: File): Promise<RawInteraction[]> {
|
|||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,17 +123,61 @@ export async function parseCSV(file: File): Promise<RawInteraction[]> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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);
|
||||||
@@ -73,15 +186,51 @@ export async function parseCSV(file: File): Promise<RawInteraction[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 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) => {
|
||||||
@@ -92,11 +241,9 @@ export async function parseExcel(file: File): Promise<RawInteraction[]> {
|
|||||||
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) {
|
||||||
@@ -104,35 +251,74 @@ export async function parseExcel(file: File): Promise<RawInteraction[]> {
|
|||||||
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 {
|
try {
|
||||||
const durationStr = row.duration_talk || row.Duration_Talk || row['Duration Talk'] || '0';
|
// === PARSING SIMPLE Y DIRECTO ===
|
||||||
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);
|
// is_abandoned
|
||||||
const holdTimeVal = isNaN(parseFloat(holdStr)) ? 0 : parseFloat(holdStr);
|
const isAbandonedRaw = getColumnValue(row, 'is_abandoned', 'Is_Abandoned', 'Is Abandoned', 'abandoned');
|
||||||
const wrapUpTimeVal = isNaN(parseFloat(wrapStr)) ? 0 : parseFloat(wrapStr);
|
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);
|
||||||
}
|
}
|
||||||
@@ -141,6 +327,16 @@ export async function parseExcel(file: File): Promise<RawInteraction[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
@@ -205,14 +401,22 @@ export function validateInteractions(interactions: RawInteraction[]): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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.`);
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
15
frontend/utils/formatters.ts
Normal file
15
frontend/utils/formatters.ts
Normal 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
260
frontend/utils/serverCache.ts
Normal file
260
frontend/utils/serverCache.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
43
nginx/conf.d/beyond.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user