feat: implement route planning dashboard including KPIs, user workload tracking, and route assignment management features
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -18,16 +18,27 @@
|
||||
"polyfills": ["zone.js"],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [{ "glob": "**/*", "input": "public" }],
|
||||
"styles": ["src/styles.css"],
|
||||
"styles": [
|
||||
"src/styles.css",
|
||||
"node_modules/leaflet/dist/leaflet.css",
|
||||
"node_modules/leaflet.markercluster/dist/MarkerCluster.css",
|
||||
"node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{ "type": "initial", "maximumWarning": "1.5MB", "maximumError": "2MB" },
|
||||
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
|
||||
{ "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" },
|
||||
{ "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "10kB" }
|
||||
],
|
||||
"outputHashing": "all"
|
||||
"outputHashing": "all",
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
|
||||
Generated
+16
@@ -17,6 +17,8 @@
|
||||
"@primeng/themes": "^21.0.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"primeicons": "^7.0.0",
|
||||
"primeng": "^21.0.0",
|
||||
"rxjs": "~7.8.2",
|
||||
@@ -6226,6 +6228,20 @@
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/leaflet.heat": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz",
|
||||
"integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ=="
|
||||
},
|
||||
"node_modules/leaflet.markercluster": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
|
||||
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/listr2": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"@primeng/themes": "^21.0.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"primeicons": "^7.0.0",
|
||||
"primeng": "^21.0.0",
|
||||
"rxjs": "~7.8.2",
|
||||
|
||||
@@ -2,11 +2,13 @@ import { Routes } from '@angular/router';
|
||||
import { LoginComponent } from './public/login/login.component';
|
||||
import { HomeComponent } from './private/home/home.component';
|
||||
import { AssignedRouteDetailComponent } from './private/home/assigned-route-detail/assigned-route-detail.component';
|
||||
import { RouteOverviewMapComponent } from './private/home/route-overview-map/route-overview-map.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'login', pathMatch: 'full' },
|
||||
{ path: 'login', component: LoginComponent },
|
||||
{ path: 'home', component: HomeComponent, pathMatch: 'full' },
|
||||
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent },
|
||||
{ path: 'home/mapa', component: RouteOverviewMapComponent },
|
||||
{ path: '**', redirectTo: 'login' }
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -196,3 +196,31 @@
|
||||
background: var(--glm-verde);
|
||||
border: 3px solid #fff;
|
||||
}
|
||||
|
||||
.location-pin {
|
||||
position: relative;
|
||||
width: 30px;
|
||||
height: 38px;
|
||||
background: #c9252d;
|
||||
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
|
||||
}
|
||||
|
||||
.location-pin::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3px 3px 7px;
|
||||
background: #ff424a;
|
||||
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
|
||||
}
|
||||
|
||||
.location-pin::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #620000;
|
||||
}
|
||||
|
||||
+2
@@ -25,6 +25,8 @@
|
||||
<div><dt>Usuario</dt><dd>{{ routeData.json.nombre_usuario }}</dd></div>
|
||||
<div><dt>PDV</dt><dd>{{ routeData.json.nombre_pdv }}</dd></div>
|
||||
<div><dt>Día</dt><dd>{{ routeData.json.dia }}</dd></div>
|
||||
<div><dt>Orden</dt><dd>{{ visitOrder }} de {{ totalStops }}</dd></div>
|
||||
<div><dt>Origen del tramo</dt><dd>{{ segmentOriginName }}</dd></div>
|
||||
<div><dt>Distancia</dt><dd>{{ routeData.json.distancia }} km</dd></div>
|
||||
<div><dt>Duración</dt><dd>{{ (routeData.json.horas_desplazamiento * 60).toFixed(0) }} min</dd></div>
|
||||
<div><dt>Horas trabajo</dt><dd>{{ routeData.json.horas_trabajo }}</dd></div>
|
||||
|
||||
+89
-35
@@ -1,11 +1,12 @@
|
||||
import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
import * as L from 'leaflet';
|
||||
import { RouteState } from '../../../core/route-state/route';
|
||||
import { OrsService } from '../../../core/services/ors.service';
|
||||
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
||||
import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -26,9 +27,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
routeData: RouteAssignedResponse | null = null;
|
||||
isLoading = false;
|
||||
error: string | null = null;
|
||||
segmentOriginName = '';
|
||||
visitOrder = 0;
|
||||
totalStops = 0;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private routeState: RouteState,
|
||||
private orsService: OrsService,
|
||||
private cdr: ChangeDetectorRef
|
||||
@@ -36,11 +41,30 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
|
||||
ngOnInit() {
|
||||
this.sub = this.routeState.selectedRoute$.subscribe((route: RouteAssignedResponse | null) => {
|
||||
this.routeData = route;
|
||||
if (route && this.map) {
|
||||
this.loadAndShowRoute(route);
|
||||
} else if (!route) {
|
||||
this.error = 'No hay ruta seleccionada';
|
||||
if (route) {
|
||||
this.routeData = route;
|
||||
if (this.map) {
|
||||
this.loadAndShowRoute(route);
|
||||
}
|
||||
this.error = null;
|
||||
} else {
|
||||
// Try to resolve from queryParams + sessionStorage snapshot
|
||||
const qp = this.activatedRoute.snapshot.queryParams;
|
||||
const pdvId = qp['pdv_id'];
|
||||
const usuarioId = qp['usuario_id'];
|
||||
if (pdvId) {
|
||||
const found = this.routeState.findRoute(pdvId, usuarioId);
|
||||
if (found) {
|
||||
this.routeData = found;
|
||||
if (this.map) this.loadAndShowRoute(found);
|
||||
this.error = null;
|
||||
} else {
|
||||
// No data at all (e.g., direct URL after hard refresh with empty storage)
|
||||
this.error = 'No hay ruta seleccionada. Vuelve al listado y selecciona una ruta.';
|
||||
}
|
||||
} else if (!this.routeData) {
|
||||
this.error = 'No hay ruta seleccionada';
|
||||
}
|
||||
}
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
@@ -50,6 +74,18 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
this.initMap();
|
||||
if (this.routeData) {
|
||||
this.loadAndShowRoute(this.routeData);
|
||||
} else {
|
||||
// Re-attempt resolution after map init (queryParams case)
|
||||
const qp = this.activatedRoute.snapshot.queryParams;
|
||||
if (qp['pdv_id']) {
|
||||
const found = this.routeState.findRoute(qp['pdv_id'], qp['usuario_id']);
|
||||
if (found) {
|
||||
this.routeData = found;
|
||||
this.error = null;
|
||||
this.loadAndShowRoute(found);
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +100,8 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
|
||||
private initMap() {
|
||||
this.map = L.map('map').setView([18.4861, -69.9312], 13);
|
||||
const pdvPane = this.map.createPane('pdvPane');
|
||||
pdvPane.style.zIndex = '650';
|
||||
|
||||
this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
@@ -77,13 +115,20 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
this.cdr.detectChanges();
|
||||
|
||||
const json = route.json;
|
||||
const origin: [number, number] = [Number(json.longitud_usuario), Number(json.latitud_usuario)];
|
||||
const sequence = sequenceRoutes(this.routeState.getAssignedSnapshot().filter(item =>
|
||||
String(item.json.usuario_id) === String(json.usuario_id) && item.json.dia === json.dia
|
||||
));
|
||||
const segment = sequence.find(item => item.route === route) ?? sequence.find(item => item.route.json.pdv_id === json.pdv_id);
|
||||
const origin: [number, number] = segment?.origin ?? [Number(json.longitud_usuario), Number(json.latitud_usuario)];
|
||||
const destination: [number, number] = [Number(json.longitud_pdv), Number(json.latitud_pdv)];
|
||||
this.segmentOriginName = segment?.previousPdvName ?? `Casa de ${json.nombre_usuario}`;
|
||||
this.visitOrder = segment?.order ?? 1;
|
||||
this.totalStops = segment?.totalStops ?? 1;
|
||||
|
||||
this.centerMapOnPoints(origin, destination);
|
||||
|
||||
try {
|
||||
const geojson = await this.orsService.processPairs(
|
||||
const results = await this.orsService.processPairs(
|
||||
[{ pdv_id: json.pdv_id, origin, destination }],
|
||||
1,
|
||||
true
|
||||
@@ -91,13 +136,14 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
|
||||
if (this.destroyed) return;
|
||||
|
||||
if (geojson[0] && !geojson[0].error) {
|
||||
this.showRouteOnMap(geojson[0], origin, destination, json);
|
||||
const result = results[0] as { error?: string; geojson?: unknown } | undefined;
|
||||
if (result && !result.error && result.geojson) {
|
||||
this.showRouteOnMap(result.geojson, origin, destination, json, this.segmentOriginName);
|
||||
} else {
|
||||
this.showFallbackRoute(origin, destination, json);
|
||||
this.showFallbackRoute(origin, destination, json, this.segmentOriginName);
|
||||
}
|
||||
} catch {
|
||||
this.showFallbackRoute(origin, destination, json);
|
||||
this.showFallbackRoute(origin, destination, json, this.segmentOriginName);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
@@ -112,31 +158,38 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
this.map.fitBounds(bounds.pad(0.3));
|
||||
}
|
||||
|
||||
private extractCoords(geojson: any): L.LatLngExpression[] {
|
||||
if (geojson.type === 'FeatureCollection' && geojson.features?.length) {
|
||||
return geojson.features[0].geometry.coordinates.map(
|
||||
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: number[]) => [c[1], c[0]] as L.LatLngExpression
|
||||
);
|
||||
}
|
||||
if (geojson.type === 'LineString') {
|
||||
return geojson.coordinates.map(
|
||||
if (g.type === 'LineString' && g.coordinates) {
|
||||
return g.coordinates.map(
|
||||
(c: number[]) => [c[1], c[0]] as L.LatLngExpression
|
||||
);
|
||||
}
|
||||
// ORS geojson response wrapped
|
||||
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 showRouteOnMap(geojson: any, origin: [number, number], destination: [number, number], json: any) {
|
||||
private showRouteOnMap(geojson: unknown, origin: [number, number], destination: [number, number], json: RouteAssignedResponse['json'], originName: string) {
|
||||
const coords = this.extractCoords(geojson);
|
||||
if (!coords.length) {
|
||||
this.showFallbackRoute(origin, destination, json);
|
||||
this.showFallbackRoute(origin, destination, json, originName);
|
||||
return;
|
||||
}
|
||||
|
||||
const distance = geojson.features?.[0]?.properties?.summary?.distance
|
||||
? (geojson.features[0].properties.summary.distance / 1000).toFixed(2) : 'N/A';
|
||||
const duration = geojson.features?.[0]?.properties?.summary?.duration
|
||||
? (geojson.features[0].properties.summary.duration / 60).toFixed(0) : 'N/A';
|
||||
const g = geojson as { features?: { properties?: { summary?: { distance: number; duration: number } } }[] };
|
||||
const distance = g.features?.[0]?.properties?.summary?.distance
|
||||
? (g.features[0].properties.summary.distance / 1000).toFixed(2) : 'N/A';
|
||||
const duration = g.features?.[0]?.properties?.summary?.duration
|
||||
? (g.features[0].properties.summary.duration / 60).toFixed(0) : 'N/A';
|
||||
|
||||
this.routeGlow = L.polyline(coords, {
|
||||
color: '#6CC24A',
|
||||
@@ -163,11 +216,11 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
lineCap: 'round'
|
||||
}).addTo(this.map);
|
||||
|
||||
this.routeLine.bindPopup(
|
||||
`<strong style="color:#4F758B">${json.nombre_usuario} → ${json.nombre_pdv}</strong><br>Distancia: ${distance} km<br>Duración: ${duration} min`
|
||||
(this.routeLine as L.Polyline).bindPopup(
|
||||
`<strong style="color:#4F758B">${originName} → ${json.nombre_pdv}</strong><br>Distancia: ${distance} km<br>Duración: ${duration} min`
|
||||
);
|
||||
|
||||
this.addMarkers(origin, destination, json, distance, duration);
|
||||
this.addMarkers(origin, destination, json, distance, duration, originName);
|
||||
const group = new L.FeatureGroup([
|
||||
...(this.routeGlow ? [this.routeGlow] : []),
|
||||
...(this.routeLine ? [this.routeLine] : []),
|
||||
@@ -176,13 +229,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
|
||||
private showFallbackRoute(origin: [number, number], destination: [number, number], json: any) {
|
||||
private showFallbackRoute(origin: [number, number], destination: [number, number], json: RouteAssignedResponse['json'], originName: string) {
|
||||
this.routeLine = L.polyline(
|
||||
[[origin[1], origin[0]], [destination[1], destination[0]]],
|
||||
{ color: '#FF6A13', weight: 5, dashArray: '12, 10', opacity: 0.9 }
|
||||
).addTo(this.map);
|
||||
|
||||
this.addMarkers(origin, destination, json, 'N/A', 'N/A');
|
||||
this.addMarkers(origin, destination, json, 'N/A', 'N/A', originName);
|
||||
const group = new L.FeatureGroup([this.routeLine, ...this.markers]);
|
||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
@@ -190,9 +243,10 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
private addMarkers(
|
||||
origin: [number, number],
|
||||
destination: [number, number],
|
||||
json: any,
|
||||
json: RouteAssignedResponse['json'],
|
||||
distance: string,
|
||||
duration: string
|
||||
duration: string,
|
||||
originName: string
|
||||
) {
|
||||
const startIcon = L.divIcon({
|
||||
className: 'custom-marker marker-start',
|
||||
@@ -204,17 +258,17 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
|
||||
const endIcon = L.divIcon({
|
||||
className: 'custom-marker marker-end',
|
||||
html: '<div class="marker-inner">B</div>',
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
html: '<div class="location-pin"><span></span></div>',
|
||||
iconSize: [30, 38],
|
||||
iconAnchor: [15, 38],
|
||||
popupAnchor: [0, -18]
|
||||
});
|
||||
|
||||
this.markers.push(
|
||||
L.marker([origin[1], origin[0]], { icon: startIcon })
|
||||
.addTo(this.map)
|
||||
.bindPopup(`<strong>Origen:</strong> ${json.nombre_usuario}`),
|
||||
L.marker([destination[1], destination[0]], { icon: endIcon })
|
||||
.bindPopup(`<strong>Origen del tramo:</strong> ${originName}`),
|
||||
L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
||||
.addTo(this.map)
|
||||
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
|
||||
);
|
||||
|
||||
@@ -69,3 +69,31 @@
|
||||
color: var(--glm-acero);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.link-usuario {
|
||||
color: var(--glm-azul);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: transparent;
|
||||
transition: text-decoration-color 0.15s;
|
||||
}
|
||||
|
||||
.link-usuario:hover {
|
||||
text-decoration-color: var(--glm-verde);
|
||||
color: var(--glm-verde);
|
||||
}
|
||||
.inline-edit {
|
||||
width: 100%;
|
||||
min-width: 100px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--glm-verde);
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: var(--glm-gris-oscuro);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,19 @@
|
||||
(input)="dt.filterGlobal($any($event.target).value, 'contains')"
|
||||
/>
|
||||
<div class="actions-right">
|
||||
<button type="button" class="btn-secondary" (click)="goToOverviewMap()" pTooltip="Ver mapa filtrado por usuario" tooltipPosition="top">
|
||||
<i class="pi pi-map"></i> Ver mapa
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="downloadCSV()">
|
||||
<i class="pi pi-download"></i> Descargar CSV
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="reoptimizeLocally()" [disabled]="!canReoptimize" title="Vuelve a ejecutar el optimizador con los archivos cargados">
|
||||
<span *ngIf="reoptLoading" class="spinner-inline"></span>
|
||||
<i *ngIf="!reoptLoading" class="pi pi-sync"></i> {{ reoptLoading ? 'Reoptimizando...' : 'Reoptimizar rutas' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" *ngIf="hasLocalChanges" (click)="restoreOriginal()">
|
||||
<i class="pi pi-undo"></i> Deshacer cambios
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@@ -22,6 +32,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-error" *ngIf="reoptError"><i class="pi pi-exclamation-triangle"></i> {{ reoptError }}</div>
|
||||
|
||||
<p-table
|
||||
#dt
|
||||
@@ -60,20 +71,41 @@
|
||||
|
||||
<ng-template pTemplate="body" let-row>
|
||||
<tr>
|
||||
<td>{{ row.json.nombre_usuario }}</td>
|
||||
<td>
|
||||
<ng-container *ngIf="editingRoute === row; else userValue">
|
||||
<select class="inline-edit" [(ngModel)]="editUserId">
|
||||
<option *ngFor="let user of userOptions" [value]="user.id">{{ user.name }}</option>
|
||||
</select>
|
||||
</ng-container>
|
||||
<ng-template #userValue><a class="link-usuario" (click)="goToUserMap(row.json.usuario_id)" pTooltip="Ver todas las rutas de este usuario" tooltipPosition="top">{{ row.json.nombre_usuario }}</a></ng-template>
|
||||
</td>
|
||||
<td>{{ row.json.nombre_pdv }}</td>
|
||||
<td>{{ row.json.hora_laboral_semanal_usuario }}</td>
|
||||
<td>{{ row.json.hora_minima_semanal_pdv }}</td>
|
||||
<td>{{ row.json.horas_trabajo }}</td>
|
||||
<td>{{ row.json.dia }}</td>
|
||||
<td>
|
||||
<ng-container *ngIf="editingRoute === row; else dayValue">
|
||||
<select class="inline-edit" [(ngModel)]="editDay">
|
||||
<option *ngFor="let day of dayOptions" [value]="day">{{ day }}</option>
|
||||
</select>
|
||||
</ng-container>
|
||||
<ng-template #dayValue>{{ row.json.dia }}</ng-template>
|
||||
</td>
|
||||
<td>{{ row.json.distancia }}</td>
|
||||
<td>{{ row.json.horas_desplazamiento }}</td>
|
||||
<td class="text-center">
|
||||
<td class="text-center edit-actions">
|
||||
<ng-container *ngIf="editingRoute === row; else editButton">
|
||||
<button type="button" class="btn-icon" (click)="saveEdit()" title="Guardar"><i class="pi pi-check"></i></button>
|
||||
<button type="button" class="btn-icon" (click)="cancelEdit()" title="Cancelar"><i class="pi pi-times"></i></button>
|
||||
</ng-container>
|
||||
<ng-template #editButton>
|
||||
<button type="button" class="btn-icon" (click)="startEdit(row)" title="Editar asignación"><i class="pi pi-pencil"></i></button>
|
||||
</ng-template>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon"
|
||||
(click)="onShowMap(row)"
|
||||
pTooltip="Ver en mapa"
|
||||
pTooltip="Ver en mapa (1 ruta)"
|
||||
tooltipPosition="top"
|
||||
>
|
||||
<i class="pi pi-map-marker"></i>
|
||||
@@ -84,7 +116,7 @@
|
||||
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<tr>
|
||||
<td colspan="9" class="text-center">No hay rutas asignadas</td>
|
||||
<td colspan="9" class="text-center">No hay rutas asignadas</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
|
||||
@@ -9,6 +9,8 @@ import { TableModule } from 'primeng/table';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { TooltipModule } from 'primeng/tooltip';
|
||||
import { RouteState } from '../../../core/route-state/route';
|
||||
import { RutasService } from '../../../core/services/router.service';
|
||||
import { PlanningInputService } from '../../../core/services/planning-input.service';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -30,23 +32,37 @@ export class AssignedRouteComponent {
|
||||
assignedRoutes: RouteAssignedResponse[] = [];
|
||||
isLoading = false;
|
||||
cols: any[] = [];
|
||||
editingRoute: RouteAssignedResponse | null = null;
|
||||
editUserId = '';
|
||||
editDay = '';
|
||||
hasLocalChanges = false;
|
||||
reoptLoading = false;
|
||||
reoptError: string | null = null;
|
||||
|
||||
constructor(
|
||||
private routesState: RouteState,
|
||||
private cdr: ChangeDetectorRef,
|
||||
private router: Router
|
||||
private router: Router,
|
||||
private rutasService: RutasService,
|
||||
private planningInput: PlanningInputService
|
||||
) { }
|
||||
|
||||
private subs: import('rxjs').Subscription[] = [];
|
||||
|
||||
ngOnInit() {
|
||||
this.routesState.resetLoading();
|
||||
this.routesState.loading$.subscribe(value => {
|
||||
this.isLoading = value;
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
this.routesState.assignedRoutes$.subscribe(data => {
|
||||
this.assignedRoutes = [...data];
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
this.subs.push(
|
||||
this.routesState.loading$.subscribe(value => {
|
||||
this.isLoading = value;
|
||||
this.cdr.detectChanges();
|
||||
})
|
||||
);
|
||||
this.subs.push(
|
||||
this.routesState.assignedRoutes$.subscribe(data => {
|
||||
this.assignedRoutes = [...data];
|
||||
this.cdr.detectChanges();
|
||||
})
|
||||
);
|
||||
this.cols = [
|
||||
{ field: 'json.nombre_usuario', header: 'Usuario' },
|
||||
{ field: 'json.nombre_pdv', header: 'PDV' },
|
||||
@@ -60,14 +76,101 @@ export class AssignedRouteComponent {
|
||||
];
|
||||
|
||||
}
|
||||
onShowMap(row: RouteAssignedResponse) {
|
||||
onShowMap(row: RouteAssignedResponse) {
|
||||
this.routesState.sendRoute(row);
|
||||
this.router.navigate(['/home/assigned-route']);
|
||||
this.router.navigate(['/home/assigned-route'], {
|
||||
queryParams: { pdv_id: row.json.pdv_id, usuario_id: row.json.usuario_id, dia: row.json.dia }
|
||||
});
|
||||
}
|
||||
|
||||
goToOverviewMap() {
|
||||
// If table has filtered view, use first visible row's usuario as default; else first assigned
|
||||
const target = (this.table?.filteredValue?.[0] as RouteAssignedResponse | undefined) ?? this.assignedRoutes[0];
|
||||
if (target) {
|
||||
this.router.navigate(['/home/mapa'], {
|
||||
queryParams: { usuario_id: target.json.usuario_id, dia: 'Todos' }
|
||||
});
|
||||
} else {
|
||||
this.router.navigate(['/home/mapa']);
|
||||
}
|
||||
}
|
||||
|
||||
goToUserMap(usuarioId: string) {
|
||||
this.router.navigate(['/home/mapa'], { queryParams: { usuario_id: usuarioId, dia: 'Todos' } });
|
||||
}
|
||||
|
||||
get userOptions(): { id: string; name: string }[] {
|
||||
const users = new Map<string, string>();
|
||||
for (const route of this.assignedRoutes) users.set(String(route.json.usuario_id), route.json.nombre_usuario);
|
||||
return Array.from(users, ([id, name]) => ({ id, name }));
|
||||
}
|
||||
|
||||
get dayOptions(): string[] {
|
||||
return this.routesState.getDiasUnicos();
|
||||
}
|
||||
|
||||
startEdit(route: RouteAssignedResponse): void {
|
||||
this.editingRoute = route;
|
||||
this.editUserId = String(route.json.usuario_id);
|
||||
this.editDay = route.json.dia;
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editingRoute = null;
|
||||
}
|
||||
|
||||
saveEdit(): void {
|
||||
if (!this.editingRoute || !this.editUserId || !this.editDay) return;
|
||||
this.routesState.updateAssignedRoute(this.editingRoute, this.editUserId, this.editDay);
|
||||
this.hasLocalChanges = true;
|
||||
this.editingRoute = null;
|
||||
}
|
||||
|
||||
get canReoptimize(): boolean {
|
||||
return this.assignedRoutes.length > 0 && !!this.planningInput.getFormData() && !this.reoptLoading;
|
||||
}
|
||||
|
||||
reoptimizeLocally(): void {
|
||||
const data = this.planningInput.getFormData();
|
||||
if (!data || !this.assignedRoutes.length || this.reoptLoading) return;
|
||||
this.reoptLoading = true;
|
||||
this.reoptError = null;
|
||||
this.routesState.setLoading(true);
|
||||
this.rutasService.reoptimizarRutas(data).subscribe({
|
||||
next: response => {
|
||||
const items = response as unknown as unknown[];
|
||||
const assigned = items.find(item => item && typeof item === 'object' && 'assigned' in item) as { assigned?: unknown } | undefined;
|
||||
const noAssigned = items.find(item => item && typeof item === 'object' && 'noAssigned' in item) as { noAssigned?: unknown } | undefined;
|
||||
if (assigned?.assigned) this.routesState.setAssignedRoutes(assigned.assigned as never);
|
||||
if (noAssigned?.noAssigned) this.routesState.setNoAssignedRoutes(noAssigned.noAssigned as never);
|
||||
this.hasLocalChanges = false;
|
||||
this.reoptLoading = false;
|
||||
this.routesState.setLoading(false);
|
||||
this.cdr.detectChanges();
|
||||
},
|
||||
error: err => {
|
||||
this.reoptError = err?.error?.error || err?.message || 'No se pudo reoptimizar la planificación';
|
||||
this.reoptLoading = false;
|
||||
this.routesState.setLoading(false);
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
});
|
||||
this.editingRoute = null;
|
||||
}
|
||||
|
||||
restoreOriginal(): void {
|
||||
this.routesState.restoreOriginalAssignedRoutes();
|
||||
this.hasLocalChanges = false;
|
||||
this.editingRoute = null;
|
||||
}
|
||||
toggleRutas() {
|
||||
this.rutasExpandido = !this.rutasExpandido;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subs.forEach(s => s.unsubscribe());
|
||||
}
|
||||
|
||||
downloadCSV() {
|
||||
const headers = [
|
||||
'Usuario ID',
|
||||
@@ -119,4 +222,4 @@ export class AssignedRouteComponent {
|
||||
saveAs(blob, 'rutas_planificadas.csv');
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +233,161 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-label.has-error {
|
||||
border-color: #F5A6A0;
|
||||
background: var(--glm-rojo-ok);
|
||||
}
|
||||
|
||||
.file-label.has-success {
|
||||
border-color: var(--glm-verde);
|
||||
background: var(--glm-verde-bg);
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
background: var(--glm-surface);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.preview-success {
|
||||
border-color: var(--glm-verde-claro);
|
||||
background: var(--glm-verde-bg);
|
||||
}
|
||||
|
||||
.preview-success .preview-header {
|
||||
color: var(--glm-verde);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-error {
|
||||
border-color: #F5A6A0;
|
||||
background: var(--glm-rojo-ok);
|
||||
}
|
||||
|
||||
.preview-error .preview-header {
|
||||
color: #D32F2F;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-stats {
|
||||
margin-top: 6px;
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.preview-errors {
|
||||
margin: 8px 0 0;
|
||||
padding: 0 0 0 18px;
|
||||
color: var(--glm-gris-oscuro);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.preview-errors li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.error-value {
|
||||
color: var(--glm-acero);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.more-errors {
|
||||
color: var(--glm-acero);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.preview-loading-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--glm-acero);
|
||||
}
|
||||
|
||||
.params-card {
|
||||
margin-top: 16px;
|
||||
padding: 14px;
|
||||
background: var(--glm-surface);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid var(--glm-azul);
|
||||
}
|
||||
|
||||
.params-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--glm-azul);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.params-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.params-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.param-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.param-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--glm-azul);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.param-select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-family: Arial, sans-serif;
|
||||
background: #fff;
|
||||
color: var(--glm-gris-oscuro);
|
||||
}
|
||||
|
||||
.param-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--glm-verde);
|
||||
box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15);
|
||||
}
|
||||
|
||||
.param-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--glm-acero);
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.82rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.spinner-inline {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
|
||||
<section class="card upload-card">
|
||||
<h2 class="section-title">Subir archivos para optimización</h2>
|
||||
|
||||
|
||||
<div class="upload-grid">
|
||||
<div class="file-input-wrapper">
|
||||
<label class="file-label" for="pdvFile">
|
||||
<label class="file-label" for="pdvFile" [class.has-error]="pdvPreview && !pdvPreview.valid" [class.has-success]="pdvPreview?.valid">
|
||||
<i class="pi pi-file-excel"></i>
|
||||
<span>Archivo PDVs (Excel)</span>
|
||||
<span class="file-hint">.xlsx / .xls • máx 10MB</span>
|
||||
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 2000 filas</span>
|
||||
</label>
|
||||
<input
|
||||
id="pdvFile"
|
||||
@@ -29,16 +29,39 @@
|
||||
[class.has-file]="pdvFile"
|
||||
/>
|
||||
<div class="file-name" *ngIf="pdvFile">
|
||||
<i class="pi pi-check-circle"></i>
|
||||
<i class="pi" [class.pi-check-circle]="pdvPreview?.valid" [class.pi-exclamation-circle]="pdvPreview && !pdvPreview.valid" [class.pi-spin]="pdvPreviewLoading"></i>
|
||||
{{ pdvFile.name }}
|
||||
<span *ngIf="pdvPreviewLoading" class="preview-loading">Validando...</span>
|
||||
</div>
|
||||
|
||||
<div class="preview-card" *ngIf="pdvPreviewLoading">
|
||||
<div class="preview-loading-row"><span class="spinner-inline"></span> Analizando archivo PDV...</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-card preview-success" *ngIf="pdvPreview?.valid">
|
||||
<div class="preview-header"><i class="pi pi-check-circle"></i> PDV válido — {{ pdvPreview!.totalRows }} filas</div>
|
||||
<div class="preview-stats">{{ pdvPreview!.validRows }} filas válidas • Columnas: {{ pdvPreview!.headers.join(', ') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-card preview-error" *ngIf="pdvPreview && !pdvPreview.valid">
|
||||
<div class="preview-header"><i class="pi pi-exclamation-triangle"></i> Errores en PDV ({{ pdvPreview.errors.length }})</div>
|
||||
<div class="preview-stats" *ngIf="pdvPreview.missingHeaders.length">Faltan: {{ pdvPreview.missingHeaders.join(', ') }}</div>
|
||||
<div class="preview-stats">{{ pdvPreview.totalRows }} filas leídas • {{ pdvPreview.validRows }} válidas</div>
|
||||
<ul class="preview-errors">
|
||||
<li *ngFor="let e of pdvPreview.errors.slice(0, 8)">
|
||||
<strong>Fila {{ e.row }} — {{ e.field }}:</strong> {{ e.message }}
|
||||
<span *ngIf="e.value !== undefined && e.value !== null" class="error-value">({{ e.value }})</span>
|
||||
</li>
|
||||
<li *ngIf="pdvPreview.errors.length > 8" class="more-errors">+ {{ pdvPreview.errors.length - 8 }} errores más...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-input-wrapper">
|
||||
<label class="file-label" for="usuariosFile">
|
||||
<label class="file-label" for="usuariosFile" [class.has-error]="usuariosPreview && !usuariosPreview.valid" [class.has-success]="usuariosPreview?.valid">
|
||||
<i class="pi pi-file-excel"></i>
|
||||
<span>Archivo Usuarios (Excel)</span>
|
||||
<span class="file-hint">.xlsx / .xls • máx 10MB</span>
|
||||
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 50 filas</span>
|
||||
</label>
|
||||
<input
|
||||
id="usuariosFile"
|
||||
@@ -49,8 +72,56 @@
|
||||
[class.has-file]="usuariosFile"
|
||||
/>
|
||||
<div class="file-name" *ngIf="usuariosFile">
|
||||
<i class="pi pi-check-circle"></i>
|
||||
<i class="pi" [class.pi-check-circle]="usuariosPreview?.valid" [class.pi-exclamation-circle]="usuariosPreview && !usuariosPreview.valid"></i>
|
||||
{{ usuariosFile.name }}
|
||||
<span *ngIf="usuariosPreviewLoading" class="preview-loading">Validando...</span>
|
||||
</div>
|
||||
|
||||
<div class="preview-card" *ngIf="usuariosPreviewLoading">
|
||||
<div class="preview-loading-row"><span class="spinner-inline"></span> Analizando archivo Usuarios...</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-card preview-success" *ngIf="usuariosPreview?.valid">
|
||||
<div class="preview-header"><i class="pi pi-check-circle"></i> Usuarios válido — {{ usuariosPreview!.totalRows }} filas</div>
|
||||
<div class="preview-stats">{{ usuariosPreview!.validRows }} filas válidas • Columnas: {{ usuariosPreview!.headers.join(', ') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-card preview-error" *ngIf="usuariosPreview && !usuariosPreview.valid">
|
||||
<div class="preview-header"><i class="pi pi-exclamation-triangle"></i> Errores en Usuarios ({{ usuariosPreview.errors.length }})</div>
|
||||
<div class="preview-stats" *ngIf="usuariosPreview.missingHeaders.length">Faltan: {{ usuariosPreview.missingHeaders.join(', ') }}</div>
|
||||
<div class="preview-stats">{{ usuariosPreview.totalRows }} filas leídas • {{ usuariosPreview.validRows }} válidas</div>
|
||||
<ul class="preview-errors">
|
||||
<li *ngFor="let e of usuariosPreview.errors.slice(0, 8)">
|
||||
<strong>Fila {{ e.row }} — {{ e.field }}:</strong> {{ e.message }}
|
||||
<span *ngIf="e.value !== undefined && e.value !== null" class="error-value">({{ e.value }})</span>
|
||||
</li>
|
||||
<li *ngIf="usuariosPreview.errors.length > 8" class="more-errors">+ {{ usuariosPreview.errors.length - 8 }} errores más...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="params-card">
|
||||
<h3 class="params-title"><i class="pi pi-sliders-h"></i> Parámetros de optimización</h3>
|
||||
<div class="params-grid">
|
||||
<div class="param-field">
|
||||
<label class="param-label" for="radioKm">Radio máximo por PDV</label>
|
||||
<select id="radioKm" class="param-select" [(ngModel)]="radioKm" (ngModelChange)="onRadioChange()">
|
||||
<option [ngValue]="30">30 km — Urbano denso</option>
|
||||
<option [ngValue]="50">50 km — Urbano (default)</option>
|
||||
<option [ngValue]="80">80 km — Rural disperso</option>
|
||||
</select>
|
||||
<small class="param-hint">PDV sin usuario dentro del radio queda sin asignar</small>
|
||||
</div>
|
||||
<div class="param-field">
|
||||
<label class="param-label" for="velocidad">Velocidad promedio</label>
|
||||
<select id="velocidad" class="param-select" [(ngModel)]="velocidad" (ngModelChange)="onVelocidadChange()">
|
||||
<option [ngValue]="22">22 km/h — Centro / tráfico denso</option>
|
||||
<option [ngValue]="28">28 km/h — Urbano (default)</option>
|
||||
<option [ngValue]="35">35 km/h — Mixto</option>
|
||||
<option [ngValue]="46">46 km/h — Ruta rápida</option>
|
||||
</select>
|
||||
<small class="param-hint">Usado si ORS vial no disponible. En ciudad 22-28 es más real que 46</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,12 +136,21 @@
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
(click)="cargarRutas()"
|
||||
[disabled]="!canSubmit || isLoading"
|
||||
[disabled]="!canSubmit"
|
||||
[title]="submitDisabledReason || ''"
|
||||
>
|
||||
<span *ngIf="isLoading" class="spinner-inline"></span>
|
||||
<i *ngIf="!isLoading" class="pi pi-upload"></i>
|
||||
{{ isLoading ? 'Procesando...' : 'Cargar rutas' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
*ngIf="isLoading"
|
||||
(click)="cancelarCarga()"
|
||||
>
|
||||
<i class="pi pi-times"></i> Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@@ -80,9 +160,14 @@
|
||||
<i class="pi pi-times"></i> Limpiar
|
||||
</button>
|
||||
</div>
|
||||
<small class="hint-text" *ngIf="submitDisabledReason && !isLoading">{{ submitDisabledReason }}</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card kpis-card">
|
||||
<app-planning-kpis></app-planning-kpis>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<app-assigned-route></app-assigned-route>
|
||||
</section>
|
||||
@@ -91,4 +176,4 @@
|
||||
<h2 class="section-title">PDVs sin ruta asignada</h2>
|
||||
<app-no-assigned-route></app-no-assigned-route>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, ChangeDetectorRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AssignedRouteComponent } from './assigned-route/assigned-route.component';
|
||||
@@ -6,60 +6,170 @@ import { NoAssignedRouteComponent } from './no-assigned-route/no-assigned-route.
|
||||
import { RutasService } from '../../core/services/router.service';
|
||||
import { RouteState } from '../../core/route-state/route';
|
||||
import { ApiResponse } from '../../core/models/route.model';
|
||||
import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service';
|
||||
import { DEFAULT_PARAMS } from '../../shared/schemas/params.schema';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component';
|
||||
import { PlanningInputService } from '../../core/services/planning-input.service';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-home',
|
||||
imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent],
|
||||
imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent, PlanningKpisComponent],
|
||||
templateUrl: './home.component.html',
|
||||
styleUrls: ['./home.component.css']
|
||||
})
|
||||
export class HomeComponent {
|
||||
pdvFile: File | null = null;
|
||||
usuariosFile: File | null = null;
|
||||
pdvPreview: PreviewResult | null = null;
|
||||
usuariosPreview: PreviewResult | null = null;
|
||||
pdvPreviewLoading = false;
|
||||
usuariosPreviewLoading = false;
|
||||
uploadError: string | null = null;
|
||||
isLoading = false;
|
||||
private calcSub: Subscription | null = null;
|
||||
|
||||
// P2-A params
|
||||
radioKm: 30 | 50 | 80 = DEFAULT_PARAMS.radioKm;
|
||||
velocidad: number = DEFAULT_PARAMS.velocidad;
|
||||
|
||||
constructor(
|
||||
private rutasService: RutasService,
|
||||
private routeState: RouteState
|
||||
) {}
|
||||
private routeState: RouteState,
|
||||
private previewService: ExcelPreviewService,
|
||||
private planningInput: PlanningInputService,
|
||||
private cdr: ChangeDetectorRef
|
||||
) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem('rp_params');
|
||||
if (raw) {
|
||||
const p = JSON.parse(raw) as { radioKm?: number; velocidad?: number };
|
||||
if ([30, 50, 80].includes(p.radioKm as number)) this.radioKm = p.radioKm as 30|50|80;
|
||||
if (typeof p.velocidad === 'number' && p.velocidad >= 20 && p.velocidad <= 80) this.velocidad = p.velocidad;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
onPdvFileChange(event: Event) {
|
||||
onRadioChange() { this.persistParams(); }
|
||||
onVelocidadChange() { this.persistParams(); }
|
||||
private persistParams() {
|
||||
try { sessionStorage.setItem('rp_params', JSON.stringify({ radioKm: this.radioKm, velocidad: this.velocidad })); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async onPdvFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
this.pdvFile = input.files[0];
|
||||
this.validateFile(this.pdvFile, 'PDV');
|
||||
const file = input.files[0];
|
||||
const basicError = this.validateFileBasic(file, 'PDV');
|
||||
if (basicError) {
|
||||
this.pdvFile = null;
|
||||
this.pdvPreview = null;
|
||||
this.uploadError = basicError;
|
||||
return;
|
||||
}
|
||||
this.pdvFile = file;
|
||||
if (this.usuariosFile) this.planningInput.setFiles(file, this.usuariosFile, this.radioKm, this.velocidad);
|
||||
this.uploadError = null;
|
||||
this.pdvPreviewLoading = true;
|
||||
this.pdvPreview = null;
|
||||
this.cdr.detectChanges();
|
||||
try {
|
||||
this.pdvPreview = await this.previewService.previewFile(file, 'PDV');
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Error al leer el archivo PDV';
|
||||
this.pdvPreview = {
|
||||
fileName: file.name,
|
||||
type: 'PDV',
|
||||
valid: false,
|
||||
totalRows: 0,
|
||||
validRows: 0,
|
||||
headers: [],
|
||||
normalizedHeaders: [],
|
||||
missingHeaders: [],
|
||||
errors: [{ row: 1, field: 'file', message: msg }],
|
||||
duplicateIds: [],
|
||||
sampleValidRows: [],
|
||||
};
|
||||
} finally {
|
||||
this.pdvPreviewLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onUsuariosFileChange(event: Event) {
|
||||
async onUsuariosFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
this.usuariosFile = input.files[0];
|
||||
this.validateFile(this.usuariosFile, 'Usuarios');
|
||||
const file = input.files[0];
|
||||
const basicError = this.validateFileBasic(file, 'Usuarios');
|
||||
if (basicError) {
|
||||
this.usuariosFile = null;
|
||||
this.usuariosPreview = null;
|
||||
this.uploadError = basicError;
|
||||
return;
|
||||
}
|
||||
this.usuariosFile = file;
|
||||
if (this.pdvFile) this.planningInput.setFiles(this.pdvFile, file, this.radioKm, this.velocidad);
|
||||
this.uploadError = null;
|
||||
this.usuariosPreviewLoading = true;
|
||||
this.usuariosPreview = null;
|
||||
this.cdr.detectChanges();
|
||||
try {
|
||||
this.usuariosPreview = await this.previewService.previewFile(file, 'Usuarios');
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Error al leer el archivo Usuarios';
|
||||
this.usuariosPreview = {
|
||||
fileName: file.name,
|
||||
type: 'Usuarios',
|
||||
valid: false,
|
||||
totalRows: 0,
|
||||
validRows: 0,
|
||||
headers: [],
|
||||
normalizedHeaders: [],
|
||||
missingHeaders: [],
|
||||
errors: [{ row: 1, field: 'file', message: msg }],
|
||||
duplicateIds: [],
|
||||
sampleValidRows: [],
|
||||
};
|
||||
} finally {
|
||||
this.usuariosPreviewLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateFile(file: File, type: string) {
|
||||
private validateFileBasic(file: File, type: string): string | null {
|
||||
const validExtensions = ['.xlsx', '.xls'];
|
||||
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
|
||||
if (!validExtensions.includes(ext)) {
|
||||
this.uploadError = `El archivo ${type} debe ser .xlsx o .xls`;
|
||||
if (type === 'PDV') this.pdvFile = null;
|
||||
else this.usuariosFile = null;
|
||||
return `El archivo ${type} debe ser .xlsx o .xls`;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
this.uploadError = `El archivo ${type} supera 10MB`;
|
||||
if (type === 'PDV') this.pdvFile = null;
|
||||
else this.usuariosFile = null;
|
||||
return `El archivo ${type} supera 10MB`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return !!this.pdvFile && !!this.usuariosFile && !this.uploadError;
|
||||
if (!this.pdvFile || !this.usuariosFile) return false;
|
||||
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return false;
|
||||
if (this.isLoading) return false;
|
||||
if (this.uploadError) return false;
|
||||
// Require valid previews before enabling submit
|
||||
if (this.pdvPreview && !this.pdvPreview.valid) return false;
|
||||
if (this.usuariosPreview && !this.usuariosPreview.valid) return false;
|
||||
// Previews must exist (file was parsed)
|
||||
if (!this.pdvPreview || !this.usuariosPreview) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
get submitDisabledReason(): string | null {
|
||||
if (!this.pdvFile || !this.usuariosFile) return 'Sube ambos archivos';
|
||||
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return 'Validando archivos...';
|
||||
if (this.pdvPreview && !this.pdvPreview.valid) return 'Corrige los errores del archivo PDV';
|
||||
if (this.usuariosPreview && !this.usuariosPreview.valid) return 'Corrige los errores del archivo Usuarios';
|
||||
return null;
|
||||
}
|
||||
|
||||
cargarRutas() {
|
||||
@@ -68,39 +178,81 @@ export class HomeComponent {
|
||||
this.isLoading = true;
|
||||
this.uploadError = null;
|
||||
this.routeState.setLoading(true);
|
||||
this.planningInput.setFiles(this.pdvFile!, this.usuariosFile!, this.radioKm, this.velocidad);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('pdv_file', this.pdvFile!, this.pdvFile!.name);
|
||||
formData.append('usuarios_file', this.usuariosFile!, this.usuariosFile!.name);
|
||||
formData.append('radio_km', String(this.radioKm));
|
||||
formData.append('velocidad', String(this.velocidad));
|
||||
|
||||
this.rutasService.calcularRutas(formData).subscribe({
|
||||
this.calcSub?.unsubscribe();
|
||||
this.calcSub = this.rutasService.calcularRutas(formData).subscribe({
|
||||
next: (res: ApiResponse) => {
|
||||
const assigned = res.find(r => 'assigned' in r);
|
||||
const noAssigned = res.find(r => 'noAssigned' in r);
|
||||
// ApiResponse is [AssignedResponse, NoAssignedResponse] but backend may return object with error
|
||||
const anyRes = res as unknown as Record<string, unknown>;
|
||||
if (anyRes && 'error' in anyRes) {
|
||||
this.uploadError = String((anyRes as { error: unknown }).error);
|
||||
this.routeState.setLoading(false);
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
return;
|
||||
}
|
||||
const arr = res as unknown as unknown[];
|
||||
const assigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'assigned' in r) as unknown as { assigned: unknown } | undefined;
|
||||
const noAssigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'noAssigned' in r) as unknown as { noAssigned: unknown } | undefined;
|
||||
if (assigned) {
|
||||
this.routeState.setAssignedRoutes(assigned.assigned);
|
||||
this.routeState.setAssignedRoutes(assigned.assigned as never);
|
||||
}
|
||||
if (noAssigned) {
|
||||
this.routeState.setNoAssignedRoutes(noAssigned.noAssigned);
|
||||
this.routeState.setNoAssignedRoutes(noAssigned.noAssigned as never);
|
||||
}
|
||||
this.routeState.setLoading(false);
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
},
|
||||
error: (err) => {
|
||||
this.uploadError = err.error?.message || 'Error al procesar los archivos';
|
||||
this.uploadError = this.mapBackendError(err);
|
||||
this.routeState.setLoading(false);
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelarCarga() {
|
||||
this.calcSub?.unsubscribe();
|
||||
this.calcSub = null;
|
||||
this.isLoading = false;
|
||||
this.routeState.setLoading(false);
|
||||
this.uploadError = 'Cálculo cancelado';
|
||||
}
|
||||
|
||||
private mapBackendError(err: unknown): string {
|
||||
const anyErr = err as { error?: { error?: string; message?: string }; message?: string; status?: number; name?: string };
|
||||
if (anyErr?.name === 'TimeoutError') return 'El cálculo tardó demasiado (timeout 30s). Intenta de nuevo con menos filas.';
|
||||
if (anyErr?.error?.error) return anyErr.error.error;
|
||||
if (anyErr?.error?.message) return anyErr.error.message;
|
||||
if (anyErr?.message) return anyErr.message;
|
||||
return 'Error al procesar los archivos';
|
||||
}
|
||||
|
||||
clearFiles() {
|
||||
this.calcSub?.unsubscribe();
|
||||
this.planningInput.clear();
|
||||
this.routeState.clearAll();
|
||||
this.pdvFile = null;
|
||||
this.usuariosFile = null;
|
||||
this.pdvPreview = null;
|
||||
this.usuariosPreview = null;
|
||||
this.pdvPreviewLoading = false;
|
||||
this.usuariosPreviewLoading = false;
|
||||
this.uploadError = null;
|
||||
this.isLoading = false;
|
||||
this.routeState.setLoading(false);
|
||||
const pdvInput = document.getElementById('pdvFile') as HTMLInputElement;
|
||||
const usuariosInput = document.getElementById('usuariosFile') as HTMLInputElement;
|
||||
if (pdvInput) pdvInput.value = '';
|
||||
if (usuariosInput) usuariosInput.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,3 +11,42 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.assignment-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 230px;
|
||||
}
|
||||
|
||||
.inline-select {
|
||||
max-width: 115px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: var(--glm-gris-oscuro);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.spinner-inline {
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.table-error {
|
||||
margin-top: 10px;
|
||||
padding: 9px 12px;
|
||||
background: var(--glm-rojo-ok);
|
||||
border: 1px solid #F5A6A0;
|
||||
border-radius: 4px;
|
||||
color: var(--glm-gris-oscuro);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
+18
-3
@@ -19,14 +19,29 @@
|
||||
<td class="text-left">{{ row.minimo_horas_semana }}</td>
|
||||
<td class="text-left">{{ row.latitud }}</td>
|
||||
<td class="text-left">{{ row.longitud }}</td>
|
||||
<td class="text-left">{{ row.motivo }}</td>
|
||||
<td class="text-left">{{ row.motivo }}</td>
|
||||
<td class="assignment-actions">
|
||||
<select [(ngModel)]="assignmentUser[row.pdv_id]" class="inline-select">
|
||||
<option value="">Usuario</option>
|
||||
<option *ngFor="let user of userOptions" [value]="user.id">{{ user.name }}</option>
|
||||
</select>
|
||||
<select [(ngModel)]="assignmentDay[row.pdv_id]" class="inline-select">
|
||||
<option value="">Día</option>
|
||||
<option *ngFor="let day of dayOptions" [value]="day">{{ day }}</option>
|
||||
</select>
|
||||
<button type="button" class="btn-icon" (click)="assignRoute(row)" [disabled]="assigningId === row.pdv_id" title="Asignar y calcular ruta">
|
||||
<span *ngIf="assigningId === row.pdv_id" class="spinner-inline"></span>
|
||||
<i *ngIf="assigningId !== row.pdv_id" class="pi pi-plus"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<tr>
|
||||
<td colspan="8" class="text-center">
|
||||
<td colspan="7" class="text-center">
|
||||
No hay registros asignados
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
</p-table>
|
||||
<div class="table-error" *ngIf="actionError"><i class="pi pi-exclamation-triangle"></i> {{ actionError }}</div>
|
||||
|
||||
+67
-13
@@ -1,36 +1,49 @@
|
||||
import { Component, ChangeDetectorRef } from '@angular/core';
|
||||
import { Component, ChangeDetectorRef, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { RouteNoAssignedResponse } from '../../../core/models/route.model';
|
||||
import { RouteState } from '../../../core/route-state/route';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { OrsService } from '../../../core/services/ors.service';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-no-assigned-route',
|
||||
imports: [CommonModule, TableModule],
|
||||
imports: [CommonModule, FormsModule, TableModule],
|
||||
templateUrl: './no-assigned-route.component.html',
|
||||
styleUrls: ['./no-assigned-route.component.css']
|
||||
})
|
||||
export class NoAssignedRouteComponent {
|
||||
export class NoAssignedRouteComponent implements OnDestroy {
|
||||
|
||||
noAssignedRoutes: RouteNoAssignedResponse[] = [];
|
||||
isLoading = false;
|
||||
cols: any[] = [];
|
||||
cols: { field: string; header: string }[] = [];
|
||||
private subs: Subscription[] = [];
|
||||
assignmentUser: Record<string, string> = {};
|
||||
assignmentDay: Record<string, string> = {};
|
||||
assigningId: number | null = null;
|
||||
actionError: string | null = null;
|
||||
|
||||
constructor(
|
||||
private routesState: RouteState,
|
||||
private cdr: ChangeDetectorRef
|
||||
private cdr: ChangeDetectorRef,
|
||||
private orsService: OrsService
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.routesState.loading$.subscribe(value => {
|
||||
this.isLoading = value;
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
this.routesState.noAssignedRoutes$.subscribe(data => {
|
||||
this.noAssignedRoutes = [...data];
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
this.subs.push(
|
||||
this.routesState.loading$.subscribe(value => {
|
||||
this.isLoading = value;
|
||||
this.cdr.detectChanges();
|
||||
})
|
||||
);
|
||||
this.subs.push(
|
||||
this.routesState.noAssignedRoutes$.subscribe(data => {
|
||||
this.noAssignedRoutes = [...data];
|
||||
this.cdr.detectChanges();
|
||||
})
|
||||
);
|
||||
this.cols = [
|
||||
{ field: 'pdv_id', header: 'PDV ID' },
|
||||
{ field: 'pdv_nombre', header: 'Nombre' },
|
||||
@@ -40,4 +53,45 @@ export class NoAssignedRouteComponent {
|
||||
{ field: 'motivo', header: 'Motivo' }
|
||||
];
|
||||
}
|
||||
|
||||
get userOptions(): { id: string; name: string }[] {
|
||||
return this.routesState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre }));
|
||||
}
|
||||
|
||||
get dayOptions(): string[] {
|
||||
return this.routesState.getDiasUnicos();
|
||||
}
|
||||
|
||||
async assignRoute(route: RouteNoAssignedResponse): Promise<void> {
|
||||
const userId = this.assignmentUser[String(route.pdv_id)];
|
||||
const day = this.assignmentDay[String(route.pdv_id)];
|
||||
if (!userId || !day) {
|
||||
this.actionError = 'Selecciona usuario y día antes de asignar.';
|
||||
return;
|
||||
}
|
||||
const user = this.routesState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === userId);
|
||||
if (!user) return;
|
||||
this.assigningId = route.pdv_id;
|
||||
this.actionError = null;
|
||||
try {
|
||||
const origin = [Number(user.json.longitud_usuario), Number(user.json.latitud_usuario)];
|
||||
const destination = [Number(route.longitud), Number(route.latitud)];
|
||||
const result = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true);
|
||||
const geo = (result[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] } }).geojson;
|
||||
const summary = geo?.features?.[0]?.properties?.summary;
|
||||
if (!summary) throw new Error('ORS no devolvió una ruta vial');
|
||||
this.routesState.addAssignedFromUnassigned(route, userId, day, summary.distance / 1000, summary.duration / 3600);
|
||||
delete this.assignmentUser[String(route.pdv_id)];
|
||||
delete this.assignmentDay[String(route.pdv_id)];
|
||||
} catch {
|
||||
this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`;
|
||||
} finally {
|
||||
this.assigningId = null;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subs.forEach(s => s.unsubscribe());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
.kpis-panel {
|
||||
border-top: 3px solid var(--glm-azul);
|
||||
}
|
||||
|
||||
.kpis-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--glm-azul);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.kpis-subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.kpis-status {
|
||||
color: var(--glm-verde);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kpis-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--glm-verde);
|
||||
border-radius: 4px;
|
||||
background: var(--glm-verde-bg);
|
||||
color: var(--glm-azul);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.export-button:hover {
|
||||
background: var(--glm-verde);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 92px;
|
||||
padding: 14px;
|
||||
background: var(--glm-surface);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-left: 4px solid var(--glm-azul);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.kpi-primary { border-left-color: var(--glm-azul); }
|
||||
.kpi-success { border-left-color: var(--glm-verde); }
|
||||
.kpi-warning { border-left-color: var(--glm-naranja); }
|
||||
.kpi-blue { border-left-color: var(--glm-acero); }
|
||||
.kpi-orange { border-left-color: var(--glm-naranja); }
|
||||
|
||||
.kpi-icon {
|
||||
display: grid;
|
||||
flex: 0 0 34px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--glm-verde-bg);
|
||||
color: var(--glm-azul);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.kpi-card > div:last-child {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.kpi-card strong {
|
||||
color: var(--glm-azul);
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.kpi-card em {
|
||||
font-size: 0.8rem;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kpi-card small {
|
||||
overflow: hidden;
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.73rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.operational-grid {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.user-load-card {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
background: var(--glm-surface);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.user-load-card h3 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--glm-azul);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.user-load-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.user-load-table {
|
||||
width: 100%;
|
||||
min-width: 560px;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.user-load-table th {
|
||||
padding: 7px 8px;
|
||||
background: var(--glm-azul);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.user-load-table td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--glm-gris-claro);
|
||||
color: var(--glm-gris-oscuro);
|
||||
}
|
||||
|
||||
.user-load-table tbody tr:nth-child(even) {
|
||||
background: var(--glm-gris-claro);
|
||||
}
|
||||
|
||||
.utilization-cell {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(70px, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.utilization-bar {
|
||||
display: block;
|
||||
height: 7px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background: var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.utilization-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--glm-verde);
|
||||
}
|
||||
|
||||
.reasons-card {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
background: var(--glm-verde-bg);
|
||||
border: 1px solid var(--glm-verde-claro);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.reasons-card h3 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--glm-azul);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.reason-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.reason-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid rgba(108, 194, 74, 0.25);
|
||||
color: var(--glm-gris-oscuro);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.reason-row:last-child { border-bottom: 0; }
|
||||
.reason-row strong { color: var(--glm-azul); }
|
||||
|
||||
.kpis-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.kpis-empty i { color: var(--glm-gris-medio); }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.kpis-heading { flex-direction: column; gap: 6px; }
|
||||
.kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.kpi-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<div class="kpis-panel">
|
||||
<div class="kpis-heading">
|
||||
<div>
|
||||
<h2 class="section-title">Indicadores de planificación</h2>
|
||||
<p class="kpis-subtitle">Resumen total de la planificación actual</p>
|
||||
</div>
|
||||
<div class="kpis-actions">
|
||||
<span class="kpis-status" *ngIf="hasData"><i class="pi pi-check-circle"></i> Datos disponibles</span>
|
||||
<button type="button" class="export-button" *ngIf="hasData" (click)="exportPlanning()"><i class="pi pi-download"></i> Descargar XLSX</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kpi-grid">
|
||||
<article class="kpi-card kpi-primary">
|
||||
<div class="kpi-icon"><i class="pi pi-map-marker"></i></div>
|
||||
<div><span class="kpi-label">PDVs totales</span><strong>{{ summary.totalPdvs }}</strong><small>en la planificación</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-success">
|
||||
<div class="kpi-icon"><i class="pi pi-check"></i></div>
|
||||
<div><span class="kpi-label">PDVs asignados</span><strong>{{ summary.assignedPdvs }}</strong><small>{{ summary.coverage | number:'1.1-1' }}% de cobertura</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-warning">
|
||||
<div class="kpi-icon"><i class="pi pi-exclamation-triangle"></i></div>
|
||||
<div><span class="kpi-label">Sin asignar</span><strong>{{ summary.unassignedPdvs }}</strong><small>requieren revisión</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-blue">
|
||||
<div class="kpi-icon"><i class="pi pi-users"></i></div>
|
||||
<div><span class="kpi-label">Usuarios activos</span><strong>{{ summary.users }}</strong><small>{{ summary.days }} días planificados</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-orange">
|
||||
<div class="kpi-icon"><i class="pi pi-compass"></i></div>
|
||||
<div><span class="kpi-label">Distancia total</span><strong>{{ summary.totalDistance | number:'1.0-1' }} <em>km</em></strong><small>{{ summary.averageDistance | number:'1.0-1' }} km promedio por ruta</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-blue">
|
||||
<div class="kpi-icon"><i class="pi pi-clock"></i></div>
|
||||
<div><span class="kpi-label">Desplazamiento total</span><strong>{{ summary.totalTravelHours | number:'1.0-1' }} <em>h</em></strong><small>{{ summary.averageTravelHours | number:'1.0-1' }} h promedio por ruta</small></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="kpi-grid operational-grid" *ngIf="hasData">
|
||||
<article class="kpi-card kpi-success">
|
||||
<div class="kpi-icon"><i class="pi pi-briefcase"></i></div>
|
||||
<div><span class="kpi-label">Carga de trabajo</span><strong>{{ summary.totalWorkHours | number:'1.0-1' }} <em>h</em></strong><small>horas de trabajo planificadas</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-blue">
|
||||
<div class="kpi-icon"><i class="pi pi-chart-line"></i></div>
|
||||
<div><span class="kpi-label">Utilización promedio</span><strong>{{ summary.averageUtilization | number:'1.0-1' }}<em>%</em></strong><small>carga frente a disponibilidad</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-primary">
|
||||
<div class="kpi-icon"><i class="pi pi-sitemap"></i></div>
|
||||
<div><span class="kpi-label">Rutas por usuario</span><strong>{{ summary.averageRoutesPerUser | number:'1.0-1' }}</strong><small>promedio de asignaciones</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-warning">
|
||||
<div class="kpi-icon"><i class="pi pi-arrows-h"></i></div>
|
||||
<div><span class="kpi-label">Brecha de balance</span><strong>{{ summary.assignmentBalance }}</strong><small>diferencia entre mayor y menor carga</small></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="user-load-card" *ngIf="userLoads.length">
|
||||
<h3><i class="pi pi-users"></i> Balance de carga por usuario</h3>
|
||||
<div class="user-load-table-wrap">
|
||||
<table class="user-load-table">
|
||||
<thead>
|
||||
<tr><th>Usuario</th><th>Rutas</th><th>Distancia</th><th>Trabajo</th><th>Utilización</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let load of userLoads">
|
||||
<td>{{ load.name }}</td>
|
||||
<td>{{ load.routes }}</td>
|
||||
<td>{{ load.distance | number:'1.0-1' }} km</td>
|
||||
<td>{{ load.workHours | number:'1.0-1' }} h</td>
|
||||
<td>
|
||||
<div class="utilization-cell">
|
||||
<span>{{ load.utilization | number:'1.0-1' }}%</span>
|
||||
<span class="utilization-bar"><span [style.width.%]="barWidth(load.utilization)"></span></span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reasons-card" *ngIf="reasons.length">
|
||||
<h3><i class="pi pi-list"></i> Motivos de PDVs sin asignar</h3>
|
||||
<div class="reason-list">
|
||||
<div class="reason-row" *ngFor="let item of reasons">
|
||||
<span>{{ item.reason }}</span>
|
||||
<strong>{{ item.count }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kpis-empty" *ngIf="!hasData">
|
||||
<i class="pi pi-chart-bar"></i>
|
||||
<span>Carga una planificación para visualizar sus indicadores.</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { RouteState } from '../../../core/route-state/route';
|
||||
import { PlanningExportService } from '../../../shared/services/planning-export.service';
|
||||
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
||||
|
||||
interface KpiSummary {
|
||||
totalPdvs: number;
|
||||
assignedPdvs: number;
|
||||
unassignedPdvs: number;
|
||||
coverage: number;
|
||||
users: number;
|
||||
days: number;
|
||||
totalDistance: number;
|
||||
totalTravelHours: number;
|
||||
averageDistance: number;
|
||||
averageTravelHours: number;
|
||||
totalWorkHours: number;
|
||||
averageRoutesPerUser: number;
|
||||
averageUtilization: number;
|
||||
assignmentBalance: number;
|
||||
}
|
||||
|
||||
interface ReasonSummary {
|
||||
reason: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface UserLoad {
|
||||
name: string;
|
||||
routes: number;
|
||||
distance: number;
|
||||
workHours: number;
|
||||
capacityHours: number;
|
||||
utilization: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-planning-kpis',
|
||||
imports: [CommonModule],
|
||||
templateUrl: './planning-kpis.component.html',
|
||||
styleUrl: './planning-kpis.component.css'
|
||||
})
|
||||
export class PlanningKpisComponent implements OnInit, OnDestroy {
|
||||
summary: KpiSummary = this.emptySummary();
|
||||
reasons: ReasonSummary[] = [];
|
||||
userLoads: UserLoad[] = [];
|
||||
hasData = false;
|
||||
|
||||
private readonly subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
private routeState: RouteState,
|
||||
private exportService: PlanningExportService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.subs.push(
|
||||
this.routeState.assignedRoutes$.subscribe(() => this.refresh()),
|
||||
this.routeState.noAssignedRoutes$.subscribe(() => this.refresh())
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.forEach(subscription => subscription.unsubscribe());
|
||||
}
|
||||
|
||||
barWidth(value: number): number {
|
||||
return Math.min(Math.max(value, 0), 100);
|
||||
}
|
||||
|
||||
exportPlanning(): void {
|
||||
this.exportService.export();
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
const assigned = this.routeState.getAssignedSnapshot();
|
||||
const unassigned = this.routeState.getNoAssignedSnapshot();
|
||||
const totalPdvs = assigned.length + unassigned.length;
|
||||
const totalDistance = assigned.reduce((sum, route) => sum + this.numberValue(route.json.distancia), 0);
|
||||
const totalTravelHours = assigned.reduce((sum, route) => sum + this.numberValue(route.json.horas_desplazamiento), 0);
|
||||
const totalWorkHours = assigned.reduce((sum, route) => sum + this.parseHours(route.json.horas_trabajo), 0);
|
||||
const users = new Set(assigned.map(route => String(route.json.usuario_id))).size;
|
||||
const days = new Set(assigned.map(route => route.json.dia)).size;
|
||||
const loads = new Map<string, UserLoad>();
|
||||
|
||||
for (const route of assigned) {
|
||||
const key = String(route.json.usuario_id);
|
||||
const current = loads.get(key) ?? {
|
||||
name: route.json.nombre_usuario,
|
||||
routes: 0,
|
||||
distance: 0,
|
||||
workHours: 0,
|
||||
capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario),
|
||||
utilization: 0
|
||||
};
|
||||
current.routes++;
|
||||
current.distance += this.numberValue(route.json.distancia);
|
||||
current.workHours += this.parseHours(route.json.horas_trabajo);
|
||||
loads.set(key, current);
|
||||
}
|
||||
|
||||
this.userLoads = Array.from(loads.values())
|
||||
.map(load => ({
|
||||
...load,
|
||||
utilization: load.capacityHours ? (load.workHours / load.capacityHours) * 100 : 0
|
||||
}))
|
||||
.sort((a, b) => b.routes - a.routes || b.workHours - a.workHours);
|
||||
const routeCounts = this.userLoads.map(load => load.routes);
|
||||
const assignmentBalance = routeCounts.length ? Math.max(...routeCounts) - Math.min(...routeCounts) : 0;
|
||||
const averageUtilization = this.userLoads.length
|
||||
? this.userLoads.reduce((sum, load) => sum + load.utilization, 0) / this.userLoads.length
|
||||
: 0;
|
||||
|
||||
this.summary = {
|
||||
totalPdvs,
|
||||
assignedPdvs: assigned.length,
|
||||
unassignedPdvs: unassigned.length,
|
||||
coverage: totalPdvs ? (assigned.length / totalPdvs) * 100 : 0,
|
||||
users,
|
||||
days,
|
||||
totalDistance,
|
||||
totalTravelHours,
|
||||
averageDistance: assigned.length ? totalDistance / assigned.length : 0,
|
||||
averageTravelHours: assigned.length ? totalTravelHours / assigned.length : 0,
|
||||
totalWorkHours,
|
||||
averageRoutesPerUser: users ? assigned.length / users : 0,
|
||||
averageUtilization,
|
||||
assignmentBalance
|
||||
};
|
||||
|
||||
const reasonCounts = new Map<string, number>();
|
||||
for (const route of unassigned) {
|
||||
const reason = route.motivo?.trim() || 'Sin motivo especificado';
|
||||
reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1);
|
||||
}
|
||||
this.reasons = Array.from(reasonCounts.entries())
|
||||
.map(([reason, count]) => ({ reason, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
this.hasData = totalPdvs > 0;
|
||||
}
|
||||
|
||||
private numberValue(value: number | string | null | undefined): number {
|
||||
const result = Number(value);
|
||||
return Number.isFinite(result) ? result : 0;
|
||||
}
|
||||
|
||||
private parseHours(value: number | string | null | undefined): number {
|
||||
const result = Number.parseFloat(String(value ?? ''));
|
||||
return Number.isFinite(result) ? result : 0;
|
||||
}
|
||||
|
||||
private emptySummary(): KpiSummary {
|
||||
return {
|
||||
totalPdvs: 0,
|
||||
assignedPdvs: 0,
|
||||
unassignedPdvs: 0,
|
||||
coverage: 0,
|
||||
users: 0,
|
||||
days: 0,
|
||||
totalDistance: 0,
|
||||
totalTravelHours: 0,
|
||||
averageDistance: 0,
|
||||
averageTravelHours: 0,
|
||||
totalWorkHours: 0,
|
||||
averageRoutesPerUser: 0,
|
||||
averageUtilization: 0,
|
||||
assignmentBalance: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
+556
@@ -0,0 +1,556 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#map-overview {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 380px;
|
||||
width: auto;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.overview-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 380px;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
background: var(--glm-surface);
|
||||
border-right: 3px solid var(--glm-verde);
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
height: 42px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
background: var(--glm-surface);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
color: var(--glm-azul);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: var(--glm-verde-bg);
|
||||
border-color: var(--glm-verde);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--glm-azul);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.filters-card {
|
||||
background: var(--glm-verde-bg);
|
||||
border: 1px solid var(--glm-verde-claro);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--glm-azul);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-family: Arial, sans-serif;
|
||||
background: #fff;
|
||||
color: var(--glm-gris-oscuro);
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--glm-verde);
|
||||
box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15);
|
||||
}
|
||||
|
||||
.filter-stats {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--glm-acero);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-empty {
|
||||
font-size: 0.85rem;
|
||||
color: var(--glm-acero);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 24px 12px;
|
||||
color: var(--glm-acero);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
color: var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.error-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--glm-rojo-ok);
|
||||
border: 1px solid #F5A6A0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--glm-gris-oscuro);
|
||||
}
|
||||
|
||||
.unassigned-map-card {
|
||||
padding: 12px;
|
||||
background: #fff8ed;
|
||||
border: 1px solid #f2bf86;
|
||||
border-left: 3px solid var(--glm-naranja);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.unassigned-map-card .info-card-header { margin-bottom: 8px; }
|
||||
|
||||
.unassigned-map-list {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.unassigned-map-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px;
|
||||
border: 1px solid #f2bf86;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: var(--glm-gris-oscuro);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.unassigned-map-item:hover,
|
||||
.unassigned-map-item.selected-pending { background: #ffedd9; }
|
||||
|
||||
.pending-icon {
|
||||
display: grid;
|
||||
flex: 0 0 23px;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--glm-naranja);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pending-text { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
|
||||
.pending-text strong { font-size: 0.78rem; }
|
||||
.pending-text small { overflow: hidden; color: var(--glm-acero); font-size: 0.7rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.unassigned-detail-card { border-top-color: var(--glm-naranja); }
|
||||
|
||||
.pending-edit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.pending-edit-grid label { display: flex; flex-direction: column; gap: 3px; color: var(--glm-azul); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; }
|
||||
.pending-edit-grid input,
|
||||
.pending-edit-grid select { width: 100%; padding: 6px; border: 1px solid var(--glm-gris-medio); border-radius: 3px; background: #fff; color: var(--glm-gris-oscuro); font: inherit; font-size: 0.8rem; }
|
||||
.wide-field { grid-column: 1 / -1; }
|
||||
.pending-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
|
||||
.pending-actions .btn-primary, .pending-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
|
||||
|
||||
.routes-list h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--glm-azul);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.routes-list ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.routes-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.routes-list li:hover {
|
||||
background: var(--glm-verde-bg);
|
||||
border-color: var(--glm-verde);
|
||||
}
|
||||
|
||||
.routes-list li.selected {
|
||||
background: var(--glm-verde);
|
||||
border-color: var(--glm-verde);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.routes-list li.selected small {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.routes-list li.selected i {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.idx {
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--glm-azul);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.li-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.li-main strong {
|
||||
font-size: 0.85rem;
|
||||
color: var(--glm-gris-oscuro);
|
||||
}
|
||||
|
||||
.routes-list li.selected .li-main strong {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.li-main small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--glm-acero);
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: var(--glm-surface);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
padding: 14px;
|
||||
border-top: 3px solid var(--glm-azul);
|
||||
}
|
||||
|
||||
.info-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 2px solid var(--glm-verde);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--glm-azul);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.detail-section + .detail-section {
|
||||
margin-top: 14px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.detail-section h4 {
|
||||
margin: 0 0 5px;
|
||||
color: var(--glm-verde);
|
||||
font-size: 0.76rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.reassign-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.reassign-form label { display: flex; flex-direction: column; gap: 3px; color: var(--glm-azul); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; }
|
||||
.reassign-form select { width: 100%; padding: 6px; border: 1px solid var(--glm-gris-medio); border-radius: 3px; background: #fff; color: var(--glm-gris-oscuro); font: inherit; font-size: 0.8rem; }
|
||||
.reassign-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
|
||||
.reassign-actions .btn-primary, .reassign-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
|
||||
.reassign-hint { grid-column: 1 / -1; margin: 0; color: var(--glm-acero); font-size: 0.74rem; }
|
||||
.reassign-form .reassign-actions, .reassign-form .error-card { grid-column: 1 / -1; }
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
color: var(--glm-azul);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--glm-verde-bg);
|
||||
border-color: var(--glm-verde);
|
||||
}
|
||||
|
||||
.info-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-list div {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 10px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--glm-gris-claro);
|
||||
}
|
||||
|
||||
.info-list div:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-list dt {
|
||||
font-weight: 600;
|
||||
color: var(--glm-azul);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-list dd {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--glm-gris-oscuro);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.overview-loading {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid var(--glm-verde-claro);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
|
||||
color: var(--glm-acero);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--glm-gris-medio);
|
||||
border-top-color: var(--glm-verde);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--glm-surface);
|
||||
color: var(--glm-azul);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
padding: 8px 14px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--glm-verde-bg);
|
||||
border-color: var(--glm-verde);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.layer-toggles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: var(--glm-gris-oscuro);
|
||||
}
|
||||
|
||||
.heat-opts {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-left: 22px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--glm-acero);
|
||||
}
|
||||
|
||||
.cluster-glm {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.cluster-inner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--glm-verde);
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.custom-marker {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.marker-inner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.location-pin {
|
||||
position: relative;
|
||||
width: 30px;
|
||||
height: 38px;
|
||||
background: #c9252d;
|
||||
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
|
||||
}
|
||||
|
||||
.location-pin::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3px 3px 7px;
|
||||
background: var(--pin-color, #ff424a);
|
||||
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
|
||||
}
|
||||
|
||||
.location-pin::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #620000;
|
||||
}
|
||||
|
||||
.location-pin span {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 14px;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.unassigned-pin { --pin-color: #ff424a; }
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
<div id="map-overview"></div>
|
||||
|
||||
<div class="overview-loading" *ngIf="isLoading">
|
||||
<div class="spinner"></div>
|
||||
<p>Cargando rutas ORS... {{ orsProgress }}/{{ orsTotal }}</p>
|
||||
</div>
|
||||
|
||||
<div class="overview-sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<img src="GLM_completo.png" alt="GomezLee Marketing" class="sidebar-logo" />
|
||||
</div>
|
||||
|
||||
<div class="sidebar-header">
|
||||
<button type="button" class="back-btn" (click)="goBack()">
|
||||
<i class="pi pi-arrow-left"></i> Volver
|
||||
</button>
|
||||
<h2 class="sidebar-title">Mapa de rutas</h2>
|
||||
</div>
|
||||
|
||||
<div class="filters-card" *ngIf="usuarios.length">
|
||||
<label class="filter-label">Usuario</label>
|
||||
<select class="filter-select" [(ngModel)]="selectedUsuarioId" (ngModelChange)="onUsuarioChange()">
|
||||
<option value="Todos">Todos los usuarios</option>
|
||||
<option *ngFor="let u of usuarios" [value]="u.id">{{ u.nombre }} ({{ u.id }})</option>
|
||||
</select>
|
||||
|
||||
<label class="filter-label">Día</label>
|
||||
<select class="filter-select" [(ngModel)]="selectedDia" (ngModelChange)="onDiaChange()">
|
||||
<option value="Todos">Todos</option>
|
||||
<option *ngFor="let d of dias" [value]="d">{{ d }}</option>
|
||||
</select>
|
||||
|
||||
<div class="filter-stats" *ngIf="filteredRoutes.length">
|
||||
<span>{{ filteredRoutes.length }} PDVs</span>
|
||||
<span>{{ totalDistancia }} km</span>
|
||||
<span>{{ totalDuracionMin }} min</span>
|
||||
</div>
|
||||
|
||||
<div class="layer-toggles">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [checked]="showHeat" (change)="toggleHeat()" />
|
||||
<i class="pi pi-fire"></i> Heatmap (carga)
|
||||
</label>
|
||||
<div class="heat-opts" *ngIf="showHeat">
|
||||
<label><input type="radio" name="hw" value="uniform" [checked]="heatWeightMode==='uniform'" (change)="setHeatWeight('uniform')" /> Peso 1 (densidad)</label>
|
||||
<label><input type="radio" name="hw" value="horas_trabajo" [checked]="heatWeightMode==='horas_trabajo'" (change)="setHeatWeight('horas_trabajo')" /> Por carga (horas)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-empty" *ngIf="!filteredRoutes.length && !error">
|
||||
Sin rutas para este filtro.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" *ngIf="!usuarios.length">
|
||||
<i class="pi pi-inbox"></i>
|
||||
<p>No hay rutas cargadas.</p>
|
||||
<button type="button" class="btn-secondary" (click)="goBack()">Ir a Home</button>
|
||||
</div>
|
||||
|
||||
<div class="error-card" *ngIf="error && usuarios.length">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<section class="unassigned-map-card" *ngIf="selectedUsuarioId === 'Todos' && selectedDia === 'Todos' && unassignedRoutes.length">
|
||||
<div class="info-card-header">
|
||||
<h3>PDVs sin ruta ({{ unassignedRoutes.length }})</h3>
|
||||
</div>
|
||||
<div class="unassigned-map-list">
|
||||
<button type="button" class="unassigned-map-item" *ngFor="let pending of unassignedRoutes" [class.selected-pending]="selectedUnassigned?.pdv_id === pending.pdv_id" (click)="selectUnassigned(pending)">
|
||||
<span class="pending-icon">!</span>
|
||||
<span class="pending-text"><strong>{{ pending.pdv_nombre }}</strong><small>{{ pending.motivo }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="info-card unassigned-detail-card" *ngIf="selectedUnassigned">
|
||||
<div class="info-card-header">
|
||||
<h3>Gestionar PDV sin ruta</h3>
|
||||
<button type="button" class="btn-icon" (click)="selectedUnassigned = null"><i class="pi pi-times"></i></button>
|
||||
</div>
|
||||
<ng-container *ngIf="editingUnassigned; else pendingSummary">
|
||||
<div class="pending-edit-grid">
|
||||
<label>Nombre<input [(ngModel)]="editUnassignedName" type="text"></label>
|
||||
<label>Horas mínimas<input [(ngModel)]="editUnassignedHours" type="number" min="0.01" step="0.25"></label>
|
||||
<label>Latitud<input [(ngModel)]="editUnassignedLat" type="number" step="any"></label>
|
||||
<label>Longitud<input [(ngModel)]="editUnassignedLng" type="number" step="any"></label>
|
||||
<label class="wide-field">Motivo<input [(ngModel)]="editUnassignedReason" type="text"></label>
|
||||
</div>
|
||||
<div class="pending-actions"><button type="button" class="btn-secondary" (click)="saveUnassignedEdit()"><i class="pi pi-check"></i> Guardar</button><button type="button" class="btn-secondary" (click)="cancelUnassignedEdit()">Cancelar</button></div>
|
||||
</ng-container>
|
||||
<ng-template #pendingSummary>
|
||||
<dl class="info-list">
|
||||
<div><dt>PDV</dt><dd>{{ selectedUnassigned.pdv_nombre }}</dd></div>
|
||||
<div><dt>Motivo</dt><dd>{{ selectedUnassigned.motivo }}</dd></div>
|
||||
<div><dt>Coordenadas</dt><dd>{{ selectedUnassigned.latitud }}, {{ selectedUnassigned.longitud }}</dd></div>
|
||||
</dl>
|
||||
<div class="pending-edit-grid">
|
||||
<label>Usuario<select [(ngModel)]="assignmentUser[selectedUnassigned.pdv_id]"><option value="">Seleccionar</option><option *ngFor="let user of unassignedUserOptions" [value]="user.id">{{ user.name }}</option></select></label>
|
||||
<label>Día<select [(ngModel)]="assignmentDay[selectedUnassigned.pdv_id]"><option value="">Seleccionar</option><option *ngFor="let day of unassignedDayOptions" [value]="day">{{ day }}</option></select></label>
|
||||
</div>
|
||||
<div class="pending-actions"><button type="button" class="btn-secondary" (click)="startUnassignedEdit(selectedUnassigned)"><i class="pi pi-pencil"></i> Editar</button><button type="button" class="btn-primary" (click)="assignUnassigned(selectedUnassigned)" [disabled]="assigningUnassignedId === selectedUnassigned.pdv_id"><span *ngIf="assigningUnassignedId === selectedUnassigned.pdv_id" class="spinner-inline"></span><i *ngIf="assigningUnassignedId !== selectedUnassigned.pdv_id" class="pi pi-user-plus"></i> Reasignar</button></div>
|
||||
</ng-template>
|
||||
<div class="error-card" *ngIf="unassignedActionError">{{ unassignedActionError }}</div>
|
||||
</section>
|
||||
|
||||
<section class="info-card" *ngIf="selectedRouteDetail">
|
||||
<div class="info-card-header">
|
||||
<h3>Detalle de la ruta</h3>
|
||||
<button type="button" class="btn-icon" (click)="clearSelection()" title="Cerrar detalle">
|
||||
<i class="pi pi-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h4><i class="pi pi-user"></i> Usuario</h4>
|
||||
<dl class="info-list">
|
||||
<div><dt>Nombre</dt><dd>{{ selectedRouteDetail.json.nombre_usuario }}</dd></div>
|
||||
<div><dt>ID</dt><dd>{{ selectedRouteDetail.json.usuario_id }}</dd></div>
|
||||
<div><dt>Origen</dt><dd>{{ selectedRouteDetail.json.latitud_usuario }}, {{ selectedRouteDetail.json.longitud_usuario }}</dd></div>
|
||||
<div><dt>Horas disponibles</dt><dd>{{ selectedRouteDetail.json.hora_laboral_semanal_usuario }} h/semana</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h4><i class="pi pi-map-marker"></i> PDV</h4>
|
||||
<dl class="info-list">
|
||||
<div><dt>Nombre</dt><dd>{{ selectedRouteDetail.json.nombre_pdv }}</dd></div>
|
||||
<div><dt>ID</dt><dd>{{ selectedRouteDetail.json.pdv_id }}</dd></div>
|
||||
<div><dt>Día</dt><dd>{{ selectedRouteDetail.json.dia }}</dd></div>
|
||||
<div><dt>Orden de visita</dt><dd>{{ selectedVisitOrder }}</dd></div>
|
||||
<div><dt>Origen del tramo</dt><dd>{{ selectedSegmentOrigin }}</dd></div>
|
||||
<div><dt>Destino</dt><dd>{{ selectedRouteDetail.json.latitud_pdv }}, {{ selectedRouteDetail.json.longitud_pdv }}</dd></div>
|
||||
<div><dt>Horas trabajo</dt><dd>{{ selectedRouteDetail.json.horas_trabajo }}</dd></div>
|
||||
<div><dt>Distancia tramo</dt><dd>{{ selectedSegmentDistance | number:'1.2-2' }} km</dd></div>
|
||||
<div><dt>Duración tramo</dt><dd>{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min</dd></div>
|
||||
</dl>
|
||||
<div class="reassign-actions" *ngIf="!showReassignForm">
|
||||
<button type="button" class="btn-secondary" (click)="startReassignRoute()"><i class="pi pi-user-edit"></i> Reasignar ruta</button>
|
||||
</div>
|
||||
<div class="reassign-form" *ngIf="showReassignForm">
|
||||
<label>Nuevo usuario<select [(ngModel)]="reassignUserId"><option *ngFor="let user of unassignedUserOptions" [value]="user.id">{{ user.name }}</option></select></label>
|
||||
<label>Día<select [(ngModel)]="reassignDay"><option *ngFor="let day of unassignedDayOptions" [value]="day">{{ day }}</option></select></label>
|
||||
<p class="reassign-hint">Se recalcularán la distancia y duración con ruta vial ORS.</p>
|
||||
<div class="reassign-actions"><button type="button" class="btn-primary" (click)="reassignRoute()" [disabled]="reassigningRoute"><span *ngIf="reassigningRoute" class="spinner-inline"></span><i *ngIf="!reassigningRoute" class="pi pi-check"></i> {{ reassigningRoute ? 'Calculando...' : 'Confirmar' }}</button><button type="button" class="btn-secondary" (click)="cancelReassignRoute()" [disabled]="reassigningRoute">Cancelar</button></div>
|
||||
<div class="error-card" *ngIf="reassignError">{{ reassignError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="routes-list" *ngIf="filteredRoutes.length">
|
||||
<h3>{{ selectedUsuarioId === 'Todos' ? 'PDVs de todos los usuarios' : 'PDVs del usuario' }} ({{ filteredRoutes.length }})</h3>
|
||||
<ul>
|
||||
<li *ngFor="let r of filteredRoutes; let i = index"
|
||||
[class.selected]="selectedRouteDetail && selectedRouteDetail.json.pdv_id === r.json.pdv_id && selectedRouteDetail.json.usuario_id === r.json.usuario_id && selectedRouteDetail.json.dia === r.json.dia"
|
||||
(click)="selectRouteFromList(r)">
|
||||
<span class="idx">{{ routeVisitOrder(r) }}</span>
|
||||
<div class="li-main">
|
||||
<strong>{{ r.json.nombre_pdv }}</strong>
|
||||
<small>{{ selectedUsuarioId === 'Todos' ? r.json.nombre_usuario + ' • ' : '' }}{{ r.json.dia }} • {{ routeSegmentDistance(r) | number:'1.2-2' }} km • {{ routeSegmentDurationMinutes(r) | number:'1.0-0' }} min</small>
|
||||
</div>
|
||||
<i class="pi pi-chevron-right"></i>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
+785
@@ -0,0 +1,785 @@
|
||||
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 } 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 { haversineKm, sequenceRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||
|
||||
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;
|
||||
heatWeightMode: 'uniform' | 'horas_trabajo' = 'horas_trabajo';
|
||||
private heatPointsCache: [number, number, number][] = [];
|
||||
private unassignedMarkers = new Map<number, L.Marker>();
|
||||
|
||||
usuarios: { id: string; nombre: string }[] = [];
|
||||
dias: string[] = [];
|
||||
selectedUsuarioId: string | null = null;
|
||||
selectedDia: string = 'Todos';
|
||||
selectedRouteDetail: RouteAssignedResponse | null = null;
|
||||
|
||||
filteredRoutes: RouteAssignedResponse[] = [];
|
||||
allRoutes: RouteAssignedResponse[] = [];
|
||||
|
||||
isLoading = false;
|
||||
orsProgress = 0;
|
||||
orsTotal = 0;
|
||||
error: string | null = null;
|
||||
unassignedRoutes: RouteNoAssignedResponse[] = [];
|
||||
selectedUnassigned: RouteNoAssignedResponse | null = null;
|
||||
editingUnassigned: RouteNoAssignedResponse | null = null;
|
||||
editUnassignedName = '';
|
||||
editUnassignedHours = 0;
|
||||
editUnassignedLat = 0;
|
||||
editUnassignedLng = 0;
|
||||
editUnassignedReason = '';
|
||||
assignmentUser: Record<string, string> = {};
|
||||
assignmentDay: Record<string, string> = {};
|
||||
assigningUnassignedId: number | null = null;
|
||||
unassignedActionError: string | null = null;
|
||||
reassigningRoute = false;
|
||||
showReassignForm = false;
|
||||
reassignUserId = '';
|
||||
reassignDay = '';
|
||||
reassignError: string | null = null;
|
||||
|
||||
// Summary stats
|
||||
get totalDistancia(): string {
|
||||
const sum = this.mapRoutes.length
|
||||
? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDistanceKm ?? haversineKm(route.origin, route.destination)), 0)
|
||||
: this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.distancia), 0);
|
||||
return sum.toFixed(2);
|
||||
}
|
||||
get totalDuracionMin(): string {
|
||||
const sum = this.mapRoutes.length
|
||||
? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDurationHours ?? Number(route.data.json.horas_desplazamiento)) * 60, 0)
|
||||
: this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.horas_desplazamiento) * 60, 0);
|
||||
return sum.toFixed(0);
|
||||
}
|
||||
|
||||
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();
|
||||
// Init from queryParams default (Todos)
|
||||
const qp = this.activatedRoute.snapshot.queryParams;
|
||||
if (qp['usuario_id']) this.selectedUsuarioId = String(qp['usuario_id']);
|
||||
else if (!this.selectedUsuarioId && this.usuarios.length) this.selectedUsuarioId = 'Todos';
|
||||
if (qp['dia']) this.selectedDia = String(qp['dia']);
|
||||
// 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 => {
|
||||
let changed = false;
|
||||
if (qp['usuario_id'] && String(qp['usuario_id']) !== this.selectedUsuarioId) {
|
||||
this.selectedUsuarioId = String(qp['usuario_id']);
|
||||
changed = true;
|
||||
}
|
||||
if (qp['dia'] && qp['dia'] !== this.selectedDia) {
|
||||
this.selectedDia = String(qp['dia']);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
this.applyFilter();
|
||||
if (this.map) this.renderFiltered();
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
// 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() {
|
||||
this.map = L.map('map-overview').setView([18.4861, -69.9312], 10);
|
||||
const pdvPane = this.map.createPane('pdvPane');
|
||||
pdvPane.style.zIndex = '650';
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).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.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"><span>${segment.order}</span></div>`,
|
||||
iconSize: [30, 38],
|
||||
iconAnchor: [15, 38],
|
||||
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>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);
|
||||
});
|
||||
|
||||
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 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 pares = this.mapRoutes.map(route => ({
|
||||
pdv_id: route.requestId,
|
||||
origin: route.origin,
|
||||
destination: route.destination
|
||||
}));
|
||||
|
||||
const { promise } = this.orsService.processPairsWithProgress(pares, 2, true, this.abort$);
|
||||
try {
|
||||
const results = await promise;
|
||||
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>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.selectedRouteDetail = mr.data;
|
||||
this.highlightRoute(mr.data);
|
||||
this.fitSelectedRoute(mr);
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
selectRouteFromList(route: RouteAssignedResponse): void {
|
||||
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;
|
||||
}
|
||||
|
||||
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');
|
||||
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 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;
|
||||
}
|
||||
|
||||
get unassignedUserOptions(): { id: string; name: string }[] {
|
||||
return this.routeState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre }));
|
||||
}
|
||||
|
||||
get unassignedDayOptions(): string[] {
|
||||
return this.routeState.getDiasUnicos();
|
||||
}
|
||||
|
||||
selectUnassigned(route: RouteNoAssignedResponse): void {
|
||||
this.selectedUnassigned = route;
|
||||
this.selectedRouteDetail = 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 day = this.assignmentDay[String(route.pdv_id)];
|
||||
if (!userId || !day) {
|
||||
this.unassignedActionError = 'Selecciona usuario y 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 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');
|
||||
this.routeState.addAssignedFromUnassigned(route, userId, day, summary.distance / 1000, summary.duration / 3600);
|
||||
this.selectedUnassigned = null;
|
||||
delete this.assignmentUser[String(route.pdv_id)];
|
||||
delete this.assignmentDay[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 {
|
||||
if (this.heatWeightMode === 'uniform') return 0.8;
|
||||
const v = parseFloat(String(j.horas_trabajo));
|
||||
if (isNaN(v)) return 0.8;
|
||||
const vals = this.filteredRoutes.map(r => parseFloat(String(r.json.horas_trabajo))).filter(n => !isNaN(n));
|
||||
if (!vals.length) return 0.8;
|
||||
const min = Math.min(...vals), max = Math.max(...vals);
|
||||
if (max === min) return 0.8;
|
||||
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, {
|
||||
radius: 25, blur: 18, maxZoom: 17, minOpacity: 0.4,
|
||||
gradient: { 0.4: '#6CC24A', 0.65: '#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();
|
||||
}
|
||||
|
||||
setHeatWeight(mode: 'uniform' | 'horas_trabajo') {
|
||||
this.heatWeightMode = mode;
|
||||
this.heatPointsCache = this.filteredRoutes.map(r => {
|
||||
const j = r.json;
|
||||
return [Number(j.latitud_pdv), Number(j.longitud_pdv), this.computeWeight(j)] as [number, number, number];
|
||||
});
|
||||
this.updateHeatLayer();
|
||||
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(['/home']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './pdv.schema';
|
||||
export * from './usuario.schema';
|
||||
export * from './params.schema';
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const paramsSchema = z.object({
|
||||
radioKm: z.union([z.literal(30), z.literal(50), z.literal(80)]),
|
||||
velocidad: z.number().min(15).max(80),
|
||||
});
|
||||
|
||||
export type Params = z.infer<typeof paramsSchema>;
|
||||
// Urbano denso Santo Domingo: 22-28 km/h es más realista que 46 (autopista).
|
||||
// P2-A default 28; 46 sigue disponible como "Mixto rápido" pero ya no default.
|
||||
export const DEFAULT_PARAMS: Params = { radioKm: 50, velocidad: 28 };
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PDV_REQUIRED_HEADERS = [
|
||||
'ID',
|
||||
'Nombre del PDV',
|
||||
'Segmentación',
|
||||
'Latitud',
|
||||
'Longitud',
|
||||
'Visitas semanales',
|
||||
'Duración visita(horas)',
|
||||
'Prioridad',
|
||||
] as const;
|
||||
|
||||
export const PDV_NORMALIZED_REQUIRED = [
|
||||
'id',
|
||||
'nombre_del_pdv',
|
||||
'segmentacion',
|
||||
'latitud',
|
||||
'longitud',
|
||||
'visitas_semanales',
|
||||
'duracion_visitahoras',
|
||||
'prioridad',
|
||||
] as const;
|
||||
|
||||
export const SEGMENTACIONES = ['AA', 'A', 'B', 'C', 'D'] as const;
|
||||
|
||||
export const pdvRowSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).refine(v => String(v).trim() !== '', { message: 'ID requerido' }),
|
||||
nombre_del_pdv: z.string().min(1, 'Nombre del PDV requerido'),
|
||||
segmentacion: z.string().refine(v => (SEGMENTACIONES as readonly string[]).includes(v.toUpperCase()), {
|
||||
message: `Segmentación debe ser ${SEGMENTACIONES.join(', ')}`,
|
||||
}),
|
||||
latitud: z.number().min(-90, 'Latitud mínima -90').max(90, 'Latitud máxima 90'),
|
||||
longitud: z.number().min(-180, 'Longitud mínima -180').max(180, 'Longitud máxima 180'),
|
||||
visitas_semanales: z.number().int('Visitas semanales debe ser entero').min(1, 'Visitas semanales mínimo 1').max(7, 'Visitas semanales máximo 7'),
|
||||
duracion_visitahoras: z.number().min(0.01, 'Duración visita debe ser >0').max(24, 'Duración visita máximo 24'),
|
||||
prioridad: z.number().int('Prioridad debe ser entero').min(1, 'Prioridad mínimo 1').max(5, 'Prioridad máximo 5'),
|
||||
});
|
||||
|
||||
export type PdvRow = z.infer<typeof pdvRowSchema>;
|
||||
export const PDV_MAX_ROWS = 2000;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const USUARIO_REQUIRED_HEADERS = [
|
||||
'ID',
|
||||
'Nombre del empleado',
|
||||
'Latitud',
|
||||
'Longitud',
|
||||
'Horas por día usuario',
|
||||
'Horas por semana usuario',
|
||||
] as const;
|
||||
|
||||
export const USUARIO_NORMALIZED_REQUIRED = [
|
||||
'id',
|
||||
'nombre_del_empleado',
|
||||
'latitud',
|
||||
'longitud',
|
||||
'horas_por_dia_usuario',
|
||||
'horas_por_semana_usuario',
|
||||
] as const;
|
||||
|
||||
export const usuarioRowSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).refine(v => String(v).trim() !== '', { message: 'ID requerido' }),
|
||||
nombre_del_empleado: z.string().min(1, 'Nombre del empleado requerido'),
|
||||
latitud: z.number().min(-90, 'Latitud mínima -90').max(90, 'Latitud máxima 90'),
|
||||
longitud: z.number().min(-180, 'Longitud mínima -180').max(180, 'Longitud máxima 180'),
|
||||
horas_por_dia_usuario: z.number().min(0.5, 'Horas por día mínimo 0.5').max(24, 'Horas por día máximo 24'),
|
||||
horas_por_semana_usuario: z.number().min(1, 'Horas por semana mínimo 1').max(168, 'Horas por semana máximo 168'),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.horas_por_semana_usuario > data.horas_por_dia_usuario * 7) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Horas por semana no puede exceder 7 × horas por día',
|
||||
path: ['horas_por_semana_usuario'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type UsuarioRow = z.infer<typeof usuarioRowSchema>;
|
||||
export const USUARIO_MAX_ROWS = 50;
|
||||
@@ -0,0 +1,182 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { normalizeRow, normalizeHeader, cleanNum } from '../utils/header-normalization.utils';
|
||||
import { pdvRowSchema, PDV_NORMALIZED_REQUIRED, PDV_MAX_ROWS, SEGMENTACIONES } from '../schemas/pdv.schema';
|
||||
import { usuarioRowSchema, USUARIO_NORMALIZED_REQUIRED, USUARIO_MAX_ROWS } from '../schemas/usuario.schema';
|
||||
|
||||
export interface FieldError {
|
||||
row: number; // 1-indexed excel row (2 = first data row)
|
||||
field: string;
|
||||
message: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
fileName: string;
|
||||
type: 'PDV' | 'Usuarios';
|
||||
valid: boolean;
|
||||
totalRows: number;
|
||||
validRows: number;
|
||||
headers: string[];
|
||||
normalizedHeaders: string[];
|
||||
missingHeaders: string[];
|
||||
errors: FieldError[];
|
||||
duplicateIds: (string | number)[];
|
||||
sampleValidRows: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExcelPreviewService {
|
||||
|
||||
async previewFile(file: File, type: 'PDV' | 'Usuarios'): Promise<PreviewResult> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(buffer, { type: 'array', cellDates: true });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
if (!sheetName || !sheet) {
|
||||
throw new Error(`${type}: El archivo no contiene una hoja válida`);
|
||||
}
|
||||
const rawRows: Record<string, unknown>[] = XLSX.utils.sheet_to_json(sheet, { defval: null, raw: true });
|
||||
|
||||
const headers = this.extractHeaders(sheet);
|
||||
const normalizedHeaders = headers.map(h => normalizeHeader(String(h)));
|
||||
|
||||
const required = type === 'PDV' ? [...PDV_NORMALIZED_REQUIRED] : [...USUARIO_NORMALIZED_REQUIRED];
|
||||
const displayRequired = type === 'PDV'
|
||||
? ['ID', 'Nombre del PDV', 'Segmentación', 'Latitud', 'Longitud', 'Visitas semanales', 'Duración visita(horas)', 'Prioridad']
|
||||
: ['ID', 'Nombre del empleado', 'Latitud', 'Longitud', 'Horas por día usuario', 'Horas por semana usuario'];
|
||||
|
||||
const missingHeaders: string[] = [];
|
||||
for (let i = 0; i < required.length; i++) {
|
||||
if (!normalizedHeaders.includes(required[i])) {
|
||||
missingHeaders.push(displayRequired[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const errors: FieldError[] = [];
|
||||
const repeatedHeaders = normalizedHeaders.filter((header, index) => header && normalizedHeaders.indexOf(header) !== index);
|
||||
if (repeatedHeaders.length) {
|
||||
errors.push({ row: 1, field: 'headers', message: `Columnas duplicadas: ${Array.from(new Set(repeatedHeaders)).join(', ')}` });
|
||||
}
|
||||
if (missingHeaders.length) {
|
||||
errors.push({ row: 1, field: 'headers', message: `Columnas faltantes: ${missingHeaders.join(', ')}` });
|
||||
}
|
||||
|
||||
if (rawRows.length === 0) {
|
||||
errors.push({ row: 1, field: 'file', message: `${type}: Archivo vacío o sin filas de datos` });
|
||||
return this.buildResult(file.name, type, headers, normalizedHeaders, missingHeaders, rawRows, errors, []);
|
||||
}
|
||||
|
||||
const maxRows = type === 'PDV' ? PDV_MAX_ROWS : USUARIO_MAX_ROWS;
|
||||
if (rawRows.length > maxRows) {
|
||||
errors.push({ row: 1, field: 'file', message: `${type}: Máximo ${maxRows} filas permitidas (tiene ${rawRows.length})` });
|
||||
}
|
||||
|
||||
const normalizedRows = rawRows.map(r => normalizeRow(r as Record<string, unknown>));
|
||||
const seenIds = new Map<string, number[]>();
|
||||
const rowErrors: FieldError[] = [];
|
||||
|
||||
normalizedRows.forEach((row, idx) => {
|
||||
const excelRow = idx + 2;
|
||||
const idVal = row['id'];
|
||||
if (idVal !== null && idVal !== undefined && String(idVal).trim() !== '') {
|
||||
const key = String(idVal).trim();
|
||||
if (!seenIds.has(key)) seenIds.set(key, []);
|
||||
seenIds.get(key)!.push(excelRow);
|
||||
}
|
||||
|
||||
// Coerce numeric fields with cleanNum before zod
|
||||
const coerced: Record<string, unknown> = { ...row };
|
||||
if (type === 'PDV') {
|
||||
coerced['latitud'] = this.coerceNum(row['latitud']);
|
||||
coerced['longitud'] = this.coerceNum(row['longitud']);
|
||||
coerced['visitas_semanales'] = this.coerceNum(row['visitas_semanales']);
|
||||
coerced['duracion_visitahoras'] = this.coerceNum(row['duracion_visitahoras']);
|
||||
coerced['prioridad'] = this.coerceNum(row['prioridad']);
|
||||
if (typeof coerced['segmentacion'] === 'string') {
|
||||
coerced['segmentacion'] = String(coerced['segmentacion']).trim().toUpperCase();
|
||||
}
|
||||
// validate with zod
|
||||
const result = pdvRowSchema.safeParse(coerced);
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
const field = String(issue.path[0] ?? 'unknown');
|
||||
rowErrors.push({ row: excelRow, field, message: issue.message, value: (row as Record<string, unknown>)[field] ?? coerced[field] });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
coerced['latitud'] = this.coerceNum(row['latitud']);
|
||||
coerced['longitud'] = this.coerceNum(row['longitud']);
|
||||
coerced['horas_por_dia_usuario'] = this.coerceNum(row['horas_por_dia_usuario']);
|
||||
coerced['horas_por_semana_usuario'] = this.coerceNum(row['horas_por_semana_usuario']);
|
||||
const result = usuarioRowSchema.safeParse(coerced);
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
const field = String(issue.path[0] ?? 'unknown');
|
||||
rowErrors.push({ row: excelRow, field, message: issue.message, value: (row as Record<string, unknown>)[field] ?? coerced[field] });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const duplicateIds: (string | number)[] = [];
|
||||
for (const [id, rows] of seenIds.entries()) {
|
||||
if (rows.length > 1) {
|
||||
duplicateIds.push(id);
|
||||
for (const r of rows) {
|
||||
rowErrors.push({ row: r, field: 'id', message: `ID duplicado: ${id}`, value: id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allErrors = [...errors, ...rowErrors];
|
||||
return this.buildResult(file.name, type, headers, normalizedHeaders, missingHeaders, normalizedRows, allErrors, duplicateIds);
|
||||
}
|
||||
|
||||
private coerceNum(value: unknown): unknown {
|
||||
if (value === null || value === undefined || value === '') return value;
|
||||
const n = cleanNum(value);
|
||||
return Number.isNaN(n) ? value : n;
|
||||
}
|
||||
|
||||
private extractHeaders(sheet: XLSX.WorkSheet): string[] {
|
||||
const range = XLSX.utils.decode_range(sheet['!ref'] || 'A1');
|
||||
const headers: string[] = [];
|
||||
for (let c = range.s.c; c <= range.e.c; c++) {
|
||||
const addr = XLSX.utils.encode_cell({ r: range.s.r, c });
|
||||
const cell = sheet[addr];
|
||||
headers.push(cell ? String(cell.v) : '');
|
||||
}
|
||||
return headers.filter(h => h !== '');
|
||||
}
|
||||
|
||||
private buildResult(
|
||||
fileName: string,
|
||||
type: 'PDV' | 'Usuarios',
|
||||
headers: string[],
|
||||
normalizedHeaders: string[],
|
||||
missingHeaders: string[],
|
||||
rows: Record<string, unknown>[],
|
||||
errors: FieldError[],
|
||||
duplicateIds: (string | number)[]
|
||||
): PreviewResult {
|
||||
const validRows = Math.max(0, rows.length - new Set(errors.filter(e => e.field !== 'headers' && e.field !== 'file').map(e => e.row)).size);
|
||||
const hasHeaderErrors = missingHeaders.length > 0;
|
||||
const maxRows = type === 'PDV' ? PDV_MAX_ROWS : USUARIO_MAX_ROWS;
|
||||
const overLimit = rows.length > maxRows;
|
||||
const valid = !hasHeaderErrors && !overLimit && errors.length === 0;
|
||||
return {
|
||||
fileName,
|
||||
type,
|
||||
valid,
|
||||
totalRows: rows.length,
|
||||
validRows: valid ? rows.length : validRows,
|
||||
headers,
|
||||
normalizedHeaders,
|
||||
missingHeaders,
|
||||
errors,
|
||||
duplicateIds,
|
||||
sampleValidRows: rows.slice(0, 3),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { RouteState } from '../../core/route-state/route';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlanningExportService {
|
||||
constructor(private routeState: RouteState) {}
|
||||
|
||||
export(): void {
|
||||
const assigned = this.routeState.getAssignedSnapshot();
|
||||
const unassigned = this.routeState.getNoAssignedSnapshot();
|
||||
const users = new Map<string, { nombre: string; rutas: number; distancia: number; trabajo: number; capacidad: number }>();
|
||||
|
||||
for (const route of assigned) {
|
||||
const key = String(route.json.usuario_id);
|
||||
const current = users.get(key) ?? {
|
||||
nombre: route.json.nombre_usuario,
|
||||
rutas: 0,
|
||||
distancia: 0,
|
||||
trabajo: 0,
|
||||
capacidad: Number(route.json.hora_laboral_semanal_usuario) || 0
|
||||
};
|
||||
current.rutas++;
|
||||
current.distancia += Number(route.json.distancia) || 0;
|
||||
current.trabajo += Number.parseFloat(String(route.json.horas_trabajo ?? '')) || 0;
|
||||
users.set(key, current);
|
||||
}
|
||||
|
||||
const total = assigned.length + unassigned.length;
|
||||
const totalDistance = assigned.reduce((sum, route) => sum + (Number(route.json.distancia) || 0), 0);
|
||||
const totalTravel = assigned.reduce((sum, route) => sum + (Number(route.json.horas_desplazamiento) || 0), 0);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
const summary = [
|
||||
{ Indicador: 'PDVs totales', Valor: total },
|
||||
{ Indicador: 'PDVs asignados', Valor: assigned.length },
|
||||
{ Indicador: 'PDVs sin asignar', Valor: unassigned.length },
|
||||
{ Indicador: 'Cobertura (%)', Valor: total ? Number(((assigned.length / total) * 100).toFixed(1)) : 0 },
|
||||
{ Indicador: 'Usuarios activos', Valor: users.size },
|
||||
{ Indicador: 'Días planificados', Valor: new Set(assigned.map(route => route.json.dia)).size },
|
||||
{ Indicador: 'Distancia total (km)', Valor: Number(totalDistance.toFixed(2)) },
|
||||
{ Indicador: 'Desplazamiento total (h)', Valor: Number(totalTravel.toFixed(2)) }
|
||||
];
|
||||
|
||||
const assignedRows = assigned.map(route => ({
|
||||
'Usuario ID': route.json.usuario_id,
|
||||
Usuario: route.json.nombre_usuario,
|
||||
'PDV ID': route.json.pdv_id,
|
||||
PDV: route.json.nombre_pdv,
|
||||
Día: route.json.dia,
|
||||
'Distancia (km)': route.json.distancia,
|
||||
'Desplazamiento (h)': route.json.horas_desplazamiento,
|
||||
'Horas trabajo': route.json.horas_trabajo,
|
||||
'Horas disponibles': route.json.hora_laboral_semanal_usuario
|
||||
}));
|
||||
const unassignedRows = unassigned.map(route => ({
|
||||
'PDV ID': route.pdv_id,
|
||||
PDV: route.pdv_nombre,
|
||||
'Mínimo horas/semana': route.minimo_horas_semana,
|
||||
Latitud: route.latitud,
|
||||
Longitud: route.longitud,
|
||||
Motivo: route.motivo
|
||||
}));
|
||||
const userRows = Array.from(users, ([id, user]) => ({
|
||||
'Usuario ID': id,
|
||||
Usuario: user.nombre,
|
||||
Rutas: user.rutas,
|
||||
'Distancia (km)': Number(user.distancia.toFixed(2)),
|
||||
'Horas trabajo': Number(user.trabajo.toFixed(2)),
|
||||
'Capacidad (h)': user.capacidad,
|
||||
'Utilización (%)': user.capacidad ? Number(((user.trabajo / user.capacidad) * 100).toFixed(1)) : 0
|
||||
}));
|
||||
|
||||
this.appendSheet(workbook, summary, 'Resumen');
|
||||
this.appendSheet(workbook, assignedRows, 'Rutas asignadas');
|
||||
this.appendSheet(workbook, unassignedRows, 'PDVs sin asignar');
|
||||
this.appendSheet(workbook, userRows, 'Carga por usuario');
|
||||
|
||||
const output = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
|
||||
saveAs(new Blob([output], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), 'planificacion_rutas.xlsx');
|
||||
}
|
||||
|
||||
private appendSheet(workbook: XLSX.WorkBook, rows: object[], name: string): void {
|
||||
const sheet = XLSX.utils.json_to_sheet(rows.length ? rows : [{ Información: 'Sin registros' }]);
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export function normalizeHeader(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9 ]/gi, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '_');
|
||||
}
|
||||
|
||||
export function cleanNum(value: unknown): number {
|
||||
if (value === null || value === undefined || value === '') return NaN;
|
||||
if (typeof value === 'number') return value;
|
||||
const str = String(value).trim().replace(/[^0-9.\-]/g, '');
|
||||
if (str === '' || str === '-' || str === '.') return NaN;
|
||||
const num = Number(str);
|
||||
return Number.isFinite(num) ? num : NaN;
|
||||
}
|
||||
|
||||
export function normalizeRow(row: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key in row) {
|
||||
const newKey = normalizeHeader(key);
|
||||
let val = row[key];
|
||||
if (typeof val === 'string') val = val.trim();
|
||||
out[newKey] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1 +1,4 @@
|
||||
export * from './remove-special-character.utils';
|
||||
export * from './remove-special-character.utils';
|
||||
export * from './header-normalization.utils';
|
||||
export * from './palette.utils';
|
||||
export * from './route-sequencing.utils';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export function colorForUsuario(usuarioId: string | number): { color: string; glow: string } {
|
||||
const str = String(usuarioId);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
hash = Math.abs(hash);
|
||||
// Use a mix of GLM palette + generated HSL to avoid collisions but keep brand feel
|
||||
const glmPalette = ['#6CC24A', '#4F758B', '#FF6A13', '#C4D600', '#5B7F95', '#A4D65E', '#6B8FA3'];
|
||||
const idx = hash % glmPalette.length;
|
||||
// If palette length covers typical usuarios (50 max) collisions low; fallback to HSL for overflow
|
||||
if (hash % 2 === 0) {
|
||||
return { color: glmPalette[idx], glow: glmPalette[idx] };
|
||||
}
|
||||
const h = hash % 360;
|
||||
const s = 65 + (hash % 20);
|
||||
const l = 48 + (hash % 10);
|
||||
const hsl = `hsl(${h} ${s}% ${l}%)`;
|
||||
return { color: hsl, glow: hsl };
|
||||
}
|
||||
|
||||
export function markerStyleForUsuario(usuarioId: string | number): string {
|
||||
return colorForUsuario(usuarioId).color;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { RouteAssignedResponse } from '../../core/models/route.model';
|
||||
|
||||
export interface SequencedRoute {
|
||||
route: RouteAssignedResponse;
|
||||
origin: [number, number];
|
||||
destination: [number, number];
|
||||
order: number;
|
||||
totalStops: number;
|
||||
previousPdvName: string | null;
|
||||
}
|
||||
|
||||
export function sequenceRoutes(routes: RouteAssignedResponse[]): SequencedRoute[] {
|
||||
const groups = new Map<string, RouteAssignedResponse[]>();
|
||||
for (const route of routes) {
|
||||
const key = `${route.json.usuario_id}\u0000${route.json.dia}`;
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(route);
|
||||
groups.set(key, group);
|
||||
}
|
||||
|
||||
const sequenced: SequencedRoute[] = [];
|
||||
for (const group of groups.values()) {
|
||||
const first = group[0].json;
|
||||
let current: [number, number] = [Number(first.longitud_usuario), Number(first.latitud_usuario)];
|
||||
let previousPdvName: string | null = null;
|
||||
const remaining = [...group];
|
||||
|
||||
while (remaining.length) {
|
||||
let nearestIndex = 0;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
for (let index = 0; index < remaining.length; index++) {
|
||||
const candidate = remaining[index].json;
|
||||
const distance = haversineKm(current, [Number(candidate.longitud_pdv), Number(candidate.latitud_pdv)]);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
const [route] = remaining.splice(nearestIndex, 1);
|
||||
const destination: [number, number] = [Number(route.json.longitud_pdv), Number(route.json.latitud_pdv)];
|
||||
sequenced.push({
|
||||
route,
|
||||
origin: current,
|
||||
destination,
|
||||
order: group.length - remaining.length,
|
||||
totalStops: group.length,
|
||||
previousPdvName
|
||||
});
|
||||
current = destination;
|
||||
previousPdvName = route.json.nombre_pdv;
|
||||
}
|
||||
}
|
||||
|
||||
return sequenced;
|
||||
}
|
||||
|
||||
export function haversineKm(origin: [number, number], destination: [number, number]): number {
|
||||
const toRadians = (value: number) => value * Math.PI / 180;
|
||||
const [lon1, lat1] = origin;
|
||||
const [lon2, lat2] = destination;
|
||||
const dLat = toRadians(lat2 - lat1);
|
||||
const dLon = toRadians(lon2 - lon1);
|
||||
const a = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino',
|
||||
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
||||
requestTimeoutMs: 30000,
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino',
|
||||
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
||||
requestTimeoutMs: 30000,
|
||||
};
|
||||
@@ -6,7 +6,6 @@
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🗺️</text></svg>">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
@import "primeicons/primeicons.css";
|
||||
|
||||
/* Leaflet creates marker HTML outside Angular's style encapsulation. */
|
||||
.location-pin {
|
||||
position: relative;
|
||||
width: 30px;
|
||||
height: 38px;
|
||||
background: #c9252d;
|
||||
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
|
||||
}
|
||||
|
||||
.location-pin::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3px 3px 7px;
|
||||
background: #ff424a;
|
||||
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
|
||||
}
|
||||
|
||||
.location-pin::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #620000;
|
||||
}
|
||||
|
||||
.location-pin span {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 14px;
|
||||
color: #fff;
|
||||
font: 700 9px/14px Arial, sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:root {
|
||||
--glm-verde: #6CC24A;
|
||||
--glm-verde-claro: #A4D65E;
|
||||
|
||||
Reference in New Issue
Block a user