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 @@
+
+

+
Route Planner
+
+
+
+
+
+ {{ importMessage }}
+
+
+
+
+
+ {{ error }}
+ Cargando empleados...
+
+
+
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 @@
- Subir archivos para optimización
+ Subir PDVs para optimización
+ Cargando empleados activos...{{ activeEmployees }} empleados activos se usarán para planificar.
-
@@ -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: ``,
+ 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
-