feat:
- conexion supabase - autenticacion solo correos glm - pantalla gestor empleados
This commit is contained in:
+6
@@ -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;
|
||||
|
||||
+1
-1
@@ -270,7 +270,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
.bindPopup(`<strong>Origen del tramo:</strong> ${originName}`),
|
||||
L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
||||
.addTo(this.map)
|
||||
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
|
||||
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Usuario:</strong> ${json.nombre_usuario}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,11 +38,18 @@
|
||||
}
|
||||
|
||||
.home-header {
|
||||
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;
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
|
||||
<div class="home-container">
|
||||
<header class="home-header">
|
||||
<h1>Route Planner</h1>
|
||||
<p>Gestión de rutas asignadas y puntos de venta pendientes</p>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<section class="card upload-card">
|
||||
<h2 class="section-title">Subir archivos para optimización</h2>
|
||||
<h2 class="section-title">Subir PDVs para optimización</h2>
|
||||
<p class="employees-summary" [class.empty]="!employeesLoading && !activeEmployees"><i class="pi pi-users"></i> <span *ngIf="employeesLoading">Cargando empleados activos...</span><span *ngIf="!employeesLoading">{{ activeEmployees }} empleados activos se usarán para planificar.</span></p>
|
||||
|
||||
<div class="upload-grid">
|
||||
<div class="file-input-wrapper">
|
||||
@@ -57,48 +58,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-input-wrapper">
|
||||
<label class="file-label" for="usuariosFile" [class.has-error]="usuariosPreview && !usuariosPreview.valid" [class.has-success]="usuariosPreview?.valid">
|
||||
<i class="pi pi-file-excel"></i>
|
||||
<span>Archivo Usuarios (Excel)</span>
|
||||
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 50 filas</span>
|
||||
</label>
|
||||
<input
|
||||
id="usuariosFile"
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
(change)="onUsuariosFileChange($event)"
|
||||
class="file-input"
|
||||
[class.has-file]="usuariosFile"
|
||||
/>
|
||||
<div class="file-name" *ngIf="usuariosFile">
|
||||
<i class="pi" [class.pi-check-circle]="usuariosPreview?.valid" [class.pi-exclamation-circle]="usuariosPreview && !usuariosPreview.valid"></i>
|
||||
{{ usuariosFile.name }}
|
||||
<span *ngIf="usuariosPreviewLoading" class="preview-loading">Validando...</span>
|
||||
</div>
|
||||
|
||||
<div class="preview-card" *ngIf="usuariosPreviewLoading">
|
||||
<div class="preview-loading-row"><span class="spinner-inline"></span> Analizando archivo Usuarios...</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-card preview-success" *ngIf="usuariosPreview?.valid">
|
||||
<div class="preview-header"><i class="pi pi-check-circle"></i> Usuarios válido — {{ usuariosPreview!.totalRows }} filas</div>
|
||||
<div class="preview-stats">{{ usuariosPreview!.validRows }} filas válidas • Columnas: {{ usuariosPreview!.headers.join(', ') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-card preview-error" *ngIf="usuariosPreview && !usuariosPreview.valid">
|
||||
<div class="preview-header"><i class="pi pi-exclamation-triangle"></i> Errores en Usuarios ({{ usuariosPreview.errors.length }})</div>
|
||||
<div class="preview-stats" *ngIf="usuariosPreview.missingHeaders.length">Faltan: {{ usuariosPreview.missingHeaders.join(', ') }}</div>
|
||||
<div class="preview-stats">{{ usuariosPreview.totalRows }} filas leídas • {{ usuariosPreview.validRows }} válidas</div>
|
||||
<ul class="preview-errors">
|
||||
<li *ngFor="let e of usuariosPreview.errors.slice(0, 8)">
|
||||
<strong>Fila {{ e.row }} — {{ e.field }}:</strong> {{ e.message }}
|
||||
<span *ngIf="e.value !== undefined && e.value !== null" class="error-value">({{ e.value }})</span>
|
||||
</li>
|
||||
<li *ngIf="usuariosPreview.errors.length > 8" class="more-errors">+ {{ usuariosPreview.errors.length - 8 }} errores más...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-actions">
|
||||
@@ -130,7 +89,7 @@
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
(click)="clearFiles()"
|
||||
[disabled]="!pdvFile && !usuariosFile"
|
||||
[disabled]="!pdvFile"
|
||||
>
|
||||
<i class="pi pi-times"></i> Limpiar
|
||||
</button>
|
||||
|
||||
@@ -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<void> {
|
||||
this.employeesLoading = true;
|
||||
try {
|
||||
this.activeEmployees = (await this.employeesService.list()).filter(employee => employee.activo).length;
|
||||
} catch {
|
||||
this.activeEmployees = 0;
|
||||
} finally {
|
||||
this.employeesLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
async onPdvFileChange(event: Event) {
|
||||
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({
|
||||
|
||||
@@ -36,18 +36,6 @@
|
||||
<div class="kpi-icon"><i class="pi pi-briefcase"></i></div>
|
||||
<div><span class="kpi-label">Carga de trabajo</span><strong>{{ summary.totalWorkHours | number:'1.0-1' }} <em>h</em></strong><small>horas de trabajo planificadas</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-blue">
|
||||
<div class="kpi-icon"><i class="pi pi-chart-line"></i></div>
|
||||
<div><span class="kpi-label">Utilización promedio</span><strong>{{ summary.averageUtilization | number:'1.0-1' }}<em>%</em></strong><small>carga frente a disponibilidad</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-primary">
|
||||
<div class="kpi-icon"><i class="pi pi-sitemap"></i></div>
|
||||
<div><span class="kpi-label">Rutas por usuario</span><strong>{{ summary.averageRoutesPerUser | number:'1.0-1' }}</strong><small>promedio de asignaciones</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-warning">
|
||||
<div class="kpi-icon"><i class="pi pi-arrows-h"></i></div>
|
||||
<div><span class="kpi-label">Brecha de balance</span><strong>{{ summary.assignmentBalance }}</strong><small>diferencia entre mayor y menor carga</small></div>
|
||||
</article>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<string, string>();
|
||||
@@ -183,9 +171,6 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
||||
totalDistance: 0,
|
||||
averageDistance: 0,
|
||||
totalWorkHours: 0,
|
||||
averageRoutesPerUser: 0,
|
||||
averageUtilization: 0,
|
||||
assignmentBalance: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -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;
|
||||
|
||||
+5
-5
@@ -382,14 +382,14 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
// Marker for PDV
|
||||
const pdvIcon = L.divIcon({
|
||||
className: 'custom-marker marker-end',
|
||||
html: `<div class="location-pin"><span>${segment.order}</span></div>`,
|
||||
iconSize: [30, 38],
|
||||
iconAnchor: [15, 38],
|
||||
html: `<div class="location-pin-wrapper"><div class="location-pin"><span>${segment.order}</span></div></div>`,
|
||||
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(`<strong>${j.nombre_pdv}</strong><br>Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`);
|
||||
.bindPopup(`<strong>${j.nombre_pdv}</strong><br><strong>Usuario:</strong> ${j.nombre_usuario}<br>Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`);
|
||||
// Heat point
|
||||
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(
|
||||
`<strong>${mr.data.json.nombre_pdv}</strong><br>Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}<br>${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min`
|
||||
`<strong>${mr.data.json.nombre_pdv}</strong><br><strong>Usuario:</strong> ${mr.data.json.nombre_usuario}<br>Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}<br>${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min`
|
||||
);
|
||||
}
|
||||
line.on('click', () => this.selectRoute(mr));
|
||||
|
||||
Reference in New Issue
Block a user