first commit
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
ors:
|
||||
image: openrouteservice/openrouteservice:latest
|
||||
container_name: ors-route-planner
|
||||
ports:
|
||||
- "18080:8082"
|
||||
volumes:
|
||||
- ./ors-docker/dominican-republic-latest.osm.pbf:/home/ors/files/dominican-republic-latest.osm.pbf
|
||||
- ./ors-docker/graphs:/home/ors/graphs
|
||||
- ./ors-docker/elevation_cache:/home/ors/elevation_cache
|
||||
- ./ors-docker/config/ors-config.yml:/home/ors/config/ors-config.yml
|
||||
environment:
|
||||
- JAVA_OPTS=-Djava.awt.headless=true -server -XX:TargetSurvivorRatio=75 -XX:SurvivorRatio=64 -XX:MaxTenuringThreshold=3 -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:InitiatingHeapOccupancyPercent=80 -Xms2g -Xmx4g
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,311 @@
|
||||
##### openrouteservice settings file #####
|
||||
# This file contains parameters for openrouteservice.
|
||||
# For a full list of possible parameters see https://giscience.github.io/openrouteservice/run-instance/configuration/
|
||||
|
||||
##### General server settings #####
|
||||
#server:
|
||||
# port: 8082
|
||||
# error:
|
||||
# whitelabel:
|
||||
# enabled: false
|
||||
# # Keep the context-path at / else the war file run with tomcat will have the context-path of /ors/v2 as well.
|
||||
# servlet:
|
||||
# context-path: /ors
|
||||
#spring:
|
||||
# profiles:
|
||||
# active: default
|
||||
# mvc:
|
||||
# servlet:
|
||||
# path: /
|
||||
##### Settings related to springdoc #####
|
||||
#springdoc:
|
||||
# swagger-ui:
|
||||
# enabled: true
|
||||
# path: /swagger-ui
|
||||
# tryItOutEnabled: true
|
||||
# filter: false
|
||||
# syntaxHighlight:
|
||||
# activated: true
|
||||
# showExtensions: true
|
||||
# api-docs:
|
||||
# path: /v2/api-docs
|
||||
# version: OPENAPI_3_0
|
||||
# packages-to-scan: org.heigit.ors
|
||||
# pathsToMatch: /v2/**
|
||||
##### Logging settings #####
|
||||
#logging:
|
||||
# file:
|
||||
# name: ./logs/ors.log
|
||||
# pattern:
|
||||
# console: "%d{yyyy-MM-dd HH:mm:ss} %highlight{%-7p} %style{%50t}{Cyan} %style{[ %-40.40c{1.} ]}{Bright Cyan} %m%n"
|
||||
# file: "%d{yyyy-MM-dd HH:mm:ss} %p [%-40.40c{1.}] - %m%n"
|
||||
# level:
|
||||
# root: WARN
|
||||
# org.heigit: INFO
|
||||
##### openrouteservice specific settings #####
|
||||
ors:
|
||||
# cors:
|
||||
# allowed_origins: "*"
|
||||
# allowed_headers: Content-Type, X-Requested-With, accept, Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Authorization
|
||||
# preflight_max_age: 600
|
||||
# messages:
|
||||
# ##### ORS endpoints settings #####
|
||||
# endpoints:
|
||||
# routing:
|
||||
# enabled: true
|
||||
# attribution: openrouteservice.org, OpenStreetMap contributors, tmc - BASt
|
||||
# gpx_name: ORSRouting
|
||||
# gpx_description: This is a directions instructions file as GPX, generated from openrouteservice
|
||||
# gpx_base_url: https://openrouteservice.org/
|
||||
# gpx_support_mail: support@openrouteservice.org
|
||||
# gpx_author: openrouteservice
|
||||
# gpx_content_licence: LGPL 3.0
|
||||
# maximum_avoid_polygon_area: 200000000
|
||||
# maximum_avoid_polygon_extent: 20000
|
||||
# maximum_alternative_routes: 3
|
||||
# matrix:
|
||||
# enabled: true
|
||||
# attribution: openrouteservice.org, OpenStreetMap contributors
|
||||
# maximum_routes: 2500
|
||||
# maximum_routes_flexible: 25
|
||||
# maximum_visited_nodes: 100000
|
||||
# maximum_search_radius: 2000
|
||||
# u_turn_costs: -1
|
||||
# isochrones:
|
||||
# enabled: true
|
||||
# attribution: openrouteservice.org, OpenStreetMap contributors
|
||||
# maximum_locations: 2
|
||||
# maximum_intervals: 1
|
||||
# allow_compute_area: true
|
||||
# maximum_range_distance_default: 50000
|
||||
# maximum_range_distance:
|
||||
# - profiles: driving-car, driving-hgv
|
||||
# value: 100000
|
||||
# maximum_range_time_default: 18000
|
||||
# maximum_range_time:
|
||||
# - profiles: driving-car, driving-hgv
|
||||
# value: 3600
|
||||
# fastisochrones:
|
||||
# maximum_range_distance_default: 50000
|
||||
# maximum_range_distance:
|
||||
# - profiles: driving-car, driving-hgv
|
||||
# value: 500000
|
||||
# maximum_range_time_default: 18000
|
||||
# maximum_range_time:
|
||||
# - profiles: driving-car, driving-hgv
|
||||
# value: 10800
|
||||
# Snap:
|
||||
# enabled: true
|
||||
# attribution: openrouteservice.org, OpenStreetMap contributors
|
||||
# ##### ORS engine settings #####
|
||||
engine:
|
||||
source_file: /home/ors/files/example-heidelberg.osm.gz
|
||||
# init_threads: 1
|
||||
# preparation_mode: false
|
||||
# graphs_root_path: ./graphs
|
||||
# graphs_data_access: RAM_STORE
|
||||
# elevation:
|
||||
# preprocessed: false
|
||||
# data_access: MMAP
|
||||
# cache_clear: false
|
||||
# provider: multi
|
||||
# cache_path: ./elevation_cache
|
||||
# profile_default:
|
||||
# enabled: false
|
||||
# elevation: false
|
||||
# elevation_smoothing: false
|
||||
# encoder_flags_size: 8
|
||||
# instructions: true
|
||||
# optimize: false
|
||||
# traffic: false
|
||||
# maximum_distance: 100000
|
||||
# maximum_distance_dynamic_weights: 100000
|
||||
# maximum_distance_avoid_areas: 100000
|
||||
# maximum_waypoints: 50
|
||||
# maximum_snapping_radius: 400
|
||||
# maximum_distance_alternative_routes: 100000
|
||||
# maximum_distance_round_trip_routes: 100000
|
||||
# maximum_speed_lower_bound: 80
|
||||
# maximum_visited_nodes: 1000000
|
||||
# location_index_resolution: 500
|
||||
# location_index_search_iterations: 4
|
||||
# force_turn_costs: false
|
||||
# interpolate_bridges_and_tunnels: true
|
||||
# preparation:
|
||||
# min_network_size: 200
|
||||
# methods:
|
||||
# lm:
|
||||
# enabled: true
|
||||
# threads: 1
|
||||
# weightings: recommended,shortest
|
||||
# landmarks: 16
|
||||
# execution:
|
||||
# methods:
|
||||
# lm:
|
||||
# active_landmarks: 8
|
||||
profiles:
|
||||
car:
|
||||
enabled: true
|
||||
# profile: driving-car
|
||||
# elevation: true
|
||||
# encoder_options:
|
||||
# turn_costs: true
|
||||
# block_fords: false
|
||||
# use_acceleration: true
|
||||
# preparation:
|
||||
# min_network_size: 200
|
||||
# methods:
|
||||
# ch:
|
||||
# enabled: true
|
||||
# threads: 1
|
||||
# weightings: fastest
|
||||
# lm:
|
||||
# enabled: false
|
||||
# threads: 1
|
||||
# weightings: fastest,shortest
|
||||
# landmarks: 16
|
||||
# core:
|
||||
# enabled: true
|
||||
# threads: 1
|
||||
# weightings: fastest,shortest
|
||||
# landmarks: 64
|
||||
# lmsets: highways;allow_all
|
||||
# execution:
|
||||
# methods:
|
||||
# lm:
|
||||
# active_landmarks: 6
|
||||
# core:
|
||||
# active_landmarks: 6
|
||||
# ext_storages:
|
||||
# WayCategory:
|
||||
# HeavyVehicle:
|
||||
# WaySurfaceType:
|
||||
# RoadAccessRestrictions:
|
||||
# use_for_warnings: true
|
||||
# hgv:
|
||||
# enabled: false
|
||||
# profile: driving-hgv
|
||||
# encoder_flags_size: 8
|
||||
# encoder_options:
|
||||
# turn_costs: true
|
||||
# block_fords: false
|
||||
# use_acceleration: true
|
||||
# maximum_distance: 100000
|
||||
# elevation: true
|
||||
# preparation:
|
||||
# min_network_size: 200
|
||||
# methods:
|
||||
# ch:
|
||||
# enabled: true
|
||||
# threads: 1
|
||||
# weightings: recommended
|
||||
# core:
|
||||
# enabled: true
|
||||
# threads: 1
|
||||
# weightings: recommended,shortest
|
||||
# landmarks: 64
|
||||
# lmsets: highways;allow_all
|
||||
# execution:
|
||||
# methods:
|
||||
# core:
|
||||
# active_landmarks: 6
|
||||
# ext_storages:
|
||||
# WayCategory:
|
||||
# HeavyVehicle:
|
||||
# restrictions: true
|
||||
# WaySurfaceType:
|
||||
# bike-regular:
|
||||
# enabled: false
|
||||
# profile: cycling-regular
|
||||
# encoder_options:
|
||||
# consider_elevation: true
|
||||
# turn_costs: true
|
||||
# block_fords: false
|
||||
# elevation: true
|
||||
# ext_storages:
|
||||
# WayCategory:
|
||||
# WaySurfaceType:
|
||||
# HillIndex:
|
||||
# TrailDifficulty:
|
||||
# bike-mountain:
|
||||
# enabled: false
|
||||
# profile: cycling-mountain
|
||||
# encoder_options:
|
||||
# consider_elevation: true
|
||||
# turn_costs: true
|
||||
# block_fords: false
|
||||
# elevation: true
|
||||
# ext_storages:
|
||||
# WayCategory:
|
||||
# WaySurfaceType:
|
||||
# HillIndex:
|
||||
# TrailDifficulty:
|
||||
# bike-road:
|
||||
# enabled: false
|
||||
# profile: cycling-road
|
||||
# encoder_options:
|
||||
# consider_elevation: true
|
||||
# turn_costs: true
|
||||
# block_fords: false
|
||||
# elevation: true
|
||||
# ext_storages:
|
||||
# WayCategory:
|
||||
# WaySurfaceType:
|
||||
# HillIndex:
|
||||
# TrailDifficulty:
|
||||
# bike-electric:
|
||||
# enabled: false
|
||||
# profile: cycling-electric
|
||||
# encoder_options:
|
||||
# consider_elevation: true
|
||||
# turn_costs: true
|
||||
# block_fords: false
|
||||
# elevation: true
|
||||
# ext_storages:
|
||||
# WayCategory:
|
||||
# WaySurfaceType:
|
||||
# HillIndex:
|
||||
# TrailDifficulty:
|
||||
# walking:
|
||||
# enabled: false
|
||||
# profile: foot-walking
|
||||
# encoder_options:
|
||||
# block_fords: false
|
||||
# elevation: true
|
||||
# ext_storages:
|
||||
# WayCategory:
|
||||
# WaySurfaceType:
|
||||
# HillIndex:
|
||||
# TrailDifficulty:
|
||||
# hiking:
|
||||
# enabled: false
|
||||
# profile: foot-hiking
|
||||
# encoder_options:
|
||||
# block_fords: false
|
||||
# elevation: true
|
||||
# ext_storages:
|
||||
# WayCategory:
|
||||
# WaySurfaceType:
|
||||
# HillIndex:
|
||||
# TrailDifficulty:
|
||||
# wheelchair:
|
||||
# enabled: false
|
||||
# profile: wheelchair
|
||||
# encoder_options:
|
||||
# block_fords: true
|
||||
# elevation: true
|
||||
# maximum_snapping_radius: 50
|
||||
# ext_storages:
|
||||
# WayCategory:
|
||||
# WaySurfaceType:
|
||||
# Wheelchair:
|
||||
# KerbsOnCrossings: true
|
||||
# OsmId:
|
||||
# public-transport:
|
||||
# enabled: false
|
||||
# profile: public-transport
|
||||
# encoder_options:
|
||||
# block_fords: false
|
||||
# elevation: true
|
||||
# maximum_visited_nodes: 1000000
|
||||
# gtfs_file: ./src/test/files/vrn_gtfs_cut.zip
|
||||
@@ -0,0 +1,17 @@
|
||||
ors:
|
||||
cors:
|
||||
allowed_origins: "*"
|
||||
allowed_headers: Content-Type, X-Requested-With, accept, Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Authorization
|
||||
preflight_max_age: 600
|
||||
engine:
|
||||
profile_default:
|
||||
enabled: false
|
||||
graph_path: graphs
|
||||
build:
|
||||
source_file: /home/ors/files/dominican-republic-latest.osm.pbf
|
||||
elevation: false
|
||||
service:
|
||||
maximum_distance: 100000
|
||||
profiles:
|
||||
driving-car:
|
||||
enabled: true
|
||||
@@ -0,0 +1,17 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Angular build outputs
|
||||
dist/
|
||||
out-tsc/
|
||||
|
||||
# Angular cache
|
||||
.angular/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"route-planner": {
|
||||
"projectType": "application",
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"outputPath": "dist/route-planner",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": ["zone.js"],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [{ "glob": "**/*", "input": "public" }],
|
||||
"styles": ["src/styles.css"],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{ "type": "initial", "maximumWarning": "1.5MB", "maximumError": "2MB" },
|
||||
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": { "buildTarget": "route-planner:build:production" },
|
||||
"development": { "buildTarget": "route-planner:build:development" }
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"tsConfig": "tsconfig.spec.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false
|
||||
}
|
||||
}
|
||||
Generated
+8966
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "route-planner",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/common": "^21.1.0",
|
||||
"@angular/compiler": "^21.1.0",
|
||||
"@angular/core": "^21.1.0",
|
||||
"@angular/forms": "^21.1.0",
|
||||
"@angular/platform-browser": "^21.1.0",
|
||||
"@angular/router": "^21.1.0",
|
||||
"@primeng/themes": "^21.0.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"leaflet": "^1.9.4",
|
||||
"primeicons": "^7.0.0",
|
||||
"primeng": "^21.0.0",
|
||||
"rxjs": "~7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.0.0",
|
||||
"zone.js": "~0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^21.1.0",
|
||||
"@angular/cli": "^21.1.0",
|
||||
"@angular/compiler-cli": "^21.1.0",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/leaflet": "^1.9.12",
|
||||
"@types/node": "^24.0.0",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "~5.9.0",
|
||||
"vitest": "^4.0.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,4 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<router-outlet />
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { LoginComponent } from './public/login/login.component';
|
||||
import { HomeComponent } from './private/home/home.component';
|
||||
import { AssignedRouteDetailComponent } from './private/home/assigned-route-detail/assigned-route-detail.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'login', pathMatch: 'full' },
|
||||
{ path: 'login', component: LoginComponent },
|
||||
{ path: 'home', component: HomeComponent, pathMatch: 'full' },
|
||||
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent },
|
||||
{ path: '**', redirectTo: 'login' }
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render the router outlet', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('router-outlet')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css'
|
||||
})
|
||||
export class App {}
|
||||
@@ -0,0 +1,37 @@
|
||||
export type ApiResponse = [
|
||||
AssignedResponse,
|
||||
NoAssignedResponse
|
||||
];
|
||||
export interface AssignedResponse {
|
||||
assigned: RouteAssignedResponse[];
|
||||
}
|
||||
export interface NoAssignedResponse {
|
||||
noAssigned: RouteNoAssignedResponse[];
|
||||
}
|
||||
export interface RouteAssignedResponse {
|
||||
json: RouteAssignedJson;
|
||||
}
|
||||
export interface RouteAssignedJson {
|
||||
dia: string;
|
||||
distancia: number;
|
||||
horas_desplazamiento: number;
|
||||
hora_laboral_semanal_usuario: number;
|
||||
hora_minima_semanal_pdv: number;
|
||||
horas_trabajo: string;
|
||||
latitud_usuario: number;
|
||||
longitud_usuario: number;
|
||||
latitud_pdv: number;
|
||||
longitud_pdv: number;
|
||||
nombre_usuario: string;
|
||||
nombre_pdv: string;
|
||||
usuario_id: string;
|
||||
pdv_id: number;
|
||||
}
|
||||
export interface RouteNoAssignedResponse {
|
||||
pdv_id: number;
|
||||
pdv_nombre: string,
|
||||
minimo_horas_semana: number,
|
||||
latitud: number,
|
||||
longitud: number,
|
||||
motivo: string;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import {
|
||||
RouteAssignedResponse,
|
||||
RouteNoAssignedResponse
|
||||
} from '../models/route.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RouteState {
|
||||
|
||||
private loading = new BehaviorSubject<boolean>(false);
|
||||
loading$ = this.loading.asObservable();
|
||||
|
||||
private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>([]);
|
||||
assignedRoutes$ = this.assignedRoutes.asObservable();
|
||||
|
||||
private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>([]);
|
||||
noAssignedRoutes$ = this.noAssignedRoutes.asObservable();
|
||||
|
||||
private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(null);
|
||||
selectedRoute$ = this.selectedRoute.asObservable();
|
||||
|
||||
resetLoading() {
|
||||
this.loading.next(false);
|
||||
}
|
||||
|
||||
setLoading(value: boolean) {
|
||||
this.loading.next(value);
|
||||
}
|
||||
|
||||
setAssignedRoutes(data: RouteAssignedResponse[]) {
|
||||
this.assignedRoutes.next(data);
|
||||
}
|
||||
|
||||
setNoAssignedRoutes(data: RouteNoAssignedResponse[]) {
|
||||
this.noAssignedRoutes.next(data);
|
||||
}
|
||||
|
||||
sendRoute(route: RouteAssignedResponse) {
|
||||
this.selectedRoute.next(route);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {
|
||||
from,
|
||||
lastValueFrom,
|
||||
mergeMap,
|
||||
of,
|
||||
delay,
|
||||
catchError,
|
||||
map,
|
||||
toArray,
|
||||
timeout
|
||||
} from 'rxjs';
|
||||
|
||||
const ENDPOINT = 'http://localhost:18080/ors/v2/directions/driving-car';
|
||||
const ENDPOINTJSON = 'http://localhost:18080/ors/v2/directions/driving-car/geojson';
|
||||
const MAX_CONCURRENT_REQUESTS = 2;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class OrsService {
|
||||
|
||||
constructor(private http: HttpClient) { }
|
||||
|
||||
private fetchRoute(
|
||||
origin: number[],
|
||||
destination: number[],
|
||||
isJson = false
|
||||
) {
|
||||
const body = {
|
||||
coordinates: [origin, destination]
|
||||
};
|
||||
|
||||
return this.http.post<any>(isJson ? ENDPOINTJSON : ENDPOINT, body).pipe(
|
||||
timeout(8000),
|
||||
catchError(error => {
|
||||
throw error;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async processPairs(
|
||||
pares: {
|
||||
pdv_id: number;
|
||||
origin: number[];
|
||||
destination: number[];
|
||||
}[],
|
||||
concurrency = MAX_CONCURRENT_REQUESTS,
|
||||
isJson = false
|
||||
): Promise<any[]> {
|
||||
return lastValueFrom(
|
||||
from(pares).pipe(
|
||||
mergeMap(
|
||||
pair =>
|
||||
this.fetchRoute(pair.origin, pair.destination, isJson).pipe(
|
||||
delay(300),
|
||||
map(data => {
|
||||
if (!isJson) {
|
||||
const summary = data.routes[0].summary;
|
||||
return {
|
||||
pdv_id: pair.pdv_id,
|
||||
distance: summary.distance,
|
||||
duration: summary.duration,
|
||||
raw: data
|
||||
};
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
catchError(error =>
|
||||
of({
|
||||
pdv_id: pair.pdv_id,
|
||||
error: error.message || 'Error ORS'
|
||||
})
|
||||
)
|
||||
),
|
||||
concurrency
|
||||
),
|
||||
toArray()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { ApiResponse } from '../../core/models/route.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RutasService {
|
||||
|
||||
constructor(private http: HttpClient) { }
|
||||
|
||||
calcularRutas(data: FormData) {
|
||||
return this.http.post<ApiResponse>(
|
||||
'https://agenteit.digitalcompass.agency/webhook/calcular-camino',
|
||||
data
|
||||
);
|
||||
}
|
||||
}
|
||||
+198
@@ -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;
|
||||
}
|
||||
+41
@@ -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>
|
||||
+243
@@ -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' }
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
.login-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(160deg, #4F758B 0%, #5B7F95 40%, #6CC24A 100%);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 40px 36px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
border-top: 4px solid var(--glm-verde);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.glm-logo {
|
||||
height: 70px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
color: var(--glm-azul);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--glm-acero);
|
||||
font-size: 14px;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.glm-brand-text {
|
||||
margin: 0 0 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--glm-verde);
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--glm-verde);
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--glm-azul);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-family: Arial, sans-serif;
|
||||
color: var(--glm-gris-oscuro);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-group input::placeholder {
|
||||
color: var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--glm-verde);
|
||||
box-shadow: 0 0 0 3px rgba(108, 194, 74, 0.15);
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 11px;
|
||||
background: var(--glm-azul);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button[type="submit"]:hover:not(:disabled) {
|
||||
background: var(--glm-verde);
|
||||
}
|
||||
|
||||
button[type="submit"]:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
font-size: 12px;
|
||||
color: var(--glm-gris-medio);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
|
||||
<div class="logo">
|
||||
<img src="GLM_completo.png" alt="GomezLee Marketing" class="glm-logo" />
|
||||
</div>
|
||||
|
||||
<p class="glm-brand-text">GOMEZLEE MARKETING</p>
|
||||
|
||||
<p class="subtitle">
|
||||
Planifica rutas inteligentes de forma eficiente
|
||||
</p>
|
||||
|
||||
<form (ngSubmit)="login()" #form="ngForm">
|
||||
|
||||
<div class="input-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
[(ngModel)]="email"
|
||||
required
|
||||
placeholder="usuario@empresa.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
[(ngModel)]="password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" [disabled]="loading || form.invalid">
|
||||
<span *ngIf="!loading">Iniciar sesión</span>
|
||||
<span *ngIf="loading">Verificando ruta...</span>
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
<div class="footer">
|
||||
© 2026 Route Planner System
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-login',
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './login.component.html',
|
||||
styleUrls: ['./login.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class LoginComponent {
|
||||
|
||||
email = '';
|
||||
password = '';
|
||||
loading = false;
|
||||
|
||||
constructor(private router: Router) {}
|
||||
login() {
|
||||
this.loading = true;
|
||||
|
||||
setTimeout(() => {
|
||||
this.loading = false;
|
||||
this.router.navigate(['/home']);
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './remove-special-character.utils';
|
||||
@@ -0,0 +1,6 @@
|
||||
export function removeSpecialCharacter(text: string): string {
|
||||
return text
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-zA-Z0-9\s]/g, '');
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>GLM Route Planner</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🗺️</text></svg>">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { providePrimeNG } from 'primeng/config';
|
||||
import Aura from '@primeng/themes/aura';
|
||||
|
||||
import { App } from './app/app';
|
||||
import { routes } from './app/app.routes';
|
||||
|
||||
bootstrapApplication(App, {
|
||||
providers: [
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
providePrimeNG({
|
||||
theme: {
|
||||
preset: Aura
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
@import "primeicons/primeicons.css";
|
||||
|
||||
:root {
|
||||
--glm-verde: #6CC24A;
|
||||
--glm-verde-claro: #A4D65E;
|
||||
--glm-lima: #C4D600;
|
||||
--glm-azul: #4F758B;
|
||||
--glm-azul-medio: #5B7F95;
|
||||
--glm-acero: #6B8FA3;
|
||||
--glm-naranja: #FF6A13;
|
||||
--glm-gris-oscuro: #4A4A4A;
|
||||
--glm-gris-medio: #D0D0D0;
|
||||
--glm-gris-claro: #F5F5F5;
|
||||
--glm-verde-bg: #EEF6E8;
|
||||
--glm-azul-seccion: #D6E8F4;
|
||||
--glm-verde-ok: #E8F5E9;
|
||||
--glm-rojo-ok: #FDECEA;
|
||||
--glm-input-bg: #FFF3CD;
|
||||
|
||||
--glm-primary: #6CC24A;
|
||||
--glm-primary-hover: #5AB33D;
|
||||
--glm-surface: #FFFFFF;
|
||||
--glm-surface-ground: #F5F5F5;
|
||||
--glm-surface-card: #FFFFFF;
|
||||
--glm-surface-border: #D0D0D0;
|
||||
--glm-surface-hover: #F0F0F0;
|
||||
--glm-text-color: #4A4A4A;
|
||||
--glm-text-secondary: #6B8FA3;
|
||||
--glm-text-muted: #D0D0D0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Arial, "Helvetica Neue", sans-serif;
|
||||
background-color: var(--glm-gris-claro);
|
||||
color: var(--glm-text-color);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* PrimeNG theme overrides */
|
||||
:root {
|
||||
--primary-color: #6CC24A;
|
||||
--primary-color-text: #ffffff;
|
||||
--surface-ground: #F5F5F5;
|
||||
--surface-card: #FFFFFF;
|
||||
--surface-border: #D0D0D0;
|
||||
--surface-hover: #F0F0F0;
|
||||
--text-color: #4A4A4A;
|
||||
--text-color-secondary: #6B8FA3;
|
||||
--green-50: #EEF6E8;
|
||||
--green-200: #A4D65E;
|
||||
--green-600: #6CC24A;
|
||||
--green-700: #5AB33D;
|
||||
--red-50: #FDECEA;
|
||||
--red-200: #F5A6A0;
|
||||
--red-700: #D32F2F;
|
||||
--primary-color-rgb: 108, 194, 74;
|
||||
--primary-color-translucent: rgba(108, 194, 74, 0.2);
|
||||
}
|
||||
|
||||
/* PrimeNG DataTable GLM overrides */
|
||||
.p-datatable .p-datatable-thead > tr > th {
|
||||
background: var(--glm-azul) !important;
|
||||
color: #ffffff !important;
|
||||
font-family: Arial, sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
border-color: #3d6175 !important;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.p-datatable .p-datatable-tbody > tr:nth-child(even) {
|
||||
background: var(--glm-gris-claro) !important;
|
||||
}
|
||||
|
||||
.p-datatable .p-datatable-tbody > tr:nth-child(odd) {
|
||||
background: var(--glm-surface) !important;
|
||||
}
|
||||
|
||||
.p-datatable .p-datatable-tbody > tr > td {
|
||||
font-family: Arial, sans-serif;
|
||||
color: var(--glm-gris-oscuro);
|
||||
font-size: 0.85rem;
|
||||
border-color: var(--glm-gris-medio);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.p-datatable .p-datatable-tbody > tr:hover {
|
||||
background: var(--glm-verde-bg) !important;
|
||||
}
|
||||
|
||||
.p-datatable .p-paginator {
|
||||
background: var(--glm-surface);
|
||||
border-color: var(--glm-gris-medio);
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.p-datatable .p-column-filter-input {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.p-inputtext {
|
||||
font-family: Arial, sans-serif;
|
||||
border-color: var(--glm-gris-medio);
|
||||
}
|
||||
|
||||
.p-inputtext:enabled:focus {
|
||||
border-color: var(--glm-verde);
|
||||
box-shadow: 0 0 0 2px var(--glm-primary-translucent);
|
||||
}
|
||||
|
||||
.p-datatable .p-sortable-column-icon {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.p-datatable .p-datatable-caption {
|
||||
background: var(--glm-verde-bg);
|
||||
border: 1px solid var(--glm-verde-claro);
|
||||
border-bottom: none;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": ["src/main.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/out-tsc",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": ["ES2022", "dom"],
|
||||
"useDefineForClassFields": false
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user