12 Commits

Author SHA1 Message Date
EidanThen 69f26cb124 feat: unir dashboard y mapa general en una misma pantalla 2026-10-05 14:13:01 -04:00
EidanThen afbcc792ae Merge pull request 'feat: agregar generación de reportes PDF y recomendaciones para PDVs sin asignar' (#7) from feature/resumen-datos into dev
Reviewed-on: #7
2026-09-28 19:17:19 +00:00
EidanThen b3d6653143 feat: agregar generación de reportes PDF y recomendaciones para PDVs sin asignar 2026-09-26 10:10:52 -04:00
EidanThen 23e01f398b Merge pull request 'feat: creacion de proyectos, gestor de empleados' (#6) from feat/proyecto-pdvs into dev
Reviewed-on: #6
2026-09-17 19:06:25 +00:00
EidanThen 0736db2b5c feat: creacion de proyectos, gestor de empleados 2026-09-17 15:04:52 -04:00
EidanThen 0c652a0bd7 Merge pull request 'feat: - conexion supabase - autenticacion solo correos glm - pantalla gestor empleados' (#5) from feat/menu-gestionar-empleados into dev
Reviewed-on: #5
2026-09-05 16:43:33 +00:00
EidanThen c45182012c feat:
- conexion supabase
- autenticacion solo correos glm
- pantalla gestor empleados
2026-09-05 12:42:29 -04:00
EidanThen bb6757b492 Merge pull request 'feat: añadir lista de empleados con carga al reasignar ruta' (#4) from feat/mapa-menu-ui into dev
Reviewed-on: #4
2026-09-03 21:28:43 +00:00
EidanThen 8f34462876 feat: añadir lista de empleados con carga al reasignar ruta 2026-09-03 17:26:56 -04:00
EidanThen 540bc46fc2 feat: eliminar filtro de tiempo de distancia en las rutas 2026-09-03 13:48:17 -04:00
EidanThen 7d696e6f16 feat: filtro todos seleccionado por default 2026-09-01 12:12:50 -04:00
EidanThen 65fe788281 fix: contar pdv unico 2026-09-01 11:58:15 -04:00
85 changed files with 4394 additions and 1613 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+223
View File
@@ -0,0 +1,223 @@
{
"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><td>${route.costoCombustible != null ? `${escape(route.monedaCombustible)} ${escape(route.costoCombustible)}` : '\\u2014'}</td></tr>`).join('') || '<tr><td colspan=\"8\">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 ?? {};const fuel = payload.fuel ?? {};\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 class=\"metric\"><b>${fuel.configured ? `${escape(summary.fuelLiters)} L` : '\\u2014'}</b>Combustible</div><div class=\"metric\"><b>${fuel.configured ? `${escape(summary.fuelCurrency)} ${escape(summary.fuelCost)}` : '\\u2014'}</b>Costo combustible</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><th>Costo comb.</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
+1 -1
View File
@@ -30,7 +30,7 @@
"production": { "production": {
"budgets": [ "budgets": [
{ "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" }, { "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" },
{ "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "10kB" } { "type": "anyComponentStyle", "maximumWarning": "8kB", "maximumError": "15kB" }
], ],
"outputHashing": "all", "outputHashing": "all",
"fileReplacements": [ "fileReplacements": [
+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', redirectTo: 'home', pathMatch: 'full' },
{ path: 'home/mapa', redirectTo: 'home', pathMatch: 'full' },
{ 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,20 @@
export interface RuteroProyecto {
id: string;
nombre: string;
descripcion: string | null;
pais: string | null;
rendimiento_km_l: number | null;
precio_por_litro: number | 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,26 @@ export interface RouteAssignedJson {
usuario_id: string; usuario_id: string;
pdv_id: number; pdv_id: number;
visit_order?: number; visit_order?: number;
prioridad?: number;
segmentacion?: string;
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 +55,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];
}
+66 -17
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;
@@ -172,9 +201,17 @@ export class RouteState {
const profiles = Array.from(new Map(current.map(route => [String(route.json.usuario_id), route])).values()); const profiles = Array.from(new Map(current.map(route => [String(route.json.usuario_id), route])).values());
if (profiles.length < 2) return; if (profiles.length < 2) return;
const segmentWeight: Record<string, number> = { AA: 100, A: 80, B: 60, C: 40, D: 20 };
const priorityOf = (route: (typeof current)[number]): number => {
const value = Number(route.json.prioridad);
return Number.isFinite(value) ? value : 5;
};
const segmentOf = (route: (typeof current)[number]): number =>
segmentWeight[String(route.json.segmentacion ?? 'D').toUpperCase()] ?? 20;
const load = new Map<string, number>(profiles.map(profile => [String(profile.json.usuario_id), 0])); const load = new Map<string, number>(profiles.map(profile => [String(profile.json.usuario_id), 0]));
const optimized = [...current] const optimized = [...current]
.sort((a, b) => Number(b.json.hora_minima_semanal_pdv) - Number(a.json.hora_minima_semanal_pdv)) .sort((a, b) => priorityOf(a) - priorityOf(b) || segmentOf(b) - segmentOf(a) ||
Number(b.json.hora_minima_semanal_pdv) - Number(a.json.hora_minima_semanal_pdv))
.map(route => { .map(route => {
const routeHours = Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0); const routeHours = Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0);
const user = profiles const user = profiles
@@ -195,6 +232,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 +264,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 +301,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 +328,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 +336,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 +344,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 +352,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({
@@ -26,6 +24,7 @@ const MAX_CONCURRENT_REQUESTS = 2;
export class OrsService { export class OrsService {
private geoCache = new Map<string, unknown>(); private geoCache = new Map<string, unknown>();
private failedCache = new Map<string, string>();
constructor(private http: HttpClient) { } constructor(private http: HttpClient) { }
@@ -39,10 +38,15 @@ export class OrsService {
isJson = false isJson = false
) { ) {
const body = { const body = {
coordinates: [origin, destination] coordinates: [origin, destination],
radiuses: [1000, 1000]
}; };
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 +54,19 @@ export class OrsService {
); );
} }
private isMexico(coordinates: number[]): boolean {
const [longitude, latitude] = coordinates;
return latitude >= 14 && latitude <= 33 && longitude >= -119 && longitude <= -86;
}
private orsErrorMessage(error: unknown): string {
const err = error as { message?: string; error?: { error?: { message?: string; code?: number }; message?: string } };
const serverMessage = err?.error?.error?.message ?? err?.error?.message;
if (serverMessage) return String(serverMessage);
if (err?.message) return String(err.message);
return 'Error ORS';
}
async processPairs( async processPairs(
pares: { pares: {
pdv_id: number; pdv_id: number;
@@ -92,6 +109,17 @@ export class OrsService {
}) })
); );
} }
if (isJson && this.failedCache.has(key)) {
const errOut = { pdv_id: pair.pdv_id, error: this.failedCache.get(key) ?? 'Sin conexión vial (ORS).' };
return of(errOut).pipe(
map(data => {
completed++;
progressSubject.next({ completed, total: pares.length, item: data as unknown });
if (completed === pares.length) progressSubject.complete();
return data as unknown;
})
);
}
return this.fetchRoute(pair.origin, pair.destination, isJson).pipe( return this.fetchRoute(pair.origin, pair.destination, isJson).pipe(
delay(300), delay(300),
map(data => { map(data => {
@@ -117,9 +145,11 @@ export class OrsService {
} }
}), }),
catchError(error => { catchError(error => {
const message = this.orsErrorMessage(error);
if (isJson) this.failedCache.set(key, message);
const errOut = { const errOut = {
pdv_id: pair.pdv_id, pdv_id: pair.pdv_id,
error: (error as Error).message || 'Error ORS' error: message
}; };
completed++; completed++;
progressSubject.next({ completed, total: pares.length, item: errOut }); progressSubject.next({ completed, total: pares.length, item: errOut });
@@ -144,5 +174,6 @@ export class OrsService {
clearCache() { clearCache() {
this.geoCache.clear(); this.geoCache.clear();
this.failedCache.clear();
} }
} }
@@ -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,117 @@
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' | 'rendimiento_km_l' | 'precio_por_litro'>>): 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(),
rendimiento_km_l: null,
precio_por_litro: null,
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; background: #fff; color: var(--glm-gris-oscuro); font-size: .85rem; font-weight: 600; }.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 { position:relative; z-index:30; 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; color:var(--glm-gris-oscuro); font-size:.85rem; font-weight:600; text-transform:none; }.bulk-actions select { min-width:200px; }.bulk-actions select option,.employee-form-grid select option,.editing-row select option { color:var(--glm-gris-oscuro); background:#fff; }.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>
@@ -5,8 +5,11 @@ import { Subscription } from 'rxjs';
import * as L from 'leaflet'; import * as L from 'leaflet';
import { RouteState } from '../../../core/route-state/route'; import { RouteState } from '../../../core/route-state/route';
import { OrsService } from '../../../core/services/ors.service'; import { OrsService } from '../../../core/services/ors.service';
import { ProjectService } from '../../../core/services/project.service';
import { RouteAssignedResponse } from '../../../core/models/route.model'; import { RouteAssignedResponse } from '../../../core/models/route.model';
import { centerForCountry } from '../../../shared/utils/country-center.utils';
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,11 +34,20 @@ 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,
private routeState: RouteState, private routeState: RouteState,
private orsService: OrsService, private orsService: OrsService,
private projects: ProjectService,
private cdr: ChangeDetectorRef private cdr: ChangeDetectorRef
) {} ) {}
@@ -52,8 +64,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 +91,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 +112,35 @@ 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 {
const projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? '';
void this.projects.get(projectId).then(project => {
const center = centerForCountry(project?.pais ?? '');
this.map.setView([center.lat, center.lng], 10);
}).catch(() => {
this.map.setView([19.4326, -99.1332], 5);
});
}
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 +162,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 +197,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 +298,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 +321,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 +343,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,18 +31,125 @@
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: 1600px;
margin: 0 auto; margin: 0 auto;
padding: 24px; padding: 20px 24px 32px;
}
.planning-toolbar-card {
padding: 16px 20px;
}
.planning-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
}
.toolbar-summary {
margin: 0;
flex: 1 1 260px;
}
.toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-left: auto;
}
.planning-toolbar-card .upload-error {
margin-top: 12px;
}
.fuel-config { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
.fuel-config label { display: grid; gap: 5px; color: var(--glm-azul); font-size: .75rem; font-weight: 700; text-transform: uppercase; }
.fuel-config input { border: 1px solid #9aaab3; border-radius: 4px; background: #fff; color: #2f3f48; font: inherit; padding: 8px; width: 180px; }
.fuel-summary { margin: 4px 0 0; color: var(--glm-acero); font-size: .82rem; }
.dashboard-map-card {
padding: 20px;
}
.dashboard-map-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 16px;
margin-bottom: 12px;
}
.dashboard-map-header .section-title {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
}
.dashboard-map-hint {
margin: 0;
color: var(--glm-acero);
font-size: 0.85rem;
}
.dashboard-map-panel {
position: relative;
min-height: 480px;
}
.dashboard-map-panel app-route-overview-map {
display: block;
position: relative;
height: 620px;
}
@media (max-width: 1100px) {
.home-container {
padding: 16px;
}
.dashboard-map-panel app-route-overview-map {
height: auto;
}
} }
.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);
@@ -74,100 +181,7 @@
letter-spacing: 0.3px; letter-spacing: 0.3px;
} }
/* Upload card */ .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-card {
padding: 24px;
}
.upload-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
margin-bottom: 20px;
}
@media (min-width: 768px) {
.upload-grid {
grid-template-columns: 1fr 1fr;
}
}
.file-input-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
.file-label {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px;
border: 2px dashed var(--glm-gris-medio);
border-radius: 4px;
background: var(--glm-verde-bg);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.file-label:hover {
border-color: var(--glm-verde);
background: #e5f5dc;
}
.file-label:has(.file-input:focus-visible) {
border-color: var(--glm-verde);
box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.2);
}
.file-label i {
font-size: 2rem;
color: var(--glm-verde);
}
.file-label span:first-of-type {
font-weight: 600;
color: var(--glm-azul);
font-size: 14px;
}
.file-hint {
font-size: 0.8rem;
color: var(--glm-acero);
}
.file-input {
position: absolute;
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
z-index: -1;
}
.file-name {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--glm-verde-bg);
border: 1px solid var(--glm-verde-claro);
border-radius: 4px;
color: var(--glm-gris-oscuro);
font-size: 0.9rem;
}
.file-name i {
color: var(--glm-verde);
}
.upload-actions {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--glm-gris-medio);
}
.upload-error { .upload-error {
display: flex; display: flex;
@@ -233,89 +247,6 @@
cursor: not-allowed; cursor: not-allowed;
} }
.file-label.has-error {
border-color: #F5A6A0;
background: var(--glm-rojo-ok);
}
.file-label.has-success {
border-color: var(--glm-verde);
background: var(--glm-verde-bg);
}
.preview-card {
padding: 12px;
border-radius: 4px;
border: 1px solid var(--glm-gris-medio);
background: var(--glm-surface);
font-size: 0.85rem;
}
.preview-success {
border-color: var(--glm-verde-claro);
background: var(--glm-verde-bg);
}
.preview-success .preview-header {
color: var(--glm-verde);
font-weight: 700;
display: flex;
align-items: center;
gap: 6px;
}
.preview-error {
border-color: #F5A6A0;
background: var(--glm-rojo-ok);
}
.preview-error .preview-header {
color: #D32F2F;
font-weight: 700;
display: flex;
align-items: center;
gap: 6px;
}
.preview-stats {
margin-top: 6px;
color: var(--glm-acero);
font-size: 0.8rem;
}
.preview-errors {
margin: 8px 0 0;
padding: 0 0 0 18px;
color: var(--glm-gris-oscuro);
font-size: 0.82rem;
}
.preview-errors li {
margin-bottom: 4px;
}
.error-value {
color: var(--glm-acero);
font-style: italic;
}
.more-errors {
color: var(--glm-acero);
font-style: italic;
}
.preview-loading {
color: var(--glm-acero);
font-size: 0.8rem;
}
.preview-loading-row {
display: flex;
align-items: center;
gap: 8px;
color: var(--glm-acero);
}
.params-card { .params-card {
margin-top: 16px; margin-top: 16px;
padding: 14px; padding: 14px;
@@ -1,122 +1,30 @@
<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><div class="fuel-config" *ngIf="editingProject"><label>Rendimiento (km/L)<input type="number" min="0.1" step="0.1" [(ngModel)]="fuelRendimiento" placeholder="Ej. 12"></label><label>Precio por litro<input type="number" min="0" step="0.01" [(ngModel)]="fuelPrecio" placeholder="Ej. 24"></label></div><p *ngIf="!editingProject">Gestión de rutas asignadas y puntos de venta pendientes</p><p class="fuel-summary" *ngIf="!editingProject && (fuelRendimiento || fuelPrecio)">Combustible: {{ fuelRendimiento ?? '—' }} km/L · {{ fuelPrecio ?? '—' }}/L</p></div>
<p>Gestión de rutas asignadas y puntos de venta pendientes</p>
</header> </header>
<section class="card upload-card"> <section class="card planning-toolbar-card">
<h2 class="section-title">Subir archivos para optimización</h2> <div class="planning-toolbar">
<p class="employees-summary toolbar-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="actions-row toolbar-actions">
<div class="file-input-wrapper">
<label class="file-label" for="pdvFile" [class.has-error]="pdvPreview && !pdvPreview.valid" [class.has-success]="pdvPreview?.valid">
<i class="pi pi-file-excel"></i>
<span>Archivo PDVs (Excel)</span>
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 2000 filas</span>
</label>
<input
id="pdvFile"
type="file"
accept=".xlsx,.xls"
(change)="onPdvFileChange($event)"
class="file-input"
[class.has-file]="pdvFile"
/>
<div class="file-name" *ngIf="pdvFile">
<i class="pi" [class.pi-check-circle]="pdvPreview?.valid" [class.pi-exclamation-circle]="pdvPreview && !pdvPreview.valid" [class.pi-spin]="pdvPreviewLoading"></i>
{{ pdvFile.name }}
<span *ngIf="pdvPreviewLoading" class="preview-loading">Validando...</span>
</div>
<div class="preview-card" *ngIf="pdvPreviewLoading">
<div class="preview-loading-row"><span class="spinner-inline"></span> Analizando archivo PDV...</div>
</div>
<div class="preview-card preview-success" *ngIf="pdvPreview?.valid">
<div class="preview-header"><i class="pi pi-check-circle"></i> PDV válido — {{ pdvPreview!.totalRows }} filas</div>
<div class="preview-stats">{{ pdvPreview!.validRows }} filas válidas • Columnas: {{ pdvPreview!.headers.join(', ') }}</div>
</div>
<div class="preview-card preview-error" *ngIf="pdvPreview && !pdvPreview.valid">
<div class="preview-header"><i class="pi pi-exclamation-triangle"></i> Errores en PDV ({{ pdvPreview.errors.length }})</div>
<div class="preview-stats" *ngIf="pdvPreview.missingHeaders.length">Faltan: {{ pdvPreview.missingHeaders.join(', ') }}</div>
<div class="preview-stats">{{ pdvPreview.totalRows }} filas leídas • {{ pdvPreview.validRows }} válidas</div>
<ul class="preview-errors">
<li *ngFor="let e of pdvPreview.errors.slice(0, 8)">
<strong>Fila {{ e.row }} — {{ e.field }}:</strong> {{ e.message }}
<span *ngIf="e.value !== undefined && e.value !== null" class="error-value">({{ e.value }})</span>
</li>
<li *ngIf="pdvPreview.errors.length > 8" class="more-errors">+ {{ pdvPreview.errors.length - 8 }} errores más...</li>
</ul>
</div>
</div>
<div class="file-input-wrapper">
<label class="file-label" for="usuariosFile" [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 class="upload-actions">
<div class="upload-error" *ngIf="uploadError">
<i class="pi pi-exclamation-triangle"></i>
{{ uploadError }}
</div>
<div class="actions-row">
<button <button
type="button" type="button"
class="btn-primary" class="btn-primary"
(click)="cargarRutas()" (click)="cargarRutas()"
[disabled]="!canSubmit" [disabled]="submitDisabled"
[title]="submitDisabledReason || ''" title="Simular rutas"
> >
<span *ngIf="isLoading" class="spinner-inline"></span> <span *ngIf="isLoading" class="spinner-inline"></span>
<i *ngIf="!isLoading" class="pi pi-upload"></i> <i *ngIf="!isLoading" class="pi pi-upload"></i>
{{ isLoading ? 'Procesando...' : 'Cargar rutas' }} {{ isLoading ? 'Simulando...' : 'Simular rutas' }}
</button> </button>
<button <button
type="button" type="button"
@@ -130,25 +38,40 @@
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>
</div> </div>
<div class="upload-error" *ngIf="uploadError">
<i class="pi pi-exclamation-triangle"></i>
{{ uploadError }}
</div>
<small class="hint-text" *ngIf="submitHint && !isLoading">{{ submitHint }}</small>
</section>
<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">
<app-planning-kpis></app-planning-kpis> <app-planning-kpis></app-planning-kpis>
</section> </section>
<section class="card"> <section class="card dashboard-map-card">
<app-assigned-route></app-assigned-route> <div class="dashboard-map-header">
<h2 class="section-title"><i class="pi pi-map"></i> Mapa general y detalle de ruta</h2>
<p class="dashboard-map-hint">Selecciona una ruta en el mapa o en el balance para ver su detalle sin cambiar de pantalla.</p>
</div>
<div class="dashboard-map-panel">
<app-route-overview-map [embedded]="true" [country]="projectCountry"></app-route-overview-map>
</div>
</section> </section>
<section class="card"> <section class="card">
<h2 class="section-title">PDVs sin ruta asignada</h2> <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>
<app-no-assigned-route></app-no-assigned-route> <app-no-assigned-route *ngIf="unassignedExpanded"></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';
@@ -9,146 +8,177 @@ import { ApiResponse } from '../../core/models/route.model';
import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service'; import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service';
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 { RouteOverviewMapComponent } from './route-overview-map/route-overview-map.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, RouteOverviewMapComponent],
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;
pdvPreview: PreviewResult | null = null; pdvPreview: PreviewResult | null = null;
usuariosPreview: PreviewResult | null = null;
pdvPreviewLoading = false; pdvPreviewLoading = 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;
fuelRendimiento: number | null = null;
fuelPrecio: number | null = null;
filtersExpanded = true;
unassignedExpanded = true;
planningFilters: PlanningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
submitDisabled = true;
submitHint: string | null = 'Sube el archivo PDV';
private calcSub: Subscription | null = null; private calcSub: Subscription | null = null;
private safeDetectChanges(): void {
setTimeout(() => {
try { this.cdr.detectChanges(); } catch { /* ignore destroyed view */ }
});
}
private updateSubmitState(): void {
this.submitDisabled = !this.canSubmit;
this.submitHint = this.computeSubmitHint();
}
private computeSubmitHint(): string | null {
if (!this.pdvFile) return 'Sube el archivo PDV';
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';
return null;
}
constructor( constructor(
private rutasService: RutasService, private rutasService: RutasService,
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
) {} ) {}
async onPdvFileChange(event: Event) { ngOnInit(): void {
const input = event.target as HTMLInputElement; void this.initialize();
if (input.files && input.files.length > 0) {
const file = input.files[0];
const basicError = this.validateFileBasic(file, 'PDV');
if (basicError) {
this.pdvFile = null;
this.pdvPreview = null;
this.uploadError = basicError;
return;
} }
this.pdvFile = file;
if (this.usuariosFile) this.planningInput.setFiles(file, this.usuariosFile); private async initialize(): Promise<void> {
this.uploadError = null; await this.loadProject();
this.pdvPreviewLoading = true; await this.loadEmployees();
this.pdvPreview = null; }
this.cdr.detectChanges();
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 { try {
this.pdvPreview = await this.previewService.previewFile(file, 'PDV'); const [project, input] = await Promise.all([this.projectsService.get(this.projectId), this.projectsService.projectInput(this.projectId)]);
} catch (e: unknown) { this.projectName = project.nombre;
const msg = e instanceof Error ? e.message : 'Error al leer el archivo PDV'; this.projectDescription = project.descripcion ?? '';
this.pdvPreview = { this.projectCountry = input.country;
fileName: file.name, this.fuelRendimiento = project.rendimiento_km_l ?? null;
type: 'PDV', this.fuelPrecio = project.precio_por_litro ?? null;
valid: false, this.planningFilter.setPdvs(input.rows);
totalRows: 0, this.pdvFile = input.file;
validRows: 0, this.pdvPreview = await this.previewService.previewFile(input.file, 'PDV');
headers: [], } catch (error: any) {
normalizedHeaders: [], this.uploadError = error?.message ?? 'No se pudo cargar el proyecto.';
missingHeaders: [],
errors: [{ row: 1, field: 'file', message: msg }],
duplicateIds: [],
sampleValidRows: [],
};
} finally { } finally {
this.pdvPreviewLoading = false; this.updateSubmitState(); this.safeDetectChanges();
this.cdr.detectChanges();
}
} }
} }
async onUsuariosFileChange(event: Event) { startProjectEdit(): void { this.editingProject = true; }
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) { cancelProjectEdit(): void { this.editingProject = false; void this.loadProject(); }
const file = input.files[0];
const basicError = this.validateFileBasic(file, 'Usuarios'); async saveProject(): Promise<void> {
if (basicError) { if (!this.projectId || !this.projectName.trim()) return;
this.usuariosFile = null; if (this.fuelRendimiento != null && !(this.fuelRendimiento > 0)) {
this.usuariosPreview = null; this.uploadError = 'El rendimiento debe ser mayor a 0 km/L.';
this.uploadError = basicError;
return; return;
} }
this.usuariosFile = file; if (this.fuelPrecio != null && !(this.fuelPrecio >= 0)) {
if (this.pdvFile) this.planningInput.setFiles(this.pdvFile, file); this.uploadError = 'El precio por litro no puede ser negativo.';
this.uploadError = null; return;
this.usuariosPreviewLoading = true; }
this.usuariosPreview = null; this.projectSaving = true;
this.cdr.detectChanges();
try { try {
this.usuariosPreview = await this.previewService.previewFile(file, 'Usuarios'); await this.projectsService.update(this.projectId, {
} catch (e: unknown) { nombre: this.projectName.trim(),
const msg = e instanceof Error ? e.message : 'Error al leer el archivo Usuarios'; descripcion: this.projectDescription.trim() || null,
this.usuariosPreview = { rendimiento_km_l: this.fuelRendimiento,
fileName: file.name, precio_por_litro: this.fuelPrecio
type: 'Usuarios', });
valid: false, this.editingProject = false;
totalRows: 0, } catch (error: any) { this.uploadError = error?.message ?? 'No se pudo actualizar el proyecto.'; }
validRows: 0, finally { this.projectSaving = false; this.cdr.detectChanges(); }
headers: [],
normalizedHeaders: [],
missingHeaders: [],
errors: [{ row: 1, field: 'file', message: msg }],
duplicateIds: [],
sampleValidRows: [],
};
} finally {
this.usuariosPreviewLoading = false;
this.cdr.detectChanges();
}
}
} }
private validateFileBasic(file: File, type: string): string | null { async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
const validExtensions = ['.xlsx', '.xls'];
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); filterOptions(key: PlanningFilterKey): string[] { return this.planningFilter.options(key); }
if (!validExtensions.includes(ext)) { applyPlanningFilters(): void { this.planningFilter.set(this.planningFilters); }
return `El archivo ${type} debe ser .xlsx o .xls`; clearPlanningFilters(): void { this.planningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' }; this.applyPlanningFilters(); }
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.updateSubmitState(); this.safeDetectChanges();
} }
if (file.size > 10 * 1024 * 1024) {
return `El archivo ${type} supera 10MB`;
}
return null;
} }
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;
} }
@@ -165,14 +195,24 @@ export class HomeComponent {
this.uploadError = 'No se pudo preparar el archivo PDV para el optimizador.'; this.uploadError = 'No se pudo preparar el archivo PDV para el optimizador.';
this.routeState.setLoading(false); this.routeState.setLoading(false);
this.isLoading = false; this.isLoading = false;
this.cdr.detectChanges(); this.updateSubmitState(); this.safeDetectChanges();
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({
@@ -183,12 +223,30 @@ export class HomeComponent {
this.uploadError = String((anyRes as { error: unknown }).error); this.uploadError = String((anyRes as { error: unknown }).error);
this.routeState.setLoading(false); this.routeState.setLoading(false);
this.isLoading = false; this.isLoading = false;
this.cdr.detectChanges(); this.updateSubmitState(); this.safeDetectChanges();
return;
}
const payload = Array.isArray(res)
? res
: 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.updateSubmitState(); this.safeDetectChanges();
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.updateSubmitState(); this.safeDetectChanges();
return; return;
} }
const arr = res as unknown as unknown[];
const assigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'assigned' in r) as unknown as { assigned: unknown } | undefined;
const noAssigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'noAssigned' in r) as unknown as { noAssigned: unknown } | undefined;
if (assigned) { if (assigned) {
this.routeState.setAssignedRoutes(assigned.assigned as never); this.routeState.setAssignedRoutes(assigned.assigned as never);
} }
@@ -197,13 +255,13 @@ export class HomeComponent {
} }
this.routeState.setLoading(false); this.routeState.setLoading(false);
this.isLoading = false; this.isLoading = false;
this.cdr.detectChanges(); this.updateSubmitState(); this.safeDetectChanges();
}, },
error: (err) => { error: (err) => {
this.uploadError = this.mapBackendError(err); this.uploadError = this.mapBackendError(err);
this.routeState.setLoading(false); this.routeState.setLoading(false);
this.isLoading = false; this.isLoading = false;
this.cdr.detectChanges(); this.updateSubmitState(); this.safeDetectChanges();
} }
}); });
} }
@@ -218,7 +276,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,20 +285,15 @@ 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.pdvPreview = null; this.pdvPreview = null;
this.usuariosPreview = null;
this.pdvPreviewLoading = false; this.pdvPreviewLoading = false;
this.usuariosPreviewLoading = false;
this.uploadError = null; this.uploadError = null;
this.isLoading = false; this.isLoading = false;
this.routeState.setLoading(false); this.routeState.setLoading(false);
const pdvInput = document.getElementById('pdvFile') as HTMLInputElement; this.updateSubmitState();
const usuariosInput = document.getElementById('usuariosFile') as HTMLInputElement; this.safeDetectChanges();
if (pdvInput) pdvInput.value = '';
if (usuariosInput) usuariosInput.value = '';
} }
} }
@@ -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[] = [];
@@ -24,43 +27,59 @@ export class NoAssignedRouteComponent implements OnDestroy {
assignmentDays: Record<string, string[]> = {}; assignmentDays: Record<string, string[]> = {};
assigningId: number | null = null; assigningId: number | null = null;
actionError: string | null = null; actionError: string | null = null;
userOptions: { id: string; name: string }[] = [];
dayOptions: string[] = [];
private safeDetectChanges(): void {
setTimeout(() => {
try { this.cdr.detectChanges(); } catch { /* ignore destroyed view */ }
});
}
private refreshOptions(): void {
this.userOptions = this.routesState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre }));
this.dayOptions = this.routesState.getDiasUnicos();
}
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() {
this.refreshOptions();
this.subs.push( this.subs.push(
this.routesState.loading$.subscribe(value => { this.routesState.loading$.subscribe(value => {
this.isLoading = value; this.isLoading = value;
this.cdr.detectChanges(); this.safeDetectChanges();
}) })
); );
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.cdr.detectChanges(); this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
this.refreshOptions();
this.safeDetectChanges();
}) })
); );
this.subs.push(this.filters.filters$.subscribe(() => {
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
this.safeDetectChanges();
}));
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' }
]; ];
} }
get userOptions(): { id: string; name: string }[] {
return this.routesState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre }));
}
get dayOptions(): string[] {
return this.routesState.getDiasUnicos();
}
toggleDay(pdvId: number, day: string, checked: boolean): void { toggleDay(pdvId: number, day: string, checked: boolean): void {
const key = String(pdvId); const key = String(pdvId);
@@ -103,7 +122,8 @@ export class NoAssignedRouteComponent implements OnDestroy {
this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`; this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`;
} finally { } finally {
this.assigningId = null; this.assigningId = null;
this.cdr.detectChanges(); this.refreshOptions();
this.safeDetectChanges();
} }
} }
@@ -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;
@@ -82,6 +88,7 @@
.kpi-warning { border-left-color: var(--glm-naranja); } .kpi-warning { border-left-color: var(--glm-naranja); }
.kpi-blue { border-left-color: var(--glm-acero); } .kpi-blue { border-left-color: var(--glm-acero); }
.kpi-orange { border-left-color: var(--glm-naranja); } .kpi-orange { border-left-color: var(--glm-naranja); }
.kpi-fuel { border-left-color: var(--glm-verde); }
.kpi-icon { .kpi-icon {
display: grid; display: grid;
@@ -130,10 +137,6 @@
white-space: nowrap; white-space: nowrap;
} }
.operational-grid {
margin-top: 12px;
}
.user-load-card { .user-load-card {
margin-top: 14px; margin-top: 14px;
padding: 12px 14px; padding: 12px 14px;
@@ -178,6 +181,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,99 +1,281 @@
<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]="exportLoading"
>
<i class="pi" [class.pi-spin]="exportLoading" [class.pi-file-excel]="!exportLoading" [class.pi-spinner]="exportLoading"></i> {{ exportLoading ? "Generando Excel..." : "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 || exportError">
{{ reoptError || editError || exportError }}
</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>
</article> <span class="kpi-label">Distancia</span
<article class="kpi-card kpi-blue"> ><strong
<div class="kpi-icon"><i class="pi pi-clock"></i></div> >{{ summary.totalDistance | number: "1.0-1" }} <em>km</em></strong
<div><span class="kpi-label">Desplazamiento total</span><strong>{{ summary.totalTravelHours | number:'1.0-1' }} <em>h</em></strong><small>{{ summary.averageTravelHours | number:'1.0-1' }} h promedio por ruta</small></div> >
</article>
</div> </div>
</article>
<div class="kpi-grid operational-grid" *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"> <article class="kpi-card kpi-fuel">
<div class="kpi-icon"><i class="pi pi-chart-line"></i></div> <div class="kpi-icon"><i class="pi pi-money-bill"></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> <div>
</article> <span class="kpi-label">Combustible</span
<article class="kpi-card kpi-primary"> ><strong *ngIf="fuelConfigured"
<div class="kpi-icon"><i class="pi pi-sitemap"></i></div> >{{ summary.fuelLiters | number: "1.0-1" }} <em>L</em></strong
<div><span class="kpi-label">Rutas por usuario</span><strong>{{ summary.averageRoutesPerUser | number:'1.0-1' }}</strong><small>promedio de asignaciones</small></div> ><strong *ngIf="!fuelConfigured">—</strong
</article> ><small *ngIf="fuelConfigured">{{ fuelCurrency }} {{ summary.fuelCost | number: "1.2-2" }}</small
<article class="kpi-card kpi-warning"> ><small *ngIf="!fuelConfigured">Sin configurar en el proyecto</small>
<div class="kpi-icon"><i class="pi pi-arrows-h"></i></div> </div>
<div><span class="kpi-label">Brecha de balance</span><strong>{{ summary.assignmentBalance }}</strong><small>diferencia entre mayor y menor carga</small></div>
</article> </article>
</div> </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>Costo comb.</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 *ngIf="fuelConfigured">{{ fuelCurrency }} {{ load.fuelCost | number: "1.2-2" }}</td>
<td *ngIf="!fuelConfigured">—</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="9">
<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,186 +1,193 @@
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 { PlanningExportService } from '../../../shared/services/planning-export.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';
import { ProjectService } from '../../../core/services/project.service';
import { currencyForCountry, fuelCost, isFuelConfigured, type FuelConfig } from '../../../shared/utils/fuel-estimation.utils';
interface KpiSummary { interface KpiSummary { totalPdvs: number; assignedPdvs: number; unassignedPdvs: number; coverage: number; users: number; days: number; totalDistance: number; averageDistance: number; totalWorkHours: number; fuelLiters: number; fuelCost: number; }
totalPdvs: number; interface ReasonSummary { reason: string; count: number; }
assignedPdvs: number;
unassignedPdvs: number;
coverage: number;
users: number;
days: number;
totalDistance: number;
totalTravelHours: number;
averageDistance: number;
averageTravelHours: number;
totalWorkHours: number;
averageRoutesPerUser: number;
averageUtilization: number;
assignmentBalance: number;
}
interface ReasonSummary {
reason: string;
count: number;
}
interface UserLoad { interface UserLoad {
id: string; id: string; name: string; uniquePdvs: number; visits: number; routes: RouteAssignedResponse[]; distance: number; workHours: number; fuelLiters: number; fuelCost: 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;
exportLoading = false;
exportError = '';
readonly userLoadPageSize = 14;
userLoadPage = 1;
fuelConfig: FuelConfig = { rendimientoKmL: null, precioPorLitro: null };
fuelCurrency = '$';
fuelConfigured = false;
private readonly subs: Subscription[] = []; private readonly subs: Subscription[] = [];
constructor( constructor(
private routeState: RouteState, private routeState: RouteState,
private exportService: PlanningExportService private filters: PlanningFilterService,
private excelExport: PlanningExportService,
private rutasService: RutasService,
private planningInput: PlanningInputService,
private projects: ProjectService,
private router: Router,
private activatedRoute: ActivatedRoute,
private cdr: ChangeDetectorRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
void this.loadFuelConfig();
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 { private async loadFuelConfig(): Promise<void> {
this.subs.forEach(subscription => subscription.unsubscribe()); try {
const project = await this.projects.get(this.projectId);
this.fuelConfig = { rendimientoKmL: project.rendimiento_km_l ?? null, precioPorLitro: project.precio_por_litro ?? null };
this.fuelCurrency = currencyForCountry(project.pais);
} catch {
this.fuelConfig = { rendimientoKmL: null, precioPorLitro: null };
this.fuelCurrency = '$';
} finally {
this.fuelConfigured = isFuelConfigured(this.fuelConfig);
this.refresh();
this.cdr.detectChanges();
} }
barWidth(value: number): number {
return Math.min(Math.max(value, 0), 100);
} }
ngOnDestroy(): void { this.subs.forEach(subscription => subscription.unsubscribe()); }
utilizationClass(load: UserLoad): string { barWidth(value: number): number { return Math.min(Math.max(value, 0), 100); }
return load.status; utilizationClass(load: UserLoad): string { return load.status; }
async downloadRoutes(): Promise<void> {
if (this.exportLoading || !this.hasData) return;
this.exportLoading = true;
this.exportError = '';
try {
await this.excelExport.export(this.projectId);
} catch (error: any) {
this.exportError = error?.message ?? 'No se pudo generar el Excel de rutas.';
} finally {
this.exportLoading = false;
this.cdr.detectChanges();
} }
exportPlanning(): void {
this.exportService.export();
} }
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); }
get userOptions(): { id: string; name: string }[] { return this.userLoads.map(load => ({ id: load.id, name: load.name })); }
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; }
showRoute(route: RouteAssignedResponse): void {
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 totalPdvs = assigned.length + unassigned.length; const assignedPdvIds = new Set(assigned.map(route => String(route.json.pdv_id)));
const unassignedPdvIds = new Set(unassigned.map(route => String(route.pdv_id)).filter(id => !assignedPdvIds.has(id)));
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 totalTravelHours = dailyRoutes.reduce((sum, day) => sum + day.travelHours, 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, fuelLiters: 0, fuelCost: 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; const fuel = fuelCost(load.distance, this.fuelConfig);
load.fuelLiters = fuel.liters;
load.fuelCost = fuel.cost;
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; const fuelTotal = fuelCost(totalDistance, this.fuelConfig);
const averageUtilization = this.userLoads.length this.fuelConfigured = isFuelConfigured(this.fuelConfig);
? this.userLoads.reduce((sum, load) => sum + load.utilization, 0) / this.userLoads.length 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, fuelLiters: fuelTotal.liters, fuelCost: fuelTotal.cost };
: 0; const reasonByPdv = new Map<string, string>();
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');
this.summary = { const counts = new Map<string, number>(); for (const reason of reasonByPdv.values()) counts.set(reason, (counts.get(reason) ?? 0) + 1);
totalPdvs, this.reasons = [...counts].map(([reason, count]) => ({ reason, count })).sort((a, b) => b.count - a.count);
assignedPdvs: assigned.length,
unassignedPdvs: unassigned.length,
coverage: totalPdvs ? (assigned.length / totalPdvs) * 100 : 0,
users,
days,
totalDistance,
totalTravelHours,
averageDistance: assigned.length ? totalDistance / assigned.length : 0,
averageTravelHours: assigned.length ? totalTravelHours / assigned.length : 0,
totalWorkHours,
averageRoutesPerUser: users ? assigned.length / users : 0,
averageUtilization,
assignmentBalance
};
const reasonCounts = new Map<string, number>();
for (const route of unassigned) {
const reason = route.motivo?.trim() || 'Sin motivo especificado';
reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1);
}
this.reasons = Array.from(reasonCounts.entries())
.map(([reason, count]) => ({ reason, count }))
.sort((a, b) => b.count - a.count);
this.hasData = totalPdvs > 0; 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, fuelLiters: 0, fuelCost: 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,
totalTravelHours: 0,
averageDistance: 0,
averageTravelHours: 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);
@@ -112,6 +115,41 @@
box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15); box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15);
} }
.segmented-control {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0;
overflow: hidden;
border: 1px solid var(--glm-gris-medio);
border-radius: 4px;
background: #fff;
}
.segmented-control button {
padding: 8px 4px;
border: 0;
border-right: 1px solid var(--glm-gris-medio);
background: #fff;
color: var(--glm-acero);
cursor: pointer;
font: inherit;
font-size: 0.78rem;
font-weight: 700;
}
.segmented-control button:last-child {
border-right: 0;
}
.segmented-control button:hover {
background: var(--glm-verde-bg);
}
.segmented-control button.active {
background: var(--glm-verde);
color: #fff;
}
.filter-stats { .filter-stats {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -126,6 +164,8 @@
color: var(--glm-acero); color: var(--glm-acero);
} }
.heatmap-help { display: inline-grid; width: 16px; height: 16px; margin-left: auto; place-items: center; border: 1px solid var(--glm-acero); border-radius: 50%; color: var(--glm-acero); cursor: help; font-size: 0.68rem; font-weight: 700; }
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 24px 12px; padding: 24px 12px;
@@ -151,20 +191,45 @@
color: var(--glm-gris-oscuro); color: var(--glm-gris-oscuro);
} }
.unassigned-map-card { .unassigned-bottom-drawer {
padding: 12px; position: fixed;
background: #fff8ed; left: 398px;
right: 18px;
bottom: 18px;
z-index: 950;
overflow: hidden;
border: 1px solid #f2bf86; border: 1px solid #f2bf86;
border-left: 3px solid var(--glm-naranja); border-left: 4px solid var(--glm-naranja);
border-radius: 4px; border-radius: 10px 10px 4px 4px;
background: #fff8ed;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.22);
} }
.unassigned-map-card .info-card-header { margin-bottom: 8px; } .unassigned-drawer-toggle {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border: 0;
background: #fff8ed;
color: var(--glm-azul);
cursor: pointer;
font: inherit;
font-weight: 700;
text-align: left;
}
.unassigned-map-list { .unassigned-drawer-toggle span { display: inline-flex; align-items: center; gap: 8px; }
.unassigned-drawer-toggle .pi-exclamation-triangle { color: var(--glm-naranja); }
.unassigned-drawer-content {
display: grid; display: grid;
gap: 5px; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
max-height: 180px; gap: 8px;
max-height: 240px;
padding: 0 12px 12px;
overflow-y: auto; overflow-y: auto;
} }
@@ -219,86 +284,21 @@
.pending-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; } .pending-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
.pending-actions .btn-primary, .pending-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; } .pending-actions .btn-primary, .pending-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
.routes-list h3 { .employee-load-list { display: grid; gap: 5px; max-height: 318px; margin-top: 10px; overflow-y: auto; border-top: 1px solid var(--glm-gris-claro); padding-top: 8px; }
margin: 0 0 8px; .employee-load-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 8px; width: 100%; padding: 7px 8px; border: 1px solid var(--glm-gris-medio); border-radius: 4px; background: #fff; color: var(--glm-gris-oscuro); cursor: pointer; font: inherit; text-align: left; }
font-size: 0.85rem; .employee-load-row:hover, .employee-load-row.selected { border-color: var(--glm-verde); background: var(--glm-verde-bg); }
color: var(--glm-azul); .employee-load-row.over-capacity { border-color: #c9252d; background: var(--glm-rojo-ok); }
font-weight: 700; .employee-load-name { overflow: hidden; color: var(--glm-azul); font-size: 0.78rem; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
text-transform: uppercase; .employee-load-values { color: var(--glm-acero); font-size: 0.7rem; white-space: nowrap; }
} .employee-load-bar { grid-column: 1 / -1; display: block; height: 6px; overflow: hidden; border-radius: 6px; background: var(--glm-gris-medio); }
.employee-load-bar span { display: block; height: 100%; border-radius: inherit; background: var(--glm-verde); }
.employee-load-bar.warning span { background: var(--glm-naranja); }
.employee-load-bar.over-capacity span { background: #c9252d; }
.routes-list ul { @media (max-width: 900px) {
list-style: none; #map-overview { left: 0; }
margin: 0; .overview-sidebar { width: 340px; max-width: calc(100vw - 24px); }
padding: 0; .unassigned-bottom-drawer { right: 12px; bottom: 12px; left: 12px; }
display: flex;
flex-direction: column;
gap: 6px;
}
.routes-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--glm-gris-medio);
border-radius: 4px;
background: #fff;
cursor: pointer;
transition: all 0.15s;
}
.routes-list li:hover {
background: var(--glm-verde-bg);
border-color: var(--glm-verde);
}
.routes-list li.selected {
background: var(--glm-verde);
border-color: var(--glm-verde);
color: #fff;
}
.routes-list li.selected small {
color: rgba(255, 255, 255, 0.85);
}
.routes-list li.selected i {
color: #fff;
}
.idx {
min-width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--glm-azul);
color: #fff;
font-size: 0.75rem;
font-weight: 700;
}
.li-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.li-main strong {
font-size: 0.85rem;
color: var(--glm-gris-oscuro);
}
.routes-list li.selected .li-main strong {
color: #fff;
}
.li-main small {
font-size: 0.75rem;
color: var(--glm-acero);
} }
.info-card { .info-card {
@@ -309,6 +309,12 @@
border-top: 3px solid var(--glm-azul); border-top: 3px solid var(--glm-azul);
} }
.map-location-button {
margin-top: 0;
padding: 6px 9px;
font-size: 0.75rem;
}
.info-card-header { .info-card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -339,6 +345,13 @@
text-transform: uppercase; text-transform: uppercase;
} }
.visit-group-title {
margin: 8px 0 4px;
color: var(--glm-azul);
font-size: 0.76rem;
font-weight: 700;
}
.reassign-form { .reassign-form {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -353,15 +366,22 @@
.reassign-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; } .reassign-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
.reassign-actions .btn-primary, .reassign-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; } .reassign-actions .btn-primary, .reassign-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
.reassign-hint { grid-column: 1 / -1; margin: 0; color: var(--glm-acero); font-size: 0.74rem; } .reassign-hint { grid-column: 1 / -1; margin: 0; color: var(--glm-acero); font-size: 0.74rem; }
.reassign-form .reassign-actions, .reassign-form .error-card { grid-column: 1 / -1; } .reassign-form .reassign-actions, .reassign-form .employee-load-list, .reassign-form .error-card { grid-column: 1 / -1; }
.daily-routes-card { padding: 12px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: var(--glm-surface); } .daily-routes-card { padding: 12px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: var(--glm-surface); }
.daily-routes-card h3 { margin: 0 0 8px; color: var(--glm-azul); font-size: 0.82rem; text-transform: uppercase; } .daily-routes-card h3 { margin: 0 0 8px; color: var(--glm-azul); font-size: 0.82rem; text-transform: uppercase; }
.daily-route-group { border-bottom: 1px solid var(--glm-gris-claro); }
.daily-route-group:last-child { border-bottom: 0; }
.daily-route-toggle { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 0; border: 0; background: transparent; color: var(--glm-gris-oscuro); cursor: pointer; font: inherit; text-align: left; }
.daily-route-toggle:hover { background: var(--glm-gris-claro); }
.daily-route-toggle-title { color: var(--glm-azul); font-weight: 700; }
.daily-route-toggle > span:last-child { color: var(--glm-acero); font-size: 0.78rem; }
.daily-route-group-content { padding-left: 14px; }
.daily-route { display: grid; gap: 3px; padding: 8px 0; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); font-size: 0.78rem; } .daily-route { display: grid; gap: 3px; padding: 8px 0; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); font-size: 0.78rem; }
.daily-route:last-child { border-bottom: 0; } .daily-route:last-child { border-bottom: 0; }
.daily-route span, .daily-route small { color: var(--glm-acero); } .daily-route span, .daily-route small { color: var(--glm-acero); }
.daily-route.over-capacity small { color: #c9252d; font-weight: 700; } .daily-route.over-capacity small { color: #c9252d; font-weight: 700; }
.routes-list li:focus-visible, .btn-icon:focus-visible, button:focus-visible, select:focus-visible { outline: 3px solid rgba(108, 194, 74, 0.45); outline-offset: 2px; } .btn-icon:focus-visible, button:focus-visible, select:focus-visible { outline: 3px solid rgba(108, 194, 74, 0.45); outline-offset: 2px; }
.btn-icon { .btn-icon {
background: transparent; background: transparent;
@@ -386,6 +406,7 @@
.info-list div { .info-list div {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
align-items: center;
gap: 10px; gap: 10px;
padding: 5px 0; padding: 5px 0;
border-bottom: 1px solid var(--glm-gris-claro); border-bottom: 1px solid var(--glm-gris-claro);
@@ -474,40 +495,46 @@
color: var(--glm-gris-oscuro); color: var(--glm-gris-oscuro);
} }
.heat-opts {
display: flex;
gap: 12px;
padding-left: 22px;
font-size: 0.78rem;
color: var(--glm-acero);
}
.cluster-glm {
background: none !important;
border: none !important;
}
.cluster-inner {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--glm-verde);
border: 3px solid #fff;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
color: #fff;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-family: Arial, sans-serif;
font-size: 12px;
}
.custom-marker { .custom-marker {
background: none !important; background: none !important;
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;
@@ -521,3 +548,62 @@
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
} }
:host.embedded {
height: 620px;
min-height: 480px;
overflow: hidden;
border: 1px solid var(--glm-gris-medio);
border-radius: 4px;
background: #fff;
}
:host.embedded #map-overview {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 340px;
height: 100%;
}
:host.embedded .overview-sidebar {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 340px;
z-index: 500;
}
:host.embedded .unassigned-bottom-drawer {
position: absolute;
left: 352px;
right: 12px;
bottom: 12px;
z-index: 600;
}
@media (max-width: 1100px) {
:host.embedded {
height: auto;
min-height: 0;
}
:host.embedded #map-overview {
position: relative;
left: 0;
height: 420px;
}
:host.embedded .overview-sidebar {
position: relative;
width: 100%;
max-height: none;
}
:host.embedded .unassigned-bottom-drawer {
position: relative;
left: 0;
right: 0;
bottom: 0;
margin: 0 12px 12px;
}
}
@@ -6,22 +6,29 @@
</div> </div>
<div class="overview-sidebar"> <div class="overview-sidebar">
<div class="sidebar-brand"> <div class="sidebar-brand" *ngIf="!embedded">
<img src="GLM_completo.png" alt="GomezLee Marketing" class="sidebar-logo" /> <img src="GLM_completo.png" alt="GomezLee Marketing" class="sidebar-logo" />
</div> </div>
<div class="sidebar-header"> <div class="sidebar-header">
<button type="button" class="back-btn" (click)="goBack()" aria-label="Volver a la planificación"> <button type="button" class="back-btn" *ngIf="!embedded" (click)="goBack()" aria-label="Volver a la planificación">
<i class="pi pi-arrow-left"></i> Volver <i class="pi pi-arrow-left"></i> Volver
</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">Ciudad</label>
<select class="filter-select" [(ngModel)]="selectedMetro" (ngModelChange)="onMetroChange()">
<option value="Todos">Todas las ciudades</option>
<option *ngFor="let metro of metros" [value]="metro">{{ metro }}</option>
</select>
<label class="filter-label">Usuario</label> <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>
@@ -30,8 +37,15 @@
<option *ngFor="let d of dias" [value]="d">{{ d }}</option> <option *ngFor="let d of dias" [value]="d">{{ d }}</option>
</select> </select>
<label class="filter-label">Asignación</label>
<div class="segmented-control" role="group" aria-label="Filtrar PDVs por asignación">
<button type="button" [class.active]="selectedAsignacion === 'Todos'" (click)="selectedAsignacion = 'Todos'; onAsignacionChange()">Todos</button>
<button type="button" [class.active]="selectedAsignacion === 'Asignados'" (click)="selectedAsignacion = 'Asignados'; onAsignacionChange()">Asignados</button>
<button type="button" [class.active]="selectedAsignacion === 'NoAsignados'" (click)="selectedAsignacion = 'NoAsignados'; onAsignacionChange()">Sin asignar</button>
</div>
<div class="filter-stats" *ngIf="filteredRoutes.length"> <div class="filter-stats" *ngIf="filteredRoutes.length">
<span>{{ filteredRoutes.length }} PDVs</span> <span>{{ uniqueFilteredRoutes.length }} PDVs</span>
<span>{{ totalDistancia }} km</span> <span>{{ totalDistancia }} km</span>
<span>{{ totalDuracionMin }} min</span> <span>{{ totalDuracionMin }} min</span>
</div> </div>
@@ -40,22 +54,27 @@
<label class="toggle"> <label class="toggle">
<input type="checkbox" [checked]="showHeat" (change)="toggleHeat()" /> <input type="checkbox" [checked]="showHeat" (change)="toggleHeat()" />
<i class="pi pi-fire"></i> Heatmap (carga) <i class="pi pi-fire"></i> Heatmap (carga)
<span class="heatmap-help" title="El mapa muestra con mayor intensidad las zonas donde se concentra más carga de trabajo en horas.">?</span>
</label> </label>
<div class="heat-opts" *ngIf="showHeat">
<label><input type="radio" name="hw" value="uniform" [checked]="heatWeightMode==='uniform'" (change)="setHeatWeight('uniform')" /> Peso 1 (densidad)</label>
<label><input type="radio" name="hw" value="horas_trabajo" [checked]="heatWeightMode==='horas_trabajo'" (change)="setHeatWeight('horas_trabajo')" /> Por carga (horas)</label>
</div>
</div> </div>
<div class="filter-empty" *ngIf="!filteredRoutes.length && !error"> <div class="filter-empty" *ngIf="!filteredRoutes.length && !error">
Sin rutas para este filtro. Sin rutas para este filtro.
</div> </div>
<div class="filter-empty" *ngIf="overviewMode && filteredRoutes.length">
Vista general ({{ filteredRoutes.length }} PDVs). Filtra por cobertura, zona, usuario o día para ver el detalle vial completo.
</div>
<div class="filter-empty" *ngIf="orsFailCount">
{{ orsFailCount }} tramo(s) sin conexión vial (línea recta). Toca la línea para ver el motivo.
</div>
</div> </div>
<div class="empty-state" *ngIf="!usuarios.length"> <div class="empty-state" *ngIf="!usuarios.length">
<i class="pi pi-inbox"></i> <i class="pi pi-inbox"></i>
<p>No hay rutas cargadas.</p> <p>No hay rutas cargadas.</p>
<button type="button" class="btn-secondary" (click)="goBack()">Ir a Home</button> <button type="button" class="btn-secondary" *ngIf="!embedded" (click)="goBack()">Ir a Home</button>
</div> </div>
<div class="error-card" *ngIf="error && usuarios.length"> <div class="error-card" *ngIf="error && usuarios.length">
@@ -63,19 +82,7 @@
{{ error }} {{ error }}
</div> </div>
<section class="unassigned-map-card" *ngIf="selectedUsuarioId === 'Todos' && selectedDia === 'Todos' && unassignedRoutes.length"> <section class="info-card unassigned-detail-card" *ngIf="selectedUnassigned && showUnassigned">
<div class="info-card-header">
<h3>PDVs sin ruta ({{ unassignedRoutes.length }})</h3>
</div>
<div class="unassigned-map-list">
<button type="button" class="unassigned-map-item" *ngFor="let pending of unassignedRoutes" [class.selected-pending]="selectedUnassigned?.pdv_id === pending.pdv_id" (click)="selectUnassigned(pending)">
<span class="pending-icon">!</span>
<span class="pending-text"><strong>{{ pending.pdv_nombre }}</strong><small>{{ pending.motivo }}</small></span>
</button>
</div>
</section>
<section class="info-card unassigned-detail-card" *ngIf="selectedUnassigned">
<div class="info-card-header"> <div class="info-card-header">
<h3>Gestionar PDV sin ruta</h3> <h3>Gestionar PDV sin ruta</h3>
<button type="button" class="btn-icon" (click)="selectedUnassigned = null"><i class="pi pi-times"></i></button> <button type="button" class="btn-icon" (click)="selectedUnassigned = null"><i class="pi pi-times"></i></button>
@@ -101,6 +108,13 @@
<label>Días<div class="day-checkboxes"><label *ngFor="let day of unassignedDayOptions"><input type="checkbox" [checked]="(assignmentDays[selectedUnassigned.pdv_id] || []).includes(day)" (change)="toggleUnassignedDay(selectedUnassigned.pdv_id, day, $any($event.target).checked)">{{ day.substring(0, 3) }}</label></div></label> <label>Días<div class="day-checkboxes"><label *ngFor="let day of unassignedDayOptions"><input type="checkbox" [checked]="(assignmentDays[selectedUnassigned.pdv_id] || []).includes(day)" (change)="toggleUnassignedDay(selectedUnassigned.pdv_id, day, $any($event.target).checked)">{{ day.substring(0, 3) }}</label></div></label>
</div> </div>
<div class="pending-actions"><button type="button" class="btn-secondary" (click)="startUnassignedEdit(selectedUnassigned)"><i class="pi pi-pencil"></i> Editar</button><button type="button" class="btn-primary" (click)="assignUnassigned(selectedUnassigned)" [disabled]="assigningUnassignedId === selectedUnassigned.pdv_id"><span *ngIf="assigningUnassignedId === selectedUnassigned.pdv_id" class="spinner-inline"></span><i *ngIf="assigningUnassignedId !== selectedUnassigned.pdv_id" class="pi pi-user-plus"></i> Reasignar</button></div> <div class="pending-actions"><button type="button" class="btn-secondary" (click)="startUnassignedEdit(selectedUnassigned)"><i class="pi pi-pencil"></i> Editar</button><button type="button" class="btn-primary" (click)="assignUnassigned(selectedUnassigned)" [disabled]="assigningUnassignedId === selectedUnassigned.pdv_id"><span *ngIf="assigningUnassignedId === selectedUnassigned.pdv_id" class="spinner-inline"></span><i *ngIf="assigningUnassignedId !== selectedUnassigned.pdv_id" class="pi pi-user-plus"></i> Reasignar</button></div>
<div class="employee-load-list" aria-label="Carga de empleados">
<button type="button" class="employee-load-row" *ngFor="let employee of unassignedEmployeeLoads" [class.selected]="assignmentUser[selectedUnassigned.pdv_id] === employee.id" [class.over-capacity]="employeeLoadStatus(employee) === 'over-capacity'" (click)="selectUnassignedEmployee(employee.id)">
<span class="employee-load-name">{{ employee.name }}</span>
<span class="employee-load-values">{{ employee.workHours | number:'1.0-1' }} / {{ employee.capacityHours | number:'1.0-1' }} h · {{ employee.utilization | number:'1.0-1' }}%</span>
<span class="employee-load-bar" [ngClass]="employeeLoadStatus(employee)"><span [style.width.%]="employeeLoadWidth(employee)"></span></span>
</button>
</div>
</ng-template> </ng-template>
<div class="error-card" *ngIf="unassignedActionError">{{ unassignedActionError }}</div> <div class="error-card" *ngIf="unassignedActionError">{{ unassignedActionError }}</div>
</section> </section>
@@ -129,11 +143,23 @@
<div><dt>Día</dt><dd>{{ selectedRouteDetail.json.dia }}</dd></div> <div><dt>Día</dt><dd>{{ selectedRouteDetail.json.dia }}</dd></div>
<div><dt>Orden de visita</dt><dd>{{ selectedVisitOrder }}</dd></div> <div><dt>Orden de visita</dt><dd>{{ selectedVisitOrder }}</dd></div>
<div><dt>Origen del tramo</dt><dd>{{ selectedSegmentOrigin }}</dd></div> <div><dt>Origen del tramo</dt><dd>{{ selectedSegmentOrigin }}</dd></div>
<div><dt>Destino</dt><dd>{{ selectedRouteDetail.json.latitud_pdv }}, {{ selectedRouteDetail.json.longitud_pdv }}</dd></div> <div><dt>Destino</dt><dd><button type="button" class="btn-secondary map-location-button" (click)="openDestinationInGoogleMaps()" title="Abrir ubicación en Google Maps"><i class="pi pi-map"></i> Ver en Google Maps</button></dd></div>
<div><dt>Horas trabajo</dt><dd>{{ selectedRouteDetail.json.horas_trabajo }}</dd></div> <div><dt>Horas trabajo</dt><dd>{{ selectedRouteDetail.json.horas_trabajo }}</dd></div>
<div><dt>Distancia tramo</dt><dd>{{ selectedSegmentDistance | number:'1.2-2' }} km</dd></div> <div><dt>Distancia tramo</dt><dd>{{ selectedSegmentDistance | number:'1.2-2' }} km</dd></div>
<div><dt>Duración tramo</dt><dd>{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min</dd></div> <div><dt>Duración tramo</dt><dd>{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min</dd></div>
</dl> </dl>
<div class="detail-section" *ngIf="otherPdvVisitGroups.length">
<h4><i class="pi pi-calendar"></i> Otras visitas de este PDV</h4>
<div *ngFor="let group of otherPdvVisitGroups">
<p class="visit-group-title">Usuario: {{ group.usuario }}</p>
<div class="employee-load-list" [attr.aria-label]="'Visitas de ' + group.usuario">
<button type="button" class="employee-load-row" *ngFor="let visit of group.visits" (click)="selectRouteFromList(visit.route)">
<span class="employee-load-name">{{ visit.dia }} (parada {{ visit.orden }})</span>
<span class="employee-load-values">{{ visit.km | number:'1.2-2' }} km · {{ visit.min | number:'1.0-0' }} min</span>
</button>
</div>
</div>
</div>
<div class="reassign-actions" *ngIf="!showReassignForm"> <div class="reassign-actions" *ngIf="!showReassignForm">
<button type="button" class="btn-secondary" (click)="moveSelectedVisit(-1)" [disabled]="!canMoveSelectedVisitUp" aria-label="Mover visita anterior"><i class="pi pi-arrow-up"></i> Subir</button> <button type="button" class="btn-secondary" (click)="moveSelectedVisit(-1)" [disabled]="!canMoveSelectedVisitUp" aria-label="Mover visita anterior"><i class="pi pi-arrow-up"></i> Subir</button>
<button type="button" class="btn-secondary" (click)="moveSelectedVisit(1)" [disabled]="!canMoveSelectedVisitDown" aria-label="Mover visita posterior"><i class="pi pi-arrow-down"></i> Bajar</button> <button type="button" class="btn-secondary" (click)="moveSelectedVisit(1)" [disabled]="!canMoveSelectedVisitDown" aria-label="Mover visita posterior"><i class="pi pi-arrow-down"></i> Bajar</button>
@@ -145,6 +171,13 @@
<label>Día<select [(ngModel)]="reassignDay"><option *ngFor="let day of unassignedDayOptions" [value]="day">{{ day }}</option></select></label> <label>Día<select [(ngModel)]="reassignDay"><option *ngFor="let day of unassignedDayOptions" [value]="day">{{ day }}</option></select></label>
<p class="reassign-hint">Se recalcularán la distancia y duración con ruta vial ORS.</p> <p class="reassign-hint">Se recalcularán la distancia y duración con ruta vial ORS.</p>
<div class="reassign-actions"><button type="button" class="btn-primary" (click)="reassignRoute()" [disabled]="reassigningRoute"><span *ngIf="reassigningRoute" class="spinner-inline"></span><i *ngIf="!reassigningRoute" class="pi pi-check"></i> {{ reassigningRoute ? 'Calculando...' : 'Confirmar' }}</button><button type="button" class="btn-secondary" (click)="cancelReassignRoute()" [disabled]="reassigningRoute">Cancelar</button></div> <div class="reassign-actions"><button type="button" class="btn-primary" (click)="reassignRoute()" [disabled]="reassigningRoute"><span *ngIf="reassigningRoute" class="spinner-inline"></span><i *ngIf="!reassigningRoute" class="pi pi-check"></i> {{ reassigningRoute ? 'Calculando...' : 'Confirmar' }}</button><button type="button" class="btn-secondary" (click)="cancelReassignRoute()" [disabled]="reassigningRoute">Cancelar</button></div>
<div class="employee-load-list" aria-label="Carga de empleados">
<button type="button" class="employee-load-row" *ngFor="let employee of reassignmentEmployeeLoads" [class.selected]="reassignUserId === employee.id" [class.over-capacity]="employeeLoadStatus(employee) === 'over-capacity'" (click)="selectReassignmentEmployee(employee.id)">
<span class="employee-load-name">{{ employee.name }}</span>
<span class="employee-load-values">{{ employee.workHours | number:'1.0-1' }} / {{ employee.capacityHours | number:'1.0-1' }} h · {{ employee.utilization | number:'1.0-1' }}%</span>
<span class="employee-load-bar" [ngClass]="employeeLoadStatus(employee)"><span [style.width.%]="employeeLoadWidth(employee)"></span></span>
</button>
</div>
<div class="error-card" *ngIf="reassignError">{{ reassignError }}</div> <div class="error-card" *ngIf="reassignError">{{ reassignError }}</div>
</div> </div>
</div> </div>
@@ -152,26 +185,32 @@
<section class="daily-routes-card" *ngIf="dailySummaries.length"> <section class="daily-routes-card" *ngIf="dailySummaries.length">
<h3>Rutas diarias</h3> <h3>Rutas diarias</h3>
<article class="daily-route" *ngFor="let day of dailySummaries" [class.over-capacity]="day.exceedsCapacity"> <article class="daily-route-group" *ngFor="let group of dailyGroups">
<strong>{{ day.userName }} · {{ day.day }}</strong> <button type="button" class="daily-route-toggle" (click)="toggleDailyRouteDay(group.day)" [attr.aria-expanded]="isDailyRouteDayExpanded(group.day)">
<span>{{ day.routes.length }} paradas · {{ day.distanceKm | number:'1.1-1' }} km · {{ (day.travelHours * 60) | number:'1.0-0' }} min</span> <span class="daily-route-toggle-title"><i class="pi" [class.pi-chevron-right]="!isDailyRouteDayExpanded(group.day)" [class.pi-chevron-down]="isDailyRouteDayExpanded(group.day)"></i> {{ group.day }}</span>
<small [attr.aria-label]="day.exceedsCapacity ? 'Capacidad excedida' : 'Dentro de capacidad'">{{ day.totalHours | number:'1.1-1' }} / {{ day.capacityHours | number:'1.1-1' }} h {{ day.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}</small> <span>{{ group.totalStops }} paradas · {{ group.totalDistance | number:'1.1-1' }} km</span>
</button>
<div class="daily-route-group-content" *ngIf="isDailyRouteDayExpanded(group.day)">
<article class="daily-route" *ngFor="let route of group.routes" [class.over-capacity]="route.exceedsCapacity">
<strong>{{ route.userName }}</strong>
<span>{{ route.routes.length }} paradas · {{ route.distanceKm | number:'1.1-1' }} km</span>
<small [attr.aria-label]="route.exceedsCapacity ? 'Capacidad excedida' : 'Dentro de capacidad'">{{ route.totalHours | number:'1.1-1' }} / {{ route.capacityHours | number:'1.1-1' }} h {{ route.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}</small>
</article>
</div>
</article> </article>
</section> </section>
<div class="routes-list" *ngIf="filteredRoutes.length">
<h3>{{ selectedUsuarioId === 'Todos' ? 'PDVs de todos los usuarios' : 'PDVs del usuario' }} ({{ filteredRoutes.length }})</h3>
<ul>
<li *ngFor="let r of filteredRoutes; let i = index" tabindex="0" role="button"
[class.selected]="selectedRouteDetail && selectedRouteDetail.json.pdv_id === r.json.pdv_id && selectedRouteDetail.json.usuario_id === r.json.usuario_id && selectedRouteDetail.json.dia === r.json.dia"
(click)="selectRouteFromList(r)" (keydown.enter)="selectRouteFromList(r)" (keydown.space)="selectRouteFromList(r); $event.preventDefault()">
<span class="idx">{{ routeVisitOrder(r) }}</span>
<div class="li-main">
<strong>{{ r.json.nombre_pdv }}</strong>
<small>{{ selectedUsuarioId === 'Todos' ? r.json.nombre_usuario + ' • ' : '' }}{{ r.json.dia }} • {{ routeSegmentDistance(r) | number:'1.2-2' }} km • {{ routeSegmentDurationMinutes(r) | number:'1.0-0' }} min</small>
</div>
<i class="pi pi-chevron-right"></i>
</li>
</ul>
</div>
</div> </div>
<section class="unassigned-bottom-drawer" *ngIf="showUnassigned && selectedUsuarioId === 'Todos' && selectedDia === 'Todos' && unassignedRoutes.length" [class.open]="isUnassignedDrawerOpen">
<button type="button" class="unassigned-drawer-toggle" (click)="toggleUnassignedDrawer()" [attr.aria-expanded]="isUnassignedDrawerOpen">
<span><i class="pi pi-exclamation-triangle"></i> PDVs sin ruta ({{ unassignedRoutes.length }})</span>
<i class="pi" [class.pi-chevron-up]="!isUnassignedDrawerOpen" [class.pi-chevron-down]="isUnassignedDrawerOpen"></i>
</button>
<div class="unassigned-drawer-content" *ngIf="isUnassignedDrawerOpen">
<button type="button" class="unassigned-map-item" *ngFor="let pending of unassignedRoutes" [class.selected-pending]="selectedUnassigned?.pdv_id === pending.pdv_id" (click)="selectUnassigned(pending)">
<span class="pending-icon">!</span>
<span class="pending-text"><strong>{{ pending.pdv_nombre }}</strong><small>{{ pending.motivo }}</small></span>
</button>
</div>
</section>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
.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; }
}
.success { color: var(--glm-verde); font-weight: 700; }
.error { color: #c9252d; font-weight: 700; }
.import-errors { margin: 12px 0 0; padding: 12px 14px; border: 1px solid #F5A6A0; border-left: 3px solid #c9252d; border-radius: 4px; background: #fff5f4; color: var(--glm-gris-oscuro); font-size: .85rem; }
.import-errors p { margin: 0 0 8px; font-weight: 700; }
.import-errors ul { margin: 0; padding-left: 18px; }
.import-errors li { margin-bottom: 4px; }
@@ -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><div class="import-errors" *ngIf="importErrors.length"><p *ngIf="importMissingHeaders.length">Faltan: {{ importMissingHeaders.join(', ') }}</p><ul><li *ngFor="let e of importErrors.slice(0, 8)"><strong>Fila {{ e.row }} — {{ e.field }}:</strong> {{ e.message }}<span *ngIf="e.value !== undefined && e.value !== null"> ({{ e.value }})</span></li><li *ngIf="importErrors.length > 8">+ {{ importErrors.length - 8 }} errores más...</li></ul></div>
<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,213 @@
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, FieldError } 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 = '';
importErrors: FieldError[] = [];
importMissingHeaders: string[] = [];
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 = ''; this.importErrors = []; this.importMissingHeaders = [];
try {
const result = await this.preview.previewFile(file, 'PDV');
if (!result.valid) {
this.importErrors = result.errors;
this.importMissingHeaders = result.missingHeaders;
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,46 +3,63 @@ 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';
import { ProjectService } from '../../core/services/project.service';
import { currencyForCountry, fuelCost, isFuelConfigured } from '../utils/fuel-estimation.utils';
import { recommendationForUnassigned } from '../utils/unassigned-recommendation.utils';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PlanningExportService { export class PlanningExportService {
constructor(private routeState: RouteState) {} constructor(private routeState: RouteState, private filters: PlanningFilterService, private projects: ProjectService) {}
export(): void { async export(projectId: string): Promise<void> {
const assigned = this.routeState.getAssignedSnapshot(); const project = await this.projects.get(projectId);
const unassigned = this.routeState.getNoAssignedSnapshot(); const fuelConfig = { rendimientoKmL: project.rendimiento_km_l ?? null, precioPorLitro: project.precio_por_litro ?? null };
const users = new Map<string, { nombre: string; rutas: number; distancia: number; trabajo: number; capacidad: number }>(); const fuelConfigured = isFuelConfigured(fuelConfig);
const fuelCurrency = currencyForCountry(project.pais);
const assigned = this.filters.filterAssigned(this.routeState.getAssignedSnapshot());
const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot())
.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
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 pending = unassigned.filter(route => !assignedIds.has(String(route.pdv_id)));
const unassignedIds = new Set(pending.map(route => String(route.pdv_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);
const fuelTotal = fuelCost(totalDistance, fuelConfig);
const workbook = XLSX.utils.book_new(); const workbook = XLSX.utils.book_new();
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)) },
{ Indicador: 'Desplazamiento total (h)', Valor: Number(totalTravel.toFixed(2)) } { Indicador: 'Desplazamiento total (h)', Valor: Number(totalTravel.toFixed(2)) },
{ Indicador: 'Combustible total (L)', Valor: fuelConfigured ? Number(fuelTotal.liters.toFixed(2)) : 'Sin configurar' },
{ Indicador: `Costo combustible total (${fuelCurrency})`, Valor: fuelConfigured ? Number(fuelTotal.cost.toFixed(2)) : 'Sin configurar' }
]; ];
const assignedRows = sequenceRoutes(assigned).map(segment => ({ const assignedRows = sequenceRoutes(assigned).map(segment => ({
@@ -56,26 +73,30 @@ export class PlanningExportService {
'Horas trabajo': segment.route.json.horas_trabajo, 'Horas trabajo': segment.route.json.horas_trabajo,
'Horas disponibles': segment.route.json.hora_laboral_semanal_usuario, 'Horas disponibles': segment.route.json.hora_laboral_semanal_usuario,
'Orden visita': segment.order, 'Orden visita': segment.order,
'Origen del tramo': segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}` 'Origen del tramo': segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}`,
'Costo combustible': fuelConfigured ? Number(fuelCost(Number(segment.route.json.distancia) || 0, fuelConfig).cost.toFixed(2)) : 'Sin configurar'
})); }));
const unassignedRows = unassigned.map(route => ({ const unassignedRows = pending.map(route => ({
'PDV ID': route.pdv_id, 'PDV ID': route.pdv_id,
PDV: route.pdv_nombre, PDV: route.pdv_nombre,
'Mínimo horas/semana': route.minimo_horas_semana, 'Mínimo horas/semana': route.minimo_horas_semana,
Latitud: route.latitud, Latitud: route.latitud,
Longitud: route.longitud, Longitud: route.longitud,
Motivo: route.motivo Motivo: route.motivo,
Recomendación: route.recomendacion ?? ''
})); }));
const userRows = Array.from(users, ([id, user]) => { const userRows = Array.from(users, ([id, user]) => {
const utilization = user.capacidad ? (user.trabajo / user.capacidad) * 100 : 0; const utilization = user.capacidad ? (user.trabajo / user.capacidad) * 100 : 0;
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,
'Utilización (%)': Number(utilization.toFixed(1)), 'Utilización (%)': Number(utilization.toFixed(1)),
[`Costo combustible (${fuelCurrency})`]: fuelConfigured ? Number(fuelCost(user.distancia, fuelConfig).cost.toFixed(2)) : 'Sin configurar',
Estado: utilization > 100 ? 'Excede capacidad' : utilization > 90 ? 'Riesgo' : 'OK' Estado: utilization > 100 ? 'Excede capacidad' : utilization > 90 ? 'Riesgo' : 'OK'
}; };
}); });
@@ -0,0 +1,104 @@
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';
import { currencyForCountry, fuelCost, isFuelConfigured } from '../utils/fuel-estimation.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 totalDistanceKm = dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0);
const fuelConfig = { rendimientoKmL: project.rendimiento_km_l ?? null, precioPorLitro: project.precio_por_litro ?? null };
const fuelConfigured = isFuelConfigured(fuelConfig);
const fuelTotal = fuelCost(totalDistanceKm, fuelConfig);
const fuelCurrency = currencyForCountry(project.pais);
const payload = {
project: { id: project.id, name: project.nombre, country: project.pais ?? '', description: project.descripcion ?? '' },
filters: this.filters.snapshot(),
generatedAt: new Date().toISOString(),
fuel: {
configured: fuelConfigured,
rendimientoKmL: fuelConfig.rendimientoKmL,
precioPorLitro: fuelConfig.precioPorLitro,
currency: fuelCurrency
},
summary: {
totalPdvs,
assignedPdvs: assignedIds.size,
unassignedPdvs: pending.length,
coveragePercent: totalPdvs ? Number(((assignedIds.size / totalPdvs) * 100).toFixed(1)) : 0,
totalDistanceKm: Number(totalDistanceKm.toFixed(2)),
totalWorkHours: Number(dailyRoutes.reduce((sum, route) => sum + route.workHours, 0).toFixed(2)),
fuelLiters: Number(fuelTotal.liters.toFixed(2)),
fuelCost: Number(fuelTotal.cost.toFixed(2)),
fuelCurrency
},
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,
costoCombustible: fuelConfigured ? Number(fuelCost(Number(segment.route.json.distancia) || 0, fuelConfig).cost.toFixed(2)) : null,
monedaCombustible: fuelCurrency,
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.';
}
}
@@ -0,0 +1,25 @@
export interface CountryCenter {
lat: number;
lng: number;
zoom: number;
}
function normalize(value: string | null | undefined): string {
return (value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]/gi, '')
.toLowerCase();
}
export function centerForCountry(country: string | null | undefined): CountryCenter {
const normalized = normalize(country);
if (!normalized) return { lat: 19.4326, lng: -99.1332, zoom: 5 };
if (['mexico', 'mx', 'cdmx', 'ciudaddemexico', 'mexicocity', 'estadosunidosmexicanos'].includes(normalized)) {
return { lat: 19.4326, lng: -99.1332, zoom: 10 };
}
if (['republicadominicana', 'dominicana', 'rd', 'do', 'santodomingo'].includes(normalized)) {
return { lat: 18.4861, lng: -69.9312, zoom: 8 };
}
return { lat: 19.4326, lng: -99.1332, zoom: 5 };
}
@@ -0,0 +1,43 @@
export interface FuelConfig {
rendimientoKmL: number | null;
precioPorLitro: number | null;
}
export function currencyForCountry(country: string | null | undefined): string {
const normalized = (country ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]/gi, '')
.toLowerCase();
if (!normalized) return '$';
if (['mexico', 'mx', 'cdmx', 'ciudaddemexico', 'mexicocity', 'estadosunidosmexicanos'].includes(normalized)) return 'MX$';
if (['republicadominicana', 'dominicana', 'rd', 'do', 'santodomingo'].includes(normalized)) return 'RD$';
return '$';
}
export function isFuelConfigured(config: FuelConfig): boolean {
return (
config.rendimientoKmL != null &&
config.precioPorLitro != null &&
Number.isFinite(config.rendimientoKmL) &&
Number.isFinite(config.precioPorLitro) &&
config.rendimientoKmL > 0 &&
config.precioPorLitro >= 0
);
}
export function fuelLiters(distanceKm: number, rendimientoKmL: number): number {
if (!Number.isFinite(distanceKm) || distanceKm <= 0) return 0;
if (!Number.isFinite(rendimientoKmL) || rendimientoKmL <= 0) return 0;
return distanceKm / rendimientoKmL;
}
export function fuelCost(distanceKm: number, config: FuelConfig): { liters: number; cost: number } {
if (!isFuelConfigured(config)) return { liters: 0, cost: 0 };
const liters = fuelLiters(distanceKm, config.rendimientoKmL as number);
return { liters, cost: liters * (config.precioPorLitro as number) };
}
export function formatFuelCost(cost: number, currency: string): string {
return `${currency} ${cost.toFixed(2)}`;
}
@@ -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',
}; };
+24
View File
@@ -40,6 +40,30 @@
text-align: center; text-align: center;
} }
/* leaflet.heat creates its canvas in the map overlay pane, outside Angular styles. */
.leaflet-heatmap-layer {
z-index: 700 !important;
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;
+3
View File
@@ -0,0 +1,3 @@
alter table public.rutero_proyectos
add column if not exists rendimiento_km_l numeric null,
add column if not exists precio_por_litro numeric null;
+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');