feat: implement route planning dashboard including KPIs, user workload tracking, and route assignment management features
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user