feat: subir json del flujo de n8n #1

Merged
EidanThen merged 3 commits from dev into main 2026-08-31 22:19:08 +00:00
41 changed files with 3811 additions and 151 deletions
Showing only changes of commit cd5c57c5ee - Show all commits
File diff suppressed because one or more lines are too long
+15 -4
View File
@@ -18,16 +18,27 @@
"polyfills": ["zone.js"], "polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"assets": [{ "glob": "**/*", "input": "public" }], "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": [] "scripts": []
}, },
"configurations": { "configurations": {
"production": { "production": {
"budgets": [ "budgets": [
{ "type": "initial", "maximumWarning": "1.5MB", "maximumError": "2MB" }, { "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" },
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" } { "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "10kB" }
], ],
"outputHashing": "all" "outputHashing": "all",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
}, },
"development": { "development": {
"optimization": false, "optimization": false,
+16
View File
@@ -17,6 +17,8 @@
"@primeng/themes": "^21.0.0", "@primeng/themes": "^21.0.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"leaflet.markercluster": "^1.5.3",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primeng": "^21.0.0", "primeng": "^21.0.0",
"rxjs": "~7.8.2", "rxjs": "~7.8.2",
@@ -6226,6 +6228,20 @@
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause" "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": { "node_modules/listr2": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
+2
View File
@@ -19,6 +19,8 @@
"@primeng/themes": "^21.0.0", "@primeng/themes": "^21.0.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"leaflet.markercluster": "^1.5.3",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primeng": "^21.0.0", "primeng": "^21.0.0",
"rxjs": "~7.8.2", "rxjs": "~7.8.2",
+2
View File
@@ -2,11 +2,13 @@ import { Routes } from '@angular/router';
import { LoginComponent } from './public/login/login.component'; import { LoginComponent } from './public/login/login.component';
import { HomeComponent } from './private/home/home.component'; import { HomeComponent } from './private/home/home.component';
import { AssignedRouteDetailComponent } from './private/home/assigned-route-detail/assigned-route-detail.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 = [ export const routes: Routes = [
{ path: '', redirectTo: 'login', pathMatch: 'full' }, { path: '', redirectTo: 'login', pathMatch: 'full' },
{ path: 'login', component: LoginComponent }, { path: 'login', component: LoginComponent },
{ path: 'home', component: HomeComponent, pathMatch: 'full' }, { path: 'home', component: HomeComponent, pathMatch: 'full' },
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent }, { path: 'home/assigned-route', component: AssignedRouteDetailComponent },
{ path: 'home/mapa', component: RouteOverviewMapComponent },
{ path: '**', redirectTo: 'login' } { path: '**', redirectTo: 'login' }
]; ];
+205 -3
View File
@@ -5,19 +5,26 @@ import {
RouteNoAssignedResponse RouteNoAssignedResponse
} from '../models/route.model'; } 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' }) @Injectable({ providedIn: 'root' })
export class RouteState { export class RouteState {
private loading = new BehaviorSubject<boolean>(false); private loading = new BehaviorSubject<boolean>(false);
loading$ = this.loading.asObservable(); loading$ = this.loading.asObservable();
private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>([]); private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>(this.loadAssigned());
assignedRoutes$ = this.assignedRoutes.asObservable(); assignedRoutes$ = this.assignedRoutes.asObservable();
private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>([]); private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>(this.loadNoAssigned());
noAssignedRoutes$ = this.noAssignedRoutes.asObservable(); noAssignedRoutes$ = this.noAssignedRoutes.asObservable();
private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(null); private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(this.loadSelected());
selectedRoute$ = this.selectedRoute.asObservable(); selectedRoute$ = this.selectedRoute.asObservable();
resetLoading() { resetLoading() {
@@ -30,13 +37,208 @@ export class RouteState {
setAssignedRoutes(data: RouteAssignedResponse[]) { setAssignedRoutes(data: RouteAssignedResponse[]) {
this.assignedRoutes.next(data); 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[]) { setNoAssignedRoutes(data: RouteNoAssignedResponse[]) {
this.noAssignedRoutes.next(data); this.noAssignedRoutes.next(data);
this.persist(STORAGE_NO_ASSIGNED, data);
this.persist(STORAGE_NO_ASSIGNED_ORIGINAL, data);
} }
sendRoute(route: RouteAssignedResponse) { sendRoute(route: RouteAssignedResponse) {
this.selectedRoute.next(route); 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, catchError,
map, map,
toArray, toArray,
timeout timeout,
Observable,
Subject
} from 'rxjs'; } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
const ENDPOINT = 'http://localhost:18080/ors/v2/directions/driving-car'; const ENDPOINT = `${environment.orsBaseUrl}/directions/driving-car`;
const ENDPOINTJSON = 'http://localhost:18080/ors/v2/directions/driving-car/geojson'; const ENDPOINTJSON = `${environment.orsBaseUrl}/directions/driving-car/geojson`;
const MAX_CONCURRENT_REQUESTS = 2; const MAX_CONCURRENT_REQUESTS = 2;
@Injectable({ @Injectable({
@@ -21,8 +25,14 @@ const MAX_CONCURRENT_REQUESTS = 2;
}) })
export class OrsService { export class OrsService {
private geoCache = new Map<string, unknown>();
constructor(private http: HttpClient) { } 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( private fetchRoute(
origin: number[], origin: number[],
destination: number[], destination: number[],
@@ -32,7 +42,7 @@ export class OrsService {
coordinates: [origin, destination] 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), timeout(8000),
catchError(error => { catchError(error => {
throw error; throw error;
@@ -48,37 +58,91 @@ export class OrsService {
}[], }[],
concurrency = MAX_CONCURRENT_REQUESTS, concurrency = MAX_CONCURRENT_REQUESTS,
isJson = false isJson = false
): Promise<any[]> { ): Promise<unknown[]> {
return lastValueFrom( return this.processPairsWithProgress(pares, concurrency, isJson).promise;
from(pares).pipe( }
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( mergeMap(
pair => pair => {
this.fetchRoute(pair.origin, pair.destination, isJson).pipe( 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 => {
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), delay(300),
map(data => { map(data => {
if (isJson) this.geoCache.set(key, data);
if (!isJson) { if (!isJson) {
const summary = data.routes[0].summary; const summary = (data as { routes: { summary: { distance: number; duration: number } }[] }).routes[0].summary;
return { const out = {
pdv_id: pair.pdv_id, pdv_id: pair.pdv_id,
distance: summary.distance, distance: summary.distance,
duration: summary.duration, duration: summary.duration,
raw: data raw: data
}; };
completed++;
progressSubject.next({ completed, total: pares.length, item: out });
if (completed === pares.length) progressSubject.complete();
return out;
} else { } else {
return data; 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 => catchError(error => {
of({ const errOut = {
pdv_id: pair.pdv_id, pdv_id: pair.pdv_id,
error: error.message || 'Error ORS' 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 concurrency
), ),
toArray() 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 { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { ApiResponse } from '../../core/models/route.model'; import { ApiResponse } from '../models/route.model';
import { environment } from '../../../environments/environment';
import { catchError, timeout, throwError } from 'rxjs';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class RutasService { export class RutasService {
@@ -9,8 +11,20 @@ export class RutasService {
calcularRutas(data: FormData) { calcularRutas(data: FormData) {
return this.http.post<ApiResponse>( return this.http.post<ApiResponse>(
'https://agenteit.digitalcompass.agency/webhook/calcular-camino', environment.n8nWebhookUrl,
data 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);
}
} }
@@ -196,3 +196,31 @@
background: var(--glm-verde); background: var(--glm-verde);
border: 3px solid #fff; 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;
}
@@ -25,6 +25,8 @@
<div><dt>Usuario</dt><dd>{{ routeData.json.nombre_usuario }}</dd></div> <div><dt>Usuario</dt><dd>{{ routeData.json.nombre_usuario }}</dd></div>
<div><dt>PDV</dt><dd>{{ routeData.json.nombre_pdv }}</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>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>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>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> <div><dt>Horas trabajo</dt><dd>{{ routeData.json.horas_trabajo }}</dd></div>
@@ -1,11 +1,12 @@
import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core'; import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Router } from '@angular/router'; import { Router, ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import * as L from 'leaflet'; import * as L from 'leaflet';
import { RouteState } from '../../../core/route-state/route'; import { RouteState } from '../../../core/route-state/route';
import { OrsService } from '../../../core/services/ors.service'; import { OrsService } from '../../../core/services/ors.service';
import { RouteAssignedResponse } from '../../../core/models/route.model'; import { RouteAssignedResponse } from '../../../core/models/route.model';
import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils';
@Component({ @Component({
standalone: true, standalone: true,
@@ -26,9 +27,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
routeData: RouteAssignedResponse | null = null; routeData: RouteAssignedResponse | null = null;
isLoading = false; isLoading = false;
error: string | null = null; error: string | null = null;
segmentOriginName = '';
visitOrder = 0;
totalStops = 0;
constructor( constructor(
private router: Router, private router: Router,
private activatedRoute: ActivatedRoute,
private routeState: RouteState, private routeState: RouteState,
private orsService: OrsService, private orsService: OrsService,
private cdr: ChangeDetectorRef private cdr: ChangeDetectorRef
@@ -36,12 +41,31 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
ngOnInit() { ngOnInit() {
this.sub = this.routeState.selectedRoute$.subscribe((route: RouteAssignedResponse | null) => { this.sub = this.routeState.selectedRoute$.subscribe((route: RouteAssignedResponse | null) => {
if (route) {
this.routeData = route; this.routeData = route;
if (route && this.map) { if (this.map) {
this.loadAndShowRoute(route); this.loadAndShowRoute(route);
} else if (!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.error = 'No hay ruta seleccionada';
} }
}
this.cdr.detectChanges(); this.cdr.detectChanges();
}); });
} }
@@ -50,6 +74,18 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
this.initMap(); this.initMap();
if (this.routeData) { if (this.routeData) {
this.loadAndShowRoute(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() { private initMap() {
this.map = L.map('map').setView([18.4861, -69.9312], 13); 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', { this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors' attribution: '© OpenStreetMap contributors'
@@ -77,13 +115,20 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
this.cdr.detectChanges(); this.cdr.detectChanges();
const json = route.json; 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)]; 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); this.centerMapOnPoints(origin, destination);
try { try {
const geojson = await this.orsService.processPairs( const results = await this.orsService.processPairs(
[{ pdv_id: json.pdv_id, origin, destination }], [{ pdv_id: json.pdv_id, origin, destination }],
1, 1,
true true
@@ -91,13 +136,14 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
if (this.destroyed) return; if (this.destroyed) return;
if (geojson[0] && !geojson[0].error) { const result = results[0] as { error?: string; geojson?: unknown } | undefined;
this.showRouteOnMap(geojson[0], origin, destination, json); if (result && !result.error && result.geojson) {
this.showRouteOnMap(result.geojson, origin, destination, json, this.segmentOriginName);
} else { } else {
this.showFallbackRoute(origin, destination, json); this.showFallbackRoute(origin, destination, json, this.segmentOriginName);
} }
} catch { } catch {
this.showFallbackRoute(origin, destination, json); this.showFallbackRoute(origin, destination, json, this.segmentOriginName);
} finally { } finally {
this.isLoading = false; this.isLoading = false;
this.cdr.detectChanges(); this.cdr.detectChanges();
@@ -112,31 +158,38 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
this.map.fitBounds(bounds.pad(0.3)); this.map.fitBounds(bounds.pad(0.3));
} }
private extractCoords(geojson: any): L.LatLngExpression[] { private extractCoords(geojson: unknown): L.LatLngExpression[] {
if (geojson.type === 'FeatureCollection' && geojson.features?.length) { const g = geojson as { type: string; features?: { geometry: { coordinates: number[][] } }[]; coordinates?: number[][] };
return geojson.features[0].geometry.coordinates.map( if (g.type === 'FeatureCollection' && g.features?.length) {
return g.features[0].geometry.coordinates.map(
(c: number[]) => [c[1], c[0]] as L.LatLngExpression (c: number[]) => [c[1], c[0]] as L.LatLngExpression
); );
} }
if (geojson.type === 'LineString') { if (g.type === 'LineString' && g.coordinates) {
return geojson.coordinates.map( return g.coordinates.map(
(c: number[]) => [c[1], c[0]] as L.LatLngExpression (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 []; 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); const coords = this.extractCoords(geojson);
if (!coords.length) { if (!coords.length) {
this.showFallbackRoute(origin, destination, json); this.showFallbackRoute(origin, destination, json, originName);
return; return;
} }
const distance = geojson.features?.[0]?.properties?.summary?.distance const g = geojson as { features?: { properties?: { summary?: { distance: number; duration: number } } }[] };
? (geojson.features[0].properties.summary.distance / 1000).toFixed(2) : 'N/A'; const distance = g.features?.[0]?.properties?.summary?.distance
const duration = geojson.features?.[0]?.properties?.summary?.duration ? (g.features[0].properties.summary.distance / 1000).toFixed(2) : 'N/A';
? (geojson.features[0].properties.summary.duration / 60).toFixed(0) : '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, { this.routeGlow = L.polyline(coords, {
color: '#6CC24A', color: '#6CC24A',
@@ -163,11 +216,11 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
lineCap: 'round' lineCap: 'round'
}).addTo(this.map); }).addTo(this.map);
this.routeLine.bindPopup( (this.routeLine as L.Polyline).bindPopup(
`<strong style="color:#4F758B">${json.nombre_usuario}${json.nombre_pdv}</strong><br>Distancia: ${distance} km<br>Duración: ${duration} min` `<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([ const group = new L.FeatureGroup([
...(this.routeGlow ? [this.routeGlow] : []), ...(this.routeGlow ? [this.routeGlow] : []),
...(this.routeLine ? [this.routeLine] : []), ...(this.routeLine ? [this.routeLine] : []),
@@ -176,13 +229,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
this.map.fitBounds(group.getBounds().pad(0.1)); 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( this.routeLine = L.polyline(
[[origin[1], origin[0]], [destination[1], destination[0]]], [[origin[1], origin[0]], [destination[1], destination[0]]],
{ color: '#FF6A13', weight: 5, dashArray: '12, 10', opacity: 0.9 } { color: '#FF6A13', weight: 5, dashArray: '12, 10', opacity: 0.9 }
).addTo(this.map); ).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]); const group = new L.FeatureGroup([this.routeLine, ...this.markers]);
this.map.fitBounds(group.getBounds().pad(0.1)); this.map.fitBounds(group.getBounds().pad(0.1));
} }
@@ -190,9 +243,10 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
private addMarkers( private addMarkers(
origin: [number, number], origin: [number, number],
destination: [number, number], destination: [number, number],
json: any, json: RouteAssignedResponse['json'],
distance: string, distance: string,
duration: string duration: string,
originName: string
) { ) {
const startIcon = L.divIcon({ const startIcon = L.divIcon({
className: 'custom-marker marker-start', className: 'custom-marker marker-start',
@@ -204,17 +258,17 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
const endIcon = L.divIcon({ const endIcon = L.divIcon({
className: 'custom-marker marker-end', className: 'custom-marker marker-end',
html: '<div class="marker-inner">B</div>', html: '<div class="location-pin"><span></span></div>',
iconSize: [30, 30], iconSize: [30, 38],
iconAnchor: [15, 15], iconAnchor: [15, 38],
popupAnchor: [0, -18] popupAnchor: [0, -18]
}); });
this.markers.push( this.markers.push(
L.marker([origin[1], origin[0]], { icon: startIcon }) L.marker([origin[1], origin[0]], { icon: startIcon })
.addTo(this.map) .addTo(this.map)
.bindPopup(`<strong>Origen:</strong> ${json.nombre_usuario}`), .bindPopup(`<strong>Origen del tramo:</strong> ${originName}`),
L.marker([destination[1], destination[0]], { icon: endIcon }) L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 })
.addTo(this.map) .addTo(this.map)
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`) .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); color: var(--glm-acero);
font-size: 13px; 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')" (input)="dt.filterGlobal($any($event.target).value, 'contains')"
/> />
<div class="actions-right"> <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()"> <button type="button" class="btn-secondary" (click)="downloadCSV()">
<i class="pi pi-download"></i> Descargar CSV <i class="pi pi-download"></i> Descargar CSV
</button> </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 <button
type="button" type="button"
class="btn-secondary" class="btn-secondary"
@@ -22,6 +32,7 @@
</button> </button>
</div> </div>
</div> </div>
<div class="table-error" *ngIf="reoptError"><i class="pi pi-exclamation-triangle"></i> {{ reoptError }}</div>
<p-table <p-table
#dt #dt
@@ -60,20 +71,41 @@
<ng-template pTemplate="body" let-row> <ng-template pTemplate="body" let-row>
<tr> <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.nombre_pdv }}</td>
<td>{{ row.json.hora_laboral_semanal_usuario }}</td> <td>{{ row.json.hora_laboral_semanal_usuario }}</td>
<td>{{ row.json.hora_minima_semanal_pdv }}</td> <td>{{ row.json.hora_minima_semanal_pdv }}</td>
<td>{{ row.json.horas_trabajo }}</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.distancia }}</td>
<td>{{ row.json.horas_desplazamiento }}</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 <button
type="button" type="button"
class="btn-icon" class="btn-icon"
(click)="onShowMap(row)" (click)="onShowMap(row)"
pTooltip="Ver en mapa" pTooltip="Ver en mapa (1 ruta)"
tooltipPosition="top" tooltipPosition="top"
> >
<i class="pi pi-map-marker"></i> <i class="pi pi-map-marker"></i>
@@ -9,6 +9,8 @@ import { TableModule } from 'primeng/table';
import { InputTextModule } from 'primeng/inputtext'; import { InputTextModule } from 'primeng/inputtext';
import { TooltipModule } from 'primeng/tooltip'; import { TooltipModule } from 'primeng/tooltip';
import { RouteState } from '../../../core/route-state/route'; import { RouteState } from '../../../core/route-state/route';
import { RutasService } from '../../../core/services/router.service';
import { PlanningInputService } from '../../../core/services/planning-input.service';
@Component({ @Component({
standalone: true, standalone: true,
@@ -30,23 +32,37 @@ export class AssignedRouteComponent {
assignedRoutes: RouteAssignedResponse[] = []; assignedRoutes: RouteAssignedResponse[] = [];
isLoading = false; isLoading = false;
cols: any[] = []; cols: any[] = [];
editingRoute: RouteAssignedResponse | null = null;
editUserId = '';
editDay = '';
hasLocalChanges = false;
reoptLoading = false;
reoptError: string | null = null;
constructor( constructor(
private routesState: RouteState, private routesState: RouteState,
private cdr: ChangeDetectorRef, private cdr: ChangeDetectorRef,
private router: Router private router: Router,
private rutasService: RutasService,
private planningInput: PlanningInputService
) { } ) { }
private subs: import('rxjs').Subscription[] = [];
ngOnInit() { ngOnInit() {
this.routesState.resetLoading(); this.routesState.resetLoading();
this.subs.push(
this.routesState.loading$.subscribe(value => { this.routesState.loading$.subscribe(value => {
this.isLoading = value; this.isLoading = value;
this.cdr.detectChanges(); this.cdr.detectChanges();
}); })
);
this.subs.push(
this.routesState.assignedRoutes$.subscribe(data => { this.routesState.assignedRoutes$.subscribe(data => {
this.assignedRoutes = [...data]; this.assignedRoutes = [...data];
this.cdr.detectChanges(); this.cdr.detectChanges();
}); })
);
this.cols = [ this.cols = [
{ field: 'json.nombre_usuario', header: 'Usuario' }, { field: 'json.nombre_usuario', header: 'Usuario' },
{ field: 'json.nombre_pdv', header: 'PDV' }, { field: 'json.nombre_pdv', header: 'PDV' },
@@ -62,12 +78,99 @@ export class AssignedRouteComponent {
} }
onShowMap(row: RouteAssignedResponse) { onShowMap(row: RouteAssignedResponse) {
this.routesState.sendRoute(row); 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() { toggleRutas() {
this.rutasExpandido = !this.rutasExpandido; this.rutasExpandido = !this.rutasExpandido;
} }
ngOnDestroy() {
this.subs.forEach(s => s.unsubscribe());
}
downloadCSV() { downloadCSV() {
const headers = [ const headers = [
'Usuario ID', 'Usuario ID',
@@ -233,6 +233,161 @@
cursor: not-allowed; 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 { .spinner-inline {
width: 16px; width: 16px;
height: 16px; height: 16px;
@@ -15,10 +15,10 @@
<div class="upload-grid"> <div class="upload-grid">
<div class="file-input-wrapper"> <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> <i class="pi pi-file-excel"></i>
<span>Archivo PDVs (Excel)</span> <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> </label>
<input <input
id="pdvFile" id="pdvFile"
@@ -29,16 +29,39 @@
[class.has-file]="pdvFile" [class.has-file]="pdvFile"
/> />
<div class="file-name" *ngIf="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 }} {{ 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> </div>
<div class="file-input-wrapper"> <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> <i class="pi pi-file-excel"></i>
<span>Archivo Usuarios (Excel)</span> <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> </label>
<input <input
id="usuariosFile" id="usuariosFile"
@@ -49,8 +72,56 @@
[class.has-file]="usuariosFile" [class.has-file]="usuariosFile"
/> />
<div class="file-name" *ngIf="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 }} {{ 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> </div>
</div> </div>
@@ -65,12 +136,21 @@
type="button" type="button"
class="btn-primary" class="btn-primary"
(click)="cargarRutas()" (click)="cargarRutas()"
[disabled]="!canSubmit || isLoading" [disabled]="!canSubmit"
[title]="submitDisabledReason || ''"
> >
<span *ngIf="isLoading" class="spinner-inline"></span> <span *ngIf="isLoading" class="spinner-inline"></span>
<i *ngIf="!isLoading" class="pi pi-upload"></i> <i *ngIf="!isLoading" class="pi pi-upload"></i>
{{ isLoading ? 'Procesando...' : 'Cargar rutas' }} {{ isLoading ? 'Procesando...' : 'Cargar rutas' }}
</button> </button>
<button
type="button"
class="btn-secondary"
*ngIf="isLoading"
(click)="cancelarCarga()"
>
<i class="pi pi-times"></i> Cancelar
</button>
<button <button
type="button" type="button"
class="btn-secondary" class="btn-secondary"
@@ -80,9 +160,14 @@
<i class="pi pi-times"></i> Limpiar <i class="pi pi-times"></i> Limpiar
</button> </button>
</div> </div>
<small class="hint-text" *ngIf="submitDisabledReason && !isLoading">{{ submitDisabledReason }}</small>
</div> </div>
</section> </section>
<section class="card kpis-card">
<app-planning-kpis></app-planning-kpis>
</section>
<section class="card"> <section class="card">
<app-assigned-route></app-assigned-route> <app-assigned-route></app-assigned-route>
</section> </section>
@@ -1,4 +1,4 @@
import { Component } from '@angular/core'; import { Component, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { AssignedRouteComponent } from './assigned-route/assigned-route.component'; 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 { RutasService } from '../../core/services/router.service';
import { RouteState } from '../../core/route-state/route'; import { RouteState } from '../../core/route-state/route';
import { ApiResponse } from '../../core/models/route.model'; 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({ @Component({
standalone: true, standalone: true,
selector: 'app-home', selector: 'app-home',
imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent], imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent, PlanningKpisComponent],
templateUrl: './home.component.html', templateUrl: './home.component.html',
styleUrls: ['./home.component.css'] styleUrls: ['./home.component.css']
}) })
export class HomeComponent { export class HomeComponent {
pdvFile: File | null = null; pdvFile: File | null = null;
usuariosFile: File | null = null; usuariosFile: File | null = null;
pdvPreview: PreviewResult | null = null;
usuariosPreview: PreviewResult | null = null;
pdvPreviewLoading = false;
usuariosPreviewLoading = false;
uploadError: string | null = null; uploadError: string | null = null;
isLoading = false; isLoading = false;
private calcSub: Subscription | null = null;
// P2-A params
radioKm: 30 | 50 | 80 = DEFAULT_PARAMS.radioKm;
velocidad: number = DEFAULT_PARAMS.velocidad;
constructor( constructor(
private rutasService: RutasService, 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; const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) { if (input.files && input.files.length > 0) {
this.pdvFile = input.files[0]; const file = input.files[0];
this.validateFile(this.pdvFile, 'PDV'); 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.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; const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) { if (input.files && input.files.length > 0) {
this.usuariosFile = input.files[0]; const file = input.files[0];
this.validateFile(this.usuariosFile, 'Usuarios'); 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.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 validExtensions = ['.xlsx', '.xls'];
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
if (!validExtensions.includes(ext)) { if (!validExtensions.includes(ext)) {
this.uploadError = `El archivo ${type} debe ser .xlsx o .xls`; return `El archivo ${type} debe ser .xlsx o .xls`;
if (type === 'PDV') this.pdvFile = null;
else this.usuariosFile = null;
} }
if (file.size > 10 * 1024 * 1024) { if (file.size > 10 * 1024 * 1024) {
this.uploadError = `El archivo ${type} supera 10MB`; return `El archivo ${type} supera 10MB`;
if (type === 'PDV') this.pdvFile = null;
else this.usuariosFile = null;
} }
return null;
} }
get canSubmit(): boolean { 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() { cargarRutas() {
@@ -68,36 +178,78 @@ export class HomeComponent {
this.isLoading = true; this.isLoading = true;
this.uploadError = null; this.uploadError = null;
this.routeState.setLoading(true); this.routeState.setLoading(true);
this.planningInput.setFiles(this.pdvFile!, this.usuariosFile!, this.radioKm, this.velocidad);
const formData = new FormData(); const formData = new FormData();
formData.append('pdv_file', this.pdvFile!, this.pdvFile!.name); formData.append('pdv_file', this.pdvFile!, this.pdvFile!.name);
formData.append('usuarios_file', this.usuariosFile!, this.usuariosFile!.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) => { next: (res: ApiResponse) => {
const assigned = res.find(r => 'assigned' in r); // ApiResponse is [AssignedResponse, NoAssignedResponse] but backend may return object with error
const noAssigned = res.find(r => 'noAssigned' in r); 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) { if (assigned) {
this.routeState.setAssignedRoutes(assigned.assigned); this.routeState.setAssignedRoutes(assigned.assigned as never);
} }
if (noAssigned) { if (noAssigned) {
this.routeState.setNoAssignedRoutes(noAssigned.noAssigned); this.routeState.setNoAssignedRoutes(noAssigned.noAssigned as never);
} }
this.routeState.setLoading(false); this.routeState.setLoading(false);
this.isLoading = false; this.isLoading = false;
this.cdr.detectChanges();
}, },
error: (err) => { error: (err) => {
this.uploadError = err.error?.message || 'Error al procesar los archivos'; this.uploadError = this.mapBackendError(err);
this.routeState.setLoading(false); this.routeState.setLoading(false);
this.isLoading = 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() { clearFiles() {
this.calcSub?.unsubscribe();
this.planningInput.clear();
this.routeState.clearAll();
this.pdvFile = null; this.pdvFile = null;
this.usuariosFile = null; this.usuariosFile = null;
this.pdvPreview = null;
this.usuariosPreview = null;
this.pdvPreviewLoading = false;
this.usuariosPreviewLoading = false;
this.uploadError = null; this.uploadError = null;
this.isLoading = false;
this.routeState.setLoading(false);
const pdvInput = document.getElementById('pdvFile') as HTMLInputElement; const pdvInput = document.getElementById('pdvFile') as HTMLInputElement;
const usuariosInput = document.getElementById('usuariosFile') as HTMLInputElement; const usuariosInput = document.getElementById('usuariosFile') as HTMLInputElement;
if (pdvInput) pdvInput.value = ''; if (pdvInput) pdvInput.value = '';
@@ -11,3 +11,42 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.3px; 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); } }
@@ -20,13 +20,28 @@
<td class="text-left">{{ row.latitud }}</td> <td class="text-left">{{ row.latitud }}</td>
<td class="text-left">{{ row.longitud }}</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> </tr>
</ng-template> </ng-template>
<ng-template pTemplate="emptymessage"> <ng-template pTemplate="emptymessage">
<tr> <tr>
<td colspan="8" class="text-center"> <td colspan="7" class="text-center">
No hay registros asignados No hay registros asignados
</td> </td>
</tr> </tr>
</ng-template> </ng-template>
</p-table> </p-table>
<div class="table-error" *ngIf="actionError"><i class="pi pi-exclamation-triangle"></i> {{ actionError }}</div>
@@ -1,36 +1,49 @@
import { Component, ChangeDetectorRef } from '@angular/core'; import { Component, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TableModule } from 'primeng/table'; import { TableModule } from 'primeng/table';
import { RouteNoAssignedResponse } from '../../../core/models/route.model'; import { RouteNoAssignedResponse } from '../../../core/models/route.model';
import { RouteState } from '../../../core/route-state/route'; import { RouteState } from '../../../core/route-state/route';
import { Subscription } from 'rxjs';
import { OrsService } from '../../../core/services/ors.service';
@Component({ @Component({
standalone: true, standalone: true,
selector: 'app-no-assigned-route', selector: 'app-no-assigned-route',
imports: [CommonModule, TableModule], imports: [CommonModule, FormsModule, TableModule],
templateUrl: './no-assigned-route.component.html', templateUrl: './no-assigned-route.component.html',
styleUrls: ['./no-assigned-route.component.css'] styleUrls: ['./no-assigned-route.component.css']
}) })
export class NoAssignedRouteComponent { export class NoAssignedRouteComponent implements OnDestroy {
noAssignedRoutes: RouteNoAssignedResponse[] = []; noAssignedRoutes: RouteNoAssignedResponse[] = [];
isLoading = false; 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( constructor(
private routesState: RouteState, private routesState: RouteState,
private cdr: ChangeDetectorRef private cdr: ChangeDetectorRef,
private orsService: OrsService
) {} ) {}
ngOnInit() { ngOnInit() {
this.subs.push(
this.routesState.loading$.subscribe(value => { this.routesState.loading$.subscribe(value => {
this.isLoading = value; this.isLoading = value;
this.cdr.detectChanges(); this.cdr.detectChanges();
}); })
);
this.subs.push(
this.routesState.noAssignedRoutes$.subscribe(data => { this.routesState.noAssignedRoutes$.subscribe(data => {
this.noAssignedRoutes = [...data]; this.noAssignedRoutes = [...data];
this.cdr.detectChanges(); this.cdr.detectChanges();
}); })
);
this.cols = [ this.cols = [
{ field: 'pdv_id', header: 'PDV ID' }, { field: 'pdv_id', header: 'PDV ID' },
{ field: 'pdv_nombre', header: 'Nombre' }, { field: 'pdv_nombre', header: 'Nombre' },
@@ -40,4 +53,45 @@ export class NoAssignedRouteComponent {
{ field: 'motivo', header: 'Motivo' } { 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
};
}
}
@@ -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; }
@@ -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>
@@ -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,
};
-1
View File
@@ -6,7 +6,6 @@
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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="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> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>
+40
View File
@@ -1,5 +1,45 @@
@import "primeicons/primeicons.css"; @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 { :root {
--glm-verde: #6CC24A; --glm-verde: #6CC24A;
--glm-verde-claro: #A4D65E; --glm-verde-claro: #A4D65E;