Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f34462876 | |||
| 540bc46fc2 | |||
| 7d696e6f16 | |||
| 65fe788281 |
@@ -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,13 +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>
|
||||||
<article class="kpi-card kpi-blue">
|
<ng-container *ngIf="hasData">
|
||||||
<div class="kpi-icon"><i class="pi pi-clock"></i></div>
|
|
||||||
<div><span class="kpi-label">Desplazamiento total</span><strong>{{ summary.totalTravelHours | number:'1.0-1' }} <em>h</em></strong><small>{{ summary.averageTravelHours | number:'1.0-1' }} h promedio por ruta</small></div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
||||||
@@ -54,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">
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ interface KpiSummary {
|
|||||||
users: number;
|
users: number;
|
||||||
days: number;
|
days: number;
|
||||||
totalDistance: number;
|
totalDistance: number;
|
||||||
totalTravelHours: number;
|
|
||||||
averageDistance: number;
|
averageDistance: number;
|
||||||
averageTravelHours: number;
|
|
||||||
totalWorkHours: number;
|
totalWorkHours: number;
|
||||||
averageRoutesPerUser: number;
|
averageRoutesPerUser: number;
|
||||||
averageUtilization: number;
|
averageUtilization: number;
|
||||||
@@ -86,10 +84,15 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
|||||||
private refresh(): void {
|
private refresh(): void {
|
||||||
const assigned = this.routeState.getAssignedSnapshot();
|
const assigned = this.routeState.getAssignedSnapshot();
|
||||||
const unassigned = this.routeState.getNoAssignedSnapshot();
|
const unassigned = this.routeState.getNoAssignedSnapshot();
|
||||||
const totalPdvs = assigned.length + unassigned.length;
|
const assignedPdvIds = new Set(assigned.map(route => String(route.json.pdv_id)));
|
||||||
|
// A PDV with at least one scheduled visit is assigned; repeated weekly visits and
|
||||||
|
// partial-assignment alerts must not inflate the planning coverage counters.
|
||||||
|
const unassignedPdvIds = new Set(unassigned
|
||||||
|
.map(route => String(route.pdv_id))
|
||||||
|
.filter(pdvId => !assignedPdvIds.has(pdvId)));
|
||||||
|
const totalPdvs = new Set([...assignedPdvIds, ...unassignedPdvIds]).size;
|
||||||
const dailyRoutes = summarizeDailyRoutes(assigned);
|
const dailyRoutes = summarizeDailyRoutes(assigned);
|
||||||
const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0);
|
const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0);
|
||||||
const totalTravelHours = dailyRoutes.reduce((sum, day) => sum + day.travelHours, 0);
|
|
||||||
const totalWorkHours = dailyRoutes.reduce((sum, day) => sum + day.workHours, 0);
|
const totalWorkHours = dailyRoutes.reduce((sum, day) => sum + day.workHours, 0);
|
||||||
const users = new Set(assigned.map(route => String(route.json.usuario_id))).size;
|
const users = new Set(assigned.map(route => String(route.json.usuario_id))).size;
|
||||||
const days = new Set(assigned.map(route => route.json.dia)).size;
|
const days = new Set(assigned.map(route => route.json.dia)).size;
|
||||||
@@ -134,24 +137,28 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.summary = {
|
this.summary = {
|
||||||
totalPdvs,
|
totalPdvs,
|
||||||
assignedPdvs: assigned.length,
|
assignedPdvs: assignedPdvIds.size,
|
||||||
unassignedPdvs: unassigned.length,
|
unassignedPdvs: unassignedPdvIds.size,
|
||||||
coverage: totalPdvs ? (assigned.length / totalPdvs) * 100 : 0,
|
coverage: totalPdvs ? (assignedPdvIds.size / totalPdvs) * 100 : 0,
|
||||||
users,
|
users,
|
||||||
days,
|
days,
|
||||||
totalDistance,
|
totalDistance,
|
||||||
totalTravelHours,
|
|
||||||
averageDistance: assigned.length ? totalDistance / assigned.length : 0,
|
averageDistance: assigned.length ? totalDistance / assigned.length : 0,
|
||||||
averageTravelHours: assigned.length ? totalTravelHours / assigned.length : 0,
|
|
||||||
totalWorkHours,
|
totalWorkHours,
|
||||||
averageRoutesPerUser: users ? assigned.length / users : 0,
|
averageRoutesPerUser: users ? assigned.length / users : 0,
|
||||||
averageUtilization,
|
averageUtilization,
|
||||||
assignmentBalance
|
assignmentBalance
|
||||||
};
|
};
|
||||||
|
|
||||||
const reasonCounts = new Map<string, number>();
|
const reasonByPdv = new Map<string, string>();
|
||||||
for (const route of unassigned) {
|
for (const route of unassigned) {
|
||||||
|
const pdvId = String(route.pdv_id);
|
||||||
|
if (assignedPdvIds.has(pdvId) || reasonByPdv.has(pdvId)) continue;
|
||||||
const reason = route.motivo?.trim() || 'Sin motivo especificado';
|
const reason = route.motivo?.trim() || 'Sin motivo especificado';
|
||||||
|
reasonByPdv.set(pdvId, reason);
|
||||||
|
}
|
||||||
|
const reasonCounts = new Map<string, number>();
|
||||||
|
for (const reason of reasonByPdv.values()) {
|
||||||
reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1);
|
reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
this.reasons = Array.from(reasonCounts.entries())
|
this.reasons = Array.from(reasonCounts.entries())
|
||||||
@@ -174,9 +181,7 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
|||||||
users: 0,
|
users: 0,
|
||||||
days: 0,
|
days: 0,
|
||||||
totalDistance: 0,
|
totalDistance: 0,
|
||||||
totalTravelHours: 0,
|
|
||||||
averageDistance: 0,
|
averageDistance: 0,
|
||||||
averageTravelHours: 0,
|
|
||||||
totalWorkHours: 0,
|
totalWorkHours: 0,
|
||||||
averageRoutesPerUser: 0,
|
averageRoutesPerUser: 0,
|
||||||
averageUtilization: 0,
|
averageUtilization: 0,
|
||||||
|
|||||||
+66
-119
@@ -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,15 +321,22 @@
|
|||||||
.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; }
|
||||||
|
.daily-route-group { border-bottom: 1px solid var(--glm-gris-claro); }
|
||||||
|
.daily-route-group:last-child { border-bottom: 0; }
|
||||||
|
.daily-route-toggle { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 0; border: 0; background: transparent; color: var(--glm-gris-oscuro); cursor: pointer; font: inherit; text-align: left; }
|
||||||
|
.daily-route-toggle:hover { background: var(--glm-gris-claro); }
|
||||||
|
.daily-route-toggle-title { color: var(--glm-azul); font-weight: 700; }
|
||||||
|
.daily-route-toggle > span:last-child { color: var(--glm-acero); font-size: 0.78rem; }
|
||||||
|
.daily-route-group-content { padding-left: 14px; }
|
||||||
.daily-route { display: grid; gap: 3px; padding: 8px 0; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); font-size: 0.78rem; }
|
.daily-route { display: grid; gap: 3px; padding: 8px 0; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); font-size: 0.78rem; }
|
||||||
.daily-route:last-child { border-bottom: 0; }
|
.daily-route: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;
|
||||||
@@ -386,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);
|
||||||
@@ -474,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;
|
||||||
|
|||||||
+42
-37
@@ -31,7 +31,7 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div class="filter-stats" *ngIf="filteredRoutes.length">
|
<div class="filter-stats" *ngIf="filteredRoutes.length">
|
||||||
<span>{{ filteredRoutes.length }} PDVs</span>
|
<span>{{ uniqueFilteredRoutes.length }} PDVs</span>
|
||||||
<span>{{ totalDistancia }} km</span>
|
<span>{{ totalDistancia }} km</span>
|
||||||
<span>{{ totalDuracionMin }} min</span>
|
<span>{{ totalDuracionMin }} min</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -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>
|
||||||
@@ -101,6 +86,13 @@
|
|||||||
<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>
|
||||||
@@ -152,26 +151,32 @@
|
|||||||
|
|
||||||
<section class="daily-routes-card" *ngIf="dailySummaries.length">
|
<section class="daily-routes-card" *ngIf="dailySummaries.length">
|
||||||
<h3>Rutas diarias</h3>
|
<h3>Rutas diarias</h3>
|
||||||
<article class="daily-route" *ngFor="let day of dailySummaries" [class.over-capacity]="day.exceedsCapacity">
|
<article class="daily-route-group" *ngFor="let group of dailyGroups">
|
||||||
<strong>{{ day.userName }} · {{ day.day }}</strong>
|
<button type="button" class="daily-route-toggle" (click)="toggleDailyRouteDay(group.day)" [attr.aria-expanded]="isDailyRouteDayExpanded(group.day)">
|
||||||
<span>{{ day.routes.length }} paradas · {{ day.distanceKm | number:'1.1-1' }} km · {{ (day.travelHours * 60) | number:'1.0-0' }} min</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>
|
||||||
<small [attr.aria-label]="day.exceedsCapacity ? 'Capacidad excedida' : 'Dentro de capacidad'">{{ day.totalHours | number:'1.1-1' }} / {{ day.capacityHours | number:'1.1-1' }} h {{ day.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}</small>
|
<span>{{ group.totalStops }} paradas · {{ group.totalDistance | number:'1.1-1' }} km</span>
|
||||||
|
</button>
|
||||||
|
<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">
|
||||||
|
<strong>{{ route.userName }}</strong>
|
||||||
|
<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>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</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" tabindex="0" role="button"
|
|
||||||
[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)" (keydown.enter)="selectRouteFromList(r)" (keydown.space)="selectRouteFromList(r); $event.preventDefault()">
|
|
||||||
<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>
|
</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>
|
||||||
|
|||||||
+161
-41
@@ -11,6 +11,21 @@ import { RouteAssignedResponse, RouteNoAssignedResponse } from '../../../core/mo
|
|||||||
import { colorForUsuario } from '../../../shared/utils/palette.utils';
|
import { colorForUsuario } from '../../../shared/utils/palette.utils';
|
||||||
import { DailyRouteSummary, haversineKm, sequenceRoutes, summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
|
import { DailyRouteSummary, haversineKm, sequenceRoutes, summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||||
|
|
||||||
|
interface DailyRouteGroup {
|
||||||
|
day: string;
|
||||||
|
routes: DailyRouteSummary[];
|
||||||
|
totalStops: number;
|
||||||
|
totalDistance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmployeeLoadOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
workHours: number;
|
||||||
|
capacityHours: number;
|
||||||
|
utilization: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface MapRoute {
|
interface MapRoute {
|
||||||
data: RouteAssignedResponse;
|
data: RouteAssignedResponse;
|
||||||
color: string;
|
color: string;
|
||||||
@@ -45,14 +60,14 @@ 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>();
|
||||||
|
|
||||||
usuarios: { id: string; nombre: string }[] = [];
|
usuarios: { id: string; nombre: string }[] = [];
|
||||||
dias: string[] = [];
|
dias: string[] = [];
|
||||||
selectedUsuarioId: string | null = null;
|
selectedUsuarioId: string = 'Todos';
|
||||||
selectedDia: string = 'Todos';
|
selectedDia: string = 'Todos';
|
||||||
|
expandedDailyRouteDay: string | null = null;
|
||||||
selectedRouteDetail: RouteAssignedResponse | null = null;
|
selectedRouteDetail: RouteAssignedResponse | null = null;
|
||||||
|
|
||||||
filteredRoutes: RouteAssignedResponse[] = [];
|
filteredRoutes: RouteAssignedResponse[] = [];
|
||||||
@@ -63,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 = '';
|
||||||
@@ -81,16 +97,41 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
reassignError: string | null = null;
|
reassignError: string | null = null;
|
||||||
|
|
||||||
// Summary stats
|
// Summary stats
|
||||||
|
get uniqueFilteredRoutes(): RouteAssignedResponse[] {
|
||||||
|
const unique = new Map<string, RouteAssignedResponse>();
|
||||||
|
for (const route of this.filteredRoutes) {
|
||||||
|
const pdvId = String(route.json.pdv_id);
|
||||||
|
if (!unique.has(pdvId)) unique.set(pdvId, route);
|
||||||
|
}
|
||||||
|
return Array.from(unique.values());
|
||||||
|
}
|
||||||
|
|
||||||
get totalDistancia(): string {
|
get totalDistancia(): string {
|
||||||
|
const uniqueIds = new Set(this.uniqueFilteredRoutes.map(route => String(route.json.pdv_id)));
|
||||||
const sum = this.mapRoutes.length
|
const sum = this.mapRoutes.length
|
||||||
? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDistanceKm ?? haversineKm(route.origin, route.destination)), 0)
|
? this.mapRoutes
|
||||||
: this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.distancia), 0);
|
.filter(route => uniqueIds.has(String(route.data.json.pdv_id)))
|
||||||
|
.reduce((acc, route) => {
|
||||||
|
const firstSegment = this.mapRoutes.find(candidate => String(candidate.data.json.pdv_id) === String(route.data.json.pdv_id));
|
||||||
|
return firstSegment === route
|
||||||
|
? acc + (route.segmentDistanceKm ?? haversineKm(route.origin, route.destination))
|
||||||
|
: acc;
|
||||||
|
}, 0)
|
||||||
|
: this.uniqueFilteredRoutes.reduce((acc, route) => acc + Number(route.json.distancia), 0);
|
||||||
return sum.toFixed(2);
|
return sum.toFixed(2);
|
||||||
}
|
}
|
||||||
get totalDuracionMin(): string {
|
get totalDuracionMin(): string {
|
||||||
|
const uniqueIds = new Set(this.uniqueFilteredRoutes.map(route => String(route.json.pdv_id)));
|
||||||
const sum = this.mapRoutes.length
|
const sum = this.mapRoutes.length
|
||||||
? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDurationHours ?? Number(route.data.json.horas_desplazamiento)) * 60, 0)
|
? this.mapRoutes
|
||||||
: this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.horas_desplazamiento) * 60, 0);
|
.filter(route => uniqueIds.has(String(route.data.json.pdv_id)))
|
||||||
|
.reduce((acc, route) => {
|
||||||
|
const firstSegment = this.mapRoutes.find(candidate => String(candidate.data.json.pdv_id) === String(route.data.json.pdv_id));
|
||||||
|
return firstSegment === route
|
||||||
|
? acc + (route.segmentDurationHours ?? Number(route.data.json.horas_desplazamiento)) * 60
|
||||||
|
: acc;
|
||||||
|
}, 0)
|
||||||
|
: this.uniqueFilteredRoutes.reduce((acc, route) => acc + Number(route.json.horas_desplazamiento) * 60, 0);
|
||||||
return sum.toFixed(0);
|
return sum.toFixed(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +139,44 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
return summarizeDailyRoutes(this.filteredRoutes);
|
return summarizeDailyRoutes(this.filteredRoutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get dailyGroups(): DailyRouteGroup[] {
|
||||||
|
const order = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes'];
|
||||||
|
const groups = new Map<string, DailyRouteSummary[]>();
|
||||||
|
for (const summary of this.dailySummaries) {
|
||||||
|
const routes = groups.get(summary.day) ?? [];
|
||||||
|
routes.push(summary);
|
||||||
|
groups.set(summary.day, routes);
|
||||||
|
}
|
||||||
|
return Array.from(groups, ([day, routes]) => ({
|
||||||
|
day,
|
||||||
|
routes,
|
||||||
|
totalStops: routes.reduce((sum, route) => sum + route.routes.length, 0),
|
||||||
|
totalDistance: routes.reduce((sum, route) => sum + route.distanceKm, 0)
|
||||||
|
})).sort((a, b) => order.indexOf(a.day) - order.indexOf(b.day));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleDailyRouteDay(day: string): void {
|
||||||
|
this.expandedDailyRouteDay = this.expandedDailyRouteDay === day ? null : day;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDailyRouteDayExpanded(day: string): boolean {
|
||||||
|
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,
|
||||||
@@ -112,11 +191,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
this.allRoutes = [...routes];
|
this.allRoutes = [...routes];
|
||||||
this.usuarios = this.routeState.getUsuariosUnicos();
|
this.usuarios = this.routeState.getUsuariosUnicos();
|
||||||
this.dias = this.routeState.getDiasUnicos();
|
this.dias = this.routeState.getDiasUnicos();
|
||||||
// Init from queryParams default (Todos)
|
// Start each map load with the complete planning selected by default.
|
||||||
const qp = this.activatedRoute.snapshot.queryParams;
|
const qp = this.activatedRoute.snapshot.queryParams;
|
||||||
if (qp['usuario_id']) this.selectedUsuarioId = String(qp['usuario_id']);
|
if (qp['pdv_id']) {
|
||||||
else if (!this.selectedUsuarioId && this.usuarios.length) this.selectedUsuarioId = 'Todos';
|
this.selectedUsuarioId = 'Todos';
|
||||||
if (qp['dia']) this.selectedDia = String(qp['dia']);
|
this.selectedDia = 'Todos';
|
||||||
|
}
|
||||||
// If focus pdv provided, auto-select detail after map loads
|
// If focus pdv provided, auto-select detail after map loads
|
||||||
this.applyFilter();
|
this.applyFilter();
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
@@ -135,20 +215,6 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
// React to queryParams changes
|
// React to queryParams changes
|
||||||
this.subs.push(
|
this.subs.push(
|
||||||
this.activatedRoute.queryParams.subscribe(qp => {
|
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
|
// Focus a specific route if pdv_id passed
|
||||||
if (qp['pdv_id'] && this.filteredRoutes.length) {
|
if (qp['pdv_id'] && this.filteredRoutes.length) {
|
||||||
const found = this.filteredRoutes.find(r => String(r.json.pdv_id) === String(qp['pdv_id']));
|
const found = this.filteredRoutes.find(r => String(r.json.pdv_id) === String(qp['pdv_id']));
|
||||||
@@ -225,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);
|
||||||
@@ -492,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);
|
||||||
@@ -499,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);
|
||||||
@@ -647,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] ?? []);
|
||||||
@@ -665,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();
|
||||||
@@ -769,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user