feat: creacion de proyectos, gestor de empleados

This commit is contained in:
2026-09-17 15:04:52 -04:00
parent 0c652a0bd7
commit 0736db2b5c
44 changed files with 919 additions and 125 deletions
@@ -1,4 +1,5 @@
.employees-topbar { display: flex; align-items: center; gap: 12px; padding: 9px 24px; border-bottom: 3px solid var(--glm-verde); background: #fff; color: var(--glm-azul); font-weight: 700; }
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
.employees-topbar img { height: 38px; width: auto; }
.topbar-actions { display: flex; gap: 8px; margin-left: auto; }
.topbar-actions button, .table-header button, .form-actions button, .row-actions button { border: 1px solid var(--glm-gris-medio); border-radius: 4px; background: #fff; color: var(--glm-azul); cursor: pointer; font: inherit; font-size: .78rem; font-weight: 600; padding: 7px 10px; }
@@ -1,7 +1,7 @@
<div class="employees-topbar">
<img src="GLM_completo.png" alt="GomezLee Marketing" />
<span>Route Planner</span>
<div class="topbar-actions"><button type="button" (click)="goHome()"><i class="pi pi-home"></i> Planificación</button><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
<span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div>
<div class="topbar-actions"><button type="button" (click)="goBack()"><i class="pi pi-arrow-left"></i> Volver</button><button type="button" (click)="goHome()"><i class="pi pi-home"></i> Proyectos</button><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
</div>
<main class="employees-page">
@@ -2,9 +2,11 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Location } from '@angular/common';
import { Employee, EmployeeInput } from '../../core/models/employee.model';
import { EmployeeService } from '../../core/services/employee.service';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
import { WORK_SCHEDULES, workSchedule } from '../../core/models/work-schedule.model';
import { normalizeRow, cleanNum } from '../../shared/utils/header-normalization.utils';
import * as XLSX from 'xlsx';
@@ -36,6 +38,8 @@ export class EmployeesComponent implements OnDestroy, OnInit {
private employeesService: EmployeeService,
private auth: AuthService,
private router: Router,
private location: Location,
public profile: UserProfileService,
private cdr: ChangeDetectorRef
) {}
@@ -225,6 +229,11 @@ export class EmployeesComponent implements OnDestroy, OnInit {
await this.router.navigate(['/home']);
}
goBack(): void {
if (history.length > 1) this.location.back();
else void this.router.navigate(['/home']);
}
private nullable(value: string | null): string | null {
return value?.trim() || null;
}
@@ -257,7 +266,7 @@ export class EmployeesComponent implements OnDestroy, OnInit {
email: this.nullable(String(row['email'] ?? '')),
perfil: this.nullable(String(row['perfil_de_acceso'] ?? '')),
pais: this.nullable(String(row['pais'] ?? '')),
regional: this.nullable(String(row['regionales'] ?? '')),
regional: this.nullable(String(row['regionales'] ?? row['regional'] ?? '')),
latitud: latitude,
longitud: longitude,
horas_por_dia: dailyHours,
@@ -99,12 +99,26 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
}
private initMap() {
this.map = L.map('map').setView([18.4861, -69.9312], 13);
const territoryBounds = this.routeData ? L.latLngBounds([
[Number(this.routeData.json.latitud_usuario), Number(this.routeData.json.longitud_usuario)],
[Number(this.routeData.json.latitud_pdv), Number(this.routeData.json.longitud_pdv)]
]) : null;
this.map = L.map('map', { maxBoundsViscosity: 0.85 });
if (territoryBounds?.isValid()) {
this.map.fitBounds(territoryBounds.pad(0.3));
this.map.setMaxBounds(territoryBounds.pad(0.6));
} else {
this.map.setView([18.4861, -69.9312], 13);
}
const pdvPane = this.map.createPane('pdvPane');
pdvPane.style.zIndex = '650';
this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
attribution: '© OpenStreetMap contributors',
bounds: territoryBounds?.isValid() ? territoryBounds.pad(0.6) : undefined,
keepBuffer: 1,
noWrap: true,
updateWhenIdle: true
}).addTo(this.map);
}
@@ -127,6 +141,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
this.centerMapOnPoints(origin, destination);
if (json.geojson) {
this.showRouteOnMap(json.geojson, origin, destination, json, this.segmentOriginName);
this.isLoading = false;
this.cdr.detectChanges();
return;
}
try {
const results = await this.orsService.processPairs(
[{ pdv_id: json.pdv_id, origin, destination }],
@@ -292,6 +313,6 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
}
goBack() {
this.router.navigate(['/home']);
this.router.navigate(['/proyectos', this.activatedRoute.snapshot.paramMap.get('id')]);
}
}
@@ -93,7 +93,7 @@
<ng-template #dayValue>{{ row.json.dia }}</ng-template>
</td>
<td>{{ row.json.distancia }}</td>
<td>{{ row.json.horas_desplazamiento }}</td>
<td>{{ (row.json.horas_desplazamiento * 60).toFixed(0) }} min</td>
<td class="text-center edit-actions">
<ng-container *ngIf="editingRoute === row; else editButton">
<button type="button" class="btn-icon" (click)="saveEdit()" title="Guardar"><i class="pi pi-check"></i></button>
@@ -1,7 +1,7 @@
import { Component, ChangeDetectorRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { RouteAssignedResponse } from '../../../core/models/route.model';
import { saveAs } from 'file-saver';
import { Table } from 'primeng/table';
@@ -44,6 +44,7 @@ export class AssignedRouteComponent {
private routesState: RouteState,
private cdr: ChangeDetectorRef,
private router: Router,
private activatedRoute: ActivatedRoute,
private rutasService: RutasService,
private planningInput: PlanningInputService
) { }
@@ -79,7 +80,7 @@ export class AssignedRouteComponent {
}
onShowMap(row: RouteAssignedResponse) {
this.routesState.sendRoute(row);
this.router.navigate(['/home/assigned-route'], {
this.router.navigate(['/proyectos', this.projectId, 'ruta'], {
queryParams: { pdv_id: row.json.pdv_id, usuario_id: row.json.usuario_id, dia: row.json.dia }
});
}
@@ -88,18 +89,20 @@ export class AssignedRouteComponent {
// If table has filtered view, use first visible row's usuario as default; else first assigned
const target = (this.table?.filteredValue?.[0] as RouteAssignedResponse | undefined) ?? this.assignedRoutes[0];
if (target) {
this.router.navigate(['/home/mapa'], {
this.router.navigate(['/proyectos', this.projectId, 'mapa'], {
queryParams: { usuario_id: target.json.usuario_id, dia: 'Todos' }
});
} else {
this.router.navigate(['/home/mapa']);
this.router.navigate(['/proyectos', this.projectId, 'mapa']);
}
}
goToUserMap(usuarioId: string) {
this.router.navigate(['/home/mapa'], { queryParams: { usuario_id: usuarioId, dia: 'Todos' } });
this.router.navigate(['/proyectos', this.projectId, 'mapa'], { queryParams: { usuario_id: usuarioId, dia: 'Todos' } });
}
private get projectId(): string { return this.activatedRoute.snapshot.paramMap.get('id') ?? ''; }
get userOptions(): { id: string; name: string }[] {
const users = new Map<string, string>();
for (const route of this.assignedRoutes) users.set(String(route.json.usuario_id), route.json.nombre_usuario);
@@ -31,6 +31,10 @@
color: var(--glm-verde);
}
.projects-link { margin-left: auto; color: var(--glm-azul); font-size: .82rem; text-decoration: none; }
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
.projects-link button,.home-employees-link,.edit-project,.project-edit-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:inherit; font-weight:700; padding:8px 11px; text-decoration:none; }.home-employees-link { display:inline-block; margin-bottom:12px; }.project-heading { width:100%; }.project-title-line { display:flex; align-items:center; gap:14px; }.project-title-line h1 { margin:0; }.back-projects { color:var(--glm-azul); font-size:.85rem; font-weight:700; text-decoration:none; white-space:nowrap; }.edit-project,.project-edit-actions { margin-left:auto; }.project-title-line input,.project-heading textarea { border:1px solid #9aaab3; border-radius:4px; background:#fff; color:#2f3f48; font:inherit; padding:8px; }.project-heading textarea { display:block; width:100%; margin-top:10px; resize:vertical; }.project-edit-actions { display:flex; gap:6px; }.project-edit-actions button:first-child { background:var(--glm-azul); color:#fff; }
.home-container {
max-width: 1000px;
margin: 0 auto;
@@ -1,31 +1,31 @@
<div class="glm-topbar">
<img src="GLM_completo.png" alt="GomezLee Marketing" class="glm-topbar-logo" />
<span class="glm-topbar-sep">|</span>
<span class="glm-topbar-context">Route Planner</span>
<span class="glm-topbar-context">Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="projects-link"><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
</div>
<div class="home-container">
<a class="employees-link home-employees-link" routerLink="/empleados"><i class="pi pi-users"></i> Gestionar empleados</a>
<header class="home-header">
<div><h1>Route Planner</h1><p>Gestión de rutas asignadas y puntos de venta pendientes</p></div>
<a class="employees-link" routerLink="/empleados"><i class="pi pi-users"></i> Gestionar empleados</a>
<div class="project-heading"><div class="project-title-line"><a class="back-projects" routerLink="/home"><i class="pi pi-arrow-left"></i> Mis proyectos</a><h1 *ngIf="!editingProject">{{ projectName || 'Proyecto' }}</h1><input *ngIf="editingProject" [(ngModel)]="projectName" maxlength="120"><button *ngIf="!editingProject" type="button" class="edit-project" (click)="startProjectEdit()"><i class="pi pi-pencil"></i> Editar proyecto</button><div *ngIf="editingProject" class="project-edit-actions"><button type="button" (click)="saveProject()" [disabled]="projectSaving">Guardar</button><button type="button" (click)="cancelProjectEdit()" [disabled]="projectSaving">Cancelar</button></div></div><textarea *ngIf="editingProject" [(ngModel)]="projectDescription" maxlength="500" placeholder="Descripción del proyecto"></textarea><p *ngIf="!editingProject">Gestión de rutas asignadas y puntos de venta pendientes</p></div>
</header>
<section class="card upload-card">
<h2 class="section-title">Subir PDVs para optimización</h2>
<p class="employees-summary" [class.empty]="!employeesLoading && !activeEmployees"><i class="pi pi-users"></i> <span *ngIf="employeesLoading">Cargando empleados activos...</span><span *ngIf="!employeesLoading">{{ activeEmployees }} empleados activos se usarán para planificar.</span></p>
<h2 class="section-title">PDVs del proyecto</h2>
<p class="employees-summary" [class.empty]="!employeesLoading && !activeEmployees"><i class="pi pi-users"></i> <span *ngIf="employeesLoading">Cargando empleados activos...</span><span *ngIf="!employeesLoading">{{ activeEmployees }} empleados activos de {{ projectCountry }} se usarán para planificar.</span></p>
<div class="upload-grid">
<div class="file-input-wrapper">
<label class="file-label" for="pdvFile" [class.has-error]="pdvPreview && !pdvPreview.valid" [class.has-success]="pdvPreview?.valid">
<i class="pi pi-file-excel"></i>
<span>Archivo PDVs (Excel)</span>
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 2000 filas</span>
<span>Archivo PDVs guardado</span>
<span class="file-hint">Los PDVs pertenecen a este proyecto</span>
</label>
<input
id="pdvFile"
type="file"
accept=".xlsx,.xls"
(change)="onPdvFileChange($event)"
disabled
class="file-input"
[class.has-file]="pdvFile"
/>
@@ -10,8 +10,11 @@ import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-
import { Subscription } from 'rxjs';
import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component';
import { PlanningInputService } from '../../core/services/planning-input.service';
import { RouterLink } from '@angular/router';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { EmployeeService } from '../../core/services/employee.service';
import { ProjectService } from '../../core/services/project.service';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
@Component({
standalone: true,
@@ -31,6 +34,12 @@ export class HomeComponent implements OnInit {
employeesLoading = true;
uploadError: string | null = null;
isLoading = false;
projectName = '';
projectId = '';
editingProject = false;
projectDescription = '';
projectCountry = '';
projectSaving = false;
private calcSub: Subscription | null = null;
constructor(
@@ -39,17 +48,60 @@ export class HomeComponent implements OnInit {
private previewService: ExcelPreviewService,
private planningInput: PlanningInputService,
private employeesService: EmployeeService,
private projectsService: ProjectService,
private activatedRoute: ActivatedRoute,
private auth: AuthService,
private router: Router,
public profile: UserProfileService,
private cdr: ChangeDetectorRef
) {}
ngOnInit(): void {
void this.loadEmployees();
void this.initialize();
}
private async initialize(): Promise<void> {
await this.loadProject();
await this.loadEmployees();
}
private async loadProject(): Promise<void> {
this.projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? '';
if (!this.projectId) return;
try {
const [project, input] = await Promise.all([this.projectsService.get(this.projectId), this.projectsService.projectInput(this.projectId)]);
this.projectName = project.nombre;
this.projectDescription = project.descripcion ?? '';
this.projectCountry = input.country;
this.pdvFile = input.file;
this.pdvPreview = await this.previewService.previewFile(input.file, 'PDV');
} catch (error: any) {
this.uploadError = error?.message ?? 'No se pudo cargar el proyecto.';
} finally {
this.cdr.detectChanges();
}
}
startProjectEdit(): void { this.editingProject = true; }
cancelProjectEdit(): void { this.editingProject = false; void this.loadProject(); }
async saveProject(): Promise<void> {
if (!this.projectId || !this.projectName.trim()) return;
this.projectSaving = true;
try {
await this.projectsService.update(this.projectId, { nombre: this.projectName.trim(), descripcion: this.projectDescription.trim() || null });
this.editingProject = false;
} catch (error: any) { this.uploadError = error?.message ?? 'No se pudo actualizar el proyecto.'; }
finally { this.projectSaving = false; this.cdr.detectChanges(); }
}
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
private async loadEmployees(): Promise<void> {
this.employeesLoading = true;
try {
this.activeEmployees = (await this.employeesService.list()).filter(employee => employee.activo).length;
this.activeEmployees = (await this.employeesService.active(this.projectCountry)).length;
} catch {
this.activeEmployees = 0;
} finally {
@@ -190,7 +242,7 @@ export class HomeComponent implements OnInit {
}
let employeesFile: File;
try {
employeesFile = await this.employeesService.activeEmployeesFile();
employeesFile = await this.employeesService.activeEmployeesFile(this.projectCountry);
} catch {
this.uploadError = 'No se pudieron obtener los empleados activos.';
this.routeState.setLoading(false);
@@ -202,6 +254,7 @@ export class HomeComponent implements OnInit {
const formData = new FormData();
formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name);
formData.append('usuarios_file', employeesFile, employeesFile.name);
formData.append('pais', this.projectCountry);
this.calcSub?.unsubscribe();
this.calcSub = this.rutasService.calcularRutas(formData).subscribe({
@@ -247,7 +300,7 @@ export class HomeComponent implements OnInit {
private mapBackendError(err: unknown): string {
const anyErr = err as { error?: { error?: string; message?: string }; message?: string; status?: number; name?: string };
if (anyErr?.name === 'TimeoutError') return 'El cálculo tardó demasiado (timeout 30s). Intenta de nuevo con menos filas.';
if (anyErr?.name === 'TimeoutError') return 'El cálculo tardó demasiado (timeout 180s). Intenta de nuevo con menos filas.';
if (anyErr?.error?.error) return anyErr.error.error;
if (anyErr?.error?.message) return anyErr.error.message;
if (anyErr?.message) return anyErr.message;
@@ -77,6 +77,9 @@
font-weight: 700;
}
.territory-summary { margin: 4px 0 0; color: var(--glm-acero); font-size: .76rem; line-height: 1.35; }
.territory-summary i { color: var(--glm-verde); margin-right: 3px; }
.filters-card {
background: var(--glm-verde-bg);
border: 1px solid var(--glm-verde-claro);
@@ -16,6 +16,7 @@
</button>
<h2 class="sidebar-title">Mapa de rutas</h2>
</div>
<p class="territory-summary" *ngIf="activeTerritories.length"><i class="pi pi-map-marker"></i> {{ activeTerritories.join(', ') }}</p>
<div class="filters-card" *ngIf="usuarios.length">
<label class="filter-label">Usuario</label>
@@ -96,6 +96,14 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
reassignDay = '';
reassignError: string | null = null;
get activeTerritories(): string[] {
const values = [
...this.allRoutes.map(route => route.json.metro || route.json.ciudad || route.json.estado || route.json.provincia || route.json.region || route.json.zona),
...this.unassignedRoutes.map(route => route.metro || route.ciudad || route.estado || route.provincia || route.region || route.zona)
].filter((value): value is string => !!value?.trim());
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
}
// Summary stats
get uniqueFilteredRoutes(): RouteAssignedResponse[] {
const unique = new Map<string, RouteAssignedResponse>();
@@ -140,7 +148,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
}
get dailyGroups(): DailyRouteGroup[] {
const order = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes'];
const order = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes', 'Sábado', 'Sabado'];
const groups = new Map<string, DailyRouteSummary[]>();
for (const summary of this.dailySummaries) {
const routes = groups.get(summary.day) ?? [];
@@ -289,7 +297,14 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
}
private initMap() {
this.map = L.map('map-overview').setView([18.4861, -69.9312], 10);
const territoryBounds = this.projectBounds();
this.map = L.map('map-overview', { maxBoundsViscosity: 0.85 });
if (territoryBounds) {
this.map.fitBounds(territoryBounds.pad(0.15));
this.map.setMaxBounds(territoryBounds.pad(0.35));
} else {
this.map.setView([18.4861, -69.9312], 10);
}
const pdvPane = this.map.createPane('pdvPane');
const heatPane = this.map.createPane('heatPane');
pdvPane.style.zIndex = '750';
@@ -297,7 +312,11 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
const popupPane = this.map.getPane('popupPane');
if (popupPane) popupPane.style.zIndex = '1000';
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
attribution: '© OpenStreetMap contributors',
bounds: territoryBounds?.pad(0.35),
keepBuffer: 1,
noWrap: true,
updateWhenIdle: true
}).addTo(this.map);
// Fix tiles after sidebar overlay
setTimeout(() => this.map?.invalidateSize(), 200);
@@ -336,6 +355,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
this.orsProgress = 0;
this.orsTotal = 0;
if (!this.filteredRoutes.length) {
this.renderUnassignedMarkers();
const territoryBounds = this.projectBounds();
if (territoryBounds) this.map.fitBounds(territoryBounds.pad(0.15));
this.cdr.detectChanges();
return;
}
@@ -414,6 +436,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
marker.on('click', onClick);
});
if (this.selectedUsuarioId === 'Todos' && this.selectedDia === 'Todos') {
for (const route of this.unassignedRoutes) {
if (this.validPoint(route.latitud, route.longitud)) bounds.push([Number(route.latitud), Number(route.longitud)]);
}
}
this.updateHeatLayer();
this.toggleLayerVisibility();
@@ -428,6 +456,25 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
this.cdr.detectChanges();
}
private projectBounds(): L.LatLngBounds | null {
const points: L.LatLngTuple[] = [];
for (const route of this.allRoutes) {
const data = route.json;
if (this.validPoint(data.latitud_usuario, data.longitud_usuario)) points.push([Number(data.latitud_usuario), Number(data.longitud_usuario)]);
if (this.validPoint(data.latitud_pdv, data.longitud_pdv)) points.push([Number(data.latitud_pdv), Number(data.longitud_pdv)]);
}
for (const route of this.unassignedRoutes) {
if (this.validPoint(route.latitud, route.longitud)) points.push([Number(route.latitud), Number(route.longitud)]);
}
return points.length ? L.latLngBounds(points) : null;
}
private validPoint(latitude: unknown, longitude: unknown): boolean {
const lat = Number(latitude);
const lng = Number(longitude);
return Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
}
private renderUnassignedMarkers(): void {
if (!this.map || this.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return;
for (const route of this.unassignedRoutes) {
@@ -452,15 +499,20 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
this.orsProgress = 0;
this.cdr.detectChanges();
const pares = this.mapRoutes.map(route => ({
const embeddedResults = this.mapRoutes
.filter(route => !!route.data.json.geojson)
.map(route => ({ pdv_id: route.requestId, geojson: route.data.json.geojson }));
const pares = this.mapRoutes.filter(route => !route.data.json.geojson).map(route => ({
pdv_id: route.requestId,
origin: route.origin,
destination: route.destination
}));
const { promise } = this.orsService.processPairsWithProgress(pares, 2, true, this.abort$);
try {
const results = await promise;
const remoteResults = pares.length
? await this.orsService.processPairsWithProgress(pares, 2, true, this.abort$).promise
: [];
const results = [...embeddedResults, ...remoteResults];
if (!results.length) {
this.isLoading = false;
this.cdr.detectChanges();
@@ -950,6 +1002,6 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
}
goBack() {
this.router.navigate(['/home']);
this.router.navigate(['/proyectos', this.activatedRoute.snapshot.paramMap.get('id')]);
}
}
@@ -0,0 +1 @@
<div class="projects-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="topbar-actions"><a routerLink="/home">Mis proyectos</a><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div></div><main class="projects-page narrow"><header><div><h1>Crear proyecto</h1><p>Define el proyecto e importa los PDVs que se planificarán.</p></div></header><section class="project-form"><label>Nombre del proyecto<input [(ngModel)]="name" placeholder="Ej. Ruta Santo Domingo Norte" maxlength="120"></label><label>País<select [(ngModel)]="country"><option value="Republica Dominicana">República Dominicana</option><option value="MEXICO">México</option></select></label><label class="file-label">Archivo PDVs (Excel)<input type="file" accept=".xlsx,.xls" (change)="fileChanged($event)"><span>.xlsx / .xls, máximo 10 MB. El país también se detecta desde el archivo.</span></label><p *ngIf="file">{{ file.name }}</p><p *ngIf="loading">Validando archivo...</p><p class="valid" *ngIf="preview?.valid">{{ preview!.validRows }} PDVs válidos para crear el proyecto.</p><p class="error" *ngIf="preview && !preview.valid">El archivo tiene errores. Corrígelos antes de crear el proyecto.</p><p class="error" *ngIf="error">{{ error }}</p><button class="primary" type="button" [disabled]="!canCreate" (click)="create()">{{ saving ? 'Creando proyecto...' : 'Crear proyecto' }}</button></section></main>
@@ -0,0 +1,52 @@
import { ChangeDetectorRef, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import * as XLSX from 'xlsx';
import { ProjectService } from '../../core/services/project.service';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service';
@Component({ standalone: true, selector: 'app-new-project', imports: [CommonModule, FormsModule, RouterLink], templateUrl: './new-project.component.html', styleUrl: './projects.component.css' })
export class NewProjectComponent {
name = '';
country = 'Republica Dominicana';
file: File | null = null;
preview: PreviewResult | null = null;
loading = false;
saving = false;
error = '';
constructor(private projects: ProjectService, private auth: AuthService, public profile: UserProfileService, private previewService: ExcelPreviewService, private router: Router, private cdr: ChangeDetectorRef) {}
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
async fileChanged(event: Event): Promise<void> {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
this.file = file; this.preview = null; this.error = ''; this.loading = true;
try {
this.preview = await this.previewService.previewFile(file, 'PDV');
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(workbook.Sheets[workbook.SheetNames[0]], { defval: null, raw: true });
const value = rows.map(row => Object.entries(row).find(([key]) => key.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase() === 'pais')?.[1]).find(Boolean);
const normalized = String(value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase();
if (normalized.includes('MEXICO')) this.country = 'MEXICO';
else if (normalized.includes('DOMINICANA')) this.country = 'Republica Dominicana';
}
catch (error: any) { this.error = error?.message ?? 'No se pudo leer el archivo.'; }
finally { this.loading = false; this.cdr.detectChanges(); }
}
get canCreate(): boolean { return !!this.name.trim() && !!this.country && !!this.file && !!this.preview?.valid && !this.saving; }
async create(): Promise<void> {
if (!this.canCreate || !this.file) return;
this.saving = true; this.error = '';
try {
const workbook = XLSX.read(await this.file.arrayBuffer(), { type: 'array' });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: null, raw: true });
const projectRows = rows.map(row => ({ ...row, 'País': this.country }));
const project = await this.projects.create(this.name, projectRows);
await this.router.navigate(['/proyectos', project.id]);
} catch (error: any) { this.error = error?.message ?? 'No se pudo crear el proyecto.'; }
finally { this.saving = false; this.cdr.detectChanges(); }
}
}
@@ -0,0 +1,2 @@
<div class="projects-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="topbar-actions"><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div></div>
<main class="projects-page"><a class="manage-employees" routerLink="/empleados"><i class="pi pi-users"></i> Gestionar empleados</a><header><div><h1>Mis proyectos</h1><p>Organiza y conserva tus planificaciones de rutas.</p></div><a class="primary" routerLink="/proyectos/nuevo"><i class="pi pi-plus"></i> Crear proyecto</a></header><p class="error" *ngIf="error">{{ error }}</p><p *ngIf="loading">Cargando proyectos...</p><section class="project-grid" *ngIf="!loading"><article class="project-card" *ngFor="let project of projects"><span class="state">{{ project.estado }}</span><h2>{{ project.nombre }}</h2><p>{{ project.descripcion || 'Sin descripción' }}</p><small>Actualizado: {{ project.updated_at | date:'medium' }}</small><div class="project-card-actions"><button type="button" (click)="open(project)" [disabled]="deletingId === project.id">Abrir proyecto</button><button type="button" class="danger" (click)="remove(project)" [disabled]="deletingId === project.id"><i class="pi pi-trash"></i> {{ deletingId === project.id ? 'Eliminando...' : 'Eliminar' }}</button></div></article><section class="empty" *ngIf="!projects.length"><i class="pi pi-folder-open"></i><h2>Aún no tienes proyectos</h2><p>Crea uno e importa su archivo de PDVs para comenzar.</p><a class="primary" routerLink="/proyectos/nuevo">Crear proyecto</a></section></section></main>
@@ -0,0 +1,39 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { ProjectService } from '../../core/services/project.service';
import { RuteroProyecto } from '../../core/models/project.model';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
@Component({ standalone: true, selector: 'app-project-list', imports: [CommonModule, RouterLink], templateUrl: './project-list.component.html', styleUrl: './projects.component.css' })
export class ProjectListComponent implements OnInit {
projects: RuteroProyecto[] = [];
loading = true;
deletingId = '';
error = '';
constructor(private projectsService: ProjectService, private auth: AuthService, public profile: UserProfileService, private router: Router, private cdr: ChangeDetectorRef) {}
ngOnInit(): void { void this.load(); }
async load(): Promise<void> {
this.loading = true;
try { this.projects = await this.projectsService.list(); }
catch (error: any) { this.error = error?.message ?? 'No se pudieron cargar los proyectos.'; }
finally { this.loading = false; this.cdr.detectChanges(); }
}
open(project: RuteroProyecto): void { void this.router.navigate(['/proyectos', project.id]); }
async remove(project: RuteroProyecto): Promise<void> {
if (!confirm(`Eliminar permanentemente el proyecto "${project.nombre}" y todos sus PDVs y rutas?`)) return;
this.deletingId = project.id;
this.error = '';
try {
await this.projectsService.remove(project.id);
this.projects = this.projects.filter(item => item.id !== project.id);
} catch (error: any) {
this.error = error?.message ?? 'No se pudo eliminar el proyecto.';
} finally {
this.deletingId = '';
this.cdr.detectChanges();
}
}
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
}
@@ -0,0 +1,5 @@
.projects-topbar { display:flex; align-items:center; gap:12px; padding:9px 24px; border-bottom:3px solid var(--glm-verde); color:var(--glm-azul); font-weight:700; background:#fff; }.projects-topbar img { height:38px; }.projects-topbar a { margin-left:auto; color:var(--glm-azul); text-decoration:none; }.projects-page { max-width:1100px; margin:0 auto; padding:30px 24px; }.projects-page header { display:flex; justify-content:space-between; align-items:flex-start; gap:20px; border-bottom:3px solid var(--glm-verde); padding-bottom:14px; }.projects-page h1,.projects-page h2 { color:var(--glm-azul); margin:0; }.projects-page header p,.project-card p,small { color:var(--glm-acero); }.primary,.project-card button { display:inline-block; border:1px solid var(--glm-azul); border-radius:4px; padding:9px 12px; background:var(--glm-azul); color:#fff; font:inherit; font-weight:700; text-decoration:none; cursor:pointer; }.project-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(245px,1fr)); gap:16px; margin-top:22px; }.project-card,.project-form,.empty { border:1px solid var(--glm-gris-medio); border-top:3px solid var(--glm-azul); border-radius:4px; padding:18px; background:#fff; }.project-card { display:flex; flex-direction:column; gap:12px; }.project-card button { margin-top:auto; }.state { align-self:flex-start; color:var(--glm-verde); font-size:.75rem; font-weight:700; text-transform:uppercase; }.empty { grid-column:1/-1; text-align:center; padding:38px; }.narrow { max-width:720px; }.project-form { display:grid; gap:16px; margin-top:20px; }.project-form label { display:grid; gap:6px; color:var(--glm-azul); font-size:.8rem; font-weight:700; text-transform:uppercase; }.project-form input { padding:10px; border:1px solid var(--glm-gris-medio); border-radius:4px; font:inherit; }.file-label span { color:var(--glm-acero); font-size:.75rem; text-transform:none; }.error { color:#c9252d; }.valid { color:var(--glm-verde); font-weight:700; } @media(max-width:640px){.projects-page{padding:20px 16px}.projects-page header{display:grid}.projects-topbar{padding:9px 16px}}
.project-form input,.project-form select { background:#fff; color:#2f3f48; border-color:#9aaab3; }.project-form select { padding:10px; border:1px solid #9aaab3; border-radius:4px; font:inherit; }.project-form input::placeholder { color:#71808a; opacity:1; }.project-form input:focus,.project-form select:focus { outline:2px solid rgba(108,194,74,.35); border-color:var(--glm-verde); background:#fff; color:#1f2d34; }.project-form input[type=file] { color:#2f3f48; background:#fff; cursor:pointer; }
.topbar-actions { display:flex; align-items:center; gap:8px; margin-left:auto; }.topbar-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:inherit; font-size:.82rem; font-weight:700; padding:8px 12px; }.topbar-actions button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }.manage-employees { display:inline-flex; align-items:center; gap:6px; margin-bottom:14px; border:1px solid var(--glm-gris-medio); border-radius:4px; padding:8px 12px; color:var(--glm-azul); font-weight:700; text-decoration:none; background:#fff; }.manage-employees:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
.project-card-actions { display:flex; gap:8px; margin-top:auto; }.project-card-actions button { margin-top:0; }.project-card-actions .danger { border-color:#c9252d; background:#fff; color:#c9252d; }.project-card-actions .danger:hover:not(:disabled) { background:#c9252d; color:#fff; }.project-card-actions button:disabled { cursor:not-allowed; opacity:.65; }