Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3d6653143 | |||
| 23e01f398b | |||
| 0736db2b5c | |||
| 0c652a0bd7 | |||
| c45182012c | |||
| bb6757b492 | |||
| 8f34462876 | |||
| 540bc46fc2 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"name": "Generar reporte de rutas PDF",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "generar-rutas-pdf",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2.1,
|
||||
"position": [-600, 260],
|
||||
"id": "e91ef4ce-4c71-4a7b-91d7-1a4f1c2db015",
|
||||
"name": "Webhook: Generate PDF"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const payload = $json.body && typeof $json.body === 'object' ? $json.body : $json;\nconst errors = [];\nif (!payload?.project?.id || !payload?.project?.name) errors.push('Se requiere la información del proyecto.');\nif (!Array.isArray(payload?.assignedRoutes)) errors.push('assignedRoutes debe ser una lista.');\nif (!Array.isArray(payload?.unassignedPdvs)) errors.push('unassignedPdvs debe ser una lista.');\nif ((payload?.assignedRoutes?.length ?? 0) + (payload?.unassignedPdvs?.length ?? 0) > 5000) errors.push('El reporte excede el máximo de 5,000 registros.');\nif (errors.length) throw new Error(errors.join(' '));\nreturn [{ json: { payload } }];"
|
||||
},
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [-360, 260],
|
||||
"id": "abbd2d6e-5c04-4bbb-ae1b-e4f0d2e4574e",
|
||||
"name": "Validate Report Payload",
|
||||
"onError": "continueErrorOutput"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const payload = $json.payload;\nconst escape = value => String(value ?? '').replace(/[&<>\"']/g, char => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[char]));\nconst filters = Object.entries(payload.filters ?? {}).filter(([, value]) => value).map(([key, value]) => `<span>${escape(key)}: ${escape(value)}</span>`).join('');\nconst routeRows = payload.assignedRoutes.map(route => `<tr><td>${escape(route.usuario)}</td><td>${escape(route.dia)}</td><td>${escape(route.orden)}</td><td>${escape(route.pdv)}</td><td>${escape(route.origen)}</td><td>${escape(route.distanciaKm)} km</td><td>${escape(route.horasTrabajo)} h</td></tr>`).join('') || '<tr><td colspan=\"7\">Sin rutas asignadas para los filtros seleccionados.</td></tr>';\nconst unassignedRows = payload.unassignedPdvs.map(route => `<tr><td>${escape(route.pdv_id)}</td><td>${escape(route.pdv_nombre)}</td><td>${escape(route.minimo_horas_semana)} h</td><td>${escape(route.motivo)}</td><td>${escape(route.recomendacion)}</td></tr>`).join('') || '<tr><td colspan=\"5\">No hay PDVs sin asignar para los filtros seleccionados.</td></tr>';\nconst summary = payload.summary ?? {};\nconst html = `<!doctype html><html><head><meta charset=\"utf-8\"><style>body{font-family:Arial,sans-serif;color:#243746;margin:28px;font-size:11px}h1{color:#4F758B;margin:0}h2{color:#4F758B;border-bottom:2px solid #6CC24A;padding-bottom:6px;margin-top:28px}p{line-height:1.45}.meta{color:#61798a}.filters span{display:inline-block;background:#eef6e8;border:1px solid #a4d65e;border-radius:3px;margin:3px 5px 3px 0;padding:4px 6px}.summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:15px 0}.metric{border:1px solid #d0d0d0;border-left:3px solid #6CC24A;padding:8px}.metric b{display:block;color:#4F758B;font-size:16px}table{width:100%;border-collapse:collapse;margin-top:10px}th{background:#4F758B;color:#fff;text-align:left;padding:7px}td{border-bottom:1px solid #e5e5e5;padding:7px;vertical-align:top}.footer{margin-top:24px;color:#6b8fa3;font-size:9px}</style></head><body><h1>Reporte de rutas</h1><p class=\"meta\"><b>${escape(payload.project.name)}</b><br>${escape(payload.project.country)}${payload.project.description ? `<br>${escape(payload.project.description)}` : ''}<br>Generado: ${escape(payload.generatedAt)}</p>${filters ? `<div class=\"filters\"><b>Filtros aplicados:</b><br>${filters}</div>` : '<p><b>Filtros aplicados:</b> Ninguno</p>'}<div class=\"summary\"><div class=\"metric\"><b>${escape(summary.totalPdvs)}</b>PDVs totales</div><div class=\"metric\"><b>${escape(summary.assignedPdvs)}</b>PDVs asignados</div><div class=\"metric\"><b>${escape(summary.unassignedPdvs)}</b>PDVs sin asignar</div><div class=\"metric\"><b>${escape(summary.coveragePercent)}%</b>Cobertura</div><div class=\"metric\"><b>${escape(summary.totalDistanceKm)} km</b>Distancia</div><div class=\"metric\"><b>${escape(summary.totalWorkHours)} h</b>Trabajo</div></div><h2>Rutas asignadas</h2><table><thead><tr><th>Usuario</th><th>Día</th><th>Orden</th><th>PDV</th><th>Origen</th><th>Distancia</th><th>Trabajo</th></tr></thead><tbody>${routeRows}</tbody></table><h2>PDVs sin asignar</h2><table><thead><tr><th>PDV ID</th><th>PDV</th><th>Horas/semana</th><th>Motivo</th><th>Recomendación</th></tr></thead><tbody>${unassignedRows}</tbody></table><p class=\"footer\">Route Planner · GomezLee Marketing</p></body></html>`;\nreturn [{ json: { fileName: `rutas_${String(payload.project.name).replace(/[^a-z0-9]+/gi, '_').toLowerCase()}.pdf` }, binary: { data: { data: Buffer.from(html).toString('base64'), mimeType: 'text/html', fileName: 'index.html' } } }];"
|
||||
},
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [-100, 260],
|
||||
"id": "9258c394-683f-4569-8ced-af2337ec8dba",
|
||||
"name": "Build Report HTML",
|
||||
"onError": "continueErrorOutput",
|
||||
"notes": "Genera tablas deterministas; no usa IA para alterar cifras, rutas ni recomendaciones."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "http://host.docker.internal:3000/forms/chromium/convert/html",
|
||||
"authentication": "none",
|
||||
"sendBody": true,
|
||||
"contentType": "multipart-form-data",
|
||||
"bodyParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"parameterType": "formBinaryData",
|
||||
"name": "files",
|
||||
"inputDataFieldName": "data"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"responseFormat": "file",
|
||||
"outputPropertyName": "data"
|
||||
}
|
||||
},
|
||||
"timeout": 120000
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [180, 260],
|
||||
"id": "73e2759c-d9f9-49dc-925f-3ad1ee7b3897",
|
||||
"name": "Convert HTML to PDF",
|
||||
"retryOnFail": true,
|
||||
"maxTries": 3,
|
||||
"waitBetweenTries": 3000,
|
||||
"onError": "continueErrorOutput",
|
||||
"notes": "Gotenberg debe estar disponible en host.docker.internal:3000 desde el contenedor n8n."
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "binary",
|
||||
"responseBinaryPropertyName": "data",
|
||||
"options": {
|
||||
"responseHeaders": {
|
||||
"entries": [
|
||||
{
|
||||
"name": "Content-Disposition",
|
||||
"value": "attachment; filename=\"reporte_rutas.pdf\""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.5,
|
||||
"position": [460, 200],
|
||||
"id": "93928c3e-1e9c-4f77-a644-c156fae907a5",
|
||||
"name": "Respond PDF"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ { error: 'validation_error', message: $json.error?.message || $json.message || 'El reporte no contiene datos válidos.' } }}",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.5,
|
||||
"position": [-100, 460],
|
||||
"id": "c8b69f14-45d0-4f55-9f7c-2e3fb3425f14",
|
||||
"name": "Respond Validation Error"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ { error: 'pdf_generation_error', message: 'No se pudo generar el PDF de rutas.', detail: $json.error?.message || $json.message || 'Gotenberg no está disponible.' } }}",
|
||||
"options": {
|
||||
"responseCode": 502
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.5,
|
||||
"position": [460, 430],
|
||||
"id": "c8d6e363-80b6-46ca-a8c6-32ce393207cc",
|
||||
"name": "Respond PDF Error"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook: Generate PDF": { "main": [[{ "node": "Validate Report Payload", "type": "main", "index": 0 }]] },
|
||||
"Validate Report Payload": { "main": [[{ "node": "Build Report HTML", "type": "main", "index": 0 }], [{ "node": "Respond Validation Error", "type": "main", "index": 0 }]] },
|
||||
"Build Report HTML": { "main": [[{ "node": "Convert HTML to PDF", "type": "main", "index": 0 }], [{ "node": "Respond Validation Error", "type": "main", "index": 0 }]] },
|
||||
"Convert HTML to PDF": { "main": [[{ "node": "Respond PDF", "type": "main", "index": 0 }], [{ "node": "Respond PDF Error", "type": "main", "index": 0 }]] }
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
```
|
||||
@@ -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:
|
||||
ors:
|
||||
image: openrouteservice/openrouteservice:latest
|
||||
container_name: ors-route-planner
|
||||
container_name: ors-republica-dominicana
|
||||
ports:
|
||||
- "18080:8082"
|
||||
volumes:
|
||||
@@ -10,5 +10,7 @@ services:
|
||||
- ./ors-docker/elevation_cache:/home/ors/elevation_cache
|
||||
- ./ors-docker/config/ors-config.yml:/home/ors/config/ors-config.yml
|
||||
environment:
|
||||
- JAVA_OPTS=-Djava.awt.headless=true -server -XX:TargetSurvivorRatio=75 -XX:SurvivorRatio=64 -XX:MaxTenuringThreshold=3 -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:InitiatingHeapOccupancyPercent=80 -Xms2g -Xmx4g
|
||||
- XMS=1g
|
||||
- XMX=4g
|
||||
- ADDITIONAL_JAVA_OPTS=-XX:MaxGCPauseMillis=200 -XX:InitiatingHeapOccupancyPercent=80
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
gotenberg:
|
||||
image: gotenberg/gotenberg:8
|
||||
container_name: route-planner-pdf
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
@@ -30,7 +30,7 @@
|
||||
"production": {
|
||||
"budgets": [
|
||||
{ "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" },
|
||||
{ "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "10kB" }
|
||||
{ "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "12kB" }
|
||||
],
|
||||
"outputHashing": "all",
|
||||
"fileReplacements": [
|
||||
|
||||
Generated
+102
@@ -15,6 +15,7 @@
|
||||
"@angular/platform-browser": "^21.1.0",
|
||||
"@angular/router": "^21.1.0",
|
||||
"@primeng/themes": "^21.0.0",
|
||||
"@supabase/supabase-js": "^2.115.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
@@ -4037,6 +4038,98 @@
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
|
||||
@@ -5888,6 +5981,15 @@
|
||||
"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": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@angular/platform-browser": "^21.1.0",
|
||||
"@angular/router": "^21.1.0",
|
||||
"@primeng/themes": "^21.0.0",
|
||||
"@supabase/supabase-js": "^2.115.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
|
||||
@@ -3,12 +3,23 @@ import { LoginComponent } from './public/login/login.component';
|
||||
import { HomeComponent } from './private/home/home.component';
|
||||
import { AssignedRouteDetailComponent } from './private/home/assigned-route-detail/assigned-route-detail.component';
|
||||
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 = [
|
||||
{ path: '', redirectTo: 'login', pathMatch: 'full' },
|
||||
{ path: 'login', component: LoginComponent },
|
||||
{ path: 'home', component: HomeComponent, pathMatch: 'full' },
|
||||
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent },
|
||||
{ path: 'home/mapa', component: RouteOverviewMapComponent },
|
||||
{ path: 'home', component: ProjectListComponent, pathMatch: 'full', canActivate: [authGuard] },
|
||||
{ path: 'proyectos/nuevo', component: NewProjectComponent, canActivate: [authGuard] },
|
||||
{ path: 'proyectos/:id', component: HomeComponent, pathMatch: 'full', canActivate: [authGuard] },
|
||||
{ path: 'proyectos/:id/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] },
|
||||
{ path: 'proyectos/:id/ruta', component: AssignedRouteDetailComponent, canActivate: [authGuard] },
|
||||
{ path: 'proyectos/:id/empleados', component: EmployeesComponent, canActivate: [authGuard] },
|
||||
{ path: 'proyectos/:id/pdvs', component: PdvsComponent, canActivate: [authGuard] },
|
||||
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent, canActivate: [authGuard] },
|
||||
{ path: 'home/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] },
|
||||
{ path: '**', redirectTo: 'login' }
|
||||
];
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface Employee {
|
||||
id: string;
|
||||
proyecto_id: string;
|
||||
employee_code: string;
|
||||
nombre: string;
|
||||
usuario: string | null;
|
||||
email: string | null;
|
||||
perfil: string | null;
|
||||
pais: string | null;
|
||||
regional: string | null;
|
||||
latitud: number;
|
||||
longitud: number;
|
||||
horas_por_dia: number;
|
||||
dias_por_semana: number;
|
||||
jornada_codigo: string;
|
||||
horas_descanso_por_dia: number;
|
||||
dias_descanso: string[];
|
||||
visitas_maximas_por_dia: number;
|
||||
activo: boolean;
|
||||
}
|
||||
|
||||
export type EmployeeInput = Omit<Employee, 'id' | 'proyecto_id'>;
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface RuteroProyecto {
|
||||
id: string;
|
||||
nombre: string;
|
||||
descripcion: string | null;
|
||||
pais: string | null;
|
||||
propietario_id: string;
|
||||
estado: 'optimizado' | 'archivado' | null;
|
||||
version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RuteroProyectoPdv {
|
||||
id: string;
|
||||
proyecto_id: string;
|
||||
pdv_id: string;
|
||||
pdv_data: Record<string, unknown>;
|
||||
}
|
||||
@@ -27,6 +27,24 @@ export interface RouteAssignedJson {
|
||||
usuario_id: string;
|
||||
pdv_id: number;
|
||||
visit_order?: number;
|
||||
geojson?: unknown;
|
||||
horas_por_dia_usuario?: number;
|
||||
horas_descanso_por_dia?: number;
|
||||
jornada_codigo?: string;
|
||||
cobertura?: string;
|
||||
pais?: string;
|
||||
ciudad?: string;
|
||||
estado?: string;
|
||||
provincia?: string;
|
||||
region?: string;
|
||||
zona?: string;
|
||||
metro?: string;
|
||||
pais_usuario?: string;
|
||||
ciudad_usuario?: string;
|
||||
estado_usuario?: string;
|
||||
provincia_usuario?: string;
|
||||
region_usuario?: string;
|
||||
zona_usuario?: string;
|
||||
}
|
||||
export interface RouteNoAssignedResponse {
|
||||
pdv_id: number;
|
||||
@@ -35,4 +53,12 @@ export interface RouteAssignedJson {
|
||||
latitud: number,
|
||||
longitud: number,
|
||||
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];
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const STORAGE_ASSIGNED_ORIGINAL = 'rp_assigned_routes_original';
|
||||
const STORAGE_NO_ASSIGNED = 'rp_no_assigned_routes';
|
||||
const STORAGE_NO_ASSIGNED_ORIGINAL = 'rp_no_assigned_routes_original';
|
||||
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 {
|
||||
userId: string;
|
||||
day: string;
|
||||
@@ -21,17 +21,18 @@ export interface UnassignedAssignment {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RouteState {
|
||||
private projectId: string | null = null;
|
||||
|
||||
private loading = new BehaviorSubject<boolean>(false);
|
||||
loading$ = this.loading.asObservable();
|
||||
|
||||
private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>(this.loadAssigned());
|
||||
private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>([]);
|
||||
assignedRoutes$ = this.assignedRoutes.asObservable();
|
||||
|
||||
private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>(this.loadNoAssigned());
|
||||
private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>([]);
|
||||
noAssignedRoutes$ = this.noAssignedRoutes.asObservable();
|
||||
|
||||
private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(this.loadSelected());
|
||||
private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(null);
|
||||
selectedRoute$ = this.selectedRoute.asObservable();
|
||||
|
||||
resetLoading() {
|
||||
@@ -42,6 +43,15 @@ export class RouteState {
|
||||
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[]) {
|
||||
this.assignedRoutes.next(data);
|
||||
this.persist(STORAGE_ASSIGNED, data);
|
||||
@@ -65,6 +75,12 @@ export class RouteState {
|
||||
nombre_usuario: profile.json.nombre_usuario,
|
||||
latitud_usuario: profile.json.latitud_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,
|
||||
dia: day,
|
||||
distancia: metrics?.distance ?? item.json.distancia,
|
||||
@@ -98,7 +114,20 @@ export class RouteState {
|
||||
nombre_usuario: profile.json.nombre_usuario,
|
||||
nombre_pdv: route.pdv_nombre,
|
||||
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;
|
||||
});
|
||||
if (newRoutes.some(item => !item)) return;
|
||||
@@ -195,6 +224,12 @@ export class RouteState {
|
||||
nombre_usuario: user.json.nombre_usuario,
|
||||
latitud_usuario: user.json.latitud_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
|
||||
}
|
||||
};
|
||||
@@ -221,13 +256,14 @@ export class RouteState {
|
||||
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 routes = this.getAssignedSnapshot();
|
||||
const found = routes.find(r => {
|
||||
const jp = r.json;
|
||||
if (String(jp.pdv_id) !== pid) return false;
|
||||
if (usuarioId && String(jp.usuario_id) !== String(usuarioId)) return false;
|
||||
if (day && jp.dia !== day) return false;
|
||||
return true;
|
||||
});
|
||||
return found ?? null;
|
||||
@@ -257,17 +293,18 @@ export class RouteState {
|
||||
this.noAssignedRoutes.next([]);
|
||||
this.selectedRoute.next(null);
|
||||
try {
|
||||
sessionStorage.removeItem(STORAGE_ASSIGNED);
|
||||
sessionStorage.removeItem(STORAGE_ASSIGNED_ORIGINAL);
|
||||
sessionStorage.removeItem(STORAGE_NO_ASSIGNED);
|
||||
sessionStorage.removeItem(STORAGE_NO_ASSIGNED_ORIGINAL);
|
||||
sessionStorage.removeItem(STORAGE_SELECTED);
|
||||
sessionStorage.removeItem(this.storageKey(STORAGE_ASSIGNED));
|
||||
sessionStorage.removeItem(this.storageKey(STORAGE_ASSIGNED_ORIGINAL));
|
||||
sessionStorage.removeItem(this.storageKey(STORAGE_NO_ASSIGNED));
|
||||
sessionStorage.removeItem(this.storageKey(STORAGE_NO_ASSIGNED_ORIGINAL));
|
||||
sessionStorage.removeItem(this.storageKey(STORAGE_SELECTED));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private persist(key: string, data: unknown) {
|
||||
if (!this.projectId) return;
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(data));
|
||||
sessionStorage.setItem(this.storageKey(key), JSON.stringify(data));
|
||||
} catch { /* ignore storage errors */ }
|
||||
}
|
||||
|
||||
@@ -283,7 +320,7 @@ export class RouteState {
|
||||
|
||||
private loadAssigned(): RouteAssignedResponse[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_ASSIGNED);
|
||||
const raw = sessionStorage.getItem(this.storageKey(STORAGE_ASSIGNED));
|
||||
if (raw) return JSON.parse(raw) as RouteAssignedResponse[];
|
||||
} catch { /* ignore */ }
|
||||
return [];
|
||||
@@ -291,7 +328,7 @@ export class RouteState {
|
||||
|
||||
private loadOriginalAssigned(): RouteAssignedResponse[] {
|
||||
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[];
|
||||
} catch { /* ignore */ }
|
||||
return this.loadAssigned();
|
||||
@@ -299,7 +336,7 @@ export class RouteState {
|
||||
|
||||
private loadNoAssigned(): RouteNoAssignedResponse[] {
|
||||
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[];
|
||||
} catch { /* ignore */ }
|
||||
return [];
|
||||
@@ -307,9 +344,13 @@ export class RouteState {
|
||||
|
||||
private loadSelected(): RouteAssignedResponse | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_SELECTED);
|
||||
const raw = sessionStorage.getItem(this.storageKey(STORAGE_SELECTED));
|
||||
if (raw) return JSON.parse(raw) as RouteAssignedResponse;
|
||||
} catch { /* ignore */ }
|
||||
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 { environment } from '../../../environments/environment';
|
||||
|
||||
const ENDPOINT = `${environment.orsBaseUrl}/directions/driving-car`;
|
||||
const ENDPOINTJSON = `${environment.orsBaseUrl}/directions/driving-car/geojson`;
|
||||
const MAX_CONCURRENT_REQUESTS = 2;
|
||||
|
||||
@Injectable({
|
||||
@@ -41,8 +39,12 @@ export class OrsService {
|
||||
const body = {
|
||||
coordinates: [origin, destination]
|
||||
};
|
||||
const baseUrl = this.isMexico(origin) || this.isMexico(destination)
|
||||
? environment.orsMexicoBaseUrl
|
||||
: environment.orsBaseUrl;
|
||||
const endpoint = `${baseUrl}/directions/driving-car${isJson ? '/geojson' : ''}`;
|
||||
|
||||
return this.http.post<unknown>(isJson ? ENDPOINTJSON : ENDPOINT, body).pipe(
|
||||
return this.http.post<unknown>(endpoint, body).pipe(
|
||||
timeout(8000),
|
||||
catchError(error => {
|
||||
throw error;
|
||||
@@ -50,6 +52,11 @@ export class OrsService {
|
||||
);
|
||||
}
|
||||
|
||||
private isMexico(coordinates: number[]): boolean {
|
||||
const [longitude, latitude] = coordinates;
|
||||
return latitude >= 14 && latitude <= 33 && longitude >= -119 && longitude <= -86;
|
||||
}
|
||||
|
||||
async processPairs(
|
||||
pares: {
|
||||
pdv_id: number;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { RouteAssignedResponse, RouteNoAssignedResponse } from '../models/route.model';
|
||||
import { normalizeRow } from '../../shared/utils/header-normalization.utils';
|
||||
|
||||
export interface PlanningFilters {
|
||||
cobertura: string;
|
||||
zona: string;
|
||||
region: string;
|
||||
estado: string;
|
||||
ciudad: string;
|
||||
}
|
||||
|
||||
export type PlanningFilterKey = keyof PlanningFilters;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlanningFilterService {
|
||||
private readonly empty: PlanningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
|
||||
private readonly filtersSubject = new BehaviorSubject<PlanningFilters>({ ...this.empty });
|
||||
readonly filters$ = this.filtersSubject.asObservable();
|
||||
private metadata = new Map<string, Record<string, unknown>>();
|
||||
|
||||
setPdvs(rows: Record<string, unknown>[]): void {
|
||||
this.metadata = new Map(rows.map((row, index) => {
|
||||
const normalized = normalizeRow(row);
|
||||
return [String(normalized['id'] ?? index + 1), normalized];
|
||||
}));
|
||||
this.clear();
|
||||
}
|
||||
|
||||
set(filters: PlanningFilters): void { this.filtersSubject.next({ ...filters }); }
|
||||
clear(): void { this.filtersSubject.next({ ...this.empty }); }
|
||||
snapshot(): PlanningFilters { return this.filtersSubject.value; }
|
||||
|
||||
options(key: PlanningFilterKey): string[] {
|
||||
const values = [...this.metadata.values()].map(row => String(row[key] ?? '').trim()).filter(Boolean);
|
||||
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
filterAssigned(routes: RouteAssignedResponse[]): RouteAssignedResponse[] {
|
||||
return routes.filter(route => this.matches(String(route.json.pdv_id), route.json as unknown as Record<string, unknown>));
|
||||
}
|
||||
|
||||
filterUnassigned(routes: RouteNoAssignedResponse[]): RouteNoAssignedResponse[] {
|
||||
return routes.filter(route => this.matches(String(route.pdv_id), route as unknown as Record<string, unknown>));
|
||||
}
|
||||
|
||||
private matches(id: string, fallback: Record<string, unknown>): boolean {
|
||||
const metadata = this.metadata.get(id) ?? {};
|
||||
const filters = this.filtersSubject.value;
|
||||
return (Object.keys(filters) as PlanningFilterKey[]).every(key => {
|
||||
const selected = filters[key];
|
||||
if (!selected) return true;
|
||||
return String(metadata[key] ?? fallback[key] ?? '').trim() === selected;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,27 @@ import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlanningInputService {
|
||||
private projectId: string | null = null;
|
||||
private pdvFile: 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.usuariosFile = usuariosFile;
|
||||
}
|
||||
|
||||
getFormData(): FormData | null {
|
||||
if (!this.pdvFile || !this.usuariosFile) return null;
|
||||
getFormData(projectId: string): FormData | null {
|
||||
if (this.projectId !== projectId || !this.pdvFile || !this.usuariosFile) return null;
|
||||
const data = new FormData();
|
||||
data.append('pdv_file', this.pdvFile, this.pdvFile.name);
|
||||
data.append('usuarios_file', this.usuariosFile, this.usuariosFile.name);
|
||||
return data;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
clear(projectId?: string): void {
|
||||
if (projectId && this.projectId !== projectId) return;
|
||||
this.projectId = null;
|
||||
this.pdvFile = null;
|
||||
this.usuariosFile = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { RuteroProyecto, RuteroProyectoPdv } from '../models/project.model';
|
||||
import { supabaseClient } from './supabase.client';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProjectService {
|
||||
async list(): Promise<RuteroProyecto[]> {
|
||||
const { data, error } = await supabaseClient.from('rutero_proyectos').select('*').order('updated_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
return (data ?? []) as RuteroProyecto[];
|
||||
}
|
||||
|
||||
async get(id: string): Promise<RuteroProyecto> {
|
||||
const { data, error } = await supabaseClient.from('rutero_proyectos').select('*').eq('id', id).single();
|
||||
if (error) throw error;
|
||||
return data as RuteroProyecto;
|
||||
}
|
||||
|
||||
async update(id: string, changes: Partial<Pick<RuteroProyecto, 'nombre' | 'descripcion'>>): Promise<void> {
|
||||
const { error } = await supabaseClient.from('rutero_proyectos').update(changes).eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const dependentTables = ['rutero_proyecto_rutas', 'rutero_proyecto_empleados', 'rutero_proyecto_pdvs'];
|
||||
for (const table of dependentTables) {
|
||||
const { error } = await supabaseClient.from(table).delete().eq('proyecto_id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
const { error } = await supabaseClient.from('rutero_proyectos').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async create(nombre: string, descripcion: string, pais: string): Promise<RuteroProyecto> {
|
||||
const { data: sessionData, error: sessionError } = await supabaseClient.auth.getSession();
|
||||
if (sessionError) throw sessionError;
|
||||
const ownerId = sessionData.session?.user.id;
|
||||
if (!ownerId) throw new Error('No hay una sesión activa.');
|
||||
const projectId = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const project: RuteroProyecto = {
|
||||
id: projectId,
|
||||
nombre: nombre.trim(),
|
||||
descripcion: descripcion.trim() || null,
|
||||
pais: pais.trim(),
|
||||
propietario_id: ownerId,
|
||||
estado: null,
|
||||
version: 1,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
};
|
||||
const { error } = await supabaseClient
|
||||
.from('rutero_proyectos')
|
||||
.insert({ id: projectId, nombre: project.nombre, descripcion: project.descripcion, pais: project.pais, propietario_id: ownerId, estado: null });
|
||||
if (error) throw error;
|
||||
return project;
|
||||
}
|
||||
|
||||
async pdvs(id: string): Promise<RuteroProyectoPdv[]> {
|
||||
const { data, error } = await supabaseClient.from('rutero_proyecto_pdvs').select('*').eq('proyecto_id', id).order('created_at');
|
||||
if (error) throw error;
|
||||
return (data ?? []) as RuteroProyectoPdv[];
|
||||
}
|
||||
|
||||
async upsertPdvs(projectId: string, rows: Record<string, unknown>[]): Promise<void> {
|
||||
const pdvs = rows.map((pdv, index) => ({
|
||||
proyecto_id: projectId,
|
||||
pdv_id: String(pdv['ID'] ?? pdv['id'] ?? pdv['Id'] ?? index + 1),
|
||||
pdv_data: pdv
|
||||
}));
|
||||
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').upsert(pdvs, { onConflict: 'proyecto_id,pdv_id' });
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async addPdv(projectId: string, pdv: Record<string, unknown>): Promise<void> {
|
||||
const pdvId = String(pdv['ID'] ?? '').trim();
|
||||
if (!pdvId) throw new Error('El ID del PDV es obligatorio.');
|
||||
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').insert({ proyecto_id: projectId, pdv_id: pdvId, pdv_data: pdv });
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async updatePdv(id: string, pdv: Record<string, unknown>): Promise<void> {
|
||||
const pdvId = String(pdv['ID'] ?? '').trim();
|
||||
if (!pdvId) throw new Error('El ID del PDV es obligatorio.');
|
||||
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').update({ pdv_id: pdvId, pdv_data: pdv }).eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async removePdv(id: string): Promise<void> {
|
||||
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async projectInput(id: string): Promise<{ file: File; country: string; rows: Record<string, unknown>[] }> {
|
||||
const [project, pdvs] = await Promise.all([this.get(id), this.pdvs(id)]);
|
||||
const rows = pdvs.map(pdv => pdv.pdv_data);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), 'PDVs');
|
||||
const data = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
|
||||
const country = rows.map(row => Object.entries(row).find(([key]) => {
|
||||
const normalized = key.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
||||
return normalized === 'pais' || normalized === 'country';
|
||||
})?.[1]).find(value => String(value ?? '').trim());
|
||||
return {
|
||||
file: new File([data], 'pdvs_proyecto.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
||||
country: String(country ?? project.pais ?? 'Republica Dominicana').trim(),
|
||||
rows
|
||||
};
|
||||
}
|
||||
|
||||
async pdvFile(id: string): Promise<File> {
|
||||
return (await this.projectInput(id)).file;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export class RutasService {
|
||||
timeout(environment.requestTimeoutMs),
|
||||
catchError((err: HttpErrorResponse | Error) => {
|
||||
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);
|
||||
})
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
export const supabaseClient = createClient(environment.supabaseUrl, environment.supabaseAnonKey, {
|
||||
auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true }
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { UserProfile } from '../models/user-profile.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserProfileService {
|
||||
private readonly storageKey = 'rp_user_profile';
|
||||
private profile: UserProfile | null = null;
|
||||
avatarUrl = '';
|
||||
constructor() { this.restore(); }
|
||||
get current(): UserProfile | null { return this.profile; }
|
||||
clear(): void {
|
||||
this.profile = null;
|
||||
this.avatarUrl = '';
|
||||
sessionStorage.removeItem(this.storageKey);
|
||||
}
|
||||
|
||||
async load(email: string, avatarUrl = ''): Promise<UserProfile> {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), 15000);
|
||||
try {
|
||||
const response = await fetch(environment.userProfileWebhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), signal: controller.signal });
|
||||
if (!response.ok) throw new Error(`El webhook respondió con ${response.status}.`);
|
||||
const data = await response.json() as Partial<UserProfile>;
|
||||
if (!data.nombre || !data.pais) throw new Error('El webhook no devolvió nombre y país.');
|
||||
this.profile = { nombre: data.nombre, pais: data.pais };
|
||||
this.avatarUrl = avatarUrl;
|
||||
sessionStorage.setItem(this.storageKey, JSON.stringify({ ...this.profile, avatarUrl }));
|
||||
return this.profile;
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') throw new Error('La consulta de datos del usuario tardó demasiado.');
|
||||
throw error;
|
||||
} finally { window.clearTimeout(timeout); }
|
||||
}
|
||||
|
||||
private restore(): void {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(this.storageKey);
|
||||
if (!stored) return;
|
||||
const data = JSON.parse(stored) as Partial<UserProfile> & { avatarUrl?: string };
|
||||
if (!data.nombre || !data.pais) return;
|
||||
this.profile = { nombre: data.nombre, pais: data.pais };
|
||||
this.avatarUrl = data.avatarUrl ?? '';
|
||||
} catch {
|
||||
sessionStorage.removeItem(this.storageKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.employees-topbar { display: flex; align-items: center; gap: 12px; padding: 9px 24px; border-bottom: 3px solid var(--glm-verde); background: #fff; color: var(--glm-azul); font-weight: 700; }
|
||||
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
|
||||
.employees-topbar img { height: 38px; width: auto; }
|
||||
.topbar-actions { display: flex; gap: 8px; margin-left: auto; }
|
||||
.topbar-actions button, .table-header button, .form-actions button, .row-actions button { border: 1px solid var(--glm-gris-medio); border-radius: 4px; background: #fff; color: var(--glm-azul); cursor: pointer; font: inherit; font-size: .78rem; font-weight: 600; padding: 7px 10px; }
|
||||
.topbar-actions button:hover, .table-header button:hover, .form-actions button:hover, .row-actions button:hover { background: var(--glm-verde-bg); border-color: var(--glm-verde); }
|
||||
.employees-page { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
.employees-page header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; border-bottom: 3px solid var(--glm-verde); padding-bottom: 12px; }
|
||||
.page-back-button { display:inline-flex; align-items:center; gap:6px; margin-bottom:18px; padding:8px 11px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:700 .82rem Arial,sans-serif; }.page-back-button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }
|
||||
.employees-page h1, .employees-page h2 { margin: 0; color: var(--glm-azul); }
|
||||
.employees-page h1 { font-size: 1.45rem; }.employees-page h2 { font-size: 1rem; }
|
||||
.employees-page header p, .weekly-capacity { color: var(--glm-acero); }
|
||||
.import-button { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 1px solid var(--glm-verde); border-radius: 4px; background: var(--glm-verde-bg); color: var(--glm-azul); cursor: pointer; font-size: .82rem; font-weight: 700; }.import-button input { display: none; }.import-message { color: var(--glm-verde); font-weight: 600; }
|
||||
.employee-form-card, .employee-table-card { margin-top: 16px; padding: 18px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: #fff; }
|
||||
.employee-form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.employee-form-grid label { display: flex; flex-direction: column; gap: 4px; color: var(--glm-azul); font-size: .75rem; font-weight: 700; text-transform: uppercase; }
|
||||
.employee-form-grid input, .employee-form-grid select { padding: 8px; border: 1px solid #9aaab3; border-radius: 4px; background: #fff; color: #2f3f48; font: inherit; font-weight: 500; text-transform: none; }
|
||||
.employee-form-grid input::placeholder { color: #71808a; opacity: 1; }
|
||||
.employee-form-grid input:focus, .employee-form-grid select:focus { outline: 2px solid rgba(108, 194, 74, .35); border-color: var(--glm-verde); background: #fff; color: #1f2d34; }
|
||||
.form-actions { display: flex; gap: 8px; }.form-actions button:first-child { background: var(--glm-azul); color: #fff; }.form-error { color: #c9252d; font-size: .82rem; }.table-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }.table-wrap { overflow-x: auto; margin-top: 12px; }table { width: 100%; min-width: 920px; border-collapse: collapse; font-size: .82rem; }th { padding: 9px; background: var(--glm-azul); color: #fff; text-align: left; }td { padding: 9px; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); }.status { color: var(--glm-verde); font-weight: 700; }.status.inactive { color: #c9252d; }.row-actions { display: flex; gap: 5px; }.row-actions .delete { color: #c9252d; }.loading { color: var(--glm-acero); }.rest-days-button { max-width: 155px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.rest-days-picker { display: grid; gap: 4px; margin-top: 6px; padding: 6px; border: 1px solid var(--glm-gris-medio); background: #fff; }.rest-days-picker label { font-size: .72rem; white-space: nowrap; }
|
||||
.editing-row { background: var(--glm-verde-bg); }.editing-row select, .inline-number { width: 100%; min-width: 92px; box-sizing: border-box; padding: 7px; border: 1px solid var(--glm-verde); border-radius: 4px; font: inherit; }.inline-number { max-width: 100px; }.inline-picker { min-width: 130px; margin: 0; }.editing-row .row-actions { flex-wrap: wrap; }
|
||||
.table-header > div { display:flex; align-items:center; gap:8px; }.selected-count { color:var(--glm-azul); font-size:.78rem; font-weight:700; }.select-column { width:38px; }.bulk-actions { display:flex; flex-wrap:wrap; align-items:end; gap:10px; margin:12px 0; padding:12px; border:1px solid var(--glm-verde-claro); background:var(--glm-verde-bg); }.bulk-actions > label,.bulk-actions fieldset { display:grid; gap:4px; margin:0; color:var(--glm-azul); font-size:.72rem; font-weight:700; text-transform:uppercase; }.bulk-actions input,.bulk-actions select { min-height:32px; padding:5px 7px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; font:inherit; }.bulk-actions fieldset { grid-template-columns:repeat(2,max-content); gap:5px 8px; padding:6px 8px; text-transform:none; }.bulk-actions legend { padding:0 3px; text-transform:uppercase; }.bulk-actions fieldset label { font-size:.72rem; font-weight:400; }.bulk-actions fieldset input { min-height:0; padding:0; }.bulk-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:7px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.bulk-actions button:hover:not(:disabled) { background:#fff; border-color:var(--glm-verde); }.bulk-actions button.delete { color:#c9252d; }
|
||||
@media (max-width: 760px) { .employees-page { padding: 16px; }.employee-form-grid { grid-template-columns: 1fr; }.employees-topbar { padding: 9px 16px; }.topbar-actions button { font-size: 0; }.topbar-actions button i { font-size: .9rem; } }
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="employees-topbar">
|
||||
<img src="GLM_completo.png" alt="GomezLee Marketing" />
|
||||
<span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div>
|
||||
<div class="topbar-actions"><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
|
||||
</div>
|
||||
<main class="employees-page">
|
||||
<button type="button" class="page-back-button" (click)="goHome()"><i class="pi pi-arrow-left"></i> Volver</button>
|
||||
<header><div><h1>Gestionar empleados</h1><p>{{ activeEmployees }} activos de {{ employees.length }} empleados registrados</p></div><label class="import-button"><i class="pi pi-file-import"></i> {{ importing ? 'Importando...' : 'Importar empleados' }}<input type="file" accept=".xlsx,.xls" (change)="importFile($event)" [disabled]="importing"></label></header>
|
||||
<p class="import-message" *ngIf="importMessage">{{ importMessage }}</p>
|
||||
<section class="employee-form-card"><h2>Nuevo empleado</h2><form (ngSubmit)="save()" #employeeForm="ngForm"><div class="employee-form-grid"><label>ID<input name="employeeCode" [(ngModel)]="form.employee_code" required></label><label>Nombre<input name="nombre" [(ngModel)]="form.nombre" required></label><label>Usuario<input name="usuario" [(ngModel)]="form.usuario"></label><label>Email<input type="email" name="email" [(ngModel)]="form.email"></label><label>Perfil<input name="perfil" [(ngModel)]="form.perfil"></label><label>Regional<input name="regional" [(ngModel)]="form.regional"></label><label>País<input name="pais" [(ngModel)]="form.pais"></label><label>Estado<select name="activo" [(ngModel)]="form.activo"><option [ngValue]="true">Activo</option><option [ngValue]="false">Inactivo</option></select></label><label>Latitud<input type="number" name="latitud" [(ngModel)]="form.latitud" step="any" required></label><label>Longitud<input type="number" name="longitud" [(ngModel)]="form.longitud" step="any" required></label><label>Horas por día<input type="number" name="hoursDay" [(ngModel)]="form.horas_por_dia" min="0.5" max="24" step="0.5" required></label><label>Jornada de trabajo<select name="schedule" [(ngModel)]="form.jornada_codigo"><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></label><label>Horas de descanso/día<input type="number" name="dailyRest" [(ngModel)]="form.horas_descanso_por_dia" min="0" max="3" step="1" required></label><label>Visitas máximas/día<input type="number" name="visitsDay" [(ngModel)]="form.visitas_maximas_por_dia" min="1" required></label></div><p class="weekly-capacity">Capacidad semanal: {{ selectedScheduleHours }} h · Capacidad diaria efectiva: {{ form.horas_por_dia - form.horas_descanso_por_dia | number:'1.0-1' }} h</p><p class="form-error" *ngIf="error">{{ error }}</p><div class="form-actions"><button type="submit" [disabled]="saving || employeeForm.invalid"><i class="pi pi-save"></i> {{ saving ? 'Guardando...' : 'Agregar empleado' }}</button></div></form></section>
|
||||
<section class="employee-table-card"><div class="table-header"><h2>Empleados registrados</h2><div><span class="selected-count" *ngIf="selectedIds.size">{{ selectedIds.size }} seleccionados</span><button type="button" (click)="toggleAll(!allSelected)" [disabled]="loading">{{ allSelected ? 'Deseleccionar todos' : 'Seleccionar todos' }}</button><button type="button" (click)="load()" [disabled]="loading"><i class="pi pi-refresh"></i> Actualizar</button></div></div><p class="form-error" *ngIf="error && !loading">{{ error }}</p><div class="bulk-actions" *ngIf="selectedIds.size"><label>Jornada<select [(ngModel)]="bulkSchedule"><option value="">Sin cambio</option><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></label><label>Descanso/día<input type="number" min="0" max="3" step="1" [(ngModel)]="bulkRestHours" placeholder="Sin cambio"></label><label>Visitas/día<input type="number" min="1" step="1" [(ngModel)]="bulkVisits" placeholder="Sin cambio"></label><fieldset><legend>Días de descanso</legend><label *ngFor="let day of ['Lunes','Martes','Miercoles','Jueves','Viernes','Sabado','Domingo']"><input type="checkbox" [checked]="bulkRestDays.has(day)" (change)="toggleBulkRestDay(day, $any($event.target).checked)">{{ day }}</label></fieldset><button type="button" (click)="applyBulkEmployeeChanges()" [disabled]="bulkSaving">Aplicar cambios</button><button type="button" (click)="setSelectedActive(true)" [disabled]="bulkSaving">Activar</button><button type="button" (click)="setSelectedActive(false)" [disabled]="bulkSaving">Desactivar</button><button type="button" class="delete" (click)="removeSelected()" [disabled]="bulkSaving">Eliminar seleccionados</button></div><p class="loading" *ngIf="loading">Cargando empleados...</p><div class="table-wrap" *ngIf="!loading"><table><thead><tr><th class="select-column"><input type="checkbox" [checked]="allSelected" (change)="toggleAll($any($event.target).checked)" aria-label="Seleccionar todos los empleados"></th><th>ID</th><th>Nombre</th><th>Regional</th><th>Jornada</th><th>Descanso/día</th><th>Días de descanso</th><th>Visitas/día</th><th>Estado</th><th>Acciones</th></tr></thead><tbody><tr *ngFor="let employee of employees" [class.editing-row]="editingId === employee.id"><td><input type="checkbox" [checked]="selectedIds.has(employee.id)" (change)="toggleSelection(employee.id, $any($event.target).checked)" [attr.aria-label]="'Seleccionar ' + employee.nombre"></td><td>{{ employee.employee_code }}</td><td>{{ employee.nombre }}</td><td>{{ employee.regional || '-' }}</td><td><ng-container *ngIf="editingId === employee.id; else scheduleText"><select [(ngModel)]="employee.jornada_codigo" [name]="'schedule-' + employee.id" (ngModelChange)="onInlineScheduleChange(employee)"><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></ng-container><ng-template #scheduleText>{{ scheduleLabel(employee) }}</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else restText"><input class="inline-number" type="number" [(ngModel)]="employee.horas_descanso_por_dia" [name]="'rest-' + employee.id" min="0" max="3" step="1"></ng-container><ng-template #restText>{{ employee.horas_descanso_por_dia }} h</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else daysText"><div class="rest-days-picker inline-picker"><label *ngFor="let day of availableRestDays(employee)"><input type="checkbox" [checked]="employee.dias_descanso.includes(day)" (change)="toggleRestDay(employee, day, $any($event.target).checked)">{{ day }}</label></div></ng-container><ng-template #daysText>{{ employee.dias_descanso.length ? employee.dias_descanso.join(', ') : 'Ninguno' }}</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else visitsText"><input class="inline-number" type="number" [(ngModel)]="employee.visitas_maximas_por_dia" [name]="'visits-' + employee.id" min="1" step="1"></ng-container><ng-template #visitsText>{{ employee.visitas_maximas_por_dia }}</ng-template></td><td><span class="status" [class.inactive]="!employee.activo">{{ employee.activo ? 'Activo' : 'Inactivo' }}</span></td><td class="row-actions"><ng-container *ngIf="editingId === employee.id; else normalActions"><button type="button" (click)="saveInline(employee)" [disabled]="saving">Guardar</button><button type="button" (click)="cancelEdit()">Cancelar</button></ng-container><ng-template #normalActions><button type="button" (click)="edit(employee)">Editar</button><button type="button" (click)="toggleActive(employee)">{{ employee.activo ? 'Desactivar' : 'Activar' }}</button><button type="button" class="delete" (click)="remove(employee)">Eliminar</button></ng-template></td></tr><tr *ngIf="!employees.length"><td colspan="10">No hay empleados registrados.</td></tr></tbody></table></div></section>
|
||||
</main>
|
||||
@@ -0,0 +1,394 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Location } from '@angular/common';
|
||||
import { Employee, EmployeeInput } from '../../core/models/employee.model';
|
||||
import { EmployeeService } from '../../core/services/employee.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { UserProfileService } from '../../core/services/user-profile.service';
|
||||
import { WORK_SCHEDULES, workSchedule } from '../../core/models/work-schedule.model';
|
||||
import { normalizeRow, cleanNum } from '../../shared/utils/header-normalization.utils';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
type EmployeeForm = Omit<EmployeeInput, 'activo'> & { activo: boolean };
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-employees',
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './employees.component.html',
|
||||
styleUrls: ['./employees.component.css']
|
||||
})
|
||||
export class EmployeesComponent implements OnDestroy, OnInit {
|
||||
employees: Employee[] = [];
|
||||
form = this.emptyForm();
|
||||
editingId: string | null = null;
|
||||
loading = true;
|
||||
saving = false;
|
||||
error = '';
|
||||
importMessage = '';
|
||||
importing = false;
|
||||
openRestDaysFor: string | null = null;
|
||||
readonly schedules = WORK_SCHEDULES;
|
||||
selectedIds = new Set<string>();
|
||||
bulkSchedule = '';
|
||||
bulkRestHours: number | null = null;
|
||||
bulkVisits: number | null = null;
|
||||
bulkRestDays = new Set<string>();
|
||||
bulkSaving = false;
|
||||
private destroyed = false;
|
||||
private loadSequence = 0;
|
||||
projectId = '';
|
||||
|
||||
constructor(
|
||||
private employeesService: EmployeeService,
|
||||
private auth: AuthService,
|
||||
private router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private location: Location,
|
||||
public profile: UserProfileService,
|
||||
private cdr: ChangeDetectorRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? '';
|
||||
if (!this.projectId) {
|
||||
void this.router.navigate(['/home']);
|
||||
return;
|
||||
}
|
||||
void this.load();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyed = true;
|
||||
this.loadSequence++;
|
||||
}
|
||||
|
||||
get activeEmployees(): number {
|
||||
return this.employees.filter(employee => employee.activo).length;
|
||||
}
|
||||
|
||||
get selectedScheduleHours(): number {
|
||||
return workSchedule(this.form.jornada_codigo).weeklyHours;
|
||||
}
|
||||
|
||||
get allSelected(): boolean {
|
||||
return this.employees.length > 0 && this.employees.every(employee => this.selectedIds.has(employee.id));
|
||||
}
|
||||
|
||||
get selectedEmployees(): Employee[] {
|
||||
return this.employees.filter(employee => this.selectedIds.has(employee.id));
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
const sequence = ++this.loadSequence;
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const employees = await this.employeesService.list(this.projectId);
|
||||
if (this.destroyed || sequence !== this.loadSequence) return;
|
||||
this.employees = employees;
|
||||
this.selectedIds.clear();
|
||||
this.cdr.detectChanges();
|
||||
} catch (error: any) {
|
||||
if (!this.destroyed && sequence === this.loadSequence) {
|
||||
this.error = error?.message ?? 'No se pudieron cargar los empleados.';
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
if (!this.destroyed && sequence === this.loadSequence) this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
edit(employee: Employee): void {
|
||||
this.editingId = employee.id;
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editingId = null;
|
||||
this.error = '';
|
||||
void this.load();
|
||||
}
|
||||
|
||||
async saveInline(employee: Employee): Promise<void> {
|
||||
const rest = Number(employee.horas_descanso_por_dia);
|
||||
const visits = Number(employee.visitas_maximas_por_dia);
|
||||
if (!Number.isInteger(rest) || rest < 0 || rest > 3 || !Number.isInteger(visits) || visits < 1) {
|
||||
this.error = 'Descanso/día debe ser un entero entre 0 y 3, y Visitas/día debe ser un entero mayor que 0.';
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.error = '';
|
||||
try {
|
||||
await this.employeesService.update(employee.id, {
|
||||
...employee,
|
||||
dias_por_semana: workSchedule(employee.jornada_codigo).days.length,
|
||||
horas_descanso_por_dia: rest,
|
||||
visitas_maximas_por_dia: visits,
|
||||
dias_descanso: (employee.dias_descanso ?? []).filter(day => workSchedule(employee.jornada_codigo).days.includes(day))
|
||||
});
|
||||
this.editingId = null;
|
||||
await this.load();
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudo actualizar el empleado.';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onInlineScheduleChange(employee: Employee): void {
|
||||
employee.dias_descanso = (employee.dias_descanso ?? []).filter(day => workSchedule(employee.jornada_codigo).days.includes(day));
|
||||
employee.dias_por_semana = workSchedule(employee.jornada_codigo).days.length;
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
if (!this.form.employee_code.trim() || !this.form.nombre.trim()) {
|
||||
this.error = 'El ID y el nombre son obligatorios.';
|
||||
return;
|
||||
}
|
||||
if (this.form.horas_descanso_por_dia >= this.form.horas_por_dia) {
|
||||
this.error = 'Las horas de descanso deben ser menores que las horas de trabajo diarias.';
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.error = '';
|
||||
const employee: EmployeeInput = {
|
||||
...this.form,
|
||||
dias_por_semana: workSchedule(this.form.jornada_codigo).days.length,
|
||||
employee_code: this.form.employee_code.trim(),
|
||||
nombre: this.form.nombre.trim(),
|
||||
usuario: this.nullable(this.form.usuario),
|
||||
email: this.nullable(this.form.email),
|
||||
perfil: this.nullable(this.form.perfil),
|
||||
pais: this.nullable(this.form.pais),
|
||||
regional: this.nullable(this.form.regional)
|
||||
};
|
||||
try {
|
||||
if (this.editingId) await this.employeesService.update(this.editingId, employee);
|
||||
else await this.employeesService.create(this.projectId, employee);
|
||||
this.cancelEdit();
|
||||
await this.load();
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudo guardar el empleado.';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async toggleActive(employee: Employee): Promise<void> {
|
||||
try {
|
||||
await this.employeesService.update(employee.id, { ...employee, activo: !employee.activo });
|
||||
await this.load();
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudo actualizar el estado.';
|
||||
}
|
||||
}
|
||||
|
||||
toggleSelection(id: string, checked: boolean): void {
|
||||
checked ? this.selectedIds.add(id) : this.selectedIds.delete(id);
|
||||
}
|
||||
|
||||
toggleAll(checked: boolean): void {
|
||||
this.selectedIds = checked ? new Set(this.employees.map(employee => employee.id)) : new Set<string>();
|
||||
}
|
||||
|
||||
toggleBulkRestDay(day: string, checked: boolean): void {
|
||||
checked ? this.bulkRestDays.add(day) : this.bulkRestDays.delete(day);
|
||||
}
|
||||
|
||||
async applyBulkEmployeeChanges(): Promise<void> {
|
||||
const selected = this.selectedEmployees;
|
||||
if (!selected.length) return;
|
||||
if (!this.bulkSchedule && this.bulkRestHours === null && this.bulkVisits === null && !this.bulkRestDays.size) {
|
||||
this.error = 'Selecciona al menos un cambio masivo para aplicar.';
|
||||
return;
|
||||
}
|
||||
if (this.bulkRestHours !== null && (!Number.isInteger(this.bulkRestHours) || this.bulkRestHours < 0 || this.bulkRestHours > 3)) {
|
||||
this.error = 'Descanso/día debe ser un entero entre 0 y 3.';
|
||||
return;
|
||||
}
|
||||
if (this.bulkVisits !== null && (!Number.isInteger(this.bulkVisits) || this.bulkVisits < 1)) {
|
||||
this.error = 'Visitas/día debe ser un entero mayor que 0.';
|
||||
return;
|
||||
}
|
||||
this.bulkSaving = true;
|
||||
this.error = '';
|
||||
try {
|
||||
await Promise.all(selected.map(employee => {
|
||||
const schedule = this.bulkSchedule || employee.jornada_codigo;
|
||||
const restDays = this.bulkRestDays.size
|
||||
? Array.from(this.bulkRestDays).filter(day => workSchedule(schedule).days.includes(day))
|
||||
: (this.bulkSchedule ? employee.dias_descanso.filter(day => workSchedule(schedule).days.includes(day)) : employee.dias_descanso);
|
||||
return this.employeesService.update(employee.id, {
|
||||
...employee,
|
||||
jornada_codigo: schedule,
|
||||
dias_por_semana: workSchedule(schedule).days.length,
|
||||
dias_descanso: restDays,
|
||||
horas_descanso_por_dia: this.bulkRestHours ?? employee.horas_descanso_por_dia,
|
||||
visitas_maximas_por_dia: this.bulkVisits ?? employee.visitas_maximas_por_dia
|
||||
});
|
||||
}));
|
||||
this.importMessage = `${selected.length} empleados actualizados.`;
|
||||
this.resetBulkFields();
|
||||
await this.load();
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudieron aplicar los cambios masivos.';
|
||||
} finally {
|
||||
this.bulkSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async setSelectedActive(active: boolean): Promise<void> {
|
||||
const selected = this.selectedEmployees;
|
||||
if (!selected.length) return;
|
||||
this.bulkSaving = true;
|
||||
try {
|
||||
await Promise.all(selected.map(employee => this.employeesService.update(employee.id, { ...employee, activo: active })));
|
||||
this.importMessage = `${selected.length} empleados ${active ? 'activados' : 'desactivados'}.`;
|
||||
await this.load();
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudo actualizar el estado de los empleados.';
|
||||
} finally {
|
||||
this.bulkSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async removeSelected(): Promise<void> {
|
||||
const selected = this.selectedEmployees;
|
||||
if (!selected.length || !window.confirm(`¿Eliminar ${selected.length} empleados seleccionados? Esta acción no se puede deshacer.`)) return;
|
||||
this.bulkSaving = true;
|
||||
try {
|
||||
await Promise.all(selected.map(employee => this.employeesService.remove(employee.id)));
|
||||
this.importMessage = `${selected.length} empleados eliminados.`;
|
||||
await this.load();
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudieron eliminar los empleados.';
|
||||
} finally {
|
||||
this.bulkSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
scheduleLabel(employee: Employee): string {
|
||||
return workSchedule(employee.jornada_codigo).label;
|
||||
}
|
||||
|
||||
availableRestDays(employee: Employee): string[] {
|
||||
return workSchedule(employee.jornada_codigo).days;
|
||||
}
|
||||
|
||||
toggleRestDayEditor(employee: Employee): void {
|
||||
this.openRestDaysFor = this.openRestDaysFor === employee.id ? null : employee.id;
|
||||
}
|
||||
|
||||
async toggleRestDay(employee: Employee, day: string, checked: boolean): Promise<void> {
|
||||
const days = new Set(employee.dias_descanso ?? []);
|
||||
checked ? days.add(day) : days.delete(day);
|
||||
employee.dias_descanso = Array.from(days);
|
||||
}
|
||||
|
||||
async importFile(event: Event): Promise<void> {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
this.importing = true;
|
||||
this.importMessage = '';
|
||||
this.error = '';
|
||||
try {
|
||||
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { defval: null, raw: true }).map(row => normalizeRow(row as Record<string, unknown>));
|
||||
const employees = rows.map((row, index) => this.fromExcelRow(row, index + 2));
|
||||
const invalid = employees.filter((employee): employee is null => employee === null).length;
|
||||
const valid = employees.filter((employee): employee is EmployeeInput => employee !== null);
|
||||
if (!valid.length) throw new Error('El archivo no contiene empleados válidos.');
|
||||
await this.employeesService.upsert(this.projectId, valid);
|
||||
this.importMessage = `${valid.length} empleados guardados${invalid ? `; ${invalid} filas omitidas por datos inválidos` : ''}.`;
|
||||
await this.load();
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudo importar el archivo.';
|
||||
} finally {
|
||||
this.importing = false;
|
||||
(event.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async remove(employee: Employee): Promise<void> {
|
||||
if (!window.confirm(`¿Eliminar a ${employee.nombre}? Esta acción no se puede deshacer.`)) return;
|
||||
try {
|
||||
await this.employeesService.remove(employee.id);
|
||||
await this.load();
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudo eliminar el empleado.';
|
||||
}
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
this.destroyed = true;
|
||||
this.loadSequence++;
|
||||
this.employees = [];
|
||||
this.loading = true;
|
||||
await this.auth.signOut();
|
||||
await this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
async goHome(): Promise<void> {
|
||||
await this.router.navigate(['/proyectos', this.projectId]);
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
if (history.length > 1) this.location.back();
|
||||
else void this.router.navigate(['/proyectos', this.projectId]);
|
||||
}
|
||||
|
||||
private nullable(value: string | null): string | null {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
private resetBulkFields(): void {
|
||||
this.bulkSchedule = '';
|
||||
this.bulkRestHours = null;
|
||||
this.bulkVisits = null;
|
||||
this.bulkRestDays.clear();
|
||||
}
|
||||
|
||||
private emptyForm(): EmployeeForm {
|
||||
return {
|
||||
employee_code: '', nombre: '', usuario: '', email: '', perfil: 'Promotor', pais: 'Republica Dominicana', regional: '',
|
||||
latitud: 18.4861, longitud: -69.9312, horas_por_dia: 8, dias_por_semana: 5, jornada_codigo: 'LUN_VIE_40', horas_descanso_por_dia: 1, dias_descanso: [], visitas_maximas_por_dia: 3, activo: true
|
||||
};
|
||||
}
|
||||
|
||||
private fromExcelRow(row: Record<string, unknown>, excelRow: number): EmployeeInput | null {
|
||||
const code = String(row['id'] ?? '').trim();
|
||||
const nombre = String(row['nombre_del_empleado'] ?? '').trim();
|
||||
const latitude = cleanNum(row['latitud']);
|
||||
const longitude = cleanNum(row['longitud']);
|
||||
const dailyHours = cleanNum(row['horas_por_dia_usuario']);
|
||||
const weeklyHours = cleanNum(row['horas_por_semana_usuario']);
|
||||
if (!code || !nombre || !Number.isFinite(latitude) || !Number.isFinite(longitude) || !Number.isFinite(dailyHours)) return null;
|
||||
const jornadaTexto = String(row['jornada'] ?? '').trim();
|
||||
const jornadaCodigo = this.schedules.some(schedule => schedule.code === jornadaTexto)
|
||||
? jornadaTexto
|
||||
: weeklyHours === 44 ? 'LUN_SAB_44' : 'LUN_VIE_40';
|
||||
const restDays = String(row['dias_descanso'] ?? '').split(',').map(day => day.trim()).filter(day => workSchedule(jornadaCodigo).days.includes(day));
|
||||
const active = !['no', 'n', '0', 'false'].includes(String(row['usuario_activo'] ?? 'si').trim().toLowerCase());
|
||||
return {
|
||||
employee_code: code,
|
||||
nombre,
|
||||
usuario: this.nullable(String(row['usuario'] ?? '')),
|
||||
email: this.nullable(String(row['email'] ?? '')),
|
||||
perfil: this.nullable(String(row['perfil_de_acceso'] ?? '')),
|
||||
pais: this.nullable(String(row['pais'] ?? '')),
|
||||
regional: this.nullable(String(row['regionales'] ?? row['regional'] ?? '')),
|
||||
latitud: latitude,
|
||||
longitud: longitude,
|
||||
horas_por_dia: dailyHours,
|
||||
dias_por_semana: workSchedule(jornadaCodigo).days.length,
|
||||
jornada_codigo: jornadaCodigo,
|
||||
horas_descanso_por_dia: Math.min(3, Math.max(0, Math.round(cleanNum(row['horas_descanso_por_dia']) || 1))),
|
||||
dias_descanso: restDays,
|
||||
visitas_maximas_por_dia: Math.max(1, cleanNum(row['visitas_maximas_por_dia']) || 3),
|
||||
activo: active
|
||||
};
|
||||
}
|
||||
}
|
||||
+26
-2
@@ -6,7 +6,7 @@
|
||||
.detail-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 340px;
|
||||
z-index: 900;
|
||||
@@ -16,7 +16,7 @@
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
background: var(--glm-surface);
|
||||
border-left: 3px solid var(--glm-verde);
|
||||
border-right: 3px solid var(--glm-verde);
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
@@ -61,6 +61,24 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -206,6 +224,12 @@
|
||||
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 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
+11
-15
@@ -9,17 +9,16 @@
|
||||
<div class="sidebar-brand">
|
||||
<img src="GLM_completo.png" alt="GomezLee Marketing" class="sidebar-logo" />
|
||||
</div>
|
||||
<div class="sidebar-header">
|
||||
<button type="button" class="back-btn" (click)="goBack()">
|
||||
<i class="pi pi-arrow-left"></i> Volver
|
||||
</button>
|
||||
<div class="detail-title">
|
||||
<div class="sidebar-header">
|
||||
<button type="button" class="back-btn" (click)="goBack()">
|
||||
<i class="pi pi-arrow-left"></i> Volver
|
||||
</button>
|
||||
<div class="detail-title">
|
||||
<h2>{{ routeData.json.nombre_usuario }} → {{ routeData.json.nombre_pdv }}</h2>
|
||||
<span class="badge">{{ routeData.json.dia }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="info-card">
|
||||
</div>
|
||||
<section class="info-card">
|
||||
<h3>Información de la ruta</h3>
|
||||
<dl class="info-list">
|
||||
<div><dt>Usuario</dt><dd>{{ routeData.json.nombre_usuario }}</dd></div>
|
||||
@@ -33,11 +32,8 @@
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="info-card">
|
||||
<h3>Coordenadas</h3>
|
||||
<dl class="info-list">
|
||||
<div><dt>Usuario (lat, lng)</dt><dd>{{ routeData.json.latitud_usuario }}, {{ routeData.json.longitud_usuario }}</dd></div>
|
||||
<div><dt>PDV (lat, lng)</dt><dd>{{ routeData.json.latitud_pdv }}, {{ routeData.json.longitud_pdv }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="info-card">
|
||||
<h3>PDV destino</h3>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+52
-10
@@ -7,6 +7,7 @@ import { RouteState } from '../../../core/route-state/route';
|
||||
import { OrsService } from '../../../core/services/ors.service';
|
||||
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
||||
import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||
import { colorForUsuario } from '../../../shared/utils/palette.utils';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -31,6 +32,14 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
visitOrder = 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(
|
||||
private router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
@@ -52,8 +61,9 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
const qp = this.activatedRoute.snapshot.queryParams;
|
||||
const pdvId = qp['pdv_id'];
|
||||
const usuarioId = qp['usuario_id'];
|
||||
const day = qp['dia'];
|
||||
if (pdvId) {
|
||||
const found = this.routeState.findRoute(pdvId, usuarioId);
|
||||
const found = this.routeState.findRoute(pdvId, usuarioId, day);
|
||||
if (found) {
|
||||
this.routeData = found;
|
||||
if (this.map) this.loadAndShowRoute(found);
|
||||
@@ -78,7 +88,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
// Re-attempt resolution after map init (queryParams case)
|
||||
const qp = this.activatedRoute.snapshot.queryParams;
|
||||
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) {
|
||||
this.routeData = found;
|
||||
this.error = null;
|
||||
@@ -99,13 +109,29 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
}
|
||||
|
||||
private initMap() {
|
||||
this.map = L.map('map').setView([18.4861, -69.9312], 13);
|
||||
const territoryBounds = this.routeData ? L.latLngBounds([
|
||||
[Number(this.routeData.json.latitud_usuario), Number(this.routeData.json.longitud_usuario)],
|
||||
[Number(this.routeData.json.latitud_pdv), Number(this.routeData.json.longitud_pdv)]
|
||||
]) : null;
|
||||
this.map = L.map('map', { maxBoundsViscosity: 0.85 });
|
||||
if (territoryBounds?.isValid()) {
|
||||
this.fitRouteBounds(territoryBounds);
|
||||
} else {
|
||||
this.map.setView([18.4861, -69.9312], 13);
|
||||
}
|
||||
const pdvPane = this.map.createPane('pdvPane');
|
||||
pdvPane.style.zIndex = '650';
|
||||
|
||||
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);
|
||||
setTimeout(() => {
|
||||
this.map.invalidateSize();
|
||||
if (territoryBounds?.isValid()) this.fitRouteBounds(territoryBounds);
|
||||
});
|
||||
}
|
||||
|
||||
private async loadAndShowRoute(route: RouteAssignedResponse) {
|
||||
@@ -127,6 +153,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
|
||||
this.centerMapOnPoints(origin, destination);
|
||||
|
||||
if (json.geojson) {
|
||||
this.showRouteOnMap(json.geojson, origin, destination, json, this.segmentOriginName);
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await this.orsService.processPairs(
|
||||
[{ pdv_id: json.pdv_id, origin, destination }],
|
||||
@@ -155,7 +188,15 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
L.latLng(origin[1], origin[0]),
|
||||
L.latLng(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[] {
|
||||
@@ -248,11 +289,12 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
duration: string,
|
||||
originName: string
|
||||
) {
|
||||
const startColor = colorForUsuario(json.usuario_id).color;
|
||||
const startIcon = L.divIcon({
|
||||
className: 'custom-marker marker-start',
|
||||
html: '<div class="marker-inner">A</div>',
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
html: `<div class="marker-inner" style="background:${startColor};border:3px solid #fff;">A</div>`,
|
||||
iconSize: [34, 34],
|
||||
iconAnchor: [17, 17],
|
||||
popupAnchor: [0, -18]
|
||||
});
|
||||
|
||||
@@ -270,7 +312,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
.bindPopup(`<strong>Origen del tramo:</strong> ${originName}`),
|
||||
L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
||||
.addTo(this.map)
|
||||
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
|
||||
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Usuario:</strong> ${json.nombre_usuario}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,6 +334,6 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.router.navigate(['/home']);
|
||||
this.router.navigate(['/proyectos', this.activatedRoute.snapshot.paramMap.get('id')]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
.assigned-route-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--glm-surface);
|
||||
color: var(--glm-azul);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--glm-verde-bg);
|
||||
border-color: var(--glm-verde);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
color: var(--glm-azul);
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
padding: 6px 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--glm-verde);
|
||||
border-color: var(--glm-verde);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.table-caption {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-caption .font-bold {
|
||||
color: var(--glm-azul);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.table-caption .count {
|
||||
color: var(--glm-acero);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.link-usuario {
|
||||
color: var(--glm-azul);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: transparent;
|
||||
transition: text-decoration-color 0.15s;
|
||||
}
|
||||
|
||||
.link-usuario:hover {
|
||||
text-decoration-color: var(--glm-verde);
|
||||
color: var(--glm-verde);
|
||||
}
|
||||
.inline-edit {
|
||||
width: 100%;
|
||||
min-width: 100px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--glm-verde);
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: var(--glm-gris-oscuro);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
<div class="assigned-route-container">
|
||||
<div class="header-actions">
|
||||
<input
|
||||
pInputText
|
||||
type="text"
|
||||
placeholder="Buscar..."
|
||||
(input)="dt.filterGlobal($any($event.target).value, 'contains')"
|
||||
/>
|
||||
<div class="actions-right">
|
||||
<button type="button" class="btn-secondary" (click)="goToOverviewMap()" pTooltip="Ver mapa filtrado por usuario" tooltipPosition="top">
|
||||
<i class="pi pi-map"></i> Ver mapa
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="downloadCSV()">
|
||||
<i class="pi pi-download"></i> Descargar CSV
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="reoptimizeLocally()" [disabled]="!canReoptimize" title="Vuelve a ejecutar el optimizador con los archivos cargados">
|
||||
<span *ngIf="reoptLoading" class="spinner-inline"></span>
|
||||
<i *ngIf="!reoptLoading" class="pi pi-sync"></i> {{ reoptLoading ? 'Reoptimizando...' : 'Reoptimizar rutas' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" *ngIf="hasLocalChanges" (click)="restoreOriginal()">
|
||||
<i class="pi pi-undo"></i> Deshacer cambios
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
(click)="toggleRutas()"
|
||||
pTooltip="Mostrar u ocultar el detalle de rutas"
|
||||
tooltipPosition="top"
|
||||
>
|
||||
<i [class]="rutasExpandido ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"></i>
|
||||
{{ rutasExpandido ? 'Ocultar rutas' : 'Ver rutas' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-error" *ngIf="reoptError"><i class="pi pi-exclamation-triangle"></i> {{ reoptError }}</div>
|
||||
<div class="table-error" *ngIf="editError"><i class="pi pi-exclamation-triangle"></i> {{ editError }}</div>
|
||||
|
||||
<p-table
|
||||
#dt
|
||||
[value]="assignedRoutes"
|
||||
[loading]="isLoading"
|
||||
[paginator]="true"
|
||||
[rows]="10"
|
||||
size="small"
|
||||
[columns]="cols"
|
||||
[rowsPerPageOptions]="[10, 25, 50]"
|
||||
styleClass="p-datatable-sm text-sm"
|
||||
>
|
||||
<ng-template pTemplate="caption">
|
||||
<div class="table-caption">
|
||||
<span class="font-bold">Rutas asignadas</span>
|
||||
<span class="count">{{ assignedRoutes.length }} registros</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="header">
|
||||
<tr>
|
||||
<th *ngFor="let col of cols" pSortableColumn="{{ col.field }}">
|
||||
{{ col.header }}
|
||||
<p-sortIcon [field]="col.field"></p-sortIcon>
|
||||
<p-columnFilter
|
||||
[field]="col.field"
|
||||
display="menu"
|
||||
matchMode="contains"
|
||||
[showMatchModes]="false"
|
||||
[showOperator]="false"
|
||||
[showAddButton]="false"
|
||||
></p-columnFilter>
|
||||
</th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="body" let-row>
|
||||
<tr>
|
||||
<td>
|
||||
<ng-container *ngIf="editingRoute === row; else userValue">
|
||||
<select class="inline-edit" [(ngModel)]="editUserId">
|
||||
<option *ngFor="let user of userOptions" [value]="user.id">{{ user.name }}</option>
|
||||
</select>
|
||||
</ng-container>
|
||||
<ng-template #userValue><a class="link-usuario" (click)="goToUserMap(row.json.usuario_id)" pTooltip="Ver todas las rutas de este usuario" tooltipPosition="top">{{ row.json.nombre_usuario }}</a></ng-template>
|
||||
</td>
|
||||
<td>{{ row.json.nombre_pdv }}</td>
|
||||
<td>{{ row.json.hora_laboral_semanal_usuario }}</td>
|
||||
<td>{{ row.json.hora_minima_semanal_pdv }}</td>
|
||||
<td>{{ row.json.horas_trabajo }}</td>
|
||||
<td>
|
||||
<ng-container *ngIf="editingRoute === row; else dayValue">
|
||||
<select class="inline-edit" [(ngModel)]="editDay">
|
||||
<option *ngFor="let day of dayOptions" [value]="day">{{ day }}</option>
|
||||
</select>
|
||||
</ng-container>
|
||||
<ng-template #dayValue>{{ row.json.dia }}</ng-template>
|
||||
</td>
|
||||
<td>{{ row.json.distancia }}</td>
|
||||
<td>{{ row.json.horas_desplazamiento }}</td>
|
||||
<td class="text-center edit-actions">
|
||||
<ng-container *ngIf="editingRoute === row; else editButton">
|
||||
<button type="button" class="btn-icon" (click)="saveEdit()" title="Guardar"><i class="pi pi-check"></i></button>
|
||||
<button type="button" class="btn-icon" (click)="cancelEdit()" title="Cancelar"><i class="pi pi-times"></i></button>
|
||||
</ng-container>
|
||||
<ng-template #editButton>
|
||||
<button type="button" class="btn-icon" (click)="startEdit(row)" title="Editar asignación"><i class="pi pi-pencil"></i></button>
|
||||
</ng-template>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon"
|
||||
(click)="onShowMap(row)"
|
||||
pTooltip="Ver en mapa (1 ruta)"
|
||||
tooltipPosition="top"
|
||||
>
|
||||
<i class="pi pi-map-marker"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<tr>
|
||||
<td colspan="9" class="text-center">No hay rutas asignadas</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
</div>
|
||||
@@ -1,237 +0,0 @@
|
||||
import { Component, ChangeDetectorRef, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { Table } from 'primeng/table';
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { TooltipModule } from 'primeng/tooltip';
|
||||
import { RouteState } from '../../../core/route-state/route';
|
||||
import { RutasService } from '../../../core/services/router.service';
|
||||
import { PlanningInputService } from '../../../core/services/planning-input.service';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-assigned-route',
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
TableModule,
|
||||
InputTextModule,
|
||||
TooltipModule
|
||||
],
|
||||
templateUrl: './assigned-route.component.html',
|
||||
styleUrl: './assigned-route.component.css'
|
||||
})
|
||||
export class AssignedRouteComponent {
|
||||
@ViewChild('dt') table!: Table;
|
||||
rutasExpandido = false;
|
||||
tablaRutas = '';
|
||||
assignedRoutes: RouteAssignedResponse[] = [];
|
||||
isLoading = false;
|
||||
cols: any[] = [];
|
||||
editingRoute: RouteAssignedResponse | null = null;
|
||||
editUserId = '';
|
||||
editDay = '';
|
||||
hasLocalChanges = false;
|
||||
editError: string | null = null;
|
||||
reoptLoading = false;
|
||||
reoptError: string | null = null;
|
||||
|
||||
constructor(
|
||||
private routesState: RouteState,
|
||||
private cdr: ChangeDetectorRef,
|
||||
private router: Router,
|
||||
private rutasService: RutasService,
|
||||
private planningInput: PlanningInputService
|
||||
) { }
|
||||
|
||||
private subs: import('rxjs').Subscription[] = [];
|
||||
|
||||
ngOnInit() {
|
||||
this.routesState.resetLoading();
|
||||
this.subs.push(
|
||||
this.routesState.loading$.subscribe(value => {
|
||||
this.isLoading = value;
|
||||
this.cdr.detectChanges();
|
||||
})
|
||||
);
|
||||
this.subs.push(
|
||||
this.routesState.assignedRoutes$.subscribe(data => {
|
||||
this.assignedRoutes = [...data];
|
||||
this.cdr.detectChanges();
|
||||
})
|
||||
);
|
||||
this.cols = [
|
||||
{ field: 'json.nombre_usuario', header: 'Usuario' },
|
||||
{ field: 'json.nombre_pdv', header: 'PDV' },
|
||||
{ field: 'json.hora_laboral_semanal_usuario', header: 'Sem. Usuario (hr)' },
|
||||
{ field: 'json.hora_minima_semanal_pdv', header: 'Horas Min. PDV' },
|
||||
{ field: 'json.horas_trabajo', header: 'Horas Trabajo' },
|
||||
{ field: 'json.dia', header: 'Día' },
|
||||
{ field: 'json.distancia', header: 'Distancia (km)' },
|
||||
{ field: 'json.horas_desplazamiento', header: 'Duración (min)' },
|
||||
{ field: '', header: '' }
|
||||
];
|
||||
|
||||
}
|
||||
onShowMap(row: RouteAssignedResponse) {
|
||||
this.routesState.sendRoute(row);
|
||||
this.router.navigate(['/home/assigned-route'], {
|
||||
queryParams: { pdv_id: row.json.pdv_id, usuario_id: row.json.usuario_id, dia: row.json.dia }
|
||||
});
|
||||
}
|
||||
|
||||
goToOverviewMap() {
|
||||
// If table has filtered view, use first visible row's usuario as default; else first assigned
|
||||
const target = (this.table?.filteredValue?.[0] as RouteAssignedResponse | undefined) ?? this.assignedRoutes[0];
|
||||
if (target) {
|
||||
this.router.navigate(['/home/mapa'], {
|
||||
queryParams: { usuario_id: target.json.usuario_id, dia: 'Todos' }
|
||||
});
|
||||
} else {
|
||||
this.router.navigate(['/home/mapa']);
|
||||
}
|
||||
}
|
||||
|
||||
goToUserMap(usuarioId: string) {
|
||||
this.router.navigate(['/home/mapa'], { queryParams: { usuario_id: usuarioId, dia: 'Todos' } });
|
||||
}
|
||||
|
||||
get userOptions(): { id: string; name: string }[] {
|
||||
const users = new Map<string, string>();
|
||||
for (const route of this.assignedRoutes) users.set(String(route.json.usuario_id), route.json.nombre_usuario);
|
||||
return Array.from(users, ([id, name]) => ({ id, name }));
|
||||
}
|
||||
|
||||
get dayOptions(): string[] {
|
||||
return this.routesState.getDiasUnicos();
|
||||
}
|
||||
|
||||
startEdit(route: RouteAssignedResponse): void {
|
||||
this.editingRoute = route;
|
||||
this.editUserId = String(route.json.usuario_id);
|
||||
this.editDay = route.json.dia;
|
||||
this.editError = null;
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editingRoute = null;
|
||||
}
|
||||
|
||||
saveEdit(): void {
|
||||
if (!this.editingRoute || !this.editUserId || !this.editDay) return;
|
||||
const capacity = this.routesState.canAssignToDay(
|
||||
this.editUserId,
|
||||
this.editDay,
|
||||
Number(this.editingRoute.json.horas_trabajo || this.editingRoute.json.hora_minima_semanal_pdv || 0),
|
||||
this.editingRoute
|
||||
);
|
||||
if (!capacity.allowed) {
|
||||
this.editError = `La asignación excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`;
|
||||
return;
|
||||
}
|
||||
this.routesState.updateAssignedRoute(this.editingRoute, this.editUserId, this.editDay);
|
||||
this.hasLocalChanges = true;
|
||||
this.editingRoute = null;
|
||||
}
|
||||
|
||||
get canReoptimize(): boolean {
|
||||
return this.assignedRoutes.length > 0 && !!this.planningInput.getFormData() && !this.reoptLoading;
|
||||
}
|
||||
|
||||
reoptimizeLocally(): void {
|
||||
const data = this.planningInput.getFormData();
|
||||
if (!data || !this.assignedRoutes.length || this.reoptLoading) return;
|
||||
this.reoptLoading = true;
|
||||
this.reoptError = null;
|
||||
this.routesState.setLoading(true);
|
||||
this.rutasService.reoptimizarRutas(data).subscribe({
|
||||
next: response => {
|
||||
const items = response as unknown as unknown[];
|
||||
const assigned = items.find(item => item && typeof item === 'object' && 'assigned' in item) as { assigned?: unknown } | undefined;
|
||||
const noAssigned = items.find(item => item && typeof item === 'object' && 'noAssigned' in item) as { noAssigned?: unknown } | undefined;
|
||||
if (assigned?.assigned) this.routesState.setAssignedRoutes(assigned.assigned as never);
|
||||
if (noAssigned?.noAssigned) this.routesState.setNoAssignedRoutes(noAssigned.noAssigned as never);
|
||||
this.hasLocalChanges = false;
|
||||
this.reoptLoading = false;
|
||||
this.routesState.setLoading(false);
|
||||
this.cdr.detectChanges();
|
||||
},
|
||||
error: err => {
|
||||
this.reoptError = err?.error?.error || err?.message || 'No se pudo reoptimizar la planificación';
|
||||
this.reoptLoading = false;
|
||||
this.routesState.setLoading(false);
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
});
|
||||
this.editingRoute = null;
|
||||
}
|
||||
|
||||
restoreOriginal(): void {
|
||||
this.routesState.restoreOriginalAssignedRoutes();
|
||||
this.hasLocalChanges = false;
|
||||
this.editingRoute = null;
|
||||
}
|
||||
toggleRutas() {
|
||||
this.rutasExpandido = !this.rutasExpandido;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subs.forEach(s => s.unsubscribe());
|
||||
}
|
||||
|
||||
downloadCSV() {
|
||||
const headers = [
|
||||
'Usuario ID',
|
||||
'Usuario',
|
||||
'Long Usuario',
|
||||
'Lat Usuario',
|
||||
'PDV ID',
|
||||
'PDV',
|
||||
'Long PDV',
|
||||
'Lat PDV',
|
||||
'Horas Usuario',
|
||||
'Horas PDV',
|
||||
'Horas Trabajo',
|
||||
'Día',
|
||||
'Distancia',
|
||||
'Duracion'
|
||||
];
|
||||
|
||||
const data =
|
||||
this.table.filteredValue && this.table.filteredValue.length
|
||||
? this.table.filteredValue
|
||||
: this.assignedRoutes;
|
||||
|
||||
const rows = data.map(r => [
|
||||
r.json.usuario_id,
|
||||
r.json.nombre_usuario,
|
||||
r.json.longitud_usuario,
|
||||
r.json.latitud_usuario,
|
||||
r.json.pdv_id,
|
||||
r.json.nombre_pdv,
|
||||
r.json.longitud_pdv,
|
||||
r.json.latitud_pdv,
|
||||
r.json.hora_laboral_semanal_usuario,
|
||||
r.json.hora_minima_semanal_pdv,
|
||||
r.json.horas_trabajo,
|
||||
r.json.dia,
|
||||
r.json.distancia,
|
||||
r.json.horas_desplazamiento
|
||||
]);
|
||||
|
||||
const csvContent =
|
||||
[headers, ...rows].map(r => r.join(',')).join('\n');
|
||||
|
||||
const bom = '\uFEFF';
|
||||
const blob = new Blob([bom + csvContent], {
|
||||
type: 'text/csv;charset=utf-8;'
|
||||
});
|
||||
|
||||
saveAs(blob, 'rutas_planificadas.csv');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -31,6 +31,10 @@
|
||||
color: var(--glm-verde);
|
||||
}
|
||||
|
||||
.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 {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
@@ -38,11 +42,37 @@
|
||||
}
|
||||
|
||||
.home-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 3px solid var(--glm-verde);
|
||||
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 {
|
||||
margin: 0;
|
||||
color: var(--glm-azul);
|
||||
@@ -79,6 +109,8 @@
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.employees-summary { display: flex; align-items: center; gap: 7px; margin: -2px 0 16px; color: var(--glm-acero); font-size: .88rem; }.employees-summary i { color: var(--glm-verde); }.employees-summary.empty, .employees-summary.empty i { color: #c9252d; }
|
||||
|
||||
.upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
<div class="glm-topbar">
|
||||
<img src="GLM_completo.png" alt="GomezLee Marketing" class="glm-topbar-logo" />
|
||||
<span class="glm-topbar-sep">|</span>
|
||||
<span class="glm-topbar-context">Route Planner</span>
|
||||
<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 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">
|
||||
<h1>Route Planner</h1>
|
||||
<p>Gestión de rutas asignadas y puntos de venta pendientes</p>
|
||||
<div class="project-heading"><div class="project-title-line"><h1 *ngIf="!editingProject">{{ projectName || 'Proyecto' }}</h1><input *ngIf="editingProject" [(ngModel)]="projectName" maxlength="120"><button *ngIf="!editingProject" type="button" class="edit-project" (click)="startProjectEdit()"><i class="pi pi-pencil"></i> Editar proyecto</button><div *ngIf="editingProject" class="project-edit-actions"><button type="button" (click)="saveProject()" [disabled]="projectSaving">Guardar</button><button type="button" (click)="cancelProjectEdit()" [disabled]="projectSaving">Cancelar</button></div></div><textarea *ngIf="editingProject" [(ngModel)]="projectDescription" maxlength="500" placeholder="Descripción del proyecto"></textarea><p *ngIf="!editingProject">Gestión de rutas asignadas y puntos de venta pendientes</p></div>
|
||||
</header>
|
||||
|
||||
<section class="card upload-card">
|
||||
<h2 class="section-title">Subir archivos para optimización</h2>
|
||||
<button type="button" class="dashboard-section-toggle" (click)="toggleInputs()"><span><i class="pi pi-database"></i> Datos para planificación</span><i class="pi" [class.pi-chevron-down]="inputsExpanded" [class.pi-chevron-right]="!inputsExpanded"></i></button>
|
||||
<div *ngIf="inputsExpanded">
|
||||
<p class="employees-summary" [class.empty]="!employeesLoading && !activeEmployees"><i class="pi pi-users"></i> <span *ngIf="employeesLoading">Cargando empleados activos...</span><span *ngIf="!employeesLoading">{{ activeEmployees }} empleados activos de este proyecto se usarán para planificar.</span></p>
|
||||
|
||||
<div class="upload-grid">
|
||||
<div class="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>
|
||||
<span>Archivo PDVs guardado</span>
|
||||
<span class="file-hint">Los PDVs pertenecen a este proyecto</span>
|
||||
</label>
|
||||
<input
|
||||
id="pdvFile"
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
(change)="onPdvFileChange($event)"
|
||||
disabled
|
||||
class="file-input"
|
||||
[class.has-file]="pdvFile"
|
||||
/>
|
||||
@@ -57,48 +60,6 @@
|
||||
</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">
|
||||
@@ -130,13 +91,21 @@
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
(click)="clearFiles()"
|
||||
[disabled]="!pdvFile && !usuariosFile"
|
||||
[disabled]="!pdvFile"
|
||||
>
|
||||
<i class="pi pi-times"></i> Limpiar
|
||||
</button>
|
||||
</div>
|
||||
<small class="hint-text" *ngIf="submitDisabledReason && !isLoading">{{ submitDisabledReason }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="map-overview-link"><a class="employees-link" [routerLink]="['/proyectos', projectId, 'mapa']"><i class="pi pi-map"></i> Ver mapa general</a></div>
|
||||
|
||||
<section class="card planning-filters-card">
|
||||
<button type="button" class="dashboard-section-toggle" (click)="toggleFilters()"><span><i class="pi pi-filter"></i> Filtros del dashboard</span><i class="pi" [class.pi-chevron-down]="filtersExpanded" [class.pi-chevron-right]="!filtersExpanded"></i></button>
|
||||
<div class="planning-filters-grid" *ngIf="filtersExpanded"><label>Cobertura<select [(ngModel)]="planningFilters.cobertura" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('cobertura')" [value]="value">{{ value }}</option></select></label><label>Zona<select [(ngModel)]="planningFilters.zona" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('zona')" [value]="value">{{ value }}</option></select></label><label>Región<select [(ngModel)]="planningFilters.region" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('region')" [value]="value">{{ value }}</option></select></label><label>Estado<select [(ngModel)]="planningFilters.estado" (ngModelChange)="applyPlanningFilters()"><option value="">Todos</option><option *ngFor="let value of filterOptions('estado')" [value]="value">{{ value }}</option></select></label><label>Ciudad<select [(ngModel)]="planningFilters.ciudad" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('ciudad')" [value]="value">{{ value }}</option></select></label><button type="button" class="btn-secondary" (click)="clearPlanningFilters()"><i class="pi pi-filter-slash"></i> Limpiar</button></div>
|
||||
</section>
|
||||
|
||||
<section class="card kpis-card">
|
||||
@@ -144,11 +113,7 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<app-assigned-route></app-assigned-route>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="section-title">PDVs sin ruta asignada</h2>
|
||||
<app-no-assigned-route></app-no-assigned-route>
|
||||
<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 *ngIf="unassignedExpanded"></app-no-assigned-route>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Component, ChangeDetectorRef } from '@angular/core';
|
||||
import { Component, ChangeDetectorRef, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AssignedRouteComponent } from './assigned-route/assigned-route.component';
|
||||
import { NoAssignedRouteComponent } from './no-assigned-route/no-assigned-route.component';
|
||||
import { RutasService } from '../../core/services/router.service';
|
||||
import { RouteState } from '../../core/route-state/route';
|
||||
@@ -10,23 +9,41 @@ import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-
|
||||
import { Subscription } from 'rxjs';
|
||||
import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component';
|
||||
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({
|
||||
standalone: true,
|
||||
selector: 'app-home',
|
||||
imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent, PlanningKpisComponent],
|
||||
imports: [CommonModule, FormsModule, RouterLink, NoAssignedRouteComponent, PlanningKpisComponent],
|
||||
templateUrl: './home.component.html',
|
||||
styleUrls: ['./home.component.css']
|
||||
})
|
||||
export class HomeComponent {
|
||||
export class HomeComponent implements OnInit {
|
||||
pdvFile: File | null = null;
|
||||
usuariosFile: File | null = null;
|
||||
pdvPreview: PreviewResult | null = null;
|
||||
usuariosPreview: PreviewResult | null = null;
|
||||
pdvPreviewLoading = false;
|
||||
usuariosPreviewLoading = false;
|
||||
activeEmployees = 0;
|
||||
employeesLoading = true;
|
||||
uploadError: string | null = null;
|
||||
isLoading = false;
|
||||
projectName = '';
|
||||
projectId = '';
|
||||
editingProject = false;
|
||||
projectDescription = '';
|
||||
projectCountry = '';
|
||||
projectSaving = false;
|
||||
inputsExpanded = true;
|
||||
filtersExpanded = true;
|
||||
unassignedExpanded = false;
|
||||
planningFilters: PlanningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
|
||||
private calcSub: Subscription | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -34,9 +51,82 @@ export class HomeComponent {
|
||||
private routeState: RouteState,
|
||||
private previewService: ExcelPreviewService,
|
||||
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
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
void this.initialize();
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
await this.loadProject();
|
||||
await this.loadEmployees();
|
||||
}
|
||||
|
||||
private async loadProject(): Promise<void> {
|
||||
this.projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? '';
|
||||
if (!this.projectId) return;
|
||||
this.routeState.useProject(this.projectId);
|
||||
this.planningInput.clear();
|
||||
this.planningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
|
||||
this.applyPlanningFilters();
|
||||
try {
|
||||
const [project, input] = await Promise.all([this.projectsService.get(this.projectId), this.projectsService.projectInput(this.projectId)]);
|
||||
this.projectName = project.nombre;
|
||||
this.projectDescription = project.descripcion ?? '';
|
||||
this.projectCountry = input.country;
|
||||
this.planningFilter.setPdvs(input.rows);
|
||||
this.pdvFile = input.file;
|
||||
this.pdvPreview = await this.previewService.previewFile(input.file, 'PDV');
|
||||
} catch (error: any) {
|
||||
this.uploadError = error?.message ?? 'No se pudo cargar el proyecto.';
|
||||
} finally {
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
startProjectEdit(): void { this.editingProject = true; }
|
||||
|
||||
cancelProjectEdit(): void { this.editingProject = false; void this.loadProject(); }
|
||||
|
||||
async saveProject(): Promise<void> {
|
||||
if (!this.projectId || !this.projectName.trim()) return;
|
||||
this.projectSaving = true;
|
||||
try {
|
||||
await this.projectsService.update(this.projectId, { nombre: this.projectName.trim(), descripcion: this.projectDescription.trim() || null });
|
||||
this.editingProject = false;
|
||||
} catch (error: any) { this.uploadError = error?.message ?? 'No se pudo actualizar el proyecto.'; }
|
||||
finally { this.projectSaving = false; this.cdr.detectChanges(); }
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
|
||||
|
||||
filterOptions(key: PlanningFilterKey): string[] { return this.planningFilter.options(key); }
|
||||
applyPlanningFilters(): void { this.planningFilter.set(this.planningFilters); }
|
||||
clearPlanningFilters(): void { this.planningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' }; this.applyPlanningFilters(); }
|
||||
toggleInputs(): void { this.inputsExpanded = !this.inputsExpanded; }
|
||||
toggleFilters(): void { this.filtersExpanded = !this.filtersExpanded; }
|
||||
toggleUnassigned(): void { this.unassignedExpanded = !this.unassignedExpanded; }
|
||||
|
||||
private async loadEmployees(): Promise<void> {
|
||||
this.employeesLoading = true;
|
||||
try {
|
||||
this.activeEmployees = (await this.employeesService.active(this.projectId, this.projectCountry)).length;
|
||||
} catch {
|
||||
this.activeEmployees = 0;
|
||||
} finally {
|
||||
this.employeesLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
async onPdvFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
@@ -49,7 +139,7 @@ export class HomeComponent {
|
||||
return;
|
||||
}
|
||||
this.pdvFile = file;
|
||||
if (this.usuariosFile) this.planningInput.setFiles(file, this.usuariosFile);
|
||||
if (this.usuariosFile) this.planningInput.setFiles(this.projectId, file, this.usuariosFile);
|
||||
this.uploadError = null;
|
||||
this.pdvPreviewLoading = true;
|
||||
this.pdvPreview = null;
|
||||
@@ -90,7 +180,7 @@ export class HomeComponent {
|
||||
return;
|
||||
}
|
||||
this.usuariosFile = file;
|
||||
if (this.pdvFile) this.planningInput.setFiles(this.pdvFile, file);
|
||||
if (this.pdvFile) this.planningInput.setFiles(this.projectId, this.pdvFile, file);
|
||||
this.uploadError = null;
|
||||
this.usuariosPreviewLoading = true;
|
||||
this.usuariosPreview = null;
|
||||
@@ -132,23 +222,22 @@ export class HomeComponent {
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
if (!this.pdvFile || !this.usuariosFile) return false;
|
||||
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return false;
|
||||
if (!this.pdvFile || !this.activeEmployees) return false;
|
||||
if (this.pdvPreviewLoading || this.employeesLoading) return false;
|
||||
if (this.isLoading) return false;
|
||||
if (this.uploadError) return false;
|
||||
// Require valid previews before enabling submit
|
||||
if (this.pdvPreview && !this.pdvPreview.valid) return false;
|
||||
if (this.usuariosPreview && !this.usuariosPreview.valid) return false;
|
||||
// Previews must exist (file was parsed)
|
||||
if (!this.pdvPreview || !this.usuariosPreview) return false;
|
||||
if (!this.pdvPreview) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
get submitDisabledReason(): string | null {
|
||||
if (!this.pdvFile || !this.usuariosFile) return 'Sube ambos archivos';
|
||||
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return 'Validando archivos...';
|
||||
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';
|
||||
if (this.usuariosPreview && !this.usuariosPreview.valid) return 'Corrige los errores del archivo Usuarios';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -168,11 +257,21 @@ export class HomeComponent {
|
||||
this.cdr.detectChanges();
|
||||
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();
|
||||
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 = this.rutasService.calcularRutas(formData).subscribe({
|
||||
@@ -186,9 +285,27 @@ export class HomeComponent {
|
||||
this.cdr.detectChanges();
|
||||
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;
|
||||
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.cdr.detectChanges();
|
||||
return;
|
||||
}
|
||||
const assigned = payload.find(r => r && typeof r === 'object' && 'assigned' in r) as { assigned: unknown } | undefined;
|
||||
const noAssigned = payload.find(r => r && typeof r === 'object' && 'noAssigned' in r) as { noAssigned: unknown } | undefined;
|
||||
if (!assigned && !noAssigned) {
|
||||
this.uploadError = 'El optimizador devolvió un formato de respuesta no válido. Verifica el workflow de n8n.';
|
||||
this.routeState.setLoading(false);
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
return;
|
||||
}
|
||||
if (assigned) {
|
||||
this.routeState.setAssignedRoutes(assigned.assigned as never);
|
||||
}
|
||||
@@ -218,7 +335,7 @@ export class HomeComponent {
|
||||
|
||||
private mapBackendError(err: unknown): 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?.message) return anyErr.error.message;
|
||||
if (anyErr?.message) return anyErr.message;
|
||||
@@ -227,7 +344,7 @@ export class HomeComponent {
|
||||
|
||||
clearFiles() {
|
||||
this.calcSub?.unsubscribe();
|
||||
this.planningInput.clear();
|
||||
this.planningInput.clear(this.projectId);
|
||||
this.routeState.clearAll();
|
||||
this.pdvFile = null;
|
||||
this.usuariosFile = null;
|
||||
|
||||
+2
-1
@@ -20,6 +20,7 @@
|
||||
<td class="text-left">{{ row.latitud }}</td>
|
||||
<td class="text-left">{{ row.longitud }}</td>
|
||||
<td class="text-left">{{ row.motivo }}</td>
|
||||
<td class="text-left">{{ row.recomendacion }}</td>
|
||||
<td class="assignment-actions">
|
||||
<select [(ngModel)]="assignmentUser[row.pdv_id]" class="inline-select">
|
||||
<option value="">Usuario</option>
|
||||
@@ -37,7 +38,7 @@
|
||||
</ng-template>
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<tr>
|
||||
<td colspan="7" class="text-center">
|
||||
<td colspan="8" class="text-center">
|
||||
No hay registros asignados
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+13
-3
@@ -6,6 +6,8 @@ import { RouteNoAssignedResponse } from '../../../core/models/route.model';
|
||||
import { RouteState, UnassignedAssignment } from '../../../core/route-state/route';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { OrsService } from '../../../core/services/ors.service';
|
||||
import { PlanningFilterService } from '../../../core/services/planning-filter.service';
|
||||
import { recommendationForUnassigned } from '../../../shared/utils/unassigned-recommendation.utils';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -17,6 +19,7 @@ import { OrsService } from '../../../core/services/ors.service';
|
||||
export class NoAssignedRouteComponent implements OnDestroy {
|
||||
|
||||
noAssignedRoutes: RouteNoAssignedResponse[] = [];
|
||||
private allNoAssignedRoutes: RouteNoAssignedResponse[] = [];
|
||||
isLoading = false;
|
||||
cols: { field: string; header: string }[] = [];
|
||||
private subs: Subscription[] = [];
|
||||
@@ -28,7 +31,8 @@ export class NoAssignedRouteComponent implements OnDestroy {
|
||||
constructor(
|
||||
private routesState: RouteState,
|
||||
private cdr: ChangeDetectorRef,
|
||||
private orsService: OrsService
|
||||
private orsService: OrsService,
|
||||
private filters: PlanningFilterService
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
@@ -40,17 +44,23 @@ export class NoAssignedRouteComponent implements OnDestroy {
|
||||
);
|
||||
this.subs.push(
|
||||
this.routesState.noAssignedRoutes$.subscribe(data => {
|
||||
this.noAssignedRoutes = [...data];
|
||||
this.allNoAssignedRoutes = data.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
|
||||
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
|
||||
this.cdr.detectChanges();
|
||||
})
|
||||
);
|
||||
this.subs.push(this.filters.filters$.subscribe(() => {
|
||||
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
|
||||
this.cdr.detectChanges();
|
||||
}));
|
||||
this.cols = [
|
||||
{ field: 'pdv_id', header: 'PDV ID' },
|
||||
{ field: 'pdv_nombre', header: 'Nombre' },
|
||||
{ field: 'minimo_horas_semana', header: 'Min. Horas/Sem' },
|
||||
{ field: 'latitud', header: 'Latitud' },
|
||||
{ field: 'longitud', header: 'Longitud' },
|
||||
{ field: 'motivo', header: 'Motivo' }
|
||||
{ field: 'motivo', header: 'Motivo' },
|
||||
{ field: 'recomendacion', header: 'Recomendación' }
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -130,10 +136,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.operational-grid {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.user-load-card {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
@@ -178,6 +180,14 @@
|
||||
.user-load-table tbody tr:nth-child(even) {
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
@@ -1,99 +1,267 @@
|
||||
<div class="kpis-panel">
|
||||
<div class="kpis-heading">
|
||||
<div>
|
||||
<h2 class="section-title">Indicadores de planificación</h2>
|
||||
<p class="kpis-subtitle">Resumen total de la planificación actual</p>
|
||||
<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">
|
||||
<p class="kpis-subtitle">Resumen de la selección actual</p>
|
||||
<div class="kpis-actions">
|
||||
<span class="kpis-status" *ngIf="hasData"
|
||||
><i class="pi pi-check-circle"></i> Datos disponibles</span
|
||||
><button
|
||||
type="button"
|
||||
class="export-button"
|
||||
*ngIf="hasData"
|
||||
(click)="downloadRoutes()"
|
||||
[disabled]="pdfLoading"
|
||||
>
|
||||
<i class="pi" [class.pi-spin]="pdfLoading" [class.pi-file-pdf]="!pdfLoading" [class.pi-spinner]="pdfLoading"></i> {{ pdfLoading ? "Generando PDF..." : "Descargar rutas" }}</button
|
||||
><button
|
||||
type="button"
|
||||
class="export-button"
|
||||
*ngIf="hasData"
|
||||
(click)="reoptimize()"
|
||||
[disabled]="!canReoptimize"
|
||||
>
|
||||
<i class="pi pi-sync"></i>
|
||||
{{ reoptLoading ? "Reoptimizando..." : "Reoptimizar" }}</button
|
||||
><button
|
||||
type="button"
|
||||
class="export-button"
|
||||
*ngIf="hasLocalChanges"
|
||||
(click)="restoreOriginal()"
|
||||
>
|
||||
<i class="pi pi-undo"></i> Deshacer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpis-actions">
|
||||
<span class="kpis-status" *ngIf="hasData"><i class="pi pi-check-circle"></i> Datos disponibles</span>
|
||||
<button type="button" class="export-button" *ngIf="hasData" (click)="exportPlanning()"><i class="pi pi-download"></i> Descargar XLSX</button>
|
||||
<div class="table-error" *ngIf="reoptError || editError || pdfError">
|
||||
{{ reoptError || editError || pdfError }}
|
||||
</div>
|
||||
<div class="kpi-grid">
|
||||
<article class="kpi-card kpi-primary">
|
||||
<div class="kpi-icon"><i class="pi pi-map-marker"></i></div>
|
||||
<div>
|
||||
<span class="kpi-label">PDVs totales</span
|
||||
><strong>{{ summary.totalPdvs }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-success">
|
||||
<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" }}% cobertura</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-warning">
|
||||
<div class="kpi-icon"><i class="pi pi-exclamation-triangle"></i></div>
|
||||
<div>
|
||||
<span class="kpi-label">Sin asignar</span
|
||||
><strong>{{ summary.unassignedPdvs }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-blue">
|
||||
<div class="kpi-icon"><i class="pi pi-users"></i></div>
|
||||
<div>
|
||||
<span class="kpi-label">Usuarios</span
|
||||
><strong>{{ summary.users }}</strong
|
||||
><small>{{ summary.days }} días</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-orange">
|
||||
<div class="kpi-icon"><i class="pi pi-compass"></i></div>
|
||||
<div>
|
||||
<span class="kpi-label">Distancia</span
|
||||
><strong
|
||||
>{{ summary.totalDistance | number: "1.0-1" }} <em>km</em></strong
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-success">
|
||||
<div class="kpi-icon"><i class="pi pi-briefcase"></i></div>
|
||||
<div>
|
||||
<span class="kpi-label">Trabajo</span
|
||||
><strong
|
||||
>{{ summary.totalWorkHours | number: "1.0-1" }} <em>h</em></strong
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kpi-grid">
|
||||
<article class="kpi-card kpi-primary">
|
||||
<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>
|
||||
</article>
|
||||
<article class="kpi-card kpi-success">
|
||||
<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>
|
||||
</article>
|
||||
<article class="kpi-card kpi-warning">
|
||||
<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>
|
||||
</article>
|
||||
<article class="kpi-card kpi-blue">
|
||||
<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>
|
||||
</article>
|
||||
<article class="kpi-card kpi-orange">
|
||||
<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>
|
||||
</article>
|
||||
<article class="kpi-card kpi-blue">
|
||||
<div class="kpi-icon"><i class="pi pi-clock"></i></div>
|
||||
<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 class="kpi-grid operational-grid" *ngIf="hasData">
|
||||
<article class="kpi-card kpi-success">
|
||||
<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>
|
||||
</article>
|
||||
<article class="kpi-card kpi-blue">
|
||||
<div class="kpi-icon"><i class="pi pi-chart-line"></i></div>
|
||||
<div><span class="kpi-label">Utilización promedio</span><strong>{{ summary.averageUtilization | number:'1.0-1' }}<em>%</em></strong><small>carga frente a disponibilidad</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-primary">
|
||||
<div class="kpi-icon"><i class="pi pi-sitemap"></i></div>
|
||||
<div><span class="kpi-label">Rutas por usuario</span><strong>{{ summary.averageRoutesPerUser | number:'1.0-1' }}</strong><small>promedio de asignaciones</small></div>
|
||||
</article>
|
||||
<article class="kpi-card kpi-warning">
|
||||
<div class="kpi-icon"><i class="pi pi-arrows-h"></i></div>
|
||||
<div><span class="kpi-label">Brecha de balance</span><strong>{{ summary.assignmentBalance }}</strong><small>diferencia entre mayor y menor carga</small></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="user-load-card" *ngIf="userLoads.length">
|
||||
<h3><i class="pi pi-users"></i> Balance de carga por usuario</h3>
|
||||
<div class="user-load-table-wrap">
|
||||
<button type="button" class="subsection-toggle" (click)="toggleBalance()">
|
||||
<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">
|
||||
<thead>
|
||||
<tr><th>Usuario</th><th>Rutas</th><th>Distancia</th><th>Trabajo</th><th>Utilización</th></tr>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Usuario</th>
|
||||
<th>PDVs</th>
|
||||
<th>Visitas</th>
|
||||
<th>Distancia</th>
|
||||
<th>Trabajo</th>
|
||||
<th>Carga</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let load of userLoads" [class.load-over-capacity]="load.status === 'over-capacity'">
|
||||
<td>{{ load.name }}</td>
|
||||
<td>{{ load.routes }}</td>
|
||||
<td>{{ load.distance | number:'1.0-1' }} km</td>
|
||||
<td>{{ load.workHours | number:'1.0-1' }} h</td>
|
||||
<td>
|
||||
<div class="utilization-cell" [ngClass]="utilizationClass(load)">
|
||||
<span>{{ load.utilization | number:'1.0-1' }}%</span>
|
||||
<span class="capacity-warning" *ngIf="load.status === 'over-capacity'" [title]="load.alertMessage" aria-label="Alerta de capacidad">⚠️</span>
|
||||
<span class="utilization-bar" [title]="load.alertMessage"><span [style.width.%]="barWidth(load.utilization)"></span></span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<ng-container *ngFor="let load of paginatedUserLoads"
|
||||
><tr
|
||||
class="load-row"
|
||||
[class.load-over-capacity]="load.status === 'over-capacity'"
|
||||
(click)="toggleUser(load.id)"
|
||||
>
|
||||
<td>
|
||||
<i
|
||||
class="pi"
|
||||
[class.pi-chevron-down]="isExpanded(load.id)"
|
||||
[class.pi-chevron-right]="!isExpanded(load.id)"
|
||||
></i>
|
||||
</td>
|
||||
<td>{{ load.name }}</td>
|
||||
<td>{{ load.uniquePdvs }}</td>
|
||||
<td>{{ load.visits }}</td>
|
||||
<td>{{ load.distance | number: "1.0-1" }} km</td>
|
||||
<td>{{ load.workHours | number: "1.0-1" }} h</td>
|
||||
<td>
|
||||
<div
|
||||
class="utilization-cell"
|
||||
[ngClass]="utilizationClass(load)"
|
||||
>
|
||||
<span>{{ load.utilization | number: "1.0-1" }}%</span
|
||||
><span class="utilization-bar" [title]="load.alertMessage"
|
||||
><span [style.width.%]="barWidth(load.utilization)"></span
|
||||
></span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
(click)="$event.stopPropagation(); showUserMap(load.id)"
|
||||
title="Ver usuario en mapa"
|
||||
>
|
||||
<i class="pi pi-map"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="visit-detail-row" *ngIf="isExpanded(load.id)">
|
||||
<td colspan="8">
|
||||
<table class="visits-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PDV</th>
|
||||
<th>Día</th>
|
||||
<th>Orden</th>
|
||||
<th>Trabajo</th>
|
||||
<th>Distancia</th>
|
||||
<th>Duración</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let route of load.routes">
|
||||
<td>{{ route.json.nombre_pdv }}</td>
|
||||
<td>
|
||||
<select
|
||||
*ngIf="editingRoute === route; else dayText"
|
||||
[(ngModel)]="editDay"
|
||||
>
|
||||
<option *ngFor="let day of dayOptions" [value]="day">
|
||||
{{ day }}
|
||||
</option></select
|
||||
><ng-template #dayText>{{
|
||||
route.json.dia
|
||||
}}</ng-template>
|
||||
</td>
|
||||
<td>{{ route.json.visit_order || "-" }}</td>
|
||||
<td>{{ route.json.horas_trabajo }} h</td>
|
||||
<td>{{ route.json.distancia | number: "1.0-2" }} km</td>
|
||||
<td>
|
||||
{{
|
||||
route.json.horas_desplazamiento * 60 | number: "1.0-0"
|
||||
}}
|
||||
min
|
||||
</td>
|
||||
<td>
|
||||
<ng-container
|
||||
*ngIf="editingRoute === route; else normalActions"
|
||||
><select [(ngModel)]="editUserId">
|
||||
<option
|
||||
*ngFor="let user of userOptions"
|
||||
[value]="user.id"
|
||||
>
|
||||
{{ user.name }}
|
||||
</option></select
|
||||
><button class="icon-button" (click)="saveEdit()">
|
||||
<i class="pi pi-check"></i></button
|
||||
><button class="icon-button" (click)="cancelEdit()">
|
||||
<i class="pi pi-times"></i></button></ng-container
|
||||
><ng-template #normalActions
|
||||
><button
|
||||
class="icon-button"
|
||||
(click)="startEdit(route)"
|
||||
>
|
||||
<i class="pi pi-pencil"></i></button
|
||||
><button
|
||||
class="icon-button"
|
||||
(click)="showRoute(route)"
|
||||
>
|
||||
<i class="pi pi-map-marker"></i></button
|
||||
></ng-template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</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 class="reasons-card" *ngIf="reasons.length">
|
||||
<h3><i class="pi pi-list"></i> Motivos de PDVs sin asignar</h3>
|
||||
<div class="reason-list">
|
||||
<button type="button" class="subsection-toggle" (click)="toggleReasons()">
|
||||
<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">
|
||||
<span>{{ item.reason }}</span>
|
||||
<strong>{{ item.count }}</strong>
|
||||
<span>{{ item.reason }}</span
|
||||
><strong>{{ item.count }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kpis-empty" *ngIf="!hasData">
|
||||
<i class="pi pi-chart-bar"></i>
|
||||
<span>Carga una planificación para visualizar sus indicadores.</span>
|
||||
<i class="pi pi-chart-bar"></i
|
||||
><span>Carga una planificación para visualizar sus indicadores.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,198 +1,166 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
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 { Subscription } from 'rxjs';
|
||||
import { RouteState } from '../../../core/route-state/route';
|
||||
import { PlanningExportService } from '../../../shared/services/planning-export.service';
|
||||
import { RoutePdfReportService } from '../../../shared/services/route-pdf-report.service';
|
||||
import { summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
||||
import { PlanningFilterService } from '../../../core/services/planning-filter.service';
|
||||
import { RutasService } from '../../../core/services/router.service';
|
||||
import { PlanningInputService } from '../../../core/services/planning-input.service';
|
||||
|
||||
interface KpiSummary {
|
||||
totalPdvs: 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 KpiSummary { totalPdvs: number; assignedPdvs: number; unassignedPdvs: number; coverage: number; users: number; days: number; totalDistance: number; averageDistance: number; totalWorkHours: number; }
|
||||
interface ReasonSummary { reason: string; count: number; }
|
||||
interface UserLoad {
|
||||
id: string;
|
||||
name: string;
|
||||
routes: number;
|
||||
distance: number;
|
||||
workHours: number;
|
||||
capacityHours: number;
|
||||
utilization: number;
|
||||
status: 'ok' | 'warning' | 'over-capacity';
|
||||
alertMessage: string;
|
||||
id: string; name: string; uniquePdvs: number; visits: number; routes: RouteAssignedResponse[]; distance: number; workHours: number;
|
||||
capacityHours: number; utilization: number; status: 'ok' | 'warning' | 'over-capacity'; alertMessage: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-planning-kpis',
|
||||
imports: [CommonModule],
|
||||
templateUrl: './planning-kpis.component.html',
|
||||
styleUrl: './planning-kpis.component.css'
|
||||
})
|
||||
@Component({ standalone: true, selector: 'app-planning-kpis', imports: [CommonModule, FormsModule], templateUrl: './planning-kpis.component.html', styleUrl: './planning-kpis.component.css' })
|
||||
export class PlanningKpisComponent implements OnInit, OnDestroy {
|
||||
summary: KpiSummary = this.emptySummary();
|
||||
reasons: ReasonSummary[] = [];
|
||||
userLoads: UserLoad[] = [];
|
||||
hasData = false;
|
||||
|
||||
metricsExpanded = true;
|
||||
balanceExpanded = true;
|
||||
reasonsExpanded = false;
|
||||
expandedUsers = new Set<string>();
|
||||
editingRoute: RouteAssignedResponse | null = null;
|
||||
editUserId = '';
|
||||
editDay = '';
|
||||
editError = '';
|
||||
reoptLoading = false;
|
||||
reoptError = '';
|
||||
hasLocalChanges = false;
|
||||
pdfLoading = false;
|
||||
pdfError = '';
|
||||
readonly userLoadPageSize = 14;
|
||||
userLoadPage = 1;
|
||||
private readonly subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
private routeState: RouteState,
|
||||
private exportService: PlanningExportService
|
||||
private filters: PlanningFilterService,
|
||||
private pdfReport: RoutePdfReportService,
|
||||
private rutasService: RutasService,
|
||||
private planningInput: PlanningInputService,
|
||||
private router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private cdr: ChangeDetectorRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.subs.push(
|
||||
this.routeState.assignedRoutes$.subscribe(() => this.refresh()),
|
||||
this.routeState.noAssignedRoutes$.subscribe(() => this.refresh())
|
||||
this.routeState.noAssignedRoutes$.subscribe(() => this.refresh()),
|
||||
this.filters.filters$.subscribe(() => this.refresh())
|
||||
);
|
||||
}
|
||||
ngOnDestroy(): void { this.subs.forEach(subscription => subscription.unsubscribe()); }
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.forEach(subscription => subscription.unsubscribe());
|
||||
barWidth(value: number): number { return Math.min(Math.max(value, 0), 100); }
|
||||
utilizationClass(load: UserLoad): string { return load.status; }
|
||||
async downloadRoutes(): Promise<void> {
|
||||
if (this.pdfLoading || !this.hasData) return;
|
||||
this.pdfLoading = true;
|
||||
this.pdfError = '';
|
||||
try {
|
||||
await this.pdfReport.download(this.projectId);
|
||||
} catch (error: any) {
|
||||
this.pdfError = error?.message ?? 'No se pudo generar el PDF de rutas.';
|
||||
} finally {
|
||||
this.pdfLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
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); }
|
||||
|
||||
barWidth(value: number): number {
|
||||
return Math.min(Math.max(value, 0), 100);
|
||||
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; }
|
||||
|
||||
utilizationClass(load: UserLoad): string {
|
||||
return load.status;
|
||||
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 } });
|
||||
}
|
||||
|
||||
exportPlanning(): void {
|
||||
this.exportService.export();
|
||||
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 {
|
||||
const assigned = this.routeState.getAssignedSnapshot();
|
||||
const unassigned = this.routeState.getNoAssignedSnapshot();
|
||||
const assigned = this.filters.filterAssigned(this.routeState.getAssignedSnapshot());
|
||||
const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot());
|
||||
const assignedPdvIds = new Set(assigned.map(route => String(route.json.pdv_id)));
|
||||
// A PDV with at least one scheduled visit is assigned; repeated weekly visits and
|
||||
// partial-assignment alerts must not inflate the planning coverage counters.
|
||||
const unassignedPdvIds = new Set(unassigned
|
||||
.map(route => String(route.pdv_id))
|
||||
.filter(pdvId => !assignedPdvIds.has(pdvId)));
|
||||
const 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 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 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>();
|
||||
|
||||
for (const route of assigned) {
|
||||
const key = String(route.json.usuario_id);
|
||||
const current = loads.get(key) ?? {
|
||||
id: key,
|
||||
name: route.json.nombre_usuario,
|
||||
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);
|
||||
const id = String(route.json.usuario_id);
|
||||
const load = loads.get(id) ?? { id, name: route.json.nombre_usuario, uniquePdvs: 0, visits: 0, routes: [], distance: 0, workHours: 0, capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario), utilization: 0, status: 'ok', alertMessage: '' };
|
||||
load.routes.push(route); load.visits++;
|
||||
loads.set(id, load);
|
||||
}
|
||||
|
||||
this.userLoads = Array.from(loads.values()).map(load => {
|
||||
this.userLoads = [...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);
|
||||
load.distance = userDays.reduce((sum, day) => sum + day.distanceKm, 0);
|
||||
load.workHours = userDays.reduce((sum, day) => sum + day.workHours, 0);
|
||||
load.utilization = load.capacityHours ? (load.workHours / load.capacityHours) * 100 : 0;
|
||||
load.utilization = load.capacityHours ? load.workHours / load.capacityHours * 100 : 0;
|
||||
load.status = load.utilization > 100 ? 'over-capacity' : load.utilization > 90 ? 'warning' : 'ok';
|
||||
load.alertMessage = load.status === 'over-capacity'
|
||||
? `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).`;
|
||||
load.alertMessage = `${load.name}: ${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h.`;
|
||||
return load;
|
||||
}).sort((a, b) => b.routes - a.routes || b.workHours - a.workHours);
|
||||
const routeCounts = this.userLoads.map(load => load.routes);
|
||||
const assignmentBalance = routeCounts.length ? Math.max(...routeCounts) - Math.min(...routeCounts) : 0;
|
||||
const averageUtilization = this.userLoads.length
|
||||
? this.userLoads.reduce((sum, load) => sum + load.utilization, 0) / this.userLoads.length
|
||||
: 0;
|
||||
|
||||
this.summary = {
|
||||
totalPdvs,
|
||||
assignedPdvs: assignedPdvIds.size,
|
||||
unassignedPdvs: unassignedPdvIds.size,
|
||||
coverage: totalPdvs ? (assignedPdvIds.size / totalPdvs) * 100 : 0,
|
||||
users,
|
||||
days,
|
||||
totalDistance,
|
||||
totalTravelHours,
|
||||
averageDistance: assigned.length ? totalDistance / assigned.length : 0,
|
||||
averageTravelHours: assigned.length ? totalTravelHours / assigned.length : 0,
|
||||
totalWorkHours,
|
||||
averageRoutesPerUser: users ? assigned.length / users : 0,
|
||||
averageUtilization,
|
||||
assignmentBalance
|
||||
};
|
||||
|
||||
}).sort((a, b) => b.visits - a.visits || b.workHours - a.workHours);
|
||||
this.userLoadPage = Math.min(this.userLoadPage, this.userLoadPages);
|
||||
this.summary = { totalPdvs, assignedPdvs: assignedPdvIds.size, unassignedPdvs: unassignedPdvIds.size, coverage: totalPdvs ? assignedPdvIds.size / totalPdvs * 100 : 0, users: loads.size, days: new Set(assigned.map(route => route.json.dia)).size, totalDistance, averageDistance: assignedPdvIds.size ? totalDistance / assignedPdvIds.size : 0, totalWorkHours };
|
||||
const reasonByPdv = new Map<string, string>();
|
||||
for (const route of unassigned) {
|
||||
const pdvId = String(route.pdv_id);
|
||||
if (assignedPdvIds.has(pdvId) || reasonByPdv.has(pdvId)) continue;
|
||||
const reason = route.motivo?.trim() || 'Sin motivo especificado';
|
||||
reasonByPdv.set(pdvId, reason);
|
||||
}
|
||||
const reasonCounts = new Map<string, number>();
|
||||
for (const reason of reasonByPdv.values()) {
|
||||
reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1);
|
||||
}
|
||||
this.reasons = Array.from(reasonCounts.entries())
|
||||
.map(([reason, count]) => ({ reason, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
for (const route of unassigned) if (!assignedPdvIds.has(String(route.pdv_id)) && !reasonByPdv.has(String(route.pdv_id))) reasonByPdv.set(String(route.pdv_id), route.motivo?.trim() || 'Sin motivo especificado');
|
||||
const counts = new Map<string, number>(); for (const reason of reasonByPdv.values()) counts.set(reason, (counts.get(reason) ?? 0) + 1);
|
||||
this.reasons = [...counts].map(([reason, count]) => ({ reason, count })).sort((a, b) => b.count - a.count);
|
||||
this.hasData = totalPdvs > 0;
|
||||
}
|
||||
|
||||
private numberValue(value: number | string | null | undefined): number {
|
||||
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
|
||||
};
|
||||
}
|
||||
private numberValue(value: number | string | null | undefined): number { const result = Number(value); return Number.isFinite(result) ? result : 0; }
|
||||
private emptySummary(): KpiSummary { return { totalPdvs: 0, assignedPdvs: 0, unassignedPdvs: 0, coverage: 0, users: 0, days: 0, totalDistance: 0, averageDistance: 0, totalWorkHours: 0 }; }
|
||||
}
|
||||
|
||||
+104
-119
@@ -77,6 +77,9 @@
|
||||
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 {
|
||||
background: var(--glm-verde-bg);
|
||||
border: 1px solid var(--glm-verde-claro);
|
||||
@@ -126,6 +129,8 @@
|
||||
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 {
|
||||
text-align: center;
|
||||
padding: 24px 12px;
|
||||
@@ -151,20 +156,45 @@
|
||||
color: var(--glm-gris-oscuro);
|
||||
}
|
||||
|
||||
.unassigned-map-card {
|
||||
padding: 12px;
|
||||
background: #fff8ed;
|
||||
.unassigned-bottom-drawer {
|
||||
position: fixed;
|
||||
left: 398px;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 950;
|
||||
overflow: hidden;
|
||||
border: 1px solid #f2bf86;
|
||||
border-left: 3px solid var(--glm-naranja);
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid var(--glm-naranja);
|
||||
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;
|
||||
gap: 5px;
|
||||
max-height: 180px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 240px;
|
||||
padding: 0 12px 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -219,86 +249,21 @@
|
||||
.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; }
|
||||
|
||||
.routes-list h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--glm-azul);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.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; }
|
||||
.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; }
|
||||
.employee-load-row:hover, .employee-load-row.selected { border-color: var(--glm-verde); background: var(--glm-verde-bg); }
|
||||
.employee-load-row.over-capacity { border-color: #c9252d; background: var(--glm-rojo-ok); }
|
||||
.employee-load-name { overflow: hidden; color: var(--glm-azul); font-size: 0.78rem; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.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 {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
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);
|
||||
@media (max-width: 900px) {
|
||||
#map-overview { left: 0; }
|
||||
.overview-sidebar { width: 340px; max-width: calc(100vw - 24px); }
|
||||
.unassigned-bottom-drawer { right: 12px; bottom: 12px; left: 12px; }
|
||||
}
|
||||
|
||||
.info-card {
|
||||
@@ -309,6 +274,12 @@
|
||||
border-top: 3px solid var(--glm-azul);
|
||||
}
|
||||
|
||||
.map-location-button {
|
||||
margin-top: 0;
|
||||
padding: 6px 9px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.info-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -353,15 +324,22 @@
|
||||
.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-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 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:last-child { border-bottom: 0; }
|
||||
.daily-route span, .daily-route small { color: var(--glm-acero); }
|
||||
.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 {
|
||||
background: transparent;
|
||||
@@ -386,6 +364,7 @@
|
||||
.info-list div {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--glm-gris-claro);
|
||||
@@ -474,40 +453,46 @@
|
||||
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 {
|
||||
background: 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 {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
|
||||
+54
-43
@@ -16,12 +16,18 @@
|
||||
</button>
|
||||
<h2 class="sidebar-title">Mapa de rutas</h2>
|
||||
</div>
|
||||
<p class="territory-summary" *ngIf="activeTerritories.length"><i class="pi pi-map-marker"></i> {{ activeTerritories.join(', ') }}</p>
|
||||
|
||||
<div class="filters-card" *ngIf="usuarios.length">
|
||||
<label class="filter-label">Metro de prueba</label>
|
||||
<select class="filter-select" [(ngModel)]="selectedMetro" (ngModelChange)="onMetroChange()">
|
||||
<option *ngFor="let metro of metros" [value]="metro">{{ metro }}</option>
|
||||
</select>
|
||||
|
||||
<label class="filter-label">Usuario</label>
|
||||
<select class="filter-select" [(ngModel)]="selectedUsuarioId" (ngModelChange)="onUsuarioChange()">
|
||||
<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>
|
||||
|
||||
<label class="filter-label">Día</label>
|
||||
@@ -31,7 +37,7 @@
|
||||
</select>
|
||||
|
||||
<div class="filter-stats" *ngIf="filteredRoutes.length">
|
||||
<span>{{ filteredRoutes.length }} PDVs</span>
|
||||
<span>{{ uniqueFilteredRoutes.length }} PDVs</span>
|
||||
<span>{{ totalDistancia }} km</span>
|
||||
<span>{{ totalDuracionMin }} min</span>
|
||||
</div>
|
||||
@@ -40,11 +46,8 @@
|
||||
<label class="toggle">
|
||||
<input type="checkbox" [checked]="showHeat" (change)="toggleHeat()" />
|
||||
<i class="pi pi-fire"></i> Heatmap (carga)
|
||||
<span class="heatmap-help" title="El mapa muestra con mayor intensidad las zonas donde se concentra más carga de trabajo en horas.">?</span>
|
||||
</label>
|
||||
<div 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 class="filter-empty" *ngIf="!filteredRoutes.length && !error">
|
||||
@@ -63,18 +66,6 @@
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<section class="unassigned-map-card" *ngIf="selectedUsuarioId === 'Todos' && selectedDia === 'Todos' && unassignedRoutes.length">
|
||||
<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">
|
||||
<h3>Gestionar PDV sin ruta</h3>
|
||||
@@ -96,11 +87,18 @@
|
||||
<div><dt>Motivo</dt><dd>{{ selectedUnassigned.motivo }}</dd></div>
|
||||
<div><dt>Coordenadas</dt><dd>{{ selectedUnassigned.latitud }}, {{ selectedUnassigned.longitud }}</dd></div>
|
||||
</dl>
|
||||
<div class="pending-edit-grid">
|
||||
<label>Usuario<select [(ngModel)]="assignmentUser[selectedUnassigned.pdv_id]"><option value="">Seleccionar</option><option *ngFor="let user of unassignedUserOptions" [value]="user.id">{{ user.name }}</option></select></label>
|
||||
<label>Días<div class="day-checkboxes"><label *ngFor="let day of unassignedDayOptions"><input type="checkbox" [checked]="(assignmentDays[selectedUnassigned.pdv_id] || []).includes(day)" (change)="toggleUnassignedDay(selectedUnassigned.pdv_id, day, $any($event.target).checked)">{{ day.substring(0, 3) }}</label></div></label>
|
||||
</div>
|
||||
<div class="pending-actions"><button type="button" class="btn-secondary" (click)="startUnassignedEdit(selectedUnassigned)"><i class="pi pi-pencil"></i> Editar</button><button type="button" class="btn-primary" (click)="assignUnassigned(selectedUnassigned)" [disabled]="assigningUnassignedId === selectedUnassigned.pdv_id"><span *ngIf="assigningUnassignedId === selectedUnassigned.pdv_id" class="spinner-inline"></span><i *ngIf="assigningUnassignedId !== selectedUnassigned.pdv_id" class="pi pi-user-plus"></i> Reasignar</button></div>
|
||||
<div class="pending-edit-grid">
|
||||
<label>Usuario<select [(ngModel)]="assignmentUser[selectedUnassigned.pdv_id]"><option value="">Seleccionar</option><option *ngFor="let user of unassignedUserOptions" [value]="user.id">{{ user.name }}</option></select></label>
|
||||
<label>Días<div class="day-checkboxes"><label *ngFor="let day of unassignedDayOptions"><input type="checkbox" [checked]="(assignmentDays[selectedUnassigned.pdv_id] || []).includes(day)" (change)="toggleUnassignedDay(selectedUnassigned.pdv_id, day, $any($event.target).checked)">{{ day.substring(0, 3) }}</label></div></label>
|
||||
</div>
|
||||
<div class="pending-actions"><button type="button" class="btn-secondary" (click)="startUnassignedEdit(selectedUnassigned)"><i class="pi pi-pencil"></i> Editar</button><button type="button" class="btn-primary" (click)="assignUnassigned(selectedUnassigned)" [disabled]="assigningUnassignedId === selectedUnassigned.pdv_id"><span *ngIf="assigningUnassignedId === selectedUnassigned.pdv_id" class="spinner-inline"></span><i *ngIf="assigningUnassignedId !== selectedUnassigned.pdv_id" class="pi pi-user-plus"></i> Reasignar</button></div>
|
||||
<div class="employee-load-list" aria-label="Carga de empleados">
|
||||
<button type="button" class="employee-load-row" *ngFor="let employee of unassignedEmployeeLoads" [class.selected]="assignmentUser[selectedUnassigned.pdv_id] === employee.id" [class.over-capacity]="employeeLoadStatus(employee) === 'over-capacity'" (click)="selectUnassignedEmployee(employee.id)">
|
||||
<span class="employee-load-name">{{ employee.name }}</span>
|
||||
<span class="employee-load-values">{{ employee.workHours | number:'1.0-1' }} / {{ employee.capacityHours | number:'1.0-1' }} h · {{ employee.utilization | number:'1.0-1' }}%</span>
|
||||
<span class="employee-load-bar" [ngClass]="employeeLoadStatus(employee)"><span [style.width.%]="employeeLoadWidth(employee)"></span></span>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
<div class="error-card" *ngIf="unassignedActionError">{{ unassignedActionError }}</div>
|
||||
</section>
|
||||
@@ -129,7 +127,7 @@
|
||||
<div><dt>Día</dt><dd>{{ selectedRouteDetail.json.dia }}</dd></div>
|
||||
<div><dt>Orden de visita</dt><dd>{{ selectedVisitOrder }}</dd></div>
|
||||
<div><dt>Origen del tramo</dt><dd>{{ selectedSegmentOrigin }}</dd></div>
|
||||
<div><dt>Destino</dt><dd>{{ 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>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>
|
||||
@@ -145,6 +143,13 @@
|
||||
<label>Día<select [(ngModel)]="reassignDay"><option *ngFor="let day of unassignedDayOptions" [value]="day">{{ day }}</option></select></label>
|
||||
<p class="reassign-hint">Se recalcularán la distancia y duración con ruta vial ORS.</p>
|
||||
<div class="reassign-actions"><button type="button" class="btn-primary" (click)="reassignRoute()" [disabled]="reassigningRoute"><span *ngIf="reassigningRoute" class="spinner-inline"></span><i *ngIf="!reassigningRoute" class="pi pi-check"></i> {{ reassigningRoute ? 'Calculando...' : 'Confirmar' }}</button><button type="button" class="btn-secondary" (click)="cancelReassignRoute()" [disabled]="reassigningRoute">Cancelar</button></div>
|
||||
<div class="employee-load-list" aria-label="Carga de empleados">
|
||||
<button type="button" class="employee-load-row" *ngFor="let employee of reassignmentEmployeeLoads" [class.selected]="reassignUserId === employee.id" [class.over-capacity]="employeeLoadStatus(employee) === 'over-capacity'" (click)="selectReassignmentEmployee(employee.id)">
|
||||
<span class="employee-load-name">{{ employee.name }}</span>
|
||||
<span class="employee-load-values">{{ employee.workHours | number:'1.0-1' }} / {{ employee.capacityHours | number:'1.0-1' }} h · {{ employee.utilization | number:'1.0-1' }}%</span>
|
||||
<span class="employee-load-bar" [ngClass]="employeeLoadStatus(employee)"><span [style.width.%]="employeeLoadWidth(employee)"></span></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="error-card" *ngIf="reassignError">{{ reassignError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,26 +157,32 @@
|
||||
|
||||
<section class="daily-routes-card" *ngIf="dailySummaries.length">
|
||||
<h3>Rutas diarias</h3>
|
||||
<article class="daily-route" *ngFor="let day of dailySummaries" [class.over-capacity]="day.exceedsCapacity">
|
||||
<strong>{{ day.userName }} · {{ day.day }}</strong>
|
||||
<span>{{ day.routes.length }} paradas · {{ day.distanceKm | number:'1.1-1' }} km · {{ (day.travelHours * 60) | number:'1.0-0' }} min</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>
|
||||
<article class="daily-route-group" *ngFor="let group of dailyGroups">
|
||||
<button type="button" class="daily-route-toggle" (click)="toggleDailyRouteDay(group.day)" [attr.aria-expanded]="isDailyRouteDayExpanded(group.day)">
|
||||
<span class="daily-route-toggle-title"><i class="pi" [class.pi-chevron-right]="!isDailyRouteDayExpanded(group.day)" [class.pi-chevron-down]="isDailyRouteDayExpanded(group.day)"></i> {{ group.day }}</span>
|
||||
<span>{{ group.totalStops }} paradas · {{ group.totalDistance | number:'1.1-1' }} km</span>
|
||||
</button>
|
||||
<div class="daily-route-group-content" *ngIf="isDailyRouteDayExpanded(group.day)">
|
||||
<article class="daily-route" *ngFor="let route of group.routes" [class.over-capacity]="route.exceedsCapacity">
|
||||
<strong>{{ route.userName }}</strong>
|
||||
<span>{{ route.routes.length }} paradas · {{ route.distanceKm | number:'1.1-1' }} km</span>
|
||||
<small [attr.aria-label]="route.exceedsCapacity ? 'Capacidad excedida' : 'Dentro de capacidad'">{{ route.totalHours | number:'1.1-1' }} / {{ route.capacityHours | number:'1.1-1' }} h {{ route.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}</small>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div 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>
|
||||
|
||||
<section class="unassigned-bottom-drawer" *ngIf="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>
|
||||
|
||||
+303
-59
@@ -11,6 +11,21 @@ import { RouteAssignedResponse, RouteNoAssignedResponse } from '../../../core/mo
|
||||
import { colorForUsuario } from '../../../shared/utils/palette.utils';
|
||||
import { DailyRouteSummary, haversineKm, sequenceRoutes, summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||
|
||||
interface DailyRouteGroup {
|
||||
day: string;
|
||||
routes: DailyRouteSummary[];
|
||||
totalStops: number;
|
||||
totalDistance: number;
|
||||
}
|
||||
|
||||
interface EmployeeLoadOption {
|
||||
id: string;
|
||||
name: string;
|
||||
workHours: number;
|
||||
capacityHours: number;
|
||||
utilization: number;
|
||||
}
|
||||
|
||||
interface MapRoute {
|
||||
data: RouteAssignedResponse;
|
||||
color: string;
|
||||
@@ -45,7 +60,6 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
private selectedMapRoute: MapRoute | null = null;
|
||||
private heatLayer?: any;
|
||||
showHeat = true;
|
||||
heatWeightMode: 'uniform' | 'horas_trabajo' = 'horas_trabajo';
|
||||
private heatPointsCache: [number, number, number][] = [];
|
||||
private unassignedMarkers = new Map<number, L.Marker>();
|
||||
|
||||
@@ -53,6 +67,8 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
dias: string[] = [];
|
||||
selectedUsuarioId: string = 'Todos';
|
||||
selectedDia: string = 'Todos';
|
||||
selectedMetro = '';
|
||||
expandedDailyRouteDay: string | null = null;
|
||||
selectedRouteDetail: RouteAssignedResponse | null = null;
|
||||
|
||||
filteredRoutes: RouteAssignedResponse[] = [];
|
||||
@@ -63,6 +79,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
orsTotal = 0;
|
||||
error: string | null = null;
|
||||
unassignedRoutes: RouteNoAssignedResponse[] = [];
|
||||
isUnassignedDrawerOpen = false;
|
||||
selectedUnassigned: RouteNoAssignedResponse | null = null;
|
||||
editingUnassigned: RouteNoAssignedResponse | null = null;
|
||||
editUnassignedName = '';
|
||||
@@ -79,18 +96,70 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
reassignUserId = '';
|
||||
reassignDay = '';
|
||||
reassignError: string | null = null;
|
||||
private renderVersion = 0;
|
||||
|
||||
get metros(): string[] {
|
||||
const values = [
|
||||
...this.allRoutes.map(route => route.json.metro),
|
||||
...this.unassignedRoutes.map(route => route.metro)
|
||||
].map(value => String(value ?? '').trim()).filter(Boolean);
|
||||
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
get filteredUnassignedRoutes(): RouteNoAssignedResponse[] {
|
||||
return this.unassignedRoutes.filter(route => String(route.metro ?? '').trim() === this.selectedMetro);
|
||||
}
|
||||
|
||||
get metroUsuarios(): { id: string; nombre: string }[] {
|
||||
const users = new Map<string, string>();
|
||||
for (const route of this.allRoutes) {
|
||||
if (String(route.json.metro ?? '').trim() === this.selectedMetro) {
|
||||
users.set(String(route.json.usuario_id), route.json.nombre_usuario);
|
||||
}
|
||||
}
|
||||
return Array.from(users, ([id, nombre]) => ({ id, nombre }));
|
||||
}
|
||||
|
||||
get activeTerritories(): string[] {
|
||||
return this.selectedMetro ? [this.selectedMetro] : [];
|
||||
}
|
||||
|
||||
// Summary stats
|
||||
get uniqueFilteredRoutes(): RouteAssignedResponse[] {
|
||||
const unique = new Map<string, RouteAssignedResponse>();
|
||||
for (const route of this.filteredRoutes) {
|
||||
const pdvId = String(route.json.pdv_id);
|
||||
if (!unique.has(pdvId)) unique.set(pdvId, route);
|
||||
}
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
get totalDistancia(): string {
|
||||
const uniqueIds = new Set(this.uniqueFilteredRoutes.map(route => String(route.json.pdv_id)));
|
||||
const sum = this.mapRoutes.length
|
||||
? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDistanceKm ?? haversineKm(route.origin, route.destination)), 0)
|
||||
: this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.distancia), 0);
|
||||
? this.mapRoutes
|
||||
.filter(route => uniqueIds.has(String(route.data.json.pdv_id)))
|
||||
.reduce((acc, route) => {
|
||||
const firstSegment = this.mapRoutes.find(candidate => String(candidate.data.json.pdv_id) === String(route.data.json.pdv_id));
|
||||
return firstSegment === route
|
||||
? acc + (route.segmentDistanceKm ?? haversineKm(route.origin, route.destination))
|
||||
: acc;
|
||||
}, 0)
|
||||
: this.uniqueFilteredRoutes.reduce((acc, route) => acc + Number(route.json.distancia), 0);
|
||||
return sum.toFixed(2);
|
||||
}
|
||||
get totalDuracionMin(): string {
|
||||
const uniqueIds = new Set(this.uniqueFilteredRoutes.map(route => String(route.json.pdv_id)));
|
||||
const sum = this.mapRoutes.length
|
||||
? this.mapRoutes.reduce((acc, route) => acc + (route.segmentDurationHours ?? Number(route.data.json.horas_desplazamiento)) * 60, 0)
|
||||
: this.filteredRoutes.reduce((acc, route) => acc + Number(route.json.horas_desplazamiento) * 60, 0);
|
||||
? this.mapRoutes
|
||||
.filter(route => uniqueIds.has(String(route.data.json.pdv_id)))
|
||||
.reduce((acc, route) => {
|
||||
const firstSegment = this.mapRoutes.find(candidate => String(candidate.data.json.pdv_id) === String(route.data.json.pdv_id));
|
||||
return firstSegment === route
|
||||
? acc + (route.segmentDurationHours ?? Number(route.data.json.horas_desplazamiento)) * 60
|
||||
: acc;
|
||||
}, 0)
|
||||
: this.uniqueFilteredRoutes.reduce((acc, route) => acc + Number(route.json.horas_desplazamiento) * 60, 0);
|
||||
return sum.toFixed(0);
|
||||
}
|
||||
|
||||
@@ -98,6 +167,44 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
return summarizeDailyRoutes(this.filteredRoutes);
|
||||
}
|
||||
|
||||
get dailyGroups(): DailyRouteGroup[] {
|
||||
const order = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes', 'Sábado', 'Sabado'];
|
||||
const groups = new Map<string, DailyRouteSummary[]>();
|
||||
for (const summary of this.dailySummaries) {
|
||||
const routes = groups.get(summary.day) ?? [];
|
||||
routes.push(summary);
|
||||
groups.set(summary.day, routes);
|
||||
}
|
||||
return Array.from(groups, ([day, routes]) => ({
|
||||
day,
|
||||
routes,
|
||||
totalStops: routes.reduce((sum, route) => sum + route.routes.length, 0),
|
||||
totalDistance: routes.reduce((sum, route) => sum + route.distanceKm, 0)
|
||||
})).sort((a, b) => order.indexOf(a.day) - order.indexOf(b.day));
|
||||
}
|
||||
|
||||
toggleDailyRouteDay(day: string): void {
|
||||
this.expandedDailyRouteDay = this.expandedDailyRouteDay === day ? null : day;
|
||||
}
|
||||
|
||||
isDailyRouteDayExpanded(day: string): boolean {
|
||||
return this.expandedDailyRouteDay === day;
|
||||
}
|
||||
|
||||
openDestinationInGoogleMaps(): void {
|
||||
if (!this.selectedRouteDetail) return;
|
||||
const { latitud_pdv, longitud_pdv } = this.selectedRouteDetail.json;
|
||||
const latitude = Number(latitud_pdv);
|
||||
const longitude = Number(longitud_pdv);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return;
|
||||
const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${latitude},${longitude}`)}`;
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
toggleUnassignedDrawer(): void {
|
||||
this.isUnassignedDrawerOpen = !this.isUnassignedDrawerOpen;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private routeState: RouteState,
|
||||
private orsService: OrsService,
|
||||
@@ -107,17 +214,13 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.routeState.useProject(this.activatedRoute.snapshot.paramMap.get('id') ?? '');
|
||||
this.subs.push(
|
||||
this.routeState.assignedRoutes$.subscribe(routes => {
|
||||
this.allRoutes = [...routes];
|
||||
this.usuarios = this.routeState.getUsuariosUnicos();
|
||||
this.dias = this.routeState.getDiasUnicos();
|
||||
// Start each map load with the complete planning selected by default.
|
||||
const qp = this.activatedRoute.snapshot.queryParams;
|
||||
if (qp['pdv_id']) {
|
||||
this.selectedUsuarioId = 'Todos';
|
||||
this.selectedDia = 'Todos';
|
||||
}
|
||||
this.applyQueryFilters(this.activatedRoute.snapshot.queryParams);
|
||||
// If focus pdv provided, auto-select detail after map loads
|
||||
this.applyFilter();
|
||||
this.cdr.detectChanges();
|
||||
@@ -136,9 +239,16 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
// React to queryParams changes
|
||||
this.subs.push(
|
||||
this.activatedRoute.queryParams.subscribe(qp => {
|
||||
// Focus a specific route if pdv_id passed
|
||||
this.applyQueryFilters(qp);
|
||||
this.applyFilter();
|
||||
if (this.map) this.renderFiltered();
|
||||
// Focus a specific route if pdv_id passed.
|
||||
if (qp['pdv_id'] && this.filteredRoutes.length) {
|
||||
const found = this.filteredRoutes.find(r => String(r.json.pdv_id) === String(qp['pdv_id']));
|
||||
const found = this.filteredRoutes.find(r =>
|
||||
String(r.json.pdv_id) === String(qp['pdv_id']) &&
|
||||
(!qp['usuario_id'] || String(r.json.usuario_id) === String(qp['usuario_id'])) &&
|
||||
(!qp['dia'] || qp['dia'] === 'Todos' || r.json.dia === qp['dia'])
|
||||
);
|
||||
if (found) {
|
||||
this.selectedRouteDetail = found;
|
||||
this.highlightRoute(found);
|
||||
@@ -170,35 +280,42 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
|
||||
onUsuarioChange() {
|
||||
this.updateQueryParams();
|
||||
this.applyFilter();
|
||||
this.renderFiltered();
|
||||
}
|
||||
|
||||
onDiaChange() {
|
||||
this.updateQueryParams();
|
||||
this.applyFilter();
|
||||
this.renderFiltered();
|
||||
}
|
||||
|
||||
onMetroChange() {
|
||||
this.expandedDailyRouteDay = null;
|
||||
if (this.selectedUsuarioId !== 'Todos' && !this.metroUsuarios.some(user => user.id === this.selectedUsuarioId)) {
|
||||
this.selectedUsuarioId = 'Todos';
|
||||
}
|
||||
this.updateQueryParams();
|
||||
}
|
||||
|
||||
private updateQueryParams() {
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.activatedRoute,
|
||||
queryParams: { usuario_id: this.selectedUsuarioId, dia: this.selectedDia },
|
||||
queryParams: { metro: this.selectedMetro, usuario_id: this.selectedUsuarioId, dia: this.selectedDia, pdv_id: null },
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true
|
||||
});
|
||||
}
|
||||
|
||||
private applyFilter() {
|
||||
let list = this.selectedUsuarioId === 'Todos'
|
||||
? [...this.allRoutes]
|
||||
: this.allRoutes.filter(r => String(r.json.usuario_id) === String(this.selectedUsuarioId));
|
||||
let list = this.allRoutes.filter(route => String(route.json.metro ?? '').trim() === this.selectedMetro);
|
||||
if (this.selectedUsuarioId !== 'Todos') {
|
||||
list = list.filter(r => String(r.json.usuario_id) === String(this.selectedUsuarioId));
|
||||
}
|
||||
if (this.selectedDia !== 'Todos') {
|
||||
list = list.filter(r => r.json.dia === this.selectedDia);
|
||||
}
|
||||
const pdvId = this.activatedRoute.snapshot.queryParams['pdv_id'];
|
||||
if (pdvId) list = list.filter(route => String(route.json.pdv_id) === String(pdvId));
|
||||
this.filteredRoutes = list;
|
||||
if (this.filteredRoutes.length === 0) {
|
||||
this.error = this.allRoutes.length === 0 ? 'No hay rutas cargadas. Sube archivos en Home.' : 'Sin rutas para este usuario/día.';
|
||||
this.error = this.allRoutes.length === 0 ? 'No hay rutas cargadas. Sube archivos en Home.' : 'Sin rutas para este metro, usuario o día.';
|
||||
} else {
|
||||
this.error = null;
|
||||
}
|
||||
@@ -209,12 +326,50 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
}
|
||||
|
||||
private applyQueryFilters(queryParams: Record<string, string | undefined>): void {
|
||||
const userId = queryParams['usuario_id'];
|
||||
const day = queryParams['dia'];
|
||||
this.selectedUsuarioId = userId && this.usuarios.some(user => String(user.id) === String(userId)) ? String(userId) : 'Todos';
|
||||
this.selectedDia = day && day !== 'Todos' && this.dias.includes(day) ? day : 'Todos';
|
||||
const requestedMetro = String(queryParams['metro'] ?? '').trim();
|
||||
const userMetro = this.selectedUsuarioId === 'Todos' ? '' : String(
|
||||
this.allRoutes.find(route => String(route.json.usuario_id) === this.selectedUsuarioId)?.json.metro ?? ''
|
||||
).trim();
|
||||
const defaultMetro = this.metros.find(metro => ['cdmx', 'ciudaddemexico', 'mexicocity'].includes(this.normalizeMetro(metro))) ?? this.metros[0] ?? '';
|
||||
this.selectedMetro = this.metros.includes(requestedMetro)
|
||||
? requestedMetro
|
||||
: this.metros.includes(userMetro)
|
||||
? userMetro
|
||||
: this.metros.includes(this.selectedMetro)
|
||||
? this.selectedMetro
|
||||
: defaultMetro;
|
||||
}
|
||||
|
||||
private normalizeMetro(value: string): string {
|
||||
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
}
|
||||
|
||||
private initMap() {
|
||||
this.map = L.map('map-overview').setView([18.4861, -69.9312], 10);
|
||||
const territoryBounds = this.projectBounds();
|
||||
this.map = L.map('map-overview', { maxBoundsViscosity: 0.85 });
|
||||
if (territoryBounds) {
|
||||
this.map.fitBounds(territoryBounds.pad(0.15));
|
||||
this.map.setMaxBounds(territoryBounds.pad(0.35));
|
||||
} else {
|
||||
this.map.setView([18.4861, -69.9312], 10);
|
||||
}
|
||||
const pdvPane = this.map.createPane('pdvPane');
|
||||
pdvPane.style.zIndex = '650';
|
||||
const heatPane = this.map.createPane('heatPane');
|
||||
pdvPane.style.zIndex = '750';
|
||||
heatPane.style.zIndex = '700';
|
||||
const popupPane = this.map.getPane('popupPane');
|
||||
if (popupPane) popupPane.style.zIndex = '1000';
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
bounds: territoryBounds?.pad(0.35),
|
||||
keepBuffer: 1,
|
||||
noWrap: true,
|
||||
updateWhenIdle: true
|
||||
}).addTo(this.map);
|
||||
// Fix tiles after sidebar overlay
|
||||
setTimeout(() => this.map?.invalidateSize(), 200);
|
||||
@@ -247,12 +402,16 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
|
||||
private async renderFiltered() {
|
||||
if (!this.map) return;
|
||||
const version = ++this.renderVersion;
|
||||
this.abort$.next();
|
||||
this.clearMap();
|
||||
this.isLoading = false;
|
||||
this.orsProgress = 0;
|
||||
this.orsTotal = 0;
|
||||
if (!this.filteredRoutes.length) {
|
||||
this.renderUnassignedMarkers();
|
||||
const territoryBounds = this.projectBounds();
|
||||
if (territoryBounds) this.map.fitBounds(territoryBounds.pad(0.15));
|
||||
this.cdr.detectChanges();
|
||||
return;
|
||||
}
|
||||
@@ -284,7 +443,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
sequencedRoutes.forEach((segment, idx) => {
|
||||
const route = segment.route;
|
||||
const j = route.json;
|
||||
const color = colorForUsuario(j.usuario_id).color;
|
||||
const color = colorForUsuario(`${j.usuario_id}|${j.dia}`).color;
|
||||
const origin = segment.origin;
|
||||
const dest = segment.destination;
|
||||
bounds.push([origin[1], origin[0]]);
|
||||
@@ -299,14 +458,14 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
// Marker for PDV
|
||||
const pdvIcon = L.divIcon({
|
||||
className: 'custom-marker marker-end',
|
||||
html: `<div class="location-pin"><span>${segment.order}</span></div>`,
|
||||
iconSize: [30, 38],
|
||||
iconAnchor: [15, 38],
|
||||
html: `<div class="location-pin-wrapper"><div class="location-pin"><span>${segment.order}</span></div></div>`,
|
||||
iconSize: [42, 50],
|
||||
iconAnchor: [21, 46],
|
||||
popupAnchor: [0, -18]
|
||||
});
|
||||
const marker = L.marker([Number(j.latitud_pdv), Number(j.longitud_pdv)], { icon: pdvIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
||||
.addTo(this.map!)
|
||||
.bindPopup(`<strong>${j.nombre_pdv}</strong><br>Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`);
|
||||
.bindPopup(`<strong>${j.nombre_pdv}</strong><br><strong>Usuario:</strong> ${j.nombre_usuario}<br>Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`);
|
||||
// Heat point
|
||||
const w = this.computeWeight(j);
|
||||
this.heatPointsCache.push([Number(j.latitud_pdv), Number(j.longitud_pdv), w]);
|
||||
@@ -331,6 +490,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
marker.on('click', onClick);
|
||||
});
|
||||
|
||||
if (this.selectedUsuarioId === 'Todos' && this.selectedDia === 'Todos') {
|
||||
for (const route of this.filteredUnassignedRoutes) {
|
||||
if (this.validPoint(route.latitud, route.longitud)) bounds.push([Number(route.latitud), Number(route.longitud)]);
|
||||
}
|
||||
}
|
||||
|
||||
this.updateHeatLayer();
|
||||
this.toggleLayerVisibility();
|
||||
|
||||
@@ -340,14 +505,33 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Progressive ORS hydration — always in background (heat only hides visually)
|
||||
this.hydrateWithOrs();
|
||||
this.hydrateWithOrs(version);
|
||||
this.renderUnassignedMarkers();
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
private projectBounds(): L.LatLngBounds | null {
|
||||
const points: L.LatLngTuple[] = [];
|
||||
for (const route of this.filteredRoutes) {
|
||||
const data = route.json;
|
||||
if (this.validPoint(data.latitud_usuario, data.longitud_usuario)) points.push([Number(data.latitud_usuario), Number(data.longitud_usuario)]);
|
||||
if (this.validPoint(data.latitud_pdv, data.longitud_pdv)) points.push([Number(data.latitud_pdv), Number(data.longitud_pdv)]);
|
||||
}
|
||||
for (const route of this.filteredUnassignedRoutes) {
|
||||
if (this.validPoint(route.latitud, route.longitud)) points.push([Number(route.latitud), Number(route.longitud)]);
|
||||
}
|
||||
return points.length ? L.latLngBounds(points) : null;
|
||||
}
|
||||
|
||||
private validPoint(latitude: unknown, longitude: unknown): boolean {
|
||||
const lat = Number(latitude);
|
||||
const lng = Number(longitude);
|
||||
return Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
|
||||
}
|
||||
|
||||
private renderUnassignedMarkers(): void {
|
||||
if (!this.map || this.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return;
|
||||
for (const route of this.unassignedRoutes) {
|
||||
for (const route of this.filteredUnassignedRoutes) {
|
||||
const icon = L.divIcon({
|
||||
className: 'custom-marker unassigned-marker',
|
||||
html: '<div class="location-pin unassigned-pin"><span>!</span></div>',
|
||||
@@ -362,22 +546,29 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
}
|
||||
|
||||
private async hydrateWithOrs() {
|
||||
private async hydrateWithOrs(version: number) {
|
||||
if (!this.filteredRoutes.length) return;
|
||||
this.isLoading = true;
|
||||
this.orsTotal = this.filteredRoutes.length;
|
||||
this.orsProgress = 0;
|
||||
this.cdr.detectChanges();
|
||||
|
||||
const pares = this.mapRoutes.map(route => ({
|
||||
const routesForRender = [...this.mapRoutes];
|
||||
const embeddedResults = routesForRender
|
||||
.filter(route => !!route.data.json.geojson)
|
||||
.map(route => ({ pdv_id: route.requestId, geojson: route.data.json.geojson }));
|
||||
const pares = routesForRender.filter(route => !route.data.json.geojson).map(route => ({
|
||||
pdv_id: route.requestId,
|
||||
origin: route.origin,
|
||||
destination: route.destination
|
||||
}));
|
||||
|
||||
const { promise } = this.orsService.processPairsWithProgress(pares, 2, true, this.abort$);
|
||||
try {
|
||||
const results = await promise;
|
||||
const remoteResults = pares.length
|
||||
? await this.orsService.processPairsWithProgress(pares, 2, true, this.abort$).promise
|
||||
: [];
|
||||
if (version !== this.renderVersion) return;
|
||||
const results = [...embeddedResults, ...remoteResults];
|
||||
if (!results.length) {
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
@@ -399,8 +590,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
}
|
||||
const useMap = geoByRequest.size > 0;
|
||||
for (let i = 0; i < this.mapRoutes.length; i++) {
|
||||
const mr = this.mapRoutes[i];
|
||||
for (let i = 0; i < routesForRender.length; i++) {
|
||||
if (version !== this.renderVersion) return;
|
||||
const mr = routesForRender[i];
|
||||
let geo: unknown = null;
|
||||
if (useMap) {
|
||||
geo = geoByRequest.get(mr.requestId);
|
||||
@@ -436,7 +628,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
mr.segmentDistanceKm = summary.distance / 1000;
|
||||
mr.segmentDurationHours = summary.duration / 3600;
|
||||
mr.marker?.setPopupContent(
|
||||
`<strong>${mr.data.json.nombre_pdv}</strong><br>Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}<br>${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min`
|
||||
`<strong>${mr.data.json.nombre_pdv}</strong><br><strong>Usuario:</strong> ${mr.data.json.nombre_usuario}<br>Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}<br>${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min`
|
||||
);
|
||||
}
|
||||
line.on('click', () => this.selectRoute(mr));
|
||||
@@ -452,8 +644,10 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
} catch {
|
||||
// Ignore abort / network errors, fallback lines remain
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
if (version === this.renderVersion) {
|
||||
this.isLoading = false;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,6 +673,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
|
||||
private selectRoute(mr: MapRoute) {
|
||||
this.selectedUnassigned = null;
|
||||
this.editingUnassigned = null;
|
||||
this.unassignedActionError = null;
|
||||
this.selectedRouteDetail = mr.data;
|
||||
this.highlightRoute(mr.data);
|
||||
this.fitSelectedRoute(mr);
|
||||
@@ -486,6 +683,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
|
||||
selectRouteFromList(route: RouteAssignedResponse): void {
|
||||
this.selectedUnassigned = null;
|
||||
this.editingUnassigned = null;
|
||||
this.unassignedActionError = null;
|
||||
const mapRoute = this.mapRoutes.find(mr => this.sameRoute(mr.data, route));
|
||||
if (mapRoute) {
|
||||
this.selectRoute(mapRoute);
|
||||
@@ -634,14 +834,62 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
left.json.dia === right.json.dia;
|
||||
}
|
||||
|
||||
private getEmployeeLoads(excluding?: RouteAssignedResponse): EmployeeLoadOption[] {
|
||||
const profiles = new Map<string, RouteAssignedResponse>();
|
||||
const workHours = new Map<string, number>();
|
||||
for (const route of this.routeState.getAssignedSnapshot()) {
|
||||
const id = String(route.json.usuario_id);
|
||||
if (!profiles.has(id)) profiles.set(id, route);
|
||||
if (route !== excluding) {
|
||||
workHours.set(id, (workHours.get(id) ?? 0) + Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0));
|
||||
}
|
||||
}
|
||||
return Array.from(profiles, ([id, profile]) => {
|
||||
const capacityHours = Number(profile.json.hora_laboral_semanal_usuario || 0);
|
||||
const assignedHours = workHours.get(id) ?? 0;
|
||||
return {
|
||||
id,
|
||||
name: profile.json.nombre_usuario,
|
||||
workHours: assignedHours,
|
||||
capacityHours,
|
||||
utilization: capacityHours ? (assignedHours / capacityHours) * 100 : 0
|
||||
};
|
||||
}).sort((a, b) => a.utilization - b.utilization || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
get unassignedUserOptions(): { id: string; name: string }[] {
|
||||
return this.routeState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre }));
|
||||
}
|
||||
|
||||
get unassignedEmployeeLoads(): EmployeeLoadOption[] {
|
||||
return this.getEmployeeLoads();
|
||||
}
|
||||
|
||||
get reassignmentEmployeeLoads(): EmployeeLoadOption[] {
|
||||
return this.getEmployeeLoads(this.selectedRouteDetail ?? undefined);
|
||||
}
|
||||
|
||||
get unassignedDayOptions(): string[] {
|
||||
return this.routeState.getDiasUnicos();
|
||||
}
|
||||
|
||||
employeeLoadWidth(employee: EmployeeLoadOption): number {
|
||||
return Math.min(Math.max(employee.utilization, 0), 100);
|
||||
}
|
||||
|
||||
employeeLoadStatus(employee: EmployeeLoadOption): 'ok' | 'warning' | 'over-capacity' {
|
||||
return employee.utilization > 100 ? 'over-capacity' : employee.utilization > 90 ? 'warning' : 'ok';
|
||||
}
|
||||
|
||||
selectUnassignedEmployee(employeeId: string): void {
|
||||
if (!this.selectedUnassigned) return;
|
||||
this.assignmentUser[String(this.selectedUnassigned.pdv_id)] = employeeId;
|
||||
}
|
||||
|
||||
selectReassignmentEmployee(employeeId: string): void {
|
||||
this.reassignUserId = employeeId;
|
||||
}
|
||||
|
||||
toggleUnassignedDay(pdvId: number, day: string, checked: boolean): void {
|
||||
const key = String(pdvId);
|
||||
const days = new Set(this.assignmentDays[key] ?? []);
|
||||
@@ -652,6 +900,8 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
selectUnassigned(route: RouteNoAssignedResponse): void {
|
||||
this.selectedUnassigned = route;
|
||||
this.selectedRouteDetail = null;
|
||||
this.showReassignForm = false;
|
||||
this.reassignError = null;
|
||||
this.map?.setView([route.latitud, route.longitud], Math.max(this.map.getZoom(), 14));
|
||||
this.unassignedMarkers.get(route.pdv_id)?.openPopup();
|
||||
this.cdr.detectChanges();
|
||||
@@ -756,13 +1006,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
|
||||
private computeWeight(j: RouteAssignedResponse['json']): number {
|
||||
if (this.heatWeightMode === 'uniform') return 0.8;
|
||||
const v = parseFloat(String(j.horas_trabajo));
|
||||
if (isNaN(v)) return 0.8;
|
||||
if (isNaN(v)) return 1;
|
||||
const vals = this.filteredRoutes.map(r => parseFloat(String(r.json.horas_trabajo))).filter(n => !isNaN(n));
|
||||
if (!vals.length) return 0.8;
|
||||
if (!vals.length) return 1;
|
||||
const min = Math.min(...vals), max = Math.max(...vals);
|
||||
if (max === min) return 0.8;
|
||||
if (max === min) return 1;
|
||||
return 0.3 + 0.7 * (v - min) / (max - min);
|
||||
}
|
||||
|
||||
@@ -773,8 +1022,13 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
if (!this.showHeat || !this.heatPointsCache.length || !this.map) return;
|
||||
this.heatLayer = (L as any).heatLayer(this.heatPointsCache, {
|
||||
radius: 25, blur: 18, maxZoom: 17, minOpacity: 0.4,
|
||||
gradient: { 0.4: '#6CC24A', 0.65: '#FF6A13', 1.0: '#D32F2F' }
|
||||
pane: 'heatPane',
|
||||
radius: 32,
|
||||
blur: 24,
|
||||
maxZoom: 18,
|
||||
minOpacity: 0.55,
|
||||
max: 1.8,
|
||||
gradient: { 0.2: '#b7f05f', 0.45: '#6CC24A', 0.7: '#FF6A13', 1.0: '#D32F2F' }
|
||||
}).addTo(this.map);
|
||||
}
|
||||
|
||||
@@ -784,21 +1038,11 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
this.toggleLayerVisibility();
|
||||
const needsHydrate = !this.mapRoutes.some(m => m.orsLine) || this.orsTotal === 0 || this.orsProgress < this.orsTotal;
|
||||
if (!this.showHeat && needsHydrate) {
|
||||
this.hydrateWithOrs();
|
||||
this.hydrateWithOrs(this.renderVersion);
|
||||
}
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
setHeatWeight(mode: 'uniform' | 'horas_trabajo') {
|
||||
this.heatWeightMode = mode;
|
||||
this.heatPointsCache = this.filteredRoutes.map(r => {
|
||||
const j = r.json;
|
||||
return [Number(j.latitud_pdv), Number(j.longitud_pdv), this.computeWeight(j)] as [number, number, number];
|
||||
});
|
||||
this.updateHeatLayer();
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
private toggleLayerVisibility() {
|
||||
this.mapRoutes.forEach(mr => {
|
||||
if (mr.fallbackLine) (mr.fallbackLine as any).setStyle({ opacity: 0.6 });
|
||||
@@ -817,6 +1061,6 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.router.navigate(['/home']);
|
||||
this.router.navigate(['/proyectos', this.activatedRoute.snapshot.paramMap.get('id')]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
.pdvs-topbar{display:flex;align-items:center;gap:10px;padding:9px 24px;border-bottom:3px solid var(--glm-verde);background:#fff;color:var(--glm-azul);font-weight:700}.pdvs-topbar img{height:38px}.pdvs-topbar .user-profile{display:flex;align-items:center;gap:8px;margin-left:auto}.pdvs-topbar .user-profile img,.avatar-fallback{width:30px;height:30px;border-radius:50%}.avatar-fallback{display:grid;place-items:center;background:var(--glm-verde);color:#fff}.pdvs-topbar button{padding:8px 10px;border:1px solid var(--glm-gris-medio);border-radius:4px;background:#fff;color:var(--glm-azul);font-weight:700;cursor:pointer}.pdvs-page{max-width:1200px;margin:auto;padding:26px 24px}.pdvs-page>header{display:flex;justify-content:space-between;gap:20px;border-bottom:3px solid var(--glm-verde);padding-bottom:14px}.pdvs-page h1,.pdvs-page h2{margin:0;color:var(--glm-azul)}.pdvs-page header p{color:var(--glm-acero)}.import-button,.primary{padding:9px 12px;border-radius:4px;background:var(--glm-azul);color:#fff;font-weight:700;cursor:pointer}.import-button input{display:none}.pdv-form,.pdv-table{margin-top:18px;padding:18px;border:1px solid var(--glm-gris-medio);border-top:3px solid var(--glm-azul);border-radius:4px;background:#fff}.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-top:14px}.form-grid label{display:grid;gap:5px;color:var(--glm-azul);font-size:.75rem;font-weight:700;text-transform:uppercase}.form-grid input,.form-grid select{padding:9px;border:1px solid #9aaab3;border-radius:4px;background:#fff;color:#2f3f48}.form-actions{display:flex;gap:8px;margin-top:14px}.form-actions button{padding:9px 12px;border:1px solid var(--glm-gris-medio);border-radius:4px;cursor:pointer}.table-wrap{overflow:auto;margin-top:12px}table{width:100%;border-collapse:collapse;font-size:.82rem}th{padding:9px;background:var(--glm-azul);color:#fff;text-align:left}td{padding:9px;border-bottom:1px solid var(--glm-gris-claro)}td button{margin-right:5px;padding:6px;border:1px solid var(--glm-gris-medio);border-radius:4px;background:#fff;cursor:pointer}.danger{color:#c9252d}.error{color:#c9252d}.success{color:var(--glm-verde);font-weight:700}@media(max-width:650px){.pdvs-topbar{padding:8px 12px;flex-wrap:wrap}.pdvs-page{padding:18px 12px}.pdvs-page>header{flex-direction:column}}
|
||||
.import-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; width: fit-content; min-width: 0; white-space: nowrap; text-decoration: none; }
|
||||
.pdvs-page > header .import-button { align-self: flex-start; height: auto; }
|
||||
.pdv-table td:last-child { white-space: nowrap; }
|
||||
.pdv-table td:last-child button { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; margin: 0 6px 0 0; padding: 0; font-size: 1rem; line-height: 1; }
|
||||
.pdv-table td:last-child button.edit-button { width: auto; min-width: 86px; padding: 0 10px; gap: 6px; }
|
||||
.pdv-table td:last-child button.edit-button,
|
||||
.pdv-table td.actions-cell button.edit-button { display: inline-flex !important; align-items: center !important; justify-content: center !important; width: auto !important; min-width: 86px !important; height: 38px !important; padding: 0 12px !important; gap: 6px !important; border: 1px solid var(--glm-gris-medio) !important; border-radius: 4px !important; background: #fff !important; color: var(--glm-azul) !important; font: 700 .78rem/1 Arial, sans-serif !important; visibility: visible !important; opacity: 1 !important; }
|
||||
.pdv-table td:last-child button.edit-button span,
|
||||
.pdv-table td.actions-cell button.edit-button span { display: inline !important; color: var(--glm-azul) !important; font-size: .78rem !important; font-weight: 700 !important; visibility: visible !important; }
|
||||
.pdv-table td.actions-cell { white-space: nowrap; min-width: 170px; }
|
||||
.pdv-table td:last-child button:last-child { margin-right: 0; }
|
||||
.pdv-table td:last-child button.danger { color: #c9252d; }
|
||||
.pdv-table .row-actions { display: flex; gap: 5px; min-width: 250px; white-space: nowrap; }
|
||||
.pdv-table .row-actions button { width: auto !important; min-width: 0 !important; height: auto !important; margin: 0 !important; padding: 7px 10px !important; border: 1px solid var(--glm-gris-medio) !important; border-radius: 4px !important; background: #fff !important; color: var(--glm-azul) !important; font: 600 .78rem/1.2 Arial, sans-serif !important; }
|
||||
.pdv-table .row-actions button:hover { background: var(--glm-verde-bg) !important; border-color: var(--glm-verde) !important; }
|
||||
.pdv-table .row-actions button.delete { color: #c9252d !important; }
|
||||
.pdv-table .table-header { display:flex; justify-content:space-between; align-items:center; gap:12px; }.pdv-table .table-header > div { display:flex; align-items:center; gap:8px; }.pdv-table .selected-count { color:var(--glm-azul); font-size:.78rem; font-weight:700; }.pdv-table .table-header button,.pdv-table .bulk-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:7px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.pdv-table .table-header button:hover,.pdv-table .bulk-actions button:hover:not(:disabled) { background:var(--glm-verde-bg); border-color:var(--glm-verde); }.pdv-table .bulk-actions { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0; padding:12px; border:1px solid var(--glm-verde-claro); background:var(--glm-verde-bg); }.pdv-table .bulk-actions button.delete { color:#c9252d; }.pdv-table .select-column { width:38px; }
|
||||
.page-back-button { display:inline-flex; align-items:center; gap:6px; margin-bottom:18px; padding:8px 11px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:700 .82rem Arial,sans-serif; }.page-back-button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.pdvs-page { padding: 18px 14px; }
|
||||
.pdvs-page > header { align-items: flex-start; flex-direction: column; }
|
||||
.import-button { align-self: flex-start; }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<div class="pdvs-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
|
||||
<main class="pdvs-page"><button type="button" class="page-back-button" (click)="goBack()"><i class="pi pi-arrow-left"></i> Volver</button><header><div><h1>Gestionar PDVs</h1><p>{{ pdvs.length }} puntos de venta registrados en este proyecto</p></div><label class="import-button"><i class="pi pi-file-import"></i> {{ importing ? 'Importando...' : 'Importar Excel' }}<input type="file" accept=".xlsx,.xls" [disabled]="importing" (change)="importFile($event)"></label></header>
|
||||
<p class="success" *ngIf="message">{{ message }}</p><p class="error" *ngIf="error">{{ error }}</p>
|
||||
<section class="pdv-form"><h2>{{ editing ? 'Editar PDV' : 'Nuevo PDV' }}</h2><div class="form-grid"><label>ID<input [(ngModel)]="form.id"></label><label>Nombre<input [(ngModel)]="form.nombre"></label><label>Segmentación<select [(ngModel)]="form.segmentacion"><option *ngFor="let value of ['AA','A','B','C','D']" [value]="value">{{ value }}</option></select></label><label>Latitud<input type="number" step="any" [(ngModel)]="form.latitud"></label><label>Longitud<input type="number" step="any" [(ngModel)]="form.longitud"></label><label>Visitas semanales<input type="number" min="1" max="7" [(ngModel)]="form.visitas"></label><label>Duración (horas)<input type="number" min="0.1" step="0.1" [(ngModel)]="form.duracion"></label><label>Prioridad<input type="number" min="1" max="5" [(ngModel)]="form.prioridad"></label><label>Cobertura<input [(ngModel)]="form.cobertura"></label><label>Zona<input [(ngModel)]="form.zona"></label><label>Región<input [(ngModel)]="form.region"></label><label>Estado<input [(ngModel)]="form.estado"></label><label>Ciudad<input [(ngModel)]="form.ciudad"></label><label>Estado<select [(ngModel)]="form.activo"><option [ngValue]="true">Activo</option><option [ngValue]="false">Inactivo</option></select></label></div><div class="form-actions"><button type="button" class="primary" (click)="save()" [disabled]="saving">{{ saving ? 'Guardando...' : (editing ? 'Guardar cambios' : 'Crear PDV') }}</button><button type="button" *ngIf="editing" (click)="cancelEdit()">Cancelar</button></div></section>
|
||||
<section class="pdv-table"><div class="table-header"><h2>PDVs del proyecto</h2><div><span class="selected-count" *ngIf="selectedIds.size">{{ selectedIds.size }} seleccionados</span><button type="button" (click)="toggleAll(!allSelected)" [disabled]="loading">{{ allSelected ? 'Deseleccionar todos' : 'Seleccionar todos' }}</button></div></div><p *ngIf="loading">Cargando...</p><div class="bulk-actions" *ngIf="selectedIds.size"><button type="button" (click)="setSelectedActive(true)" [disabled]="bulkSaving">Activar</button><button type="button" (click)="setSelectedActive(false)" [disabled]="bulkSaving">Desactivar</button><button type="button" class="delete" (click)="removeSelected()" [disabled]="bulkSaving">Eliminar seleccionados</button></div><div class="table-wrap" *ngIf="!loading"><table><thead><tr><th class="select-column"><input type="checkbox" [checked]="allSelected" (change)="toggleAll($any($event.target).checked)" aria-label="Seleccionar todos los PDVs"></th><th>ID</th><th>Nombre</th><th>Segmentación</th><th>Ciudad</th><th>Estado</th><th>Zona</th><th>Acciones</th></tr></thead><tbody><tr *ngFor="let pdv of pdvs"><td><input type="checkbox" [checked]="selectedIds.has(pdv.id)" (change)="toggleSelection(pdv.id, $any($event.target).checked)" [attr.aria-label]="'Seleccionar ' + pdv.pdv_id"></td><td>{{ pdv.pdv_id }}</td><td>{{ value(pdv, 'nombre_del_pdv') }}</td><td>{{ value(pdv, 'segmentacion') }}</td><td>{{ value(pdv, 'ciudad') }}</td><td>{{ value(pdv, 'estado') }}</td><td>{{ value(pdv, 'zona') }}</td><td class="row-actions"><button type="button" (click)="edit(pdv)">Editar</button><button type="button" (click)="toggleActive(pdv)">{{ isActive(pdv) ? 'Desactivar' : 'Activar' }}</button><button type="button" class="delete" (click)="remove(pdv)">Eliminar</button></td></tr><tr *ngIf="!pdvs.length"><td colspan="8">No hay PDVs registrados.</td></tr></tbody></table></div></section></main>
|
||||
@@ -0,0 +1,207 @@
|
||||
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { ProjectService } from '../../core/services/project.service';
|
||||
import { RuteroProyectoPdv } from '../../core/models/project.model';
|
||||
import { ExcelPreviewService } from '../../shared/services/excel-preview.service';
|
||||
import { normalizeRow } from '../../shared/utils/header-normalization.utils';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { UserProfileService } from '../../core/services/user-profile.service';
|
||||
|
||||
interface PdvForm {
|
||||
id: string;
|
||||
nombre: string;
|
||||
segmentacion: string;
|
||||
latitud: number;
|
||||
longitud: number;
|
||||
visitas: number;
|
||||
duracion: number;
|
||||
prioridad: number;
|
||||
activo: boolean;
|
||||
cobertura: string;
|
||||
zona: string;
|
||||
region: string;
|
||||
estado: string;
|
||||
ciudad: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-pdvs',
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './pdvs.component.html',
|
||||
styleUrl: './pdvs.component.css'
|
||||
})
|
||||
export class PdvsComponent implements OnInit {
|
||||
projectId = '';
|
||||
pdvs: RuteroProyectoPdv[] = [];
|
||||
form = this.emptyForm();
|
||||
editing: RuteroProyectoPdv | null = null;
|
||||
loading = true;
|
||||
saving = false;
|
||||
importing = false;
|
||||
error = '';
|
||||
message = '';
|
||||
projectCountry = 'Republica Dominicana';
|
||||
selectedIds = new Set<string>();
|
||||
bulkSaving = false;
|
||||
|
||||
constructor(
|
||||
private projects: ProjectService,
|
||||
private preview: ExcelPreviewService,
|
||||
private auth: AuthService,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
public profile: UserProfileService,
|
||||
private cdr: ChangeDetectorRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.projectId = this.route.snapshot.paramMap.get('id') ?? '';
|
||||
if (!this.projectId) { void this.router.navigate(['/home']); return; }
|
||||
void this.load();
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
this.pdvs = await this.projects.pdvs(this.projectId);
|
||||
this.selectedIds.clear();
|
||||
const country = this.pdvs.map(pdv => normalizeRow(pdv.pdv_data)['pais']).find(Boolean);
|
||||
if (country) this.projectCountry = String(country);
|
||||
}
|
||||
catch (error: any) { this.error = error?.message ?? 'No se pudieron cargar los PDVs.'; }
|
||||
finally { this.loading = false; this.cdr.detectChanges(); }
|
||||
}
|
||||
|
||||
edit(pdv: RuteroProyectoPdv): void {
|
||||
const row = normalizeRow(pdv.pdv_data);
|
||||
this.editing = pdv;
|
||||
this.form = {
|
||||
id: String(row['id'] ?? pdv.pdv_id), nombre: String(row['nombre_del_pdv'] ?? ''), segmentacion: String(row['segmentacion'] ?? 'D'),
|
||||
latitud: Number(row['latitud'] ?? 0), longitud: Number(row['longitud'] ?? 0), visitas: Number(row['visitas_semanales'] ?? 1),
|
||||
duracion: Number(row['duracion_visitahoras'] ?? row['duracion_visita_horas'] ?? 1), prioridad: Number(row['prioridad'] ?? 5),
|
||||
activo: !['no', 'n', '0', 'false'].includes(String(row['activo'] ?? 'si').toLowerCase()), cobertura: String(row['cobertura'] ?? ''),
|
||||
zona: String(row['zona'] ?? ''), region: String(row['region'] ?? ''), estado: String(row['estado'] ?? ''), ciudad: String(row['ciudad'] ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
cancelEdit(): void { this.editing = null; this.form = this.emptyForm(); this.error = ''; }
|
||||
|
||||
async save(): Promise<void> {
|
||||
if (!this.form.id.trim() || !this.form.nombre.trim()) { this.error = 'ID y nombre son obligatorios.'; return; }
|
||||
if (!Number.isFinite(this.form.latitud) || !Number.isFinite(this.form.longitud)) { this.error = 'Coordenadas inválidas.'; return; }
|
||||
this.saving = true; this.error = ''; this.message = '';
|
||||
const row: Record<string, unknown> = {
|
||||
...(this.editing?.pdv_data ?? {}), ID: this.form.id.trim(), 'Nombre del PDV': this.form.nombre.trim(), Segmentación: this.form.segmentacion,
|
||||
Latitud: this.form.latitud, Longitud: this.form.longitud, 'Visitas semanales': this.form.visitas, 'Duración visita(horas)': this.form.duracion,
|
||||
Prioridad: this.form.prioridad, Activo: this.form.activo ? 'Sí' : 'No', Cobertura: this.form.cobertura, Zona: this.form.zona,
|
||||
Región: this.form.region, Estado: this.form.estado, Ciudad: this.form.ciudad, País: normalizeRow(this.editing?.pdv_data ?? {})['pais'] ?? this.projectCountry
|
||||
};
|
||||
try {
|
||||
if (this.editing) await this.projects.updatePdv(this.editing.id, row);
|
||||
else await this.projects.addPdv(this.projectId, row);
|
||||
this.message = this.editing ? 'PDV actualizado.' : 'PDV creado.';
|
||||
this.cancelEdit();
|
||||
await this.load();
|
||||
} catch (error: any) { this.error = error?.message ?? 'No se pudo guardar el PDV.'; }
|
||||
finally { this.saving = false; this.cdr.detectChanges(); }
|
||||
}
|
||||
|
||||
async importFile(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
this.importing = true; this.error = ''; this.message = '';
|
||||
try {
|
||||
const result = await this.preview.previewFile(file, 'PDV');
|
||||
if (!result.valid) throw new Error(result.errors[0]?.message ?? 'El archivo PDV contiene errores.');
|
||||
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
|
||||
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(workbook.Sheets[workbook.SheetNames[0]], { defval: null, raw: true });
|
||||
await this.projects.upsertPdvs(this.projectId, rows);
|
||||
this.message = `${rows.length} PDVs importados o actualizados.`;
|
||||
await this.load();
|
||||
} catch (error: any) { this.error = error?.message ?? 'No se pudo importar el archivo.'; }
|
||||
finally { this.importing = false; input.value = ''; this.cdr.detectChanges(); }
|
||||
}
|
||||
|
||||
async remove(pdv: RuteroProyectoPdv): Promise<void> {
|
||||
if (!confirm(`¿Eliminar el PDV ${pdv.pdv_id}?`)) return;
|
||||
try { await this.projects.removePdv(pdv.id); await this.load(); }
|
||||
catch (error: any) { this.error = error?.message ?? 'No se pudo eliminar el PDV.'; }
|
||||
}
|
||||
|
||||
isActive(pdv: RuteroProyectoPdv): boolean {
|
||||
return !['no', 'n', '0', 'false'].includes(String(normalizeRow(pdv.pdv_data)['activo'] ?? 'si').toLowerCase());
|
||||
}
|
||||
|
||||
async toggleActive(pdv: RuteroProyectoPdv): Promise<void> {
|
||||
const active = !this.isActive(pdv);
|
||||
try {
|
||||
await this.projects.updatePdv(pdv.id, { ...pdv.pdv_data, Activo: active ? 'Sí' : 'No' });
|
||||
this.message = active ? 'PDV activado.' : 'PDV desactivado.';
|
||||
await this.load();
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudo actualizar el estado del PDV.';
|
||||
}
|
||||
}
|
||||
|
||||
get allSelected(): boolean {
|
||||
return this.pdvs.length > 0 && this.pdvs.every(pdv => this.selectedIds.has(pdv.id));
|
||||
}
|
||||
|
||||
get selectedPdvs(): RuteroProyectoPdv[] {
|
||||
return this.pdvs.filter(pdv => this.selectedIds.has(pdv.id));
|
||||
}
|
||||
|
||||
toggleSelection(id: string, checked: boolean): void {
|
||||
checked ? this.selectedIds.add(id) : this.selectedIds.delete(id);
|
||||
}
|
||||
|
||||
toggleAll(checked: boolean): void {
|
||||
this.selectedIds = checked ? new Set(this.pdvs.map(pdv => pdv.id)) : new Set<string>();
|
||||
}
|
||||
|
||||
async setSelectedActive(active: boolean): Promise<void> {
|
||||
const selected = this.selectedPdvs;
|
||||
if (!selected.length) return;
|
||||
this.bulkSaving = true;
|
||||
this.error = '';
|
||||
try {
|
||||
await Promise.all(selected.map(pdv => this.projects.updatePdv(pdv.id, { ...pdv.pdv_data, Activo: active ? 'Sí' : 'No' })));
|
||||
this.message = `${selected.length} PDVs ${active ? 'activados' : 'desactivados'}.`;
|
||||
await this.load();
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudo actualizar el estado de los PDVs.';
|
||||
} finally {
|
||||
this.bulkSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async removeSelected(): Promise<void> {
|
||||
const selected = this.selectedPdvs;
|
||||
if (!selected.length || !confirm(`¿Eliminar ${selected.length} PDVs seleccionados? Esta acción no se puede deshacer.`)) return;
|
||||
this.bulkSaving = true;
|
||||
this.error = '';
|
||||
try {
|
||||
await Promise.all(selected.map(pdv => this.projects.removePdv(pdv.id)));
|
||||
this.message = `${selected.length} PDVs eliminados.`;
|
||||
await this.load();
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudieron eliminar los PDVs.';
|
||||
} finally {
|
||||
this.bulkSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
value(pdv: RuteroProyectoPdv, field: string): unknown { return normalizeRow(pdv.pdv_data)[field] ?? '-'; }
|
||||
goBack(): void { void this.router.navigate(['/proyectos', this.projectId]); }
|
||||
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
|
||||
|
||||
private emptyForm(): PdvForm {
|
||||
return { id: '', nombre: '', segmentacion: 'D', latitud: 18.4861, longitud: -69.9312, visitas: 1, duracion: 1, prioridad: 5, activo: true, cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<div class="projects-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="topbar-actions"><a routerLink="/home">Mis proyectos</a><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div></div><main class="projects-page narrow"><header><div><h1>Crear proyecto</h1><p>Define el proyecto. Podrás agregar sus PDVs después desde su gestor.</p></div></header><section class="project-form"><label>Nombre del proyecto<input [(ngModel)]="name" placeholder="Ej. Ruta Santo Domingo Norte" maxlength="120"></label><label>Descripción<textarea [(ngModel)]="description" placeholder="Describe el objetivo o alcance del proyecto" maxlength="500"></textarea></label><label>País<select [(ngModel)]="country"><option value="Republica Dominicana">República Dominicana</option><option value="MEXICO">México</option></select></label><p class="error" *ngIf="error">{{ error }}</p><button class="primary" type="button" [disabled]="!canCreate" (click)="create()">{{ saving ? 'Creando proyecto...' : 'Crear proyecto' }}</button></section></main>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ChangeDetectorRef, Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { ProjectService } from '../../core/services/project.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { UserProfileService } from '../../core/services/user-profile.service';
|
||||
|
||||
@Component({ standalone: true, selector: 'app-new-project', imports: [CommonModule, FormsModule, RouterLink], templateUrl: './new-project.component.html', styleUrl: './projects.component.css' })
|
||||
export class NewProjectComponent {
|
||||
name = '';
|
||||
description = '';
|
||||
country = 'Republica Dominicana';
|
||||
saving = false;
|
||||
error = '';
|
||||
constructor(private projects: ProjectService, private auth: AuthService, public profile: UserProfileService, private router: Router, private cdr: ChangeDetectorRef) {}
|
||||
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
|
||||
get canCreate(): boolean { return !!this.name.trim() && !!this.country && !this.saving; }
|
||||
async create(): Promise<void> {
|
||||
if (!this.canCreate) return;
|
||||
this.saving = true; this.error = '';
|
||||
try {
|
||||
const project = await this.projects.create(this.name, this.description, this.country);
|
||||
await this.router.navigate(['/proyectos', project.id]);
|
||||
} catch (error: any) { this.error = error?.message ?? 'No se pudo crear el proyecto.'; }
|
||||
finally { this.saving = false; this.cdr.detectChanges(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="projects-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="topbar-actions"><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div></div>
|
||||
<main class="projects-page"><header><div><h1>Mis proyectos</h1><p>Organiza y conserva tus planificaciones de rutas.</p></div><a class="primary" routerLink="/proyectos/nuevo"><i class="pi pi-plus"></i> Crear proyecto</a></header><p class="error" *ngIf="error">{{ error }}</p><p *ngIf="loading">Cargando proyectos...</p><section class="project-grid" *ngIf="!loading"><article class="project-card" *ngFor="let project of projects"><h2>{{ project.nombre }}</h2><p>{{ project.descripcion || 'Sin descripción' }}</p><small>Actualizado: {{ project.updated_at | date:'medium' }}</small><div class="project-card-actions"><button type="button" (click)="open(project)" [disabled]="deletingId === project.id">Abrir proyecto</button><button type="button" class="danger" (click)="requestRemove(project)" [disabled]="deletingId === project.id"><i class="pi pi-trash"></i> Eliminar</button></div></article><section class="empty" *ngIf="!projects.length"><i class="pi pi-folder-open"></i><h2>Aún no tienes proyectos</h2><p>Crea uno y agrega sus PDVs cuando estés listo para planificar.</p><a class="primary" routerLink="/proyectos/nuevo">Crear proyecto</a></section></section></main>
|
||||
<div class="delete-dialog-backdrop" *ngIf="projectToDelete" (click)="closeDeleteDialog()"><section class="delete-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-project-title" (click)="$event.stopPropagation()"><i class="pi pi-exclamation-triangle"></i><h2 id="delete-project-title">Eliminar proyecto</h2><p>¿Eliminar permanentemente <strong>{{ projectToDelete.nombre }}</strong>?</p><p class="delete-dialog-warning">También se eliminarán sus PDVs, empleados y rutas. Esta acción no se puede deshacer.</p><div><button type="button" (click)="closeDeleteDialog()" [disabled]="!!deletingId">Cancelar</button><button type="button" class="danger" (click)="remove()" [disabled]="!!deletingId">{{ deletingId ? 'Eliminando...' : 'Eliminar proyecto' }}</button></div></section></div>
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ChangeDetectorRef, Component, HostListener, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { ProjectService } from '../../core/services/project.service';
|
||||
import { RuteroProyecto } from '../../core/models/project.model';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { UserProfileService } from '../../core/services/user-profile.service';
|
||||
|
||||
@Component({ standalone: true, selector: 'app-project-list', imports: [CommonModule, RouterLink], templateUrl: './project-list.component.html', styleUrl: './projects.component.css' })
|
||||
export class ProjectListComponent implements OnInit {
|
||||
projects: RuteroProyecto[] = [];
|
||||
loading = true;
|
||||
deletingId = '';
|
||||
projectToDelete: RuteroProyecto | null = null;
|
||||
error = '';
|
||||
constructor(private projectsService: ProjectService, private auth: AuthService, public profile: UserProfileService, private router: Router, private cdr: ChangeDetectorRef) {}
|
||||
ngOnInit(): void { void this.load(); }
|
||||
async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try { this.projects = await this.projectsService.list(); }
|
||||
catch (error: any) { this.error = error?.message ?? 'No se pudieron cargar los proyectos.'; }
|
||||
finally { this.loading = false; this.cdr.detectChanges(); }
|
||||
}
|
||||
open(project: RuteroProyecto): void { void this.router.navigate(['/proyectos', project.id]); }
|
||||
requestRemove(project: RuteroProyecto): void { this.projectToDelete = project; }
|
||||
closeDeleteDialog(): void { if (!this.deletingId) this.projectToDelete = null; }
|
||||
@HostListener('document:keydown.escape') onEscape(): void { this.closeDeleteDialog(); }
|
||||
async remove(): Promise<void> {
|
||||
const project = this.projectToDelete;
|
||||
if (!project) return;
|
||||
this.deletingId = project.id;
|
||||
this.error = '';
|
||||
try {
|
||||
await this.projectsService.remove(project.id);
|
||||
this.projects = this.projects.filter(item => item.id !== project.id);
|
||||
} catch (error: any) {
|
||||
this.error = error?.message ?? 'No se pudo eliminar el proyecto.';
|
||||
} finally {
|
||||
this.deletingId = '';
|
||||
this.projectToDelete = null;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.projects-topbar { display:flex; align-items:center; gap:12px; padding:9px 24px; border-bottom:3px solid var(--glm-verde); color:var(--glm-azul); font-weight:700; background:#fff; }.projects-topbar img { height:38px; }.projects-topbar a { margin-left:auto; color:var(--glm-azul); text-decoration:none; }.projects-page { max-width:1100px; margin:0 auto; padding:30px 24px; }.projects-page header { display:flex; justify-content:space-between; align-items:flex-start; gap:20px; border-bottom:3px solid var(--glm-verde); padding-bottom:14px; }.projects-page h1,.projects-page h2 { color:var(--glm-azul); margin:0; }.projects-page header p,.project-card p,small { color:var(--glm-acero); }.primary,.project-card button { display:inline-block; border:1px solid var(--glm-azul); border-radius:4px; padding:9px 12px; background:var(--glm-azul); color:#fff; font:inherit; font-weight:700; text-decoration:none; cursor:pointer; }.project-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(245px,1fr)); gap:16px; margin-top:22px; }.project-card,.project-form,.empty { border:1px solid var(--glm-gris-medio); border-top:3px solid var(--glm-azul); border-radius:4px; padding:18px; background:#fff; }.project-card { display:flex; flex-direction:column; gap:12px; }.project-card button { margin-top:auto; }.state { align-self:flex-start; color:var(--glm-verde); font-size:.75rem; font-weight:700; text-transform:uppercase; }.empty { grid-column:1/-1; text-align:center; padding:38px; }.narrow { max-width:720px; }.project-form { display:grid; gap:16px; margin-top:20px; }.project-form label { display:grid; gap:6px; color:var(--glm-azul); font-size:.8rem; font-weight:700; text-transform:uppercase; }.project-form input { padding:10px; border:1px solid var(--glm-gris-medio); border-radius:4px; font:inherit; }.file-label span { color:var(--glm-acero); font-size:.75rem; text-transform:none; }.error { color:#c9252d; }.valid { color:var(--glm-verde); font-weight:700; } @media(max-width:640px){.projects-page{padding:20px 16px}.projects-page header{display:grid}.projects-topbar{padding:9px 16px}}
|
||||
.project-form input,.project-form select { background:#fff; color:#2f3f48; border-color:#9aaab3; }.project-form select { padding:10px; border:1px solid #9aaab3; border-radius:4px; font:inherit; }.project-form input::placeholder { color:#71808a; opacity:1; }.project-form input:focus,.project-form select:focus { outline:2px solid rgba(108,194,74,.35); border-color:var(--glm-verde); background:#fff; color:#1f2d34; }.project-form input[type=file] { color:#2f3f48; background:#fff; cursor:pointer; }
|
||||
.topbar-actions { display:flex; align-items:center; gap:8px; margin-left:auto; }.topbar-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:inherit; font-size:.82rem; font-weight:700; padding:8px 12px; }.topbar-actions button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }.manage-employees { display:inline-flex; align-items:center; gap:6px; margin-bottom:14px; border:1px solid var(--glm-gris-medio); border-radius:4px; padding:8px 12px; color:var(--glm-azul); font-weight:700; text-decoration:none; background:#fff; }.manage-employees:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }
|
||||
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
|
||||
.project-card-actions { display:flex; gap:8px; margin-top:auto; }.project-card-actions button { margin-top:0; }.project-card-actions .danger { border-color:#c9252d; background:#fff; color:#c9252d; }.project-card-actions .danger:hover:not(:disabled) { background:#c9252d; color:#fff; }.project-card-actions button:disabled { cursor:not-allowed; opacity:.65; }
|
||||
.project-form textarea { min-height:88px; padding:10px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-gris-oscuro); font:inherit; resize:vertical; }
|
||||
.delete-dialog-backdrop { position:fixed; inset:0; z-index:2000; display:grid; place-items:center; padding:20px; background:rgba(24,36,44,.56); }.delete-dialog { width:min(100%,440px); padding:26px; border-top:3px solid #c9252d; border-radius:4px; background:#fff; box-shadow:0 18px 48px rgba(0,0,0,.28); }.delete-dialog>i { color:#c9252d; font-size:1.5rem; }.delete-dialog h2 { margin:10px 0 8px; color:var(--glm-azul); }.delete-dialog p { margin:8px 0; color:var(--glm-gris-oscuro); line-height:1.45; }.delete-dialog-warning { color:var(--glm-acero)!important; font-size:.9rem; }.delete-dialog>div { display:flex; justify-content:flex-end; gap:8px; margin-top:20px; }.delete-dialog button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:9px 12px; background:#fff; color:var(--glm-azul); cursor:pointer; font:700 .82rem Arial,sans-serif; }.delete-dialog button.danger { border-color:#c9252d; color:#c9252d; }.delete-dialog button.danger:hover:not(:disabled) { background:#c9252d; color:#fff; }.delete-dialog button:disabled { cursor:not-allowed; opacity:.6; }
|
||||
@@ -87,13 +87,17 @@
|
||||
box-shadow: 0 0 0 3px rgba(108, 194, 74, 0.15);
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
.google-login {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
padding: 11px;
|
||||
background: var(--glm-azul);
|
||||
color: #fff;
|
||||
border: none;
|
||||
background: #fff;
|
||||
color: var(--glm-gris-oscuro);
|
||||
border: 1px solid var(--glm-gris-medio);
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
font-family: Arial, sans-serif;
|
||||
@@ -102,15 +106,25 @@ button[type="submit"] {
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button[type="submit"]:hover:not(:disabled) {
|
||||
background: var(--glm-verde);
|
||||
.google-login:hover:not(:disabled) {
|
||||
border-color: var(--glm-azul);
|
||||
background: var(--glm-verde-bg);
|
||||
}
|
||||
|
||||
button[type="submit"]:disabled {
|
||||
.google-login:disabled {
|
||||
opacity: 0.6;
|
||||
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 {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
|
||||
@@ -11,36 +11,15 @@
|
||||
Planifica rutas inteligentes de forma eficiente
|
||||
</p>
|
||||
|
||||
<form (ngSubmit)="login()" #form="ngForm">
|
||||
|
||||
<div class="input-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
[(ngModel)]="email"
|
||||
required
|
||||
placeholder="usuario@empresa.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
[(ngModel)]="password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" [disabled]="loading || form.invalid">
|
||||
<span *ngIf="!loading">Iniciar sesión</span>
|
||||
<span *ngIf="loading">Verificando ruta...</span>
|
||||
<div class="login-actions">
|
||||
<button type="button" class="google-login" (click)="loginWithGoogle()" [disabled]="loading">
|
||||
<span class="google-mark">G</span>
|
||||
<span *ngIf="!loading">Continuar con Google</span>
|
||||
<span *ngIf="loading">{{ loadingText }}</span>
|
||||
</button>
|
||||
|
||||
</form>
|
||||
<p class="domain-note">Solo cuentas @gomezleemarketing.com</p>
|
||||
<p class="login-error" *ngIf="error">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
© 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 { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { UserProfileService } from '../../core/services/user-profile.service';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-login',
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [CommonModule],
|
||||
templateUrl: './login.component.html',
|
||||
styleUrls: ['./login.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class LoginComponent {
|
||||
export class LoginComponent implements OnInit {
|
||||
|
||||
email = '';
|
||||
password = '';
|
||||
loading = false;
|
||||
error = '';
|
||||
loadingText = 'Redirigiendo a Google...';
|
||||
|
||||
constructor(private router: Router) {}
|
||||
login() {
|
||||
constructor(private router: Router, private auth: AuthService, private profile: UserProfileService, private cdr: ChangeDetectorRef) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const session = await this.auth.getSession();
|
||||
if (!session) return;
|
||||
this.loading = true;
|
||||
|
||||
setTimeout(() => {
|
||||
this.loadingText = 'Obteniendo datos del usuario...';
|
||||
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.router.navigate(['/home']);
|
||||
}, 1500);
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
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 const USUARIO_MAX_ROWS = 50;
|
||||
export const USUARIO_MAX_ROWS = 300;
|
||||
|
||||
@@ -165,7 +165,15 @@ export class ExcelPreviewService {
|
||||
'Longitud': cleanNum(row['longitud']),
|
||||
'Visitas semanales': Number.isFinite(visits) ? visits : 1,
|
||||
'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();
|
||||
|
||||
@@ -3,32 +3,37 @@ import * as XLSX from 'xlsx';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { RouteState } from '../../core/route-state/route';
|
||||
import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils';
|
||||
import { PlanningFilterService } from '../../core/services/planning-filter.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlanningExportService {
|
||||
constructor(private routeState: RouteState) {}
|
||||
constructor(private routeState: RouteState, private filters: PlanningFilterService) {}
|
||||
|
||||
export(): void {
|
||||
const assigned = this.routeState.getAssignedSnapshot();
|
||||
const unassigned = this.routeState.getNoAssignedSnapshot();
|
||||
const users = new Map<string, { nombre: string; rutas: number; distancia: number; trabajo: number; capacidad: number }>();
|
||||
const assigned = this.filters.filterAssigned(this.routeState.getAssignedSnapshot());
|
||||
const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot());
|
||||
const users = new Map<string, { nombre: string; visitas: number; pdvs: Set<string>; distancia: number; trabajo: number; capacidad: number }>();
|
||||
|
||||
for (const route of assigned) {
|
||||
const key = String(route.json.usuario_id);
|
||||
const current = users.get(key) ?? {
|
||||
nombre: route.json.nombre_usuario,
|
||||
rutas: 0,
|
||||
visitas: 0,
|
||||
pdvs: new Set<string>(),
|
||||
distancia: 0,
|
||||
trabajo: 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.trabajo += Number.parseFloat(String(route.json.horas_trabajo ?? '')) || 0;
|
||||
users.set(key, current);
|
||||
}
|
||||
|
||||
const total = assigned.length + unassigned.length;
|
||||
const assignedIds = new Set(assigned.map(route => String(route.json.pdv_id)));
|
||||
const unassignedIds = new Set(unassigned.map(route => String(route.pdv_id)).filter(id => !assignedIds.has(id)));
|
||||
const total = assignedIds.size + unassignedIds.size;
|
||||
const dailyRoutes = summarizeDailyRoutes(assigned);
|
||||
const totalDistance = dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0);
|
||||
const totalTravel = dailyRoutes.reduce((sum, route) => sum + route.travelHours, 0);
|
||||
@@ -36,9 +41,9 @@ export class PlanningExportService {
|
||||
|
||||
const summary = [
|
||||
{ Indicador: 'PDVs totales', Valor: total },
|
||||
{ Indicador: 'PDVs asignados', Valor: assigned.length },
|
||||
{ Indicador: 'PDVs sin asignar', Valor: unassigned.length },
|
||||
{ Indicador: 'Cobertura (%)', Valor: total ? Number(((assigned.length / total) * 100).toFixed(1)) : 0 },
|
||||
{ Indicador: 'PDVs asignados', Valor: assignedIds.size },
|
||||
{ Indicador: 'PDVs sin asignar', Valor: unassignedIds.size },
|
||||
{ Indicador: 'Cobertura (%)', Valor: total ? Number(((assignedIds.size / total) * 100).toFixed(1)) : 0 },
|
||||
{ Indicador: 'Usuarios activos', Valor: users.size },
|
||||
{ Indicador: 'Días planificados', Valor: new Set(assigned.map(route => route.json.dia)).size },
|
||||
{ Indicador: 'Distancia total (km)', Valor: Number(totalDistance.toFixed(2)) },
|
||||
@@ -71,7 +76,8 @@ export class PlanningExportService {
|
||||
return {
|
||||
'Usuario ID': id,
|
||||
Usuario: user.nombre,
|
||||
Rutas: user.rutas,
|
||||
PDVs: user.pdvs.size,
|
||||
Visitas: user.visitas,
|
||||
'Distancia (km)': Number(user.distancia.toFixed(2)),
|
||||
'Horas trabajo': Number(user.trabajo.toFixed(2)),
|
||||
'Capacidad (h)': user.capacidad,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { RouteState } from '../../core/route-state/route';
|
||||
import { ProjectService } from '../../core/services/project.service';
|
||||
import { PlanningFilterService } from '../../core/services/planning-filter.service';
|
||||
import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils';
|
||||
import { recommendationForUnassigned } from '../utils/unassigned-recommendation.utils';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RoutePdfReportService {
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
private routeState: RouteState,
|
||||
private projects: ProjectService,
|
||||
private filters: PlanningFilterService
|
||||
) {}
|
||||
|
||||
async download(projectId: string): Promise<void> {
|
||||
const [project, assigned, unassigned] = await Promise.all([
|
||||
this.projects.get(projectId),
|
||||
Promise.resolve(this.filters.filterAssigned(this.routeState.getAssignedSnapshot())),
|
||||
Promise.resolve(this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot()))
|
||||
]);
|
||||
const assignedIds = new Set(assigned.map(route => String(route.json.pdv_id)));
|
||||
const pending = unassigned
|
||||
.filter(route => !assignedIds.has(String(route.pdv_id)))
|
||||
.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
|
||||
const dailyRoutes = summarizeDailyRoutes(assigned);
|
||||
const totalPdvs = assignedIds.size + new Set(pending.map(route => String(route.pdv_id))).size;
|
||||
const payload = {
|
||||
project: { id: project.id, name: project.nombre, country: project.pais ?? '', description: project.descripcion ?? '' },
|
||||
filters: this.filters.snapshot(),
|
||||
generatedAt: new Date().toISOString(),
|
||||
summary: {
|
||||
totalPdvs,
|
||||
assignedPdvs: assignedIds.size,
|
||||
unassignedPdvs: pending.length,
|
||||
coveragePercent: totalPdvs ? Number(((assignedIds.size / totalPdvs) * 100).toFixed(1)) : 0,
|
||||
totalDistanceKm: Number(dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0).toFixed(2)),
|
||||
totalWorkHours: Number(dailyRoutes.reduce((sum, route) => sum + route.workHours, 0).toFixed(2))
|
||||
},
|
||||
assignedRoutes: sequenceRoutes(assigned).map(segment => ({
|
||||
usuarioId: segment.route.json.usuario_id,
|
||||
usuario: segment.route.json.nombre_usuario,
|
||||
pdvId: segment.route.json.pdv_id,
|
||||
pdv: segment.route.json.nombre_pdv,
|
||||
dia: segment.route.json.dia,
|
||||
orden: segment.order,
|
||||
origen: segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}`,
|
||||
distanciaKm: segment.route.json.distancia,
|
||||
desplazamientoHoras: segment.route.json.horas_desplazamiento,
|
||||
horasTrabajo: segment.route.json.horas_trabajo,
|
||||
cobertura: segment.route.json.cobertura ?? '',
|
||||
zona: segment.route.json.zona ?? '',
|
||||
region: segment.route.json.region ?? '',
|
||||
estado: segment.route.json.estado ?? '',
|
||||
ciudad: segment.route.json.ciudad ?? ''
|
||||
})),
|
||||
unassignedPdvs: pending
|
||||
};
|
||||
try {
|
||||
const pdf = await firstValueFrom(this.http.post(environment.n8nPdfWebhookUrl, payload, { responseType: 'blob' }));
|
||||
const fileName = `rutas_${this.fileName(project.nombre)}.pdf`;
|
||||
saveAs(pdf, fileName);
|
||||
} catch (error) {
|
||||
throw new Error(await this.messageForError(error));
|
||||
}
|
||||
}
|
||||
|
||||
private fileName(value: string): string {
|
||||
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'proyecto';
|
||||
}
|
||||
|
||||
private async messageForError(error: unknown): Promise<string> {
|
||||
const response = error as HttpErrorResponse;
|
||||
if (response.error instanceof Blob) {
|
||||
try {
|
||||
const body = JSON.parse(await response.error.text()) as { message?: string; error?: string };
|
||||
return body.message ?? body.error ?? 'No se pudo generar el PDF.';
|
||||
} catch { /* use fallback */ }
|
||||
}
|
||||
return response.error?.message ?? response.message ?? 'No se pudo generar el PDF.';
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,37 @@
|
||||
const assignedColors = new Map<string, string>();
|
||||
const usedColors = new Set<string>();
|
||||
|
||||
function hashId(value: string): number {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function routeColor(id: string): string {
|
||||
const existing = assignedColors.get(id);
|
||||
if (existing) return existing;
|
||||
const hash = hashId(id);
|
||||
let attempt = 0;
|
||||
let color = '';
|
||||
do {
|
||||
// Restricted to dark navy/indigo/violet/plum. Avoids red, cyan, green, yellow and orange.
|
||||
const hue = 225 + ((hash + attempt * 37) % 106);
|
||||
const saturation = 58 + (((hash >>> 8) + attempt * 11) % 27);
|
||||
const lightness = 27 + (((hash >>> 16) + attempt * 7) % 17);
|
||||
color = `hsl(${hue} ${saturation}% ${lightness}%)`;
|
||||
attempt++;
|
||||
} while (usedColors.has(color));
|
||||
assignedColors.set(id, color);
|
||||
usedColors.add(color);
|
||||
return color;
|
||||
}
|
||||
|
||||
export function colorForUsuario(usuarioId: string | number): { color: string; glow: string } {
|
||||
const str = String(usuarioId);
|
||||
let hash = 0;
|
||||
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 };
|
||||
const color = routeColor(String(usuarioId));
|
||||
return { color, glow: color };
|
||||
}
|
||||
|
||||
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 = {
|
||||
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',
|
||||
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 = {
|
||||
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',
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -40,6 +40,30 @@
|
||||
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 {
|
||||
--glm-verde: #6CC24A;
|
||||
--glm-verde-claro: #A4D65E;
|
||||
|
||||
@@ -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()
|
||||
));
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user