187 lines
6.3 KiB
TypeScript
187 lines
6.3 KiB
TypeScript
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 { summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
|
|
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 {
|
|
id: string;
|
|
name: string;
|
|
routes: number;
|
|
distance: number;
|
|
workHours: number;
|
|
capacityHours: number;
|
|
utilization: number;
|
|
status: 'ok' | 'warning' | 'over-capacity';
|
|
alertMessage: string;
|
|
}
|
|
|
|
@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);
|
|
}
|
|
|
|
utilizationClass(load: UserLoad): string {
|
|
return load.status;
|
|
}
|
|
|
|
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 dailyRoutes = summarizeDailyRoutes(assigned);
|
|
const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0);
|
|
const totalTravelHours = dailyRoutes.reduce((sum, day) => sum + day.travelHours, 0);
|
|
const totalWorkHours = dailyRoutes.reduce((sum, day) => sum + day.workHours, 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) ?? {
|
|
id: key,
|
|
name: route.json.nombre_usuario,
|
|
routes: 0,
|
|
distance: 0,
|
|
workHours: 0,
|
|
capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario),
|
|
utilization: 0,
|
|
status: 'ok',
|
|
alertMessage: ''
|
|
};
|
|
current.routes++;
|
|
// Sequential distance and work are aggregated per user below.
|
|
loads.set(key, current);
|
|
}
|
|
|
|
this.userLoads = Array.from(loads.values()).map(load => {
|
|
const userDays = dailyRoutes.filter(day => day.userId === load.id);
|
|
load.distance = userDays.reduce((sum, day) => sum + day.distanceKm, 0);
|
|
load.workHours = userDays.reduce((sum, day) => sum + day.workHours, 0);
|
|
load.utilization = load.capacityHours ? (load.workHours / load.capacityHours) * 100 : 0;
|
|
load.status = load.utilization > 100 ? 'over-capacity' : load.utilization > 90 ? 'warning' : 'ok';
|
|
load.alertMessage = load.status === 'over-capacity'
|
|
? `Alerta: ${load.name} supera el 100% de su capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h). Reasigna PDVs o reduce horas.`
|
|
: load.status === 'warning'
|
|
? `${load.name} está sobre el 90% de su capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).`
|
|
: `${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: 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 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
|
|
};
|
|
}
|
|
}
|