1008 lines
39 KiB
TypeScript
1008 lines
39 KiB
TypeScript
import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { Router, ActivatedRoute } from '@angular/router';
|
|
import { Subscription, Subject } from 'rxjs';
|
|
import * as L from 'leaflet';
|
|
import 'leaflet.heat';
|
|
import { RouteState, UnassignedAssignment } from '../../../core/route-state/route';
|
|
import { OrsService } from '../../../core/services/ors.service';
|
|
import { RouteAssignedResponse, RouteNoAssignedResponse } from '../../../core/models/route.model';
|
|
import { colorForUsuario } from '../../../shared/utils/palette.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 {
|
|
data: RouteAssignedResponse;
|
|
color: string;
|
|
origin: [number, number];
|
|
destination: [number, number];
|
|
visitOrder: number;
|
|
totalStops: number;
|
|
previousPdvName: string | null;
|
|
requestId: number;
|
|
segmentDistanceKm?: number;
|
|
segmentDurationHours?: number;
|
|
fallbackLine?: L.Polyline;
|
|
orsLine?: L.Polyline;
|
|
orsGlow?: L.Polyline;
|
|
marker?: L.Marker;
|
|
startMarker?: L.Marker;
|
|
}
|
|
|
|
@Component({
|
|
standalone: true,
|
|
selector: 'app-route-overview-map',
|
|
imports: [CommonModule, FormsModule],
|
|
templateUrl: './route-overview-map.component.html',
|
|
styleUrls: ['./route-overview-map.component.css']
|
|
})
|
|
export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewInit {
|
|
private map?: L.Map;
|
|
private abort$ = new Subject<void>();
|
|
private subs: Subscription[] = [];
|
|
private mapRoutes: MapRoute[] = [];
|
|
private startMarkers: L.Marker[] = [];
|
|
private selectedMapRoute: MapRoute | null = null;
|
|
private heatLayer?: any;
|
|
showHeat = true;
|
|
private heatPointsCache: [number, number, number][] = [];
|
|
private unassignedMarkers = new Map<number, L.Marker>();
|
|
|
|
usuarios: { id: string; nombre: string }[] = [];
|
|
dias: string[] = [];
|
|
selectedUsuarioId: string = 'Todos';
|
|
selectedDia: string = 'Todos';
|
|
expandedDailyRouteDay: string | null = null;
|
|
selectedRouteDetail: RouteAssignedResponse | null = null;
|
|
|
|
filteredRoutes: RouteAssignedResponse[] = [];
|
|
allRoutes: RouteAssignedResponse[] = [];
|
|
|
|
isLoading = false;
|
|
orsProgress = 0;
|
|
orsTotal = 0;
|
|
error: string | null = null;
|
|
unassignedRoutes: RouteNoAssignedResponse[] = [];
|
|
isUnassignedDrawerOpen = false;
|
|
selectedUnassigned: RouteNoAssignedResponse | null = null;
|
|
editingUnassigned: RouteNoAssignedResponse | null = null;
|
|
editUnassignedName = '';
|
|
editUnassignedHours = 0;
|
|
editUnassignedLat = 0;
|
|
editUnassignedLng = 0;
|
|
editUnassignedReason = '';
|
|
assignmentUser: Record<string, string> = {};
|
|
assignmentDays: Record<string, string[]> = {};
|
|
assigningUnassignedId: number | null = null;
|
|
unassignedActionError: string | null = null;
|
|
reassigningRoute = false;
|
|
showReassignForm = false;
|
|
reassignUserId = '';
|
|
reassignDay = '';
|
|
reassignError: string | null = null;
|
|
|
|
get activeTerritories(): string[] {
|
|
const values = [
|
|
...this.allRoutes.map(route => route.json.metro || route.json.ciudad || route.json.estado || route.json.provincia || route.json.region || route.json.zona),
|
|
...this.unassignedRoutes.map(route => route.metro || route.ciudad || route.estado || route.provincia || route.region || route.zona)
|
|
].filter((value): value is string => !!value?.trim());
|
|
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
}
|
|
|
|
// 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 {
|
|
const uniqueIds = new Set(this.uniqueFilteredRoutes.map(route => String(route.json.pdv_id)));
|
|
const sum = this.mapRoutes.length
|
|
? this.mapRoutes
|
|
.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);
|
|
}
|
|
get totalDuracionMin(): string {
|
|
const uniqueIds = new Set(this.uniqueFilteredRoutes.map(route => String(route.json.pdv_id)));
|
|
const sum = this.mapRoutes.length
|
|
? this.mapRoutes
|
|
.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);
|
|
}
|
|
|
|
get dailySummaries(): DailyRouteSummary[] {
|
|
return summarizeDailyRoutes(this.filteredRoutes);
|
|
}
|
|
|
|
get dailyGroups(): DailyRouteGroup[] {
|
|
const order = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes', 'Sábado', 'Sabado'];
|
|
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(
|
|
private routeState: RouteState,
|
|
private orsService: OrsService,
|
|
private router: Router,
|
|
private activatedRoute: ActivatedRoute,
|
|
private cdr: ChangeDetectorRef
|
|
) {}
|
|
|
|
ngOnInit() {
|
|
this.subs.push(
|
|
this.routeState.assignedRoutes$.subscribe(routes => {
|
|
this.allRoutes = [...routes];
|
|
this.usuarios = this.routeState.getUsuariosUnicos();
|
|
this.dias = this.routeState.getDiasUnicos();
|
|
// Start each map load with the complete planning selected by default.
|
|
const qp = this.activatedRoute.snapshot.queryParams;
|
|
if (qp['pdv_id']) {
|
|
this.selectedUsuarioId = 'Todos';
|
|
this.selectedDia = 'Todos';
|
|
}
|
|
// If focus pdv provided, auto-select detail after map loads
|
|
this.applyFilter();
|
|
this.cdr.detectChanges();
|
|
if (this.map) this.renderFiltered();
|
|
})
|
|
);
|
|
|
|
this.subs.push(
|
|
this.routeState.noAssignedRoutes$.subscribe(routes => {
|
|
this.unassignedRoutes = [...routes];
|
|
if (this.map) this.renderFiltered();
|
|
this.cdr.detectChanges();
|
|
})
|
|
);
|
|
|
|
// React to queryParams changes
|
|
this.subs.push(
|
|
this.activatedRoute.queryParams.subscribe(qp => {
|
|
// Focus a specific route if pdv_id passed
|
|
if (qp['pdv_id'] && this.filteredRoutes.length) {
|
|
const found = this.filteredRoutes.find(r => String(r.json.pdv_id) === String(qp['pdv_id']));
|
|
if (found) {
|
|
this.selectedRouteDetail = found;
|
|
this.highlightRoute(found);
|
|
}
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
ngAfterViewInit() {
|
|
this.initMap();
|
|
this.applyFilter();
|
|
// Defer render to ensure container has size (fixes white tiles)
|
|
setTimeout(() => {
|
|
this.map?.invalidateSize();
|
|
this.renderFiltered();
|
|
}, 100);
|
|
}
|
|
|
|
ngOnDestroy() {
|
|
this.abort$.next();
|
|
this.abort$.complete();
|
|
this.subs.forEach(s => s.unsubscribe());
|
|
const onResize = (this as any)._onResize;
|
|
if (onResize) window.removeEventListener('resize', onResize);
|
|
this.clearMap();
|
|
if (this.map) this.map.remove();
|
|
}
|
|
|
|
onUsuarioChange() {
|
|
this.updateQueryParams();
|
|
this.applyFilter();
|
|
this.renderFiltered();
|
|
}
|
|
|
|
onDiaChange() {
|
|
this.updateQueryParams();
|
|
this.applyFilter();
|
|
this.renderFiltered();
|
|
}
|
|
|
|
private updateQueryParams() {
|
|
this.router.navigate([], {
|
|
relativeTo: this.activatedRoute,
|
|
queryParams: { usuario_id: this.selectedUsuarioId, dia: this.selectedDia },
|
|
queryParamsHandling: 'merge',
|
|
replaceUrl: true
|
|
});
|
|
}
|
|
|
|
private applyFilter() {
|
|
let list = this.selectedUsuarioId === 'Todos'
|
|
? [...this.allRoutes]
|
|
: this.allRoutes.filter(r => String(r.json.usuario_id) === String(this.selectedUsuarioId));
|
|
if (this.selectedDia !== 'Todos') {
|
|
list = list.filter(r => r.json.dia === this.selectedDia);
|
|
}
|
|
this.filteredRoutes = list;
|
|
if (this.filteredRoutes.length === 0) {
|
|
this.error = this.allRoutes.length === 0 ? 'No hay rutas cargadas. Sube archivos en Home.' : 'Sin rutas para este usuario/día.';
|
|
} else {
|
|
this.error = null;
|
|
}
|
|
// Clear selected detail if not in filtered
|
|
if (this.selectedRouteDetail && !this.filteredRoutes.some(route => this.sameRoute(route, this.selectedRouteDetail!))) {
|
|
this.selectedRouteDetail = null;
|
|
this.selectedMapRoute = null;
|
|
}
|
|
}
|
|
|
|
private initMap() {
|
|
const territoryBounds = this.projectBounds();
|
|
this.map = L.map('map-overview', { maxBoundsViscosity: 0.85 });
|
|
if (territoryBounds) {
|
|
this.map.fitBounds(territoryBounds.pad(0.15));
|
|
this.map.setMaxBounds(territoryBounds.pad(0.35));
|
|
} else {
|
|
this.map.setView([18.4861, -69.9312], 10);
|
|
}
|
|
const pdvPane = this.map.createPane('pdvPane');
|
|
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',
|
|
bounds: territoryBounds?.pad(0.35),
|
|
keepBuffer: 1,
|
|
noWrap: true,
|
|
updateWhenIdle: true
|
|
}).addTo(this.map);
|
|
// Fix tiles after sidebar overlay
|
|
setTimeout(() => this.map?.invalidateSize(), 200);
|
|
// Resize handler
|
|
const onResize = () => this.map?.invalidateSize();
|
|
window.addEventListener('resize', onResize);
|
|
// store for cleanup
|
|
(this as any)._onResize = onResize;
|
|
}
|
|
|
|
private clearMap() {
|
|
for (const mr of this.mapRoutes) {
|
|
if (mr.fallbackLine) this.map?.removeLayer(mr.fallbackLine);
|
|
if (mr.orsLine) this.map?.removeLayer(mr.orsLine);
|
|
if (mr.orsGlow) this.map?.removeLayer(mr.orsGlow);
|
|
if (mr.marker) this.map?.removeLayer(mr.marker);
|
|
}
|
|
this.unassignedMarkers.forEach(marker => this.map?.removeLayer(marker));
|
|
this.unassignedMarkers.clear();
|
|
if (this.heatLayer) {
|
|
this.map?.removeLayer(this.heatLayer);
|
|
this.heatLayer = undefined;
|
|
}
|
|
for (const m of this.startMarkers) this.map?.removeLayer(m);
|
|
this.heatPointsCache = [];
|
|
this.mapRoutes = [];
|
|
this.startMarkers = [];
|
|
this.selectedMapRoute = null;
|
|
}
|
|
|
|
private async renderFiltered() {
|
|
if (!this.map) return;
|
|
this.abort$.next();
|
|
this.clearMap();
|
|
this.isLoading = false;
|
|
this.orsProgress = 0;
|
|
this.orsTotal = 0;
|
|
if (!this.filteredRoutes.length) {
|
|
this.renderUnassignedMarkers();
|
|
const territoryBounds = this.projectBounds();
|
|
if (territoryBounds) this.map.fitBounds(territoryBounds.pad(0.15));
|
|
this.cdr.detectChanges();
|
|
return;
|
|
}
|
|
|
|
// Add one colored start marker per user in the global view.
|
|
const starts = new Map<string, RouteAssignedResponse['json']>();
|
|
this.filteredRoutes.forEach(route => starts.set(String(route.json.usuario_id), route.json));
|
|
starts.forEach((start, userId) => {
|
|
const color = colorForUsuario(userId).color;
|
|
const count = this.filteredRoutes.filter(route => String(route.json.usuario_id) === userId).length;
|
|
const startIcon = L.divIcon({
|
|
className: 'custom-marker marker-start',
|
|
html: `<div class="marker-inner" style="background:${color};border:3px solid #fff;">A</div>`,
|
|
iconSize: [34, 34],
|
|
iconAnchor: [17, 17],
|
|
popupAnchor: [0, -18]
|
|
});
|
|
this.startMarkers.push(
|
|
L.marker([Number(start.latitud_usuario), Number(start.longitud_usuario)], { icon: startIcon, zIndexOffset: 100 })
|
|
.addTo(this.map!)
|
|
.bindPopup(`<strong>Usuario:</strong> ${start.nombre_usuario}<br><strong>PDVs:</strong> ${count}`)
|
|
);
|
|
});
|
|
|
|
// Build one daily chain per user: home -> nearest PDV -> next nearest PDV.
|
|
const bounds: L.LatLngExpression[] = [];
|
|
const sequencedRoutes = sequenceRoutes(this.filteredRoutes);
|
|
this.filteredRoutes = sequencedRoutes.map(segment => segment.route);
|
|
sequencedRoutes.forEach((segment, idx) => {
|
|
const route = segment.route;
|
|
const j = route.json;
|
|
const color = colorForUsuario(j.usuario_id).color;
|
|
const origin = segment.origin;
|
|
const dest = segment.destination;
|
|
bounds.push([origin[1], origin[0]]);
|
|
bounds.push([Number(j.latitud_pdv), Number(j.longitud_pdv)]);
|
|
|
|
// Fallback dashed line (visible immediately)
|
|
const fallbackLine = L.polyline(
|
|
[[origin[1], origin[0]], [dest[1], dest[0]]],
|
|
{ color, weight: 4, dashArray: '10, 10', opacity: 0.6 }
|
|
).addTo(this.map!);
|
|
|
|
// Marker for PDV
|
|
const pdvIcon = L.divIcon({
|
|
className: 'custom-marker marker-end',
|
|
html: `<div class="location-pin-wrapper"><div class="location-pin"><span>${segment.order}</span></div></div>`,
|
|
iconSize: [42, 50],
|
|
iconAnchor: [21, 46],
|
|
popupAnchor: [0, -18]
|
|
});
|
|
const marker = L.marker([Number(j.latitud_pdv), Number(j.longitud_pdv)], { icon: pdvIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
|
.addTo(this.map!)
|
|
.bindPopup(`<strong>${j.nombre_pdv}</strong><br><strong>Usuario:</strong> ${j.nombre_usuario}<br>Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`);
|
|
// Heat point
|
|
const w = this.computeWeight(j);
|
|
this.heatPointsCache.push([Number(j.latitud_pdv), Number(j.longitud_pdv), w]);
|
|
|
|
const mr: MapRoute = {
|
|
data: route,
|
|
color,
|
|
origin,
|
|
destination: dest,
|
|
visitOrder: segment.order,
|
|
totalStops: segment.totalStops,
|
|
previousPdvName: segment.previousPdvName,
|
|
requestId: idx + 1,
|
|
fallbackLine,
|
|
marker
|
|
};
|
|
this.mapRoutes.push(mr);
|
|
|
|
// Click handlers
|
|
const onClick = () => this.selectRoute(mr);
|
|
fallbackLine.on('click', onClick);
|
|
marker.on('click', onClick);
|
|
});
|
|
|
|
if (this.selectedUsuarioId === 'Todos' && this.selectedDia === 'Todos') {
|
|
for (const route of this.unassignedRoutes) {
|
|
if (this.validPoint(route.latitud, route.longitud)) bounds.push([Number(route.latitud), Number(route.longitud)]);
|
|
}
|
|
}
|
|
|
|
this.updateHeatLayer();
|
|
this.toggleLayerVisibility();
|
|
|
|
// Fit bounds
|
|
try {
|
|
this.map!.fitBounds(L.latLngBounds(bounds as L.LatLngTuple[]).pad(0.15));
|
|
} catch { /* ignore */ }
|
|
|
|
// Progressive ORS hydration — always in background (heat only hides visually)
|
|
this.hydrateWithOrs();
|
|
this.renderUnassignedMarkers();
|
|
this.cdr.detectChanges();
|
|
}
|
|
|
|
private projectBounds(): L.LatLngBounds | null {
|
|
const points: L.LatLngTuple[] = [];
|
|
for (const route of this.allRoutes) {
|
|
const data = route.json;
|
|
if (this.validPoint(data.latitud_usuario, data.longitud_usuario)) points.push([Number(data.latitud_usuario), Number(data.longitud_usuario)]);
|
|
if (this.validPoint(data.latitud_pdv, data.longitud_pdv)) points.push([Number(data.latitud_pdv), Number(data.longitud_pdv)]);
|
|
}
|
|
for (const route of this.unassignedRoutes) {
|
|
if (this.validPoint(route.latitud, route.longitud)) points.push([Number(route.latitud), Number(route.longitud)]);
|
|
}
|
|
return points.length ? L.latLngBounds(points) : null;
|
|
}
|
|
|
|
private validPoint(latitude: unknown, longitude: unknown): boolean {
|
|
const lat = Number(latitude);
|
|
const lng = Number(longitude);
|
|
return Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
|
|
}
|
|
|
|
private renderUnassignedMarkers(): void {
|
|
if (!this.map || this.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return;
|
|
for (const route of this.unassignedRoutes) {
|
|
const icon = L.divIcon({
|
|
className: 'custom-marker unassigned-marker',
|
|
html: '<div class="location-pin unassigned-pin"><span>!</span></div>',
|
|
iconSize: [30, 38],
|
|
iconAnchor: [15, 38]
|
|
});
|
|
const marker = L.marker([route.latitud, route.longitud], { icon, pane: 'pdvPane', zIndexOffset: 1000 })
|
|
.addTo(this.map)
|
|
.bindPopup(`<strong>PDV sin ruta</strong><br>${route.pdv_nombre}<br>Motivo: ${route.motivo}`);
|
|
marker.on('click', () => this.selectUnassigned(route));
|
|
this.unassignedMarkers.set(route.pdv_id, marker);
|
|
}
|
|
}
|
|
|
|
private async hydrateWithOrs() {
|
|
if (!this.filteredRoutes.length) return;
|
|
this.isLoading = true;
|
|
this.orsTotal = this.filteredRoutes.length;
|
|
this.orsProgress = 0;
|
|
this.cdr.detectChanges();
|
|
|
|
const embeddedResults = this.mapRoutes
|
|
.filter(route => !!route.data.json.geojson)
|
|
.map(route => ({ pdv_id: route.requestId, geojson: route.data.json.geojson }));
|
|
const pares = this.mapRoutes.filter(route => !route.data.json.geojson).map(route => ({
|
|
pdv_id: route.requestId,
|
|
origin: route.origin,
|
|
destination: route.destination
|
|
}));
|
|
|
|
try {
|
|
const remoteResults = pares.length
|
|
? await this.orsService.processPairsWithProgress(pares, 2, true, this.abort$).promise
|
|
: [];
|
|
const results = [...embeddedResults, ...remoteResults];
|
|
if (!results.length) {
|
|
this.isLoading = false;
|
|
this.cdr.detectChanges();
|
|
return;
|
|
}
|
|
const geoByRequest = new Map<number, unknown>();
|
|
for (const item of results) {
|
|
const g = item as { pdv_id?: number; geojson?: unknown; error?: string };
|
|
if (g && !g.error && g.geojson) {
|
|
geoByRequest.set(g.pdv_id!, g.geojson);
|
|
} else if (g && g.error) {
|
|
// error entry, skip but count progress
|
|
} else {
|
|
// fallback: if item is raw geojson without wrapper (old cache), treat as geojson
|
|
const maybeGeo = item as unknown;
|
|
if (maybeGeo && (maybeGeo as any).type) {
|
|
// cannot map, will fallback to index correlation below
|
|
}
|
|
}
|
|
}
|
|
const useMap = geoByRequest.size > 0;
|
|
for (let i = 0; i < this.mapRoutes.length; i++) {
|
|
const mr = this.mapRoutes[i];
|
|
let geo: unknown = null;
|
|
if (useMap) {
|
|
geo = geoByRequest.get(mr.requestId);
|
|
} else {
|
|
const res = results[i] as { error?: string; geojson?: unknown; type?: string; features?: unknown[] };
|
|
if ((res as any)?.error) {
|
|
this.orsProgress = i + 1;
|
|
this.cdr.detectChanges();
|
|
continue;
|
|
}
|
|
geo = (res as any)?.geojson ?? res;
|
|
}
|
|
if (!geo) {
|
|
this.orsProgress = i + 1;
|
|
this.cdr.detectChanges();
|
|
continue;
|
|
}
|
|
const coords = this.extractCoords(geo);
|
|
if (!coords.length) {
|
|
this.orsProgress = i + 1;
|
|
this.cdr.detectChanges();
|
|
continue;
|
|
}
|
|
if (mr.fallbackLine) {
|
|
this.map?.removeLayer(mr.fallbackLine);
|
|
mr.fallbackLine = undefined;
|
|
}
|
|
// Heatmap and route layers are independent; both remain visible together.
|
|
const glow = L.polyline(coords, { color: mr.color, weight: 12, opacity: 0.25, lineCap: 'round', lineJoin: 'round' }).addTo(this.map!);
|
|
const line = L.polyline(coords, { color: mr.color, weight: 5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }).addTo(this.map!);
|
|
const summary = this.extractSummary(geo);
|
|
if (summary) {
|
|
mr.segmentDistanceKm = summary.distance / 1000;
|
|
mr.segmentDurationHours = summary.duration / 3600;
|
|
mr.marker?.setPopupContent(
|
|
`<strong>${mr.data.json.nombre_pdv}</strong><br><strong>Usuario:</strong> ${mr.data.json.nombre_usuario}<br>Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}<br>${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min`
|
|
);
|
|
}
|
|
line.on('click', () => this.selectRoute(mr));
|
|
mr.orsGlow = glow;
|
|
mr.orsLine = line;
|
|
if (this.selectedRouteDetail && this.sameRoute(this.selectedRouteDetail, mr.data)) {
|
|
this.fitSelectedRoute(mr);
|
|
}
|
|
this.orsProgress = i + 1;
|
|
this.cdr.detectChanges();
|
|
}
|
|
this.toggleLayerVisibility();
|
|
} catch {
|
|
// Ignore abort / network errors, fallback lines remain
|
|
} finally {
|
|
this.isLoading = false;
|
|
this.cdr.detectChanges();
|
|
}
|
|
}
|
|
|
|
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 => [c[1], c[0]] as L.LatLngExpression);
|
|
}
|
|
if (g?.type === 'LineString' && g.coordinates) {
|
|
return g.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression);
|
|
}
|
|
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 extractSummary(geojson: unknown): { distance: number; duration: number } | null {
|
|
const summary = (geojson as { features?: { properties?: { summary?: { distance: number; duration: number } } }[] })
|
|
?.features?.[0]?.properties?.summary;
|
|
return summary ?? null;
|
|
}
|
|
|
|
private selectRoute(mr: MapRoute) {
|
|
this.selectedUnassigned = null;
|
|
this.editingUnassigned = null;
|
|
this.unassignedActionError = null;
|
|
this.selectedRouteDetail = mr.data;
|
|
this.highlightRoute(mr.data);
|
|
this.fitSelectedRoute(mr);
|
|
this.cdr.detectChanges();
|
|
}
|
|
|
|
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);
|
|
return;
|
|
}
|
|
this.selectedRouteDetail = route;
|
|
this.cdr.detectChanges();
|
|
}
|
|
|
|
startReassignRoute(): void {
|
|
if (!this.selectedRouteDetail) return;
|
|
this.showReassignForm = true;
|
|
this.reassignUserId = String(this.selectedRouteDetail.json.usuario_id);
|
|
this.reassignDay = this.selectedRouteDetail.json.dia;
|
|
this.reassignError = null;
|
|
}
|
|
|
|
cancelReassignRoute(): void {
|
|
this.showReassignForm = false;
|
|
this.reassignError = null;
|
|
}
|
|
|
|
moveSelectedVisit(direction: -1 | 1): void {
|
|
if (!this.selectedRouteDetail) return;
|
|
this.routeState.moveRouteVisit(this.selectedRouteDetail, direction);
|
|
}
|
|
|
|
resetSelectedDayOrder(): void {
|
|
if (!this.selectedRouteDetail) return;
|
|
this.routeState.resetRouteVisitOrder(this.selectedRouteDetail.json.usuario_id, this.selectedRouteDetail.json.dia);
|
|
}
|
|
|
|
async reassignRoute(): Promise<void> {
|
|
const route = this.selectedRouteDetail;
|
|
if (!route || !this.reassignUserId || !this.reassignDay || this.reassigningRoute) return;
|
|
const sameUser = String(route.json.usuario_id) === this.reassignUserId;
|
|
const sameDay = route.json.dia === this.reassignDay;
|
|
if (sameUser && sameDay) {
|
|
this.cancelReassignRoute();
|
|
return;
|
|
}
|
|
const target = this.routeState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === this.reassignUserId);
|
|
if (!target) {
|
|
this.reassignError = 'No se encontró el usuario seleccionado.';
|
|
return;
|
|
}
|
|
this.reassigningRoute = true;
|
|
this.reassignError = null;
|
|
try {
|
|
const origin = [Number(target.json.longitud_usuario), Number(target.json.latitud_usuario)];
|
|
const destination = [Number(route.json.longitud_pdv), Number(route.json.latitud_pdv)];
|
|
const results = await this.orsService.processPairs([{ pdv_id: route.json.pdv_id, origin, destination }], 1, true);
|
|
const response = results[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; error?: string } | undefined;
|
|
const summary = response?.geojson?.features?.[0]?.properties?.summary;
|
|
if (response?.error || !summary) throw new Error('ORS no devolvió una ruta vial');
|
|
const capacity = this.routeState.canAssignToDay(
|
|
this.reassignUserId,
|
|
this.reassignDay,
|
|
Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0),
|
|
route,
|
|
summary.duration / 3600
|
|
);
|
|
if (!capacity.allowed) {
|
|
this.reassignError = `La reasignación excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`;
|
|
return;
|
|
}
|
|
this.routeState.updateAssignedRoute(route, this.reassignUserId, this.reassignDay, {
|
|
distance: summary.distance / 1000,
|
|
duration: summary.duration / 3600
|
|
});
|
|
this.selectedRouteDetail = this.routeState.findRoute(route.json.pdv_id, this.reassignUserId);
|
|
this.showReassignForm = false;
|
|
} catch {
|
|
this.reassignError = 'No se pudo recalcular la ruta vial. La asignación anterior se mantuvo.';
|
|
} finally {
|
|
this.reassigningRoute = false;
|
|
this.cdr.detectChanges();
|
|
}
|
|
}
|
|
|
|
private fitSelectedRoute(mr: MapRoute): void {
|
|
if (!this.map) return;
|
|
const line = mr.orsLine || mr.fallbackLine;
|
|
if (!line) return;
|
|
const bounds = line.getBounds();
|
|
const json = mr.data.json;
|
|
bounds.extend([Number(json.latitud_usuario), Number(json.longitud_usuario)]);
|
|
bounds.extend([Number(json.latitud_pdv), Number(json.longitud_pdv)]);
|
|
if (bounds.isValid()) {
|
|
this.map.fitBounds(bounds, { padding: [36, 36], maxZoom: 16, animate: true, duration: 0.5 });
|
|
}
|
|
}
|
|
|
|
routeVisitOrder(route: RouteAssignedResponse): number {
|
|
return this.mapRoutes.find(item => this.sameRoute(item.data, route))?.visitOrder ?? 0;
|
|
}
|
|
|
|
routeSegmentDistance(route: RouteAssignedResponse): number {
|
|
const segment = this.mapRoutes.find(item => this.sameRoute(item.data, route));
|
|
return segment?.segmentDistanceKm ?? Number(route.json.distancia);
|
|
}
|
|
|
|
routeSegmentDurationMinutes(route: RouteAssignedResponse): number {
|
|
const segment = this.mapRoutes.find(item => this.sameRoute(item.data, route));
|
|
return (segment?.segmentDurationHours ?? Number(route.json.horas_desplazamiento)) * 60;
|
|
}
|
|
|
|
get selectedVisitOrder(): string {
|
|
const segment = this.selectedMapSegment;
|
|
return segment ? `${segment.visitOrder} de ${segment.totalStops}` : '-';
|
|
}
|
|
|
|
get canMoveSelectedVisitUp(): boolean {
|
|
return (this.selectedMapSegment?.visitOrder ?? 0) > 1;
|
|
}
|
|
|
|
get canMoveSelectedVisitDown(): boolean {
|
|
const segment = this.selectedMapSegment;
|
|
return !!segment && segment.visitOrder < segment.totalStops;
|
|
}
|
|
|
|
get selectedSegmentOrigin(): string {
|
|
const segment = this.selectedMapSegment;
|
|
if (!segment) return '-';
|
|
return segment.previousPdvName ?? `Casa de ${segment.data.json.nombre_usuario}`;
|
|
}
|
|
|
|
get selectedSegmentDistance(): number {
|
|
const segment = this.selectedMapSegment;
|
|
return segment?.segmentDistanceKm ?? Number(this.selectedRouteDetail?.json.distancia ?? 0);
|
|
}
|
|
|
|
get selectedSegmentDurationMinutes(): number {
|
|
const segment = this.selectedMapSegment;
|
|
return (segment?.segmentDurationHours ?? Number(this.selectedRouteDetail?.json.horas_desplazamiento ?? 0)) * 60;
|
|
}
|
|
|
|
private get selectedMapSegment(): MapRoute | null {
|
|
if (!this.selectedRouteDetail) return null;
|
|
return this.mapRoutes.find(item => this.sameRoute(item.data, this.selectedRouteDetail!)) ?? null;
|
|
}
|
|
|
|
private sameRoute(left: RouteAssignedResponse, right: RouteAssignedResponse): boolean {
|
|
return left.json.pdv_id === right.json.pdv_id &&
|
|
String(left.json.usuario_id) === String(right.json.usuario_id) &&
|
|
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 }[] {
|
|
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] ?? []);
|
|
checked ? days.add(day) : days.delete(day);
|
|
this.assignmentDays[key] = Array.from(days);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
startUnassignedEdit(route: RouteNoAssignedResponse): void {
|
|
this.editingUnassigned = route;
|
|
this.editUnassignedName = route.pdv_nombre;
|
|
this.editUnassignedHours = route.minimo_horas_semana;
|
|
this.editUnassignedLat = route.latitud;
|
|
this.editUnassignedLng = route.longitud;
|
|
this.editUnassignedReason = route.motivo;
|
|
this.unassignedActionError = null;
|
|
}
|
|
|
|
cancelUnassignedEdit(): void {
|
|
this.editingUnassigned = null;
|
|
}
|
|
|
|
saveUnassignedEdit(): void {
|
|
if (!this.editingUnassigned || !this.editUnassignedName.trim()) {
|
|
this.unassignedActionError = 'El nombre del PDV es obligatorio.';
|
|
return;
|
|
}
|
|
if (!Number.isFinite(this.editUnassignedLat) || this.editUnassignedLat < -90 || this.editUnassignedLat > 90 ||
|
|
!Number.isFinite(this.editUnassignedLng) || this.editUnassignedLng < -180 || this.editUnassignedLng > 180) {
|
|
this.unassignedActionError = 'Latitud o longitud inválida.';
|
|
return;
|
|
}
|
|
if (!Number.isFinite(this.editUnassignedHours) || this.editUnassignedHours <= 0) {
|
|
this.unassignedActionError = 'Las horas mínimas deben ser mayores que cero.';
|
|
return;
|
|
}
|
|
this.routeState.updateNoAssignedRoute(this.editingUnassigned, {
|
|
pdv_nombre: this.editUnassignedName.trim(),
|
|
minimo_horas_semana: this.editUnassignedHours,
|
|
latitud: this.editUnassignedLat,
|
|
longitud: this.editUnassignedLng,
|
|
motivo: this.editUnassignedReason.trim()
|
|
});
|
|
this.selectedUnassigned = { ...this.editingUnassigned, pdv_nombre: this.editUnassignedName.trim(), latitud: this.editUnassignedLat, longitud: this.editUnassignedLng };
|
|
this.editingUnassigned = null;
|
|
}
|
|
|
|
async assignUnassigned(route: RouteNoAssignedResponse): Promise<void> {
|
|
const userId = this.assignmentUser[String(route.pdv_id)];
|
|
const days = this.assignmentDays[String(route.pdv_id)] ?? [];
|
|
if (!userId || !days.length) {
|
|
this.unassignedActionError = 'Selecciona usuario y al menos un día antes de asignar.';
|
|
return;
|
|
}
|
|
const user = this.routeState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === userId);
|
|
if (!user) return;
|
|
this.assigningUnassignedId = route.pdv_id;
|
|
this.unassignedActionError = null;
|
|
try {
|
|
const origin = [Number(user.json.longitud_usuario), Number(user.json.latitud_usuario)];
|
|
const destination = [Number(route.longitud), Number(route.latitud)];
|
|
const assignments: UnassignedAssignment[] = [];
|
|
for (const day of days) {
|
|
const results = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true);
|
|
const geo = (results[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; error?: string });
|
|
const summary = geo?.geojson?.features?.[0]?.properties?.summary;
|
|
if (geo?.error || !summary) throw new Error('No se obtuvo una ruta vial');
|
|
const capacity = this.routeState.canAssignToDay(userId, day, route.minimo_horas_semana, undefined, summary.duration / 3600);
|
|
if (!capacity.allowed) {
|
|
this.unassignedActionError = `${day}: excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`;
|
|
return;
|
|
}
|
|
assignments.push({ userId, day, distance: summary.distance / 1000, duration: summary.duration / 3600 });
|
|
}
|
|
this.routeState.addAssignedFromUnassignedMany(route, assignments);
|
|
this.selectedUnassigned = null;
|
|
delete this.assignmentUser[String(route.pdv_id)];
|
|
delete this.assignmentDays[String(route.pdv_id)];
|
|
} catch {
|
|
this.unassignedActionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`;
|
|
} finally {
|
|
this.assigningUnassignedId = null;
|
|
this.cdr.detectChanges();
|
|
}
|
|
}
|
|
|
|
highlightRoute(route: RouteAssignedResponse) {
|
|
// Reset previous selection style
|
|
if (this.selectedMapRoute) {
|
|
const prev = this.selectedMapRoute.orsLine || this.selectedMapRoute.fallbackLine;
|
|
if (prev) (prev as L.Polyline).setStyle?.({ weight: this.selectedMapRoute.orsLine ? 5 : 4, opacity: this.selectedMapRoute.orsLine ? 0.95 : 0.6 });
|
|
}
|
|
const mr = this.mapRoutes.find(item => this.sameRoute(item.data, route));
|
|
if (mr) {
|
|
this.selectedMapRoute = mr;
|
|
const line = mr.orsLine || mr.fallbackLine;
|
|
if (line) (line as L.Polyline).setStyle({ weight: 8, opacity: 1 });
|
|
// Bring to front
|
|
if (mr.orsLine) mr.orsLine.bringToFront?.();
|
|
else if (mr.fallbackLine) mr.fallbackLine.bringToFront?.();
|
|
try {
|
|
mr.marker?.openPopup();
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
private computeWeight(j: RouteAssignedResponse['json']): number {
|
|
const v = parseFloat(String(j.horas_trabajo));
|
|
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 1;
|
|
const min = Math.min(...vals), max = Math.max(...vals);
|
|
if (max === min) return 1;
|
|
return 0.3 + 0.7 * (v - min) / (max - min);
|
|
}
|
|
|
|
private updateHeatLayer() {
|
|
if (this.heatLayer) {
|
|
this.map?.removeLayer(this.heatLayer);
|
|
this.heatLayer = undefined;
|
|
}
|
|
if (!this.showHeat || !this.heatPointsCache.length || !this.map) return;
|
|
this.heatLayer = (L as any).heatLayer(this.heatPointsCache, {
|
|
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);
|
|
}
|
|
|
|
toggleHeat() {
|
|
this.showHeat = !this.showHeat;
|
|
this.updateHeatLayer();
|
|
this.toggleLayerVisibility();
|
|
const needsHydrate = !this.mapRoutes.some(m => m.orsLine) || this.orsTotal === 0 || this.orsProgress < this.orsTotal;
|
|
if (!this.showHeat && needsHydrate) {
|
|
this.hydrateWithOrs();
|
|
}
|
|
this.cdr.detectChanges();
|
|
}
|
|
|
|
private toggleLayerVisibility() {
|
|
this.mapRoutes.forEach(mr => {
|
|
if (mr.fallbackLine) (mr.fallbackLine as any).setStyle({ opacity: 0.6 });
|
|
if (mr.orsLine) (mr.orsLine as any).setStyle({ opacity: 0.95 });
|
|
if (mr.orsGlow) (mr.orsGlow as any).setStyle({ opacity: 0.25 });
|
|
});
|
|
}
|
|
|
|
clearSelection() {
|
|
if (this.selectedMapRoute) {
|
|
const line = this.selectedMapRoute.orsLine || this.selectedMapRoute.fallbackLine;
|
|
if (line) (line as L.Polyline).setStyle({ weight: this.selectedMapRoute.orsLine ? 5 : 4, opacity: this.selectedMapRoute.orsLine ? 0.95 : 0.6 });
|
|
}
|
|
this.selectedMapRoute = null;
|
|
this.selectedRouteDetail = null;
|
|
}
|
|
|
|
goBack() {
|
|
this.router.navigate(['/proyectos', this.activatedRoute.snapshot.paramMap.get('id')]);
|
|
}
|
|
}
|