Merge pull request 'feat: añadir lista de empleados con carga al reasignar ruta' (#4) from feat/mapa-menu-ui into dev

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-09-03 21:28:43 +00:00
7 changed files with 199 additions and 172 deletions
+1 -1
View File
@@ -30,7 +30,7 @@
"production": { "production": {
"budgets": [ "budgets": [
{ "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" }, { "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" },
{ "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "10kB" } { "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "12kB" }
], ],
"outputHashing": "all", "outputHashing": "all",
"fileReplacements": [ "fileReplacements": [
@@ -130,10 +130,6 @@
white-space: nowrap; white-space: nowrap;
} }
.operational-grid {
margin-top: 12px;
}
.user-load-card { .user-load-card {
margin-top: 14px; margin-top: 14px;
padding: 12px 14px; padding: 12px 14px;
@@ -31,9 +31,7 @@
<div class="kpi-icon"><i class="pi pi-compass"></i></div> <div class="kpi-icon"><i class="pi pi-compass"></i></div>
<div><span class="kpi-label">Distancia total</span><strong>{{ summary.totalDistance | number:'1.0-1' }} <em>km</em></strong><small>{{ summary.averageDistance | number:'1.0-1' }} km promedio por ruta</small></div> <div><span class="kpi-label">Distancia total</span><strong>{{ summary.totalDistance | number:'1.0-1' }} <em>km</em></strong><small>{{ summary.averageDistance | number:'1.0-1' }} km promedio por ruta</small></div>
</article> </article>
</div> <ng-container *ngIf="hasData">
<div class="kpi-grid operational-grid" *ngIf="hasData">
<article class="kpi-card kpi-success"> <article class="kpi-card kpi-success">
<div class="kpi-icon"><i class="pi pi-briefcase"></i></div> <div class="kpi-icon"><i class="pi pi-briefcase"></i></div>
<div><span class="kpi-label">Carga de trabajo</span><strong>{{ summary.totalWorkHours | number:'1.0-1' }} <em>h</em></strong><small>horas de trabajo planificadas</small></div> <div><span class="kpi-label">Carga de trabajo</span><strong>{{ summary.totalWorkHours | number:'1.0-1' }} <em>h</em></strong><small>horas de trabajo planificadas</small></div>
@@ -50,6 +48,7 @@
<div class="kpi-icon"><i class="pi pi-arrows-h"></i></div> <div class="kpi-icon"><i class="pi pi-arrows-h"></i></div>
<div><span class="kpi-label">Brecha de balance</span><strong>{{ summary.assignmentBalance }}</strong><small>diferencia entre mayor y menor carga</small></div> <div><span class="kpi-label">Brecha de balance</span><strong>{{ summary.assignmentBalance }}</strong><small>diferencia entre mayor y menor carga</small></div>
</article> </article>
</ng-container>
</div> </div>
<div class="user-load-card" *ngIf="userLoads.length"> <div class="user-load-card" *ngIf="userLoads.length">
@@ -126,6 +126,8 @@
color: var(--glm-acero); color: var(--glm-acero);
} }
.heatmap-help { display: inline-grid; width: 16px; height: 16px; margin-left: auto; place-items: center; border: 1px solid var(--glm-acero); border-radius: 50%; color: var(--glm-acero); cursor: help; font-size: 0.68rem; font-weight: 700; }
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 24px 12px; padding: 24px 12px;
@@ -151,20 +153,45 @@
color: var(--glm-gris-oscuro); color: var(--glm-gris-oscuro);
} }
.unassigned-map-card { .unassigned-bottom-drawer {
padding: 12px; position: fixed;
background: #fff8ed; left: 398px;
right: 18px;
bottom: 18px;
z-index: 950;
overflow: hidden;
border: 1px solid #f2bf86; border: 1px solid #f2bf86;
border-left: 3px solid var(--glm-naranja); border-left: 4px solid var(--glm-naranja);
border-radius: 4px; border-radius: 10px 10px 4px 4px;
background: #fff8ed;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.22);
} }
.unassigned-map-card .info-card-header { margin-bottom: 8px; } .unassigned-drawer-toggle {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border: 0;
background: #fff8ed;
color: var(--glm-azul);
cursor: pointer;
font: inherit;
font-weight: 700;
text-align: left;
}
.unassigned-map-list { .unassigned-drawer-toggle span { display: inline-flex; align-items: center; gap: 8px; }
.unassigned-drawer-toggle .pi-exclamation-triangle { color: var(--glm-naranja); }
.unassigned-drawer-content {
display: grid; display: grid;
gap: 5px; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
max-height: 180px; gap: 8px;
max-height: 240px;
padding: 0 12px 12px;
overflow-y: auto; overflow-y: auto;
} }
@@ -219,86 +246,21 @@
.pending-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; } .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; } .pending-actions .btn-primary, .pending-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
.routes-list h3 { .employee-load-list { display: grid; gap: 5px; max-height: 318px; margin-top: 10px; overflow-y: auto; border-top: 1px solid var(--glm-gris-claro); padding-top: 8px; }
margin: 0 0 8px; .employee-load-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 8px; width: 100%; padding: 7px 8px; border: 1px solid var(--glm-gris-medio); border-radius: 4px; background: #fff; color: var(--glm-gris-oscuro); cursor: pointer; font: inherit; text-align: left; }
font-size: 0.85rem; .employee-load-row:hover, .employee-load-row.selected { border-color: var(--glm-verde); background: var(--glm-verde-bg); }
color: var(--glm-azul); .employee-load-row.over-capacity { border-color: #c9252d; background: var(--glm-rojo-ok); }
font-weight: 700; .employee-load-name { overflow: hidden; color: var(--glm-azul); font-size: 0.78rem; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
text-transform: uppercase; .employee-load-values { color: var(--glm-acero); font-size: 0.7rem; white-space: nowrap; }
} .employee-load-bar { grid-column: 1 / -1; display: block; height: 6px; overflow: hidden; border-radius: 6px; background: var(--glm-gris-medio); }
.employee-load-bar span { display: block; height: 100%; border-radius: inherit; background: var(--glm-verde); }
.employee-load-bar.warning span { background: var(--glm-naranja); }
.employee-load-bar.over-capacity span { background: #c9252d; }
.routes-list ul { @media (max-width: 900px) {
list-style: none; #map-overview { left: 0; }
margin: 0; .overview-sidebar { width: 340px; max-width: calc(100vw - 24px); }
padding: 0; .unassigned-bottom-drawer { right: 12px; bottom: 12px; left: 12px; }
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 { .info-card {
@@ -309,6 +271,12 @@
border-top: 3px solid var(--glm-azul); border-top: 3px solid var(--glm-azul);
} }
.map-location-button {
margin-top: 0;
padding: 6px 9px;
font-size: 0.75rem;
}
.info-card-header { .info-card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -353,7 +321,7 @@
.reassign-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; } .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-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-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; } .reassign-form .reassign-actions, .reassign-form .employee-load-list, .reassign-form .error-card { grid-column: 1 / -1; }
.daily-routes-card { padding: 12px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: var(--glm-surface); } .daily-routes-card { padding: 12px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: var(--glm-surface); }
.daily-routes-card h3 { margin: 0 0 8px; color: var(--glm-azul); font-size: 0.82rem; text-transform: uppercase; } .daily-routes-card h3 { margin: 0 0 8px; color: var(--glm-azul); font-size: 0.82rem; text-transform: uppercase; }
@@ -368,7 +336,7 @@
.daily-route:last-child { border-bottom: 0; } .daily-route:last-child { border-bottom: 0; }
.daily-route span, .daily-route small { color: var(--glm-acero); } .daily-route span, .daily-route small { color: var(--glm-acero); }
.daily-route.over-capacity small { color: #c9252d; font-weight: 700; } .daily-route.over-capacity small { color: #c9252d; font-weight: 700; }
.routes-list li:focus-visible, .btn-icon:focus-visible, button:focus-visible, select:focus-visible { outline: 3px solid rgba(108, 194, 74, 0.45); outline-offset: 2px; } .btn-icon:focus-visible, button:focus-visible, select:focus-visible { outline: 3px solid rgba(108, 194, 74, 0.45); outline-offset: 2px; }
.btn-icon { .btn-icon {
background: transparent; background: transparent;
@@ -393,6 +361,7 @@
.info-list div { .info-list div {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
align-items: center;
gap: 10px; gap: 10px;
padding: 5px 0; padding: 5px 0;
border-bottom: 1px solid var(--glm-gris-claro); border-bottom: 1px solid var(--glm-gris-claro);
@@ -481,35 +450,6 @@
color: var(--glm-gris-oscuro); 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 { .custom-marker {
background: none !important; background: none !important;
border: none !important; border: none !important;
@@ -40,11 +40,8 @@
<label class="toggle"> <label class="toggle">
<input type="checkbox" [checked]="showHeat" (change)="toggleHeat()" /> <input type="checkbox" [checked]="showHeat" (change)="toggleHeat()" />
<i class="pi pi-fire"></i> Heatmap (carga) <i class="pi pi-fire"></i> Heatmap (carga)
<span class="heatmap-help" title="El mapa muestra con mayor intensidad las zonas donde se concentra más carga de trabajo en horas.">?</span>
</label> </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>
<div class="filter-empty" *ngIf="!filteredRoutes.length && !error"> <div class="filter-empty" *ngIf="!filteredRoutes.length && !error">
@@ -63,18 +60,6 @@
{{ error }} {{ error }}
</div> </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"> <section class="info-card unassigned-detail-card" *ngIf="selectedUnassigned">
<div class="info-card-header"> <div class="info-card-header">
<h3>Gestionar PDV sin ruta</h3> <h3>Gestionar PDV sin ruta</h3>
@@ -96,11 +81,18 @@
<div><dt>Motivo</dt><dd>{{ selectedUnassigned.motivo }}</dd></div> <div><dt>Motivo</dt><dd>{{ selectedUnassigned.motivo }}</dd></div>
<div><dt>Coordenadas</dt><dd>{{ selectedUnassigned.latitud }}, {{ selectedUnassigned.longitud }}</dd></div> <div><dt>Coordenadas</dt><dd>{{ selectedUnassigned.latitud }}, {{ selectedUnassigned.longitud }}</dd></div>
</dl> </dl>
<div class="pending-edit-grid"> <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>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ías<div class="day-checkboxes"><label *ngFor="let day of unassignedDayOptions"><input type="checkbox" [checked]="(assignmentDays[selectedUnassigned.pdv_id] || []).includes(day)" (change)="toggleUnassignedDay(selectedUnassigned.pdv_id, day, $any($event.target).checked)">{{ day.substring(0, 3) }}</label></div></label> <label>Días<div class="day-checkboxes"><label *ngFor="let day of unassignedDayOptions"><input type="checkbox" [checked]="(assignmentDays[selectedUnassigned.pdv_id] || []).includes(day)" (change)="toggleUnassignedDay(selectedUnassigned.pdv_id, day, $any($event.target).checked)">{{ day.substring(0, 3) }}</label></div></label>
</div> </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> <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>
<div class="employee-load-list" aria-label="Carga de empleados">
<button type="button" class="employee-load-row" *ngFor="let employee of unassignedEmployeeLoads" [class.selected]="assignmentUser[selectedUnassigned.pdv_id] === employee.id" [class.over-capacity]="employeeLoadStatus(employee) === 'over-capacity'" (click)="selectUnassignedEmployee(employee.id)">
<span class="employee-load-name">{{ employee.name }}</span>
<span class="employee-load-values">{{ employee.workHours | number:'1.0-1' }} / {{ employee.capacityHours | number:'1.0-1' }} h · {{ employee.utilization | number:'1.0-1' }}%</span>
<span class="employee-load-bar" [ngClass]="employeeLoadStatus(employee)"><span [style.width.%]="employeeLoadWidth(employee)"></span></span>
</button>
</div>
</ng-template> </ng-template>
<div class="error-card" *ngIf="unassignedActionError">{{ unassignedActionError }}</div> <div class="error-card" *ngIf="unassignedActionError">{{ unassignedActionError }}</div>
</section> </section>
@@ -129,7 +121,7 @@
<div><dt>Día</dt><dd>{{ selectedRouteDetail.json.dia }}</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>Orden de visita</dt><dd>{{ selectedVisitOrder }}</dd></div>
<div><dt>Origen del tramo</dt><dd>{{ selectedSegmentOrigin }}</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>Destino</dt><dd><button type="button" class="btn-secondary map-location-button" (click)="openDestinationInGoogleMaps()" title="Abrir ubicación en Google Maps"><i class="pi pi-map"></i> Ver en Google Maps</button></dd></div>
<div><dt>Horas trabajo</dt><dd>{{ selectedRouteDetail.json.horas_trabajo }}</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>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> <div><dt>Duración tramo</dt><dd>{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min</dd></div>
@@ -145,6 +137,13 @@
<label>Día<select [(ngModel)]="reassignDay"><option *ngFor="let day of unassignedDayOptions" [value]="day">{{ day }}</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> <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="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="employee-load-list" aria-label="Carga de empleados">
<button type="button" class="employee-load-row" *ngFor="let employee of reassignmentEmployeeLoads" [class.selected]="reassignUserId === employee.id" [class.over-capacity]="employeeLoadStatus(employee) === 'over-capacity'" (click)="selectReassignmentEmployee(employee.id)">
<span class="employee-load-name">{{ employee.name }}</span>
<span class="employee-load-values">{{ employee.workHours | number:'1.0-1' }} / {{ employee.capacityHours | number:'1.0-1' }} h · {{ employee.utilization | number:'1.0-1' }}%</span>
<span class="employee-load-bar" [ngClass]="employeeLoadStatus(employee)"><span [style.width.%]="employeeLoadWidth(employee)"></span></span>
</button>
</div>
<div class="error-card" *ngIf="reassignError">{{ reassignError }}</div> <div class="error-card" *ngIf="reassignError">{{ reassignError }}</div>
</div> </div>
</div> </div>
@@ -155,12 +154,12 @@
<article class="daily-route-group" *ngFor="let group of dailyGroups"> <article class="daily-route-group" *ngFor="let group of dailyGroups">
<button type="button" class="daily-route-toggle" (click)="toggleDailyRouteDay(group.day)" [attr.aria-expanded]="isDailyRouteDayExpanded(group.day)"> <button type="button" class="daily-route-toggle" (click)="toggleDailyRouteDay(group.day)" [attr.aria-expanded]="isDailyRouteDayExpanded(group.day)">
<span class="daily-route-toggle-title"><i class="pi" [class.pi-chevron-right]="!isDailyRouteDayExpanded(group.day)" [class.pi-chevron-down]="isDailyRouteDayExpanded(group.day)"></i> {{ group.day }}</span> <span class="daily-route-toggle-title"><i class="pi" [class.pi-chevron-right]="!isDailyRouteDayExpanded(group.day)" [class.pi-chevron-down]="isDailyRouteDayExpanded(group.day)"></i> {{ group.day }}</span>
<span>{{ group.totalStops }} paradas · {{ group.totalDistance | number:'1.1-1' }} km · {{ (group.totalTravelHours * 60) | number:'1.0-0' }} min</span> <span>{{ group.totalStops }} paradas · {{ group.totalDistance | number:'1.1-1' }} km</span>
</button> </button>
<div class="daily-route-group-content" *ngIf="isDailyRouteDayExpanded(group.day)"> <div class="daily-route-group-content" *ngIf="isDailyRouteDayExpanded(group.day)">
<article class="daily-route" *ngFor="let route of group.routes" [class.over-capacity]="route.exceedsCapacity"> <article class="daily-route" *ngFor="let route of group.routes" [class.over-capacity]="route.exceedsCapacity">
<strong>{{ route.userName }}</strong> <strong>{{ route.userName }}</strong>
<span>{{ route.routes.length }} paradas · {{ route.distanceKm | number:'1.1-1' }} km · {{ (route.travelHours * 60) | number:'1.0-0' }} min</span> <span>{{ route.routes.length }} paradas · {{ route.distanceKm | number:'1.1-1' }} km</span>
<small [attr.aria-label]="route.exceedsCapacity ? 'Capacidad excedida' : 'Dentro de capacidad'">{{ route.totalHours | number:'1.1-1' }} / {{ route.capacityHours | number:'1.1-1' }} h {{ route.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}</small> <small [attr.aria-label]="route.exceedsCapacity ? 'Capacidad excedida' : 'Dentro de capacidad'">{{ route.totalHours | number:'1.1-1' }} / {{ route.capacityHours | number:'1.1-1' }} h {{ route.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}</small>
</article> </article>
</div> </div>
@@ -168,3 +167,16 @@
</section> </section>
</div> </div>
<section class="unassigned-bottom-drawer" *ngIf="selectedUsuarioId === 'Todos' && selectedDia === 'Todos' && unassignedRoutes.length" [class.open]="isUnassignedDrawerOpen">
<button type="button" class="unassigned-drawer-toggle" (click)="toggleUnassignedDrawer()" [attr.aria-expanded]="isUnassignedDrawerOpen">
<span><i class="pi pi-exclamation-triangle"></i> PDVs sin ruta ({{ unassignedRoutes.length }})</span>
<i class="pi" [class.pi-chevron-up]="!isUnassignedDrawerOpen" [class.pi-chevron-down]="isUnassignedDrawerOpen"></i>
</button>
<div class="unassigned-drawer-content" *ngIf="isUnassignedDrawerOpen">
<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>
@@ -16,7 +16,14 @@ interface DailyRouteGroup {
routes: DailyRouteSummary[]; routes: DailyRouteSummary[];
totalStops: number; totalStops: number;
totalDistance: number; totalDistance: number;
totalTravelHours: number; }
interface EmployeeLoadOption {
id: string;
name: string;
workHours: number;
capacityHours: number;
utilization: number;
} }
interface MapRoute { interface MapRoute {
@@ -53,7 +60,6 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
private selectedMapRoute: MapRoute | null = null; private selectedMapRoute: MapRoute | null = null;
private heatLayer?: any; private heatLayer?: any;
showHeat = true; showHeat = true;
heatWeightMode: 'uniform' | 'horas_trabajo' = 'horas_trabajo';
private heatPointsCache: [number, number, number][] = []; private heatPointsCache: [number, number, number][] = [];
private unassignedMarkers = new Map<number, L.Marker>(); private unassignedMarkers = new Map<number, L.Marker>();
@@ -72,6 +78,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
orsTotal = 0; orsTotal = 0;
error: string | null = null; error: string | null = null;
unassignedRoutes: RouteNoAssignedResponse[] = []; unassignedRoutes: RouteNoAssignedResponse[] = [];
isUnassignedDrawerOpen = false;
selectedUnassigned: RouteNoAssignedResponse | null = null; selectedUnassigned: RouteNoAssignedResponse | null = null;
editingUnassigned: RouteNoAssignedResponse | null = null; editingUnassigned: RouteNoAssignedResponse | null = null;
editUnassignedName = ''; editUnassignedName = '';
@@ -144,8 +151,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
day, day,
routes, routes,
totalStops: routes.reduce((sum, route) => sum + route.routes.length, 0), totalStops: routes.reduce((sum, route) => sum + route.routes.length, 0),
totalDistance: routes.reduce((sum, route) => sum + route.distanceKm, 0), totalDistance: routes.reduce((sum, route) => sum + route.distanceKm, 0)
totalTravelHours: routes.reduce((sum, route) => sum + route.travelHours, 0)
})).sort((a, b) => order.indexOf(a.day) - order.indexOf(b.day)); })).sort((a, b) => order.indexOf(a.day) - order.indexOf(b.day));
} }
@@ -157,6 +163,20 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
return this.expandedDailyRouteDay === day; return this.expandedDailyRouteDay === day;
} }
openDestinationInGoogleMaps(): void {
if (!this.selectedRouteDetail) return;
const { latitud_pdv, longitud_pdv } = this.selectedRouteDetail.json;
const latitude = Number(latitud_pdv);
const longitude = Number(longitud_pdv);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return;
const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${latitude},${longitude}`)}`;
window.open(url, '_blank', 'noopener,noreferrer');
}
toggleUnassignedDrawer(): void {
this.isUnassignedDrawerOpen = !this.isUnassignedDrawerOpen;
}
constructor( constructor(
private routeState: RouteState, private routeState: RouteState,
private orsService: OrsService, private orsService: OrsService,
@@ -271,7 +291,11 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
private initMap() { private initMap() {
this.map = L.map('map-overview').setView([18.4861, -69.9312], 10); this.map = L.map('map-overview').setView([18.4861, -69.9312], 10);
const pdvPane = this.map.createPane('pdvPane'); const pdvPane = this.map.createPane('pdvPane');
pdvPane.style.zIndex = '650'; const heatPane = this.map.createPane('heatPane');
pdvPane.style.zIndex = '750';
heatPane.style.zIndex = '700';
const popupPane = this.map.getPane('popupPane');
if (popupPane) popupPane.style.zIndex = '1000';
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors' attribution: '© OpenStreetMap contributors'
}).addTo(this.map); }).addTo(this.map);
@@ -538,6 +562,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} }
private selectRoute(mr: MapRoute) { private selectRoute(mr: MapRoute) {
this.selectedUnassigned = null;
this.editingUnassigned = null;
this.unassignedActionError = null;
this.selectedRouteDetail = mr.data; this.selectedRouteDetail = mr.data;
this.highlightRoute(mr.data); this.highlightRoute(mr.data);
this.fitSelectedRoute(mr); this.fitSelectedRoute(mr);
@@ -545,6 +572,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} }
selectRouteFromList(route: RouteAssignedResponse): void { selectRouteFromList(route: RouteAssignedResponse): void {
this.selectedUnassigned = null;
this.editingUnassigned = null;
this.unassignedActionError = null;
const mapRoute = this.mapRoutes.find(mr => this.sameRoute(mr.data, route)); const mapRoute = this.mapRoutes.find(mr => this.sameRoute(mr.data, route));
if (mapRoute) { if (mapRoute) {
this.selectRoute(mapRoute); this.selectRoute(mapRoute);
@@ -693,14 +723,62 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
left.json.dia === right.json.dia; left.json.dia === right.json.dia;
} }
private getEmployeeLoads(excluding?: RouteAssignedResponse): EmployeeLoadOption[] {
const profiles = new Map<string, RouteAssignedResponse>();
const workHours = new Map<string, number>();
for (const route of this.routeState.getAssignedSnapshot()) {
const id = String(route.json.usuario_id);
if (!profiles.has(id)) profiles.set(id, route);
if (route !== excluding) {
workHours.set(id, (workHours.get(id) ?? 0) + Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0));
}
}
return Array.from(profiles, ([id, profile]) => {
const capacityHours = Number(profile.json.hora_laboral_semanal_usuario || 0);
const assignedHours = workHours.get(id) ?? 0;
return {
id,
name: profile.json.nombre_usuario,
workHours: assignedHours,
capacityHours,
utilization: capacityHours ? (assignedHours / capacityHours) * 100 : 0
};
}).sort((a, b) => a.utilization - b.utilization || a.name.localeCompare(b.name));
}
get unassignedUserOptions(): { id: string; name: string }[] { get unassignedUserOptions(): { id: string; name: string }[] {
return this.routeState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre })); return this.routeState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre }));
} }
get unassignedEmployeeLoads(): EmployeeLoadOption[] {
return this.getEmployeeLoads();
}
get reassignmentEmployeeLoads(): EmployeeLoadOption[] {
return this.getEmployeeLoads(this.selectedRouteDetail ?? undefined);
}
get unassignedDayOptions(): string[] { get unassignedDayOptions(): string[] {
return this.routeState.getDiasUnicos(); return this.routeState.getDiasUnicos();
} }
employeeLoadWidth(employee: EmployeeLoadOption): number {
return Math.min(Math.max(employee.utilization, 0), 100);
}
employeeLoadStatus(employee: EmployeeLoadOption): 'ok' | 'warning' | 'over-capacity' {
return employee.utilization > 100 ? 'over-capacity' : employee.utilization > 90 ? 'warning' : 'ok';
}
selectUnassignedEmployee(employeeId: string): void {
if (!this.selectedUnassigned) return;
this.assignmentUser[String(this.selectedUnassigned.pdv_id)] = employeeId;
}
selectReassignmentEmployee(employeeId: string): void {
this.reassignUserId = employeeId;
}
toggleUnassignedDay(pdvId: number, day: string, checked: boolean): void { toggleUnassignedDay(pdvId: number, day: string, checked: boolean): void {
const key = String(pdvId); const key = String(pdvId);
const days = new Set(this.assignmentDays[key] ?? []); const days = new Set(this.assignmentDays[key] ?? []);
@@ -711,6 +789,8 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
selectUnassigned(route: RouteNoAssignedResponse): void { selectUnassigned(route: RouteNoAssignedResponse): void {
this.selectedUnassigned = route; this.selectedUnassigned = route;
this.selectedRouteDetail = null; this.selectedRouteDetail = null;
this.showReassignForm = false;
this.reassignError = null;
this.map?.setView([route.latitud, route.longitud], Math.max(this.map.getZoom(), 14)); this.map?.setView([route.latitud, route.longitud], Math.max(this.map.getZoom(), 14));
this.unassignedMarkers.get(route.pdv_id)?.openPopup(); this.unassignedMarkers.get(route.pdv_id)?.openPopup();
this.cdr.detectChanges(); this.cdr.detectChanges();
@@ -815,13 +895,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} }
private computeWeight(j: RouteAssignedResponse['json']): number { private computeWeight(j: RouteAssignedResponse['json']): number {
if (this.heatWeightMode === 'uniform') return 0.8;
const v = parseFloat(String(j.horas_trabajo)); const v = parseFloat(String(j.horas_trabajo));
if (isNaN(v)) return 0.8; if (isNaN(v)) return 1;
const vals = this.filteredRoutes.map(r => parseFloat(String(r.json.horas_trabajo))).filter(n => !isNaN(n)); const vals = this.filteredRoutes.map(r => parseFloat(String(r.json.horas_trabajo))).filter(n => !isNaN(n));
if (!vals.length) return 0.8; if (!vals.length) return 1;
const min = Math.min(...vals), max = Math.max(...vals); const min = Math.min(...vals), max = Math.max(...vals);
if (max === min) return 0.8; if (max === min) return 1;
return 0.3 + 0.7 * (v - min) / (max - min); return 0.3 + 0.7 * (v - min) / (max - min);
} }
@@ -832,8 +911,13 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} }
if (!this.showHeat || !this.heatPointsCache.length || !this.map) return; if (!this.showHeat || !this.heatPointsCache.length || !this.map) return;
this.heatLayer = (L as any).heatLayer(this.heatPointsCache, { this.heatLayer = (L as any).heatLayer(this.heatPointsCache, {
radius: 25, blur: 18, maxZoom: 17, minOpacity: 0.4, pane: 'heatPane',
gradient: { 0.4: '#6CC24A', 0.65: '#FF6A13', 1.0: '#D32F2F' } radius: 32,
blur: 24,
maxZoom: 18,
minOpacity: 0.55,
max: 1.8,
gradient: { 0.2: '#b7f05f', 0.45: '#6CC24A', 0.7: '#FF6A13', 1.0: '#D32F2F' }
}).addTo(this.map); }).addTo(this.map);
} }
@@ -848,16 +932,6 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
this.cdr.detectChanges(); 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() { private toggleLayerVisibility() {
this.mapRoutes.forEach(mr => { this.mapRoutes.forEach(mr => {
if (mr.fallbackLine) (mr.fallbackLine as any).setStyle({ opacity: 0.6 }); if (mr.fallbackLine) (mr.fallbackLine as any).setStyle({ opacity: 0.6 });
+6
View File
@@ -40,6 +40,12 @@
text-align: center; text-align: center;
} }
/* leaflet.heat creates its canvas in the map overlay pane, outside Angular styles. */
.leaflet-heatmap-layer {
z-index: 700 !important;
pointer-events: none;
}
:root { :root {
--glm-verde: #6CC24A; --glm-verde: #6CC24A;
--glm-verde-claro: #A4D65E; --glm-verde-claro: #A4D65E;