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
@@ -196,3 +196,31 @@
background: var(--glm-verde);
border: 3px solid #fff;
}
.location-pin {
position: relative;
width: 30px;
height: 38px;
background: #c9252d;
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
}
.location-pin::before {
content: '';
position: absolute;
inset: 3px 3px 7px;
background: #ff424a;
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
}
.location-pin::after {
content: '';
position: absolute;
top: 8px;
left: 8px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #620000;
}
@@ -25,6 +25,8 @@
<div><dt>Usuario</dt><dd>{{ routeData.json.nombre_usuario }}</dd></div>
<div><dt>PDV</dt><dd>{{ routeData.json.nombre_pdv }}</dd></div>
<div><dt>Día</dt><dd>{{ routeData.json.dia }}</dd></div>
<div><dt>Orden</dt><dd>{{ visitOrder }} de {{ totalStops }}</dd></div>
<div><dt>Origen del tramo</dt><dd>{{ segmentOriginName }}</dd></div>
<div><dt>Distancia</dt><dd>{{ routeData.json.distancia }} km</dd></div>
<div><dt>Duración</dt><dd>{{ (routeData.json.horas_desplazamiento * 60).toFixed(0) }} min</dd></div>
<div><dt>Horas trabajo</dt><dd>{{ routeData.json.horas_trabajo }}</dd></div>
@@ -1,11 +1,12 @@
import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Router, ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import * as L from 'leaflet';
import { RouteState } from '../../../core/route-state/route';
import { OrsService } from '../../../core/services/ors.service';
import { RouteAssignedResponse } from '../../../core/models/route.model';
import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils';
@Component({
standalone: true,
@@ -26,9 +27,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
routeData: RouteAssignedResponse | null = null;
isLoading = false;
error: string | null = null;
segmentOriginName = '';
visitOrder = 0;
totalStops = 0;
constructor(
private router: Router,
private activatedRoute: ActivatedRoute,
private routeState: RouteState,
private orsService: OrsService,
private cdr: ChangeDetectorRef
@@ -36,11 +41,30 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
ngOnInit() {
this.sub = this.routeState.selectedRoute$.subscribe((route: RouteAssignedResponse | null) => {
this.routeData = route;
if (route && this.map) {
this.loadAndShowRoute(route);
} else if (!route) {
this.error = 'No hay ruta seleccionada';
if (route) {
this.routeData = route;
if (this.map) {
this.loadAndShowRoute(route);
}
this.error = null;
} else {
// Try to resolve from queryParams + sessionStorage snapshot
const qp = this.activatedRoute.snapshot.queryParams;
const pdvId = qp['pdv_id'];
const usuarioId = qp['usuario_id'];
if (pdvId) {
const found = this.routeState.findRoute(pdvId, usuarioId);
if (found) {
this.routeData = found;
if (this.map) this.loadAndShowRoute(found);
this.error = null;
} else {
// No data at all (e.g., direct URL after hard refresh with empty storage)
this.error = 'No hay ruta seleccionada. Vuelve al listado y selecciona una ruta.';
}
} else if (!this.routeData) {
this.error = 'No hay ruta seleccionada';
}
}
this.cdr.detectChanges();
});
@@ -50,6 +74,18 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
this.initMap();
if (this.routeData) {
this.loadAndShowRoute(this.routeData);
} else {
// Re-attempt resolution after map init (queryParams case)
const qp = this.activatedRoute.snapshot.queryParams;
if (qp['pdv_id']) {
const found = this.routeState.findRoute(qp['pdv_id'], qp['usuario_id']);
if (found) {
this.routeData = found;
this.error = null;
this.loadAndShowRoute(found);
this.cdr.detectChanges();
}
}
}
}
@@ -64,6 +100,8 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
private initMap() {
this.map = L.map('map').setView([18.4861, -69.9312], 13);
const pdvPane = this.map.createPane('pdvPane');
pdvPane.style.zIndex = '650';
this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
@@ -77,13 +115,20 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
this.cdr.detectChanges();
const json = route.json;
const origin: [number, number] = [Number(json.longitud_usuario), Number(json.latitud_usuario)];
const sequence = sequenceRoutes(this.routeState.getAssignedSnapshot().filter(item =>
String(item.json.usuario_id) === String(json.usuario_id) && item.json.dia === json.dia
));
const segment = sequence.find(item => item.route === route) ?? sequence.find(item => item.route.json.pdv_id === json.pdv_id);
const origin: [number, number] = segment?.origin ?? [Number(json.longitud_usuario), Number(json.latitud_usuario)];
const destination: [number, number] = [Number(json.longitud_pdv), Number(json.latitud_pdv)];
this.segmentOriginName = segment?.previousPdvName ?? `Casa de ${json.nombre_usuario}`;
this.visitOrder = segment?.order ?? 1;
this.totalStops = segment?.totalStops ?? 1;
this.centerMapOnPoints(origin, destination);
try {
const geojson = await this.orsService.processPairs(
const results = await this.orsService.processPairs(
[{ pdv_id: json.pdv_id, origin, destination }],
1,
true
@@ -91,13 +136,14 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
if (this.destroyed) return;
if (geojson[0] && !geojson[0].error) {
this.showRouteOnMap(geojson[0], origin, destination, json);
const result = results[0] as { error?: string; geojson?: unknown } | undefined;
if (result && !result.error && result.geojson) {
this.showRouteOnMap(result.geojson, origin, destination, json, this.segmentOriginName);
} else {
this.showFallbackRoute(origin, destination, json);
this.showFallbackRoute(origin, destination, json, this.segmentOriginName);
}
} catch {
this.showFallbackRoute(origin, destination, json);
this.showFallbackRoute(origin, destination, json, this.segmentOriginName);
} finally {
this.isLoading = false;
this.cdr.detectChanges();
@@ -112,31 +158,38 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
this.map.fitBounds(bounds.pad(0.3));
}
private extractCoords(geojson: any): L.LatLngExpression[] {
if (geojson.type === 'FeatureCollection' && geojson.features?.length) {
return geojson.features[0].geometry.coordinates.map(
private extractCoords(geojson: unknown): L.LatLngExpression[] {
const g = geojson as { type: string; features?: { geometry: { coordinates: number[][] } }[]; coordinates?: number[][] };
if (g.type === 'FeatureCollection' && g.features?.length) {
return g.features[0].geometry.coordinates.map(
(c: number[]) => [c[1], c[0]] as L.LatLngExpression
);
}
if (geojson.type === 'LineString') {
return geojson.coordinates.map(
if (g.type === 'LineString' && g.coordinates) {
return g.coordinates.map(
(c: number[]) => [c[1], c[0]] as L.LatLngExpression
);
}
// ORS geojson response wrapped
const maybeFeature = (geojson as { features?: { geometry: { coordinates: number[][] } }[] })?.features;
if (maybeFeature?.length) {
return maybeFeature[0].geometry.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression);
}
return [];
}
private showRouteOnMap(geojson: any, origin: [number, number], destination: [number, number], json: any) {
private showRouteOnMap(geojson: unknown, origin: [number, number], destination: [number, number], json: RouteAssignedResponse['json'], originName: string) {
const coords = this.extractCoords(geojson);
if (!coords.length) {
this.showFallbackRoute(origin, destination, json);
this.showFallbackRoute(origin, destination, json, originName);
return;
}
const distance = geojson.features?.[0]?.properties?.summary?.distance
? (geojson.features[0].properties.summary.distance / 1000).toFixed(2) : 'N/A';
const duration = geojson.features?.[0]?.properties?.summary?.duration
? (geojson.features[0].properties.summary.duration / 60).toFixed(0) : 'N/A';
const g = geojson as { features?: { properties?: { summary?: { distance: number; duration: number } } }[] };
const distance = g.features?.[0]?.properties?.summary?.distance
? (g.features[0].properties.summary.distance / 1000).toFixed(2) : 'N/A';
const duration = g.features?.[0]?.properties?.summary?.duration
? (g.features[0].properties.summary.duration / 60).toFixed(0) : 'N/A';
this.routeGlow = L.polyline(coords, {
color: '#6CC24A',
@@ -163,11 +216,11 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
lineCap: 'round'
}).addTo(this.map);
this.routeLine.bindPopup(
`<strong style="color:#4F758B">${json.nombre_usuario}${json.nombre_pdv}</strong><br>Distancia: ${distance} km<br>Duración: ${duration} min`
(this.routeLine as L.Polyline).bindPopup(
`<strong style="color:#4F758B">${originName}${json.nombre_pdv}</strong><br>Distancia: ${distance} km<br>Duración: ${duration} min`
);
this.addMarkers(origin, destination, json, distance, duration);
this.addMarkers(origin, destination, json, distance, duration, originName);
const group = new L.FeatureGroup([
...(this.routeGlow ? [this.routeGlow] : []),
...(this.routeLine ? [this.routeLine] : []),
@@ -176,13 +229,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
this.map.fitBounds(group.getBounds().pad(0.1));
}
private showFallbackRoute(origin: [number, number], destination: [number, number], json: any) {
private showFallbackRoute(origin: [number, number], destination: [number, number], json: RouteAssignedResponse['json'], originName: string) {
this.routeLine = L.polyline(
[[origin[1], origin[0]], [destination[1], destination[0]]],
{ color: '#FF6A13', weight: 5, dashArray: '12, 10', opacity: 0.9 }
).addTo(this.map);
this.addMarkers(origin, destination, json, 'N/A', 'N/A');
this.addMarkers(origin, destination, json, 'N/A', 'N/A', originName);
const group = new L.FeatureGroup([this.routeLine, ...this.markers]);
this.map.fitBounds(group.getBounds().pad(0.1));
}
@@ -190,9 +243,10 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
private addMarkers(
origin: [number, number],
destination: [number, number],
json: any,
json: RouteAssignedResponse['json'],
distance: string,
duration: string
duration: string,
originName: string
) {
const startIcon = L.divIcon({
className: 'custom-marker marker-start',
@@ -204,17 +258,17 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
const endIcon = L.divIcon({
className: 'custom-marker marker-end',
html: '<div class="marker-inner">B</div>',
iconSize: [30, 30],
iconAnchor: [15, 15],
html: '<div class="location-pin"><span></span></div>',
iconSize: [30, 38],
iconAnchor: [15, 38],
popupAnchor: [0, -18]
});
this.markers.push(
L.marker([origin[1], origin[0]], { icon: startIcon })
.addTo(this.map)
.bindPopup(`<strong>Origen:</strong> ${json.nombre_usuario}`),
L.marker([destination[1], destination[0]], { icon: endIcon })
.bindPopup(`<strong>Origen del tramo:</strong> ${originName}`),
L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 })
.addTo(this.map)
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
);
@@ -69,3 +69,31 @@
color: var(--glm-acero);
font-size: 13px;
}
.link-usuario {
color: var(--glm-azul);
font-weight: 600;
cursor: pointer;
text-decoration: underline;
text-decoration-color: transparent;
transition: text-decoration-color 0.15s;
}
.link-usuario:hover {
text-decoration-color: var(--glm-verde);
color: var(--glm-verde);
}
.inline-edit {
width: 100%;
min-width: 100px;
padding: 4px 6px;
border: 1px solid var(--glm-verde);
border-radius: 3px;
background: #fff;
color: var(--glm-gris-oscuro);
font: inherit;
}
.edit-actions {
white-space: nowrap;
}
@@ -7,9 +7,19 @@
(input)="dt.filterGlobal($any($event.target).value, 'contains')"
/>
<div class="actions-right">
<button type="button" class="btn-secondary" (click)="goToOverviewMap()" pTooltip="Ver mapa filtrado por usuario" tooltipPosition="top">
<i class="pi pi-map"></i> Ver mapa
</button>
<button type="button" class="btn-secondary" (click)="downloadCSV()">
<i class="pi pi-download"></i> Descargar CSV
</button>
<button type="button" class="btn-secondary" (click)="reoptimizeLocally()" [disabled]="!canReoptimize" title="Vuelve a ejecutar el optimizador con los archivos cargados">
<span *ngIf="reoptLoading" class="spinner-inline"></span>
<i *ngIf="!reoptLoading" class="pi pi-sync"></i> {{ reoptLoading ? 'Reoptimizando...' : 'Reoptimizar rutas' }}
</button>
<button type="button" class="btn-secondary" *ngIf="hasLocalChanges" (click)="restoreOriginal()">
<i class="pi pi-undo"></i> Deshacer cambios
</button>
<button
type="button"
class="btn-secondary"
@@ -22,6 +32,7 @@
</button>
</div>
</div>
<div class="table-error" *ngIf="reoptError"><i class="pi pi-exclamation-triangle"></i> {{ reoptError }}</div>
<p-table
#dt
@@ -60,20 +71,41 @@
<ng-template pTemplate="body" let-row>
<tr>
<td>{{ row.json.nombre_usuario }}</td>
<td>
<ng-container *ngIf="editingRoute === row; else userValue">
<select class="inline-edit" [(ngModel)]="editUserId">
<option *ngFor="let user of userOptions" [value]="user.id">{{ user.name }}</option>
</select>
</ng-container>
<ng-template #userValue><a class="link-usuario" (click)="goToUserMap(row.json.usuario_id)" pTooltip="Ver todas las rutas de este usuario" tooltipPosition="top">{{ row.json.nombre_usuario }}</a></ng-template>
</td>
<td>{{ row.json.nombre_pdv }}</td>
<td>{{ row.json.hora_laboral_semanal_usuario }}</td>
<td>{{ row.json.hora_minima_semanal_pdv }}</td>
<td>{{ row.json.horas_trabajo }}</td>
<td>{{ row.json.dia }}</td>
<td>
<ng-container *ngIf="editingRoute === row; else dayValue">
<select class="inline-edit" [(ngModel)]="editDay">
<option *ngFor="let day of dayOptions" [value]="day">{{ day }}</option>
</select>
</ng-container>
<ng-template #dayValue>{{ row.json.dia }}</ng-template>
</td>
<td>{{ row.json.distancia }}</td>
<td>{{ row.json.horas_desplazamiento }}</td>
<td class="text-center">
<td class="text-center edit-actions">
<ng-container *ngIf="editingRoute === row; else editButton">
<button type="button" class="btn-icon" (click)="saveEdit()" title="Guardar"><i class="pi pi-check"></i></button>
<button type="button" class="btn-icon" (click)="cancelEdit()" title="Cancelar"><i class="pi pi-times"></i></button>
</ng-container>
<ng-template #editButton>
<button type="button" class="btn-icon" (click)="startEdit(row)" title="Editar asignación"><i class="pi pi-pencil"></i></button>
</ng-template>
<button
type="button"
class="btn-icon"
(click)="onShowMap(row)"
pTooltip="Ver en mapa"
pTooltip="Ver en mapa (1 ruta)"
tooltipPosition="top"
>
<i class="pi pi-map-marker"></i>
@@ -84,7 +116,7 @@
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="9" class="text-center">No hay rutas asignadas</td>
<td colspan="9" class="text-center">No hay rutas asignadas</td>
</tr>
</ng-template>
</p-table>
@@ -9,6 +9,8 @@ import { TableModule } from 'primeng/table';
import { InputTextModule } from 'primeng/inputtext';
import { TooltipModule } from 'primeng/tooltip';
import { RouteState } from '../../../core/route-state/route';
import { RutasService } from '../../../core/services/router.service';
import { PlanningInputService } from '../../../core/services/planning-input.service';
@Component({
standalone: true,
@@ -30,23 +32,37 @@ export class AssignedRouteComponent {
assignedRoutes: RouteAssignedResponse[] = [];
isLoading = false;
cols: any[] = [];
editingRoute: RouteAssignedResponse | null = null;
editUserId = '';
editDay = '';
hasLocalChanges = false;
reoptLoading = false;
reoptError: string | null = null;
constructor(
private routesState: RouteState,
private cdr: ChangeDetectorRef,
private router: Router
private router: Router,
private rutasService: RutasService,
private planningInput: PlanningInputService
) { }
private subs: import('rxjs').Subscription[] = [];
ngOnInit() {
this.routesState.resetLoading();
this.routesState.loading$.subscribe(value => {
this.isLoading = value;
this.cdr.detectChanges();
});
this.routesState.assignedRoutes$.subscribe(data => {
this.assignedRoutes = [...data];
this.cdr.detectChanges();
});
this.subs.push(
this.routesState.loading$.subscribe(value => {
this.isLoading = value;
this.cdr.detectChanges();
})
);
this.subs.push(
this.routesState.assignedRoutes$.subscribe(data => {
this.assignedRoutes = [...data];
this.cdr.detectChanges();
})
);
this.cols = [
{ field: 'json.nombre_usuario', header: 'Usuario' },
{ field: 'json.nombre_pdv', header: 'PDV' },
@@ -60,14 +76,101 @@ export class AssignedRouteComponent {
];
}
onShowMap(row: RouteAssignedResponse) {
onShowMap(row: RouteAssignedResponse) {
this.routesState.sendRoute(row);
this.router.navigate(['/home/assigned-route']);
this.router.navigate(['/home/assigned-route'], {
queryParams: { pdv_id: row.json.pdv_id, usuario_id: row.json.usuario_id, dia: row.json.dia }
});
}
goToOverviewMap() {
// If table has filtered view, use first visible row's usuario as default; else first assigned
const target = (this.table?.filteredValue?.[0] as RouteAssignedResponse | undefined) ?? this.assignedRoutes[0];
if (target) {
this.router.navigate(['/home/mapa'], {
queryParams: { usuario_id: target.json.usuario_id, dia: 'Todos' }
});
} else {
this.router.navigate(['/home/mapa']);
}
}
goToUserMap(usuarioId: string) {
this.router.navigate(['/home/mapa'], { queryParams: { usuario_id: usuarioId, dia: 'Todos' } });
}
get userOptions(): { id: string; name: string }[] {
const users = new Map<string, string>();
for (const route of this.assignedRoutes) users.set(String(route.json.usuario_id), route.json.nombre_usuario);
return Array.from(users, ([id, name]) => ({ id, name }));
}
get dayOptions(): string[] {
return this.routesState.getDiasUnicos();
}
startEdit(route: RouteAssignedResponse): void {
this.editingRoute = route;
this.editUserId = String(route.json.usuario_id);
this.editDay = route.json.dia;
}
cancelEdit(): void {
this.editingRoute = null;
}
saveEdit(): void {
if (!this.editingRoute || !this.editUserId || !this.editDay) return;
this.routesState.updateAssignedRoute(this.editingRoute, this.editUserId, this.editDay);
this.hasLocalChanges = true;
this.editingRoute = null;
}
get canReoptimize(): boolean {
return this.assignedRoutes.length > 0 && !!this.planningInput.getFormData() && !this.reoptLoading;
}
reoptimizeLocally(): void {
const data = this.planningInput.getFormData();
if (!data || !this.assignedRoutes.length || this.reoptLoading) return;
this.reoptLoading = true;
this.reoptError = null;
this.routesState.setLoading(true);
this.rutasService.reoptimizarRutas(data).subscribe({
next: response => {
const items = response as unknown as unknown[];
const assigned = items.find(item => item && typeof item === 'object' && 'assigned' in item) as { assigned?: unknown } | undefined;
const noAssigned = items.find(item => item && typeof item === 'object' && 'noAssigned' in item) as { noAssigned?: unknown } | undefined;
if (assigned?.assigned) this.routesState.setAssignedRoutes(assigned.assigned as never);
if (noAssigned?.noAssigned) this.routesState.setNoAssignedRoutes(noAssigned.noAssigned as never);
this.hasLocalChanges = false;
this.reoptLoading = false;
this.routesState.setLoading(false);
this.cdr.detectChanges();
},
error: err => {
this.reoptError = err?.error?.error || err?.message || 'No se pudo reoptimizar la planificación';
this.reoptLoading = false;
this.routesState.setLoading(false);
this.cdr.detectChanges();
}
});
this.editingRoute = null;
}
restoreOriginal(): void {
this.routesState.restoreOriginalAssignedRoutes();
this.hasLocalChanges = false;
this.editingRoute = null;
}
toggleRutas() {
this.rutasExpandido = !this.rutasExpandido;
}
ngOnDestroy() {
this.subs.forEach(s => s.unsubscribe());
}
downloadCSV() {
const headers = [
'Usuario ID',
@@ -119,4 +222,4 @@ export class AssignedRouteComponent {
saveAs(blob, 'rutas_planificadas.csv');
}
}
}
@@ -233,6 +233,161 @@
cursor: not-allowed;
}
.file-label.has-error {
border-color: #F5A6A0;
background: var(--glm-rojo-ok);
}
.file-label.has-success {
border-color: var(--glm-verde);
background: var(--glm-verde-bg);
}
.preview-card {
padding: 12px;
border-radius: 4px;
border: 1px solid var(--glm-gris-medio);
background: var(--glm-surface);
font-size: 0.85rem;
}
.preview-success {
border-color: var(--glm-verde-claro);
background: var(--glm-verde-bg);
}
.preview-success .preview-header {
color: var(--glm-verde);
font-weight: 700;
display: flex;
align-items: center;
gap: 6px;
}
.preview-error {
border-color: #F5A6A0;
background: var(--glm-rojo-ok);
}
.preview-error .preview-header {
color: #D32F2F;
font-weight: 700;
display: flex;
align-items: center;
gap: 6px;
}
.preview-stats {
margin-top: 6px;
color: var(--glm-acero);
font-size: 0.8rem;
}
.preview-errors {
margin: 8px 0 0;
padding: 0 0 0 18px;
color: var(--glm-gris-oscuro);
font-size: 0.82rem;
}
.preview-errors li {
margin-bottom: 4px;
}
.error-value {
color: var(--glm-acero);
font-style: italic;
}
.more-errors {
color: var(--glm-acero);
font-style: italic;
}
.preview-loading {
color: var(--glm-acero);
font-size: 0.8rem;
}
.preview-loading-row {
display: flex;
align-items: center;
gap: 8px;
color: var(--glm-acero);
}
.params-card {
margin-top: 16px;
padding: 14px;
background: var(--glm-surface);
border: 1px solid var(--glm-gris-medio);
border-radius: 4px;
border-left: 3px solid var(--glm-azul);
}
.params-title {
margin: 0 0 10px;
font-size: 0.9rem;
color: var(--glm-azul);
font-weight: 700;
display: flex;
align-items: center;
gap: 6px;
}
.params-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
@media (min-width: 600px) {
.params-grid {
grid-template-columns: 1fr 1fr;
}
}
.param-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.param-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--glm-azul);
text-transform: uppercase;
letter-spacing: 0.2px;
}
.param-select {
padding: 8px 10px;
border: 1px solid var(--glm-gris-medio);
border-radius: 4px;
font-size: 0.9rem;
font-family: Arial, sans-serif;
background: #fff;
color: var(--glm-gris-oscuro);
}
.param-select:focus {
outline: none;
border-color: var(--glm-verde);
box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15);
}
.param-hint {
font-size: 0.75rem;
color: var(--glm-acero);
}
.hint-text {
color: var(--glm-acero);
font-size: 0.82rem;
margin-top: 4px;
}
.spinner-inline {
width: 16px;
height: 16px;
@@ -12,13 +12,13 @@
<section class="card upload-card">
<h2 class="section-title">Subir archivos para optimización</h2>
<div class="upload-grid">
<div class="file-input-wrapper">
<label class="file-label" for="pdvFile">
<label class="file-label" for="pdvFile" [class.has-error]="pdvPreview && !pdvPreview.valid" [class.has-success]="pdvPreview?.valid">
<i class="pi pi-file-excel"></i>
<span>Archivo PDVs (Excel)</span>
<span class="file-hint">.xlsx / .xls • máx 10MB</span>
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 2000 filas</span>
</label>
<input
id="pdvFile"
@@ -29,16 +29,39 @@
[class.has-file]="pdvFile"
/>
<div class="file-name" *ngIf="pdvFile">
<i class="pi pi-check-circle"></i>
<i class="pi" [class.pi-check-circle]="pdvPreview?.valid" [class.pi-exclamation-circle]="pdvPreview && !pdvPreview.valid" [class.pi-spin]="pdvPreviewLoading"></i>
{{ pdvFile.name }}
<span *ngIf="pdvPreviewLoading" class="preview-loading">Validando...</span>
</div>
<div class="preview-card" *ngIf="pdvPreviewLoading">
<div class="preview-loading-row"><span class="spinner-inline"></span> Analizando archivo PDV...</div>
</div>
<div class="preview-card preview-success" *ngIf="pdvPreview?.valid">
<div class="preview-header"><i class="pi pi-check-circle"></i> PDV válido — {{ pdvPreview!.totalRows }} filas</div>
<div class="preview-stats">{{ pdvPreview!.validRows }} filas válidas • Columnas: {{ pdvPreview!.headers.join(', ') }}</div>
</div>
<div class="preview-card preview-error" *ngIf="pdvPreview && !pdvPreview.valid">
<div class="preview-header"><i class="pi pi-exclamation-triangle"></i> Errores en PDV ({{ pdvPreview.errors.length }})</div>
<div class="preview-stats" *ngIf="pdvPreview.missingHeaders.length">Faltan: {{ pdvPreview.missingHeaders.join(', ') }}</div>
<div class="preview-stats">{{ pdvPreview.totalRows }} filas leídas • {{ pdvPreview.validRows }} válidas</div>
<ul class="preview-errors">
<li *ngFor="let e of pdvPreview.errors.slice(0, 8)">
<strong>Fila {{ e.row }} — {{ e.field }}:</strong> {{ e.message }}
<span *ngIf="e.value !== undefined && e.value !== null" class="error-value">({{ e.value }})</span>
</li>
<li *ngIf="pdvPreview.errors.length > 8" class="more-errors">+ {{ pdvPreview.errors.length - 8 }} errores más...</li>
</ul>
</div>
</div>
<div class="file-input-wrapper">
<label class="file-label" for="usuariosFile">
<label class="file-label" for="usuariosFile" [class.has-error]="usuariosPreview && !usuariosPreview.valid" [class.has-success]="usuariosPreview?.valid">
<i class="pi pi-file-excel"></i>
<span>Archivo Usuarios (Excel)</span>
<span class="file-hint">.xlsx / .xls • máx 10MB</span>
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 50 filas</span>
</label>
<input
id="usuariosFile"
@@ -49,8 +72,56 @@
[class.has-file]="usuariosFile"
/>
<div class="file-name" *ngIf="usuariosFile">
<i class="pi pi-check-circle"></i>
<i class="pi" [class.pi-check-circle]="usuariosPreview?.valid" [class.pi-exclamation-circle]="usuariosPreview && !usuariosPreview.valid"></i>
{{ usuariosFile.name }}
<span *ngIf="usuariosPreviewLoading" class="preview-loading">Validando...</span>
</div>
<div class="preview-card" *ngIf="usuariosPreviewLoading">
<div class="preview-loading-row"><span class="spinner-inline"></span> Analizando archivo Usuarios...</div>
</div>
<div class="preview-card preview-success" *ngIf="usuariosPreview?.valid">
<div class="preview-header"><i class="pi pi-check-circle"></i> Usuarios válido — {{ usuariosPreview!.totalRows }} filas</div>
<div class="preview-stats">{{ usuariosPreview!.validRows }} filas válidas • Columnas: {{ usuariosPreview!.headers.join(', ') }}</div>
</div>
<div class="preview-card preview-error" *ngIf="usuariosPreview && !usuariosPreview.valid">
<div class="preview-header"><i class="pi pi-exclamation-triangle"></i> Errores en Usuarios ({{ usuariosPreview.errors.length }})</div>
<div class="preview-stats" *ngIf="usuariosPreview.missingHeaders.length">Faltan: {{ usuariosPreview.missingHeaders.join(', ') }}</div>
<div class="preview-stats">{{ usuariosPreview.totalRows }} filas leídas • {{ usuariosPreview.validRows }} válidas</div>
<ul class="preview-errors">
<li *ngFor="let e of usuariosPreview.errors.slice(0, 8)">
<strong>Fila {{ e.row }} — {{ e.field }}:</strong> {{ e.message }}
<span *ngIf="e.value !== undefined && e.value !== null" class="error-value">({{ e.value }})</span>
</li>
<li *ngIf="usuariosPreview.errors.length > 8" class="more-errors">+ {{ usuariosPreview.errors.length - 8 }} errores más...</li>
</ul>
</div>
</div>
</div>
<div class="params-card">
<h3 class="params-title"><i class="pi pi-sliders-h"></i> Parámetros de optimización</h3>
<div class="params-grid">
<div class="param-field">
<label class="param-label" for="radioKm">Radio máximo por PDV</label>
<select id="radioKm" class="param-select" [(ngModel)]="radioKm" (ngModelChange)="onRadioChange()">
<option [ngValue]="30">30 km — Urbano denso</option>
<option [ngValue]="50">50 km — Urbano (default)</option>
<option [ngValue]="80">80 km — Rural disperso</option>
</select>
<small class="param-hint">PDV sin usuario dentro del radio queda sin asignar</small>
</div>
<div class="param-field">
<label class="param-label" for="velocidad">Velocidad promedio</label>
<select id="velocidad" class="param-select" [(ngModel)]="velocidad" (ngModelChange)="onVelocidadChange()">
<option [ngValue]="22">22 km/h — Centro / tráfico denso</option>
<option [ngValue]="28">28 km/h — Urbano (default)</option>
<option [ngValue]="35">35 km/h — Mixto</option>
<option [ngValue]="46">46 km/h — Ruta rápida</option>
</select>
<small class="param-hint">Usado si ORS vial no disponible. En ciudad 22-28 es más real que 46</small>
</div>
</div>
</div>
@@ -65,12 +136,21 @@
type="button"
class="btn-primary"
(click)="cargarRutas()"
[disabled]="!canSubmit || isLoading"
[disabled]="!canSubmit"
[title]="submitDisabledReason || ''"
>
<span *ngIf="isLoading" class="spinner-inline"></span>
<i *ngIf="!isLoading" class="pi pi-upload"></i>
{{ isLoading ? 'Procesando...' : 'Cargar rutas' }}
</button>
<button
type="button"
class="btn-secondary"
*ngIf="isLoading"
(click)="cancelarCarga()"
>
<i class="pi pi-times"></i> Cancelar
</button>
<button
type="button"
class="btn-secondary"
@@ -80,9 +160,14 @@
<i class="pi pi-times"></i> Limpiar
</button>
</div>
<small class="hint-text" *ngIf="submitDisabledReason && !isLoading">{{ submitDisabledReason }}</small>
</div>
</section>
<section class="card kpis-card">
<app-planning-kpis></app-planning-kpis>
</section>
<section class="card">
<app-assigned-route></app-assigned-route>
</section>
@@ -91,4 +176,4 @@
<h2 class="section-title">PDVs sin ruta asignada</h2>
<app-no-assigned-route></app-no-assigned-route>
</section>
</div>
</div>
@@ -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 = '';
}
}
}
@@ -11,3 +11,42 @@
text-transform: uppercase;
letter-spacing: 0.3px;
}
.assignment-actions {
display: flex;
align-items: center;
gap: 5px;
min-width: 230px;
}
.inline-select {
max-width: 115px;
padding: 5px;
border: 1px solid var(--glm-gris-medio);
border-radius: 3px;
background: #fff;
color: var(--glm-gris-oscuro);
font-size: 0.75rem;
}
.spinner-inline {
display: inline-block;
width: 13px;
height: 13px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.table-error {
margin-top: 10px;
padding: 9px 12px;
background: var(--glm-rojo-ok);
border: 1px solid #F5A6A0;
border-radius: 4px;
color: var(--glm-gris-oscuro);
font-size: 0.82rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
@@ -19,14 +19,29 @@
<td class="text-left">{{ row.minimo_horas_semana }}</td>
<td class="text-left">{{ row.latitud }}</td>
<td class="text-left">{{ row.longitud }}</td>
<td class="text-left">{{ row.motivo }}</td>
<td class="text-left">{{ row.motivo }}</td>
<td class="assignment-actions">
<select [(ngModel)]="assignmentUser[row.pdv_id]" class="inline-select">
<option value="">Usuario</option>
<option *ngFor="let user of userOptions" [value]="user.id">{{ user.name }}</option>
</select>
<select [(ngModel)]="assignmentDay[row.pdv_id]" class="inline-select">
<option value="">Día</option>
<option *ngFor="let day of dayOptions" [value]="day">{{ day }}</option>
</select>
<button type="button" class="btn-icon" (click)="assignRoute(row)" [disabled]="assigningId === row.pdv_id" title="Asignar y calcular ruta">
<span *ngIf="assigningId === row.pdv_id" class="spinner-inline"></span>
<i *ngIf="assigningId !== row.pdv_id" class="pi pi-plus"></i>
</button>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="8" class="text-center">
<td colspan="7" class="text-center">
No hay registros asignados
</td>
</tr>
</ng-template>
</p-table>
</p-table>
<div class="table-error" *ngIf="actionError"><i class="pi pi-exclamation-triangle"></i> {{ actionError }}</div>
@@ -1,36 +1,49 @@
import { Component, ChangeDetectorRef } from '@angular/core';
import { Component, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TableModule } from 'primeng/table';
import { RouteNoAssignedResponse } from '../../../core/models/route.model';
import { RouteState } from '../../../core/route-state/route';
import { Subscription } from 'rxjs';
import { OrsService } from '../../../core/services/ors.service';
@Component({
standalone: true,
selector: 'app-no-assigned-route',
imports: [CommonModule, TableModule],
imports: [CommonModule, FormsModule, TableModule],
templateUrl: './no-assigned-route.component.html',
styleUrls: ['./no-assigned-route.component.css']
})
export class NoAssignedRouteComponent {
export class NoAssignedRouteComponent implements OnDestroy {
noAssignedRoutes: RouteNoAssignedResponse[] = [];
isLoading = false;
cols: any[] = [];
cols: { field: string; header: string }[] = [];
private subs: Subscription[] = [];
assignmentUser: Record<string, string> = {};
assignmentDay: Record<string, string> = {};
assigningId: number | null = null;
actionError: string | null = null;
constructor(
private routesState: RouteState,
private cdr: ChangeDetectorRef
private cdr: ChangeDetectorRef,
private orsService: OrsService
) {}
ngOnInit() {
this.routesState.loading$.subscribe(value => {
this.isLoading = value;
this.cdr.detectChanges();
});
this.routesState.noAssignedRoutes$.subscribe(data => {
this.noAssignedRoutes = [...data];
this.cdr.detectChanges();
});
this.subs.push(
this.routesState.loading$.subscribe(value => {
this.isLoading = value;
this.cdr.detectChanges();
})
);
this.subs.push(
this.routesState.noAssignedRoutes$.subscribe(data => {
this.noAssignedRoutes = [...data];
this.cdr.detectChanges();
})
);
this.cols = [
{ field: 'pdv_id', header: 'PDV ID' },
{ field: 'pdv_nombre', header: 'Nombre' },
@@ -40,4 +53,45 @@ export class NoAssignedRouteComponent {
{ field: 'motivo', header: 'Motivo' }
];
}
get userOptions(): { id: string; name: string }[] {
return this.routesState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre }));
}
get dayOptions(): string[] {
return this.routesState.getDiasUnicos();
}
async assignRoute(route: RouteNoAssignedResponse): Promise<void> {
const userId = this.assignmentUser[String(route.pdv_id)];
const day = this.assignmentDay[String(route.pdv_id)];
if (!userId || !day) {
this.actionError = 'Selecciona usuario y día antes de asignar.';
return;
}
const user = this.routesState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === userId);
if (!user) return;
this.assigningId = route.pdv_id;
this.actionError = null;
try {
const origin = [Number(user.json.longitud_usuario), Number(user.json.latitud_usuario)];
const destination = [Number(route.longitud), Number(route.latitud)];
const result = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true);
const geo = (result[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] } }).geojson;
const summary = geo?.features?.[0]?.properties?.summary;
if (!summary) throw new Error('ORS no devolvió una ruta vial');
this.routesState.addAssignedFromUnassigned(route, userId, day, summary.distance / 1000, summary.duration / 3600);
delete this.assignmentUser[String(route.pdv_id)];
delete this.assignmentDay[String(route.pdv_id)];
} catch {
this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`;
} finally {
this.assigningId = null;
this.cdr.detectChanges();
}
}
ngOnDestroy() {
this.subs.forEach(s => s.unsubscribe());
}
}
@@ -0,0 +1,256 @@
.kpis-panel {
border-top: 3px solid var(--glm-azul);
}
.kpis-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.section-title {
margin: 0;
font-size: 16px;
color: var(--glm-azul);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.kpis-subtitle {
margin: 4px 0 0;
color: var(--glm-acero);
font-size: 0.82rem;
}
.kpis-status {
color: var(--glm-verde);
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
}
.kpis-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.export-button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
border: 1px solid var(--glm-verde);
border-radius: 4px;
background: var(--glm-verde-bg);
color: var(--glm-azul);
cursor: pointer;
font-size: 0.78rem;
font-weight: 600;
}
.export-button:hover {
background: var(--glm-verde);
color: #fff;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.kpi-card {
display: flex;
align-items: center;
gap: 12px;
min-height: 92px;
padding: 14px;
background: var(--glm-surface);
border: 1px solid var(--glm-gris-medio);
border-left: 4px solid var(--glm-azul);
border-radius: 4px;
}
.kpi-primary { border-left-color: var(--glm-azul); }
.kpi-success { border-left-color: var(--glm-verde); }
.kpi-warning { border-left-color: var(--glm-naranja); }
.kpi-blue { border-left-color: var(--glm-acero); }
.kpi-orange { border-left-color: var(--glm-naranja); }
.kpi-icon {
display: grid;
flex: 0 0 34px;
width: 34px;
height: 34px;
place-items: center;
border-radius: 50%;
background: var(--glm-verde-bg);
color: var(--glm-azul);
font-size: 0.95rem;
}
.kpi-card > div:last-child {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.kpi-label {
color: var(--glm-acero);
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.2px;
}
.kpi-card strong {
color: var(--glm-azul);
font-size: 1.45rem;
line-height: 1.15;
}
.kpi-card em {
font-size: 0.8rem;
font-style: normal;
font-weight: 600;
}
.kpi-card small {
overflow: hidden;
color: var(--glm-acero);
font-size: 0.73rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.operational-grid {
margin-top: 12px;
}
.user-load-card {
margin-top: 14px;
padding: 12px 14px;
background: var(--glm-surface);
border: 1px solid var(--glm-gris-medio);
border-radius: 4px;
}
.user-load-card h3 {
margin: 0 0 8px;
color: var(--glm-azul);
font-size: 0.82rem;
text-transform: uppercase;
}
.user-load-table-wrap {
overflow-x: auto;
}
.user-load-table {
width: 100%;
min-width: 560px;
border-collapse: collapse;
font-size: 0.8rem;
}
.user-load-table th {
padding: 7px 8px;
background: var(--glm-azul);
color: #fff;
font-size: 0.72rem;
text-align: left;
text-transform: uppercase;
}
.user-load-table td {
padding: 8px;
border-bottom: 1px solid var(--glm-gris-claro);
color: var(--glm-gris-oscuro);
}
.user-load-table tbody tr:nth-child(even) {
background: var(--glm-gris-claro);
}
.utilization-cell {
display: grid;
grid-template-columns: 48px minmax(70px, 1fr);
align-items: center;
gap: 8px;
min-width: 130px;
}
.utilization-bar {
display: block;
height: 7px;
overflow: hidden;
border-radius: 10px;
background: var(--glm-gris-medio);
}
.utilization-bar span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--glm-verde);
}
.reasons-card {
margin-top: 14px;
padding: 12px 14px;
background: var(--glm-verde-bg);
border: 1px solid var(--glm-verde-claro);
border-radius: 4px;
}
.reasons-card h3 {
margin: 0 0 8px;
color: var(--glm-azul);
font-size: 0.82rem;
text-transform: uppercase;
}
.reason-list {
display: grid;
gap: 4px;
}
.reason-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 5px 0;
border-bottom: 1px solid rgba(108, 194, 74, 0.25);
color: var(--glm-gris-oscuro);
font-size: 0.82rem;
}
.reason-row:last-child { border-bottom: 0; }
.reason-row strong { color: var(--glm-azul); }
.kpis-empty {
display: flex;
align-items: center;
gap: 8px;
padding: 14px;
color: var(--glm-acero);
font-size: 0.85rem;
}
.kpis-empty i { color: var(--glm-gris-medio); }
@media (max-width: 700px) {
.kpis-heading { flex-direction: column; gap: 6px; }
.kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 430px) {
.kpi-grid { grid-template-columns: 1fr; }
}
@@ -0,0 +1,98 @@
<div class="kpis-panel">
<div class="kpis-heading">
<div>
<h2 class="section-title">Indicadores de planificación</h2>
<p class="kpis-subtitle">Resumen total de la planificación actual</p>
</div>
<div class="kpis-actions">
<span class="kpis-status" *ngIf="hasData"><i class="pi pi-check-circle"></i> Datos disponibles</span>
<button type="button" class="export-button" *ngIf="hasData" (click)="exportPlanning()"><i class="pi pi-download"></i> Descargar XLSX</button>
</div>
</div>
<div class="kpi-grid">
<article class="kpi-card kpi-primary">
<div class="kpi-icon"><i class="pi pi-map-marker"></i></div>
<div><span class="kpi-label">PDVs totales</span><strong>{{ summary.totalPdvs }}</strong><small>en la planificación</small></div>
</article>
<article class="kpi-card kpi-success">
<div class="kpi-icon"><i class="pi pi-check"></i></div>
<div><span class="kpi-label">PDVs asignados</span><strong>{{ summary.assignedPdvs }}</strong><small>{{ summary.coverage | number:'1.1-1' }}% de cobertura</small></div>
</article>
<article class="kpi-card kpi-warning">
<div class="kpi-icon"><i class="pi pi-exclamation-triangle"></i></div>
<div><span class="kpi-label">Sin asignar</span><strong>{{ summary.unassignedPdvs }}</strong><small>requieren revisión</small></div>
</article>
<article class="kpi-card kpi-blue">
<div class="kpi-icon"><i class="pi pi-users"></i></div>
<div><span class="kpi-label">Usuarios activos</span><strong>{{ summary.users }}</strong><small>{{ summary.days }} días planificados</small></div>
</article>
<article class="kpi-card kpi-orange">
<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>
</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 class="kpi-grid operational-grid" *ngIf="hasData">
<article class="kpi-card kpi-success">
<div class="kpi-icon"><i class="pi pi-briefcase"></i></div>
<div><span class="kpi-label">Carga de trabajo</span><strong>{{ summary.totalWorkHours | number:'1.0-1' }} <em>h</em></strong><small>horas de trabajo planificadas</small></div>
</article>
<article class="kpi-card kpi-blue">
<div class="kpi-icon"><i class="pi pi-chart-line"></i></div>
<div><span class="kpi-label">Utilización promedio</span><strong>{{ summary.averageUtilization | number:'1.0-1' }}<em>%</em></strong><small>carga frente a disponibilidad</small></div>
</article>
<article class="kpi-card kpi-primary">
<div class="kpi-icon"><i class="pi pi-sitemap"></i></div>
<div><span class="kpi-label">Rutas por usuario</span><strong>{{ summary.averageRoutesPerUser | number:'1.0-1' }}</strong><small>promedio de asignaciones</small></div>
</article>
<article class="kpi-card kpi-warning">
<div class="kpi-icon"><i class="pi pi-arrows-h"></i></div>
<div><span class="kpi-label">Brecha de balance</span><strong>{{ summary.assignmentBalance }}</strong><small>diferencia entre mayor y menor carga</small></div>
</article>
</div>
<div class="user-load-card" *ngIf="userLoads.length">
<h3><i class="pi pi-users"></i> Balance de carga por usuario</h3>
<div class="user-load-table-wrap">
<table class="user-load-table">
<thead>
<tr><th>Usuario</th><th>Rutas</th><th>Distancia</th><th>Trabajo</th><th>Utilización</th></tr>
</thead>
<tbody>
<tr *ngFor="let load of userLoads">
<td>{{ load.name }}</td>
<td>{{ load.routes }}</td>
<td>{{ load.distance | number:'1.0-1' }} km</td>
<td>{{ load.workHours | number:'1.0-1' }} h</td>
<td>
<div class="utilization-cell">
<span>{{ load.utilization | number:'1.0-1' }}%</span>
<span class="utilization-bar"><span [style.width.%]="barWidth(load.utilization)"></span></span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="reasons-card" *ngIf="reasons.length">
<h3><i class="pi pi-list"></i> Motivos de PDVs sin asignar</h3>
<div class="reason-list">
<div class="reason-row" *ngFor="let item of reasons">
<span>{{ item.reason }}</span>
<strong>{{ item.count }}</strong>
</div>
</div>
</div>
<div class="kpis-empty" *ngIf="!hasData">
<i class="pi pi-chart-bar"></i>
<span>Carga una planificación para visualizar sus indicadores.</span>
</div>
</div>
@@ -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
};
}
}
@@ -0,0 +1,556 @@
:host {
display: block;
height: 100vh;
position: relative;
}
#map-overview {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 380px;
width: auto;
height: 100vh;
}
.overview-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 380px;
z-index: 900;
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
overflow-y: auto;
background: var(--glm-surface);
border-right: 3px solid var(--glm-verde);
}
.sidebar-brand {
display: flex;
justify-content: center;
padding-bottom: 10px;
border-bottom: 2px solid var(--glm-gris-medio);
}
.sidebar-logo {
height: 42px;
width: auto;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 10px;
border-bottom: 2px solid var(--glm-gris-medio);
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
background: var(--glm-surface);
border: 1px solid var(--glm-gris-medio);
border-radius: 4px;
color: var(--glm-azul);
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
font-family: Arial, sans-serif;
}
.back-btn:hover {
background: var(--glm-verde-bg);
border-color: var(--glm-verde);
}
.sidebar-title {
margin: 0;
font-size: 1rem;
color: var(--glm-azul);
font-weight: 700;
}
.filters-card {
background: var(--glm-verde-bg);
border: 1px solid var(--glm-verde-claro);
border-radius: 4px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-label {
font-size: 0.75rem;
font-weight: 700;
color: var(--glm-azul);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.filter-select {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--glm-gris-medio);
border-radius: 4px;
font-size: 0.9rem;
font-family: Arial, sans-serif;
background: #fff;
color: var(--glm-gris-oscuro);
}
.filter-select:focus {
outline: none;
border-color: var(--glm-verde);
box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15);
}
.filter-stats {
display: flex;
gap: 10px;
margin-top: 6px;
font-size: 0.82rem;
color: var(--glm-acero);
font-weight: 600;
}
.filter-empty {
font-size: 0.85rem;
color: var(--glm-acero);
}
.empty-state {
text-align: center;
padding: 24px 12px;
color: var(--glm-acero);
}
.empty-state i {
font-size: 2rem;
margin-bottom: 8px;
display: block;
color: var(--glm-gris-medio);
}
.error-card {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--glm-rojo-ok);
border: 1px solid #F5A6A0;
border-radius: 4px;
font-size: 0.85rem;
color: var(--glm-gris-oscuro);
}
.unassigned-map-card {
padding: 12px;
background: #fff8ed;
border: 1px solid #f2bf86;
border-left: 3px solid var(--glm-naranja);
border-radius: 4px;
}
.unassigned-map-card .info-card-header { margin-bottom: 8px; }
.unassigned-map-list {
display: grid;
gap: 5px;
max-height: 180px;
overflow-y: auto;
}
.unassigned-map-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px;
border: 1px solid #f2bf86;
border-radius: 3px;
background: #fff;
color: var(--glm-gris-oscuro);
cursor: pointer;
text-align: left;
}
.unassigned-map-item:hover,
.unassigned-map-item.selected-pending { background: #ffedd9; }
.pending-icon {
display: grid;
flex: 0 0 23px;
width: 23px;
height: 23px;
place-items: center;
border-radius: 50%;
background: var(--glm-naranja);
color: #fff;
font-weight: 700;
}
.pending-text { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
.pending-text strong { font-size: 0.78rem; }
.pending-text small { overflow: hidden; color: var(--glm-acero); font-size: 0.7rem; text-overflow: ellipsis; white-space: nowrap; }
.unassigned-detail-card { border-top-color: var(--glm-naranja); }
.pending-edit-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.pending-edit-grid label { display: flex; flex-direction: column; gap: 3px; color: var(--glm-azul); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; }
.pending-edit-grid input,
.pending-edit-grid select { width: 100%; padding: 6px; border: 1px solid var(--glm-gris-medio); border-radius: 3px; background: #fff; color: var(--glm-gris-oscuro); font: inherit; font-size: 0.8rem; }
.wide-field { grid-column: 1 / -1; }
.pending-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
.pending-actions .btn-primary, .pending-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
.routes-list h3 {
margin: 0 0 8px;
font-size: 0.85rem;
color: var(--glm-azul);
font-weight: 700;
text-transform: uppercase;
}
.routes-list ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.routes-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--glm-gris-medio);
border-radius: 4px;
background: #fff;
cursor: pointer;
transition: all 0.15s;
}
.routes-list li:hover {
background: var(--glm-verde-bg);
border-color: var(--glm-verde);
}
.routes-list li.selected {
background: var(--glm-verde);
border-color: var(--glm-verde);
color: #fff;
}
.routes-list li.selected small {
color: rgba(255, 255, 255, 0.85);
}
.routes-list li.selected i {
color: #fff;
}
.idx {
min-width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--glm-azul);
color: #fff;
font-size: 0.75rem;
font-weight: 700;
}
.li-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.li-main strong {
font-size: 0.85rem;
color: var(--glm-gris-oscuro);
}
.routes-list li.selected .li-main strong {
color: #fff;
}
.li-main small {
font-size: 0.75rem;
color: var(--glm-acero);
}
.info-card {
background: var(--glm-surface);
border: 1px solid var(--glm-gris-medio);
border-radius: 4px;
padding: 14px;
border-top: 3px solid var(--glm-azul);
}
.info-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
border-bottom: 2px solid var(--glm-verde);
padding-bottom: 8px;
}
.info-card-header h3 {
margin: 0;
font-size: 0.85rem;
color: var(--glm-azul);
font-weight: 700;
text-transform: uppercase;
}
.detail-section + .detail-section {
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid var(--glm-gris-medio);
}
.detail-section h4 {
margin: 0 0 5px;
color: var(--glm-verde);
font-size: 0.76rem;
text-transform: uppercase;
}
.reassign-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--glm-gris-medio);
}
.reassign-form label { display: flex; flex-direction: column; gap: 3px; color: var(--glm-azul); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; }
.reassign-form select { width: 100%; padding: 6px; border: 1px solid var(--glm-gris-medio); border-radius: 3px; background: #fff; color: var(--glm-gris-oscuro); font: inherit; font-size: 0.8rem; }
.reassign-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
.reassign-actions .btn-primary, .reassign-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
.reassign-hint { grid-column: 1 / -1; margin: 0; color: var(--glm-acero); font-size: 0.74rem; }
.reassign-form .reassign-actions, .reassign-form .error-card { grid-column: 1 / -1; }
.btn-icon {
background: transparent;
border: 1px solid var(--glm-gris-medio);
border-radius: 4px;
padding: 4px 6px;
cursor: pointer;
color: var(--glm-azul);
}
.btn-icon:hover {
background: var(--glm-verde-bg);
border-color: var(--glm-verde);
}
.info-list {
display: grid;
gap: 8px;
margin: 0;
}
.info-list div {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
padding: 5px 0;
border-bottom: 1px solid var(--glm-gris-claro);
}
.info-list div:last-child {
border-bottom: none;
}
.info-list dt {
font-weight: 600;
color: var(--glm-azul);
font-size: 0.78rem;
text-transform: uppercase;
}
.info-list dd {
margin: 0;
font-size: 0.85rem;
color: var(--glm-gris-oscuro);
word-break: break-word;
}
.overview-loading {
position: fixed;
top: 16px;
right: 16px;
z-index: 999;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid var(--glm-verde-claro);
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
color: var(--glm-acero);
font-size: 0.85rem;
}
.spinner {
width: 18px;
height: 18px;
border: 2px solid var(--glm-gris-medio);
border-top-color: var(--glm-verde);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.btn-secondary {
background: var(--glm-surface);
color: var(--glm-azul);
border: 1px solid var(--glm-gris-medio);
border-radius: 4px;
padding: 8px 14px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.btn-secondary:hover {
background: var(--glm-verde-bg);
border-color: var(--glm-verde);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.layer-toggles {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--glm-gris-medio);
}
.toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
color: var(--glm-gris-oscuro);
}
.heat-opts {
display: flex;
gap: 12px;
padding-left: 22px;
font-size: 0.78rem;
color: var(--glm-acero);
}
.cluster-glm {
background: none !important;
border: none !important;
}
.cluster-inner {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--glm-verde);
border: 3px solid #fff;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
color: #fff;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-family: Arial, sans-serif;
font-size: 12px;
}
.custom-marker {
background: none !important;
border: none !important;
}
.marker-inner {
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
color: #fff;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
font-family: Arial, sans-serif;
}
.location-pin {
position: relative;
width: 30px;
height: 38px;
background: #c9252d;
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
}
.location-pin::before {
content: '';
position: absolute;
inset: 3px 3px 7px;
background: var(--pin-color, #ff424a);
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
}
.location-pin::after {
content: '';
position: absolute;
top: 8px;
left: 8px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #620000;
}
.location-pin span {
position: absolute;
z-index: 1;
top: 8px;
left: 8px;
width: 14px;
color: #fff;
font-size: 9px;
font-weight: 700;
line-height: 14px;
text-align: center;
}
.unassigned-pin { --pin-color: #ff424a; }
@@ -0,0 +1,165 @@
<div id="map-overview"></div>
<div class="overview-loading" *ngIf="isLoading">
<div class="spinner"></div>
<p>Cargando rutas ORS... {{ orsProgress }}/{{ orsTotal }}</p>
</div>
<div class="overview-sidebar">
<div class="sidebar-brand">
<img src="GLM_completo.png" alt="GomezLee Marketing" class="sidebar-logo" />
</div>
<div class="sidebar-header">
<button type="button" class="back-btn" (click)="goBack()">
<i class="pi pi-arrow-left"></i> Volver
</button>
<h2 class="sidebar-title">Mapa de rutas</h2>
</div>
<div class="filters-card" *ngIf="usuarios.length">
<label class="filter-label">Usuario</label>
<select class="filter-select" [(ngModel)]="selectedUsuarioId" (ngModelChange)="onUsuarioChange()">
<option value="Todos">Todos los usuarios</option>
<option *ngFor="let u of usuarios" [value]="u.id">{{ u.nombre }} ({{ u.id }})</option>
</select>
<label class="filter-label">Día</label>
<select class="filter-select" [(ngModel)]="selectedDia" (ngModelChange)="onDiaChange()">
<option value="Todos">Todos</option>
<option *ngFor="let d of dias" [value]="d">{{ d }}</option>
</select>
<div class="filter-stats" *ngIf="filteredRoutes.length">
<span>{{ filteredRoutes.length }} PDVs</span>
<span>{{ totalDistancia }} km</span>
<span>{{ totalDuracionMin }} min</span>
</div>
<div class="layer-toggles">
<label class="toggle">
<input type="checkbox" [checked]="showHeat" (change)="toggleHeat()" />
<i class="pi pi-fire"></i> Heatmap (carga)
</label>
<div class="heat-opts" *ngIf="showHeat">
<label><input type="radio" name="hw" value="uniform" [checked]="heatWeightMode==='uniform'" (change)="setHeatWeight('uniform')" /> Peso 1 (densidad)</label>
<label><input type="radio" name="hw" value="horas_trabajo" [checked]="heatWeightMode==='horas_trabajo'" (change)="setHeatWeight('horas_trabajo')" /> Por carga (horas)</label>
</div>
</div>
<div class="filter-empty" *ngIf="!filteredRoutes.length && !error">
Sin rutas para este filtro.
</div>
</div>
<div class="empty-state" *ngIf="!usuarios.length">
<i class="pi pi-inbox"></i>
<p>No hay rutas cargadas.</p>
<button type="button" class="btn-secondary" (click)="goBack()">Ir a Home</button>
</div>
<div class="error-card" *ngIf="error && usuarios.length">
<i class="pi pi-exclamation-triangle"></i>
{{ error }}
</div>
<section class="unassigned-map-card" *ngIf="selectedUsuarioId === 'Todos' && selectedDia === 'Todos' && unassignedRoutes.length">
<div class="info-card-header">
<h3>PDVs sin ruta ({{ unassignedRoutes.length }})</h3>
</div>
<div class="unassigned-map-list">
<button type="button" class="unassigned-map-item" *ngFor="let pending of unassignedRoutes" [class.selected-pending]="selectedUnassigned?.pdv_id === pending.pdv_id" (click)="selectUnassigned(pending)">
<span class="pending-icon">!</span>
<span class="pending-text"><strong>{{ pending.pdv_nombre }}</strong><small>{{ pending.motivo }}</small></span>
</button>
</div>
</section>
<section class="info-card unassigned-detail-card" *ngIf="selectedUnassigned">
<div class="info-card-header">
<h3>Gestionar PDV sin ruta</h3>
<button type="button" class="btn-icon" (click)="selectedUnassigned = null"><i class="pi pi-times"></i></button>
</div>
<ng-container *ngIf="editingUnassigned; else pendingSummary">
<div class="pending-edit-grid">
<label>Nombre<input [(ngModel)]="editUnassignedName" type="text"></label>
<label>Horas mínimas<input [(ngModel)]="editUnassignedHours" type="number" min="0.01" step="0.25"></label>
<label>Latitud<input [(ngModel)]="editUnassignedLat" type="number" step="any"></label>
<label>Longitud<input [(ngModel)]="editUnassignedLng" type="number" step="any"></label>
<label class="wide-field">Motivo<input [(ngModel)]="editUnassignedReason" type="text"></label>
</div>
<div class="pending-actions"><button type="button" class="btn-secondary" (click)="saveUnassignedEdit()"><i class="pi pi-check"></i> Guardar</button><button type="button" class="btn-secondary" (click)="cancelUnassignedEdit()">Cancelar</button></div>
</ng-container>
<ng-template #pendingSummary>
<dl class="info-list">
<div><dt>PDV</dt><dd>{{ selectedUnassigned.pdv_nombre }}</dd></div>
<div><dt>Motivo</dt><dd>{{ selectedUnassigned.motivo }}</dd></div>
<div><dt>Coordenadas</dt><dd>{{ selectedUnassigned.latitud }}, {{ selectedUnassigned.longitud }}</dd></div>
</dl>
<div class="pending-edit-grid">
<label>Usuario<select [(ngModel)]="assignmentUser[selectedUnassigned.pdv_id]"><option value="">Seleccionar</option><option *ngFor="let user of unassignedUserOptions" [value]="user.id">{{ user.name }}</option></select></label>
<label>Día<select [(ngModel)]="assignmentDay[selectedUnassigned.pdv_id]"><option value="">Seleccionar</option><option *ngFor="let day of unassignedDayOptions" [value]="day">{{ day }}</option></select></label>
</div>
<div class="pending-actions"><button type="button" class="btn-secondary" (click)="startUnassignedEdit(selectedUnassigned)"><i class="pi pi-pencil"></i> Editar</button><button type="button" class="btn-primary" (click)="assignUnassigned(selectedUnassigned)" [disabled]="assigningUnassignedId === selectedUnassigned.pdv_id"><span *ngIf="assigningUnassignedId === selectedUnassigned.pdv_id" class="spinner-inline"></span><i *ngIf="assigningUnassignedId !== selectedUnassigned.pdv_id" class="pi pi-user-plus"></i> Reasignar</button></div>
</ng-template>
<div class="error-card" *ngIf="unassignedActionError">{{ unassignedActionError }}</div>
</section>
<section class="info-card" *ngIf="selectedRouteDetail">
<div class="info-card-header">
<h3>Detalle de la ruta</h3>
<button type="button" class="btn-icon" (click)="clearSelection()" title="Cerrar detalle">
<i class="pi pi-times"></i>
</button>
</div>
<div class="detail-section">
<h4><i class="pi pi-user"></i> Usuario</h4>
<dl class="info-list">
<div><dt>Nombre</dt><dd>{{ selectedRouteDetail.json.nombre_usuario }}</dd></div>
<div><dt>ID</dt><dd>{{ selectedRouteDetail.json.usuario_id }}</dd></div>
<div><dt>Origen</dt><dd>{{ selectedRouteDetail.json.latitud_usuario }}, {{ selectedRouteDetail.json.longitud_usuario }}</dd></div>
<div><dt>Horas disponibles</dt><dd>{{ selectedRouteDetail.json.hora_laboral_semanal_usuario }} h/semana</dd></div>
</dl>
</div>
<div class="detail-section">
<h4><i class="pi pi-map-marker"></i> PDV</h4>
<dl class="info-list">
<div><dt>Nombre</dt><dd>{{ selectedRouteDetail.json.nombre_pdv }}</dd></div>
<div><dt>ID</dt><dd>{{ selectedRouteDetail.json.pdv_id }}</dd></div>
<div><dt>Día</dt><dd>{{ selectedRouteDetail.json.dia }}</dd></div>
<div><dt>Orden de visita</dt><dd>{{ selectedVisitOrder }}</dd></div>
<div><dt>Origen del tramo</dt><dd>{{ selectedSegmentOrigin }}</dd></div>
<div><dt>Destino</dt><dd>{{ selectedRouteDetail.json.latitud_pdv }}, {{ selectedRouteDetail.json.longitud_pdv }}</dd></div>
<div><dt>Horas trabajo</dt><dd>{{ selectedRouteDetail.json.horas_trabajo }}</dd></div>
<div><dt>Distancia tramo</dt><dd>{{ selectedSegmentDistance | number:'1.2-2' }} km</dd></div>
<div><dt>Duración tramo</dt><dd>{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min</dd></div>
</dl>
<div class="reassign-actions" *ngIf="!showReassignForm">
<button type="button" class="btn-secondary" (click)="startReassignRoute()"><i class="pi pi-user-edit"></i> Reasignar ruta</button>
</div>
<div class="reassign-form" *ngIf="showReassignForm">
<label>Nuevo usuario<select [(ngModel)]="reassignUserId"><option *ngFor="let user of unassignedUserOptions" [value]="user.id">{{ user.name }}</option></select></label>
<label>Día<select [(ngModel)]="reassignDay"><option *ngFor="let day of unassignedDayOptions" [value]="day">{{ day }}</option></select></label>
<p class="reassign-hint">Se recalcularán la distancia y duración con ruta vial ORS.</p>
<div class="reassign-actions"><button type="button" class="btn-primary" (click)="reassignRoute()" [disabled]="reassigningRoute"><span *ngIf="reassigningRoute" class="spinner-inline"></span><i *ngIf="!reassigningRoute" class="pi pi-check"></i> {{ reassigningRoute ? 'Calculando...' : 'Confirmar' }}</button><button type="button" class="btn-secondary" (click)="cancelReassignRoute()" [disabled]="reassigningRoute">Cancelar</button></div>
<div class="error-card" *ngIf="reassignError">{{ reassignError }}</div>
</div>
</div>
</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"
[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)">
<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>
@@ -0,0 +1,785 @@
import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { Subscription, Subject } from 'rxjs';
import * as L from 'leaflet';
import 'leaflet.heat';
import { RouteState } from '../../../core/route-state/route';
import { OrsService } from '../../../core/services/ors.service';
import { RouteAssignedResponse, RouteNoAssignedResponse } from '../../../core/models/route.model';
import { colorForUsuario } from '../../../shared/utils/palette.utils';
import { haversineKm, sequenceRoutes } from '../../../shared/utils/route-sequencing.utils';
interface MapRoute {
data: RouteAssignedResponse;
color: string;
origin: [number, number];
destination: [number, number];
visitOrder: number;
totalStops: number;
previousPdvName: string | null;
requestId: number;
segmentDistanceKm?: number;
segmentDurationHours?: number;
fallbackLine?: L.Polyline;
orsLine?: L.Polyline;
orsGlow?: L.Polyline;
marker?: L.Marker;
startMarker?: L.Marker;
}
@Component({
standalone: true,
selector: 'app-route-overview-map',
imports: [CommonModule, FormsModule],
templateUrl: './route-overview-map.component.html',
styleUrls: ['./route-overview-map.component.css']
})
export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewInit {
private map?: L.Map;
private abort$ = new Subject<void>();
private subs: Subscription[] = [];
private mapRoutes: MapRoute[] = [];
private startMarkers: L.Marker[] = [];
private selectedMapRoute: MapRoute | null = null;
private heatLayer?: any;
showHeat = true;
heatWeightMode: 'uniform' | 'horas_trabajo' = 'horas_trabajo';
private heatPointsCache: [number, number, number][] = [];
private unassignedMarkers = new Map<number, L.Marker>();
usuarios: { id: string; nombre: string }[] = [];
dias: string[] = [];
selectedUsuarioId: string | null = null;
selectedDia: string = 'Todos';
selectedRouteDetail: RouteAssignedResponse | null = null;
filteredRoutes: RouteAssignedResponse[] = [];
allRoutes: RouteAssignedResponse[] = [];
isLoading = false;
orsProgress = 0;
orsTotal = 0;
error: string | null = null;
unassignedRoutes: RouteNoAssignedResponse[] = [];
selectedUnassigned: RouteNoAssignedResponse | null = null;
editingUnassigned: RouteNoAssignedResponse | null = null;
editUnassignedName = '';
editUnassignedHours = 0;
editUnassignedLat = 0;
editUnassignedLng = 0;
editUnassignedReason = '';
assignmentUser: Record<string, string> = {};
assignmentDay: Record<string, string> = {};
assigningUnassignedId: number | null = null;
unassignedActionError: string | null = null;
reassigningRoute = false;
showReassignForm = false;
reassignUserId = '';
reassignDay = '';
reassignError: string | null = null;
// Summary stats
get totalDistancia(): string {
const sum = this.mapRoutes.length
? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDistanceKm ?? haversineKm(route.origin, route.destination)), 0)
: this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.distancia), 0);
return sum.toFixed(2);
}
get totalDuracionMin(): string {
const sum = this.mapRoutes.length
? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDurationHours ?? Number(route.data.json.horas_desplazamiento)) * 60, 0)
: this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.horas_desplazamiento) * 60, 0);
return sum.toFixed(0);
}
constructor(
private routeState: RouteState,
private orsService: OrsService,
private router: Router,
private activatedRoute: ActivatedRoute,
private cdr: ChangeDetectorRef
) {}
ngOnInit() {
this.subs.push(
this.routeState.assignedRoutes$.subscribe(routes => {
this.allRoutes = [...routes];
this.usuarios = this.routeState.getUsuariosUnicos();
this.dias = this.routeState.getDiasUnicos();
// Init from queryParams default (Todos)
const qp = this.activatedRoute.snapshot.queryParams;
if (qp['usuario_id']) this.selectedUsuarioId = String(qp['usuario_id']);
else if (!this.selectedUsuarioId && this.usuarios.length) this.selectedUsuarioId = 'Todos';
if (qp['dia']) this.selectedDia = String(qp['dia']);
// If focus pdv provided, auto-select detail after map loads
this.applyFilter();
this.cdr.detectChanges();
if (this.map) this.renderFiltered();
})
);
this.subs.push(
this.routeState.noAssignedRoutes$.subscribe(routes => {
this.unassignedRoutes = [...routes];
if (this.map) this.renderFiltered();
this.cdr.detectChanges();
})
);
// React to queryParams changes
this.subs.push(
this.activatedRoute.queryParams.subscribe(qp => {
let changed = false;
if (qp['usuario_id'] && String(qp['usuario_id']) !== this.selectedUsuarioId) {
this.selectedUsuarioId = String(qp['usuario_id']);
changed = true;
}
if (qp['dia'] && qp['dia'] !== this.selectedDia) {
this.selectedDia = String(qp['dia']);
changed = true;
}
if (changed) {
this.applyFilter();
if (this.map) this.renderFiltered();
this.cdr.detectChanges();
}
// Focus a specific route if pdv_id passed
if (qp['pdv_id'] && this.filteredRoutes.length) {
const found = this.filteredRoutes.find(r => String(r.json.pdv_id) === String(qp['pdv_id']));
if (found) {
this.selectedRouteDetail = found;
this.highlightRoute(found);
}
}
})
);
}
ngAfterViewInit() {
this.initMap();
this.applyFilter();
// Defer render to ensure container has size (fixes white tiles)
setTimeout(() => {
this.map?.invalidateSize();
this.renderFiltered();
}, 100);
}
ngOnDestroy() {
this.abort$.next();
this.abort$.complete();
this.subs.forEach(s => s.unsubscribe());
const onResize = (this as any)._onResize;
if (onResize) window.removeEventListener('resize', onResize);
this.clearMap();
if (this.map) this.map.remove();
}
onUsuarioChange() {
this.updateQueryParams();
this.applyFilter();
this.renderFiltered();
}
onDiaChange() {
this.updateQueryParams();
this.applyFilter();
this.renderFiltered();
}
private updateQueryParams() {
this.router.navigate([], {
relativeTo: this.activatedRoute,
queryParams: { usuario_id: this.selectedUsuarioId, dia: this.selectedDia },
queryParamsHandling: 'merge',
replaceUrl: true
});
}
private applyFilter() {
let list = this.selectedUsuarioId === 'Todos'
? [...this.allRoutes]
: this.allRoutes.filter(r => String(r.json.usuario_id) === String(this.selectedUsuarioId));
if (this.selectedDia !== 'Todos') {
list = list.filter(r => r.json.dia === this.selectedDia);
}
this.filteredRoutes = list;
if (this.filteredRoutes.length === 0) {
this.error = this.allRoutes.length === 0 ? 'No hay rutas cargadas. Sube archivos en Home.' : 'Sin rutas para este usuario/día.';
} else {
this.error = null;
}
// Clear selected detail if not in filtered
if (this.selectedRouteDetail && !this.filteredRoutes.some(route => this.sameRoute(route, this.selectedRouteDetail!))) {
this.selectedRouteDetail = null;
this.selectedMapRoute = null;
}
}
private initMap() {
this.map = L.map('map-overview').setView([18.4861, -69.9312], 10);
const pdvPane = this.map.createPane('pdvPane');
pdvPane.style.zIndex = '650';
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(this.map);
// Fix tiles after sidebar overlay
setTimeout(() => this.map?.invalidateSize(), 200);
// Resize handler
const onResize = () => this.map?.invalidateSize();
window.addEventListener('resize', onResize);
// store for cleanup
(this as any)._onResize = onResize;
}
private clearMap() {
for (const mr of this.mapRoutes) {
if (mr.fallbackLine) this.map?.removeLayer(mr.fallbackLine);
if (mr.orsLine) this.map?.removeLayer(mr.orsLine);
if (mr.orsGlow) this.map?.removeLayer(mr.orsGlow);
if (mr.marker) this.map?.removeLayer(mr.marker);
}
this.unassignedMarkers.forEach(marker => this.map?.removeLayer(marker));
this.unassignedMarkers.clear();
if (this.heatLayer) {
this.map?.removeLayer(this.heatLayer);
this.heatLayer = undefined;
}
for (const m of this.startMarkers) this.map?.removeLayer(m);
this.heatPointsCache = [];
this.mapRoutes = [];
this.startMarkers = [];
this.selectedMapRoute = null;
}
private async renderFiltered() {
if (!this.map) return;
this.abort$.next();
this.clearMap();
this.isLoading = false;
this.orsProgress = 0;
this.orsTotal = 0;
if (!this.filteredRoutes.length) {
this.cdr.detectChanges();
return;
}
// Add one colored start marker per user in the global view.
const starts = new Map<string, RouteAssignedResponse['json']>();
this.filteredRoutes.forEach(route => starts.set(String(route.json.usuario_id), route.json));
starts.forEach((start, userId) => {
const color = colorForUsuario(userId).color;
const count = this.filteredRoutes.filter(route => String(route.json.usuario_id) === userId).length;
const startIcon = L.divIcon({
className: 'custom-marker marker-start',
html: `<div class="marker-inner" style="background:${color};border:3px solid #fff;">A</div>`,
iconSize: [34, 34],
iconAnchor: [17, 17],
popupAnchor: [0, -18]
});
this.startMarkers.push(
L.marker([Number(start.latitud_usuario), Number(start.longitud_usuario)], { icon: startIcon, zIndexOffset: 100 })
.addTo(this.map!)
.bindPopup(`<strong>Usuario:</strong> ${start.nombre_usuario}<br><strong>PDVs:</strong> ${count}`)
);
});
// Build one daily chain per user: home -> nearest PDV -> next nearest PDV.
const bounds: L.LatLngExpression[] = [];
const sequencedRoutes = sequenceRoutes(this.filteredRoutes);
this.filteredRoutes = sequencedRoutes.map(segment => segment.route);
sequencedRoutes.forEach((segment, idx) => {
const route = segment.route;
const j = route.json;
const color = colorForUsuario(j.usuario_id).color;
const origin = segment.origin;
const dest = segment.destination;
bounds.push([origin[1], origin[0]]);
bounds.push([Number(j.latitud_pdv), Number(j.longitud_pdv)]);
// Fallback dashed line (visible immediately)
const fallbackLine = L.polyline(
[[origin[1], origin[0]], [dest[1], dest[0]]],
{ color, weight: 4, dashArray: '10, 10', opacity: 0.6 }
).addTo(this.map!);
// Marker for PDV
const pdvIcon = L.divIcon({
className: 'custom-marker marker-end',
html: `<div class="location-pin"><span>${segment.order}</span></div>`,
iconSize: [30, 38],
iconAnchor: [15, 38],
popupAnchor: [0, -18]
});
const marker = L.marker([Number(j.latitud_pdv), Number(j.longitud_pdv)], { icon: pdvIcon, pane: 'pdvPane', zIndexOffset: 1000 })
.addTo(this.map!)
.bindPopup(`<strong>${j.nombre_pdv}</strong><br>Parada ${segment.order} de ${segment.totalStops}${j.dia}`);
// Heat point
const w = this.computeWeight(j);
this.heatPointsCache.push([Number(j.latitud_pdv), Number(j.longitud_pdv), w]);
const mr: MapRoute = {
data: route,
color,
origin,
destination: dest,
visitOrder: segment.order,
totalStops: segment.totalStops,
previousPdvName: segment.previousPdvName,
requestId: idx + 1,
fallbackLine,
marker
};
this.mapRoutes.push(mr);
// Click handlers
const onClick = () => this.selectRoute(mr);
fallbackLine.on('click', onClick);
marker.on('click', onClick);
});
this.updateHeatLayer();
this.toggleLayerVisibility();
// Fit bounds
try {
this.map!.fitBounds(L.latLngBounds(bounds as L.LatLngTuple[]).pad(0.15));
} catch { /* ignore */ }
// Progressive ORS hydration — always in background (heat only hides visually)
this.hydrateWithOrs();
this.renderUnassignedMarkers();
this.cdr.detectChanges();
}
private renderUnassignedMarkers(): void {
if (!this.map || this.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return;
for (const route of this.unassignedRoutes) {
const icon = L.divIcon({
className: 'custom-marker unassigned-marker',
html: '<div class="location-pin unassigned-pin"><span>!</span></div>',
iconSize: [30, 38],
iconAnchor: [15, 38]
});
const marker = L.marker([route.latitud, route.longitud], { icon, pane: 'pdvPane', zIndexOffset: 1000 })
.addTo(this.map)
.bindPopup(`<strong>PDV sin ruta</strong><br>${route.pdv_nombre}<br>Motivo: ${route.motivo}`);
marker.on('click', () => this.selectUnassigned(route));
this.unassignedMarkers.set(route.pdv_id, marker);
}
}
private async hydrateWithOrs() {
if (!this.filteredRoutes.length) return;
this.isLoading = true;
this.orsTotal = this.filteredRoutes.length;
this.orsProgress = 0;
this.cdr.detectChanges();
const pares = this.mapRoutes.map(route => ({
pdv_id: route.requestId,
origin: route.origin,
destination: route.destination
}));
const { promise } = this.orsService.processPairsWithProgress(pares, 2, true, this.abort$);
try {
const results = await promise;
if (!results.length) {
this.isLoading = false;
this.cdr.detectChanges();
return;
}
const geoByRequest = new Map<number, unknown>();
for (const item of results) {
const g = item as { pdv_id?: number; geojson?: unknown; error?: string };
if (g && !g.error && g.geojson) {
geoByRequest.set(g.pdv_id!, g.geojson);
} else if (g && g.error) {
// error entry, skip but count progress
} else {
// fallback: if item is raw geojson without wrapper (old cache), treat as geojson
const maybeGeo = item as unknown;
if (maybeGeo && (maybeGeo as any).type) {
// cannot map, will fallback to index correlation below
}
}
}
const useMap = geoByRequest.size > 0;
for (let i = 0; i < this.mapRoutes.length; i++) {
const mr = this.mapRoutes[i];
let geo: unknown = null;
if (useMap) {
geo = geoByRequest.get(mr.requestId);
} else {
const res = results[i] as { error?: string; geojson?: unknown; type?: string; features?: unknown[] };
if ((res as any)?.error) {
this.orsProgress = i + 1;
this.cdr.detectChanges();
continue;
}
geo = (res as any)?.geojson ?? res;
}
if (!geo) {
this.orsProgress = i + 1;
this.cdr.detectChanges();
continue;
}
const coords = this.extractCoords(geo);
if (!coords.length) {
this.orsProgress = i + 1;
this.cdr.detectChanges();
continue;
}
if (mr.fallbackLine) {
this.map?.removeLayer(mr.fallbackLine);
mr.fallbackLine = undefined;
}
// Heatmap and route layers are independent; both remain visible together.
const glow = L.polyline(coords, { color: mr.color, weight: 12, opacity: 0.25, lineCap: 'round', lineJoin: 'round' }).addTo(this.map!);
const line = L.polyline(coords, { color: mr.color, weight: 5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }).addTo(this.map!);
const summary = this.extractSummary(geo);
if (summary) {
mr.segmentDistanceKm = summary.distance / 1000;
mr.segmentDurationHours = summary.duration / 3600;
mr.marker?.setPopupContent(
`<strong>${mr.data.json.nombre_pdv}</strong><br>Parada ${mr.visitOrder} de ${mr.totalStops}${mr.data.json.dia}<br>${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min`
);
}
line.on('click', () => this.selectRoute(mr));
mr.orsGlow = glow;
mr.orsLine = line;
if (this.selectedRouteDetail && this.sameRoute(this.selectedRouteDetail, mr.data)) {
this.fitSelectedRoute(mr);
}
this.orsProgress = i + 1;
this.cdr.detectChanges();
}
this.toggleLayerVisibility();
} catch {
// Ignore abort / network errors, fallback lines remain
} finally {
this.isLoading = false;
this.cdr.detectChanges();
}
}
private extractCoords(geojson: unknown): L.LatLngExpression[] {
const g = geojson as { type: string; features?: { geometry: { coordinates: number[][] } }[]; coordinates?: number[][] };
if (g?.type === 'FeatureCollection' && g.features?.length) {
return g.features[0].geometry.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression);
}
if (g?.type === 'LineString' && g.coordinates) {
return g.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression);
}
const maybeFeature = (geojson as { features?: { geometry: { coordinates: number[][] } }[] })?.features;
if (maybeFeature?.length) {
return maybeFeature[0].geometry.coordinates.map(c => [c[1], c[0]] as L.LatLngExpression);
}
return [];
}
private extractSummary(geojson: unknown): { distance: number; duration: number } | null {
const summary = (geojson as { features?: { properties?: { summary?: { distance: number; duration: number } } }[] })
?.features?.[0]?.properties?.summary;
return summary ?? null;
}
private selectRoute(mr: MapRoute) {
this.selectedRouteDetail = mr.data;
this.highlightRoute(mr.data);
this.fitSelectedRoute(mr);
this.cdr.detectChanges();
}
selectRouteFromList(route: RouteAssignedResponse): void {
const mapRoute = this.mapRoutes.find(mr => this.sameRoute(mr.data, route));
if (mapRoute) {
this.selectRoute(mapRoute);
return;
}
this.selectedRouteDetail = route;
this.cdr.detectChanges();
}
startReassignRoute(): void {
if (!this.selectedRouteDetail) return;
this.showReassignForm = true;
this.reassignUserId = String(this.selectedRouteDetail.json.usuario_id);
this.reassignDay = this.selectedRouteDetail.json.dia;
this.reassignError = null;
}
cancelReassignRoute(): void {
this.showReassignForm = false;
this.reassignError = null;
}
async reassignRoute(): Promise<void> {
const route = this.selectedRouteDetail;
if (!route || !this.reassignUserId || !this.reassignDay || this.reassigningRoute) return;
const sameUser = String(route.json.usuario_id) === this.reassignUserId;
const sameDay = route.json.dia === this.reassignDay;
if (sameUser && sameDay) {
this.cancelReassignRoute();
return;
}
const target = this.routeState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === this.reassignUserId);
if (!target) {
this.reassignError = 'No se encontró el usuario seleccionado.';
return;
}
this.reassigningRoute = true;
this.reassignError = null;
try {
const origin = [Number(target.json.longitud_usuario), Number(target.json.latitud_usuario)];
const destination = [Number(route.json.longitud_pdv), Number(route.json.latitud_pdv)];
const results = await this.orsService.processPairs([{ pdv_id: route.json.pdv_id, origin, destination }], 1, true);
const response = results[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; error?: string } | undefined;
const summary = response?.geojson?.features?.[0]?.properties?.summary;
if (response?.error || !summary) throw new Error('ORS no devolvió una ruta vial');
this.routeState.updateAssignedRoute(route, this.reassignUserId, this.reassignDay, {
distance: summary.distance / 1000,
duration: summary.duration / 3600
});
this.selectedRouteDetail = this.routeState.findRoute(route.json.pdv_id, this.reassignUserId);
this.showReassignForm = false;
} catch {
this.reassignError = 'No se pudo recalcular la ruta vial. La asignación anterior se mantuvo.';
} finally {
this.reassigningRoute = false;
this.cdr.detectChanges();
}
}
private fitSelectedRoute(mr: MapRoute): void {
if (!this.map) return;
const line = mr.orsLine || mr.fallbackLine;
if (!line) return;
const bounds = line.getBounds();
const json = mr.data.json;
bounds.extend([Number(json.latitud_usuario), Number(json.longitud_usuario)]);
bounds.extend([Number(json.latitud_pdv), Number(json.longitud_pdv)]);
if (bounds.isValid()) {
this.map.fitBounds(bounds, { padding: [36, 36], maxZoom: 16, animate: true, duration: 0.5 });
}
}
routeVisitOrder(route: RouteAssignedResponse): number {
return this.mapRoutes.find(item => this.sameRoute(item.data, route))?.visitOrder ?? 0;
}
routeSegmentDistance(route: RouteAssignedResponse): number {
const segment = this.mapRoutes.find(item => this.sameRoute(item.data, route));
return segment?.segmentDistanceKm ?? Number(route.json.distancia);
}
routeSegmentDurationMinutes(route: RouteAssignedResponse): number {
const segment = this.mapRoutes.find(item => this.sameRoute(item.data, route));
return (segment?.segmentDurationHours ?? Number(route.json.horas_desplazamiento)) * 60;
}
get selectedVisitOrder(): string {
const segment = this.selectedMapSegment;
return segment ? `${segment.visitOrder} de ${segment.totalStops}` : '-';
}
get selectedSegmentOrigin(): string {
const segment = this.selectedMapSegment;
if (!segment) return '-';
return segment.previousPdvName ?? `Casa de ${segment.data.json.nombre_usuario}`;
}
get selectedSegmentDistance(): number {
const segment = this.selectedMapSegment;
return segment?.segmentDistanceKm ?? Number(this.selectedRouteDetail?.json.distancia ?? 0);
}
get selectedSegmentDurationMinutes(): number {
const segment = this.selectedMapSegment;
return (segment?.segmentDurationHours ?? Number(this.selectedRouteDetail?.json.horas_desplazamiento ?? 0)) * 60;
}
private get selectedMapSegment(): MapRoute | null {
if (!this.selectedRouteDetail) return null;
return this.mapRoutes.find(item => this.sameRoute(item.data, this.selectedRouteDetail!)) ?? null;
}
private sameRoute(left: RouteAssignedResponse, right: RouteAssignedResponse): boolean {
return left.json.pdv_id === right.json.pdv_id &&
String(left.json.usuario_id) === String(right.json.usuario_id) &&
left.json.dia === right.json.dia;
}
get unassignedUserOptions(): { id: string; name: string }[] {
return this.routeState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre }));
}
get unassignedDayOptions(): string[] {
return this.routeState.getDiasUnicos();
}
selectUnassigned(route: RouteNoAssignedResponse): void {
this.selectedUnassigned = route;
this.selectedRouteDetail = null;
this.map?.setView([route.latitud, route.longitud], Math.max(this.map.getZoom(), 14));
this.unassignedMarkers.get(route.pdv_id)?.openPopup();
this.cdr.detectChanges();
}
startUnassignedEdit(route: RouteNoAssignedResponse): void {
this.editingUnassigned = route;
this.editUnassignedName = route.pdv_nombre;
this.editUnassignedHours = route.minimo_horas_semana;
this.editUnassignedLat = route.latitud;
this.editUnassignedLng = route.longitud;
this.editUnassignedReason = route.motivo;
this.unassignedActionError = null;
}
cancelUnassignedEdit(): void {
this.editingUnassigned = null;
}
saveUnassignedEdit(): void {
if (!this.editingUnassigned || !this.editUnassignedName.trim()) {
this.unassignedActionError = 'El nombre del PDV es obligatorio.';
return;
}
if (!Number.isFinite(this.editUnassignedLat) || this.editUnassignedLat < -90 || this.editUnassignedLat > 90 ||
!Number.isFinite(this.editUnassignedLng) || this.editUnassignedLng < -180 || this.editUnassignedLng > 180) {
this.unassignedActionError = 'Latitud o longitud inválida.';
return;
}
if (!Number.isFinite(this.editUnassignedHours) || this.editUnassignedHours <= 0) {
this.unassignedActionError = 'Las horas mínimas deben ser mayores que cero.';
return;
}
this.routeState.updateNoAssignedRoute(this.editingUnassigned, {
pdv_nombre: this.editUnassignedName.trim(),
minimo_horas_semana: this.editUnassignedHours,
latitud: this.editUnassignedLat,
longitud: this.editUnassignedLng,
motivo: this.editUnassignedReason.trim()
});
this.selectedUnassigned = { ...this.editingUnassigned, pdv_nombre: this.editUnassignedName.trim(), latitud: this.editUnassignedLat, longitud: this.editUnassignedLng };
this.editingUnassigned = null;
}
async assignUnassigned(route: RouteNoAssignedResponse): Promise<void> {
const userId = this.assignmentUser[String(route.pdv_id)];
const day = this.assignmentDay[String(route.pdv_id)];
if (!userId || !day) {
this.unassignedActionError = 'Selecciona usuario y día antes de asignar.';
return;
}
const user = this.routeState.getAssignedSnapshot().find(item => String(item.json.usuario_id) === userId);
if (!user) return;
this.assigningUnassignedId = route.pdv_id;
this.unassignedActionError = null;
try {
const origin = [Number(user.json.longitud_usuario), Number(user.json.latitud_usuario)];
const destination = [Number(route.longitud), Number(route.latitud)];
const results = await this.orsService.processPairs([{ pdv_id: route.pdv_id, origin, destination }], 1, true);
const geo = (results[0] as { geojson?: { features?: { properties?: { summary?: { distance: number; duration: number } } }[] }; error?: string });
const summary = geo?.geojson?.features?.[0]?.properties?.summary;
if (geo?.error || !summary) throw new Error('No se obtuvo una ruta vial');
this.routeState.addAssignedFromUnassigned(route, userId, day, summary.distance / 1000, summary.duration / 3600);
this.selectedUnassigned = null;
delete this.assignmentUser[String(route.pdv_id)];
delete this.assignmentDay[String(route.pdv_id)];
} catch {
this.unassignedActionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`;
} finally {
this.assigningUnassignedId = null;
this.cdr.detectChanges();
}
}
highlightRoute(route: RouteAssignedResponse) {
// Reset previous selection style
if (this.selectedMapRoute) {
const prev = this.selectedMapRoute.orsLine || this.selectedMapRoute.fallbackLine;
if (prev) (prev as L.Polyline).setStyle?.({ weight: this.selectedMapRoute.orsLine ? 5 : 4, opacity: this.selectedMapRoute.orsLine ? 0.95 : 0.6 });
}
const mr = this.mapRoutes.find(item => this.sameRoute(item.data, route));
if (mr) {
this.selectedMapRoute = mr;
const line = mr.orsLine || mr.fallbackLine;
if (line) (line as L.Polyline).setStyle({ weight: 8, opacity: 1 });
// Bring to front
if (mr.orsLine) mr.orsLine.bringToFront?.();
else if (mr.fallbackLine) mr.fallbackLine.bringToFront?.();
try {
mr.marker?.openPopup();
} catch {}
}
}
private computeWeight(j: RouteAssignedResponse['json']): number {
if (this.heatWeightMode === 'uniform') return 0.8;
const v = parseFloat(String(j.horas_trabajo));
if (isNaN(v)) return 0.8;
const vals = this.filteredRoutes.map(r => parseFloat(String(r.json.horas_trabajo))).filter(n => !isNaN(n));
if (!vals.length) return 0.8;
const min = Math.min(...vals), max = Math.max(...vals);
if (max === min) return 0.8;
return 0.3 + 0.7 * (v - min) / (max - min);
}
private updateHeatLayer() {
if (this.heatLayer) {
this.map?.removeLayer(this.heatLayer);
this.heatLayer = undefined;
}
if (!this.showHeat || !this.heatPointsCache.length || !this.map) return;
this.heatLayer = (L as any).heatLayer(this.heatPointsCache, {
radius: 25, blur: 18, maxZoom: 17, minOpacity: 0.4,
gradient: { 0.4: '#6CC24A', 0.65: '#FF6A13', 1.0: '#D32F2F' }
}).addTo(this.map);
}
toggleHeat() {
this.showHeat = !this.showHeat;
this.updateHeatLayer();
this.toggleLayerVisibility();
const needsHydrate = !this.mapRoutes.some(m => m.orsLine) || this.orsTotal === 0 || this.orsProgress < this.orsTotal;
if (!this.showHeat && needsHydrate) {
this.hydrateWithOrs();
}
this.cdr.detectChanges();
}
setHeatWeight(mode: 'uniform' | 'horas_trabajo') {
this.heatWeightMode = mode;
this.heatPointsCache = this.filteredRoutes.map(r => {
const j = r.json;
return [Number(j.latitud_pdv), Number(j.longitud_pdv), this.computeWeight(j)] as [number, number, number];
});
this.updateHeatLayer();
this.cdr.detectChanges();
}
private toggleLayerVisibility() {
this.mapRoutes.forEach(mr => {
if (mr.fallbackLine) (mr.fallbackLine as any).setStyle({ opacity: 0.6 });
if (mr.orsLine) (mr.orsLine as any).setStyle({ opacity: 0.95 });
if (mr.orsGlow) (mr.orsGlow as any).setStyle({ opacity: 0.25 });
});
}
clearSelection() {
if (this.selectedMapRoute) {
const line = this.selectedMapRoute.orsLine || this.selectedMapRoute.fallbackLine;
if (line) (line as L.Polyline).setStyle({ weight: this.selectedMapRoute.orsLine ? 5 : 4, opacity: this.selectedMapRoute.orsLine ? 0.95 : 0.6 });
}
this.selectedMapRoute = null;
this.selectedRouteDetail = null;
}
goBack() {
this.router.navigate(['/home']);
}
}