8 Commits

58 changed files with 2003 additions and 475 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
# OpenRouteService por país
- República Dominicana: `http://localhost:18080/ors/v2`
- México: `http://localhost:18081/ors/v2`
Cada país mantiene su propio archivo OSM, configuración y directorio de grafos. Desde contenedores Docker, como n8n, usa `host.docker.internal` en lugar de `localhost`.
El workflow selecciona el servicio según la columna `País` del archivo PDV. `MEXICO` usa `18081`; cualquier proyecto anterior sin país se mantiene en República Dominicana y usa `18080`.
La imagen ORS configura memoria con `XMS`, `XMX` y `ADDITIONAL_JAVA_OPTS`; no se debe sobrescribir `JAVA_OPTS`, porque el entrypoint de la imagen lo reconstruye.
```powershell
docker compose -f ors-app/republica-dominicana/docker-compose.yml up -d
docker compose -f ors-app/mexico/docker-compose.yml up -d
```
+16
View File
@@ -0,0 +1,16 @@
services:
ors:
image: openrouteservice/openrouteservice:latest
container_name: ors-mexico
ports:
- "18081:8082"
volumes:
- ./ors-docker/mexico-latest.osm.pbf:/home/ors/files/mexico-latest.osm.pbf
- ./ors-docker/graphs:/home/ors/graphs
- ./ors-docker/elevation_cache:/home/ors/elevation_cache
- ./ors-docker/config/ors-config.yml:/home/ors/config/ors-config.yml
environment:
- XMS=4g
- XMX=12g
- ADDITIONAL_JAVA_OPTS=-XX:MaxGCPauseMillis=300 -XX:InitiatingHeapOccupancyPercent=75
restart: unless-stopped
@@ -0,0 +1,17 @@
ors:
cors:
allowed_origins: "*"
allowed_headers: Content-Type, X-Requested-With, accept, Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Authorization
preflight_max_age: 600
engine:
profile_default:
enabled: false
graph_path: graphs
build:
source_file: /home/ors/files/mexico-latest.osm.pbf
elevation: false
service:
maximum_distance: 500000
profiles:
driving-car:
enabled: true
@@ -1,7 +1,7 @@
services: services:
ors: ors:
image: openrouteservice/openrouteservice:latest image: openrouteservice/openrouteservice:latest
container_name: ors-route-planner container_name: ors-republica-dominicana
ports: ports:
- "18080:8082" - "18080:8082"
volumes: volumes:
@@ -10,5 +10,7 @@ services:
- ./ors-docker/elevation_cache:/home/ors/elevation_cache - ./ors-docker/elevation_cache:/home/ors/elevation_cache
- ./ors-docker/config/ors-config.yml:/home/ors/config/ors-config.yml - ./ors-docker/config/ors-config.yml:/home/ors/config/ors-config.yml
environment: environment:
- JAVA_OPTS=-Djava.awt.headless=true -server -XX:TargetSurvivorRatio=75 -XX:SurvivorRatio=64 -XX:MaxTenuringThreshold=3 -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:InitiatingHeapOccupancyPercent=80 -Xms2g -Xmx4g - XMS=1g
- XMX=4g
- ADDITIONAL_JAVA_OPTS=-XX:MaxGCPauseMillis=200 -XX:InitiatingHeapOccupancyPercent=80
restart: unless-stopped restart: unless-stopped
+1 -1
View File
@@ -30,7 +30,7 @@
"production": { "production": {
"budgets": [ "budgets": [
{ "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" }, { "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" },
{ "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "10kB" } { "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "12kB" }
], ],
"outputHashing": "all", "outputHashing": "all",
"fileReplacements": [ "fileReplacements": [
+102
View File
@@ -15,6 +15,7 @@
"@angular/platform-browser": "^21.1.0", "@angular/platform-browser": "^21.1.0",
"@angular/router": "^21.1.0", "@angular/router": "^21.1.0",
"@primeng/themes": "^21.0.0", "@primeng/themes": "^21.0.0",
"@supabase/supabase-js": "^2.115.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.heat": "^0.2.0",
@@ -4037,6 +4038,98 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@supabase/auth-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.115.0.tgz",
"integrity": "sha512-YNQlQWm1H0gsXHSY8Jd/xepBhjO0Zhwx04iW17A83/joQ5kFiUin6iPj9s9kZZvupnwLjXVP/diTFiLz2jUbwQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.115.0.tgz",
"integrity": "sha512-p97V6/YFcdp+zblFDVJaE8f9rGKTNz0PRzyJ2d1w/EYIU5lwidKuc3l/wM+u27ACmAC62albvoGiUGzLPSg7Aw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
"integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.115.0.tgz",
"integrity": "sha512-DdERcurLh5t84pgSywDg2LjLR5le7XAzl52/iQhC7FdboWDqGfDPMfaWJV3MHvhyxSlSNzbpSQG+RiQOBSn/4w==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.115.0.tgz",
"integrity": "sha512-5HyBkvlA/IUV2v8jX3uLTdy15jmTPRVXwXKoxvH2SKw5jZMNURxCxt+VpCEukscXQlY7pUpH8Cy4NH6io4iaRw==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "0.4.5",
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.115.0.tgz",
"integrity": "sha512-dLyIxzbO+MCcKHhcce8rVUCQX1iyqXqQ8ytgkOVYJ7D+Zp0qKylPtQH3hamgxrGSxtDjaw47Urpzw2iK9PsKdA==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.115.0.tgz",
"integrity": "sha512-PYJSxtCo37R7tTZW6pAqsxUeSx/dlhA7zn8RzKEUSCqyTxCUhG+iHrDTb04UyVBYbttaUbhwkNTvb8h5HW+uZA==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.115.0",
"@supabase/functions-js": "2.115.0",
"@supabase/postgrest-js": "2.115.0",
"@supabase/realtime-js": "2.115.0",
"@supabase/storage-js": "2.115.0"
},
"engines": {
"node": ">=22.0.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@tufjs/canonical-json": { "node_modules/@tufjs/canonical-json": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
@@ -5888,6 +5981,15 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.7.3", "version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+1
View File
@@ -17,6 +17,7 @@
"@angular/platform-browser": "^21.1.0", "@angular/platform-browser": "^21.1.0",
"@angular/router": "^21.1.0", "@angular/router": "^21.1.0",
"@primeng/themes": "^21.0.0", "@primeng/themes": "^21.0.0",
"@supabase/supabase-js": "^2.115.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.heat": "^0.2.0",
+12 -3
View File
@@ -3,12 +3,21 @@ 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'; import { RouteOverviewMapComponent } from './private/home/route-overview-map/route-overview-map.component';
import { authGuard } from './core/services/auth.guard';
import { EmployeesComponent } from './private/employees/employees.component';
import { ProjectListComponent } from './private/projects/project-list.component';
import { NewProjectComponent } from './private/projects/new-project.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: ProjectListComponent, pathMatch: 'full', canActivate: [authGuard] },
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent }, { path: 'proyectos/nuevo', component: NewProjectComponent, canActivate: [authGuard] },
{ path: 'home/mapa', component: RouteOverviewMapComponent }, { path: 'proyectos/:id', component: HomeComponent, pathMatch: 'full', canActivate: [authGuard] },
{ path: 'proyectos/:id/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] },
{ path: 'proyectos/:id/ruta', component: AssignedRouteDetailComponent, canActivate: [authGuard] },
{ path: 'empleados', component: EmployeesComponent, canActivate: [authGuard] },
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent, canActivate: [authGuard] },
{ path: 'home/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] },
{ path: '**', redirectTo: 'login' } { path: '**', redirectTo: 'login' }
]; ];
@@ -0,0 +1,21 @@
export interface Employee {
id: string;
employee_code: string;
nombre: string;
usuario: string | null;
email: string | null;
perfil: string | null;
pais: string | null;
regional: string | null;
latitud: number;
longitud: number;
horas_por_dia: number;
dias_por_semana: number;
jornada_codigo: string;
horas_descanso_por_dia: number;
dias_descanso: string[];
visitas_maximas_por_dia: number;
activo: boolean;
}
export type EmployeeInput = Omit<Employee, 'id'>;
@@ -0,0 +1,17 @@
export interface RuteroProyecto {
id: string;
nombre: string;
descripcion: string | null;
propietario_id: string;
estado: 'borrador' | 'optimizado' | 'archivado';
version: number;
created_at: string;
updated_at: string;
}
export interface RuteroProyectoPdv {
id: string;
proyecto_id: string;
pdv_id: string;
pdv_data: Record<string, unknown>;
}
@@ -27,6 +27,23 @@ export interface RouteAssignedJson {
usuario_id: string; usuario_id: string;
pdv_id: number; pdv_id: number;
visit_order?: number; visit_order?: number;
geojson?: unknown;
horas_por_dia_usuario?: number;
horas_descanso_por_dia?: number;
jornada_codigo?: string;
pais?: string;
ciudad?: string;
estado?: string;
provincia?: string;
region?: string;
zona?: string;
metro?: string;
pais_usuario?: string;
ciudad_usuario?: string;
estado_usuario?: string;
provincia_usuario?: string;
region_usuario?: string;
zona_usuario?: string;
} }
export interface RouteNoAssignedResponse { export interface RouteNoAssignedResponse {
pdv_id: number; pdv_id: number;
@@ -35,4 +52,11 @@ export interface RouteAssignedJson {
latitud: number, latitud: number,
longitud: number, longitud: number,
motivo: string; motivo: string;
pais?: string;
ciudad?: string;
estado?: string;
provincia?: string;
region?: string;
zona?: string;
metro?: string;
} }
@@ -0,0 +1,4 @@
export interface UserProfile {
nombre: string;
pais: string;
}
@@ -0,0 +1,17 @@
export interface WorkSchedule {
code: 'LUN_DOM_44' | 'LUN_SAB_40' | 'LUN_SAB_44' | 'LUN_VIE_40';
label: string;
weeklyHours: number;
days: string[];
}
export const WORK_SCHEDULES: WorkSchedule[] = [
{ code: 'LUN_DOM_44', label: 'Lun a Dom - 44h', weeklyHours: 44, days: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado', 'Domingo'] },
{ code: 'LUN_SAB_40', label: 'Lun a Sáb - 40h', weeklyHours: 40, days: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'] },
{ code: 'LUN_SAB_44', label: 'Lun a Sáb - 44h', weeklyHours: 44, days: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'] },
{ code: 'LUN_VIE_40', label: 'Lun a Vie - 40h', weeklyHours: 40, days: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes'] }
];
export function workSchedule(code: string): WorkSchedule {
return WORK_SCHEDULES.find(schedule => schedule.code === code) ?? WORK_SCHEDULES[3];
}
@@ -11,7 +11,7 @@ const STORAGE_ASSIGNED_ORIGINAL = 'rp_assigned_routes_original';
const STORAGE_NO_ASSIGNED = 'rp_no_assigned_routes'; const STORAGE_NO_ASSIGNED = 'rp_no_assigned_routes';
const STORAGE_NO_ASSIGNED_ORIGINAL = 'rp_no_assigned_routes_original'; const STORAGE_NO_ASSIGNED_ORIGINAL = 'rp_no_assigned_routes_original';
const STORAGE_SELECTED = 'rp_selected_route'; const STORAGE_SELECTED = 'rp_selected_route';
const DAY_ORDER = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes']; const DAY_ORDER = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes', 'Sábado', 'Sabado'];
export interface UnassignedAssignment { export interface UnassignedAssignment {
userId: string; userId: string;
day: string; day: string;
@@ -65,6 +65,12 @@ export class RouteState {
nombre_usuario: profile.json.nombre_usuario, nombre_usuario: profile.json.nombre_usuario,
latitud_usuario: profile.json.latitud_usuario, latitud_usuario: profile.json.latitud_usuario,
longitud_usuario: profile.json.longitud_usuario, longitud_usuario: profile.json.longitud_usuario,
pais_usuario: profile.json.pais_usuario,
ciudad_usuario: profile.json.ciudad_usuario,
estado_usuario: profile.json.estado_usuario,
provincia_usuario: profile.json.provincia_usuario,
region_usuario: profile.json.region_usuario,
zona_usuario: profile.json.zona_usuario,
hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario, hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario,
dia: day, dia: day,
distancia: metrics?.distance ?? item.json.distancia, distancia: metrics?.distance ?? item.json.distancia,
@@ -98,7 +104,20 @@ export class RouteState {
nombre_usuario: profile.json.nombre_usuario, nombre_usuario: profile.json.nombre_usuario,
nombre_pdv: route.pdv_nombre, nombre_pdv: route.pdv_nombre,
usuario_id: profile.json.usuario_id, usuario_id: profile.json.usuario_id,
pdv_id: route.pdv_id pdv_id: route.pdv_id,
pais: route.pais,
ciudad: route.ciudad,
estado: route.estado,
provincia: route.provincia,
region: route.region,
zona: route.zona,
metro: route.metro,
pais_usuario: profile.json.pais_usuario,
ciudad_usuario: profile.json.ciudad_usuario,
estado_usuario: profile.json.estado_usuario,
provincia_usuario: profile.json.provincia_usuario,
region_usuario: profile.json.region_usuario,
zona_usuario: profile.json.zona_usuario
} } as RouteAssignedResponse; } } as RouteAssignedResponse;
}); });
if (newRoutes.some(item => !item)) return; if (newRoutes.some(item => !item)) return;
@@ -195,6 +214,12 @@ export class RouteState {
nombre_usuario: user.json.nombre_usuario, nombre_usuario: user.json.nombre_usuario,
latitud_usuario: user.json.latitud_usuario, latitud_usuario: user.json.latitud_usuario,
longitud_usuario: user.json.longitud_usuario, longitud_usuario: user.json.longitud_usuario,
pais_usuario: user.json.pais_usuario,
ciudad_usuario: user.json.ciudad_usuario,
estado_usuario: user.json.estado_usuario,
provincia_usuario: user.json.provincia_usuario,
region_usuario: user.json.region_usuario,
zona_usuario: user.json.zona_usuario,
hora_laboral_semanal_usuario: user.json.hora_laboral_semanal_usuario hora_laboral_semanal_usuario: user.json.hora_laboral_semanal_usuario
} }
}; };
@@ -0,0 +1,9 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = async () => {
const auth = inject(AuthService);
const router = inject(Router);
return await auth.getSession() ? true : router.parseUrl('/login');
};
@@ -0,0 +1,42 @@
import { Injectable } from '@angular/core';
import { Session } from '@supabase/supabase-js';
import { supabaseClient } from './supabase.client';
import { UserProfileService } from './user-profile.service';
const CORPORATE_DOMAIN = '@gomezleemarketing.com';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly client = supabaseClient;
constructor(private profile: UserProfileService) {}
async signIn(email: string, password: string): Promise<string | null> {
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail.endsWith(CORPORATE_DOMAIN)) {
return `Solo se permiten correos ${CORPORATE_DOMAIN}.`;
}
const { error } = await this.client.auth.signInWithPassword({ email: normalizedEmail, password });
return error?.message ?? null;
}
async signInWithGoogle(): Promise<string | null> {
const { error } = await this.client.auth.signInWithOAuth({
provider: 'google',
// Return to login so the OAuth callback can load the profile from n8n
// before private routes are opened.
options: { redirectTo: `${window.location.origin}/login` }
});
return error?.message ?? null;
}
async getSession(): Promise<Session | null> {
const { data } = await this.client.auth.getSession();
const email = data.session?.user.email?.toLowerCase() ?? '';
return email.endsWith(CORPORATE_DOMAIN) ? data.session : null;
}
async signOut(): Promise<void> {
await this.client.auth.signOut();
this.profile.clear();
}
}
@@ -0,0 +1,80 @@
import { Injectable } from '@angular/core';
import { Employee, EmployeeInput } from '../models/employee.model';
import { workSchedule } from '../models/work-schedule.model';
import * as XLSX from 'xlsx';
import { supabaseClient } from './supabase.client';
@Injectable({ providedIn: 'root' })
export class EmployeeService {
private readonly client = supabaseClient;
private readonly table = 'rutero_empleados';
async list(): Promise<Employee[]> {
const { data: sessionData, error: sessionError } = await this.client.auth.getSession();
if (sessionError) throw sessionError;
if (!sessionData.session) throw new Error('No hay una sesión Supabase activa. Cierra sesión e inicia sesión nuevamente.');
const { data, error } = await this.client.from(this.table).select('*').order('nombre');
if (error) throw new Error(`Supabase ${error.code ?? 'error'}: ${error.message}`);
return (data ?? []).map(row => ({
...row,
jornada_codigo: row.jornada_codigo ?? 'LUN_VIE_40',
horas_descanso_por_dia: Number(row.horas_descanso_por_dia ?? 1),
dias_descanso: Array.isArray(row.dias_descanso) ? row.dias_descanso : []
})) as Employee[];
}
async create(employee: EmployeeInput): Promise<void> {
const { error } = await this.client.from(this.table).insert(employee);
if (error) throw error;
}
async upsert(employees: EmployeeInput[]): Promise<void> {
const { error } = await this.client.from(this.table).upsert(employees, { onConflict: 'employee_code' });
if (error) throw error;
}
async update(id: string, employee: EmployeeInput): Promise<void> {
const { error } = await this.client.from(this.table).update(employee).eq('id', id);
if (error) throw error;
}
async remove(id: string): Promise<void> {
const { error } = await this.client.from(this.table).delete().eq('id', id);
if (error) throw error;
}
async active(country = ''): Promise<Employee[]> {
const expectedCountry = this.normalizeCountry(country);
return (await this.list()).filter(employee => employee.activo && (!expectedCountry || this.normalizeCountry(employee.pais ?? '') === expectedCountry));
}
async activeEmployeesFile(country = ''): Promise<File> {
const employees = await this.active(country);
const rows = employees.map(employee => ({
ID: employee.employee_code,
'Nombre del empleado': employee.nombre,
'País': employee.pais,
Regional: employee.regional,
Latitud: employee.latitud,
Longitud: employee.longitud,
'Horas por día usuario': employee.horas_por_dia,
'Horas por semana usuario': workSchedule(employee.jornada_codigo).weeklyHours,
'Visitas maximas por dia': employee.visitas_maximas_por_dia,
Jornada: employee.jornada_codigo,
'Horas descanso por día': employee.horas_descanso_por_dia,
'Días descanso': (employee.dias_descanso ?? []).join(', '),
'Usuario activo': 'Sí'
}));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), 'Usuarios');
const data = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
return new File([data], 'empleados_activos.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}
private normalizeCountry(country: string): string {
const normalized = country.normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().toUpperCase();
if (normalized.includes('MEXICO')) return 'MEXICO';
if (normalized.includes('DOMINICANA')) return 'REPUBLICA DOMINICANA';
return normalized;
}
}
@@ -16,8 +16,6 @@ import {
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
const ENDPOINT = `${environment.orsBaseUrl}/directions/driving-car`;
const ENDPOINTJSON = `${environment.orsBaseUrl}/directions/driving-car/geojson`;
const MAX_CONCURRENT_REQUESTS = 2; const MAX_CONCURRENT_REQUESTS = 2;
@Injectable({ @Injectable({
@@ -41,8 +39,12 @@ export class OrsService {
const body = { const body = {
coordinates: [origin, destination] coordinates: [origin, destination]
}; };
const baseUrl = this.isMexico(origin) || this.isMexico(destination)
? environment.orsMexicoBaseUrl
: environment.orsBaseUrl;
const endpoint = `${baseUrl}/directions/driving-car${isJson ? '/geojson' : ''}`;
return this.http.post<unknown>(isJson ? ENDPOINTJSON : ENDPOINT, body).pipe( return this.http.post<unknown>(endpoint, body).pipe(
timeout(8000), timeout(8000),
catchError(error => { catchError(error => {
throw error; throw error;
@@ -50,6 +52,11 @@ export class OrsService {
); );
} }
private isMexico(coordinates: number[]): boolean {
const [longitude, latitude] = coordinates;
return latitude >= 14 && latitude <= 33 && longitude >= -119 && longitude <= -86;
}
async processPairs( async processPairs(
pares: { pares: {
pdv_id: number; pdv_id: number;
@@ -0,0 +1,94 @@
import { Injectable } from '@angular/core';
import * as XLSX from 'xlsx';
import { RuteroProyecto, RuteroProyectoPdv } from '../models/project.model';
import { supabaseClient } from './supabase.client';
@Injectable({ providedIn: 'root' })
export class ProjectService {
async list(): Promise<RuteroProyecto[]> {
const { data, error } = await supabaseClient.from('rutero_proyectos').select('*').order('updated_at', { ascending: false });
if (error) throw error;
return (data ?? []) as RuteroProyecto[];
}
async get(id: string): Promise<RuteroProyecto> {
const { data, error } = await supabaseClient.from('rutero_proyectos').select('*').eq('id', id).single();
if (error) throw error;
return data as RuteroProyecto;
}
async update(id: string, changes: Partial<Pick<RuteroProyecto, 'nombre' | 'descripcion'>>): Promise<void> {
const { error } = await supabaseClient.from('rutero_proyectos').update(changes).eq('id', id);
if (error) throw error;
}
async remove(id: string): Promise<void> {
const dependentTables = ['rutero_proyecto_rutas', 'rutero_proyecto_pdvs'];
for (const table of dependentTables) {
const { error } = await supabaseClient.from(table).delete().eq('proyecto_id', id);
if (error) throw error;
}
const { error } = await supabaseClient.from('rutero_proyectos').delete().eq('id', id);
if (error) throw error;
}
async create(nombre: string, rows: Record<string, unknown>[]): Promise<RuteroProyecto> {
const { data: sessionData, error: sessionError } = await supabaseClient.auth.getSession();
if (sessionError) throw sessionError;
const ownerId = sessionData.session?.user.id;
if (!ownerId) throw new Error('No hay una sesión activa.');
const projectId = crypto.randomUUID();
const now = new Date().toISOString();
const project: RuteroProyecto = {
id: projectId,
nombre: nombre.trim(),
descripcion: null,
propietario_id: ownerId,
estado: 'borrador',
version: 1,
created_at: now,
updated_at: now
};
const { error } = await supabaseClient
.from('rutero_proyectos')
.insert({ id: projectId, nombre: project.nombre, propietario_id: ownerId });
if (error) throw error;
const pdvs = rows.map((pdv, index) => ({
proyecto_id: projectId,
pdv_id: String(pdv['ID'] ?? pdv['id'] ?? pdv['Id'] ?? index + 1),
pdv_data: pdv
}));
const { error: pdvError } = await supabaseClient.from('rutero_proyecto_pdvs').insert(pdvs);
if (pdvError) {
await supabaseClient.from('rutero_proyectos').delete().eq('id', projectId);
throw pdvError;
}
return project;
}
async pdvs(id: string): Promise<RuteroProyectoPdv[]> {
const { data, error } = await supabaseClient.from('rutero_proyecto_pdvs').select('*').eq('proyecto_id', id).order('created_at');
if (error) throw error;
return (data ?? []) as RuteroProyectoPdv[];
}
async projectInput(id: string): Promise<{ file: File; country: string }> {
const pdvs = await this.pdvs(id);
const rows = pdvs.map(pdv => pdv.pdv_data);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), 'PDVs');
const data = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
const country = rows.map(row => Object.entries(row).find(([key]) => {
const normalized = key.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
return normalized === 'pais' || normalized === 'country';
})?.[1]).find(value => String(value ?? '').trim());
return {
file: new File([data], 'pdvs_proyecto.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
country: String(country ?? 'Republica Dominicana').trim()
};
}
async pdvFile(id: string): Promise<File> {
return (await this.projectInput(id)).file;
}
}
@@ -17,7 +17,7 @@ export class RutasService {
timeout(environment.requestTimeoutMs), timeout(environment.requestTimeoutMs),
catchError((err: HttpErrorResponse | Error) => { catchError((err: HttpErrorResponse | Error) => {
if (err instanceof Error && err.name === 'TimeoutError') { 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(() => Object.assign(new Error(`Timeout al calcular rutas (${environment.requestTimeoutMs / 1000}s). Intenta con menos filas o reintenta.`), { name: 'TimeoutError' }));
} }
return throwError(() => err); return throwError(() => err);
}) })
@@ -0,0 +1,6 @@
import { createClient } from '@supabase/supabase-js';
import { environment } from '../../../environments/environment';
export const supabaseClient = createClient(environment.supabaseUrl, environment.supabaseAnonKey, {
auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true }
});
@@ -0,0 +1,48 @@
import { Injectable } from '@angular/core';
import { environment } from '../../../environments/environment';
import { UserProfile } from '../models/user-profile.model';
@Injectable({ providedIn: 'root' })
export class UserProfileService {
private readonly storageKey = 'rp_user_profile';
private profile: UserProfile | null = null;
avatarUrl = '';
constructor() { this.restore(); }
get current(): UserProfile | null { return this.profile; }
clear(): void {
this.profile = null;
this.avatarUrl = '';
sessionStorage.removeItem(this.storageKey);
}
async load(email: string, avatarUrl = ''): Promise<UserProfile> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(environment.userProfileWebhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), signal: controller.signal });
if (!response.ok) throw new Error(`El webhook respondió con ${response.status}.`);
const data = await response.json() as Partial<UserProfile>;
if (!data.nombre || !data.pais) throw new Error('El webhook no devolvió nombre y país.');
this.profile = { nombre: data.nombre, pais: data.pais };
this.avatarUrl = avatarUrl;
sessionStorage.setItem(this.storageKey, JSON.stringify({ ...this.profile, avatarUrl }));
return this.profile;
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') throw new Error('La consulta de datos del usuario tardó demasiado.');
throw error;
} finally { window.clearTimeout(timeout); }
}
private restore(): void {
try {
const stored = sessionStorage.getItem(this.storageKey);
if (!stored) return;
const data = JSON.parse(stored) as Partial<UserProfile> & { avatarUrl?: string };
if (!data.nombre || !data.pais) return;
this.profile = { nombre: data.nombre, pais: data.pais };
this.avatarUrl = data.avatarUrl ?? '';
} catch {
sessionStorage.removeItem(this.storageKey);
}
}
}
@@ -0,0 +1,21 @@
.employees-topbar { display: flex; align-items: center; gap: 12px; padding: 9px 24px; border-bottom: 3px solid var(--glm-verde); background: #fff; color: var(--glm-azul); font-weight: 700; }
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
.employees-topbar img { height: 38px; width: auto; }
.topbar-actions { display: flex; gap: 8px; margin-left: auto; }
.topbar-actions button, .table-header button, .form-actions button, .row-actions button { border: 1px solid var(--glm-gris-medio); border-radius: 4px; background: #fff; color: var(--glm-azul); cursor: pointer; font: inherit; font-size: .78rem; font-weight: 600; padding: 7px 10px; }
.topbar-actions button:hover, .table-header button:hover, .form-actions button:hover, .row-actions button:hover { background: var(--glm-verde-bg); border-color: var(--glm-verde); }
.employees-page { max-width: 1200px; margin: 0 auto; padding: 24px; }
.employees-page header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; border-bottom: 3px solid var(--glm-verde); padding-bottom: 12px; }
.employees-page h1, .employees-page h2 { margin: 0; color: var(--glm-azul); }
.employees-page h1 { font-size: 1.45rem; }.employees-page h2 { font-size: 1rem; }
.employees-page header p, .weekly-capacity { color: var(--glm-acero); }
.import-button { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 1px solid var(--glm-verde); border-radius: 4px; background: var(--glm-verde-bg); color: var(--glm-azul); cursor: pointer; font-size: .82rem; font-weight: 700; }.import-button input { display: none; }.import-message { color: var(--glm-verde); font-weight: 600; }
.employee-form-card, .employee-table-card { margin-top: 16px; padding: 18px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: #fff; }
.employee-form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 16px; }
.employee-form-grid label { display: flex; flex-direction: column; gap: 4px; color: var(--glm-azul); font-size: .75rem; font-weight: 700; text-transform: uppercase; }
.employee-form-grid input, .employee-form-grid select { padding: 8px; border: 1px solid #9aaab3; border-radius: 4px; background: #fff; color: #2f3f48; font: inherit; font-weight: 500; text-transform: none; }
.employee-form-grid input::placeholder { color: #71808a; opacity: 1; }
.employee-form-grid input:focus, .employee-form-grid select:focus { outline: 2px solid rgba(108, 194, 74, .35); border-color: var(--glm-verde); background: #fff; color: #1f2d34; }
.form-actions { display: flex; gap: 8px; }.form-actions button:first-child { background: var(--glm-azul); color: #fff; }.form-error { color: #c9252d; font-size: .82rem; }.table-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }.table-wrap { overflow-x: auto; margin-top: 12px; }table { width: 100%; min-width: 920px; border-collapse: collapse; font-size: .82rem; }th { padding: 9px; background: var(--glm-azul); color: #fff; text-align: left; }td { padding: 9px; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); }.status { color: var(--glm-verde); font-weight: 700; }.status.inactive { color: #c9252d; }.row-actions { display: flex; gap: 5px; }.row-actions .delete { color: #c9252d; }.loading { color: var(--glm-acero); }.rest-days-button { max-width: 155px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.rest-days-picker { display: grid; gap: 4px; margin-top: 6px; padding: 6px; border: 1px solid var(--glm-gris-medio); background: #fff; }.rest-days-picker label { font-size: .72rem; white-space: nowrap; }
.editing-row { background: var(--glm-verde-bg); }.editing-row select, .inline-number { width: 100%; min-width: 92px; box-sizing: border-box; padding: 7px; border: 1px solid var(--glm-verde); border-radius: 4px; font: inherit; }.inline-number { max-width: 100px; }.inline-picker { min-width: 130px; margin: 0; }.editing-row .row-actions { flex-wrap: wrap; }
@media (max-width: 760px) { .employees-page { padding: 16px; }.employee-form-grid { grid-template-columns: 1fr; }.employees-topbar { padding: 9px 16px; }.topbar-actions button { font-size: 0; }.topbar-actions button i { font-size: .9rem; } }
@@ -0,0 +1,50 @@
<div class="employees-topbar">
<img src="GLM_completo.png" alt="GomezLee Marketing" />
<span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div>
<div class="topbar-actions"><button type="button" (click)="goBack()"><i class="pi pi-arrow-left"></i> Volver</button><button type="button" (click)="goHome()"><i class="pi pi-home"></i> Proyectos</button><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
</div>
<main class="employees-page">
<header><div><h1>Gestionar empleados</h1><p>{{ activeEmployees }} activos de {{ employees.length }} empleados registrados</p></div><label class="import-button"><i class="pi pi-file-import"></i> {{ importing ? 'Importando...' : 'Importar empleados' }}<input type="file" accept=".xlsx,.xls" (change)="importFile($event)" [disabled]="importing"></label></header>
<p class="import-message" *ngIf="importMessage">{{ importMessage }}</p>
<section class="employee-form-card">
<h2>Nuevo empleado</h2>
<form (ngSubmit)="save()" #employeeForm="ngForm">
<div class="employee-form-grid">
<label>ID<input name="employeeCode" [(ngModel)]="form.employee_code" required></label>
<label>Nombre<input name="nombre" [(ngModel)]="form.nombre" required></label>
<label>Usuario<input name="usuario" [(ngModel)]="form.usuario"></label>
<label>Email<input type="email" name="email" [(ngModel)]="form.email"></label>
<label>Perfil<input name="perfil" [(ngModel)]="form.perfil"></label>
<label>Regional<input name="regional" [(ngModel)]="form.regional"></label>
<label>País<input name="pais" [(ngModel)]="form.pais"></label>
<label>Estado<select name="activo" [(ngModel)]="form.activo"><option [ngValue]="true">Activo</option><option [ngValue]="false">Inactivo</option></select></label>
<label>Latitud<input type="number" name="latitud" [(ngModel)]="form.latitud" step="any" required></label>
<label>Longitud<input type="number" name="longitud" [(ngModel)]="form.longitud" step="any" required></label>
<label>Horas por día<input type="number" name="hoursDay" [(ngModel)]="form.horas_por_dia" min="0.5" max="24" step="0.5" required></label>
<label>Jornada de trabajo<select name="schedule" [(ngModel)]="form.jornada_codigo"><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></label>
<label>Horas de descanso/día<input type="number" name="dailyRest" [(ngModel)]="form.horas_descanso_por_dia" min="0" max="3" step="1" required></label>
<label>Visitas máximas/día<input type="number" name="visitsDay" [(ngModel)]="form.visitas_maximas_por_dia" min="1" required></label>
</div>
<p class="weekly-capacity">Capacidad semanal: {{ selectedScheduleHours }} h · Capacidad diaria efectiva: {{ form.horas_por_dia - form.horas_descanso_por_dia | number:'1.0-1' }} h</p>
<p class="form-error" *ngIf="error">{{ error }}</p>
<div class="form-actions"><button type="submit" [disabled]="saving || employeeForm.invalid"><i class="pi pi-save"></i> {{ saving ? 'Guardando...' : 'Agregar empleado' }}</button></div>
</form>
</section>
<section class="employee-table-card">
<div class="table-header"><h2>Empleados registrados</h2><button type="button" (click)="load()" [disabled]="loading"><i class="pi pi-refresh"></i> Actualizar</button></div>
<p class="form-error" *ngIf="error && !loading">{{ error }}</p>
<p class="loading" *ngIf="loading">Cargando empleados...</p>
<div class="table-wrap" *ngIf="!loading">
<table>
<thead><tr><th>ID</th><th>Nombre</th><th>Regional</th><th>Jornada</th><th>Descanso/día</th><th>Días de descanso</th><th>Visitas/día</th><th>Estado</th><th>Acciones</th></tr></thead>
<tbody>
<tr *ngFor="let employee of employees" [class.editing-row]="editingId === employee.id"><td>{{ employee.employee_code }}</td><td>{{ employee.nombre }}</td><td>{{ employee.regional || '-' }}</td><td><ng-container *ngIf="editingId === employee.id; else scheduleText"><select [(ngModel)]="employee.jornada_codigo" [name]="'schedule-' + employee.id" (ngModelChange)="onInlineScheduleChange(employee)"><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></ng-container><ng-template #scheduleText>{{ scheduleLabel(employee) }}</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else restText"><input class="inline-number" type="number" [(ngModel)]="employee.horas_descanso_por_dia" [name]="'rest-' + employee.id" min="0" max="3" step="1"></ng-container><ng-template #restText>{{ employee.horas_descanso_por_dia }} h</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else daysText"><div class="rest-days-picker inline-picker"><label *ngFor="let day of availableRestDays(employee)"><input type="checkbox" [checked]="employee.dias_descanso.includes(day)" (change)="toggleRestDay(employee, day, $any($event.target).checked)">{{ day }}</label></div></ng-container><ng-template #daysText>{{ employee.dias_descanso.length ? employee.dias_descanso.join(', ') : 'Ninguno' }}</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else visitsText"><input class="inline-number" type="number" [(ngModel)]="employee.visitas_maximas_por_dia" [name]="'visits-' + employee.id" min="1" step="1"></ng-container><ng-template #visitsText>{{ employee.visitas_maximas_por_dia }}</ng-template></td><td><span class="status" [class.inactive]="!employee.activo">{{ employee.activo ? 'Activo' : 'Inactivo' }}</span></td><td class="row-actions"><ng-container *ngIf="editingId === employee.id; else normalActions"><button type="button" (click)="saveInline(employee)" [disabled]="saving">Guardar</button><button type="button" (click)="cancelEdit()" [disabled]="saving">Cancelar</button></ng-container><ng-template #normalActions><button type="button" (click)="edit(employee)">Editar</button><button type="button" (click)="toggleActive(employee)">{{ employee.activo ? 'Desactivar' : 'Activar' }}</button><button type="button" class="delete" (click)="remove(employee)">Eliminar</button></ng-template></td></tr>
<tr *ngIf="!employees.length"><td colspan="9">No hay empleados registrados.</td></tr>
</tbody>
</table>
</div>
</section>
</main>
@@ -0,0 +1,281 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Location } from '@angular/common';
import { Employee, EmployeeInput } from '../../core/models/employee.model';
import { EmployeeService } from '../../core/services/employee.service';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
import { WORK_SCHEDULES, workSchedule } from '../../core/models/work-schedule.model';
import { normalizeRow, cleanNum } from '../../shared/utils/header-normalization.utils';
import * as XLSX from 'xlsx';
type EmployeeForm = Omit<EmployeeInput, 'activo'> & { activo: boolean };
@Component({
standalone: true,
selector: 'app-employees',
imports: [CommonModule, FormsModule],
templateUrl: './employees.component.html',
styleUrls: ['./employees.component.css']
})
export class EmployeesComponent implements OnDestroy, OnInit {
employees: Employee[] = [];
form = this.emptyForm();
editingId: string | null = null;
loading = true;
saving = false;
error = '';
importMessage = '';
importing = false;
openRestDaysFor: string | null = null;
readonly schedules = WORK_SCHEDULES;
private destroyed = false;
private loadSequence = 0;
constructor(
private employeesService: EmployeeService,
private auth: AuthService,
private router: Router,
private location: Location,
public profile: UserProfileService,
private cdr: ChangeDetectorRef
) {}
ngOnInit(): void {
void this.load();
}
ngOnDestroy(): void {
this.destroyed = true;
this.loadSequence++;
}
get activeEmployees(): number {
return this.employees.filter(employee => employee.activo).length;
}
get selectedScheduleHours(): number {
return workSchedule(this.form.jornada_codigo).weeklyHours;
}
async load(): Promise<void> {
const sequence = ++this.loadSequence;
this.loading = true;
this.error = '';
try {
const employees = await this.employeesService.list();
if (this.destroyed || sequence !== this.loadSequence) return;
this.employees = employees;
this.cdr.detectChanges();
} catch (error: any) {
if (!this.destroyed && sequence === this.loadSequence) {
this.error = error?.message ?? 'No se pudieron cargar los empleados.';
}
} finally {
this.loading = false;
if (!this.destroyed && sequence === this.loadSequence) this.cdr.detectChanges();
}
}
edit(employee: Employee): void {
this.editingId = employee.id;
}
cancelEdit(): void {
this.editingId = null;
this.error = '';
void this.load();
}
async saveInline(employee: Employee): Promise<void> {
const rest = Number(employee.horas_descanso_por_dia);
const visits = Number(employee.visitas_maximas_por_dia);
if (!Number.isInteger(rest) || rest < 0 || rest > 3 || !Number.isInteger(visits) || visits < 1) {
this.error = 'Descanso/día debe ser un entero entre 0 y 3, y Visitas/día debe ser un entero mayor que 0.';
return;
}
this.saving = true;
this.error = '';
try {
await this.employeesService.update(employee.id, {
...employee,
dias_por_semana: workSchedule(employee.jornada_codigo).days.length,
horas_descanso_por_dia: rest,
visitas_maximas_por_dia: visits,
dias_descanso: (employee.dias_descanso ?? []).filter(day => workSchedule(employee.jornada_codigo).days.includes(day))
});
this.editingId = null;
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudo actualizar el empleado.';
} finally {
this.saving = false;
}
}
onInlineScheduleChange(employee: Employee): void {
employee.dias_descanso = (employee.dias_descanso ?? []).filter(day => workSchedule(employee.jornada_codigo).days.includes(day));
employee.dias_por_semana = workSchedule(employee.jornada_codigo).days.length;
}
async save(): Promise<void> {
if (!this.form.employee_code.trim() || !this.form.nombre.trim()) {
this.error = 'El ID y el nombre son obligatorios.';
return;
}
if (this.form.horas_descanso_por_dia >= this.form.horas_por_dia) {
this.error = 'Las horas de descanso deben ser menores que las horas de trabajo diarias.';
return;
}
this.saving = true;
this.error = '';
const employee: EmployeeInput = {
...this.form,
dias_por_semana: workSchedule(this.form.jornada_codigo).days.length,
employee_code: this.form.employee_code.trim(),
nombre: this.form.nombre.trim(),
usuario: this.nullable(this.form.usuario),
email: this.nullable(this.form.email),
perfil: this.nullable(this.form.perfil),
pais: this.nullable(this.form.pais),
regional: this.nullable(this.form.regional)
};
try {
if (this.editingId) await this.employeesService.update(this.editingId, employee);
else await this.employeesService.create(employee);
this.cancelEdit();
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudo guardar el empleado.';
} finally {
this.saving = false;
}
}
async toggleActive(employee: Employee): Promise<void> {
try {
await this.employeesService.update(employee.id, { ...employee, activo: !employee.activo });
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudo actualizar el estado.';
}
}
scheduleLabel(employee: Employee): string {
return workSchedule(employee.jornada_codigo).label;
}
availableRestDays(employee: Employee): string[] {
return workSchedule(employee.jornada_codigo).days;
}
toggleRestDayEditor(employee: Employee): void {
this.openRestDaysFor = this.openRestDaysFor === employee.id ? null : employee.id;
}
async toggleRestDay(employee: Employee, day: string, checked: boolean): Promise<void> {
const days = new Set(employee.dias_descanso ?? []);
checked ? days.add(day) : days.delete(day);
employee.dias_descanso = Array.from(days);
}
async importFile(event: Event): Promise<void> {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
this.importing = true;
this.importMessage = '';
this.error = '';
try {
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(sheet, { defval: null, raw: true }).map(row => normalizeRow(row as Record<string, unknown>));
const employees = rows.map((row, index) => this.fromExcelRow(row, index + 2));
const invalid = employees.filter((employee): employee is null => employee === null).length;
const valid = employees.filter((employee): employee is EmployeeInput => employee !== null);
if (!valid.length) throw new Error('El archivo no contiene empleados válidos.');
await this.employeesService.upsert(valid);
this.importMessage = `${valid.length} empleados guardados${invalid ? `; ${invalid} filas omitidas por datos inválidos` : ''}.`;
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudo importar el archivo.';
} finally {
this.importing = false;
(event.target as HTMLInputElement).value = '';
}
}
async remove(employee: Employee): Promise<void> {
if (!window.confirm(`¿Eliminar a ${employee.nombre}? Esta acción no se puede deshacer.`)) return;
try {
await this.employeesService.remove(employee.id);
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudo eliminar el empleado.';
}
}
async signOut(): Promise<void> {
this.destroyed = true;
this.loadSequence++;
this.employees = [];
this.loading = true;
await this.auth.signOut();
await this.router.navigate(['/login']);
}
async goHome(): Promise<void> {
await this.router.navigate(['/home']);
}
goBack(): void {
if (history.length > 1) this.location.back();
else void this.router.navigate(['/home']);
}
private nullable(value: string | null): string | null {
return value?.trim() || null;
}
private emptyForm(): EmployeeForm {
return {
employee_code: '', nombre: '', usuario: '', email: '', perfil: 'Promotor', pais: 'Republica Dominicana', regional: '',
latitud: 18.4861, longitud: -69.9312, horas_por_dia: 8, dias_por_semana: 5, jornada_codigo: 'LUN_VIE_40', horas_descanso_por_dia: 1, dias_descanso: [], visitas_maximas_por_dia: 3, activo: true
};
}
private fromExcelRow(row: Record<string, unknown>, excelRow: number): EmployeeInput | null {
const code = String(row['id'] ?? '').trim();
const nombre = String(row['nombre_del_empleado'] ?? '').trim();
const latitude = cleanNum(row['latitud']);
const longitude = cleanNum(row['longitud']);
const dailyHours = cleanNum(row['horas_por_dia_usuario']);
const weeklyHours = cleanNum(row['horas_por_semana_usuario']);
if (!code || !nombre || !Number.isFinite(latitude) || !Number.isFinite(longitude) || !Number.isFinite(dailyHours)) return null;
const jornadaTexto = String(row['jornada'] ?? '').trim();
const jornadaCodigo = this.schedules.some(schedule => schedule.code === jornadaTexto)
? jornadaTexto
: weeklyHours === 44 ? 'LUN_SAB_44' : 'LUN_VIE_40';
const restDays = String(row['dias_descanso'] ?? '').split(',').map(day => day.trim()).filter(day => workSchedule(jornadaCodigo).days.includes(day));
const active = !['no', 'n', '0', 'false'].includes(String(row['usuario_activo'] ?? 'si').trim().toLowerCase());
return {
employee_code: code,
nombre,
usuario: this.nullable(String(row['usuario'] ?? '')),
email: this.nullable(String(row['email'] ?? '')),
perfil: this.nullable(String(row['perfil_de_acceso'] ?? '')),
pais: this.nullable(String(row['pais'] ?? '')),
regional: this.nullable(String(row['regionales'] ?? row['regional'] ?? '')),
latitud: latitude,
longitud: longitude,
horas_por_dia: dailyHours,
dias_por_semana: workSchedule(jornadaCodigo).days.length,
jornada_codigo: jornadaCodigo,
horas_descanso_por_dia: Math.min(3, Math.max(0, Math.round(cleanNum(row['horas_descanso_por_dia']) || 1))),
dias_descanso: restDays,
visitas_maximas_por_dia: Math.max(1, cleanNum(row['visitas_maximas_por_dia']) || 3),
activo: active
};
}
}
@@ -206,6 +206,12 @@
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42)); filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
} }
.location-pin:hover {
transform: scale(1.12);
transform-origin: center bottom;
cursor: pointer;
}
.location-pin::before { .location-pin::before {
content: ''; content: '';
position: absolute; position: absolute;
@@ -99,12 +99,26 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
} }
private initMap() { private initMap() {
this.map = L.map('map').setView([18.4861, -69.9312], 13); const territoryBounds = this.routeData ? L.latLngBounds([
[Number(this.routeData.json.latitud_usuario), Number(this.routeData.json.longitud_usuario)],
[Number(this.routeData.json.latitud_pdv), Number(this.routeData.json.longitud_pdv)]
]) : null;
this.map = L.map('map', { maxBoundsViscosity: 0.85 });
if (territoryBounds?.isValid()) {
this.map.fitBounds(territoryBounds.pad(0.3));
this.map.setMaxBounds(territoryBounds.pad(0.6));
} else {
this.map.setView([18.4861, -69.9312], 13);
}
const pdvPane = this.map.createPane('pdvPane'); const pdvPane = this.map.createPane('pdvPane');
pdvPane.style.zIndex = '650'; 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',
bounds: territoryBounds?.isValid() ? territoryBounds.pad(0.6) : undefined,
keepBuffer: 1,
noWrap: true,
updateWhenIdle: true
}).addTo(this.map); }).addTo(this.map);
} }
@@ -127,6 +141,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
this.centerMapOnPoints(origin, destination); this.centerMapOnPoints(origin, destination);
if (json.geojson) {
this.showRouteOnMap(json.geojson, origin, destination, json, this.segmentOriginName);
this.isLoading = false;
this.cdr.detectChanges();
return;
}
try { try {
const results = await this.orsService.processPairs( const results = await this.orsService.processPairs(
[{ pdv_id: json.pdv_id, origin, destination }], [{ pdv_id: json.pdv_id, origin, destination }],
@@ -270,7 +291,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
.bindPopup(`<strong>Origen del tramo:</strong> ${originName}`), .bindPopup(`<strong>Origen del tramo:</strong> ${originName}`),
L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 }) 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>Usuario:</strong> ${json.nombre_usuario}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
); );
} }
@@ -292,6 +313,6 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
} }
goBack() { goBack() {
this.router.navigate(['/home']); this.router.navigate(['/proyectos', this.activatedRoute.snapshot.paramMap.get('id')]);
} }
} }
@@ -93,7 +93,7 @@
<ng-template #dayValue>{{ row.json.dia }}</ng-template> <ng-template #dayValue>{{ row.json.dia }}</ng-template>
</td> </td>
<td>{{ row.json.distancia }}</td> <td>{{ row.json.distancia }}</td>
<td>{{ row.json.horas_desplazamiento }}</td> <td>{{ (row.json.horas_desplazamiento * 60).toFixed(0) }} min</td>
<td class="text-center edit-actions"> <td class="text-center edit-actions">
<ng-container *ngIf="editingRoute === row; else editButton"> <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)="saveEdit()" title="Guardar"><i class="pi pi-check"></i></button>
@@ -1,7 +1,7 @@
import { Component, ChangeDetectorRef, ViewChild } from '@angular/core'; import { Component, ChangeDetectorRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { RouteAssignedResponse } from '../../../core/models/route.model'; import { RouteAssignedResponse } from '../../../core/models/route.model';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import { Table } from 'primeng/table'; import { Table } from 'primeng/table';
@@ -44,6 +44,7 @@ export class AssignedRouteComponent {
private routesState: RouteState, private routesState: RouteState,
private cdr: ChangeDetectorRef, private cdr: ChangeDetectorRef,
private router: Router, private router: Router,
private activatedRoute: ActivatedRoute,
private rutasService: RutasService, private rutasService: RutasService,
private planningInput: PlanningInputService private planningInput: PlanningInputService
) { } ) { }
@@ -79,7 +80,7 @@ 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(['/proyectos', this.projectId, 'ruta'], {
queryParams: { pdv_id: row.json.pdv_id, usuario_id: row.json.usuario_id, dia: row.json.dia } queryParams: { pdv_id: row.json.pdv_id, usuario_id: row.json.usuario_id, dia: row.json.dia }
}); });
} }
@@ -88,18 +89,20 @@ export class AssignedRouteComponent {
// If table has filtered view, use first visible row's usuario as default; else first assigned // 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]; const target = (this.table?.filteredValue?.[0] as RouteAssignedResponse | undefined) ?? this.assignedRoutes[0];
if (target) { if (target) {
this.router.navigate(['/home/mapa'], { this.router.navigate(['/proyectos', this.projectId, 'mapa'], {
queryParams: { usuario_id: target.json.usuario_id, dia: 'Todos' } queryParams: { usuario_id: target.json.usuario_id, dia: 'Todos' }
}); });
} else { } else {
this.router.navigate(['/home/mapa']); this.router.navigate(['/proyectos', this.projectId, 'mapa']);
} }
} }
goToUserMap(usuarioId: string) { goToUserMap(usuarioId: string) {
this.router.navigate(['/home/mapa'], { queryParams: { usuario_id: usuarioId, dia: 'Todos' } }); this.router.navigate(['/proyectos', this.projectId, 'mapa'], { queryParams: { usuario_id: usuarioId, dia: 'Todos' } });
} }
private get projectId(): string { return this.activatedRoute.snapshot.paramMap.get('id') ?? ''; }
get userOptions(): { id: string; name: string }[] { get userOptions(): { id: string; name: string }[] {
const users = new Map<string, string>(); const users = new Map<string, string>();
for (const route of this.assignedRoutes) users.set(String(route.json.usuario_id), route.json.nombre_usuario); for (const route of this.assignedRoutes) users.set(String(route.json.usuario_id), route.json.nombre_usuario);
@@ -31,6 +31,10 @@
color: var(--glm-verde); color: var(--glm-verde);
} }
.projects-link { margin-left: auto; color: var(--glm-azul); font-size: .82rem; text-decoration: none; }
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
.projects-link button,.home-employees-link,.edit-project,.project-edit-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:inherit; font-weight:700; padding:8px 11px; text-decoration:none; }.home-employees-link { display:inline-block; margin-bottom:12px; }.project-heading { width:100%; }.project-title-line { display:flex; align-items:center; gap:14px; }.project-title-line h1 { margin:0; }.back-projects { color:var(--glm-azul); font-size:.85rem; font-weight:700; text-decoration:none; white-space:nowrap; }.edit-project,.project-edit-actions { margin-left:auto; }.project-title-line input,.project-heading textarea { border:1px solid #9aaab3; border-radius:4px; background:#fff; color:#2f3f48; font:inherit; padding:8px; }.project-heading textarea { display:block; width:100%; margin-top:10px; resize:vertical; }.project-edit-actions { display:flex; gap:6px; }.project-edit-actions button:first-child { background:var(--glm-azul); color:#fff; }
.home-container { .home-container {
max-width: 1000px; max-width: 1000px;
margin: 0 auto; margin: 0 auto;
@@ -38,11 +42,18 @@
} }
.home-header { .home-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px; margin-bottom: 24px;
border-bottom: 3px solid var(--glm-verde); border-bottom: 3px solid var(--glm-verde);
padding-bottom: 16px; padding-bottom: 16px;
} }
.employees-link { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 1px solid var(--glm-verde); border-radius: 4px; background: var(--glm-verde-bg); color: var(--glm-azul); font-size: .82rem; font-weight: 700; text-decoration: none; white-space: nowrap; }
.employees-link:hover { background: var(--glm-verde); color: #fff; }
.home-header h1 { .home-header h1 {
margin: 0; margin: 0;
color: var(--glm-azul); color: var(--glm-azul);
@@ -79,6 +90,8 @@
padding: 24px; padding: 24px;
} }
.employees-summary { display: flex; align-items: center; gap: 7px; margin: -2px 0 16px; color: var(--glm-acero); font-size: .88rem; }.employees-summary i { color: var(--glm-verde); }.employees-summary.empty, .employees-summary.empty i { color: #c9252d; }
.upload-grid { .upload-grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -1,30 +1,31 @@
<div class="glm-topbar"> <div class="glm-topbar">
<img src="GLM_completo.png" alt="GomezLee Marketing" class="glm-topbar-logo" /> <img src="GLM_completo.png" alt="GomezLee Marketing" class="glm-topbar-logo" />
<span class="glm-topbar-sep">|</span> <span class="glm-topbar-sep">|</span>
<span class="glm-topbar-context">Route Planner</span> <span class="glm-topbar-context">Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="projects-link"><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
</div> </div>
<div class="home-container"> <div class="home-container">
<a class="employees-link home-employees-link" routerLink="/empleados"><i class="pi pi-users"></i> Gestionar empleados</a>
<header class="home-header"> <header class="home-header">
<h1>Route Planner</h1> <div class="project-heading"><div class="project-title-line"><a class="back-projects" routerLink="/home"><i class="pi pi-arrow-left"></i> Mis proyectos</a><h1 *ngIf="!editingProject">{{ projectName || 'Proyecto' }}</h1><input *ngIf="editingProject" [(ngModel)]="projectName" maxlength="120"><button *ngIf="!editingProject" type="button" class="edit-project" (click)="startProjectEdit()"><i class="pi pi-pencil"></i> Editar proyecto</button><div *ngIf="editingProject" class="project-edit-actions"><button type="button" (click)="saveProject()" [disabled]="projectSaving">Guardar</button><button type="button" (click)="cancelProjectEdit()" [disabled]="projectSaving">Cancelar</button></div></div><textarea *ngIf="editingProject" [(ngModel)]="projectDescription" maxlength="500" placeholder="Descripción del proyecto"></textarea><p *ngIf="!editingProject">Gestión de rutas asignadas y puntos de venta pendientes</p></div>
<p>Gestión de rutas asignadas y puntos de venta pendientes</p>
</header> </header>
<section class="card upload-card"> <section class="card upload-card">
<h2 class="section-title">Subir archivos para optimización</h2> <h2 class="section-title">PDVs del proyecto</h2>
<p class="employees-summary" [class.empty]="!employeesLoading && !activeEmployees"><i class="pi pi-users"></i> <span *ngIf="employeesLoading">Cargando empleados activos...</span><span *ngIf="!employeesLoading">{{ activeEmployees }} empleados activos de {{ projectCountry }} se usarán para planificar.</span></p>
<div class="upload-grid"> <div class="upload-grid">
<div class="file-input-wrapper"> <div class="file-input-wrapper">
<label class="file-label" for="pdvFile" [class.has-error]="pdvPreview && !pdvPreview.valid" [class.has-success]="pdvPreview?.valid"> <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 guardado</span>
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 2000 filas</span> <span class="file-hint">Los PDVs pertenecen a este proyecto</span>
</label> </label>
<input <input
id="pdvFile" id="pdvFile"
type="file" type="file"
accept=".xlsx,.xls" accept=".xlsx,.xls"
(change)="onPdvFileChange($event)" disabled
class="file-input" class="file-input"
[class.has-file]="pdvFile" [class.has-file]="pdvFile"
/> />
@@ -57,48 +58,6 @@
</div> </div>
</div> </div>
<div class="file-input-wrapper">
<label class="file-label" for="usuariosFile" [class.has-error]="usuariosPreview && !usuariosPreview.valid" [class.has-success]="usuariosPreview?.valid">
<i class="pi pi-file-excel"></i>
<span>Archivo Usuarios (Excel)</span>
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 50 filas</span>
</label>
<input
id="usuariosFile"
type="file"
accept=".xlsx,.xls"
(change)="onUsuariosFileChange($event)"
class="file-input"
[class.has-file]="usuariosFile"
/>
<div class="file-name" *ngIf="usuariosFile">
<i class="pi" [class.pi-check-circle]="usuariosPreview?.valid" [class.pi-exclamation-circle]="usuariosPreview && !usuariosPreview.valid"></i>
{{ usuariosFile.name }}
<span *ngIf="usuariosPreviewLoading" class="preview-loading">Validando...</span>
</div>
<div class="preview-card" *ngIf="usuariosPreviewLoading">
<div class="preview-loading-row"><span class="spinner-inline"></span> Analizando archivo Usuarios...</div>
</div>
<div class="preview-card preview-success" *ngIf="usuariosPreview?.valid">
<div class="preview-header"><i class="pi pi-check-circle"></i> Usuarios válido — {{ usuariosPreview!.totalRows }} filas</div>
<div class="preview-stats">{{ usuariosPreview!.validRows }} filas válidas • Columnas: {{ usuariosPreview!.headers.join(', ') }}</div>
</div>
<div class="preview-card preview-error" *ngIf="usuariosPreview && !usuariosPreview.valid">
<div class="preview-header"><i class="pi pi-exclamation-triangle"></i> Errores en Usuarios ({{ usuariosPreview.errors.length }})</div>
<div class="preview-stats" *ngIf="usuariosPreview.missingHeaders.length">Faltan: {{ usuariosPreview.missingHeaders.join(', ') }}</div>
<div class="preview-stats">{{ usuariosPreview.totalRows }} filas leídas • {{ usuariosPreview.validRows }} válidas</div>
<ul class="preview-errors">
<li *ngFor="let e of usuariosPreview.errors.slice(0, 8)">
<strong>Fila {{ e.row }} — {{ e.field }}:</strong> {{ e.message }}
<span *ngIf="e.value !== undefined && e.value !== null" class="error-value">({{ e.value }})</span>
</li>
<li *ngIf="usuariosPreview.errors.length > 8" class="more-errors">+ {{ usuariosPreview.errors.length - 8 }} errores más...</li>
</ul>
</div>
</div>
</div> </div>
<div class="upload-actions"> <div class="upload-actions">
@@ -130,7 +89,7 @@
type="button" type="button"
class="btn-secondary" class="btn-secondary"
(click)="clearFiles()" (click)="clearFiles()"
[disabled]="!pdvFile && !usuariosFile" [disabled]="!pdvFile"
> >
<i class="pi pi-times"></i> Limpiar <i class="pi pi-times"></i> Limpiar
</button> </button>
@@ -1,4 +1,4 @@
import { Component, ChangeDetectorRef } from '@angular/core'; import { Component, ChangeDetectorRef, OnInit } 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';
@@ -10,23 +10,36 @@ import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component'; import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component';
import { PlanningInputService } from '../../core/services/planning-input.service'; import { PlanningInputService } from '../../core/services/planning-input.service';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { EmployeeService } from '../../core/services/employee.service';
import { ProjectService } from '../../core/services/project.service';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
@Component({ @Component({
standalone: true, standalone: true,
selector: 'app-home', selector: 'app-home',
imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent, PlanningKpisComponent], imports: [CommonModule, FormsModule, RouterLink, 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 implements OnInit {
pdvFile: File | null = null; pdvFile: File | null = null;
usuariosFile: File | null = null; usuariosFile: File | null = null;
pdvPreview: PreviewResult | null = null; pdvPreview: PreviewResult | null = null;
usuariosPreview: PreviewResult | null = null; usuariosPreview: PreviewResult | null = null;
pdvPreviewLoading = false; pdvPreviewLoading = false;
usuariosPreviewLoading = false; usuariosPreviewLoading = false;
activeEmployees = 0;
employeesLoading = true;
uploadError: string | null = null; uploadError: string | null = null;
isLoading = false; isLoading = false;
projectName = '';
projectId = '';
editingProject = false;
projectDescription = '';
projectCountry = '';
projectSaving = false;
private calcSub: Subscription | null = null; private calcSub: Subscription | null = null;
constructor( constructor(
@@ -34,9 +47,69 @@ export class HomeComponent {
private routeState: RouteState, private routeState: RouteState,
private previewService: ExcelPreviewService, private previewService: ExcelPreviewService,
private planningInput: PlanningInputService, private planningInput: PlanningInputService,
private employeesService: EmployeeService,
private projectsService: ProjectService,
private activatedRoute: ActivatedRoute,
private auth: AuthService,
private router: Router,
public profile: UserProfileService,
private cdr: ChangeDetectorRef private cdr: ChangeDetectorRef
) {} ) {}
ngOnInit(): void {
void this.initialize();
}
private async initialize(): Promise<void> {
await this.loadProject();
await this.loadEmployees();
}
private async loadProject(): Promise<void> {
this.projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? '';
if (!this.projectId) return;
try {
const [project, input] = await Promise.all([this.projectsService.get(this.projectId), this.projectsService.projectInput(this.projectId)]);
this.projectName = project.nombre;
this.projectDescription = project.descripcion ?? '';
this.projectCountry = input.country;
this.pdvFile = input.file;
this.pdvPreview = await this.previewService.previewFile(input.file, 'PDV');
} catch (error: any) {
this.uploadError = error?.message ?? 'No se pudo cargar el proyecto.';
} finally {
this.cdr.detectChanges();
}
}
startProjectEdit(): void { this.editingProject = true; }
cancelProjectEdit(): void { this.editingProject = false; void this.loadProject(); }
async saveProject(): Promise<void> {
if (!this.projectId || !this.projectName.trim()) return;
this.projectSaving = true;
try {
await this.projectsService.update(this.projectId, { nombre: this.projectName.trim(), descripcion: this.projectDescription.trim() || null });
this.editingProject = false;
} catch (error: any) { this.uploadError = error?.message ?? 'No se pudo actualizar el proyecto.'; }
finally { this.projectSaving = false; this.cdr.detectChanges(); }
}
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
private async loadEmployees(): Promise<void> {
this.employeesLoading = true;
try {
this.activeEmployees = (await this.employeesService.active(this.projectCountry)).length;
} catch {
this.activeEmployees = 0;
} finally {
this.employeesLoading = false;
this.cdr.detectChanges();
}
}
async onPdvFileChange(event: Event) { 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) {
@@ -132,23 +205,22 @@ export class HomeComponent {
} }
get canSubmit(): boolean { get canSubmit(): boolean {
if (!this.pdvFile || !this.usuariosFile) return false; if (!this.pdvFile || !this.activeEmployees) return false;
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return false; if (this.pdvPreviewLoading || this.employeesLoading) return false;
if (this.isLoading) return false; if (this.isLoading) return false;
if (this.uploadError) return false; if (this.uploadError) return false;
// Require valid previews before enabling submit // Require valid previews before enabling submit
if (this.pdvPreview && !this.pdvPreview.valid) return false; if (this.pdvPreview && !this.pdvPreview.valid) return false;
if (this.usuariosPreview && !this.usuariosPreview.valid) return false; if (!this.pdvPreview) return false;
// Previews must exist (file was parsed)
if (!this.pdvPreview || !this.usuariosPreview) return false;
return true; return true;
} }
get submitDisabledReason(): string | null { get submitDisabledReason(): string | null {
if (!this.pdvFile || !this.usuariosFile) return 'Sube ambos archivos'; if (!this.pdvFile) return 'Sube el archivo PDV';
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return 'Validando archivos...'; if (this.employeesLoading) return 'Cargando empleados...';
if (!this.activeEmployees) return 'No hay empleados activos. Regístralos en Gestionar empleados.';
if (this.pdvPreviewLoading) return 'Validando archivo PDV...';
if (this.pdvPreview && !this.pdvPreview.valid) return 'Corrige los errores del archivo PDV'; 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; return null;
} }
@@ -168,11 +240,21 @@ export class HomeComponent {
this.cdr.detectChanges(); this.cdr.detectChanges();
return; return;
} }
this.planningInput.setFiles(pdvForOptimizer, this.usuariosFile!); let employeesFile: File;
try {
employeesFile = await this.employeesService.activeEmployeesFile(this.projectCountry);
} catch {
this.uploadError = 'No se pudieron obtener los empleados activos.';
this.routeState.setLoading(false);
this.isLoading = false;
return;
}
this.planningInput.setFiles(pdvForOptimizer, employeesFile);
const formData = new FormData(); const formData = new FormData();
formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name); formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name);
formData.append('usuarios_file', this.usuariosFile!, this.usuariosFile!.name); formData.append('usuarios_file', employeesFile, employeesFile.name);
formData.append('pais', this.projectCountry);
this.calcSub?.unsubscribe(); this.calcSub?.unsubscribe();
this.calcSub = this.rutasService.calcularRutas(formData).subscribe({ this.calcSub = this.rutasService.calcularRutas(formData).subscribe({
@@ -218,7 +300,7 @@ export class HomeComponent {
private mapBackendError(err: unknown): string { private mapBackendError(err: unknown): string {
const anyErr = err as { error?: { error?: string; message?: string }; message?: string; status?: number; name?: 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?.name === 'TimeoutError') return 'El cálculo tardó demasiado (timeout 180s). Intenta de nuevo con menos filas.';
if (anyErr?.error?.error) return anyErr.error.error; if (anyErr?.error?.error) return anyErr.error.error;
if (anyErr?.error?.message) return anyErr.error.message; if (anyErr?.error?.message) return anyErr.error.message;
if (anyErr?.message) return anyErr.message; if (anyErr?.message) return anyErr.message;
@@ -130,10 +130,6 @@
white-space: nowrap; white-space: nowrap;
} }
.operational-grid {
margin-top: 12px;
}
.user-load-card { .user-load-card {
margin-top: 14px; margin-top: 14px;
padding: 12px 14px; padding: 12px 14px;
@@ -31,29 +31,12 @@
<div class="kpi-icon"><i class="pi pi-compass"></i></div> <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> <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>
<article class="kpi-card kpi-blue"> <ng-container *ngIf="hasData">
<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"> <article class="kpi-card kpi-success">
<div class="kpi-icon"><i class="pi pi-briefcase"></i></div> <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> <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>
<article class="kpi-card kpi-blue"> </ng-container>
<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>
<div class="user-load-card" *ngIf="userLoads.length"> <div class="user-load-card" *ngIf="userLoads.length">
@@ -14,13 +14,8 @@ interface KpiSummary {
users: number; users: number;
days: number; days: number;
totalDistance: number; totalDistance: number;
totalTravelHours: number;
averageDistance: number; averageDistance: number;
averageTravelHours: number;
totalWorkHours: number; totalWorkHours: number;
averageRoutesPerUser: number;
averageUtilization: number;
assignmentBalance: number;
} }
interface ReasonSummary { interface ReasonSummary {
@@ -86,10 +81,15 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
private refresh(): void { private refresh(): void {
const assigned = this.routeState.getAssignedSnapshot(); const assigned = this.routeState.getAssignedSnapshot();
const unassigned = this.routeState.getNoAssignedSnapshot(); const unassigned = this.routeState.getNoAssignedSnapshot();
const totalPdvs = assigned.length + unassigned.length; const assignedPdvIds = new Set(assigned.map(route => String(route.json.pdv_id)));
// A PDV with at least one scheduled visit is assigned; repeated weekly visits and
// partial-assignment alerts must not inflate the planning coverage counters.
const unassignedPdvIds = new Set(unassigned
.map(route => String(route.pdv_id))
.filter(pdvId => !assignedPdvIds.has(pdvId)));
const totalPdvs = new Set([...assignedPdvIds, ...unassignedPdvIds]).size;
const dailyRoutes = summarizeDailyRoutes(assigned); const dailyRoutes = summarizeDailyRoutes(assigned);
const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0); const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0);
const totalTravelHours = dailyRoutes.reduce((sum, day) => sum + day.travelHours, 0);
const totalWorkHours = dailyRoutes.reduce((sum, day) => sum + day.workHours, 0); const totalWorkHours = dailyRoutes.reduce((sum, day) => sum + day.workHours, 0);
const users = new Set(assigned.map(route => String(route.json.usuario_id))).size; 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 days = new Set(assigned.map(route => route.json.dia)).size;
@@ -126,32 +126,27 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
: `${load.name} está dentro de capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).`; : `${load.name} está dentro de capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).`;
return load; return load;
}).sort((a, b) => b.routes - a.routes || b.workHours - a.workHours); }).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 = { this.summary = {
totalPdvs, totalPdvs,
assignedPdvs: assigned.length, assignedPdvs: assignedPdvIds.size,
unassignedPdvs: unassigned.length, unassignedPdvs: unassignedPdvIds.size,
coverage: totalPdvs ? (assigned.length / totalPdvs) * 100 : 0, coverage: totalPdvs ? (assignedPdvIds.size / totalPdvs) * 100 : 0,
users, users,
days, days,
totalDistance, totalDistance,
totalTravelHours,
averageDistance: assigned.length ? totalDistance / assigned.length : 0, averageDistance: assigned.length ? totalDistance / assigned.length : 0,
averageTravelHours: assigned.length ? totalTravelHours / assigned.length : 0,
totalWorkHours, totalWorkHours,
averageRoutesPerUser: users ? assigned.length / users : 0,
averageUtilization,
assignmentBalance
}; };
const reasonCounts = new Map<string, number>(); const reasonByPdv = new Map<string, string>();
for (const route of unassigned) { for (const route of unassigned) {
const pdvId = String(route.pdv_id);
if (assignedPdvIds.has(pdvId) || reasonByPdv.has(pdvId)) continue;
const reason = route.motivo?.trim() || 'Sin motivo especificado'; const reason = route.motivo?.trim() || 'Sin motivo especificado';
reasonByPdv.set(pdvId, reason);
}
const reasonCounts = new Map<string, number>();
for (const reason of reasonByPdv.values()) {
reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1); reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1);
} }
this.reasons = Array.from(reasonCounts.entries()) this.reasons = Array.from(reasonCounts.entries())
@@ -174,13 +169,8 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
users: 0, users: 0,
days: 0, days: 0,
totalDistance: 0, totalDistance: 0,
totalTravelHours: 0,
averageDistance: 0, averageDistance: 0,
averageTravelHours: 0,
totalWorkHours: 0, totalWorkHours: 0,
averageRoutesPerUser: 0,
averageUtilization: 0,
assignmentBalance: 0
}; };
} }
} }
@@ -77,6 +77,9 @@
font-weight: 700; font-weight: 700;
} }
.territory-summary { margin: 4px 0 0; color: var(--glm-acero); font-size: .76rem; line-height: 1.35; }
.territory-summary i { color: var(--glm-verde); margin-right: 3px; }
.filters-card { .filters-card {
background: var(--glm-verde-bg); background: var(--glm-verde-bg);
border: 1px solid var(--glm-verde-claro); border: 1px solid var(--glm-verde-claro);
@@ -126,6 +129,8 @@
color: var(--glm-acero); color: var(--glm-acero);
} }
.heatmap-help { display: inline-grid; width: 16px; height: 16px; margin-left: auto; place-items: center; border: 1px solid var(--glm-acero); border-radius: 50%; color: var(--glm-acero); cursor: help; font-size: 0.68rem; font-weight: 700; }
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 24px 12px; padding: 24px 12px;
@@ -151,20 +156,45 @@
color: var(--glm-gris-oscuro); color: var(--glm-gris-oscuro);
} }
.unassigned-map-card { .unassigned-bottom-drawer {
padding: 12px; position: fixed;
background: #fff8ed; left: 398px;
right: 18px;
bottom: 18px;
z-index: 950;
overflow: hidden;
border: 1px solid #f2bf86; border: 1px solid #f2bf86;
border-left: 3px solid var(--glm-naranja); border-left: 4px solid var(--glm-naranja);
border-radius: 4px; border-radius: 10px 10px 4px 4px;
background: #fff8ed;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.22);
} }
.unassigned-map-card .info-card-header { margin-bottom: 8px; } .unassigned-drawer-toggle {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border: 0;
background: #fff8ed;
color: var(--glm-azul);
cursor: pointer;
font: inherit;
font-weight: 700;
text-align: left;
}
.unassigned-map-list { .unassigned-drawer-toggle span { display: inline-flex; align-items: center; gap: 8px; }
.unassigned-drawer-toggle .pi-exclamation-triangle { color: var(--glm-naranja); }
.unassigned-drawer-content {
display: grid; display: grid;
gap: 5px; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
max-height: 180px; gap: 8px;
max-height: 240px;
padding: 0 12px 12px;
overflow-y: auto; overflow-y: auto;
} }
@@ -219,86 +249,21 @@
.pending-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; } .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; } .pending-actions .btn-primary, .pending-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
.routes-list h3 { .employee-load-list { display: grid; gap: 5px; max-height: 318px; margin-top: 10px; overflow-y: auto; border-top: 1px solid var(--glm-gris-claro); padding-top: 8px; }
margin: 0 0 8px; .employee-load-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 8px; width: 100%; padding: 7px 8px; border: 1px solid var(--glm-gris-medio); border-radius: 4px; background: #fff; color: var(--glm-gris-oscuro); cursor: pointer; font: inherit; text-align: left; }
font-size: 0.85rem; .employee-load-row:hover, .employee-load-row.selected { border-color: var(--glm-verde); background: var(--glm-verde-bg); }
color: var(--glm-azul); .employee-load-row.over-capacity { border-color: #c9252d; background: var(--glm-rojo-ok); }
font-weight: 700; .employee-load-name { overflow: hidden; color: var(--glm-azul); font-size: 0.78rem; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
text-transform: uppercase; .employee-load-values { color: var(--glm-acero); font-size: 0.7rem; white-space: nowrap; }
} .employee-load-bar { grid-column: 1 / -1; display: block; height: 6px; overflow: hidden; border-radius: 6px; background: var(--glm-gris-medio); }
.employee-load-bar span { display: block; height: 100%; border-radius: inherit; background: var(--glm-verde); }
.employee-load-bar.warning span { background: var(--glm-naranja); }
.employee-load-bar.over-capacity span { background: #c9252d; }
.routes-list ul { @media (max-width: 900px) {
list-style: none; #map-overview { left: 0; }
margin: 0; .overview-sidebar { width: 340px; max-width: calc(100vw - 24px); }
padding: 0; .unassigned-bottom-drawer { right: 12px; bottom: 12px; left: 12px; }
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 { .info-card {
@@ -309,6 +274,12 @@
border-top: 3px solid var(--glm-azul); border-top: 3px solid var(--glm-azul);
} }
.map-location-button {
margin-top: 0;
padding: 6px 9px;
font-size: 0.75rem;
}
.info-card-header { .info-card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -353,15 +324,22 @@
.reassign-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; } .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-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-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; } .reassign-form .reassign-actions, .reassign-form .employee-load-list, .reassign-form .error-card { grid-column: 1 / -1; }
.daily-routes-card { padding: 12px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: var(--glm-surface); } .daily-routes-card { padding: 12px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: var(--glm-surface); }
.daily-routes-card h3 { margin: 0 0 8px; color: var(--glm-azul); font-size: 0.82rem; text-transform: uppercase; } .daily-routes-card h3 { margin: 0 0 8px; color: var(--glm-azul); font-size: 0.82rem; text-transform: uppercase; }
.daily-route-group { border-bottom: 1px solid var(--glm-gris-claro); }
.daily-route-group:last-child { border-bottom: 0; }
.daily-route-toggle { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 0; border: 0; background: transparent; color: var(--glm-gris-oscuro); cursor: pointer; font: inherit; text-align: left; }
.daily-route-toggle:hover { background: var(--glm-gris-claro); }
.daily-route-toggle-title { color: var(--glm-azul); font-weight: 700; }
.daily-route-toggle > span:last-child { color: var(--glm-acero); font-size: 0.78rem; }
.daily-route-group-content { padding-left: 14px; }
.daily-route { display: grid; gap: 3px; padding: 8px 0; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); font-size: 0.78rem; } .daily-route { display: grid; gap: 3px; padding: 8px 0; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); font-size: 0.78rem; }
.daily-route:last-child { border-bottom: 0; } .daily-route:last-child { border-bottom: 0; }
.daily-route span, .daily-route small { color: var(--glm-acero); } .daily-route span, .daily-route small { color: var(--glm-acero); }
.daily-route.over-capacity small { color: #c9252d; font-weight: 700; } .daily-route.over-capacity small { color: #c9252d; font-weight: 700; }
.routes-list li:focus-visible, .btn-icon:focus-visible, button:focus-visible, select:focus-visible { outline: 3px solid rgba(108, 194, 74, 0.45); outline-offset: 2px; } .btn-icon:focus-visible, button:focus-visible, select:focus-visible { outline: 3px solid rgba(108, 194, 74, 0.45); outline-offset: 2px; }
.btn-icon { .btn-icon {
background: transparent; background: transparent;
@@ -386,6 +364,7 @@
.info-list div { .info-list div {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
align-items: center;
gap: 10px; gap: 10px;
padding: 5px 0; padding: 5px 0;
border-bottom: 1px solid var(--glm-gris-claro); border-bottom: 1px solid var(--glm-gris-claro);
@@ -474,40 +453,46 @@
color: var(--glm-gris-oscuro); 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 { .custom-marker {
background: none !important; background: none !important;
border: none !important; border: none !important;
} }
.location-pin-wrapper {
width: 42px;
height: 50px;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 3px;
border-radius: 50%;
cursor: pointer;
transition: transform 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
}
.location-pin-wrapper:hover {
transform: scale(1.12);
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.22);
}
.location-pin-wrapper .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-wrapper .location-pin span {
position: absolute;
inset: 8px 0 auto;
color: #fff;
font: 700 12px Arial, sans-serif;
text-align: center;
}
.marker-inner { .marker-inner {
width: 30px; width: 30px;
height: 30px; height: 30px;
@@ -16,6 +16,7 @@
</button> </button>
<h2 class="sidebar-title">Mapa de rutas</h2> <h2 class="sidebar-title">Mapa de rutas</h2>
</div> </div>
<p class="territory-summary" *ngIf="activeTerritories.length"><i class="pi pi-map-marker"></i> {{ activeTerritories.join(', ') }}</p>
<div class="filters-card" *ngIf="usuarios.length"> <div class="filters-card" *ngIf="usuarios.length">
<label class="filter-label">Usuario</label> <label class="filter-label">Usuario</label>
@@ -31,7 +32,7 @@
</select> </select>
<div class="filter-stats" *ngIf="filteredRoutes.length"> <div class="filter-stats" *ngIf="filteredRoutes.length">
<span>{{ filteredRoutes.length }} PDVs</span> <span>{{ uniqueFilteredRoutes.length }} PDVs</span>
<span>{{ totalDistancia }} km</span> <span>{{ totalDistancia }} km</span>
<span>{{ totalDuracionMin }} min</span> <span>{{ totalDuracionMin }} min</span>
</div> </div>
@@ -40,11 +41,8 @@
<label class="toggle"> <label class="toggle">
<input type="checkbox" [checked]="showHeat" (change)="toggleHeat()" /> <input type="checkbox" [checked]="showHeat" (change)="toggleHeat()" />
<i class="pi pi-fire"></i> Heatmap (carga) <i class="pi pi-fire"></i> Heatmap (carga)
<span class="heatmap-help" title="El mapa muestra con mayor intensidad las zonas donde se concentra más carga de trabajo en horas.">?</span>
</label> </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>
<div class="filter-empty" *ngIf="!filteredRoutes.length && !error"> <div class="filter-empty" *ngIf="!filteredRoutes.length && !error">
@@ -63,18 +61,6 @@
{{ error }} {{ error }}
</div> </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"> <section class="info-card unassigned-detail-card" *ngIf="selectedUnassigned">
<div class="info-card-header"> <div class="info-card-header">
<h3>Gestionar PDV sin ruta</h3> <h3>Gestionar PDV sin ruta</h3>
@@ -96,11 +82,18 @@
<div><dt>Motivo</dt><dd>{{ selectedUnassigned.motivo }}</dd></div> <div><dt>Motivo</dt><dd>{{ selectedUnassigned.motivo }}</dd></div>
<div><dt>Coordenadas</dt><dd>{{ selectedUnassigned.latitud }}, {{ selectedUnassigned.longitud }}</dd></div> <div><dt>Coordenadas</dt><dd>{{ selectedUnassigned.latitud }}, {{ selectedUnassigned.longitud }}</dd></div>
</dl> </dl>
<div class="pending-edit-grid"> <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>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ías<div class="day-checkboxes"><label *ngFor="let day of unassignedDayOptions"><input type="checkbox" [checked]="(assignmentDays[selectedUnassigned.pdv_id] || []).includes(day)" (change)="toggleUnassignedDay(selectedUnassigned.pdv_id, day, $any($event.target).checked)">{{ day.substring(0, 3) }}</label></div></label> <label>Días<div class="day-checkboxes"><label *ngFor="let day of unassignedDayOptions"><input type="checkbox" [checked]="(assignmentDays[selectedUnassigned.pdv_id] || []).includes(day)" (change)="toggleUnassignedDay(selectedUnassigned.pdv_id, day, $any($event.target).checked)">{{ day.substring(0, 3) }}</label></div></label>
</div> </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> <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>
<div class="employee-load-list" aria-label="Carga de empleados">
<button type="button" class="employee-load-row" *ngFor="let employee of unassignedEmployeeLoads" [class.selected]="assignmentUser[selectedUnassigned.pdv_id] === employee.id" [class.over-capacity]="employeeLoadStatus(employee) === 'over-capacity'" (click)="selectUnassignedEmployee(employee.id)">
<span class="employee-load-name">{{ employee.name }}</span>
<span class="employee-load-values">{{ employee.workHours | number:'1.0-1' }} / {{ employee.capacityHours | number:'1.0-1' }} h · {{ employee.utilization | number:'1.0-1' }}%</span>
<span class="employee-load-bar" [ngClass]="employeeLoadStatus(employee)"><span [style.width.%]="employeeLoadWidth(employee)"></span></span>
</button>
</div>
</ng-template> </ng-template>
<div class="error-card" *ngIf="unassignedActionError">{{ unassignedActionError }}</div> <div class="error-card" *ngIf="unassignedActionError">{{ unassignedActionError }}</div>
</section> </section>
@@ -129,7 +122,7 @@
<div><dt>Día</dt><dd>{{ selectedRouteDetail.json.dia }}</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>Orden de visita</dt><dd>{{ selectedVisitOrder }}</dd></div>
<div><dt>Origen del tramo</dt><dd>{{ selectedSegmentOrigin }}</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>Destino</dt><dd><button type="button" class="btn-secondary map-location-button" (click)="openDestinationInGoogleMaps()" title="Abrir ubicación en Google Maps"><i class="pi pi-map"></i> Ver en Google Maps</button></dd></div>
<div><dt>Horas trabajo</dt><dd>{{ selectedRouteDetail.json.horas_trabajo }}</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>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> <div><dt>Duración tramo</dt><dd>{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min</dd></div>
@@ -145,6 +138,13 @@
<label>Día<select [(ngModel)]="reassignDay"><option *ngFor="let day of unassignedDayOptions" [value]="day">{{ day }}</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> <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="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="employee-load-list" aria-label="Carga de empleados">
<button type="button" class="employee-load-row" *ngFor="let employee of reassignmentEmployeeLoads" [class.selected]="reassignUserId === employee.id" [class.over-capacity]="employeeLoadStatus(employee) === 'over-capacity'" (click)="selectReassignmentEmployee(employee.id)">
<span class="employee-load-name">{{ employee.name }}</span>
<span class="employee-load-values">{{ employee.workHours | number:'1.0-1' }} / {{ employee.capacityHours | number:'1.0-1' }} h · {{ employee.utilization | number:'1.0-1' }}%</span>
<span class="employee-load-bar" [ngClass]="employeeLoadStatus(employee)"><span [style.width.%]="employeeLoadWidth(employee)"></span></span>
</button>
</div>
<div class="error-card" *ngIf="reassignError">{{ reassignError }}</div> <div class="error-card" *ngIf="reassignError">{{ reassignError }}</div>
</div> </div>
</div> </div>
@@ -152,26 +152,32 @@
<section class="daily-routes-card" *ngIf="dailySummaries.length"> <section class="daily-routes-card" *ngIf="dailySummaries.length">
<h3>Rutas diarias</h3> <h3>Rutas diarias</h3>
<article class="daily-route" *ngFor="let day of dailySummaries" [class.over-capacity]="day.exceedsCapacity"> <article class="daily-route-group" *ngFor="let group of dailyGroups">
<strong>{{ day.userName }} · {{ day.day }}</strong> <button type="button" class="daily-route-toggle" (click)="toggleDailyRouteDay(group.day)" [attr.aria-expanded]="isDailyRouteDayExpanded(group.day)">
<span>{{ day.routes.length }} paradas · {{ day.distanceKm | number:'1.1-1' }} km · {{ (day.travelHours * 60) | number:'1.0-0' }} min</span> <span class="daily-route-toggle-title"><i class="pi" [class.pi-chevron-right]="!isDailyRouteDayExpanded(group.day)" [class.pi-chevron-down]="isDailyRouteDayExpanded(group.day)"></i> {{ group.day }}</span>
<small [attr.aria-label]="day.exceedsCapacity ? 'Capacidad excedida' : 'Dentro de capacidad'">{{ day.totalHours | number:'1.1-1' }} / {{ day.capacityHours | number:'1.1-1' }} h {{ day.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}</small> <span>{{ group.totalStops }} paradas · {{ group.totalDistance | number:'1.1-1' }} km</span>
</button>
<div class="daily-route-group-content" *ngIf="isDailyRouteDayExpanded(group.day)">
<article class="daily-route" *ngFor="let route of group.routes" [class.over-capacity]="route.exceedsCapacity">
<strong>{{ route.userName }}</strong>
<span>{{ route.routes.length }} paradas · {{ route.distanceKm | number:'1.1-1' }} km</span>
<small [attr.aria-label]="route.exceedsCapacity ? 'Capacidad excedida' : 'Dentro de capacidad'">{{ route.totalHours | number:'1.1-1' }} / {{ route.capacityHours | number:'1.1-1' }} h {{ route.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}</small>
</article>
</div>
</article> </article>
</section> </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" tabindex="0" role="button"
[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)" (keydown.enter)="selectRouteFromList(r)" (keydown.space)="selectRouteFromList(r); $event.preventDefault()">
<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> </div>
<section class="unassigned-bottom-drawer" *ngIf="selectedUsuarioId === 'Todos' && selectedDia === 'Todos' && unassignedRoutes.length" [class.open]="isUnassignedDrawerOpen">
<button type="button" class="unassigned-drawer-toggle" (click)="toggleUnassignedDrawer()" [attr.aria-expanded]="isUnassignedDrawerOpen">
<span><i class="pi pi-exclamation-triangle"></i> PDVs sin ruta ({{ unassignedRoutes.length }})</span>
<i class="pi" [class.pi-chevron-up]="!isUnassignedDrawerOpen" [class.pi-chevron-down]="isUnassignedDrawerOpen"></i>
</button>
<div class="unassigned-drawer-content" *ngIf="isUnassignedDrawerOpen">
<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>
@@ -11,6 +11,21 @@ import { RouteAssignedResponse, RouteNoAssignedResponse } from '../../../core/mo
import { colorForUsuario } from '../../../shared/utils/palette.utils'; import { colorForUsuario } from '../../../shared/utils/palette.utils';
import { DailyRouteSummary, haversineKm, sequenceRoutes, summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils'; import { DailyRouteSummary, haversineKm, sequenceRoutes, summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
interface DailyRouteGroup {
day: string;
routes: DailyRouteSummary[];
totalStops: number;
totalDistance: number;
}
interface EmployeeLoadOption {
id: string;
name: string;
workHours: number;
capacityHours: number;
utilization: number;
}
interface MapRoute { interface MapRoute {
data: RouteAssignedResponse; data: RouteAssignedResponse;
color: string; color: string;
@@ -45,14 +60,14 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
private selectedMapRoute: MapRoute | null = null; private selectedMapRoute: MapRoute | null = null;
private heatLayer?: any; private heatLayer?: any;
showHeat = true; showHeat = true;
heatWeightMode: 'uniform' | 'horas_trabajo' = 'horas_trabajo';
private heatPointsCache: [number, number, number][] = []; private heatPointsCache: [number, number, number][] = [];
private unassignedMarkers = new Map<number, L.Marker>(); private unassignedMarkers = new Map<number, L.Marker>();
usuarios: { id: string; nombre: string }[] = []; usuarios: { id: string; nombre: string }[] = [];
dias: string[] = []; dias: string[] = [];
selectedUsuarioId: string | null = null; selectedUsuarioId: string = 'Todos';
selectedDia: string = 'Todos'; selectedDia: string = 'Todos';
expandedDailyRouteDay: string | null = null;
selectedRouteDetail: RouteAssignedResponse | null = null; selectedRouteDetail: RouteAssignedResponse | null = null;
filteredRoutes: RouteAssignedResponse[] = []; filteredRoutes: RouteAssignedResponse[] = [];
@@ -63,6 +78,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
orsTotal = 0; orsTotal = 0;
error: string | null = null; error: string | null = null;
unassignedRoutes: RouteNoAssignedResponse[] = []; unassignedRoutes: RouteNoAssignedResponse[] = [];
isUnassignedDrawerOpen = false;
selectedUnassigned: RouteNoAssignedResponse | null = null; selectedUnassigned: RouteNoAssignedResponse | null = null;
editingUnassigned: RouteNoAssignedResponse | null = null; editingUnassigned: RouteNoAssignedResponse | null = null;
editUnassignedName = ''; editUnassignedName = '';
@@ -80,17 +96,50 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
reassignDay = ''; reassignDay = '';
reassignError: string | null = null; reassignError: string | null = null;
get activeTerritories(): string[] {
const values = [
...this.allRoutes.map(route => route.json.metro || route.json.ciudad || route.json.estado || route.json.provincia || route.json.region || route.json.zona),
...this.unassignedRoutes.map(route => route.metro || route.ciudad || route.estado || route.provincia || route.region || route.zona)
].filter((value): value is string => !!value?.trim());
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
}
// Summary stats // Summary stats
get uniqueFilteredRoutes(): RouteAssignedResponse[] {
const unique = new Map<string, RouteAssignedResponse>();
for (const route of this.filteredRoutes) {
const pdvId = String(route.json.pdv_id);
if (!unique.has(pdvId)) unique.set(pdvId, route);
}
return Array.from(unique.values());
}
get totalDistancia(): string { get totalDistancia(): string {
const uniqueIds = new Set(this.uniqueFilteredRoutes.map(route => String(route.json.pdv_id)));
const sum = this.mapRoutes.length const sum = this.mapRoutes.length
? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDistanceKm ?? haversineKm(route.origin, route.destination)), 0) ? this.mapRoutes
: this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.distancia), 0); .filter(route => uniqueIds.has(String(route.data.json.pdv_id)))
.reduce((acc, route) => {
const firstSegment = this.mapRoutes.find(candidate => String(candidate.data.json.pdv_id) === String(route.data.json.pdv_id));
return firstSegment === route
? acc + (route.segmentDistanceKm ?? haversineKm(route.origin, route.destination))
: acc;
}, 0)
: this.uniqueFilteredRoutes.reduce((acc, route) => acc + Number(route.json.distancia), 0);
return sum.toFixed(2); return sum.toFixed(2);
} }
get totalDuracionMin(): string { get totalDuracionMin(): string {
const uniqueIds = new Set(this.uniqueFilteredRoutes.map(route => String(route.json.pdv_id)));
const sum = this.mapRoutes.length const sum = this.mapRoutes.length
? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDurationHours ?? Number(route.data.json.horas_desplazamiento)) * 60, 0) ? this.mapRoutes
: this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.horas_desplazamiento) * 60, 0); .filter(route => uniqueIds.has(String(route.data.json.pdv_id)))
.reduce((acc, route) => {
const firstSegment = this.mapRoutes.find(candidate => String(candidate.data.json.pdv_id) === String(route.data.json.pdv_id));
return firstSegment === route
? acc + (route.segmentDurationHours ?? Number(route.data.json.horas_desplazamiento)) * 60
: acc;
}, 0)
: this.uniqueFilteredRoutes.reduce((acc, route) => acc + Number(route.json.horas_desplazamiento) * 60, 0);
return sum.toFixed(0); return sum.toFixed(0);
} }
@@ -98,6 +147,44 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
return summarizeDailyRoutes(this.filteredRoutes); return summarizeDailyRoutes(this.filteredRoutes);
} }
get dailyGroups(): DailyRouteGroup[] {
const order = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes', 'Sábado', 'Sabado'];
const groups = new Map<string, DailyRouteSummary[]>();
for (const summary of this.dailySummaries) {
const routes = groups.get(summary.day) ?? [];
routes.push(summary);
groups.set(summary.day, routes);
}
return Array.from(groups, ([day, routes]) => ({
day,
routes,
totalStops: routes.reduce((sum, route) => sum + route.routes.length, 0),
totalDistance: routes.reduce((sum, route) => sum + route.distanceKm, 0)
})).sort((a, b) => order.indexOf(a.day) - order.indexOf(b.day));
}
toggleDailyRouteDay(day: string): void {
this.expandedDailyRouteDay = this.expandedDailyRouteDay === day ? null : day;
}
isDailyRouteDayExpanded(day: string): boolean {
return this.expandedDailyRouteDay === day;
}
openDestinationInGoogleMaps(): void {
if (!this.selectedRouteDetail) return;
const { latitud_pdv, longitud_pdv } = this.selectedRouteDetail.json;
const latitude = Number(latitud_pdv);
const longitude = Number(longitud_pdv);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return;
const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${latitude},${longitude}`)}`;
window.open(url, '_blank', 'noopener,noreferrer');
}
toggleUnassignedDrawer(): void {
this.isUnassignedDrawerOpen = !this.isUnassignedDrawerOpen;
}
constructor( constructor(
private routeState: RouteState, private routeState: RouteState,
private orsService: OrsService, private orsService: OrsService,
@@ -112,11 +199,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
this.allRoutes = [...routes]; this.allRoutes = [...routes];
this.usuarios = this.routeState.getUsuariosUnicos(); this.usuarios = this.routeState.getUsuariosUnicos();
this.dias = this.routeState.getDiasUnicos(); this.dias = this.routeState.getDiasUnicos();
// Init from queryParams default (Todos) // Start each map load with the complete planning selected by default.
const qp = this.activatedRoute.snapshot.queryParams; const qp = this.activatedRoute.snapshot.queryParams;
if (qp['usuario_id']) this.selectedUsuarioId = String(qp['usuario_id']); if (qp['pdv_id']) {
else if (!this.selectedUsuarioId && this.usuarios.length) this.selectedUsuarioId = 'Todos'; this.selectedUsuarioId = 'Todos';
if (qp['dia']) this.selectedDia = String(qp['dia']); this.selectedDia = 'Todos';
}
// If focus pdv provided, auto-select detail after map loads // If focus pdv provided, auto-select detail after map loads
this.applyFilter(); this.applyFilter();
this.cdr.detectChanges(); this.cdr.detectChanges();
@@ -135,20 +223,6 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
// React to queryParams changes // React to queryParams changes
this.subs.push( this.subs.push(
this.activatedRoute.queryParams.subscribe(qp => { 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 // Focus a specific route if pdv_id passed
if (qp['pdv_id'] && this.filteredRoutes.length) { if (qp['pdv_id'] && this.filteredRoutes.length) {
const found = this.filteredRoutes.find(r => String(r.json.pdv_id) === String(qp['pdv_id'])); const found = this.filteredRoutes.find(r => String(r.json.pdv_id) === String(qp['pdv_id']));
@@ -223,11 +297,26 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} }
private initMap() { private initMap() {
this.map = L.map('map-overview').setView([18.4861, -69.9312], 10); const territoryBounds = this.projectBounds();
this.map = L.map('map-overview', { maxBoundsViscosity: 0.85 });
if (territoryBounds) {
this.map.fitBounds(territoryBounds.pad(0.15));
this.map.setMaxBounds(territoryBounds.pad(0.35));
} else {
this.map.setView([18.4861, -69.9312], 10);
}
const pdvPane = this.map.createPane('pdvPane'); const pdvPane = this.map.createPane('pdvPane');
pdvPane.style.zIndex = '650'; const heatPane = this.map.createPane('heatPane');
pdvPane.style.zIndex = '750';
heatPane.style.zIndex = '700';
const popupPane = this.map.getPane('popupPane');
if (popupPane) popupPane.style.zIndex = '1000';
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors' attribution: '© OpenStreetMap contributors',
bounds: territoryBounds?.pad(0.35),
keepBuffer: 1,
noWrap: true,
updateWhenIdle: true
}).addTo(this.map); }).addTo(this.map);
// Fix tiles after sidebar overlay // Fix tiles after sidebar overlay
setTimeout(() => this.map?.invalidateSize(), 200); setTimeout(() => this.map?.invalidateSize(), 200);
@@ -266,6 +355,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
this.orsProgress = 0; this.orsProgress = 0;
this.orsTotal = 0; this.orsTotal = 0;
if (!this.filteredRoutes.length) { if (!this.filteredRoutes.length) {
this.renderUnassignedMarkers();
const territoryBounds = this.projectBounds();
if (territoryBounds) this.map.fitBounds(territoryBounds.pad(0.15));
this.cdr.detectChanges(); this.cdr.detectChanges();
return; return;
} }
@@ -312,14 +404,14 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
// Marker for PDV // Marker for PDV
const pdvIcon = L.divIcon({ const pdvIcon = L.divIcon({
className: 'custom-marker marker-end', className: 'custom-marker marker-end',
html: `<div class="location-pin"><span>${segment.order}</span></div>`, html: `<div class="location-pin-wrapper"><div class="location-pin"><span>${segment.order}</span></div></div>`,
iconSize: [30, 38], iconSize: [42, 50],
iconAnchor: [15, 38], iconAnchor: [21, 46],
popupAnchor: [0, -18] popupAnchor: [0, -18]
}); });
const marker = L.marker([Number(j.latitud_pdv), Number(j.longitud_pdv)], { icon: pdvIcon, pane: 'pdvPane', zIndexOffset: 1000 }) const marker = L.marker([Number(j.latitud_pdv), Number(j.longitud_pdv)], { icon: pdvIcon, pane: 'pdvPane', zIndexOffset: 1000 })
.addTo(this.map!) .addTo(this.map!)
.bindPopup(`<strong>${j.nombre_pdv}</strong><br>Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`); .bindPopup(`<strong>${j.nombre_pdv}</strong><br><strong>Usuario:</strong> ${j.nombre_usuario}<br>Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`);
// Heat point // Heat point
const w = this.computeWeight(j); const w = this.computeWeight(j);
this.heatPointsCache.push([Number(j.latitud_pdv), Number(j.longitud_pdv), w]); this.heatPointsCache.push([Number(j.latitud_pdv), Number(j.longitud_pdv), w]);
@@ -344,6 +436,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
marker.on('click', onClick); marker.on('click', onClick);
}); });
if (this.selectedUsuarioId === 'Todos' && this.selectedDia === 'Todos') {
for (const route of this.unassignedRoutes) {
if (this.validPoint(route.latitud, route.longitud)) bounds.push([Number(route.latitud), Number(route.longitud)]);
}
}
this.updateHeatLayer(); this.updateHeatLayer();
this.toggleLayerVisibility(); this.toggleLayerVisibility();
@@ -358,6 +456,25 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
this.cdr.detectChanges(); this.cdr.detectChanges();
} }
private projectBounds(): L.LatLngBounds | null {
const points: L.LatLngTuple[] = [];
for (const route of this.allRoutes) {
const data = route.json;
if (this.validPoint(data.latitud_usuario, data.longitud_usuario)) points.push([Number(data.latitud_usuario), Number(data.longitud_usuario)]);
if (this.validPoint(data.latitud_pdv, data.longitud_pdv)) points.push([Number(data.latitud_pdv), Number(data.longitud_pdv)]);
}
for (const route of this.unassignedRoutes) {
if (this.validPoint(route.latitud, route.longitud)) points.push([Number(route.latitud), Number(route.longitud)]);
}
return points.length ? L.latLngBounds(points) : null;
}
private validPoint(latitude: unknown, longitude: unknown): boolean {
const lat = Number(latitude);
const lng = Number(longitude);
return Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
}
private renderUnassignedMarkers(): void { private renderUnassignedMarkers(): void {
if (!this.map || this.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return; if (!this.map || this.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return;
for (const route of this.unassignedRoutes) { for (const route of this.unassignedRoutes) {
@@ -382,15 +499,20 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
this.orsProgress = 0; this.orsProgress = 0;
this.cdr.detectChanges(); this.cdr.detectChanges();
const pares = this.mapRoutes.map(route => ({ const embeddedResults = this.mapRoutes
.filter(route => !!route.data.json.geojson)
.map(route => ({ pdv_id: route.requestId, geojson: route.data.json.geojson }));
const pares = this.mapRoutes.filter(route => !route.data.json.geojson).map(route => ({
pdv_id: route.requestId, pdv_id: route.requestId,
origin: route.origin, origin: route.origin,
destination: route.destination destination: route.destination
})); }));
const { promise } = this.orsService.processPairsWithProgress(pares, 2, true, this.abort$);
try { try {
const results = await promise; const remoteResults = pares.length
? await this.orsService.processPairsWithProgress(pares, 2, true, this.abort$).promise
: [];
const results = [...embeddedResults, ...remoteResults];
if (!results.length) { if (!results.length) {
this.isLoading = false; this.isLoading = false;
this.cdr.detectChanges(); this.cdr.detectChanges();
@@ -449,7 +571,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
mr.segmentDistanceKm = summary.distance / 1000; mr.segmentDistanceKm = summary.distance / 1000;
mr.segmentDurationHours = summary.duration / 3600; mr.segmentDurationHours = summary.duration / 3600;
mr.marker?.setPopupContent( 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` `<strong>${mr.data.json.nombre_pdv}</strong><br><strong>Usuario:</strong> ${mr.data.json.nombre_usuario}<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)); line.on('click', () => this.selectRoute(mr));
@@ -492,6 +614,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} }
private selectRoute(mr: MapRoute) { private selectRoute(mr: MapRoute) {
this.selectedUnassigned = null;
this.editingUnassigned = null;
this.unassignedActionError = null;
this.selectedRouteDetail = mr.data; this.selectedRouteDetail = mr.data;
this.highlightRoute(mr.data); this.highlightRoute(mr.data);
this.fitSelectedRoute(mr); this.fitSelectedRoute(mr);
@@ -499,6 +624,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} }
selectRouteFromList(route: RouteAssignedResponse): void { selectRouteFromList(route: RouteAssignedResponse): void {
this.selectedUnassigned = null;
this.editingUnassigned = null;
this.unassignedActionError = null;
const mapRoute = this.mapRoutes.find(mr => this.sameRoute(mr.data, route)); const mapRoute = this.mapRoutes.find(mr => this.sameRoute(mr.data, route));
if (mapRoute) { if (mapRoute) {
this.selectRoute(mapRoute); this.selectRoute(mapRoute);
@@ -647,14 +775,62 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
left.json.dia === right.json.dia; left.json.dia === right.json.dia;
} }
private getEmployeeLoads(excluding?: RouteAssignedResponse): EmployeeLoadOption[] {
const profiles = new Map<string, RouteAssignedResponse>();
const workHours = new Map<string, number>();
for (const route of this.routeState.getAssignedSnapshot()) {
const id = String(route.json.usuario_id);
if (!profiles.has(id)) profiles.set(id, route);
if (route !== excluding) {
workHours.set(id, (workHours.get(id) ?? 0) + Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0));
}
}
return Array.from(profiles, ([id, profile]) => {
const capacityHours = Number(profile.json.hora_laboral_semanal_usuario || 0);
const assignedHours = workHours.get(id) ?? 0;
return {
id,
name: profile.json.nombre_usuario,
workHours: assignedHours,
capacityHours,
utilization: capacityHours ? (assignedHours / capacityHours) * 100 : 0
};
}).sort((a, b) => a.utilization - b.utilization || a.name.localeCompare(b.name));
}
get unassignedUserOptions(): { id: string; name: string }[] { get unassignedUserOptions(): { id: string; name: string }[] {
return this.routeState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre })); return this.routeState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre }));
} }
get unassignedEmployeeLoads(): EmployeeLoadOption[] {
return this.getEmployeeLoads();
}
get reassignmentEmployeeLoads(): EmployeeLoadOption[] {
return this.getEmployeeLoads(this.selectedRouteDetail ?? undefined);
}
get unassignedDayOptions(): string[] { get unassignedDayOptions(): string[] {
return this.routeState.getDiasUnicos(); return this.routeState.getDiasUnicos();
} }
employeeLoadWidth(employee: EmployeeLoadOption): number {
return Math.min(Math.max(employee.utilization, 0), 100);
}
employeeLoadStatus(employee: EmployeeLoadOption): 'ok' | 'warning' | 'over-capacity' {
return employee.utilization > 100 ? 'over-capacity' : employee.utilization > 90 ? 'warning' : 'ok';
}
selectUnassignedEmployee(employeeId: string): void {
if (!this.selectedUnassigned) return;
this.assignmentUser[String(this.selectedUnassigned.pdv_id)] = employeeId;
}
selectReassignmentEmployee(employeeId: string): void {
this.reassignUserId = employeeId;
}
toggleUnassignedDay(pdvId: number, day: string, checked: boolean): void { toggleUnassignedDay(pdvId: number, day: string, checked: boolean): void {
const key = String(pdvId); const key = String(pdvId);
const days = new Set(this.assignmentDays[key] ?? []); const days = new Set(this.assignmentDays[key] ?? []);
@@ -665,6 +841,8 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
selectUnassigned(route: RouteNoAssignedResponse): void { selectUnassigned(route: RouteNoAssignedResponse): void {
this.selectedUnassigned = route; this.selectedUnassigned = route;
this.selectedRouteDetail = null; this.selectedRouteDetail = null;
this.showReassignForm = false;
this.reassignError = null;
this.map?.setView([route.latitud, route.longitud], Math.max(this.map.getZoom(), 14)); this.map?.setView([route.latitud, route.longitud], Math.max(this.map.getZoom(), 14));
this.unassignedMarkers.get(route.pdv_id)?.openPopup(); this.unassignedMarkers.get(route.pdv_id)?.openPopup();
this.cdr.detectChanges(); this.cdr.detectChanges();
@@ -769,13 +947,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} }
private computeWeight(j: RouteAssignedResponse['json']): number { private computeWeight(j: RouteAssignedResponse['json']): number {
if (this.heatWeightMode === 'uniform') return 0.8;
const v = parseFloat(String(j.horas_trabajo)); const v = parseFloat(String(j.horas_trabajo));
if (isNaN(v)) return 0.8; if (isNaN(v)) return 1;
const vals = this.filteredRoutes.map(r => parseFloat(String(r.json.horas_trabajo))).filter(n => !isNaN(n)); const vals = this.filteredRoutes.map(r => parseFloat(String(r.json.horas_trabajo))).filter(n => !isNaN(n));
if (!vals.length) return 0.8; if (!vals.length) return 1;
const min = Math.min(...vals), max = Math.max(...vals); const min = Math.min(...vals), max = Math.max(...vals);
if (max === min) return 0.8; if (max === min) return 1;
return 0.3 + 0.7 * (v - min) / (max - min); return 0.3 + 0.7 * (v - min) / (max - min);
} }
@@ -786,8 +963,13 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} }
if (!this.showHeat || !this.heatPointsCache.length || !this.map) return; if (!this.showHeat || !this.heatPointsCache.length || !this.map) return;
this.heatLayer = (L as any).heatLayer(this.heatPointsCache, { this.heatLayer = (L as any).heatLayer(this.heatPointsCache, {
radius: 25, blur: 18, maxZoom: 17, minOpacity: 0.4, pane: 'heatPane',
gradient: { 0.4: '#6CC24A', 0.65: '#FF6A13', 1.0: '#D32F2F' } radius: 32,
blur: 24,
maxZoom: 18,
minOpacity: 0.55,
max: 1.8,
gradient: { 0.2: '#b7f05f', 0.45: '#6CC24A', 0.7: '#FF6A13', 1.0: '#D32F2F' }
}).addTo(this.map); }).addTo(this.map);
} }
@@ -802,16 +984,6 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
this.cdr.detectChanges(); 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() { private toggleLayerVisibility() {
this.mapRoutes.forEach(mr => { this.mapRoutes.forEach(mr => {
if (mr.fallbackLine) (mr.fallbackLine as any).setStyle({ opacity: 0.6 }); if (mr.fallbackLine) (mr.fallbackLine as any).setStyle({ opacity: 0.6 });
@@ -830,6 +1002,6 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} }
goBack() { goBack() {
this.router.navigate(['/home']); this.router.navigate(['/proyectos', this.activatedRoute.snapshot.paramMap.get('id')]);
} }
} }
@@ -0,0 +1 @@
<div class="projects-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="topbar-actions"><a routerLink="/home">Mis proyectos</a><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div></div><main class="projects-page narrow"><header><div><h1>Crear proyecto</h1><p>Define el proyecto e importa los PDVs que se planificarán.</p></div></header><section class="project-form"><label>Nombre del proyecto<input [(ngModel)]="name" placeholder="Ej. Ruta Santo Domingo Norte" maxlength="120"></label><label>País<select [(ngModel)]="country"><option value="Republica Dominicana">República Dominicana</option><option value="MEXICO">México</option></select></label><label class="file-label">Archivo PDVs (Excel)<input type="file" accept=".xlsx,.xls" (change)="fileChanged($event)"><span>.xlsx / .xls, máximo 10 MB. El país también se detecta desde el archivo.</span></label><p *ngIf="file">{{ file.name }}</p><p *ngIf="loading">Validando archivo...</p><p class="valid" *ngIf="preview?.valid">{{ preview!.validRows }} PDVs válidos para crear el proyecto.</p><p class="error" *ngIf="preview && !preview.valid">El archivo tiene errores. Corrígelos antes de crear el proyecto.</p><p class="error" *ngIf="error">{{ error }}</p><button class="primary" type="button" [disabled]="!canCreate" (click)="create()">{{ saving ? 'Creando proyecto...' : 'Crear proyecto' }}</button></section></main>
@@ -0,0 +1,52 @@
import { ChangeDetectorRef, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import * as XLSX from 'xlsx';
import { ProjectService } from '../../core/services/project.service';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service';
@Component({ standalone: true, selector: 'app-new-project', imports: [CommonModule, FormsModule, RouterLink], templateUrl: './new-project.component.html', styleUrl: './projects.component.css' })
export class NewProjectComponent {
name = '';
country = 'Republica Dominicana';
file: File | null = null;
preview: PreviewResult | null = null;
loading = false;
saving = false;
error = '';
constructor(private projects: ProjectService, private auth: AuthService, public profile: UserProfileService, private previewService: ExcelPreviewService, private router: Router, private cdr: ChangeDetectorRef) {}
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
async fileChanged(event: Event): Promise<void> {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
this.file = file; this.preview = null; this.error = ''; this.loading = true;
try {
this.preview = await this.previewService.previewFile(file, 'PDV');
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(workbook.Sheets[workbook.SheetNames[0]], { defval: null, raw: true });
const value = rows.map(row => Object.entries(row).find(([key]) => key.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase() === 'pais')?.[1]).find(Boolean);
const normalized = String(value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase();
if (normalized.includes('MEXICO')) this.country = 'MEXICO';
else if (normalized.includes('DOMINICANA')) this.country = 'Republica Dominicana';
}
catch (error: any) { this.error = error?.message ?? 'No se pudo leer el archivo.'; }
finally { this.loading = false; this.cdr.detectChanges(); }
}
get canCreate(): boolean { return !!this.name.trim() && !!this.country && !!this.file && !!this.preview?.valid && !this.saving; }
async create(): Promise<void> {
if (!this.canCreate || !this.file) return;
this.saving = true; this.error = '';
try {
const workbook = XLSX.read(await this.file.arrayBuffer(), { type: 'array' });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: null, raw: true });
const projectRows = rows.map(row => ({ ...row, 'País': this.country }));
const project = await this.projects.create(this.name, projectRows);
await this.router.navigate(['/proyectos', project.id]);
} catch (error: any) { this.error = error?.message ?? 'No se pudo crear el proyecto.'; }
finally { this.saving = false; this.cdr.detectChanges(); }
}
}
@@ -0,0 +1,2 @@
<div class="projects-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="topbar-actions"><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div></div>
<main class="projects-page"><a class="manage-employees" routerLink="/empleados"><i class="pi pi-users"></i> Gestionar empleados</a><header><div><h1>Mis proyectos</h1><p>Organiza y conserva tus planificaciones de rutas.</p></div><a class="primary" routerLink="/proyectos/nuevo"><i class="pi pi-plus"></i> Crear proyecto</a></header><p class="error" *ngIf="error">{{ error }}</p><p *ngIf="loading">Cargando proyectos...</p><section class="project-grid" *ngIf="!loading"><article class="project-card" *ngFor="let project of projects"><span class="state">{{ project.estado }}</span><h2>{{ project.nombre }}</h2><p>{{ project.descripcion || 'Sin descripción' }}</p><small>Actualizado: {{ project.updated_at | date:'medium' }}</small><div class="project-card-actions"><button type="button" (click)="open(project)" [disabled]="deletingId === project.id">Abrir proyecto</button><button type="button" class="danger" (click)="remove(project)" [disabled]="deletingId === project.id"><i class="pi pi-trash"></i> {{ deletingId === project.id ? 'Eliminando...' : 'Eliminar' }}</button></div></article><section class="empty" *ngIf="!projects.length"><i class="pi pi-folder-open"></i><h2>Aún no tienes proyectos</h2><p>Crea uno e importa su archivo de PDVs para comenzar.</p><a class="primary" routerLink="/proyectos/nuevo">Crear proyecto</a></section></section></main>
@@ -0,0 +1,39 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { ProjectService } from '../../core/services/project.service';
import { RuteroProyecto } from '../../core/models/project.model';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
@Component({ standalone: true, selector: 'app-project-list', imports: [CommonModule, RouterLink], templateUrl: './project-list.component.html', styleUrl: './projects.component.css' })
export class ProjectListComponent implements OnInit {
projects: RuteroProyecto[] = [];
loading = true;
deletingId = '';
error = '';
constructor(private projectsService: ProjectService, private auth: AuthService, public profile: UserProfileService, private router: Router, private cdr: ChangeDetectorRef) {}
ngOnInit(): void { void this.load(); }
async load(): Promise<void> {
this.loading = true;
try { this.projects = await this.projectsService.list(); }
catch (error: any) { this.error = error?.message ?? 'No se pudieron cargar los proyectos.'; }
finally { this.loading = false; this.cdr.detectChanges(); }
}
open(project: RuteroProyecto): void { void this.router.navigate(['/proyectos', project.id]); }
async remove(project: RuteroProyecto): Promise<void> {
if (!confirm(`Eliminar permanentemente el proyecto "${project.nombre}" y todos sus PDVs y rutas?`)) return;
this.deletingId = project.id;
this.error = '';
try {
await this.projectsService.remove(project.id);
this.projects = this.projects.filter(item => item.id !== project.id);
} catch (error: any) {
this.error = error?.message ?? 'No se pudo eliminar el proyecto.';
} finally {
this.deletingId = '';
this.cdr.detectChanges();
}
}
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
}
@@ -0,0 +1,5 @@
.projects-topbar { display:flex; align-items:center; gap:12px; padding:9px 24px; border-bottom:3px solid var(--glm-verde); color:var(--glm-azul); font-weight:700; background:#fff; }.projects-topbar img { height:38px; }.projects-topbar a { margin-left:auto; color:var(--glm-azul); text-decoration:none; }.projects-page { max-width:1100px; margin:0 auto; padding:30px 24px; }.projects-page header { display:flex; justify-content:space-between; align-items:flex-start; gap:20px; border-bottom:3px solid var(--glm-verde); padding-bottom:14px; }.projects-page h1,.projects-page h2 { color:var(--glm-azul); margin:0; }.projects-page header p,.project-card p,small { color:var(--glm-acero); }.primary,.project-card button { display:inline-block; border:1px solid var(--glm-azul); border-radius:4px; padding:9px 12px; background:var(--glm-azul); color:#fff; font:inherit; font-weight:700; text-decoration:none; cursor:pointer; }.project-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(245px,1fr)); gap:16px; margin-top:22px; }.project-card,.project-form,.empty { border:1px solid var(--glm-gris-medio); border-top:3px solid var(--glm-azul); border-radius:4px; padding:18px; background:#fff; }.project-card { display:flex; flex-direction:column; gap:12px; }.project-card button { margin-top:auto; }.state { align-self:flex-start; color:var(--glm-verde); font-size:.75rem; font-weight:700; text-transform:uppercase; }.empty { grid-column:1/-1; text-align:center; padding:38px; }.narrow { max-width:720px; }.project-form { display:grid; gap:16px; margin-top:20px; }.project-form label { display:grid; gap:6px; color:var(--glm-azul); font-size:.8rem; font-weight:700; text-transform:uppercase; }.project-form input { padding:10px; border:1px solid var(--glm-gris-medio); border-radius:4px; font:inherit; }.file-label span { color:var(--glm-acero); font-size:.75rem; text-transform:none; }.error { color:#c9252d; }.valid { color:var(--glm-verde); font-weight:700; } @media(max-width:640px){.projects-page{padding:20px 16px}.projects-page header{display:grid}.projects-topbar{padding:9px 16px}}
.project-form input,.project-form select { background:#fff; color:#2f3f48; border-color:#9aaab3; }.project-form select { padding:10px; border:1px solid #9aaab3; border-radius:4px; font:inherit; }.project-form input::placeholder { color:#71808a; opacity:1; }.project-form input:focus,.project-form select:focus { outline:2px solid rgba(108,194,74,.35); border-color:var(--glm-verde); background:#fff; color:#1f2d34; }.project-form input[type=file] { color:#2f3f48; background:#fff; cursor:pointer; }
.topbar-actions { display:flex; align-items:center; gap:8px; margin-left:auto; }.topbar-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:inherit; font-size:.82rem; font-weight:700; padding:8px 12px; }.topbar-actions button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }.manage-employees { display:inline-flex; align-items:center; gap:6px; margin-bottom:14px; border:1px solid var(--glm-gris-medio); border-radius:4px; padding:8px 12px; color:var(--glm-azul); font-weight:700; text-decoration:none; background:#fff; }.manage-employees:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
.project-card-actions { display:flex; gap:8px; margin-top:auto; }.project-card-actions button { margin-top:0; }.project-card-actions .danger { border-color:#c9252d; background:#fff; color:#c9252d; }.project-card-actions .danger:hover:not(:disabled) { background:#c9252d; color:#fff; }.project-card-actions button:disabled { cursor:not-allowed; opacity:.65; }
@@ -87,13 +87,17 @@
box-shadow: 0 0 0 3px rgba(108, 194, 74, 0.15); box-shadow: 0 0 0 3px rgba(108, 194, 74, 0.15);
} }
button[type="submit"] { .google-login {
width: 100%; width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 8px; margin-top: 8px;
padding: 11px; padding: 11px;
background: var(--glm-azul); background: #fff;
color: #fff; color: var(--glm-gris-oscuro);
border: none; border: 1px solid var(--glm-gris-medio);
border-radius: 4px; border-radius: 4px;
font-size: 15px; font-size: 15px;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
@@ -102,15 +106,25 @@ button[type="submit"] {
transition: background 0.2s; transition: background 0.2s;
} }
button[type="submit"]:hover:not(:disabled) { .google-login:hover:not(:disabled) {
background: var(--glm-verde); border-color: var(--glm-azul);
background: var(--glm-verde-bg);
} }
button[type="submit"]:disabled { .google-login:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.google-mark { color: #4285f4; font-size: 1.1rem; font-weight: 700; }
.domain-note { margin: 10px 0 0; color: var(--glm-acero); font-size: .78rem; text-align: center; }
.login-error {
margin: 12px 0 0;
color: #c9252d;
font-size: 0.82rem;
}
.footer { .footer {
text-align: center; text-align: center;
margin-top: 24px; margin-top: 24px;
@@ -11,36 +11,15 @@
Planifica rutas inteligentes de forma eficiente Planifica rutas inteligentes de forma eficiente
</p> </p>
<form (ngSubmit)="login()" #form="ngForm"> <div class="login-actions">
<button type="button" class="google-login" (click)="loginWithGoogle()" [disabled]="loading">
<div class="input-group"> <span class="google-mark">G</span>
<label>Email</label> <span *ngIf="!loading">Continuar con Google</span>
<input <span *ngIf="loading">{{ loadingText }}</span>
type="email"
name="email"
[(ngModel)]="email"
required
placeholder="usuario@empresa.com"
/>
</div>
<div class="input-group">
<label>Contraseña</label>
<input
type="password"
name="password"
[(ngModel)]="password"
required
placeholder="••••••••"
/>
</div>
<button type="submit" [disabled]="loading || form.invalid">
<span *ngIf="!loading">Iniciar sesión</span>
<span *ngIf="loading">Verificando ruta...</span>
</button> </button>
<p class="domain-note">Solo cuentas @gomezleemarketing.com</p>
</form> <p class="login-error" *ngIf="error">{{ error }}</p>
</div>
<div class="footer"> <div class="footer">
© 2026 Route Planner System © 2026 Route Planner System
@@ -1,29 +1,57 @@
import { Component, ChangeDetectionStrategy } from '@angular/core'; import { Component, ChangeDetectionStrategy, ChangeDetectorRef, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
@Component({ @Component({
standalone: true, standalone: true,
selector: 'app-login', selector: 'app-login',
imports: [CommonModule, FormsModule], imports: [CommonModule],
templateUrl: './login.component.html', templateUrl: './login.component.html',
styleUrls: ['./login.component.css'], styleUrls: ['./login.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class LoginComponent { export class LoginComponent implements OnInit {
email = '';
password = '';
loading = false; loading = false;
error = '';
loadingText = 'Redirigiendo a Google...';
constructor(private router: Router) {} constructor(private router: Router, private auth: AuthService, private profile: UserProfileService, private cdr: ChangeDetectorRef) {}
login() {
async ngOnInit(): Promise<void> {
const session = await this.auth.getSession();
if (!session) return;
this.loading = true; this.loading = true;
this.loadingText = 'Obteniendo datos del usuario...';
setTimeout(() => { try {
await this.profile.load(session.user.email ?? '', String(session.user.user_metadata?.['avatar_url'] ?? ''));
await this.router.navigate(['/home']);
} catch (error: any) {
await this.auth.signOut();
this.error = error?.message ?? 'No se pudieron obtener los datos del usuario.';
} finally {
this.loading = false; this.loading = false;
this.router.navigate(['/home']); this.cdr.detectChanges();
}, 1500); }
}
async loginWithGoogle() {
this.error = '';
this.loading = true;
this.loadingText = 'Redirigiendo a Google...';
try {
const error = await this.auth.signInWithGoogle();
if (error) {
this.error = error;
return;
}
} catch {
this.error = 'No se pudo iniciar sesión. Intenta de nuevo.';
} finally {
this.loading = false;
this.cdr.detectChanges();
}
} }
} }
@@ -36,4 +36,4 @@ export const usuarioRowSchema = z.object({
}); });
export type UsuarioRow = z.infer<typeof usuarioRowSchema>; export type UsuarioRow = z.infer<typeof usuarioRowSchema>;
export const USUARIO_MAX_ROWS = 50; export const USUARIO_MAX_ROWS = 300;
@@ -1,6 +1,10 @@
export const environment = { export const environment = {
production: true, production: true,
n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino', n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino',
orsBaseUrl: 'http://localhost:18080/ors/v2', orsBaseUrl: 'http://localhost:18080/ors/v2',
requestTimeoutMs: 30000, orsMexicoBaseUrl: 'http://localhost:18081/ors/v2',
requestTimeoutMs: 180000,
supabaseUrl: 'https://dbit.digitalcompass.agency',
userProfileWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/19486044-4dbd-4751-87ae-f6664148bb8a',
supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE',
}; };
@@ -1,6 +1,10 @@
export const environment = { export const environment = {
production: false, production: false,
n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino', n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino',
orsBaseUrl: 'http://localhost:18080/ors/v2', orsBaseUrl: 'http://localhost:18080/ors/v2',
requestTimeoutMs: 30000, orsMexicoBaseUrl: 'http://localhost:18081/ors/v2',
requestTimeoutMs: 180000,
supabaseUrl: 'https://dbit.digitalcompass.agency',
userProfileWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/19486044-4dbd-4751-87ae-f6664148bb8a',
supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE',
}; };
+6
View File
@@ -40,6 +40,12 @@
text-align: center; text-align: center;
} }
/* leaflet.heat creates its canvas in the map overlay pane, outside Angular styles. */
.leaflet-heatmap-layer {
z-index: 700 !important;
pointer-events: none;
}
:root { :root {
--glm-verde: #6CC24A; --glm-verde: #6CC24A;
--glm-verde-claro: #A4D65E; --glm-verde-claro: #A4D65E;
+36
View File
@@ -0,0 +1,36 @@
alter table public.rutero_empleados
add column if not exists jornada_codigo text not null default 'LUN_VIE_40',
add column if not exists horas_descanso_por_dia numeric(4,2) not null default 1,
add column if not exists dias_descanso jsonb not null default '[]'::jsonb;
alter table public.rutero_empleados
add constraint rutero_empleados_jornada_valida
check (jornada_codigo in ('LUN_DOM_44', 'LUN_SAB_40', 'LUN_SAB_44', 'LUN_VIE_40')) not valid;
alter table public.rutero_empleados
add constraint rutero_empleados_descanso_valido
check (horas_descanso_por_dia >= 0 and horas_descanso_por_dia < 24) not valid;
alter table public.rutero_empleados enable row level security;
drop policy if exists "Solo GomezLee puede consultar empleados" on public.rutero_empleados;
drop policy if exists "Solo GomezLee puede crear empleados" on public.rutero_empleados;
drop policy if exists "Solo GomezLee puede editar empleados" on public.rutero_empleados;
drop policy if exists "Solo GomezLee puede eliminar empleados" on public.rutero_empleados;
create policy "Solo GomezLee puede consultar empleados"
on public.rutero_empleados for select to authenticated
using (lower(coalesce(auth.jwt() ->> 'email', '')) like '%@gomezleemarketing.com');
create policy "Solo GomezLee puede crear empleados"
on public.rutero_empleados for insert to authenticated
with check (lower(coalesce(auth.jwt() ->> 'email', '')) like '%@gomezleemarketing.com');
create policy "Solo GomezLee puede editar empleados"
on public.rutero_empleados for update to authenticated
using (lower(coalesce(auth.jwt() ->> 'email', '')) like '%@gomezleemarketing.com')
with check (lower(coalesce(auth.jwt() ->> 'email', '')) like '%@gomezleemarketing.com');
create policy "Solo GomezLee puede eliminar empleados"
on public.rutero_empleados for delete to authenticated
using (lower(coalesce(auth.jwt() ->> 'email', '')) like '%@gomezleemarketing.com');