feat: implement route planning dashboard including KPIs, user workload tracking, and route assignment management features

This commit is contained in:
2026-08-29 11:55:03 -04:00
parent a0ea3fea48
commit cd5c57c5ee
41 changed files with 3811 additions and 151 deletions
+205 -3
View File
@@ -5,19 +5,26 @@ import {
RouteNoAssignedResponse
} from '../models/route.model';
const STORAGE_ASSIGNED = 'rp_assigned_routes';
const STORAGE_ASSIGNED_ORIGINAL = 'rp_assigned_routes_original';
const STORAGE_NO_ASSIGNED = 'rp_no_assigned_routes';
const STORAGE_NO_ASSIGNED_ORIGINAL = 'rp_no_assigned_routes_original';
const STORAGE_SELECTED = 'rp_selected_route';
const DAY_ORDER = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes'];
@Injectable({ providedIn: 'root' })
export class RouteState {
private loading = new BehaviorSubject<boolean>(false);
loading$ = this.loading.asObservable();
private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>([]);
private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>(this.loadAssigned());
assignedRoutes$ = this.assignedRoutes.asObservable();
private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>([]);
private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>(this.loadNoAssigned());
noAssignedRoutes$ = this.noAssignedRoutes.asObservable();
private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(null);
private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(this.loadSelected());
selectedRoute$ = this.selectedRoute.asObservable();
resetLoading() {
@@ -30,13 +37,208 @@ export class RouteState {
setAssignedRoutes(data: RouteAssignedResponse[]) {
this.assignedRoutes.next(data);
this.persist(STORAGE_ASSIGNED, data);
this.persist(STORAGE_ASSIGNED_ORIGINAL, data);
}
updateAssignedRoute(
route: RouteAssignedResponse,
userId: string,
day: string,
metrics?: { distance: number; duration: number }
): void {
const profile = this.assignedRoutes.getValue().find(item => String(item.json.usuario_id) === String(userId));
if (!profile) return;
const updated = this.assignedRoutes.getValue().map(item => {
if (item !== route) return item;
return {
json: {
...item.json,
usuario_id: profile.json.usuario_id,
nombre_usuario: profile.json.nombre_usuario,
latitud_usuario: profile.json.latitud_usuario,
longitud_usuario: profile.json.longitud_usuario,
hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario,
dia: day,
distancia: metrics?.distance ?? item.json.distancia,
horas_desplazamiento: metrics?.duration ?? item.json.horas_desplazamiento
}
};
});
this.setDraftAssignedRoutes(updated);
}
addAssignedFromUnassigned(route: RouteNoAssignedResponse, userId: string, day: string, distance: number, duration: number): void {
const profile = this.assignedRoutes.getValue().find(item => String(item.json.usuario_id) === String(userId));
if (!profile) return;
const newRoute: RouteAssignedResponse = {
json: {
dia: day,
distancia: distance,
horas_desplazamiento: duration,
hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario,
hora_minima_semanal_pdv: route.minimo_horas_semana,
horas_trabajo: String(route.minimo_horas_semana),
latitud_usuario: profile.json.latitud_usuario,
longitud_usuario: profile.json.longitud_usuario,
latitud_pdv: route.latitud,
longitud_pdv: route.longitud,
nombre_usuario: profile.json.nombre_usuario,
nombre_pdv: route.pdv_nombre,
usuario_id: profile.json.usuario_id,
pdv_id: route.pdv_id
}
};
this.setDraftAssignedRoutes([...this.assignedRoutes.getValue(), newRoute]);
this.setDraftNoAssignedRoutes(this.noAssignedRoutes.getValue().filter(item => item.pdv_id !== route.pdv_id));
}
updateNoAssignedRoute(route: RouteNoAssignedResponse, changes: Partial<RouteNoAssignedResponse>): void {
const updated = this.noAssignedRoutes.getValue().map(item => item.pdv_id === route.pdv_id ? { ...item, ...changes } : item);
this.setDraftNoAssignedRoutes(updated);
}
restoreOriginalAssignedRoutes(): void {
const original = this.loadOriginalAssigned();
this.setDraftAssignedRoutes(original);
}
reoptimizeAssignedRoutes(): void {
const current = this.assignedRoutes.getValue();
const profiles = Array.from(new Map(current.map(route => [String(route.json.usuario_id), route])).values());
if (profiles.length < 2) return;
const load = new Map<string, number>(profiles.map(profile => [String(profile.json.usuario_id), 0]));
const optimized = [...current]
.sort((a, b) => Number(b.json.hora_minima_semanal_pdv) - Number(a.json.hora_minima_semanal_pdv))
.map(route => {
const user = profiles
.slice()
.sort((a, b) => (load.get(String(a.json.usuario_id)) ?? 0) - (load.get(String(b.json.usuario_id)) ?? 0))[0];
const userId = String(user.json.usuario_id);
load.set(userId, (load.get(userId) ?? 0) + 1);
return {
json: {
...route.json,
usuario_id: user.json.usuario_id,
nombre_usuario: user.json.nombre_usuario,
latitud_usuario: user.json.latitud_usuario,
longitud_usuario: user.json.longitud_usuario,
hora_laboral_semanal_usuario: user.json.hora_laboral_semanal_usuario
}
};
});
this.setDraftAssignedRoutes(optimized);
}
setNoAssignedRoutes(data: RouteNoAssignedResponse[]) {
this.noAssignedRoutes.next(data);
this.persist(STORAGE_NO_ASSIGNED, data);
this.persist(STORAGE_NO_ASSIGNED_ORIGINAL, data);
}
sendRoute(route: RouteAssignedResponse) {
this.selectedRoute.next(route);
this.persist(STORAGE_SELECTED, route);
}
getAssignedSnapshot(): RouteAssignedResponse[] {
return this.assignedRoutes.getValue();
}
getNoAssignedSnapshot(): RouteNoAssignedResponse[] {
return this.noAssignedRoutes.getValue();
}
findRoute(pdvId: string | number, usuarioId?: string): RouteAssignedResponse | null {
const pid = String(pdvId);
const routes = this.getAssignedSnapshot();
const found = routes.find(r => {
const jp = r.json;
if (String(jp.pdv_id) !== pid) return false;
if (usuarioId && String(jp.usuario_id) !== String(usuarioId)) return false;
return true;
});
return found ?? null;
}
getUsuariosUnicos(): { id: string; nombre: string }[] {
const map = new Map<string, string>();
for (const r of this.getAssignedSnapshot()) {
const id = String(r.json.usuario_id);
if (!map.has(id)) map.set(id, r.json.nombre_usuario);
}
return Array.from(map.entries()).map(([id, nombre]) => ({ id, nombre }));
}
getDiasUnicos(): string[] {
const set = new Set<string>();
for (const r of this.getAssignedSnapshot()) set.add(r.json.dia);
return Array.from(set).sort((a, b) => {
const indexA = DAY_ORDER.indexOf(a);
const indexB = DAY_ORDER.indexOf(b);
return (indexA < 0 ? DAY_ORDER.length : indexA) - (indexB < 0 ? DAY_ORDER.length : indexB);
});
}
clearAll() {
this.assignedRoutes.next([]);
this.noAssignedRoutes.next([]);
this.selectedRoute.next(null);
try {
sessionStorage.removeItem(STORAGE_ASSIGNED);
sessionStorage.removeItem(STORAGE_ASSIGNED_ORIGINAL);
sessionStorage.removeItem(STORAGE_NO_ASSIGNED);
sessionStorage.removeItem(STORAGE_NO_ASSIGNED_ORIGINAL);
sessionStorage.removeItem(STORAGE_SELECTED);
} catch { /* ignore */ }
}
private persist(key: string, data: unknown) {
try {
sessionStorage.setItem(key, JSON.stringify(data));
} catch { /* ignore storage errors */ }
}
private setDraftAssignedRoutes(data: RouteAssignedResponse[]): void {
this.assignedRoutes.next(data);
this.persist(STORAGE_ASSIGNED, data);
}
private setDraftNoAssignedRoutes(data: RouteNoAssignedResponse[]): void {
this.noAssignedRoutes.next(data);
this.persist(STORAGE_NO_ASSIGNED, data);
}
private loadAssigned(): RouteAssignedResponse[] {
try {
const raw = sessionStorage.getItem(STORAGE_ASSIGNED);
if (raw) return JSON.parse(raw) as RouteAssignedResponse[];
} catch { /* ignore */ }
return [];
}
private loadOriginalAssigned(): RouteAssignedResponse[] {
try {
const raw = sessionStorage.getItem(STORAGE_ASSIGNED_ORIGINAL);
if (raw) return JSON.parse(raw) as RouteAssignedResponse[];
} catch { /* ignore */ }
return this.loadAssigned();
}
private loadNoAssigned(): RouteNoAssignedResponse[] {
try {
const raw = sessionStorage.getItem(STORAGE_NO_ASSIGNED);
if (raw) return JSON.parse(raw) as RouteNoAssignedResponse[];
} catch { /* ignore */ }
return [];
}
private loadSelected(): RouteAssignedResponse | null {
try {
const raw = sessionStorage.getItem(STORAGE_SELECTED);
if (raw) return JSON.parse(raw) as RouteAssignedResponse;
} catch { /* ignore */ }
return null;
}
}
@@ -9,11 +9,15 @@ import {
catchError,
map,
toArray,
timeout
timeout,
Observable,
Subject
} from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
const ENDPOINT = 'http://localhost:18080/ors/v2/directions/driving-car';
const ENDPOINTJSON = 'http://localhost:18080/ors/v2/directions/driving-car/geojson';
const ENDPOINT = `${environment.orsBaseUrl}/directions/driving-car`;
const ENDPOINTJSON = `${environment.orsBaseUrl}/directions/driving-car/geojson`;
const MAX_CONCURRENT_REQUESTS = 2;
@Injectable({
@@ -21,8 +25,14 @@ const MAX_CONCURRENT_REQUESTS = 2;
})
export class OrsService {
private geoCache = new Map<string, unknown>();
constructor(private http: HttpClient) { }
private cacheKey(origin: number[], dest: number[]): string {
return `${origin[0].toFixed(5)},${origin[1].toFixed(5)}->${dest[0].toFixed(5)},${dest[1].toFixed(5)}`;
}
private fetchRoute(
origin: number[],
destination: number[],
@@ -32,7 +42,7 @@ export class OrsService {
coordinates: [origin, destination]
};
return this.http.post<any>(isJson ? ENDPOINTJSON : ENDPOINT, body).pipe(
return this.http.post<unknown>(isJson ? ENDPOINTJSON : ENDPOINT, body).pipe(
timeout(8000),
catchError(error => {
throw error;
@@ -48,37 +58,91 @@ export class OrsService {
}[],
concurrency = MAX_CONCURRENT_REQUESTS,
isJson = false
): Promise<any[]> {
return lastValueFrom(
from(pares).pipe(
mergeMap(
pair =>
this.fetchRoute(pair.origin, pair.destination, isJson).pipe(
delay(300),
): Promise<unknown[]> {
return this.processPairsWithProgress(pares, concurrency, isJson).promise;
}
processPairsWithProgress(
pares: {
pdv_id: number;
origin: number[];
destination: number[];
}[],
concurrency = MAX_CONCURRENT_REQUESTS,
isJson = false,
abort$?: Observable<void>
): { promise: Promise<unknown[]>; progress$: Observable<{ completed: number; total: number; item: unknown }> } {
const progressSubject = new Subject<{ completed: number; total: number; item: unknown }>();
let completed = 0;
let source$: Observable<unknown[]>;
const base$ = from(pares).pipe(
mergeMap(
pair => {
const key = this.cacheKey(pair.origin, pair.destination);
if (isJson && this.geoCache.has(key)) {
const cached = this.geoCache.get(key);
const wrapped = { pdv_id: pair.pdv_id, geojson: cached };
return of(wrapped).pipe(
map(data => {
if (!isJson) {
const summary = data.routes[0].summary;
return {
pdv_id: pair.pdv_id,
distance: summary.distance,
duration: summary.duration,
raw: data
};
} else {
return data;
}
}),
catchError(error =>
of({
completed++;
progressSubject.next({ completed, total: pares.length, item: data as unknown });
if (completed === pares.length) progressSubject.complete();
return data as unknown;
})
);
}
return this.fetchRoute(pair.origin, pair.destination, isJson).pipe(
delay(300),
map(data => {
if (isJson) this.geoCache.set(key, data);
if (!isJson) {
const summary = (data as { routes: { summary: { distance: number; duration: number } }[] }).routes[0].summary;
const out = {
pdv_id: pair.pdv_id,
error: error.message || 'Error ORS'
})
)
),
concurrency
),
toArray()
)
);
distance: summary.distance,
duration: summary.duration,
raw: data
};
completed++;
progressSubject.next({ completed, total: pares.length, item: out });
if (completed === pares.length) progressSubject.complete();
return out;
} else {
const wrapped = { pdv_id: pair.pdv_id, geojson: data };
completed++;
progressSubject.next({ completed, total: pares.length, item: wrapped as unknown });
if (completed === pares.length) progressSubject.complete();
return wrapped as unknown;
}
}),
catchError(error => {
const errOut = {
pdv_id: pair.pdv_id,
error: (error as Error).message || 'Error ORS'
};
completed++;
progressSubject.next({ completed, total: pares.length, item: errOut });
if (completed === pares.length) progressSubject.complete();
return of(errOut);
})
);
},
concurrency
),
toArray()
) as Observable<unknown[]>;
source$ = abort$ ? (base$.pipe(takeUntil(abort$)) as Observable<unknown[]>) : base$;
const promise = lastValueFrom(source$).catch(() => {
progressSubject.complete();
return [];
});
return { promise, progress$: progressSubject.asObservable() };
}
clearCache() {
this.geoCache.clear();
}
}
@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class PlanningInputService {
private pdvFile: File | null = null;
private usuariosFile: File | null = null;
private radioKm = 50;
private velocidad = 28;
setFiles(pdvFile: File, usuariosFile: File, radioKm: number, velocidad: number): void {
this.pdvFile = pdvFile;
this.usuariosFile = usuariosFile;
this.radioKm = radioKm;
this.velocidad = velocidad;
}
getFormData(): FormData | null {
if (!this.pdvFile || !this.usuariosFile) return null;
const data = new FormData();
data.append('pdv_file', this.pdvFile, this.pdvFile.name);
data.append('usuarios_file', this.usuariosFile, this.usuariosFile.name);
data.append('radio_km', String(this.radioKm));
data.append('velocidad', String(this.velocidad));
return data;
}
clear(): void {
this.pdvFile = null;
this.usuariosFile = null;
}
}
@@ -1,6 +1,8 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ApiResponse } from '../../core/models/route.model';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { ApiResponse } from '../models/route.model';
import { environment } from '../../../environments/environment';
import { catchError, timeout, throwError } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class RutasService {
@@ -9,8 +11,20 @@ export class RutasService {
calcularRutas(data: FormData) {
return this.http.post<ApiResponse>(
'https://agenteit.digitalcompass.agency/webhook/calcular-camino',
environment.n8nWebhookUrl,
data
).pipe(
timeout(environment.requestTimeoutMs),
catchError((err: HttpErrorResponse | Error) => {
if (err instanceof Error && err.name === 'TimeoutError') {
return throwError(() => Object.assign(new Error('Timeout al calcular rutas (30s). Intenta con menos filas o reintenta.'), { name: 'TimeoutError' }));
}
return throwError(() => err);
})
);
}
}
reoptimizarRutas(data: FormData) {
return this.calcularRutas(data);
}
}