feat: creacion de proyectos, gestor de empleados
This commit is contained in:
@@ -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,
|
||||
|
||||
+24
-3
@@ -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);
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
+59
-7
@@ -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; }
|
||||
Reference in New Issue
Block a user