Eliminación de aleatoriedad en las dimensiones reales. Integración completa con backend menos el benchmark
This commit is contained in:
@@ -91,7 +91,7 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
|||||||
setUploadMethod('file');
|
setUploadMethod('file');
|
||||||
toast.success(`Archivo "${selectedFile.name}" cargado`, { icon: '📄' });
|
toast.success(`Archivo "${selectedFile.name}" cargado`, { icon: '📄' });
|
||||||
} else {
|
} else {
|
||||||
toast.error('Tipo de archivo no válido. Sube un CSV o Excel.', { icon: '❌' });
|
toast.error('Tipo de archivo no válido. Sube un CSV.', { icon: '❌' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -393,7 +393,7 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h4 className="font-semibold text-slate-900 mb-2 flex items-center gap-2">
|
<h4 className="font-semibold text-slate-900 mb-2 flex items-center gap-2">
|
||||||
<UploadCloud size={18} className="text-[#6D84E3]" />
|
<UploadCloud size={18} className="text-[#6D84E3]" />
|
||||||
Subir Archivo CSV/Excel
|
Subir Archivo CSV
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
{uploadMethod === 'file' && (
|
{uploadMethod === 'file' && (
|
||||||
@@ -447,7 +447,8 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Opción 2: URL Google Sheets */}
|
|
||||||
|
{/* Opción 2: URL Google Sheets
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'border-2 rounded-lg p-4 transition-all',
|
'border-2 rounded-lg p-4 transition-all',
|
||||||
uploadMethod === 'url' ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300'
|
uploadMethod === 'url' ? 'border-[#6D84E3] bg-blue-50' : 'border-slate-300'
|
||||||
@@ -486,6 +487,7 @@ const DataInputRedesigned: React.FC<DataInputRedesignedProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
*/}
|
||||||
|
|
||||||
{/* Opción 3: Datos sintéticos */}
|
{/* Opción 3: Datos sintéticos */}
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
|
|||||||
@@ -228,6 +228,246 @@ const RECOMMENDATIONS: Recommendation[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
// === RECOMENDACIONES BASADAS EN DATOS REALES ===
|
||||||
|
const MAX_RECOMMENDATIONS = 4;
|
||||||
|
|
||||||
|
const generateRecommendationsFromData = (
|
||||||
|
analysis: AnalysisData
|
||||||
|
): Recommendation[] => {
|
||||||
|
const dimensions = analysis.dimensions || [];
|
||||||
|
const dimScoreMap = new Map<string, number>();
|
||||||
|
|
||||||
|
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<string, number>();
|
||||||
|
|
||||||
|
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[] => {
|
const generateFindingsFromTemplates = (): Finding[] => {
|
||||||
return [
|
return [
|
||||||
...new Set(
|
...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
|
// v2.0: Añadir percentiles múltiples
|
||||||
const generateBenchmarkData = (): BenchmarkDataPoint[] => {
|
const generateBenchmarkData = (): BenchmarkDataPoint[] => {
|
||||||
const userAHT = randomInt(380, 450);
|
const userAHT = randomInt(380, 450);
|
||||||
@@ -557,7 +914,7 @@ export const generateAnalysis = async (
|
|||||||
|
|
||||||
// 1) Intentar backend + mapeo
|
// 1) Intentar backend + mapeo
|
||||||
try {
|
try {
|
||||||
const raw = await callAnalysisApiRaw({
|
const raw = await callAnalysisApiRaw({
|
||||||
tier,
|
tier,
|
||||||
costPerHour,
|
costPerHour,
|
||||||
avgCsat,
|
avgCsat,
|
||||||
@@ -567,16 +924,7 @@ export const generateAnalysis = async (
|
|||||||
|
|
||||||
const mapped = mapBackendResultsToAnalysisData(raw, tier);
|
const mapped = mapBackendResultsToAnalysisData(raw, tier);
|
||||||
|
|
||||||
// 👉 Rellenamos desde el frontend las partes que el backend aún no devuelve
|
// Heatmap: primero lo construimos a partir de datos reales del backend
|
||||||
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
|
|
||||||
mapped.heatmapData = buildHeatmapFromBackend(
|
mapped.heatmapData = buildHeatmapFromBackend(
|
||||||
raw,
|
raw,
|
||||||
costPerHour,
|
costPerHour,
|
||||||
@@ -584,10 +932,26 @@ export const generateAnalysis = async (
|
|||||||
segmentMapping
|
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;
|
return mapped;
|
||||||
|
|
||||||
|
|
||||||
} catch (apiError) {
|
} catch (apiError) {
|
||||||
console.error(
|
console.error(
|
||||||
'❌ Backend /analysis no disponible o mapeo incompleto, fallback a lógica local:',
|
'❌ Backend /analysis no disponible o mapeo incompleto, fallback a lógica local:',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
EconomicModelData,
|
EconomicModelData,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { BackendRawResults } from './apiClient';
|
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';
|
import type { HeatmapDataPoint, CustomerSegment } from '../types';
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +18,21 @@ function safeNumber(value: any, fallback = 0): number {
|
|||||||
return Number.isFinite(n) ? n : fallback;
|
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 {
|
function inferTierFromScore(score: number): TierKey {
|
||||||
if (score >= 8) return 'gold';
|
if (score >= 8) return 'gold';
|
||||||
if (score >= 5) return 'silver';
|
if (score >= 5) return 'silver';
|
||||||
@@ -382,6 +397,145 @@ function buildPerformanceDimension(
|
|||||||
return dimension;
|
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) ====
|
// ==== Economía y costes (economy_costs) ====
|
||||||
|
|
||||||
function buildEconomicModel(raw: BackendRawResults): EconomicModelData {
|
function buildEconomicModel(raw: BackendRawResults): EconomicModelData {
|
||||||
@@ -572,12 +726,17 @@ export function mapBackendResultsToAnalysisData(
|
|||||||
const { dimension: volumetryDimension, extraKpis } =
|
const { dimension: volumetryDimension, extraKpis } =
|
||||||
buildVolumetryDimension(raw);
|
buildVolumetryDimension(raw);
|
||||||
const performanceDimension = buildPerformanceDimension(raw);
|
const performanceDimension = buildPerformanceDimension(raw);
|
||||||
|
const satisfactionDimension = buildSatisfactionDimension(raw);
|
||||||
const economyDimension = buildEconomyDimension(raw);
|
const economyDimension = buildEconomyDimension(raw);
|
||||||
|
const efficiencyDimension = buildEfficiencyDimension(raw);
|
||||||
|
|
||||||
const dimensions: DimensionAnalysis[] = [];
|
const dimensions: DimensionAnalysis[] = [];
|
||||||
if (volumetryDimension) dimensions.push(volumetryDimension);
|
if (volumetryDimension) dimensions.push(volumetryDimension);
|
||||||
if (performanceDimension) dimensions.push(performanceDimension);
|
if (performanceDimension) dimensions.push(performanceDimension);
|
||||||
|
if (satisfactionDimension) dimensions.push(satisfactionDimension);
|
||||||
if (economyDimension) dimensions.push(economyDimension);
|
if (economyDimension) dimensions.push(economyDimension);
|
||||||
|
if (efficiencyDimension) dimensions.push(efficiencyDimension);
|
||||||
|
|
||||||
|
|
||||||
const op = raw?.operational_performance;
|
const op = raw?.operational_performance;
|
||||||
const cs = raw?.customer_satisfaction;
|
const cs = raw?.customer_satisfaction;
|
||||||
@@ -864,17 +1023,8 @@ export function buildHeatmapFromBackend(
|
|||||||
); // 0-100
|
); // 0-100
|
||||||
|
|
||||||
// Métricas normalizadas 0-100 para el color del heatmap
|
// Métricas normalizadas 0-100 para el color del heatmap
|
||||||
const ahtMetric = aht_mean
|
const ahtMetric = normalizeAhtMetric(aht_mean);
|
||||||
? Math.max(
|
;
|
||||||
0,
|
|
||||||
Math.min(
|
|
||||||
100,
|
|
||||||
Math.round(
|
|
||||||
100 - ((aht_mean - 240) / 310) * 100
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const holdMetric = hold_p50
|
const holdMetric = hold_p50
|
||||||
? Math.max(
|
? Math.max(
|
||||||
|
|||||||
5
notas.md
5
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
|
# 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
|
# Hacer que funcione de alguna manera el selector de JSON
|
||||||
# Dockerizar
|
# Dockerizar
|
||||||
# Limpieza de código
|
# Limpieza de código
|
||||||
|
|
||||||
|
# Todo es real, menos el benchmark y sus potential savings
|
||||||
|
# Falta hacer funcionar los selectores de paquetes
|
||||||
Reference in New Issue
Block a user