diff --git a/frontend/components/DataInputRedesigned.tsx b/frontend/components/DataInputRedesigned.tsx index 3e266a3..016696f 100644 --- a/frontend/components/DataInputRedesigned.tsx +++ b/frontend/components/DataInputRedesigned.tsx @@ -91,7 +91,7 @@ const DataInputRedesigned: React.FC = ({ setUploadMethod('file'); toast.success(`Archivo "${selectedFile.name}" cargado`, { icon: '📄' }); } else { - toast.error('Tipo de archivo no válido. Sube un CSV o Excel.', { icon: '❌' }); + toast.error('Tipo de archivo no válido. Sube un CSV.', { icon: '❌' }); } } }; @@ -393,7 +393,7 @@ const DataInputRedesigned: React.FC = ({

- Subir Archivo CSV/Excel + Subir Archivo CSV

{uploadMethod === 'file' && ( @@ -447,7 +447,8 @@ const DataInputRedesigned: React.FC = ({
- {/* Opción 2: URL Google Sheets */} + + {/* Opción 2: URL Google Sheets
= ({
+ */} {/* Opción 3: Datos sintéticos */}
{ + const dimensions = analysis.dimensions || []; + const dimScoreMap = new Map(); + + dimensions.forEach((d) => { + if (d.id && typeof d.score === 'number') { + dimScoreMap.set(d.id, d.score); + } + }); + + const overallScore = + typeof analysis.overallHealthScore === 'number' + ? analysis.overallHealthScore + : 70; + + const econ = analysis.economicModel; + const annualSavings = econ?.annualSavings ?? 0; + const currentCost = econ?.currentAnnualCost ?? 0; + + // Relevancia por recomendación + const scoredTemplates = RECOMMENDATIONS.map((tpl, index) => { + const dimId = tpl.dimensionId || 'overall'; + const dimScore = dimScoreMap.get(dimId) ?? overallScore; + + let relevance = 0; + + // 1) Dimensiones débiles => más relevancia + if (dimScore < 60) relevance += 3; + else if (dimScore < 75) relevance += 2; + else if (dimScore < 85) relevance += 1; + + // 2) Prioridad declarada en la plantilla + if (tpl.priority === 'high') relevance += 2; + else if (tpl.priority === 'medium') relevance += 1; + + // 3) Refuerzo en función del potencial económico + if ( + annualSavings > 0 && + currentCost > 0 && + annualSavings / currentCost > 0.15 && + dimId === 'economy' + ) { + relevance += 2; + } + + // 4) Ligera penalización si la dimensión ya está muy bien (>85) + if (dimScore > 85) relevance -= 1; + + return { + tpl, + relevance, + index, // por si queremos desempatar + }; + }); + + // Filtramos las que no aportan nada (relevance <= 0) + let filtered = scoredTemplates.filter((s) => s.relevance > 0); + + // Si ninguna pasa el filtro (por ejemplo, todo muy bien), + // nos quedamos al menos con 2–3 de las de mayor prioridad + if (filtered.length === 0) { + filtered = scoredTemplates + .slice() + .sort((a, b) => { + const prioWeight = (p?: 'high' | 'medium' | 'low') => { + if (p === 'high') return 3; + if (p === 'medium') return 2; + return 1; + }; + return ( + prioWeight(b.tpl.priority) - prioWeight(a.tpl.priority) + ); + }) + .slice(0, MAX_RECOMMENDATIONS); + } else { + // Ordenamos por relevancia (desc), y en empate, por orden original + filtered.sort((a, b) => { + if (b.relevance !== a.relevance) { + return b.relevance - a.relevance; + } + return a.index - b.index; + }); + } + + const selected = filtered.slice(0, MAX_RECOMMENDATIONS).map((s) => s.tpl); + + // Mapear a tipo Recommendation completo + return selected.map((rec, i): Recommendation => ({ + priority: + rec.priority || (i === 0 ? ('high' as const) : ('medium' as const)), + title: rec.title || 'Recomendación', + description: rec.description || rec.text, + impact: + rec.impact || + 'Mejora estimada del 10-20% en los KPIs clave.', + timeline: rec.timeline || '4-8 semanas', + // campos obligatorios: + text: + rec.text || + rec.description || + 'Recomendación prioritaria basada en el análisis de datos.', + dimensionId: rec.dimensionId || 'overall', + })); +}; + +// === FINDINGS BASADOS EN DATOS REALES === + +const MAX_FINDINGS = 5; + +const generateFindingsFromData = ( + analysis: AnalysisData +): Finding[] => { + const dimensions = analysis.dimensions || []; + const dimScoreMap = new Map(); + + dimensions.forEach((d) => { + if (d.id && typeof d.score === 'number') { + dimScoreMap.set(d.id, d.score); + } + }); + + const overallScore = + typeof analysis.overallHealthScore === 'number' + ? analysis.overallHealthScore + : 70; + + // Miramos volumetría para reforzar algunos findings + const volumetryDim = dimensions.find( + (d) => d.id === 'volumetry_distribution' + ); + const offHoursPct = + volumetryDim?.distribution_data?.off_hours_pct ?? 0; + + // Relevancia por finding + const scoredTemplates = KEY_FINDINGS.map((tpl, index) => { + const dimId = tpl.dimensionId || 'overall'; + const dimScore = dimScoreMap.get(dimId) ?? overallScore; + + let relevance = 0; + + // 1) Dimensiones débiles => más relevancia + if (dimScore < 60) relevance += 3; + else if (dimScore < 75) relevance += 2; + else if (dimScore < 85) relevance += 1; + + // 2) Tipo de finding (critical > warning > info) + if (tpl.type === 'critical') relevance += 3; + else if (tpl.type === 'warning') relevance += 2; + else relevance += 1; + + // 3) Impacto (high > medium > low) + if (tpl.impact === 'high') relevance += 2; + else if (tpl.impact === 'medium') relevance += 1; + + // 4) Refuerzo en volumetría si hay mucha demanda fuera de horario + if ( + offHoursPct > 0.25 && + tpl.dimensionId === 'volumetry_distribution' + ) { + relevance += 2; + if ( + tpl.title?.toLowerCase().includes('fuera de horario') || + tpl.text + ?.toLowerCase() + .includes('fuera del horario laboral') + ) { + relevance += 1; + } + } + + return { + tpl, + relevance, + index, + }; + }); + + // Filtramos los que no aportan nada (relevance <= 0) + let filtered = scoredTemplates.filter((s) => s.relevance > 0); + + // Si nada pasa el filtro, cogemos al menos algunos por prioridad/tipo + if (filtered.length === 0) { + filtered = scoredTemplates + .slice() + .sort((a, b) => { + const typeWeight = (t?: Finding['type']) => { + if (t === 'critical') return 3; + if (t === 'warning') return 2; + return 1; + }; + const impactWeight = (imp?: string) => { + if (imp === 'high') return 3; + if (imp === 'medium') return 2; + return 1; + }; + const scoreA = + typeWeight(a.tpl.type) + impactWeight(a.tpl.impact); + const scoreB = + typeWeight(b.tpl.type) + impactWeight(b.tpl.impact); + return scoreB - scoreA; + }) + .slice(0, MAX_FINDINGS); + } else { + // Ordenamos por relevancia (desc), y en empate, por orden original + filtered.sort((a, b) => { + if (b.relevance !== a.relevance) { + return b.relevance - a.relevance; + } + return a.index - b.index; + }); + } + + const selected = filtered.slice(0, MAX_FINDINGS).map((s) => s.tpl); + + // Mapear a tipo Finding completo + return selected.map((finding, i): Finding => ({ + type: + finding.type || + (i === 0 + ? ('warning' as const) + : ('info' as const)), + title: finding.title || 'Hallazgo', + description: finding.description || finding.text, + // campos obligatorios: + text: + finding.text || + finding.description || + 'Hallazgo relevante basado en datos.', + dimensionId: finding.dimensionId || 'overall', + impact: finding.impact, + })); +}; + + const generateFindingsFromTemplates = (): Finding[] => { return [ ...new Set( @@ -478,6 +718,123 @@ const generateEconomicModelData = (): EconomicModelData => { }; }; +// v2.x: Generar Opportunity Matrix a partir de datos REALES (heatmap + modelo económico) +const generateOpportunitiesFromHeatmap = ( + heatmapData: HeatmapDataPoint[], + economicModel?: EconomicModelData +): Opportunity[] => { + if (!heatmapData || heatmapData.length === 0) return []; + + // Ahorro anual total calculado por el backend (si existe) + const globalSavings = economicModel?.annualSavings ?? 0; + + // 1) Calculamos un "peso" por skill en función de: + // - coste anual + // - ineficiencia (FCR bajo) + // - readiness (facilidad para automatizar) + const scored = heatmapData.map((h) => { + const annualCost = h.annual_cost ?? 0; + const readiness = h.automation_readiness ?? 0; + const fcrScore = h.metrics?.fcr ?? 0; + + // FCR bajo => más ineficiencia + const ineffPenalty = Math.max(0, 100 - fcrScore); // 0–100 + // Peso base: coste alto + ineficiencia alta + readiness alto + const baseWeight = + annualCost * + (1 + ineffPenalty / 100) * + (0.3 + 0.7 * (readiness / 100)); + + const weight = !Number.isFinite(baseWeight) || baseWeight < 0 ? 0 : baseWeight; + + return { heat: h, weight }; + }); + + const totalWeight = + scored.reduce((sum, s) => sum + s.weight, 0) || 1; + + // 2) Asignamos "savings" (ahorro potencial) por skill + const opportunitiesWithSavings = scored.map((s) => { + const { heat } = s; + const annualCost = heat.annual_cost ?? 0; + + // Si el backend nos da un ahorro anual total, lo distribuimos proporcionalmente + const savings = + globalSavings > 0 && totalWeight > 0 + ? (globalSavings * s.weight) / totalWeight + : // Si no hay dato de ahorro global, suponemos un 20% del coste anual + annualCost * 0.2; + + return { + heat, + savings: Math.max(0, savings), + }; + }); + + const maxSavings = + opportunitiesWithSavings.reduce( + (max, s) => (s.savings > max ? s.savings : max), + 0 + ) || 1; + + // 3) Construimos cada oportunidad + return opportunitiesWithSavings.map((item, index) => { + const { heat, savings } = item; + const skillName = heat.skill || `Skill ${index + 1}`; + + // Impacto: relativo al mayor ahorro + const impactRaw = (savings / maxSavings) * 10; + const impact = Math.max( + 3, + Math.min(10, Math.round(impactRaw)) + ); + + // Factibilidad base: a partir del automation_readiness (0–100) + const readiness = heat.automation_readiness ?? 0; + const feasibilityRaw = (readiness / 100) * 7 + 3; // 3–10 + const feasibility = Math.max( + 3, + Math.min(10, Math.round(feasibilityRaw)) + ); + + // Dimensión a la que lo vinculamos (solo decorativo de momento) + const dimensionId = + readiness >= 70 + ? 'volumetry_distribution' + : readiness >= 40 + ? 'efficiency' + : 'economy'; + + // Segmento de cliente (high/medium/low) si lo tenemos + const customer_segment = heat.segment; + + // Nombre legible que incluye el skill -> esto ayuda a + // OpportunityMatrixPro a encontrar el skill en el heatmap + const namePrefix = + readiness >= 70 + ? 'Automatizar ' + : readiness >= 40 + ? 'Augmentar con IA en ' + : 'Optimizar proceso en '; + + const idSlug = skillName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + + return { + id: `opp_${index + 1}_${idSlug}`, + name: `${namePrefix}${skillName}`, + impact, + feasibility, + savings: Math.round(savings), + dimensionId, + customer_segment, + }; + }); +}; + + // v2.0: Añadir percentiles múltiples const generateBenchmarkData = (): BenchmarkDataPoint[] => { const userAHT = randomInt(380, 450); @@ -557,7 +914,7 @@ export const generateAnalysis = async ( // 1) Intentar backend + mapeo try { - const raw = await callAnalysisApiRaw({ + const raw = await callAnalysisApiRaw({ tier, costPerHour, avgCsat, @@ -567,16 +924,7 @@ export const generateAnalysis = async ( const mapped = mapBackendResultsToAnalysisData(raw, tier); - // 👉 Rellenamos desde el frontend las partes que el backend aún no devuelve - mapped.findings = generateFindingsFromTemplates(); - mapped.recommendations = generateRecommendationsFromTemplates(); - mapped.opportunities = generateOpportunityMatrixData(); - mapped.roadmap = generateRoadmapData(); - - // Benchmark: de momento no tenemos datos reales -> no lo generamos en modo backend - mapped.benchmarkData = []; - - // Heatmap: ahora se construye a partir de datos reales del backend + // Heatmap: primero lo construimos a partir de datos reales del backend mapped.heatmapData = buildHeatmapFromBackend( raw, costPerHour, @@ -584,10 +932,26 @@ export const generateAnalysis = async ( segmentMapping ); + // Oportunidades: AHORA basadas en heatmap real + modelo económico del backend + mapped.opportunities = generateOpportunitiesFromHeatmap( + mapped.heatmapData, + mapped.economicModel + ); - console.log('✅ Usando resultados del backend mapeados + findings/benchmark del frontend'); + // 👉 El resto sigue siendo "frontend-driven" de momento + mapped.findings = generateFindingsFromData(mapped); + mapped.recommendations = generateRecommendationsFromData(mapped); + mapped.roadmap = generateRoadmapData(); + + // Benchmark: de momento no tenemos datos reales -> no lo generamos en modo backend + mapped.benchmarkData = []; + + console.log( + '✅ Usando resultados del backend mapeados (heatmap + opportunities reales)' + ); return mapped; + } catch (apiError) { console.error( '❌ Backend /analysis no disponible o mapeo incompleto, fallback a lógica local:', diff --git a/frontend/utils/backendMapper.ts b/frontend/utils/backendMapper.ts index 502d1f2..6996f9f 100644 --- a/frontend/utils/backendMapper.ts +++ b/frontend/utils/backendMapper.ts @@ -9,7 +9,7 @@ import type { EconomicModelData, } from '../types'; import type { BackendRawResults } from './apiClient'; -import { BarChartHorizontal, Zap, DollarSign } from 'lucide-react'; +import { BarChartHorizontal, Zap, DollarSign, Smile, Target } from 'lucide-react'; import type { HeatmapDataPoint, CustomerSegment } from '../types'; @@ -18,6 +18,21 @@ function safeNumber(value: any, fallback = 0): number { return Number.isFinite(n) ? n : fallback; } +function normalizeAhtMetric(ahtSeconds: number): number { + if (!Number.isFinite(ahtSeconds) || ahtSeconds <= 0) return 0; + + // Ajusta estos números si ves que tus AHTs reales son muy distintos + const MIN_AHT = 300; // AHT muy bueno + const MAX_AHT = 1000; // AHT muy malo + + const clamped = Math.max(MIN_AHT, Math.min(MAX_AHT, ahtSeconds)); + const ratio = (clamped - MIN_AHT) / (MAX_AHT - MIN_AHT); // 0 (mejor) -> 1 (peor) + const score = 100 - ratio * 100; // 100 (mejor) -> 0 (peor) + + return Math.round(score); +} + + function inferTierFromScore(score: number): TierKey { if (score >= 8) return 'gold'; if (score >= 5) return 'silver'; @@ -382,6 +397,145 @@ function buildPerformanceDimension( return dimension; } +// ==== Satisfacción (customer_satisfaction) ==== + +function buildSatisfactionDimension( + raw: BackendRawResults +): DimensionAnalysis | undefined { + const cs = raw?.customer_satisfaction; + if (!cs) return undefined; + + // CSAT global viene ya calculado en el backend (1–5) + const csatGlobalRaw = safeNumber(cs?.csat_global, NaN); + if (!Number.isFinite(csatGlobalRaw) || csatGlobalRaw <= 0) { + return undefined; + } + + // Normalizamos 1–5 a 0–100 + const csat = Math.max(1, Math.min(5, csatGlobalRaw)); + const score = Math.max( + 0, + Math.min(100, Math.round((csat / 5) * 100)) + ); + + let summary = `CSAT global de ${csat.toFixed(1)}/5. `; + + if (score >= 85) { + summary += + 'La satisfacción del cliente es muy alta y consistente en la mayoría de interacciones.'; + } else if (score >= 70) { + summary += + 'La satisfacción del cliente es razonable, pero existen áreas claras de mejora en algunos journeys o motivos de contacto.'; + } else { + summary += + 'La satisfacción del cliente se sitúa por debajo de los niveles objetivo y requiere un plan de mejora específico sobre los principales drivers de insatisfacción.'; + } + + const kpi: Kpi = { + label: 'CSAT global (backend)', + value: `${csat.toFixed(1)}/5`, + }; + + const dimension: DimensionAnalysis = { + id: 'satisfaction', + name: 'satisfaction', + title: 'Voz del cliente y satisfacción', + score, + percentile: undefined, + summary, + kpi, + icon: Smile, + }; + + return dimension; +} + +// ==== Eficiencia (FCR + escalaciones + recurrencia) ==== + +function buildEfficiencyDimension( + raw: BackendRawResults +): DimensionAnalysis | undefined { + const op = raw?.operational_performance; + if (!op) return undefined; + + // FCR: viene como porcentaje 0–100, o lo aproximamos a partir de escalaciones + const fcrPctRaw = safeNumber(op.fcr_rate, NaN); + const escRateRaw = safeNumber(op.escalation_rate, NaN); + const recurrenceRaw = safeNumber(op.recurrence_rate_7d, NaN); + + const fcrPct = Number.isFinite(fcrPctRaw) && fcrPctRaw >= 0 + ? Math.max(0, Math.min(100, fcrPctRaw)) + : Number.isFinite(escRateRaw) + ? Math.max(0, Math.min(100, 100 - escRateRaw)) + : NaN; + + if (!Number.isFinite(fcrPct)) { + // Sin FCR ni escalaciones no podemos construir bien la dimensión + return undefined; + } + + let score = fcrPct; + + // Penalizar por escalaciones altas + if (Number.isFinite(escRateRaw)) { + const esc = escRateRaw as number; + if (esc > 20) score -= 20; + else if (esc > 10) score -= 10; + else if (esc > 5) score -= 5; + } + + // Penalizar por recurrencia (repetición de contactos a 7 días) + if (Number.isFinite(recurrenceRaw)) { + const rec = recurrenceRaw as number; // asumimos ya en % + if (rec > 20) score -= 15; + else if (rec > 10) score -= 10; + else if (rec > 5) score -= 5; + } + + score = Math.max(0, Math.min(100, Math.round(score))); + + const escText = Number.isFinite(escRateRaw) + ? `${(escRateRaw as number).toFixed(1)}%` + : 'N/D'; + const recText = Number.isFinite(recurrenceRaw) + ? `${(recurrenceRaw as number).toFixed(1)}%` + : 'N/D'; + + let summary = `FCR estimado de ${fcrPct.toFixed( + 1 + )}%, con una tasa de escalación del ${escText} y una recurrencia a 7 días de ${recText}. `; + + if (score >= 80) { + summary += + 'La operación presenta una alta tasa de resolución en primer contacto y pocas escalaciones, lo que indica procesos eficientes.'; + } else if (score >= 60) { + summary += + 'La eficiencia es razonable, aunque existen oportunidades de mejora en la resolución al primer contacto y en la reducción de contactos repetidos.'; + } else { + summary += + 'La eficiencia operativa es baja: hay demasiadas escalaciones o contactos repetidos, lo que impacta negativamente en costes y experiencia de cliente.'; + } + + const kpi: Kpi = { + label: 'FCR estimado (backend)', + value: `${fcrPct.toFixed(1)}%`, + }; + + const dimension: DimensionAnalysis = { + id: 'efficiency', + name: 'efficiency', + title: 'Resolución y eficiencia', + score, + percentile: undefined, + summary, + kpi, + icon: Target, + }; + + return dimension; +} + + // ==== Economía y costes (economy_costs) ==== function buildEconomicModel(raw: BackendRawResults): EconomicModelData { @@ -572,12 +726,17 @@ export function mapBackendResultsToAnalysisData( const { dimension: volumetryDimension, extraKpis } = buildVolumetryDimension(raw); const performanceDimension = buildPerformanceDimension(raw); + const satisfactionDimension = buildSatisfactionDimension(raw); const economyDimension = buildEconomyDimension(raw); + const efficiencyDimension = buildEfficiencyDimension(raw); const dimensions: DimensionAnalysis[] = []; if (volumetryDimension) dimensions.push(volumetryDimension); if (performanceDimension) dimensions.push(performanceDimension); + if (satisfactionDimension) dimensions.push(satisfactionDimension); if (economyDimension) dimensions.push(economyDimension); + if (efficiencyDimension) dimensions.push(efficiencyDimension); + const op = raw?.operational_performance; const cs = raw?.customer_satisfaction; @@ -864,17 +1023,8 @@ export function buildHeatmapFromBackend( ); // 0-100 // Métricas normalizadas 0-100 para el color del heatmap - const ahtMetric = aht_mean - ? Math.max( - 0, - Math.min( - 100, - Math.round( - 100 - ((aht_mean - 240) / 310) * 100 - ) - ) - ) - : 0; + const ahtMetric = normalizeAhtMetric(aht_mean); +; const holdMetric = hold_p50 ? Math.max( diff --git a/notas.md b/notas.md index 31f2c71..fbbcb64 100644 --- a/notas.md +++ b/notas.md @@ -15,4 +15,7 @@ npm run dev # Comparar los sintéticos con la demo y ver que ofrecen los mismos datos. Faltan cosas # Hacer que funcione de alguna manera el selector de JSON # Dockerizar -# Limpieza de código \ No newline at end of file +# Limpieza de código + +# Todo es real, menos el benchmark y sus potential savings +# Falta hacer funcionar los selectores de paquetes \ No newline at end of file