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 }}
-
-
-
-
-
-
-
@@ -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;