1 Commits

Author SHA1 Message Date
EidanThen 540bc46fc2 feat: eliminar filtro de tiempo de distancia en las rutas 2026-09-03 13:48:17 -04:00
5 changed files with 83 additions and 35 deletions
@@ -31,10 +31,6 @@
<div class="kpi-icon"><i class="pi pi-compass"></i></div> <div class="kpi-icon"><i class="pi pi-compass"></i></div>
<div><span class="kpi-label">Distancia total</span><strong>{{ summary.totalDistance | number:'1.0-1' }} <em>km</em></strong><small>{{ summary.averageDistance | number:'1.0-1' }} km promedio por ruta</small></div> <div><span class="kpi-label">Distancia total</span><strong>{{ summary.totalDistance | number:'1.0-1' }} <em>km</em></strong><small>{{ summary.averageDistance | number:'1.0-1' }} km promedio por ruta</small></div>
</article> </article>
<article class="kpi-card kpi-blue">
<div class="kpi-icon"><i class="pi pi-clock"></i></div>
<div><span class="kpi-label">Desplazamiento total</span><strong>{{ summary.totalTravelHours | number:'1.0-1' }} <em>h</em></strong><small>{{ summary.averageTravelHours | number:'1.0-1' }} h promedio por ruta</small></div>
</article>
</div> </div>
<div class="kpi-grid operational-grid" *ngIf="hasData"> <div class="kpi-grid operational-grid" *ngIf="hasData">
@@ -14,9 +14,7 @@ interface KpiSummary {
users: number; users: number;
days: number; days: number;
totalDistance: number; totalDistance: number;
totalTravelHours: number;
averageDistance: number; averageDistance: number;
averageTravelHours: number;
totalWorkHours: number; totalWorkHours: number;
averageRoutesPerUser: number; averageRoutesPerUser: number;
averageUtilization: number; averageUtilization: number;
@@ -95,7 +93,6 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
const totalPdvs = new Set([...assignedPdvIds, ...unassignedPdvIds]).size; const totalPdvs = new Set([...assignedPdvIds, ...unassignedPdvIds]).size;
const dailyRoutes = summarizeDailyRoutes(assigned); const dailyRoutes = summarizeDailyRoutes(assigned);
const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0); 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 totalWorkHours = dailyRoutes.reduce((sum, day) => sum + day.workHours, 0);
const users = new Set(assigned.map(route => String(route.json.usuario_id))).size; 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 days = new Set(assigned.map(route => route.json.dia)).size;
@@ -146,9 +143,7 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
users, users,
days, days,
totalDistance, totalDistance,
totalTravelHours,
averageDistance: assigned.length ? totalDistance / assigned.length : 0, averageDistance: assigned.length ? totalDistance / assigned.length : 0,
averageTravelHours: assigned.length ? totalTravelHours / assigned.length : 0,
totalWorkHours, totalWorkHours,
averageRoutesPerUser: users ? assigned.length / users : 0, averageRoutesPerUser: users ? assigned.length / users : 0,
averageUtilization, averageUtilization,
@@ -186,9 +181,7 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
users: 0, users: 0,
days: 0, days: 0,
totalDistance: 0, totalDistance: 0,
totalTravelHours: 0,
averageDistance: 0, averageDistance: 0,
averageTravelHours: 0,
totalWorkHours: 0, totalWorkHours: 0,
averageRoutesPerUser: 0, averageRoutesPerUser: 0,
averageUtilization: 0, averageUtilization: 0,
@@ -357,6 +357,13 @@
.daily-routes-card { padding: 12px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: var(--glm-surface); } .daily-routes-card { padding: 12px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: var(--glm-surface); }
.daily-routes-card h3 { margin: 0 0 8px; color: var(--glm-azul); font-size: 0.82rem; text-transform: uppercase; } .daily-routes-card h3 { margin: 0 0 8px; color: var(--glm-azul); font-size: 0.82rem; text-transform: uppercase; }
.daily-route-group { border-bottom: 1px solid var(--glm-gris-claro); }
.daily-route-group:last-child { border-bottom: 0; }
.daily-route-toggle { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 0; border: 0; background: transparent; color: var(--glm-gris-oscuro); cursor: pointer; font: inherit; text-align: left; }
.daily-route-toggle:hover { background: var(--glm-gris-claro); }
.daily-route-toggle-title { color: var(--glm-azul); font-weight: 700; }
.daily-route-toggle > span:last-child { color: var(--glm-acero); font-size: 0.78rem; }
.daily-route-group-content { padding-left: 14px; }
.daily-route { display: grid; gap: 3px; padding: 8px 0; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); font-size: 0.78rem; } .daily-route { display: grid; gap: 3px; padding: 8px 0; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); font-size: 0.78rem; }
.daily-route:last-child { border-bottom: 0; } .daily-route:last-child { border-bottom: 0; }
.daily-route span, .daily-route small { color: var(--glm-acero); } .daily-route span, .daily-route small { color: var(--glm-acero); }
@@ -31,7 +31,7 @@
</select> </select>
<div class="filter-stats" *ngIf="filteredRoutes.length"> <div class="filter-stats" *ngIf="filteredRoutes.length">
<span>{{ filteredRoutes.length }} PDVs</span> <span>{{ uniqueFilteredRoutes.length }} PDVs</span>
<span>{{ totalDistancia }} km</span> <span>{{ totalDistancia }} km</span>
<span>{{ totalDuracionMin }} min</span> <span>{{ totalDuracionMin }} min</span>
</div> </div>
@@ -152,26 +152,19 @@
<section class="daily-routes-card" *ngIf="dailySummaries.length"> <section class="daily-routes-card" *ngIf="dailySummaries.length">
<h3>Rutas diarias</h3> <h3>Rutas diarias</h3>
<article class="daily-route" *ngFor="let day of dailySummaries" [class.over-capacity]="day.exceedsCapacity"> <article class="daily-route-group" *ngFor="let group of dailyGroups">
<strong>{{ day.userName }} · {{ day.day }}</strong> <button type="button" class="daily-route-toggle" (click)="toggleDailyRouteDay(group.day)" [attr.aria-expanded]="isDailyRouteDayExpanded(group.day)">
<span>{{ day.routes.length }} paradas · {{ day.distanceKm | number:'1.1-1' }} km · {{ (day.travelHours * 60) | number:'1.0-0' }} min</span> <span class="daily-route-toggle-title"><i class="pi" [class.pi-chevron-right]="!isDailyRouteDayExpanded(group.day)" [class.pi-chevron-down]="isDailyRouteDayExpanded(group.day)"></i> {{ group.day }}</span>
<small [attr.aria-label]="day.exceedsCapacity ? 'Capacidad excedida' : 'Dentro de capacidad'">{{ day.totalHours | number:'1.1-1' }} / {{ day.capacityHours | number:'1.1-1' }} h {{ day.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}</small> <span>{{ group.totalStops }} paradas · {{ group.totalDistance | number:'1.1-1' }} km · {{ (group.totalTravelHours * 60) | number:'1.0-0' }} min</span>
</button>
<div class="daily-route-group-content" *ngIf="isDailyRouteDayExpanded(group.day)">
<article class="daily-route" *ngFor="let route of group.routes" [class.over-capacity]="route.exceedsCapacity">
<strong>{{ route.userName }}</strong>
<span>{{ route.routes.length }} paradas · {{ route.distanceKm | number:'1.1-1' }} km · {{ (route.travelHours * 60) | number:'1.0-0' }} min</span>
<small [attr.aria-label]="route.exceedsCapacity ? 'Capacidad excedida' : 'Dentro de capacidad'">{{ route.totalHours | number:'1.1-1' }} / {{ route.capacityHours | number:'1.1-1' }} h {{ route.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}</small>
</article>
</div>
</article> </article>
</section> </section>
<div class="routes-list" *ngIf="filteredRoutes.length">
<h3>{{ selectedUsuarioId === 'Todos' ? 'PDVs de todos los usuarios' : 'PDVs del usuario' }} ({{ filteredRoutes.length }})</h3>
<ul>
<li *ngFor="let r of filteredRoutes; let i = index" tabindex="0" role="button"
[class.selected]="selectedRouteDetail && selectedRouteDetail.json.pdv_id === r.json.pdv_id && selectedRouteDetail.json.usuario_id === r.json.usuario_id && selectedRouteDetail.json.dia === r.json.dia"
(click)="selectRouteFromList(r)" (keydown.enter)="selectRouteFromList(r)" (keydown.space)="selectRouteFromList(r); $event.preventDefault()">
<span class="idx">{{ routeVisitOrder(r) }}</span>
<div class="li-main">
<strong>{{ r.json.nombre_pdv }}</strong>
<small>{{ selectedUsuarioId === 'Todos' ? r.json.nombre_usuario + ' • ' : '' }}{{ r.json.dia }} • {{ routeSegmentDistance(r) | number:'1.2-2' }} km • {{ routeSegmentDurationMinutes(r) | number:'1.0-0' }} min</small>
</div>
<i class="pi pi-chevron-right"></i>
</li>
</ul>
</div>
</div> </div>
@@ -11,6 +11,14 @@ import { RouteAssignedResponse, RouteNoAssignedResponse } from '../../../core/mo
import { colorForUsuario } from '../../../shared/utils/palette.utils'; import { colorForUsuario } from '../../../shared/utils/palette.utils';
import { DailyRouteSummary, haversineKm, sequenceRoutes, summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils'; import { DailyRouteSummary, haversineKm, sequenceRoutes, summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
interface DailyRouteGroup {
day: string;
routes: DailyRouteSummary[];
totalStops: number;
totalDistance: number;
totalTravelHours: number;
}
interface MapRoute { interface MapRoute {
data: RouteAssignedResponse; data: RouteAssignedResponse;
color: string; color: string;
@@ -53,6 +61,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
dias: string[] = []; dias: string[] = [];
selectedUsuarioId: string = 'Todos'; selectedUsuarioId: string = 'Todos';
selectedDia: string = 'Todos'; selectedDia: string = 'Todos';
expandedDailyRouteDay: string | null = null;
selectedRouteDetail: RouteAssignedResponse | null = null; selectedRouteDetail: RouteAssignedResponse | null = null;
filteredRoutes: RouteAssignedResponse[] = []; filteredRoutes: RouteAssignedResponse[] = [];
@@ -81,16 +90,41 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
reassignError: string | null = null; reassignError: string | null = null;
// Summary stats // Summary stats
get uniqueFilteredRoutes(): RouteAssignedResponse[] {
const unique = new Map<string, RouteAssignedResponse>();
for (const route of this.filteredRoutes) {
const pdvId = String(route.json.pdv_id);
if (!unique.has(pdvId)) unique.set(pdvId, route);
}
return Array.from(unique.values());
}
get totalDistancia(): string { get totalDistancia(): string {
const uniqueIds = new Set(this.uniqueFilteredRoutes.map(route => String(route.json.pdv_id)));
const sum = this.mapRoutes.length const sum = this.mapRoutes.length
? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDistanceKm ?? haversineKm(route.origin, route.destination)), 0) ? this.mapRoutes
: this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.distancia), 0); .filter(route => uniqueIds.has(String(route.data.json.pdv_id)))
.reduce((acc, route) => {
const firstSegment = this.mapRoutes.find(candidate => String(candidate.data.json.pdv_id) === String(route.data.json.pdv_id));
return firstSegment === route
? acc + (route.segmentDistanceKm ?? haversineKm(route.origin, route.destination))
: acc;
}, 0)
: this.uniqueFilteredRoutes.reduce((acc, route) => acc + Number(route.json.distancia), 0);
return sum.toFixed(2); return sum.toFixed(2);
} }
get totalDuracionMin(): string { get totalDuracionMin(): string {
const uniqueIds = new Set(this.uniqueFilteredRoutes.map(route => String(route.json.pdv_id)));
const sum = this.mapRoutes.length const sum = this.mapRoutes.length
? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDurationHours ?? Number(route.data.json.horas_desplazamiento)) * 60, 0) ? this.mapRoutes
: this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.horas_desplazamiento) * 60, 0); .filter(route => uniqueIds.has(String(route.data.json.pdv_id)))
.reduce((acc, route) => {
const firstSegment = this.mapRoutes.find(candidate => String(candidate.data.json.pdv_id) === String(route.data.json.pdv_id));
return firstSegment === route
? acc + (route.segmentDurationHours ?? Number(route.data.json.horas_desplazamiento)) * 60
: acc;
}, 0)
: this.uniqueFilteredRoutes.reduce((acc, route) => acc + Number(route.json.horas_desplazamiento) * 60, 0);
return sum.toFixed(0); return sum.toFixed(0);
} }
@@ -98,6 +132,31 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
return summarizeDailyRoutes(this.filteredRoutes); return summarizeDailyRoutes(this.filteredRoutes);
} }
get dailyGroups(): DailyRouteGroup[] {
const order = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes'];
const groups = new Map<string, DailyRouteSummary[]>();
for (const summary of this.dailySummaries) {
const routes = groups.get(summary.day) ?? [];
routes.push(summary);
groups.set(summary.day, routes);
}
return Array.from(groups, ([day, routes]) => ({
day,
routes,
totalStops: routes.reduce((sum, route) => sum + route.routes.length, 0),
totalDistance: routes.reduce((sum, route) => sum + route.distanceKm, 0),
totalTravelHours: routes.reduce((sum, route) => sum + route.travelHours, 0)
})).sort((a, b) => order.indexOf(a.day) - order.indexOf(b.day));
}
toggleDailyRouteDay(day: string): void {
this.expandedDailyRouteDay = this.expandedDailyRouteDay === day ? null : day;
}
isDailyRouteDayExpanded(day: string): boolean {
return this.expandedDailyRouteDay === day;
}
constructor( constructor(
private routeState: RouteState, private routeState: RouteState,
private orsService: OrsService, private orsService: OrsService,