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;
}
}