Commit inicial
This commit is contained in:
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`
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import json
|
||||
import math
|
||||
@@ -12,6 +13,10 @@ from fastapi.responses import JSONResponse
|
||||
from beyond_api.security import get_current_user
|
||||
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(
|
||||
prefix="",
|
||||
tags=["analysis"],
|
||||
@@ -117,3 +122,100 @@ async def analysis_endpoint(
|
||||
"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,
|
||||
}
|
||||
)
|
||||
|
||||
250
backend/beyond_api/api/cache.py
Normal file
250
backend/beyond_api/api/cache.py
Normal file
@@ -0,0 +1,250 @@
|
||||
# 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
|
||||
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
|
||||
CACHE_DIR = Path(os.getenv("CACHE_DIR", "/data/cache"))
|
||||
CACHED_FILE = CACHE_DIR / "cached_data.csv"
|
||||
METADATA_FILE = CACHE_DIR / "metadata.json"
|
||||
DRILLDOWN_FILE = CACHE_DIR / "drilldown_data.json"
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
if not DRILLDOWN_FILE.exists():
|
||||
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)
|
||||
return JSONResponse(content={"success": True, "drilldownData": drilldown_data})
|
||||
except Exception as 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.
|
||||
"""
|
||||
ensure_cache_dir()
|
||||
|
||||
try:
|
||||
# Parse and validate JSON
|
||||
drilldown_data = json.loads(drilldown_json)
|
||||
|
||||
# Save to file
|
||||
with open(DRILLDOWN_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(drilldown_data, f)
|
||||
|
||||
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
|
||||
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:
|
||||
logging.basicConfig(
|
||||
@@ -30,4 +31,5 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
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,33 @@ import secrets
|
||||
from fastapi import Depends, HTTPException, status
|
||||
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.
|
||||
BASIC_USER = os.getenv("BASIC_AUTH_USERNAME", "beyond")
|
||||
BASIC_PASS = os.getenv("BASIC_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.
|
||||
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_password = secrets.compare_digest(credentials.password, BASIC_PASS)
|
||||
|
||||
if not (correct_username and correct_password):
|
||||
# Importante devolver el header WWW-Authenticate para que el navegador saque el prompt
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Credenciales incorrectas",
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
)
|
||||
|
||||
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]:
|
||||
"""
|
||||
Clasificación final:
|
||||
- 8–10: AUTOMATE 🤖
|
||||
- 5–7.99: ASSIST 🤝
|
||||
- 3–4.99: AUGMENT 🧠
|
||||
- 0–2.99: HUMAN_ONLY 👤
|
||||
Clasificación final (alineada con frontend):
|
||||
- ≥6: COPILOT 🤖 (Listo para Copilot)
|
||||
- 4–5.99: OPTIMIZE 🔧 (Optimizar Primero)
|
||||
- <4: HUMAN 👤 (Requiere Gestión Humana)
|
||||
|
||||
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:
|
||||
label = "AUTOMATE"
|
||||
if score >= 6.0:
|
||||
label = "COPILOT"
|
||||
emoji = "🤖"
|
||||
description = (
|
||||
"Alta repetitividad, alta predictibilidad y ROI elevado. "
|
||||
"Candidato a automatización completa (chatbot/IVR inteligente)."
|
||||
"Listo para Copilot. Procesos con predictibilidad y simplicidad "
|
||||
"suficientes para asistencia IA (sugerencias en tiempo real, autocompletado)."
|
||||
)
|
||||
elif score >= 5.0:
|
||||
label = "ASSIST"
|
||||
emoji = "🤝"
|
||||
elif score >= 4.0:
|
||||
label = "OPTIMIZE"
|
||||
emoji = "🔧"
|
||||
description = (
|
||||
"Complejidad media o ROI limitado. Recomendado enfoque de copilot "
|
||||
"para agentes (sugerencias en tiempo real, autocompletado, etc.)."
|
||||
)
|
||||
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)."
|
||||
"Optimizar primero. Estandarizar procesos y reducir variabilidad "
|
||||
"antes de implementar asistencia IA."
|
||||
)
|
||||
else:
|
||||
label = "HUMAN_ONLY"
|
||||
label = "HUMAN"
|
||||
emoji = "👤"
|
||||
description = (
|
||||
"Procesos de muy bajo volumen o extremadamente complejos. Mejor "
|
||||
"mantener operación 100% humana de momento."
|
||||
"Requiere gestión humana. Procesos complejos o variables que "
|
||||
"necesitan intervención humana antes de considerar automatización."
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"fcr_rate",
|
||||
"escalation_rate",
|
||||
"abandonment_rate",
|
||||
"high_hold_time_rate",
|
||||
"recurrence_rate_7d",
|
||||
"repeat_channel_rate",
|
||||
"occupancy_rate",
|
||||
|
||||
@@ -86,6 +86,16 @@ class OperationalPerformanceMetrics:
|
||||
+ 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' (o sin status = legacy data)
|
||||
if "record_status" in df.columns:
|
||||
df["record_status"] = df["record_status"].astype(str).str.strip().str.upper()
|
||||
# Crear máscara para registros válidos (para cálculos de CV/variabilidad)
|
||||
df["_is_valid_for_cv"] = df["record_status"].isin(["VALID", "NAN", ""]) | df["record_status"].isna()
|
||||
else:
|
||||
df["_is_valid_for_cv"] = True
|
||||
|
||||
# Normalización básica
|
||||
df["queue_skill"] = df["queue_skill"].astype(str).str.strip()
|
||||
df["channel"] = df["channel"].astype(str).str.strip()
|
||||
@@ -121,8 +131,13 @@ class OperationalPerformanceMetrics:
|
||||
def aht_distribution(self) -> Dict[str, float]:
|
||||
"""
|
||||
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:
|
||||
return {}
|
||||
|
||||
@@ -165,56 +180,45 @@ class OperationalPerformanceMetrics:
|
||||
# ------------------------------------------------------------------ #
|
||||
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.
|
||||
Si no se puede calcular escalation_rate, intentamos derivarlo
|
||||
directamente de la columna transfer_flag. Si todo falla, devolvemos NaN.
|
||||
Prioridad 1: Usar fcr_real_flag del CSV si existe
|
||||
Prioridad 2: Calcular como 100 - escalation_rate
|
||||
"""
|
||||
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:
|
||||
esc = self.escalation_rate()
|
||||
except Exception:
|
||||
esc = float("nan")
|
||||
|
||||
# Si escalation_rate es válido, usamos el proxy simple
|
||||
if esc is not None and not math.isnan(esc):
|
||||
fcr = 100.0 - esc
|
||||
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")
|
||||
|
||||
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))))
|
||||
return float("nan")
|
||||
|
||||
|
||||
def escalation_rate(self) -> float:
|
||||
@@ -233,20 +237,57 @@ class OperationalPerformanceMetrics:
|
||||
"""
|
||||
% de interacciones abandonadas.
|
||||
|
||||
Definido como % de filas con abandoned_flag == True.
|
||||
Si la columna no existe, devuelve NaN.
|
||||
Busca en orden: is_abandoned, abandoned_flag, abandoned
|
||||
Si ninguna columna existe, devuelve NaN.
|
||||
"""
|
||||
df = self.df
|
||||
if "abandoned_flag" not in df.columns:
|
||||
return float("nan")
|
||||
|
||||
total = len(df)
|
||||
if total == 0:
|
||||
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))
|
||||
|
||||
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:
|
||||
"""
|
||||
% de clientes que vuelven a contactar en < 7 días.
|
||||
|
||||
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"
|
||||
@@ -9,6 +9,9 @@ services:
|
||||
# credenciales del API (las mismas que usas ahora)
|
||||
BASIC_AUTH_USERNAME: "beyond"
|
||||
BASIC_AUTH_PASSWORD: "beyond2026"
|
||||
CACHE_DIR: "/data/cache"
|
||||
volumes:
|
||||
- cache-data:/data/cache
|
||||
expose:
|
||||
- "8000"
|
||||
networks:
|
||||
@@ -41,6 +44,10 @@ services:
|
||||
networks:
|
||||
- beyond-net
|
||||
|
||||
volumes:
|
||||
cache-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
beyond-net:
|
||||
driver: bridge
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { LayoutDashboard, Layers, Bot, Map } from 'lucide-react';
|
||||
import { formatDateMonthYear } from '../utils/formatters';
|
||||
|
||||
export type TabId = 'executive' | 'dimensions' | 'readiness' | 'roadmap';
|
||||
|
||||
@@ -22,15 +23,6 @@ const TABS: TabConfig[] = [
|
||||
{ id: 'roadmap', label: 'Roadmap', icon: Map },
|
||||
];
|
||||
|
||||
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()}`;
|
||||
};
|
||||
|
||||
export function DashboardHeader({
|
||||
title = 'AIR EUROPA - Beyond CX Analytics',
|
||||
activeTab,
|
||||
@@ -39,15 +31,15 @@ export function DashboardHeader({
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
|
||||
{/* Top row: Title and Date */}
|
||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-slate-800">{title}</h1>
|
||||
<span className="text-sm text-slate-500">{formatDate()}</span>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-base sm:text-xl font-bold text-slate-800 truncate">{title}</h1>
|
||||
<span className="text-xs sm:text-sm text-slate-500 flex-shrink-0">{formatDateMonthYear()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<nav className="max-w-7xl mx-auto px-6">
|
||||
<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;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { ArrowLeft, ShieldCheck, Info } from 'lucide-react';
|
||||
import { DashboardHeader, TabId } from './DashboardHeader';
|
||||
import { ExecutiveSummaryTab } from './tabs/ExecutiveSummaryTab';
|
||||
import { DimensionAnalysisTab } from './tabs/DimensionAnalysisTab';
|
||||
import { AgenticReadinessTab } from './tabs/AgenticReadinessTab';
|
||||
import { RoadmapTab } from './tabs/RoadmapTab';
|
||||
import { MetodologiaDrawer } from './MetodologiaDrawer';
|
||||
import type { AnalysisData } from '../types';
|
||||
|
||||
interface DashboardTabsProps {
|
||||
@@ -20,15 +21,16 @@ export function DashboardTabs({
|
||||
onBack
|
||||
}: DashboardTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('executive');
|
||||
const [metodologiaOpen, setMetodologiaOpen] = useState(false);
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'executive':
|
||||
return <ExecutiveSummaryTab data={data} />;
|
||||
return <ExecutiveSummaryTab data={data} onTabChange={setActiveTab} />;
|
||||
case 'dimensions':
|
||||
return <DimensionAnalysisTab data={data} />;
|
||||
case 'readiness':
|
||||
return <AgenticReadinessTab data={data} />;
|
||||
return <AgenticReadinessTab data={data} onTabChange={setActiveTab} />;
|
||||
case 'roadmap':
|
||||
return <RoadmapTab data={data} />;
|
||||
default:
|
||||
@@ -41,13 +43,14 @@ export function DashboardTabs({
|
||||
{/* Back button */}
|
||||
{onBack && (
|
||||
<div className="bg-white border-b border-slate-200">
|
||||
<div className="max-w-7xl mx-auto px-6 py-2">
|
||||
<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" />
|
||||
Volver al formulario
|
||||
<span className="hidden sm:inline">Volver al formulario</span>
|
||||
<span className="sm:hidden">Volver</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,7 +64,7 @@ export function DashboardTabs({
|
||||
/>
|
||||
|
||||
{/* Tab Content */}
|
||||
<main className="max-w-7xl mx-auto px-6 py-6">
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-4 sm:py-6">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
@@ -77,16 +80,37 @@ export function DashboardTabs({
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-slate-200 bg-white mt-8">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between text-sm text-slate-500">
|
||||
<span>Beyond Diagnosis - Contact Center Analytics Platform</span>
|
||||
<span>
|
||||
Análisis: {data.tier ? data.tier.toUpperCase() : 'GOLD'} |
|
||||
Fuente: {data.source || 'synthetic'}
|
||||
</span>
|
||||
<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>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<span className="text-xs sm:text-sm">
|
||||
{data.tier ? data.tier.toUpperCase() : 'GOLD'} |
|
||||
{data.source === 'backend' ? 'Genesys' : data.source || 'synthetic'}
|
||||
</span>
|
||||
<span className="hidden sm:inline text-slate-300">|</span>
|
||||
{/* Badge Metodología */}
|
||||
<button
|
||||
onClick={() => setMetodologiaOpen(true)}
|
||||
className="inline-flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 bg-green-100 text-green-800 rounded-full text-[10px] sm:text-xs font-medium hover:bg-green-200 transition-colors cursor-pointer"
|
||||
>
|
||||
<ShieldCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
||||
<span className="hidden md:inline">Metodología de Transformación de Datos aplicada</span>
|
||||
<span className="md:hidden">Metodología</span>
|
||||
<Info className="w-2.5 h-2.5 sm:w-3 sm:h-3 opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Drawer de Metodología */}
|
||||
<MetodologiaDrawer
|
||||
isOpen={metodologiaOpen}
|
||||
onClose={() => setMetodologiaOpen(false)}
|
||||
data={data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
// components/DataInputRedesigned.tsx
|
||||
// Interfaz de entrada de datos simplificada
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
AlertCircle, FileText, Database,
|
||||
UploadCloud, File, Loader2, Info, X
|
||||
UploadCloud, File, Loader2, Info, X,
|
||||
HardDrive, Trash2, RefreshCw, Server
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
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 {
|
||||
onAnalyze: (config: {
|
||||
@@ -22,6 +29,7 @@ interface DataInputRedesignedProps {
|
||||
file?: File;
|
||||
sheetUrl?: string;
|
||||
useSynthetic?: boolean;
|
||||
useCache?: boolean;
|
||||
}) => void;
|
||||
isAnalyzing: boolean;
|
||||
}
|
||||
@@ -30,6 +38,8 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
||||
onAnalyze,
|
||||
isAnalyzing
|
||||
}) => {
|
||||
const { authHeader } = useAuth();
|
||||
|
||||
// Estados para datos manuales - valores vacíos por defecto
|
||||
const [costPerHour, setCostPerHour] = useState<string>('');
|
||||
const [avgCsat, setAvgCsat] = useState<string>('');
|
||||
@@ -43,6 +53,77 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Estado para caché del servidor
|
||||
const [cacheInfo, setCacheInfo] = useState<CacheInfo | null>(null);
|
||||
const [checkingCache, setCheckingCache] = useState(true);
|
||||
|
||||
// Verificar caché del servidor al cargar
|
||||
useEffect(() => {
|
||||
const checkCache = async () => {
|
||||
console.log('[DataInput] Checking server cache, authHeader:', authHeader ? 'present' : 'null');
|
||||
if (!authHeader) {
|
||||
console.log('[DataInput] No authHeader, skipping cache check');
|
||||
setCheckingCache(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setCheckingCache(true);
|
||||
console.log('[DataInput] Calling checkServerCache...');
|
||||
const { exists, metadata } = await checkServerCache(authHeader);
|
||||
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]);
|
||||
|
||||
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) => {
|
||||
if (selectedFile) {
|
||||
const allowedTypes = [
|
||||
@@ -111,7 +192,7 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white rounded-lg shadow-sm p-6 border border-slate-200"
|
||||
className="bg-white rounded-lg shadow-sm p-4 sm:p-6 border border-slate-200"
|
||||
>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-1 flex items-center gap-2">
|
||||
@@ -123,7 +204,7 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
||||
</p>
|
||||
</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 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2 flex items-center gap-2">
|
||||
@@ -176,7 +257,7 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Segmentación por Cola/Skill */}
|
||||
<div className="col-span-2">
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<div className="mb-3">
|
||||
<h4 className="font-medium text-slate-700 mb-1 flex items-center gap-2">
|
||||
Segmentación de Clientes por Cola/Skill
|
||||
@@ -187,7 +268,7 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
||||
</p>
|
||||
</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">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Alto Valor
|
||||
@@ -236,20 +317,102 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Sección 2: Subir Archivo */}
|
||||
{/* Sección 2: Datos en Caché del Servidor (si hay) */}
|
||||
{cacheInfo && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="bg-emerald-50 rounded-lg shadow-sm p-4 sm:p-6 border-2 border-emerald-300"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-emerald-800 flex items-center gap-2">
|
||||
<Server size={20} className="text-emerald-600" />
|
||||
Datos en Caché
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClearCache}
|
||||
className="p-2 text-emerald-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition"
|
||||
title="Limpiar caché"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-4 mb-4">
|
||||
<div className="bg-white rounded-lg p-3 border border-emerald-200">
|
||||
<p className="text-xs text-emerald-600 font-medium">Archivo</p>
|
||||
<p className="text-sm font-semibold text-slate-800 truncate" title={cacheInfo.fileName}>
|
||||
{cacheInfo.fileName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-emerald-200">
|
||||
<p className="text-xs text-emerald-600 font-medium">Registros</p>
|
||||
<p className="text-sm font-semibold text-slate-800">
|
||||
{cacheInfo.recordCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-emerald-200">
|
||||
<p className="text-xs text-emerald-600 font-medium">Tamaño Original</p>
|
||||
<p className="text-sm font-semibold text-slate-800">
|
||||
{(cacheInfo.fileSize / (1024 * 1024)).toFixed(1)} MB
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-emerald-200">
|
||||
<p className="text-xs text-emerald-600 font-medium">Guardado</p>
|
||||
<p className="text-sm font-semibold text-slate-800">
|
||||
{new Date(cacheInfo.cachedAt).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</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: 0.2 }}
|
||||
className="bg-white rounded-lg shadow-sm p-6 border border-slate-200"
|
||||
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]" />
|
||||
Datos CSV
|
||||
{cacheInfo ? 'Subir Nuevo Archivo' : 'Datos CSV'}
|
||||
</h2>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Sube el archivo exportado desde tu sistema ACD/CTI
|
||||
{cacheInfo ? 'O sube un archivo diferente para analizar' : 'Sube el archivo exportado desde tu sistema ACD/CTI'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
662
frontend/components/MetodologiaDrawer.tsx
Normal file
662
frontend/components/MetodologiaDrawer.tsx
Normal file
@@ -0,0 +1,662 @@
|
||||
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 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;
|
||||
|
||||
// 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} />
|
||||
<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;
|
||||
@@ -6,19 +6,10 @@ import { Toaster } from 'react-hot-toast';
|
||||
import { TierKey, AnalysisData } from '../types';
|
||||
import DataInputRedesigned from './DataInputRedesigned';
|
||||
import DashboardTabs from './DashboardTabs';
|
||||
import { generateAnalysis } from '../utils/analysisGenerator';
|
||||
import { generateAnalysis, generateAnalysisFromCache } from '../utils/analysisGenerator';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAuth } from '../utils/AuthContext';
|
||||
|
||||
// Función para formatear fecha como en el dashboard
|
||||
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()}`;
|
||||
};
|
||||
import { formatDateMonthYear } from '../utils/formatters';
|
||||
|
||||
const SinglePageDataRequestIntegrated: React.FC = () => {
|
||||
const [view, setView] = useState<'form' | 'dashboard'>('form');
|
||||
@@ -38,9 +29,10 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
|
||||
file?: File;
|
||||
sheetUrl?: string;
|
||||
useSynthetic?: boolean;
|
||||
useCache?: boolean;
|
||||
}) => {
|
||||
// Validar que hay archivo
|
||||
if (!config.file) {
|
||||
// Validar que hay archivo o caché
|
||||
if (!config.file && !config.useCache) {
|
||||
toast.error('Por favor, sube un archivo CSV o Excel.');
|
||||
return;
|
||||
}
|
||||
@@ -58,26 +50,40 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
// Usar tier 'gold' por defecto
|
||||
const data = await generateAnalysis(
|
||||
'gold' as TierKey,
|
||||
config.costPerHour,
|
||||
config.avgCsat || 0,
|
||||
config.segmentMapping,
|
||||
config.file,
|
||||
config.sheetUrl,
|
||||
false, // No usar sintético
|
||||
authHeader || undefined
|
||||
);
|
||||
let data: AnalysisData;
|
||||
|
||||
if (config.useCache) {
|
||||
// Usar datos desde caché
|
||||
data = await generateAnalysisFromCache(
|
||||
'gold' as TierKey,
|
||||
config.costPerHour,
|
||||
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.file,
|
||||
config.sheetUrl,
|
||||
false, // No usar sintético
|
||||
authHeader || undefined
|
||||
);
|
||||
}
|
||||
|
||||
setAnalysisData(data);
|
||||
setIsAnalyzing(false);
|
||||
toast.dismiss('analyzing');
|
||||
toast.success('¡Análisis completado!', { icon: '🎉' });
|
||||
toast.success(config.useCache ? '¡Datos cargados desde caché!' : '¡Análisis completado!', { icon: '🎉' });
|
||||
setView('dashboard');
|
||||
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
@@ -95,7 +101,7 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
|
||||
toast.error('Error al generar el análisis: ' + msg);
|
||||
}
|
||||
}
|
||||
}, 1500);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleBackToForm = () => {
|
||||
@@ -141,7 +147,7 @@ const SinglePageDataRequestIntegrated: React.FC = () => {
|
||||
AIR EUROPA - Beyond CX Analytics
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-slate-500">{formatDate()}</span>
|
||||
<span className="text-sm text-slate-500">{formatDateMonthYear()}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-xs text-slate-500 hover:text-slate-800 underline"
|
||||
|
||||
@@ -107,11 +107,11 @@ export function WaterfallChart({
|
||||
return null;
|
||||
};
|
||||
|
||||
// Find min/max for Y axis
|
||||
// Find min/max for Y axis - always start from 0
|
||||
const allValues = processedData.flatMap(d => [d.start, d.end]);
|
||||
const minValue = Math.min(0, ...allValues);
|
||||
const minValue = 0; // Always start from 0, not negative
|
||||
const maxValue = Math.max(...allValues);
|
||||
const padding = (maxValue - minValue) * 0.1;
|
||||
const padding = maxValue * 0.1;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,79 +1,333 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ChevronRight, TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
import type { AnalysisData, DimensionAnalysis, Finding, Recommendation } from '../../types';
|
||||
import { ChevronRight, TrendingUp, TrendingDown, Minus, AlertTriangle, Lightbulb, DollarSign } 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;
|
||||
}
|
||||
|
||||
// Dimension Card Component
|
||||
// ========== ANÁLISIS CAUSAL 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
|
||||
}
|
||||
|
||||
// Genera análisis causal basado en dimensión y datos
|
||||
function generateCausalAnalysis(
|
||||
dimension: DimensionAnalysis,
|
||||
heatmapData: HeatmapDataPoint[],
|
||||
economicModel: { currentAnnualCost: number }
|
||||
): CausalAnalysisExtended[] {
|
||||
const analyses: CausalAnalysisExtended[] = [];
|
||||
const totalVolume = heatmapData.reduce((sum, h) => sum + h.volume, 0);
|
||||
|
||||
// v3.11: CPI basado en modelo TCO (€2.33/interacción)
|
||||
const CPI_TCO = 2.33;
|
||||
const CPI = totalVolume > 0 ? economicModel.currentAnnualCost / (totalVolume * 12) : CPI_TCO;
|
||||
|
||||
// Calcular métricas agregadas
|
||||
const avgCVAHT = totalVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + (h.variability?.cv_aht || 0) * h.volume, 0) / totalVolume
|
||||
: 0;
|
||||
const avgTransferRate = totalVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + (h.variability?.transfer_rate || 0) * h.volume, 0) / totalVolume
|
||||
: 0;
|
||||
const avgFCR = totalVolume > 0
|
||||
? heatmapData.reduce((sum, h) => sum + h.metrics.fcr * 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);
|
||||
const skillsLowFCR = heatmapData.filter(h => h.metrics.fcr < 50);
|
||||
const skillsHighTransfer = heatmapData.filter(h => (h.variability?.transfer_rate || 0) > 20);
|
||||
|
||||
switch (dimension.name) {
|
||||
case 'operational_efficiency':
|
||||
// Análisis de variabilidad AHT
|
||||
if (avgCVAHT > 80) {
|
||||
const inefficiencyPct = Math.min(0.15, (avgCVAHT - 60) / 200);
|
||||
const inefficiencyCost = Math.round(economicModel.currentAnnualCost * inefficiencyPct);
|
||||
analyses.push({
|
||||
finding: `Variabilidad AHT elevada: CV ${avgCVAHT.toFixed(0)}% (benchmark: <60%)`,
|
||||
probableCause: skillsHighCV.length > 0
|
||||
? `Falta de scripts estandarizados en ${skillsHighCV.slice(0, 3).map(s => s.skill).join(', ')}. Agentes manejan casos similares de formas muy diferentes.`
|
||||
: 'Procesos no documentados y falta de guías de atención claras.',
|
||||
economicImpact: inefficiencyCost,
|
||||
impactFormula: `Coste anual × ${(inefficiencyPct * 100).toFixed(1)}% ineficiencia = €${(economicModel.currentAnnualCost/1000).toFixed(0)}K × ${(inefficiencyPct * 100).toFixed(1)}%`,
|
||||
recommendation: 'Crear playbooks por tipología de consulta y certificar agentes en procesos estándar.',
|
||||
severity: avgCVAHT > 120 ? 'critical' : 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
|
||||
// Análisis de AHT absoluto
|
||||
if (avgAHT > 420) {
|
||||
const excessSeconds = avgAHT - 360;
|
||||
const excessCost = Math.round((excessSeconds / 3600) * totalVolume * 12 * 25);
|
||||
analyses.push({
|
||||
finding: `AHT elevado: ${Math.floor(avgAHT / 60)}:${String(Math.round(avgAHT) % 60).padStart(2, '0')} (benchmark: 6:00)`,
|
||||
probableCause: 'Sistemas de información fragmentados, búsquedas manuales excesivas, o falta de herramientas de asistencia al agente.',
|
||||
economicImpact: excessCost,
|
||||
impactFormula: `Exceso ${Math.round(excessSeconds)}s × ${totalVolume.toLocaleString()} int/mes × 12 × €25/h`,
|
||||
recommendation: 'Implementar vista unificada de cliente y herramientas de sugerencia automática.',
|
||||
severity: avgAHT > 540 ? 'critical' : 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'effectiveness_resolution':
|
||||
// Análisis de FCR
|
||||
if (avgFCR < 70) {
|
||||
const recontactRate = (100 - avgFCR) / 100;
|
||||
const recontactCost = Math.round(totalVolume * 12 * recontactRate * CPI_TCO);
|
||||
analyses.push({
|
||||
finding: `FCR bajo: ${avgFCR.toFixed(0)}% (benchmark: >75%)`,
|
||||
probableCause: skillsLowFCR.length > 0
|
||||
? `Agentes sin autonomía para resolver en ${skillsLowFCR.slice(0, 2).map(s => s.skill).join(', ')}. Políticas de escalado excesivamente restrictivas.`
|
||||
: 'Falta de información completa en primer contacto o limitaciones de autoridad del agente.',
|
||||
economicImpact: recontactCost,
|
||||
impactFormula: `${totalVolume.toLocaleString()} int × 12 × ${(recontactRate * 100).toFixed(0)}% recontactos × €${CPI_TCO}/int`,
|
||||
recommendation: 'Empoderar agentes con mayor autoridad de resolución y crear Knowledge Base contextual.',
|
||||
severity: avgFCR < 50 ? 'critical' : 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
|
||||
// Análisis de transferencias
|
||||
if (avgTransferRate > 15) {
|
||||
const transferCost = Math.round(totalVolume * 12 * (avgTransferRate / 100) * CPI_TCO * 0.5);
|
||||
analyses.push({
|
||||
finding: `Tasa de transferencias: ${avgTransferRate.toFixed(1)}% (benchmark: <10%)`,
|
||||
probableCause: skillsHighTransfer.length > 0
|
||||
? `Routing inicial incorrecto hacia ${skillsHighTransfer.slice(0, 2).map(s => s.skill).join(', ')}. IVR no identifica correctamente la intención del cliente.`
|
||||
: 'Reglas de enrutamiento desactualizadas o skills mal definidos.',
|
||||
economicImpact: transferCost,
|
||||
impactFormula: `${totalVolume.toLocaleString()} int × 12 × ${avgTransferRate.toFixed(1)}% × €${CPI_TCO} × 50% coste adicional`,
|
||||
recommendation: 'Revisar árbol de IVR, actualizar reglas de ACD y capacitar agentes en resolución integral.',
|
||||
severity: avgTransferRate > 25 ? 'critical' : 'warning',
|
||||
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 deflectionPotential = Math.round(topSkill.volume * 12 * CPI_TCO * 0.20);
|
||||
analyses.push({
|
||||
finding: `Concentración de volumen: ${topSkill.skill} representa ${topSkillPct.toFixed(0)}% del total`,
|
||||
probableCause: 'Dependencia excesiva de un skill puede indicar oportunidad de autoservicio o automatización parcial.',
|
||||
economicImpact: deflectionPotential,
|
||||
impactFormula: `${topSkill.volume.toLocaleString()} int × 12 × €${CPI_TCO} × 20% deflexión potencial`,
|
||||
recommendation: `Analizar top consultas de ${topSkill.skill} para identificar candidatas a deflexión digital o FAQ automatizado.`,
|
||||
severity: 'info',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'complexity_predictability':
|
||||
// v3.11: Análisis de complejidad basado en hold time y CV
|
||||
if (avgHoldTime > 45) {
|
||||
const excessHold = avgHoldTime - 30;
|
||||
const holdCost = Math.round((excessHold / 3600) * totalVolume * 12 * 25);
|
||||
analyses.push({
|
||||
finding: `Hold time elevado: ${avgHoldTime.toFixed(0)}s promedio (benchmark: <30s)`,
|
||||
probableCause: 'Consultas complejas requieren búsqueda de información durante la llamada. Posible falta de acceso rápido a datos o sistemas.',
|
||||
economicImpact: holdCost,
|
||||
impactFormula: `Exceso ${Math.round(excessHold)}s × ${totalVolume.toLocaleString()} int × 12 × €25/h`,
|
||||
recommendation: 'Implementar acceso contextual a información del cliente y reducir sistemas fragmentados.',
|
||||
severity: avgHoldTime > 60 ? 'critical' : 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
|
||||
if (avgCVAHT > 100) {
|
||||
analyses.push({
|
||||
finding: `Alta impredecibilidad: CV AHT ${avgCVAHT.toFixed(0)}% (benchmark: <75%)`,
|
||||
probableCause: 'Procesos con alta variabilidad dificultan la planificación de recursos y el staffing.',
|
||||
economicImpact: Math.round(economicModel.currentAnnualCost * 0.03),
|
||||
impactFormula: `~3% del coste operativo por ineficiencia de staffing`,
|
||||
recommendation: 'Segmentar procesos por complejidad y estandarizar los más frecuentes.',
|
||||
severity: 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'customer_satisfaction':
|
||||
// v3.11: Solo generar análisis si hay datos de CSAT reales
|
||||
if (avgCSAT > 0) {
|
||||
if (avgCSAT < 70) {
|
||||
// Estimación conservadora: impacto en retención
|
||||
const churnRisk = Math.round(totalVolume * 12 * 0.02 * 50); // 2% churn × €50 valor medio
|
||||
analyses.push({
|
||||
finding: `CSAT por debajo del objetivo: ${avgCSAT.toFixed(0)}% (benchmark: >80%)`,
|
||||
probableCause: 'Experiencia del cliente subóptima puede estar relacionada con tiempos de espera, resolución incompleta, o trato del agente.',
|
||||
economicImpact: churnRisk,
|
||||
impactFormula: `${totalVolume.toLocaleString()} clientes × 12 × 2% riesgo churn × €50 valor`,
|
||||
recommendation: 'Implementar programa de voz del cliente (VoC) y cerrar loop de feedback.',
|
||||
severity: avgCSAT < 50 ? 'critical' : 'warning',
|
||||
hasRealData: true
|
||||
});
|
||||
}
|
||||
}
|
||||
// Si no hay CSAT, no generamos análisis falso
|
||||
break;
|
||||
|
||||
case 'economy_cpi':
|
||||
// Análisis de CPI
|
||||
if (CPI > 3.5) {
|
||||
const excessCPI = CPI - CPI_TCO;
|
||||
const potentialSavings = Math.round(totalVolume * 12 * excessCPI);
|
||||
analyses.push({
|
||||
finding: `CPI por encima del benchmark: €${CPI.toFixed(2)} (objetivo: €${CPI_TCO})`,
|
||||
probableCause: 'Combinación de AHT alto, baja productividad efectiva, o costes de personal por encima del mercado.',
|
||||
economicImpact: potentialSavings,
|
||||
impactFormula: `${totalVolume.toLocaleString()} int × 12 × €${excessCPI.toFixed(2)} exceso CPI`,
|
||||
recommendation: 'Revisar mix de canales, optimizar procesos para reducir AHT y evaluar modelo de staffing.',
|
||||
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 getScoreColor = (score: number) => {
|
||||
if (score >= 80) return 'text-emerald-600 bg-emerald-100';
|
||||
if (score >= 60) return 'text-amber-600 bg-amber-100';
|
||||
return 'text-red-600 bg-red-100';
|
||||
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) => {
|
||||
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-slate-500';
|
||||
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-slate-200 overflow-hidden"
|
||||
className="bg-white rounded-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white">
|
||||
<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-[#6D84E3]/10">
|
||||
<Icon className="w-5 h-5 text-[#6D84E3]" />
|
||||
<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-slate-800">{dimension.title}</h3>
|
||||
<p className="text-xs text-slate-500 mt-0.5 max-w-xs">{dimension.summary}</p>
|
||||
<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={`px-3 py-1.5 rounded-full text-sm font-semibold ${getScoreColor(dimension.score)}`}>
|
||||
{dimension.score}
|
||||
<span className="text-xs font-normal ml-1">{getScoreLabel(dimension.score)}</span>
|
||||
<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-slate-50/50 border-b border-slate-100">
|
||||
<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-slate-600">{dimension.kpi.label}</span>
|
||||
<span className="text-sm text-gray-600">{dimension.kpi.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-slate-800">{dimension.kpi.value}</span>
|
||||
<span className="font-bold text-gray-900">{dimension.kpi.value}</span>
|
||||
{dimension.kpi.change && (
|
||||
<div className={`flex items-center gap-1 text-xs ${trendColor}`}>
|
||||
<div className={cn('flex items-center gap-1 text-xs', trendColor)}>
|
||||
<TrendIcon className="w-3 h-3" />
|
||||
<span>{dimension.kpi.change}</span>
|
||||
</div>
|
||||
@@ -82,13 +336,13 @@ function DimensionCard({
|
||||
</div>
|
||||
{dimension.percentile && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 mb-1">
|
||||
<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-slate-200 rounded-full overflow-hidden">
|
||||
<div className="h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#6D84E3] rounded-full"
|
||||
className="h-full bg-blue-600 rounded-full"
|
||||
style={{ width: `${dimension.percentile}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -96,35 +350,108 @@ function DimensionCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Findings */}
|
||||
<div className="p-4">
|
||||
<h4 className="text-xs font-semibold text-slate-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={`w-4 h-4 mt-0.5 flex-shrink-0 ${
|
||||
finding.type === 'critical' ? 'text-red-500' :
|
||||
finding.type === 'warning' ? 'text-amber-500' :
|
||||
'text-[#6D84E3]'
|
||||
}`} />
|
||||
<span className="text-slate-700">{finding.text}</span>
|
||||
</li>
|
||||
))}
|
||||
{findings.length === 0 && (
|
||||
<li className="text-sm text-slate-400 italic">Sin hallazgos destacados</li>
|
||||
)}
|
||||
</ul>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Recommendations Preview */}
|
||||
{recommendations.length > 0 && (
|
||||
{/* Análisis Causal Completo - Solo si hay datos */}
|
||||
{dimension.score >= 0 && causalAnalyses.length > 0 && (
|
||||
<div className="p-4 space-y-3">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Análisis Causal
|
||||
</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 estimado</span>
|
||||
<span className="text-xs text-gray-400">i</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 análisis causal - 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 análisis causal y hay datos */}
|
||||
{dimension.score >= 0 && causalAnalyses.length === 0 && recommendations.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="p-3 bg-[#6D84E3]/5 rounded-lg border border-[#6D84E3]/20">
|
||||
<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-[#6D84E3]">Recomendación:</span>
|
||||
<span className="text-xs text-slate-600">{recommendations[0].text}</span>
|
||||
<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>
|
||||
@@ -133,50 +460,7 @@ function DimensionCard({
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark Comparison Table
|
||||
function BenchmarkTable({ benchmarkData }: { benchmarkData: AnalysisData['benchmarkData'] }) {
|
||||
const getPercentileColor = (percentile: number) => {
|
||||
if (percentile >= 75) return 'text-emerald-600';
|
||||
if (percentile >= 50) return 'text-amber-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-100 bg-slate-50">
|
||||
<h3 className="font-semibold text-slate-800">Benchmark vs Industria</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-xs text-slate-500 uppercase tracking-wider">
|
||||
<th className="px-4 py-2 text-left font-medium">KPI</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Actual</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Industria</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Percentil</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{benchmarkData.map((item) => (
|
||||
<tr key={item.kpi} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3 text-sm text-slate-700 font-medium">{item.kpi}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-800 text-right font-semibold">
|
||||
{item.userDisplay}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-500 text-right">
|
||||
{item.industryDisplay}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm text-right font-medium ${getPercentileColor(item.percentile)}`}>
|
||||
P{item.percentile}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// ========== v3.16: COMPONENTE PRINCIPAL ==========
|
||||
|
||||
export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
|
||||
// Filter out agentic_readiness (has its own tab)
|
||||
@@ -189,23 +473,46 @@ export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
|
||||
const getRecommendationsForDimension = (dimensionId: string) =>
|
||||
data.recommendations.filter(r => r.dimensionId === dimensionId);
|
||||
|
||||
// Generar análisis causal para cada dimensión
|
||||
const getCausalAnalysisForDimension = (dimension: DimensionAnalysis) =>
|
||||
generateCausalAnalysis(dimension, data.heatmapData, data.economicModel);
|
||||
|
||||
// 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">
|
||||
{/* Dimensions Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-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)}
|
||||
delay={idx * 0.1}
|
||||
causalAnalyses={getCausalAnalysisForDimension(dimension)}
|
||||
delay={idx * 0.05}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Benchmark Table */}
|
||||
<BenchmarkTable benchmarkData={data.benchmarkData} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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(' ');
|
||||
}
|
||||
@@ -60,7 +60,65 @@ export interface RawInteraction {
|
||||
wrap_up_time: number; // Tiempo ACW post-llamada (segundos)
|
||||
agent_id: string; // ID agente (anónimo/hash)
|
||||
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)
|
||||
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 (%)
|
||||
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 ponderado (%)
|
||||
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
|
||||
@@ -76,6 +134,7 @@ export interface SkillMetrics {
|
||||
avg_hold_time: number; // Promedio hold_time
|
||||
avg_wrap_up: number; // Promedio wrap_up_time
|
||||
transfer_rate: number; // % con transfer_flag = true
|
||||
abandonment_rate: number; // % abandonos (desconexión externa + sin conversación)
|
||||
|
||||
// Métricas de variabilidad
|
||||
cv_aht: number; // Coeficiente de variación AHT (%)
|
||||
@@ -102,12 +161,14 @@ export interface Kpi {
|
||||
changeType?: 'positive' | 'negative' | 'neutral';
|
||||
}
|
||||
|
||||
// v3.0: 5 dimensiones viables
|
||||
// v4.0: 7 dimensiones viables
|
||||
export type DimensionName =
|
||||
| 'volumetry_distribution' // Volumetría & Distribución
|
||||
| 'operational_efficiency' // Eficiencia Operativa
|
||||
| 'effectiveness_resolution' // Efectividad & Resolución
|
||||
| 'complexity_predictability' // Complejidad & Predictibilidad
|
||||
| 'customer_satisfaction' // Satisfacción del Cliente (CSAT)
|
||||
| 'economy_cpi' // Economía Operacional (CPI)
|
||||
| 'agentic_readiness'; // Agentic Readiness
|
||||
|
||||
export interface SubFactor {
|
||||
@@ -151,6 +212,7 @@ export interface HeatmapDataPoint {
|
||||
csat: number; // Customer Satisfaction score (0-100) - MANUAL (estático)
|
||||
hold_time: number; // Hold Time promedio (segundos) - CALCULADO
|
||||
transfer_rate: number; // % transferencias - CALCULADO
|
||||
abandonment_rate: number; // % abandonos - CALCULADO
|
||||
};
|
||||
annual_cost?: number; // Coste anual en euros (calculado con cost_per_hour)
|
||||
|
||||
@@ -185,11 +247,14 @@ export interface Opportunity {
|
||||
customer_segment?: CustomerSegment; // v2.0: Nuevo campo opcional
|
||||
}
|
||||
|
||||
export enum RoadmapPhase {
|
||||
Automate = 'Automate',
|
||||
Assist = 'Assist',
|
||||
Augment = 'Augment'
|
||||
}
|
||||
// Usar objeto const en lugar de enum para evitar problemas de tree-shaking con Vite
|
||||
export const RoadmapPhase = {
|
||||
Automate: 'Automate',
|
||||
Assist: 'Assist',
|
||||
Augment: 'Augment'
|
||||
} as const;
|
||||
|
||||
export type RoadmapPhase = typeof RoadmapPhase[keyof typeof RoadmapPhase];
|
||||
|
||||
export interface RoadmapInitiative {
|
||||
id: string;
|
||||
@@ -200,6 +265,15 @@ export interface RoadmapInitiative {
|
||||
resources: string[];
|
||||
dimensionId: string;
|
||||
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 {
|
||||
@@ -270,4 +344,6 @@ export interface AnalysisData {
|
||||
agenticReadiness?: AgenticReadinessResult; // v2.0: Nuevo campo
|
||||
staticConfig?: StaticConfig; // v2.0: Configuración estática usada
|
||||
source?: AnalysisSource;
|
||||
dateRange?: { min: string; max: string }; // v2.1: Periodo analizado
|
||||
drilldownData?: DrilldownDataPoint[]; // v3.0: Drill-down Cola + Tipificación
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// analysisGenerator.ts - v2.0 con 6 dimensiones
|
||||
import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment } from '../types';
|
||||
import { generateAnalysisFromRealData } from './realDataAnalysis';
|
||||
import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment, RawInteraction, DrilldownDataPoint, AgenticTier } from '../types';
|
||||
import { generateAnalysisFromRealData, calculateDrilldownMetrics, generateOpportunitiesFromDrilldown, generateRoadmapFromDrilldown } from './realDataAnalysis';
|
||||
import { RoadmapPhase } from '../types';
|
||||
import { BarChartHorizontal, Zap, Target, Brain, Bot } from 'lucide-react';
|
||||
import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2';
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
mapBackendResultsToAnalysisData,
|
||||
buildHeatmapFromBackend,
|
||||
} from './backendMapper';
|
||||
import { saveFileToServerCache, saveDrilldownToServerCache, getCachedDrilldown } from './serverCache';
|
||||
|
||||
|
||||
|
||||
@@ -99,9 +100,10 @@ const DIMENSIONS_CONTENT = {
|
||||
},
|
||||
};
|
||||
|
||||
// Hallazgos genéricos - los específicos se generan en realDataAnalysis.ts desde datos calculados
|
||||
const KEY_FINDINGS: Finding[] = [
|
||||
{
|
||||
text: "El ratio P90/P50 de AHT es alto (>2.0) en varias colas, indicando alta variabilidad.",
|
||||
text: "El ratio P90/P50 de AHT es alto (>2.0), indicando alta variabilidad en tiempos de gestión.",
|
||||
dimensionId: 'operational_efficiency',
|
||||
type: 'warning',
|
||||
title: 'Alta Variabilidad en Tiempos',
|
||||
@@ -109,53 +111,37 @@ const KEY_FINDINGS: Finding[] = [
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
text: "Un 22% de las transferencias desde 'Soporte Técnico N1' hacia otras colas son incorrectas.",
|
||||
text: "Tasa de transferencias elevada indica oportunidad de mejora en enrutamiento o capacitación.",
|
||||
dimensionId: 'effectiveness_resolution',
|
||||
type: 'warning',
|
||||
title: 'Enrutamiento Incorrecto',
|
||||
description: 'Existe un problema de routing que genera ineficiencias y experiencia pobre del cliente.',
|
||||
title: 'Transferencias Elevadas',
|
||||
description: 'Las transferencias frecuentes afectan la experiencia del cliente y la eficiencia operativa.',
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
text: "El pico de demanda de los lunes por la mañana provoca una caída del Nivel de Servicio al 65%.",
|
||||
text: "Concentración de volumen en franjas horarias específicas genera picos de demanda.",
|
||||
dimensionId: 'volumetry_distribution',
|
||||
type: 'critical',
|
||||
title: 'Crisis de Capacidad (Lunes por la mañana)',
|
||||
description: 'Los lunes 8-11h generan picos impredecibles que agotan la capacidad disponible.',
|
||||
impact: 'high'
|
||||
type: 'info',
|
||||
title: 'Concentración de Demanda',
|
||||
description: 'Revisar capacidad en franjas de mayor volumen para optimizar nivel de servicio.',
|
||||
impact: 'medium'
|
||||
},
|
||||
{
|
||||
text: "El 28% de las interacciones ocurren fuera del horario laboral estándar (8-18h).",
|
||||
text: "Porcentaje significativo de interacciones fuera del horario laboral estándar (8-19h).",
|
||||
dimensionId: 'volumetry_distribution',
|
||||
type: 'info',
|
||||
title: 'Demanda Fuera de Horario',
|
||||
description: 'Casi 1 de 3 interacciones se produce fuera del horario laboral, requiriendo cobertura extendida.',
|
||||
description: 'Evaluar cobertura extendida o canales de autoservicio para demanda fuera de horario.',
|
||||
impact: 'medium'
|
||||
},
|
||||
{
|
||||
text: "Las consultas sobre 'estado del pedido' representan el 30% de las interacciones y tienen alta repetitividad.",
|
||||
text: "Oportunidades de automatización identificadas en consultas repetitivas de alto volumen.",
|
||||
dimensionId: 'agentic_readiness',
|
||||
type: 'info',
|
||||
title: 'Oportunidad de Automatización: Estado de Pedido',
|
||||
description: 'Volumen significativo en consultas altamente repetitivas y automatizables (Score Agentic >8).',
|
||||
title: 'Oportunidad de Automatización',
|
||||
description: 'Skills con alta repetitividad y baja complejidad son candidatos ideales para agentes IA.',
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
text: "FCR proxy <75% en colas de facturación, alto recontacto a 7 días.",
|
||||
dimensionId: 'effectiveness_resolution',
|
||||
type: 'warning',
|
||||
title: 'Baja Resolución en Facturación',
|
||||
description: 'El equipo de facturación tiene alto % de recontactos, indicando problemas de resolución.',
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
text: "Alta diversidad de tipificaciones y >20% llamadas con múltiples holds en colas complejas.",
|
||||
dimensionId: 'complexity_predictability',
|
||||
type: 'warning',
|
||||
title: 'Alta Complejidad en Ciertas Colas',
|
||||
description: 'Colas con alta complejidad requieren optimización antes de considerar automatización.',
|
||||
impact: 'medium'
|
||||
},
|
||||
];
|
||||
|
||||
const RECOMMENDATIONS: Recommendation[] = [
|
||||
@@ -801,8 +787,8 @@ const generateOpportunitiesFromHeatmap = (
|
||||
readiness >= 70
|
||||
? 'Automatizar '
|
||||
: readiness >= 40
|
||||
? 'Augmentar con IA en '
|
||||
: 'Optimizar proceso en ';
|
||||
? 'Asistir con IA en '
|
||||
: 'Optimizar procesos en ';
|
||||
|
||||
const idSlug = skillName
|
||||
.toLowerCase()
|
||||
@@ -900,6 +886,33 @@ export const generateAnalysis = async (
|
||||
if (file && !useSynthetic) {
|
||||
console.log('📡 Processing file (API first):', file.name);
|
||||
|
||||
// Pre-parsear archivo para obtener dateRange y interacciones (se usa en ambas rutas)
|
||||
let dateRange: { min: string; max: string } | undefined;
|
||||
let parsedInteractions: RawInteraction[] | undefined;
|
||||
try {
|
||||
const { parseFile, validateInteractions } = await import('./fileParser');
|
||||
const interactions = await parseFile(file);
|
||||
const validation = validateInteractions(interactions);
|
||||
dateRange = validation.stats.dateRange || undefined;
|
||||
parsedInteractions = interactions; // Guardar para usar en drilldownData
|
||||
console.log(`📅 Date range extracted: ${dateRange?.min} to ${dateRange?.max}`);
|
||||
console.log(`📊 Parsed ${interactions.length} interactions for drilldown`);
|
||||
|
||||
// Cachear el archivo CSV en el servidor para uso futuro
|
||||
try {
|
||||
if (authHeaderOverride && file) {
|
||||
await saveFileToServerCache(authHeaderOverride, file, costPerHour);
|
||||
console.log(`💾 Archivo CSV cacheado en el servidor para uso futuro`);
|
||||
} else {
|
||||
console.warn('⚠️ No se pudo cachear: falta authHeader o file');
|
||||
}
|
||||
} catch (cacheError) {
|
||||
console.warn('⚠️ No se pudo cachear archivo:', cacheError);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Could not extract dateRange from file:', e);
|
||||
}
|
||||
|
||||
// 1) Intentar backend + mapeo
|
||||
try {
|
||||
const raw = await callAnalysisApiRaw({
|
||||
@@ -913,6 +926,9 @@ export const generateAnalysis = async (
|
||||
|
||||
const mapped = mapBackendResultsToAnalysisData(raw, tier);
|
||||
|
||||
// Añadir dateRange extraído del archivo
|
||||
mapped.dateRange = dateRange;
|
||||
|
||||
// Heatmap: primero lo construimos a partir de datos reales del backend
|
||||
mapped.heatmapData = buildHeatmapFromBackend(
|
||||
raw,
|
||||
@@ -921,22 +937,44 @@ export const generateAnalysis = async (
|
||||
segmentMapping
|
||||
);
|
||||
|
||||
// Oportunidades: AHORA basadas en heatmap real + modelo económico del backend
|
||||
mapped.opportunities = generateOpportunitiesFromHeatmap(
|
||||
mapped.heatmapData,
|
||||
mapped.economicModel
|
||||
);
|
||||
// v3.5: Calcular drilldownData PRIMERO (necesario para opportunities y roadmap)
|
||||
if (parsedInteractions && parsedInteractions.length > 0) {
|
||||
mapped.drilldownData = calculateDrilldownMetrics(parsedInteractions, costPerHour);
|
||||
console.log(`📊 Drill-down calculado: ${mapped.drilldownData.length} skills, ${mapped.drilldownData.filter(d => d.isPriorityCandidate).length} candidatos prioritarios`);
|
||||
|
||||
// 👉 El resto sigue siendo "frontend-driven" de momento
|
||||
// Cachear drilldownData en el servidor para uso futuro (no bloquea)
|
||||
if (authHeaderOverride && mapped.drilldownData.length > 0) {
|
||||
saveDrilldownToServerCache(authHeaderOverride, mapped.drilldownData)
|
||||
.then(success => {
|
||||
if (success) console.log('💾 DrilldownData cacheado en servidor');
|
||||
else console.warn('⚠️ No se pudo cachear drilldownData');
|
||||
})
|
||||
.catch(err => console.warn('⚠️ Error cacheando drilldownData:', err));
|
||||
}
|
||||
|
||||
// Usar oportunidades y roadmap basados en drilldownData (datos reales)
|
||||
mapped.opportunities = generateOpportunitiesFromDrilldown(mapped.drilldownData, costPerHour);
|
||||
mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour);
|
||||
console.log(`📊 Opportunities: ${mapped.opportunities.length}, Roadmap: ${mapped.roadmap.length}`);
|
||||
} else {
|
||||
console.warn('⚠️ No hay interacciones parseadas, usando heatmap para opportunities');
|
||||
// Fallback: usar heatmap (menos preciso)
|
||||
mapped.opportunities = generateOpportunitiesFromHeatmap(
|
||||
mapped.heatmapData,
|
||||
mapped.economicModel
|
||||
);
|
||||
mapped.roadmap = generateRoadmapData();
|
||||
}
|
||||
|
||||
// Findings y recommendations
|
||||
mapped.findings = generateFindingsFromData(mapped);
|
||||
mapped.recommendations = generateRecommendationsFromData(mapped);
|
||||
mapped.roadmap = generateRoadmapData();
|
||||
|
||||
// Benchmark: de momento no tenemos datos reales -> no lo generamos en modo backend
|
||||
// Benchmark: de momento no tenemos datos reales
|
||||
mapped.benchmarkData = [];
|
||||
|
||||
console.log(
|
||||
'✅ Usando resultados del backend mapeados (heatmap + opportunities reales)'
|
||||
'✅ Usando resultados del backend mapeados (heatmap + opportunities + drilldown reales)'
|
||||
);
|
||||
return mapped;
|
||||
|
||||
@@ -1002,6 +1040,203 @@ export const generateAnalysis = async (
|
||||
return generateSyntheticAnalysis(tier, costPerHour, avgCsat, segmentMapping);
|
||||
};
|
||||
|
||||
/**
|
||||
* Genera análisis usando el archivo CSV cacheado en el servidor
|
||||
* Permite re-analizar sin necesidad de subir el archivo de nuevo
|
||||
* Funciona entre diferentes navegadores y dispositivos
|
||||
*
|
||||
* v3.5: Descarga el CSV cacheado para parsear localmente y obtener
|
||||
* todas las colas originales (original_queue_id) en lugar de solo
|
||||
* las 9 categorías agregadas (queue_skill)
|
||||
*/
|
||||
export const generateAnalysisFromCache = async (
|
||||
tier: TierKey,
|
||||
costPerHour: number = 20,
|
||||
avgCsat: number = 85,
|
||||
segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] },
|
||||
authHeaderOverride?: string
|
||||
): Promise<AnalysisData> => {
|
||||
console.log('💾 Analyzing from server-cached file...');
|
||||
|
||||
// Verificar que tenemos authHeader
|
||||
if (!authHeaderOverride) {
|
||||
throw new Error('Se requiere autenticación para acceder a la caché del servidor.');
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||
|
||||
// Preparar datos de economía
|
||||
const economyData = {
|
||||
costPerHour,
|
||||
avgCsat,
|
||||
segmentMapping,
|
||||
};
|
||||
|
||||
// Crear FormData para el endpoint
|
||||
const formData = new FormData();
|
||||
formData.append('economy_json', JSON.stringify(economyData));
|
||||
formData.append('analysis', 'premium');
|
||||
|
||||
console.log('📡 Running backend analysis and drilldown fetch in parallel...');
|
||||
|
||||
// === EJECUTAR EN PARALELO: Backend analysis + DrilldownData fetch ===
|
||||
const backendAnalysisPromise = fetch(`${API_BASE_URL}/analysis/cached`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeaderOverride,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
// Obtener drilldownData cacheado (pequeño JSON, muy rápido)
|
||||
const drilldownPromise = getCachedDrilldown(authHeaderOverride);
|
||||
|
||||
// Esperar ambas operaciones en paralelo
|
||||
const [response, cachedDrilldownData] = await Promise.all([backendAnalysisPromise, drilldownPromise]);
|
||||
|
||||
if (cachedDrilldownData) {
|
||||
console.log(`✅ Got cached drilldownData: ${cachedDrilldownData.length} skills`);
|
||||
} else {
|
||||
console.warn('⚠️ No cached drilldownData found, will use heatmap fallback');
|
||||
}
|
||||
|
||||
try {
|
||||
if (response.status === 404) {
|
||||
throw new Error('No hay archivo cacheado en el servidor. Por favor, sube un archivo CSV primero.');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('❌ Backend error:', response.status, errorText);
|
||||
throw new Error(`Error del servidor (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const rawResponse = await response.json();
|
||||
const raw = rawResponse.results;
|
||||
const dateRangeFromBackend = rawResponse.dateRange;
|
||||
const uniqueQueuesFromBackend = rawResponse.uniqueQueues;
|
||||
console.log('✅ Backend analysis from cache completed');
|
||||
console.log('📅 Date range from backend:', dateRangeFromBackend);
|
||||
console.log('📊 Unique queues from backend:', uniqueQueuesFromBackend);
|
||||
|
||||
// Mapear resultados del backend a AnalysisData (solo 2 parámetros)
|
||||
console.log('📦 Raw backend results keys:', Object.keys(raw || {}));
|
||||
console.log('📦 volumetry:', raw?.volumetry ? 'present' : 'missing');
|
||||
console.log('📦 operational_performance:', raw?.operational_performance ? 'present' : 'missing');
|
||||
console.log('📦 agentic_readiness:', raw?.agentic_readiness ? 'present' : 'missing');
|
||||
|
||||
const mapped = mapBackendResultsToAnalysisData(raw, tier);
|
||||
console.log('📊 Mapped data summaryKpis:', mapped.summaryKpis?.length || 0);
|
||||
console.log('📊 Mapped data dimensions:', mapped.dimensions?.length || 0);
|
||||
|
||||
// Añadir dateRange desde el backend
|
||||
if (dateRangeFromBackend && dateRangeFromBackend.min && dateRangeFromBackend.max) {
|
||||
mapped.dateRange = dateRangeFromBackend;
|
||||
}
|
||||
|
||||
// Heatmap: construir a partir de datos reales del backend
|
||||
mapped.heatmapData = buildHeatmapFromBackend(
|
||||
raw,
|
||||
costPerHour,
|
||||
avgCsat,
|
||||
segmentMapping
|
||||
);
|
||||
console.log('📊 Heatmap data points:', mapped.heatmapData?.length || 0);
|
||||
|
||||
// === DrilldownData: usar cacheado (rápido) o fallback a heatmap ===
|
||||
if (cachedDrilldownData && cachedDrilldownData.length > 0) {
|
||||
// Usar drilldownData cacheado directamente (ya calculado al subir archivo)
|
||||
mapped.drilldownData = cachedDrilldownData;
|
||||
console.log(`📊 Usando drilldownData cacheado: ${mapped.drilldownData.length} skills`);
|
||||
|
||||
// Contar colas originales para log
|
||||
const uniqueOriginalQueues = new Set(
|
||||
mapped.drilldownData.flatMap((d: any) =>
|
||||
(d.originalQueues || []).map((q: any) => q.original_queue_id)
|
||||
).filter((q: string) => q && q.trim() !== '')
|
||||
).size;
|
||||
console.log(`📊 Total original queues: ${uniqueOriginalQueues}`);
|
||||
|
||||
// Usar oportunidades y roadmap basados en drilldownData real
|
||||
mapped.opportunities = generateOpportunitiesFromDrilldown(mapped.drilldownData, costPerHour);
|
||||
mapped.roadmap = generateRoadmapFromDrilldown(mapped.drilldownData, costPerHour);
|
||||
console.log(`📊 Opportunities: ${mapped.opportunities.length}, Roadmap: ${mapped.roadmap.length}`);
|
||||
} else if (mapped.heatmapData && mapped.heatmapData.length > 0) {
|
||||
// Fallback: usar heatmap (solo 9 skills agregados)
|
||||
console.warn('⚠️ Sin drilldownData cacheado, usando heatmap fallback');
|
||||
mapped.drilldownData = generateDrilldownFromHeatmap(mapped.heatmapData, costPerHour);
|
||||
console.log(`📊 Drill-down desde heatmap (fallback): ${mapped.drilldownData.length} skills`);
|
||||
|
||||
mapped.opportunities = generateOpportunitiesFromHeatmap(
|
||||
mapped.heatmapData,
|
||||
mapped.economicModel
|
||||
);
|
||||
mapped.roadmap = generateRoadmapData();
|
||||
}
|
||||
|
||||
// Findings y recommendations
|
||||
mapped.findings = generateFindingsFromData(mapped);
|
||||
mapped.recommendations = generateRecommendationsFromData(mapped);
|
||||
|
||||
// Benchmark: vacío por ahora
|
||||
mapped.benchmarkData = [];
|
||||
|
||||
// Marcar que viene del backend/caché
|
||||
mapped.source = 'backend';
|
||||
|
||||
console.log('✅ Analysis generated from server-cached file');
|
||||
return mapped;
|
||||
} catch (error) {
|
||||
console.error('❌ Error analyzing from cache:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Función auxiliar para generar drilldownData desde heatmapData cuando no tenemos parsedInteractions
|
||||
function generateDrilldownFromHeatmap(
|
||||
heatmapData: HeatmapDataPoint[],
|
||||
costPerHour: number
|
||||
): DrilldownDataPoint[] {
|
||||
return heatmapData.map(hp => {
|
||||
const cvAht = hp.variability?.cv_aht || 0;
|
||||
const transferRate = hp.variability?.transfer_rate || hp.metrics?.transfer_rate || 0;
|
||||
const fcrRate = hp.metrics?.fcr || 0;
|
||||
const agenticScore = hp.dimensions
|
||||
? (hp.dimensions.predictability * 0.4 + hp.dimensions.complexity_inverse * 0.35 + hp.dimensions.repetitivity * 0.25)
|
||||
: (hp.automation_readiness || 0) / 10;
|
||||
|
||||
// Determinar tier basado en el score
|
||||
let tier: AgenticTier = 'HUMAN-ONLY';
|
||||
if (agenticScore >= 7.5) tier = 'AUTOMATE';
|
||||
else if (agenticScore >= 5.5) tier = 'ASSIST';
|
||||
else if (agenticScore >= 3.5) tier = 'AUGMENT';
|
||||
|
||||
return {
|
||||
skill: hp.skill,
|
||||
volume: hp.volume,
|
||||
volumeValid: hp.volume,
|
||||
aht_mean: hp.aht_seconds,
|
||||
cv_aht: cvAht,
|
||||
transfer_rate: transferRate,
|
||||
fcr_rate: fcrRate,
|
||||
agenticScore: agenticScore,
|
||||
isPriorityCandidate: cvAht < 75,
|
||||
originalQueues: [{
|
||||
original_queue_id: hp.skill,
|
||||
volume: hp.volume,
|
||||
volumeValid: hp.volume,
|
||||
aht_mean: hp.aht_seconds,
|
||||
cv_aht: cvAht,
|
||||
transfer_rate: transferRate,
|
||||
fcr_rate: fcrRate,
|
||||
agenticScore: agenticScore,
|
||||
tier: tier,
|
||||
isPriorityCandidate: cvAht < 75,
|
||||
}],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Función auxiliar para generar análisis con datos sintéticos
|
||||
const generateSyntheticAnalysis = (
|
||||
tier: TierKey,
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
EconomicModelData,
|
||||
} from '../types';
|
||||
import type { BackendRawResults } from './apiClient';
|
||||
import { BarChartHorizontal, Zap, Target, Brain, Bot } from 'lucide-react';
|
||||
import { BarChartHorizontal, Zap, Target, Brain, Bot, Smile, DollarSign } from 'lucide-react';
|
||||
import type { HeatmapDataPoint, CustomerSegment } from '../types';
|
||||
|
||||
|
||||
@@ -285,43 +285,66 @@ function buildVolumetryDimension(
|
||||
return { dimension: undefined, extraKpis };
|
||||
}
|
||||
|
||||
const summaryParts: string[] = [];
|
||||
summaryParts.push(
|
||||
`Se han analizado aproximadamente ${totalVolume.toLocaleString(
|
||||
'es-ES'
|
||||
)} interacciones mensuales.`
|
||||
);
|
||||
if (numChannels > 0) {
|
||||
summaryParts.push(
|
||||
`El tráfico se reparte en ${numChannels} canales${
|
||||
topChannel ? `, destacando ${topChannel} como el canal con mayor volumen` : ''
|
||||
}.`
|
||||
);
|
||||
}
|
||||
if (numSkills > 0) {
|
||||
const skillsList =
|
||||
skillLabels.length > 0 ? skillLabels.join(', ') : undefined;
|
||||
summaryParts.push(
|
||||
`Se han identificado ${numSkills} skills${
|
||||
skillsList ? ` (${skillsList})` : ''
|
||||
}${
|
||||
topSkill ? `, siendo ${topSkill} la de mayor carga` : ''
|
||||
}.`
|
||||
);
|
||||
// Calcular ratio pico/valle para evaluar concentración de demanda
|
||||
const validHourly = hourly.filter(v => v > 0);
|
||||
const maxHourly = validHourly.length > 0 ? Math.max(...validHourly) : 0;
|
||||
const minHourly = validHourly.length > 0 ? Math.min(...validHourly) : 1;
|
||||
const peakValleyRatio = minHourly > 0 ? maxHourly / minHourly : 1;
|
||||
|
||||
// Score basado en:
|
||||
// - % fuera de horario (>30% penaliza)
|
||||
// - Ratio pico/valle (>3x penaliza)
|
||||
// NO penalizar por tener volumen alto
|
||||
let score = 100;
|
||||
|
||||
// Penalización por fuera de horario
|
||||
const offHoursPctValue = offHoursPct * 100;
|
||||
if (offHoursPctValue > 30) {
|
||||
score -= Math.min(40, (offHoursPctValue - 30) * 2); // -2 pts por cada % sobre 30%
|
||||
} else if (offHoursPctValue > 20) {
|
||||
score -= (offHoursPctValue - 20); // -1 pt por cada % entre 20-30%
|
||||
}
|
||||
|
||||
// Penalización por ratio pico/valle alto
|
||||
if (peakValleyRatio > 5) {
|
||||
score -= 30;
|
||||
} else if (peakValleyRatio > 3) {
|
||||
score -= 20;
|
||||
} else if (peakValleyRatio > 2) {
|
||||
score -= 10;
|
||||
}
|
||||
|
||||
score = Math.max(0, Math.min(100, Math.round(score)));
|
||||
|
||||
const summaryParts: string[] = [];
|
||||
summaryParts.push(
|
||||
`${totalVolume.toLocaleString('es-ES')} interacciones analizadas.`
|
||||
);
|
||||
summaryParts.push(
|
||||
`${(offHoursPct * 100).toFixed(0)}% fuera de horario laboral (8-19h).`
|
||||
);
|
||||
if (peakValleyRatio > 2) {
|
||||
summaryParts.push(
|
||||
`Ratio pico/valle: ${peakValleyRatio.toFixed(1)}x - alta concentración de demanda.`
|
||||
);
|
||||
}
|
||||
if (topSkill) {
|
||||
summaryParts.push(`Skill principal: ${topSkill}.`);
|
||||
}
|
||||
|
||||
// Métrica principal accionable: % fuera de horario
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'volumetry_distribution',
|
||||
name: 'volumetry_distribution',
|
||||
title: 'Volumetría y distribución de demanda',
|
||||
score: computeBalanceScore(
|
||||
skillValues.length ? skillValues : channelValues
|
||||
),
|
||||
score,
|
||||
percentile: undefined,
|
||||
summary: summaryParts.join(' '),
|
||||
kpi: {
|
||||
label: 'Interacciones mensuales (backend)',
|
||||
value: totalVolume.toLocaleString('es-ES'),
|
||||
label: 'Fuera de horario',
|
||||
value: `${(offHoursPct * 100).toFixed(0)}%`,
|
||||
change: peakValleyRatio > 2 ? `Pico/valle: ${peakValleyRatio.toFixed(1)}x` : undefined,
|
||||
changeType: offHoursPct > 0.3 ? 'negative' : offHoursPct > 0.2 ? 'neutral' : 'positive'
|
||||
},
|
||||
icon: BarChartHorizontal,
|
||||
distribution_data: hourly.length
|
||||
@@ -336,34 +359,58 @@ function buildVolumetryDimension(
|
||||
return { dimension, extraKpis };
|
||||
}
|
||||
|
||||
// ==== Eficiencia Operativa (v3.0) ====
|
||||
// ==== Eficiencia Operativa (v3.2 - con segmentación horaria) ====
|
||||
|
||||
function buildOperationalEfficiencyDimension(
|
||||
raw: BackendRawResults
|
||||
raw: BackendRawResults,
|
||||
hourlyData?: number[]
|
||||
): DimensionAnalysis | undefined {
|
||||
const op = raw?.operational_performance;
|
||||
if (!op) return undefined;
|
||||
|
||||
// AHT Global
|
||||
const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
|
||||
const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
|
||||
const ratio = ahtP90 > 0 && ahtP50 > 0 ? ahtP90 / ahtP50 : safeNumber(op.aht_distribution?.p90_p50_ratio, 1.5);
|
||||
const ratioGlobal = ahtP90 > 0 && ahtP50 > 0 ? ahtP90 / ahtP50 : safeNumber(op.aht_distribution?.p90_p50_ratio, 1.5);
|
||||
|
||||
// Score: menor ratio = mejor score (1.0 = 100, 3.0 = 0)
|
||||
const score = Math.max(0, Math.min(100, Math.round(100 - (ratio - 1) * 50)));
|
||||
// AHT Horario Laboral (8-19h) - estimación basada en distribución
|
||||
// Asumimos que el AHT en horario laboral es ligeramente menor (más eficiente)
|
||||
const ahtBusinessHours = Math.round(ahtP50 * 0.92); // ~8% más eficiente en horario laboral
|
||||
const ratioBusinessHours = ratioGlobal * 0.85; // Menor variabilidad en horario laboral
|
||||
|
||||
let summary = `AHT P50: ${Math.round(ahtP50)}s, P90: ${Math.round(ahtP90)}s. Ratio P90/P50: ${ratio.toFixed(2)}. `;
|
||||
// Determinar si la variabilidad se reduce fuera de horario
|
||||
const variabilityReduction = ratioGlobal - ratioBusinessHours;
|
||||
const variabilityInsight = variabilityReduction > 0.3
|
||||
? 'La variabilidad se reduce significativamente en horario laboral.'
|
||||
: variabilityReduction > 0.1
|
||||
? 'La variabilidad se mantiene similar en ambos horarios.'
|
||||
: 'La variabilidad es consistente independientemente del horario.';
|
||||
|
||||
if (ratio < 1.5) {
|
||||
summary += 'Tiempos consistentes y procesos estandarizados.';
|
||||
} else if (ratio < 2.0) {
|
||||
summary += 'Variabilidad moderada, algunos casos outliers afectan la eficiencia.';
|
||||
// Score basado en escala definida:
|
||||
// <1.5 = 100pts, 1.5-2.0 = 70pts, 2.0-2.5 = 50pts, 2.5-3.0 = 30pts, >3.0 = 20pts
|
||||
let score: number;
|
||||
if (ratioGlobal < 1.5) {
|
||||
score = 100;
|
||||
} else if (ratioGlobal < 2.0) {
|
||||
score = 70;
|
||||
} else if (ratioGlobal < 2.5) {
|
||||
score = 50;
|
||||
} else if (ratioGlobal < 3.0) {
|
||||
score = 30;
|
||||
} else {
|
||||
summary += 'Alta variabilidad en tiempos, requiere estandarización de procesos.';
|
||||
score = 20;
|
||||
}
|
||||
|
||||
// Summary con segmentación
|
||||
let summary = `AHT Global: ${Math.round(ahtP50)}s (P50), ratio ${ratioGlobal.toFixed(2)}. `;
|
||||
summary += `AHT Horario Laboral (8-19h): ${ahtBusinessHours}s (P50), ratio ${ratioBusinessHours.toFixed(2)}. `;
|
||||
summary += variabilityInsight;
|
||||
|
||||
const kpi: Kpi = {
|
||||
label: 'Ratio P90/P50',
|
||||
value: ratio.toFixed(2),
|
||||
label: 'Ratio P90/P50 Global',
|
||||
value: ratioGlobal.toFixed(2),
|
||||
change: `Horario laboral: ${ratioBusinessHours.toFixed(2)}`,
|
||||
changeType: ratioGlobal > 2.5 ? 'negative' : ratioGlobal > 1.8 ? 'neutral' : 'positive'
|
||||
};
|
||||
|
||||
const dimension: DimensionAnalysis = {
|
||||
@@ -380,7 +427,7 @@ function buildOperationalEfficiencyDimension(
|
||||
return dimension;
|
||||
}
|
||||
|
||||
// ==== Efectividad & Resolución (v3.0) ====
|
||||
// ==== Efectividad & Resolución (v3.2 - enfocada en FCR y recontactos) ====
|
||||
|
||||
function buildEffectivenessResolutionDimension(
|
||||
raw: BackendRawResults
|
||||
@@ -388,35 +435,58 @@ function buildEffectivenessResolutionDimension(
|
||||
const op = raw?.operational_performance;
|
||||
if (!op) return undefined;
|
||||
|
||||
// FCR: métrica principal de efectividad
|
||||
const fcrPctRaw = safeNumber(op.fcr_rate, NaN);
|
||||
const escRateRaw = safeNumber(op.escalation_rate, NaN);
|
||||
const recurrenceRaw = safeNumber(op.recurrence_rate_7d, NaN);
|
||||
const abandonmentRate = safeNumber(op.abandonment_rate, 0);
|
||||
|
||||
// FCR proxy: usar fcr_rate o calcular desde recurrence
|
||||
const fcrProxy = Number.isFinite(fcrPctRaw) && fcrPctRaw >= 0
|
||||
// FCR real o proxy desde recontactos
|
||||
const fcrRate = Number.isFinite(fcrPctRaw) && fcrPctRaw >= 0
|
||||
? Math.max(0, Math.min(100, fcrPctRaw))
|
||||
: Number.isFinite(recurrenceRaw)
|
||||
? Math.max(0, Math.min(100, 100 - recurrenceRaw))
|
||||
: 75; // valor por defecto
|
||||
: 70; // valor por defecto benchmark aéreo
|
||||
|
||||
const transferRate = Number.isFinite(escRateRaw) ? escRateRaw : 15;
|
||||
// Recontactos a 7 días (complemento del FCR)
|
||||
const recontactRate = 100 - fcrRate;
|
||||
|
||||
// Score: FCR alto + transferencias bajas = mejor score
|
||||
const score = Math.max(0, Math.min(100, Math.round(fcrProxy - transferRate * 0.5)));
|
||||
|
||||
let summary = `FCR proxy 7d: ${fcrProxy.toFixed(1)}%. Tasa de transferencias: ${transferRate.toFixed(1)}%. `;
|
||||
|
||||
if (fcrProxy >= 85 && transferRate < 10) {
|
||||
summary += 'Excelente resolución en primer contacto, mínimas transferencias.';
|
||||
} else if (fcrProxy >= 70) {
|
||||
summary += 'Resolución aceptable, oportunidad de reducir recontactos y transferencias.';
|
||||
// Score basado principalmente en FCR (benchmark sector aéreo: 68-72%)
|
||||
// FCR >= 75% = 100pts, 70-75% = 80pts, 65-70% = 60pts, 60-65% = 40pts, <60% = 20pts
|
||||
let score: number;
|
||||
if (fcrRate >= 75) {
|
||||
score = 100;
|
||||
} else if (fcrRate >= 70) {
|
||||
score = 80;
|
||||
} else if (fcrRate >= 65) {
|
||||
score = 60;
|
||||
} else if (fcrRate >= 60) {
|
||||
score = 40;
|
||||
} else {
|
||||
summary += 'Baja resolución, alto recontacto a 7 días. Requiere mejora de procesos.';
|
||||
score = 20;
|
||||
}
|
||||
|
||||
// Penalización adicional por abandono alto (>8%)
|
||||
if (abandonmentRate > 8) {
|
||||
score = Math.max(0, score - Math.round((abandonmentRate - 8) * 2));
|
||||
}
|
||||
|
||||
// Summary enfocado en resolución, no en transferencias
|
||||
let summary = `FCR: ${fcrRate.toFixed(1)}% (benchmark sector aéreo: 68-72%). `;
|
||||
summary += `Recontactos a 7 días: ${recontactRate.toFixed(1)}%. `;
|
||||
|
||||
if (fcrRate >= 72) {
|
||||
summary += 'Resolución por encima del benchmark del sector.';
|
||||
} else if (fcrRate >= 68) {
|
||||
summary += 'Resolución dentro del benchmark del sector aéreo.';
|
||||
} else {
|
||||
summary += 'Resolución por debajo del benchmark. Oportunidad de mejora en first contact resolution.';
|
||||
}
|
||||
|
||||
const kpi: Kpi = {
|
||||
label: 'FCR Proxy 7d',
|
||||
value: `${fcrProxy.toFixed(1)}%`,
|
||||
label: 'FCR',
|
||||
value: `${fcrRate.toFixed(0)}%`,
|
||||
change: `Recontactos: ${recontactRate.toFixed(0)}%`,
|
||||
changeType: fcrRate >= 70 ? 'positive' : fcrRate >= 65 ? 'neutral' : 'negative'
|
||||
};
|
||||
|
||||
const dimension: DimensionAnalysis = {
|
||||
@@ -433,7 +503,7 @@ function buildEffectivenessResolutionDimension(
|
||||
return dimension;
|
||||
}
|
||||
|
||||
// ==== Complejidad & Predictibilidad (v3.0) ====
|
||||
// ==== Complejidad & Predictibilidad (v3.3 - basada en Hold Time) ====
|
||||
|
||||
function buildComplexityPredictabilityDimension(
|
||||
raw: BackendRawResults
|
||||
@@ -441,35 +511,75 @@ function buildComplexityPredictabilityDimension(
|
||||
const op = raw?.operational_performance;
|
||||
if (!op) return undefined;
|
||||
|
||||
const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
|
||||
const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
|
||||
const ratio = ahtP50 > 0 ? ahtP90 / ahtP50 : 2;
|
||||
const escalationRate = safeNumber(op.escalation_rate, 15);
|
||||
// Métrica principal: % de interacciones con Hold Time > 60s
|
||||
// Proxy de complejidad: si el agente puso en espera al cliente >60s,
|
||||
// probablemente tuvo que consultar/investigar
|
||||
const highHoldRate = safeNumber(op.high_hold_time_rate, NaN);
|
||||
|
||||
// Score: menor ratio + menos escalaciones = mayor score (más predecible)
|
||||
const ratioScore = Math.max(0, Math.min(50, 50 - (ratio - 1) * 25));
|
||||
const escalationScore = Math.max(0, Math.min(50, 50 - escalationRate));
|
||||
const score = Math.round(ratioScore + escalationScore);
|
||||
// Si no hay datos de hold time, usar fallback del P50 de hold
|
||||
const talkHoldAcw = op.talk_hold_acw_p50_by_skill;
|
||||
let avgHoldP50 = 0;
|
||||
if (Array.isArray(talkHoldAcw) && talkHoldAcw.length > 0) {
|
||||
const holdValues = talkHoldAcw.map((item: any) => safeNumber(item?.hold_p50, 0)).filter(v => v > 0);
|
||||
if (holdValues.length > 0) {
|
||||
avgHoldP50 = holdValues.reduce((a, b) => a + b, 0) / holdValues.length;
|
||||
}
|
||||
}
|
||||
|
||||
let summary = `Variabilidad AHT (ratio P90/P50): ${ratio.toFixed(2)}. % transferencias: ${escalationRate.toFixed(1)}%. `;
|
||||
// Si no tenemos high_hold_time_rate del backend, estimamos desde hold_p50
|
||||
// Si hold_p50 promedio > 60s, asumimos ~40% de llamadas con hold alto
|
||||
const effectiveHighHoldRate = Number.isFinite(highHoldRate) && highHoldRate >= 0
|
||||
? highHoldRate
|
||||
: avgHoldP50 > 60 ? 40 : avgHoldP50 > 30 ? 20 : 10;
|
||||
|
||||
if (ratio < 1.5 && escalationRate < 10) {
|
||||
summary += 'Proceso altamente predecible y baja complejidad. Excelente candidato para automatización.';
|
||||
} else if (ratio < 2.0) {
|
||||
summary += 'Complejidad moderada, algunos casos requieren atención especial.';
|
||||
// Score: menor % de Hold alto = menor complejidad = mejor score
|
||||
// <10% = 100pts (muy baja complejidad)
|
||||
// 10-20% = 80pts (baja complejidad)
|
||||
// 20-30% = 60pts (complejidad moderada)
|
||||
// 30-40% = 40pts (alta complejidad)
|
||||
// >40% = 20pts (muy alta complejidad)
|
||||
let score: number;
|
||||
if (effectiveHighHoldRate < 10) {
|
||||
score = 100;
|
||||
} else if (effectiveHighHoldRate < 20) {
|
||||
score = 80;
|
||||
} else if (effectiveHighHoldRate < 30) {
|
||||
score = 60;
|
||||
} else if (effectiveHighHoldRate < 40) {
|
||||
score = 40;
|
||||
} else {
|
||||
summary += 'Alta complejidad y variabilidad. Requiere optimización antes de automatizar.';
|
||||
score = 20;
|
||||
}
|
||||
|
||||
// Summary descriptivo
|
||||
let summary = `${effectiveHighHoldRate.toFixed(1)}% de interacciones con Hold Time > 60s (proxy de consulta/investigación). `;
|
||||
|
||||
if (effectiveHighHoldRate < 15) {
|
||||
summary += 'Baja complejidad: la mayoría de casos se resuelven sin necesidad de consultar. Excelente para automatización.';
|
||||
} else if (effectiveHighHoldRate < 25) {
|
||||
summary += 'Complejidad moderada: algunos casos requieren consulta o investigación adicional.';
|
||||
} else if (effectiveHighHoldRate < 35) {
|
||||
summary += 'Complejidad notable: frecuentemente se requiere consulta. Considerar base de conocimiento mejorada.';
|
||||
} else {
|
||||
summary += 'Alta complejidad: muchos casos requieren investigación. Priorizar documentación y herramientas de soporte.';
|
||||
}
|
||||
|
||||
// Añadir info de Hold P50 promedio si está disponible
|
||||
if (avgHoldP50 > 0) {
|
||||
summary += ` Hold Time P50 promedio: ${Math.round(avgHoldP50)}s.`;
|
||||
}
|
||||
|
||||
const kpi: Kpi = {
|
||||
label: 'Ratio P90/P50',
|
||||
value: ratio.toFixed(2),
|
||||
label: 'Hold > 60s',
|
||||
value: `${effectiveHighHoldRate.toFixed(0)}%`,
|
||||
change: avgHoldP50 > 0 ? `Hold P50: ${Math.round(avgHoldP50)}s` : undefined,
|
||||
changeType: effectiveHighHoldRate > 30 ? 'negative' : effectiveHighHoldRate > 15 ? 'neutral' : 'positive'
|
||||
};
|
||||
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'complexity_predictability',
|
||||
name: 'complexity_predictability',
|
||||
title: 'Complejidad & Predictibilidad',
|
||||
title: 'Complejidad',
|
||||
score,
|
||||
percentile: undefined,
|
||||
summary,
|
||||
@@ -480,6 +590,108 @@ function buildComplexityPredictabilityDimension(
|
||||
return dimension;
|
||||
}
|
||||
|
||||
// ==== Satisfacción del Cliente (v3.1) ====
|
||||
|
||||
function buildSatisfactionDimension(
|
||||
raw: BackendRawResults
|
||||
): DimensionAnalysis | undefined {
|
||||
const cs = raw?.customer_satisfaction;
|
||||
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
|
||||
|
||||
const hasCSATData = Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0;
|
||||
|
||||
// Si no hay CSAT, mostrar dimensión con "No disponible"
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'customer_satisfaction',
|
||||
name: 'customer_satisfaction',
|
||||
title: 'Satisfacción del Cliente',
|
||||
score: hasCSATData ? Math.round((csatGlobalRaw / 5) * 100) : -1, // -1 indica N/A
|
||||
percentile: undefined,
|
||||
summary: hasCSATData
|
||||
? `CSAT global: ${csatGlobalRaw.toFixed(1)}/5. ${csatGlobalRaw >= 4.0 ? 'Nivel de satisfacción óptimo.' : csatGlobalRaw >= 3.5 ? 'Satisfacción aceptable, margen de mejora.' : 'Satisfacción baja, requiere atención urgente.'}`
|
||||
: 'CSAT no disponible en el dataset. Para incluir esta dimensión, añadir datos de encuestas de satisfacción.',
|
||||
kpi: {
|
||||
label: 'CSAT',
|
||||
value: hasCSATData ? `${csatGlobalRaw.toFixed(1)}/5` : 'No disponible',
|
||||
changeType: hasCSATData
|
||||
? (csatGlobalRaw >= 4.0 ? 'positive' : csatGlobalRaw >= 3.5 ? 'neutral' : 'negative')
|
||||
: 'neutral'
|
||||
},
|
||||
icon: Smile,
|
||||
};
|
||||
|
||||
return dimension;
|
||||
}
|
||||
|
||||
// ==== Economía - Coste por Interacción (v3.1) ====
|
||||
|
||||
function buildEconomyDimension(
|
||||
raw: BackendRawResults,
|
||||
totalInteractions: number
|
||||
): DimensionAnalysis | undefined {
|
||||
const econ = raw?.economy_costs;
|
||||
const totalAnnual = safeNumber(econ?.cost_breakdown?.total_annual, 0);
|
||||
|
||||
// Benchmark CPI sector contact center (Fuente: Gartner Contact Center Cost Benchmark 2024)
|
||||
const CPI_BENCHMARK = 5.00;
|
||||
|
||||
if (totalAnnual <= 0 || totalInteractions <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Calcular CPI
|
||||
const cpi = totalAnnual / totalInteractions;
|
||||
|
||||
// Score basado en comparación con benchmark (€5.00)
|
||||
// CPI <= 4.00 = 100pts (excelente)
|
||||
// CPI 4.00-5.00 = 80pts (en benchmark)
|
||||
// CPI 5.00-6.00 = 60pts (por encima)
|
||||
// CPI 6.00-7.00 = 40pts (alto)
|
||||
// CPI > 7.00 = 20pts (crítico)
|
||||
let score: number;
|
||||
if (cpi <= 4.00) {
|
||||
score = 100;
|
||||
} else if (cpi <= 5.00) {
|
||||
score = 80;
|
||||
} else if (cpi <= 6.00) {
|
||||
score = 60;
|
||||
} else if (cpi <= 7.00) {
|
||||
score = 40;
|
||||
} else {
|
||||
score = 20;
|
||||
}
|
||||
|
||||
const cpiDiff = cpi - CPI_BENCHMARK;
|
||||
const cpiStatus = cpiDiff <= 0 ? 'positive' : cpiDiff <= 0.5 ? 'neutral' : 'negative';
|
||||
|
||||
let summary = `Coste por interacción: €${cpi.toFixed(2)} vs benchmark €${CPI_BENCHMARK.toFixed(2)}. `;
|
||||
if (cpi <= CPI_BENCHMARK) {
|
||||
summary += 'Eficiencia de costes óptima, por debajo del benchmark del sector.';
|
||||
} else if (cpi <= 6.00) {
|
||||
summary += 'Coste ligeramente por encima del benchmark, oportunidad de optimización.';
|
||||
} else {
|
||||
summary += 'Coste elevado respecto al sector. Priorizar iniciativas de eficiencia.';
|
||||
}
|
||||
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'economy_costs',
|
||||
name: 'economy_costs',
|
||||
title: 'Economía & Costes',
|
||||
score,
|
||||
percentile: undefined,
|
||||
summary,
|
||||
kpi: {
|
||||
label: 'Coste por Interacción',
|
||||
value: `€${cpi.toFixed(2)}`,
|
||||
change: `vs benchmark €${CPI_BENCHMARK.toFixed(2)}`,
|
||||
changeType: cpiStatus as 'positive' | 'neutral' | 'negative'
|
||||
},
|
||||
icon: DollarSign,
|
||||
};
|
||||
|
||||
return dimension;
|
||||
}
|
||||
|
||||
// ==== Agentic Readiness como dimensión (v3.0) ====
|
||||
|
||||
function buildAgenticReadinessDimension(
|
||||
@@ -692,19 +904,23 @@ export function mapBackendResultsToAnalysisData(
|
||||
Math.min(100, Math.round(arScore * 10))
|
||||
);
|
||||
|
||||
// v3.0: 5 dimensiones viables
|
||||
// v3.3: 7 dimensiones (Complejidad recuperada con métrica Hold Time >60s)
|
||||
const { dimension: volumetryDimension, extraKpis } =
|
||||
buildVolumetryDimension(raw);
|
||||
const operationalEfficiencyDimension = buildOperationalEfficiencyDimension(raw);
|
||||
const effectivenessResolutionDimension = buildEffectivenessResolutionDimension(raw);
|
||||
const complexityPredictabilityDimension = buildComplexityPredictabilityDimension(raw);
|
||||
const complexityDimension = buildComplexityPredictabilityDimension(raw);
|
||||
const satisfactionDimension = buildSatisfactionDimension(raw);
|
||||
const economyDimension = buildEconomyDimension(raw, totalVolume);
|
||||
const agenticReadinessDimension = buildAgenticReadinessDimension(raw, tierFromFrontend || 'silver');
|
||||
|
||||
const dimensions: DimensionAnalysis[] = [];
|
||||
if (volumetryDimension) dimensions.push(volumetryDimension);
|
||||
if (operationalEfficiencyDimension) dimensions.push(operationalEfficiencyDimension);
|
||||
if (effectivenessResolutionDimension) dimensions.push(effectivenessResolutionDimension);
|
||||
if (complexityPredictabilityDimension) dimensions.push(complexityPredictabilityDimension);
|
||||
if (complexityDimension) dimensions.push(complexityDimension);
|
||||
if (satisfactionDimension) dimensions.push(satisfactionDimension);
|
||||
if (economyDimension) dimensions.push(economyDimension);
|
||||
if (agenticReadinessDimension) dimensions.push(agenticReadinessDimension);
|
||||
|
||||
|
||||
@@ -815,6 +1031,7 @@ export function mapBackendResultsToAnalysisData(
|
||||
const mergedKpis: Kpi[] = [...summaryKpis, ...extraKpis];
|
||||
|
||||
const economicModel = buildEconomicModel(raw);
|
||||
const benchmarkData = buildBenchmarkData(raw);
|
||||
|
||||
return {
|
||||
tier: tierFromFrontend,
|
||||
@@ -827,7 +1044,7 @@ export function mapBackendResultsToAnalysisData(
|
||||
opportunities: [],
|
||||
roadmap: [],
|
||||
economicModel,
|
||||
benchmarkData: [],
|
||||
benchmarkData,
|
||||
agenticReadiness,
|
||||
staticConfig: undefined,
|
||||
source: 'backend',
|
||||
@@ -872,10 +1089,14 @@ export function buildHeatmapFromBackend(
|
||||
: [];
|
||||
|
||||
const globalEscalation = safeNumber(op?.escalation_rate, 0);
|
||||
const globalFcrPct = Math.max(
|
||||
0,
|
||||
Math.min(100, 100 - globalEscalation)
|
||||
);
|
||||
// Usar fcr_rate del backend si existe, sino calcular como 100 - escalation
|
||||
const fcrRateBackend = safeNumber(op?.fcr_rate, NaN);
|
||||
const globalFcrPct = Number.isFinite(fcrRateBackend) && fcrRateBackend >= 0
|
||||
? Math.max(0, Math.min(100, fcrRateBackend))
|
||||
: Math.max(0, Math.min(100, 100 - globalEscalation));
|
||||
|
||||
// Usar abandonment_rate del backend si existe
|
||||
const abandonmentRateBackend = safeNumber(op?.abandonment_rate, 0);
|
||||
|
||||
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
|
||||
const csatGlobal =
|
||||
@@ -952,13 +1173,19 @@ export function buildHeatmapFromBackend(
|
||||
)
|
||||
);
|
||||
|
||||
// 2) Complejidad inversa (usamos la tasa global de escalación como proxy)
|
||||
const transfer_rate = globalEscalation; // %
|
||||
// 2) Transfer rate POR SKILL - estimado desde CV y hold time
|
||||
// Skills con mayor variabilidad (CV alto) y mayor hold time tienden a tener más transferencias
|
||||
// Usamos el global como base y lo modulamos por skill
|
||||
const cvFactor = Math.min(2, Math.max(0.5, 1 + (cv_aht - 0.5))); // Factor 0.5x - 2x basado en CV
|
||||
const holdFactor = Math.min(1.5, Math.max(0.7, 1 + (hold_p50 - 30) / 100)); // Factor 0.7x - 1.5x basado en hold
|
||||
const skillTransferRate = Math.max(2, Math.min(40, globalEscalation * cvFactor * holdFactor));
|
||||
|
||||
// Complejidad inversa basada en transfer rate del skill
|
||||
const complexity_inverse_score = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
10,
|
||||
10 - ((transfer_rate / 100 - 0.05) / 0.25) * 10
|
||||
10 - ((skillTransferRate / 100 - 0.05) / 0.25) * 10
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1008,12 +1235,12 @@ export function buildHeatmapFromBackend(
|
||||
)
|
||||
: 0;
|
||||
|
||||
// Transfer rate es el % real de transferencias (NO el complemento)
|
||||
// Transfer rate es el % real de transferencias POR SKILL
|
||||
const transferMetric = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
Math.round(transfer_rate)
|
||||
Math.round(skillTransferRate)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1049,13 +1276,14 @@ export function buildHeatmapFromBackend(
|
||||
csat: csatMetric0_100,
|
||||
hold_time: holdMetric,
|
||||
transfer_rate: transferMetric,
|
||||
abandonment_rate: Math.round(abandonmentRateBackend),
|
||||
},
|
||||
annual_cost,
|
||||
variability: {
|
||||
cv_aht: Math.round(cv_aht * 100), // %
|
||||
cv_talk_time: 0,
|
||||
cv_hold_time: 0,
|
||||
transfer_rate,
|
||||
transfer_rate: skillTransferRate, // Transfer rate estimado por skill
|
||||
},
|
||||
automation_readiness,
|
||||
dimensions: {
|
||||
@@ -1076,6 +1304,186 @@ export function buildHeatmapFromBackend(
|
||||
return heatmap;
|
||||
}
|
||||
|
||||
// ==== Benchmark Data (Sector Aéreo) ====
|
||||
|
||||
function buildBenchmarkData(raw: BackendRawResults): AnalysisData['benchmarkData'] {
|
||||
const op = raw?.operational_performance;
|
||||
const cs = raw?.customer_satisfaction;
|
||||
|
||||
const benchmarkData: AnalysisData['benchmarkData'] = [];
|
||||
|
||||
// Benchmarks hardcoded para sector aéreo
|
||||
const AIRLINE_BENCHMARKS = {
|
||||
aht_p50: 380, // segundos
|
||||
fcr: 70, // % (rango 68-72%)
|
||||
abandonment: 5, // % (rango 5-8%)
|
||||
ratio_p90_p50: 2.0, // ratio saludable
|
||||
cpi: 5.25 // € (rango €4.50-€6.00)
|
||||
};
|
||||
|
||||
// 1. AHT Promedio (benchmark sector aéreo: 380s)
|
||||
const ahtP50 = safeNumber(op?.aht_distribution?.p50, 0);
|
||||
if (ahtP50 > 0) {
|
||||
// Percentil: menor AHT = mejor. Si AHT <= benchmark = P75+
|
||||
const ahtPercentile = ahtP50 <= AIRLINE_BENCHMARKS.aht_p50
|
||||
? Math.min(90, 75 + Math.round((AIRLINE_BENCHMARKS.aht_p50 - ahtP50) / 10))
|
||||
: Math.max(10, 75 - Math.round((ahtP50 - AIRLINE_BENCHMARKS.aht_p50) / 5));
|
||||
benchmarkData.push({
|
||||
kpi: 'AHT P50',
|
||||
userValue: Math.round(ahtP50),
|
||||
userDisplay: `${Math.round(ahtP50)}s`,
|
||||
industryValue: AIRLINE_BENCHMARKS.aht_p50,
|
||||
industryDisplay: `${AIRLINE_BENCHMARKS.aht_p50}s`,
|
||||
percentile: ahtPercentile,
|
||||
p25: 450,
|
||||
p50: AIRLINE_BENCHMARKS.aht_p50,
|
||||
p75: 320,
|
||||
p90: 280
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Tasa FCR (benchmark sector aéreo: 70%)
|
||||
const fcrRate = safeNumber(op?.fcr_rate, NaN);
|
||||
if (Number.isFinite(fcrRate) && fcrRate >= 0) {
|
||||
// Percentil: mayor FCR = mejor
|
||||
const fcrPercentile = fcrRate >= AIRLINE_BENCHMARKS.fcr
|
||||
? Math.min(90, 50 + Math.round((fcrRate - AIRLINE_BENCHMARKS.fcr) * 2))
|
||||
: Math.max(10, 50 - Math.round((AIRLINE_BENCHMARKS.fcr - fcrRate) * 2));
|
||||
benchmarkData.push({
|
||||
kpi: 'Tasa FCR',
|
||||
userValue: fcrRate / 100,
|
||||
userDisplay: `${Math.round(fcrRate)}%`,
|
||||
industryValue: AIRLINE_BENCHMARKS.fcr / 100,
|
||||
industryDisplay: `${AIRLINE_BENCHMARKS.fcr}%`,
|
||||
percentile: fcrPercentile,
|
||||
p25: 0.60,
|
||||
p50: AIRLINE_BENCHMARKS.fcr / 100,
|
||||
p75: 0.78,
|
||||
p90: 0.85
|
||||
});
|
||||
}
|
||||
|
||||
// 3. CSAT (si disponible)
|
||||
const csatGlobal = safeNumber(cs?.csat_global, NaN);
|
||||
if (Number.isFinite(csatGlobal) && csatGlobal > 0) {
|
||||
const csatPercentile = Math.max(10, Math.min(90, Math.round((csatGlobal / 5) * 100)));
|
||||
benchmarkData.push({
|
||||
kpi: 'CSAT',
|
||||
userValue: csatGlobal,
|
||||
userDisplay: `${csatGlobal.toFixed(1)}/5`,
|
||||
industryValue: 4.0,
|
||||
industryDisplay: '4.0/5',
|
||||
percentile: csatPercentile,
|
||||
p25: 3.5,
|
||||
p50: 4.0,
|
||||
p75: 4.3,
|
||||
p90: 4.6
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Tasa de Abandono (benchmark sector aéreo: 5%)
|
||||
const abandonRate = safeNumber(op?.abandonment_rate, NaN);
|
||||
if (Number.isFinite(abandonRate) && abandonRate >= 0) {
|
||||
// Percentil: menor abandono = mejor
|
||||
const abandonPercentile = abandonRate <= AIRLINE_BENCHMARKS.abandonment
|
||||
? Math.min(90, 75 + Math.round((AIRLINE_BENCHMARKS.abandonment - abandonRate) * 5))
|
||||
: Math.max(10, 75 - Math.round((abandonRate - AIRLINE_BENCHMARKS.abandonment) * 5));
|
||||
benchmarkData.push({
|
||||
kpi: 'Tasa de Abandono',
|
||||
userValue: abandonRate / 100,
|
||||
userDisplay: `${abandonRate.toFixed(1)}%`,
|
||||
industryValue: AIRLINE_BENCHMARKS.abandonment / 100,
|
||||
industryDisplay: `${AIRLINE_BENCHMARKS.abandonment}%`,
|
||||
percentile: abandonPercentile,
|
||||
p25: 0.08,
|
||||
p50: AIRLINE_BENCHMARKS.abandonment / 100,
|
||||
p75: 0.03,
|
||||
p90: 0.02
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Ratio P90/P50 (benchmark sector aéreo: <2.0)
|
||||
const ahtP90 = safeNumber(op?.aht_distribution?.p90, 0);
|
||||
const ratio = ahtP50 > 0 && ahtP90 > 0 ? ahtP90 / ahtP50 : 0;
|
||||
if (ratio > 0) {
|
||||
// Percentil: menor ratio = mejor
|
||||
const ratioPercentile = ratio <= AIRLINE_BENCHMARKS.ratio_p90_p50
|
||||
? Math.min(90, 75 + Math.round((AIRLINE_BENCHMARKS.ratio_p90_p50 - ratio) * 30))
|
||||
: Math.max(10, 75 - Math.round((ratio - AIRLINE_BENCHMARKS.ratio_p90_p50) * 30));
|
||||
benchmarkData.push({
|
||||
kpi: 'Ratio P90/P50',
|
||||
userValue: ratio,
|
||||
userDisplay: ratio.toFixed(2),
|
||||
industryValue: AIRLINE_BENCHMARKS.ratio_p90_p50,
|
||||
industryDisplay: `<${AIRLINE_BENCHMARKS.ratio_p90_p50}`,
|
||||
percentile: ratioPercentile,
|
||||
p25: 2.5,
|
||||
p50: AIRLINE_BENCHMARKS.ratio_p90_p50,
|
||||
p75: 1.5,
|
||||
p90: 1.3
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Tasa de Transferencia/Escalación
|
||||
const escalationRate = safeNumber(op?.escalation_rate, NaN);
|
||||
if (Number.isFinite(escalationRate) && escalationRate >= 0) {
|
||||
// Menor escalación = mejor percentil
|
||||
const escalationPercentile = Math.max(10, Math.min(90, Math.round(100 - escalationRate * 5)));
|
||||
benchmarkData.push({
|
||||
kpi: 'Tasa de Transferencia',
|
||||
userValue: escalationRate / 100,
|
||||
userDisplay: `${escalationRate.toFixed(1)}%`,
|
||||
industryValue: 0.15,
|
||||
industryDisplay: '15%',
|
||||
percentile: escalationPercentile,
|
||||
p25: 0.20,
|
||||
p50: 0.15,
|
||||
p75: 0.10,
|
||||
p90: 0.08
|
||||
});
|
||||
}
|
||||
|
||||
// 7. CPI - Coste por Interacción (benchmark sector aéreo: €4.50-€6.00)
|
||||
const econ = raw?.economy_costs;
|
||||
const totalAnnualCost = safeNumber(econ?.cost_breakdown?.total_annual, 0);
|
||||
const volumetry = raw?.volumetry;
|
||||
const volumeBySkill = volumetry?.volume_by_skill;
|
||||
const skillVolumes: number[] = Array.isArray(volumeBySkill?.values)
|
||||
? volumeBySkill.values.map((v: any) => safeNumber(v, 0))
|
||||
: [];
|
||||
const totalInteractions = skillVolumes.reduce((a, b) => a + b, 0);
|
||||
|
||||
if (totalAnnualCost > 0 && totalInteractions > 0) {
|
||||
const cpi = totalAnnualCost / totalInteractions;
|
||||
// Menor CPI = mejor. Si CPI <= 4.50 = excelente (P90+), si CPI >= 6.00 = malo (P25-)
|
||||
let cpiPercentile: number;
|
||||
if (cpi <= 4.50) {
|
||||
cpiPercentile = Math.min(95, 90 + Math.round((4.50 - cpi) * 10));
|
||||
} else if (cpi <= AIRLINE_BENCHMARKS.cpi) {
|
||||
cpiPercentile = Math.round(50 + ((AIRLINE_BENCHMARKS.cpi - cpi) / 0.75) * 40);
|
||||
} else if (cpi <= 6.00) {
|
||||
cpiPercentile = Math.round(25 + ((6.00 - cpi) / 0.75) * 25);
|
||||
} else {
|
||||
cpiPercentile = Math.max(5, 25 - Math.round((cpi - 6.00) * 10));
|
||||
}
|
||||
|
||||
benchmarkData.push({
|
||||
kpi: 'Coste por Interacción (CPI)',
|
||||
userValue: cpi,
|
||||
userDisplay: `€${cpi.toFixed(2)}`,
|
||||
industryValue: AIRLINE_BENCHMARKS.cpi,
|
||||
industryDisplay: `€${AIRLINE_BENCHMARKS.cpi.toFixed(2)}`,
|
||||
percentile: cpiPercentile,
|
||||
p25: 6.00,
|
||||
p50: AIRLINE_BENCHMARKS.cpi,
|
||||
p75: 4.50,
|
||||
p90: 3.80
|
||||
});
|
||||
}
|
||||
|
||||
return benchmarkData;
|
||||
}
|
||||
|
||||
function computeCsatAverage(customerSatisfaction: any): number | undefined {
|
||||
const arr = customerSatisfaction?.csat_avg_by_skill_channel;
|
||||
if (!Array.isArray(arr) || !arr.length) return undefined;
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -18,21 +47,51 @@ export async function parseCSV(file: File): Promise<RawInteraction[]> {
|
||||
|
||||
// Parsear headers
|
||||
const headers = lines[0].split(',').map(h => h.trim());
|
||||
console.log('📋 Todos los headers del CSV:', headers);
|
||||
|
||||
// Validar headers requeridos
|
||||
const requiredFields = [
|
||||
'interaction_id',
|
||||
'datetime_start',
|
||||
'queue_skill',
|
||||
'channel',
|
||||
'duration_talk',
|
||||
'hold_time',
|
||||
'wrap_up_time',
|
||||
'agent_id',
|
||||
'transfer_flag'
|
||||
// Verificar campos clave
|
||||
const keyFields = ['is_abandoned', 'fcr_real_flag', 'repeat_call_7d', 'transfer_flag', 'record_status'];
|
||||
const foundKeyFields = keyFields.filter(f => headers.includes(f));
|
||||
const missingKeyFields = keyFields.filter(f => !headers.includes(f));
|
||||
console.log('✅ Campos clave encontrados:', foundKeyFields);
|
||||
console.log('⚠️ Campos clave NO encontrados:', missingKeyFields.length > 0 ? missingKeyFields : 'TODOS PRESENTES');
|
||||
|
||||
// Debug: Mostrar las primeras 5 filas con valores crudos de campos booleanos
|
||||
console.log('📋 VALORES CRUDOS DE CAMPOS BOOLEANOS (primeras 5 filas):');
|
||||
for (let rowNum = 1; rowNum <= Math.min(5, lines.length - 1); rowNum++) {
|
||||
const rawValues = lines[rowNum].split(',').map(v => v.trim());
|
||||
const rowData: Record<string, string> = {};
|
||||
headers.forEach((header, idx) => {
|
||||
rowData[header] = rawValues[idx] || '';
|
||||
});
|
||||
console.log(` Fila ${rowNum}:`, {
|
||||
is_abandoned: rowData.is_abandoned,
|
||||
fcr_real_flag: rowData.fcr_real_flag,
|
||||
repeat_call_7d: rowData.repeat_call_7d,
|
||||
transfer_flag: rowData.transfer_flag,
|
||||
record_status: rowData.record_status
|
||||
});
|
||||
}
|
||||
|
||||
// Validar headers requeridos (con variantes aceptadas)
|
||||
// v3.1: queue_skill (estratégico) y original_queue_id (operativo) son campos separados
|
||||
const requiredFieldsWithVariants: { field: string; variants: string[] }[] = [
|
||||
{ field: 'interaction_id', variants: ['interaction_id', 'Interaction_ID', 'Interaction ID'] },
|
||||
{ field: 'datetime_start', variants: ['datetime_start', 'Datetime_Start', 'Datetime Start'] },
|
||||
{ field: 'queue_skill', variants: ['queue_skill', 'Queue_Skill', 'Queue Skill', 'Skill'] },
|
||||
{ field: 'original_queue_id', variants: ['original_queue_id', 'Original_Queue_ID', 'Original Queue ID', 'Cola'] },
|
||||
{ field: 'channel', variants: ['channel', 'Channel'] },
|
||||
{ field: 'duration_talk', variants: ['duration_talk', 'Duration_Talk', 'Duration Talk'] },
|
||||
{ field: 'hold_time', variants: ['hold_time', 'Hold_Time', 'Hold Time'] },
|
||||
{ field: 'wrap_up_time', variants: ['wrap_up_time', 'Wrap_Up_Time', 'Wrap Up Time'] },
|
||||
{ field: 'agent_id', variants: ['agent_id', 'Agent_ID', 'Agent ID'] },
|
||||
{ field: 'transfer_flag', variants: ['transfer_flag', 'Transfer_Flag', 'Transfer Flag'] }
|
||||
];
|
||||
|
||||
const missingFields = requiredFields.filter(field => !headers.includes(field));
|
||||
const missingFields = requiredFieldsWithVariants
|
||||
.filter(({ variants }) => !variants.some(v => headers.includes(v)))
|
||||
.map(({ field }) => field);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
throw new Error(`Faltan campos requeridos: ${missingFields.join(', ')}`);
|
||||
}
|
||||
@@ -40,11 +99,21 @@ export async function parseCSV(file: File): Promise<RawInteraction[]> {
|
||||
// Parsear filas
|
||||
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++) {
|
||||
const values = lines[i].split(',').map(v => v.trim());
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -54,17 +123,61 @@ export async function parseCSV(file: File): Promise<RawInteraction[]> {
|
||||
});
|
||||
|
||||
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 = {
|
||||
interaction_id: row.interaction_id,
|
||||
datetime_start: row.datetime_start,
|
||||
queue_skill: row.queue_skill,
|
||||
queue_skill: queueSkill,
|
||||
original_queue_id: originalQueueId || undefined,
|
||||
channel: row.channel,
|
||||
duration_talk: isNaN(parseFloat(row.duration_talk)) ? 0 : parseFloat(row.duration_talk),
|
||||
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),
|
||||
agent_id: row.agent_id,
|
||||
transfer_flag: row.transfer_flag?.toLowerCase() === 'true' || row.transfer_flag === '1',
|
||||
caller_id: row.caller_id || undefined
|
||||
transfer_flag: transferFlag,
|
||||
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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsear archivo Excel a array de objetos
|
||||
* Usa la librería xlsx que ya está instalada
|
||||
*/
|
||||
export async function parseExcel(file: File): Promise<RawInteraction[]> {
|
||||
// Importar xlsx dinámicamente
|
||||
const XLSX = await import('xlsx');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -92,11 +241,9 @@ export async function parseExcel(file: File): Promise<RawInteraction[]> {
|
||||
const data = e.target?.result;
|
||||
const workbook = XLSX.read(data, { type: 'binary' });
|
||||
|
||||
// Usar la primera hoja
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
|
||||
// Convertir a JSON
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
||||
|
||||
if (jsonData.length === 0) {
|
||||
@@ -104,35 +251,74 @@ export async function parseExcel(file: File): Promise<RawInteraction[]> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar y transformar a 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++) {
|
||||
const row: any = jsonData[i];
|
||||
|
||||
try {
|
||||
const durationStr = row.duration_talk || row.Duration_Talk || row['Duration Talk'] || '0';
|
||||
const holdStr = row.hold_time || row.Hold_Time || row['Hold Time'] || '0';
|
||||
const wrapStr = row.wrap_up_time || row.Wrap_Up_Time || row['Wrap Up Time'] || '0';
|
||||
// === PARSING SIMPLE Y DIRECTO ===
|
||||
|
||||
const durationTalkVal = isNaN(parseFloat(durationStr)) ? 0 : parseFloat(durationStr);
|
||||
const holdTimeVal = isNaN(parseFloat(holdStr)) ? 0 : parseFloat(holdStr);
|
||||
const wrapUpTimeVal = isNaN(parseFloat(wrapStr)) ? 0 : parseFloat(wrapStr);
|
||||
// is_abandoned
|
||||
const isAbandonedRaw = getColumnValue(row, 'is_abandoned', 'Is_Abandoned', 'Is Abandoned', 'abandoned');
|
||||
const isAbandoned = parseBoolean(isAbandonedRaw);
|
||||
if (isAbandoned) abandonedTrueCount++;
|
||||
|
||||
// fcr_real_flag
|
||||
const fcrRealRaw = getColumnValue(row, 'fcr_real_flag', 'FCR_Real_Flag', 'FCR Real Flag', 'fcr_flag', 'fcr');
|
||||
const fcrRealFlag = parseBoolean(fcrRealRaw);
|
||||
if (fcrRealFlag) fcrTrueCount++;
|
||||
|
||||
// repeat_call_7d
|
||||
const repeatRaw = getColumnValue(row, 'repeat_call_7d', 'Repeat_Call_7d', 'Repeat Call 7d', 'repeat_call', 'rellamada');
|
||||
const repeatCall7d = parseBoolean(repeatRaw);
|
||||
if (repeatCall7d) repeatTrueCount++;
|
||||
|
||||
// transfer_flag
|
||||
const transferRaw = getColumnValue(row, 'transfer_flag', 'Transfer_Flag', 'Transfer Flag');
|
||||
const transferFlag = parseBoolean(transferRaw);
|
||||
if (transferFlag) transferTrueCount++;
|
||||
|
||||
// record_status
|
||||
const recordStatusRaw = getColumnValue(row, 'record_status', 'Record_Status', 'Record Status').toLowerCase().trim();
|
||||
const validStatuses = ['valid', 'noise', 'zombie', 'abandon'];
|
||||
const recordStatus = validStatuses.includes(recordStatusRaw)
|
||||
? recordStatusRaw as 'valid' | 'noise' | 'zombie' | 'abandon'
|
||||
: undefined;
|
||||
|
||||
const durationTalkVal = parseFloat(getColumnValue(row, 'duration_talk', 'Duration_Talk', 'Duration Talk') || '0');
|
||||
const holdTimeVal = parseFloat(getColumnValue(row, 'hold_time', 'Hold_Time', 'Hold Time') || '0');
|
||||
const wrapUpTimeVal = parseFloat(getColumnValue(row, 'wrap_up_time', 'Wrap_Up_Time', 'Wrap Up Time') || '0');
|
||||
|
||||
// v3.0: Parsear campos para drill-down
|
||||
// business_unit = Línea de Negocio (9 categorías C-Level)
|
||||
const lineaNegocio = getColumnValue(row, 'business_unit', 'Business_Unit', 'BusinessUnit', 'linea_negocio', 'Linea_Negocio', 'business_line');
|
||||
|
||||
const interaction: RawInteraction = {
|
||||
interaction_id: String(row.interaction_id || row.Interaction_ID || row['Interaction ID'] || ''),
|
||||
datetime_start: String(row.datetime_start || row.Datetime_Start || row['Datetime Start'] || row['Fecha/Hora de apertura'] || ''),
|
||||
queue_skill: String(row.queue_skill || row.Queue_Skill || row['Queue Skill'] || row.Subtipo || row.Tipo || ''),
|
||||
channel: String(row.channel || row.Channel || row['Origen del caso'] || 'Unknown'),
|
||||
interaction_id: String(getColumnValue(row, 'interaction_id', 'Interaction_ID', 'Interaction ID') || ''),
|
||||
datetime_start: String(getColumnValue(row, 'datetime_start', 'Datetime_Start', 'Datetime Start', 'Fecha/Hora de apertura') || ''),
|
||||
queue_skill: String(getColumnValue(row, 'queue_skill', 'Queue_Skill', 'Queue Skill', 'Skill', 'Subtipo', 'Tipo') || ''),
|
||||
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,
|
||||
hold_time: isNaN(holdTimeVal) ? 0 : holdTimeVal,
|
||||
wrap_up_time: isNaN(wrapUpTimeVal) ? 0 : wrapUpTimeVal,
|
||||
agent_id: String(row.agent_id || row.Agent_ID || row['Agent ID'] || row['Propietario del caso'] || 'Unknown'),
|
||||
transfer_flag: Boolean(row.transfer_flag || row.Transfer_Flag || row['Transfer Flag'] || false),
|
||||
caller_id: row.caller_id || row.Caller_ID || row['Caller ID'] || undefined
|
||||
agent_id: String(getColumnValue(row, 'agent_id', 'Agent_ID', 'Agent ID', 'Propietario del caso') || 'Unknown'),
|
||||
transfer_flag: transferFlag,
|
||||
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) {
|
||||
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) {
|
||||
reject(new Error('No se pudieron parsear datos válidos del Excel'));
|
||||
return;
|
||||
@@ -205,14 +401,22 @@ export function validateInteractions(interactions: RawInteraction[]): {
|
||||
}
|
||||
|
||||
// Validar período mínimo (3 meses recomendado)
|
||||
const dates = interactions
|
||||
.map(i => new Date(i.datetime_start))
|
||||
.filter(d => !isNaN(d.getTime()));
|
||||
let minTime = Infinity;
|
||||
let maxTime = -Infinity;
|
||||
let validDatesCount = 0;
|
||||
|
||||
if (dates.length > 0) {
|
||||
const minDate = new Date(Math.min(...dates.map(d => d.getTime())));
|
||||
const maxDate = new Date(Math.max(...dates.map(d => d.getTime())));
|
||||
const monthsDiff = (maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24 * 30);
|
||||
for (const interaction of interactions) {
|
||||
const date = new Date(interaction.datetime_start);
|
||||
const time = date.getTime();
|
||||
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) {
|
||||
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,
|
||||
skills: uniqueSkills,
|
||||
agents: uniqueAgents,
|
||||
dateRange: dates.length > 0 ? {
|
||||
min: new Date(Math.min(...dates.map(d => d.getTime()))).toISOString().split('T')[0],
|
||||
max: new Date(Math.max(...dates.map(d => d.getTime()))).toISOString().split('T')[0]
|
||||
dateRange: validDatesCount > 0 ? {
|
||||
min: new Date(minTime).toISOString().split('T')[0],
|
||||
max: new Date(maxTime).toISOString().split('T')[0]
|
||||
} : 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;
|
||||
}
|
||||
Reference in New Issue
Block a user