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(); 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(); 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 = {}; assignmentDays: Record = {}; 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(); 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(); 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(); 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: `
A
`, 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(`Usuario: ${start.nombre_usuario}
PDVs: ${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: `
${segment.order}
`, 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(`${j.nombre_pdv}
Usuario: ${j.nombre_usuario}
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: '
!
', iconSize: [30, 38], iconAnchor: [15, 38] }); const marker = L.marker([route.latitud, route.longitud], { icon, pane: 'pdvPane', zIndexOffset: 1000 }) .addTo(this.map) .bindPopup(`PDV sin ruta
${route.pdv_nombre}
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(); 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( `${mr.data.json.nombre_pdv}
Usuario: ${mr.data.json.nombre_usuario}
Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}
${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 { 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(); 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] ?? []); 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 { 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')]); } }