7 Commits

75 changed files with 3188 additions and 1005 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+141
View File
@@ -0,0 +1,141 @@
{
"name": "Generar reporte de rutas PDF",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "generar-rutas-pdf",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [-600, 260],
"id": "e91ef4ce-4c71-4a7b-91d7-1a4f1c2db015",
"name": "Webhook: Generate PDF"
},
{
"parameters": {
"jsCode": "const payload = $json.body && typeof $json.body === 'object' ? $json.body : $json;\nconst errors = [];\nif (!payload?.project?.id || !payload?.project?.name) errors.push('Se requiere la información del proyecto.');\nif (!Array.isArray(payload?.assignedRoutes)) errors.push('assignedRoutes debe ser una lista.');\nif (!Array.isArray(payload?.unassignedPdvs)) errors.push('unassignedPdvs debe ser una lista.');\nif ((payload?.assignedRoutes?.length ?? 0) + (payload?.unassignedPdvs?.length ?? 0) > 5000) errors.push('El reporte excede el máximo de 5,000 registros.');\nif (errors.length) throw new Error(errors.join(' '));\nreturn [{ json: { payload } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-360, 260],
"id": "abbd2d6e-5c04-4bbb-ae1b-e4f0d2e4574e",
"name": "Validate Report Payload",
"onError": "continueErrorOutput"
},
{
"parameters": {
"jsCode": "const payload = $json.payload;\nconst escape = value => String(value ?? '').replace(/[&<>\"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[char]));\nconst filters = Object.entries(payload.filters ?? {}).filter(([, value]) => value).map(([key, value]) => `<span>${escape(key)}: ${escape(value)}</span>`).join('');\nconst routeRows = payload.assignedRoutes.map(route => `<tr><td>${escape(route.usuario)}</td><td>${escape(route.dia)}</td><td>${escape(route.orden)}</td><td>${escape(route.pdv)}</td><td>${escape(route.origen)}</td><td>${escape(route.distanciaKm)} km</td><td>${escape(route.horasTrabajo)} h</td></tr>`).join('') || '<tr><td colspan=\"7\">Sin rutas asignadas para los filtros seleccionados.</td></tr>';\nconst unassignedRows = payload.unassignedPdvs.map(route => `<tr><td>${escape(route.pdv_id)}</td><td>${escape(route.pdv_nombre)}</td><td>${escape(route.minimo_horas_semana)} h</td><td>${escape(route.motivo)}</td><td>${escape(route.recomendacion)}</td></tr>`).join('') || '<tr><td colspan=\"5\">No hay PDVs sin asignar para los filtros seleccionados.</td></tr>';\nconst summary = payload.summary ?? {};\nconst html = `<!doctype html><html><head><meta charset=\"utf-8\"><style>body{font-family:Arial,sans-serif;color:#243746;margin:28px;font-size:11px}h1{color:#4F758B;margin:0}h2{color:#4F758B;border-bottom:2px solid #6CC24A;padding-bottom:6px;margin-top:28px}p{line-height:1.45}.meta{color:#61798a}.filters span{display:inline-block;background:#eef6e8;border:1px solid #a4d65e;border-radius:3px;margin:3px 5px 3px 0;padding:4px 6px}.summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:15px 0}.metric{border:1px solid #d0d0d0;border-left:3px solid #6CC24A;padding:8px}.metric b{display:block;color:#4F758B;font-size:16px}table{width:100%;border-collapse:collapse;margin-top:10px}th{background:#4F758B;color:#fff;text-align:left;padding:7px}td{border-bottom:1px solid #e5e5e5;padding:7px;vertical-align:top}.footer{margin-top:24px;color:#6b8fa3;font-size:9px}</style></head><body><h1>Reporte de rutas</h1><p class=\"meta\"><b>${escape(payload.project.name)}</b><br>${escape(payload.project.country)}${payload.project.description ? `<br>${escape(payload.project.description)}` : ''}<br>Generado: ${escape(payload.generatedAt)}</p>${filters ? `<div class=\"filters\"><b>Filtros aplicados:</b><br>${filters}</div>` : '<p><b>Filtros aplicados:</b> Ninguno</p>'}<div class=\"summary\"><div class=\"metric\"><b>${escape(summary.totalPdvs)}</b>PDVs totales</div><div class=\"metric\"><b>${escape(summary.assignedPdvs)}</b>PDVs asignados</div><div class=\"metric\"><b>${escape(summary.unassignedPdvs)}</b>PDVs sin asignar</div><div class=\"metric\"><b>${escape(summary.coveragePercent)}%</b>Cobertura</div><div class=\"metric\"><b>${escape(summary.totalDistanceKm)} km</b>Distancia</div><div class=\"metric\"><b>${escape(summary.totalWorkHours)} h</b>Trabajo</div></div><h2>Rutas asignadas</h2><table><thead><tr><th>Usuario</th><th>Día</th><th>Orden</th><th>PDV</th><th>Origen</th><th>Distancia</th><th>Trabajo</th></tr></thead><tbody>${routeRows}</tbody></table><h2>PDVs sin asignar</h2><table><thead><tr><th>PDV ID</th><th>PDV</th><th>Horas/semana</th><th>Motivo</th><th>Recomendación</th></tr></thead><tbody>${unassignedRows}</tbody></table><p class=\"footer\">Route Planner · GomezLee Marketing</p></body></html>`;\nreturn [{ json: { fileName: `rutas_${String(payload.project.name).replace(/[^a-z0-9]+/gi, '_').toLowerCase()}.pdf` }, binary: { data: { data: Buffer.from(html).toString('base64'), mimeType: 'text/html', fileName: 'index.html' } } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-100, 260],
"id": "9258c394-683f-4569-8ced-af2337ec8dba",
"name": "Build Report HTML",
"onError": "continueErrorOutput",
"notes": "Genera tablas deterministas; no usa IA para alterar cifras, rutas ni recomendaciones."
},
{
"parameters": {
"method": "POST",
"url": "http://host.docker.internal:3000/forms/chromium/convert/html",
"authentication": "none",
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"parameterType": "formBinaryData",
"name": "files",
"inputDataFieldName": "data"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "file",
"outputPropertyName": "data"
}
},
"timeout": 120000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [180, 260],
"id": "73e2759c-d9f9-49dc-925f-3ad1ee7b3897",
"name": "Convert HTML to PDF",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000,
"onError": "continueErrorOutput",
"notes": "Gotenberg debe estar disponible en host.docker.internal:3000 desde el contenedor n8n."
},
{
"parameters": {
"respondWith": "binary",
"responseBinaryPropertyName": "data",
"options": {
"responseHeaders": {
"entries": [
{
"name": "Content-Disposition",
"value": "attachment; filename=\"reporte_rutas.pdf\""
}
]
}
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [460, 200],
"id": "93928c3e-1e9c-4f77-a644-c156fae907a5",
"name": "Respond PDF"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { error: 'validation_error', message: $json.error?.message || $json.message || 'El reporte no contiene datos válidos.' } }}",
"options": {
"responseCode": 400
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [-100, 460],
"id": "c8b69f14-45d0-4f55-9f7c-2e3fb3425f14",
"name": "Respond Validation Error"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { error: 'pdf_generation_error', message: 'No se pudo generar el PDF de rutas.', detail: $json.error?.message || $json.message || 'Gotenberg no está disponible.' } }}",
"options": {
"responseCode": 502
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [460, 430],
"id": "c8d6e363-80b6-46ca-a8c6-32ce393207cc",
"name": "Respond PDF Error"
}
],
"connections": {
"Webhook: Generate PDF": { "main": [[{ "node": "Validate Report Payload", "type": "main", "index": 0 }]] },
"Validate Report Payload": { "main": [[{ "node": "Build Report HTML", "type": "main", "index": 0 }], [{ "node": "Respond Validation Error", "type": "main", "index": 0 }]] },
"Build Report HTML": { "main": [[{ "node": "Convert HTML to PDF", "type": "main", "index": 0 }], [{ "node": "Respond Validation Error", "type": "main", "index": 0 }]] },
"Convert HTML to PDF": { "main": [[{ "node": "Respond PDF", "type": "main", "index": 0 }], [{ "node": "Respond PDF Error", "type": "main", "index": 0 }]] }
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"meta": {
"templateCredsSetupCompleted": true
},
"tags": []
}
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
# OpenRouteService por país
- República Dominicana: `http://localhost:18080/ors/v2`
- México: `http://localhost:18081/ors/v2`
Cada país mantiene su propio archivo OSM, configuración y directorio de grafos. Desde contenedores Docker, como n8n, usa `host.docker.internal` en lugar de `localhost`.
El workflow selecciona el servicio según la columna `País` del archivo PDV. `MEXICO` usa `18081`; cualquier proyecto anterior sin país se mantiene en República Dominicana y usa `18080`.
La imagen ORS configura memoria con `XMS`, `XMX` y `ADDITIONAL_JAVA_OPTS`; no se debe sobrescribir `JAVA_OPTS`, porque el entrypoint de la imagen lo reconstruye.
```powershell
docker compose -f ors-app/republica-dominicana/docker-compose.yml up -d
docker compose -f ors-app/mexico/docker-compose.yml up -d
```
+16
View File
@@ -0,0 +1,16 @@
services:
ors:
image: openrouteservice/openrouteservice:latest
container_name: ors-mexico
ports:
- "18081:8082"
volumes:
- ./ors-docker/mexico-latest.osm.pbf:/home/ors/files/mexico-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:
- XMS=4g
- XMX=12g
- ADDITIONAL_JAVA_OPTS=-XX:MaxGCPauseMillis=300 -XX:InitiatingHeapOccupancyPercent=75
restart: unless-stopped
@@ -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/mexico-latest.osm.pbf
elevation: false
service:
maximum_distance: 500000
profiles:
driving-car:
enabled: true
@@ -1,7 +1,7 @@
services: services:
ors: ors:
image: openrouteservice/openrouteservice:latest image: openrouteservice/openrouteservice:latest
container_name: ors-route-planner container_name: ors-republica-dominicana
ports: ports:
- "18080:8082" - "18080:8082"
volumes: volumes:
@@ -10,5 +10,7 @@ services:
- ./ors-docker/elevation_cache:/home/ors/elevation_cache - ./ors-docker/elevation_cache:/home/ors/elevation_cache
- ./ors-docker/config/ors-config.yml:/home/ors/config/ors-config.yml - ./ors-docker/config/ors-config.yml:/home/ors/config/ors-config.yml
environment: 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 - XMS=1g
- XMX=4g
- ADDITIONAL_JAVA_OPTS=-XX:MaxGCPauseMillis=200 -XX:InitiatingHeapOccupancyPercent=80
restart: unless-stopped restart: unless-stopped
+7
View File
@@ -0,0 +1,7 @@
services:
gotenberg:
image: gotenberg/gotenberg:8
container_name: route-planner-pdf
ports:
- "3000:3000"
restart: unless-stopped
+102
View File
@@ -15,6 +15,7 @@
"@angular/platform-browser": "^21.1.0", "@angular/platform-browser": "^21.1.0",
"@angular/router": "^21.1.0", "@angular/router": "^21.1.0",
"@primeng/themes": "^21.0.0", "@primeng/themes": "^21.0.0",
"@supabase/supabase-js": "^2.115.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0", "leaflet.heat": "^0.2.0",
@@ -4037,6 +4038,98 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@supabase/auth-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.115.0.tgz",
"integrity": "sha512-YNQlQWm1H0gsXHSY8Jd/xepBhjO0Zhwx04iW17A83/joQ5kFiUin6iPj9s9kZZvupnwLjXVP/diTFiLz2jUbwQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.115.0.tgz",
"integrity": "sha512-p97V6/YFcdp+zblFDVJaE8f9rGKTNz0PRzyJ2d1w/EYIU5lwidKuc3l/wM+u27ACmAC62albvoGiUGzLPSg7Aw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
"integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.115.0.tgz",
"integrity": "sha512-DdERcurLh5t84pgSywDg2LjLR5le7XAzl52/iQhC7FdboWDqGfDPMfaWJV3MHvhyxSlSNzbpSQG+RiQOBSn/4w==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.115.0.tgz",
"integrity": "sha512-5HyBkvlA/IUV2v8jX3uLTdy15jmTPRVXwXKoxvH2SKw5jZMNURxCxt+VpCEukscXQlY7pUpH8Cy4NH6io4iaRw==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "0.4.5",
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.115.0.tgz",
"integrity": "sha512-dLyIxzbO+MCcKHhcce8rVUCQX1iyqXqQ8ytgkOVYJ7D+Zp0qKylPtQH3hamgxrGSxtDjaw47Urpzw2iK9PsKdA==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.115.0.tgz",
"integrity": "sha512-PYJSxtCo37R7tTZW6pAqsxUeSx/dlhA7zn8RzKEUSCqyTxCUhG+iHrDTb04UyVBYbttaUbhwkNTvb8h5HW+uZA==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.115.0",
"@supabase/functions-js": "2.115.0",
"@supabase/postgrest-js": "2.115.0",
"@supabase/realtime-js": "2.115.0",
"@supabase/storage-js": "2.115.0"
},
"engines": {
"node": ">=22.0.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@tufjs/canonical-json": { "node_modules/@tufjs/canonical-json": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
@@ -5888,6 +5981,15 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.7.3", "version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+1
View File
@@ -17,6 +17,7 @@
"@angular/platform-browser": "^21.1.0", "@angular/platform-browser": "^21.1.0",
"@angular/router": "^21.1.0", "@angular/router": "^21.1.0",
"@primeng/themes": "^21.0.0", "@primeng/themes": "^21.0.0",
"@supabase/supabase-js": "^2.115.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0", "leaflet.heat": "^0.2.0",
+14 -3
View File
@@ -3,12 +3,23 @@ import { LoginComponent } from './public/login/login.component';
import { HomeComponent } from './private/home/home.component'; import { HomeComponent } from './private/home/home.component';
import { AssignedRouteDetailComponent } from './private/home/assigned-route-detail/assigned-route-detail.component'; import { AssignedRouteDetailComponent } from './private/home/assigned-route-detail/assigned-route-detail.component';
import { RouteOverviewMapComponent } from './private/home/route-overview-map/route-overview-map.component'; import { RouteOverviewMapComponent } from './private/home/route-overview-map/route-overview-map.component';
import { authGuard } from './core/services/auth.guard';
import { EmployeesComponent } from './private/employees/employees.component';
import { ProjectListComponent } from './private/projects/project-list.component';
import { NewProjectComponent } from './private/projects/new-project.component';
import { PdvsComponent } from './private/pdvs/pdvs.component';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', redirectTo: 'login', pathMatch: 'full' }, { path: '', redirectTo: 'login', pathMatch: 'full' },
{ path: 'login', component: LoginComponent }, { path: 'login', component: LoginComponent },
{ path: 'home', component: HomeComponent, pathMatch: 'full' }, { path: 'home', component: ProjectListComponent, pathMatch: 'full', canActivate: [authGuard] },
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent }, { path: 'proyectos/nuevo', component: NewProjectComponent, canActivate: [authGuard] },
{ path: 'home/mapa', component: RouteOverviewMapComponent }, { path: 'proyectos/:id', component: HomeComponent, pathMatch: 'full', canActivate: [authGuard] },
{ path: 'proyectos/:id/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] },
{ path: 'proyectos/:id/ruta', component: AssignedRouteDetailComponent, canActivate: [authGuard] },
{ path: 'proyectos/:id/empleados', component: EmployeesComponent, canActivate: [authGuard] },
{ path: 'proyectos/:id/pdvs', component: PdvsComponent, canActivate: [authGuard] },
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent, canActivate: [authGuard] },
{ path: 'home/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] },
{ path: '**', redirectTo: 'login' } { path: '**', redirectTo: 'login' }
]; ];
@@ -0,0 +1,22 @@
export interface Employee {
id: string;
proyecto_id: string;
employee_code: string;
nombre: string;
usuario: string | null;
email: string | null;
perfil: string | null;
pais: string | null;
regional: string | null;
latitud: number;
longitud: number;
horas_por_dia: number;
dias_por_semana: number;
jornada_codigo: string;
horas_descanso_por_dia: number;
dias_descanso: string[];
visitas_maximas_por_dia: number;
activo: boolean;
}
export type EmployeeInput = Omit<Employee, 'id' | 'proyecto_id'>;
@@ -0,0 +1,18 @@
export interface RuteroProyecto {
id: string;
nombre: string;
descripcion: string | null;
pais: string | null;
propietario_id: string;
estado: 'optimizado' | 'archivado' | null;
version: number;
created_at: string;
updated_at: string;
}
export interface RuteroProyectoPdv {
id: string;
proyecto_id: string;
pdv_id: string;
pdv_data: Record<string, unknown>;
}
@@ -27,6 +27,24 @@ export interface RouteAssignedJson {
usuario_id: string; usuario_id: string;
pdv_id: number; pdv_id: number;
visit_order?: number; visit_order?: number;
geojson?: unknown;
horas_por_dia_usuario?: number;
horas_descanso_por_dia?: number;
jornada_codigo?: string;
cobertura?: string;
pais?: string;
ciudad?: string;
estado?: string;
provincia?: string;
region?: string;
zona?: string;
metro?: string;
pais_usuario?: string;
ciudad_usuario?: string;
estado_usuario?: string;
provincia_usuario?: string;
region_usuario?: string;
zona_usuario?: string;
} }
export interface RouteNoAssignedResponse { export interface RouteNoAssignedResponse {
pdv_id: number; pdv_id: number;
@@ -35,4 +53,12 @@ export interface RouteAssignedJson {
latitud: number, latitud: number,
longitud: number, longitud: number,
motivo: string; motivo: string;
recomendacion?: string;
pais?: string;
ciudad?: string;
estado?: string;
provincia?: string;
region?: string;
zona?: string;
metro?: string;
} }
@@ -0,0 +1,4 @@
export interface UserProfile {
nombre: string;
pais: string;
}
@@ -0,0 +1,17 @@
export interface WorkSchedule {
code: 'LUN_DOM_44' | 'LUN_SAB_40' | 'LUN_SAB_44' | 'LUN_VIE_40';
label: string;
weeklyHours: number;
days: string[];
}
export const WORK_SCHEDULES: WorkSchedule[] = [
{ code: 'LUN_DOM_44', label: 'Lun a Dom - 44h', weeklyHours: 44, days: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado', 'Domingo'] },
{ code: 'LUN_SAB_40', label: 'Lun a Sáb - 40h', weeklyHours: 40, days: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'] },
{ code: 'LUN_SAB_44', label: 'Lun a Sáb - 44h', weeklyHours: 44, days: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado'] },
{ code: 'LUN_VIE_40', label: 'Lun a Vie - 40h', weeklyHours: 40, days: ['Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes'] }
];
export function workSchedule(code: string): WorkSchedule {
return WORK_SCHEDULES.find(schedule => schedule.code === code) ?? WORK_SCHEDULES[3];
}
+57 -16
View File
@@ -11,7 +11,7 @@ const STORAGE_ASSIGNED_ORIGINAL = 'rp_assigned_routes_original';
const STORAGE_NO_ASSIGNED = 'rp_no_assigned_routes'; const STORAGE_NO_ASSIGNED = 'rp_no_assigned_routes';
const STORAGE_NO_ASSIGNED_ORIGINAL = 'rp_no_assigned_routes_original'; const STORAGE_NO_ASSIGNED_ORIGINAL = 'rp_no_assigned_routes_original';
const STORAGE_SELECTED = 'rp_selected_route'; const STORAGE_SELECTED = 'rp_selected_route';
const DAY_ORDER = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes']; const DAY_ORDER = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes', 'Sábado', 'Sabado'];
export interface UnassignedAssignment { export interface UnassignedAssignment {
userId: string; userId: string;
day: string; day: string;
@@ -21,17 +21,18 @@ export interface UnassignedAssignment {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class RouteState { export class RouteState {
private projectId: string | null = null;
private loading = new BehaviorSubject<boolean>(false); private loading = new BehaviorSubject<boolean>(false);
loading$ = this.loading.asObservable(); loading$ = this.loading.asObservable();
private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>(this.loadAssigned()); private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>([]);
assignedRoutes$ = this.assignedRoutes.asObservable(); assignedRoutes$ = this.assignedRoutes.asObservable();
private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>(this.loadNoAssigned()); private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>([]);
noAssignedRoutes$ = this.noAssignedRoutes.asObservable(); noAssignedRoutes$ = this.noAssignedRoutes.asObservable();
private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(this.loadSelected()); private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(null);
selectedRoute$ = this.selectedRoute.asObservable(); selectedRoute$ = this.selectedRoute.asObservable();
resetLoading() { resetLoading() {
@@ -42,6 +43,15 @@ export class RouteState {
this.loading.next(value); this.loading.next(value);
} }
useProject(projectId: string): void {
if (!projectId || this.projectId === projectId) return;
this.projectId = projectId;
this.assignedRoutes.next(this.loadAssigned());
this.noAssignedRoutes.next(this.loadNoAssigned());
this.selectedRoute.next(this.loadSelected());
this.loading.next(false);
}
setAssignedRoutes(data: RouteAssignedResponse[]) { setAssignedRoutes(data: RouteAssignedResponse[]) {
this.assignedRoutes.next(data); this.assignedRoutes.next(data);
this.persist(STORAGE_ASSIGNED, data); this.persist(STORAGE_ASSIGNED, data);
@@ -65,6 +75,12 @@ export class RouteState {
nombre_usuario: profile.json.nombre_usuario, nombre_usuario: profile.json.nombre_usuario,
latitud_usuario: profile.json.latitud_usuario, latitud_usuario: profile.json.latitud_usuario,
longitud_usuario: profile.json.longitud_usuario, longitud_usuario: profile.json.longitud_usuario,
pais_usuario: profile.json.pais_usuario,
ciudad_usuario: profile.json.ciudad_usuario,
estado_usuario: profile.json.estado_usuario,
provincia_usuario: profile.json.provincia_usuario,
region_usuario: profile.json.region_usuario,
zona_usuario: profile.json.zona_usuario,
hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario, hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario,
dia: day, dia: day,
distancia: metrics?.distance ?? item.json.distancia, distancia: metrics?.distance ?? item.json.distancia,
@@ -98,7 +114,20 @@ export class RouteState {
nombre_usuario: profile.json.nombre_usuario, nombre_usuario: profile.json.nombre_usuario,
nombre_pdv: route.pdv_nombre, nombre_pdv: route.pdv_nombre,
usuario_id: profile.json.usuario_id, usuario_id: profile.json.usuario_id,
pdv_id: route.pdv_id pdv_id: route.pdv_id,
pais: route.pais,
ciudad: route.ciudad,
estado: route.estado,
provincia: route.provincia,
region: route.region,
zona: route.zona,
metro: route.metro,
pais_usuario: profile.json.pais_usuario,
ciudad_usuario: profile.json.ciudad_usuario,
estado_usuario: profile.json.estado_usuario,
provincia_usuario: profile.json.provincia_usuario,
region_usuario: profile.json.region_usuario,
zona_usuario: profile.json.zona_usuario
} } as RouteAssignedResponse; } } as RouteAssignedResponse;
}); });
if (newRoutes.some(item => !item)) return; if (newRoutes.some(item => !item)) return;
@@ -195,6 +224,12 @@ export class RouteState {
nombre_usuario: user.json.nombre_usuario, nombre_usuario: user.json.nombre_usuario,
latitud_usuario: user.json.latitud_usuario, latitud_usuario: user.json.latitud_usuario,
longitud_usuario: user.json.longitud_usuario, longitud_usuario: user.json.longitud_usuario,
pais_usuario: user.json.pais_usuario,
ciudad_usuario: user.json.ciudad_usuario,
estado_usuario: user.json.estado_usuario,
provincia_usuario: user.json.provincia_usuario,
region_usuario: user.json.region_usuario,
zona_usuario: user.json.zona_usuario,
hora_laboral_semanal_usuario: user.json.hora_laboral_semanal_usuario hora_laboral_semanal_usuario: user.json.hora_laboral_semanal_usuario
} }
}; };
@@ -221,13 +256,14 @@ export class RouteState {
return this.noAssignedRoutes.getValue(); return this.noAssignedRoutes.getValue();
} }
findRoute(pdvId: string | number, usuarioId?: string): RouteAssignedResponse | null { findRoute(pdvId: string | number, usuarioId?: string, day?: string): RouteAssignedResponse | null {
const pid = String(pdvId); const pid = String(pdvId);
const routes = this.getAssignedSnapshot(); const routes = this.getAssignedSnapshot();
const found = routes.find(r => { const found = routes.find(r => {
const jp = r.json; const jp = r.json;
if (String(jp.pdv_id) !== pid) return false; if (String(jp.pdv_id) !== pid) return false;
if (usuarioId && String(jp.usuario_id) !== String(usuarioId)) return false; if (usuarioId && String(jp.usuario_id) !== String(usuarioId)) return false;
if (day && jp.dia !== day) return false;
return true; return true;
}); });
return found ?? null; return found ?? null;
@@ -257,17 +293,18 @@ export class RouteState {
this.noAssignedRoutes.next([]); this.noAssignedRoutes.next([]);
this.selectedRoute.next(null); this.selectedRoute.next(null);
try { try {
sessionStorage.removeItem(STORAGE_ASSIGNED); sessionStorage.removeItem(this.storageKey(STORAGE_ASSIGNED));
sessionStorage.removeItem(STORAGE_ASSIGNED_ORIGINAL); sessionStorage.removeItem(this.storageKey(STORAGE_ASSIGNED_ORIGINAL));
sessionStorage.removeItem(STORAGE_NO_ASSIGNED); sessionStorage.removeItem(this.storageKey(STORAGE_NO_ASSIGNED));
sessionStorage.removeItem(STORAGE_NO_ASSIGNED_ORIGINAL); sessionStorage.removeItem(this.storageKey(STORAGE_NO_ASSIGNED_ORIGINAL));
sessionStorage.removeItem(STORAGE_SELECTED); sessionStorage.removeItem(this.storageKey(STORAGE_SELECTED));
} catch { /* ignore */ } } catch { /* ignore */ }
} }
private persist(key: string, data: unknown) { private persist(key: string, data: unknown) {
if (!this.projectId) return;
try { try {
sessionStorage.setItem(key, JSON.stringify(data)); sessionStorage.setItem(this.storageKey(key), JSON.stringify(data));
} catch { /* ignore storage errors */ } } catch { /* ignore storage errors */ }
} }
@@ -283,7 +320,7 @@ export class RouteState {
private loadAssigned(): RouteAssignedResponse[] { private loadAssigned(): RouteAssignedResponse[] {
try { try {
const raw = sessionStorage.getItem(STORAGE_ASSIGNED); const raw = sessionStorage.getItem(this.storageKey(STORAGE_ASSIGNED));
if (raw) return JSON.parse(raw) as RouteAssignedResponse[]; if (raw) return JSON.parse(raw) as RouteAssignedResponse[];
} catch { /* ignore */ } } catch { /* ignore */ }
return []; return [];
@@ -291,7 +328,7 @@ export class RouteState {
private loadOriginalAssigned(): RouteAssignedResponse[] { private loadOriginalAssigned(): RouteAssignedResponse[] {
try { try {
const raw = sessionStorage.getItem(STORAGE_ASSIGNED_ORIGINAL); const raw = sessionStorage.getItem(this.storageKey(STORAGE_ASSIGNED_ORIGINAL));
if (raw) return JSON.parse(raw) as RouteAssignedResponse[]; if (raw) return JSON.parse(raw) as RouteAssignedResponse[];
} catch { /* ignore */ } } catch { /* ignore */ }
return this.loadAssigned(); return this.loadAssigned();
@@ -299,7 +336,7 @@ export class RouteState {
private loadNoAssigned(): RouteNoAssignedResponse[] { private loadNoAssigned(): RouteNoAssignedResponse[] {
try { try {
const raw = sessionStorage.getItem(STORAGE_NO_ASSIGNED); const raw = sessionStorage.getItem(this.storageKey(STORAGE_NO_ASSIGNED));
if (raw) return JSON.parse(raw) as RouteNoAssignedResponse[]; if (raw) return JSON.parse(raw) as RouteNoAssignedResponse[];
} catch { /* ignore */ } } catch { /* ignore */ }
return []; return [];
@@ -307,9 +344,13 @@ export class RouteState {
private loadSelected(): RouteAssignedResponse | null { private loadSelected(): RouteAssignedResponse | null {
try { try {
const raw = sessionStorage.getItem(STORAGE_SELECTED); const raw = sessionStorage.getItem(this.storageKey(STORAGE_SELECTED));
if (raw) return JSON.parse(raw) as RouteAssignedResponse; if (raw) return JSON.parse(raw) as RouteAssignedResponse;
} catch { /* ignore */ } } catch { /* ignore */ }
return null; return null;
} }
private storageKey(key: string): string {
return this.projectId ? `${key}:${this.projectId}` : key;
}
} }
@@ -0,0 +1,9 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = async () => {
const auth = inject(AuthService);
const router = inject(Router);
return await auth.getSession() ? true : router.parseUrl('/login');
};
@@ -0,0 +1,42 @@
import { Injectable } from '@angular/core';
import { Session } from '@supabase/supabase-js';
import { supabaseClient } from './supabase.client';
import { UserProfileService } from './user-profile.service';
const CORPORATE_DOMAIN = '@gomezleemarketing.com';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly client = supabaseClient;
constructor(private profile: UserProfileService) {}
async signIn(email: string, password: string): Promise<string | null> {
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail.endsWith(CORPORATE_DOMAIN)) {
return `Solo se permiten correos ${CORPORATE_DOMAIN}.`;
}
const { error } = await this.client.auth.signInWithPassword({ email: normalizedEmail, password });
return error?.message ?? null;
}
async signInWithGoogle(): Promise<string | null> {
const { error } = await this.client.auth.signInWithOAuth({
provider: 'google',
// Return to login so the OAuth callback can load the profile from n8n
// before private routes are opened.
options: { redirectTo: `${window.location.origin}/login` }
});
return error?.message ?? null;
}
async getSession(): Promise<Session | null> {
const { data } = await this.client.auth.getSession();
const email = data.session?.user.email?.toLowerCase() ?? '';
return email.endsWith(CORPORATE_DOMAIN) ? data.session : null;
}
async signOut(): Promise<void> {
await this.client.auth.signOut();
this.profile.clear();
}
}
@@ -0,0 +1,82 @@
import { Injectable } from '@angular/core';
import { Employee, EmployeeInput } from '../models/employee.model';
import { workSchedule } from '../models/work-schedule.model';
import * as XLSX from 'xlsx';
import { supabaseClient } from './supabase.client';
@Injectable({ providedIn: 'root' })
export class EmployeeService {
private readonly client = supabaseClient;
private readonly table = 'rutero_proyecto_empleados';
async list(projectId: string): Promise<Employee[]> {
const { data: sessionData, error: sessionError } = await this.client.auth.getSession();
if (sessionError) throw sessionError;
if (!sessionData.session) throw new Error('No hay una sesión Supabase activa. Cierra sesión e inicia sesión nuevamente.');
const { data, error } = await this.client.from(this.table).select('*').eq('proyecto_id', projectId).order('nombre');
if (error) throw new Error(`Supabase ${error.code ?? 'error'}: ${error.message}`);
return (data ?? []).map(row => ({
...row,
jornada_codigo: row.jornada_codigo ?? 'LUN_VIE_40',
horas_descanso_por_dia: Number(row.horas_descanso_por_dia ?? 1),
dias_descanso: Array.isArray(row.dias_descanso) ? row.dias_descanso : []
})) as Employee[];
}
async create(projectId: string, employee: EmployeeInput): Promise<void> {
const { error } = await this.client.from(this.table).insert({ ...employee, proyecto_id: projectId });
if (error) throw error;
}
async upsert(projectId: string, employees: EmployeeInput[]): Promise<void> {
const rows = employees.map(employee => ({ ...employee, proyecto_id: projectId }));
const { error } = await this.client.from(this.table).upsert(rows, { onConflict: 'proyecto_id,employee_code' });
if (error) throw error;
}
async update(id: string, employee: EmployeeInput): Promise<void> {
const { error } = await this.client.from(this.table).update(employee).eq('id', id);
if (error) throw error;
}
async remove(id: string): Promise<void> {
const { error } = await this.client.from(this.table).delete().eq('id', id);
if (error) throw error;
}
async active(projectId: string, country?: string): Promise<Employee[]> {
return (await this.list(projectId)).filter(employee => employee.activo && (!country || this.sameCountry(employee.pais, country)));
}
async activeEmployeesFile(projectId: string, country?: string): Promise<File> {
const employees = await this.active(projectId, country);
const rows = employees.map(employee => ({
ID: employee.employee_code,
'Nombre del empleado': employee.nombre,
'País': employee.pais,
Regional: employee.regional,
Latitud: employee.latitud,
Longitud: employee.longitud,
'Horas por día usuario': employee.horas_por_dia,
'Horas por semana usuario': workSchedule(employee.jornada_codigo).weeklyHours,
'Visitas maximas por dia': employee.visitas_maximas_por_dia,
Jornada: employee.jornada_codigo,
'Horas descanso por día': employee.horas_descanso_por_dia,
'Días descanso': (employee.dias_descanso ?? []).join(', '),
'Usuario activo': 'Sí'
}));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), 'Usuarios');
const data = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
return new File([data], 'empleados_activos.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}
private sameCountry(employeeCountry: string | null, projectCountry: string): boolean {
const normalize = (value: string | null) => String(value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z]/gi, '').toLowerCase();
const employee = normalize(employeeCountry);
const project = normalize(projectCountry);
if (employee === project) return true;
return (employee === 'mexico' && project === 'mexico') || (employee === 'republicadominicana' && project === 'republicadominicana');
}
}
@@ -16,8 +16,6 @@ import {
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
const ENDPOINT = `${environment.orsBaseUrl}/directions/driving-car`;
const ENDPOINTJSON = `${environment.orsBaseUrl}/directions/driving-car/geojson`;
const MAX_CONCURRENT_REQUESTS = 2; const MAX_CONCURRENT_REQUESTS = 2;
@Injectable({ @Injectable({
@@ -41,8 +39,12 @@ export class OrsService {
const body = { const body = {
coordinates: [origin, destination] coordinates: [origin, destination]
}; };
const baseUrl = this.isMexico(origin) || this.isMexico(destination)
? environment.orsMexicoBaseUrl
: environment.orsBaseUrl;
const endpoint = `${baseUrl}/directions/driving-car${isJson ? '/geojson' : ''}`;
return this.http.post<unknown>(isJson ? ENDPOINTJSON : ENDPOINT, body).pipe( return this.http.post<unknown>(endpoint, body).pipe(
timeout(8000), timeout(8000),
catchError(error => { catchError(error => {
throw error; throw error;
@@ -50,6 +52,11 @@ export class OrsService {
); );
} }
private isMexico(coordinates: number[]): boolean {
const [longitude, latitude] = coordinates;
return latitude >= 14 && latitude <= 33 && longitude >= -119 && longitude <= -86;
}
async processPairs( async processPairs(
pares: { pares: {
pdv_id: number; pdv_id: number;
@@ -0,0 +1,57 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { RouteAssignedResponse, RouteNoAssignedResponse } from '../models/route.model';
import { normalizeRow } from '../../shared/utils/header-normalization.utils';
export interface PlanningFilters {
cobertura: string;
zona: string;
region: string;
estado: string;
ciudad: string;
}
export type PlanningFilterKey = keyof PlanningFilters;
@Injectable({ providedIn: 'root' })
export class PlanningFilterService {
private readonly empty: PlanningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
private readonly filtersSubject = new BehaviorSubject<PlanningFilters>({ ...this.empty });
readonly filters$ = this.filtersSubject.asObservable();
private metadata = new Map<string, Record<string, unknown>>();
setPdvs(rows: Record<string, unknown>[]): void {
this.metadata = new Map(rows.map((row, index) => {
const normalized = normalizeRow(row);
return [String(normalized['id'] ?? index + 1), normalized];
}));
this.clear();
}
set(filters: PlanningFilters): void { this.filtersSubject.next({ ...filters }); }
clear(): void { this.filtersSubject.next({ ...this.empty }); }
snapshot(): PlanningFilters { return this.filtersSubject.value; }
options(key: PlanningFilterKey): string[] {
const values = [...this.metadata.values()].map(row => String(row[key] ?? '').trim()).filter(Boolean);
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
}
filterAssigned(routes: RouteAssignedResponse[]): RouteAssignedResponse[] {
return routes.filter(route => this.matches(String(route.json.pdv_id), route.json as unknown as Record<string, unknown>));
}
filterUnassigned(routes: RouteNoAssignedResponse[]): RouteNoAssignedResponse[] {
return routes.filter(route => this.matches(String(route.pdv_id), route as unknown as Record<string, unknown>));
}
private matches(id: string, fallback: Record<string, unknown>): boolean {
const metadata = this.metadata.get(id) ?? {};
const filters = this.filtersSubject.value;
return (Object.keys(filters) as PlanningFilterKey[]).every(key => {
const selected = filters[key];
if (!selected) return true;
return String(metadata[key] ?? fallback[key] ?? '').trim() === selected;
});
}
}
@@ -2,23 +2,27 @@ import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PlanningInputService { export class PlanningInputService {
private projectId: string | null = null;
private pdvFile: File | null = null; private pdvFile: File | null = null;
private usuariosFile: File | null = null; private usuariosFile: File | null = null;
setFiles(pdvFile: File, usuariosFile: File): void { setFiles(projectId: string, pdvFile: File, usuariosFile: File): void {
this.projectId = projectId;
this.pdvFile = pdvFile; this.pdvFile = pdvFile;
this.usuariosFile = usuariosFile; this.usuariosFile = usuariosFile;
} }
getFormData(): FormData | null { getFormData(projectId: string): FormData | null {
if (!this.pdvFile || !this.usuariosFile) return null; if (this.projectId !== projectId || !this.pdvFile || !this.usuariosFile) return null;
const data = new FormData(); const data = new FormData();
data.append('pdv_file', this.pdvFile, this.pdvFile.name); data.append('pdv_file', this.pdvFile, this.pdvFile.name);
data.append('usuarios_file', this.usuariosFile, this.usuariosFile.name); data.append('usuarios_file', this.usuariosFile, this.usuariosFile.name);
return data; return data;
} }
clear(): void { clear(projectId?: string): void {
if (projectId && this.projectId !== projectId) return;
this.projectId = null;
this.pdvFile = null; this.pdvFile = null;
this.usuariosFile = null; this.usuariosFile = null;
} }
@@ -0,0 +1,115 @@
import { Injectable } from '@angular/core';
import * as XLSX from 'xlsx';
import { RuteroProyecto, RuteroProyectoPdv } from '../models/project.model';
import { supabaseClient } from './supabase.client';
@Injectable({ providedIn: 'root' })
export class ProjectService {
async list(): Promise<RuteroProyecto[]> {
const { data, error } = await supabaseClient.from('rutero_proyectos').select('*').order('updated_at', { ascending: false });
if (error) throw error;
return (data ?? []) as RuteroProyecto[];
}
async get(id: string): Promise<RuteroProyecto> {
const { data, error } = await supabaseClient.from('rutero_proyectos').select('*').eq('id', id).single();
if (error) throw error;
return data as RuteroProyecto;
}
async update(id: string, changes: Partial<Pick<RuteroProyecto, 'nombre' | 'descripcion'>>): Promise<void> {
const { error } = await supabaseClient.from('rutero_proyectos').update(changes).eq('id', id);
if (error) throw error;
}
async remove(id: string): Promise<void> {
const dependentTables = ['rutero_proyecto_rutas', 'rutero_proyecto_empleados', 'rutero_proyecto_pdvs'];
for (const table of dependentTables) {
const { error } = await supabaseClient.from(table).delete().eq('proyecto_id', id);
if (error) throw error;
}
const { error } = await supabaseClient.from('rutero_proyectos').delete().eq('id', id);
if (error) throw error;
}
async create(nombre: string, descripcion: string, pais: string): Promise<RuteroProyecto> {
const { data: sessionData, error: sessionError } = await supabaseClient.auth.getSession();
if (sessionError) throw sessionError;
const ownerId = sessionData.session?.user.id;
if (!ownerId) throw new Error('No hay una sesión activa.');
const projectId = crypto.randomUUID();
const now = new Date().toISOString();
const project: RuteroProyecto = {
id: projectId,
nombre: nombre.trim(),
descripcion: descripcion.trim() || null,
pais: pais.trim(),
propietario_id: ownerId,
estado: null,
version: 1,
created_at: now,
updated_at: now
};
const { error } = await supabaseClient
.from('rutero_proyectos')
.insert({ id: projectId, nombre: project.nombre, descripcion: project.descripcion, pais: project.pais, propietario_id: ownerId, estado: null });
if (error) throw error;
return project;
}
async pdvs(id: string): Promise<RuteroProyectoPdv[]> {
const { data, error } = await supabaseClient.from('rutero_proyecto_pdvs').select('*').eq('proyecto_id', id).order('created_at');
if (error) throw error;
return (data ?? []) as RuteroProyectoPdv[];
}
async upsertPdvs(projectId: string, rows: Record<string, unknown>[]): Promise<void> {
const pdvs = rows.map((pdv, index) => ({
proyecto_id: projectId,
pdv_id: String(pdv['ID'] ?? pdv['id'] ?? pdv['Id'] ?? index + 1),
pdv_data: pdv
}));
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').upsert(pdvs, { onConflict: 'proyecto_id,pdv_id' });
if (error) throw error;
}
async addPdv(projectId: string, pdv: Record<string, unknown>): Promise<void> {
const pdvId = String(pdv['ID'] ?? '').trim();
if (!pdvId) throw new Error('El ID del PDV es obligatorio.');
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').insert({ proyecto_id: projectId, pdv_id: pdvId, pdv_data: pdv });
if (error) throw error;
}
async updatePdv(id: string, pdv: Record<string, unknown>): Promise<void> {
const pdvId = String(pdv['ID'] ?? '').trim();
if (!pdvId) throw new Error('El ID del PDV es obligatorio.');
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').update({ pdv_id: pdvId, pdv_data: pdv }).eq('id', id);
if (error) throw error;
}
async removePdv(id: string): Promise<void> {
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').delete().eq('id', id);
if (error) throw error;
}
async projectInput(id: string): Promise<{ file: File; country: string; rows: Record<string, unknown>[] }> {
const [project, pdvs] = await Promise.all([this.get(id), this.pdvs(id)]);
const rows = pdvs.map(pdv => pdv.pdv_data);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), 'PDVs');
const data = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
const country = rows.map(row => Object.entries(row).find(([key]) => {
const normalized = key.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
return normalized === 'pais' || normalized === 'country';
})?.[1]).find(value => String(value ?? '').trim());
return {
file: new File([data], 'pdvs_proyecto.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
country: String(country ?? project.pais ?? 'Republica Dominicana').trim(),
rows
};
}
async pdvFile(id: string): Promise<File> {
return (await this.projectInput(id)).file;
}
}
@@ -17,7 +17,7 @@ export class RutasService {
timeout(environment.requestTimeoutMs), timeout(environment.requestTimeoutMs),
catchError((err: HttpErrorResponse | Error) => { catchError((err: HttpErrorResponse | Error) => {
if (err instanceof Error && err.name === 'TimeoutError') { if (err instanceof Error && err.name === 'TimeoutError') {
return throwError(() => Object.assign(new Error('Timeout al calcular rutas (30s). Intenta con menos filas o reintenta.'), { name: 'TimeoutError' })); return throwError(() => Object.assign(new Error(`Timeout al calcular rutas (${environment.requestTimeoutMs / 1000}s). Intenta con menos filas o reintenta.`), { name: 'TimeoutError' }));
} }
return throwError(() => err); return throwError(() => err);
}) })
@@ -0,0 +1,6 @@
import { createClient } from '@supabase/supabase-js';
import { environment } from '../../../environments/environment';
export const supabaseClient = createClient(environment.supabaseUrl, environment.supabaseAnonKey, {
auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true }
});
@@ -0,0 +1,48 @@
import { Injectable } from '@angular/core';
import { environment } from '../../../environments/environment';
import { UserProfile } from '../models/user-profile.model';
@Injectable({ providedIn: 'root' })
export class UserProfileService {
private readonly storageKey = 'rp_user_profile';
private profile: UserProfile | null = null;
avatarUrl = '';
constructor() { this.restore(); }
get current(): UserProfile | null { return this.profile; }
clear(): void {
this.profile = null;
this.avatarUrl = '';
sessionStorage.removeItem(this.storageKey);
}
async load(email: string, avatarUrl = ''): Promise<UserProfile> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(environment.userProfileWebhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), signal: controller.signal });
if (!response.ok) throw new Error(`El webhook respondió con ${response.status}.`);
const data = await response.json() as Partial<UserProfile>;
if (!data.nombre || !data.pais) throw new Error('El webhook no devolvió nombre y país.');
this.profile = { nombre: data.nombre, pais: data.pais };
this.avatarUrl = avatarUrl;
sessionStorage.setItem(this.storageKey, JSON.stringify({ ...this.profile, avatarUrl }));
return this.profile;
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') throw new Error('La consulta de datos del usuario tardó demasiado.');
throw error;
} finally { window.clearTimeout(timeout); }
}
private restore(): void {
try {
const stored = sessionStorage.getItem(this.storageKey);
if (!stored) return;
const data = JSON.parse(stored) as Partial<UserProfile> & { avatarUrl?: string };
if (!data.nombre || !data.pais) return;
this.profile = { nombre: data.nombre, pais: data.pais };
this.avatarUrl = data.avatarUrl ?? '';
} catch {
sessionStorage.removeItem(this.storageKey);
}
}
}
@@ -0,0 +1,23 @@
.employees-topbar { display: flex; align-items: center; gap: 12px; padding: 9px 24px; border-bottom: 3px solid var(--glm-verde); background: #fff; color: var(--glm-azul); font-weight: 700; }
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
.employees-topbar img { height: 38px; width: auto; }
.topbar-actions { display: flex; gap: 8px; margin-left: auto; }
.topbar-actions button, .table-header button, .form-actions button, .row-actions button { border: 1px solid var(--glm-gris-medio); border-radius: 4px; background: #fff; color: var(--glm-azul); cursor: pointer; font: inherit; font-size: .78rem; font-weight: 600; padding: 7px 10px; }
.topbar-actions button:hover, .table-header button:hover, .form-actions button:hover, .row-actions button:hover { background: var(--glm-verde-bg); border-color: var(--glm-verde); }
.employees-page { max-width: 1200px; margin: 0 auto; padding: 24px; }
.employees-page header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; border-bottom: 3px solid var(--glm-verde); padding-bottom: 12px; }
.page-back-button { display:inline-flex; align-items:center; gap:6px; margin-bottom:18px; padding:8px 11px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:700 .82rem Arial,sans-serif; }.page-back-button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }
.employees-page h1, .employees-page h2 { margin: 0; color: var(--glm-azul); }
.employees-page h1 { font-size: 1.45rem; }.employees-page h2 { font-size: 1rem; }
.employees-page header p, .weekly-capacity { color: var(--glm-acero); }
.import-button { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 1px solid var(--glm-verde); border-radius: 4px; background: var(--glm-verde-bg); color: var(--glm-azul); cursor: pointer; font-size: .82rem; font-weight: 700; }.import-button input { display: none; }.import-message { color: var(--glm-verde); font-weight: 600; }
.employee-form-card, .employee-table-card { margin-top: 16px; padding: 18px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: #fff; }
.employee-form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 16px; }
.employee-form-grid label { display: flex; flex-direction: column; gap: 4px; color: var(--glm-azul); font-size: .75rem; font-weight: 700; text-transform: uppercase; }
.employee-form-grid input, .employee-form-grid select { padding: 8px; border: 1px solid #9aaab3; border-radius: 4px; background: #fff; color: #2f3f48; font: inherit; font-weight: 500; text-transform: none; }
.employee-form-grid input::placeholder { color: #71808a; opacity: 1; }
.employee-form-grid input:focus, .employee-form-grid select:focus { outline: 2px solid rgba(108, 194, 74, .35); border-color: var(--glm-verde); background: #fff; color: #1f2d34; }
.form-actions { display: flex; gap: 8px; }.form-actions button:first-child { background: var(--glm-azul); color: #fff; }.form-error { color: #c9252d; font-size: .82rem; }.table-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }.table-wrap { overflow-x: auto; margin-top: 12px; }table { width: 100%; min-width: 920px; border-collapse: collapse; font-size: .82rem; }th { padding: 9px; background: var(--glm-azul); color: #fff; text-align: left; }td { padding: 9px; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); }.status { color: var(--glm-verde); font-weight: 700; }.status.inactive { color: #c9252d; }.row-actions { display: flex; gap: 5px; }.row-actions .delete { color: #c9252d; }.loading { color: var(--glm-acero); }.rest-days-button { max-width: 155px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.rest-days-picker { display: grid; gap: 4px; margin-top: 6px; padding: 6px; border: 1px solid var(--glm-gris-medio); background: #fff; }.rest-days-picker label { font-size: .72rem; white-space: nowrap; }
.editing-row { background: var(--glm-verde-bg); }.editing-row select, .inline-number { width: 100%; min-width: 92px; box-sizing: border-box; padding: 7px; border: 1px solid var(--glm-verde); border-radius: 4px; font: inherit; }.inline-number { max-width: 100px; }.inline-picker { min-width: 130px; margin: 0; }.editing-row .row-actions { flex-wrap: wrap; }
.table-header > div { display:flex; align-items:center; gap:8px; }.selected-count { color:var(--glm-azul); font-size:.78rem; font-weight:700; }.select-column { width:38px; }.bulk-actions { display:flex; flex-wrap:wrap; align-items:end; gap:10px; margin:12px 0; padding:12px; border:1px solid var(--glm-verde-claro); background:var(--glm-verde-bg); }.bulk-actions > label,.bulk-actions fieldset { display:grid; gap:4px; margin:0; color:var(--glm-azul); font-size:.72rem; font-weight:700; text-transform:uppercase; }.bulk-actions input,.bulk-actions select { min-height:32px; padding:5px 7px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; font:inherit; }.bulk-actions fieldset { grid-template-columns:repeat(2,max-content); gap:5px 8px; padding:6px 8px; text-transform:none; }.bulk-actions legend { padding:0 3px; text-transform:uppercase; }.bulk-actions fieldset label { font-size:.72rem; font-weight:400; }.bulk-actions fieldset input { min-height:0; padding:0; }.bulk-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:7px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.bulk-actions button:hover:not(:disabled) { background:#fff; border-color:var(--glm-verde); }.bulk-actions button.delete { color:#c9252d; }
@media (max-width: 760px) { .employees-page { padding: 16px; }.employee-form-grid { grid-template-columns: 1fr; }.employees-topbar { padding: 9px 16px; }.topbar-actions button { font-size: 0; }.topbar-actions button i { font-size: .9rem; } }
@@ -0,0 +1,12 @@
<div class="employees-topbar">
<img src="GLM_completo.png" alt="GomezLee Marketing" />
<span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div>
<div class="topbar-actions"><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
</div>
<main class="employees-page">
<button type="button" class="page-back-button" (click)="goHome()"><i class="pi pi-arrow-left"></i> Volver</button>
<header><div><h1>Gestionar empleados</h1><p>{{ activeEmployees }} activos de {{ employees.length }} empleados registrados</p></div><label class="import-button"><i class="pi pi-file-import"></i> {{ importing ? 'Importando...' : 'Importar empleados' }}<input type="file" accept=".xlsx,.xls" (change)="importFile($event)" [disabled]="importing"></label></header>
<p class="import-message" *ngIf="importMessage">{{ importMessage }}</p>
<section class="employee-form-card"><h2>Nuevo empleado</h2><form (ngSubmit)="save()" #employeeForm="ngForm"><div class="employee-form-grid"><label>ID<input name="employeeCode" [(ngModel)]="form.employee_code" required></label><label>Nombre<input name="nombre" [(ngModel)]="form.nombre" required></label><label>Usuario<input name="usuario" [(ngModel)]="form.usuario"></label><label>Email<input type="email" name="email" [(ngModel)]="form.email"></label><label>Perfil<input name="perfil" [(ngModel)]="form.perfil"></label><label>Regional<input name="regional" [(ngModel)]="form.regional"></label><label>País<input name="pais" [(ngModel)]="form.pais"></label><label>Estado<select name="activo" [(ngModel)]="form.activo"><option [ngValue]="true">Activo</option><option [ngValue]="false">Inactivo</option></select></label><label>Latitud<input type="number" name="latitud" [(ngModel)]="form.latitud" step="any" required></label><label>Longitud<input type="number" name="longitud" [(ngModel)]="form.longitud" step="any" required></label><label>Horas por día<input type="number" name="hoursDay" [(ngModel)]="form.horas_por_dia" min="0.5" max="24" step="0.5" required></label><label>Jornada de trabajo<select name="schedule" [(ngModel)]="form.jornada_codigo"><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></label><label>Horas de descanso/día<input type="number" name="dailyRest" [(ngModel)]="form.horas_descanso_por_dia" min="0" max="3" step="1" required></label><label>Visitas máximas/día<input type="number" name="visitsDay" [(ngModel)]="form.visitas_maximas_por_dia" min="1" required></label></div><p class="weekly-capacity">Capacidad semanal: {{ selectedScheduleHours }} h · Capacidad diaria efectiva: {{ form.horas_por_dia - form.horas_descanso_por_dia | number:'1.0-1' }} h</p><p class="form-error" *ngIf="error">{{ error }}</p><div class="form-actions"><button type="submit" [disabled]="saving || employeeForm.invalid"><i class="pi pi-save"></i> {{ saving ? 'Guardando...' : 'Agregar empleado' }}</button></div></form></section>
<section class="employee-table-card"><div class="table-header"><h2>Empleados registrados</h2><div><span class="selected-count" *ngIf="selectedIds.size">{{ selectedIds.size }} seleccionados</span><button type="button" (click)="toggleAll(!allSelected)" [disabled]="loading">{{ allSelected ? 'Deseleccionar todos' : 'Seleccionar todos' }}</button><button type="button" (click)="load()" [disabled]="loading"><i class="pi pi-refresh"></i> Actualizar</button></div></div><p class="form-error" *ngIf="error && !loading">{{ error }}</p><div class="bulk-actions" *ngIf="selectedIds.size"><label>Jornada<select [(ngModel)]="bulkSchedule"><option value="">Sin cambio</option><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></label><label>Descanso/día<input type="number" min="0" max="3" step="1" [(ngModel)]="bulkRestHours" placeholder="Sin cambio"></label><label>Visitas/día<input type="number" min="1" step="1" [(ngModel)]="bulkVisits" placeholder="Sin cambio"></label><fieldset><legend>Días de descanso</legend><label *ngFor="let day of ['Lunes','Martes','Miercoles','Jueves','Viernes','Sabado','Domingo']"><input type="checkbox" [checked]="bulkRestDays.has(day)" (change)="toggleBulkRestDay(day, $any($event.target).checked)">{{ day }}</label></fieldset><button type="button" (click)="applyBulkEmployeeChanges()" [disabled]="bulkSaving">Aplicar cambios</button><button type="button" (click)="setSelectedActive(true)" [disabled]="bulkSaving">Activar</button><button type="button" (click)="setSelectedActive(false)" [disabled]="bulkSaving">Desactivar</button><button type="button" class="delete" (click)="removeSelected()" [disabled]="bulkSaving">Eliminar seleccionados</button></div><p class="loading" *ngIf="loading">Cargando empleados...</p><div class="table-wrap" *ngIf="!loading"><table><thead><tr><th class="select-column"><input type="checkbox" [checked]="allSelected" (change)="toggleAll($any($event.target).checked)" aria-label="Seleccionar todos los empleados"></th><th>ID</th><th>Nombre</th><th>Regional</th><th>Jornada</th><th>Descanso/día</th><th>Días de descanso</th><th>Visitas/día</th><th>Estado</th><th>Acciones</th></tr></thead><tbody><tr *ngFor="let employee of employees" [class.editing-row]="editingId === employee.id"><td><input type="checkbox" [checked]="selectedIds.has(employee.id)" (change)="toggleSelection(employee.id, $any($event.target).checked)" [attr.aria-label]="'Seleccionar ' + employee.nombre"></td><td>{{ employee.employee_code }}</td><td>{{ employee.nombre }}</td><td>{{ employee.regional || '-' }}</td><td><ng-container *ngIf="editingId === employee.id; else scheduleText"><select [(ngModel)]="employee.jornada_codigo" [name]="'schedule-' + employee.id" (ngModelChange)="onInlineScheduleChange(employee)"><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></ng-container><ng-template #scheduleText>{{ scheduleLabel(employee) }}</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else restText"><input class="inline-number" type="number" [(ngModel)]="employee.horas_descanso_por_dia" [name]="'rest-' + employee.id" min="0" max="3" step="1"></ng-container><ng-template #restText>{{ employee.horas_descanso_por_dia }} h</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else daysText"><div class="rest-days-picker inline-picker"><label *ngFor="let day of availableRestDays(employee)"><input type="checkbox" [checked]="employee.dias_descanso.includes(day)" (change)="toggleRestDay(employee, day, $any($event.target).checked)">{{ day }}</label></div></ng-container><ng-template #daysText>{{ employee.dias_descanso.length ? employee.dias_descanso.join(', ') : 'Ninguno' }}</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else visitsText"><input class="inline-number" type="number" [(ngModel)]="employee.visitas_maximas_por_dia" [name]="'visits-' + employee.id" min="1" step="1"></ng-container><ng-template #visitsText>{{ employee.visitas_maximas_por_dia }}</ng-template></td><td><span class="status" [class.inactive]="!employee.activo">{{ employee.activo ? 'Activo' : 'Inactivo' }}</span></td><td class="row-actions"><ng-container *ngIf="editingId === employee.id; else normalActions"><button type="button" (click)="saveInline(employee)" [disabled]="saving">Guardar</button><button type="button" (click)="cancelEdit()">Cancelar</button></ng-container><ng-template #normalActions><button type="button" (click)="edit(employee)">Editar</button><button type="button" (click)="toggleActive(employee)">{{ employee.activo ? 'Desactivar' : 'Activar' }}</button><button type="button" class="delete" (click)="remove(employee)">Eliminar</button></ng-template></td></tr><tr *ngIf="!employees.length"><td colspan="10">No hay empleados registrados.</td></tr></tbody></table></div></section>
</main>
@@ -0,0 +1,394 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Location } from '@angular/common';
import { Employee, EmployeeInput } from '../../core/models/employee.model';
import { EmployeeService } from '../../core/services/employee.service';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
import { WORK_SCHEDULES, workSchedule } from '../../core/models/work-schedule.model';
import { normalizeRow, cleanNum } from '../../shared/utils/header-normalization.utils';
import * as XLSX from 'xlsx';
type EmployeeForm = Omit<EmployeeInput, 'activo'> & { activo: boolean };
@Component({
standalone: true,
selector: 'app-employees',
imports: [CommonModule, FormsModule],
templateUrl: './employees.component.html',
styleUrls: ['./employees.component.css']
})
export class EmployeesComponent implements OnDestroy, OnInit {
employees: Employee[] = [];
form = this.emptyForm();
editingId: string | null = null;
loading = true;
saving = false;
error = '';
importMessage = '';
importing = false;
openRestDaysFor: string | null = null;
readonly schedules = WORK_SCHEDULES;
selectedIds = new Set<string>();
bulkSchedule = '';
bulkRestHours: number | null = null;
bulkVisits: number | null = null;
bulkRestDays = new Set<string>();
bulkSaving = false;
private destroyed = false;
private loadSequence = 0;
projectId = '';
constructor(
private employeesService: EmployeeService,
private auth: AuthService,
private router: Router,
private activatedRoute: ActivatedRoute,
private location: Location,
public profile: UserProfileService,
private cdr: ChangeDetectorRef
) {}
ngOnInit(): void {
this.projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? '';
if (!this.projectId) {
void this.router.navigate(['/home']);
return;
}
void this.load();
}
ngOnDestroy(): void {
this.destroyed = true;
this.loadSequence++;
}
get activeEmployees(): number {
return this.employees.filter(employee => employee.activo).length;
}
get selectedScheduleHours(): number {
return workSchedule(this.form.jornada_codigo).weeklyHours;
}
get allSelected(): boolean {
return this.employees.length > 0 && this.employees.every(employee => this.selectedIds.has(employee.id));
}
get selectedEmployees(): Employee[] {
return this.employees.filter(employee => this.selectedIds.has(employee.id));
}
async load(): Promise<void> {
const sequence = ++this.loadSequence;
this.loading = true;
this.error = '';
try {
const employees = await this.employeesService.list(this.projectId);
if (this.destroyed || sequence !== this.loadSequence) return;
this.employees = employees;
this.selectedIds.clear();
this.cdr.detectChanges();
} catch (error: any) {
if (!this.destroyed && sequence === this.loadSequence) {
this.error = error?.message ?? 'No se pudieron cargar los empleados.';
}
} finally {
this.loading = false;
if (!this.destroyed && sequence === this.loadSequence) this.cdr.detectChanges();
}
}
edit(employee: Employee): void {
this.editingId = employee.id;
}
cancelEdit(): void {
this.editingId = null;
this.error = '';
void this.load();
}
async saveInline(employee: Employee): Promise<void> {
const rest = Number(employee.horas_descanso_por_dia);
const visits = Number(employee.visitas_maximas_por_dia);
if (!Number.isInteger(rest) || rest < 0 || rest > 3 || !Number.isInteger(visits) || visits < 1) {
this.error = 'Descanso/día debe ser un entero entre 0 y 3, y Visitas/día debe ser un entero mayor que 0.';
return;
}
this.saving = true;
this.error = '';
try {
await this.employeesService.update(employee.id, {
...employee,
dias_por_semana: workSchedule(employee.jornada_codigo).days.length,
horas_descanso_por_dia: rest,
visitas_maximas_por_dia: visits,
dias_descanso: (employee.dias_descanso ?? []).filter(day => workSchedule(employee.jornada_codigo).days.includes(day))
});
this.editingId = null;
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudo actualizar el empleado.';
} finally {
this.saving = false;
}
}
onInlineScheduleChange(employee: Employee): void {
employee.dias_descanso = (employee.dias_descanso ?? []).filter(day => workSchedule(employee.jornada_codigo).days.includes(day));
employee.dias_por_semana = workSchedule(employee.jornada_codigo).days.length;
}
async save(): Promise<void> {
if (!this.form.employee_code.trim() || !this.form.nombre.trim()) {
this.error = 'El ID y el nombre son obligatorios.';
return;
}
if (this.form.horas_descanso_por_dia >= this.form.horas_por_dia) {
this.error = 'Las horas de descanso deben ser menores que las horas de trabajo diarias.';
return;
}
this.saving = true;
this.error = '';
const employee: EmployeeInput = {
...this.form,
dias_por_semana: workSchedule(this.form.jornada_codigo).days.length,
employee_code: this.form.employee_code.trim(),
nombre: this.form.nombre.trim(),
usuario: this.nullable(this.form.usuario),
email: this.nullable(this.form.email),
perfil: this.nullable(this.form.perfil),
pais: this.nullable(this.form.pais),
regional: this.nullable(this.form.regional)
};
try {
if (this.editingId) await this.employeesService.update(this.editingId, employee);
else await this.employeesService.create(this.projectId, employee);
this.cancelEdit();
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudo guardar el empleado.';
} finally {
this.saving = false;
}
}
async toggleActive(employee: Employee): Promise<void> {
try {
await this.employeesService.update(employee.id, { ...employee, activo: !employee.activo });
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudo actualizar el estado.';
}
}
toggleSelection(id: string, checked: boolean): void {
checked ? this.selectedIds.add(id) : this.selectedIds.delete(id);
}
toggleAll(checked: boolean): void {
this.selectedIds = checked ? new Set(this.employees.map(employee => employee.id)) : new Set<string>();
}
toggleBulkRestDay(day: string, checked: boolean): void {
checked ? this.bulkRestDays.add(day) : this.bulkRestDays.delete(day);
}
async applyBulkEmployeeChanges(): Promise<void> {
const selected = this.selectedEmployees;
if (!selected.length) return;
if (!this.bulkSchedule && this.bulkRestHours === null && this.bulkVisits === null && !this.bulkRestDays.size) {
this.error = 'Selecciona al menos un cambio masivo para aplicar.';
return;
}
if (this.bulkRestHours !== null && (!Number.isInteger(this.bulkRestHours) || this.bulkRestHours < 0 || this.bulkRestHours > 3)) {
this.error = 'Descanso/día debe ser un entero entre 0 y 3.';
return;
}
if (this.bulkVisits !== null && (!Number.isInteger(this.bulkVisits) || this.bulkVisits < 1)) {
this.error = 'Visitas/día debe ser un entero mayor que 0.';
return;
}
this.bulkSaving = true;
this.error = '';
try {
await Promise.all(selected.map(employee => {
const schedule = this.bulkSchedule || employee.jornada_codigo;
const restDays = this.bulkRestDays.size
? Array.from(this.bulkRestDays).filter(day => workSchedule(schedule).days.includes(day))
: (this.bulkSchedule ? employee.dias_descanso.filter(day => workSchedule(schedule).days.includes(day)) : employee.dias_descanso);
return this.employeesService.update(employee.id, {
...employee,
jornada_codigo: schedule,
dias_por_semana: workSchedule(schedule).days.length,
dias_descanso: restDays,
horas_descanso_por_dia: this.bulkRestHours ?? employee.horas_descanso_por_dia,
visitas_maximas_por_dia: this.bulkVisits ?? employee.visitas_maximas_por_dia
});
}));
this.importMessage = `${selected.length} empleados actualizados.`;
this.resetBulkFields();
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudieron aplicar los cambios masivos.';
} finally {
this.bulkSaving = false;
}
}
async setSelectedActive(active: boolean): Promise<void> {
const selected = this.selectedEmployees;
if (!selected.length) return;
this.bulkSaving = true;
try {
await Promise.all(selected.map(employee => this.employeesService.update(employee.id, { ...employee, activo: active })));
this.importMessage = `${selected.length} empleados ${active ? 'activados' : 'desactivados'}.`;
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudo actualizar el estado de los empleados.';
} finally {
this.bulkSaving = false;
}
}
async removeSelected(): Promise<void> {
const selected = this.selectedEmployees;
if (!selected.length || !window.confirm(`¿Eliminar ${selected.length} empleados seleccionados? Esta acción no se puede deshacer.`)) return;
this.bulkSaving = true;
try {
await Promise.all(selected.map(employee => this.employeesService.remove(employee.id)));
this.importMessage = `${selected.length} empleados eliminados.`;
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudieron eliminar los empleados.';
} finally {
this.bulkSaving = false;
}
}
scheduleLabel(employee: Employee): string {
return workSchedule(employee.jornada_codigo).label;
}
availableRestDays(employee: Employee): string[] {
return workSchedule(employee.jornada_codigo).days;
}
toggleRestDayEditor(employee: Employee): void {
this.openRestDaysFor = this.openRestDaysFor === employee.id ? null : employee.id;
}
async toggleRestDay(employee: Employee, day: string, checked: boolean): Promise<void> {
const days = new Set(employee.dias_descanso ?? []);
checked ? days.add(day) : days.delete(day);
employee.dias_descanso = Array.from(days);
}
async importFile(event: Event): Promise<void> {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
this.importing = true;
this.importMessage = '';
this.error = '';
try {
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(sheet, { defval: null, raw: true }).map(row => normalizeRow(row as Record<string, unknown>));
const employees = rows.map((row, index) => this.fromExcelRow(row, index + 2));
const invalid = employees.filter((employee): employee is null => employee === null).length;
const valid = employees.filter((employee): employee is EmployeeInput => employee !== null);
if (!valid.length) throw new Error('El archivo no contiene empleados válidos.');
await this.employeesService.upsert(this.projectId, valid);
this.importMessage = `${valid.length} empleados guardados${invalid ? `; ${invalid} filas omitidas por datos inválidos` : ''}.`;
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudo importar el archivo.';
} finally {
this.importing = false;
(event.target as HTMLInputElement).value = '';
}
}
async remove(employee: Employee): Promise<void> {
if (!window.confirm(`¿Eliminar a ${employee.nombre}? Esta acción no se puede deshacer.`)) return;
try {
await this.employeesService.remove(employee.id);
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudo eliminar el empleado.';
}
}
async signOut(): Promise<void> {
this.destroyed = true;
this.loadSequence++;
this.employees = [];
this.loading = true;
await this.auth.signOut();
await this.router.navigate(['/login']);
}
async goHome(): Promise<void> {
await this.router.navigate(['/proyectos', this.projectId]);
}
goBack(): void {
if (history.length > 1) this.location.back();
else void this.router.navigate(['/proyectos', this.projectId]);
}
private nullable(value: string | null): string | null {
return value?.trim() || null;
}
private resetBulkFields(): void {
this.bulkSchedule = '';
this.bulkRestHours = null;
this.bulkVisits = null;
this.bulkRestDays.clear();
}
private emptyForm(): EmployeeForm {
return {
employee_code: '', nombre: '', usuario: '', email: '', perfil: 'Promotor', pais: 'Republica Dominicana', regional: '',
latitud: 18.4861, longitud: -69.9312, horas_por_dia: 8, dias_por_semana: 5, jornada_codigo: 'LUN_VIE_40', horas_descanso_por_dia: 1, dias_descanso: [], visitas_maximas_por_dia: 3, activo: true
};
}
private fromExcelRow(row: Record<string, unknown>, excelRow: number): EmployeeInput | null {
const code = String(row['id'] ?? '').trim();
const nombre = String(row['nombre_del_empleado'] ?? '').trim();
const latitude = cleanNum(row['latitud']);
const longitude = cleanNum(row['longitud']);
const dailyHours = cleanNum(row['horas_por_dia_usuario']);
const weeklyHours = cleanNum(row['horas_por_semana_usuario']);
if (!code || !nombre || !Number.isFinite(latitude) || !Number.isFinite(longitude) || !Number.isFinite(dailyHours)) return null;
const jornadaTexto = String(row['jornada'] ?? '').trim();
const jornadaCodigo = this.schedules.some(schedule => schedule.code === jornadaTexto)
? jornadaTexto
: weeklyHours === 44 ? 'LUN_SAB_44' : 'LUN_VIE_40';
const restDays = String(row['dias_descanso'] ?? '').split(',').map(day => day.trim()).filter(day => workSchedule(jornadaCodigo).days.includes(day));
const active = !['no', 'n', '0', 'false'].includes(String(row['usuario_activo'] ?? 'si').trim().toLowerCase());
return {
employee_code: code,
nombre,
usuario: this.nullable(String(row['usuario'] ?? '')),
email: this.nullable(String(row['email'] ?? '')),
perfil: this.nullable(String(row['perfil_de_acceso'] ?? '')),
pais: this.nullable(String(row['pais'] ?? '')),
regional: this.nullable(String(row['regionales'] ?? row['regional'] ?? '')),
latitud: latitude,
longitud: longitude,
horas_por_dia: dailyHours,
dias_por_semana: workSchedule(jornadaCodigo).days.length,
jornada_codigo: jornadaCodigo,
horas_descanso_por_dia: Math.min(3, Math.max(0, Math.round(cleanNum(row['horas_descanso_por_dia']) || 1))),
dias_descanso: restDays,
visitas_maximas_por_dia: Math.max(1, cleanNum(row['visitas_maximas_por_dia']) || 3),
activo: active
};
}
}
@@ -6,7 +6,7 @@
.detail-sidebar { .detail-sidebar {
position: fixed; position: fixed;
top: 0; top: 0;
right: 0; left: 0;
bottom: 0; bottom: 0;
width: 340px; width: 340px;
z-index: 900; z-index: 900;
@@ -16,7 +16,7 @@
padding: 16px; padding: 16px;
overflow-y: auto; overflow-y: auto;
background: var(--glm-surface); background: var(--glm-surface);
border-left: 3px solid var(--glm-verde); border-right: 3px solid var(--glm-verde);
} }
.sidebar-brand { .sidebar-brand {
@@ -61,6 +61,24 @@
color: var(--glm-gris-oscuro); color: var(--glm-gris-oscuro);
} }
.maps-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border: 1px solid var(--glm-verde);
border-radius: 4px;
background: var(--glm-verde-bg);
color: var(--glm-azul);
font-size: 0.78rem;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
margin-top: 12px;
}
.maps-btn:hover { background: var(--glm-verde); color: #fff; }
.detail-title { .detail-title {
flex: 1; flex: 1;
} }
@@ -206,6 +224,12 @@
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42)); filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
} }
.location-pin:hover {
transform: scale(1.12);
transform-origin: center bottom;
cursor: pointer;
}
.location-pin::before { .location-pin::before {
content: ''; content: '';
position: absolute; position: absolute;
@@ -18,7 +18,6 @@
<span class="badge">{{ routeData.json.dia }}</span> <span class="badge">{{ routeData.json.dia }}</span>
</div> </div>
</div> </div>
<section class="info-card"> <section class="info-card">
<h3>Información de la ruta</h3> <h3>Información de la ruta</h3>
<dl class="info-list"> <dl class="info-list">
@@ -34,10 +33,7 @@
</section> </section>
<section class="info-card"> <section class="info-card">
<h3>Coordenadas</h3> <h3>PDV destino</h3>
<dl class="info-list"> <a *ngIf="googleMapsUrl" class="maps-btn" [href]="googleMapsUrl" target="_blank" rel="noopener noreferrer"><i class="pi pi-map-marker"></i> Ver en Google Maps</a>
<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> </section>
</div> </div>
@@ -7,6 +7,7 @@ import { RouteState } from '../../../core/route-state/route';
import { OrsService } from '../../../core/services/ors.service'; import { OrsService } from '../../../core/services/ors.service';
import { RouteAssignedResponse } from '../../../core/models/route.model'; import { RouteAssignedResponse } from '../../../core/models/route.model';
import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils'; import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils';
import { colorForUsuario } from '../../../shared/utils/palette.utils';
@Component({ @Component({
standalone: true, standalone: true,
@@ -31,6 +32,14 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
visitOrder = 0; visitOrder = 0;
totalStops = 0; totalStops = 0;
get googleMapsUrl(): string | null {
if (!this.routeData) return null;
const route = this.routeData.json;
const origin = `${route.latitud_usuario},${route.longitud_usuario}`;
const destination = `${route.latitud_pdv},${route.longitud_pdv}`;
return `https://www.google.com/maps/dir/?api=1&origin=${encodeURIComponent(origin)}&destination=${encodeURIComponent(destination)}&travelmode=driving`;
}
constructor( constructor(
private router: Router, private router: Router,
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
@@ -52,8 +61,9 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
const qp = this.activatedRoute.snapshot.queryParams; const qp = this.activatedRoute.snapshot.queryParams;
const pdvId = qp['pdv_id']; const pdvId = qp['pdv_id'];
const usuarioId = qp['usuario_id']; const usuarioId = qp['usuario_id'];
const day = qp['dia'];
if (pdvId) { if (pdvId) {
const found = this.routeState.findRoute(pdvId, usuarioId); const found = this.routeState.findRoute(pdvId, usuarioId, day);
if (found) { if (found) {
this.routeData = found; this.routeData = found;
if (this.map) this.loadAndShowRoute(found); if (this.map) this.loadAndShowRoute(found);
@@ -78,7 +88,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
// Re-attempt resolution after map init (queryParams case) // Re-attempt resolution after map init (queryParams case)
const qp = this.activatedRoute.snapshot.queryParams; const qp = this.activatedRoute.snapshot.queryParams;
if (qp['pdv_id']) { if (qp['pdv_id']) {
const found = this.routeState.findRoute(qp['pdv_id'], qp['usuario_id']); const found = this.routeState.findRoute(qp['pdv_id'], qp['usuario_id'], qp['dia']);
if (found) { if (found) {
this.routeData = found; this.routeData = found;
this.error = null; this.error = null;
@@ -99,13 +109,29 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
} }
private initMap() { private initMap() {
this.map = L.map('map').setView([18.4861, -69.9312], 13); const territoryBounds = this.routeData ? L.latLngBounds([
[Number(this.routeData.json.latitud_usuario), Number(this.routeData.json.longitud_usuario)],
[Number(this.routeData.json.latitud_pdv), Number(this.routeData.json.longitud_pdv)]
]) : null;
this.map = L.map('map', { maxBoundsViscosity: 0.85 });
if (territoryBounds?.isValid()) {
this.fitRouteBounds(territoryBounds);
} else {
this.map.setView([18.4861, -69.9312], 13);
}
const pdvPane = this.map.createPane('pdvPane'); const pdvPane = this.map.createPane('pdvPane');
pdvPane.style.zIndex = '650'; pdvPane.style.zIndex = '650';
this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors' attribution: '© OpenStreetMap contributors',
keepBuffer: 1,
noWrap: true,
updateWhenIdle: true
}).addTo(this.map); }).addTo(this.map);
setTimeout(() => {
this.map.invalidateSize();
if (territoryBounds?.isValid()) this.fitRouteBounds(territoryBounds);
});
} }
private async loadAndShowRoute(route: RouteAssignedResponse) { private async loadAndShowRoute(route: RouteAssignedResponse) {
@@ -127,6 +153,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
this.centerMapOnPoints(origin, destination); this.centerMapOnPoints(origin, destination);
if (json.geojson) {
this.showRouteOnMap(json.geojson, origin, destination, json, this.segmentOriginName);
this.isLoading = false;
this.cdr.detectChanges();
return;
}
try { try {
const results = await this.orsService.processPairs( const results = await this.orsService.processPairs(
[{ pdv_id: json.pdv_id, origin, destination }], [{ pdv_id: json.pdv_id, origin, destination }],
@@ -155,7 +188,15 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
L.latLng(origin[1], origin[0]), L.latLng(origin[1], origin[0]),
L.latLng(destination[1], destination[0]) L.latLng(destination[1], destination[0])
]); ]);
this.map.fitBounds(bounds.pad(0.3)); this.fitRouteBounds(bounds);
}
private fitRouteBounds(bounds: L.LatLngBounds): void {
this.map.fitBounds(bounds.pad(0.3), {
paddingTopLeft: L.point(380, 30),
paddingBottomRight: L.point(30, 30),
maxZoom: 15
});
} }
private extractCoords(geojson: unknown): L.LatLngExpression[] { private extractCoords(geojson: unknown): L.LatLngExpression[] {
@@ -248,11 +289,12 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
duration: string, duration: string,
originName: string originName: string
) { ) {
const startColor = colorForUsuario(json.usuario_id).color;
const startIcon = L.divIcon({ const startIcon = L.divIcon({
className: 'custom-marker marker-start', className: 'custom-marker marker-start',
html: '<div class="marker-inner">A</div>', html: `<div class="marker-inner" style="background:${startColor};border:3px solid #fff;">A</div>`,
iconSize: [30, 30], iconSize: [34, 34],
iconAnchor: [15, 15], iconAnchor: [17, 17],
popupAnchor: [0, -18] popupAnchor: [0, -18]
}); });
@@ -270,7 +312,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
.bindPopup(`<strong>Origen del tramo:</strong> ${originName}`), .bindPopup(`<strong>Origen del tramo:</strong> ${originName}`),
L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 }) L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 })
.addTo(this.map) .addTo(this.map)
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`) .bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Usuario:</strong> ${json.nombre_usuario}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
); );
} }
@@ -292,6 +334,6 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
} }
goBack() { goBack() {
this.router.navigate(['/home']); this.router.navigate(['/proyectos', this.activatedRoute.snapshot.paramMap.get('id')]);
} }
} }
@@ -1,99 +0,0 @@
.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;
}
.link-usuario {
color: var(--glm-azul);
font-weight: 600;
cursor: pointer;
text-decoration: underline;
text-decoration-color: transparent;
transition: text-decoration-color 0.15s;
}
.link-usuario:hover {
text-decoration-color: var(--glm-verde);
color: var(--glm-verde);
}
.inline-edit {
width: 100%;
min-width: 100px;
padding: 4px 6px;
border: 1px solid var(--glm-verde);
border-radius: 3px;
background: #fff;
color: var(--glm-gris-oscuro);
font: inherit;
}
.edit-actions {
white-space: nowrap;
}
@@ -1,124 +0,0 @@
<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)="goToOverviewMap()" pTooltip="Ver mapa filtrado por usuario" tooltipPosition="top">
<i class="pi pi-map"></i> Ver mapa
</button>
<button type="button" class="btn-secondary" (click)="downloadCSV()">
<i class="pi pi-download"></i> Descargar CSV
</button>
<button type="button" class="btn-secondary" (click)="reoptimizeLocally()" [disabled]="!canReoptimize" title="Vuelve a ejecutar el optimizador con los archivos cargados">
<span *ngIf="reoptLoading" class="spinner-inline"></span>
<i *ngIf="!reoptLoading" class="pi pi-sync"></i> {{ reoptLoading ? 'Reoptimizando...' : 'Reoptimizar rutas' }}
</button>
<button type="button" class="btn-secondary" *ngIf="hasLocalChanges" (click)="restoreOriginal()">
<i class="pi pi-undo"></i> Deshacer cambios
</button>
<button
type="button"
class="btn-secondary"
(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>
<div class="table-error" *ngIf="reoptError"><i class="pi pi-exclamation-triangle"></i> {{ reoptError }}</div>
<div class="table-error" *ngIf="editError"><i class="pi pi-exclamation-triangle"></i> {{ editError }}</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>
<ng-container *ngIf="editingRoute === row; else userValue">
<select class="inline-edit" [(ngModel)]="editUserId">
<option *ngFor="let user of userOptions" [value]="user.id">{{ user.name }}</option>
</select>
</ng-container>
<ng-template #userValue><a class="link-usuario" (click)="goToUserMap(row.json.usuario_id)" pTooltip="Ver todas las rutas de este usuario" tooltipPosition="top">{{ row.json.nombre_usuario }}</a></ng-template>
</td>
<td>{{ row.json.nombre_pdv }}</td>
<td>{{ row.json.hora_laboral_semanal_usuario }}</td>
<td>{{ row.json.hora_minima_semanal_pdv }}</td>
<td>{{ row.json.horas_trabajo }}</td>
<td>
<ng-container *ngIf="editingRoute === row; else dayValue">
<select class="inline-edit" [(ngModel)]="editDay">
<option *ngFor="let day of dayOptions" [value]="day">{{ day }}</option>
</select>
</ng-container>
<ng-template #dayValue>{{ row.json.dia }}</ng-template>
</td>
<td>{{ row.json.distancia }}</td>
<td>{{ row.json.horas_desplazamiento }}</td>
<td class="text-center edit-actions">
<ng-container *ngIf="editingRoute === row; else editButton">
<button type="button" class="btn-icon" (click)="saveEdit()" title="Guardar"><i class="pi pi-check"></i></button>
<button type="button" class="btn-icon" (click)="cancelEdit()" title="Cancelar"><i class="pi pi-times"></i></button>
</ng-container>
<ng-template #editButton>
<button type="button" class="btn-icon" (click)="startEdit(row)" title="Editar asignación"><i class="pi pi-pencil"></i></button>
</ng-template>
<button
type="button"
class="btn-icon"
(click)="onShowMap(row)"
pTooltip="Ver en mapa (1 ruta)"
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>
@@ -1,237 +0,0 @@
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';
import { RutasService } from '../../../core/services/router.service';
import { PlanningInputService } from '../../../core/services/planning-input.service';
@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[] = [];
editingRoute: RouteAssignedResponse | null = null;
editUserId = '';
editDay = '';
hasLocalChanges = false;
editError: string | null = null;
reoptLoading = false;
reoptError: string | null = null;
constructor(
private routesState: RouteState,
private cdr: ChangeDetectorRef,
private router: Router,
private rutasService: RutasService,
private planningInput: PlanningInputService
) { }
private subs: import('rxjs').Subscription[] = [];
ngOnInit() {
this.routesState.resetLoading();
this.subs.push(
this.routesState.loading$.subscribe(value => {
this.isLoading = value;
this.cdr.detectChanges();
})
);
this.subs.push(
this.routesState.assignedRoutes$.subscribe(data => {
this.assignedRoutes = [...data];
this.cdr.detectChanges();
})
);
this.cols = [
{ field: 'json.nombre_usuario', header: 'Usuario' },
{ field: 'json.nombre_pdv', header: 'PDV' },
{ 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'], {
queryParams: { pdv_id: row.json.pdv_id, usuario_id: row.json.usuario_id, dia: row.json.dia }
});
}
goToOverviewMap() {
// If table has filtered view, use first visible row's usuario as default; else first assigned
const target = (this.table?.filteredValue?.[0] as RouteAssignedResponse | undefined) ?? this.assignedRoutes[0];
if (target) {
this.router.navigate(['/home/mapa'], {
queryParams: { usuario_id: target.json.usuario_id, dia: 'Todos' }
});
} else {
this.router.navigate(['/home/mapa']);
}
}
goToUserMap(usuarioId: string) {
this.router.navigate(['/home/mapa'], { queryParams: { usuario_id: usuarioId, dia: 'Todos' } });
}
get userOptions(): { id: string; name: string }[] {
const users = new Map<string, string>();
for (const route of this.assignedRoutes) users.set(String(route.json.usuario_id), route.json.nombre_usuario);
return Array.from(users, ([id, name]) => ({ id, name }));
}
get dayOptions(): string[] {
return this.routesState.getDiasUnicos();
}
startEdit(route: RouteAssignedResponse): void {
this.editingRoute = route;
this.editUserId = String(route.json.usuario_id);
this.editDay = route.json.dia;
this.editError = null;
}
cancelEdit(): void {
this.editingRoute = null;
}
saveEdit(): void {
if (!this.editingRoute || !this.editUserId || !this.editDay) return;
const capacity = this.routesState.canAssignToDay(
this.editUserId,
this.editDay,
Number(this.editingRoute.json.horas_trabajo || this.editingRoute.json.hora_minima_semanal_pdv || 0),
this.editingRoute
);
if (!capacity.allowed) {
this.editError = `La asignación excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`;
return;
}
this.routesState.updateAssignedRoute(this.editingRoute, this.editUserId, this.editDay);
this.hasLocalChanges = true;
this.editingRoute = null;
}
get canReoptimize(): boolean {
return this.assignedRoutes.length > 0 && !!this.planningInput.getFormData() && !this.reoptLoading;
}
reoptimizeLocally(): void {
const data = this.planningInput.getFormData();
if (!data || !this.assignedRoutes.length || this.reoptLoading) return;
this.reoptLoading = true;
this.reoptError = null;
this.routesState.setLoading(true);
this.rutasService.reoptimizarRutas(data).subscribe({
next: response => {
const items = response as unknown as unknown[];
const assigned = items.find(item => item && typeof item === 'object' && 'assigned' in item) as { assigned?: unknown } | undefined;
const noAssigned = items.find(item => item && typeof item === 'object' && 'noAssigned' in item) as { noAssigned?: unknown } | undefined;
if (assigned?.assigned) this.routesState.setAssignedRoutes(assigned.assigned as never);
if (noAssigned?.noAssigned) this.routesState.setNoAssignedRoutes(noAssigned.noAssigned as never);
this.hasLocalChanges = false;
this.reoptLoading = false;
this.routesState.setLoading(false);
this.cdr.detectChanges();
},
error: err => {
this.reoptError = err?.error?.error || err?.message || 'No se pudo reoptimizar la planificación';
this.reoptLoading = false;
this.routesState.setLoading(false);
this.cdr.detectChanges();
}
});
this.editingRoute = null;
}
restoreOriginal(): void {
this.routesState.restoreOriginalAssignedRoutes();
this.hasLocalChanges = false;
this.editingRoute = null;
}
toggleRutas() {
this.rutasExpandido = !this.rutasExpandido;
}
ngOnDestroy() {
this.subs.forEach(s => s.unsubscribe());
}
downloadCSV() {
const headers = [
'Usuario ID',
'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');
}
}
@@ -31,6 +31,10 @@
color: var(--glm-verde); color: var(--glm-verde);
} }
.projects-link { margin-left: auto; color: var(--glm-azul); font-size: .82rem; text-decoration: none; }
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
.projects-link button,.home-employees-link,.edit-project,.project-edit-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:inherit; font-weight:700; padding:8px 11px; text-decoration:none; }.home-employees-link { display:inline-block; margin-bottom:12px; }.project-heading { width:100%; }.project-title-line { display:flex; align-items:center; gap:14px; }.project-title-line h1 { margin:0; }.back-projects { color:var(--glm-azul); font-size:.85rem; font-weight:700; text-decoration:none; white-space:nowrap; }.edit-project,.project-edit-actions { margin-left:auto; }.project-title-line input,.project-heading textarea { border:1px solid #9aaab3; border-radius:4px; background:#fff; color:#2f3f48; font:inherit; padding:8px; }.project-heading textarea { display:block; width:100%; margin-top:10px; resize:vertical; }.project-edit-actions { display:flex; gap:6px; }.project-edit-actions button:first-child { background:var(--glm-azul); color:#fff; }
.home-container { .home-container {
max-width: 1000px; max-width: 1000px;
margin: 0 auto; margin: 0 auto;
@@ -38,11 +42,37 @@
} }
.home-header { .home-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px; margin-bottom: 24px;
border-bottom: 3px solid var(--glm-verde); border-bottom: 3px solid var(--glm-verde);
padding-bottom: 16px; padding-bottom: 16px;
} }
.employees-link { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 1px solid var(--glm-verde); border-radius: 4px; background: var(--glm-verde-bg); color: var(--glm-azul); font-size: .82rem; font-weight: 700; text-decoration: none; white-space: nowrap; }
.employees-link:hover { background: var(--glm-verde); color: #fff; }
.map-overview-link { margin: -8px 0 16px; }
.project-back-link { display:inline-flex; align-items:center; gap:6px; margin-bottom:18px; }
.project-management-links { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:18px; }
.project-management-links .home-employees-link { margin-bottom:0; }
.dashboard-section-toggle { display:flex; width:100%; align-items:center; justify-content:space-between; gap:12px; padding:0 0 12px; border:0; background:transparent; color:var(--glm-azul); cursor:pointer; font:inherit; font-weight:700; text-align:left; text-transform:uppercase; }
.dashboard-section-toggle span { display:inline-flex; align-items:center; gap:7px; }
.planning-filters-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:10px; align-items:end; min-width:0; }
.planning-filters-grid label { display:grid; min-width:0; gap:5px; color:var(--glm-azul); font-size:.72rem; font-weight:700; text-transform:uppercase; }
.planning-filters-grid select { display:block; width:100%; min-width:0; box-sizing:border-box; padding:8px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-gris-oscuro); }
.planning-filters-grid .btn-secondary { grid-column:1; width:100%; margin-top:5px; }
@media (max-width: 900px) {
.planning-filters-grid { grid-template-columns:repeat(3,minmax(0,1fr)); }
.planning-filters-grid .btn-secondary { grid-column:auto; }
}
@media (max-width: 600px) {
.planning-filters-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
}
.home-header h1 { .home-header h1 {
margin: 0; margin: 0;
color: var(--glm-azul); color: var(--glm-azul);
@@ -79,6 +109,8 @@
padding: 24px; padding: 24px;
} }
.employees-summary { display: flex; align-items: center; gap: 7px; margin: -2px 0 16px; color: var(--glm-acero); font-size: .88rem; }.employees-summary i { color: var(--glm-verde); }.employees-summary.empty, .employees-summary.empty i { color: #c9252d; }
.upload-grid { .upload-grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -1,30 +1,33 @@
<div class="glm-topbar"> <div class="glm-topbar">
<img src="GLM_completo.png" alt="GomezLee Marketing" class="glm-topbar-logo" /> <img src="GLM_completo.png" alt="GomezLee Marketing" class="glm-topbar-logo" />
<span class="glm-topbar-sep">|</span> <span class="glm-topbar-sep">|</span>
<span class="glm-topbar-context">Route Planner</span> <span class="glm-topbar-context">Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="projects-link"><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
</div> </div>
<div class="home-container"> <div class="home-container">
<a class="back-projects project-back-link" routerLink="/home"><i class="pi pi-arrow-left"></i> Mis proyectos</a>
<div class="project-management-links"><a class="employees-link home-employees-link" [routerLink]="['/proyectos', projectId, 'empleados']"><i class="pi pi-users"></i> Gestionar empleados</a><a class="employees-link home-employees-link" [routerLink]="['/proyectos', projectId, 'pdvs']"><i class="pi pi-map-marker"></i> Gestionar PDVs</a></div>
<header class="home-header"> <header class="home-header">
<h1>Route Planner</h1> <div class="project-heading"><div class="project-title-line"><h1 *ngIf="!editingProject">{{ projectName || 'Proyecto' }}</h1><input *ngIf="editingProject" [(ngModel)]="projectName" maxlength="120"><button *ngIf="!editingProject" type="button" class="edit-project" (click)="startProjectEdit()"><i class="pi pi-pencil"></i> Editar proyecto</button><div *ngIf="editingProject" class="project-edit-actions"><button type="button" (click)="saveProject()" [disabled]="projectSaving">Guardar</button><button type="button" (click)="cancelProjectEdit()" [disabled]="projectSaving">Cancelar</button></div></div><textarea *ngIf="editingProject" [(ngModel)]="projectDescription" maxlength="500" placeholder="Descripción del proyecto"></textarea><p *ngIf="!editingProject">Gestión de rutas asignadas y puntos de venta pendientes</p></div>
<p>Gestión de rutas asignadas y puntos de venta pendientes</p>
</header> </header>
<section class="card upload-card"> <section class="card upload-card">
<h2 class="section-title">Subir archivos para optimización</h2> <button type="button" class="dashboard-section-toggle" (click)="toggleInputs()"><span><i class="pi pi-database"></i> Datos para planificación</span><i class="pi" [class.pi-chevron-down]="inputsExpanded" [class.pi-chevron-right]="!inputsExpanded"></i></button>
<div *ngIf="inputsExpanded">
<p class="employees-summary" [class.empty]="!employeesLoading && !activeEmployees"><i class="pi pi-users"></i> <span *ngIf="employeesLoading">Cargando empleados activos...</span><span *ngIf="!employeesLoading">{{ activeEmployees }} empleados activos de este proyecto se usarán para planificar.</span></p>
<div class="upload-grid"> <div class="upload-grid">
<div class="file-input-wrapper"> <div class="file-input-wrapper">
<label class="file-label" for="pdvFile" [class.has-error]="pdvPreview && !pdvPreview.valid" [class.has-success]="pdvPreview?.valid"> <label class="file-label" for="pdvFile" [class.has-error]="pdvPreview && !pdvPreview.valid" [class.has-success]="pdvPreview?.valid">
<i class="pi pi-file-excel"></i> <i class="pi pi-file-excel"></i>
<span>Archivo PDVs (Excel)</span> <span>Archivo PDVs guardado</span>
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 2000 filas</span> <span class="file-hint">Los PDVs pertenecen a este proyecto</span>
</label> </label>
<input <input
id="pdvFile" id="pdvFile"
type="file" type="file"
accept=".xlsx,.xls" accept=".xlsx,.xls"
(change)="onPdvFileChange($event)" disabled
class="file-input" class="file-input"
[class.has-file]="pdvFile" [class.has-file]="pdvFile"
/> />
@@ -57,48 +60,6 @@
</div> </div>
</div> </div>
<div class="file-input-wrapper">
<label class="file-label" for="usuariosFile" [class.has-error]="usuariosPreview && !usuariosPreview.valid" [class.has-success]="usuariosPreview?.valid">
<i class="pi pi-file-excel"></i>
<span>Archivo Usuarios (Excel)</span>
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 50 filas</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" [class.pi-check-circle]="usuariosPreview?.valid" [class.pi-exclamation-circle]="usuariosPreview && !usuariosPreview.valid"></i>
{{ usuariosFile.name }}
<span *ngIf="usuariosPreviewLoading" class="preview-loading">Validando...</span>
</div>
<div class="preview-card" *ngIf="usuariosPreviewLoading">
<div class="preview-loading-row"><span class="spinner-inline"></span> Analizando archivo Usuarios...</div>
</div>
<div class="preview-card preview-success" *ngIf="usuariosPreview?.valid">
<div class="preview-header"><i class="pi pi-check-circle"></i> Usuarios válido — {{ usuariosPreview!.totalRows }} filas</div>
<div class="preview-stats">{{ usuariosPreview!.validRows }} filas válidas • Columnas: {{ usuariosPreview!.headers.join(', ') }}</div>
</div>
<div class="preview-card preview-error" *ngIf="usuariosPreview && !usuariosPreview.valid">
<div class="preview-header"><i class="pi pi-exclamation-triangle"></i> Errores en Usuarios ({{ usuariosPreview.errors.length }})</div>
<div class="preview-stats" *ngIf="usuariosPreview.missingHeaders.length">Faltan: {{ usuariosPreview.missingHeaders.join(', ') }}</div>
<div class="preview-stats">{{ usuariosPreview.totalRows }} filas leídas • {{ usuariosPreview.validRows }} válidas</div>
<ul class="preview-errors">
<li *ngFor="let e of usuariosPreview.errors.slice(0, 8)">
<strong>Fila {{ e.row }} — {{ e.field }}:</strong> {{ e.message }}
<span *ngIf="e.value !== undefined && e.value !== null" class="error-value">({{ e.value }})</span>
</li>
<li *ngIf="usuariosPreview.errors.length > 8" class="more-errors">+ {{ usuariosPreview.errors.length - 8 }} errores más...</li>
</ul>
</div>
</div>
</div> </div>
<div class="upload-actions"> <div class="upload-actions">
@@ -130,13 +91,21 @@
type="button" type="button"
class="btn-secondary" class="btn-secondary"
(click)="clearFiles()" (click)="clearFiles()"
[disabled]="!pdvFile && !usuariosFile" [disabled]="!pdvFile"
> >
<i class="pi pi-times"></i> Limpiar <i class="pi pi-times"></i> Limpiar
</button> </button>
</div> </div>
<small class="hint-text" *ngIf="submitDisabledReason && !isLoading">{{ submitDisabledReason }}</small> <small class="hint-text" *ngIf="submitDisabledReason && !isLoading">{{ submitDisabledReason }}</small>
</div> </div>
</div>
</section>
<div class="map-overview-link"><a class="employees-link" [routerLink]="['/proyectos', projectId, 'mapa']"><i class="pi pi-map"></i> Ver mapa general</a></div>
<section class="card planning-filters-card">
<button type="button" class="dashboard-section-toggle" (click)="toggleFilters()"><span><i class="pi pi-filter"></i> Filtros del dashboard</span><i class="pi" [class.pi-chevron-down]="filtersExpanded" [class.pi-chevron-right]="!filtersExpanded"></i></button>
<div class="planning-filters-grid" *ngIf="filtersExpanded"><label>Cobertura<select [(ngModel)]="planningFilters.cobertura" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('cobertura')" [value]="value">{{ value }}</option></select></label><label>Zona<select [(ngModel)]="planningFilters.zona" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('zona')" [value]="value">{{ value }}</option></select></label><label>Región<select [(ngModel)]="planningFilters.region" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('region')" [value]="value">{{ value }}</option></select></label><label>Estado<select [(ngModel)]="planningFilters.estado" (ngModelChange)="applyPlanningFilters()"><option value="">Todos</option><option *ngFor="let value of filterOptions('estado')" [value]="value">{{ value }}</option></select></label><label>Ciudad<select [(ngModel)]="planningFilters.ciudad" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('ciudad')" [value]="value">{{ value }}</option></select></label><button type="button" class="btn-secondary" (click)="clearPlanningFilters()"><i class="pi pi-filter-slash"></i> Limpiar</button></div>
</section> </section>
<section class="card kpis-card"> <section class="card kpis-card">
@@ -144,11 +113,7 @@
</section> </section>
<section class="card"> <section class="card">
<app-assigned-route></app-assigned-route> <button type="button" class="dashboard-section-toggle" (click)="toggleUnassigned()"><span><i class="pi pi-exclamation-triangle"></i> PDVs sin ruta asignada</span><i class="pi" [class.pi-chevron-down]="unassignedExpanded" [class.pi-chevron-right]="!unassignedExpanded"></i></button>
</section> <app-no-assigned-route *ngIf="unassignedExpanded"></app-no-assigned-route>
<section class="card">
<h2 class="section-title">PDVs sin ruta asignada</h2>
<app-no-assigned-route></app-no-assigned-route>
</section> </section>
</div> </div>
@@ -1,7 +1,6 @@
import { Component, ChangeDetectorRef } from '@angular/core'; import { Component, ChangeDetectorRef, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { AssignedRouteComponent } from './assigned-route/assigned-route.component';
import { NoAssignedRouteComponent } from './no-assigned-route/no-assigned-route.component'; import { NoAssignedRouteComponent } from './no-assigned-route/no-assigned-route.component';
import { RutasService } from '../../core/services/router.service'; import { RutasService } from '../../core/services/router.service';
import { RouteState } from '../../core/route-state/route'; import { RouteState } from '../../core/route-state/route';
@@ -10,23 +9,41 @@ import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component'; import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component';
import { PlanningInputService } from '../../core/services/planning-input.service'; import { PlanningInputService } from '../../core/services/planning-input.service';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { EmployeeService } from '../../core/services/employee.service';
import { ProjectService } from '../../core/services/project.service';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
import { PlanningFilterKey, PlanningFilters, PlanningFilterService } from '../../core/services/planning-filter.service';
@Component({ @Component({
standalone: true, standalone: true,
selector: 'app-home', selector: 'app-home',
imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent, PlanningKpisComponent], imports: [CommonModule, FormsModule, RouterLink, NoAssignedRouteComponent, PlanningKpisComponent],
templateUrl: './home.component.html', templateUrl: './home.component.html',
styleUrls: ['./home.component.css'] styleUrls: ['./home.component.css']
}) })
export class HomeComponent { export class HomeComponent implements OnInit {
pdvFile: File | null = null; pdvFile: File | null = null;
usuariosFile: File | null = null; usuariosFile: File | null = null;
pdvPreview: PreviewResult | null = null; pdvPreview: PreviewResult | null = null;
usuariosPreview: PreviewResult | null = null; usuariosPreview: PreviewResult | null = null;
pdvPreviewLoading = false; pdvPreviewLoading = false;
usuariosPreviewLoading = false; usuariosPreviewLoading = false;
activeEmployees = 0;
employeesLoading = true;
uploadError: string | null = null; uploadError: string | null = null;
isLoading = false; isLoading = false;
projectName = '';
projectId = '';
editingProject = false;
projectDescription = '';
projectCountry = '';
projectSaving = false;
inputsExpanded = true;
filtersExpanded = true;
unassignedExpanded = false;
planningFilters: PlanningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
private calcSub: Subscription | null = null; private calcSub: Subscription | null = null;
constructor( constructor(
@@ -34,9 +51,82 @@ export class HomeComponent {
private routeState: RouteState, private routeState: RouteState,
private previewService: ExcelPreviewService, private previewService: ExcelPreviewService,
private planningInput: PlanningInputService, private planningInput: PlanningInputService,
private employeesService: EmployeeService,
private projectsService: ProjectService,
private activatedRoute: ActivatedRoute,
private auth: AuthService,
private router: Router,
public profile: UserProfileService,
public planningFilter: PlanningFilterService,
private cdr: ChangeDetectorRef private cdr: ChangeDetectorRef
) {} ) {}
ngOnInit(): void {
void this.initialize();
}
private async initialize(): Promise<void> {
await this.loadProject();
await this.loadEmployees();
}
private async loadProject(): Promise<void> {
this.projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? '';
if (!this.projectId) return;
this.routeState.useProject(this.projectId);
this.planningInput.clear();
this.planningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
this.applyPlanningFilters();
try {
const [project, input] = await Promise.all([this.projectsService.get(this.projectId), this.projectsService.projectInput(this.projectId)]);
this.projectName = project.nombre;
this.projectDescription = project.descripcion ?? '';
this.projectCountry = input.country;
this.planningFilter.setPdvs(input.rows);
this.pdvFile = input.file;
this.pdvPreview = await this.previewService.previewFile(input.file, 'PDV');
} catch (error: any) {
this.uploadError = error?.message ?? 'No se pudo cargar el proyecto.';
} finally {
this.cdr.detectChanges();
}
}
startProjectEdit(): void { this.editingProject = true; }
cancelProjectEdit(): void { this.editingProject = false; void this.loadProject(); }
async saveProject(): Promise<void> {
if (!this.projectId || !this.projectName.trim()) return;
this.projectSaving = true;
try {
await this.projectsService.update(this.projectId, { nombre: this.projectName.trim(), descripcion: this.projectDescription.trim() || null });
this.editingProject = false;
} catch (error: any) { this.uploadError = error?.message ?? 'No se pudo actualizar el proyecto.'; }
finally { this.projectSaving = false; this.cdr.detectChanges(); }
}
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
filterOptions(key: PlanningFilterKey): string[] { return this.planningFilter.options(key); }
applyPlanningFilters(): void { this.planningFilter.set(this.planningFilters); }
clearPlanningFilters(): void { this.planningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' }; this.applyPlanningFilters(); }
toggleInputs(): void { this.inputsExpanded = !this.inputsExpanded; }
toggleFilters(): void { this.filtersExpanded = !this.filtersExpanded; }
toggleUnassigned(): void { this.unassignedExpanded = !this.unassignedExpanded; }
private async loadEmployees(): Promise<void> {
this.employeesLoading = true;
try {
this.activeEmployees = (await this.employeesService.active(this.projectId, this.projectCountry)).length;
} catch {
this.activeEmployees = 0;
} finally {
this.employeesLoading = false;
this.cdr.detectChanges();
}
}
async onPdvFileChange(event: Event) { async onPdvFileChange(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) { if (input.files && input.files.length > 0) {
@@ -49,7 +139,7 @@ export class HomeComponent {
return; return;
} }
this.pdvFile = file; this.pdvFile = file;
if (this.usuariosFile) this.planningInput.setFiles(file, this.usuariosFile); if (this.usuariosFile) this.planningInput.setFiles(this.projectId, file, this.usuariosFile);
this.uploadError = null; this.uploadError = null;
this.pdvPreviewLoading = true; this.pdvPreviewLoading = true;
this.pdvPreview = null; this.pdvPreview = null;
@@ -90,7 +180,7 @@ export class HomeComponent {
return; return;
} }
this.usuariosFile = file; this.usuariosFile = file;
if (this.pdvFile) this.planningInput.setFiles(this.pdvFile, file); if (this.pdvFile) this.planningInput.setFiles(this.projectId, this.pdvFile, file);
this.uploadError = null; this.uploadError = null;
this.usuariosPreviewLoading = true; this.usuariosPreviewLoading = true;
this.usuariosPreview = null; this.usuariosPreview = null;
@@ -132,23 +222,22 @@ export class HomeComponent {
} }
get canSubmit(): boolean { get canSubmit(): boolean {
if (!this.pdvFile || !this.usuariosFile) return false; if (!this.pdvFile || !this.activeEmployees) return false;
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return false; if (this.pdvPreviewLoading || this.employeesLoading) return false;
if (this.isLoading) return false; if (this.isLoading) return false;
if (this.uploadError) return false; if (this.uploadError) return false;
// Require valid previews before enabling submit // Require valid previews before enabling submit
if (this.pdvPreview && !this.pdvPreview.valid) return false; if (this.pdvPreview && !this.pdvPreview.valid) return false;
if (this.usuariosPreview && !this.usuariosPreview.valid) return false; if (!this.pdvPreview) return false;
// Previews must exist (file was parsed)
if (!this.pdvPreview || !this.usuariosPreview) return false;
return true; return true;
} }
get submitDisabledReason(): string | null { get submitDisabledReason(): string | null {
if (!this.pdvFile || !this.usuariosFile) return 'Sube ambos archivos'; if (!this.pdvFile) return 'Sube el archivo PDV';
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return 'Validando archivos...'; if (this.employeesLoading) return 'Cargando empleados...';
if (!this.activeEmployees) return 'No hay empleados activos. Regístralos en Gestionar empleados.';
if (this.pdvPreviewLoading) return 'Validando archivo PDV...';
if (this.pdvPreview && !this.pdvPreview.valid) return 'Corrige los errores del archivo PDV'; if (this.pdvPreview && !this.pdvPreview.valid) return 'Corrige los errores del archivo PDV';
if (this.usuariosPreview && !this.usuariosPreview.valid) return 'Corrige los errores del archivo Usuarios';
return null; return null;
} }
@@ -168,11 +257,21 @@ export class HomeComponent {
this.cdr.detectChanges(); this.cdr.detectChanges();
return; return;
} }
this.planningInput.setFiles(pdvForOptimizer, this.usuariosFile!); let employeesFile: File;
try {
employeesFile = await this.employeesService.activeEmployeesFile(this.projectId, this.projectCountry);
} catch {
this.uploadError = 'No se pudieron obtener los empleados activos.';
this.routeState.setLoading(false);
this.isLoading = false;
return;
}
this.planningInput.setFiles(this.projectId, pdvForOptimizer, employeesFile);
const formData = new FormData(); const formData = new FormData();
formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name); formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name);
formData.append('usuarios_file', this.usuariosFile!, this.usuariosFile!.name); formData.append('usuarios_file', employeesFile, employeesFile.name);
formData.append('pais', this.projectCountry);
this.calcSub?.unsubscribe(); this.calcSub?.unsubscribe();
this.calcSub = this.rutasService.calcularRutas(formData).subscribe({ this.calcSub = this.rutasService.calcularRutas(formData).subscribe({
@@ -186,9 +285,27 @@ export class HomeComponent {
this.cdr.detectChanges(); this.cdr.detectChanges();
return; return;
} }
const arr = res as unknown as unknown[]; const payload = Array.isArray(res)
const assigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'assigned' in r) as unknown as { assigned: unknown } | undefined; ? res
const noAssigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'noAssigned' in r) as unknown as { noAssigned: unknown } | undefined; : res && typeof res === 'object'
? [res]
: null;
if (!payload) {
this.uploadError = 'El optimizador devolvió una respuesta vacía. Verifica que el workflow de n8n esté publicado.';
this.routeState.setLoading(false);
this.isLoading = false;
this.cdr.detectChanges();
return;
}
const assigned = payload.find(r => r && typeof r === 'object' && 'assigned' in r) as { assigned: unknown } | undefined;
const noAssigned = payload.find(r => r && typeof r === 'object' && 'noAssigned' in r) as { noAssigned: unknown } | undefined;
if (!assigned && !noAssigned) {
this.uploadError = 'El optimizador devolvió un formato de respuesta no válido. Verifica el workflow de n8n.';
this.routeState.setLoading(false);
this.isLoading = false;
this.cdr.detectChanges();
return;
}
if (assigned) { if (assigned) {
this.routeState.setAssignedRoutes(assigned.assigned as never); this.routeState.setAssignedRoutes(assigned.assigned as never);
} }
@@ -218,7 +335,7 @@ export class HomeComponent {
private mapBackendError(err: unknown): string { private mapBackendError(err: unknown): string {
const anyErr = err as { error?: { error?: string; message?: string }; message?: string; status?: number; name?: string }; const anyErr = err as { error?: { error?: string; message?: string }; message?: string; status?: number; name?: string };
if (anyErr?.name === 'TimeoutError') return 'El cálculo tardó demasiado (timeout 30s). Intenta de nuevo con menos filas.'; if (anyErr?.name === 'TimeoutError') return 'El cálculo tardó demasiado (timeout 180s). Intenta de nuevo con menos filas.';
if (anyErr?.error?.error) return anyErr.error.error; if (anyErr?.error?.error) return anyErr.error.error;
if (anyErr?.error?.message) return anyErr.error.message; if (anyErr?.error?.message) return anyErr.error.message;
if (anyErr?.message) return anyErr.message; if (anyErr?.message) return anyErr.message;
@@ -227,7 +344,7 @@ export class HomeComponent {
clearFiles() { clearFiles() {
this.calcSub?.unsubscribe(); this.calcSub?.unsubscribe();
this.planningInput.clear(); this.planningInput.clear(this.projectId);
this.routeState.clearAll(); this.routeState.clearAll();
this.pdvFile = null; this.pdvFile = null;
this.usuariosFile = null; this.usuariosFile = null;
@@ -20,6 +20,7 @@
<td class="text-left">{{ row.latitud }}</td> <td class="text-left">{{ row.latitud }}</td>
<td class="text-left">{{ row.longitud }}</td> <td class="text-left">{{ row.longitud }}</td>
<td class="text-left">{{ row.motivo }}</td> <td class="text-left">{{ row.motivo }}</td>
<td class="text-left">{{ row.recomendacion }}</td>
<td class="assignment-actions"> <td class="assignment-actions">
<select [(ngModel)]="assignmentUser[row.pdv_id]" class="inline-select"> <select [(ngModel)]="assignmentUser[row.pdv_id]" class="inline-select">
<option value="">Usuario</option> <option value="">Usuario</option>
@@ -37,7 +38,7 @@
</ng-template> </ng-template>
<ng-template pTemplate="emptymessage"> <ng-template pTemplate="emptymessage">
<tr> <tr>
<td colspan="7" class="text-center"> <td colspan="8" class="text-center">
No hay registros asignados No hay registros asignados
</td> </td>
</tr> </tr>
@@ -6,6 +6,8 @@ import { RouteNoAssignedResponse } from '../../../core/models/route.model';
import { RouteState, UnassignedAssignment } from '../../../core/route-state/route'; import { RouteState, UnassignedAssignment } from '../../../core/route-state/route';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { OrsService } from '../../../core/services/ors.service'; import { OrsService } from '../../../core/services/ors.service';
import { PlanningFilterService } from '../../../core/services/planning-filter.service';
import { recommendationForUnassigned } from '../../../shared/utils/unassigned-recommendation.utils';
@Component({ @Component({
standalone: true, standalone: true,
@@ -17,6 +19,7 @@ import { OrsService } from '../../../core/services/ors.service';
export class NoAssignedRouteComponent implements OnDestroy { export class NoAssignedRouteComponent implements OnDestroy {
noAssignedRoutes: RouteNoAssignedResponse[] = []; noAssignedRoutes: RouteNoAssignedResponse[] = [];
private allNoAssignedRoutes: RouteNoAssignedResponse[] = [];
isLoading = false; isLoading = false;
cols: { field: string; header: string }[] = []; cols: { field: string; header: string }[] = [];
private subs: Subscription[] = []; private subs: Subscription[] = [];
@@ -28,7 +31,8 @@ export class NoAssignedRouteComponent implements OnDestroy {
constructor( constructor(
private routesState: RouteState, private routesState: RouteState,
private cdr: ChangeDetectorRef, private cdr: ChangeDetectorRef,
private orsService: OrsService private orsService: OrsService,
private filters: PlanningFilterService
) {} ) {}
ngOnInit() { ngOnInit() {
@@ -40,17 +44,23 @@ export class NoAssignedRouteComponent implements OnDestroy {
); );
this.subs.push( this.subs.push(
this.routesState.noAssignedRoutes$.subscribe(data => { this.routesState.noAssignedRoutes$.subscribe(data => {
this.noAssignedRoutes = [...data]; this.allNoAssignedRoutes = data.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
this.cdr.detectChanges(); this.cdr.detectChanges();
}) })
); );
this.subs.push(this.filters.filters$.subscribe(() => {
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
this.cdr.detectChanges();
}));
this.cols = [ this.cols = [
{ field: 'pdv_id', header: 'PDV ID' }, { field: 'pdv_id', header: 'PDV ID' },
{ field: 'pdv_nombre', header: 'Nombre' }, { field: 'pdv_nombre', header: 'Nombre' },
{ field: 'minimo_horas_semana', header: 'Min. Horas/Sem' }, { field: 'minimo_horas_semana', header: 'Min. Horas/Sem' },
{ field: 'latitud', header: 'Latitud' }, { field: 'latitud', header: 'Latitud' },
{ field: 'longitud', header: 'Longitud' }, { field: 'longitud', header: 'Longitud' },
{ field: 'motivo', header: 'Motivo' } { field: 'motivo', header: 'Motivo' },
{ field: 'recomendacion', header: 'Recomendación' }
]; ];
} }
@@ -2,6 +2,12 @@
border-top: 3px solid var(--glm-azul); border-top: 3px solid var(--glm-azul);
} }
.section-toggle,.subsection-toggle { display:flex; width:100%; align-items:center; justify-content:space-between; gap:12px; border:0; background:transparent; color:var(--glm-azul); cursor:pointer; font:inherit; font-weight:700; text-align:left; text-transform:uppercase; }
.section-toggle { padding:10px 0 14px; font-size:1rem; }
.subsection-toggle { padding:0 0 8px; font-size:.82rem; }
.section-toggle span,.subsection-toggle span { display:inline-flex; align-items:center; gap:7px; }
.table-error { margin:8px 0; color:#c9252d; font-size:.82rem; font-weight:700; }
.kpis-heading { .kpis-heading {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -174,6 +180,14 @@
.user-load-table tbody tr:nth-child(even) { .user-load-table tbody tr:nth-child(even) {
background: var(--glm-gris-claro); background: var(--glm-gris-claro);
} }
.load-row { cursor:pointer; }
.load-row:hover { background:var(--glm-verde-bg)!important; }
.visit-detail-row>td { padding:10px 14px; background:#f8fafb; }
.visits-table { width:100%; border-collapse:collapse; font-size:.76rem; }
.visits-table th { background:#5b7080; }
.visits-table select { max-width:150px; padding:5px; border:1px solid var(--glm-gris-medio); border-radius:4px; }
.icon-button { margin:0 2px; padding:5px 7px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; }
.user-load-pagination { display:flex; align-items:center; justify-content:flex-end; gap:10px; margin-top:10px; color:var(--glm-acero); font-size:.78rem; }.user-load-pagination button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:6px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.user-load-pagination button:hover:not(:disabled) { background:var(--glm-verde-bg); border-color:var(--glm-verde); }.user-load-pagination button:disabled { cursor:not-allowed; opacity:.55; }
.utilization-cell { .utilization-cell {
display: flex; display: flex;
@@ -1,94 +1,267 @@
<div class="kpis-panel"> <div class="kpis-panel">
<button
type="button"
class="section-toggle"
(click)="toggleMetrics()"
[attr.aria-expanded]="metricsExpanded"
>
<span><i class="pi pi-chart-bar"></i> Indicadores de planificación</span
><i
class="pi"
[class.pi-chevron-down]="metricsExpanded"
[class.pi-chevron-right]="!metricsExpanded"
></i>
</button>
<div *ngIf="metricsExpanded">
<div class="kpis-heading"> <div class="kpis-heading">
<div> <p class="kpis-subtitle">Resumen de la selección actual</p>
<h2 class="section-title">Indicadores de planificación</h2>
<p class="kpis-subtitle">Resumen total de la planificación actual</p>
</div>
<div class="kpis-actions"> <div class="kpis-actions">
<span class="kpis-status" *ngIf="hasData"><i class="pi pi-check-circle"></i> Datos disponibles</span> <span class="kpis-status" *ngIf="hasData"
<button type="button" class="export-button" *ngIf="hasData" (click)="exportPlanning()"><i class="pi pi-download"></i> Descargar XLSX</button> ><i class="pi pi-check-circle"></i> Datos disponibles</span
><button
type="button"
class="export-button"
*ngIf="hasData"
(click)="downloadRoutes()"
[disabled]="pdfLoading"
>
<i class="pi" [class.pi-spin]="pdfLoading" [class.pi-file-pdf]="!pdfLoading" [class.pi-spinner]="pdfLoading"></i> {{ pdfLoading ? "Generando PDF..." : "Descargar rutas" }}</button
><button
type="button"
class="export-button"
*ngIf="hasData"
(click)="reoptimize()"
[disabled]="!canReoptimize"
>
<i class="pi pi-sync"></i>
{{ reoptLoading ? "Reoptimizando..." : "Reoptimizar" }}</button
><button
type="button"
class="export-button"
*ngIf="hasLocalChanges"
(click)="restoreOriginal()"
>
<i class="pi pi-undo"></i> Deshacer
</button>
</div> </div>
</div> </div>
<div class="table-error" *ngIf="reoptError || editError || pdfError">
{{ reoptError || editError || pdfError }}
</div>
<div class="kpi-grid"> <div class="kpi-grid">
<article class="kpi-card kpi-primary"> <article class="kpi-card kpi-primary">
<div class="kpi-icon"><i class="pi pi-map-marker"></i></div> <div class="kpi-icon"><i class="pi pi-map-marker"></i></div>
<div><span class="kpi-label">PDVs totales</span><strong>{{ summary.totalPdvs }}</strong><small>en la planificación</small></div> <div>
<span class="kpi-label">PDVs totales</span
><strong>{{ summary.totalPdvs }}</strong>
</div>
</article> </article>
<article class="kpi-card kpi-success"> <article class="kpi-card kpi-success">
<div class="kpi-icon"><i class="pi pi-check"></i></div> <div class="kpi-icon"><i class="pi pi-check"></i></div>
<div><span class="kpi-label">PDVs asignados</span><strong>{{ summary.assignedPdvs }}</strong><small>{{ summary.coverage | number:'1.1-1' }}% de cobertura</small></div> <div>
<span class="kpi-label">PDVs asignados</span
><strong>{{ summary.assignedPdvs }}</strong
><small>{{ summary.coverage | number: "1.1-1" }}% cobertura</small>
</div>
</article> </article>
<article class="kpi-card kpi-warning"> <article class="kpi-card kpi-warning">
<div class="kpi-icon"><i class="pi pi-exclamation-triangle"></i></div> <div class="kpi-icon"><i class="pi pi-exclamation-triangle"></i></div>
<div><span class="kpi-label">Sin asignar</span><strong>{{ summary.unassignedPdvs }}</strong><small>requieren revisión</small></div> <div>
<span class="kpi-label">Sin asignar</span
><strong>{{ summary.unassignedPdvs }}</strong>
</div>
</article> </article>
<article class="kpi-card kpi-blue"> <article class="kpi-card kpi-blue">
<div class="kpi-icon"><i class="pi pi-users"></i></div> <div class="kpi-icon"><i class="pi pi-users"></i></div>
<div><span class="kpi-label">Usuarios activos</span><strong>{{ summary.users }}</strong><small>{{ summary.days }} días planificados</small></div> <div>
<span class="kpi-label">Usuarios</span
><strong>{{ summary.users }}</strong
><small>{{ summary.days }} días</small>
</div>
</article> </article>
<article class="kpi-card kpi-orange"> <article class="kpi-card kpi-orange">
<div class="kpi-icon"><i class="pi pi-compass"></i></div> <div class="kpi-icon"><i class="pi pi-compass"></i></div>
<div><span class="kpi-label">Distancia total</span><strong>{{ summary.totalDistance | number:'1.0-1' }} <em>km</em></strong><small>{{ summary.averageDistance | number:'1.0-1' }} km promedio por ruta</small></div> <div>
<span class="kpi-label">Distancia</span
><strong
>{{ summary.totalDistance | number: "1.0-1" }} <em>km</em></strong
>
</div>
</article> </article>
<ng-container *ngIf="hasData">
<article class="kpi-card kpi-success"> <article class="kpi-card kpi-success">
<div class="kpi-icon"><i class="pi pi-briefcase"></i></div> <div class="kpi-icon"><i class="pi pi-briefcase"></i></div>
<div><span class="kpi-label">Carga de trabajo</span><strong>{{ summary.totalWorkHours | number:'1.0-1' }} <em>h</em></strong><small>horas de trabajo planificadas</small></div> <div>
<span class="kpi-label">Trabajo</span
><strong
>{{ summary.totalWorkHours | number: "1.0-1" }} <em>h</em></strong
>
</div>
</article> </article>
<article class="kpi-card kpi-blue"> </div>
<div class="kpi-icon"><i class="pi pi-chart-line"></i></div>
<div><span class="kpi-label">Utilización promedio</span><strong>{{ summary.averageUtilization | number:'1.0-1' }}<em>%</em></strong><small>carga frente a disponibilidad</small></div>
</article>
<article class="kpi-card kpi-primary">
<div class="kpi-icon"><i class="pi pi-sitemap"></i></div>
<div><span class="kpi-label">Rutas por usuario</span><strong>{{ summary.averageRoutesPerUser | number:'1.0-1' }}</strong><small>promedio de asignaciones</small></div>
</article>
<article class="kpi-card kpi-warning">
<div class="kpi-icon"><i class="pi pi-arrows-h"></i></div>
<div><span class="kpi-label">Brecha de balance</span><strong>{{ summary.assignmentBalance }}</strong><small>diferencia entre mayor y menor carga</small></div>
</article>
</ng-container>
</div> </div>
<div class="user-load-card" *ngIf="userLoads.length"> <div class="user-load-card" *ngIf="userLoads.length">
<h3><i class="pi pi-users"></i> Balance de carga por usuario</h3> <button type="button" class="subsection-toggle" (click)="toggleBalance()">
<div class="user-load-table-wrap"> <span><i class="pi pi-users"></i> Balance de carga por usuario</span
><i
class="pi"
[class.pi-chevron-down]="balanceExpanded"
[class.pi-chevron-right]="!balanceExpanded"
></i>
</button>
<div class="user-load-table-wrap" *ngIf="balanceExpanded">
<table class="user-load-table"> <table class="user-load-table">
<thead> <thead>
<tr><th>Usuario</th><th>Rutas</th><th>Distancia</th><th>Trabajo</th><th>Utilización</th></tr> <tr>
<th></th>
<th>Usuario</th>
<th>PDVs</th>
<th>Visitas</th>
<th>Distancia</th>
<th>Trabajo</th>
<th>Carga</th>
<th></th>
</tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let load of userLoads" [class.load-over-capacity]="load.status === 'over-capacity'"> <ng-container *ngFor="let load of paginatedUserLoads"
<td>{{ load.name }}</td> ><tr
<td>{{ load.routes }}</td> class="load-row"
<td>{{ load.distance | number:'1.0-1' }} km</td> [class.load-over-capacity]="load.status === 'over-capacity'"
<td>{{ load.workHours | number:'1.0-1' }} h</td> (click)="toggleUser(load.id)"
>
<td> <td>
<div class="utilization-cell" [ngClass]="utilizationClass(load)"> <i
<span>{{ load.utilization | number:'1.0-1' }}%</span> class="pi"
<span class="capacity-warning" *ngIf="load.status === 'over-capacity'" [title]="load.alertMessage" aria-label="Alerta de capacidad">⚠️</span> [class.pi-chevron-down]="isExpanded(load.id)"
<span class="utilization-bar" [title]="load.alertMessage"><span [style.width.%]="barWidth(load.utilization)"></span></span> [class.pi-chevron-right]="!isExpanded(load.id)"
></i>
</td>
<td>{{ load.name }}</td>
<td>{{ load.uniquePdvs }}</td>
<td>{{ load.visits }}</td>
<td>{{ load.distance | number: "1.0-1" }} km</td>
<td>{{ load.workHours | number: "1.0-1" }} h</td>
<td>
<div
class="utilization-cell"
[ngClass]="utilizationClass(load)"
>
<span>{{ load.utilization | number: "1.0-1" }}%</span
><span class="utilization-bar" [title]="load.alertMessage"
><span [style.width.%]="barWidth(load.utilization)"></span
></span>
</div> </div>
</td> </td>
<td>
<button
type="button"
class="icon-button"
(click)="$event.stopPropagation(); showUserMap(load.id)"
title="Ver usuario en mapa"
>
<i class="pi pi-map"></i>
</button>
</td>
</tr>
<tr class="visit-detail-row" *ngIf="isExpanded(load.id)">
<td colspan="8">
<table class="visits-table">
<thead>
<tr>
<th>PDV</th>
<th>Día</th>
<th>Orden</th>
<th>Trabajo</th>
<th>Distancia</th>
<th>Duración</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let route of load.routes">
<td>{{ route.json.nombre_pdv }}</td>
<td>
<select
*ngIf="editingRoute === route; else dayText"
[(ngModel)]="editDay"
>
<option *ngFor="let day of dayOptions" [value]="day">
{{ day }}
</option></select
><ng-template #dayText>{{
route.json.dia
}}</ng-template>
</td>
<td>{{ route.json.visit_order || "-" }}</td>
<td>{{ route.json.horas_trabajo }} h</td>
<td>{{ route.json.distancia | number: "1.0-2" }} km</td>
<td>
{{
route.json.horas_desplazamiento * 60 | number: "1.0-0"
}}
min
</td>
<td>
<ng-container
*ngIf="editingRoute === route; else normalActions"
><select [(ngModel)]="editUserId">
<option
*ngFor="let user of userOptions"
[value]="user.id"
>
{{ user.name }}
</option></select
><button class="icon-button" (click)="saveEdit()">
<i class="pi pi-check"></i></button
><button class="icon-button" (click)="cancelEdit()">
<i class="pi pi-times"></i></button></ng-container
><ng-template #normalActions
><button
class="icon-button"
(click)="startEdit(route)"
>
<i class="pi pi-pencil"></i></button
><button
class="icon-button"
(click)="showRoute(route)"
>
<i class="pi pi-map-marker"></i></button
></ng-template>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</td></tr
></ng-container>
</tbody>
</table>
</div>
<div class="user-load-pagination" *ngIf="userLoadPages > 1">
<button type="button" (click)="previousUserLoadPage()" [disabled]="userLoadPage === 1">Anterior</button>
<span>Página {{ userLoadPage }} de {{ userLoadPages }}</span>
<button type="button" (click)="nextUserLoadPage()" [disabled]="userLoadPage === userLoadPages">Siguiente</button>
</div> </div>
</div> </div>
<div class="reasons-card" *ngIf="reasons.length"> <div class="reasons-card" *ngIf="reasons.length">
<h3><i class="pi pi-list"></i> Motivos de PDVs sin asignar</h3> <button type="button" class="subsection-toggle" (click)="toggleReasons()">
<div class="reason-list"> <span><i class="pi pi-list"></i> Motivos de PDVs sin asignar</span
><i
class="pi"
[class.pi-chevron-down]="reasonsExpanded"
[class.pi-chevron-right]="!reasonsExpanded"
></i>
</button>
<div class="reason-list" *ngIf="reasonsExpanded">
<div class="reason-row" *ngFor="let item of reasons"> <div class="reason-row" *ngFor="let item of reasons">
<span>{{ item.reason }}</span> <span>{{ item.reason }}</span
<strong>{{ item.count }}</strong> ><strong>{{ item.count }}</strong>
</div> </div>
</div> </div>
</div> </div>
<div class="kpis-empty" *ngIf="!hasData"> <div class="kpis-empty" *ngIf="!hasData">
<i class="pi pi-chart-bar"></i> <i class="pi pi-chart-bar"></i
<span>Carga una planificación para visualizar sus indicadores.</span> ><span>Carga una planificación para visualizar sus indicadores.</span>
</div> </div>
</div> </div>
@@ -1,191 +1,166 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { RouteState } from '../../../core/route-state/route'; import { RouteState } from '../../../core/route-state/route';
import { PlanningExportService } from '../../../shared/services/planning-export.service'; import { RoutePdfReportService } from '../../../shared/services/route-pdf-report.service';
import { summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils'; import { summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
import { RouteAssignedResponse } from '../../../core/models/route.model'; import { RouteAssignedResponse } from '../../../core/models/route.model';
import { PlanningFilterService } from '../../../core/services/planning-filter.service';
import { RutasService } from '../../../core/services/router.service';
import { PlanningInputService } from '../../../core/services/planning-input.service';
interface KpiSummary { interface KpiSummary { totalPdvs: number; assignedPdvs: number; unassignedPdvs: number; coverage: number; users: number; days: number; totalDistance: number; averageDistance: number; totalWorkHours: number; }
totalPdvs: number; interface ReasonSummary { reason: string; count: number; }
assignedPdvs: number;
unassignedPdvs: number;
coverage: number;
users: number;
days: number;
totalDistance: number;
averageDistance: number;
totalWorkHours: number;
averageRoutesPerUser: number;
averageUtilization: number;
assignmentBalance: number;
}
interface ReasonSummary {
reason: string;
count: number;
}
interface UserLoad { interface UserLoad {
id: string; id: string; name: string; uniquePdvs: number; visits: number; routes: RouteAssignedResponse[]; distance: number; workHours: number;
name: string; capacityHours: number; utilization: number; status: 'ok' | 'warning' | 'over-capacity'; alertMessage: string;
routes: number;
distance: number;
workHours: number;
capacityHours: number;
utilization: number;
status: 'ok' | 'warning' | 'over-capacity';
alertMessage: string;
} }
@Component({ @Component({ standalone: true, selector: 'app-planning-kpis', imports: [CommonModule, FormsModule], templateUrl: './planning-kpis.component.html', styleUrl: './planning-kpis.component.css' })
standalone: true,
selector: 'app-planning-kpis',
imports: [CommonModule],
templateUrl: './planning-kpis.component.html',
styleUrl: './planning-kpis.component.css'
})
export class PlanningKpisComponent implements OnInit, OnDestroy { export class PlanningKpisComponent implements OnInit, OnDestroy {
summary: KpiSummary = this.emptySummary(); summary: KpiSummary = this.emptySummary();
reasons: ReasonSummary[] = []; reasons: ReasonSummary[] = [];
userLoads: UserLoad[] = []; userLoads: UserLoad[] = [];
hasData = false; hasData = false;
metricsExpanded = true;
balanceExpanded = true;
reasonsExpanded = false;
expandedUsers = new Set<string>();
editingRoute: RouteAssignedResponse | null = null;
editUserId = '';
editDay = '';
editError = '';
reoptLoading = false;
reoptError = '';
hasLocalChanges = false;
pdfLoading = false;
pdfError = '';
readonly userLoadPageSize = 14;
userLoadPage = 1;
private readonly subs: Subscription[] = []; private readonly subs: Subscription[] = [];
constructor( constructor(
private routeState: RouteState, private routeState: RouteState,
private exportService: PlanningExportService private filters: PlanningFilterService,
private pdfReport: RoutePdfReportService,
private rutasService: RutasService,
private planningInput: PlanningInputService,
private router: Router,
private activatedRoute: ActivatedRoute,
private cdr: ChangeDetectorRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.subs.push( this.subs.push(
this.routeState.assignedRoutes$.subscribe(() => this.refresh()), this.routeState.assignedRoutes$.subscribe(() => this.refresh()),
this.routeState.noAssignedRoutes$.subscribe(() => this.refresh()) this.routeState.noAssignedRoutes$.subscribe(() => this.refresh()),
this.filters.filters$.subscribe(() => this.refresh())
); );
} }
ngOnDestroy(): void { this.subs.forEach(subscription => subscription.unsubscribe()); }
ngOnDestroy(): void { barWidth(value: number): number { return Math.min(Math.max(value, 0), 100); }
this.subs.forEach(subscription => subscription.unsubscribe()); utilizationClass(load: UserLoad): string { return load.status; }
async downloadRoutes(): Promise<void> {
if (this.pdfLoading || !this.hasData) return;
this.pdfLoading = true;
this.pdfError = '';
try {
await this.pdfReport.download(this.projectId);
} catch (error: any) {
this.pdfError = error?.message ?? 'No se pudo generar el PDF de rutas.';
} finally {
this.pdfLoading = false;
this.cdr.detectChanges();
} }
barWidth(value: number): number {
return Math.min(Math.max(value, 0), 100);
} }
toggleUser(id: string): void { this.expandedUsers.has(id) ? this.expandedUsers.delete(id) : this.expandedUsers.add(id); }
isExpanded(id: string): boolean { return this.expandedUsers.has(id); }
toggleMetrics(): void { this.metricsExpanded = !this.metricsExpanded; }
toggleBalance(): void { this.balanceExpanded = !this.balanceExpanded; }
toggleReasons(): void { this.reasonsExpanded = !this.reasonsExpanded; }
previousUserLoadPage(): void { this.userLoadPage = Math.max(1, this.userLoadPage - 1); }
nextUserLoadPage(): void { this.userLoadPage = Math.min(this.userLoadPages, this.userLoadPage + 1); }
utilizationClass(load: UserLoad): string { get userOptions(): { id: string; name: string }[] { return this.userLoads.map(load => ({ id: load.id, name: load.name })); }
return load.status; get userLoadPages(): number { return Math.max(1, Math.ceil(this.userLoads.length / this.userLoadPageSize)); }
get paginatedUserLoads(): UserLoad[] {
const start = (this.userLoadPage - 1) * this.userLoadPageSize;
return this.userLoads.slice(start, start + this.userLoadPageSize);
} }
get dayOptions(): string[] { return this.routeState.getDiasUnicos(); }
get canReoptimize(): boolean { return this.hasData && !!this.planningInput.getFormData(this.projectId) && !this.reoptLoading; }
exportPlanning(): void { showRoute(route: RouteAssignedResponse): void {
this.exportService.export(); this.routeState.sendRoute(route);
void this.router.navigate(['/proyectos', this.projectId, 'ruta'], { queryParams: { pdv_id: route.json.pdv_id, usuario_id: route.json.usuario_id, dia: route.json.dia } });
} }
showUserMap(userId: string): void { void this.router.navigate(['/proyectos', this.projectId, 'mapa'], { queryParams: { usuario_id: userId, dia: 'Todos' } }); }
startEdit(route: RouteAssignedResponse): void { this.editingRoute = route; this.editUserId = String(route.json.usuario_id); this.editDay = route.json.dia; this.editError = ''; }
cancelEdit(): void { this.editingRoute = null; this.editError = ''; }
saveEdit(): void {
if (!this.editingRoute || !this.editUserId || !this.editDay) return;
const capacity = this.routeState.canAssignToDay(this.editUserId, this.editDay, Number(this.editingRoute.json.horas_trabajo || this.editingRoute.json.hora_minima_semanal_pdv || 0), this.editingRoute);
if (!capacity.allowed) { this.editError = `La asignación excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`; return; }
this.routeState.updateAssignedRoute(this.editingRoute, this.editUserId, this.editDay);
this.hasLocalChanges = true; this.editingRoute = null;
}
reoptimize(): void {
const data = this.planningInput.getFormData(this.projectId);
if (!data || !this.canReoptimize) return;
this.reoptLoading = true; this.reoptError = ''; this.routeState.setLoading(true);
this.rutasService.reoptimizarRutas(data).subscribe({
next: response => {
const items = response as unknown as unknown[];
const assigned = items.find(item => item && typeof item === 'object' && 'assigned' in item) as { assigned?: unknown } | undefined;
const noAssigned = items.find(item => item && typeof item === 'object' && 'noAssigned' in item) as { noAssigned?: unknown } | undefined;
if (assigned?.assigned) this.routeState.setAssignedRoutes(assigned.assigned as never);
if (noAssigned?.noAssigned) this.routeState.setNoAssignedRoutes(noAssigned.noAssigned as never);
this.hasLocalChanges = false; this.reoptLoading = false; this.routeState.setLoading(false); this.cdr.detectChanges();
},
error: error => { this.reoptError = error?.error?.error || error?.message || 'No se pudo reoptimizar'; this.reoptLoading = false; this.routeState.setLoading(false); this.cdr.detectChanges(); }
});
}
restoreOriginal(): void { this.routeState.restoreOriginalAssignedRoutes(); this.hasLocalChanges = false; this.editingRoute = null; }
private get projectId(): string { return this.activatedRoute.snapshot.paramMap.get('id') ?? ''; }
private refresh(): void { private refresh(): void {
const assigned = this.routeState.getAssignedSnapshot(); const assigned = this.filters.filterAssigned(this.routeState.getAssignedSnapshot());
const unassigned = this.routeState.getNoAssignedSnapshot(); const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot());
const assignedPdvIds = new Set(assigned.map(route => String(route.json.pdv_id))); const assignedPdvIds = new Set(assigned.map(route => String(route.json.pdv_id)));
// A PDV with at least one scheduled visit is assigned; repeated weekly visits and const unassignedPdvIds = new Set(unassigned.map(route => String(route.pdv_id)).filter(id => !assignedPdvIds.has(id)));
// partial-assignment alerts must not inflate the planning coverage counters.
const unassignedPdvIds = new Set(unassigned
.map(route => String(route.pdv_id))
.filter(pdvId => !assignedPdvIds.has(pdvId)));
const totalPdvs = new Set([...assignedPdvIds, ...unassignedPdvIds]).size; const totalPdvs = new Set([...assignedPdvIds, ...unassignedPdvIds]).size;
const dailyRoutes = summarizeDailyRoutes(assigned); const dailyRoutes = summarizeDailyRoutes(assigned);
const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0); const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0);
const totalWorkHours = dailyRoutes.reduce((sum, day) => sum + day.workHours, 0); const totalWorkHours = dailyRoutes.reduce((sum, day) => sum + day.workHours, 0);
const users = new Set(assigned.map(route => String(route.json.usuario_id))).size;
const days = new Set(assigned.map(route => route.json.dia)).size;
const loads = new Map<string, UserLoad>(); const loads = new Map<string, UserLoad>();
for (const route of assigned) { for (const route of assigned) {
const key = String(route.json.usuario_id); const id = String(route.json.usuario_id);
const current = loads.get(key) ?? { const load = loads.get(id) ?? { id, name: route.json.nombre_usuario, uniquePdvs: 0, visits: 0, routes: [], distance: 0, workHours: 0, capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario), utilization: 0, status: 'ok', alertMessage: '' };
id: key, load.routes.push(route); load.visits++;
name: route.json.nombre_usuario, loads.set(id, load);
routes: 0,
distance: 0,
workHours: 0,
capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario),
utilization: 0,
status: 'ok',
alertMessage: ''
};
current.routes++;
// Sequential distance and work are aggregated per user below.
loads.set(key, current);
} }
this.userLoads = [...loads.values()].map(load => {
this.userLoads = Array.from(loads.values()).map(load => { load.routes.sort((a, b) => a.json.dia.localeCompare(b.json.dia) || Number(a.json.visit_order ?? 0) - Number(b.json.visit_order ?? 0));
load.uniquePdvs = new Set(load.routes.map(route => String(route.json.pdv_id))).size;
const userDays = dailyRoutes.filter(day => day.userId === load.id); const userDays = dailyRoutes.filter(day => day.userId === load.id);
load.distance = userDays.reduce((sum, day) => sum + day.distanceKm, 0); load.distance = userDays.reduce((sum, day) => sum + day.distanceKm, 0);
load.workHours = userDays.reduce((sum, day) => sum + day.workHours, 0); load.workHours = userDays.reduce((sum, day) => sum + day.workHours, 0);
load.utilization = load.capacityHours ? (load.workHours / load.capacityHours) * 100 : 0; load.utilization = load.capacityHours ? load.workHours / load.capacityHours * 100 : 0;
load.status = load.utilization > 100 ? 'over-capacity' : load.utilization > 90 ? 'warning' : 'ok'; load.status = load.utilization > 100 ? 'over-capacity' : load.utilization > 90 ? 'warning' : 'ok';
load.alertMessage = load.status === 'over-capacity' load.alertMessage = `${load.name}: ${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h.`;
? `Alerta: ${load.name} supera el 100% de su capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h). Reasigna PDVs o reduce horas.`
: load.status === 'warning'
? `${load.name} está sobre el 90% de su capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).`
: `${load.name} está dentro de capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).`;
return load; return load;
}).sort((a, b) => b.routes - a.routes || b.workHours - a.workHours); }).sort((a, b) => b.visits - a.visits || b.workHours - a.workHours);
const routeCounts = this.userLoads.map(load => load.routes); this.userLoadPage = Math.min(this.userLoadPage, this.userLoadPages);
const assignmentBalance = routeCounts.length ? Math.max(...routeCounts) - Math.min(...routeCounts) : 0; this.summary = { totalPdvs, assignedPdvs: assignedPdvIds.size, unassignedPdvs: unassignedPdvIds.size, coverage: totalPdvs ? assignedPdvIds.size / totalPdvs * 100 : 0, users: loads.size, days: new Set(assigned.map(route => route.json.dia)).size, totalDistance, averageDistance: assignedPdvIds.size ? totalDistance / assignedPdvIds.size : 0, totalWorkHours };
const averageUtilization = this.userLoads.length
? this.userLoads.reduce((sum, load) => sum + load.utilization, 0) / this.userLoads.length
: 0;
this.summary = {
totalPdvs,
assignedPdvs: assignedPdvIds.size,
unassignedPdvs: unassignedPdvIds.size,
coverage: totalPdvs ? (assignedPdvIds.size / totalPdvs) * 100 : 0,
users,
days,
totalDistance,
averageDistance: assigned.length ? totalDistance / assigned.length : 0,
totalWorkHours,
averageRoutesPerUser: users ? assigned.length / users : 0,
averageUtilization,
assignmentBalance
};
const reasonByPdv = new Map<string, string>(); const reasonByPdv = new Map<string, string>();
for (const route of unassigned) { for (const route of unassigned) if (!assignedPdvIds.has(String(route.pdv_id)) && !reasonByPdv.has(String(route.pdv_id))) reasonByPdv.set(String(route.pdv_id), route.motivo?.trim() || 'Sin motivo especificado');
const pdvId = String(route.pdv_id); const counts = new Map<string, number>(); for (const reason of reasonByPdv.values()) counts.set(reason, (counts.get(reason) ?? 0) + 1);
if (assignedPdvIds.has(pdvId) || reasonByPdv.has(pdvId)) continue; this.reasons = [...counts].map(([reason, count]) => ({ reason, count })).sort((a, b) => b.count - a.count);
const reason = route.motivo?.trim() || 'Sin motivo especificado';
reasonByPdv.set(pdvId, reason);
}
const reasonCounts = new Map<string, number>();
for (const reason of reasonByPdv.values()) {
reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1);
}
this.reasons = Array.from(reasonCounts.entries())
.map(([reason, count]) => ({ reason, count }))
.sort((a, b) => b.count - a.count);
this.hasData = totalPdvs > 0; this.hasData = totalPdvs > 0;
} }
private numberValue(value: number | string | null | undefined): number { const result = Number(value); return Number.isFinite(result) ? result : 0; }
private numberValue(value: number | string | null | undefined): number { private emptySummary(): KpiSummary { return { totalPdvs: 0, assignedPdvs: 0, unassignedPdvs: 0, coverage: 0, users: 0, days: 0, totalDistance: 0, averageDistance: 0, totalWorkHours: 0 }; }
const result = Number(value);
return Number.isFinite(result) ? result : 0;
}
private emptySummary(): KpiSummary {
return {
totalPdvs: 0,
assignedPdvs: 0,
unassignedPdvs: 0,
coverage: 0,
users: 0,
days: 0,
totalDistance: 0,
averageDistance: 0,
totalWorkHours: 0,
averageRoutesPerUser: 0,
averageUtilization: 0,
assignmentBalance: 0
};
}
} }
@@ -77,6 +77,9 @@
font-weight: 700; font-weight: 700;
} }
.territory-summary { margin: 4px 0 0; color: var(--glm-acero); font-size: .76rem; line-height: 1.35; }
.territory-summary i { color: var(--glm-verde); margin-right: 3px; }
.filters-card { .filters-card {
background: var(--glm-verde-bg); background: var(--glm-verde-bg);
border: 1px solid var(--glm-verde-claro); border: 1px solid var(--glm-verde-claro);
@@ -455,6 +458,41 @@
border: none !important; border: none !important;
} }
.location-pin-wrapper {
width: 42px;
height: 50px;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 3px;
border-radius: 50%;
cursor: pointer;
transition: transform 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
}
.location-pin-wrapper:hover {
transform: scale(1.12);
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.22);
}
.location-pin-wrapper .location-pin {
position: relative;
width: 30px;
height: 38px;
background: #c9252d;
clip-path: polygon(50% 100%, 8% 43%, 5% 31%, 10% 18%, 22% 7%, 36% 2%, 50% 0, 64% 2%, 78% 7%, 90% 18%, 95% 31%, 92% 43%);
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
}
.location-pin-wrapper .location-pin span {
position: absolute;
inset: 8px 0 auto;
color: #fff;
font: 700 12px Arial, sans-serif;
text-align: center;
}
.marker-inner { .marker-inner {
width: 30px; width: 30px;
height: 30px; height: 30px;
@@ -16,12 +16,18 @@
</button> </button>
<h2 class="sidebar-title">Mapa de rutas</h2> <h2 class="sidebar-title">Mapa de rutas</h2>
</div> </div>
<p class="territory-summary" *ngIf="activeTerritories.length"><i class="pi pi-map-marker"></i> {{ activeTerritories.join(', ') }}</p>
<div class="filters-card" *ngIf="usuarios.length"> <div class="filters-card" *ngIf="usuarios.length">
<label class="filter-label">Metro de prueba</label>
<select class="filter-select" [(ngModel)]="selectedMetro" (ngModelChange)="onMetroChange()">
<option *ngFor="let metro of metros" [value]="metro">{{ metro }}</option>
</select>
<label class="filter-label">Usuario</label> <label class="filter-label">Usuario</label>
<select class="filter-select" [(ngModel)]="selectedUsuarioId" (ngModelChange)="onUsuarioChange()"> <select class="filter-select" [(ngModel)]="selectedUsuarioId" (ngModelChange)="onUsuarioChange()">
<option value="Todos">Todos los usuarios</option> <option value="Todos">Todos los usuarios</option>
<option *ngFor="let u of usuarios" [value]="u.id">{{ u.nombre }} ({{ u.id }})</option> <option *ngFor="let u of metroUsuarios" [value]="u.id">{{ u.nombre }} ({{ u.id }})</option>
</select> </select>
<label class="filter-label">Día</label> <label class="filter-label">Día</label>
@@ -67,6 +67,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
dias: string[] = []; dias: string[] = [];
selectedUsuarioId: string = 'Todos'; selectedUsuarioId: string = 'Todos';
selectedDia: string = 'Todos'; selectedDia: string = 'Todos';
selectedMetro = '';
expandedDailyRouteDay: string | null = null; expandedDailyRouteDay: string | null = null;
selectedRouteDetail: RouteAssignedResponse | null = null; selectedRouteDetail: RouteAssignedResponse | null = null;
@@ -95,6 +96,33 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
reassignUserId = ''; reassignUserId = '';
reassignDay = ''; reassignDay = '';
reassignError: string | null = null; reassignError: string | null = null;
private renderVersion = 0;
get metros(): string[] {
const values = [
...this.allRoutes.map(route => route.json.metro),
...this.unassignedRoutes.map(route => route.metro)
].map(value => String(value ?? '').trim()).filter(Boolean);
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
}
get filteredUnassignedRoutes(): RouteNoAssignedResponse[] {
return this.unassignedRoutes.filter(route => String(route.metro ?? '').trim() === this.selectedMetro);
}
get metroUsuarios(): { id: string; nombre: string }[] {
const users = new Map<string, string>();
for (const route of this.allRoutes) {
if (String(route.json.metro ?? '').trim() === this.selectedMetro) {
users.set(String(route.json.usuario_id), route.json.nombre_usuario);
}
}
return Array.from(users, ([id, nombre]) => ({ id, nombre }));
}
get activeTerritories(): string[] {
return this.selectedMetro ? [this.selectedMetro] : [];
}
// Summary stats // Summary stats
get uniqueFilteredRoutes(): RouteAssignedResponse[] { get uniqueFilteredRoutes(): RouteAssignedResponse[] {
@@ -140,7 +168,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} }
get dailyGroups(): DailyRouteGroup[] { get dailyGroups(): DailyRouteGroup[] {
const order = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes']; const order = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes', 'Sábado', 'Sabado'];
const groups = new Map<string, DailyRouteSummary[]>(); const groups = new Map<string, DailyRouteSummary[]>();
for (const summary of this.dailySummaries) { for (const summary of this.dailySummaries) {
const routes = groups.get(summary.day) ?? []; const routes = groups.get(summary.day) ?? [];
@@ -186,17 +214,13 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
) {} ) {}
ngOnInit() { ngOnInit() {
this.routeState.useProject(this.activatedRoute.snapshot.paramMap.get('id') ?? '');
this.subs.push( this.subs.push(
this.routeState.assignedRoutes$.subscribe(routes => { this.routeState.assignedRoutes$.subscribe(routes => {
this.allRoutes = [...routes]; this.allRoutes = [...routes];
this.usuarios = this.routeState.getUsuariosUnicos(); this.usuarios = this.routeState.getUsuariosUnicos();
this.dias = this.routeState.getDiasUnicos(); this.dias = this.routeState.getDiasUnicos();
// Start each map load with the complete planning selected by default. this.applyQueryFilters(this.activatedRoute.snapshot.queryParams);
const qp = this.activatedRoute.snapshot.queryParams;
if (qp['pdv_id']) {
this.selectedUsuarioId = 'Todos';
this.selectedDia = 'Todos';
}
// If focus pdv provided, auto-select detail after map loads // If focus pdv provided, auto-select detail after map loads
this.applyFilter(); this.applyFilter();
this.cdr.detectChanges(); this.cdr.detectChanges();
@@ -215,9 +239,16 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
// React to queryParams changes // React to queryParams changes
this.subs.push( this.subs.push(
this.activatedRoute.queryParams.subscribe(qp => { this.activatedRoute.queryParams.subscribe(qp => {
// Focus a specific route if pdv_id passed this.applyQueryFilters(qp);
this.applyFilter();
if (this.map) this.renderFiltered();
// Focus a specific route if pdv_id passed.
if (qp['pdv_id'] && this.filteredRoutes.length) { if (qp['pdv_id'] && this.filteredRoutes.length) {
const found = this.filteredRoutes.find(r => String(r.json.pdv_id) === String(qp['pdv_id'])); const found = this.filteredRoutes.find(r =>
String(r.json.pdv_id) === String(qp['pdv_id']) &&
(!qp['usuario_id'] || String(r.json.usuario_id) === String(qp['usuario_id'])) &&
(!qp['dia'] || qp['dia'] === 'Todos' || r.json.dia === qp['dia'])
);
if (found) { if (found) {
this.selectedRouteDetail = found; this.selectedRouteDetail = found;
this.highlightRoute(found); this.highlightRoute(found);
@@ -249,35 +280,42 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
onUsuarioChange() { onUsuarioChange() {
this.updateQueryParams(); this.updateQueryParams();
this.applyFilter();
this.renderFiltered();
} }
onDiaChange() { onDiaChange() {
this.updateQueryParams(); this.updateQueryParams();
this.applyFilter(); }
this.renderFiltered();
onMetroChange() {
this.expandedDailyRouteDay = null;
if (this.selectedUsuarioId !== 'Todos' && !this.metroUsuarios.some(user => user.id === this.selectedUsuarioId)) {
this.selectedUsuarioId = 'Todos';
}
this.updateQueryParams();
} }
private updateQueryParams() { private updateQueryParams() {
this.router.navigate([], { this.router.navigate([], {
relativeTo: this.activatedRoute, relativeTo: this.activatedRoute,
queryParams: { usuario_id: this.selectedUsuarioId, dia: this.selectedDia }, queryParams: { metro: this.selectedMetro, usuario_id: this.selectedUsuarioId, dia: this.selectedDia, pdv_id: null },
queryParamsHandling: 'merge', queryParamsHandling: 'merge',
replaceUrl: true replaceUrl: true
}); });
} }
private applyFilter() { private applyFilter() {
let list = this.selectedUsuarioId === 'Todos' let list = this.allRoutes.filter(route => String(route.json.metro ?? '').trim() === this.selectedMetro);
? [...this.allRoutes] if (this.selectedUsuarioId !== 'Todos') {
: this.allRoutes.filter(r => String(r.json.usuario_id) === String(this.selectedUsuarioId)); list = list.filter(r => String(r.json.usuario_id) === String(this.selectedUsuarioId));
}
if (this.selectedDia !== 'Todos') { if (this.selectedDia !== 'Todos') {
list = list.filter(r => r.json.dia === this.selectedDia); list = list.filter(r => r.json.dia === this.selectedDia);
} }
const pdvId = this.activatedRoute.snapshot.queryParams['pdv_id'];
if (pdvId) list = list.filter(route => String(route.json.pdv_id) === String(pdvId));
this.filteredRoutes = list; this.filteredRoutes = list;
if (this.filteredRoutes.length === 0) { if (this.filteredRoutes.length === 0) {
this.error = this.allRoutes.length === 0 ? 'No hay rutas cargadas. Sube archivos en Home.' : 'Sin rutas para este usuario/día.'; this.error = this.allRoutes.length === 0 ? 'No hay rutas cargadas. Sube archivos en Home.' : 'Sin rutas para este metro, usuario o día.';
} else { } else {
this.error = null; this.error = null;
} }
@@ -288,8 +326,38 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} }
} }
private applyQueryFilters(queryParams: Record<string, string | undefined>): void {
const userId = queryParams['usuario_id'];
const day = queryParams['dia'];
this.selectedUsuarioId = userId && this.usuarios.some(user => String(user.id) === String(userId)) ? String(userId) : 'Todos';
this.selectedDia = day && day !== 'Todos' && this.dias.includes(day) ? day : 'Todos';
const requestedMetro = String(queryParams['metro'] ?? '').trim();
const userMetro = this.selectedUsuarioId === 'Todos' ? '' : String(
this.allRoutes.find(route => String(route.json.usuario_id) === this.selectedUsuarioId)?.json.metro ?? ''
).trim();
const defaultMetro = this.metros.find(metro => ['cdmx', 'ciudaddemexico', 'mexicocity'].includes(this.normalizeMetro(metro))) ?? this.metros[0] ?? '';
this.selectedMetro = this.metros.includes(requestedMetro)
? requestedMetro
: this.metros.includes(userMetro)
? userMetro
: this.metros.includes(this.selectedMetro)
? this.selectedMetro
: defaultMetro;
}
private normalizeMetro(value: string): string {
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]/gi, '').toLowerCase();
}
private initMap() { private initMap() {
this.map = L.map('map-overview').setView([18.4861, -69.9312], 10); const territoryBounds = this.projectBounds();
this.map = L.map('map-overview', { maxBoundsViscosity: 0.85 });
if (territoryBounds) {
this.map.fitBounds(territoryBounds.pad(0.15));
this.map.setMaxBounds(territoryBounds.pad(0.35));
} else {
this.map.setView([18.4861, -69.9312], 10);
}
const pdvPane = this.map.createPane('pdvPane'); const pdvPane = this.map.createPane('pdvPane');
const heatPane = this.map.createPane('heatPane'); const heatPane = this.map.createPane('heatPane');
pdvPane.style.zIndex = '750'; pdvPane.style.zIndex = '750';
@@ -297,7 +365,11 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
const popupPane = this.map.getPane('popupPane'); const popupPane = this.map.getPane('popupPane');
if (popupPane) popupPane.style.zIndex = '1000'; if (popupPane) popupPane.style.zIndex = '1000';
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors' attribution: '© OpenStreetMap contributors',
bounds: territoryBounds?.pad(0.35),
keepBuffer: 1,
noWrap: true,
updateWhenIdle: true
}).addTo(this.map); }).addTo(this.map);
// Fix tiles after sidebar overlay // Fix tiles after sidebar overlay
setTimeout(() => this.map?.invalidateSize(), 200); setTimeout(() => this.map?.invalidateSize(), 200);
@@ -330,12 +402,16 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
private async renderFiltered() { private async renderFiltered() {
if (!this.map) return; if (!this.map) return;
const version = ++this.renderVersion;
this.abort$.next(); this.abort$.next();
this.clearMap(); this.clearMap();
this.isLoading = false; this.isLoading = false;
this.orsProgress = 0; this.orsProgress = 0;
this.orsTotal = 0; this.orsTotal = 0;
if (!this.filteredRoutes.length) { if (!this.filteredRoutes.length) {
this.renderUnassignedMarkers();
const territoryBounds = this.projectBounds();
if (territoryBounds) this.map.fitBounds(territoryBounds.pad(0.15));
this.cdr.detectChanges(); this.cdr.detectChanges();
return; return;
} }
@@ -367,7 +443,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
sequencedRoutes.forEach((segment, idx) => { sequencedRoutes.forEach((segment, idx) => {
const route = segment.route; const route = segment.route;
const j = route.json; const j = route.json;
const color = colorForUsuario(j.usuario_id).color; const color = colorForUsuario(`${j.usuario_id}|${j.dia}`).color;
const origin = segment.origin; const origin = segment.origin;
const dest = segment.destination; const dest = segment.destination;
bounds.push([origin[1], origin[0]]); bounds.push([origin[1], origin[0]]);
@@ -382,14 +458,14 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
// Marker for PDV // Marker for PDV
const pdvIcon = L.divIcon({ const pdvIcon = L.divIcon({
className: 'custom-marker marker-end', className: 'custom-marker marker-end',
html: `<div class="location-pin"><span>${segment.order}</span></div>`, html: `<div class="location-pin-wrapper"><div class="location-pin"><span>${segment.order}</span></div></div>`,
iconSize: [30, 38], iconSize: [42, 50],
iconAnchor: [15, 38], iconAnchor: [21, 46],
popupAnchor: [0, -18] popupAnchor: [0, -18]
}); });
const marker = L.marker([Number(j.latitud_pdv), Number(j.longitud_pdv)], { icon: pdvIcon, pane: 'pdvPane', zIndexOffset: 1000 }) const marker = L.marker([Number(j.latitud_pdv), Number(j.longitud_pdv)], { icon: pdvIcon, pane: 'pdvPane', zIndexOffset: 1000 })
.addTo(this.map!) .addTo(this.map!)
.bindPopup(`<strong>${j.nombre_pdv}</strong><br>Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`); .bindPopup(`<strong>${j.nombre_pdv}</strong><br><strong>Usuario:</strong> ${j.nombre_usuario}<br>Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`);
// Heat point // Heat point
const w = this.computeWeight(j); const w = this.computeWeight(j);
this.heatPointsCache.push([Number(j.latitud_pdv), Number(j.longitud_pdv), w]); this.heatPointsCache.push([Number(j.latitud_pdv), Number(j.longitud_pdv), w]);
@@ -414,6 +490,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
marker.on('click', onClick); marker.on('click', onClick);
}); });
if (this.selectedUsuarioId === 'Todos' && this.selectedDia === 'Todos') {
for (const route of this.filteredUnassignedRoutes) {
if (this.validPoint(route.latitud, route.longitud)) bounds.push([Number(route.latitud), Number(route.longitud)]);
}
}
this.updateHeatLayer(); this.updateHeatLayer();
this.toggleLayerVisibility(); this.toggleLayerVisibility();
@@ -423,14 +505,33 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} catch { /* ignore */ } } catch { /* ignore */ }
// Progressive ORS hydration — always in background (heat only hides visually) // Progressive ORS hydration — always in background (heat only hides visually)
this.hydrateWithOrs(); this.hydrateWithOrs(version);
this.renderUnassignedMarkers(); this.renderUnassignedMarkers();
this.cdr.detectChanges(); this.cdr.detectChanges();
} }
private projectBounds(): L.LatLngBounds | null {
const points: L.LatLngTuple[] = [];
for (const route of this.filteredRoutes) {
const data = route.json;
if (this.validPoint(data.latitud_usuario, data.longitud_usuario)) points.push([Number(data.latitud_usuario), Number(data.longitud_usuario)]);
if (this.validPoint(data.latitud_pdv, data.longitud_pdv)) points.push([Number(data.latitud_pdv), Number(data.longitud_pdv)]);
}
for (const route of this.filteredUnassignedRoutes) {
if (this.validPoint(route.latitud, route.longitud)) points.push([Number(route.latitud), Number(route.longitud)]);
}
return points.length ? L.latLngBounds(points) : null;
}
private validPoint(latitude: unknown, longitude: unknown): boolean {
const lat = Number(latitude);
const lng = Number(longitude);
return Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
}
private renderUnassignedMarkers(): void { private renderUnassignedMarkers(): void {
if (!this.map || this.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return; if (!this.map || this.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return;
for (const route of this.unassignedRoutes) { for (const route of this.filteredUnassignedRoutes) {
const icon = L.divIcon({ const icon = L.divIcon({
className: 'custom-marker unassigned-marker', className: 'custom-marker unassigned-marker',
html: '<div class="location-pin unassigned-pin"><span>!</span></div>', html: '<div class="location-pin unassigned-pin"><span>!</span></div>',
@@ -445,22 +546,29 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} }
} }
private async hydrateWithOrs() { private async hydrateWithOrs(version: number) {
if (!this.filteredRoutes.length) return; if (!this.filteredRoutes.length) return;
this.isLoading = true; this.isLoading = true;
this.orsTotal = this.filteredRoutes.length; this.orsTotal = this.filteredRoutes.length;
this.orsProgress = 0; this.orsProgress = 0;
this.cdr.detectChanges(); this.cdr.detectChanges();
const pares = this.mapRoutes.map(route => ({ const routesForRender = [...this.mapRoutes];
const embeddedResults = routesForRender
.filter(route => !!route.data.json.geojson)
.map(route => ({ pdv_id: route.requestId, geojson: route.data.json.geojson }));
const pares = routesForRender.filter(route => !route.data.json.geojson).map(route => ({
pdv_id: route.requestId, pdv_id: route.requestId,
origin: route.origin, origin: route.origin,
destination: route.destination destination: route.destination
})); }));
const { promise } = this.orsService.processPairsWithProgress(pares, 2, true, this.abort$);
try { try {
const results = await promise; const remoteResults = pares.length
? await this.orsService.processPairsWithProgress(pares, 2, true, this.abort$).promise
: [];
if (version !== this.renderVersion) return;
const results = [...embeddedResults, ...remoteResults];
if (!results.length) { if (!results.length) {
this.isLoading = false; this.isLoading = false;
this.cdr.detectChanges(); this.cdr.detectChanges();
@@ -482,8 +590,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} }
} }
const useMap = geoByRequest.size > 0; const useMap = geoByRequest.size > 0;
for (let i = 0; i < this.mapRoutes.length; i++) { for (let i = 0; i < routesForRender.length; i++) {
const mr = this.mapRoutes[i]; if (version !== this.renderVersion) return;
const mr = routesForRender[i];
let geo: unknown = null; let geo: unknown = null;
if (useMap) { if (useMap) {
geo = geoByRequest.get(mr.requestId); geo = geoByRequest.get(mr.requestId);
@@ -519,7 +628,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
mr.segmentDistanceKm = summary.distance / 1000; mr.segmentDistanceKm = summary.distance / 1000;
mr.segmentDurationHours = summary.duration / 3600; mr.segmentDurationHours = summary.duration / 3600;
mr.marker?.setPopupContent( mr.marker?.setPopupContent(
`<strong>${mr.data.json.nombre_pdv}</strong><br>Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}<br>${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min` `<strong>${mr.data.json.nombre_pdv}</strong><br><strong>Usuario:</strong> ${mr.data.json.nombre_usuario}<br>Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}<br>${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min`
); );
} }
line.on('click', () => this.selectRoute(mr)); line.on('click', () => this.selectRoute(mr));
@@ -535,10 +644,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} catch { } catch {
// Ignore abort / network errors, fallback lines remain // Ignore abort / network errors, fallback lines remain
} finally { } finally {
if (version === this.renderVersion) {
this.isLoading = false; this.isLoading = false;
this.cdr.detectChanges(); this.cdr.detectChanges();
} }
} }
}
private extractCoords(geojson: unknown): L.LatLngExpression[] { private extractCoords(geojson: unknown): L.LatLngExpression[] {
const g = geojson as { type: string; features?: { geometry: { coordinates: number[][] } }[]; coordinates?: number[][] }; const g = geojson as { type: string; features?: { geometry: { coordinates: number[][] } }[]; coordinates?: number[][] };
@@ -927,7 +1038,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
this.toggleLayerVisibility(); this.toggleLayerVisibility();
const needsHydrate = !this.mapRoutes.some(m => m.orsLine) || this.orsTotal === 0 || this.orsProgress < this.orsTotal; const needsHydrate = !this.mapRoutes.some(m => m.orsLine) || this.orsTotal === 0 || this.orsProgress < this.orsTotal;
if (!this.showHeat && needsHydrate) { if (!this.showHeat && needsHydrate) {
this.hydrateWithOrs(); this.hydrateWithOrs(this.renderVersion);
} }
this.cdr.detectChanges(); this.cdr.detectChanges();
} }
@@ -950,6 +1061,6 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} }
goBack() { goBack() {
this.router.navigate(['/home']); this.router.navigate(['/proyectos', this.activatedRoute.snapshot.paramMap.get('id')]);
} }
} }
@@ -0,0 +1,25 @@
.pdvs-topbar{display:flex;align-items:center;gap:10px;padding:9px 24px;border-bottom:3px solid var(--glm-verde);background:#fff;color:var(--glm-azul);font-weight:700}.pdvs-topbar img{height:38px}.pdvs-topbar .user-profile{display:flex;align-items:center;gap:8px;margin-left:auto}.pdvs-topbar .user-profile img,.avatar-fallback{width:30px;height:30px;border-radius:50%}.avatar-fallback{display:grid;place-items:center;background:var(--glm-verde);color:#fff}.pdvs-topbar button{padding:8px 10px;border:1px solid var(--glm-gris-medio);border-radius:4px;background:#fff;color:var(--glm-azul);font-weight:700;cursor:pointer}.pdvs-page{max-width:1200px;margin:auto;padding:26px 24px}.pdvs-page>header{display:flex;justify-content:space-between;gap:20px;border-bottom:3px solid var(--glm-verde);padding-bottom:14px}.pdvs-page h1,.pdvs-page h2{margin:0;color:var(--glm-azul)}.pdvs-page header p{color:var(--glm-acero)}.import-button,.primary{padding:9px 12px;border-radius:4px;background:var(--glm-azul);color:#fff;font-weight:700;cursor:pointer}.import-button input{display:none}.pdv-form,.pdv-table{margin-top:18px;padding:18px;border:1px solid var(--glm-gris-medio);border-top:3px solid var(--glm-azul);border-radius:4px;background:#fff}.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-top:14px}.form-grid label{display:grid;gap:5px;color:var(--glm-azul);font-size:.75rem;font-weight:700;text-transform:uppercase}.form-grid input,.form-grid select{padding:9px;border:1px solid #9aaab3;border-radius:4px;background:#fff;color:#2f3f48}.form-actions{display:flex;gap:8px;margin-top:14px}.form-actions button{padding:9px 12px;border:1px solid var(--glm-gris-medio);border-radius:4px;cursor:pointer}.table-wrap{overflow:auto;margin-top:12px}table{width:100%;border-collapse:collapse;font-size:.82rem}th{padding:9px;background:var(--glm-azul);color:#fff;text-align:left}td{padding:9px;border-bottom:1px solid var(--glm-gris-claro)}td button{margin-right:5px;padding:6px;border:1px solid var(--glm-gris-medio);border-radius:4px;background:#fff;cursor:pointer}.danger{color:#c9252d}.error{color:#c9252d}.success{color:var(--glm-verde);font-weight:700}@media(max-width:650px){.pdvs-topbar{padding:8px 12px;flex-wrap:wrap}.pdvs-page{padding:18px 12px}.pdvs-page>header{flex-direction:column}}
.import-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; width: fit-content; min-width: 0; white-space: nowrap; text-decoration: none; }
.pdvs-page > header .import-button { align-self: flex-start; height: auto; }
.pdv-table td:last-child { white-space: nowrap; }
.pdv-table td:last-child button { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; margin: 0 6px 0 0; padding: 0; font-size: 1rem; line-height: 1; }
.pdv-table td:last-child button.edit-button { width: auto; min-width: 86px; padding: 0 10px; gap: 6px; }
.pdv-table td:last-child button.edit-button,
.pdv-table td.actions-cell button.edit-button { display: inline-flex !important; align-items: center !important; justify-content: center !important; width: auto !important; min-width: 86px !important; height: 38px !important; padding: 0 12px !important; gap: 6px !important; border: 1px solid var(--glm-gris-medio) !important; border-radius: 4px !important; background: #fff !important; color: var(--glm-azul) !important; font: 700 .78rem/1 Arial, sans-serif !important; visibility: visible !important; opacity: 1 !important; }
.pdv-table td:last-child button.edit-button span,
.pdv-table td.actions-cell button.edit-button span { display: inline !important; color: var(--glm-azul) !important; font-size: .78rem !important; font-weight: 700 !important; visibility: visible !important; }
.pdv-table td.actions-cell { white-space: nowrap; min-width: 170px; }
.pdv-table td:last-child button:last-child { margin-right: 0; }
.pdv-table td:last-child button.danger { color: #c9252d; }
.pdv-table .row-actions { display: flex; gap: 5px; min-width: 250px; white-space: nowrap; }
.pdv-table .row-actions button { width: auto !important; min-width: 0 !important; height: auto !important; margin: 0 !important; padding: 7px 10px !important; border: 1px solid var(--glm-gris-medio) !important; border-radius: 4px !important; background: #fff !important; color: var(--glm-azul) !important; font: 600 .78rem/1.2 Arial, sans-serif !important; }
.pdv-table .row-actions button:hover { background: var(--glm-verde-bg) !important; border-color: var(--glm-verde) !important; }
.pdv-table .row-actions button.delete { color: #c9252d !important; }
.pdv-table .table-header { display:flex; justify-content:space-between; align-items:center; gap:12px; }.pdv-table .table-header > div { display:flex; align-items:center; gap:8px; }.pdv-table .selected-count { color:var(--glm-azul); font-size:.78rem; font-weight:700; }.pdv-table .table-header button,.pdv-table .bulk-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:7px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.pdv-table .table-header button:hover,.pdv-table .bulk-actions button:hover:not(:disabled) { background:var(--glm-verde-bg); border-color:var(--glm-verde); }.pdv-table .bulk-actions { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0; padding:12px; border:1px solid var(--glm-verde-claro); background:var(--glm-verde-bg); }.pdv-table .bulk-actions button.delete { color:#c9252d; }.pdv-table .select-column { width:38px; }
.page-back-button { display:inline-flex; align-items:center; gap:6px; margin-bottom:18px; padding:8px 11px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:700 .82rem Arial,sans-serif; }.page-back-button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }
@media (max-width: 640px) {
.pdvs-page { padding: 18px 14px; }
.pdvs-page > header { align-items: flex-start; flex-direction: column; }
.import-button { align-self: flex-start; }
}
@@ -0,0 +1,5 @@
<div class="pdvs-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
<main class="pdvs-page"><button type="button" class="page-back-button" (click)="goBack()"><i class="pi pi-arrow-left"></i> Volver</button><header><div><h1>Gestionar PDVs</h1><p>{{ pdvs.length }} puntos de venta registrados en este proyecto</p></div><label class="import-button"><i class="pi pi-file-import"></i> {{ importing ? 'Importando...' : 'Importar Excel' }}<input type="file" accept=".xlsx,.xls" [disabled]="importing" (change)="importFile($event)"></label></header>
<p class="success" *ngIf="message">{{ message }}</p><p class="error" *ngIf="error">{{ error }}</p>
<section class="pdv-form"><h2>{{ editing ? 'Editar PDV' : 'Nuevo PDV' }}</h2><div class="form-grid"><label>ID<input [(ngModel)]="form.id"></label><label>Nombre<input [(ngModel)]="form.nombre"></label><label>Segmentación<select [(ngModel)]="form.segmentacion"><option *ngFor="let value of ['AA','A','B','C','D']" [value]="value">{{ value }}</option></select></label><label>Latitud<input type="number" step="any" [(ngModel)]="form.latitud"></label><label>Longitud<input type="number" step="any" [(ngModel)]="form.longitud"></label><label>Visitas semanales<input type="number" min="1" max="7" [(ngModel)]="form.visitas"></label><label>Duración (horas)<input type="number" min="0.1" step="0.1" [(ngModel)]="form.duracion"></label><label>Prioridad<input type="number" min="1" max="5" [(ngModel)]="form.prioridad"></label><label>Cobertura<input [(ngModel)]="form.cobertura"></label><label>Zona<input [(ngModel)]="form.zona"></label><label>Región<input [(ngModel)]="form.region"></label><label>Estado<input [(ngModel)]="form.estado"></label><label>Ciudad<input [(ngModel)]="form.ciudad"></label><label>Estado<select [(ngModel)]="form.activo"><option [ngValue]="true">Activo</option><option [ngValue]="false">Inactivo</option></select></label></div><div class="form-actions"><button type="button" class="primary" (click)="save()" [disabled]="saving">{{ saving ? 'Guardando...' : (editing ? 'Guardar cambios' : 'Crear PDV') }}</button><button type="button" *ngIf="editing" (click)="cancelEdit()">Cancelar</button></div></section>
<section class="pdv-table"><div class="table-header"><h2>PDVs del proyecto</h2><div><span class="selected-count" *ngIf="selectedIds.size">{{ selectedIds.size }} seleccionados</span><button type="button" (click)="toggleAll(!allSelected)" [disabled]="loading">{{ allSelected ? 'Deseleccionar todos' : 'Seleccionar todos' }}</button></div></div><p *ngIf="loading">Cargando...</p><div class="bulk-actions" *ngIf="selectedIds.size"><button type="button" (click)="setSelectedActive(true)" [disabled]="bulkSaving">Activar</button><button type="button" (click)="setSelectedActive(false)" [disabled]="bulkSaving">Desactivar</button><button type="button" class="delete" (click)="removeSelected()" [disabled]="bulkSaving">Eliminar seleccionados</button></div><div class="table-wrap" *ngIf="!loading"><table><thead><tr><th class="select-column"><input type="checkbox" [checked]="allSelected" (change)="toggleAll($any($event.target).checked)" aria-label="Seleccionar todos los PDVs"></th><th>ID</th><th>Nombre</th><th>Segmentación</th><th>Ciudad</th><th>Estado</th><th>Zona</th><th>Acciones</th></tr></thead><tbody><tr *ngFor="let pdv of pdvs"><td><input type="checkbox" [checked]="selectedIds.has(pdv.id)" (change)="toggleSelection(pdv.id, $any($event.target).checked)" [attr.aria-label]="'Seleccionar ' + pdv.pdv_id"></td><td>{{ pdv.pdv_id }}</td><td>{{ value(pdv, 'nombre_del_pdv') }}</td><td>{{ value(pdv, 'segmentacion') }}</td><td>{{ value(pdv, 'ciudad') }}</td><td>{{ value(pdv, 'estado') }}</td><td>{{ value(pdv, 'zona') }}</td><td class="row-actions"><button type="button" (click)="edit(pdv)">Editar</button><button type="button" (click)="toggleActive(pdv)">{{ isActive(pdv) ? 'Desactivar' : 'Activar' }}</button><button type="button" class="delete" (click)="remove(pdv)">Eliminar</button></td></tr><tr *ngIf="!pdvs.length"><td colspan="8">No hay PDVs registrados.</td></tr></tbody></table></div></section></main>
@@ -0,0 +1,207 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import * as XLSX from 'xlsx';
import { ProjectService } from '../../core/services/project.service';
import { RuteroProyectoPdv } from '../../core/models/project.model';
import { ExcelPreviewService } from '../../shared/services/excel-preview.service';
import { normalizeRow } from '../../shared/utils/header-normalization.utils';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
interface PdvForm {
id: string;
nombre: string;
segmentacion: string;
latitud: number;
longitud: number;
visitas: number;
duracion: number;
prioridad: number;
activo: boolean;
cobertura: string;
zona: string;
region: string;
estado: string;
ciudad: string;
}
@Component({
standalone: true,
selector: 'app-pdvs',
imports: [CommonModule, FormsModule],
templateUrl: './pdvs.component.html',
styleUrl: './pdvs.component.css'
})
export class PdvsComponent implements OnInit {
projectId = '';
pdvs: RuteroProyectoPdv[] = [];
form = this.emptyForm();
editing: RuteroProyectoPdv | null = null;
loading = true;
saving = false;
importing = false;
error = '';
message = '';
projectCountry = 'Republica Dominicana';
selectedIds = new Set<string>();
bulkSaving = false;
constructor(
private projects: ProjectService,
private preview: ExcelPreviewService,
private auth: AuthService,
private route: ActivatedRoute,
private router: Router,
public profile: UserProfileService,
private cdr: ChangeDetectorRef
) {}
ngOnInit(): void {
this.projectId = this.route.snapshot.paramMap.get('id') ?? '';
if (!this.projectId) { void this.router.navigate(['/home']); return; }
void this.load();
}
async load(): Promise<void> {
this.loading = true;
this.error = '';
try {
this.pdvs = await this.projects.pdvs(this.projectId);
this.selectedIds.clear();
const country = this.pdvs.map(pdv => normalizeRow(pdv.pdv_data)['pais']).find(Boolean);
if (country) this.projectCountry = String(country);
}
catch (error: any) { this.error = error?.message ?? 'No se pudieron cargar los PDVs.'; }
finally { this.loading = false; this.cdr.detectChanges(); }
}
edit(pdv: RuteroProyectoPdv): void {
const row = normalizeRow(pdv.pdv_data);
this.editing = pdv;
this.form = {
id: String(row['id'] ?? pdv.pdv_id), nombre: String(row['nombre_del_pdv'] ?? ''), segmentacion: String(row['segmentacion'] ?? 'D'),
latitud: Number(row['latitud'] ?? 0), longitud: Number(row['longitud'] ?? 0), visitas: Number(row['visitas_semanales'] ?? 1),
duracion: Number(row['duracion_visitahoras'] ?? row['duracion_visita_horas'] ?? 1), prioridad: Number(row['prioridad'] ?? 5),
activo: !['no', 'n', '0', 'false'].includes(String(row['activo'] ?? 'si').toLowerCase()), cobertura: String(row['cobertura'] ?? ''),
zona: String(row['zona'] ?? ''), region: String(row['region'] ?? ''), estado: String(row['estado'] ?? ''), ciudad: String(row['ciudad'] ?? '')
};
}
cancelEdit(): void { this.editing = null; this.form = this.emptyForm(); this.error = ''; }
async save(): Promise<void> {
if (!this.form.id.trim() || !this.form.nombre.trim()) { this.error = 'ID y nombre son obligatorios.'; return; }
if (!Number.isFinite(this.form.latitud) || !Number.isFinite(this.form.longitud)) { this.error = 'Coordenadas inválidas.'; return; }
this.saving = true; this.error = ''; this.message = '';
const row: Record<string, unknown> = {
...(this.editing?.pdv_data ?? {}), ID: this.form.id.trim(), 'Nombre del PDV': this.form.nombre.trim(), Segmentación: this.form.segmentacion,
Latitud: this.form.latitud, Longitud: this.form.longitud, 'Visitas semanales': this.form.visitas, 'Duración visita(horas)': this.form.duracion,
Prioridad: this.form.prioridad, Activo: this.form.activo ? 'Sí' : 'No', Cobertura: this.form.cobertura, Zona: this.form.zona,
Región: this.form.region, Estado: this.form.estado, Ciudad: this.form.ciudad, País: normalizeRow(this.editing?.pdv_data ?? {})['pais'] ?? this.projectCountry
};
try {
if (this.editing) await this.projects.updatePdv(this.editing.id, row);
else await this.projects.addPdv(this.projectId, row);
this.message = this.editing ? 'PDV actualizado.' : 'PDV creado.';
this.cancelEdit();
await this.load();
} catch (error: any) { this.error = error?.message ?? 'No se pudo guardar el PDV.'; }
finally { this.saving = false; this.cdr.detectChanges(); }
}
async importFile(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
this.importing = true; this.error = ''; this.message = '';
try {
const result = await this.preview.previewFile(file, 'PDV');
if (!result.valid) throw new Error(result.errors[0]?.message ?? 'El archivo PDV contiene errores.');
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(workbook.Sheets[workbook.SheetNames[0]], { defval: null, raw: true });
await this.projects.upsertPdvs(this.projectId, rows);
this.message = `${rows.length} PDVs importados o actualizados.`;
await this.load();
} catch (error: any) { this.error = error?.message ?? 'No se pudo importar el archivo.'; }
finally { this.importing = false; input.value = ''; this.cdr.detectChanges(); }
}
async remove(pdv: RuteroProyectoPdv): Promise<void> {
if (!confirm(`¿Eliminar el PDV ${pdv.pdv_id}?`)) return;
try { await this.projects.removePdv(pdv.id); await this.load(); }
catch (error: any) { this.error = error?.message ?? 'No se pudo eliminar el PDV.'; }
}
isActive(pdv: RuteroProyectoPdv): boolean {
return !['no', 'n', '0', 'false'].includes(String(normalizeRow(pdv.pdv_data)['activo'] ?? 'si').toLowerCase());
}
async toggleActive(pdv: RuteroProyectoPdv): Promise<void> {
const active = !this.isActive(pdv);
try {
await this.projects.updatePdv(pdv.id, { ...pdv.pdv_data, Activo: active ? 'Sí' : 'No' });
this.message = active ? 'PDV activado.' : 'PDV desactivado.';
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudo actualizar el estado del PDV.';
}
}
get allSelected(): boolean {
return this.pdvs.length > 0 && this.pdvs.every(pdv => this.selectedIds.has(pdv.id));
}
get selectedPdvs(): RuteroProyectoPdv[] {
return this.pdvs.filter(pdv => this.selectedIds.has(pdv.id));
}
toggleSelection(id: string, checked: boolean): void {
checked ? this.selectedIds.add(id) : this.selectedIds.delete(id);
}
toggleAll(checked: boolean): void {
this.selectedIds = checked ? new Set(this.pdvs.map(pdv => pdv.id)) : new Set<string>();
}
async setSelectedActive(active: boolean): Promise<void> {
const selected = this.selectedPdvs;
if (!selected.length) return;
this.bulkSaving = true;
this.error = '';
try {
await Promise.all(selected.map(pdv => this.projects.updatePdv(pdv.id, { ...pdv.pdv_data, Activo: active ? 'Sí' : 'No' })));
this.message = `${selected.length} PDVs ${active ? 'activados' : 'desactivados'}.`;
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudo actualizar el estado de los PDVs.';
} finally {
this.bulkSaving = false;
}
}
async removeSelected(): Promise<void> {
const selected = this.selectedPdvs;
if (!selected.length || !confirm(`¿Eliminar ${selected.length} PDVs seleccionados? Esta acción no se puede deshacer.`)) return;
this.bulkSaving = true;
this.error = '';
try {
await Promise.all(selected.map(pdv => this.projects.removePdv(pdv.id)));
this.message = `${selected.length} PDVs eliminados.`;
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudieron eliminar los PDVs.';
} finally {
this.bulkSaving = false;
}
}
value(pdv: RuteroProyectoPdv, field: string): unknown { return normalizeRow(pdv.pdv_data)[field] ?? '-'; }
goBack(): void { void this.router.navigate(['/proyectos', this.projectId]); }
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
private emptyForm(): PdvForm {
return { id: '', nombre: '', segmentacion: 'D', latitud: 18.4861, longitud: -69.9312, visitas: 1, duracion: 1, prioridad: 5, activo: true, cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
}
}
@@ -0,0 +1 @@
<div class="projects-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="topbar-actions"><a routerLink="/home">Mis proyectos</a><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div></div><main class="projects-page narrow"><header><div><h1>Crear proyecto</h1><p>Define el proyecto. Podrás agregar sus PDVs después desde su gestor.</p></div></header><section class="project-form"><label>Nombre del proyecto<input [(ngModel)]="name" placeholder="Ej. Ruta Santo Domingo Norte" maxlength="120"></label><label>Descripción<textarea [(ngModel)]="description" placeholder="Describe el objetivo o alcance del proyecto" maxlength="500"></textarea></label><label>País<select [(ngModel)]="country"><option value="Republica Dominicana">República Dominicana</option><option value="MEXICO">México</option></select></label><p class="error" *ngIf="error">{{ error }}</p><button class="primary" type="button" [disabled]="!canCreate" (click)="create()">{{ saving ? 'Creando proyecto...' : 'Crear proyecto' }}</button></section></main>
@@ -0,0 +1,28 @@
import { ChangeDetectorRef, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { ProjectService } from '../../core/services/project.service';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
@Component({ standalone: true, selector: 'app-new-project', imports: [CommonModule, FormsModule, RouterLink], templateUrl: './new-project.component.html', styleUrl: './projects.component.css' })
export class NewProjectComponent {
name = '';
description = '';
country = 'Republica Dominicana';
saving = false;
error = '';
constructor(private projects: ProjectService, private auth: AuthService, public profile: UserProfileService, private router: Router, private cdr: ChangeDetectorRef) {}
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
get canCreate(): boolean { return !!this.name.trim() && !!this.country && !this.saving; }
async create(): Promise<void> {
if (!this.canCreate) return;
this.saving = true; this.error = '';
try {
const project = await this.projects.create(this.name, this.description, this.country);
await this.router.navigate(['/proyectos', project.id]);
} catch (error: any) { this.error = error?.message ?? 'No se pudo crear el proyecto.'; }
finally { this.saving = false; this.cdr.detectChanges(); }
}
}
@@ -0,0 +1,3 @@
<div class="projects-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="topbar-actions"><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div></div>
<main class="projects-page"><header><div><h1>Mis proyectos</h1><p>Organiza y conserva tus planificaciones de rutas.</p></div><a class="primary" routerLink="/proyectos/nuevo"><i class="pi pi-plus"></i> Crear proyecto</a></header><p class="error" *ngIf="error">{{ error }}</p><p *ngIf="loading">Cargando proyectos...</p><section class="project-grid" *ngIf="!loading"><article class="project-card" *ngFor="let project of projects"><h2>{{ project.nombre }}</h2><p>{{ project.descripcion || 'Sin descripción' }}</p><small>Actualizado: {{ project.updated_at | date:'medium' }}</small><div class="project-card-actions"><button type="button" (click)="open(project)" [disabled]="deletingId === project.id">Abrir proyecto</button><button type="button" class="danger" (click)="requestRemove(project)" [disabled]="deletingId === project.id"><i class="pi pi-trash"></i> Eliminar</button></div></article><section class="empty" *ngIf="!projects.length"><i class="pi pi-folder-open"></i><h2>Aún no tienes proyectos</h2><p>Crea uno y agrega sus PDVs cuando estés listo para planificar.</p><a class="primary" routerLink="/proyectos/nuevo">Crear proyecto</a></section></section></main>
<div class="delete-dialog-backdrop" *ngIf="projectToDelete" (click)="closeDeleteDialog()"><section class="delete-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-project-title" (click)="$event.stopPropagation()"><i class="pi pi-exclamation-triangle"></i><h2 id="delete-project-title">Eliminar proyecto</h2><p>¿Eliminar permanentemente <strong>{{ projectToDelete.nombre }}</strong>?</p><p class="delete-dialog-warning">También se eliminarán sus PDVs, empleados y rutas. Esta acción no se puede deshacer.</p><div><button type="button" (click)="closeDeleteDialog()" [disabled]="!!deletingId">Cancelar</button><button type="button" class="danger" (click)="remove()" [disabled]="!!deletingId">{{ deletingId ? 'Eliminando...' : 'Eliminar proyecto' }}</button></div></section></div>
@@ -0,0 +1,45 @@
import { ChangeDetectorRef, Component, HostListener, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { ProjectService } from '../../core/services/project.service';
import { RuteroProyecto } from '../../core/models/project.model';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
@Component({ standalone: true, selector: 'app-project-list', imports: [CommonModule, RouterLink], templateUrl: './project-list.component.html', styleUrl: './projects.component.css' })
export class ProjectListComponent implements OnInit {
projects: RuteroProyecto[] = [];
loading = true;
deletingId = '';
projectToDelete: RuteroProyecto | null = null;
error = '';
constructor(private projectsService: ProjectService, private auth: AuthService, public profile: UserProfileService, private router: Router, private cdr: ChangeDetectorRef) {}
ngOnInit(): void { void this.load(); }
async load(): Promise<void> {
this.loading = true;
try { this.projects = await this.projectsService.list(); }
catch (error: any) { this.error = error?.message ?? 'No se pudieron cargar los proyectos.'; }
finally { this.loading = false; this.cdr.detectChanges(); }
}
open(project: RuteroProyecto): void { void this.router.navigate(['/proyectos', project.id]); }
requestRemove(project: RuteroProyecto): void { this.projectToDelete = project; }
closeDeleteDialog(): void { if (!this.deletingId) this.projectToDelete = null; }
@HostListener('document:keydown.escape') onEscape(): void { this.closeDeleteDialog(); }
async remove(): Promise<void> {
const project = this.projectToDelete;
if (!project) return;
this.deletingId = project.id;
this.error = '';
try {
await this.projectsService.remove(project.id);
this.projects = this.projects.filter(item => item.id !== project.id);
} catch (error: any) {
this.error = error?.message ?? 'No se pudo eliminar el proyecto.';
} finally {
this.deletingId = '';
this.projectToDelete = null;
this.cdr.detectChanges();
}
}
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
}
@@ -0,0 +1,7 @@
.projects-topbar { display:flex; align-items:center; gap:12px; padding:9px 24px; border-bottom:3px solid var(--glm-verde); color:var(--glm-azul); font-weight:700; background:#fff; }.projects-topbar img { height:38px; }.projects-topbar a { margin-left:auto; color:var(--glm-azul); text-decoration:none; }.projects-page { max-width:1100px; margin:0 auto; padding:30px 24px; }.projects-page header { display:flex; justify-content:space-between; align-items:flex-start; gap:20px; border-bottom:3px solid var(--glm-verde); padding-bottom:14px; }.projects-page h1,.projects-page h2 { color:var(--glm-azul); margin:0; }.projects-page header p,.project-card p,small { color:var(--glm-acero); }.primary,.project-card button { display:inline-block; border:1px solid var(--glm-azul); border-radius:4px; padding:9px 12px; background:var(--glm-azul); color:#fff; font:inherit; font-weight:700; text-decoration:none; cursor:pointer; }.project-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(245px,1fr)); gap:16px; margin-top:22px; }.project-card,.project-form,.empty { border:1px solid var(--glm-gris-medio); border-top:3px solid var(--glm-azul); border-radius:4px; padding:18px; background:#fff; }.project-card { display:flex; flex-direction:column; gap:12px; }.project-card button { margin-top:auto; }.state { align-self:flex-start; color:var(--glm-verde); font-size:.75rem; font-weight:700; text-transform:uppercase; }.empty { grid-column:1/-1; text-align:center; padding:38px; }.narrow { max-width:720px; }.project-form { display:grid; gap:16px; margin-top:20px; }.project-form label { display:grid; gap:6px; color:var(--glm-azul); font-size:.8rem; font-weight:700; text-transform:uppercase; }.project-form input { padding:10px; border:1px solid var(--glm-gris-medio); border-radius:4px; font:inherit; }.file-label span { color:var(--glm-acero); font-size:.75rem; text-transform:none; }.error { color:#c9252d; }.valid { color:var(--glm-verde); font-weight:700; } @media(max-width:640px){.projects-page{padding:20px 16px}.projects-page header{display:grid}.projects-topbar{padding:9px 16px}}
.project-form input,.project-form select { background:#fff; color:#2f3f48; border-color:#9aaab3; }.project-form select { padding:10px; border:1px solid #9aaab3; border-radius:4px; font:inherit; }.project-form input::placeholder { color:#71808a; opacity:1; }.project-form input:focus,.project-form select:focus { outline:2px solid rgba(108,194,74,.35); border-color:var(--glm-verde); background:#fff; color:#1f2d34; }.project-form input[type=file] { color:#2f3f48; background:#fff; cursor:pointer; }
.topbar-actions { display:flex; align-items:center; gap:8px; margin-left:auto; }.topbar-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:inherit; font-size:.82rem; font-weight:700; padding:8px 12px; }.topbar-actions button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }.manage-employees { display:inline-flex; align-items:center; gap:6px; margin-bottom:14px; border:1px solid var(--glm-gris-medio); border-radius:4px; padding:8px 12px; color:var(--glm-azul); font-weight:700; text-decoration:none; background:#fff; }.manage-employees:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
.project-card-actions { display:flex; gap:8px; margin-top:auto; }.project-card-actions button { margin-top:0; }.project-card-actions .danger { border-color:#c9252d; background:#fff; color:#c9252d; }.project-card-actions .danger:hover:not(:disabled) { background:#c9252d; color:#fff; }.project-card-actions button:disabled { cursor:not-allowed; opacity:.65; }
.project-form textarea { min-height:88px; padding:10px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-gris-oscuro); font:inherit; resize:vertical; }
.delete-dialog-backdrop { position:fixed; inset:0; z-index:2000; display:grid; place-items:center; padding:20px; background:rgba(24,36,44,.56); }.delete-dialog { width:min(100%,440px); padding:26px; border-top:3px solid #c9252d; border-radius:4px; background:#fff; box-shadow:0 18px 48px rgba(0,0,0,.28); }.delete-dialog>i { color:#c9252d; font-size:1.5rem; }.delete-dialog h2 { margin:10px 0 8px; color:var(--glm-azul); }.delete-dialog p { margin:8px 0; color:var(--glm-gris-oscuro); line-height:1.45; }.delete-dialog-warning { color:var(--glm-acero)!important; font-size:.9rem; }.delete-dialog>div { display:flex; justify-content:flex-end; gap:8px; margin-top:20px; }.delete-dialog button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:9px 12px; background:#fff; color:var(--glm-azul); cursor:pointer; font:700 .82rem Arial,sans-serif; }.delete-dialog button.danger { border-color:#c9252d; color:#c9252d; }.delete-dialog button.danger:hover:not(:disabled) { background:#c9252d; color:#fff; }.delete-dialog button:disabled { cursor:not-allowed; opacity:.6; }
@@ -87,13 +87,17 @@
box-shadow: 0 0 0 3px rgba(108, 194, 74, 0.15); box-shadow: 0 0 0 3px rgba(108, 194, 74, 0.15);
} }
button[type="submit"] { .google-login {
width: 100%; width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 8px; margin-top: 8px;
padding: 11px; padding: 11px;
background: var(--glm-azul); background: #fff;
color: #fff; color: var(--glm-gris-oscuro);
border: none; border: 1px solid var(--glm-gris-medio);
border-radius: 4px; border-radius: 4px;
font-size: 15px; font-size: 15px;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
@@ -102,15 +106,25 @@ button[type="submit"] {
transition: background 0.2s; transition: background 0.2s;
} }
button[type="submit"]:hover:not(:disabled) { .google-login:hover:not(:disabled) {
background: var(--glm-verde); border-color: var(--glm-azul);
background: var(--glm-verde-bg);
} }
button[type="submit"]:disabled { .google-login:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.google-mark { color: #4285f4; font-size: 1.1rem; font-weight: 700; }
.domain-note { margin: 10px 0 0; color: var(--glm-acero); font-size: .78rem; text-align: center; }
.login-error {
margin: 12px 0 0;
color: #c9252d;
font-size: 0.82rem;
}
.footer { .footer {
text-align: center; text-align: center;
margin-top: 24px; margin-top: 24px;
@@ -11,36 +11,15 @@
Planifica rutas inteligentes de forma eficiente Planifica rutas inteligentes de forma eficiente
</p> </p>
<form (ngSubmit)="login()" #form="ngForm"> <div class="login-actions">
<button type="button" class="google-login" (click)="loginWithGoogle()" [disabled]="loading">
<div class="input-group"> <span class="google-mark">G</span>
<label>Email</label> <span *ngIf="!loading">Continuar con Google</span>
<input <span *ngIf="loading">{{ loadingText }}</span>
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> </button>
<p class="domain-note">Solo cuentas @gomezleemarketing.com</p>
</form> <p class="login-error" *ngIf="error">{{ error }}</p>
</div>
<div class="footer"> <div class="footer">
© 2026 Route Planner System © 2026 Route Planner System
@@ -1,29 +1,57 @@
import { Component, ChangeDetectionStrategy } from '@angular/core'; import { Component, ChangeDetectionStrategy, ChangeDetectorRef, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
@Component({ @Component({
standalone: true, standalone: true,
selector: 'app-login', selector: 'app-login',
imports: [CommonModule, FormsModule], imports: [CommonModule],
templateUrl: './login.component.html', templateUrl: './login.component.html',
styleUrls: ['./login.component.css'], styleUrls: ['./login.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class LoginComponent { export class LoginComponent implements OnInit {
email = '';
password = '';
loading = false; loading = false;
error = '';
loadingText = 'Redirigiendo a Google...';
constructor(private router: Router) {} constructor(private router: Router, private auth: AuthService, private profile: UserProfileService, private cdr: ChangeDetectorRef) {}
login() {
async ngOnInit(): Promise<void> {
const session = await this.auth.getSession();
if (!session) return;
this.loading = true; this.loading = true;
this.loadingText = 'Obteniendo datos del usuario...';
setTimeout(() => { try {
await this.profile.load(session.user.email ?? '', String(session.user.user_metadata?.['avatar_url'] ?? ''));
await this.router.navigate(['/home']);
} catch (error: any) {
await this.auth.signOut();
this.error = error?.message ?? 'No se pudieron obtener los datos del usuario.';
} finally {
this.loading = false; this.loading = false;
this.router.navigate(['/home']); this.cdr.detectChanges();
}, 1500); }
}
async loginWithGoogle() {
this.error = '';
this.loading = true;
this.loadingText = 'Redirigiendo a Google...';
try {
const error = await this.auth.signInWithGoogle();
if (error) {
this.error = error;
return;
}
} catch {
this.error = 'No se pudo iniciar sesión. Intenta de nuevo.';
} finally {
this.loading = false;
this.cdr.detectChanges();
}
} }
} }
@@ -36,4 +36,4 @@ export const usuarioRowSchema = z.object({
}); });
export type UsuarioRow = z.infer<typeof usuarioRowSchema>; export type UsuarioRow = z.infer<typeof usuarioRowSchema>;
export const USUARIO_MAX_ROWS = 50; export const USUARIO_MAX_ROWS = 300;
@@ -165,7 +165,15 @@ export class ExcelPreviewService {
'Longitud': cleanNum(row['longitud']), 'Longitud': cleanNum(row['longitud']),
'Visitas semanales': Number.isFinite(visits) ? visits : 1, 'Visitas semanales': Number.isFinite(visits) ? visits : 1,
'Duración visita(horas)': Number.isFinite(duration) ? duration : weeklyHours, 'Duración visita(horas)': Number.isFinite(duration) ? duration : weeklyHours,
'Prioridad': cleanNum(row['prioridad']) 'Prioridad': cleanNum(row['prioridad']),
'Activo': row['activo'] ?? 'Sí',
'País': row['pais'] ?? row['country'] ?? '',
'Cobertura': row['cobertura'] ?? '',
'Zona': row['zona'] ?? '',
'Región': row['region'] ?? row['regional'] ?? '',
'Estado': row['estado'] ?? '',
'Ciudad': row['ciudad'] ?? '',
'Metro de prueba': row['metro_de_prueba'] ?? ''
}; };
}); });
const output = XLSX.utils.book_new(); const output = XLSX.utils.book_new();
@@ -3,32 +3,37 @@ import * as XLSX from 'xlsx';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import { RouteState } from '../../core/route-state/route'; import { RouteState } from '../../core/route-state/route';
import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils'; import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils';
import { PlanningFilterService } from '../../core/services/planning-filter.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PlanningExportService { export class PlanningExportService {
constructor(private routeState: RouteState) {} constructor(private routeState: RouteState, private filters: PlanningFilterService) {}
export(): void { export(): void {
const assigned = this.routeState.getAssignedSnapshot(); const assigned = this.filters.filterAssigned(this.routeState.getAssignedSnapshot());
const unassigned = this.routeState.getNoAssignedSnapshot(); const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot());
const users = new Map<string, { nombre: string; rutas: number; distancia: number; trabajo: number; capacidad: number }>(); const users = new Map<string, { nombre: string; visitas: number; pdvs: Set<string>; distancia: number; trabajo: number; capacidad: number }>();
for (const route of assigned) { for (const route of assigned) {
const key = String(route.json.usuario_id); const key = String(route.json.usuario_id);
const current = users.get(key) ?? { const current = users.get(key) ?? {
nombre: route.json.nombre_usuario, nombre: route.json.nombre_usuario,
rutas: 0, visitas: 0,
pdvs: new Set<string>(),
distancia: 0, distancia: 0,
trabajo: 0, trabajo: 0,
capacidad: Number(route.json.hora_laboral_semanal_usuario) || 0 capacidad: Number(route.json.hora_laboral_semanal_usuario) || 0
}; };
current.rutas++; current.visitas++;
current.pdvs.add(String(route.json.pdv_id));
current.distancia += Number(route.json.distancia) || 0; current.distancia += Number(route.json.distancia) || 0;
current.trabajo += Number.parseFloat(String(route.json.horas_trabajo ?? '')) || 0; current.trabajo += Number.parseFloat(String(route.json.horas_trabajo ?? '')) || 0;
users.set(key, current); users.set(key, current);
} }
const total = assigned.length + unassigned.length; const assignedIds = new Set(assigned.map(route => String(route.json.pdv_id)));
const unassignedIds = new Set(unassigned.map(route => String(route.pdv_id)).filter(id => !assignedIds.has(id)));
const total = assignedIds.size + unassignedIds.size;
const dailyRoutes = summarizeDailyRoutes(assigned); const dailyRoutes = summarizeDailyRoutes(assigned);
const totalDistance = dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0); const totalDistance = dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0);
const totalTravel = dailyRoutes.reduce((sum, route) => sum + route.travelHours, 0); const totalTravel = dailyRoutes.reduce((sum, route) => sum + route.travelHours, 0);
@@ -36,9 +41,9 @@ export class PlanningExportService {
const summary = [ const summary = [
{ Indicador: 'PDVs totales', Valor: total }, { Indicador: 'PDVs totales', Valor: total },
{ Indicador: 'PDVs asignados', Valor: assigned.length }, { Indicador: 'PDVs asignados', Valor: assignedIds.size },
{ Indicador: 'PDVs sin asignar', Valor: unassigned.length }, { Indicador: 'PDVs sin asignar', Valor: unassignedIds.size },
{ Indicador: 'Cobertura (%)', Valor: total ? Number(((assigned.length / total) * 100).toFixed(1)) : 0 }, { Indicador: 'Cobertura (%)', Valor: total ? Number(((assignedIds.size / total) * 100).toFixed(1)) : 0 },
{ Indicador: 'Usuarios activos', Valor: users.size }, { Indicador: 'Usuarios activos', Valor: users.size },
{ Indicador: 'Días planificados', Valor: new Set(assigned.map(route => route.json.dia)).size }, { Indicador: 'Días planificados', Valor: new Set(assigned.map(route => route.json.dia)).size },
{ Indicador: 'Distancia total (km)', Valor: Number(totalDistance.toFixed(2)) }, { Indicador: 'Distancia total (km)', Valor: Number(totalDistance.toFixed(2)) },
@@ -71,7 +76,8 @@ export class PlanningExportService {
return { return {
'Usuario ID': id, 'Usuario ID': id,
Usuario: user.nombre, Usuario: user.nombre,
Rutas: user.rutas, PDVs: user.pdvs.size,
Visitas: user.visitas,
'Distancia (km)': Number(user.distancia.toFixed(2)), 'Distancia (km)': Number(user.distancia.toFixed(2)),
'Horas trabajo': Number(user.trabajo.toFixed(2)), 'Horas trabajo': Number(user.trabajo.toFixed(2)),
'Capacidad (h)': user.capacidad, 'Capacidad (h)': user.capacidad,
@@ -0,0 +1,87 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { saveAs } from 'file-saver';
import { environment } from '../../../environments/environment';
import { RouteState } from '../../core/route-state/route';
import { ProjectService } from '../../core/services/project.service';
import { PlanningFilterService } from '../../core/services/planning-filter.service';
import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils';
import { recommendationForUnassigned } from '../utils/unassigned-recommendation.utils';
@Injectable({ providedIn: 'root' })
export class RoutePdfReportService {
constructor(
private http: HttpClient,
private routeState: RouteState,
private projects: ProjectService,
private filters: PlanningFilterService
) {}
async download(projectId: string): Promise<void> {
const [project, assigned, unassigned] = await Promise.all([
this.projects.get(projectId),
Promise.resolve(this.filters.filterAssigned(this.routeState.getAssignedSnapshot())),
Promise.resolve(this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot()))
]);
const assignedIds = new Set(assigned.map(route => String(route.json.pdv_id)));
const pending = unassigned
.filter(route => !assignedIds.has(String(route.pdv_id)))
.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
const dailyRoutes = summarizeDailyRoutes(assigned);
const totalPdvs = assignedIds.size + new Set(pending.map(route => String(route.pdv_id))).size;
const payload = {
project: { id: project.id, name: project.nombre, country: project.pais ?? '', description: project.descripcion ?? '' },
filters: this.filters.snapshot(),
generatedAt: new Date().toISOString(),
summary: {
totalPdvs,
assignedPdvs: assignedIds.size,
unassignedPdvs: pending.length,
coveragePercent: totalPdvs ? Number(((assignedIds.size / totalPdvs) * 100).toFixed(1)) : 0,
totalDistanceKm: Number(dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0).toFixed(2)),
totalWorkHours: Number(dailyRoutes.reduce((sum, route) => sum + route.workHours, 0).toFixed(2))
},
assignedRoutes: sequenceRoutes(assigned).map(segment => ({
usuarioId: segment.route.json.usuario_id,
usuario: segment.route.json.nombre_usuario,
pdvId: segment.route.json.pdv_id,
pdv: segment.route.json.nombre_pdv,
dia: segment.route.json.dia,
orden: segment.order,
origen: segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}`,
distanciaKm: segment.route.json.distancia,
desplazamientoHoras: segment.route.json.horas_desplazamiento,
horasTrabajo: segment.route.json.horas_trabajo,
cobertura: segment.route.json.cobertura ?? '',
zona: segment.route.json.zona ?? '',
region: segment.route.json.region ?? '',
estado: segment.route.json.estado ?? '',
ciudad: segment.route.json.ciudad ?? ''
})),
unassignedPdvs: pending
};
try {
const pdf = await firstValueFrom(this.http.post(environment.n8nPdfWebhookUrl, payload, { responseType: 'blob' }));
const fileName = `rutas_${this.fileName(project.nombre)}.pdf`;
saveAs(pdf, fileName);
} catch (error) {
throw new Error(await this.messageForError(error));
}
}
private fileName(value: string): string {
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'proyecto';
}
private async messageForError(error: unknown): Promise<string> {
const response = error as HttpErrorResponse;
if (response.error instanceof Blob) {
try {
const body = JSON.parse(await response.error.text()) as { message?: string; error?: string };
return body.message ?? body.error ?? 'No se pudo generar el PDF.';
} catch { /* use fallback */ }
}
return response.error?.message ?? response.message ?? 'No se pudo generar el PDF.';
}
}
@@ -1,22 +1,37 @@
const assignedColors = new Map<string, string>();
const usedColors = new Set<string>();
function hashId(value: string): number {
let hash = 2166136261;
for (let index = 0; index < value.length; index++) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function routeColor(id: string): string {
const existing = assignedColors.get(id);
if (existing) return existing;
const hash = hashId(id);
let attempt = 0;
let color = '';
do {
// Restricted to dark navy/indigo/violet/plum. Avoids red, cyan, green, yellow and orange.
const hue = 225 + ((hash + attempt * 37) % 106);
const saturation = 58 + (((hash >>> 8) + attempt * 11) % 27);
const lightness = 27 + (((hash >>> 16) + attempt * 7) % 17);
color = `hsl(${hue} ${saturation}% ${lightness}%)`;
attempt++;
} while (usedColors.has(color));
assignedColors.set(id, color);
usedColors.add(color);
return color;
}
export function colorForUsuario(usuarioId: string | number): { color: string; glow: string } { export function colorForUsuario(usuarioId: string | number): { color: string; glow: string } {
const str = String(usuarioId); const color = routeColor(String(usuarioId));
let hash = 0; return { color, glow: color };
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
hash = Math.abs(hash);
// Use a mix of GLM palette + generated HSL to avoid collisions but keep brand feel
const glmPalette = ['#6CC24A', '#4F758B', '#FF6A13', '#C4D600', '#5B7F95', '#A4D65E', '#6B8FA3'];
const idx = hash % glmPalette.length;
// If palette length covers typical usuarios (50 max) collisions low; fallback to HSL for overflow
if (hash % 2 === 0) {
return { color: glmPalette[idx], glow: glmPalette[idx] };
}
const h = hash % 360;
const s = 65 + (hash % 20);
const l = 48 + (hash % 10);
const hsl = `hsl(${h} ${s}% ${l}%)`;
return { color: hsl, glow: hsl };
} }
export function markerStyleForUsuario(usuarioId: string | number): string { export function markerStyleForUsuario(usuarioId: string | number): string {
@@ -0,0 +1,16 @@
import { RouteNoAssignedResponse } from '../../core/models/route.model';
export function recommendationForUnassigned(route: RouteNoAssignedResponse): string {
const reason = String(route.motivo ?? '').toLowerCase();
if (/coordenad|latitud|longitud/.test(reason)) return 'Corregir las coordenadas del PDV antes de volver a planificar.';
if (/capacidad|visitas maxima|visitas máximas|jornada/.test(reason)) {
return 'Redistribuir PDVs, ampliar capacidad o incorporar personal adicional.';
}
if (/distancia|desplazamiento|recorrido|lejan/.test(reason)) {
return 'Asignar personal más cercano o revisar la cobertura y zona del PDV.';
}
if (/empleado|usuario|disponib|compatible|sin asign/.test(reason)) {
return 'Reubicar o contratar personal disponible para esta zona.';
}
return 'Revisar datos del PDV, disponibilidad de personal y reglas de planificación.';
}
@@ -1,6 +1,11 @@
export const environment = { export const environment = {
production: true, production: true,
n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino', n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino',
n8nPdfWebhookUrl: 'http://localhost:5678/webhook/generar-rutas-pdf',
orsBaseUrl: 'http://localhost:18080/ors/v2', orsBaseUrl: 'http://localhost:18080/ors/v2',
requestTimeoutMs: 30000, orsMexicoBaseUrl: 'http://localhost:18081/ors/v2',
requestTimeoutMs: 180000,
supabaseUrl: 'https://dbit.digitalcompass.agency',
userProfileWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/19486044-4dbd-4751-87ae-f6664148bb8a',
supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE',
}; };
@@ -1,6 +1,11 @@
export const environment = { export const environment = {
production: false, production: false,
n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino', n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino',
n8nPdfWebhookUrl: 'http://localhost:5678/webhook/generar-rutas-pdf',
orsBaseUrl: 'http://localhost:18080/ors/v2', orsBaseUrl: 'http://localhost:18080/ors/v2',
requestTimeoutMs: 30000, orsMexicoBaseUrl: 'http://localhost:18081/ors/v2',
requestTimeoutMs: 180000,
supabaseUrl: 'https://dbit.digitalcompass.agency',
userProfileWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/19486044-4dbd-4751-87ae-f6664148bb8a',
supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE',
}; };
+18
View File
@@ -46,6 +46,24 @@
pointer-events: none; pointer-events: none;
} }
/* Leaflet-generated marker elements do not receive Angular's scoped attributes. */
.custom-marker {
background: none !important;
border: none !important;
}
.custom-marker .marker-inner {
display: flex;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #fff;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
font: 700 12px/1 Arial, sans-serif;
}
:root { :root {
--glm-verde: #6CC24A; --glm-verde: #6CC24A;
--glm-verde-claro: #A4D65E; --glm-verde-claro: #A4D65E;
+102
View File
@@ -0,0 +1,102 @@
create table if not exists public.rutero_proyecto_empleados (
id uuid primary key default gen_random_uuid(),
proyecto_id uuid not null references public.rutero_proyectos(id) on delete cascade,
employee_code text not null,
nombre text not null,
usuario text,
email text,
perfil text,
pais text,
regional text,
latitud double precision not null,
longitud double precision not null,
horas_por_dia numeric(5,2) not null default 8,
dias_por_semana integer not null default 5,
jornada_codigo text not null default 'LUN_VIE_40',
horas_descanso_por_dia numeric(4,2) not null default 1,
dias_descanso jsonb not null default '[]'::jsonb,
visitas_maximas_por_dia integer not null default 3,
activo boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (proyecto_id, employee_code),
constraint rutero_proyecto_empleados_jornada_valida check (jornada_codigo in ('LUN_DOM_44', 'LUN_SAB_40', 'LUN_SAB_44', 'LUN_VIE_40')),
constraint rutero_proyecto_empleados_descanso_valido check (horas_descanso_por_dia >= 0 and horas_descanso_por_dia <= 3),
constraint rutero_proyecto_empleados_visitas_validas check (visitas_maximas_por_dia > 0)
);
create index if not exists rutero_proyecto_empleados_proyecto_idx
on public.rutero_proyecto_empleados (proyecto_id);
-- Preserve current behavior once: copy the existing global catalog into every
-- existing project. From this point onward each copy is edited independently.
insert into public.rutero_proyecto_empleados (
proyecto_id, employee_code, nombre, usuario, email, perfil, pais, regional,
latitud, longitud, horas_por_dia, dias_por_semana, jornada_codigo,
horas_descanso_por_dia, dias_descanso, visitas_maximas_por_dia, activo
)
select proyecto.id, empleado.employee_code, empleado.nombre, empleado.usuario,
empleado.email, empleado.perfil, empleado.pais, empleado.regional,
empleado.latitud, empleado.longitud, empleado.horas_por_dia,
empleado.dias_por_semana, empleado.jornada_codigo,
empleado.horas_descanso_por_dia, empleado.dias_descanso,
empleado.visitas_maximas_por_dia, empleado.activo
from public.rutero_proyectos proyecto
cross join public.rutero_empleados empleado
on conflict (proyecto_id, employee_code) do nothing;
alter table public.rutero_proyecto_empleados enable row level security;
drop policy if exists "Consultar empleados de proyectos propios" on public.rutero_proyecto_empleados;
drop policy if exists "Crear empleados en proyectos propios" on public.rutero_proyecto_empleados;
drop policy if exists "Editar empleados de proyectos propios" on public.rutero_proyecto_empleados;
drop policy if exists "Eliminar empleados de proyectos propios" on public.rutero_proyecto_empleados;
create policy "Consultar empleados de proyectos propios"
on public.rutero_proyecto_empleados for select to authenticated
using (exists (
select 1 from public.rutero_proyectos proyecto
where proyecto.id = proyecto_id and proyecto.propietario_id = auth.uid()
));
create policy "Crear empleados en proyectos propios"
on public.rutero_proyecto_empleados for insert to authenticated
with check (exists (
select 1 from public.rutero_proyectos proyecto
where proyecto.id = proyecto_id and proyecto.propietario_id = auth.uid()
));
create policy "Editar empleados de proyectos propios"
on public.rutero_proyecto_empleados for update to authenticated
using (exists (
select 1 from public.rutero_proyectos proyecto
where proyecto.id = proyecto_id and proyecto.propietario_id = auth.uid()
))
with check (exists (
select 1 from public.rutero_proyectos proyecto
where proyecto.id = proyecto_id and proyecto.propietario_id = auth.uid()
));
create policy "Eliminar empleados de proyectos propios"
on public.rutero_proyecto_empleados for delete to authenticated
using (exists (
select 1 from public.rutero_proyectos proyecto
where proyecto.id = proyecto_id and proyecto.propietario_id = auth.uid()
));
create unique index if not exists rutero_proyecto_pdvs_proyecto_pdv_idx
on public.rutero_proyecto_pdvs (proyecto_id, pdv_id);
alter table public.rutero_proyecto_pdvs enable row level security;
drop policy if exists "Gestionar PDVs de proyectos propios" on public.rutero_proyecto_pdvs;
create policy "Gestionar PDVs de proyectos propios"
on public.rutero_proyecto_pdvs for all to authenticated
using (exists (
select 1 from public.rutero_proyectos proyecto
where proyecto.id = proyecto_id and proyecto.propietario_id = auth.uid()
))
with check (exists (
select 1 from public.rutero_proyectos proyecto
where proyecto.id = proyecto_id and proyecto.propietario_id = auth.uid()
));
+24
View File
@@ -0,0 +1,24 @@
alter table public.rutero_proyectos
add column if not exists pais text;
-- Preserve the country of projects created before pais was stored explicitly.
update public.rutero_proyectos proyecto
set pais = source.pais
from (
select distinct on (pdv.proyecto_id)
pdv.proyecto_id,
nullif(trim(pdv.pdv_data ->> 'País'), '') as pais
from public.rutero_proyecto_pdvs pdv
where nullif(trim(pdv.pdv_data ->> 'País'), '') is not null
order by pdv.proyecto_id, pdv.created_at
) source
where proyecto.id = source.proyecto_id
and proyecto.pais is null;
alter table public.rutero_proyectos
alter column estado drop not null,
alter column estado drop default;
update public.rutero_proyectos
set estado = null
where estado = 'borrador';
+36
View File
@@ -0,0 +1,36 @@
alter table public.rutero_empleados
add column if not exists jornada_codigo text not null default 'LUN_VIE_40',
add column if not exists horas_descanso_por_dia numeric(4,2) not null default 1,
add column if not exists dias_descanso jsonb not null default '[]'::jsonb;
alter table public.rutero_empleados
add constraint rutero_empleados_jornada_valida
check (jornada_codigo in ('LUN_DOM_44', 'LUN_SAB_40', 'LUN_SAB_44', 'LUN_VIE_40')) not valid;
alter table public.rutero_empleados
add constraint rutero_empleados_descanso_valido
check (horas_descanso_por_dia >= 0 and horas_descanso_por_dia < 24) not valid;
alter table public.rutero_empleados enable row level security;
drop policy if exists "Solo GomezLee puede consultar empleados" on public.rutero_empleados;
drop policy if exists "Solo GomezLee puede crear empleados" on public.rutero_empleados;
drop policy if exists "Solo GomezLee puede editar empleados" on public.rutero_empleados;
drop policy if exists "Solo GomezLee puede eliminar empleados" on public.rutero_empleados;
create policy "Solo GomezLee puede consultar empleados"
on public.rutero_empleados for select to authenticated
using (lower(coalesce(auth.jwt() ->> 'email', '')) like '%@gomezleemarketing.com');
create policy "Solo GomezLee puede crear empleados"
on public.rutero_empleados for insert to authenticated
with check (lower(coalesce(auth.jwt() ->> 'email', '')) like '%@gomezleemarketing.com');
create policy "Solo GomezLee puede editar empleados"
on public.rutero_empleados for update to authenticated
using (lower(coalesce(auth.jwt() ->> 'email', '')) like '%@gomezleemarketing.com')
with check (lower(coalesce(auth.jwt() ->> 'email', '')) like '%@gomezleemarketing.com');
create policy "Solo GomezLee puede eliminar empleados"
on public.rutero_empleados for delete to authenticated
using (lower(coalesce(auth.jwt() ->> 'email', '')) like '%@gomezleemarketing.com');