feat: creacion de proyectos, gestor de empleados #6

Merged
EidanThen merged 1 commits from feat/proyecto-pdvs into dev 2026-09-17 19:06:25 +00:00
44 changed files with 919 additions and 125 deletions
Showing only changes of commit 0736db2b5c - Show all commits
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
+7 -1
View File
@@ -5,11 +5,17 @@ import { AssignedRouteDetailComponent } from './private/home/assigned-route-deta
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 { authGuard } from './core/services/auth.guard';
import { EmployeesComponent } from './private/employees/employees.component'; 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', canActivate: [authGuard] }, { path: 'home', component: ProjectListComponent, pathMatch: 'full', canActivate: [authGuard] },
{ path: 'proyectos/nuevo', component: NewProjectComponent, canActivate: [authGuard] },
{ 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: 'empleados', component: EmployeesComponent, canActivate: [authGuard] },
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent, canActivate: [authGuard] }, { path: 'home/assigned-route', component: AssignedRouteDetailComponent, canActivate: [authGuard] },
{ path: 'home/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] }, { path: 'home/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] },
@@ -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;
}
@@ -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
} }
}; };
@@ -1,12 +1,14 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Session } from '@supabase/supabase-js'; import { Session } from '@supabase/supabase-js';
import { supabaseClient } from './supabase.client'; import { supabaseClient } from './supabase.client';
import { UserProfileService } from './user-profile.service';
const CORPORATE_DOMAIN = '@gomezleemarketing.com'; const CORPORATE_DOMAIN = '@gomezleemarketing.com';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthService { export class AuthService {
private readonly client = supabaseClient; private readonly client = supabaseClient;
constructor(private profile: UserProfileService) {}
async signIn(email: string, password: string): Promise<string | null> { async signIn(email: string, password: string): Promise<string | null> {
const normalizedEmail = email.trim().toLowerCase(); const normalizedEmail = email.trim().toLowerCase();
@@ -20,7 +22,9 @@ export class AuthService {
async signInWithGoogle(): Promise<string | null> { async signInWithGoogle(): Promise<string | null> {
const { error } = await this.client.auth.signInWithOAuth({ const { error } = await this.client.auth.signInWithOAuth({
provider: 'google', provider: 'google',
options: { redirectTo: `${window.location.origin}/home` } // 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; return error?.message ?? null;
} }
@@ -33,5 +37,6 @@ export class AuthService {
async signOut(): Promise<void> { async signOut(): Promise<void> {
await this.client.auth.signOut(); await this.client.auth.signOut();
this.profile.clear();
} }
} }
@@ -43,11 +43,18 @@ export class EmployeeService {
if (error) throw error; if (error) throw error;
} }
async activeEmployeesFile(): Promise<File> { async active(country = ''): Promise<Employee[]> {
const employees = (await this.list()).filter(employee => employee.activo); 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 => ({ const rows = employees.map(employee => ({
ID: employee.employee_code, ID: employee.employee_code,
'Nombre del empleado': employee.nombre, 'Nombre del empleado': employee.nombre,
'País': employee.pais,
Regional: employee.regional,
Latitud: employee.latitud, Latitud: employee.latitud,
Longitud: employee.longitud, Longitud: employee.longitud,
'Horas por día usuario': employee.horas_por_dia, 'Horas por día usuario': employee.horas_por_dia,
@@ -63,4 +70,11 @@ export class EmployeeService {
const data = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); const data = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
return new File([data], 'empleados_activos.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); 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,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);
}
}
}
@@ -1,4 +1,5 @@
.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; } .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; } .employees-topbar img { height: 38px; width: auto; }
.topbar-actions { display: flex; gap: 8px; margin-left: 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, .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; }
@@ -1,7 +1,7 @@
<div class="employees-topbar"> <div class="employees-topbar">
<img src="GLM_completo.png" alt="GomezLee Marketing" /> <img src="GLM_completo.png" alt="GomezLee Marketing" />
<span>Route Planner</span> <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)="goHome()"><i class="pi pi-home"></i> Planificación</button><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></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> </div>
<main class="employees-page"> <main class="employees-page">
@@ -2,9 +2,11 @@ import { ChangeDetectorRef, Component, OnDestroy, 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 { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Location } from '@angular/common';
import { Employee, EmployeeInput } from '../../core/models/employee.model'; import { Employee, EmployeeInput } from '../../core/models/employee.model';
import { EmployeeService } from '../../core/services/employee.service'; import { EmployeeService } from '../../core/services/employee.service';
import { AuthService } from '../../core/services/auth.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 { WORK_SCHEDULES, workSchedule } from '../../core/models/work-schedule.model';
import { normalizeRow, cleanNum } from '../../shared/utils/header-normalization.utils'; import { normalizeRow, cleanNum } from '../../shared/utils/header-normalization.utils';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
@@ -36,6 +38,8 @@ export class EmployeesComponent implements OnDestroy, OnInit {
private employeesService: EmployeeService, private employeesService: EmployeeService,
private auth: AuthService, private auth: AuthService,
private router: Router, private router: Router,
private location: Location,
public profile: UserProfileService,
private cdr: ChangeDetectorRef private cdr: ChangeDetectorRef
) {} ) {}
@@ -225,6 +229,11 @@ export class EmployeesComponent implements OnDestroy, OnInit {
await this.router.navigate(['/home']); 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 { private nullable(value: string | null): string | null {
return value?.trim() || null; return value?.trim() || null;
} }
@@ -257,7 +266,7 @@ export class EmployeesComponent implements OnDestroy, OnInit {
email: this.nullable(String(row['email'] ?? '')), email: this.nullable(String(row['email'] ?? '')),
perfil: this.nullable(String(row['perfil_de_acceso'] ?? '')), perfil: this.nullable(String(row['perfil_de_acceso'] ?? '')),
pais: this.nullable(String(row['pais'] ?? '')), pais: this.nullable(String(row['pais'] ?? '')),
regional: this.nullable(String(row['regionales'] ?? '')), regional: this.nullable(String(row['regionales'] ?? row['regional'] ?? '')),
latitud: latitude, latitud: latitude,
longitud: longitude, longitud: longitude,
horas_por_dia: dailyHours, horas_por_dia: dailyHours,
@@ -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 }],
@@ -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;
@@ -1,31 +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">
<div><h1>Route Planner</h1><p>Gestión de rutas asignadas y puntos de venta pendientes</p></div> <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>
<a class="employees-link" routerLink="/empleados"><i class="pi pi-users"></i> Gestionar empleados</a>
</header> </header>
<section class="card upload-card"> <section class="card upload-card">
<h2 class="section-title">Subir PDVs 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 se usarán para planificar.</span></p> <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"
/> />
@@ -10,8 +10,11 @@ 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 { RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { EmployeeService } from '../../core/services/employee.service'; 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,
@@ -31,6 +34,12 @@ export class HomeComponent implements OnInit {
employeesLoading = true; 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(
@@ -39,17 +48,60 @@ export class HomeComponent implements OnInit {
private previewService: ExcelPreviewService, private previewService: ExcelPreviewService,
private planningInput: PlanningInputService, private planningInput: PlanningInputService,
private employeesService: EmployeeService, 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 { ngOnInit(): void {
void this.loadEmployees(); 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> { private async loadEmployees(): Promise<void> {
this.employeesLoading = true; this.employeesLoading = true;
try { try {
this.activeEmployees = (await this.employeesService.list()).filter(employee => employee.activo).length; this.activeEmployees = (await this.employeesService.active(this.projectCountry)).length;
} catch { } catch {
this.activeEmployees = 0; this.activeEmployees = 0;
} finally { } finally {
@@ -190,7 +242,7 @@ export class HomeComponent implements OnInit {
} }
let employeesFile: File; let employeesFile: File;
try { try {
employeesFile = await this.employeesService.activeEmployeesFile(); employeesFile = await this.employeesService.activeEmployeesFile(this.projectCountry);
} catch { } catch {
this.uploadError = 'No se pudieron obtener los empleados activos.'; this.uploadError = 'No se pudieron obtener los empleados activos.';
this.routeState.setLoading(false); this.routeState.setLoading(false);
@@ -202,6 +254,7 @@ export class HomeComponent implements OnInit {
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', employeesFile, employeesFile.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({
@@ -247,7 +300,7 @@ export class HomeComponent implements OnInit {
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;
@@ -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);
@@ -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>
@@ -96,6 +96,14 @@ 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[] { get uniqueFilteredRoutes(): RouteAssignedResponse[] {
const unique = new Map<string, RouteAssignedResponse>(); const unique = new Map<string, RouteAssignedResponse>();
@@ -140,7 +148,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} }
get dailyGroups(): DailyRouteGroup[] { get dailyGroups(): DailyRouteGroup[] {
const order = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes']; const order = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes', 'Sábado', 'Sabado'];
const groups = new Map<string, DailyRouteSummary[]>(); const groups = new Map<string, DailyRouteSummary[]>();
for (const summary of this.dailySummaries) { for (const summary of this.dailySummaries) {
const routes = groups.get(summary.day) ?? []; const routes = groups.get(summary.day) ?? [];
@@ -289,7 +297,14 @@ 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');
const heatPane = this.map.createPane('heatPane'); const heatPane = this.map.createPane('heatPane');
pdvPane.style.zIndex = '750'; pdvPane.style.zIndex = '750';
@@ -297,7 +312,11 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
const popupPane = this.map.getPane('popupPane'); const popupPane = this.map.getPane('popupPane');
if (popupPane) popupPane.style.zIndex = '1000'; 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);
@@ -336,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;
} }
@@ -414,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();
@@ -428,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) {
@@ -452,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();
@@ -950,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; }
@@ -15,7 +15,7 @@
<button type="button" class="google-login" (click)="loginWithGoogle()" [disabled]="loading"> <button type="button" class="google-login" (click)="loginWithGoogle()" [disabled]="loading">
<span class="google-mark">G</span> <span class="google-mark">G</span>
<span *ngIf="!loading">Continuar con Google</span> <span *ngIf="!loading">Continuar con Google</span>
<span *ngIf="loading">Redirigiendo a Google...</span> <span *ngIf="loading">{{ loadingText }}</span>
</button> </button>
<p class="domain-note">Solo cuentas @gomezleemarketing.com</p> <p class="domain-note">Solo cuentas @gomezleemarketing.com</p>
<p class="login-error" *ngIf="error">{{ error }}</p> <p class="login-error" *ngIf="error">{{ error }}</p>
@@ -2,6 +2,7 @@ import { Component, ChangeDetectionStrategy, ChangeDetectorRef, OnInit } from '@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthService } from '../../core/services/auth.service'; import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
@Component({ @Component({
standalone: true, standalone: true,
@@ -15,16 +16,31 @@ export class LoginComponent implements OnInit {
loading = false; loading = false;
error = ''; error = '';
loadingText = 'Redirigiendo a Google...';
constructor(private router: Router, private auth: AuthService, private cdr: ChangeDetectorRef) {} constructor(private router: Router, private auth: AuthService, private profile: UserProfileService, private cdr: ChangeDetectorRef) {}
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
if (await this.auth.getSession()) await this.router.navigate(['/home']); const session = await this.auth.getSession();
if (!session) return;
this.loading = true;
this.loadingText = 'Obteniendo datos del usuario...';
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.cdr.detectChanges();
}
} }
async loginWithGoogle() { async loginWithGoogle() {
this.error = ''; this.error = '';
this.loading = true; this.loading = true;
this.loadingText = 'Redirigiendo a Google...';
try { try {
const error = await this.auth.signInWithGoogle(); const error = await this.auth.signInWithGoogle();
if (error) { if (error) {
@@ -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,8 +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', 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', supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE',
}; };
@@ -1,8 +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', 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', supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE',
}; };