Commit inicial
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -996,12 +1034,209 @@ export const generateAnalysis = async (
|
||||
if (sheetUrl && !useSynthetic) {
|
||||
console.warn('🔗 Google Sheets URL processing not implemented yet, using synthetic data');
|
||||
}
|
||||
|
||||
|
||||
// Generar datos sintéticos (fallback)
|
||||
console.log('✨ Generating synthetic data');
|
||||
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,134 +5,320 @@
|
||||
|
||||
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
|
||||
*/
|
||||
export async function parseCSV(file: File): Promise<RawInteraction[]> {
|
||||
const text = await file.text();
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
|
||||
|
||||
if (lines.length < 2) {
|
||||
throw new Error('El archivo CSV está vacío o no tiene datos');
|
||||
}
|
||||
|
||||
|
||||
// Parsear headers
|
||||
const headers = lines[0].split(',').map(h => h.trim());
|
||||
|
||||
// Validar headers requeridos
|
||||
const requiredFields = [
|
||||
'interaction_id',
|
||||
'datetime_start',
|
||||
'queue_skill',
|
||||
'channel',
|
||||
'duration_talk',
|
||||
'hold_time',
|
||||
'wrap_up_time',
|
||||
'agent_id',
|
||||
'transfer_flag'
|
||||
console.log('📋 Todos los headers del CSV:', headers);
|
||||
|
||||
// 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(', ')}`);
|
||||
}
|
||||
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
const row: any = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index];
|
||||
});
|
||||
|
||||
|
||||
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);
|
||||
} catch (error) {
|
||||
console.warn(`Error parseando fila ${i + 1}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// === DEBUG SUMMARY ===
|
||||
const total = interactions.length;
|
||||
console.log('');
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('📊 RESUMEN DE PARSING CSV - VALORES BOOLEANOS');
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log(`Total registros parseados: ${total}`);
|
||||
console.log('');
|
||||
console.log(`is_abandoned:`);
|
||||
console.log(` TRUE: ${abandonedTrueCount} (${((abandonedTrueCount/total)*100).toFixed(1)}%)`);
|
||||
console.log(` FALSE: ${abandonedFalseCount} (${((abandonedFalseCount/total)*100).toFixed(1)}%)`);
|
||||
console.log('');
|
||||
console.log(`fcr_real_flag:`);
|
||||
console.log(` TRUE: ${fcrTrueCount} (${((fcrTrueCount/total)*100).toFixed(1)}%)`);
|
||||
console.log(` FALSE: ${fcrFalseCount} (${((fcrFalseCount/total)*100).toFixed(1)}%)`);
|
||||
console.log('');
|
||||
console.log(`repeat_call_7d:`);
|
||||
console.log(` TRUE: ${repeatTrueCount} (${((repeatTrueCount/total)*100).toFixed(1)}%)`);
|
||||
console.log(` FALSE: ${repeatFalseCount} (${((repeatFalseCount/total)*100).toFixed(1)}%)`);
|
||||
console.log('');
|
||||
console.log(`transfer_flag:`);
|
||||
console.log(` TRUE: ${transferTrueCount} (${((transferTrueCount/total)*100).toFixed(1)}%)`);
|
||||
console.log(` FALSE: ${transferFalseCount} (${((transferFalseCount/total)*100).toFixed(1)}%)`);
|
||||
console.log('');
|
||||
|
||||
// Calcular métricas esperadas
|
||||
const expectedAbandonRate = (abandonedTrueCount / total) * 100;
|
||||
const expectedFCR_fromFlag = (fcrTrueCount / total) * 100;
|
||||
const expectedFCR_calculated = ((total - transferTrueCount - repeatTrueCount +
|
||||
interactions.filter(i => i.transfer_flag && i.repeat_call_7d).length) / total) * 100;
|
||||
|
||||
console.log('📈 MÉTRICAS ESPERADAS:');
|
||||
console.log(` Abandonment Rate (is_abandoned=TRUE): ${expectedAbandonRate.toFixed(1)}%`);
|
||||
console.log(` FCR (fcr_real_flag=TRUE): ${expectedFCR_fromFlag.toFixed(1)}%`);
|
||||
console.log(` FCR calculado (no transfer AND no repeat): ~${expectedFCR_calculated.toFixed(1)}%`);
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('');
|
||||
|
||||
return interactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
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) {
|
||||
reject(new Error('El archivo Excel está vacío'));
|
||||
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';
|
||||
|
||||
const durationTalkVal = isNaN(parseFloat(durationStr)) ? 0 : parseFloat(durationStr);
|
||||
const holdTimeVal = isNaN(parseFloat(holdStr)) ? 0 : parseFloat(holdStr);
|
||||
const wrapUpTimeVal = isNaN(parseFloat(wrapStr)) ? 0 : parseFloat(wrapStr);
|
||||
try {
|
||||
// === PARSING SIMPLE Y DIRECTO ===
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -140,22 +326,32 @@ export async function parseExcel(file: File): Promise<RawInteraction[]> {
|
||||
console.warn(`Error parseando fila ${i + 1}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Debug summary
|
||||
const total = interactions.length;
|
||||
console.log('📊 Excel Parsing Summary:', {
|
||||
total,
|
||||
is_abandoned_TRUE: `${abandonedTrueCount} (${((abandonedTrueCount/total)*100).toFixed(1)}%)`,
|
||||
fcr_real_flag_TRUE: `${fcrTrueCount} (${((fcrTrueCount/total)*100).toFixed(1)}%)`,
|
||||
repeat_call_7d_TRUE: `${repeatTrueCount} (${((repeatTrueCount/total)*100).toFixed(1)}%)`,
|
||||
transfer_flag_TRUE: `${transferTrueCount} (${((transferTrueCount/total)*100).toFixed(1)}%)`
|
||||
});
|
||||
|
||||
if (interactions.length === 0) {
|
||||
reject(new Error('No se pudieron parsear datos válidos del Excel'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
resolve(interactions);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error('Error leyendo el archivo'));
|
||||
};
|
||||
|
||||
|
||||
reader.readAsBinaryString(file);
|
||||
});
|
||||
}
|
||||
@@ -165,7 +361,7 @@ export async function parseExcel(file: File): Promise<RawInteraction[]> {
|
||||
*/
|
||||
export async function parseFile(file: File): Promise<RawInteraction[]> {
|
||||
const fileName = file.name.toLowerCase();
|
||||
|
||||
|
||||
if (fileName.endsWith('.csv')) {
|
||||
return parseCSV(file);
|
||||
} else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) {
|
||||
@@ -193,7 +389,7 @@ export function validateInteractions(interactions: RawInteraction[]): {
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
|
||||
if (interactions.length === 0) {
|
||||
errors.push('No hay interacciones para validar');
|
||||
return {
|
||||
@@ -203,39 +399,47 @@ export function validateInteractions(interactions: RawInteraction[]): {
|
||||
stats: { total: 0, valid: 0, invalid: 0, skills: 0, agents: 0, dateRange: null }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Validar período mínimo (3 meses recomendado)
|
||||
const dates = interactions
|
||||
.map(i => new Date(i.datetime_start))
|
||||
.filter(d => !isNaN(d.getTime()));
|
||||
|
||||
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);
|
||||
|
||||
let minTime = Infinity;
|
||||
let maxTime = -Infinity;
|
||||
let validDatesCount = 0;
|
||||
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Contar skills y agentes únicos
|
||||
const uniqueSkills = new Set(interactions.map(i => i.queue_skill)).size;
|
||||
const uniqueAgents = new Set(interactions.map(i => i.agent_id)).size;
|
||||
|
||||
|
||||
if (uniqueSkills < 3) {
|
||||
warnings.push(`Solo ${uniqueSkills} skills detectados. Se recomienda tener al menos 3 para análisis comparativo.`);
|
||||
}
|
||||
|
||||
|
||||
// Validar datos de tiempo
|
||||
const invalidTimes = interactions.filter(i =>
|
||||
const invalidTimes = interactions.filter(i =>
|
||||
i.duration_talk < 0 || i.hold_time < 0 || i.wrap_up_time < 0
|
||||
).length;
|
||||
|
||||
|
||||
if (invalidTimes > 0) {
|
||||
warnings.push(`${invalidTimes} interacciones tienen tiempos negativos (serán filtradas).`);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
@@ -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