217 lines
15 KiB
HTML
217 lines
15 KiB
HTML
<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" *ngIf="!embedded">
|
|
<img src="GLM_completo.png" alt="GomezLee Marketing" class="sidebar-logo" />
|
|
</div>
|
|
|
|
<div class="sidebar-header">
|
|
<button type="button" class="back-btn" *ngIf="!embedded" (click)="goBack()" aria-label="Volver a la planificación">
|
|
<i class="pi pi-arrow-left"></i> Volver
|
|
</button>
|
|
<h2 class="sidebar-title">Mapa de rutas</h2>
|
|
</div>
|
|
<p class="territory-summary" *ngIf="activeTerritories.length"><i class="pi pi-map-marker"></i> {{ activeTerritories.join(', ') }}</p>
|
|
|
|
<div class="filters-card" *ngIf="usuarios.length">
|
|
<label class="filter-label">Ciudad</label>
|
|
<select class="filter-select" [(ngModel)]="selectedMetro" (ngModelChange)="onMetroChange()">
|
|
<option value="Todos">Todas las ciudades</option>
|
|
<option *ngFor="let metro of metros" [value]="metro">{{ metro }}</option>
|
|
</select>
|
|
|
|
<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 metroUsuarios" [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>
|
|
|
|
<label class="filter-label">Asignación</label>
|
|
<div class="segmented-control" role="group" aria-label="Filtrar PDVs por asignación">
|
|
<button type="button" [class.active]="selectedAsignacion === 'Todos'" (click)="selectedAsignacion = 'Todos'; onAsignacionChange()">Todos</button>
|
|
<button type="button" [class.active]="selectedAsignacion === 'Asignados'" (click)="selectedAsignacion = 'Asignados'; onAsignacionChange()">Asignados</button>
|
|
<button type="button" [class.active]="selectedAsignacion === 'NoAsignados'" (click)="selectedAsignacion = 'NoAsignados'; onAsignacionChange()">Sin asignar</button>
|
|
</div>
|
|
|
|
<div class="filter-stats" *ngIf="filteredRoutes.length">
|
|
<span>{{ uniqueFilteredRoutes.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)
|
|
<span class="heatmap-help" title="El mapa muestra con mayor intensidad las zonas donde se concentra más carga de trabajo en horas.">?</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="filter-empty" *ngIf="!filteredRoutes.length && !error">
|
|
Sin rutas para este filtro.
|
|
</div>
|
|
|
|
<div class="filter-empty" *ngIf="overviewMode && filteredRoutes.length">
|
|
Vista general ({{ filteredRoutes.length }} PDVs). Filtra por cobertura, zona, usuario o día para ver el detalle vial completo.
|
|
</div>
|
|
|
|
<div class="filter-empty" *ngIf="orsFailCount">
|
|
{{ orsFailCount }} tramo(s) sin conexión vial (línea recta). Toca la línea para ver el motivo.
|
|
</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" *ngIf="!embedded" (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="info-card unassigned-detail-card" *ngIf="selectedUnassigned && showUnassigned">
|
|
<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ías<div class="day-checkboxes"><label *ngFor="let day of unassignedDayOptions"><input type="checkbox" [checked]="(assignmentDays[selectedUnassigned.pdv_id] || []).includes(day)" (change)="toggleUnassignedDay(selectedUnassigned.pdv_id, day, $any($event.target).checked)">{{ day.substring(0, 3) }}</label></div></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>
|
|
<div class="employee-load-list" aria-label="Carga de empleados">
|
|
<button type="button" class="employee-load-row" *ngFor="let employee of unassignedEmployeeLoads" [class.selected]="assignmentUser[selectedUnassigned.pdv_id] === employee.id" [class.over-capacity]="employeeLoadStatus(employee) === 'over-capacity'" (click)="selectUnassignedEmployee(employee.id)">
|
|
<span class="employee-load-name">{{ employee.name }}</span>
|
|
<span class="employee-load-values">{{ employee.workHours | number:'1.0-1' }} / {{ employee.capacityHours | number:'1.0-1' }} h · {{ employee.utilization | number:'1.0-1' }}%</span>
|
|
<span class="employee-load-bar" [ngClass]="employeeLoadStatus(employee)"><span [style.width.%]="employeeLoadWidth(employee)"></span></span>
|
|
</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" aria-label="Cerrar detalle de ruta">
|
|
<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><button type="button" class="btn-secondary map-location-button" (click)="openDestinationInGoogleMaps()" title="Abrir ubicación en Google Maps"><i class="pi pi-map"></i> Ver en Google Maps</button></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="detail-section" *ngIf="otherPdvVisitGroups.length">
|
|
<h4><i class="pi pi-calendar"></i> Otras visitas de este PDV</h4>
|
|
<div *ngFor="let group of otherPdvVisitGroups">
|
|
<p class="visit-group-title">Usuario: {{ group.usuario }}</p>
|
|
<div class="employee-load-list" [attr.aria-label]="'Visitas de ' + group.usuario">
|
|
<button type="button" class="employee-load-row" *ngFor="let visit of group.visits" (click)="selectRouteFromList(visit.route)">
|
|
<span class="employee-load-name">{{ visit.dia }} (parada {{ visit.orden }})</span>
|
|
<span class="employee-load-values">{{ visit.km | number:'1.2-2' }} km · {{ visit.min | number:'1.0-0' }} min</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="reassign-actions" *ngIf="!showReassignForm">
|
|
<button type="button" class="btn-secondary" (click)="moveSelectedVisit(-1)" [disabled]="!canMoveSelectedVisitUp" aria-label="Mover visita anterior"><i class="pi pi-arrow-up"></i> Subir</button>
|
|
<button type="button" class="btn-secondary" (click)="moveSelectedVisit(1)" [disabled]="!canMoveSelectedVisitDown" aria-label="Mover visita posterior"><i class="pi pi-arrow-down"></i> Bajar</button>
|
|
<button type="button" class="btn-secondary" (click)="resetSelectedDayOrder()"><i class="pi pi-refresh"></i> Orden automático</button>
|
|
<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="employee-load-list" aria-label="Carga de empleados">
|
|
<button type="button" class="employee-load-row" *ngFor="let employee of reassignmentEmployeeLoads" [class.selected]="reassignUserId === employee.id" [class.over-capacity]="employeeLoadStatus(employee) === 'over-capacity'" (click)="selectReassignmentEmployee(employee.id)">
|
|
<span class="employee-load-name">{{ employee.name }}</span>
|
|
<span class="employee-load-values">{{ employee.workHours | number:'1.0-1' }} / {{ employee.capacityHours | number:'1.0-1' }} h · {{ employee.utilization | number:'1.0-1' }}%</span>
|
|
<span class="employee-load-bar" [ngClass]="employeeLoadStatus(employee)"><span [style.width.%]="employeeLoadWidth(employee)"></span></span>
|
|
</button>
|
|
</div>
|
|
<div class="error-card" *ngIf="reassignError">{{ reassignError }}</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="daily-routes-card" *ngIf="dailySummaries.length">
|
|
<h3>Rutas diarias</h3>
|
|
<article class="daily-route-group" *ngFor="let group of dailyGroups">
|
|
<button type="button" class="daily-route-toggle" (click)="toggleDailyRouteDay(group.day)" [attr.aria-expanded]="isDailyRouteDayExpanded(group.day)">
|
|
<span class="daily-route-toggle-title"><i class="pi" [class.pi-chevron-right]="!isDailyRouteDayExpanded(group.day)" [class.pi-chevron-down]="isDailyRouteDayExpanded(group.day)"></i> {{ group.day }}</span>
|
|
<span>{{ group.totalStops }} paradas · {{ group.totalDistance | number:'1.1-1' }} km</span>
|
|
</button>
|
|
<div class="daily-route-group-content" *ngIf="isDailyRouteDayExpanded(group.day)">
|
|
<article class="daily-route" *ngFor="let route of group.routes" [class.over-capacity]="route.exceedsCapacity">
|
|
<strong>{{ route.userName }}</strong>
|
|
<span>{{ route.routes.length }} paradas · {{ route.distanceKm | number:'1.1-1' }} km</span>
|
|
<small [attr.aria-label]="route.exceedsCapacity ? 'Capacidad excedida' : 'Dentro de capacidad'">{{ route.totalHours | number:'1.1-1' }} / {{ route.capacityHours | number:'1.1-1' }} h {{ route.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}</small>
|
|
</article>
|
|
</div>
|
|
</article>
|
|
</section>
|
|
|
|
</div>
|
|
|
|
<section class="unassigned-bottom-drawer" *ngIf="showUnassigned && selectedUsuarioId === 'Todos' && selectedDia === 'Todos' && unassignedRoutes.length" [class.open]="isUnassignedDrawerOpen">
|
|
<button type="button" class="unassigned-drawer-toggle" (click)="toggleUnassignedDrawer()" [attr.aria-expanded]="isUnassignedDrawerOpen">
|
|
<span><i class="pi pi-exclamation-triangle"></i> PDVs sin ruta ({{ unassignedRoutes.length }})</span>
|
|
<i class="pi" [class.pi-chevron-up]="!isUnassignedDrawerOpen" [class.pi-chevron-down]="isUnassignedDrawerOpen"></i>
|
|
</button>
|
|
<div class="unassigned-drawer-content" *ngIf="isUnassignedDrawerOpen">
|
|
<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>
|