feat: implement route planning dashboard including KPIs, user workload tracking, and route assignment management features
This commit is contained in:
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user