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
@@ -0,0 +1,173 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Subscription } from 'rxjs';
import { RouteState } from '../../../core/route-state/route';
import { PlanningExportService } from '../../../shared/services/planning-export.service';
import { RouteAssignedResponse } from '../../../core/models/route.model';
interface KpiSummary {
totalPdvs: number;
assignedPdvs: number;
unassignedPdvs: number;
coverage: number;
users: number;
days: number;
totalDistance: number;
totalTravelHours: number;
averageDistance: number;
averageTravelHours: number;
totalWorkHours: number;
averageRoutesPerUser: number;
averageUtilization: number;
assignmentBalance: number;
}
interface ReasonSummary {
reason: string;
count: number;
}
interface UserLoad {
name: string;
routes: number;
distance: number;
workHours: number;
capacityHours: number;
utilization: number;
}
@Component({
standalone: true,
selector: 'app-planning-kpis',
imports: [CommonModule],
templateUrl: './planning-kpis.component.html',
styleUrl: './planning-kpis.component.css'
})
export class PlanningKpisComponent implements OnInit, OnDestroy {
summary: KpiSummary = this.emptySummary();
reasons: ReasonSummary[] = [];
userLoads: UserLoad[] = [];
hasData = false;
private readonly subs: Subscription[] = [];
constructor(
private routeState: RouteState,
private exportService: PlanningExportService
) {}
ngOnInit(): void {
this.subs.push(
this.routeState.assignedRoutes$.subscribe(() => this.refresh()),
this.routeState.noAssignedRoutes$.subscribe(() => this.refresh())
);
}
ngOnDestroy(): void {
this.subs.forEach(subscription => subscription.unsubscribe());
}
barWidth(value: number): number {
return Math.min(Math.max(value, 0), 100);
}
exportPlanning(): void {
this.exportService.export();
}
private refresh(): void {
const assigned = this.routeState.getAssignedSnapshot();
const unassigned = this.routeState.getNoAssignedSnapshot();
const totalPdvs = assigned.length + unassigned.length;
const totalDistance = assigned.reduce((sum, route) => sum + this.numberValue(route.json.distancia), 0);
const totalTravelHours = assigned.reduce((sum, route) => sum + this.numberValue(route.json.horas_desplazamiento), 0);
const totalWorkHours = assigned.reduce((sum, route) => sum + this.parseHours(route.json.horas_trabajo), 0);
const users = new Set(assigned.map(route => String(route.json.usuario_id))).size;
const days = new Set(assigned.map(route => route.json.dia)).size;
const loads = new Map<string, UserLoad>();
for (const route of assigned) {
const key = String(route.json.usuario_id);
const current = loads.get(key) ?? {
name: route.json.nombre_usuario,
routes: 0,
distance: 0,
workHours: 0,
capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario),
utilization: 0
};
current.routes++;
current.distance += this.numberValue(route.json.distancia);
current.workHours += this.parseHours(route.json.horas_trabajo);
loads.set(key, current);
}
this.userLoads = Array.from(loads.values())
.map(load => ({
...load,
utilization: load.capacityHours ? (load.workHours / load.capacityHours) * 100 : 0
}))
.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: assigned.length,
unassignedPdvs: unassigned.length,
coverage: totalPdvs ? (assigned.length / totalPdvs) * 100 : 0,
users,
days,
totalDistance,
totalTravelHours,
averageDistance: assigned.length ? totalDistance / assigned.length : 0,
averageTravelHours: assigned.length ? totalTravelHours / assigned.length : 0,
totalWorkHours,
averageRoutesPerUser: users ? assigned.length / users : 0,
averageUtilization,
assignmentBalance
};
const reasonCounts = new Map<string, number>();
for (const route of unassigned) {
const reason = route.motivo?.trim() || 'Sin motivo especificado';
reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1);
}
this.reasons = Array.from(reasonCounts.entries())
.map(([reason, count]) => ({ reason, count }))
.sort((a, b) => b.count - a.count);
this.hasData = totalPdvs > 0;
}
private numberValue(value: number | string | null | undefined): number {
const result = Number(value);
return Number.isFinite(result) ? result : 0;
}
private parseHours(value: number | string | null | undefined): number {
const result = Number.parseFloat(String(value ?? ''));
return Number.isFinite(result) ? result : 0;
}
private emptySummary(): KpiSummary {
return {
totalPdvs: 0,
assignedPdvs: 0,
unassignedPdvs: 0,
coverage: 0,
users: 0,
days: 0,
totalDistance: 0,
totalTravelHours: 0,
averageDistance: 0,
averageTravelHours: 0,
totalWorkHours: 0,
averageRoutesPerUser: 0,
averageUtilization: 0,
assignmentBalance: 0
};
}
}