Compare commits
2 Commits
0c652a0bd7
...
23e01f398b
| Author | SHA1 | Date | |
|---|---|---|---|
| 23e01f398b | |||
| 0736db2b5c |
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
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
|||||||
+24
-3
@@ -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);
|
||||||
|
|||||||
+1
@@ -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>
|
||||||
|
|||||||
+59
-7
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user