diff --git a/Proyecto/Optimizacion ruta1.1.xlsx b/Proyecto/Optimizacion ruta1.1.xlsx deleted file mode 100644 index 2af1a31..0000000 Binary files a/Proyecto/Optimizacion ruta1.1.xlsx and /dev/null differ diff --git a/Proyecto/Plantilla_PDV (2).xlsx b/Proyecto/Plantilla_PDV (2).xlsx deleted file mode 100644 index 48b9e8e..0000000 Binary files a/Proyecto/Plantilla_PDV (2).xlsx and /dev/null differ diff --git a/Proyecto/Plantilla_PDV test.xlsx b/Proyecto/Plantilla_PDV test.xlsx new file mode 100644 index 0000000..1e825c4 Binary files /dev/null and b/Proyecto/Plantilla_PDV test.xlsx differ diff --git a/Proyecto/Plantilla_Usuarios (2).xlsx b/Proyecto/Plantilla_Usuarios (2).xlsx deleted file mode 100644 index 095d1a5..0000000 Binary files a/Proyecto/Plantilla_Usuarios (2).xlsx and /dev/null differ diff --git a/Proyecto/Plantilla_Usuarios test.xlsx b/Proyecto/Plantilla_Usuarios test.xlsx new file mode 100644 index 0000000..09b44e2 Binary files /dev/null and b/Proyecto/Plantilla_Usuarios test.xlsx differ diff --git a/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json new file mode 100644 index 0000000..2f6054a --- /dev/null +++ b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json @@ -0,0 +1,403 @@ +{ + "name": "OptimizadorRutasPDV TEST - Fixed v4", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "calcular-camino", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -736, + 544 + ], + "id": "f84fe2f2-2875-4e5c-ad99-4b4cc7a2c8d2", + "name": "Webhook", + "webhookId": "01fc4a9c-bad3-42b8-9f60-b87d91302502" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{$binary.pdv_file.fileName}}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "exists", + "singleValue": true + } + }, + { + "id": "c2", + "leftValue": "={{$binary.usuarios_file.fileName}}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "exists", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + -496, + 544 + ], + "id": "5ecfc62e-849c-442b-90b8-4885494e3820", + "name": "Validate Files" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "{\n \"error\": \"Debe subir ambos archivos: PDV y Usuarios\"\n}", + "options": {} + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + -496, + 768 + ], + "id": "2c0adaf5-050d-4c2e-a509-ed77cc5a6784", + "name": "Error: Missing Files" + }, + { + "parameters": { + "operation": "xlsx", + "binaryPropertyName": "pdv_file", + "options": { + "headerRow": true + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + -160, + 416 + ], + "id": "90f15538-1ae9-454b-9038-b3d3cbf39f00", + "name": "Extract PDV" + }, + { + "parameters": { + "operation": "xlsx", + "binaryPropertyName": "usuarios_file", + "options": { + "headerRow": true + } + }, + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1.1, + "position": [ + -160, + 640 + ], + "id": "a8a72a9e-7e38-486a-9db2-7efb130c6963", + "name": "Extract Usuarios" + }, + { + "parameters": { + "jsCode": "const allRows = $input.all().map(item => item.json);\nif (!allRows.length) {\n return [{ json: { success: false, data: [], message: 'PDV: Archivo vacío' } }];\n}\nconst keys = new Set(allRows.flatMap(r => Object.keys(r)));\nconst required = ['ID','Nombre del PDV','Segmentación','Latitud','Longitud','Visitas semanales','Duración visita(horas)','Prioridad'];\nconst missing = required.filter(c => !keys.has(c));\nlet message = '';\nlet success = true;\nif (missing.length) {\n success = false;\n message = `PDV: Columnas faltantes: ${missing.join(', ')}`;\n}\nif (allRows.length > 2000) {\n success = false;\n message = 'PDV: Máximo 2000 filas permitidas';\n}\nreturn [{ json: { success, data: $input.all(), message } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 80, + 416 + ], + "id": "e7acdf1f-4741-4189-91b9-30a90d42b531", + "name": "Validate PDV" + }, + { + "parameters": { + "jsCode": "const allRows = $input.all().map(item => item.json);\nif (!allRows.length) {\n return [{ json: { success: false, data: [], message: 'Usuarios: Archivo vacío' } }];\n}\nconst keys = new Set(allRows.flatMap(r => Object.keys(r)));\nconst required = ['ID','Nombre del empleado','Latitud','Longitud','Horas por día usuario','Horas por semana usuario'];\nconst missing = required.filter(c => !keys.has(c));\nlet message = '';\nlet success = true;\nif (missing.length) {\n success = false;\n message = `Usuarios: Columnas faltantes: ${missing.join(', ')}`;\n}\nif (allRows.length > 50) {\n success = false;\n message = 'Usuarios: Máximo 50 filas permitidas';\n}\nreturn [{ json: { success, data: $input.all(), message } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 80, + 640 + ], + "id": "0d734c5f-bf19-4eaa-9d82-16657523b53c", + "name": "Validate Usuarios" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 336, + 544 + ], + "id": "e13702a2-1b82-4a1f-bba6-e8880c8346aa", + "name": "Merge Validations" + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ error: $json[0].message || $json[1].message || 'Error de validación' }) }}", + "options": {} + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 576, + 768 + ], + "id": "d496c883-3206-47f5-89f4-f829ed167845", + "name": "Error: Validation Failed" + }, + { + "parameters": { + "jsCode": "return await (async () => {\nconst items = $input.all();\n\nconst pdvResult = items[0]?.json;\nconst userResult = items[1]?.json;\n\nif (!pdvResult?.success) {\n return [{ json: { error: pdvResult?.message || 'Error validación PDVs' } }];\n}\nif (!userResult?.success) {\n return [{ json: { error: userResult?.message || 'Error validación Usuarios' } }];\n}\n\nconst pdvsRaw = pdvResult.data.map(item => item.json);\nconst usuariosRaw = userResult.data.map(item => item.json);\n\nif (!pdvsRaw.length || !usuariosRaw.length) {\n return [{ json: { error: 'Archivos vacíos o sin datos válidos' } }];\n}\n\nconst DIAS_SEMANA = ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes'];\n\nconst asignaciones = [];\nconst PDVnoAsignados = [];\n\nfunction calcularDistancia(lat1, lon1, lat2, lon2) {\n const R = 6371;\n const dLat = (lat2 - lat1) * Math.PI / 180;\n const dLon = (lon2 - lon1) * Math.PI / 180;\n const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLon/2)**2;\n return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));\n}\n\nfunction formatHeader(value) {\n return value.toLowerCase()\n .normalize('NFD').replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9 ]/gi, '').trim().replace(/\\s+/g, '_');\n}\n\nfunction cleanNum(val) {\n if (val === null || val === undefined) return 0;\n if (typeof val === 'number') return val;\n return parseFloat(String(val).replace(/[^0-9.\\-]/g, '')) || 0;\n}\n\nfunction renameHeaders(rows) {\n return rows.map(row => {\n const newRow = {};\n for (const key in row) {\n const newKey = formatHeader(key);\n let val = row[key];\n if (typeof val === 'string') val = val.trim();\n newRow[newKey] = val;\n }\n return newRow;\n });\n}\n\nconst pdvs = renameHeaders(pdvsRaw);\nconst usuarios = renameHeaders(usuariosRaw);\n\n// Mapear columnas del Excel a nombres internos — P1 robustez\nfunction mapPdv(p) {\n const duracion = cleanNum(p.duracion_visitahoras ?? p.duracion_visita_horas ?? p.duracion_visita ?? 0);\n return {\n id: p.id,\n nombre_del_pdv: p.nombre_del_pdv,\n segmentacion: (p.segmentacion || 'D').toString().trim().toUpperCase(),\n latitud: cleanNum(p.latitud),\n longitud: cleanNum(p.longitud),\n visitas_semanales: Math.max(1, Math.min(7, Math.round(cleanNum(p.visitas_semanales) || 1))),\n duracion_visita_horas: duracion,\n prioridad: Math.round(cleanNum(p.prioridad) || 5),\n activo: (p.activo ?? 'Si').toString().trim(),\n };\n}\n\nfunction mapUsuario(u) {\n return {\n id: u.id,\n nombre_del_empleado: u.nombre_del_empleado,\n latitud: cleanNum(u.latitud),\n longitud: cleanNum(u.longitud),\n horas_por_dia_usuario: cleanNum(u.horas_por_dia_usuario),\n horas_por_semana_usuario: cleanNum(u.horas_por_semana_usuario),\n visitas_maximas_por_dia: Math.max(1, Math.round(cleanNum(u.visitas_maximas_por_dia) || 8)),\n usuario_activo: (u.usuario_activo ?? u.usuarioactivo ?? 'Si').toString().trim(),\n };\n}\n\nlet pdvsMapeados = pdvs.map(mapPdv).filter(p => {\n const v = p.activo.toLowerCase();\n return v !== 'n' && v !== 'no' && v !== '0';\n});\nlet usuariosMapeados = usuarios.map(mapUsuario).filter(u => {\n const v = u.usuario_activo.toLowerCase();\n return v !== 'n' && v !== 'no' && v !== '0';\n});\n\nif (!pdvsMapeados.length) {\n return [{ json: { error: 'No hay PDVs activos para asignar' } }];\n}\nif (!usuariosMapeados.length) {\n return [{ json: { error: 'No hay usuarios activos disponibles' } }];\n}\n\nfunction getDiasParaVisitas(n) {\n n = Math.max(1, Math.min(5, Math.round(n)));\n if (n === 1) return [DIAS_SEMANA[0]];\n if (n === 2) return [DIAS_SEMANA[0], DIAS_SEMANA[3]];\n if (n === 3) return [DIAS_SEMANA[0], DIAS_SEMANA[2], DIAS_SEMANA[4]];\n if (n === 4) return [DIAS_SEMANA[0], DIAS_SEMANA[1], DIAS_SEMANA[3], DIAS_SEMANA[4]];\n return [...DIAS_SEMANA];\n}\n\nfunction asignarPdvUsuarios() {\n const orden = ['AA', 'A', 'B', 'C', 'D'];\n const segmentWeight = { AA: 100, A: 80, B: 60, C: 40, D: 20 };\n const horasDisp = {};\n const horasNecesita = {};\n const pdvsAsignados = new Set();\n\n for (const u of usuariosMapeados) horasDisp[u.id] = u.horas_por_semana_usuario;\n for (const p of pdvsMapeados) horasNecesita[p.id] = p.duracion_visita_horas * p.visitas_semanales;\n\n const pdvsPendientes = [...pdvsMapeados].sort((a, b) => {\n const idxA = orden.indexOf(a.segmentacion);\n const idxB = orden.indexOf(b.segmentacion);\n const ia = idxA === -1 ? 99 : idxA;\n const ib = idxB === -1 ? 99 : idxB;\n const scoreA = (segmentWeight[a.segmentacion] || 0) + (6 - a.prioridad) * 6;\n const scoreB = (segmentWeight[b.segmentacion] || 0) + (6 - b.prioridad) * 6;\n return scoreB - scoreA || ia - ib || a.prioridad - b.prioridad;\n });\n\n function candidatosViablesParaUsuario(usuario) {\n return pdvsPendientes\n .filter(pdv => !pdvsAsignados.has(pdv.id) && horasNecesita[pdv.id] > 0.001 && horasNecesita[pdv.id] <= horasDisp[usuario.id] + 0.001)\n .map(pdv => ({\n pdv,\n distancia: calcularDistancia(usuario.latitud, usuario.longitud, pdv.latitud, pdv.longitud),\n score: (segmentWeight[pdv.segmentacion] || 0) + (6 - pdv.prioridad) * 6\n }))\n .sort((a, b) => a.distancia - b.distancia || b.score - a.score);\n }\n\n let assignedInRound = true;\n while (assignedInRound) {\n assignedInRound = false;\n const opcionesPorUsuario = usuariosMapeados\n .filter(usuario => horasDisp[usuario.id] > 0.001)\n .map(usuario => ({ usuario, candidatos: candidatosViablesParaUsuario(usuario) }))\n .filter(item => item.candidatos.length > 0)\n .sort((a, b) => b.candidatos[0].distancia - a.candidatos[0].distancia);\n\n const usuariosAsignadosEnRonda = new Set();\n for (const opcion of opcionesPorUsuario) {\n const usuario = opcion.usuario;\n if (usuariosAsignadosEnRonda.has(usuario.id)) continue;\n\n const candidato = candidatosViablesParaUsuario(usuario)[0];\n if (!candidato) continue;\n\n const pdv = candidato.pdv;\n const horasAsignadas = horasNecesita[pdv.id];\n\n asignaciones.push({\n usuario_id: usuario.id,\n pdv_id: pdv.id,\n distancia: candidato.distancia,\n horas_asignadas: horasAsignadas,\n horasDiariasUsuario: usuario.horas_por_dia_usuario,\n visitas_semanales: pdv.visitas_semanales,\n visitas_maximas_por_dia: usuario.visitas_maximas_por_dia,\n });\n\n horasDisp[usuario.id] -= horasAsignadas;\n horasNecesita[pdv.id] = 0;\n pdvsAsignados.add(pdv.id);\n usuariosAsignadosEnRonda.add(usuario.id);\n assignedInRound = true;\n }\n }\n\n for (const pdv of pdvsPendientes) {\n const faltante = horasNecesita[pdv.id];\n if (faltante > 0.001) {\n PDVnoAsignados.push({\n pdv_id: pdv.id,\n pdv_nombre: pdv.nombre_del_pdv,\n minimo_horas_semana: Number(faltante.toFixed(2)),\n latitud: pdv.latitud,\n longitud: pdv.longitud,\n motivo: 'No hay usuario disponible con capacidad por carga para asignar el PDV completo'\n });\n }\n }\n}\n\nfunction asignarDiasHorasVisita() {\n const visitas = [];\n const visitasPorDia = {}; // usuario_id -> dia -> count\n for (const u of usuariosMapeados) {\n visitasPorDia[u.id] = {};\n for (const d of DIAS_SEMANA) visitasPorDia[u.id][d] = 0;\n }\n\n for (const a of asignaciones) {\n const nVisitas = a.visitas_semanales || 1;\n const diasAsignados = getDiasParaVisitas(nVisitas);\n const horasPorVisita = a.horas_asignadas / diasAsignados.length;\n const tiempoDesplazamiento = 0;\n\n // Validar horas por día antes de asignar\n if (horasPorVisita > a.horasDiariasUsuario + 0.001) {\n PDVnoAsignados.push({\n pdv_id: a.pdv_id,\n pdv_nombre: pdvsById[a.pdv_id]?.nombre_del_pdv || '',\n minimo_horas_semana: Number(a.horas_asignadas.toFixed(2)),\n latitud: pdvsById[a.pdv_id]?.latitud || 0,\n longitud: pdvsById[a.pdv_id]?.longitud || 0,\n motivo: `Excede horas por día del usuario (${horasPorVisita.toFixed(1)}h > ${a.horasDiariasUsuario}h)`\n });\n continue;\n }\n\n for (const dia of diasAsignados) {\n // Respetar visitas máximas por día\n const maxVisitas = a.visitas_maximas_por_dia || 8;\n if (visitasPorDia[a.usuario_id][dia] >= maxVisitas) {\n // Buscar siguiente día con cupo\n let diaAlterno = null;\n for (const d of DIAS_SEMANA) {\n if (visitasPorDia[a.usuario_id][d] < maxVisitas) { diaAlterno = d; break; }\n }\n if (!diaAlterno) {\n PDVnoAsignados.push({\n pdv_id: a.pdv_id,\n pdv_nombre: pdvsById[a.pdv_id]?.nombre_del_pdv || '',\n minimo_horas_semana: Number(horasPorVisita.toFixed(2)),\n latitud: pdvsById[a.pdv_id]?.latitud || 0,\n longitud: pdvsById[a.pdv_id]?.longitud || 0,\n motivo: `Visitas máximas por día excedidas para ${dia} (máx ${maxVisitas})`\n });\n continue;\n }\n visitas.push({\n usuario_id: a.usuario_id,\n pdv_id: a.pdv_id,\n dia: diaAlterno,\n horas_trabajo: Number(horasPorVisita.toFixed(2)),\n horas_desplazamiento: Number(tiempoDesplazamiento.toFixed(2)),\n distancia: Number(a.distancia.toFixed(2)),\n });\n visitasPorDia[a.usuario_id][diaAlterno]++;\n } else {\n visitas.push({\n usuario_id: a.usuario_id,\n pdv_id: a.pdv_id,\n dia: dia,\n horas_trabajo: Number(horasPorVisita.toFixed(2)),\n horas_desplazamiento: Number(tiempoDesplazamiento.toFixed(2)),\n distancia: Number(a.distancia.toFixed(2)),\n });\n visitasPorDia[a.usuario_id][dia]++;\n }\n }\n }\n return visitas;\n}\n\n// Necesitamos pdvsById antes para asignarDias\nconst pdvsByIdPre = Object.fromEntries(pdvsMapeados.map(p => [p.id, p]));\n// Exponer global para asignarDiasHorasVisita (usado en push de error)\nglobalThis.pdvsById = pdvsByIdPre;\nvar pdvsById = pdvsByIdPre;\n\nasignarPdvUsuarios();\nlet visitas = asignarDiasHorasVisita();\n\n// --- P2.3: Orden intra-día greedy por cercanía Haversine (mejora listado sin cambiar día) ---\ntry {\n const grupoKey = (v) => `${v.usuario_id}__${v.dia}`;\n const grupos = {};\n for (const v of visitas) {\n const k = grupoKey(v);\n if (!grupos[k]) grupos[k] = [];\n grupos[k].push(v);\n }\n let visitasOrdenadas = [];\n for (const k in grupos) {\n const arr = grupos[k];\n if (arr.length <= 2) { visitasOrdenadas.push(...arr); continue; }\n const usuariosByIdPreLocal = Object.fromEntries(usuariosMapeados.map(u=>[String(u.id), u]));\n const firstU = usuariosByIdPreLocal[String(arr[0].usuario_id)] || usuariosMapeados.find(u=> String(u.id)===String(arr[0].usuario_id));\n // Nearest neighbor greedy starting from usuario location\n let restantes = [...arr];\n // Start with pdv closest to usuario\n restantes.sort((a,b)=>{\n const pa = pdvsByIdPre[a.pdv_id], pb = pdvsByIdPre[b.pdv_id];\n if (!pa||!pb) return 0;\n return calcularDistancia(firstU.latitud, firstU.longitud, pa.latitud, pa.longitud) - calcularDistancia(firstU.latitud, firstU.longitud, pb.latitud, pb.longitud);\n });\n let ordenado = [restantes.shift()];\n while (restantes.length) {\n const last = ordenado[ordenado.length-1];\n const lp = pdvsByIdPre[last.pdv_id];\n let bestIdx = 0, bestDist = Infinity;\n for (let i=0;i [u.id, u]));\nconst pdvsByIdFinal = Object.fromEntries(pdvsMapeados.map(p => [p.id, p]));\n// Reasignar para uso posterior\npdvsById = pdvsByIdFinal;\n\nconst resultadoFinal = visitas.map(v => {\n const u = usuariosById[v.usuario_id];\n const p = pdvsById[v.pdv_id];\n if (!u || !p) return null;\n return {\n usuario_id: v.usuario_id,\n nombre_usuario: u.nombre_del_empleado,\n latitud_usuario: u.latitud,\n longitud_usuario: u.longitud,\n hora_laboral_semanal_usuario: u.horas_por_semana_usuario,\n pdv_id: v.pdv_id,\n nombre_pdv: p.nombre_del_pdv,\n latitud_pdv: p.latitud,\n longitud_pdv: p.longitud,\n hora_minima_semanal_pdv: p.duracion_visita_horas * p.visitas_semanales,\n dia: v.dia,\n horas_trabajo: v.horas_trabajo,\n horas_desplazamiento: v.horas_desplazamiento,\n distancia: v.distancia,\n };\n}).filter(Boolean);\n\nconst a = resultadoFinal.map(r => ({ json: r }));\n// P1: ya no necesita bFiltrado tramposo, pero lo mantenemos por compatibilidad sin duplicar pdv_id con horas faltantes\nconst pdvIdsAsignados = new Set(a.map(x => x.json.pdv_id));\nconst bFiltrado = PDVnoAsignados.filter(pdv => {\n if (pdv.motivo && (pdv.motivo.includes('faltan') || pdv.motivo.includes('Visitas m') || pdv.motivo.includes('Excede'))) return true;\n return !pdvIdsAsignados.has(pdv.pdv_id);\n});\n\nreturn [{ assigned: a }, { noAssigned: bFiltrado }];\n\n})();" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 816, + 544 + ], + "id": "e9fe9826-4755-497e-ae6e-274e7c1d4f66", + "name": "Optimize Routes" + }, + { + "parameters": { + "respondWith": "allIncomingItems", + "options": {} + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 1024, + 544 + ], + "id": "b024c203-1b36-4f7c-97bb-3923481b7a9b", + "name": "Respond Success" + }, + { + "parameters": { + "jsCode": "const items = $input.all();\nconst pdv = items[0]; // Validate PDV output\nconst usuarios = items[1]; // Validate Usuarios output\n\nconst pdvOk = pdv?.json?.success === true;\nconst usuariosOk = usuarios?.json?.success === true;\n\nif (pdvOk && usuariosOk) {\n // Both passed → send to Optimize Routes (output 0)\n return items;\n} else {\n // Failed → send to Error (output 1)\n const msgs = [];\n if (!pdvOk) msgs.push(pdv?.json?.message || 'Error PDV');\n if (!usuariosOk) msgs.push(usuarios?.json?.message || 'Error Usuarios');\n throw new Error(msgs.join(' | '));\n}" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 576, + 544 + ], + "id": "27eb73de-4b08-4c44-97de-4d122a98749c", + "name": "Code in JavaScript", + "onError": "continueErrorOutput" + } + ], + "pinData": { + "Webhook": [ + { + "json": { + "headers": { + "host": "agenteit.digitalcompass.agency", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36", + "content-length": "19829", + "accept": "application/json, text/plain, */*", + "accept-encoding": "gzip, deflate, br, zstd", + "accept-language": "en-US,en;q=0.6", + "content-type": "multipart/form-data; boundary=----WebKitFormBoundaryeTQ0nhU3IDSv0jrS", + "origin": "http://localhost:4200", + "priority": "u=1, i", + "referer": "http://localhost:4200/", + "sec-ch-ua": "\"Not=A?Brand\";v=\"99\", \"Brave\";v=\"151\", \"Chromium\";v=\"151\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"Windows\"", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "cross-site", + "sec-gpc": "1", + "x-forwarded-for": "152.0.172.53", + "x-forwarded-host": "agenteit.digitalcompass.agency", + "x-forwarded-port": "443", + "x-forwarded-proto": "https", + "x-forwarded-server": "07b4f09d2c65", + "x-real-ip": "152.0.172.53" + }, + "params": {}, + "query": {}, + "body": {}, + "webhookUrl": "https://agenteit.digitalcompass.agency/webhook/calcular-camino", + "executionMode": "production" + }, + "binary": { + "pdv_file": { + "mimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "fileExtension": "xlsx", + "data": "filesystem-v2", + "fileName": "Plantilla_PDV (2).xlsx", + "id": "filesystem-v2:workflows/IkKMqgb7uZuRk9d9/executions/48469/binary_data/48a414e1-9c74-469b-9bec-04283032f093", + "fileSize": "9.53 kB", + "bytes": 9528 + }, + "usuarios_file": { + "mimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "fileExtension": "xlsx", + "data": "filesystem-v2", + "fileName": "Plantilla_Usuarios (2).xlsx", + "id": "filesystem-v2:workflows/IkKMqgb7uZuRk9d9/executions/48469/binary_data/6553aefd-4d40-45dd-9cdd-bfb1b1d1ec84", + "fileSize": "9.82 kB", + "bytes": 9825 + } + }, + "pairedItem": { + "item": 0 + } + } + ] + }, + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Validate Files", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Files": { + "main": [ + [ + { + "node": "Extract PDV", + "type": "main", + "index": 0 + }, + { + "node": "Extract Usuarios", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Error: Missing Files", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract PDV": { + "main": [ + [ + { + "node": "Validate PDV", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Usuarios": { + "main": [ + [ + { + "node": "Validate Usuarios", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate PDV": { + "main": [ + [ + { + "node": "Merge Validations", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Usuarios": { + "main": [ + [ + { + "node": "Merge Validations", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge Validations": { + "main": [ + [ + { + "node": "Code in JavaScript", + "type": "main", + "index": 0 + } + ] + ] + }, + "Optimize Routes": { + "main": [ + [ + { + "node": "Respond Success", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code in JavaScript": { + "main": [ + [ + { + "node": "Optimize Routes", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Error: Validation Failed", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "timeSavedMode": "fixed", + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": true + }, + "versionId": "f0dcd3f6-8043-4c22-9ca9-a477ae69db53", + "meta": { + "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" + }, + "id": "IkKMqgb7uZuRk9d9", + "tags": [] +} \ No newline at end of file diff --git a/route-planner/angular.json b/route-planner/angular.json index b15a551..db8aa6d 100644 --- a/route-planner/angular.json +++ b/route-planner/angular.json @@ -18,16 +18,27 @@ "polyfills": ["zone.js"], "tsConfig": "tsconfig.app.json", "assets": [{ "glob": "**/*", "input": "public" }], - "styles": ["src/styles.css"], + "styles": [ + "src/styles.css", + "node_modules/leaflet/dist/leaflet.css", + "node_modules/leaflet.markercluster/dist/MarkerCluster.css", + "node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css" + ], "scripts": [] }, "configurations": { "production": { "budgets": [ - { "type": "initial", "maximumWarning": "1.5MB", "maximumError": "2MB" }, - { "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" } + { "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" }, + { "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "10kB" } ], - "outputHashing": "all" + "outputHashing": "all", + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ] }, "development": { "optimization": false, diff --git a/route-planner/package-lock.json b/route-planner/package-lock.json index e8b64a2..5e31a70 100644 --- a/route-planner/package-lock.json +++ b/route-planner/package-lock.json @@ -17,6 +17,8 @@ "@primeng/themes": "^21.0.0", "file-saver": "^2.0.5", "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", + "leaflet.markercluster": "^1.5.3", "primeicons": "^7.0.0", "primeng": "^21.0.0", "rxjs": "~7.8.2", @@ -6226,6 +6228,20 @@ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "license": "BSD-2-Clause" }, + "node_modules/leaflet.heat": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz", + "integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ==" + }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/listr2": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", diff --git a/route-planner/package.json b/route-planner/package.json index 0edfa83..6659ebe 100644 --- a/route-planner/package.json +++ b/route-planner/package.json @@ -19,6 +19,8 @@ "@primeng/themes": "^21.0.0", "file-saver": "^2.0.5", "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", + "leaflet.markercluster": "^1.5.3", "primeicons": "^7.0.0", "primeng": "^21.0.0", "rxjs": "~7.8.2", diff --git a/route-planner/src/app/app.routes.ts b/route-planner/src/app/app.routes.ts index 133996e..0c37435 100644 --- a/route-planner/src/app/app.routes.ts +++ b/route-planner/src/app/app.routes.ts @@ -2,11 +2,13 @@ import { Routes } from '@angular/router'; import { LoginComponent } from './public/login/login.component'; import { HomeComponent } from './private/home/home.component'; import { AssignedRouteDetailComponent } from './private/home/assigned-route-detail/assigned-route-detail.component'; +import { RouteOverviewMapComponent } from './private/home/route-overview-map/route-overview-map.component'; export const routes: Routes = [ { path: '', redirectTo: 'login', pathMatch: 'full' }, { path: 'login', component: LoginComponent }, { path: 'home', component: HomeComponent, pathMatch: 'full' }, { path: 'home/assigned-route', component: AssignedRouteDetailComponent }, + { path: 'home/mapa', component: RouteOverviewMapComponent }, { path: '**', redirectTo: 'login' } ]; diff --git a/route-planner/src/app/core/models/route.model.ts b/route-planner/src/app/core/models/route.model.ts index 0e42f57..eeccb76 100644 --- a/route-planner/src/app/core/models/route.model.ts +++ b/route-planner/src/app/core/models/route.model.ts @@ -26,6 +26,7 @@ export interface RouteAssignedJson { nombre_pdv: string; usuario_id: string; pdv_id: number; + visit_order?: number; } export interface RouteNoAssignedResponse { pdv_id: number; @@ -34,4 +35,4 @@ export interface RouteAssignedJson { latitud: number, longitud: number, motivo: string; - } \ No newline at end of file + } diff --git a/route-planner/src/app/core/route-state/route.ts b/route-planner/src/app/core/route-state/route.ts index c956f33..38ecddc 100644 --- a/route-planner/src/app/core/route-state/route.ts +++ b/route-planner/src/app/core/route-state/route.ts @@ -4,6 +4,20 @@ import { RouteAssignedResponse, RouteNoAssignedResponse } from '../models/route.model'; +import { summarizeDailyRoutes } from '../../shared/utils/route-sequencing.utils'; + +const STORAGE_ASSIGNED = 'rp_assigned_routes'; +const STORAGE_ASSIGNED_ORIGINAL = 'rp_assigned_routes_original'; +const STORAGE_NO_ASSIGNED = 'rp_no_assigned_routes'; +const STORAGE_NO_ASSIGNED_ORIGINAL = 'rp_no_assigned_routes_original'; +const STORAGE_SELECTED = 'rp_selected_route'; +const DAY_ORDER = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes']; +export interface UnassignedAssignment { + userId: string; + day: string; + distance: number; + duration: number; +} @Injectable({ providedIn: 'root' }) export class RouteState { @@ -11,13 +25,13 @@ export class RouteState { private loading = new BehaviorSubject(false); loading$ = this.loading.asObservable(); - private assignedRoutes = new BehaviorSubject([]); + private assignedRoutes = new BehaviorSubject(this.loadAssigned()); assignedRoutes$ = this.assignedRoutes.asObservable(); - private noAssignedRoutes = new BehaviorSubject([]); + private noAssignedRoutes = new BehaviorSubject(this.loadNoAssigned()); noAssignedRoutes$ = this.noAssignedRoutes.asObservable(); - private selectedRoute = new BehaviorSubject(null); + private selectedRoute = new BehaviorSubject(this.loadSelected()); selectedRoute$ = this.selectedRoute.asObservable(); resetLoading() { @@ -30,13 +44,272 @@ export class RouteState { setAssignedRoutes(data: RouteAssignedResponse[]) { this.assignedRoutes.next(data); + this.persist(STORAGE_ASSIGNED, data); + this.persist(STORAGE_ASSIGNED_ORIGINAL, data); + } + + updateAssignedRoute( + route: RouteAssignedResponse, + userId: string, + day: string, + metrics?: { distance: number; duration: number } + ): void { + const profile = this.assignedRoutes.getValue().find(item => String(item.json.usuario_id) === String(userId)); + if (!profile) return; + const updated = this.assignedRoutes.getValue().map(item => { + if (item !== route) return item; + return { + json: { + ...item.json, + usuario_id: profile.json.usuario_id, + nombre_usuario: profile.json.nombre_usuario, + latitud_usuario: profile.json.latitud_usuario, + longitud_usuario: profile.json.longitud_usuario, + hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario, + dia: day, + distancia: metrics?.distance ?? item.json.distancia, + horas_desplazamiento: metrics?.duration ?? item.json.horas_desplazamiento + } + }; + }); + this.setDraftAssignedRoutes(updated); + } + + addAssignedFromUnassigned(route: RouteNoAssignedResponse, userId: string, day: string, distance: number, duration: number): void { + this.addAssignedFromUnassignedMany(route, [{ userId, day, distance, duration }]); + } + + addAssignedFromUnassignedMany(route: RouteNoAssignedResponse, assignments: UnassignedAssignment[]): void { + const routes = this.assignedRoutes.getValue(); + const newRoutes = assignments.map(assignment => { + const profile = routes.find(item => String(item.json.usuario_id) === String(assignment.userId)); + if (!profile) return null; + return { json: { + dia: assignment.day, + distancia: assignment.distance, + horas_desplazamiento: assignment.duration, + hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario, + hora_minima_semanal_pdv: route.minimo_horas_semana, + horas_trabajo: String(route.minimo_horas_semana), + latitud_usuario: profile.json.latitud_usuario, + longitud_usuario: profile.json.longitud_usuario, + latitud_pdv: route.latitud, + longitud_pdv: route.longitud, + nombre_usuario: profile.json.nombre_usuario, + nombre_pdv: route.pdv_nombre, + usuario_id: profile.json.usuario_id, + pdv_id: route.pdv_id + } } as RouteAssignedResponse; + }); + if (newRoutes.some(item => !item)) return; + this.setDraftAssignedRoutes([...routes, ...(newRoutes as RouteAssignedResponse[])]); + this.setDraftNoAssignedRoutes(this.noAssignedRoutes.getValue().filter(item => item.pdv_id !== route.pdv_id)); + } + + updateNoAssignedRoute(route: RouteNoAssignedResponse, changes: Partial): void { + const updated = this.noAssignedRoutes.getValue().map(item => item.pdv_id === route.pdv_id ? { ...item, ...changes } : item); + this.setDraftNoAssignedRoutes(updated); + } + + canAssignToDay( + userId: string, + day: string, + requiredHours: number, + excluding?: RouteAssignedResponse, + additionalTravelHours = 0 + ): { allowed: boolean; totalHours: number; capacityHours: number } { + const userRoute = this.assignedRoutes.getValue().find(route => String(route.json.usuario_id) === String(userId)); + if (!userRoute) return { allowed: false, totalHours: 0, capacityHours: 0 }; + const weeklyCapacity = Number(userRoute.json.hora_laboral_semanal_usuario || 0); + const weeklyLoad = this.assignedRoutes.getValue() + .filter(route => String(route.json.usuario_id) === String(userId) && route !== excluding) + .reduce((sum, route) => sum + Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0), 0) + requiredHours; + if (weeklyLoad > weeklyCapacity) { + return { allowed: false, totalHours: weeklyLoad, capacityHours: weeklyCapacity }; + } + const existing = this.assignedRoutes.getValue().filter(route => + String(route.json.usuario_id) === String(userId) && route.json.dia === day && route !== excluding + ); + const capacityHours = Number(userRoute.json.hora_laboral_semanal_usuario || 0) / 5; + const workHours = existing.reduce((sum, route) => sum + Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0), 0) + requiredHours; + const existingTravelHours = summarizeDailyRoutes(existing).reduce((sum, item) => sum + item.travelHours, 0); + const totalHours = workHours + existingTravelHours + additionalTravelHours; + return { allowed: totalHours <= capacityHours, totalHours, capacityHours }; + } + + moveRouteVisit(route: RouteAssignedResponse, direction: -1 | 1): void { + const group = this.assignedRoutes.getValue().filter(item => + String(item.json.usuario_id) === String(route.json.usuario_id) && item.json.dia === route.json.dia + ); + const sequence = summarizeDailyRoutes(group)[0]?.routes ?? []; + const index = sequence.findIndex(item => item.route === route); + const target = index + direction; + if (index < 0 || target < 0 || target >= sequence.length) return; + const ordered = [...sequence]; + [ordered[index], ordered[target]] = [ordered[target], ordered[index]]; + const orderByRoute = new Map(ordered.map((item, position) => [item.route, position + 1])); + this.setDraftAssignedRoutes(this.assignedRoutes.getValue().map(item => { + const order = orderByRoute.get(item); + return order ? { json: { ...item.json, visit_order: order } } : item; + })); + } + + resetRouteVisitOrder(userId: string, day: string): void { + this.setDraftAssignedRoutes(this.assignedRoutes.getValue().map(route => + String(route.json.usuario_id) === String(userId) && route.json.dia === day + ? { json: { ...route.json, visit_order: undefined } } + : route + )); + } + + restoreOriginalAssignedRoutes(): void { + const original = this.loadOriginalAssigned(); + this.setDraftAssignedRoutes(original); + } + + reoptimizeAssignedRoutes(): void { + const current = this.assignedRoutes.getValue(); + const profiles = Array.from(new Map(current.map(route => [String(route.json.usuario_id), route])).values()); + if (profiles.length < 2) return; + + const load = new Map(profiles.map(profile => [String(profile.json.usuario_id), 0])); + const optimized = [...current] + .sort((a, b) => Number(b.json.hora_minima_semanal_pdv) - Number(a.json.hora_minima_semanal_pdv)) + .map(route => { + const routeHours = Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0); + const user = profiles + .slice() + .filter(profile => { + const userId = String(profile.json.usuario_id); + const capacity = Number(profile.json.hora_laboral_semanal_usuario || 0); + return (load.get(userId) ?? 0) + routeHours <= capacity; + }) + .sort((a, b) => (load.get(String(a.json.usuario_id)) ?? 0) - (load.get(String(b.json.usuario_id)) ?? 0))[0] + ?? profiles.slice().sort((a, b) => (load.get(String(a.json.usuario_id)) ?? 0) - (load.get(String(b.json.usuario_id)) ?? 0))[0]; + const userId = String(user.json.usuario_id); + load.set(userId, (load.get(userId) ?? 0) + routeHours); + return { + json: { + ...route.json, + usuario_id: user.json.usuario_id, + nombre_usuario: user.json.nombre_usuario, + latitud_usuario: user.json.latitud_usuario, + longitud_usuario: user.json.longitud_usuario, + hora_laboral_semanal_usuario: user.json.hora_laboral_semanal_usuario + } + }; + }); + this.setDraftAssignedRoutes(optimized); } setNoAssignedRoutes(data: RouteNoAssignedResponse[]) { this.noAssignedRoutes.next(data); + this.persist(STORAGE_NO_ASSIGNED, data); + this.persist(STORAGE_NO_ASSIGNED_ORIGINAL, data); } sendRoute(route: RouteAssignedResponse) { this.selectedRoute.next(route); + this.persist(STORAGE_SELECTED, route); + } + + getAssignedSnapshot(): RouteAssignedResponse[] { + return this.assignedRoutes.getValue(); + } + + getNoAssignedSnapshot(): RouteNoAssignedResponse[] { + return this.noAssignedRoutes.getValue(); + } + + findRoute(pdvId: string | number, usuarioId?: string): RouteAssignedResponse | null { + const pid = String(pdvId); + const routes = this.getAssignedSnapshot(); + const found = routes.find(r => { + const jp = r.json; + if (String(jp.pdv_id) !== pid) return false; + if (usuarioId && String(jp.usuario_id) !== String(usuarioId)) return false; + return true; + }); + return found ?? null; + } + + getUsuariosUnicos(): { id: string; nombre: string }[] { + const map = new Map(); + for (const r of this.getAssignedSnapshot()) { + const id = String(r.json.usuario_id); + if (!map.has(id)) map.set(id, r.json.nombre_usuario); + } + return Array.from(map.entries()).map(([id, nombre]) => ({ id, nombre })); + } + + getDiasUnicos(): string[] { + const set = new Set(); + for (const r of this.getAssignedSnapshot()) set.add(r.json.dia); + return Array.from(set).sort((a, b) => { + const indexA = DAY_ORDER.indexOf(a); + const indexB = DAY_ORDER.indexOf(b); + return (indexA < 0 ? DAY_ORDER.length : indexA) - (indexB < 0 ? DAY_ORDER.length : indexB); + }); + } + + clearAll() { + this.assignedRoutes.next([]); + this.noAssignedRoutes.next([]); + this.selectedRoute.next(null); + try { + sessionStorage.removeItem(STORAGE_ASSIGNED); + sessionStorage.removeItem(STORAGE_ASSIGNED_ORIGINAL); + sessionStorage.removeItem(STORAGE_NO_ASSIGNED); + sessionStorage.removeItem(STORAGE_NO_ASSIGNED_ORIGINAL); + sessionStorage.removeItem(STORAGE_SELECTED); + } catch { /* ignore */ } + } + + private persist(key: string, data: unknown) { + try { + sessionStorage.setItem(key, JSON.stringify(data)); + } catch { /* ignore storage errors */ } + } + + private setDraftAssignedRoutes(data: RouteAssignedResponse[]): void { + this.assignedRoutes.next(data); + this.persist(STORAGE_ASSIGNED, data); + } + + private setDraftNoAssignedRoutes(data: RouteNoAssignedResponse[]): void { + this.noAssignedRoutes.next(data); + this.persist(STORAGE_NO_ASSIGNED, data); + } + + private loadAssigned(): RouteAssignedResponse[] { + try { + const raw = sessionStorage.getItem(STORAGE_ASSIGNED); + if (raw) return JSON.parse(raw) as RouteAssignedResponse[]; + } catch { /* ignore */ } + return []; + } + + private loadOriginalAssigned(): RouteAssignedResponse[] { + try { + const raw = sessionStorage.getItem(STORAGE_ASSIGNED_ORIGINAL); + if (raw) return JSON.parse(raw) as RouteAssignedResponse[]; + } catch { /* ignore */ } + return this.loadAssigned(); + } + + private loadNoAssigned(): RouteNoAssignedResponse[] { + try { + const raw = sessionStorage.getItem(STORAGE_NO_ASSIGNED); + if (raw) return JSON.parse(raw) as RouteNoAssignedResponse[]; + } catch { /* ignore */ } + return []; + } + + private loadSelected(): RouteAssignedResponse | null { + try { + const raw = sessionStorage.getItem(STORAGE_SELECTED); + if (raw) return JSON.parse(raw) as RouteAssignedResponse; + } catch { /* ignore */ } + return null; } } diff --git a/route-planner/src/app/core/services/ors.service.ts b/route-planner/src/app/core/services/ors.service.ts index 864975b..02b5db2 100644 --- a/route-planner/src/app/core/services/ors.service.ts +++ b/route-planner/src/app/core/services/ors.service.ts @@ -9,11 +9,15 @@ import { catchError, map, toArray, - timeout + timeout, + Observable, + Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { environment } from '../../../environments/environment'; -const ENDPOINT = 'http://localhost:18080/ors/v2/directions/driving-car'; -const ENDPOINTJSON = 'http://localhost:18080/ors/v2/directions/driving-car/geojson'; +const ENDPOINT = `${environment.orsBaseUrl}/directions/driving-car`; +const ENDPOINTJSON = `${environment.orsBaseUrl}/directions/driving-car/geojson`; const MAX_CONCURRENT_REQUESTS = 2; @Injectable({ @@ -21,8 +25,14 @@ const MAX_CONCURRENT_REQUESTS = 2; }) export class OrsService { + private geoCache = new Map(); + constructor(private http: HttpClient) { } + private cacheKey(origin: number[], dest: number[]): string { + return `${origin[0].toFixed(5)},${origin[1].toFixed(5)}->${dest[0].toFixed(5)},${dest[1].toFixed(5)}`; + } + private fetchRoute( origin: number[], destination: number[], @@ -32,7 +42,7 @@ export class OrsService { coordinates: [origin, destination] }; - return this.http.post(isJson ? ENDPOINTJSON : ENDPOINT, body).pipe( + return this.http.post(isJson ? ENDPOINTJSON : ENDPOINT, body).pipe( timeout(8000), catchError(error => { throw error; @@ -48,37 +58,91 @@ export class OrsService { }[], concurrency = MAX_CONCURRENT_REQUESTS, isJson = false - ): Promise { - return lastValueFrom( - from(pares).pipe( - mergeMap( - pair => - this.fetchRoute(pair.origin, pair.destination, isJson).pipe( - delay(300), + ): Promise { + return this.processPairsWithProgress(pares, concurrency, isJson).promise; + } + + processPairsWithProgress( + pares: { + pdv_id: number; + origin: number[]; + destination: number[]; + }[], + concurrency = MAX_CONCURRENT_REQUESTS, + isJson = false, + abort$?: Observable + ): { promise: Promise; progress$: Observable<{ completed: number; total: number; item: unknown }> } { + const progressSubject = new Subject<{ completed: number; total: number; item: unknown }>(); + let completed = 0; + + let source$: Observable; + const base$ = from(pares).pipe( + mergeMap( + pair => { + const key = this.cacheKey(pair.origin, pair.destination); + if (isJson && this.geoCache.has(key)) { + const cached = this.geoCache.get(key); + const wrapped = { pdv_id: pair.pdv_id, geojson: cached }; + return of(wrapped).pipe( map(data => { - if (!isJson) { - const summary = data.routes[0].summary; - return { - pdv_id: pair.pdv_id, - distance: summary.distance, - duration: summary.duration, - raw: data - }; - } else { - return data; - } - }), - catchError(error => - of({ + completed++; + progressSubject.next({ completed, total: pares.length, item: data as unknown }); + if (completed === pares.length) progressSubject.complete(); + return data as unknown; + }) + ); + } + return this.fetchRoute(pair.origin, pair.destination, isJson).pipe( + delay(300), + map(data => { + if (isJson) this.geoCache.set(key, data); + if (!isJson) { + const summary = (data as { routes: { summary: { distance: number; duration: number } }[] }).routes[0].summary; + const out = { pdv_id: pair.pdv_id, - error: error.message || 'Error ORS' - }) - ) - ), - concurrency - ), - toArray() - ) - ); + distance: summary.distance, + duration: summary.duration, + raw: data + }; + completed++; + progressSubject.next({ completed, total: pares.length, item: out }); + if (completed === pares.length) progressSubject.complete(); + return out; + } else { + const wrapped = { pdv_id: pair.pdv_id, geojson: data }; + completed++; + progressSubject.next({ completed, total: pares.length, item: wrapped as unknown }); + if (completed === pares.length) progressSubject.complete(); + return wrapped as unknown; + } + }), + catchError(error => { + const errOut = { + pdv_id: pair.pdv_id, + error: (error as Error).message || 'Error ORS' + }; + completed++; + progressSubject.next({ completed, total: pares.length, item: errOut }); + if (completed === pares.length) progressSubject.complete(); + return of(errOut); + }) + ); + }, + concurrency + ), + toArray() + ) as Observable; + source$ = abort$ ? (base$.pipe(takeUntil(abort$)) as Observable) : base$; + + const promise = lastValueFrom(source$).catch(() => { + progressSubject.complete(); + return []; + }); + + return { promise, progress$: progressSubject.asObservable() }; + } + + clearCache() { + this.geoCache.clear(); } } diff --git a/route-planner/src/app/core/services/planning-input.service.ts b/route-planner/src/app/core/services/planning-input.service.ts new file mode 100644 index 0000000..c686c76 --- /dev/null +++ b/route-planner/src/app/core/services/planning-input.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class PlanningInputService { + private pdvFile: File | null = null; + private usuariosFile: File | null = null; + + setFiles(pdvFile: File, usuariosFile: File): void { + this.pdvFile = pdvFile; + this.usuariosFile = usuariosFile; + } + + getFormData(): FormData | null { + if (!this.pdvFile || !this.usuariosFile) return null; + const data = new FormData(); + data.append('pdv_file', this.pdvFile, this.pdvFile.name); + data.append('usuarios_file', this.usuariosFile, this.usuariosFile.name); + return data; + } + + clear(): void { + this.pdvFile = null; + this.usuariosFile = null; + } +} diff --git a/route-planner/src/app/core/services/router.service.ts b/route-planner/src/app/core/services/router.service.ts index 5cbdfdd..7fde447 100644 --- a/route-planner/src/app/core/services/router.service.ts +++ b/route-planner/src/app/core/services/router.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { ApiResponse } from '../../core/models/route.model'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { ApiResponse } from '../models/route.model'; +import { environment } from '../../../environments/environment'; +import { catchError, timeout, throwError } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class RutasService { @@ -9,8 +11,20 @@ export class RutasService { calcularRutas(data: FormData) { return this.http.post( - 'https://agenteit.digitalcompass.agency/webhook/calcular-camino', + environment.n8nWebhookUrl, data + ).pipe( + timeout(environment.requestTimeoutMs), + catchError((err: HttpErrorResponse | Error) => { + if (err instanceof Error && err.name === 'TimeoutError') { + return throwError(() => Object.assign(new Error('Timeout al calcular rutas (30s). Intenta con menos filas o reintenta.'), { name: 'TimeoutError' })); + } + return throwError(() => err); + }) ); - } + } + + reoptimizarRutas(data: FormData) { + return this.calcularRutas(data); + } } diff --git a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.css b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.css index 2c2625e..afad04a 100644 --- a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.css +++ b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.css @@ -196,3 +196,31 @@ background: var(--glm-verde); border: 3px solid #fff; } + +.location-pin { + position: relative; + width: 30px; + height: 38px; + background: #c9252d; + clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%); + filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42)); +} + +.location-pin::before { + content: ''; + position: absolute; + inset: 3px 3px 7px; + background: #ff424a; + clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%); +} + +.location-pin::after { + content: ''; + position: absolute; + top: 8px; + left: 8px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #620000; +} diff --git a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.html b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.html index 0301a51..d76fbc1 100644 --- a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.html +++ b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.html @@ -25,6 +25,8 @@
Usuario
{{ routeData.json.nombre_usuario }}
PDV
{{ routeData.json.nombre_pdv }}
Día
{{ routeData.json.dia }}
+
Orden
{{ visitOrder }} de {{ totalStops }}
+
Origen del tramo
{{ segmentOriginName }}
Distancia
{{ routeData.json.distancia }} km
Duración
{{ (routeData.json.horas_desplazamiento * 60).toFixed(0) }} min
Horas trabajo
{{ routeData.json.horas_trabajo }}
diff --git a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts index 8f8f3d5..7b96316 100644 --- a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts +++ b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts @@ -1,11 +1,12 @@ import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { Router } from '@angular/router'; +import { Router, ActivatedRoute } from '@angular/router'; import { Subscription } from 'rxjs'; import * as L from 'leaflet'; import { RouteState } from '../../../core/route-state/route'; import { OrsService } from '../../../core/services/ors.service'; import { RouteAssignedResponse } from '../../../core/models/route.model'; +import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils'; @Component({ standalone: true, @@ -26,9 +27,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie routeData: RouteAssignedResponse | null = null; isLoading = false; error: string | null = null; + segmentOriginName = ''; + visitOrder = 0; + totalStops = 0; constructor( private router: Router, + private activatedRoute: ActivatedRoute, private routeState: RouteState, private orsService: OrsService, private cdr: ChangeDetectorRef @@ -36,11 +41,30 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie ngOnInit() { this.sub = this.routeState.selectedRoute$.subscribe((route: RouteAssignedResponse | null) => { - this.routeData = route; - if (route && this.map) { - this.loadAndShowRoute(route); - } else if (!route) { - this.error = 'No hay ruta seleccionada'; + if (route) { + this.routeData = route; + if (this.map) { + this.loadAndShowRoute(route); + } + this.error = null; + } else { + // Try to resolve from queryParams + sessionStorage snapshot + const qp = this.activatedRoute.snapshot.queryParams; + const pdvId = qp['pdv_id']; + const usuarioId = qp['usuario_id']; + if (pdvId) { + const found = this.routeState.findRoute(pdvId, usuarioId); + if (found) { + this.routeData = found; + if (this.map) this.loadAndShowRoute(found); + this.error = null; + } else { + // No data at all (e.g., direct URL after hard refresh with empty storage) + this.error = 'No hay ruta seleccionada. Vuelve al listado y selecciona una ruta.'; + } + } else if (!this.routeData) { + this.error = 'No hay ruta seleccionada'; + } } this.cdr.detectChanges(); }); @@ -50,6 +74,18 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie this.initMap(); if (this.routeData) { this.loadAndShowRoute(this.routeData); + } else { + // Re-attempt resolution after map init (queryParams case) + const qp = this.activatedRoute.snapshot.queryParams; + if (qp['pdv_id']) { + const found = this.routeState.findRoute(qp['pdv_id'], qp['usuario_id']); + if (found) { + this.routeData = found; + this.error = null; + this.loadAndShowRoute(found); + this.cdr.detectChanges(); + } + } } } @@ -64,6 +100,8 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie private initMap() { this.map = L.map('map').setView([18.4861, -69.9312], 13); + const pdvPane = this.map.createPane('pdvPane'); + pdvPane.style.zIndex = '650'; this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' @@ -77,13 +115,20 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie this.cdr.detectChanges(); const json = route.json; - const origin: [number, number] = [Number(json.longitud_usuario), Number(json.latitud_usuario)]; + const sequence = sequenceRoutes(this.routeState.getAssignedSnapshot().filter(item => + String(item.json.usuario_id) === String(json.usuario_id) && item.json.dia === json.dia + )); + const segment = sequence.find(item => item.route === route) ?? sequence.find(item => item.route.json.pdv_id === json.pdv_id); + const origin: [number, number] = segment?.origin ?? [Number(json.longitud_usuario), Number(json.latitud_usuario)]; const destination: [number, number] = [Number(json.longitud_pdv), Number(json.latitud_pdv)]; + this.segmentOriginName = segment?.previousPdvName ?? `Casa de ${json.nombre_usuario}`; + this.visitOrder = segment?.order ?? 1; + this.totalStops = segment?.totalStops ?? 1; this.centerMapOnPoints(origin, destination); try { - const geojson = await this.orsService.processPairs( + const results = await this.orsService.processPairs( [{ pdv_id: json.pdv_id, origin, destination }], 1, true @@ -91,13 +136,14 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie if (this.destroyed) return; - if (geojson[0] && !geojson[0].error) { - this.showRouteOnMap(geojson[0], origin, destination, json); + const result = results[0] as { error?: string; geojson?: unknown } | undefined; + if (result && !result.error && result.geojson) { + this.showRouteOnMap(result.geojson, origin, destination, json, this.segmentOriginName); } else { - this.showFallbackRoute(origin, destination, json); + this.showFallbackRoute(origin, destination, json, this.segmentOriginName); } } catch { - this.showFallbackRoute(origin, destination, json); + this.showFallbackRoute(origin, destination, json, this.segmentOriginName); } finally { this.isLoading = false; this.cdr.detectChanges(); @@ -112,31 +158,38 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie this.map.fitBounds(bounds.pad(0.3)); } - private extractCoords(geojson: any): L.LatLngExpression[] { - if (geojson.type === 'FeatureCollection' && geojson.features?.length) { - return geojson.features[0].geometry.coordinates.map( + private extractCoords(geojson: unknown): L.LatLngExpression[] { + const g = geojson as { type: string; features?: { geometry: { coordinates: number[][] } }[]; coordinates?: number[][] }; + if (g.type === 'FeatureCollection' && g.features?.length) { + return g.features[0].geometry.coordinates.map( (c: number[]) => [c[1], c[0]] as L.LatLngExpression ); } - if (geojson.type === 'LineString') { - return geojson.coordinates.map( + if (g.type === 'LineString' && g.coordinates) { + return g.coordinates.map( (c: number[]) => [c[1], c[0]] as L.LatLngExpression ); } + // ORS geojson response wrapped + const maybeFeature = (geojson as { features?: { geometry: { coordinates: number[][] } }[] })?.features; + if (maybeFeature?.length) { + return maybeFeature[0].geometry.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression); + } return []; } - private showRouteOnMap(geojson: any, origin: [number, number], destination: [number, number], json: any) { + private showRouteOnMap(geojson: unknown, origin: [number, number], destination: [number, number], json: RouteAssignedResponse['json'], originName: string) { const coords = this.extractCoords(geojson); if (!coords.length) { - this.showFallbackRoute(origin, destination, json); + this.showFallbackRoute(origin, destination, json, originName); return; } - const distance = geojson.features?.[0]?.properties?.summary?.distance - ? (geojson.features[0].properties.summary.distance / 1000).toFixed(2) : 'N/A'; - const duration = geojson.features?.[0]?.properties?.summary?.duration - ? (geojson.features[0].properties.summary.duration / 60).toFixed(0) : 'N/A'; + const g = geojson as { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; + const distance = g.features?.[0]?.properties?.summary?.distance + ? (g.features[0].properties.summary.distance / 1000).toFixed(2) : 'N/A'; + const duration = g.features?.[0]?.properties?.summary?.duration + ? (g.features[0].properties.summary.duration / 60).toFixed(0) : 'N/A'; this.routeGlow = L.polyline(coords, { color: '#6CC24A', @@ -163,11 +216,11 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie lineCap: 'round' }).addTo(this.map); - this.routeLine.bindPopup( - `${json.nombre_usuario} → ${json.nombre_pdv}
Distancia: ${distance} km
Duración: ${duration} min` + (this.routeLine as L.Polyline).bindPopup( + `${originName} → ${json.nombre_pdv}
Distancia: ${distance} km
Duración: ${duration} min` ); - this.addMarkers(origin, destination, json, distance, duration); + this.addMarkers(origin, destination, json, distance, duration, originName); const group = new L.FeatureGroup([ ...(this.routeGlow ? [this.routeGlow] : []), ...(this.routeLine ? [this.routeLine] : []), @@ -176,13 +229,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie this.map.fitBounds(group.getBounds().pad(0.1)); } - private showFallbackRoute(origin: [number, number], destination: [number, number], json: any) { + private showFallbackRoute(origin: [number, number], destination: [number, number], json: RouteAssignedResponse['json'], originName: string) { this.routeLine = L.polyline( [[origin[1], origin[0]], [destination[1], destination[0]]], { color: '#FF6A13', weight: 5, dashArray: '12, 10', opacity: 0.9 } ).addTo(this.map); - this.addMarkers(origin, destination, json, 'N/A', 'N/A'); + this.addMarkers(origin, destination, json, 'N/A', 'N/A', originName); const group = new L.FeatureGroup([this.routeLine, ...this.markers]); this.map.fitBounds(group.getBounds().pad(0.1)); } @@ -190,9 +243,10 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie private addMarkers( origin: [number, number], destination: [number, number], - json: any, + json: RouteAssignedResponse['json'], distance: string, - duration: string + duration: string, + originName: string ) { const startIcon = L.divIcon({ className: 'custom-marker marker-start', @@ -204,17 +258,17 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie const endIcon = L.divIcon({ className: 'custom-marker marker-end', - html: '
B
', - iconSize: [30, 30], - iconAnchor: [15, 15], + html: '
', + iconSize: [30, 38], + iconAnchor: [15, 38], popupAnchor: [0, -18] }); this.markers.push( L.marker([origin[1], origin[0]], { icon: startIcon }) .addTo(this.map) - .bindPopup(`Origen: ${json.nombre_usuario}`), - L.marker([destination[1], destination[0]], { icon: endIcon }) + .bindPopup(`Origen del tramo: ${originName}`), + L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 }) .addTo(this.map) .bindPopup(`Destino: ${json.nombre_pdv}
Distancia: ${distance} km
Duración: ${duration} min`) ); diff --git a/route-planner/src/app/private/home/assigned-route/assigned-route.component.css b/route-planner/src/app/private/home/assigned-route/assigned-route.component.css index 5afc1e5..ebfd491 100644 --- a/route-planner/src/app/private/home/assigned-route/assigned-route.component.css +++ b/route-planner/src/app/private/home/assigned-route/assigned-route.component.css @@ -69,3 +69,31 @@ color: var(--glm-acero); font-size: 13px; } + +.link-usuario { + color: var(--glm-azul); + font-weight: 600; + cursor: pointer; + text-decoration: underline; + text-decoration-color: transparent; + transition: text-decoration-color 0.15s; +} + +.link-usuario:hover { + text-decoration-color: var(--glm-verde); + color: var(--glm-verde); +} +.inline-edit { + width: 100%; + min-width: 100px; + padding: 4px 6px; + border: 1px solid var(--glm-verde); + border-radius: 3px; + background: #fff; + color: var(--glm-gris-oscuro); + font: inherit; +} + +.edit-actions { + white-space: nowrap; +} diff --git a/route-planner/src/app/private/home/assigned-route/assigned-route.component.html b/route-planner/src/app/private/home/assigned-route/assigned-route.component.html index 53f388d..b438a95 100644 --- a/route-planner/src/app/private/home/assigned-route/assigned-route.component.html +++ b/route-planner/src/app/private/home/assigned-route/assigned-route.component.html @@ -7,9 +7,19 @@ (input)="dt.filterGlobal($any($event.target).value, 'contains')" />
+ + +
+
{{ reoptError }}
+
{{ editError }}
- {{ row.json.nombre_usuario }} + + + + + {{ row.json.nombre_usuario }} + {{ row.json.nombre_pdv }} {{ row.json.hora_laboral_semanal_usuario }} {{ row.json.hora_minima_semanal_pdv }} {{ row.json.horas_trabajo }} - {{ row.json.dia }} + + + + + {{ row.json.dia }} + {{ row.json.distancia }} {{ row.json.horas_desplazamiento }} - + + + + + + + + + + {{ submitDisabledReason }} +
+ +
+
@@ -91,4 +151,4 @@

PDVs sin ruta asignada

- \ No newline at end of file + diff --git a/route-planner/src/app/private/home/home.component.ts b/route-planner/src/app/private/home/home.component.ts index d6f394d..d7c40a5 100644 --- a/route-planner/src/app/private/home/home.component.ts +++ b/route-planner/src/app/private/home/home.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, ChangeDetectorRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { AssignedRouteComponent } from './assigned-route/assigned-route.component'; @@ -6,101 +6,241 @@ import { NoAssignedRouteComponent } from './no-assigned-route/no-assigned-route. import { RutasService } from '../../core/services/router.service'; import { RouteState } from '../../core/route-state/route'; import { ApiResponse } from '../../core/models/route.model'; +import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service'; +import { Subscription } from 'rxjs'; +import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component'; +import { PlanningInputService } from '../../core/services/planning-input.service'; @Component({ standalone: true, selector: 'app-home', - imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent], + imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent, PlanningKpisComponent], templateUrl: './home.component.html', styleUrls: ['./home.component.css'] }) export class HomeComponent { pdvFile: File | null = null; usuariosFile: File | null = null; + pdvPreview: PreviewResult | null = null; + usuariosPreview: PreviewResult | null = null; + pdvPreviewLoading = false; + usuariosPreviewLoading = false; uploadError: string | null = null; isLoading = false; + private calcSub: Subscription | null = null; constructor( private rutasService: RutasService, - private routeState: RouteState + private routeState: RouteState, + private previewService: ExcelPreviewService, + private planningInput: PlanningInputService, + private cdr: ChangeDetectorRef ) {} - onPdvFileChange(event: Event) { + async onPdvFileChange(event: Event) { const input = event.target as HTMLInputElement; if (input.files && input.files.length > 0) { - this.pdvFile = input.files[0]; - this.validateFile(this.pdvFile, 'PDV'); + const file = input.files[0]; + const basicError = this.validateFileBasic(file, 'PDV'); + if (basicError) { + this.pdvFile = null; + this.pdvPreview = null; + this.uploadError = basicError; + return; + } + this.pdvFile = file; + if (this.usuariosFile) this.planningInput.setFiles(file, this.usuariosFile); this.uploadError = null; + this.pdvPreviewLoading = true; + this.pdvPreview = null; + this.cdr.detectChanges(); + try { + this.pdvPreview = await this.previewService.previewFile(file, 'PDV'); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Error al leer el archivo PDV'; + this.pdvPreview = { + fileName: file.name, + type: 'PDV', + valid: false, + totalRows: 0, + validRows: 0, + headers: [], + normalizedHeaders: [], + missingHeaders: [], + errors: [{ row: 1, field: 'file', message: msg }], + duplicateIds: [], + sampleValidRows: [], + }; + } finally { + this.pdvPreviewLoading = false; + this.cdr.detectChanges(); + } } } - onUsuariosFileChange(event: Event) { + async onUsuariosFileChange(event: Event) { const input = event.target as HTMLInputElement; if (input.files && input.files.length > 0) { - this.usuariosFile = input.files[0]; - this.validateFile(this.usuariosFile, 'Usuarios'); + const file = input.files[0]; + const basicError = this.validateFileBasic(file, 'Usuarios'); + if (basicError) { + this.usuariosFile = null; + this.usuariosPreview = null; + this.uploadError = basicError; + return; + } + this.usuariosFile = file; + if (this.pdvFile) this.planningInput.setFiles(this.pdvFile, file); this.uploadError = null; + this.usuariosPreviewLoading = true; + this.usuariosPreview = null; + this.cdr.detectChanges(); + try { + this.usuariosPreview = await this.previewService.previewFile(file, 'Usuarios'); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Error al leer el archivo Usuarios'; + this.usuariosPreview = { + fileName: file.name, + type: 'Usuarios', + valid: false, + totalRows: 0, + validRows: 0, + headers: [], + normalizedHeaders: [], + missingHeaders: [], + errors: [{ row: 1, field: 'file', message: msg }], + duplicateIds: [], + sampleValidRows: [], + }; + } finally { + this.usuariosPreviewLoading = false; + this.cdr.detectChanges(); + } } } - private validateFile(file: File, type: string) { + private validateFileBasic(file: File, type: string): string | null { const validExtensions = ['.xlsx', '.xls']; const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); if (!validExtensions.includes(ext)) { - this.uploadError = `El archivo ${type} debe ser .xlsx o .xls`; - if (type === 'PDV') this.pdvFile = null; - else this.usuariosFile = null; + return `El archivo ${type} debe ser .xlsx o .xls`; } if (file.size > 10 * 1024 * 1024) { - this.uploadError = `El archivo ${type} supera 10MB`; - if (type === 'PDV') this.pdvFile = null; - else this.usuariosFile = null; + return `El archivo ${type} supera 10MB`; } + return null; } get canSubmit(): boolean { - return !!this.pdvFile && !!this.usuariosFile && !this.uploadError; + if (!this.pdvFile || !this.usuariosFile) return false; + if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return false; + if (this.isLoading) return false; + if (this.uploadError) return false; + // Require valid previews before enabling submit + if (this.pdvPreview && !this.pdvPreview.valid) return false; + if (this.usuariosPreview && !this.usuariosPreview.valid) return false; + // Previews must exist (file was parsed) + if (!this.pdvPreview || !this.usuariosPreview) return false; + return true; } - cargarRutas() { + get submitDisabledReason(): string | null { + if (!this.pdvFile || !this.usuariosFile) return 'Sube ambos archivos'; + if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return 'Validando archivos...'; + if (this.pdvPreview && !this.pdvPreview.valid) return 'Corrige los errores del archivo PDV'; + if (this.usuariosPreview && !this.usuariosPreview.valid) return 'Corrige los errores del archivo Usuarios'; + return null; + } + + async cargarRutas() { if (!this.canSubmit) return; this.isLoading = true; this.uploadError = null; this.routeState.setLoading(true); + let pdvForOptimizer: File; + try { + pdvForOptimizer = await this.previewService.preparePdvForOptimizer(this.pdvFile!); + } catch { + this.uploadError = 'No se pudo preparar el archivo PDV para el optimizador.'; + this.routeState.setLoading(false); + this.isLoading = false; + this.cdr.detectChanges(); + return; + } + this.planningInput.setFiles(pdvForOptimizer, this.usuariosFile!); const formData = new FormData(); - formData.append('pdv_file', this.pdvFile!, this.pdvFile!.name); + formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name); formData.append('usuarios_file', this.usuariosFile!, this.usuariosFile!.name); - this.rutasService.calcularRutas(formData).subscribe({ + this.calcSub?.unsubscribe(); + this.calcSub = this.rutasService.calcularRutas(formData).subscribe({ next: (res: ApiResponse) => { - const assigned = res.find(r => 'assigned' in r); - const noAssigned = res.find(r => 'noAssigned' in r); + // ApiResponse is [AssignedResponse, NoAssignedResponse] but backend may return object with error + const anyRes = res as unknown as Record; + if (anyRes && 'error' in anyRes) { + this.uploadError = String((anyRes as { error: unknown }).error); + this.routeState.setLoading(false); + this.isLoading = false; + this.cdr.detectChanges(); + return; + } + const arr = res as unknown as unknown[]; + const assigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'assigned' in r) as unknown as { assigned: unknown } | undefined; + const noAssigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'noAssigned' in r) as unknown as { noAssigned: unknown } | undefined; if (assigned) { - this.routeState.setAssignedRoutes(assigned.assigned); + this.routeState.setAssignedRoutes(assigned.assigned as never); } if (noAssigned) { - this.routeState.setNoAssignedRoutes(noAssigned.noAssigned); + this.routeState.setNoAssignedRoutes(noAssigned.noAssigned as never); } this.routeState.setLoading(false); this.isLoading = false; + this.cdr.detectChanges(); }, error: (err) => { - this.uploadError = err.error?.message || 'Error al procesar los archivos'; + this.uploadError = this.mapBackendError(err); this.routeState.setLoading(false); this.isLoading = false; + this.cdr.detectChanges(); } }); } + cancelarCarga() { + this.calcSub?.unsubscribe(); + this.calcSub = null; + this.isLoading = false; + this.routeState.setLoading(false); + this.uploadError = 'Cálculo cancelado'; + } + + private mapBackendError(err: unknown): string { + const anyErr = err as { error?: { error?: string; message?: string }; message?: string; status?: number; name?: string }; + if (anyErr?.name === 'TimeoutError') return 'El cálculo tardó demasiado (timeout 30s). Intenta de nuevo con menos filas.'; + if (anyErr?.error?.error) return anyErr.error.error; + if (anyErr?.error?.message) return anyErr.error.message; + if (anyErr?.message) return anyErr.message; + return 'Error al procesar los archivos'; + } + clearFiles() { + this.calcSub?.unsubscribe(); + this.planningInput.clear(); + this.routeState.clearAll(); this.pdvFile = null; this.usuariosFile = null; + this.pdvPreview = null; + this.usuariosPreview = null; + this.pdvPreviewLoading = false; + this.usuariosPreviewLoading = false; this.uploadError = null; + this.isLoading = false; + this.routeState.setLoading(false); const pdvInput = document.getElementById('pdvFile') as HTMLInputElement; const usuariosInput = document.getElementById('usuariosFile') as HTMLInputElement; if (pdvInput) pdvInput.value = ''; if (usuariosInput) usuariosInput.value = ''; } -} \ No newline at end of file +} diff --git a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.css b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.css index 5bdb99b..3eb2bd4 100644 --- a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.css +++ b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.css @@ -11,3 +11,58 @@ text-transform: uppercase; letter-spacing: 0.3px; } + +.assignment-actions { + display: flex; + align-items: center; + gap: 5px; + min-width: 230px; +} + +.inline-select { + max-width: 115px; + padding: 5px; + border: 1px solid var(--glm-gris-medio); + border-radius: 3px; + background: #fff; + color: var(--glm-gris-oscuro); + font-size: 0.75rem; +} + +.day-checkboxes { + display: flex; + flex-wrap: wrap; + gap: 3px 5px; + align-items: center; +} + +.day-checkboxes label { + display: inline-flex; + align-items: center; + gap: 2px; + color: var(--glm-acero); + font-size: 0.68rem; + font-weight: 600; +} + +.spinner-inline { + display: inline-block; + width: 13px; + height: 13px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.table-error { + margin-top: 10px; + padding: 9px 12px; + background: var(--glm-rojo-ok); + border: 1px solid #F5A6A0; + border-radius: 4px; + color: var(--glm-gris-oscuro); + font-size: 0.82rem; +} + +@keyframes spin { to { transform: rotate(360deg); } } diff --git a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.html b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.html index e2824a6..4795e29 100644 --- a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.html +++ b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.html @@ -19,14 +19,28 @@ {{ row.minimo_horas_semana }} {{ row.latitud }} {{ row.longitud }} - {{ row.motivo }} + {{ row.motivo }} + + +
+ +
+ + - + No hay registros asignados -
\ No newline at end of file + +
{{ actionError }}
diff --git a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts index 2128a6b..2d05d59 100644 --- a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts +++ b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.ts @@ -1,36 +1,49 @@ -import { Component, ChangeDetectorRef } from '@angular/core'; +import { Component, ChangeDetectorRef, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; import { TableModule } from 'primeng/table'; import { RouteNoAssignedResponse } from '../../../core/models/route.model'; -import { RouteState } from '../../../core/route-state/route'; +import { RouteState, UnassignedAssignment } from '../../../core/route-state/route'; +import { Subscription } from 'rxjs'; +import { OrsService } from '../../../core/services/ors.service'; @Component({ standalone: true, selector: 'app-no-assigned-route', - imports: [CommonModule, TableModule], + imports: [CommonModule, FormsModule, TableModule], templateUrl: './no-assigned-route.component.html', styleUrls: ['./no-assigned-route.component.css'] }) -export class NoAssignedRouteComponent { +export class NoAssignedRouteComponent implements OnDestroy { noAssignedRoutes: RouteNoAssignedResponse[] = []; isLoading = false; - cols: any[] = []; + cols: { field: string; header: string }[] = []; + private subs: Subscription[] = []; + assignmentUser: Record = {}; + assignmentDays: Record = {}; + assigningId: number | null = null; + actionError: string | null = null; constructor( private routesState: RouteState, - private cdr: ChangeDetectorRef + private cdr: ChangeDetectorRef, + private orsService: OrsService ) {} ngOnInit() { - this.routesState.loading$.subscribe(value => { - this.isLoading = value; - this.cdr.detectChanges(); - }); - this.routesState.noAssignedRoutes$.subscribe(data => { - this.noAssignedRoutes = [...data]; - this.cdr.detectChanges(); - }); + this.subs.push( + this.routesState.loading$.subscribe(value => { + this.isLoading = value; + this.cdr.detectChanges(); + }) + ); + this.subs.push( + this.routesState.noAssignedRoutes$.subscribe(data => { + this.noAssignedRoutes = [...data]; + this.cdr.detectChanges(); + }) + ); this.cols = [ { field: 'pdv_id', header: 'PDV ID' }, { field: 'pdv_nombre', header: 'Nombre' }, @@ -40,4 +53,61 @@ export class NoAssignedRouteComponent { { field: 'motivo', header: 'Motivo' } ]; } + + get userOptions(): { id: string; name: string }[] { + return this.routesState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre })); + } + + get dayOptions(): string[] { + return this.routesState.getDiasUnicos(); + } + + toggleDay(pdvId: number, day: string, checked: boolean): void { + const key = String(pdvId); + const days = new Set(this.assignmentDays[key] ?? []); + checked ? days.add(day) : days.delete(day); + this.assignmentDays[key] = Array.from(days); + } + + async assignRoute(route: RouteNoAssignedResponse): Promise { + const userId = this.assignmentUser[String(route.pdv_id)]; + const days = this.assignmentDays[String(route.pdv_id)] ?? []; + if (!userId || !days.length) { + this.actionError = 'Selecciona usuario y al menos un día antes de asignar.'; + return; + } + const user = this.routesState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === userId); + if (!user) return; + this.assigningId = route.pdv_id; + this.actionError = null; + try { + const origin = [Number(user.json.longitud_usuario), Number(user.json.latitud_usuario)]; + const destination = [Number(route.longitud), Number(route.latitud)]; + const assignments: UnassignedAssignment[] = []; + for (const day of days) { + const result = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true); + const geo = (result[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] } }).geojson; + const summary = geo?.features?.[0]?.properties?.summary; + if (!summary) throw new Error('ORS no devolvió una ruta vial'); + const capacity = this.routesState.canAssignToDay(userId, day, route.minimo_horas_semana, undefined, summary.duration / 3600); + if (!capacity.allowed) { + this.actionError = `${day}: excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`; + return; + } + assignments.push({ userId, day, distance: summary.distance / 1000, duration: summary.duration / 3600 }); + } + this.routesState.addAssignedFromUnassignedMany(route, assignments); + delete this.assignmentUser[String(route.pdv_id)]; + delete this.assignmentDays[String(route.pdv_id)]; + } catch { + this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`; + } finally { + this.assigningId = null; + this.cdr.detectChanges(); + } + } + + ngOnDestroy() { + this.subs.forEach(s => s.unsubscribe()); + } } diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css new file mode 100644 index 0000000..fa3450d --- /dev/null +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css @@ -0,0 +1,280 @@ +.kpis-panel { + border-top: 3px solid var(--glm-azul); +} + +.kpis-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.section-title { + margin: 0; + font-size: 16px; + color: var(--glm-azul); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.kpis-subtitle { + margin: 4px 0 0; + color: var(--glm-acero); + font-size: 0.82rem; +} + +.kpis-status { + color: var(--glm-verde); + font-size: 0.78rem; + font-weight: 600; + white-space: nowrap; +} + +.kpis-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.export-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 10px; + border: 1px solid var(--glm-verde); + border-radius: 4px; + background: var(--glm-verde-bg); + color: var(--glm-azul); + cursor: pointer; + font-size: 0.78rem; + font-weight: 600; +} + +.export-button:hover { + background: var(--glm-verde); + color: #fff; +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.kpi-card { + display: flex; + align-items: center; + gap: 12px; + min-height: 92px; + padding: 14px; + background: var(--glm-surface); + border: 1px solid var(--glm-gris-medio); + border-left: 4px solid var(--glm-azul); + border-radius: 4px; +} + +.kpi-primary { border-left-color: var(--glm-azul); } +.kpi-success { border-left-color: var(--glm-verde); } +.kpi-warning { border-left-color: var(--glm-naranja); } +.kpi-blue { border-left-color: var(--glm-acero); } +.kpi-orange { border-left-color: var(--glm-naranja); } + +.kpi-icon { + display: grid; + flex: 0 0 34px; + width: 34px; + height: 34px; + place-items: center; + border-radius: 50%; + background: var(--glm-verde-bg); + color: var(--glm-azul); + font-size: 0.95rem; +} + +.kpi-card > div:last-child { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.kpi-label { + color: var(--glm-acero); + font-size: 0.74rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.2px; +} + +.kpi-card strong { + color: var(--glm-azul); + font-size: 1.45rem; + line-height: 1.15; +} + +.kpi-card em { + font-size: 0.8rem; + font-style: normal; + font-weight: 600; +} + +.kpi-card small { + overflow: hidden; + color: var(--glm-acero); + font-size: 0.73rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.operational-grid { + margin-top: 12px; +} + +.user-load-card { + margin-top: 14px; + padding: 12px 14px; + background: var(--glm-surface); + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; +} + +.user-load-card h3 { + margin: 0 0 8px; + color: var(--glm-azul); + font-size: 0.82rem; + text-transform: uppercase; +} + +.user-load-table-wrap { + overflow-x: auto; +} + +.user-load-table { + width: 100%; + min-width: 560px; + border-collapse: collapse; + font-size: 0.8rem; +} + +.user-load-table th { + padding: 7px 8px; + background: var(--glm-azul); + color: #fff; + font-size: 0.72rem; + text-align: left; + text-transform: uppercase; +} + +.user-load-table td { + padding: 8px; + border-bottom: 1px solid var(--glm-gris-claro); + color: var(--glm-gris-oscuro); +} + +.user-load-table tbody tr:nth-child(even) { + background: var(--glm-gris-claro); +} + +.utilization-cell { + display: flex; + align-items: center; + gap: 8px; + min-width: 220px; +} + +.utilization-cell > span:first-child { + flex: 0 0 56px; +} + +.capacity-warning { + cursor: help; + font-size: 0.9rem; + line-height: 1; +} + +.utilization-bar { + display: block; + flex: 1 1 140px; + min-width: 140px; + height: 7px; + overflow: hidden; + border-radius: 10px; + background: var(--glm-gris-medio); +} + +.utilization-bar span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--glm-verde); +} + +.utilization-cell.warning .utilization-bar span { + background: var(--glm-naranja); +} + +.utilization-cell.over-capacity .utilization-bar span { + background: #c62828; +} + +.load-over-capacity td { + color: #8f1616; + font-weight: 600; +} + +.reasons-card { + margin-top: 14px; + padding: 12px 14px; + background: var(--glm-verde-bg); + border: 1px solid var(--glm-verde-claro); + border-radius: 4px; +} + +.reasons-card h3 { + margin: 0 0 8px; + color: var(--glm-azul); + font-size: 0.82rem; + text-transform: uppercase; +} + +.reason-list { + display: grid; + gap: 4px; +} + +.reason-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 5px 0; + border-bottom: 1px solid rgba(108, 194, 74, 0.25); + color: var(--glm-gris-oscuro); + font-size: 0.82rem; +} + +.reason-row:last-child { border-bottom: 0; } +.reason-row strong { color: var(--glm-azul); } + +.kpis-empty { + display: flex; + align-items: center; + gap: 8px; + padding: 14px; + color: var(--glm-acero); + font-size: 0.85rem; +} + +.kpis-empty i { color: var(--glm-gris-medio); } + +@media (max-width: 700px) { + .kpis-heading { flex-direction: column; gap: 6px; } + .kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 430px) { + .kpi-grid { grid-template-columns: 1fr; } +} diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html new file mode 100644 index 0000000..3459cd7 --- /dev/null +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html @@ -0,0 +1,99 @@ +
+
+
+

Indicadores de planificación

+

Resumen total de la planificación actual

+
+
+ Datos disponibles + +
+
+ +
+
+
+
PDVs totales{{ summary.totalPdvs }}en la planificación
+
+
+
+
PDVs asignados{{ summary.assignedPdvs }}{{ summary.coverage | number:'1.1-1' }}% de cobertura
+
+
+
+
Sin asignar{{ summary.unassignedPdvs }}requieren revisión
+
+
+
+
Usuarios activos{{ summary.users }}{{ summary.days }} días planificados
+
+
+
+
Distancia total{{ summary.totalDistance | number:'1.0-1' }} km{{ summary.averageDistance | number:'1.0-1' }} km promedio por ruta
+
+
+
+
Desplazamiento total{{ summary.totalTravelHours | number:'1.0-1' }} h{{ summary.averageTravelHours | number:'1.0-1' }} h promedio por ruta
+
+
+ +
+
+
+
Carga de trabajo{{ summary.totalWorkHours | number:'1.0-1' }} hhoras de trabajo planificadas
+
+
+
+
Utilización promedio{{ summary.averageUtilization | number:'1.0-1' }}%carga frente a disponibilidad
+
+
+
+
Rutas por usuario{{ summary.averageRoutesPerUser | number:'1.0-1' }}promedio de asignaciones
+
+
+
+
Brecha de balance{{ summary.assignmentBalance }}diferencia entre mayor y menor carga
+
+
+ +
+

Balance de carga por usuario

+
+ + + + + + + + + + + + + +
UsuarioRutasDistanciaTrabajoUtilización
{{ load.name }}{{ load.routes }}{{ load.distance | number:'1.0-1' }} km{{ load.workHours | number:'1.0-1' }} h +
+ {{ load.utilization | number:'1.0-1' }}% + ⚠️ + +
+
+
+
+ +
+

Motivos de PDVs sin asignar

+
+
+ {{ item.reason }} + {{ item.count }} +
+
+
+ +
+ + Carga una planificación para visualizar sus indicadores. +
+
diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts new file mode 100644 index 0000000..c47f6a5 --- /dev/null +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts @@ -0,0 +1,186 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Subscription } from 'rxjs'; +import { RouteState } from '../../../core/route-state/route'; +import { PlanningExportService } from '../../../shared/services/planning-export.service'; +import { summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils'; +import { RouteAssignedResponse } from '../../../core/models/route.model'; + +interface KpiSummary { + totalPdvs: number; + assignedPdvs: number; + unassignedPdvs: number; + coverage: number; + users: number; + days: number; + totalDistance: number; + totalTravelHours: number; + averageDistance: number; + averageTravelHours: number; + totalWorkHours: number; + averageRoutesPerUser: number; + averageUtilization: number; + assignmentBalance: number; +} + +interface ReasonSummary { + reason: string; + count: number; +} + +interface UserLoad { + id: string; + name: string; + routes: number; + distance: number; + workHours: number; + capacityHours: number; + utilization: number; + status: 'ok' | 'warning' | 'over-capacity'; + alertMessage: string; +} + +@Component({ + standalone: true, + selector: 'app-planning-kpis', + imports: [CommonModule], + templateUrl: './planning-kpis.component.html', + styleUrl: './planning-kpis.component.css' +}) +export class PlanningKpisComponent implements OnInit, OnDestroy { + summary: KpiSummary = this.emptySummary(); + reasons: ReasonSummary[] = []; + userLoads: UserLoad[] = []; + hasData = false; + + private readonly subs: Subscription[] = []; + + constructor( + private routeState: RouteState, + private exportService: PlanningExportService + ) {} + + ngOnInit(): void { + this.subs.push( + this.routeState.assignedRoutes$.subscribe(() => this.refresh()), + this.routeState.noAssignedRoutes$.subscribe(() => this.refresh()) + ); + } + + ngOnDestroy(): void { + this.subs.forEach(subscription => subscription.unsubscribe()); + } + + barWidth(value: number): number { + return Math.min(Math.max(value, 0), 100); + } + + utilizationClass(load: UserLoad): string { + return load.status; + } + + exportPlanning(): void { + this.exportService.export(); + } + + private refresh(): void { + const assigned = this.routeState.getAssignedSnapshot(); + const unassigned = this.routeState.getNoAssignedSnapshot(); + const totalPdvs = assigned.length + unassigned.length; + const dailyRoutes = summarizeDailyRoutes(assigned); + const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0); + const totalTravelHours = dailyRoutes.reduce((sum, day) => sum + day.travelHours, 0); + const totalWorkHours = dailyRoutes.reduce((sum, day) => sum + day.workHours, 0); + const users = new Set(assigned.map(route => String(route.json.usuario_id))).size; + const days = new Set(assigned.map(route => route.json.dia)).size; + const loads = new Map(); + + for (const route of assigned) { + const key = String(route.json.usuario_id); + const current = loads.get(key) ?? { + id: key, + name: route.json.nombre_usuario, + routes: 0, + distance: 0, + workHours: 0, + capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario), + utilization: 0, + status: 'ok', + alertMessage: '' + }; + current.routes++; + // Sequential distance and work are aggregated per user below. + loads.set(key, current); + } + + this.userLoads = Array.from(loads.values()).map(load => { + const userDays = dailyRoutes.filter(day => day.userId === load.id); + load.distance = userDays.reduce((sum, day) => sum + day.distanceKm, 0); + load.workHours = userDays.reduce((sum, day) => sum + day.workHours, 0); + load.utilization = load.capacityHours ? (load.workHours / load.capacityHours) * 100 : 0; + load.status = load.utilization > 100 ? 'over-capacity' : load.utilization > 90 ? 'warning' : 'ok'; + load.alertMessage = load.status === 'over-capacity' + ? `Alerta: ${load.name} supera el 100% de su capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h). Reasigna PDVs o reduce horas.` + : load.status === 'warning' + ? `${load.name} está sobre el 90% de su capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).` + : `${load.name} está dentro de capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).`; + return load; + }).sort((a, b) => b.routes - a.routes || b.workHours - a.workHours); + const routeCounts = this.userLoads.map(load => load.routes); + const assignmentBalance = routeCounts.length ? Math.max(...routeCounts) - Math.min(...routeCounts) : 0; + const averageUtilization = this.userLoads.length + ? this.userLoads.reduce((sum, load) => sum + load.utilization, 0) / this.userLoads.length + : 0; + + this.summary = { + totalPdvs, + assignedPdvs: assigned.length, + unassignedPdvs: unassigned.length, + coverage: totalPdvs ? (assigned.length / totalPdvs) * 100 : 0, + users, + days, + totalDistance, + totalTravelHours, + averageDistance: assigned.length ? totalDistance / assigned.length : 0, + averageTravelHours: assigned.length ? totalTravelHours / assigned.length : 0, + totalWorkHours, + averageRoutesPerUser: users ? assigned.length / users : 0, + averageUtilization, + assignmentBalance + }; + + const reasonCounts = new Map(); + for (const route of unassigned) { + const reason = route.motivo?.trim() || 'Sin motivo especificado'; + reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1); + } + this.reasons = Array.from(reasonCounts.entries()) + .map(([reason, count]) => ({ reason, count })) + .sort((a, b) => b.count - a.count); + this.hasData = totalPdvs > 0; + } + + private numberValue(value: number | string | null | undefined): number { + const result = Number(value); + return Number.isFinite(result) ? result : 0; + } + + private emptySummary(): KpiSummary { + return { + totalPdvs: 0, + assignedPdvs: 0, + unassignedPdvs: 0, + coverage: 0, + users: 0, + days: 0, + totalDistance: 0, + totalTravelHours: 0, + averageDistance: 0, + averageTravelHours: 0, + totalWorkHours: 0, + averageRoutesPerUser: 0, + averageUtilization: 0, + assignmentBalance: 0 + }; + } +} diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css new file mode 100644 index 0000000..d2879f7 --- /dev/null +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css @@ -0,0 +1,523 @@ +:host { + display: block; + height: 100vh; + position: relative; +} + +#map-overview { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 380px; + width: auto; + height: 100vh; +} + +.overview-sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 380px; + z-index: 900; + display: flex; + flex-direction: column; + gap: 14px; + padding: 16px; + overflow-y: auto; + background: var(--glm-surface); + border-right: 3px solid var(--glm-verde); +} + +.sidebar-brand { + display: flex; + justify-content: center; + padding-bottom: 10px; + border-bottom: 2px solid var(--glm-gris-medio); +} + +.sidebar-logo { + height: 42px; + width: auto; +} + +.sidebar-header { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 10px; + border-bottom: 2px solid var(--glm-gris-medio); +} + +.back-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + background: var(--glm-surface); + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; + color: var(--glm-azul); + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; + font-family: Arial, sans-serif; +} + +.back-btn:hover { + background: var(--glm-verde-bg); + border-color: var(--glm-verde); +} + +.sidebar-title { + margin: 0; + font-size: 1rem; + color: var(--glm-azul); + font-weight: 700; +} + +.filters-card { + background: var(--glm-verde-bg); + border: 1px solid var(--glm-verde-claro); + border-radius: 4px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.filter-label { + font-size: 0.75rem; + font-weight: 700; + color: var(--glm-azul); + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.filter-select { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; + font-size: 0.9rem; + font-family: Arial, sans-serif; + background: #fff; + color: var(--glm-gris-oscuro); +} + +.filter-select:focus { + outline: none; + border-color: var(--glm-verde); + box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15); +} + +.filter-stats { + display: flex; + gap: 10px; + margin-top: 6px; + font-size: 0.82rem; + color: var(--glm-acero); + font-weight: 600; +} + +.filter-empty { + font-size: 0.85rem; + color: var(--glm-acero); +} + +.empty-state { + text-align: center; + padding: 24px 12px; + color: var(--glm-acero); +} + +.empty-state i { + font-size: 2rem; + margin-bottom: 8px; + display: block; + color: var(--glm-gris-medio); +} + +.error-card { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: var(--glm-rojo-ok); + border: 1px solid #F5A6A0; + border-radius: 4px; + font-size: 0.85rem; + color: var(--glm-gris-oscuro); +} + +.unassigned-map-card { + padding: 12px; + background: #fff8ed; + border: 1px solid #f2bf86; + border-left: 3px solid var(--glm-naranja); + border-radius: 4px; +} + +.unassigned-map-card .info-card-header { margin-bottom: 8px; } + +.unassigned-map-list { + display: grid; + gap: 5px; + max-height: 180px; + overflow-y: auto; +} + +.unassigned-map-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px; + border: 1px solid #f2bf86; + border-radius: 3px; + background: #fff; + color: var(--glm-gris-oscuro); + cursor: pointer; + text-align: left; +} + +.unassigned-map-item:hover, +.unassigned-map-item.selected-pending { background: #ffedd9; } + +.pending-icon { + display: grid; + flex: 0 0 23px; + width: 23px; + height: 23px; + place-items: center; + border-radius: 50%; + background: var(--glm-naranja); + color: #fff; + font-weight: 700; +} + +.pending-text { display: flex; min-width: 0; flex-direction: column; gap: 2px; } +.pending-text strong { font-size: 0.78rem; } +.pending-text small { overflow: hidden; color: var(--glm-acero); font-size: 0.7rem; text-overflow: ellipsis; white-space: nowrap; } + +.unassigned-detail-card { border-top-color: var(--glm-naranja); } + +.pending-edit-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 8px; +} + +.pending-edit-grid label { display: flex; flex-direction: column; gap: 3px; color: var(--glm-azul); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; } +.pending-edit-grid input, +.pending-edit-grid select { width: 100%; padding: 6px; border: 1px solid var(--glm-gris-medio); border-radius: 3px; background: #fff; color: var(--glm-gris-oscuro); font: inherit; font-size: 0.8rem; } +.day-checkboxes { display: flex; flex-wrap: wrap; gap: 4px; } +.day-checkboxes label { display: inline-flex; align-items: center; gap: 3px; color: var(--glm-acero); font-size: 0.72rem; font-weight: 600; } +.wide-field { grid-column: 1 / -1; } +.pending-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; } +.pending-actions .btn-primary, .pending-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; } + +.routes-list h3 { + margin: 0 0 8px; + font-size: 0.85rem; + color: var(--glm-azul); + font-weight: 700; + text-transform: uppercase; +} + +.routes-list ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.routes-list li { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; + background: #fff; + cursor: pointer; + transition: all 0.15s; +} + +.routes-list li:hover { + background: var(--glm-verde-bg); + border-color: var(--glm-verde); +} + +.routes-list li.selected { + background: var(--glm-verde); + border-color: var(--glm-verde); + color: #fff; +} + +.routes-list li.selected small { + color: rgba(255, 255, 255, 0.85); +} + +.routes-list li.selected i { + color: #fff; +} + +.idx { + min-width: 26px; + height: 26px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: var(--glm-azul); + color: #fff; + font-size: 0.75rem; + font-weight: 700; +} + +.li-main { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.li-main strong { + font-size: 0.85rem; + color: var(--glm-gris-oscuro); +} + +.routes-list li.selected .li-main strong { + color: #fff; +} + +.li-main small { + font-size: 0.75rem; + color: var(--glm-acero); +} + +.info-card { + background: var(--glm-surface); + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; + padding: 14px; + border-top: 3px solid var(--glm-azul); +} + +.info-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + border-bottom: 2px solid var(--glm-verde); + padding-bottom: 8px; +} + +.info-card-header h3 { + margin: 0; + font-size: 0.85rem; + color: var(--glm-azul); + font-weight: 700; + text-transform: uppercase; +} + +.detail-section + .detail-section { + margin-top: 14px; + padding-top: 10px; + border-top: 1px solid var(--glm-gris-medio); +} + +.detail-section h4 { + margin: 0 0 5px; + color: var(--glm-verde); + font-size: 0.76rem; + text-transform: uppercase; +} + +.reassign-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--glm-gris-medio); +} + +.reassign-form label { display: flex; flex-direction: column; gap: 3px; color: var(--glm-azul); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; } +.reassign-form select { width: 100%; padding: 6px; border: 1px solid var(--glm-gris-medio); border-radius: 3px; background: #fff; color: var(--glm-gris-oscuro); font: inherit; font-size: 0.8rem; } +.reassign-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; } +.reassign-actions .btn-primary, .reassign-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; } +.reassign-hint { grid-column: 1 / -1; margin: 0; color: var(--glm-acero); font-size: 0.74rem; } +.reassign-form .reassign-actions, .reassign-form .error-card { grid-column: 1 / -1; } + +.daily-routes-card { padding: 12px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: var(--glm-surface); } +.daily-routes-card h3 { margin: 0 0 8px; color: var(--glm-azul); font-size: 0.82rem; text-transform: uppercase; } +.daily-route { display: grid; gap: 3px; padding: 8px 0; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); font-size: 0.78rem; } +.daily-route:last-child { border-bottom: 0; } +.daily-route span, .daily-route small { color: var(--glm-acero); } +.daily-route.over-capacity small { color: #c9252d; font-weight: 700; } +.routes-list li:focus-visible, .btn-icon:focus-visible, button:focus-visible, select:focus-visible { outline: 3px solid rgba(108, 194, 74, 0.45); outline-offset: 2px; } + +.btn-icon { + background: transparent; + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; + padding: 4px 6px; + cursor: pointer; + color: var(--glm-azul); +} + +.btn-icon:hover { + background: var(--glm-verde-bg); + border-color: var(--glm-verde); +} + +.info-list { + display: grid; + gap: 8px; + margin: 0; +} + +.info-list div { + display: grid; + grid-template-columns: auto 1fr; + gap: 10px; + padding: 5px 0; + border-bottom: 1px solid var(--glm-gris-claro); +} + +.info-list div:last-child { + border-bottom: none; +} + +.info-list dt { + font-weight: 600; + color: var(--glm-azul); + font-size: 0.78rem; + text-transform: uppercase; +} + +.info-list dd { + margin: 0; + font-size: 0.85rem; + color: var(--glm-gris-oscuro); + word-break: break-word; +} + +.overview-loading { + position: fixed; + top: 16px; + right: 16px; + z-index: 999; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.95); + border: 1px solid var(--glm-verde-claro); + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15); + color: var(--glm-acero); + font-size: 0.85rem; +} + +.spinner { + width: 18px; + height: 18px; + border: 2px solid var(--glm-gris-medio); + border-top-color: var(--glm-verde); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.btn-secondary { + background: var(--glm-surface); + color: var(--glm-azul); + border: 1px solid var(--glm-gris-medio); + border-radius: 4px; + padding: 8px 14px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; +} + +.btn-secondary:hover { + background: var(--glm-verde-bg); + border-color: var(--glm-verde); +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.layer-toggles { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--glm-gris-medio); +} + +.toggle { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + color: var(--glm-gris-oscuro); +} + +.heat-opts { + display: flex; + gap: 12px; + padding-left: 22px; + font-size: 0.78rem; + color: var(--glm-acero); +} + +.cluster-glm { + background: none !important; + border: none !important; +} + +.cluster-inner { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--glm-verde); + border: 3px solid #fff; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4); + color: #fff; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + font-family: Arial, sans-serif; + font-size: 12px; +} + +.custom-marker { + background: none !important; + border: none !important; +} + +.marker-inner { + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 12px; + color: #fff; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4); + font-family: Arial, sans-serif; +} diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html new file mode 100644 index 0000000..d08bc9f --- /dev/null +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html @@ -0,0 +1,177 @@ +
+ +
+
+

Cargando rutas ORS... {{ orsProgress }}/{{ orsTotal }}

+
+ +
+ + + + +
+ + + + + + +
+ {{ filteredRoutes.length }} PDVs + {{ totalDistancia }} km + {{ totalDuracionMin }} min +
+ +
+ +
+ + +
+
+ +
+ Sin rutas para este filtro. +
+
+ +
+ +

No hay rutas cargadas.

+ +
+ +
+ + {{ error }} +
+ +
+
+

PDVs sin ruta ({{ unassignedRoutes.length }})

+
+
+ +
+
+ +
+
+

Gestionar PDV sin ruta

+ +
+ +
+ + + + + +
+
+
+ +
+
PDV
{{ selectedUnassigned.pdv_nombre }}
+
Motivo
{{ selectedUnassigned.motivo }}
+
Coordenadas
{{ selectedUnassigned.latitud }}, {{ selectedUnassigned.longitud }}
+
+
+ + +
+
+
+
{{ unassignedActionError }}
+
+ +
+
+

Detalle de la ruta

+ +
+
+

Usuario

+
+
Nombre
{{ selectedRouteDetail.json.nombre_usuario }}
+
ID
{{ selectedRouteDetail.json.usuario_id }}
+
Origen
{{ selectedRouteDetail.json.latitud_usuario }}, {{ selectedRouteDetail.json.longitud_usuario }}
+
Horas disponibles
{{ selectedRouteDetail.json.hora_laboral_semanal_usuario }} h/semana
+
+
+
+

PDV

+
+
Nombre
{{ selectedRouteDetail.json.nombre_pdv }}
+
ID
{{ selectedRouteDetail.json.pdv_id }}
+
Día
{{ selectedRouteDetail.json.dia }}
+
Orden de visita
{{ selectedVisitOrder }}
+
Origen del tramo
{{ selectedSegmentOrigin }}
+
Destino
{{ selectedRouteDetail.json.latitud_pdv }}, {{ selectedRouteDetail.json.longitud_pdv }}
+
Horas trabajo
{{ selectedRouteDetail.json.horas_trabajo }}
+
Distancia tramo
{{ selectedSegmentDistance | number:'1.2-2' }} km
+
Duración tramo
{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min
+
+
+ + + + +
+
+ + +

Se recalcularán la distancia y duración con ruta vial ORS.

+
+
{{ reassignError }}
+
+
+
+ +
+

Rutas diarias

+
+ {{ day.userName }} · {{ day.day }} + {{ day.routes.length }} paradas · {{ day.distanceKm | number:'1.1-1' }} km · {{ (day.travelHours * 60) | number:'1.0-0' }} min + {{ day.totalHours | number:'1.1-1' }} / {{ day.capacityHours | number:'1.1-1' }} h {{ day.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }} +
+
+ +
+

{{ selectedUsuarioId === 'Todos' ? 'PDVs de todos los usuarios' : 'PDVs del usuario' }} ({{ filteredRoutes.length }})

+
    +
  • + {{ routeVisitOrder(r) }} +
    + {{ r.json.nombre_pdv }} + {{ selectedUsuarioId === 'Todos' ? r.json.nombre_usuario + ' • ' : '' }}{{ r.json.dia }} • {{ routeSegmentDistance(r) | number:'1.2-2' }} km • {{ routeSegmentDurationMinutes(r) | number:'1.0-0' }} min +
    + +
  • +
+
+
diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts new file mode 100644 index 0000000..c93e3f0 --- /dev/null +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts @@ -0,0 +1,835 @@ +import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router, ActivatedRoute } from '@angular/router'; +import { Subscription, Subject } from 'rxjs'; +import * as L from 'leaflet'; +import 'leaflet.heat'; +import { RouteState, UnassignedAssignment } from '../../../core/route-state/route'; +import { OrsService } from '../../../core/services/ors.service'; +import { RouteAssignedResponse, RouteNoAssignedResponse } from '../../../core/models/route.model'; +import { colorForUsuario } from '../../../shared/utils/palette.utils'; +import { DailyRouteSummary, haversineKm, sequenceRoutes, summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils'; + +interface MapRoute { + data: RouteAssignedResponse; + color: string; + origin: [number, number]; + destination: [number, number]; + visitOrder: number; + totalStops: number; + previousPdvName: string | null; + requestId: number; + segmentDistanceKm?: number; + segmentDurationHours?: number; + fallbackLine?: L.Polyline; + orsLine?: L.Polyline; + orsGlow?: L.Polyline; + marker?: L.Marker; + startMarker?: L.Marker; +} + +@Component({ + standalone: true, + selector: 'app-route-overview-map', + imports: [CommonModule, FormsModule], + templateUrl: './route-overview-map.component.html', + styleUrls: ['./route-overview-map.component.css'] +}) +export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewInit { + private map?: L.Map; + private abort$ = new Subject(); + private subs: Subscription[] = []; + private mapRoutes: MapRoute[] = []; + private startMarkers: L.Marker[] = []; + private selectedMapRoute: MapRoute | null = null; + private heatLayer?: any; + showHeat = true; + heatWeightMode: 'uniform' | 'horas_trabajo' = 'horas_trabajo'; + private heatPointsCache: [number, number, number][] = []; + private unassignedMarkers = new Map(); + + usuarios: { id: string; nombre: string }[] = []; + dias: string[] = []; + selectedUsuarioId: string | null = null; + selectedDia: string = 'Todos'; + selectedRouteDetail: RouteAssignedResponse | null = null; + + filteredRoutes: RouteAssignedResponse[] = []; + allRoutes: RouteAssignedResponse[] = []; + + isLoading = false; + orsProgress = 0; + orsTotal = 0; + error: string | null = null; + unassignedRoutes: RouteNoAssignedResponse[] = []; + selectedUnassigned: RouteNoAssignedResponse | null = null; + editingUnassigned: RouteNoAssignedResponse | null = null; + editUnassignedName = ''; + editUnassignedHours = 0; + editUnassignedLat = 0; + editUnassignedLng = 0; + editUnassignedReason = ''; + assignmentUser: Record = {}; + assignmentDays: Record = {}; + assigningUnassignedId: number | null = null; + unassignedActionError: string | null = null; + reassigningRoute = false; + showReassignForm = false; + reassignUserId = ''; + reassignDay = ''; + reassignError: string | null = null; + + // Summary stats + get totalDistancia(): string { + const sum = this.mapRoutes.length + ? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDistanceKm ?? haversineKm(route.origin, route.destination)), 0) + : this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.distancia), 0); + return sum.toFixed(2); + } + get totalDuracionMin(): string { + const sum = this.mapRoutes.length + ? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDurationHours ?? Number(route.data.json.horas_desplazamiento)) * 60, 0) + : this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.horas_desplazamiento) * 60, 0); + return sum.toFixed(0); + } + + get dailySummaries(): DailyRouteSummary[] { + return summarizeDailyRoutes(this.filteredRoutes); + } + + constructor( + private routeState: RouteState, + private orsService: OrsService, + private router: Router, + private activatedRoute: ActivatedRoute, + private cdr: ChangeDetectorRef + ) {} + + ngOnInit() { + this.subs.push( + this.routeState.assignedRoutes$.subscribe(routes => { + this.allRoutes = [...routes]; + this.usuarios = this.routeState.getUsuariosUnicos(); + this.dias = this.routeState.getDiasUnicos(); + // Init from queryParams default (Todos) + const qp = this.activatedRoute.snapshot.queryParams; + if (qp['usuario_id']) this.selectedUsuarioId = String(qp['usuario_id']); + else if (!this.selectedUsuarioId && this.usuarios.length) this.selectedUsuarioId = 'Todos'; + if (qp['dia']) this.selectedDia = String(qp['dia']); + // If focus pdv provided, auto-select detail after map loads + this.applyFilter(); + this.cdr.detectChanges(); + if (this.map) this.renderFiltered(); + }) + ); + + this.subs.push( + this.routeState.noAssignedRoutes$.subscribe(routes => { + this.unassignedRoutes = [...routes]; + if (this.map) this.renderFiltered(); + this.cdr.detectChanges(); + }) + ); + + // React to queryParams changes + this.subs.push( + this.activatedRoute.queryParams.subscribe(qp => { + let changed = false; + if (qp['usuario_id'] && String(qp['usuario_id']) !== this.selectedUsuarioId) { + this.selectedUsuarioId = String(qp['usuario_id']); + changed = true; + } + if (qp['dia'] && qp['dia'] !== this.selectedDia) { + this.selectedDia = String(qp['dia']); + changed = true; + } + if (changed) { + this.applyFilter(); + if (this.map) this.renderFiltered(); + this.cdr.detectChanges(); + } + // Focus a specific route if pdv_id passed + if (qp['pdv_id'] && this.filteredRoutes.length) { + const found = this.filteredRoutes.find(r => String(r.json.pdv_id) === String(qp['pdv_id'])); + if (found) { + this.selectedRouteDetail = found; + this.highlightRoute(found); + } + } + }) + ); + } + + ngAfterViewInit() { + this.initMap(); + this.applyFilter(); + // Defer render to ensure container has size (fixes white tiles) + setTimeout(() => { + this.map?.invalidateSize(); + this.renderFiltered(); + }, 100); + } + + ngOnDestroy() { + this.abort$.next(); + this.abort$.complete(); + this.subs.forEach(s => s.unsubscribe()); + const onResize = (this as any)._onResize; + if (onResize) window.removeEventListener('resize', onResize); + this.clearMap(); + if (this.map) this.map.remove(); + } + + onUsuarioChange() { + this.updateQueryParams(); + this.applyFilter(); + this.renderFiltered(); + } + + onDiaChange() { + this.updateQueryParams(); + this.applyFilter(); + this.renderFiltered(); + } + + private updateQueryParams() { + this.router.navigate([], { + relativeTo: this.activatedRoute, + queryParams: { usuario_id: this.selectedUsuarioId, dia: this.selectedDia }, + queryParamsHandling: 'merge', + replaceUrl: true + }); + } + + private applyFilter() { + let list = this.selectedUsuarioId === 'Todos' + ? [...this.allRoutes] + : this.allRoutes.filter(r => String(r.json.usuario_id) === String(this.selectedUsuarioId)); + if (this.selectedDia !== 'Todos') { + list = list.filter(r => r.json.dia === this.selectedDia); + } + this.filteredRoutes = list; + if (this.filteredRoutes.length === 0) { + this.error = this.allRoutes.length === 0 ? 'No hay rutas cargadas. Sube archivos en Home.' : 'Sin rutas para este usuario/día.'; + } else { + this.error = null; + } + // Clear selected detail if not in filtered + if (this.selectedRouteDetail && !this.filteredRoutes.some(route => this.sameRoute(route, this.selectedRouteDetail!))) { + this.selectedRouteDetail = null; + this.selectedMapRoute = null; + } + } + + private initMap() { + this.map = L.map('map-overview').setView([18.4861, -69.9312], 10); + const pdvPane = this.map.createPane('pdvPane'); + pdvPane.style.zIndex = '650'; + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(this.map); + // Fix tiles after sidebar overlay + setTimeout(() => this.map?.invalidateSize(), 200); + // Resize handler + const onResize = () => this.map?.invalidateSize(); + window.addEventListener('resize', onResize); + // store for cleanup + (this as any)._onResize = onResize; + } + + private clearMap() { + for (const mr of this.mapRoutes) { + if (mr.fallbackLine) this.map?.removeLayer(mr.fallbackLine); + if (mr.orsLine) this.map?.removeLayer(mr.orsLine); + if (mr.orsGlow) this.map?.removeLayer(mr.orsGlow); + if (mr.marker) this.map?.removeLayer(mr.marker); + } + this.unassignedMarkers.forEach(marker => this.map?.removeLayer(marker)); + this.unassignedMarkers.clear(); + if (this.heatLayer) { + this.map?.removeLayer(this.heatLayer); + this.heatLayer = undefined; + } + for (const m of this.startMarkers) this.map?.removeLayer(m); + this.heatPointsCache = []; + this.mapRoutes = []; + this.startMarkers = []; + this.selectedMapRoute = null; + } + + private async renderFiltered() { + if (!this.map) return; + this.abort$.next(); + this.clearMap(); + this.isLoading = false; + this.orsProgress = 0; + this.orsTotal = 0; + if (!this.filteredRoutes.length) { + this.cdr.detectChanges(); + return; + } + + // Add one colored start marker per user in the global view. + const starts = new Map(); + this.filteredRoutes.forEach(route => starts.set(String(route.json.usuario_id), route.json)); + starts.forEach((start, userId) => { + const color = colorForUsuario(userId).color; + const count = this.filteredRoutes.filter(route => String(route.json.usuario_id) === userId).length; + const startIcon = L.divIcon({ + className: 'custom-marker marker-start', + html: `
A
`, + iconSize: [34, 34], + iconAnchor: [17, 17], + popupAnchor: [0, -18] + }); + this.startMarkers.push( + L.marker([Number(start.latitud_usuario), Number(start.longitud_usuario)], { icon: startIcon, zIndexOffset: 100 }) + .addTo(this.map!) + .bindPopup(`Usuario: ${start.nombre_usuario}
PDVs: ${count}`) + ); + }); + + // Build one daily chain per user: home -> nearest PDV -> next nearest PDV. + const bounds: L.LatLngExpression[] = []; + const sequencedRoutes = sequenceRoutes(this.filteredRoutes); + this.filteredRoutes = sequencedRoutes.map(segment => segment.route); + sequencedRoutes.forEach((segment, idx) => { + const route = segment.route; + const j = route.json; + const color = colorForUsuario(j.usuario_id).color; + const origin = segment.origin; + const dest = segment.destination; + bounds.push([origin[1], origin[0]]); + bounds.push([Number(j.latitud_pdv), Number(j.longitud_pdv)]); + + // Fallback dashed line (visible immediately) + const fallbackLine = L.polyline( + [[origin[1], origin[0]], [dest[1], dest[0]]], + { color, weight: 4, dashArray: '10, 10', opacity: 0.6 } + ).addTo(this.map!); + + // Marker for PDV + const pdvIcon = L.divIcon({ + className: 'custom-marker marker-end', + html: `
${segment.order}
`, + iconSize: [30, 38], + iconAnchor: [15, 38], + popupAnchor: [0, -18] + }); + const marker = L.marker([Number(j.latitud_pdv), Number(j.longitud_pdv)], { icon: pdvIcon, pane: 'pdvPane', zIndexOffset: 1000 }) + .addTo(this.map!) + .bindPopup(`${j.nombre_pdv}
Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`); + // Heat point + const w = this.computeWeight(j); + this.heatPointsCache.push([Number(j.latitud_pdv), Number(j.longitud_pdv), w]); + + const mr: MapRoute = { + data: route, + color, + origin, + destination: dest, + visitOrder: segment.order, + totalStops: segment.totalStops, + previousPdvName: segment.previousPdvName, + requestId: idx + 1, + fallbackLine, + marker + }; + this.mapRoutes.push(mr); + + // Click handlers + const onClick = () => this.selectRoute(mr); + fallbackLine.on('click', onClick); + marker.on('click', onClick); + }); + + this.updateHeatLayer(); + this.toggleLayerVisibility(); + + // Fit bounds + try { + this.map!.fitBounds(L.latLngBounds(bounds as L.LatLngTuple[]).pad(0.15)); + } catch { /* ignore */ } + + // Progressive ORS hydration — always in background (heat only hides visually) + this.hydrateWithOrs(); + this.renderUnassignedMarkers(); + this.cdr.detectChanges(); + } + + private renderUnassignedMarkers(): void { + if (!this.map || this.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return; + for (const route of this.unassignedRoutes) { + const icon = L.divIcon({ + className: 'custom-marker unassigned-marker', + html: '
!
', + iconSize: [30, 38], + iconAnchor: [15, 38] + }); + const marker = L.marker([route.latitud, route.longitud], { icon, pane: 'pdvPane', zIndexOffset: 1000 }) + .addTo(this.map) + .bindPopup(`PDV sin ruta
${route.pdv_nombre}
Motivo: ${route.motivo}`); + marker.on('click', () => this.selectUnassigned(route)); + this.unassignedMarkers.set(route.pdv_id, marker); + } + } + + private async hydrateWithOrs() { + if (!this.filteredRoutes.length) return; + this.isLoading = true; + this.orsTotal = this.filteredRoutes.length; + this.orsProgress = 0; + this.cdr.detectChanges(); + + const pares = this.mapRoutes.map(route => ({ + pdv_id: route.requestId, + origin: route.origin, + destination: route.destination + })); + + const { promise } = this.orsService.processPairsWithProgress(pares, 2, true, this.abort$); + try { + const results = await promise; + if (!results.length) { + this.isLoading = false; + this.cdr.detectChanges(); + return; + } + const geoByRequest = new Map(); + for (const item of results) { + const g = item as { pdv_id?: number; geojson?: unknown; error?: string }; + if (g && !g.error && g.geojson) { + geoByRequest.set(g.pdv_id!, g.geojson); + } else if (g && g.error) { + // error entry, skip but count progress + } else { + // fallback: if item is raw geojson without wrapper (old cache), treat as geojson + const maybeGeo = item as unknown; + if (maybeGeo && (maybeGeo as any).type) { + // cannot map, will fallback to index correlation below + } + } + } + const useMap = geoByRequest.size > 0; + for (let i = 0; i < this.mapRoutes.length; i++) { + const mr = this.mapRoutes[i]; + let geo: unknown = null; + if (useMap) { + geo = geoByRequest.get(mr.requestId); + } else { + const res = results[i] as { error?: string; geojson?: unknown; type?: string; features?: unknown[] }; + if ((res as any)?.error) { + this.orsProgress = i + 1; + this.cdr.detectChanges(); + continue; + } + geo = (res as any)?.geojson ?? res; + } + if (!geo) { + this.orsProgress = i + 1; + this.cdr.detectChanges(); + continue; + } + const coords = this.extractCoords(geo); + if (!coords.length) { + this.orsProgress = i + 1; + this.cdr.detectChanges(); + continue; + } + if (mr.fallbackLine) { + this.map?.removeLayer(mr.fallbackLine); + mr.fallbackLine = undefined; + } + // Heatmap and route layers are independent; both remain visible together. + const glow = L.polyline(coords, { color: mr.color, weight: 12, opacity: 0.25, lineCap: 'round', lineJoin: 'round' }).addTo(this.map!); + const line = L.polyline(coords, { color: mr.color, weight: 5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }).addTo(this.map!); + const summary = this.extractSummary(geo); + if (summary) { + mr.segmentDistanceKm = summary.distance / 1000; + mr.segmentDurationHours = summary.duration / 3600; + mr.marker?.setPopupContent( + `${mr.data.json.nombre_pdv}
Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}
${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min` + ); + } + line.on('click', () => this.selectRoute(mr)); + mr.orsGlow = glow; + mr.orsLine = line; + if (this.selectedRouteDetail && this.sameRoute(this.selectedRouteDetail, mr.data)) { + this.fitSelectedRoute(mr); + } + this.orsProgress = i + 1; + this.cdr.detectChanges(); + } + this.toggleLayerVisibility(); + } catch { + // Ignore abort / network errors, fallback lines remain + } finally { + this.isLoading = false; + this.cdr.detectChanges(); + } + } + + private extractCoords(geojson: unknown): L.LatLngExpression[] { + const g = geojson as { type: string; features?: { geometry: { coordinates: number[][] } }[]; coordinates?: number[][] }; + if (g?.type === 'FeatureCollection' && g.features?.length) { + return g.features[0].geometry.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression); + } + if (g?.type === 'LineString' && g.coordinates) { + return g.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression); + } + const maybeFeature = (geojson as { features?: { geometry: { coordinates: number[][] } }[] })?.features; + if (maybeFeature?.length) { + return maybeFeature[0].geometry.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression); + } + return []; + } + + private extractSummary(geojson: unknown): { distance: number; duration: number } | null { + const summary = (geojson as { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }) + ?.features?.[0]?.properties?.summary; + return summary ?? null; + } + + private selectRoute(mr: MapRoute) { + this.selectedRouteDetail = mr.data; + this.highlightRoute(mr.data); + this.fitSelectedRoute(mr); + this.cdr.detectChanges(); + } + + selectRouteFromList(route: RouteAssignedResponse): void { + const mapRoute = this.mapRoutes.find(mr => this.sameRoute(mr.data, route)); + if (mapRoute) { + this.selectRoute(mapRoute); + return; + } + this.selectedRouteDetail = route; + this.cdr.detectChanges(); + } + + startReassignRoute(): void { + if (!this.selectedRouteDetail) return; + this.showReassignForm = true; + this.reassignUserId = String(this.selectedRouteDetail.json.usuario_id); + this.reassignDay = this.selectedRouteDetail.json.dia; + this.reassignError = null; + } + + cancelReassignRoute(): void { + this.showReassignForm = false; + this.reassignError = null; + } + + moveSelectedVisit(direction: -1 | 1): void { + if (!this.selectedRouteDetail) return; + this.routeState.moveRouteVisit(this.selectedRouteDetail, direction); + } + + resetSelectedDayOrder(): void { + if (!this.selectedRouteDetail) return; + this.routeState.resetRouteVisitOrder(this.selectedRouteDetail.json.usuario_id, this.selectedRouteDetail.json.dia); + } + + async reassignRoute(): Promise { + const route = this.selectedRouteDetail; + if (!route || !this.reassignUserId || !this.reassignDay || this.reassigningRoute) return; + const sameUser = String(route.json.usuario_id) === this.reassignUserId; + const sameDay = route.json.dia === this.reassignDay; + if (sameUser && sameDay) { + this.cancelReassignRoute(); + return; + } + const target = this.routeState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === this.reassignUserId); + if (!target) { + this.reassignError = 'No se encontró el usuario seleccionado.'; + return; + } + this.reassigningRoute = true; + this.reassignError = null; + try { + const origin = [Number(target.json.longitud_usuario), Number(target.json.latitud_usuario)]; + const destination = [Number(route.json.longitud_pdv), Number(route.json.latitud_pdv)]; + const results = await this.orsService.processPairs([{ pdv_id: route.json.pdv_id, origin, destination }], 1, true); + const response = results[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; error?: string } | undefined; + const summary = response?.geojson?.features?.[0]?.properties?.summary; + if (response?.error || !summary) throw new Error('ORS no devolvió una ruta vial'); + const capacity = this.routeState.canAssignToDay( + this.reassignUserId, + this.reassignDay, + Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0), + route, + summary.duration / 3600 + ); + if (!capacity.allowed) { + this.reassignError = `La reasignación excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`; + return; + } + this.routeState.updateAssignedRoute(route, this.reassignUserId, this.reassignDay, { + distance: summary.distance / 1000, + duration: summary.duration / 3600 + }); + this.selectedRouteDetail = this.routeState.findRoute(route.json.pdv_id, this.reassignUserId); + this.showReassignForm = false; + } catch { + this.reassignError = 'No se pudo recalcular la ruta vial. La asignación anterior se mantuvo.'; + } finally { + this.reassigningRoute = false; + this.cdr.detectChanges(); + } + } + + private fitSelectedRoute(mr: MapRoute): void { + if (!this.map) return; + const line = mr.orsLine || mr.fallbackLine; + if (!line) return; + const bounds = line.getBounds(); + const json = mr.data.json; + bounds.extend([Number(json.latitud_usuario), Number(json.longitud_usuario)]); + bounds.extend([Number(json.latitud_pdv), Number(json.longitud_pdv)]); + if (bounds.isValid()) { + this.map.fitBounds(bounds, { padding: [36, 36], maxZoom: 16, animate: true, duration: 0.5 }); + } + } + + routeVisitOrder(route: RouteAssignedResponse): number { + return this.mapRoutes.find(item => this.sameRoute(item.data, route))?.visitOrder ?? 0; + } + + routeSegmentDistance(route: RouteAssignedResponse): number { + const segment = this.mapRoutes.find(item => this.sameRoute(item.data, route)); + return segment?.segmentDistanceKm ?? Number(route.json.distancia); + } + + routeSegmentDurationMinutes(route: RouteAssignedResponse): number { + const segment = this.mapRoutes.find(item => this.sameRoute(item.data, route)); + return (segment?.segmentDurationHours ?? Number(route.json.horas_desplazamiento)) * 60; + } + + get selectedVisitOrder(): string { + const segment = this.selectedMapSegment; + return segment ? `${segment.visitOrder} de ${segment.totalStops}` : '-'; + } + + get canMoveSelectedVisitUp(): boolean { + return (this.selectedMapSegment?.visitOrder ?? 0) > 1; + } + + get canMoveSelectedVisitDown(): boolean { + const segment = this.selectedMapSegment; + return !!segment && segment.visitOrder < segment.totalStops; + } + + get selectedSegmentOrigin(): string { + const segment = this.selectedMapSegment; + if (!segment) return '-'; + return segment.previousPdvName ?? `Casa de ${segment.data.json.nombre_usuario}`; + } + + get selectedSegmentDistance(): number { + const segment = this.selectedMapSegment; + return segment?.segmentDistanceKm ?? Number(this.selectedRouteDetail?.json.distancia ?? 0); + } + + get selectedSegmentDurationMinutes(): number { + const segment = this.selectedMapSegment; + return (segment?.segmentDurationHours ?? Number(this.selectedRouteDetail?.json.horas_desplazamiento ?? 0)) * 60; + } + + private get selectedMapSegment(): MapRoute | null { + if (!this.selectedRouteDetail) return null; + return this.mapRoutes.find(item => this.sameRoute(item.data, this.selectedRouteDetail!)) ?? null; + } + + private sameRoute(left: RouteAssignedResponse, right: RouteAssignedResponse): boolean { + return left.json.pdv_id === right.json.pdv_id && + String(left.json.usuario_id) === String(right.json.usuario_id) && + left.json.dia === right.json.dia; + } + + get unassignedUserOptions(): { id: string; name: string }[] { + return this.routeState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre })); + } + + get unassignedDayOptions(): string[] { + return this.routeState.getDiasUnicos(); + } + + toggleUnassignedDay(pdvId: number, day: string, checked: boolean): void { + const key = String(pdvId); + const days = new Set(this.assignmentDays[key] ?? []); + checked ? days.add(day) : days.delete(day); + this.assignmentDays[key] = Array.from(days); + } + + selectUnassigned(route: RouteNoAssignedResponse): void { + this.selectedUnassigned = route; + this.selectedRouteDetail = null; + this.map?.setView([route.latitud, route.longitud], Math.max(this.map.getZoom(), 14)); + this.unassignedMarkers.get(route.pdv_id)?.openPopup(); + this.cdr.detectChanges(); + } + + startUnassignedEdit(route: RouteNoAssignedResponse): void { + this.editingUnassigned = route; + this.editUnassignedName = route.pdv_nombre; + this.editUnassignedHours = route.minimo_horas_semana; + this.editUnassignedLat = route.latitud; + this.editUnassignedLng = route.longitud; + this.editUnassignedReason = route.motivo; + this.unassignedActionError = null; + } + + cancelUnassignedEdit(): void { + this.editingUnassigned = null; + } + + saveUnassignedEdit(): void { + if (!this.editingUnassigned || !this.editUnassignedName.trim()) { + this.unassignedActionError = 'El nombre del PDV es obligatorio.'; + return; + } + if (!Number.isFinite(this.editUnassignedLat) || this.editUnassignedLat < -90 || this.editUnassignedLat > 90 || + !Number.isFinite(this.editUnassignedLng) || this.editUnassignedLng < -180 || this.editUnassignedLng > 180) { + this.unassignedActionError = 'Latitud o longitud inválida.'; + return; + } + if (!Number.isFinite(this.editUnassignedHours) || this.editUnassignedHours <= 0) { + this.unassignedActionError = 'Las horas mínimas deben ser mayores que cero.'; + return; + } + this.routeState.updateNoAssignedRoute(this.editingUnassigned, { + pdv_nombre: this.editUnassignedName.trim(), + minimo_horas_semana: this.editUnassignedHours, + latitud: this.editUnassignedLat, + longitud: this.editUnassignedLng, + motivo: this.editUnassignedReason.trim() + }); + this.selectedUnassigned = { ...this.editingUnassigned, pdv_nombre: this.editUnassignedName.trim(), latitud: this.editUnassignedLat, longitud: this.editUnassignedLng }; + this.editingUnassigned = null; + } + + async assignUnassigned(route: RouteNoAssignedResponse): Promise { + const userId = this.assignmentUser[String(route.pdv_id)]; + const days = this.assignmentDays[String(route.pdv_id)] ?? []; + if (!userId || !days.length) { + this.unassignedActionError = 'Selecciona usuario y al menos un día antes de asignar.'; + return; + } + const user = this.routeState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === userId); + if (!user) return; + this.assigningUnassignedId = route.pdv_id; + this.unassignedActionError = null; + try { + const origin = [Number(user.json.longitud_usuario), Number(user.json.latitud_usuario)]; + const destination = [Number(route.longitud), Number(route.latitud)]; + const assignments: UnassignedAssignment[] = []; + for (const day of days) { + const results = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true); + const geo = (results[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; error?: string }); + const summary = geo?.geojson?.features?.[0]?.properties?.summary; + if (geo?.error || !summary) throw new Error('No se obtuvo una ruta vial'); + const capacity = this.routeState.canAssignToDay(userId, day, route.minimo_horas_semana, undefined, summary.duration / 3600); + if (!capacity.allowed) { + this.unassignedActionError = `${day}: excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`; + return; + } + assignments.push({ userId, day, distance: summary.distance / 1000, duration: summary.duration / 3600 }); + } + this.routeState.addAssignedFromUnassignedMany(route, assignments); + this.selectedUnassigned = null; + delete this.assignmentUser[String(route.pdv_id)]; + delete this.assignmentDays[String(route.pdv_id)]; + } catch { + this.unassignedActionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`; + } finally { + this.assigningUnassignedId = null; + this.cdr.detectChanges(); + } + } + + highlightRoute(route: RouteAssignedResponse) { + // Reset previous selection style + if (this.selectedMapRoute) { + const prev = this.selectedMapRoute.orsLine || this.selectedMapRoute.fallbackLine; + if (prev) (prev as L.Polyline).setStyle?.({ weight: this.selectedMapRoute.orsLine ? 5 : 4, opacity: this.selectedMapRoute.orsLine ? 0.95 : 0.6 }); + } + const mr = this.mapRoutes.find(item => this.sameRoute(item.data, route)); + if (mr) { + this.selectedMapRoute = mr; + const line = mr.orsLine || mr.fallbackLine; + if (line) (line as L.Polyline).setStyle({ weight: 8, opacity: 1 }); + // Bring to front + if (mr.orsLine) mr.orsLine.bringToFront?.(); + else if (mr.fallbackLine) mr.fallbackLine.bringToFront?.(); + try { + mr.marker?.openPopup(); + } catch {} + } + } + + private computeWeight(j: RouteAssignedResponse['json']): number { + if (this.heatWeightMode === 'uniform') return 0.8; + const v = parseFloat(String(j.horas_trabajo)); + if (isNaN(v)) return 0.8; + const vals = this.filteredRoutes.map(r => parseFloat(String(r.json.horas_trabajo))).filter(n => !isNaN(n)); + if (!vals.length) return 0.8; + const min = Math.min(...vals), max = Math.max(...vals); + if (max === min) return 0.8; + return 0.3 + 0.7 * (v - min) / (max - min); + } + + private updateHeatLayer() { + if (this.heatLayer) { + this.map?.removeLayer(this.heatLayer); + this.heatLayer = undefined; + } + if (!this.showHeat || !this.heatPointsCache.length || !this.map) return; + this.heatLayer = (L as any).heatLayer(this.heatPointsCache, { + radius: 25, blur: 18, maxZoom: 17, minOpacity: 0.4, + gradient: { 0.4: '#6CC24A', 0.65: '#FF6A13', 1.0: '#D32F2F' } + }).addTo(this.map); + } + + toggleHeat() { + this.showHeat = !this.showHeat; + this.updateHeatLayer(); + this.toggleLayerVisibility(); + const needsHydrate = !this.mapRoutes.some(m => m.orsLine) || this.orsTotal === 0 || this.orsProgress < this.orsTotal; + if (!this.showHeat && needsHydrate) { + this.hydrateWithOrs(); + } + this.cdr.detectChanges(); + } + + setHeatWeight(mode: 'uniform' | 'horas_trabajo') { + this.heatWeightMode = mode; + this.heatPointsCache = this.filteredRoutes.map(r => { + const j = r.json; + return [Number(j.latitud_pdv), Number(j.longitud_pdv), this.computeWeight(j)] as [number, number, number]; + }); + this.updateHeatLayer(); + this.cdr.detectChanges(); + } + + private toggleLayerVisibility() { + this.mapRoutes.forEach(mr => { + if (mr.fallbackLine) (mr.fallbackLine as any).setStyle({ opacity: 0.6 }); + if (mr.orsLine) (mr.orsLine as any).setStyle({ opacity: 0.95 }); + if (mr.orsGlow) (mr.orsGlow as any).setStyle({ opacity: 0.25 }); + }); + } + + clearSelection() { + if (this.selectedMapRoute) { + const line = this.selectedMapRoute.orsLine || this.selectedMapRoute.fallbackLine; + if (line) (line as L.Polyline).setStyle({ weight: this.selectedMapRoute.orsLine ? 5 : 4, opacity: this.selectedMapRoute.orsLine ? 0.95 : 0.6 }); + } + this.selectedMapRoute = null; + this.selectedRouteDetail = null; + } + + goBack() { + this.router.navigate(['/home']); + } +} diff --git a/route-planner/src/app/shared/schemas/index.ts b/route-planner/src/app/shared/schemas/index.ts new file mode 100644 index 0000000..9696ff4 --- /dev/null +++ b/route-planner/src/app/shared/schemas/index.ts @@ -0,0 +1,2 @@ +export * from './pdv.schema'; +export * from './usuario.schema'; diff --git a/route-planner/src/app/shared/schemas/pdv.schema.ts b/route-planner/src/app/shared/schemas/pdv.schema.ts new file mode 100644 index 0000000..e579b47 --- /dev/null +++ b/route-planner/src/app/shared/schemas/pdv.schema.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; + +export const PDV_REQUIRED_HEADERS = [ + 'ID', + 'Nombre del PDV', + 'Segmentación', + 'Latitud', + 'Longitud', + 'Visitas semanales', + 'Duración visita(horas)', + 'Prioridad', +] as const; + +export const PDV_NORMALIZED_REQUIRED = [ + 'id', + 'nombre_del_pdv', + 'segmentacion', + 'latitud', + 'longitud', + 'prioridad', +] as const; + +export const SEGMENTACIONES = ['AA', 'A', 'B', 'C', 'D'] as const; + +export const pdvRowSchema = z.object({ + id: z.union([z.string(), z.number()]).refine(v => String(v).trim() !== '', { message: 'ID requerido' }), + nombre_del_pdv: z.string().min(1, 'Nombre del PDV requerido'), + segmentacion: z.string().refine(v => (SEGMENTACIONES as readonly string[]).includes(v.toUpperCase()), { + message: `Segmentación debe ser ${SEGMENTACIONES.join(', ')}`, + }), + latitud: z.number().min(-90, 'Latitud mínima -90').max(90, 'Latitud máxima 90'), + longitud: z.number().min(-180, 'Longitud mínima -180').max(180, 'Longitud máxima 180'), + visitas_semanales: z.number().int('Visitas semanales debe ser entero').min(1, 'Visitas semanales mínimo 1').max(7, 'Visitas semanales máximo 7').optional(), + duracion_visitahoras: z.number().min(0.01, 'Duración visita debe ser >0').max(168, 'Duración visita máximo 168').optional(), + minimo_horas_semana: z.number().min(0.01, 'Mínimo horas semana debe ser >0').max(168, 'Mínimo horas semana máximo 168').optional(), + prioridad: z.number().int('Prioridad debe ser entero').min(1, 'Prioridad mínimo 1').max(5, 'Prioridad máximo 5'), +}).superRefine((data, ctx) => { + const hasVisitsFormat = data.visitas_semanales !== undefined && data.duracion_visitahoras !== undefined; + if (!hasVisitsFormat && data.minimo_horas_semana === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['minimo_horas_semana'], + message: 'Incluye Mínimo horas semana o Visitas semanales + Duración visita(horas)' + }); + } +}); + +export type PdvRow = z.infer; +export const PDV_MAX_ROWS = 2000; diff --git a/route-planner/src/app/shared/schemas/usuario.schema.ts b/route-planner/src/app/shared/schemas/usuario.schema.ts new file mode 100644 index 0000000..eb1d505 --- /dev/null +++ b/route-planner/src/app/shared/schemas/usuario.schema.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +export const USUARIO_REQUIRED_HEADERS = [ + 'ID', + 'Nombre del empleado', + 'Latitud', + 'Longitud', + 'Horas por día usuario', + 'Horas por semana usuario', +] as const; + +export const USUARIO_NORMALIZED_REQUIRED = [ + 'id', + 'nombre_del_empleado', + 'latitud', + 'longitud', + 'horas_por_dia_usuario', + 'horas_por_semana_usuario', +] as const; + +export const usuarioRowSchema = z.object({ + id: z.union([z.string(), z.number()]).refine(v => String(v).trim() !== '', { message: 'ID requerido' }), + nombre_del_empleado: z.string().min(1, 'Nombre del empleado requerido'), + latitud: z.number().min(-90, 'Latitud mínima -90').max(90, 'Latitud máxima 90'), + longitud: z.number().min(-180, 'Longitud mínima -180').max(180, 'Longitud máxima 180'), + horas_por_dia_usuario: z.number().min(0.5, 'Horas por día mínimo 0.5').max(24, 'Horas por día máximo 24'), + horas_por_semana_usuario: z.number().min(1, 'Horas por semana mínimo 1').max(168, 'Horas por semana máximo 168'), +}).superRefine((data, ctx) => { + if (data.horas_por_semana_usuario > data.horas_por_dia_usuario * 7) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Horas por semana no puede exceder 7 × horas por día', + path: ['horas_por_semana_usuario'], + }); + } +}); + +export type UsuarioRow = z.infer; +export const USUARIO_MAX_ROWS = 50; diff --git a/route-planner/src/app/shared/services/excel-preview.service.ts b/route-planner/src/app/shared/services/excel-preview.service.ts new file mode 100644 index 0000000..8ed292b --- /dev/null +++ b/route-planner/src/app/shared/services/excel-preview.service.ts @@ -0,0 +1,217 @@ +import { Injectable } from '@angular/core'; +import * as XLSX from 'xlsx'; +import { normalizeRow, normalizeHeader, cleanNum } from '../utils/header-normalization.utils'; +import { pdvRowSchema, PDV_NORMALIZED_REQUIRED, PDV_MAX_ROWS, SEGMENTACIONES } from '../schemas/pdv.schema'; +import { usuarioRowSchema, USUARIO_NORMALIZED_REQUIRED, USUARIO_MAX_ROWS } from '../schemas/usuario.schema'; + +export interface FieldError { + row: number; // 1-indexed excel row (2 = first data row) + field: string; + message: string; + value?: unknown; +} + +export interface PreviewResult { + fileName: string; + type: 'PDV' | 'Usuarios'; + valid: boolean; + totalRows: number; + validRows: number; + headers: string[]; + normalizedHeaders: string[]; + missingHeaders: string[]; + errors: FieldError[]; + duplicateIds: (string | number)[]; + sampleValidRows: Record[]; +} + +@Injectable({ providedIn: 'root' }) +export class ExcelPreviewService { + + async previewFile(file: File, type: 'PDV' | 'Usuarios'): Promise { + const buffer = await file.arrayBuffer(); + const workbook = XLSX.read(buffer, { type: 'array', cellDates: true }); + const sheetName = workbook.SheetNames[0]; + const sheet = workbook.Sheets[sheetName]; + if (!sheetName || !sheet) { + throw new Error(`${type}: El archivo no contiene una hoja válida`); + } + const rawRows: Record[] = XLSX.utils.sheet_to_json(sheet, { defval: null, raw: true }); + + const headers = this.extractHeaders(sheet); + const normalizedHeaders = headers.map(h => normalizeHeader(String(h))); + + const required = type === 'PDV' ? [...PDV_NORMALIZED_REQUIRED] : [...USUARIO_NORMALIZED_REQUIRED]; + const displayRequired = type === 'PDV' + ? ['ID', 'Nombre del PDV', 'Segmentación', 'Latitud', 'Longitud', 'Visitas semanales', 'Duración visita(horas)', 'Prioridad'] + : ['ID', 'Nombre del empleado', 'Latitud', 'Longitud', 'Horas por día usuario', 'Horas por semana usuario']; + + const missingHeaders: string[] = []; + for (let i = 0; i < required.length; i++) { + if (!normalizedHeaders.includes(required[i])) { + missingHeaders.push(displayRequired[i]); + } + } + if (type === 'PDV') { + const hasVisitsFormat = normalizedHeaders.includes('visitas_semanales') && normalizedHeaders.includes('duracion_visitahoras'); + const hasWeeklyHours = normalizedHeaders.includes('minimo_horas_semana'); + if (!hasVisitsFormat && !hasWeeklyHours) { + missingHeaders.push('Mínimo horas semana o Visitas semanales + Duración visita(horas)'); + } + } + + const errors: FieldError[] = []; + const repeatedHeaders = normalizedHeaders.filter((header, index) => header && normalizedHeaders.indexOf(header) !== index); + if (repeatedHeaders.length) { + errors.push({ row: 1, field: 'headers', message: `Columnas duplicadas: ${Array.from(new Set(repeatedHeaders)).join(', ')}` }); + } + if (missingHeaders.length) { + errors.push({ row: 1, field: 'headers', message: `Columnas faltantes: ${missingHeaders.join(', ')}` }); + } + + if (rawRows.length === 0) { + errors.push({ row: 1, field: 'file', message: `${type}: Archivo vacío o sin filas de datos` }); + return this.buildResult(file.name, type, headers, normalizedHeaders, missingHeaders, rawRows, errors, []); + } + + const maxRows = type === 'PDV' ? PDV_MAX_ROWS : USUARIO_MAX_ROWS; + if (rawRows.length > maxRows) { + errors.push({ row: 1, field: 'file', message: `${type}: Máximo ${maxRows} filas permitidas (tiene ${rawRows.length})` }); + } + + const normalizedRows = rawRows.map(r => normalizeRow(r as Record)); + const seenIds = new Map(); + const rowErrors: FieldError[] = []; + + normalizedRows.forEach((row, idx) => { + const excelRow = idx + 2; + const idVal = row['id']; + if (idVal !== null && idVal !== undefined && String(idVal).trim() !== '') { + const key = String(idVal).trim(); + if (!seenIds.has(key)) seenIds.set(key, []); + seenIds.get(key)!.push(excelRow); + } + + // Coerce numeric fields with cleanNum before zod + const coerced: Record = { ...row }; + if (type === 'PDV') { + coerced['latitud'] = this.coerceNum(row['latitud']); + coerced['longitud'] = this.coerceNum(row['longitud']); + coerced['visitas_semanales'] = this.coerceNum(row['visitas_semanales']); + coerced['duracion_visitahoras'] = this.coerceNum(row['duracion_visitahoras']); + coerced['minimo_horas_semana'] = this.coerceNum(row['minimo_horas_semana']); + coerced['prioridad'] = this.coerceNum(row['prioridad']); + if (typeof coerced['segmentacion'] === 'string') { + coerced['segmentacion'] = String(coerced['segmentacion']).trim().toUpperCase(); + } + // validate with zod + const result = pdvRowSchema.safeParse(coerced); + if (!result.success) { + for (const issue of result.error.issues) { + const field = String(issue.path[0] ?? 'unknown'); + rowErrors.push({ row: excelRow, field, message: issue.message, value: (row as Record)[field] ?? coerced[field] }); + } + } + } else { + coerced['latitud'] = this.coerceNum(row['latitud']); + coerced['longitud'] = this.coerceNum(row['longitud']); + coerced['horas_por_dia_usuario'] = this.coerceNum(row['horas_por_dia_usuario']); + coerced['horas_por_semana_usuario'] = this.coerceNum(row['horas_por_semana_usuario']); + const result = usuarioRowSchema.safeParse(coerced); + if (!result.success) { + for (const issue of result.error.issues) { + const field = String(issue.path[0] ?? 'unknown'); + rowErrors.push({ row: excelRow, field, message: issue.message, value: (row as Record)[field] ?? coerced[field] }); + } + } + } + }); + + const duplicateIds: (string | number)[] = []; + for (const [id, rows] of seenIds.entries()) { + if (rows.length > 1) { + duplicateIds.push(id); + for (const r of rows) { + rowErrors.push({ row: r, field: 'id', message: `ID duplicado: ${id}`, value: id }); + } + } + } + + const allErrors = [...errors, ...rowErrors]; + return this.buildResult(file.name, type, headers, normalizedHeaders, missingHeaders, normalizedRows, allErrors, duplicateIds); + } + + private coerceNum(value: unknown): unknown { + if (value === null || value === undefined || value === '') return value; + const n = cleanNum(value); + return Number.isNaN(n) ? value : n; + } + + async preparePdvForOptimizer(file: File): Promise { + const buffer = await file.arrayBuffer(); + const workbook = XLSX.read(buffer, { type: 'array', cellDates: true }); + const sheet = workbook.Sheets[workbook.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(sheet, { defval: null, raw: true }) + .map(row => normalizeRow(row as Record)); + const normalized = rows.map(row => { + const weeklyHours = cleanNum(row['minimo_horas_semana']); + const visits = cleanNum(row['visitas_semanales']); + const duration = cleanNum(row['duracion_visitahoras']); + return { + 'ID': row['id'], + 'Nombre del PDV': row['nombre_del_pdv'], + 'Segmentación': row['segmentacion'], + 'Latitud': cleanNum(row['latitud']), + 'Longitud': cleanNum(row['longitud']), + 'Visitas semanales': Number.isFinite(visits) ? visits : 1, + 'Duración visita(horas)': Number.isFinite(duration) ? duration : weeklyHours, + 'Prioridad': cleanNum(row['prioridad']) + }; + }); + const output = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(output, XLSX.utils.json_to_sheet(normalized), 'PDV'); + const data = XLSX.write(output, { bookType: 'xlsx', type: 'array' }); + return new File([data], file.name, { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + } + + private extractHeaders(sheet: XLSX.WorkSheet): string[] { + const range = XLSX.utils.decode_range(sheet['!ref'] || 'A1'); + const headers: string[] = []; + for (let c = range.s.c; c <= range.e.c; c++) { + const addr = XLSX.utils.encode_cell({ r: range.s.r, c }); + const cell = sheet[addr]; + headers.push(cell ? String(cell.v) : ''); + } + return headers.filter(h => h !== ''); + } + + private buildResult( + fileName: string, + type: 'PDV' | 'Usuarios', + headers: string[], + normalizedHeaders: string[], + missingHeaders: string[], + rows: Record[], + errors: FieldError[], + duplicateIds: (string | number)[] + ): PreviewResult { + const validRows = Math.max(0, rows.length - new Set(errors.filter(e => e.field !== 'headers' && e.field !== 'file').map(e => e.row)).size); + const hasHeaderErrors = missingHeaders.length > 0; + const maxRows = type === 'PDV' ? PDV_MAX_ROWS : USUARIO_MAX_ROWS; + const overLimit = rows.length > maxRows; + const valid = !hasHeaderErrors && !overLimit && errors.length === 0; + return { + fileName, + type, + valid, + totalRows: rows.length, + validRows: valid ? rows.length : validRows, + headers, + normalizedHeaders, + missingHeaders, + errors, + duplicateIds, + sampleValidRows: rows.slice(0, 3), + }; + } +} diff --git a/route-planner/src/app/shared/services/planning-export.service.ts b/route-planner/src/app/shared/services/planning-export.service.ts new file mode 100644 index 0000000..758db56 --- /dev/null +++ b/route-planner/src/app/shared/services/planning-export.service.ts @@ -0,0 +1,106 @@ +import { Injectable } from '@angular/core'; +import * as XLSX from 'xlsx'; +import { saveAs } from 'file-saver'; +import { RouteState } from '../../core/route-state/route'; +import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils'; + +@Injectable({ providedIn: 'root' }) +export class PlanningExportService { + constructor(private routeState: RouteState) {} + + export(): void { + const assigned = this.routeState.getAssignedSnapshot(); + const unassigned = this.routeState.getNoAssignedSnapshot(); + const users = new Map(); + + for (const route of assigned) { + const key = String(route.json.usuario_id); + const current = users.get(key) ?? { + nombre: route.json.nombre_usuario, + rutas: 0, + distancia: 0, + trabajo: 0, + capacidad: Number(route.json.hora_laboral_semanal_usuario) || 0 + }; + current.rutas++; + current.distancia += Number(route.json.distancia) || 0; + current.trabajo += Number.parseFloat(String(route.json.horas_trabajo ?? '')) || 0; + users.set(key, current); + } + + const total = assigned.length + unassigned.length; + const dailyRoutes = summarizeDailyRoutes(assigned); + const totalDistance = dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0); + const totalTravel = dailyRoutes.reduce((sum, route) => sum + route.travelHours, 0); + const workbook = XLSX.utils.book_new(); + + const summary = [ + { Indicador: 'PDVs totales', Valor: total }, + { Indicador: 'PDVs asignados', Valor: assigned.length }, + { Indicador: 'PDVs sin asignar', Valor: unassigned.length }, + { Indicador: 'Cobertura (%)', Valor: total ? Number(((assigned.length / total) * 100).toFixed(1)) : 0 }, + { Indicador: 'Usuarios activos', Valor: users.size }, + { Indicador: 'Días planificados', Valor: new Set(assigned.map(route => route.json.dia)).size }, + { Indicador: 'Distancia total (km)', Valor: Number(totalDistance.toFixed(2)) }, + { Indicador: 'Desplazamiento total (h)', Valor: Number(totalTravel.toFixed(2)) } + ]; + + const assignedRows = sequenceRoutes(assigned).map(segment => ({ + 'Usuario ID': segment.route.json.usuario_id, + Usuario: segment.route.json.nombre_usuario, + 'PDV ID': segment.route.json.pdv_id, + PDV: segment.route.json.nombre_pdv, + Día: segment.route.json.dia, + 'Distancia (km)': segment.route.json.distancia, + 'Desplazamiento (h)': segment.route.json.horas_desplazamiento, + 'Horas trabajo': segment.route.json.horas_trabajo, + 'Horas disponibles': segment.route.json.hora_laboral_semanal_usuario, + 'Orden visita': segment.order, + 'Origen del tramo': segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}` + })); + const unassignedRows = unassigned.map(route => ({ + 'PDV ID': route.pdv_id, + PDV: route.pdv_nombre, + 'Mínimo horas/semana': route.minimo_horas_semana, + Latitud: route.latitud, + Longitud: route.longitud, + Motivo: route.motivo + })); + const userRows = Array.from(users, ([id, user]) => { + const utilization = user.capacidad ? (user.trabajo / user.capacidad) * 100 : 0; + return { + 'Usuario ID': id, + Usuario: user.nombre, + Rutas: user.rutas, + 'Distancia (km)': Number(user.distancia.toFixed(2)), + 'Horas trabajo': Number(user.trabajo.toFixed(2)), + 'Capacidad (h)': user.capacidad, + 'Utilización (%)': Number(utilization.toFixed(1)), + Estado: utilization > 100 ? 'Excede capacidad' : utilization > 90 ? 'Riesgo' : 'OK' + }; + }); + + this.appendSheet(workbook, summary, 'Resumen'); + this.appendSheet(workbook, assignedRows, 'Rutas asignadas'); + this.appendSheet(workbook, unassignedRows, 'PDVs sin asignar'); + this.appendSheet(workbook, userRows, 'Carga por usuario'); + this.appendSheet(workbook, dailyRoutes.map(route => ({ + Usuario: route.userName, + Día: route.day, + Paradas: route.routes.length, + 'Distancia secuencial (km)': Number(route.distanceKm.toFixed(2)), + 'Desplazamiento secuencial (h)': Number(route.travelHours.toFixed(2)), + 'Carga total (h)': Number(route.totalHours.toFixed(2)), + 'Capacidad diaria (h)': Number(route.capacityHours.toFixed(2)), + Estado: route.exceedsCapacity ? 'Excede capacidad' : 'OK' + })), 'Rutas diarias'); + + const output = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); + saveAs(new Blob([output], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), 'planificacion_rutas.xlsx'); + } + + private appendSheet(workbook: XLSX.WorkBook, rows: object[], name: string): void { + const sheet = XLSX.utils.json_to_sheet(rows.length ? rows : [{ Información: 'Sin registros' }]); + XLSX.utils.book_append_sheet(workbook, sheet, name); + } +} diff --git a/route-planner/src/app/shared/utils/header-normalization.utils.ts b/route-planner/src/app/shared/utils/header-normalization.utils.ts new file mode 100644 index 0000000..ac17681 --- /dev/null +++ b/route-planner/src/app/shared/utils/header-normalization.utils.ts @@ -0,0 +1,29 @@ +export function normalizeHeader(value: string): string { + return value + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9 ]/gi, '') + .trim() + .replace(/\s+/g, '_'); +} + +export function cleanNum(value: unknown): number { + if (value === null || value === undefined || value === '') return NaN; + if (typeof value === 'number') return value; + const str = String(value).trim().replace(/[^0-9.\-]/g, ''); + if (str === '' || str === '-' || str === '.') return NaN; + const num = Number(str); + return Number.isFinite(num) ? num : NaN; +} + +export function normalizeRow(row: Record): Record { + const out: Record = {}; + for (const key in row) { + const newKey = normalizeHeader(key); + let val = row[key]; + if (typeof val === 'string') val = val.trim(); + out[newKey] = val; + } + return out; +} diff --git a/route-planner/src/app/shared/utils/index.ts b/route-planner/src/app/shared/utils/index.ts index 8a726eb..08ccf84 100644 --- a/route-planner/src/app/shared/utils/index.ts +++ b/route-planner/src/app/shared/utils/index.ts @@ -1 +1,4 @@ -export * from './remove-special-character.utils'; \ No newline at end of file +export * from './remove-special-character.utils'; +export * from './header-normalization.utils'; +export * from './palette.utils'; +export * from './route-sequencing.utils'; diff --git a/route-planner/src/app/shared/utils/palette.utils.ts b/route-planner/src/app/shared/utils/palette.utils.ts new file mode 100644 index 0000000..fe27390 --- /dev/null +++ b/route-planner/src/app/shared/utils/palette.utils.ts @@ -0,0 +1,24 @@ +export function colorForUsuario(usuarioId: string | number): { color: string; glow: string } { + const str = String(usuarioId); + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0; + } + hash = Math.abs(hash); + // Use a mix of GLM palette + generated HSL to avoid collisions but keep brand feel + const glmPalette = ['#6CC24A', '#4F758B', '#FF6A13', '#C4D600', '#5B7F95', '#A4D65E', '#6B8FA3']; + const idx = hash % glmPalette.length; + // If palette length covers typical usuarios (50 max) collisions low; fallback to HSL for overflow + if (hash % 2 === 0) { + return { color: glmPalette[idx], glow: glmPalette[idx] }; + } + const h = hash % 360; + const s = 65 + (hash % 20); + const l = 48 + (hash % 10); + const hsl = `hsl(${h} ${s}% ${l}%)`; + return { color: hsl, glow: hsl }; +} + +export function markerStyleForUsuario(usuarioId: string | number): string { + return colorForUsuario(usuarioId).color; +} diff --git a/route-planner/src/app/shared/utils/route-sequencing.utils.ts b/route-planner/src/app/shared/utils/route-sequencing.utils.ts new file mode 100644 index 0000000..1465139 --- /dev/null +++ b/route-planner/src/app/shared/utils/route-sequencing.utils.ts @@ -0,0 +1,118 @@ +import { RouteAssignedResponse } from '../../core/models/route.model'; + +export interface SequencedRoute { + route: RouteAssignedResponse; + origin: [number, number]; + destination: [number, number]; + order: number; + totalStops: number; + previousPdvName: string | null; +} + +export interface DailyRouteSummary { + key: string; + userId: string; + userName: string; + day: string; + routes: SequencedRoute[]; + distanceKm: number; + travelHours: number; + workHours: number; + capacityHours: number; + totalHours: number; + exceedsCapacity: boolean; +} + +export function sequenceRoutes(routes: RouteAssignedResponse[]): SequencedRoute[] { + const groups = new Map(); + for (const route of routes) { + const key = `${route.json.usuario_id}\u0000${route.json.dia}`; + const group = groups.get(key) ?? []; + group.push(route); + groups.set(key, group); + } + + const sequenced: SequencedRoute[] = []; + for (const group of groups.values()) { + const first = group[0].json; + let current: [number, number] = [Number(first.longitud_usuario), Number(first.latitud_usuario)]; + let previousPdvName: string | null = null; + const hasManualOrder = group.every(route => Number.isFinite(route.json.visit_order)); + const remaining = hasManualOrder + ? [...group].sort((a, b) => Number(a.json.visit_order) - Number(b.json.visit_order)) + : [...group]; + + while (remaining.length) { + let nearestIndex = 0; + if (!hasManualOrder) { + let nearestDistance = Number.POSITIVE_INFINITY; + for (let index = 0; index < remaining.length; index++) { + const candidate = remaining[index].json; + const distance = haversineKm(current, [Number(candidate.longitud_pdv), Number(candidate.latitud_pdv)]); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestIndex = index; + } + } + } + + const [route] = remaining.splice(nearestIndex, 1); + const destination: [number, number] = [Number(route.json.longitud_pdv), Number(route.json.latitud_pdv)]; + sequenced.push({ + route, + origin: current, + destination, + order: group.length - remaining.length, + totalStops: group.length, + previousPdvName + }); + current = destination; + previousPdvName = route.json.nombre_pdv; + } + } + + return sequenced; +} + +export function summarizeDailyRoutes(routes: RouteAssignedResponse[]): DailyRouteSummary[] { + const sequences = sequenceRoutes(routes); + const grouped = new Map(); + for (const segment of sequences) { + const key = `${segment.route.json.usuario_id}\u0000${segment.route.json.dia}`; + const group = grouped.get(key) ?? []; + group.push(segment); + grouped.set(key, group); + } + return Array.from(grouped, ([key, segments]) => { + const first = segments[0].route.json; + const distanceKm = segments.reduce((sum, segment) => sum + haversineKm(segment.origin, segment.destination), 0); + const travelHours = segments.reduce((sum, segment) => sum + Number(segment.route.json.horas_desplazamiento || 0), 0); + const workHours = segments.reduce((sum, segment) => sum + Number(segment.route.json.horas_trabajo || segment.route.json.hora_minima_semanal_pdv || 0), 0); + const capacityHours = Number(first.hora_laboral_semanal_usuario || 0) / 5; + const totalHours = workHours + travelHours; + return { + key, + userId: String(first.usuario_id), + userName: first.nombre_usuario, + day: first.dia, + routes: segments, + distanceKm, + travelHours, + workHours, + capacityHours, + totalHours, + exceedsCapacity: totalHours > capacityHours + }; + }); +} + +export function haversineKm(origin: [number, number], destination: [number, number]): number { + const toRadians = (value: number) => value * Math.PI / 180; + const [lon1, lat1] = origin; + const [lon2, lat2] = destination; + const dLat = toRadians(lat2 - lat1); + const dLon = toRadians(lon2 - lon1); + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(dLon / 2) ** 2; + return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} diff --git a/route-planner/src/environments/environment.prod.ts b/route-planner/src/environments/environment.prod.ts new file mode 100644 index 0000000..7647f41 --- /dev/null +++ b/route-planner/src/environments/environment.prod.ts @@ -0,0 +1,6 @@ +export const environment = { + production: true, + n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino', + orsBaseUrl: 'http://localhost:18080/ors/v2', + requestTimeoutMs: 30000, +}; diff --git a/route-planner/src/environments/environment.ts b/route-planner/src/environments/environment.ts new file mode 100644 index 0000000..e99e431 --- /dev/null +++ b/route-planner/src/environments/environment.ts @@ -0,0 +1,6 @@ +export const environment = { + production: false, + n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino', + orsBaseUrl: 'http://localhost:18080/ors/v2', + requestTimeoutMs: 30000, +}; diff --git a/route-planner/src/index.html b/route-planner/src/index.html index 3f55af5..9baccbf 100644 --- a/route-planner/src/index.html +++ b/route-planner/src/index.html @@ -6,7 +6,6 @@ - diff --git a/route-planner/src/styles.css b/route-planner/src/styles.css index 0769590..2ce578c 100644 --- a/route-planner/src/styles.css +++ b/route-planner/src/styles.css @@ -1,5 +1,45 @@ @import "primeicons/primeicons.css"; +/* Leaflet creates marker HTML outside Angular's style encapsulation. */ +.location-pin { + position: relative; + width: 30px; + height: 38px; + background: #c9252d; + clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%); + filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42)); +} + +.location-pin::before { + content: ''; + position: absolute; + inset: 3px 3px 7px; + background: #ff424a; + clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%); +} + +.location-pin::after { + content: ''; + position: absolute; + top: 8px; + left: 8px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #620000; +} + +.location-pin span { + position: absolute; + z-index: 1; + top: 8px; + left: 8px; + width: 14px; + color: #fff; + font: 700 9px/14px Arial, sans-serif; + text-align: center; +} + :root { --glm-verde: #6CC24A; --glm-verde-claro: #A4D65E;