feat: creacion de proyectos, gestor de empleados

This commit is contained in:
2026-09-17 15:04:52 -04:00
parent 0c652a0bd7
commit 0736db2b5c
44 changed files with 919 additions and 125 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
# OpenRouteService por país
- República Dominicana: `http://localhost:18080/ors/v2`
- México: `http://localhost:18081/ors/v2`
Cada país mantiene su propio archivo OSM, configuración y directorio de grafos. Desde contenedores Docker, como n8n, usa `host.docker.internal` en lugar de `localhost`.
El workflow selecciona el servicio según la columna `País` del archivo PDV. `MEXICO` usa `18081`; cualquier proyecto anterior sin país se mantiene en República Dominicana y usa `18080`.
La imagen ORS configura memoria con `XMS`, `XMX` y `ADDITIONAL_JAVA_OPTS`; no se debe sobrescribir `JAVA_OPTS`, porque el entrypoint de la imagen lo reconstruye.
```powershell
docker compose -f ors-app/republica-dominicana/docker-compose.yml up -d
docker compose -f ors-app/mexico/docker-compose.yml up -d
```
+16
View File
@@ -0,0 +1,16 @@
services:
ors:
image: openrouteservice/openrouteservice:latest
container_name: ors-mexico
ports:
- "18081:8082"
volumes:
- ./ors-docker/mexico-latest.osm.pbf:/home/ors/files/mexico-latest.osm.pbf
- ./ors-docker/graphs:/home/ors/graphs
- ./ors-docker/elevation_cache:/home/ors/elevation_cache
- ./ors-docker/config/ors-config.yml:/home/ors/config/ors-config.yml
environment:
- XMS=4g
- XMX=12g
- ADDITIONAL_JAVA_OPTS=-XX:MaxGCPauseMillis=300 -XX:InitiatingHeapOccupancyPercent=75
restart: unless-stopped
@@ -0,0 +1,17 @@
ors:
cors:
allowed_origins: "*"
allowed_headers: Content-Type, X-Requested-With, accept, Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Authorization
preflight_max_age: 600
engine:
profile_default:
enabled: false
graph_path: graphs
build:
source_file: /home/ors/files/mexico-latest.osm.pbf
elevation: false
service:
maximum_distance: 500000
profiles:
driving-car:
enabled: true
@@ -1,7 +1,7 @@
services:
ors:
image: openrouteservice/openrouteservice:latest
container_name: ors-route-planner
container_name: ors-republica-dominicana
ports:
- "18080:8082"
volumes:
@@ -10,5 +10,7 @@ services:
- ./ors-docker/elevation_cache:/home/ors/elevation_cache
- ./ors-docker/config/ors-config.yml:/home/ors/config/ors-config.yml
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
+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 { authGuard } from './core/services/auth.guard';
import { EmployeesComponent } from './private/employees/employees.component';
import { ProjectListComponent } from './private/projects/project-list.component';
import { NewProjectComponent } from './private/projects/new-project.component';
export const routes: Routes = [
{ path: '', redirectTo: 'login', pathMatch: 'full' },
{ 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: 'home/assigned-route', component: AssignedRouteDetailComponent, 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;
pdv_id: 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 {
pdv_id: number;
@@ -35,4 +52,11 @@ export interface RouteAssignedJson {
latitud: number,
longitud: number,
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_ORIGINAL = 'rp_no_assigned_routes_original';
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 {
userId: string;
day: string;
@@ -65,6 +65,12 @@ export class RouteState {
nombre_usuario: profile.json.nombre_usuario,
latitud_usuario: profile.json.latitud_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,
dia: day,
distancia: metrics?.distance ?? item.json.distancia,
@@ -98,7 +104,20 @@ export class RouteState {
nombre_usuario: profile.json.nombre_usuario,
nombre_pdv: route.pdv_nombre,
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;
});
if (newRoutes.some(item => !item)) return;
@@ -195,6 +214,12 @@ export class RouteState {
nombre_usuario: user.json.nombre_usuario,
latitud_usuario: user.json.latitud_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
}
};
@@ -1,12 +1,14 @@
import { Injectable } from '@angular/core';
import { Session } from '@supabase/supabase-js';
import { supabaseClient } from './supabase.client';
import { UserProfileService } from './user-profile.service';
const CORPORATE_DOMAIN = '@gomezleemarketing.com';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly client = supabaseClient;
constructor(private profile: UserProfileService) {}
async signIn(email: string, password: string): Promise<string | null> {
const normalizedEmail = email.trim().toLowerCase();
@@ -20,7 +22,9 @@ export class AuthService {
async signInWithGoogle(): Promise<string | null> {
const { error } = await this.client.auth.signInWithOAuth({
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;
}
@@ -33,5 +37,6 @@ export class AuthService {
async signOut(): Promise<void> {
await this.client.auth.signOut();
this.profile.clear();
}
}
@@ -43,11 +43,18 @@ export class EmployeeService {
if (error) throw error;
}
async activeEmployeesFile(): Promise<File> {
const employees = (await this.list()).filter(employee => employee.activo);
async active(country = ''): Promise<Employee[]> {
const expectedCountry = this.normalizeCountry(country);
return (await this.list()).filter(employee => employee.activo && (!expectedCountry || this.normalizeCountry(employee.pais ?? '') === expectedCountry));
}
async activeEmployeesFile(country = ''): Promise<File> {
const employees = await this.active(country);
const rows = employees.map(employee => ({
ID: employee.employee_code,
'Nombre del empleado': employee.nombre,
'País': employee.pais,
Regional: employee.regional,
Latitud: employee.latitud,
Longitud: employee.longitud,
'Horas por día usuario': employee.horas_por_dia,
@@ -63,4 +70,11 @@ export class EmployeeService {
const data = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
return new File([data], 'empleados_activos.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}
private normalizeCountry(country: string): string {
const normalized = country.normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().toUpperCase();
if (normalized.includes('MEXICO')) return 'MEXICO';
if (normalized.includes('DOMINICANA')) return 'REPUBLICA DOMINICANA';
return normalized;
}
}
@@ -16,8 +16,6 @@ import {
import { takeUntil } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
const ENDPOINT = `${environment.orsBaseUrl}/directions/driving-car`;
const ENDPOINTJSON = `${environment.orsBaseUrl}/directions/driving-car/geojson`;
const MAX_CONCURRENT_REQUESTS = 2;
@Injectable({
@@ -41,8 +39,12 @@ export class OrsService {
const body = {
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),
catchError(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(
pares: {
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),
catchError((err: HttpErrorResponse | Error) => {
if (err instanceof Error && err.name === 'TimeoutError') {
return throwError(() => Object.assign(new Error('Timeout al calcular rutas (30s). Intenta con menos filas o reintenta.'), { name: 'TimeoutError' }));
return throwError(() => Object.assign(new Error(`Timeout al calcular rutas (${environment.requestTimeoutMs / 1000}s). Intenta con menos filas o reintenta.`), { name: 'TimeoutError' }));
}
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; }
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
.employees-topbar img { height: 38px; width: auto; }
.topbar-actions { display: flex; gap: 8px; margin-left: auto; }
.topbar-actions button, .table-header button, .form-actions button, .row-actions button { border: 1px solid var(--glm-gris-medio); border-radius: 4px; background: #fff; color: var(--glm-azul); cursor: pointer; font: inherit; font-size: .78rem; font-weight: 600; padding: 7px 10px; }
@@ -1,7 +1,7 @@
<div class="employees-topbar">
<img src="GLM_completo.png" alt="GomezLee Marketing" />
<span>Route Planner</span>
<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>
<span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div>
<div class="topbar-actions"><button type="button" (click)="goBack()"><i class="pi pi-arrow-left"></i> Volver</button><button type="button" (click)="goHome()"><i class="pi pi-home"></i> Proyectos</button><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
</div>
<main class="employees-page">
@@ -2,9 +2,11 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Location } from '@angular/common';
import { Employee, EmployeeInput } from '../../core/models/employee.model';
import { EmployeeService } from '../../core/services/employee.service';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
import { WORK_SCHEDULES, workSchedule } from '../../core/models/work-schedule.model';
import { normalizeRow, cleanNum } from '../../shared/utils/header-normalization.utils';
import * as XLSX from 'xlsx';
@@ -36,6 +38,8 @@ export class EmployeesComponent implements OnDestroy, OnInit {
private employeesService: EmployeeService,
private auth: AuthService,
private router: Router,
private location: Location,
public profile: UserProfileService,
private cdr: ChangeDetectorRef
) {}
@@ -225,6 +229,11 @@ export class EmployeesComponent implements OnDestroy, OnInit {
await this.router.navigate(['/home']);
}
goBack(): void {
if (history.length > 1) this.location.back();
else void this.router.navigate(['/home']);
}
private nullable(value: string | null): string | null {
return value?.trim() || null;
}
@@ -257,7 +266,7 @@ export class EmployeesComponent implements OnDestroy, OnInit {
email: this.nullable(String(row['email'] ?? '')),
perfil: this.nullable(String(row['perfil_de_acceso'] ?? '')),
pais: this.nullable(String(row['pais'] ?? '')),
regional: this.nullable(String(row['regionales'] ?? '')),
regional: this.nullable(String(row['regionales'] ?? row['regional'] ?? '')),
latitud: latitude,
longitud: longitude,
horas_por_dia: dailyHours,
@@ -99,12 +99,26 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
}
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');
pdvPane.style.zIndex = '650';
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);
}
@@ -127,6 +141,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
this.centerMapOnPoints(origin, destination);
if (json.geojson) {
this.showRouteOnMap(json.geojson, origin, destination, json, this.segmentOriginName);
this.isLoading = false;
this.cdr.detectChanges();
return;
}
try {
const results = await this.orsService.processPairs(
[{ pdv_id: json.pdv_id, origin, destination }],
@@ -292,6 +313,6 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
}
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>
</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">
<ng-container *ngIf="editingRoute === row; else editButton">
<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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { RouteAssignedResponse } from '../../../core/models/route.model';
import { saveAs } from 'file-saver';
import { Table } from 'primeng/table';
@@ -44,6 +44,7 @@ export class AssignedRouteComponent {
private routesState: RouteState,
private cdr: ChangeDetectorRef,
private router: Router,
private activatedRoute: ActivatedRoute,
private rutasService: RutasService,
private planningInput: PlanningInputService
) { }
@@ -79,7 +80,7 @@ export class AssignedRouteComponent {
}
onShowMap(row: RouteAssignedResponse) {
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 }
});
}
@@ -88,18 +89,20 @@ export class AssignedRouteComponent {
// If table has filtered view, use first visible row's usuario as default; else first assigned
const target = (this.table?.filteredValue?.[0] as RouteAssignedResponse | undefined) ?? this.assignedRoutes[0];
if (target) {
this.router.navigate(['/home/mapa'], {
this.router.navigate(['/proyectos', this.projectId, 'mapa'], {
queryParams: { usuario_id: target.json.usuario_id, dia: 'Todos' }
});
} else {
this.router.navigate(['/home/mapa']);
this.router.navigate(['/proyectos', this.projectId, 'mapa']);
}
}
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 }[] {
const users = new Map<string, string>();
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);
}
.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 {
max-width: 1000px;
margin: 0 auto;
@@ -1,31 +1,31 @@
<div class="glm-topbar">
<img src="GLM_completo.png" alt="GomezLee Marketing" class="glm-topbar-logo" />
<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 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">
<div><h1>Route Planner</h1><p>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>
<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>
</header>
<section class="card upload-card">
<h2 class="section-title">Subir PDVs para optimización</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>
<h2 class="section-title">PDVs del proyecto</h2>
<p class="employees-summary" [class.empty]="!employeesLoading && !activeEmployees"><i class="pi pi-users"></i> <span *ngIf="employeesLoading">Cargando empleados activos...</span><span *ngIf="!employeesLoading">{{ activeEmployees }} empleados activos de {{ projectCountry }} se usarán para planificar.</span></p>
<div class="upload-grid">
<div class="file-input-wrapper">
<label class="file-label" for="pdvFile" [class.has-error]="pdvPreview && !pdvPreview.valid" [class.has-success]="pdvPreview?.valid">
<i class="pi pi-file-excel"></i>
<span>Archivo PDVs (Excel)</span>
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 2000 filas</span>
<span>Archivo PDVs guardado</span>
<span class="file-hint">Los PDVs pertenecen a este proyecto</span>
</label>
<input
id="pdvFile"
type="file"
accept=".xlsx,.xls"
(change)="onPdvFileChange($event)"
disabled
class="file-input"
[class.has-file]="pdvFile"
/>
@@ -10,8 +10,11 @@ import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-
import { Subscription } from 'rxjs';
import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component';
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 { ProjectService } from '../../core/services/project.service';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
@Component({
standalone: true,
@@ -31,6 +34,12 @@ export class HomeComponent implements OnInit {
employeesLoading = true;
uploadError: string | null = null;
isLoading = false;
projectName = '';
projectId = '';
editingProject = false;
projectDescription = '';
projectCountry = '';
projectSaving = false;
private calcSub: Subscription | null = null;
constructor(
@@ -39,17 +48,60 @@ export class HomeComponent implements OnInit {
private previewService: ExcelPreviewService,
private planningInput: PlanningInputService,
private employeesService: EmployeeService,
private projectsService: ProjectService,
private activatedRoute: ActivatedRoute,
private auth: AuthService,
private router: Router,
public profile: UserProfileService,
private cdr: ChangeDetectorRef
) {}
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> {
this.employeesLoading = true;
try {
this.activeEmployees = (await this.employeesService.list()).filter(employee => employee.activo).length;
this.activeEmployees = (await this.employeesService.active(this.projectCountry)).length;
} catch {
this.activeEmployees = 0;
} finally {
@@ -190,7 +242,7 @@ export class HomeComponent implements OnInit {
}
let employeesFile: File;
try {
employeesFile = await this.employeesService.activeEmployeesFile();
employeesFile = await this.employeesService.activeEmployeesFile(this.projectCountry);
} catch {
this.uploadError = 'No se pudieron obtener los empleados activos.';
this.routeState.setLoading(false);
@@ -202,6 +254,7 @@ export class HomeComponent implements OnInit {
const formData = new FormData();
formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name);
formData.append('usuarios_file', employeesFile, employeesFile.name);
formData.append('pais', this.projectCountry);
this.calcSub?.unsubscribe();
this.calcSub = this.rutasService.calcularRutas(formData).subscribe({
@@ -247,7 +300,7 @@ export class HomeComponent implements OnInit {
private mapBackendError(err: unknown): string {
const anyErr = err as { error?: { error?: string; message?: string }; message?: string; status?: number; name?: string };
if (anyErr?.name === 'TimeoutError') return 'El cálculo tardó demasiado (timeout 30s). Intenta de nuevo con menos filas.';
if (anyErr?.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?.message) return anyErr.error.message;
if (anyErr?.message) return anyErr.message;
@@ -77,6 +77,9 @@
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 {
background: var(--glm-verde-bg);
border: 1px solid var(--glm-verde-claro);
@@ -16,6 +16,7 @@
</button>
<h2 class="sidebar-title">Mapa de rutas</h2>
</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">
<label class="filter-label">Usuario</label>
@@ -96,6 +96,14 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
reassignDay = '';
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
get uniqueFilteredRoutes(): RouteAssignedResponse[] {
const unique = new Map<string, RouteAssignedResponse>();
@@ -140,7 +148,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
}
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[]>();
for (const summary of this.dailySummaries) {
const routes = groups.get(summary.day) ?? [];
@@ -289,7 +297,14 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
}
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 heatPane = this.map.createPane('heatPane');
pdvPane.style.zIndex = '750';
@@ -297,7 +312,11 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
const popupPane = this.map.getPane('popupPane');
if (popupPane) popupPane.style.zIndex = '1000';
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);
// Fix tiles after sidebar overlay
setTimeout(() => this.map?.invalidateSize(), 200);
@@ -336,6 +355,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
this.orsProgress = 0;
this.orsTotal = 0;
if (!this.filteredRoutes.length) {
this.renderUnassignedMarkers();
const territoryBounds = this.projectBounds();
if (territoryBounds) this.map.fitBounds(territoryBounds.pad(0.15));
this.cdr.detectChanges();
return;
}
@@ -414,6 +436,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
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.toggleLayerVisibility();
@@ -428,6 +456,25 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
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 {
if (!this.map || this.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return;
for (const route of this.unassignedRoutes) {
@@ -452,15 +499,20 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
this.orsProgress = 0;
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,
origin: route.origin,
destination: route.destination
}));
const { promise } = this.orsService.processPairsWithProgress(pares, 2, true, this.abort$);
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) {
this.isLoading = false;
this.cdr.detectChanges();
@@ -950,6 +1002,6 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
}
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">
<span class="google-mark">G</span>
<span *ngIf="!loading">Continuar con Google</span>
<span *ngIf="loading">Redirigiendo a Google...</span>
<span *ngIf="loading">{{ loadingText }}</span>
</button>
<p class="domain-note">Solo cuentas @gomezleemarketing.com</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 { Router } from '@angular/router';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
@Component({
standalone: true,
@@ -15,16 +16,31 @@ export class LoginComponent implements OnInit {
loading = false;
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> {
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() {
this.error = '';
this.loading = true;
this.loadingText = 'Redirigiendo a Google...';
try {
const error = await this.auth.signInWithGoogle();
if (error) {
@@ -36,4 +36,4 @@ export const usuarioRowSchema = z.object({
});
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 = {
production: true,
n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino',
n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino',
orsBaseUrl: 'http://localhost:18080/ors/v2',
requestTimeoutMs: 30000,
orsMexicoBaseUrl: 'http://localhost:18081/ors/v2',
requestTimeoutMs: 180000,
supabaseUrl: 'https://dbit.digitalcompass.agency',
userProfileWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/19486044-4dbd-4751-87ae-f6664148bb8a',
supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE',
};
@@ -1,8 +1,10 @@
export const environment = {
production: false,
n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino',
n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino',
orsBaseUrl: 'http://localhost:18080/ors/v2',
requestTimeoutMs: 30000,
orsMexicoBaseUrl: 'http://localhost:18081/ors/v2',
requestTimeoutMs: 180000,
supabaseUrl: 'https://dbit.digitalcompass.agency',
userProfileWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/19486044-4dbd-4751-87ae-f6664148bb8a',
supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE',
};