From 8f34462876ebe1df939e4a75b4665cf1591cbc3e Mon Sep 17 00:00:00 2001 From: EidanThen Date: Thu, 3 Sep 2026 17:26:56 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20lista=20de=20empleados=20?= =?UTF-8?q?con=20carga=20al=20reasignar=20ruta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- route-planner/angular.json | 2 +- .../planning-kpis/planning-kpis.component.css | 4 - .../planning-kpis.component.html | 5 +- .../route-overview-map.component.css | 178 ++++++------------ .../route-overview-map.component.html | 60 +++--- .../route-overview-map.component.ts | 116 +++++++++--- route-planner/src/styles.css | 6 + 7 files changed, 199 insertions(+), 172 deletions(-) diff --git a/route-planner/angular.json b/route-planner/angular.json index db8aa6d..76c3c59 100644 --- a/route-planner/angular.json +++ b/route-planner/angular.json @@ -30,7 +30,7 @@ "production": { "budgets": [ { "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" }, - { "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "10kB" } + { "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "12kB" } ], "outputHashing": "all", "fileReplacements": [ diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css index fa3450d..9a322d9 100644 --- a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.css @@ -130,10 +130,6 @@ white-space: nowrap; } -.operational-grid { - margin-top: 12px; -} - .user-load-card { margin-top: 14px; padding: 12px 14px; diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html index de01728..a862d59 100644 --- a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html @@ -31,9 +31,7 @@
Distancia total{{ summary.totalDistance | number:'1.0-1' }} km{{ summary.averageDistance | number:'1.0-1' }} km promedio por ruta
- - -
+
Carga de trabajo{{ summary.totalWorkHours | number:'1.0-1' }} hhoras de trabajo planificadas
@@ -50,6 +48,7 @@
Brecha de balance{{ summary.assignmentBalance }}diferencia entre mayor y menor carga
+
diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css index 70a5a87..f63f956 100644 --- a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css @@ -126,6 +126,8 @@ 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 { text-align: center; padding: 24px 12px; @@ -151,20 +153,45 @@ color: var(--glm-gris-oscuro); } -.unassigned-map-card { - padding: 12px; - background: #fff8ed; +.unassigned-bottom-drawer { + position: fixed; + left: 398px; + right: 18px; + bottom: 18px; + z-index: 950; + overflow: hidden; border: 1px solid #f2bf86; - border-left: 3px solid var(--glm-naranja); - border-radius: 4px; + border-left: 4px solid var(--glm-naranja); + 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; - gap: 5px; - max-height: 180px; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 8px; + max-height: 240px; + padding: 0 12px 12px; overflow-y: auto; } @@ -219,86 +246,21 @@ .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; -} +.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; } +.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; } +.employee-load-row:hover, .employee-load-row.selected { border-color: var(--glm-verde); background: var(--glm-verde-bg); } +.employee-load-row.over-capacity { border-color: #c9252d; background: var(--glm-rojo-ok); } +.employee-load-name { overflow: hidden; color: var(--glm-azul); font-size: 0.78rem; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } +.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 { - 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); +@media (max-width: 900px) { + #map-overview { left: 0; } + .overview-sidebar { width: 340px; max-width: calc(100vw - 24px); } + .unassigned-bottom-drawer { right: 12px; bottom: 12px; left: 12px; } } .info-card { @@ -309,6 +271,12 @@ border-top: 3px solid var(--glm-azul); } +.map-location-button { + margin-top: 0; + padding: 6px 9px; + font-size: 0.75rem; +} + .info-card-header { display: flex; justify-content: space-between; @@ -353,7 +321,7 @@ .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; } +.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 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 span, .daily-route small { color: var(--glm-acero); } .daily-route.over-capacity small { color: #c9252d; font-weight: 700; } -.routes-list li:focus-visible, .btn-icon:focus-visible, button:focus-visible, select:focus-visible { outline: 3px solid rgba(108, 194, 74, 0.45); outline-offset: 2px; } +.btn-icon:focus-visible, button:focus-visible, select:focus-visible { outline: 3px solid rgba(108, 194, 74, 0.45); outline-offset: 2px; } .btn-icon { background: transparent; @@ -393,6 +361,7 @@ .info-list div { display: grid; grid-template-columns: auto 1fr; + align-items: center; gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--glm-gris-claro); @@ -481,35 +450,6 @@ 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; diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html index fcb4feb..d1f9d85 100644 --- a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html @@ -40,11 +40,8 @@ -
- - -
@@ -63,18 +60,6 @@ {{ error }}
-
-
-

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

-
-
- -
-
-

Gestionar PDV sin ruta

@@ -96,11 +81,18 @@
Motivo
{{ selectedUnassigned.motivo }}
Coordenadas
{{ selectedUnassigned.latitud }}, {{ selectedUnassigned.longitud }}
-
- - -
-
+
+ + +
+
+
+ +
{{ unassignedActionError }}
@@ -129,7 +121,7 @@
Día
{{ selectedRouteDetail.json.dia }}
Orden de visita
{{ selectedVisitOrder }}
Origen del tramo
{{ selectedSegmentOrigin }}
-
Destino
{{ selectedRouteDetail.json.latitud_pdv }}, {{ selectedRouteDetail.json.longitud_pdv }}
+
Destino
Horas trabajo
{{ selectedRouteDetail.json.horas_trabajo }}
Distancia tramo
{{ selectedSegmentDistance | number:'1.2-2' }} km
Duración tramo
{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min
@@ -145,6 +137,13 @@

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

+
+ +
{{ reassignError }}
@@ -155,12 +154,12 @@
{{ route.userName }} - {{ route.routes.length }} paradas · {{ route.distanceKm | number:'1.1-1' }} km · {{ (route.travelHours * 60) | number:'1.0-0' }} min + {{ route.routes.length }} paradas · {{ route.distanceKm | number:'1.1-1' }} km {{ route.totalHours | number:'1.1-1' }} / {{ route.capacityHours | number:'1.1-1' }} h {{ route.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}
@@ -168,3 +167,16 @@ + +
+ +
+ +
+
diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts index 566aa67..bee2ef3 100644 --- a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts @@ -16,7 +16,14 @@ interface DailyRouteGroup { routes: DailyRouteSummary[]; totalStops: number; totalDistance: number; - totalTravelHours: number; +} + +interface EmployeeLoadOption { + id: string; + name: string; + workHours: number; + capacityHours: number; + utilization: number; } interface MapRoute { @@ -53,7 +60,6 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn 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(); @@ -72,6 +78,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn orsTotal = 0; error: string | null = null; unassignedRoutes: RouteNoAssignedResponse[] = []; + isUnassignedDrawerOpen = false; selectedUnassigned: RouteNoAssignedResponse | null = null; editingUnassigned: RouteNoAssignedResponse | null = null; editUnassignedName = ''; @@ -144,8 +151,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn day, routes, totalStops: routes.reduce((sum, route) => sum + route.routes.length, 0), - totalDistance: routes.reduce((sum, route) => sum + route.distanceKm, 0), - totalTravelHours: routes.reduce((sum, route) => sum + route.travelHours, 0) + totalDistance: routes.reduce((sum, route) => sum + route.distanceKm, 0) })).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; } + 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( private routeState: RouteState, private orsService: OrsService, @@ -271,7 +291,11 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn private initMap() { this.map = L.map('map-overview').setView([18.4861, -69.9312], 10); 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', { attribution: '© OpenStreetMap contributors' }).addTo(this.map); @@ -538,6 +562,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn } private selectRoute(mr: MapRoute) { + this.selectedUnassigned = null; + this.editingUnassigned = null; + this.unassignedActionError = null; this.selectedRouteDetail = mr.data; this.highlightRoute(mr.data); this.fitSelectedRoute(mr); @@ -545,6 +572,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn } selectRouteFromList(route: RouteAssignedResponse): void { + this.selectedUnassigned = null; + this.editingUnassigned = null; + this.unassignedActionError = null; const mapRoute = this.mapRoutes.find(mr => this.sameRoute(mr.data, route)); if (mapRoute) { this.selectRoute(mapRoute); @@ -693,14 +723,62 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn left.json.dia === right.json.dia; } + private getEmployeeLoads(excluding?: RouteAssignedResponse): EmployeeLoadOption[] { + const profiles = new Map(); + const workHours = new Map(); + 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 }[] { 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[] { 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 { const key = String(pdvId); const days = new Set(this.assignmentDays[key] ?? []); @@ -711,6 +789,8 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn selectUnassigned(route: RouteNoAssignedResponse): void { this.selectedUnassigned = route; this.selectedRouteDetail = null; + this.showReassignForm = false; + this.reassignError = null; this.map?.setView([route.latitud, route.longitud], Math.max(this.map.getZoom(), 14)); this.unassignedMarkers.get(route.pdv_id)?.openPopup(); this.cdr.detectChanges(); @@ -815,13 +895,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn } 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; + if (isNaN(v)) return 1; 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); - if (max === min) return 0.8; + if (max === min) return 1; 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; 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' } + pane: 'heatPane', + 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); } @@ -848,16 +932,6 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn 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 }); diff --git a/route-planner/src/styles.css b/route-planner/src/styles.css index 2ce578c..89ad4b2 100644 --- a/route-planner/src/styles.css +++ b/route-planner/src/styles.css @@ -40,6 +40,12 @@ 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 { --glm-verde: #6CC24A; --glm-verde-claro: #A4D65E; -- 2.52.0