From c45182012ccb92d2efc8dca63fb0f1495845b971 Mon Sep 17 00:00:00 2001 From: EidanThen Date: Sat, 5 Sep 2026 12:42:29 -0400 Subject: [PATCH] feat: - conexion supabase - autenticacion solo correos glm - pantalla gestor empleados --- route-planner/package-lock.json | 102 +++++++ route-planner/package.json | 1 + route-planner/src/app/app.routes.ts | 9 +- .../src/app/core/models/employee.model.ts | 21 ++ .../app/core/models/work-schedule.model.ts | 17 ++ .../src/app/core/services/auth.guard.ts | 9 + .../src/app/core/services/auth.service.ts | 37 +++ .../src/app/core/services/employee.service.ts | 66 +++++ .../src/app/core/services/supabase.client.ts | 6 + .../private/employees/employees.component.css | 20 ++ .../employees/employees.component.html | 50 ++++ .../private/employees/employees.component.ts | 272 ++++++++++++++++++ .../assigned-route-detail.component.css | 6 + .../assigned-route-detail.component.ts | 2 +- .../src/app/private/home/home.component.css | 9 + .../src/app/private/home/home.component.html | 51 +--- .../src/app/private/home/home.component.ts | 55 +++- .../planning-kpis.component.html | 12 - .../planning-kpis/planning-kpis.component.ts | 15 - .../route-overview-map.component.css | 35 +++ .../route-overview-map.component.ts | 10 +- .../src/app/public/login/login.component.css | 28 +- .../src/app/public/login/login.component.html | 37 +-- .../src/app/public/login/login.component.ts | 36 ++- .../src/environments/environment.prod.ts | 2 + route-planner/src/environments/environment.ts | 2 + supabase/rutero_empleados_jornadas.sql | 36 +++ 27 files changed, 803 insertions(+), 143 deletions(-) create mode 100644 route-planner/src/app/core/models/employee.model.ts create mode 100644 route-planner/src/app/core/models/work-schedule.model.ts create mode 100644 route-planner/src/app/core/services/auth.guard.ts create mode 100644 route-planner/src/app/core/services/auth.service.ts create mode 100644 route-planner/src/app/core/services/employee.service.ts create mode 100644 route-planner/src/app/core/services/supabase.client.ts create mode 100644 route-planner/src/app/private/employees/employees.component.css create mode 100644 route-planner/src/app/private/employees/employees.component.html create mode 100644 route-planner/src/app/private/employees/employees.component.ts create mode 100644 supabase/rutero_empleados_jornadas.sql diff --git a/route-planner/package-lock.json b/route-planner/package-lock.json index 5e31a70..b832fba 100644 --- a/route-planner/package-lock.json +++ b/route-planner/package-lock.json @@ -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", diff --git a/route-planner/package.json b/route-planner/package.json index 6659ebe..1adbbd9 100644 --- a/route-planner/package.json +++ b/route-planner/package.json @@ -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", diff --git a/route-planner/src/app/app.routes.ts b/route-planner/src/app/app.routes.ts index 0c37435..d09bed9 100644 --- a/route-planner/src/app/app.routes.ts +++ b/route-planner/src/app/app.routes.ts @@ -3,12 +3,15 @@ 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'; 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: HomeComponent, pathMatch: 'full', 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' } ]; diff --git a/route-planner/src/app/core/models/employee.model.ts b/route-planner/src/app/core/models/employee.model.ts new file mode 100644 index 0000000..f7d2800 --- /dev/null +++ b/route-planner/src/app/core/models/employee.model.ts @@ -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; diff --git a/route-planner/src/app/core/models/work-schedule.model.ts b/route-planner/src/app/core/models/work-schedule.model.ts new file mode 100644 index 0000000..d57654b --- /dev/null +++ b/route-planner/src/app/core/models/work-schedule.model.ts @@ -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]; +} diff --git a/route-planner/src/app/core/services/auth.guard.ts b/route-planner/src/app/core/services/auth.guard.ts new file mode 100644 index 0000000..aed38b1 --- /dev/null +++ b/route-planner/src/app/core/services/auth.guard.ts @@ -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'); +}; diff --git a/route-planner/src/app/core/services/auth.service.ts b/route-planner/src/app/core/services/auth.service.ts new file mode 100644 index 0000000..fed9312 --- /dev/null +++ b/route-planner/src/app/core/services/auth.service.ts @@ -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 { + 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 { + const { error } = await this.client.auth.signInWithOAuth({ + provider: 'google', + options: { redirectTo: `${window.location.origin}/home` } + }); + return error?.message ?? null; + } + + async getSession(): Promise { + 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 { + await this.client.auth.signOut(); + } +} diff --git a/route-planner/src/app/core/services/employee.service.ts b/route-planner/src/app/core/services/employee.service.ts new file mode 100644 index 0000000..fcde2f2 --- /dev/null +++ b/route-planner/src/app/core/services/employee.service.ts @@ -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 { + 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 { + const { error } = await this.client.from(this.table).insert(employee); + if (error) throw error; + } + + async upsert(employees: EmployeeInput[]): Promise { + const { error } = await this.client.from(this.table).upsert(employees, { onConflict: 'employee_code' }); + if (error) throw error; + } + + async update(id: string, employee: EmployeeInput): Promise { + const { error } = await this.client.from(this.table).update(employee).eq('id', id); + if (error) throw error; + } + + async remove(id: string): Promise { + const { error } = await this.client.from(this.table).delete().eq('id', id); + if (error) throw error; + } + + async activeEmployeesFile(): Promise { + 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' }); + } +} diff --git a/route-planner/src/app/core/services/supabase.client.ts b/route-planner/src/app/core/services/supabase.client.ts new file mode 100644 index 0000000..dad1fff --- /dev/null +++ b/route-planner/src/app/core/services/supabase.client.ts @@ -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 } +}); diff --git a/route-planner/src/app/private/employees/employees.component.css b/route-planner/src/app/private/employees/employees.component.css new file mode 100644 index 0000000..c86b2b3 --- /dev/null +++ b/route-planner/src/app/private/employees/employees.component.css @@ -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; } } diff --git a/route-planner/src/app/private/employees/employees.component.html b/route-planner/src/app/private/employees/employees.component.html new file mode 100644 index 0000000..2e62676 --- /dev/null +++ b/route-planner/src/app/private/employees/employees.component.html @@ -0,0 +1,50 @@ +
+ GomezLee Marketing + Route Planner +
+
+ +
+

Gestionar empleados

{{ activeEmployees }} activos de {{ employees.length }} empleados registrados

+

{{ importMessage }}

+ +
+

Nuevo empleado

+
+
+ + + + + + + + + + + + + + +
+

Capacidad semanal: {{ selectedScheduleHours }} h · Capacidad diaria efectiva: {{ form.horas_por_dia - form.horas_descanso_por_dia | number:'1.0-1' }} h

+

{{ error }}

+
+
+
+ +
+

Empleados registrados

+

{{ error }}

+

Cargando empleados...

+
+ + + + + + +
IDNombreRegionalJornadaDescanso/díaDías de descansoVisitas/díaEstadoAcciones
{{ employee.employee_code }}{{ employee.nombre }}{{ employee.regional || '-' }}{{ scheduleLabel(employee) }}{{ employee.horas_descanso_por_dia }} h
{{ employee.dias_descanso.length ? employee.dias_descanso.join(', ') : 'Ninguno' }}
{{ employee.visitas_maximas_por_dia }}{{ employee.activo ? 'Activo' : 'Inactivo' }}
No hay empleados registrados.
+
+
+
diff --git a/route-planner/src/app/private/employees/employees.component.ts b/route-planner/src/app/private/employees/employees.component.ts new file mode 100644 index 0000000..5a26967 --- /dev/null +++ b/route-planner/src/app/private/employees/employees.component.ts @@ -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 & { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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)); + 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 { + 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 { + this.destroyed = true; + this.loadSequence++; + this.employees = []; + this.loading = true; + await this.auth.signOut(); + await this.router.navigate(['/login']); + } + + async goHome(): Promise { + 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, 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 + }; + } +} diff --git a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.css b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.css index afad04a..d836798 100644 --- a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.css +++ b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.css @@ -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; diff --git a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts index 7b96316..e1d9f08 100644 --- a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts +++ b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts @@ -270,7 +270,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie .bindPopup(`Origen del tramo: ${originName}`), L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 }) .addTo(this.map) - .bindPopup(`Destino: ${json.nombre_pdv}
Distancia: ${distance} km
Duración: ${duration} min`) + .bindPopup(`Destino: ${json.nombre_pdv}
Usuario: ${json.nombre_usuario}
Distancia: ${distance} km
Duración: ${duration} min`) ); } diff --git a/route-planner/src/app/private/home/home.component.css b/route-planner/src/app/private/home/home.component.css index 81aee46..d45ef6d 100644 --- a/route-planner/src/app/private/home/home.component.css +++ b/route-planner/src/app/private/home/home.component.css @@ -38,11 +38,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 +86,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; diff --git a/route-planner/src/app/private/home/home.component.html b/route-planner/src/app/private/home/home.component.html index 5efab18..ff225f0 100644 --- a/route-planner/src/app/private/home/home.component.html +++ b/route-planner/src/app/private/home/home.component.html @@ -6,12 +6,13 @@
-

Route Planner

-

Gestión de rutas asignadas y puntos de venta pendientes

+

Route Planner

Gestión de rutas asignadas y puntos de venta pendientes

+ Gestionar empleados
-

Subir archivos para optimización

+

Subir PDVs para optimización

+

Cargando empleados activos...{{ activeEmployees }} empleados activos se usarán para planificar.

@@ -57,48 +58,6 @@
-
- - -
- - {{ usuariosFile.name }} - Validando... -
- -
-
Analizando archivo Usuarios...
-
- -
-
Usuarios válido — {{ usuariosPreview!.totalRows }} filas
-
{{ usuariosPreview!.validRows }} filas válidas • Columnas: {{ usuariosPreview!.headers.join(', ') }}
-
- -
-
Errores en Usuarios ({{ usuariosPreview.errors.length }})
-
Faltan: {{ usuariosPreview.missingHeaders.join(', ') }}
-
{{ usuariosPreview.totalRows }} filas leídas • {{ usuariosPreview.validRows }} válidas
-
    -
  • - Fila {{ e.row }} — {{ e.field }}: {{ e.message }} - ({{ e.value }}) -
  • -
  • + {{ usuariosPreview.errors.length - 8 }} errores más...
  • -
-
-
@@ -130,7 +89,7 @@ type="button" class="btn-secondary" (click)="clearFiles()" - [disabled]="!pdvFile && !usuariosFile" + [disabled]="!pdvFile" > Limpiar diff --git a/route-planner/src/app/private/home/home.component.ts b/route-planner/src/app/private/home/home.component.ts index d7c40a5..e22ea87 100644 --- a/route-planner/src/app/private/home/home.component.ts +++ b/route-planner/src/app/private/home/home.component.ts @@ -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,21 +10,25 @@ import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel- import { Subscription } from 'rxjs'; import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component'; import { PlanningInputService } from '../../core/services/planning-input.service'; +import { RouterLink } from '@angular/router'; +import { EmployeeService } from '../../core/services/employee.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; private calcSub: Subscription | null = null; @@ -34,9 +38,26 @@ export class HomeComponent { private routeState: RouteState, private previewService: ExcelPreviewService, private planningInput: PlanningInputService, + private employeesService: EmployeeService, private cdr: ChangeDetectorRef ) {} + ngOnInit(): void { + void this.loadEmployees(); + } + + private async loadEmployees(): Promise { + 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) { const input = event.target as HTMLInputElement; if (input.files && input.files.length > 0) { @@ -132,23 +153,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 +188,20 @@ export class HomeComponent { this.cdr.detectChanges(); 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(); 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 = this.rutasService.calcularRutas(formData).subscribe({ diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html index a862d59..acc3d2e 100644 --- a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.html @@ -36,18 +36,6 @@
Carga de trabajo{{ summary.totalWorkHours | number:'1.0-1' }} hhoras de trabajo planificadas
-
-
-
Utilización promedio{{ summary.averageUtilization | number:'1.0-1' }}%carga frente a disponibilidad
-
-
-
-
Rutas por usuario{{ summary.averageRoutesPerUser | number:'1.0-1' }}promedio de asignaciones
-
-
-
-
Brecha de balance{{ summary.assignmentBalance }}diferencia entre mayor y menor carga
-
diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts index 4e1b878..a977716 100644 --- a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts @@ -16,9 +16,6 @@ interface KpiSummary { totalDistance: number; averageDistance: number; totalWorkHours: number; - averageRoutesPerUser: number; - averageUtilization: number; - assignmentBalance: number; } 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).`; 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, @@ -145,9 +136,6 @@ export class PlanningKpisComponent implements OnInit, OnDestroy { totalDistance, averageDistance: assigned.length ? totalDistance / assigned.length : 0, totalWorkHours, - averageRoutesPerUser: users ? assigned.length / users : 0, - averageUtilization, - assignmentBalance }; const reasonByPdv = new Map(); @@ -183,9 +171,6 @@ export class PlanningKpisComponent implements OnInit, OnDestroy { totalDistance: 0, averageDistance: 0, totalWorkHours: 0, - averageRoutesPerUser: 0, - averageUtilization: 0, - assignmentBalance: 0 }; } } diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css index f63f956..9cf951b 100644 --- a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.css @@ -455,6 +455,41 @@ 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; diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts index bee2ef3..4461ca4 100644 --- a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts @@ -382,14 +382,14 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn // Marker for PDV const pdvIcon = L.divIcon({ className: 'custom-marker marker-end', - html: `
${segment.order}
`, - iconSize: [30, 38], - iconAnchor: [15, 38], + html: `
${segment.order}
`, + 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(`${j.nombre_pdv}
Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`); + .bindPopup(`${j.nombre_pdv}
Usuario: ${j.nombre_usuario}
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]); @@ -519,7 +519,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn mr.segmentDistanceKm = summary.distance / 1000; mr.segmentDurationHours = summary.duration / 3600; mr.marker?.setPopupContent( - `${mr.data.json.nombre_pdv}
Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}
${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min` + `${mr.data.json.nombre_pdv}
Usuario: ${mr.data.json.nombre_usuario}
Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}
${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min` ); } line.on('click', () => this.selectRoute(mr)); diff --git a/route-planner/src/app/public/login/login.component.css b/route-planner/src/app/public/login/login.component.css index 67a189b..d59fe8d 100644 --- a/route-planner/src/app/public/login/login.component.css +++ b/route-planner/src/app/public/login/login.component.css @@ -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; diff --git a/route-planner/src/app/public/login/login.component.html b/route-planner/src/app/public/login/login.component.html index 5bcdf3d..9fa26f2 100644 --- a/route-planner/src/app/public/login/login.component.html +++ b/route-planner/src/app/public/login/login.component.html @@ -11,36 +11,15 @@ Planifica rutas inteligentes de forma eficiente

-
- -
- - -
- -
- - -
- - - -
+

Solo cuentas @gomezleemarketing.com

+ +