feat: implement route planning dashboard including KPIs, user workload tracking, and route assignment management features
This commit is contained in:
+89
-35
@@ -1,11 +1,12 @@
|
||||
import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
import * as L from 'leaflet';
|
||||
import { RouteState } from '../../../core/route-state/route';
|
||||
import { OrsService } from '../../../core/services/ors.service';
|
||||
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
||||
import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -26,9 +27,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
routeData: RouteAssignedResponse | null = null;
|
||||
isLoading = false;
|
||||
error: string | null = null;
|
||||
segmentOriginName = '';
|
||||
visitOrder = 0;
|
||||
totalStops = 0;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private routeState: RouteState,
|
||||
private orsService: OrsService,
|
||||
private cdr: ChangeDetectorRef
|
||||
@@ -36,11 +41,30 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
|
||||
ngOnInit() {
|
||||
this.sub = this.routeState.selectedRoute$.subscribe((route: RouteAssignedResponse | null) => {
|
||||
this.routeData = route;
|
||||
if (route && this.map) {
|
||||
this.loadAndShowRoute(route);
|
||||
} else if (!route) {
|
||||
this.error = 'No hay ruta seleccionada';
|
||||
if (route) {
|
||||
this.routeData = route;
|
||||
if (this.map) {
|
||||
this.loadAndShowRoute(route);
|
||||
}
|
||||
this.error = null;
|
||||
} else {
|
||||
// Try to resolve from queryParams + sessionStorage snapshot
|
||||
const qp = this.activatedRoute.snapshot.queryParams;
|
||||
const pdvId = qp['pdv_id'];
|
||||
const usuarioId = qp['usuario_id'];
|
||||
if (pdvId) {
|
||||
const found = this.routeState.findRoute(pdvId, usuarioId);
|
||||
if (found) {
|
||||
this.routeData = found;
|
||||
if (this.map) this.loadAndShowRoute(found);
|
||||
this.error = null;
|
||||
} else {
|
||||
// No data at all (e.g., direct URL after hard refresh with empty storage)
|
||||
this.error = 'No hay ruta seleccionada. Vuelve al listado y selecciona una ruta.';
|
||||
}
|
||||
} else if (!this.routeData) {
|
||||
this.error = 'No hay ruta seleccionada';
|
||||
}
|
||||
}
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
@@ -50,6 +74,18 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
this.initMap();
|
||||
if (this.routeData) {
|
||||
this.loadAndShowRoute(this.routeData);
|
||||
} else {
|
||||
// Re-attempt resolution after map init (queryParams case)
|
||||
const qp = this.activatedRoute.snapshot.queryParams;
|
||||
if (qp['pdv_id']) {
|
||||
const found = this.routeState.findRoute(qp['pdv_id'], qp['usuario_id']);
|
||||
if (found) {
|
||||
this.routeData = found;
|
||||
this.error = null;
|
||||
this.loadAndShowRoute(found);
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +100,8 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
|
||||
private initMap() {
|
||||
this.map = L.map('map').setView([18.4861, -69.9312], 13);
|
||||
const pdvPane = this.map.createPane('pdvPane');
|
||||
pdvPane.style.zIndex = '650';
|
||||
|
||||
this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
@@ -77,13 +115,20 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
this.cdr.detectChanges();
|
||||
|
||||
const json = route.json;
|
||||
const origin: [number, number] = [Number(json.longitud_usuario), Number(json.latitud_usuario)];
|
||||
const sequence = sequenceRoutes(this.routeState.getAssignedSnapshot().filter(item =>
|
||||
String(item.json.usuario_id) === String(json.usuario_id) && item.json.dia === json.dia
|
||||
));
|
||||
const segment = sequence.find(item => item.route === route) ?? sequence.find(item => item.route.json.pdv_id === json.pdv_id);
|
||||
const origin: [number, number] = segment?.origin ?? [Number(json.longitud_usuario), Number(json.latitud_usuario)];
|
||||
const destination: [number, number] = [Number(json.longitud_pdv), Number(json.latitud_pdv)];
|
||||
this.segmentOriginName = segment?.previousPdvName ?? `Casa de ${json.nombre_usuario}`;
|
||||
this.visitOrder = segment?.order ?? 1;
|
||||
this.totalStops = segment?.totalStops ?? 1;
|
||||
|
||||
this.centerMapOnPoints(origin, destination);
|
||||
|
||||
try {
|
||||
const geojson = await this.orsService.processPairs(
|
||||
const results = await this.orsService.processPairs(
|
||||
[{ pdv_id: json.pdv_id, origin, destination }],
|
||||
1,
|
||||
true
|
||||
@@ -91,13 +136,14 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
|
||||
if (this.destroyed) return;
|
||||
|
||||
if (geojson[0] && !geojson[0].error) {
|
||||
this.showRouteOnMap(geojson[0], origin, destination, json);
|
||||
const result = results[0] as { error?: string; geojson?: unknown } | undefined;
|
||||
if (result && !result.error && result.geojson) {
|
||||
this.showRouteOnMap(result.geojson, origin, destination, json, this.segmentOriginName);
|
||||
} else {
|
||||
this.showFallbackRoute(origin, destination, json);
|
||||
this.showFallbackRoute(origin, destination, json, this.segmentOriginName);
|
||||
}
|
||||
} catch {
|
||||
this.showFallbackRoute(origin, destination, json);
|
||||
this.showFallbackRoute(origin, destination, json, this.segmentOriginName);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
@@ -112,31 +158,38 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
this.map.fitBounds(bounds.pad(0.3));
|
||||
}
|
||||
|
||||
private extractCoords(geojson: any): L.LatLngExpression[] {
|
||||
if (geojson.type === 'FeatureCollection' && geojson.features?.length) {
|
||||
return geojson.features[0].geometry.coordinates.map(
|
||||
private extractCoords(geojson: unknown): L.LatLngExpression[] {
|
||||
const g = geojson as { type: string; features?: { geometry: { coordinates: number[][] } }[]; coordinates?: number[][] };
|
||||
if (g.type === 'FeatureCollection' && g.features?.length) {
|
||||
return g.features[0].geometry.coordinates.map(
|
||||
(c: number[]) => [c[1], c[0]] as L.LatLngExpression
|
||||
);
|
||||
}
|
||||
if (geojson.type === 'LineString') {
|
||||
return geojson.coordinates.map(
|
||||
if (g.type === 'LineString' && g.coordinates) {
|
||||
return g.coordinates.map(
|
||||
(c: number[]) => [c[1], c[0]] as L.LatLngExpression
|
||||
);
|
||||
}
|
||||
// ORS geojson response wrapped
|
||||
const maybeFeature = (geojson as { features?: { geometry: { coordinates: number[][] } }[] })?.features;
|
||||
if (maybeFeature?.length) {
|
||||
return maybeFeature[0].geometry.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private showRouteOnMap(geojson: any, origin: [number, number], destination: [number, number], json: any) {
|
||||
private showRouteOnMap(geojson: unknown, origin: [number, number], destination: [number, number], json: RouteAssignedResponse['json'], originName: string) {
|
||||
const coords = this.extractCoords(geojson);
|
||||
if (!coords.length) {
|
||||
this.showFallbackRoute(origin, destination, json);
|
||||
this.showFallbackRoute(origin, destination, json, originName);
|
||||
return;
|
||||
}
|
||||
|
||||
const distance = geojson.features?.[0]?.properties?.summary?.distance
|
||||
? (geojson.features[0].properties.summary.distance / 1000).toFixed(2) : 'N/A';
|
||||
const duration = geojson.features?.[0]?.properties?.summary?.duration
|
||||
? (geojson.features[0].properties.summary.duration / 60).toFixed(0) : 'N/A';
|
||||
const g = geojson as { features?: { properties?: { summary?: { distance: number; duration: number } } }[] };
|
||||
const distance = g.features?.[0]?.properties?.summary?.distance
|
||||
? (g.features[0].properties.summary.distance / 1000).toFixed(2) : 'N/A';
|
||||
const duration = g.features?.[0]?.properties?.summary?.duration
|
||||
? (g.features[0].properties.summary.duration / 60).toFixed(0) : 'N/A';
|
||||
|
||||
this.routeGlow = L.polyline(coords, {
|
||||
color: '#6CC24A',
|
||||
@@ -163,11 +216,11 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
lineCap: 'round'
|
||||
}).addTo(this.map);
|
||||
|
||||
this.routeLine.bindPopup(
|
||||
`<strong style="color:#4F758B">${json.nombre_usuario} → ${json.nombre_pdv}</strong><br>Distancia: ${distance} km<br>Duración: ${duration} min`
|
||||
(this.routeLine as L.Polyline).bindPopup(
|
||||
`<strong style="color:#4F758B">${originName} → ${json.nombre_pdv}</strong><br>Distancia: ${distance} km<br>Duración: ${duration} min`
|
||||
);
|
||||
|
||||
this.addMarkers(origin, destination, json, distance, duration);
|
||||
this.addMarkers(origin, destination, json, distance, duration, originName);
|
||||
const group = new L.FeatureGroup([
|
||||
...(this.routeGlow ? [this.routeGlow] : []),
|
||||
...(this.routeLine ? [this.routeLine] : []),
|
||||
@@ -176,13 +229,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
|
||||
private showFallbackRoute(origin: [number, number], destination: [number, number], json: any) {
|
||||
private showFallbackRoute(origin: [number, number], destination: [number, number], json: RouteAssignedResponse['json'], originName: string) {
|
||||
this.routeLine = L.polyline(
|
||||
[[origin[1], origin[0]], [destination[1], destination[0]]],
|
||||
{ color: '#FF6A13', weight: 5, dashArray: '12, 10', opacity: 0.9 }
|
||||
).addTo(this.map);
|
||||
|
||||
this.addMarkers(origin, destination, json, 'N/A', 'N/A');
|
||||
this.addMarkers(origin, destination, json, 'N/A', 'N/A', originName);
|
||||
const group = new L.FeatureGroup([this.routeLine, ...this.markers]);
|
||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
@@ -190,9 +243,10 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
private addMarkers(
|
||||
origin: [number, number],
|
||||
destination: [number, number],
|
||||
json: any,
|
||||
json: RouteAssignedResponse['json'],
|
||||
distance: string,
|
||||
duration: string
|
||||
duration: string,
|
||||
originName: string
|
||||
) {
|
||||
const startIcon = L.divIcon({
|
||||
className: 'custom-marker marker-start',
|
||||
@@ -204,17 +258,17 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
|
||||
const endIcon = L.divIcon({
|
||||
className: 'custom-marker marker-end',
|
||||
html: '<div class="marker-inner">B</div>',
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
html: '<div class="location-pin"><span></span></div>',
|
||||
iconSize: [30, 38],
|
||||
iconAnchor: [15, 38],
|
||||
popupAnchor: [0, -18]
|
||||
});
|
||||
|
||||
this.markers.push(
|
||||
L.marker([origin[1], origin[0]], { icon: startIcon })
|
||||
.addTo(this.map)
|
||||
.bindPopup(`<strong>Origen:</strong> ${json.nombre_usuario}`),
|
||||
L.marker([destination[1], destination[0]], { icon: endIcon })
|
||||
.bindPopup(`<strong>Origen del tramo:</strong> ${originName}`),
|
||||
L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
||||
.addTo(this.map)
|
||||
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user