feat: implement route planning dashboard including KPIs, user workload tracking, and route assignment management features

This commit is contained in:
2026-08-29 11:55:03 -04:00
parent a0ea3fea48
commit cd5c57c5ee
41 changed files with 3811 additions and 151 deletions
@@ -1,4 +1,4 @@
import { Component } from '@angular/core';
import { Component, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AssignedRouteComponent } from './assigned-route/assigned-route.component';
@@ -6,60 +6,170 @@ import { NoAssignedRouteComponent } from './no-assigned-route/no-assigned-route.
import { RutasService } from '../../core/services/router.service';
import { RouteState } from '../../core/route-state/route';
import { ApiResponse } from '../../core/models/route.model';
import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service';
import { DEFAULT_PARAMS } from '../../shared/schemas/params.schema';
import { Subscription } from 'rxjs';
import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component';
import { PlanningInputService } from '../../core/services/planning-input.service';
@Component({
standalone: true,
selector: 'app-home',
imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent],
imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent, PlanningKpisComponent],
templateUrl: './home.component.html',
styleUrls: ['./home.component.css']
})
export class HomeComponent {
pdvFile: File | null = null;
usuariosFile: File | null = null;
pdvPreview: PreviewResult | null = null;
usuariosPreview: PreviewResult | null = null;
pdvPreviewLoading = false;
usuariosPreviewLoading = false;
uploadError: string | null = null;
isLoading = false;
private calcSub: Subscription | null = null;
// P2-A params
radioKm: 30 | 50 | 80 = DEFAULT_PARAMS.radioKm;
velocidad: number = DEFAULT_PARAMS.velocidad;
constructor(
private rutasService: RutasService,
private routeState: RouteState
) {}
private routeState: RouteState,
private previewService: ExcelPreviewService,
private planningInput: PlanningInputService,
private cdr: ChangeDetectorRef
) {
try {
const raw = sessionStorage.getItem('rp_params');
if (raw) {
const p = JSON.parse(raw) as { radioKm?: number; velocidad?: number };
if ([30, 50, 80].includes(p.radioKm as number)) this.radioKm = p.radioKm as 30|50|80;
if (typeof p.velocidad === 'number' && p.velocidad >= 20 && p.velocidad <= 80) this.velocidad = p.velocidad;
}
} catch { /* ignore */ }
}
onPdvFileChange(event: Event) {
onRadioChange() { this.persistParams(); }
onVelocidadChange() { this.persistParams(); }
private persistParams() {
try { sessionStorage.setItem('rp_params', JSON.stringify({ radioKm: this.radioKm, velocidad: this.velocidad })); } catch { /* ignore */ }
}
async onPdvFileChange(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.pdvFile = input.files[0];
this.validateFile(this.pdvFile, 'PDV');
const file = input.files[0];
const basicError = this.validateFileBasic(file, 'PDV');
if (basicError) {
this.pdvFile = null;
this.pdvPreview = null;
this.uploadError = basicError;
return;
}
this.pdvFile = file;
if (this.usuariosFile) this.planningInput.setFiles(file, this.usuariosFile, this.radioKm, this.velocidad);
this.uploadError = null;
this.pdvPreviewLoading = true;
this.pdvPreview = null;
this.cdr.detectChanges();
try {
this.pdvPreview = await this.previewService.previewFile(file, 'PDV');
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Error al leer el archivo PDV';
this.pdvPreview = {
fileName: file.name,
type: 'PDV',
valid: false,
totalRows: 0,
validRows: 0,
headers: [],
normalizedHeaders: [],
missingHeaders: [],
errors: [{ row: 1, field: 'file', message: msg }],
duplicateIds: [],
sampleValidRows: [],
};
} finally {
this.pdvPreviewLoading = false;
this.cdr.detectChanges();
}
}
}
onUsuariosFileChange(event: Event) {
async onUsuariosFileChange(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.usuariosFile = input.files[0];
this.validateFile(this.usuariosFile, 'Usuarios');
const file = input.files[0];
const basicError = this.validateFileBasic(file, 'Usuarios');
if (basicError) {
this.usuariosFile = null;
this.usuariosPreview = null;
this.uploadError = basicError;
return;
}
this.usuariosFile = file;
if (this.pdvFile) this.planningInput.setFiles(this.pdvFile, file, this.radioKm, this.velocidad);
this.uploadError = null;
this.usuariosPreviewLoading = true;
this.usuariosPreview = null;
this.cdr.detectChanges();
try {
this.usuariosPreview = await this.previewService.previewFile(file, 'Usuarios');
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Error al leer el archivo Usuarios';
this.usuariosPreview = {
fileName: file.name,
type: 'Usuarios',
valid: false,
totalRows: 0,
validRows: 0,
headers: [],
normalizedHeaders: [],
missingHeaders: [],
errors: [{ row: 1, field: 'file', message: msg }],
duplicateIds: [],
sampleValidRows: [],
};
} finally {
this.usuariosPreviewLoading = false;
this.cdr.detectChanges();
}
}
}
private validateFile(file: File, type: string) {
private validateFileBasic(file: File, type: string): string | null {
const validExtensions = ['.xlsx', '.xls'];
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
if (!validExtensions.includes(ext)) {
this.uploadError = `El archivo ${type} debe ser .xlsx o .xls`;
if (type === 'PDV') this.pdvFile = null;
else this.usuariosFile = null;
return `El archivo ${type} debe ser .xlsx o .xls`;
}
if (file.size > 10 * 1024 * 1024) {
this.uploadError = `El archivo ${type} supera 10MB`;
if (type === 'PDV') this.pdvFile = null;
else this.usuariosFile = null;
return `El archivo ${type} supera 10MB`;
}
return null;
}
get canSubmit(): boolean {
return !!this.pdvFile && !!this.usuariosFile && !this.uploadError;
if (!this.pdvFile || !this.usuariosFile) return false;
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) 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;
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.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;
}
cargarRutas() {
@@ -68,39 +178,81 @@ export class HomeComponent {
this.isLoading = true;
this.uploadError = null;
this.routeState.setLoading(true);
this.planningInput.setFiles(this.pdvFile!, this.usuariosFile!, this.radioKm, this.velocidad);
const formData = new FormData();
formData.append('pdv_file', this.pdvFile!, this.pdvFile!.name);
formData.append('usuarios_file', this.usuariosFile!, this.usuariosFile!.name);
formData.append('radio_km', String(this.radioKm));
formData.append('velocidad', String(this.velocidad));
this.rutasService.calcularRutas(formData).subscribe({
this.calcSub?.unsubscribe();
this.calcSub = this.rutasService.calcularRutas(formData).subscribe({
next: (res: ApiResponse) => {
const assigned = res.find(r => 'assigned' in r);
const noAssigned = res.find(r => 'noAssigned' in r);
// ApiResponse is [AssignedResponse, NoAssignedResponse] but backend may return object with error
const anyRes = res as unknown as Record<string, unknown>;
if (anyRes && 'error' in anyRes) {
this.uploadError = String((anyRes as { error: unknown }).error);
this.routeState.setLoading(false);
this.isLoading = false;
this.cdr.detectChanges();
return;
}
const arr = res as unknown as unknown[];
const assigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'assigned' in r) as unknown as { assigned: unknown } | undefined;
const noAssigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'noAssigned' in r) as unknown as { noAssigned: unknown } | undefined;
if (assigned) {
this.routeState.setAssignedRoutes(assigned.assigned);
this.routeState.setAssignedRoutes(assigned.assigned as never);
}
if (noAssigned) {
this.routeState.setNoAssignedRoutes(noAssigned.noAssigned);
this.routeState.setNoAssignedRoutes(noAssigned.noAssigned as never);
}
this.routeState.setLoading(false);
this.isLoading = false;
this.cdr.detectChanges();
},
error: (err) => {
this.uploadError = err.error?.message || 'Error al procesar los archivos';
this.uploadError = this.mapBackendError(err);
this.routeState.setLoading(false);
this.isLoading = false;
this.cdr.detectChanges();
}
});
}
cancelarCarga() {
this.calcSub?.unsubscribe();
this.calcSub = null;
this.isLoading = false;
this.routeState.setLoading(false);
this.uploadError = 'Cálculo cancelado';
}
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?.error?.error) return anyErr.error.error;
if (anyErr?.error?.message) return anyErr.error.message;
if (anyErr?.message) return anyErr.message;
return 'Error al procesar los archivos';
}
clearFiles() {
this.calcSub?.unsubscribe();
this.planningInput.clear();
this.routeState.clearAll();
this.pdvFile = null;
this.usuariosFile = null;
this.pdvPreview = null;
this.usuariosPreview = null;
this.pdvPreviewLoading = false;
this.usuariosPreviewLoading = false;
this.uploadError = null;
this.isLoading = false;
this.routeState.setLoading(false);
const pdvInput = document.getElementById('pdvFile') as HTMLInputElement;
const usuariosInput = document.getElementById('usuariosFile') as HTMLInputElement;
if (pdvInput) pdvInput.value = '';
if (usuariosInput) usuariosInput.value = '';
}
}
}