Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c652a0bd7 | |||
| c45182012c |
Generated
+102
@@ -15,6 +15,7 @@
|
|||||||
"@angular/platform-browser": "^21.1.0",
|
"@angular/platform-browser": "^21.1.0",
|
||||||
"@angular/router": "^21.1.0",
|
"@angular/router": "^21.1.0",
|
||||||
"@primeng/themes": "^21.0.0",
|
"@primeng/themes": "^21.0.0",
|
||||||
|
"@supabase/supabase-js": "^2.115.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.heat": "^0.2.0",
|
"leaflet.heat": "^0.2.0",
|
||||||
@@ -4037,6 +4038,98 @@
|
|||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@supabase/auth-js": {
|
||||||
|
"version": "2.115.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.115.0.tgz",
|
||||||
|
"integrity": "sha512-YNQlQWm1H0gsXHSY8Jd/xepBhjO0Zhwx04iW17A83/joQ5kFiUin6iPj9s9kZZvupnwLjXVP/diTFiLz2jUbwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/functions-js": {
|
||||||
|
"version": "2.115.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.115.0.tgz",
|
||||||
|
"integrity": "sha512-p97V6/YFcdp+zblFDVJaE8f9rGKTNz0PRzyJ2d1w/EYIU5lwidKuc3l/wM+u27ACmAC62albvoGiUGzLPSg7Aw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/phoenix": {
|
||||||
|
"version": "0.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
|
||||||
|
"integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/postgrest-js": {
|
||||||
|
"version": "2.115.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.115.0.tgz",
|
||||||
|
"integrity": "sha512-DdERcurLh5t84pgSywDg2LjLR5le7XAzl52/iQhC7FdboWDqGfDPMfaWJV3MHvhyxSlSNzbpSQG+RiQOBSn/4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/realtime-js": {
|
||||||
|
"version": "2.115.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.115.0.tgz",
|
||||||
|
"integrity": "sha512-5HyBkvlA/IUV2v8jX3uLTdy15jmTPRVXwXKoxvH2SKw5jZMNURxCxt+VpCEukscXQlY7pUpH8Cy4NH6io4iaRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/phoenix": "0.4.5",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/storage-js": {
|
||||||
|
"version": "2.115.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.115.0.tgz",
|
||||||
|
"integrity": "sha512-dLyIxzbO+MCcKHhcce8rVUCQX1iyqXqQ8ytgkOVYJ7D+Zp0qKylPtQH3hamgxrGSxtDjaw47Urpzw2iK9PsKdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iceberg-js": "^0.8.1",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/supabase-js": {
|
||||||
|
"version": "2.115.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.115.0.tgz",
|
||||||
|
"integrity": "sha512-PYJSxtCo37R7tTZW6pAqsxUeSx/dlhA7zn8RzKEUSCqyTxCUhG+iHrDTb04UyVBYbttaUbhwkNTvb8h5HW+uZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/auth-js": "2.115.0",
|
||||||
|
"@supabase/functions-js": "2.115.0",
|
||||||
|
"@supabase/postgrest-js": "2.115.0",
|
||||||
|
"@supabase/realtime-js": "2.115.0",
|
||||||
|
"@supabase/storage-js": "2.115.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentelemetry/api": ">=1.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tufjs/canonical-json": {
|
"node_modules/@tufjs/canonical-json": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
|
||||||
@@ -5888,6 +5981,15 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iceberg-js": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.3",
|
"version": "0.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@angular/platform-browser": "^21.1.0",
|
"@angular/platform-browser": "^21.1.0",
|
||||||
"@angular/router": "^21.1.0",
|
"@angular/router": "^21.1.0",
|
||||||
"@primeng/themes": "^21.0.0",
|
"@primeng/themes": "^21.0.0",
|
||||||
|
"@supabase/supabase-js": "^2.115.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.heat": "^0.2.0",
|
"leaflet.heat": "^0.2.0",
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import { LoginComponent } from './public/login/login.component';
|
|||||||
import { HomeComponent } from './private/home/home.component';
|
import { HomeComponent } from './private/home/home.component';
|
||||||
import { AssignedRouteDetailComponent } from './private/home/assigned-route-detail/assigned-route-detail.component';
|
import { AssignedRouteDetailComponent } from './private/home/assigned-route-detail/assigned-route-detail.component';
|
||||||
import { RouteOverviewMapComponent } from './private/home/route-overview-map/route-overview-map.component';
|
import { RouteOverviewMapComponent } from './private/home/route-overview-map/route-overview-map.component';
|
||||||
|
import { authGuard } from './core/services/auth.guard';
|
||||||
|
import { EmployeesComponent } from './private/employees/employees.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', redirectTo: 'login', pathMatch: 'full' },
|
{ path: '', redirectTo: 'login', pathMatch: 'full' },
|
||||||
{ path: 'login', component: LoginComponent },
|
{ path: 'login', component: LoginComponent },
|
||||||
{ path: 'home', component: HomeComponent, pathMatch: 'full' },
|
{ path: 'home', component: HomeComponent, pathMatch: 'full', canActivate: [authGuard] },
|
||||||
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent },
|
{ path: 'empleados', component: EmployeesComponent, canActivate: [authGuard] },
|
||||||
{ path: 'home/mapa', component: RouteOverviewMapComponent },
|
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent, canActivate: [authGuard] },
|
||||||
|
{ path: 'home/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] },
|
||||||
{ path: '**', redirectTo: 'login' }
|
{ path: '**', redirectTo: 'login' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export interface Employee {
|
||||||
|
id: string;
|
||||||
|
employee_code: string;
|
||||||
|
nombre: string;
|
||||||
|
usuario: string | null;
|
||||||
|
email: string | null;
|
||||||
|
perfil: string | null;
|
||||||
|
pais: string | null;
|
||||||
|
regional: string | null;
|
||||||
|
latitud: number;
|
||||||
|
longitud: number;
|
||||||
|
horas_por_dia: number;
|
||||||
|
dias_por_semana: number;
|
||||||
|
jornada_codigo: string;
|
||||||
|
horas_descanso_por_dia: number;
|
||||||
|
dias_descanso: string[];
|
||||||
|
visitas_maximas_por_dia: number;
|
||||||
|
activo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmployeeInput = Omit<Employee, 'id'>;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export interface 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];
|
||||||
|
}
|
||||||
@@ -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,37 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Session } from '@supabase/supabase-js';
|
||||||
|
import { supabaseClient } from './supabase.client';
|
||||||
|
|
||||||
|
const CORPORATE_DOMAIN = '@gomezleemarketing.com';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AuthService {
|
||||||
|
private readonly client = supabaseClient;
|
||||||
|
|
||||||
|
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',
|
||||||
|
options: { redirectTo: `${window.location.origin}/home` }
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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 activeEmployeesFile(): Promise<File> {
|
||||||
|
const employees = (await this.list()).filter(employee => employee.activo);
|
||||||
|
const rows = employees.map(employee => ({
|
||||||
|
ID: employee.employee_code,
|
||||||
|
'Nombre del empleado': employee.nombre,
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,20 @@
|
|||||||
|
.employees-topbar { display: flex; align-items: center; gap: 12px; padding: 9px 24px; border-bottom: 3px solid var(--glm-verde); background: #fff; color: var(--glm-azul); font-weight: 700; }
|
||||||
|
.employees-topbar 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="topbar-actions"><button type="button" (click)="goHome()"><i class="pi pi-home"></i> Planificación</button><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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,272 @@
|
|||||||
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { Employee, EmployeeInput } from '../../core/models/employee.model';
|
||||||
|
import { EmployeeService } from '../../core/services/employee.service';
|
||||||
|
import { AuthService } from '../../core/services/auth.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 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']);
|
||||||
|
}
|
||||||
|
|
||||||
|
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'] ?? '')),
|
||||||
|
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));
|
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.location-pin:hover {
|
||||||
|
transform: scale(1.12);
|
||||||
|
transform-origin: center bottom;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.location-pin::before {
|
.location-pin::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
+1
-1
@@ -270,7 +270,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
.bindPopup(`<strong>Origen del tramo:</strong> ${originName}`),
|
.bindPopup(`<strong>Origen del tramo:</strong> ${originName}`),
|
||||||
L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
||||||
.addTo(this.map)
|
.addTo(this.map)
|
||||||
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
|
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Usuario:</strong> ${json.nombre_usuario}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,11 +38,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-header {
|
.home-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
border-bottom: 3px solid var(--glm-verde);
|
border-bottom: 3px solid var(--glm-verde);
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.employees-link { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 1px solid var(--glm-verde); border-radius: 4px; background: var(--glm-verde-bg); color: var(--glm-azul); font-size: .82rem; font-weight: 700; text-decoration: none; white-space: nowrap; }
|
||||||
|
.employees-link:hover { background: var(--glm-verde); color: #fff; }
|
||||||
|
|
||||||
.home-header h1 {
|
.home-header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--glm-azul);
|
color: var(--glm-azul);
|
||||||
@@ -79,6 +86,8 @@
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.employees-summary { display: flex; align-items: center; gap: 7px; margin: -2px 0 16px; color: var(--glm-acero); font-size: .88rem; }.employees-summary i { color: var(--glm-verde); }.employees-summary.empty, .employees-summary.empty i { color: #c9252d; }
|
||||||
|
|
||||||
.upload-grid {
|
.upload-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
|
|
||||||
<div class="home-container">
|
<div class="home-container">
|
||||||
<header class="home-header">
|
<header class="home-header">
|
||||||
<h1>Route Planner</h1>
|
<div><h1>Route Planner</h1><p>Gestión de rutas asignadas y puntos de venta pendientes</p></div>
|
||||||
<p>Gestión de rutas asignadas y puntos de venta pendientes</p>
|
<a class="employees-link" routerLink="/empleados"><i class="pi pi-users"></i> Gestionar empleados</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card upload-card">
|
<section class="card upload-card">
|
||||||
<h2 class="section-title">Subir archivos para optimización</h2>
|
<h2 class="section-title">Subir PDVs para optimización</h2>
|
||||||
|
<p class="employees-summary" [class.empty]="!employeesLoading && !activeEmployees"><i class="pi pi-users"></i> <span *ngIf="employeesLoading">Cargando empleados activos...</span><span *ngIf="!employeesLoading">{{ activeEmployees }} empleados activos se usarán para planificar.</span></p>
|
||||||
|
|
||||||
<div class="upload-grid">
|
<div class="upload-grid">
|
||||||
<div class="file-input-wrapper">
|
<div class="file-input-wrapper">
|
||||||
@@ -57,48 +58,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="file-input-wrapper">
|
|
||||||
<label class="file-label" for="usuariosFile" [class.has-error]="usuariosPreview && !usuariosPreview.valid" [class.has-success]="usuariosPreview?.valid">
|
|
||||||
<i class="pi pi-file-excel"></i>
|
|
||||||
<span>Archivo Usuarios (Excel)</span>
|
|
||||||
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 50 filas</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="usuariosFile"
|
|
||||||
type="file"
|
|
||||||
accept=".xlsx,.xls"
|
|
||||||
(change)="onUsuariosFileChange($event)"
|
|
||||||
class="file-input"
|
|
||||||
[class.has-file]="usuariosFile"
|
|
||||||
/>
|
|
||||||
<div class="file-name" *ngIf="usuariosFile">
|
|
||||||
<i class="pi" [class.pi-check-circle]="usuariosPreview?.valid" [class.pi-exclamation-circle]="usuariosPreview && !usuariosPreview.valid"></i>
|
|
||||||
{{ usuariosFile.name }}
|
|
||||||
<span *ngIf="usuariosPreviewLoading" class="preview-loading">Validando...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="preview-card" *ngIf="usuariosPreviewLoading">
|
|
||||||
<div class="preview-loading-row"><span class="spinner-inline"></span> Analizando archivo Usuarios...</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="preview-card preview-success" *ngIf="usuariosPreview?.valid">
|
|
||||||
<div class="preview-header"><i class="pi pi-check-circle"></i> Usuarios válido — {{ usuariosPreview!.totalRows }} filas</div>
|
|
||||||
<div class="preview-stats">{{ usuariosPreview!.validRows }} filas válidas • Columnas: {{ usuariosPreview!.headers.join(', ') }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="preview-card preview-error" *ngIf="usuariosPreview && !usuariosPreview.valid">
|
|
||||||
<div class="preview-header"><i class="pi pi-exclamation-triangle"></i> Errores en Usuarios ({{ usuariosPreview.errors.length }})</div>
|
|
||||||
<div class="preview-stats" *ngIf="usuariosPreview.missingHeaders.length">Faltan: {{ usuariosPreview.missingHeaders.join(', ') }}</div>
|
|
||||||
<div class="preview-stats">{{ usuariosPreview.totalRows }} filas leídas • {{ usuariosPreview.validRows }} válidas</div>
|
|
||||||
<ul class="preview-errors">
|
|
||||||
<li *ngFor="let e of usuariosPreview.errors.slice(0, 8)">
|
|
||||||
<strong>Fila {{ e.row }} — {{ e.field }}:</strong> {{ e.message }}
|
|
||||||
<span *ngIf="e.value !== undefined && e.value !== null" class="error-value">({{ e.value }})</span>
|
|
||||||
</li>
|
|
||||||
<li *ngIf="usuariosPreview.errors.length > 8" class="more-errors">+ {{ usuariosPreview.errors.length - 8 }} errores más...</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="upload-actions">
|
<div class="upload-actions">
|
||||||
@@ -130,7 +89,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
(click)="clearFiles()"
|
(click)="clearFiles()"
|
||||||
[disabled]="!pdvFile && !usuariosFile"
|
[disabled]="!pdvFile"
|
||||||
>
|
>
|
||||||
<i class="pi pi-times"></i> Limpiar
|
<i class="pi pi-times"></i> Limpiar
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, ChangeDetectorRef } from '@angular/core';
|
import { Component, ChangeDetectorRef, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { AssignedRouteComponent } from './assigned-route/assigned-route.component';
|
import { AssignedRouteComponent } from './assigned-route/assigned-route.component';
|
||||||
@@ -10,21 +10,25 @@ import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-
|
|||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component';
|
import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component';
|
||||||
import { PlanningInputService } from '../../core/services/planning-input.service';
|
import { PlanningInputService } from '../../core/services/planning-input.service';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { EmployeeService } from '../../core/services/employee.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent, PlanningKpisComponent],
|
imports: [CommonModule, FormsModule, RouterLink, AssignedRouteComponent, NoAssignedRouteComponent, PlanningKpisComponent],
|
||||||
templateUrl: './home.component.html',
|
templateUrl: './home.component.html',
|
||||||
styleUrls: ['./home.component.css']
|
styleUrls: ['./home.component.css']
|
||||||
})
|
})
|
||||||
export class HomeComponent {
|
export class HomeComponent implements OnInit {
|
||||||
pdvFile: File | null = null;
|
pdvFile: File | null = null;
|
||||||
usuariosFile: File | null = null;
|
usuariosFile: File | null = null;
|
||||||
pdvPreview: PreviewResult | null = null;
|
pdvPreview: PreviewResult | null = null;
|
||||||
usuariosPreview: PreviewResult | null = null;
|
usuariosPreview: PreviewResult | null = null;
|
||||||
pdvPreviewLoading = false;
|
pdvPreviewLoading = false;
|
||||||
usuariosPreviewLoading = false;
|
usuariosPreviewLoading = false;
|
||||||
|
activeEmployees = 0;
|
||||||
|
employeesLoading = true;
|
||||||
uploadError: string | null = null;
|
uploadError: string | null = null;
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
private calcSub: Subscription | null = null;
|
private calcSub: Subscription | null = null;
|
||||||
@@ -34,9 +38,26 @@ export class HomeComponent {
|
|||||||
private routeState: RouteState,
|
private routeState: RouteState,
|
||||||
private previewService: ExcelPreviewService,
|
private previewService: ExcelPreviewService,
|
||||||
private planningInput: PlanningInputService,
|
private planningInput: PlanningInputService,
|
||||||
|
private employeesService: EmployeeService,
|
||||||
private cdr: ChangeDetectorRef
|
private cdr: ChangeDetectorRef
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
void this.loadEmployees();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadEmployees(): Promise<void> {
|
||||||
|
this.employeesLoading = true;
|
||||||
|
try {
|
||||||
|
this.activeEmployees = (await this.employeesService.list()).filter(employee => employee.activo).length;
|
||||||
|
} catch {
|
||||||
|
this.activeEmployees = 0;
|
||||||
|
} finally {
|
||||||
|
this.employeesLoading = false;
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async onPdvFileChange(event: Event) {
|
async onPdvFileChange(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
if (input.files && input.files.length > 0) {
|
if (input.files && input.files.length > 0) {
|
||||||
@@ -132,23 +153,22 @@ export class HomeComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get canSubmit(): boolean {
|
get canSubmit(): boolean {
|
||||||
if (!this.pdvFile || !this.usuariosFile) return false;
|
if (!this.pdvFile || !this.activeEmployees) return false;
|
||||||
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return false;
|
if (this.pdvPreviewLoading || this.employeesLoading) return false;
|
||||||
if (this.isLoading) return false;
|
if (this.isLoading) return false;
|
||||||
if (this.uploadError) return false;
|
if (this.uploadError) return false;
|
||||||
// Require valid previews before enabling submit
|
// Require valid previews before enabling submit
|
||||||
if (this.pdvPreview && !this.pdvPreview.valid) return false;
|
if (this.pdvPreview && !this.pdvPreview.valid) return false;
|
||||||
if (this.usuariosPreview && !this.usuariosPreview.valid) return false;
|
if (!this.pdvPreview) return false;
|
||||||
// Previews must exist (file was parsed)
|
|
||||||
if (!this.pdvPreview || !this.usuariosPreview) return false;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
get submitDisabledReason(): string | null {
|
get submitDisabledReason(): string | null {
|
||||||
if (!this.pdvFile || !this.usuariosFile) return 'Sube ambos archivos';
|
if (!this.pdvFile) return 'Sube el archivo PDV';
|
||||||
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return 'Validando archivos...';
|
if (this.employeesLoading) return 'Cargando empleados...';
|
||||||
|
if (!this.activeEmployees) return 'No hay empleados activos. Regístralos en Gestionar empleados.';
|
||||||
|
if (this.pdvPreviewLoading) return 'Validando archivo PDV...';
|
||||||
if (this.pdvPreview && !this.pdvPreview.valid) return 'Corrige los errores del archivo PDV';
|
if (this.pdvPreview && !this.pdvPreview.valid) return 'Corrige los errores del archivo PDV';
|
||||||
if (this.usuariosPreview && !this.usuariosPreview.valid) return 'Corrige los errores del archivo Usuarios';
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,11 +188,20 @@ export class HomeComponent {
|
|||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.planningInput.setFiles(pdvForOptimizer, this.usuariosFile!);
|
let employeesFile: File;
|
||||||
|
try {
|
||||||
|
employeesFile = await this.employeesService.activeEmployeesFile();
|
||||||
|
} catch {
|
||||||
|
this.uploadError = 'No se pudieron obtener los empleados activos.';
|
||||||
|
this.routeState.setLoading(false);
|
||||||
|
this.isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.planningInput.setFiles(pdvForOptimizer, employeesFile);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name);
|
formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name);
|
||||||
formData.append('usuarios_file', this.usuariosFile!, this.usuariosFile!.name);
|
formData.append('usuarios_file', employeesFile, employeesFile.name);
|
||||||
|
|
||||||
this.calcSub?.unsubscribe();
|
this.calcSub?.unsubscribe();
|
||||||
this.calcSub = this.rutasService.calcularRutas(formData).subscribe({
|
this.calcSub = this.rutasService.calcularRutas(formData).subscribe({
|
||||||
|
|||||||
@@ -36,18 +36,6 @@
|
|||||||
<div class="kpi-icon"><i class="pi pi-briefcase"></i></div>
|
<div class="kpi-icon"><i class="pi pi-briefcase"></i></div>
|
||||||
<div><span class="kpi-label">Carga de trabajo</span><strong>{{ summary.totalWorkHours | number:'1.0-1' }} <em>h</em></strong><small>horas de trabajo planificadas</small></div>
|
<div><span class="kpi-label">Carga de trabajo</span><strong>{{ summary.totalWorkHours | number:'1.0-1' }} <em>h</em></strong><small>horas de trabajo planificadas</small></div>
|
||||||
</article>
|
</article>
|
||||||
<article class="kpi-card kpi-blue">
|
|
||||||
<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>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ interface KpiSummary {
|
|||||||
totalDistance: number;
|
totalDistance: number;
|
||||||
averageDistance: number;
|
averageDistance: number;
|
||||||
totalWorkHours: number;
|
totalWorkHours: number;
|
||||||
averageRoutesPerUser: number;
|
|
||||||
averageUtilization: number;
|
|
||||||
assignmentBalance: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReasonSummary {
|
interface ReasonSummary {
|
||||||
@@ -129,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).`;
|
: `${load.name} está dentro de capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).`;
|
||||||
return load;
|
return load;
|
||||||
}).sort((a, b) => b.routes - a.routes || b.workHours - a.workHours);
|
}).sort((a, b) => b.routes - a.routes || b.workHours - a.workHours);
|
||||||
const routeCounts = this.userLoads.map(load => load.routes);
|
|
||||||
const assignmentBalance = routeCounts.length ? Math.max(...routeCounts) - Math.min(...routeCounts) : 0;
|
|
||||||
const averageUtilization = this.userLoads.length
|
|
||||||
? this.userLoads.reduce((sum, load) => sum + load.utilization, 0) / this.userLoads.length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
this.summary = {
|
this.summary = {
|
||||||
totalPdvs,
|
totalPdvs,
|
||||||
assignedPdvs: assignedPdvIds.size,
|
assignedPdvs: assignedPdvIds.size,
|
||||||
@@ -145,9 +136,6 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
|||||||
totalDistance,
|
totalDistance,
|
||||||
averageDistance: assigned.length ? totalDistance / assigned.length : 0,
|
averageDistance: assigned.length ? totalDistance / assigned.length : 0,
|
||||||
totalWorkHours,
|
totalWorkHours,
|
||||||
averageRoutesPerUser: users ? assigned.length / users : 0,
|
|
||||||
averageUtilization,
|
|
||||||
assignmentBalance
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const reasonByPdv = new Map<string, string>();
|
const reasonByPdv = new Map<string, string>();
|
||||||
@@ -183,9 +171,6 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
|||||||
totalDistance: 0,
|
totalDistance: 0,
|
||||||
averageDistance: 0,
|
averageDistance: 0,
|
||||||
totalWorkHours: 0,
|
totalWorkHours: 0,
|
||||||
averageRoutesPerUser: 0,
|
|
||||||
averageUtilization: 0,
|
|
||||||
assignmentBalance: 0
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -455,6 +455,41 @@
|
|||||||
border: none !important;
|
border: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.location-pin-wrapper {
|
||||||
|
width: 42px;
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-pin-wrapper:hover {
|
||||||
|
transform: scale(1.12);
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-pin-wrapper .location-pin {
|
||||||
|
position: relative;
|
||||||
|
width: 30px;
|
||||||
|
height: 38px;
|
||||||
|
background: #c9252d;
|
||||||
|
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
|
||||||
|
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-pin-wrapper .location-pin span {
|
||||||
|
position: absolute;
|
||||||
|
inset: 8px 0 auto;
|
||||||
|
color: #fff;
|
||||||
|
font: 700 12px Arial, sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.marker-inner {
|
.marker-inner {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
|
|||||||
+5
-5
@@ -382,14 +382,14 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
// Marker for PDV
|
// Marker for PDV
|
||||||
const pdvIcon = L.divIcon({
|
const pdvIcon = L.divIcon({
|
||||||
className: 'custom-marker marker-end',
|
className: 'custom-marker marker-end',
|
||||||
html: `<div class="location-pin"><span>${segment.order}</span></div>`,
|
html: `<div class="location-pin-wrapper"><div class="location-pin"><span>${segment.order}</span></div></div>`,
|
||||||
iconSize: [30, 38],
|
iconSize: [42, 50],
|
||||||
iconAnchor: [15, 38],
|
iconAnchor: [21, 46],
|
||||||
popupAnchor: [0, -18]
|
popupAnchor: [0, -18]
|
||||||
});
|
});
|
||||||
const marker = L.marker([Number(j.latitud_pdv), Number(j.longitud_pdv)], { icon: pdvIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
const marker = L.marker([Number(j.latitud_pdv), Number(j.longitud_pdv)], { icon: pdvIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
||||||
.addTo(this.map!)
|
.addTo(this.map!)
|
||||||
.bindPopup(`<strong>${j.nombre_pdv}</strong><br>Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`);
|
.bindPopup(`<strong>${j.nombre_pdv}</strong><br><strong>Usuario:</strong> ${j.nombre_usuario}<br>Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`);
|
||||||
// Heat point
|
// Heat point
|
||||||
const w = this.computeWeight(j);
|
const w = this.computeWeight(j);
|
||||||
this.heatPointsCache.push([Number(j.latitud_pdv), Number(j.longitud_pdv), w]);
|
this.heatPointsCache.push([Number(j.latitud_pdv), Number(j.longitud_pdv), w]);
|
||||||
@@ -519,7 +519,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
mr.segmentDistanceKm = summary.distance / 1000;
|
mr.segmentDistanceKm = summary.distance / 1000;
|
||||||
mr.segmentDurationHours = summary.duration / 3600;
|
mr.segmentDurationHours = summary.duration / 3600;
|
||||||
mr.marker?.setPopupContent(
|
mr.marker?.setPopupContent(
|
||||||
`<strong>${mr.data.json.nombre_pdv}</strong><br>Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}<br>${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min`
|
`<strong>${mr.data.json.nombre_pdv}</strong><br><strong>Usuario:</strong> ${mr.data.json.nombre_usuario}<br>Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}<br>${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
line.on('click', () => this.selectRoute(mr));
|
line.on('click', () => this.selectRoute(mr));
|
||||||
|
|||||||
@@ -87,13 +87,17 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(108, 194, 74, 0.15);
|
box-shadow: 0 0 0 3px rgba(108, 194, 74, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
button[type="submit"] {
|
.google-login {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding: 11px;
|
padding: 11px;
|
||||||
background: var(--glm-azul);
|
background: #fff;
|
||||||
color: #fff;
|
color: var(--glm-gris-oscuro);
|
||||||
border: none;
|
border: 1px solid var(--glm-gris-medio);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
@@ -102,15 +106,25 @@ button[type="submit"] {
|
|||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
button[type="submit"]:hover:not(:disabled) {
|
.google-login:hover:not(:disabled) {
|
||||||
background: var(--glm-verde);
|
border-color: var(--glm-azul);
|
||||||
|
background: var(--glm-verde-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
button[type="submit"]:disabled {
|
.google-login:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.google-mark { color: #4285f4; font-size: 1.1rem; font-weight: 700; }
|
||||||
|
.domain-note { margin: 10px 0 0; color: var(--glm-acero); font-size: .78rem; text-align: center; }
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: #c9252d;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
|
|||||||
@@ -11,36 +11,15 @@
|
|||||||
Planifica rutas inteligentes de forma eficiente
|
Planifica rutas inteligentes de forma eficiente
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form (ngSubmit)="login()" #form="ngForm">
|
<div class="login-actions">
|
||||||
|
<button type="button" class="google-login" (click)="loginWithGoogle()" [disabled]="loading">
|
||||||
<div class="input-group">
|
<span class="google-mark">G</span>
|
||||||
<label>Email</label>
|
<span *ngIf="!loading">Continuar con Google</span>
|
||||||
<input
|
<span *ngIf="loading">Redirigiendo a Google...</span>
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
[(ngModel)]="email"
|
|
||||||
required
|
|
||||||
placeholder="usuario@empresa.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="input-group">
|
|
||||||
<label>Contraseña</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
[(ngModel)]="password"
|
|
||||||
required
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" [disabled]="loading || form.invalid">
|
|
||||||
<span *ngIf="!loading">Iniciar sesión</span>
|
|
||||||
<span *ngIf="loading">Verificando ruta...</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
<p class="domain-note">Solo cuentas @gomezleemarketing.com</p>
|
||||||
</form>
|
<p class="login-error" *ngIf="error">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
© 2026 Route Planner System
|
© 2026 Route Planner System
|
||||||
|
|||||||
@@ -1,29 +1,41 @@
|
|||||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, ChangeDetectionStrategy, ChangeDetectorRef, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
imports: [CommonModule, FormsModule],
|
imports: [CommonModule],
|
||||||
templateUrl: './login.component.html',
|
templateUrl: './login.component.html',
|
||||||
styleUrls: ['./login.component.css'],
|
styleUrls: ['./login.component.css'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class LoginComponent {
|
export class LoginComponent implements OnInit {
|
||||||
|
|
||||||
email = '';
|
|
||||||
password = '';
|
|
||||||
loading = false;
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
|
||||||
constructor(private router: Router) {}
|
constructor(private router: Router, private auth: AuthService, private cdr: ChangeDetectorRef) {}
|
||||||
login() {
|
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
if (await this.auth.getSession()) await this.router.navigate(['/home']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginWithGoogle() {
|
||||||
|
this.error = '';
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
try {
|
||||||
setTimeout(() => {
|
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.loading = false;
|
||||||
this.router.navigate(['/home']);
|
this.cdr.detectChanges();
|
||||||
}, 1500);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ export const environment = {
|
|||||||
n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino',
|
n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino',
|
||||||
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
||||||
requestTimeoutMs: 30000,
|
requestTimeoutMs: 30000,
|
||||||
|
supabaseUrl: 'https://dbit.digitalcompass.agency',
|
||||||
|
supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ export const environment = {
|
|||||||
n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino',
|
n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino',
|
||||||
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
||||||
requestTimeoutMs: 30000,
|
requestTimeoutMs: 30000,
|
||||||
|
supabaseUrl: 'https://dbit.digitalcompass.agency',
|
||||||
|
supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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