first commit

This commit is contained in:
2026-08-27 17:29:18 -04:00
commit 2405dda41b
44 changed files with 11368 additions and 0 deletions
@@ -0,0 +1,198 @@
#map {
width: 100%;
height: 100vh;
}
.detail-sidebar {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 340px;
z-index: 900;
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
overflow-y: auto;
background: var(--glm-surface);
border-left: 3px solid var(--glm-verde);
}
.sidebar-brand {
display: flex;
justify-content: center;
padding-bottom: 12px;
border-bottom: 2px solid var(--glm-gris-medio);
}
.sidebar-logo {
height: 45px;
width: auto;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 12px;
border-bottom: 2px solid var(--glm-gris-medio);
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
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-family: Arial, sans-serif;
font-weight: 600;
transition: all 0.2s;
}
.back-btn:hover {
background: var(--glm-verde-bg);
border-color: var(--glm-verde);
color: var(--glm-gris-oscuro);
}
.detail-title {
flex: 1;
}
.detail-title h2 {
margin: 0 0 4px;
font-size: 1.05rem;
color: var(--glm-azul);
font-weight: 700;
}
.badge {
display: inline-block;
background: var(--glm-verde);
color: #ffffff;
padding: 2px 10px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
font-family: Arial, sans-serif;
}
.map-loading {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: rgba(255, 255, 255, 0.92);
color: var(--glm-acero);
z-index: 999;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--glm-gris-medio);
border-top-color: var(--glm-verde);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.info-card {
background: var(--glm-surface);
border: 1px solid var(--glm-gris-medio);
border-radius: 4px;
padding: 16px;
border-top: 3px solid var(--glm-azul);
}
.info-card h3 {
margin: 0 0 12px;
font-size: 0.9rem;
color: var(--glm-azul);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 2px solid var(--glm-verde);
padding-bottom: 10px;
}
.info-list {
display: grid;
gap: 10px;
margin: 0;
}
.info-list div {
display: grid;
grid-template-columns: auto 1fr;
gap: 12px;
align-items: start;
padding: 6px 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.8rem;
white-space: nowrap;
text-transform: uppercase;
}
.info-list dd {
margin: 0;
color: var(--glm-gris-oscuro);
font-size: 0.9rem;
word-break: break-word;
}
.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: 13px;
color: #fff;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
font-family: Arial, sans-serif;
}
.marker-start .marker-inner {
background: var(--glm-azul);
border: 3px solid #fff;
}
.marker-end .marker-inner {
background: var(--glm-verde);
border: 3px solid #fff;
}
@@ -0,0 +1,41 @@
<div id="map"></div>
<div class="map-loading" *ngIf="isLoading">
<div class="spinner"></div>
<p>Cargando ruta...</p>
</div>
<div class="detail-sidebar" *ngIf="routeData">
<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>
<div class="detail-title">
<h2>{{ routeData.json.nombre_usuario }} → {{ routeData.json.nombre_pdv }}</h2>
<span class="badge">{{ routeData.json.dia }}</span>
</div>
</div>
<section class="info-card">
<h3>Información de la ruta</h3>
<dl class="info-list">
<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>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>
</dl>
</section>
<section class="info-card">
<h3>Coordenadas</h3>
<dl class="info-list">
<div><dt>Usuario (lat, lng)</dt><dd>{{ routeData.json.latitud_usuario }}, {{ routeData.json.longitud_usuario }}</dd></div>
<div><dt>PDV (lat, lng)</dt><dd>{{ routeData.json.latitud_pdv }}, {{ routeData.json.longitud_pdv }}</dd></div>
</dl>
</section>
</div>
@@ -0,0 +1,243 @@
import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } 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';
@Component({
standalone: true,
selector: 'app-assigned-route-detail',
imports: [CommonModule],
templateUrl: './assigned-route-detail.component.html',
styleUrl: './assigned-route-detail.component.css'
})
export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterViewInit {
private map!: L.Map;
private tileLayer!: L.TileLayer;
private routeGlow?: L.Layer;
private routeLine?: L.Layer;
private arrowLayer?: L.Layer;
private markers: L.Marker[] = [];
private sub?: Subscription;
private destroyed = false;
routeData: RouteAssignedResponse | null = null;
isLoading = false;
error: string | null = null;
constructor(
private router: Router,
private routeState: RouteState,
private orsService: OrsService,
private cdr: ChangeDetectorRef
) {}
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';
}
this.cdr.detectChanges();
});
}
ngAfterViewInit() {
this.initMap();
if (this.routeData) {
this.loadAndShowRoute(this.routeData);
}
}
ngOnDestroy() {
this.destroyed = true;
this.sub?.unsubscribe();
this.clearRoute();
if (this.map) {
this.map.remove();
}
}
private initMap() {
this.map = L.map('map').setView([18.4861, -69.9312], 13);
this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(this.map);
}
private async loadAndShowRoute(route: RouteAssignedResponse) {
this.clearRoute();
this.isLoading = true;
this.error = null;
this.cdr.detectChanges();
const json = route.json;
const origin: [number, number] = [Number(json.longitud_usuario), Number(json.latitud_usuario)];
const destination: [number, number] = [Number(json.longitud_pdv), Number(json.latitud_pdv)];
this.centerMapOnPoints(origin, destination);
try {
const geojson = await this.orsService.processPairs(
[{ pdv_id: json.pdv_id, origin, destination }],
1,
true
);
if (this.destroyed) return;
if (geojson[0] && !geojson[0].error) {
this.showRouteOnMap(geojson[0], origin, destination, json);
} else {
this.showFallbackRoute(origin, destination, json);
}
} catch {
this.showFallbackRoute(origin, destination, json);
} finally {
this.isLoading = false;
this.cdr.detectChanges();
}
}
private centerMapOnPoints(origin: [number, number], destination: [number, number]) {
const bounds = L.latLngBounds([
L.latLng(origin[1], origin[0]),
L.latLng(destination[1], destination[0])
]);
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(
(c: number[]) => [c[1], c[0]] as L.LatLngExpression
);
}
if (geojson.type === 'LineString') {
return geojson.coordinates.map(
(c: number[]) => [c[1], c[0]] as L.LatLngExpression
);
}
return [];
}
private showRouteOnMap(geojson: any, origin: [number, number], destination: [number, number], json: any) {
const coords = this.extractCoords(geojson);
if (!coords.length) {
this.showFallbackRoute(origin, destination, json);
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';
this.routeGlow = L.polyline(coords, {
color: '#6CC24A',
weight: 12,
opacity: 0.35,
lineCap: 'round',
lineJoin: 'round'
}).addTo(this.map);
this.routeLine = L.polyline(coords, {
color: '#6CC24A',
weight: 6,
opacity: 0.95,
lineCap: 'round',
lineJoin: 'round'
}).addTo(this.map);
this.arrowLayer = L.polyline(coords, {
color: '#ffffff',
weight: 4,
opacity: 0.7,
dashArray: '2, 16',
dashOffset: '0',
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.addMarkers(origin, destination, json, distance, duration);
const group = new L.FeatureGroup([
...(this.routeGlow ? [this.routeGlow] : []),
...(this.routeLine ? [this.routeLine] : []),
...this.markers
]);
this.map.fitBounds(group.getBounds().pad(0.1));
}
private showFallbackRoute(origin: [number, number], destination: [number, number], json: any) {
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');
const group = new L.FeatureGroup([this.routeLine, ...this.markers]);
this.map.fitBounds(group.getBounds().pad(0.1));
}
private addMarkers(
origin: [number, number],
destination: [number, number],
json: any,
distance: string,
duration: string
) {
const startIcon = L.divIcon({
className: 'custom-marker marker-start',
html: '<div class="marker-inner">A</div>',
iconSize: [30, 30],
iconAnchor: [15, 15],
popupAnchor: [0, -18]
});
const endIcon = L.divIcon({
className: 'custom-marker marker-end',
html: '<div class="marker-inner">B</div>',
iconSize: [30, 30],
iconAnchor: [15, 15],
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 })
.addTo(this.map)
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
);
}
private clearRoute() {
if (this.routeGlow) {
this.map.removeLayer(this.routeGlow);
this.routeGlow = undefined;
}
if (this.routeLine) {
this.map.removeLayer(this.routeLine);
this.routeLine = undefined;
}
if (this.arrowLayer) {
this.map.removeLayer(this.arrowLayer);
this.arrowLayer = undefined;
}
this.markers.forEach(m => this.map.removeLayer(m));
this.markers = [];
}
goBack() {
this.router.navigate(['/home']);
}
}
@@ -0,0 +1,71 @@
.assigned-route-container {
width: 100%;
}
.header-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.actions-right {
display: flex;
gap: 8px;
}
.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: 13px;
font-family: Arial, sans-serif;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.btn-secondary:hover {
background: var(--glm-verde-bg);
border-color: var(--glm-verde);
}
.btn-icon {
background: transparent;
border: 1px solid var(--glm-gris-medio);
border-radius: 4px;
color: var(--glm-azul);
cursor: pointer;
font-size: 15px;
padding: 6px 8px;
transition: all 0.2s;
}
.btn-icon:hover {
background: var(--glm-verde);
border-color: var(--glm-verde);
color: #ffffff;
}
.table-caption {
display: flex;
justify-content: space-between;
align-items: center;
}
.table-caption .font-bold {
color: var(--glm-azul);
font-weight: 700;
}
.table-caption .count {
color: var(--glm-acero);
font-size: 13px;
}
@@ -0,0 +1,91 @@
<div class="assigned-route-container">
<div class="header-actions">
<input
pInputText
type="text"
placeholder="Buscar..."
(input)="dt.filterGlobal($any($event.target).value, 'contains')"
/>
<div class="actions-right">
<button type="button" class="btn-secondary" (click)="downloadCSV()">
<i class="pi pi-download"></i> Descargar CSV
</button>
<button
type="button"
class="btn-secondary"
(click)="toggleRutas()"
pTooltip="Mostrar u ocultar el detalle de rutas"
tooltipPosition="top"
>
<i [class]="rutasExpandido ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"></i>
{{ rutasExpandido ? 'Ocultar rutas' : 'Ver rutas' }}
</button>
</div>
</div>
<p-table
#dt
[value]="assignedRoutes"
[loading]="isLoading"
[paginator]="true"
[rows]="10"
size="small"
[columns]="cols"
[rowsPerPageOptions]="[10, 25, 50]"
styleClass="p-datatable-sm text-sm"
>
<ng-template pTemplate="caption">
<div class="table-caption">
<span class="font-bold">Rutas asignadas</span>
<span class="count">{{ assignedRoutes.length }} registros</span>
</div>
</ng-template>
<ng-template pTemplate="header">
<tr>
<th *ngFor="let col of cols" pSortableColumn="{{ col.field }}">
{{ col.header }}
<p-sortIcon [field]="col.field"></p-sortIcon>
<p-columnFilter
[field]="col.field"
display="menu"
matchMode="contains"
[showMatchModes]="false"
[showOperator]="false"
[showAddButton]="false"
></p-columnFilter>
</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-row>
<tr>
<td>{{ row.json.nombre_usuario }}</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>{{ row.json.distancia }}</td>
<td>{{ row.json.horas_desplazamiento }}</td>
<td class="text-center">
<button
type="button"
class="btn-icon"
(click)="onShowMap(row)"
pTooltip="Ver en mapa"
tooltipPosition="top"
>
<i class="pi pi-map-marker"></i>
</button>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="9" class="text-center">No hay rutas asignadas</td>
</tr>
</ng-template>
</p-table>
</div>
@@ -0,0 +1,122 @@
import { Component, ChangeDetectorRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { RouteAssignedResponse } from '../../../core/models/route.model';
import { saveAs } from 'file-saver';
import { Table } from 'primeng/table';
import { TableModule } from 'primeng/table';
import { InputTextModule } from 'primeng/inputtext';
import { TooltipModule } from 'primeng/tooltip';
import { RouteState } from '../../../core/route-state/route';
@Component({
standalone: true,
selector: 'app-assigned-route',
imports: [
CommonModule,
FormsModule,
TableModule,
InputTextModule,
TooltipModule
],
templateUrl: './assigned-route.component.html',
styleUrl: './assigned-route.component.css'
})
export class AssignedRouteComponent {
@ViewChild('dt') table!: Table;
rutasExpandido = false;
tablaRutas = '';
assignedRoutes: RouteAssignedResponse[] = [];
isLoading = false;
cols: any[] = [];
constructor(
private routesState: RouteState,
private cdr: ChangeDetectorRef,
private router: Router
) { }
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.cols = [
{ field: 'json.nombre_usuario', header: 'Usuario' },
{ field: 'json.nombre_pdv', header: 'PDV' },
{ field: 'json.hora_laboral_semanal_usuario', header: 'Sem. Usuario (hr)' },
{ field: 'json.hora_minima_semanal_pdv', header: 'Horas Min. PDV' },
{ field: 'json.horas_trabajo', header: 'Horas Trabajo' },
{ field: 'json.dia', header: 'Día' },
{ field: 'json.distancia', header: 'Distancia (km)' },
{ field: 'json.horas_desplazamiento', header: 'Duración (min)' },
{ field: '', header: '' }
];
}
onShowMap(row: RouteAssignedResponse) {
this.routesState.sendRoute(row);
this.router.navigate(['/home/assigned-route']);
}
toggleRutas() {
this.rutasExpandido = !this.rutasExpandido;
}
downloadCSV() {
const headers = [
'Usuario ID',
'Usuario',
'Long Usuario',
'Lat Usuario',
'PDV ID',
'PDV',
'Long PDV',
'Lat PDV',
'Horas Usuario',
'Horas PDV',
'Horas Trabajo',
'Día',
'Distancia',
'Duracion'
];
const data =
this.table.filteredValue && this.table.filteredValue.length
? this.table.filteredValue
: this.assignedRoutes;
const rows = data.map(r => [
r.json.usuario_id,
r.json.nombre_usuario,
r.json.longitud_usuario,
r.json.latitud_usuario,
r.json.pdv_id,
r.json.nombre_pdv,
r.json.longitud_pdv,
r.json.latitud_pdv,
r.json.hora_laboral_semanal_usuario,
r.json.hora_minima_semanal_pdv,
r.json.horas_trabajo,
r.json.dia,
r.json.distancia,
r.json.horas_desplazamiento
]);
const csvContent =
[headers, ...rows].map(r => r.join(',')).join('\n');
const bom = '\uFEFF';
const blob = new Blob([bom + csvContent], {
type: 'text/csv;charset=utf-8;'
});
saveAs(blob, 'rutas_planificadas.csv');
}
}
@@ -0,0 +1,247 @@
.glm-topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 24px;
background: var(--glm-surface);
border-bottom: 3px solid var(--glm-verde);
font-family: Arial, sans-serif;
}
.glm-topbar-logo {
height: 40px;
width: auto;
}
.glm-topbar-brand {
font-weight: 700;
font-size: 13px;
color: var(--glm-azul);
letter-spacing: 0.5px;
}
.glm-topbar-sep {
color: var(--glm-gris-medio);
font-size: 13px;
}
.glm-topbar-context {
font-style: italic;
font-size: 12px;
color: var(--glm-verde);
}
.home-container {
max-width: 1000px;
margin: 0 auto;
padding: 24px;
}
.home-header {
margin-bottom: 24px;
border-bottom: 3px solid var(--glm-verde);
padding-bottom: 16px;
}
.home-header h1 {
margin: 0;
color: var(--glm-azul);
font-size: 24px;
font-weight: 700;
}
.home-header p {
margin: 4px 0 0;
color: var(--glm-acero);
font-size: 14px;
}
.card {
background: var(--glm-surface-card);
border-radius: 4px;
padding: 20px;
margin-bottom: 24px;
border: 1px solid var(--glm-gris-medio);
border-top: 3px solid var(--glm-verde);
}
.section-title {
margin: 0 0 12px;
font-size: 16px;
color: var(--glm-azul);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
}
/* Upload card */
.upload-card {
padding: 24px;
}
.upload-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
margin-bottom: 20px;
}
@media (min-width: 768px) {
.upload-grid {
grid-template-columns: 1fr 1fr;
}
}
.file-input-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
.file-label {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px;
border: 2px dashed var(--glm-gris-medio);
border-radius: 4px;
background: var(--glm-verde-bg);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.file-label:hover {
border-color: var(--glm-verde);
background: #e5f5dc;
}
.file-label:has(.file-input:focus-visible) {
border-color: var(--glm-verde);
box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.2);
}
.file-label i {
font-size: 2rem;
color: var(--glm-verde);
}
.file-label span:first-of-type {
font-weight: 600;
color: var(--glm-azul);
font-size: 14px;
}
.file-hint {
font-size: 0.8rem;
color: var(--glm-acero);
}
.file-input {
position: absolute;
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
z-index: -1;
}
.file-name {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--glm-verde-bg);
border: 1px solid var(--glm-verde-claro);
border-radius: 4px;
color: var(--glm-gris-oscuro);
font-size: 0.9rem;
}
.file-name i {
color: var(--glm-verde);
}
.upload-actions {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--glm-gris-medio);
}
.upload-error {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--glm-rojo-ok);
border: 1px solid #F5A6A0;
border-radius: 4px;
color: var(--glm-gris-oscuro);
font-size: 0.9rem;
}
.actions-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border: none;
font-family: Arial, sans-serif;
}
.btn-primary {
background: var(--glm-verde);
color: #ffffff;
}
.btn-primary:hover:not(:disabled) {
background: var(--glm-primary-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: var(--glm-surface);
color: var(--glm-azul);
border: 1px solid var(--glm-gris-medio);
}
.btn-secondary:hover:not(:disabled) {
background: var(--glm-verde-bg);
border-color: var(--glm-verde);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner-inline {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -0,0 +1,94 @@
<div class="glm-topbar">
<img src="GLM_completo.png" alt="GomezLee Marketing" class="glm-topbar-logo" />
<span class="glm-topbar-sep">|</span>
<span class="glm-topbar-context">Route Planner</span>
</div>
<div class="home-container">
<header class="home-header">
<h1>Route Planner</h1>
<p>Gestión de rutas asignadas y puntos de venta pendientes</p>
</header>
<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">
<i class="pi pi-file-excel"></i>
<span>Archivo PDVs (Excel)</span>
<span class="file-hint">.xlsx / .xls • máx 10MB</span>
</label>
<input
id="pdvFile"
type="file"
accept=".xlsx,.xls"
(change)="onPdvFileChange($event)"
class="file-input"
[class.has-file]="pdvFile"
/>
<div class="file-name" *ngIf="pdvFile">
<i class="pi pi-check-circle"></i>
{{ pdvFile.name }}
</div>
</div>
<div class="file-input-wrapper">
<label class="file-label" for="usuariosFile">
<i class="pi pi-file-excel"></i>
<span>Archivo Usuarios (Excel)</span>
<span class="file-hint">.xlsx / .xls • máx 10MB</span>
</label>
<input
id="usuariosFile"
type="file"
accept=".xlsx,.xls"
(change)="onUsuariosFileChange($event)"
class="file-input"
[class.has-file]="usuariosFile"
/>
<div class="file-name" *ngIf="usuariosFile">
<i class="pi pi-check-circle"></i>
{{ usuariosFile.name }}
</div>
</div>
</div>
<div class="upload-actions">
<div class="upload-error" *ngIf="uploadError">
<i class="pi pi-exclamation-triangle"></i>
{{ uploadError }}
</div>
<div class="actions-row">
<button
type="button"
class="btn-primary"
(click)="cargarRutas()"
[disabled]="!canSubmit || isLoading"
>
<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"
(click)="clearFiles()"
[disabled]="!pdvFile && !usuariosFile"
>
<i class="pi pi-times"></i> Limpiar
</button>
</div>
</div>
</section>
<section class="card">
<app-assigned-route></app-assigned-route>
</section>
<section class="card">
<h2 class="section-title">PDVs sin ruta asignada</h2>
<app-no-assigned-route></app-no-assigned-route>
</section>
</div>
@@ -0,0 +1,106 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AssignedRouteComponent } from './assigned-route/assigned-route.component';
import { NoAssignedRouteComponent } from './no-assigned-route/no-assigned-route.component';
import { RutasService } from '../../core/services/router.service';
import { RouteState } from '../../core/route-state/route';
import { ApiResponse } from '../../core/models/route.model';
@Component({
standalone: true,
selector: 'app-home',
imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent],
templateUrl: './home.component.html',
styleUrls: ['./home.component.css']
})
export class HomeComponent {
pdvFile: File | null = null;
usuariosFile: File | null = null;
uploadError: string | null = null;
isLoading = false;
constructor(
private rutasService: RutasService,
private routeState: RouteState
) {}
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');
this.uploadError = null;
}
}
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');
this.uploadError = null;
}
}
private validateFile(file: File, type: string) {
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;
}
if (file.size > 10 * 1024 * 1024) {
this.uploadError = `El archivo ${type} supera 10MB`;
if (type === 'PDV') this.pdvFile = null;
else this.usuariosFile = null;
}
}
get canSubmit(): boolean {
return !!this.pdvFile && !!this.usuariosFile && !this.uploadError;
}
cargarRutas() {
if (!this.canSubmit) return;
this.isLoading = true;
this.uploadError = null;
this.routeState.setLoading(true);
const formData = new FormData();
formData.append('pdv_file', this.pdvFile!, this.pdvFile!.name);
formData.append('usuarios_file', this.usuariosFile!, this.usuariosFile!.name);
this.rutasService.calcularRutas(formData).subscribe({
next: (res: ApiResponse) => {
const assigned = res.find(r => 'assigned' in r);
const noAssigned = res.find(r => 'noAssigned' in r);
if (assigned) {
this.routeState.setAssignedRoutes(assigned.assigned);
}
if (noAssigned) {
this.routeState.setNoAssignedRoutes(noAssigned.noAssigned);
}
this.routeState.setLoading(false);
this.isLoading = false;
},
error: (err) => {
this.uploadError = err.error?.message || 'Error al procesar los archivos';
this.routeState.setLoading(false);
this.isLoading = false;
}
});
}
clearFiles() {
this.pdvFile = null;
this.usuariosFile = null;
this.uploadError = null;
const pdvInput = document.getElementById('pdvFile') as HTMLInputElement;
const usuariosInput = document.getElementById('usuariosFile') as HTMLInputElement;
if (pdvInput) pdvInput.value = '';
if (usuariosInput) usuariosInput.value = '';
}
}
@@ -0,0 +1,13 @@
:host {
display: block;
width: 100%;
}
.section-title {
margin: 0 0 12px;
font-size: 16px;
color: var(--glm-azul);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
}
@@ -0,0 +1,32 @@
<p-table #dt [value]="noAssignedRoutes" [loading]="isLoading" [paginator]="true" [rows]="10" size="small" [columns]="cols"
[rowsPerPageOptions]="[10, 25, 50]" styleClass="p-datatable-sm text-sm">
<ng-template pTemplate="header">
<th *ngFor="let col of cols" pSortableColumn="{{ col.field }}">
{{ col.header }}
<p-sortIcon [field]="col.field"></p-sortIcon>
<p-columnFilter [field]="col.field" display="menu" matchMode="contains" [showMatchModes]="false"
[showOperator]="false" [showAddButton]="false">
</p-columnFilter>
</th>
</ng-template>
<ng-template pTemplate="body" let-row>
<tr>
<td class="text-left">{{ row.pdv_id }}</td>
<td class="text-left">{{ row.pdv_nombre }}</td>
<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>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="8" class="text-center">
No hay registros asignados
</td>
</tr>
</ng-template>
</p-table>
@@ -0,0 +1,43 @@
import { Component, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TableModule } from 'primeng/table';
import { RouteNoAssignedResponse } from '../../../core/models/route.model';
import { RouteState } from '../../../core/route-state/route';
@Component({
standalone: true,
selector: 'app-no-assigned-route',
imports: [CommonModule, TableModule],
templateUrl: './no-assigned-route.component.html',
styleUrls: ['./no-assigned-route.component.css']
})
export class NoAssignedRouteComponent {
noAssignedRoutes: RouteNoAssignedResponse[] = [];
isLoading = false;
cols: any[] = [];
constructor(
private routesState: RouteState,
private cdr: ChangeDetectorRef
) {}
ngOnInit() {
this.routesState.loading$.subscribe(value => {
this.isLoading = value;
this.cdr.detectChanges();
});
this.routesState.noAssignedRoutes$.subscribe(data => {
this.noAssignedRoutes = [...data];
this.cdr.detectChanges();
});
this.cols = [
{ field: 'pdv_id', header: 'PDV ID' },
{ field: 'pdv_nombre', header: 'Nombre' },
{ field: 'minimo_horas_semana', header: 'Min. Horas/Sem' },
{ field: 'latitud', header: 'Latitud' },
{ field: 'longitud', header: 'Longitud' },
{ field: 'motivo', header: 'Motivo' }
];
}
}