first commit
This commit is contained in:
+198
@@ -0,0 +1,198 @@
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.detail-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 340px;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
background: var(--glm-surface);
|
||||
border-left: 3px solid var(--glm-verde);
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
height: 45px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: var(--glm-surface);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
color: var(--glm-azul);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: Arial, sans-serif;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: var(--glm-verde-bg);
|
||||
border-color: var(--glm-verde);
|
||||
color: var(--glm-gris-oscuro);
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-title h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.05rem;
|
||||
color: var(--glm-azul);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background: var(--glm-verde);
|
||||
color: #ffffff;
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.map-loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--glm-acero);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--glm-gris-medio);
|
||||
border-top-color: var(--glm-verde);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: var(--glm-surface);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
border-top: 3px solid var(--glm-azul);
|
||||
}
|
||||
|
||||
.info-card h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--glm-azul);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border-bottom: 2px solid var(--glm-verde);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.info-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-list div {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--glm-gris-claro);
|
||||
}
|
||||
|
||||
.info-list div:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-list dt {
|
||||
font-weight: 600;
|
||||
color: var(--glm-azul);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-list dd {
|
||||
margin: 0;
|
||||
color: var(--glm-gris-oscuro);
|
||||
font-size: 0.9rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.custom-marker {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.marker-inner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.marker-start .marker-inner {
|
||||
background: var(--glm-azul);
|
||||
border: 3px solid #fff;
|
||||
}
|
||||
|
||||
.marker-end .marker-inner {
|
||||
background: var(--glm-verde);
|
||||
border: 3px solid #fff;
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<div id="map"></div>
|
||||
|
||||
<div class="map-loading" *ngIf="isLoading">
|
||||
<div class="spinner"></div>
|
||||
<p>Cargando ruta...</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-sidebar" *ngIf="routeData">
|
||||
<div class="sidebar-brand">
|
||||
<img src="GLM_completo.png" alt="GomezLee Marketing" class="sidebar-logo" />
|
||||
</div>
|
||||
<div class="sidebar-header">
|
||||
<button type="button" class="back-btn" (click)="goBack()">
|
||||
<i class="pi pi-arrow-left"></i> Volver
|
||||
</button>
|
||||
<div class="detail-title">
|
||||
<h2>{{ routeData.json.nombre_usuario }} → {{ routeData.json.nombre_pdv }}</h2>
|
||||
<span class="badge">{{ routeData.json.dia }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="info-card">
|
||||
<h3>Información de la ruta</h3>
|
||||
<dl class="info-list">
|
||||
<div><dt>Usuario</dt><dd>{{ routeData.json.nombre_usuario }}</dd></div>
|
||||
<div><dt>PDV</dt><dd>{{ routeData.json.nombre_pdv }}</dd></div>
|
||||
<div><dt>Día</dt><dd>{{ routeData.json.dia }}</dd></div>
|
||||
<div><dt>Distancia</dt><dd>{{ routeData.json.distancia }} km</dd></div>
|
||||
<div><dt>Duración</dt><dd>{{ (routeData.json.horas_desplazamiento * 60).toFixed(0) }} min</dd></div>
|
||||
<div><dt>Horas trabajo</dt><dd>{{ routeData.json.horas_trabajo }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="info-card">
|
||||
<h3>Coordenadas</h3>
|
||||
<dl class="info-list">
|
||||
<div><dt>Usuario (lat, lng)</dt><dd>{{ routeData.json.latitud_usuario }}, {{ routeData.json.longitud_usuario }}</dd></div>
|
||||
<div><dt>PDV (lat, lng)</dt><dd>{{ routeData.json.latitud_pdv }}, {{ routeData.json.longitud_pdv }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } 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';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-assigned-route-detail',
|
||||
imports: [CommonModule],
|
||||
templateUrl: './assigned-route-detail.component.html',
|
||||
styleUrl: './assigned-route-detail.component.css'
|
||||
})
|
||||
export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
private map!: L.Map;
|
||||
private tileLayer!: L.TileLayer;
|
||||
private routeGlow?: L.Layer;
|
||||
private routeLine?: L.Layer;
|
||||
private arrowLayer?: L.Layer;
|
||||
private markers: L.Marker[] = [];
|
||||
private sub?: Subscription;
|
||||
private destroyed = false;
|
||||
routeData: RouteAssignedResponse | null = null;
|
||||
isLoading = false;
|
||||
error: string | null = null;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private routeState: RouteState,
|
||||
private orsService: OrsService,
|
||||
private cdr: ChangeDetectorRef
|
||||
) {}
|
||||
|
||||
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';
|
||||
}
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.initMap();
|
||||
if (this.routeData) {
|
||||
this.loadAndShowRoute(this.routeData);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroyed = true;
|
||||
this.sub?.unsubscribe();
|
||||
this.clearRoute();
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private initMap() {
|
||||
this.map = L.map('map').setView([18.4861, -69.9312], 13);
|
||||
|
||||
this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(this.map);
|
||||
}
|
||||
|
||||
private async loadAndShowRoute(route: RouteAssignedResponse) {
|
||||
this.clearRoute();
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.cdr.detectChanges();
|
||||
|
||||
const json = route.json;
|
||||
const origin: [number, number] = [Number(json.longitud_usuario), Number(json.latitud_usuario)];
|
||||
const destination: [number, number] = [Number(json.longitud_pdv), Number(json.latitud_pdv)];
|
||||
|
||||
this.centerMapOnPoints(origin, destination);
|
||||
|
||||
try {
|
||||
const geojson = await this.orsService.processPairs(
|
||||
[{ pdv_id: json.pdv_id, origin, destination }],
|
||||
1,
|
||||
true
|
||||
);
|
||||
|
||||
if (this.destroyed) return;
|
||||
|
||||
if (geojson[0] && !geojson[0].error) {
|
||||
this.showRouteOnMap(geojson[0], origin, destination, json);
|
||||
} else {
|
||||
this.showFallbackRoute(origin, destination, json);
|
||||
}
|
||||
} catch {
|
||||
this.showFallbackRoute(origin, destination, json);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
private centerMapOnPoints(origin: [number, number], destination: [number, number]) {
|
||||
const bounds = L.latLngBounds([
|
||||
L.latLng(origin[1], origin[0]),
|
||||
L.latLng(destination[1], destination[0])
|
||||
]);
|
||||
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(
|
||||
(c: number[]) => [c[1], c[0]] as L.LatLngExpression
|
||||
);
|
||||
}
|
||||
if (geojson.type === 'LineString') {
|
||||
return geojson.coordinates.map(
|
||||
(c: number[]) => [c[1], c[0]] as L.LatLngExpression
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private showRouteOnMap(geojson: any, origin: [number, number], destination: [number, number], json: any) {
|
||||
const coords = this.extractCoords(geojson);
|
||||
if (!coords.length) {
|
||||
this.showFallbackRoute(origin, destination, json);
|
||||
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';
|
||||
|
||||
this.routeGlow = L.polyline(coords, {
|
||||
color: '#6CC24A',
|
||||
weight: 12,
|
||||
opacity: 0.35,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round'
|
||||
}).addTo(this.map);
|
||||
|
||||
this.routeLine = L.polyline(coords, {
|
||||
color: '#6CC24A',
|
||||
weight: 6,
|
||||
opacity: 0.95,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round'
|
||||
}).addTo(this.map);
|
||||
|
||||
this.arrowLayer = L.polyline(coords, {
|
||||
color: '#ffffff',
|
||||
weight: 4,
|
||||
opacity: 0.7,
|
||||
dashArray: '2, 16',
|
||||
dashOffset: '0',
|
||||
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.addMarkers(origin, destination, json, distance, duration);
|
||||
const group = new L.FeatureGroup([
|
||||
...(this.routeGlow ? [this.routeGlow] : []),
|
||||
...(this.routeLine ? [this.routeLine] : []),
|
||||
...this.markers
|
||||
]);
|
||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
|
||||
private showFallbackRoute(origin: [number, number], destination: [number, number], json: any) {
|
||||
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');
|
||||
const group = new L.FeatureGroup([this.routeLine, ...this.markers]);
|
||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
|
||||
private addMarkers(
|
||||
origin: [number, number],
|
||||
destination: [number, number],
|
||||
json: any,
|
||||
distance: string,
|
||||
duration: string
|
||||
) {
|
||||
const startIcon = L.divIcon({
|
||||
className: 'custom-marker marker-start',
|
||||
html: '<div class="marker-inner">A</div>',
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
popupAnchor: [0, -18]
|
||||
});
|
||||
|
||||
const endIcon = L.divIcon({
|
||||
className: 'custom-marker marker-end',
|
||||
html: '<div class="marker-inner">B</div>',
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
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 })
|
||||
.addTo(this.map)
|
||||
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
|
||||
);
|
||||
}
|
||||
|
||||
private clearRoute() {
|
||||
if (this.routeGlow) {
|
||||
this.map.removeLayer(this.routeGlow);
|
||||
this.routeGlow = undefined;
|
||||
}
|
||||
if (this.routeLine) {
|
||||
this.map.removeLayer(this.routeLine);
|
||||
this.routeLine = undefined;
|
||||
}
|
||||
if (this.arrowLayer) {
|
||||
this.map.removeLayer(this.arrowLayer);
|
||||
this.arrowLayer = undefined;
|
||||
}
|
||||
this.markers.forEach(m => this.map.removeLayer(m));
|
||||
this.markers = [];
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.router.navigate(['/home']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user