Compare commits
7 Commits
main
..
23e01f398b
| Author | SHA1 | Date | |
|---|---|---|---|
| 23e01f398b | |||
| 0736db2b5c | |||
| 0c652a0bd7 | |||
| c45182012c | |||
| bb6757b492 | |||
| 8f34462876 | |||
| 540bc46fc2 |
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:
|
||||
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
|
||||
@@ -30,7 +30,7 @@
|
||||
"production": {
|
||||
"budgets": [
|
||||
{ "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" },
|
||||
{ "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "10kB" }
|
||||
{ "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "12kB" }
|
||||
],
|
||||
"outputHashing": "all",
|
||||
"fileReplacements": [
|
||||
|
||||
Generated
+102
@@ -15,6 +15,7 @@
|
||||
"@angular/platform-browser": "^21.1.0",
|
||||
"@angular/router": "^21.1.0",
|
||||
"@primeng/themes": "^21.0.0",
|
||||
"@supabase/supabase-js": "^2.115.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
@@ -4037,6 +4038,98 @@
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.115.0.tgz",
|
||||
"integrity": "sha512-YNQlQWm1H0gsXHSY8Jd/xepBhjO0Zhwx04iW17A83/joQ5kFiUin6iPj9s9kZZvupnwLjXVP/diTFiLz2jUbwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.115.0.tgz",
|
||||
"integrity": "sha512-p97V6/YFcdp+zblFDVJaE8f9rGKTNz0PRzyJ2d1w/EYIU5lwidKuc3l/wM+u27ACmAC62albvoGiUGzLPSg7Aw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/phoenix": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
|
||||
"integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.115.0.tgz",
|
||||
"integrity": "sha512-DdERcurLh5t84pgSywDg2LjLR5le7XAzl52/iQhC7FdboWDqGfDPMfaWJV3MHvhyxSlSNzbpSQG+RiQOBSn/4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.115.0.tgz",
|
||||
"integrity": "sha512-5HyBkvlA/IUV2v8jX3uLTdy15jmTPRVXwXKoxvH2SKw5jZMNURxCxt+VpCEukscXQlY7pUpH8Cy4NH6io4iaRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/phoenix": "0.4.5",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.115.0.tgz",
|
||||
"integrity": "sha512-dLyIxzbO+MCcKHhcce8rVUCQX1iyqXqQ8ytgkOVYJ7D+Zp0qKylPtQH3hamgxrGSxtDjaw47Urpzw2iK9PsKdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iceberg-js": "^0.8.1",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.115.0.tgz",
|
||||
"integrity": "sha512-PYJSxtCo37R7tTZW6pAqsxUeSx/dlhA7zn8RzKEUSCqyTxCUhG+iHrDTb04UyVBYbttaUbhwkNTvb8h5HW+uZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.115.0",
|
||||
"@supabase/functions-js": "2.115.0",
|
||||
"@supabase/postgrest-js": "2.115.0",
|
||||
"@supabase/realtime-js": "2.115.0",
|
||||
"@supabase/storage-js": "2.115.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tufjs/canonical-json": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
|
||||
@@ -5888,6 +5981,15 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iceberg-js": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@angular/platform-browser": "^21.1.0",
|
||||
"@angular/router": "^21.1.0",
|
||||
"@primeng/themes": "^21.0.0",
|
||||
"@supabase/supabase-js": "^2.115.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
|
||||
@@ -3,12 +3,21 @@ import { LoginComponent } from './public/login/login.component';
|
||||
import { HomeComponent } from './private/home/home.component';
|
||||
import { AssignedRouteDetailComponent } from './private/home/assigned-route-detail/assigned-route-detail.component';
|
||||
import { RouteOverviewMapComponent } from './private/home/route-overview-map/route-overview-map.component';
|
||||
import { 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' },
|
||||
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent },
|
||||
{ path: 'home/mapa', component: RouteOverviewMapComponent },
|
||||
{ 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] },
|
||||
{ path: '**', redirectTo: 'login' }
|
||||
];
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface Employee {
|
||||
id: string;
|
||||
employee_code: string;
|
||||
nombre: string;
|
||||
usuario: string | null;
|
||||
email: string | null;
|
||||
perfil: string | null;
|
||||
pais: string | null;
|
||||
regional: string | null;
|
||||
latitud: number;
|
||||
longitud: number;
|
||||
horas_por_dia: number;
|
||||
dias_por_semana: number;
|
||||
jornada_codigo: string;
|
||||
horas_descanso_por_dia: number;
|
||||
dias_descanso: string[];
|
||||
visitas_maximas_por_dia: number;
|
||||
activo: boolean;
|
||||
}
|
||||
|
||||
export type EmployeeInput = Omit<Employee, 'id'>;
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface RuteroProyecto {
|
||||
id: string;
|
||||
nombre: string;
|
||||
descripcion: string | null;
|
||||
propietario_id: string;
|
||||
estado: 'borrador' | 'optimizado' | 'archivado';
|
||||
version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RuteroProyectoPdv {
|
||||
id: string;
|
||||
proyecto_id: string;
|
||||
pdv_id: string;
|
||||
pdv_data: Record<string, unknown>;
|
||||
}
|
||||
@@ -27,6 +27,23 @@ export interface RouteAssignedJson {
|
||||
usuario_id: string;
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface WorkSchedule {
|
||||
code: 'LUN_DOM_44' | 'LUN_SAB_40' | 'LUN_SAB_44' | 'LUN_VIE_40';
|
||||
label: string;
|
||||
weeklyHours: number;
|
||||
days: string[];
|
||||
}
|
||||
|
||||
export const WORK_SCHEDULES: WorkSchedule[] = [
|
||||
{ code: 'LUN_DOM_44', label: 'Lun a Dom - 44h', weeklyHours: 44, days: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado', 'Domingo'] },
|
||||
{ code: 'LUN_SAB_40', label: 'Lun a Sáb - 40h', weeklyHours: 40, days: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'] },
|
||||
{ code: 'LUN_SAB_44', label: 'Lun a Sáb - 44h', weeklyHours: 44, days: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'] },
|
||||
{ code: 'LUN_VIE_40', label: 'Lun a Vie - 40h', weeklyHours: 40, days: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes'] }
|
||||
];
|
||||
|
||||
export function workSchedule(code: string): WorkSchedule {
|
||||
return WORK_SCHEDULES.find(schedule => schedule.code === code) ?? WORK_SCHEDULES[3];
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const STORAGE_ASSIGNED_ORIGINAL = 'rp_assigned_routes_original';
|
||||
const STORAGE_NO_ASSIGNED = 'rp_no_assigned_routes';
|
||||
const STORAGE_NO_ASSIGNED_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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
export const authGuard: CanActivateFn = async () => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
return await auth.getSession() ? true : router.parseUrl('/login');
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Session } from '@supabase/supabase-js';
|
||||
import { supabaseClient } from './supabase.client';
|
||||
import { UserProfileService } from './user-profile.service';
|
||||
|
||||
const CORPORATE_DOMAIN = '@gomezleemarketing.com';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly client = supabaseClient;
|
||||
constructor(private profile: UserProfileService) {}
|
||||
|
||||
async signIn(email: string, password: string): Promise<string | null> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
if (!normalizedEmail.endsWith(CORPORATE_DOMAIN)) {
|
||||
return `Solo se permiten correos ${CORPORATE_DOMAIN}.`;
|
||||
}
|
||||
const { error } = await this.client.auth.signInWithPassword({ email: normalizedEmail, password });
|
||||
return error?.message ?? null;
|
||||
}
|
||||
|
||||
async signInWithGoogle(): Promise<string | null> {
|
||||
const { error } = await this.client.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
// Return to login so the OAuth callback can load the profile from n8n
|
||||
// before private routes are opened.
|
||||
options: { redirectTo: `${window.location.origin}/login` }
|
||||
});
|
||||
return error?.message ?? null;
|
||||
}
|
||||
|
||||
async getSession(): Promise<Session | null> {
|
||||
const { data } = await this.client.auth.getSession();
|
||||
const email = data.session?.user.email?.toLowerCase() ?? '';
|
||||
return email.endsWith(CORPORATE_DOMAIN) ? data.session : null;
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
await this.client.auth.signOut();
|
||||
this.profile.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Employee, EmployeeInput } from '../models/employee.model';
|
||||
import { workSchedule } from '../models/work-schedule.model';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { supabaseClient } from './supabase.client';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EmployeeService {
|
||||
private readonly client = supabaseClient;
|
||||
private readonly table = 'rutero_empleados';
|
||||
|
||||
async list(): Promise<Employee[]> {
|
||||
const { data: sessionData, error: sessionError } = await this.client.auth.getSession();
|
||||
if (sessionError) throw sessionError;
|
||||
if (!sessionData.session) throw new Error('No hay una sesión Supabase activa. Cierra sesión e inicia sesión nuevamente.');
|
||||
const { data, error } = await this.client.from(this.table).select('*').order('nombre');
|
||||
if (error) throw new Error(`Supabase ${error.code ?? 'error'}: ${error.message}`);
|
||||
return (data ?? []).map(row => ({
|
||||
...row,
|
||||
jornada_codigo: row.jornada_codigo ?? 'LUN_VIE_40',
|
||||
horas_descanso_por_dia: Number(row.horas_descanso_por_dia ?? 1),
|
||||
dias_descanso: Array.isArray(row.dias_descanso) ? row.dias_descanso : []
|
||||
})) as Employee[];
|
||||
}
|
||||
|
||||
async create(employee: EmployeeInput): Promise<void> {
|
||||
const { error } = await this.client.from(this.table).insert(employee);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async upsert(employees: EmployeeInput[]): Promise<void> {
|
||||
const { error } = await this.client.from(this.table).upsert(employees, { onConflict: 'employee_code' });
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async update(id: string, employee: EmployeeInput): Promise<void> {
|
||||
const { error } = await this.client.from(this.table).update(employee).eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const { error } = await this.client.from(this.table).delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async active(country = ''): Promise<Employee[]> {
|
||||
const expectedCountry = this.normalizeCountry(country);
|
||||
return (await this.list()).filter(employee => employee.activo && (!expectedCountry || this.normalizeCountry(employee.pais ?? '') === expectedCountry));
|
||||
}
|
||||
|
||||
async activeEmployeesFile(country = ''): Promise<File> {
|
||||
const employees = await this.active(country);
|
||||
const rows = employees.map(employee => ({
|
||||
ID: employee.employee_code,
|
||||
'Nombre del empleado': employee.nombre,
|
||||
'País': employee.pais,
|
||||
Regional: employee.regional,
|
||||
Latitud: employee.latitud,
|
||||
Longitud: employee.longitud,
|
||||
'Horas por día usuario': employee.horas_por_dia,
|
||||
'Horas por semana usuario': workSchedule(employee.jornada_codigo).weeklyHours,
|
||||
'Visitas maximas por dia': employee.visitas_maximas_por_dia,
|
||||
Jornada: employee.jornada_codigo,
|
||||
'Horas descanso por día': employee.horas_descanso_por_dia,
|
||||
'Días descanso': (employee.dias_descanso ?? []).join(', '),
|
||||
'Usuario activo': 'Sí'
|
||||
}));
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), 'Usuarios');
|
||||
const data = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
|
||||
return new File([data], 'empleados_activos.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
}
|
||||
|
||||
private normalizeCountry(country: string): string {
|
||||
const normalized = country.normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().toUpperCase();
|
||||
if (normalized.includes('MEXICO')) return 'MEXICO';
|
||||
if (normalized.includes('DOMINICANA')) return 'REPUBLICA DOMINICANA';
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,6 @@ import {
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { 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,6 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
export const supabaseClient = createClient(environment.supabaseUrl, environment.supabaseAnonKey, {
|
||||
auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true }
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { UserProfile } from '../models/user-profile.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserProfileService {
|
||||
private readonly storageKey = 'rp_user_profile';
|
||||
private profile: UserProfile | null = null;
|
||||
avatarUrl = '';
|
||||
constructor() { this.restore(); }
|
||||
get current(): UserProfile | null { return this.profile; }
|
||||
clear(): void {
|
||||
this.profile = null;
|
||||
this.avatarUrl = '';
|
||||
sessionStorage.removeItem(this.storageKey);
|
||||
}
|
||||
|
||||
async load(email: string, avatarUrl = ''): Promise<UserProfile> {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), 15000);
|
||||
try {
|
||||
const response = await fetch(environment.userProfileWebhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), signal: controller.signal });
|
||||
if (!response.ok) throw new Error(`El webhook respondió con ${response.status}.`);
|
||||
const data = await response.json() as Partial<UserProfile>;
|
||||
if (!data.nombre || !data.pais) throw new Error('El webhook no devolvió nombre y país.');
|
||||
this.profile = { nombre: data.nombre, pais: data.pais };
|
||||
this.avatarUrl = avatarUrl;
|
||||
sessionStorage.setItem(this.storageKey, JSON.stringify({ ...this.profile, avatarUrl }));
|
||||
return this.profile;
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') throw new Error('La consulta de datos del usuario tardó demasiado.');
|
||||
throw error;
|
||||
} finally { window.clearTimeout(timeout); }
|
||||
}
|
||||
|
||||
private restore(): void {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(this.storageKey);
|
||||
if (!stored) return;
|
||||
const data = JSON.parse(stored) as Partial<UserProfile> & { avatarUrl?: string };
|
||||
if (!data.nombre || !data.pais) return;
|
||||
this.profile = { nombre: data.nombre, pais: data.pais };
|
||||
this.avatarUrl = data.avatarUrl ?? '';
|
||||
} catch {
|
||||
sessionStorage.removeItem(this.storageKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.employees-topbar { display: flex; align-items: center; gap: 12px; padding: 9px 24px; border-bottom: 3px solid var(--glm-verde); background: #fff; color: var(--glm-azul); font-weight: 700; }
|
||||
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
|
||||
.employees-topbar img { height: 38px; width: auto; }
|
||||
.topbar-actions { display: flex; gap: 8px; margin-left: auto; }
|
||||
.topbar-actions button, .table-header button, .form-actions button, .row-actions button { border: 1px solid var(--glm-gris-medio); border-radius: 4px; background: #fff; color: var(--glm-azul); cursor: pointer; font: inherit; font-size: .78rem; font-weight: 600; padding: 7px 10px; }
|
||||
.topbar-actions button:hover, .table-header button:hover, .form-actions button:hover, .row-actions button:hover { background: var(--glm-verde-bg); border-color: var(--glm-verde); }
|
||||
.employees-page { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
.employees-page header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; border-bottom: 3px solid var(--glm-verde); padding-bottom: 12px; }
|
||||
.employees-page h1, .employees-page h2 { margin: 0; color: var(--glm-azul); }
|
||||
.employees-page h1 { font-size: 1.45rem; }.employees-page h2 { font-size: 1rem; }
|
||||
.employees-page header p, .weekly-capacity { color: var(--glm-acero); }
|
||||
.import-button { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 1px solid var(--glm-verde); border-radius: 4px; background: var(--glm-verde-bg); color: var(--glm-azul); cursor: pointer; font-size: .82rem; font-weight: 700; }.import-button input { display: none; }.import-message { color: var(--glm-verde); font-weight: 600; }
|
||||
.employee-form-card, .employee-table-card { margin-top: 16px; padding: 18px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: #fff; }
|
||||
.employee-form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.employee-form-grid label { display: flex; flex-direction: column; gap: 4px; color: var(--glm-azul); font-size: .75rem; font-weight: 700; text-transform: uppercase; }
|
||||
.employee-form-grid input, .employee-form-grid select { padding: 8px; border: 1px solid #9aaab3; border-radius: 4px; background: #fff; color: #2f3f48; font: inherit; font-weight: 500; text-transform: none; }
|
||||
.employee-form-grid input::placeholder { color: #71808a; opacity: 1; }
|
||||
.employee-form-grid input:focus, .employee-form-grid select:focus { outline: 2px solid rgba(108, 194, 74, .35); border-color: var(--glm-verde); background: #fff; color: #1f2d34; }
|
||||
.form-actions { display: flex; gap: 8px; }.form-actions button:first-child { background: var(--glm-azul); color: #fff; }.form-error { color: #c9252d; font-size: .82rem; }.table-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }.table-wrap { overflow-x: auto; margin-top: 12px; }table { width: 100%; min-width: 920px; border-collapse: collapse; font-size: .82rem; }th { padding: 9px; background: var(--glm-azul); color: #fff; text-align: left; }td { padding: 9px; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); }.status { color: var(--glm-verde); font-weight: 700; }.status.inactive { color: #c9252d; }.row-actions { display: flex; gap: 5px; }.row-actions .delete { color: #c9252d; }.loading { color: var(--glm-acero); }.rest-days-button { max-width: 155px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.rest-days-picker { display: grid; gap: 4px; margin-top: 6px; padding: 6px; border: 1px solid var(--glm-gris-medio); background: #fff; }.rest-days-picker label { font-size: .72rem; white-space: nowrap; }
|
||||
.editing-row { background: var(--glm-verde-bg); }.editing-row select, .inline-number { width: 100%; min-width: 92px; box-sizing: border-box; padding: 7px; border: 1px solid var(--glm-verde); border-radius: 4px; font: inherit; }.inline-number { max-width: 100px; }.inline-picker { min-width: 130px; margin: 0; }.editing-row .row-actions { flex-wrap: wrap; }
|
||||
@media (max-width: 760px) { .employees-page { padding: 16px; }.employee-form-grid { grid-template-columns: 1fr; }.employees-topbar { padding: 9px 16px; }.topbar-actions button { font-size: 0; }.topbar-actions button i { font-size: .9rem; } }
|
||||
@@ -0,0 +1,50 @@
|
||||
<div class="employees-topbar">
|
||||
<img src="GLM_completo.png" alt="GomezLee Marketing" />
|
||||
<span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div>
|
||||
<div class="topbar-actions"><button type="button" (click)="goBack()"><i class="pi pi-arrow-left"></i> Volver</button><button type="button" (click)="goHome()"><i class="pi pi-home"></i> Proyectos</button><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
|
||||
</div>
|
||||
|
||||
<main class="employees-page">
|
||||
<header><div><h1>Gestionar empleados</h1><p>{{ activeEmployees }} activos de {{ employees.length }} empleados registrados</p></div><label class="import-button"><i class="pi pi-file-import"></i> {{ importing ? 'Importando...' : 'Importar empleados' }}<input type="file" accept=".xlsx,.xls" (change)="importFile($event)" [disabled]="importing"></label></header>
|
||||
<p class="import-message" *ngIf="importMessage">{{ importMessage }}</p>
|
||||
|
||||
<section class="employee-form-card">
|
||||
<h2>Nuevo empleado</h2>
|
||||
<form (ngSubmit)="save()" #employeeForm="ngForm">
|
||||
<div class="employee-form-grid">
|
||||
<label>ID<input name="employeeCode" [(ngModel)]="form.employee_code" required></label>
|
||||
<label>Nombre<input name="nombre" [(ngModel)]="form.nombre" required></label>
|
||||
<label>Usuario<input name="usuario" [(ngModel)]="form.usuario"></label>
|
||||
<label>Email<input type="email" name="email" [(ngModel)]="form.email"></label>
|
||||
<label>Perfil<input name="perfil" [(ngModel)]="form.perfil"></label>
|
||||
<label>Regional<input name="regional" [(ngModel)]="form.regional"></label>
|
||||
<label>País<input name="pais" [(ngModel)]="form.pais"></label>
|
||||
<label>Estado<select name="activo" [(ngModel)]="form.activo"><option [ngValue]="true">Activo</option><option [ngValue]="false">Inactivo</option></select></label>
|
||||
<label>Latitud<input type="number" name="latitud" [(ngModel)]="form.latitud" step="any" required></label>
|
||||
<label>Longitud<input type="number" name="longitud" [(ngModel)]="form.longitud" step="any" required></label>
|
||||
<label>Horas por día<input type="number" name="hoursDay" [(ngModel)]="form.horas_por_dia" min="0.5" max="24" step="0.5" required></label>
|
||||
<label>Jornada de trabajo<select name="schedule" [(ngModel)]="form.jornada_codigo"><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></label>
|
||||
<label>Horas de descanso/día<input type="number" name="dailyRest" [(ngModel)]="form.horas_descanso_por_dia" min="0" max="3" step="1" required></label>
|
||||
<label>Visitas máximas/día<input type="number" name="visitsDay" [(ngModel)]="form.visitas_maximas_por_dia" min="1" required></label>
|
||||
</div>
|
||||
<p class="weekly-capacity">Capacidad semanal: {{ selectedScheduleHours }} h · Capacidad diaria efectiva: {{ form.horas_por_dia - form.horas_descanso_por_dia | number:'1.0-1' }} h</p>
|
||||
<p class="form-error" *ngIf="error">{{ error }}</p>
|
||||
<div class="form-actions"><button type="submit" [disabled]="saving || employeeForm.invalid"><i class="pi pi-save"></i> {{ saving ? 'Guardando...' : 'Agregar empleado' }}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="employee-table-card">
|
||||
<div class="table-header"><h2>Empleados registrados</h2><button type="button" (click)="load()" [disabled]="loading"><i class="pi pi-refresh"></i> Actualizar</button></div>
|
||||
<p class="form-error" *ngIf="error && !loading">{{ error }}</p>
|
||||
<p class="loading" *ngIf="loading">Cargando empleados...</p>
|
||||
<div class="table-wrap" *ngIf="!loading">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Nombre</th><th>Regional</th><th>Jornada</th><th>Descanso/día</th><th>Días de descanso</th><th>Visitas/día</th><th>Estado</th><th>Acciones</th></tr></thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let employee of employees" [class.editing-row]="editingId === employee.id"><td>{{ employee.employee_code }}</td><td>{{ employee.nombre }}</td><td>{{ employee.regional || '-' }}</td><td><ng-container *ngIf="editingId === employee.id; else scheduleText"><select [(ngModel)]="employee.jornada_codigo" [name]="'schedule-' + employee.id" (ngModelChange)="onInlineScheduleChange(employee)"><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></ng-container><ng-template #scheduleText>{{ scheduleLabel(employee) }}</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else restText"><input class="inline-number" type="number" [(ngModel)]="employee.horas_descanso_por_dia" [name]="'rest-' + employee.id" min="0" max="3" step="1"></ng-container><ng-template #restText>{{ employee.horas_descanso_por_dia }} h</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else daysText"><div class="rest-days-picker inline-picker"><label *ngFor="let day of availableRestDays(employee)"><input type="checkbox" [checked]="employee.dias_descanso.includes(day)" (change)="toggleRestDay(employee, day, $any($event.target).checked)">{{ day }}</label></div></ng-container><ng-template #daysText>{{ employee.dias_descanso.length ? employee.dias_descanso.join(', ') : 'Ninguno' }}</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else visitsText"><input class="inline-number" type="number" [(ngModel)]="employee.visitas_maximas_por_dia" [name]="'visits-' + employee.id" min="1" step="1"></ng-container><ng-template #visitsText>{{ employee.visitas_maximas_por_dia }}</ng-template></td><td><span class="status" [class.inactive]="!employee.activo">{{ employee.activo ? 'Activo' : 'Inactivo' }}</span></td><td class="row-actions"><ng-container *ngIf="editingId === employee.id; else normalActions"><button type="button" (click)="saveInline(employee)" [disabled]="saving">Guardar</button><button type="button" (click)="cancelEdit()" [disabled]="saving">Cancelar</button></ng-container><ng-template #normalActions><button type="button" (click)="edit(employee)">Editar</button><button type="button" (click)="toggleActive(employee)">{{ employee.activo ? 'Desactivar' : 'Activar' }}</button><button type="button" class="delete" (click)="remove(employee)">Eliminar</button></ng-template></td></tr>
|
||||
<tr *ngIf="!employees.length"><td colspan="9">No hay empleados registrados.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -0,0 +1,281 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Location } from '@angular/common';
|
||||
import { Employee, EmployeeInput } from '../../core/models/employee.model';
|
||||
import { EmployeeService } from '../../core/services/employee.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { UserProfileService } from '../../core/services/user-profile.service';
|
||||
import { WORK_SCHEDULES, workSchedule } from '../../core/models/work-schedule.model';
|
||||
import { normalizeRow, cleanNum } from '../../shared/utils/header-normalization.utils';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
type EmployeeForm = Omit<EmployeeInput, 'activo'> & { activo: boolean };
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-employees',
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './employees.component.html',
|
||||
styleUrls: ['./employees.component.css']
|
||||
})
|
||||
export class EmployeesComponent implements OnDestroy, OnInit {
|
||||
employees: Employee[] = [];
|
||||
form = this.emptyForm();
|
||||
editingId: string | null = null;
|
||||
loading = true;
|
||||
saving = false;
|
||||
error = '';
|
||||
importMessage = '';
|
||||
importing = false;
|
||||
openRestDaysFor: string | null = null;
|
||||
readonly schedules = WORK_SCHEDULES;
|
||||
private destroyed = false;
|
||||
private loadSequence = 0;
|
||||
|
||||
constructor(
|
||||
private employeesService: EmployeeService,
|
||||
private auth: AuthService,
|
||||
private router: Router,
|
||||
private location: Location,
|
||||
public profile: UserProfileService,
|
||||
private cdr: ChangeDetectorRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyed = true;
|
||||
this.loadSequence++;
|
||||
}
|
||||
|
||||
get activeEmployees(): number {
|
||||
return this.employees.filter(employee => employee.activo).length;
|
||||
}
|
||||
|
||||
get selectedScheduleHours(): number {
|
||||
return workSchedule(this.form.jornada_codigo).weeklyHours;
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
const sequence = ++this.loadSequence;
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const employees = await this.employeesService.list();
|
||||
if (this.destroyed || sequence !== this.loadSequence) return;
|
||||
this.employees = employees;
|
||||
this.cdr.detectChanges();
|
||||
} catch (error: any) {
|
||||
if (!this.destroyed && sequence === this.loadSequence) {
|
||||
this.error = error?.message ?? 'No se pudieron cargar los empleados.';
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
if (!this.destroyed && sequence === this.loadSequence) this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
edit(employee: Employee): void {
|
||||
this.editingId = employee.id;
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editingId = null;
|
||||
this.error = '';
|
||||
void this.load();
|
||||
}
|
||||
|
||||
async saveInline(employee: Employee): Promise<void> {
|
||||
const rest = Number(employee.horas_descanso_por_dia);
|
||||
const visits = Number(employee.visitas_maximas_por_dia);
|
||||
if (!Number.isInteger(rest) || rest < 0 || rest > 3 || !Number.isInteger(visits) || visits < 1) {
|
||||
this.error = 'Descanso/día debe ser un entero entre 0 y 3, y Visitas/día debe ser un entero mayor que 0.';
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.error = '';
|
||||
try {
|
||||
await this.employeesService.update(employee.id, {
|
||||
...employee,
|
||||
dias_por_semana: workSchedule(employee.jornada_codigo).days.length,
|
||||
horas_descanso_por_dia: rest,
|
||||
visitas_maximas_por_dia: visits,
|
||||
dias_descanso: (employee.dias_descanso ?? []).filter(day => workSchedule(employee.jornada_codigo).days.includes(day))
|
||||
});
|
||||
this.editingId = null;
|
||||
await this.load();
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudo actualizar el empleado.';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onInlineScheduleChange(employee: Employee): void {
|
||||
employee.dias_descanso = (employee.dias_descanso ?? []).filter(day => workSchedule(employee.jornada_codigo).days.includes(day));
|
||||
employee.dias_por_semana = workSchedule(employee.jornada_codigo).days.length;
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
if (!this.form.employee_code.trim() || !this.form.nombre.trim()) {
|
||||
this.error = 'El ID y el nombre son obligatorios.';
|
||||
return;
|
||||
}
|
||||
if (this.form.horas_descanso_por_dia >= this.form.horas_por_dia) {
|
||||
this.error = 'Las horas de descanso deben ser menores que las horas de trabajo diarias.';
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.error = '';
|
||||
const employee: EmployeeInput = {
|
||||
...this.form,
|
||||
dias_por_semana: workSchedule(this.form.jornada_codigo).days.length,
|
||||
employee_code: this.form.employee_code.trim(),
|
||||
nombre: this.form.nombre.trim(),
|
||||
usuario: this.nullable(this.form.usuario),
|
||||
email: this.nullable(this.form.email),
|
||||
perfil: this.nullable(this.form.perfil),
|
||||
pais: this.nullable(this.form.pais),
|
||||
regional: this.nullable(this.form.regional)
|
||||
};
|
||||
try {
|
||||
if (this.editingId) await this.employeesService.update(this.editingId, employee);
|
||||
else await this.employeesService.create(employee);
|
||||
this.cancelEdit();
|
||||
await this.load();
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudo guardar el empleado.';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async toggleActive(employee: Employee): Promise<void> {
|
||||
try {
|
||||
await this.employeesService.update(employee.id, { ...employee, activo: !employee.activo });
|
||||
await this.load();
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudo actualizar el estado.';
|
||||
}
|
||||
}
|
||||
|
||||
scheduleLabel(employee: Employee): string {
|
||||
return workSchedule(employee.jornada_codigo).label;
|
||||
}
|
||||
|
||||
availableRestDays(employee: Employee): string[] {
|
||||
return workSchedule(employee.jornada_codigo).days;
|
||||
}
|
||||
|
||||
toggleRestDayEditor(employee: Employee): void {
|
||||
this.openRestDaysFor = this.openRestDaysFor === employee.id ? null : employee.id;
|
||||
}
|
||||
|
||||
async toggleRestDay(employee: Employee, day: string, checked: boolean): Promise<void> {
|
||||
const days = new Set(employee.dias_descanso ?? []);
|
||||
checked ? days.add(day) : days.delete(day);
|
||||
employee.dias_descanso = Array.from(days);
|
||||
}
|
||||
|
||||
async importFile(event: Event): Promise<void> {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
this.importing = true;
|
||||
this.importMessage = '';
|
||||
this.error = '';
|
||||
try {
|
||||
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { defval: null, raw: true }).map(row => normalizeRow(row as Record<string, unknown>));
|
||||
const employees = rows.map((row, index) => this.fromExcelRow(row, index + 2));
|
||||
const invalid = employees.filter((employee): employee is null => employee === null).length;
|
||||
const valid = employees.filter((employee): employee is EmployeeInput => employee !== null);
|
||||
if (!valid.length) throw new Error('El archivo no contiene empleados válidos.');
|
||||
await this.employeesService.upsert(valid);
|
||||
this.importMessage = `${valid.length} empleados guardados${invalid ? `; ${invalid} filas omitidas por datos inválidos` : ''}.`;
|
||||
await this.load();
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudo importar el archivo.';
|
||||
} finally {
|
||||
this.importing = false;
|
||||
(event.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async remove(employee: Employee): Promise<void> {
|
||||
if (!window.confirm(`¿Eliminar a ${employee.nombre}? Esta acción no se puede deshacer.`)) return;
|
||||
try {
|
||||
await this.employeesService.remove(employee.id);
|
||||
await this.load();
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudo eliminar el empleado.';
|
||||
}
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
this.destroyed = true;
|
||||
this.loadSequence++;
|
||||
this.employees = [];
|
||||
this.loading = true;
|
||||
await this.auth.signOut();
|
||||
await this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
async goHome(): Promise<void> {
|
||||
await this.router.navigate(['/home']);
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
if (history.length > 1) this.location.back();
|
||||
else void this.router.navigate(['/home']);
|
||||
}
|
||||
|
||||
private nullable(value: string | null): string | null {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
private emptyForm(): EmployeeForm {
|
||||
return {
|
||||
employee_code: '', nombre: '', usuario: '', email: '', perfil: 'Promotor', pais: 'Republica Dominicana', regional: '',
|
||||
latitud: 18.4861, longitud: -69.9312, horas_por_dia: 8, dias_por_semana: 5, jornada_codigo: 'LUN_VIE_40', horas_descanso_por_dia: 1, dias_descanso: [], visitas_maximas_por_dia: 3, activo: true
|
||||
};
|
||||
}
|
||||
|
||||
private fromExcelRow(row: Record<string, unknown>, excelRow: number): EmployeeInput | null {
|
||||
const code = String(row['id'] ?? '').trim();
|
||||
const nombre = String(row['nombre_del_empleado'] ?? '').trim();
|
||||
const latitude = cleanNum(row['latitud']);
|
||||
const longitude = cleanNum(row['longitud']);
|
||||
const dailyHours = cleanNum(row['horas_por_dia_usuario']);
|
||||
const weeklyHours = cleanNum(row['horas_por_semana_usuario']);
|
||||
if (!code || !nombre || !Number.isFinite(latitude) || !Number.isFinite(longitude) || !Number.isFinite(dailyHours)) return null;
|
||||
const jornadaTexto = String(row['jornada'] ?? '').trim();
|
||||
const jornadaCodigo = this.schedules.some(schedule => schedule.code === jornadaTexto)
|
||||
? jornadaTexto
|
||||
: weeklyHours === 44 ? 'LUN_SAB_44' : 'LUN_VIE_40';
|
||||
const restDays = String(row['dias_descanso'] ?? '').split(',').map(day => day.trim()).filter(day => workSchedule(jornadaCodigo).days.includes(day));
|
||||
const active = !['no', 'n', '0', 'false'].includes(String(row['usuario_activo'] ?? 'si').trim().toLowerCase());
|
||||
return {
|
||||
employee_code: code,
|
||||
nombre,
|
||||
usuario: this.nullable(String(row['usuario'] ?? '')),
|
||||
email: this.nullable(String(row['email'] ?? '')),
|
||||
perfil: this.nullable(String(row['perfil_de_acceso'] ?? '')),
|
||||
pais: this.nullable(String(row['pais'] ?? '')),
|
||||
regional: this.nullable(String(row['regionales'] ?? row['regional'] ?? '')),
|
||||
latitud: latitude,
|
||||
longitud: longitude,
|
||||
horas_por_dia: dailyHours,
|
||||
dias_por_semana: workSchedule(jornadaCodigo).days.length,
|
||||
jornada_codigo: jornadaCodigo,
|
||||
horas_descanso_por_dia: Math.min(3, Math.max(0, Math.round(cleanNum(row['horas_descanso_por_dia']) || 1))),
|
||||
dias_descanso: restDays,
|
||||
visitas_maximas_por_dia: Math.max(1, cleanNum(row['visitas_maximas_por_dia']) || 3),
|
||||
activo: active
|
||||
};
|
||||
}
|
||||
}
|
||||
+6
@@ -206,6 +206,12 @@
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
|
||||
}
|
||||
|
||||
.location-pin:hover {
|
||||
transform: scale(1.12);
|
||||
transform-origin: center bottom;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.location-pin::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
+25
-4
@@ -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 }],
|
||||
@@ -270,7 +291,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
.bindPopup(`<strong>Origen del tramo:</strong> ${originName}`),
|
||||
L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
||||
.addTo(this.map)
|
||||
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
|
||||
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Usuario:</strong> ${json.nombre_usuario}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,6 +313,6 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
}
|
||||
|
||||
goBack() {
|
||||
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;
|
||||
@@ -38,11 +42,18 @@
|
||||
}
|
||||
|
||||
.home-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 3px solid var(--glm-verde);
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.employees-link { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 1px solid var(--glm-verde); border-radius: 4px; background: var(--glm-verde-bg); color: var(--glm-azul); font-size: .82rem; font-weight: 700; text-decoration: none; white-space: nowrap; }
|
||||
.employees-link:hover { background: var(--glm-verde); color: #fff; }
|
||||
|
||||
.home-header h1 {
|
||||
margin: 0;
|
||||
color: var(--glm-azul);
|
||||
@@ -79,6 +90,8 @@
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.employees-summary { display: flex; align-items: center; gap: 7px; margin: -2px 0 16px; color: var(--glm-acero); font-size: .88rem; }.employees-summary i { color: var(--glm-verde); }.employees-summary.empty, .employees-summary.empty i { color: #c9252d; }
|
||||
|
||||
.upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -1,30 +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">
|
||||
<h1>Route Planner</h1>
|
||||
<p>Gestión de rutas asignadas y puntos de venta pendientes</p>
|
||||
<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 archivos para optimización</h2>
|
||||
<h2 class="section-title">PDVs del proyecto</h2>
|
||||
<p class="employees-summary" [class.empty]="!employeesLoading && !activeEmployees"><i class="pi pi-users"></i> <span *ngIf="employeesLoading">Cargando empleados activos...</span><span *ngIf="!employeesLoading">{{ activeEmployees }} empleados activos de {{ projectCountry }} se usarán para planificar.</span></p>
|
||||
|
||||
<div class="upload-grid">
|
||||
<div class="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"
|
||||
/>
|
||||
@@ -57,48 +58,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-input-wrapper">
|
||||
<label class="file-label" for="usuariosFile" [class.has-error]="usuariosPreview && !usuariosPreview.valid" [class.has-success]="usuariosPreview?.valid">
|
||||
<i class="pi pi-file-excel"></i>
|
||||
<span>Archivo Usuarios (Excel)</span>
|
||||
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 50 filas</span>
|
||||
</label>
|
||||
<input
|
||||
id="usuariosFile"
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
(change)="onUsuariosFileChange($event)"
|
||||
class="file-input"
|
||||
[class.has-file]="usuariosFile"
|
||||
/>
|
||||
<div class="file-name" *ngIf="usuariosFile">
|
||||
<i class="pi" [class.pi-check-circle]="usuariosPreview?.valid" [class.pi-exclamation-circle]="usuariosPreview && !usuariosPreview.valid"></i>
|
||||
{{ usuariosFile.name }}
|
||||
<span *ngIf="usuariosPreviewLoading" class="preview-loading">Validando...</span>
|
||||
</div>
|
||||
|
||||
<div class="preview-card" *ngIf="usuariosPreviewLoading">
|
||||
<div class="preview-loading-row"><span class="spinner-inline"></span> Analizando archivo Usuarios...</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-card preview-success" *ngIf="usuariosPreview?.valid">
|
||||
<div class="preview-header"><i class="pi pi-check-circle"></i> Usuarios válido — {{ usuariosPreview!.totalRows }} filas</div>
|
||||
<div class="preview-stats">{{ usuariosPreview!.validRows }} filas válidas • Columnas: {{ usuariosPreview!.headers.join(', ') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-card preview-error" *ngIf="usuariosPreview && !usuariosPreview.valid">
|
||||
<div class="preview-header"><i class="pi pi-exclamation-triangle"></i> Errores en Usuarios ({{ usuariosPreview.errors.length }})</div>
|
||||
<div class="preview-stats" *ngIf="usuariosPreview.missingHeaders.length">Faltan: {{ usuariosPreview.missingHeaders.join(', ') }}</div>
|
||||
<div class="preview-stats">{{ usuariosPreview.totalRows }} filas leídas • {{ usuariosPreview.validRows }} válidas</div>
|
||||
<ul class="preview-errors">
|
||||
<li *ngFor="let e of usuariosPreview.errors.slice(0, 8)">
|
||||
<strong>Fila {{ e.row }} — {{ e.field }}:</strong> {{ e.message }}
|
||||
<span *ngIf="e.value !== undefined && e.value !== null" class="error-value">({{ e.value }})</span>
|
||||
</li>
|
||||
<li *ngIf="usuariosPreview.errors.length > 8" class="more-errors">+ {{ usuariosPreview.errors.length - 8 }} errores más...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-actions">
|
||||
@@ -130,7 +89,7 @@
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
(click)="clearFiles()"
|
||||
[disabled]="!pdvFile && !usuariosFile"
|
||||
[disabled]="!pdvFile"
|
||||
>
|
||||
<i class="pi pi-times"></i> Limpiar
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, ChangeDetectorRef } from '@angular/core';
|
||||
import { Component, ChangeDetectorRef, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AssignedRouteComponent } from './assigned-route/assigned-route.component';
|
||||
@@ -10,23 +10,36 @@ 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 { 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,
|
||||
selector: 'app-home',
|
||||
imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent, PlanningKpisComponent],
|
||||
imports: [CommonModule, FormsModule, RouterLink, AssignedRouteComponent, NoAssignedRouteComponent, PlanningKpisComponent],
|
||||
templateUrl: './home.component.html',
|
||||
styleUrls: ['./home.component.css']
|
||||
})
|
||||
export class HomeComponent {
|
||||
export class HomeComponent implements OnInit {
|
||||
pdvFile: File | null = null;
|
||||
usuariosFile: File | null = null;
|
||||
pdvPreview: PreviewResult | null = null;
|
||||
usuariosPreview: PreviewResult | null = null;
|
||||
pdvPreviewLoading = false;
|
||||
usuariosPreviewLoading = false;
|
||||
activeEmployees = 0;
|
||||
employeesLoading = true;
|
||||
uploadError: string | null = null;
|
||||
isLoading = false;
|
||||
projectName = '';
|
||||
projectId = '';
|
||||
editingProject = false;
|
||||
projectDescription = '';
|
||||
projectCountry = '';
|
||||
projectSaving = false;
|
||||
private calcSub: Subscription | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -34,9 +47,69 @@ export class HomeComponent {
|
||||
private routeState: RouteState,
|
||||
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.initialize();
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
await this.loadProject();
|
||||
await this.loadEmployees();
|
||||
}
|
||||
|
||||
private async loadProject(): Promise<void> {
|
||||
this.projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? '';
|
||||
if (!this.projectId) return;
|
||||
try {
|
||||
const [project, input] = await Promise.all([this.projectsService.get(this.projectId), this.projectsService.projectInput(this.projectId)]);
|
||||
this.projectName = project.nombre;
|
||||
this.projectDescription = project.descripcion ?? '';
|
||||
this.projectCountry = input.country;
|
||||
this.pdvFile = input.file;
|
||||
this.pdvPreview = await this.previewService.previewFile(input.file, 'PDV');
|
||||
} catch (error: any) {
|
||||
this.uploadError = error?.message ?? 'No se pudo cargar el proyecto.';
|
||||
} finally {
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
startProjectEdit(): void { this.editingProject = true; }
|
||||
|
||||
cancelProjectEdit(): void { this.editingProject = false; void this.loadProject(); }
|
||||
|
||||
async saveProject(): Promise<void> {
|
||||
if (!this.projectId || !this.projectName.trim()) return;
|
||||
this.projectSaving = true;
|
||||
try {
|
||||
await this.projectsService.update(this.projectId, { nombre: this.projectName.trim(), descripcion: this.projectDescription.trim() || null });
|
||||
this.editingProject = false;
|
||||
} catch (error: any) { this.uploadError = error?.message ?? 'No se pudo actualizar el proyecto.'; }
|
||||
finally { this.projectSaving = false; this.cdr.detectChanges(); }
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
|
||||
|
||||
private async loadEmployees(): Promise<void> {
|
||||
this.employeesLoading = true;
|
||||
try {
|
||||
this.activeEmployees = (await this.employeesService.active(this.projectCountry)).length;
|
||||
} catch {
|
||||
this.activeEmployees = 0;
|
||||
} finally {
|
||||
this.employeesLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
async onPdvFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
@@ -132,23 +205,22 @@ export class HomeComponent {
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
if (!this.pdvFile || !this.usuariosFile) return false;
|
||||
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return false;
|
||||
if (!this.pdvFile || !this.activeEmployees) return false;
|
||||
if (this.pdvPreviewLoading || this.employeesLoading) return false;
|
||||
if (this.isLoading) return false;
|
||||
if (this.uploadError) return false;
|
||||
// Require valid previews before enabling submit
|
||||
if (this.pdvPreview && !this.pdvPreview.valid) return false;
|
||||
if (this.usuariosPreview && !this.usuariosPreview.valid) return false;
|
||||
// Previews must exist (file was parsed)
|
||||
if (!this.pdvPreview || !this.usuariosPreview) return false;
|
||||
if (!this.pdvPreview) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
get submitDisabledReason(): string | null {
|
||||
if (!this.pdvFile || !this.usuariosFile) return 'Sube ambos archivos';
|
||||
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return 'Validando archivos...';
|
||||
if (!this.pdvFile) return 'Sube el archivo PDV';
|
||||
if (this.employeesLoading) return 'Cargando empleados...';
|
||||
if (!this.activeEmployees) return 'No hay empleados activos. Regístralos en Gestionar empleados.';
|
||||
if (this.pdvPreviewLoading) return 'Validando archivo PDV...';
|
||||
if (this.pdvPreview && !this.pdvPreview.valid) return 'Corrige los errores del archivo PDV';
|
||||
if (this.usuariosPreview && !this.usuariosPreview.valid) return 'Corrige los errores del archivo Usuarios';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -168,11 +240,21 @@ export class HomeComponent {
|
||||
this.cdr.detectChanges();
|
||||
return;
|
||||
}
|
||||
this.planningInput.setFiles(pdvForOptimizer, this.usuariosFile!);
|
||||
let employeesFile: File;
|
||||
try {
|
||||
employeesFile = await this.employeesService.activeEmployeesFile(this.projectCountry);
|
||||
} catch {
|
||||
this.uploadError = 'No se pudieron obtener los empleados activos.';
|
||||
this.routeState.setLoading(false);
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
this.planningInput.setFiles(pdvForOptimizer, employeesFile);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name);
|
||||
formData.append('usuarios_file', this.usuariosFile!, this.usuariosFile!.name);
|
||||
formData.append('usuarios_file', employeesFile, employeesFile.name);
|
||||
formData.append('pais', this.projectCountry);
|
||||
|
||||
this.calcSub?.unsubscribe();
|
||||
this.calcSub = this.rutasService.calcularRutas(formData).subscribe({
|
||||
@@ -218,7 +300,7 @@ export class HomeComponent {
|
||||
|
||||
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;
|
||||
|
||||
@@ -130,10 +130,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.operational-grid {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.user-load-card {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
|
||||
@@ -31,29 +31,12 @@
|
||||
<div class="kpi-icon"><i class="pi pi-compass"></i></div>
|
||||
<div><span class="kpi-label">Distancia total</span><strong>{{ summary.totalDistance | number:'1.0-1' }} <em>km</em></strong><small>{{ summary.averageDistance | number:'1.0-1' }} km promedio por ruta</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-blue">
|
||||
<div class="kpi-icon"><i class="pi pi-clock"></i></div>
|
||||
<div><span class="kpi-label">Desplazamiento total</span><strong>{{ summary.totalTravelHours | number:'1.0-1' }} <em>h</em></strong><small>{{ summary.averageTravelHours | number:'1.0-1' }} h promedio por ruta</small></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="kpi-grid operational-grid" *ngIf="hasData">
|
||||
<ng-container *ngIf="hasData">
|
||||
<article class="kpi-card kpi-success">
|
||||
<div class="kpi-icon"><i class="pi pi-briefcase"></i></div>
|
||||
<div><span class="kpi-label">Carga de trabajo</span><strong>{{ summary.totalWorkHours | number:'1.0-1' }} <em>h</em></strong><small>horas de trabajo planificadas</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-blue">
|
||||
<div class="kpi-icon"><i class="pi pi-chart-line"></i></div>
|
||||
<div><span class="kpi-label">Utilización promedio</span><strong>{{ summary.averageUtilization | number:'1.0-1' }}<em>%</em></strong><small>carga frente a disponibilidad</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-primary">
|
||||
<div class="kpi-icon"><i class="pi pi-sitemap"></i></div>
|
||||
<div><span class="kpi-label">Rutas por usuario</span><strong>{{ summary.averageRoutesPerUser | number:'1.0-1' }}</strong><small>promedio de asignaciones</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-warning">
|
||||
<div class="kpi-icon"><i class="pi pi-arrows-h"></i></div>
|
||||
<div><span class="kpi-label">Brecha de balance</span><strong>{{ summary.assignmentBalance }}</strong><small>diferencia entre mayor y menor carga</small></div>
|
||||
</article>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="user-load-card" *ngIf="userLoads.length">
|
||||
|
||||
@@ -14,13 +14,8 @@ interface KpiSummary {
|
||||
users: number;
|
||||
days: number;
|
||||
totalDistance: number;
|
||||
totalTravelHours: number;
|
||||
averageDistance: number;
|
||||
averageTravelHours: number;
|
||||
totalWorkHours: number;
|
||||
averageRoutesPerUser: number;
|
||||
averageUtilization: number;
|
||||
assignmentBalance: number;
|
||||
}
|
||||
|
||||
interface ReasonSummary {
|
||||
@@ -95,7 +90,6 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
||||
const totalPdvs = new Set([...assignedPdvIds, ...unassignedPdvIds]).size;
|
||||
const dailyRoutes = summarizeDailyRoutes(assigned);
|
||||
const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0);
|
||||
const totalTravelHours = dailyRoutes.reduce((sum, day) => sum + day.travelHours, 0);
|
||||
const totalWorkHours = dailyRoutes.reduce((sum, day) => sum + day.workHours, 0);
|
||||
const users = new Set(assigned.map(route => String(route.json.usuario_id))).size;
|
||||
const days = new Set(assigned.map(route => route.json.dia)).size;
|
||||
@@ -132,12 +126,6 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
||||
: `${load.name} está dentro de capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).`;
|
||||
return load;
|
||||
}).sort((a, b) => b.routes - a.routes || b.workHours - a.workHours);
|
||||
const routeCounts = this.userLoads.map(load => load.routes);
|
||||
const assignmentBalance = routeCounts.length ? Math.max(...routeCounts) - Math.min(...routeCounts) : 0;
|
||||
const averageUtilization = this.userLoads.length
|
||||
? this.userLoads.reduce((sum, load) => sum + load.utilization, 0) / this.userLoads.length
|
||||
: 0;
|
||||
|
||||
this.summary = {
|
||||
totalPdvs,
|
||||
assignedPdvs: assignedPdvIds.size,
|
||||
@@ -146,13 +134,8 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
||||
users,
|
||||
days,
|
||||
totalDistance,
|
||||
totalTravelHours,
|
||||
averageDistance: assigned.length ? totalDistance / assigned.length : 0,
|
||||
averageTravelHours: assigned.length ? totalTravelHours / assigned.length : 0,
|
||||
totalWorkHours,
|
||||
averageRoutesPerUser: users ? assigned.length / users : 0,
|
||||
averageUtilization,
|
||||
assignmentBalance
|
||||
};
|
||||
|
||||
const reasonByPdv = new Map<string, string>();
|
||||
@@ -186,13 +169,8 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
||||
users: 0,
|
||||
days: 0,
|
||||
totalDistance: 0,
|
||||
totalTravelHours: 0,
|
||||
averageDistance: 0,
|
||||
averageTravelHours: 0,
|
||||
totalWorkHours: 0,
|
||||
averageRoutesPerUser: 0,
|
||||
averageUtilization: 0,
|
||||
assignmentBalance: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+104
-119
@@ -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);
|
||||
@@ -126,6 +129,8 @@
|
||||
color: var(--glm-acero);
|
||||
}
|
||||
|
||||
.heatmap-help { display: inline-grid; width: 16px; height: 16px; margin-left: auto; place-items: center; border: 1px solid var(--glm-acero); border-radius: 50%; color: var(--glm-acero); cursor: help; font-size: 0.68rem; font-weight: 700; }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 24px 12px;
|
||||
@@ -151,20 +156,45 @@
|
||||
color: var(--glm-gris-oscuro);
|
||||
}
|
||||
|
||||
.unassigned-map-card {
|
||||
padding: 12px;
|
||||
background: #fff8ed;
|
||||
.unassigned-bottom-drawer {
|
||||
position: fixed;
|
||||
left: 398px;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 950;
|
||||
overflow: hidden;
|
||||
border: 1px solid #f2bf86;
|
||||
border-left: 3px solid var(--glm-naranja);
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid var(--glm-naranja);
|
||||
border-radius: 10px 10px 4px 4px;
|
||||
background: #fff8ed;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.unassigned-map-card .info-card-header { margin-bottom: 8px; }
|
||||
.unassigned-drawer-toggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: 0;
|
||||
background: #fff8ed;
|
||||
color: var(--glm-azul);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.unassigned-map-list {
|
||||
.unassigned-drawer-toggle span { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.unassigned-drawer-toggle .pi-exclamation-triangle { color: var(--glm-naranja); }
|
||||
|
||||
.unassigned-drawer-content {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
max-height: 180px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 240px;
|
||||
padding: 0 12px 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -219,86 +249,21 @@
|
||||
.pending-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
|
||||
.pending-actions .btn-primary, .pending-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
|
||||
|
||||
.routes-list h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--glm-azul);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.employee-load-list { display: grid; gap: 5px; max-height: 318px; margin-top: 10px; overflow-y: auto; border-top: 1px solid var(--glm-gris-claro); padding-top: 8px; }
|
||||
.employee-load-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 8px; width: 100%; padding: 7px 8px; border: 1px solid var(--glm-gris-medio); border-radius: 4px; background: #fff; color: var(--glm-gris-oscuro); cursor: pointer; font: inherit; text-align: left; }
|
||||
.employee-load-row:hover, .employee-load-row.selected { border-color: var(--glm-verde); background: var(--glm-verde-bg); }
|
||||
.employee-load-row.over-capacity { border-color: #c9252d; background: var(--glm-rojo-ok); }
|
||||
.employee-load-name { overflow: hidden; color: var(--glm-azul); font-size: 0.78rem; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.employee-load-values { color: var(--glm-acero); font-size: 0.7rem; white-space: nowrap; }
|
||||
.employee-load-bar { grid-column: 1 / -1; display: block; height: 6px; overflow: hidden; border-radius: 6px; background: var(--glm-gris-medio); }
|
||||
.employee-load-bar span { display: block; height: 100%; border-radius: inherit; background: var(--glm-verde); }
|
||||
.employee-load-bar.warning span { background: var(--glm-naranja); }
|
||||
.employee-load-bar.over-capacity span { background: #c9252d; }
|
||||
|
||||
.routes-list ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.routes-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.routes-list li:hover {
|
||||
background: var(--glm-verde-bg);
|
||||
border-color: var(--glm-verde);
|
||||
}
|
||||
|
||||
.routes-list li.selected {
|
||||
background: var(--glm-verde);
|
||||
border-color: var(--glm-verde);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.routes-list li.selected small {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.routes-list li.selected i {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.idx {
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--glm-azul);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.li-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.li-main strong {
|
||||
font-size: 0.85rem;
|
||||
color: var(--glm-gris-oscuro);
|
||||
}
|
||||
|
||||
.routes-list li.selected .li-main strong {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.li-main small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--glm-acero);
|
||||
@media (max-width: 900px) {
|
||||
#map-overview { left: 0; }
|
||||
.overview-sidebar { width: 340px; max-width: calc(100vw - 24px); }
|
||||
.unassigned-bottom-drawer { right: 12px; bottom: 12px; left: 12px; }
|
||||
}
|
||||
|
||||
.info-card {
|
||||
@@ -309,6 +274,12 @@
|
||||
border-top: 3px solid var(--glm-azul);
|
||||
}
|
||||
|
||||
.map-location-button {
|
||||
margin-top: 0;
|
||||
padding: 6px 9px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.info-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -353,15 +324,22 @@
|
||||
.reassign-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
|
||||
.reassign-actions .btn-primary, .reassign-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
|
||||
.reassign-hint { grid-column: 1 / -1; margin: 0; color: var(--glm-acero); font-size: 0.74rem; }
|
||||
.reassign-form .reassign-actions, .reassign-form .error-card { grid-column: 1 / -1; }
|
||||
.reassign-form .reassign-actions, .reassign-form .employee-load-list, .reassign-form .error-card { grid-column: 1 / -1; }
|
||||
|
||||
.daily-routes-card { padding: 12px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: var(--glm-surface); }
|
||||
.daily-routes-card h3 { margin: 0 0 8px; color: var(--glm-azul); font-size: 0.82rem; text-transform: uppercase; }
|
||||
.daily-route-group { border-bottom: 1px solid var(--glm-gris-claro); }
|
||||
.daily-route-group:last-child { border-bottom: 0; }
|
||||
.daily-route-toggle { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 0; border: 0; background: transparent; color: var(--glm-gris-oscuro); cursor: pointer; font: inherit; text-align: left; }
|
||||
.daily-route-toggle:hover { background: var(--glm-gris-claro); }
|
||||
.daily-route-toggle-title { color: var(--glm-azul); font-weight: 700; }
|
||||
.daily-route-toggle > span:last-child { color: var(--glm-acero); font-size: 0.78rem; }
|
||||
.daily-route-group-content { padding-left: 14px; }
|
||||
.daily-route { display: grid; gap: 3px; padding: 8px 0; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); font-size: 0.78rem; }
|
||||
.daily-route:last-child { border-bottom: 0; }
|
||||
.daily-route span, .daily-route small { color: var(--glm-acero); }
|
||||
.daily-route.over-capacity small { color: #c9252d; font-weight: 700; }
|
||||
.routes-list li:focus-visible, .btn-icon:focus-visible, button:focus-visible, select:focus-visible { outline: 3px solid rgba(108, 194, 74, 0.45); outline-offset: 2px; }
|
||||
.btn-icon:focus-visible, button:focus-visible, select:focus-visible { outline: 3px solid rgba(108, 194, 74, 0.45); outline-offset: 2px; }
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
@@ -386,6 +364,7 @@
|
||||
.info-list div {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--glm-gris-claro);
|
||||
@@ -474,40 +453,46 @@
|
||||
color: var(--glm-gris-oscuro);
|
||||
}
|
||||
|
||||
.heat-opts {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-left: 22px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--glm-acero);
|
||||
}
|
||||
|
||||
.cluster-glm {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.cluster-inner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--glm-verde);
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.custom-marker {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.location-pin-wrapper {
|
||||
width: 42px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 3px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: transform 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.location-pin-wrapper:hover {
|
||||
transform: scale(1.12);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.location-pin-wrapper .location-pin {
|
||||
position: relative;
|
||||
width: 30px;
|
||||
height: 38px;
|
||||
background: #c9252d;
|
||||
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
|
||||
}
|
||||
|
||||
.location-pin-wrapper .location-pin span {
|
||||
position: absolute;
|
||||
inset: 8px 0 auto;
|
||||
color: #fff;
|
||||
font: 700 12px Arial, sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.marker-inner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
|
||||
+42
-36
@@ -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>
|
||||
@@ -31,7 +32,7 @@
|
||||
</select>
|
||||
|
||||
<div class="filter-stats" *ngIf="filteredRoutes.length">
|
||||
<span>{{ filteredRoutes.length }} PDVs</span>
|
||||
<span>{{ uniqueFilteredRoutes.length }} PDVs</span>
|
||||
<span>{{ totalDistancia }} km</span>
|
||||
<span>{{ totalDuracionMin }} min</span>
|
||||
</div>
|
||||
@@ -40,11 +41,8 @@
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [checked]="showHeat" (change)="toggleHeat()" />
|
||||
<i class="pi pi-fire"></i> Heatmap (carga)
|
||||
<span class="heatmap-help" title="El mapa muestra con mayor intensidad las zonas donde se concentra más carga de trabajo en horas.">?</span>
|
||||
</label>
|
||||
<div class="heat-opts" *ngIf="showHeat">
|
||||
<label><input type="radio" name="hw" value="uniform" [checked]="heatWeightMode==='uniform'" (change)="setHeatWeight('uniform')" /> Peso 1 (densidad)</label>
|
||||
<label><input type="radio" name="hw" value="horas_trabajo" [checked]="heatWeightMode==='horas_trabajo'" (change)="setHeatWeight('horas_trabajo')" /> Por carga (horas)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-empty" *ngIf="!filteredRoutes.length && !error">
|
||||
@@ -63,18 +61,6 @@
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<section class="unassigned-map-card" *ngIf="selectedUsuarioId === 'Todos' && selectedDia === 'Todos' && unassignedRoutes.length">
|
||||
<div class="info-card-header">
|
||||
<h3>PDVs sin ruta ({{ unassignedRoutes.length }})</h3>
|
||||
</div>
|
||||
<div class="unassigned-map-list">
|
||||
<button type="button" class="unassigned-map-item" *ngFor="let pending of unassignedRoutes" [class.selected-pending]="selectedUnassigned?.pdv_id === pending.pdv_id" (click)="selectUnassigned(pending)">
|
||||
<span class="pending-icon">!</span>
|
||||
<span class="pending-text"><strong>{{ pending.pdv_nombre }}</strong><small>{{ pending.motivo }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="info-card unassigned-detail-card" *ngIf="selectedUnassigned">
|
||||
<div class="info-card-header">
|
||||
<h3>Gestionar PDV sin ruta</h3>
|
||||
@@ -101,6 +87,13 @@
|
||||
<label>Días<div class="day-checkboxes"><label *ngFor="let day of unassignedDayOptions"><input type="checkbox" [checked]="(assignmentDays[selectedUnassigned.pdv_id] || []).includes(day)" (change)="toggleUnassignedDay(selectedUnassigned.pdv_id, day, $any($event.target).checked)">{{ day.substring(0, 3) }}</label></div></label>
|
||||
</div>
|
||||
<div class="pending-actions"><button type="button" class="btn-secondary" (click)="startUnassignedEdit(selectedUnassigned)"><i class="pi pi-pencil"></i> Editar</button><button type="button" class="btn-primary" (click)="assignUnassigned(selectedUnassigned)" [disabled]="assigningUnassignedId === selectedUnassigned.pdv_id"><span *ngIf="assigningUnassignedId === selectedUnassigned.pdv_id" class="spinner-inline"></span><i *ngIf="assigningUnassignedId !== selectedUnassigned.pdv_id" class="pi pi-user-plus"></i> Reasignar</button></div>
|
||||
<div class="employee-load-list" aria-label="Carga de empleados">
|
||||
<button type="button" class="employee-load-row" *ngFor="let employee of unassignedEmployeeLoads" [class.selected]="assignmentUser[selectedUnassigned.pdv_id] === employee.id" [class.over-capacity]="employeeLoadStatus(employee) === 'over-capacity'" (click)="selectUnassignedEmployee(employee.id)">
|
||||
<span class="employee-load-name">{{ employee.name }}</span>
|
||||
<span class="employee-load-values">{{ employee.workHours | number:'1.0-1' }} / {{ employee.capacityHours | number:'1.0-1' }} h · {{ employee.utilization | number:'1.0-1' }}%</span>
|
||||
<span class="employee-load-bar" [ngClass]="employeeLoadStatus(employee)"><span [style.width.%]="employeeLoadWidth(employee)"></span></span>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
<div class="error-card" *ngIf="unassignedActionError">{{ unassignedActionError }}</div>
|
||||
</section>
|
||||
@@ -129,7 +122,7 @@
|
||||
<div><dt>Día</dt><dd>{{ selectedRouteDetail.json.dia }}</dd></div>
|
||||
<div><dt>Orden de visita</dt><dd>{{ selectedVisitOrder }}</dd></div>
|
||||
<div><dt>Origen del tramo</dt><dd>{{ selectedSegmentOrigin }}</dd></div>
|
||||
<div><dt>Destino</dt><dd>{{ selectedRouteDetail.json.latitud_pdv }}, {{ selectedRouteDetail.json.longitud_pdv }}</dd></div>
|
||||
<div><dt>Destino</dt><dd><button type="button" class="btn-secondary map-location-button" (click)="openDestinationInGoogleMaps()" title="Abrir ubicación en Google Maps"><i class="pi pi-map"></i> Ver en Google Maps</button></dd></div>
|
||||
<div><dt>Horas trabajo</dt><dd>{{ selectedRouteDetail.json.horas_trabajo }}</dd></div>
|
||||
<div><dt>Distancia tramo</dt><dd>{{ selectedSegmentDistance | number:'1.2-2' }} km</dd></div>
|
||||
<div><dt>Duración tramo</dt><dd>{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min</dd></div>
|
||||
@@ -145,6 +138,13 @@
|
||||
<label>Día<select [(ngModel)]="reassignDay"><option *ngFor="let day of unassignedDayOptions" [value]="day">{{ day }}</option></select></label>
|
||||
<p class="reassign-hint">Se recalcularán la distancia y duración con ruta vial ORS.</p>
|
||||
<div class="reassign-actions"><button type="button" class="btn-primary" (click)="reassignRoute()" [disabled]="reassigningRoute"><span *ngIf="reassigningRoute" class="spinner-inline"></span><i *ngIf="!reassigningRoute" class="pi pi-check"></i> {{ reassigningRoute ? 'Calculando...' : 'Confirmar' }}</button><button type="button" class="btn-secondary" (click)="cancelReassignRoute()" [disabled]="reassigningRoute">Cancelar</button></div>
|
||||
<div class="employee-load-list" aria-label="Carga de empleados">
|
||||
<button type="button" class="employee-load-row" *ngFor="let employee of reassignmentEmployeeLoads" [class.selected]="reassignUserId === employee.id" [class.over-capacity]="employeeLoadStatus(employee) === 'over-capacity'" (click)="selectReassignmentEmployee(employee.id)">
|
||||
<span class="employee-load-name">{{ employee.name }}</span>
|
||||
<span class="employee-load-values">{{ employee.workHours | number:'1.0-1' }} / {{ employee.capacityHours | number:'1.0-1' }} h · {{ employee.utilization | number:'1.0-1' }}%</span>
|
||||
<span class="employee-load-bar" [ngClass]="employeeLoadStatus(employee)"><span [style.width.%]="employeeLoadWidth(employee)"></span></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="error-card" *ngIf="reassignError">{{ reassignError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,26 +152,32 @@
|
||||
|
||||
<section class="daily-routes-card" *ngIf="dailySummaries.length">
|
||||
<h3>Rutas diarias</h3>
|
||||
<article class="daily-route" *ngFor="let day of dailySummaries" [class.over-capacity]="day.exceedsCapacity">
|
||||
<strong>{{ day.userName }} · {{ day.day }}</strong>
|
||||
<span>{{ day.routes.length }} paradas · {{ day.distanceKm | number:'1.1-1' }} km · {{ (day.travelHours * 60) | number:'1.0-0' }} min</span>
|
||||
<small [attr.aria-label]="day.exceedsCapacity ? 'Capacidad excedida' : 'Dentro de capacidad'">{{ day.totalHours | number:'1.1-1' }} / {{ day.capacityHours | number:'1.1-1' }} h {{ day.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}</small>
|
||||
<article class="daily-route-group" *ngFor="let group of dailyGroups">
|
||||
<button type="button" class="daily-route-toggle" (click)="toggleDailyRouteDay(group.day)" [attr.aria-expanded]="isDailyRouteDayExpanded(group.day)">
|
||||
<span class="daily-route-toggle-title"><i class="pi" [class.pi-chevron-right]="!isDailyRouteDayExpanded(group.day)" [class.pi-chevron-down]="isDailyRouteDayExpanded(group.day)"></i> {{ group.day }}</span>
|
||||
<span>{{ group.totalStops }} paradas · {{ group.totalDistance | number:'1.1-1' }} km</span>
|
||||
</button>
|
||||
<div class="daily-route-group-content" *ngIf="isDailyRouteDayExpanded(group.day)">
|
||||
<article class="daily-route" *ngFor="let route of group.routes" [class.over-capacity]="route.exceedsCapacity">
|
||||
<strong>{{ route.userName }}</strong>
|
||||
<span>{{ route.routes.length }} paradas · {{ route.distanceKm | number:'1.1-1' }} km</span>
|
||||
<small [attr.aria-label]="route.exceedsCapacity ? 'Capacidad excedida' : 'Dentro de capacidad'">{{ route.totalHours | number:'1.1-1' }} / {{ route.capacityHours | number:'1.1-1' }} h {{ route.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}</small>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="routes-list" *ngIf="filteredRoutes.length">
|
||||
<h3>{{ selectedUsuarioId === 'Todos' ? 'PDVs de todos los usuarios' : 'PDVs del usuario' }} ({{ filteredRoutes.length }})</h3>
|
||||
<ul>
|
||||
<li *ngFor="let r of filteredRoutes; let i = index" tabindex="0" role="button"
|
||||
[class.selected]="selectedRouteDetail && selectedRouteDetail.json.pdv_id === r.json.pdv_id && selectedRouteDetail.json.usuario_id === r.json.usuario_id && selectedRouteDetail.json.dia === r.json.dia"
|
||||
(click)="selectRouteFromList(r)" (keydown.enter)="selectRouteFromList(r)" (keydown.space)="selectRouteFromList(r); $event.preventDefault()">
|
||||
<span class="idx">{{ routeVisitOrder(r) }}</span>
|
||||
<div class="li-main">
|
||||
<strong>{{ r.json.nombre_pdv }}</strong>
|
||||
<small>{{ selectedUsuarioId === 'Todos' ? r.json.nombre_usuario + ' • ' : '' }}{{ r.json.dia }} • {{ routeSegmentDistance(r) | number:'1.2-2' }} km • {{ routeSegmentDurationMinutes(r) | number:'1.0-0' }} min</small>
|
||||
</div>
|
||||
<i class="pi pi-chevron-right"></i>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<section class="unassigned-bottom-drawer" *ngIf="selectedUsuarioId === 'Todos' && selectedDia === 'Todos' && unassignedRoutes.length" [class.open]="isUnassignedDrawerOpen">
|
||||
<button type="button" class="unassigned-drawer-toggle" (click)="toggleUnassignedDrawer()" [attr.aria-expanded]="isUnassignedDrawerOpen">
|
||||
<span><i class="pi pi-exclamation-triangle"></i> PDVs sin ruta ({{ unassignedRoutes.length }})</span>
|
||||
<i class="pi" [class.pi-chevron-up]="!isUnassignedDrawerOpen" [class.pi-chevron-down]="isUnassignedDrawerOpen"></i>
|
||||
</button>
|
||||
<div class="unassigned-drawer-content" *ngIf="isUnassignedDrawerOpen">
|
||||
<button type="button" class="unassigned-map-item" *ngFor="let pending of unassignedRoutes" [class.selected-pending]="selectedUnassigned?.pdv_id === pending.pdv_id" (click)="selectUnassigned(pending)">
|
||||
<span class="pending-icon">!</span>
|
||||
<span class="pending-text"><strong>{{ pending.pdv_nombre }}</strong><small>{{ pending.motivo }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+218
-33
@@ -11,6 +11,21 @@ import { RouteAssignedResponse, RouteNoAssignedResponse } from '../../../core/mo
|
||||
import { colorForUsuario } from '../../../shared/utils/palette.utils';
|
||||
import { DailyRouteSummary, haversineKm, sequenceRoutes, summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||
|
||||
interface DailyRouteGroup {
|
||||
day: string;
|
||||
routes: DailyRouteSummary[];
|
||||
totalStops: number;
|
||||
totalDistance: number;
|
||||
}
|
||||
|
||||
interface EmployeeLoadOption {
|
||||
id: string;
|
||||
name: string;
|
||||
workHours: number;
|
||||
capacityHours: number;
|
||||
utilization: number;
|
||||
}
|
||||
|
||||
interface MapRoute {
|
||||
data: RouteAssignedResponse;
|
||||
color: string;
|
||||
@@ -45,7 +60,6 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
private selectedMapRoute: MapRoute | null = null;
|
||||
private heatLayer?: any;
|
||||
showHeat = true;
|
||||
heatWeightMode: 'uniform' | 'horas_trabajo' = 'horas_trabajo';
|
||||
private heatPointsCache: [number, number, number][] = [];
|
||||
private unassignedMarkers = new Map<number, L.Marker>();
|
||||
|
||||
@@ -53,6 +67,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
dias: string[] = [];
|
||||
selectedUsuarioId: string = 'Todos';
|
||||
selectedDia: string = 'Todos';
|
||||
expandedDailyRouteDay: string | null = null;
|
||||
selectedRouteDetail: RouteAssignedResponse | null = null;
|
||||
|
||||
filteredRoutes: RouteAssignedResponse[] = [];
|
||||
@@ -63,6 +78,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
orsTotal = 0;
|
||||
error: string | null = null;
|
||||
unassignedRoutes: RouteNoAssignedResponse[] = [];
|
||||
isUnassignedDrawerOpen = false;
|
||||
selectedUnassigned: RouteNoAssignedResponse | null = null;
|
||||
editingUnassigned: RouteNoAssignedResponse | null = null;
|
||||
editUnassignedName = '';
|
||||
@@ -80,17 +96,50 @@ 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>();
|
||||
for (const route of this.filteredRoutes) {
|
||||
const pdvId = String(route.json.pdv_id);
|
||||
if (!unique.has(pdvId)) unique.set(pdvId, route);
|
||||
}
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
get totalDistancia(): string {
|
||||
const uniqueIds = new Set(this.uniqueFilteredRoutes.map(route => String(route.json.pdv_id)));
|
||||
const sum = this.mapRoutes.length
|
||||
? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDistanceKm ?? haversineKm(route.origin, route.destination)), 0)
|
||||
: this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.distancia), 0);
|
||||
? this.mapRoutes
|
||||
.filter(route => uniqueIds.has(String(route.data.json.pdv_id)))
|
||||
.reduce((acc, route) => {
|
||||
const firstSegment = this.mapRoutes.find(candidate => String(candidate.data.json.pdv_id) === String(route.data.json.pdv_id));
|
||||
return firstSegment === route
|
||||
? acc + (route.segmentDistanceKm ?? haversineKm(route.origin, route.destination))
|
||||
: acc;
|
||||
}, 0)
|
||||
: this.uniqueFilteredRoutes.reduce((acc, route) => acc + Number(route.json.distancia), 0);
|
||||
return sum.toFixed(2);
|
||||
}
|
||||
get totalDuracionMin(): string {
|
||||
const uniqueIds = new Set(this.uniqueFilteredRoutes.map(route => String(route.json.pdv_id)));
|
||||
const sum = this.mapRoutes.length
|
||||
? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDurationHours ?? Number(route.data.json.horas_desplazamiento)) * 60, 0)
|
||||
: this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.horas_desplazamiento) * 60, 0);
|
||||
? this.mapRoutes
|
||||
.filter(route => uniqueIds.has(String(route.data.json.pdv_id)))
|
||||
.reduce((acc, route) => {
|
||||
const firstSegment = this.mapRoutes.find(candidate => String(candidate.data.json.pdv_id) === String(route.data.json.pdv_id));
|
||||
return firstSegment === route
|
||||
? acc + (route.segmentDurationHours ?? Number(route.data.json.horas_desplazamiento)) * 60
|
||||
: acc;
|
||||
}, 0)
|
||||
: this.uniqueFilteredRoutes.reduce((acc, route) => acc + Number(route.json.horas_desplazamiento) * 60, 0);
|
||||
return sum.toFixed(0);
|
||||
}
|
||||
|
||||
@@ -98,6 +147,44 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
return summarizeDailyRoutes(this.filteredRoutes);
|
||||
}
|
||||
|
||||
get dailyGroups(): DailyRouteGroup[] {
|
||||
const order = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes', 'Sábado', 'Sabado'];
|
||||
const groups = new Map<string, DailyRouteSummary[]>();
|
||||
for (const summary of this.dailySummaries) {
|
||||
const routes = groups.get(summary.day) ?? [];
|
||||
routes.push(summary);
|
||||
groups.set(summary.day, routes);
|
||||
}
|
||||
return Array.from(groups, ([day, routes]) => ({
|
||||
day,
|
||||
routes,
|
||||
totalStops: routes.reduce((sum, route) => sum + route.routes.length, 0),
|
||||
totalDistance: routes.reduce((sum, route) => sum + route.distanceKm, 0)
|
||||
})).sort((a, b) => order.indexOf(a.day) - order.indexOf(b.day));
|
||||
}
|
||||
|
||||
toggleDailyRouteDay(day: string): void {
|
||||
this.expandedDailyRouteDay = this.expandedDailyRouteDay === day ? null : day;
|
||||
}
|
||||
|
||||
isDailyRouteDayExpanded(day: string): boolean {
|
||||
return this.expandedDailyRouteDay === day;
|
||||
}
|
||||
|
||||
openDestinationInGoogleMaps(): void {
|
||||
if (!this.selectedRouteDetail) return;
|
||||
const { latitud_pdv, longitud_pdv } = this.selectedRouteDetail.json;
|
||||
const latitude = Number(latitud_pdv);
|
||||
const longitude = Number(longitud_pdv);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return;
|
||||
const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${latitude},${longitude}`)}`;
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
toggleUnassignedDrawer(): void {
|
||||
this.isUnassignedDrawerOpen = !this.isUnassignedDrawerOpen;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private routeState: RouteState,
|
||||
private orsService: OrsService,
|
||||
@@ -210,11 +297,26 @@ 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');
|
||||
pdvPane.style.zIndex = '650';
|
||||
const heatPane = this.map.createPane('heatPane');
|
||||
pdvPane.style.zIndex = '750';
|
||||
heatPane.style.zIndex = '700';
|
||||
const popupPane = this.map.getPane('popupPane');
|
||||
if (popupPane) popupPane.style.zIndex = '1000';
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
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);
|
||||
@@ -253,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;
|
||||
}
|
||||
@@ -299,14 +404,14 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
// Marker for PDV
|
||||
const pdvIcon = L.divIcon({
|
||||
className: 'custom-marker marker-end',
|
||||
html: `<div class="location-pin"><span>${segment.order}</span></div>`,
|
||||
iconSize: [30, 38],
|
||||
iconAnchor: [15, 38],
|
||||
html: `<div class="location-pin-wrapper"><div class="location-pin"><span>${segment.order}</span></div></div>`,
|
||||
iconSize: [42, 50],
|
||||
iconAnchor: [21, 46],
|
||||
popupAnchor: [0, -18]
|
||||
});
|
||||
const marker = L.marker([Number(j.latitud_pdv), Number(j.longitud_pdv)], { icon: pdvIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
||||
.addTo(this.map!)
|
||||
.bindPopup(`<strong>${j.nombre_pdv}</strong><br>Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`);
|
||||
.bindPopup(`<strong>${j.nombre_pdv}</strong><br><strong>Usuario:</strong> ${j.nombre_usuario}<br>Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`);
|
||||
// Heat point
|
||||
const w = this.computeWeight(j);
|
||||
this.heatPointsCache.push([Number(j.latitud_pdv), Number(j.longitud_pdv), w]);
|
||||
@@ -331,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();
|
||||
|
||||
@@ -345,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) {
|
||||
@@ -369,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();
|
||||
@@ -436,7 +571,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
mr.segmentDistanceKm = summary.distance / 1000;
|
||||
mr.segmentDurationHours = summary.duration / 3600;
|
||||
mr.marker?.setPopupContent(
|
||||
`<strong>${mr.data.json.nombre_pdv}</strong><br>Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}<br>${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min`
|
||||
`<strong>${mr.data.json.nombre_pdv}</strong><br><strong>Usuario:</strong> ${mr.data.json.nombre_usuario}<br>Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}<br>${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min`
|
||||
);
|
||||
}
|
||||
line.on('click', () => this.selectRoute(mr));
|
||||
@@ -479,6 +614,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
|
||||
private selectRoute(mr: MapRoute) {
|
||||
this.selectedUnassigned = null;
|
||||
this.editingUnassigned = null;
|
||||
this.unassignedActionError = null;
|
||||
this.selectedRouteDetail = mr.data;
|
||||
this.highlightRoute(mr.data);
|
||||
this.fitSelectedRoute(mr);
|
||||
@@ -486,6 +624,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
|
||||
selectRouteFromList(route: RouteAssignedResponse): void {
|
||||
this.selectedUnassigned = null;
|
||||
this.editingUnassigned = null;
|
||||
this.unassignedActionError = null;
|
||||
const mapRoute = this.mapRoutes.find(mr => this.sameRoute(mr.data, route));
|
||||
if (mapRoute) {
|
||||
this.selectRoute(mapRoute);
|
||||
@@ -634,14 +775,62 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
left.json.dia === right.json.dia;
|
||||
}
|
||||
|
||||
private getEmployeeLoads(excluding?: RouteAssignedResponse): EmployeeLoadOption[] {
|
||||
const profiles = new Map<string, RouteAssignedResponse>();
|
||||
const workHours = new Map<string, number>();
|
||||
for (const route of this.routeState.getAssignedSnapshot()) {
|
||||
const id = String(route.json.usuario_id);
|
||||
if (!profiles.has(id)) profiles.set(id, route);
|
||||
if (route !== excluding) {
|
||||
workHours.set(id, (workHours.get(id) ?? 0) + Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0));
|
||||
}
|
||||
}
|
||||
return Array.from(profiles, ([id, profile]) => {
|
||||
const capacityHours = Number(profile.json.hora_laboral_semanal_usuario || 0);
|
||||
const assignedHours = workHours.get(id) ?? 0;
|
||||
return {
|
||||
id,
|
||||
name: profile.json.nombre_usuario,
|
||||
workHours: assignedHours,
|
||||
capacityHours,
|
||||
utilization: capacityHours ? (assignedHours / capacityHours) * 100 : 0
|
||||
};
|
||||
}).sort((a, b) => a.utilization - b.utilization || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
get unassignedUserOptions(): { id: string; name: string }[] {
|
||||
return this.routeState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre }));
|
||||
}
|
||||
|
||||
get unassignedEmployeeLoads(): EmployeeLoadOption[] {
|
||||
return this.getEmployeeLoads();
|
||||
}
|
||||
|
||||
get reassignmentEmployeeLoads(): EmployeeLoadOption[] {
|
||||
return this.getEmployeeLoads(this.selectedRouteDetail ?? undefined);
|
||||
}
|
||||
|
||||
get unassignedDayOptions(): string[] {
|
||||
return this.routeState.getDiasUnicos();
|
||||
}
|
||||
|
||||
employeeLoadWidth(employee: EmployeeLoadOption): number {
|
||||
return Math.min(Math.max(employee.utilization, 0), 100);
|
||||
}
|
||||
|
||||
employeeLoadStatus(employee: EmployeeLoadOption): 'ok' | 'warning' | 'over-capacity' {
|
||||
return employee.utilization > 100 ? 'over-capacity' : employee.utilization > 90 ? 'warning' : 'ok';
|
||||
}
|
||||
|
||||
selectUnassignedEmployee(employeeId: string): void {
|
||||
if (!this.selectedUnassigned) return;
|
||||
this.assignmentUser[String(this.selectedUnassigned.pdv_id)] = employeeId;
|
||||
}
|
||||
|
||||
selectReassignmentEmployee(employeeId: string): void {
|
||||
this.reassignUserId = employeeId;
|
||||
}
|
||||
|
||||
toggleUnassignedDay(pdvId: number, day: string, checked: boolean): void {
|
||||
const key = String(pdvId);
|
||||
const days = new Set(this.assignmentDays[key] ?? []);
|
||||
@@ -652,6 +841,8 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
selectUnassigned(route: RouteNoAssignedResponse): void {
|
||||
this.selectedUnassigned = route;
|
||||
this.selectedRouteDetail = null;
|
||||
this.showReassignForm = false;
|
||||
this.reassignError = null;
|
||||
this.map?.setView([route.latitud, route.longitud], Math.max(this.map.getZoom(), 14));
|
||||
this.unassignedMarkers.get(route.pdv_id)?.openPopup();
|
||||
this.cdr.detectChanges();
|
||||
@@ -756,13 +947,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
|
||||
private computeWeight(j: RouteAssignedResponse['json']): number {
|
||||
if (this.heatWeightMode === 'uniform') return 0.8;
|
||||
const v = parseFloat(String(j.horas_trabajo));
|
||||
if (isNaN(v)) return 0.8;
|
||||
if (isNaN(v)) return 1;
|
||||
const vals = this.filteredRoutes.map(r => parseFloat(String(r.json.horas_trabajo))).filter(n => !isNaN(n));
|
||||
if (!vals.length) return 0.8;
|
||||
if (!vals.length) return 1;
|
||||
const min = Math.min(...vals), max = Math.max(...vals);
|
||||
if (max === min) return 0.8;
|
||||
if (max === min) return 1;
|
||||
return 0.3 + 0.7 * (v - min) / (max - min);
|
||||
}
|
||||
|
||||
@@ -773,8 +963,13 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
if (!this.showHeat || !this.heatPointsCache.length || !this.map) return;
|
||||
this.heatLayer = (L as any).heatLayer(this.heatPointsCache, {
|
||||
radius: 25, blur: 18, maxZoom: 17, minOpacity: 0.4,
|
||||
gradient: { 0.4: '#6CC24A', 0.65: '#FF6A13', 1.0: '#D32F2F' }
|
||||
pane: 'heatPane',
|
||||
radius: 32,
|
||||
blur: 24,
|
||||
maxZoom: 18,
|
||||
minOpacity: 0.55,
|
||||
max: 1.8,
|
||||
gradient: { 0.2: '#b7f05f', 0.45: '#6CC24A', 0.7: '#FF6A13', 1.0: '#D32F2F' }
|
||||
}).addTo(this.map);
|
||||
}
|
||||
|
||||
@@ -789,16 +984,6 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
setHeatWeight(mode: 'uniform' | 'horas_trabajo') {
|
||||
this.heatWeightMode = mode;
|
||||
this.heatPointsCache = this.filteredRoutes.map(r => {
|
||||
const j = r.json;
|
||||
return [Number(j.latitud_pdv), Number(j.longitud_pdv), this.computeWeight(j)] as [number, number, number];
|
||||
});
|
||||
this.updateHeatLayer();
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
private toggleLayerVisibility() {
|
||||
this.mapRoutes.forEach(mr => {
|
||||
if (mr.fallbackLine) (mr.fallbackLine as any).setStyle({ opacity: 0.6 });
|
||||
@@ -817,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; }
|
||||
@@ -87,13 +87,17 @@
|
||||
box-shadow: 0 0 0 3px rgba(108, 194, 74, 0.15);
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
.google-login {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
padding: 11px;
|
||||
background: var(--glm-azul);
|
||||
color: #fff;
|
||||
border: none;
|
||||
background: #fff;
|
||||
color: var(--glm-gris-oscuro);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
font-family: Arial, sans-serif;
|
||||
@@ -102,15 +106,25 @@ button[type="submit"] {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button[type="submit"]:hover:not(:disabled) {
|
||||
background: var(--glm-verde);
|
||||
.google-login:hover:not(:disabled) {
|
||||
border-color: var(--glm-azul);
|
||||
background: var(--glm-verde-bg);
|
||||
}
|
||||
|
||||
button[type="submit"]:disabled {
|
||||
.google-login:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.google-mark { color: #4285f4; font-size: 1.1rem; font-weight: 700; }
|
||||
.domain-note { margin: 10px 0 0; color: var(--glm-acero); font-size: .78rem; text-align: center; }
|
||||
|
||||
.login-error {
|
||||
margin: 12px 0 0;
|
||||
color: #c9252d;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
|
||||
@@ -11,36 +11,15 @@
|
||||
Planifica rutas inteligentes de forma eficiente
|
||||
</p>
|
||||
|
||||
<form (ngSubmit)="login()" #form="ngForm">
|
||||
|
||||
<div class="input-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
[(ngModel)]="email"
|
||||
required
|
||||
placeholder="usuario@empresa.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
[(ngModel)]="password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" [disabled]="loading || form.invalid">
|
||||
<span *ngIf="!loading">Iniciar sesión</span>
|
||||
<span *ngIf="loading">Verificando ruta...</span>
|
||||
<div class="login-actions">
|
||||
<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">{{ loadingText }}</span>
|
||||
</button>
|
||||
|
||||
</form>
|
||||
<p class="domain-note">Solo cuentas @gomezleemarketing.com</p>
|
||||
<p class="login-error" *ngIf="error">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
© 2026 Route Planner System
|
||||
|
||||
@@ -1,29 +1,57 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, ChangeDetectorRef, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { UserProfileService } from '../../core/services/user-profile.service';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-login',
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [CommonModule],
|
||||
templateUrl: './login.component.html',
|
||||
styleUrls: ['./login.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class LoginComponent {
|
||||
export class LoginComponent implements OnInit {
|
||||
|
||||
email = '';
|
||||
password = '';
|
||||
loading = false;
|
||||
error = '';
|
||||
loadingText = 'Redirigiendo a Google...';
|
||||
|
||||
constructor(private router: Router) {}
|
||||
login() {
|
||||
constructor(private router: Router, private auth: AuthService, private profile: UserProfileService, private cdr: ChangeDetectorRef) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const session = await this.auth.getSession();
|
||||
if (!session) return;
|
||||
this.loading = true;
|
||||
|
||||
setTimeout(() => {
|
||||
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.router.navigate(['/home']);
|
||||
}, 1500);
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
async loginWithGoogle() {
|
||||
this.error = '';
|
||||
this.loading = true;
|
||||
this.loadingText = 'Redirigiendo a Google...';
|
||||
try {
|
||||
const error = await this.auth.signInWithGoogle();
|
||||
if (error) {
|
||||
this.error = error;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
this.error = 'No se pudo iniciar sesión. Intenta de nuevo.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,4 +36,4 @@ export const usuarioRowSchema = z.object({
|
||||
});
|
||||
|
||||
export type UsuarioRow = z.infer<typeof usuarioRowSchema>;
|
||||
export const USUARIO_MAX_ROWS = 50;
|
||||
export const USUARIO_MAX_ROWS = 300;
|
||||
|
||||
@@ -1,6 +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,6 +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',
|
||||
};
|
||||
|
||||
@@ -40,6 +40,12 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* leaflet.heat creates its canvas in the map overlay pane, outside Angular styles. */
|
||||
.leaflet-heatmap-layer {
|
||||
z-index: 700 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--glm-verde: #6CC24A;
|
||||
--glm-verde-claro: #A4D65E;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
alter table public.rutero_empleados
|
||||
add column if not exists jornada_codigo text not null default 'LUN_VIE_40',
|
||||
add column if not exists horas_descanso_por_dia numeric(4,2) not null default 1,
|
||||
add column if not exists dias_descanso jsonb not null default '[]'::jsonb;
|
||||
|
||||
alter table public.rutero_empleados
|
||||
add constraint rutero_empleados_jornada_valida
|
||||
check (jornada_codigo in ('LUN_DOM_44', 'LUN_SAB_40', 'LUN_SAB_44', 'LUN_VIE_40')) not valid;
|
||||
|
||||
alter table public.rutero_empleados
|
||||
add constraint rutero_empleados_descanso_valido
|
||||
check (horas_descanso_por_dia >= 0 and horas_descanso_por_dia < 24) not valid;
|
||||
|
||||
alter table public.rutero_empleados enable row level security;
|
||||
|
||||
drop policy if exists "Solo GomezLee puede consultar empleados" on public.rutero_empleados;
|
||||
drop policy if exists "Solo GomezLee puede crear empleados" on public.rutero_empleados;
|
||||
drop policy if exists "Solo GomezLee puede editar empleados" on public.rutero_empleados;
|
||||
drop policy if exists "Solo GomezLee puede eliminar empleados" on public.rutero_empleados;
|
||||
|
||||
create policy "Solo GomezLee puede consultar empleados"
|
||||
on public.rutero_empleados for select to authenticated
|
||||
using (lower(coalesce(auth.jwt() ->> 'email', '')) like '%@gomezleemarketing.com');
|
||||
|
||||
create policy "Solo GomezLee puede crear empleados"
|
||||
on public.rutero_empleados for insert to authenticated
|
||||
with check (lower(coalesce(auth.jwt() ->> 'email', '')) like '%@gomezleemarketing.com');
|
||||
|
||||
create policy "Solo GomezLee puede editar empleados"
|
||||
on public.rutero_empleados for update to authenticated
|
||||
using (lower(coalesce(auth.jwt() ->> 'email', '')) like '%@gomezleemarketing.com')
|
||||
with check (lower(coalesce(auth.jwt() ->> 'email', '')) like '%@gomezleemarketing.com');
|
||||
|
||||
create policy "Solo GomezLee puede eliminar empleados"
|
||||
on public.rutero_empleados for delete to authenticated
|
||||
using (lower(coalesce(auth.jwt() ->> 'email', '')) like '%@gomezleemarketing.com');
|
||||
Reference in New Issue
Block a user