feat: implement route planning dashboard including KPIs, user workload tracking, and route assignment management features

This commit is contained in:
2026-08-29 11:55:03 -04:00
parent a0ea3fea48
commit cd5c57c5ee
41 changed files with 3811 additions and 151 deletions
@@ -0,0 +1,556 @@
: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; }
.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; }
.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;
}
.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: var(--pin-color, #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-size: 9px;
font-weight: 700;
line-height: 14px;
text-align: center;
}
.unassigned-pin { --pin-color: #ff424a; }
@@ -0,0 +1,165 @@
<div id="map-overview"></div>
<div class="overview-loading" *ngIf="isLoading">
<div class="spinner"></div>
<p>Cargando rutas ORS... {{ orsProgress }}/{{ orsTotal }}</p>
</div>
<div class="overview-sidebar">
<div class="sidebar-brand">
<img src="GLM_completo.png" alt="GomezLee Marketing" class="sidebar-logo" />
</div>
<div class="sidebar-header">
<button type="button" class="back-btn" (click)="goBack()">
<i class="pi pi-arrow-left"></i> Volver
</button>
<h2 class="sidebar-title">Mapa de rutas</h2>
</div>
<div class="filters-card" *ngIf="usuarios.length">
<label class="filter-label">Usuario</label>
<select class="filter-select" [(ngModel)]="selectedUsuarioId" (ngModelChange)="onUsuarioChange()">
<option value="Todos">Todos los usuarios</option>
<option *ngFor="let u of usuarios" [value]="u.id">{{ u.nombre }} ({{ u.id }})</option>
</select>
<label class="filter-label">Día</label>
<select class="filter-select" [(ngModel)]="selectedDia" (ngModelChange)="onDiaChange()">
<option value="Todos">Todos</option>
<option *ngFor="let d of dias" [value]="d">{{ d }}</option>
</select>
<div class="filter-stats" *ngIf="filteredRoutes.length">
<span>{{ filteredRoutes.length }} PDVs</span>
<span>{{ totalDistancia }} km</span>
<span>{{ totalDuracionMin }} min</span>
</div>
<div class="layer-toggles">
<label class="toggle">
<input type="checkbox" [checked]="showHeat" (change)="toggleHeat()" />
<i class="pi pi-fire"></i> Heatmap (carga)
</label>
<div class="heat-opts" *ngIf="showHeat">
<label><input type="radio" name="hw" value="uniform" [checked]="heatWeightMode==='uniform'" (change)="setHeatWeight('uniform')" /> Peso 1 (densidad)</label>
<label><input type="radio" name="hw" value="horas_trabajo" [checked]="heatWeightMode==='horas_trabajo'" (change)="setHeatWeight('horas_trabajo')" /> Por carga (horas)</label>
</div>
</div>
<div class="filter-empty" *ngIf="!filteredRoutes.length && !error">
Sin rutas para este filtro.
</div>
</div>
<div class="empty-state" *ngIf="!usuarios.length">
<i class="pi pi-inbox"></i>
<p>No hay rutas cargadas.</p>
<button type="button" class="btn-secondary" (click)="goBack()">Ir a Home</button>
</div>
<div class="error-card" *ngIf="error && usuarios.length">
<i class="pi pi-exclamation-triangle"></i>
{{ error }}
</div>
<section class="unassigned-map-card" *ngIf="selectedUsuarioId === 'Todos' && selectedDia === 'Todos' && unassignedRoutes.length">
<div class="info-card-header">
<h3>PDVs sin ruta ({{ unassignedRoutes.length }})</h3>
</div>
<div class="unassigned-map-list">
<button type="button" class="unassigned-map-item" *ngFor="let pending of unassignedRoutes" [class.selected-pending]="selectedUnassigned?.pdv_id === pending.pdv_id" (click)="selectUnassigned(pending)">
<span class="pending-icon">!</span>
<span class="pending-text"><strong>{{ pending.pdv_nombre }}</strong><small>{{ pending.motivo }}</small></span>
</button>
</div>
</section>
<section class="info-card unassigned-detail-card" *ngIf="selectedUnassigned">
<div class="info-card-header">
<h3>Gestionar PDV sin ruta</h3>
<button type="button" class="btn-icon" (click)="selectedUnassigned = null"><i class="pi pi-times"></i></button>
</div>
<ng-container *ngIf="editingUnassigned; else pendingSummary">
<div class="pending-edit-grid">
<label>Nombre<input [(ngModel)]="editUnassignedName" type="text"></label>
<label>Horas mínimas<input [(ngModel)]="editUnassignedHours" type="number" min="0.01" step="0.25"></label>
<label>Latitud<input [(ngModel)]="editUnassignedLat" type="number" step="any"></label>
<label>Longitud<input [(ngModel)]="editUnassignedLng" type="number" step="any"></label>
<label class="wide-field">Motivo<input [(ngModel)]="editUnassignedReason" type="text"></label>
</div>
<div class="pending-actions"><button type="button" class="btn-secondary" (click)="saveUnassignedEdit()"><i class="pi pi-check"></i> Guardar</button><button type="button" class="btn-secondary" (click)="cancelUnassignedEdit()">Cancelar</button></div>
</ng-container>
<ng-template #pendingSummary>
<dl class="info-list">
<div><dt>PDV</dt><dd>{{ selectedUnassigned.pdv_nombre }}</dd></div>
<div><dt>Motivo</dt><dd>{{ selectedUnassigned.motivo }}</dd></div>
<div><dt>Coordenadas</dt><dd>{{ selectedUnassigned.latitud }}, {{ selectedUnassigned.longitud }}</dd></div>
</dl>
<div class="pending-edit-grid">
<label>Usuario<select [(ngModel)]="assignmentUser[selectedUnassigned.pdv_id]"><option value="">Seleccionar</option><option *ngFor="let user of unassignedUserOptions" [value]="user.id">{{ user.name }}</option></select></label>
<label>Día<select [(ngModel)]="assignmentDay[selectedUnassigned.pdv_id]"><option value="">Seleccionar</option><option *ngFor="let day of unassignedDayOptions" [value]="day">{{ day }}</option></select></label>
</div>
<div class="pending-actions"><button type="button" class="btn-secondary" (click)="startUnassignedEdit(selectedUnassigned)"><i class="pi pi-pencil"></i> Editar</button><button type="button" class="btn-primary" (click)="assignUnassigned(selectedUnassigned)" [disabled]="assigningUnassignedId === selectedUnassigned.pdv_id"><span *ngIf="assigningUnassignedId === selectedUnassigned.pdv_id" class="spinner-inline"></span><i *ngIf="assigningUnassignedId !== selectedUnassigned.pdv_id" class="pi pi-user-plus"></i> Reasignar</button></div>
</ng-template>
<div class="error-card" *ngIf="unassignedActionError">{{ unassignedActionError }}</div>
</section>
<section class="info-card" *ngIf="selectedRouteDetail">
<div class="info-card-header">
<h3>Detalle de la ruta</h3>
<button type="button" class="btn-icon" (click)="clearSelection()" title="Cerrar detalle">
<i class="pi pi-times"></i>
</button>
</div>
<div class="detail-section">
<h4><i class="pi pi-user"></i> Usuario</h4>
<dl class="info-list">
<div><dt>Nombre</dt><dd>{{ selectedRouteDetail.json.nombre_usuario }}</dd></div>
<div><dt>ID</dt><dd>{{ selectedRouteDetail.json.usuario_id }}</dd></div>
<div><dt>Origen</dt><dd>{{ selectedRouteDetail.json.latitud_usuario }}, {{ selectedRouteDetail.json.longitud_usuario }}</dd></div>
<div><dt>Horas disponibles</dt><dd>{{ selectedRouteDetail.json.hora_laboral_semanal_usuario }} h/semana</dd></div>
</dl>
</div>
<div class="detail-section">
<h4><i class="pi pi-map-marker"></i> PDV</h4>
<dl class="info-list">
<div><dt>Nombre</dt><dd>{{ selectedRouteDetail.json.nombre_pdv }}</dd></div>
<div><dt>ID</dt><dd>{{ selectedRouteDetail.json.pdv_id }}</dd></div>
<div><dt>Día</dt><dd>{{ selectedRouteDetail.json.dia }}</dd></div>
<div><dt>Orden de visita</dt><dd>{{ selectedVisitOrder }}</dd></div>
<div><dt>Origen del tramo</dt><dd>{{ selectedSegmentOrigin }}</dd></div>
<div><dt>Destino</dt><dd>{{ selectedRouteDetail.json.latitud_pdv }}, {{ selectedRouteDetail.json.longitud_pdv }}</dd></div>
<div><dt>Horas trabajo</dt><dd>{{ selectedRouteDetail.json.horas_trabajo }}</dd></div>
<div><dt>Distancia tramo</dt><dd>{{ selectedSegmentDistance | number:'1.2-2' }} km</dd></div>
<div><dt>Duración tramo</dt><dd>{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min</dd></div>
</dl>
<div class="reassign-actions" *ngIf="!showReassignForm">
<button type="button" class="btn-secondary" (click)="startReassignRoute()"><i class="pi pi-user-edit"></i> Reasignar ruta</button>
</div>
<div class="reassign-form" *ngIf="showReassignForm">
<label>Nuevo usuario<select [(ngModel)]="reassignUserId"><option *ngFor="let user of unassignedUserOptions" [value]="user.id">{{ user.name }}</option></select></label>
<label>Día<select [(ngModel)]="reassignDay"><option *ngFor="let day of unassignedDayOptions" [value]="day">{{ day }}</option></select></label>
<p class="reassign-hint">Se recalcularán la distancia y duración con ruta vial ORS.</p>
<div class="reassign-actions"><button type="button" class="btn-primary" (click)="reassignRoute()" [disabled]="reassigningRoute"><span *ngIf="reassigningRoute" class="spinner-inline"></span><i *ngIf="!reassigningRoute" class="pi pi-check"></i> {{ reassigningRoute ? 'Calculando...' : 'Confirmar' }}</button><button type="button" class="btn-secondary" (click)="cancelReassignRoute()" [disabled]="reassigningRoute">Cancelar</button></div>
<div class="error-card" *ngIf="reassignError">{{ reassignError }}</div>
</div>
</div>
</section>
<div class="routes-list" *ngIf="filteredRoutes.length">
<h3>{{ selectedUsuarioId === 'Todos' ? 'PDVs de todos los usuarios' : 'PDVs del usuario' }} ({{ filteredRoutes.length }})</h3>
<ul>
<li *ngFor="let r of filteredRoutes; let i = index"
[class.selected]="selectedRouteDetail && selectedRouteDetail.json.pdv_id === r.json.pdv_id && selectedRouteDetail.json.usuario_id === r.json.usuario_id && selectedRouteDetail.json.dia === r.json.dia"
(click)="selectRouteFromList(r)">
<span class="idx">{{ routeVisitOrder(r) }}</span>
<div class="li-main">
<strong>{{ r.json.nombre_pdv }}</strong>
<small>{{ selectedUsuarioId === 'Todos' ? r.json.nombre_usuario + ' • ' : '' }}{{ r.json.dia }} • {{ routeSegmentDistance(r) | number:'1.2-2' }} km • {{ routeSegmentDurationMinutes(r) | number:'1.0-0' }} min</small>
</div>
<i class="pi pi-chevron-right"></i>
</li>
</ul>
</div>
</div>
@@ -0,0 +1,785 @@
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 } 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 { haversineKm, sequenceRoutes } 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<void>();
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<number, L.Marker>();
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<string, string> = {};
assignmentDay: Record<string, string> = {};
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);
}
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<string, RouteAssignedResponse['json']>();
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: `<div class="marker-inner" style="background:${color};border:3px solid #fff;">A</div>`,
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(`<strong>Usuario:</strong> ${start.nombre_usuario}<br><strong>PDVs:</strong> ${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: `<div class="location-pin"><span>${segment.order}</span></div>`,
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(`<strong>${j.nombre_pdv}</strong><br>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: '<div class="location-pin unassigned-pin"><span>!</span></div>',
iconSize: [30, 38],
iconAnchor: [15, 38]
});
const marker = L.marker([route.latitud, route.longitud], { icon, pane: 'pdvPane', zIndexOffset: 1000 })
.addTo(this.map)
.bindPopup(`<strong>PDV sin ruta</strong><br>${route.pdv_nombre}<br>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<number, unknown>();
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(
`<strong>${mr.data.json.nombre_pdv}</strong><br>Parada ${mr.visitOrder} de ${mr.totalStops}${mr.data.json.dia}<br>${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;
}
async reassignRoute(): Promise<void> {
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');
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 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();
}
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<void> {
const userId = this.assignmentUser[String(route.pdv_id)];
const day = this.assignmentDay[String(route.pdv_id)];
if (!userId || !day) {
this.unassignedActionError = 'Selecciona usuario y 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 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');
this.routeState.addAssignedFromUnassigned(route, userId, day, summary.distance / 1000, summary.duration / 3600);
this.selectedUnassigned = null;
delete this.assignmentUser[String(route.pdv_id)];
delete this.assignmentDay[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']);
}
}