feat: implement route planning dashboard including KPIs, user workload tracking, and route assignment management features
This commit is contained in:
+556
@@ -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; }
|
||||
+165
@@ -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>
|
||||
+785
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user