Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2ae1cef58 | |||
| fbb378b009 |
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,223 +0,0 @@
|
|||||||
{
|
|
||||||
"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><td>${route.costoCombustible != null ? `${escape(route.monedaCombustible)} ${escape(route.costoCombustible)}` : '\\u2014'}</td></tr>`).join('') || '<tr><td colspan=\"8\">Sin rutas asignadas para los filtros seleccionados.</td></tr>';\nconst unassignedRows = payload.unassignedPdvs.map(route => `<tr><td>${escape(route.pdv_id)}</td><td>${escape(route.pdv_nombre)}</td><td>${escape(route.minimo_horas_semana)} h</td><td>${escape(route.motivo)}</td><td>${escape(route.recomendacion)}</td></tr>`).join('') || '<tr><td colspan=\"5\">No hay PDVs sin asignar para los filtros seleccionados.</td></tr>';\nconst summary = payload.summary ?? {};const fuel = payload.fuel ?? {};\nconst html = `<!doctype html><html><head><meta charset=\"utf-8\"><style>body{font-family:Arial,sans-serif;color:#243746;margin:28px;font-size:11px}h1{color:#4F758B;margin:0}h2{color:#4F758B;border-bottom:2px solid #6CC24A;padding-bottom:6px;margin-top:28px}p{line-height:1.45}.meta{color:#61798a}.filters span{display:inline-block;background:#eef6e8;border:1px solid #a4d65e;border-radius:3px;margin:3px 5px 3px 0;padding:4px 6px}.summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:15px 0}.metric{border:1px solid #d0d0d0;border-left:3px solid #6CC24A;padding:8px}.metric b{display:block;color:#4F758B;font-size:16px}table{width:100%;border-collapse:collapse;margin-top:10px}th{background:#4F758B;color:#fff;text-align:left;padding:7px}td{border-bottom:1px solid #e5e5e5;padding:7px;vertical-align:top}.footer{margin-top:24px;color:#6b8fa3;font-size:9px}</style></head><body><h1>Reporte de rutas</h1><p class=\"meta\"><b>${escape(payload.project.name)}</b><br>${escape(payload.project.country)}${payload.project.description ? `<br>${escape(payload.project.description)}` : ''}<br>Generado: ${escape(payload.generatedAt)}</p>${filters ? `<div class=\"filters\"><b>Filtros aplicados:</b><br>${filters}</div>` : '<p><b>Filtros aplicados:</b> Ninguno</p>'}<div class=\"summary\"><div class=\"metric\"><b>${escape(summary.totalPdvs)}</b>PDVs totales</div><div class=\"metric\"><b>${escape(summary.assignedPdvs)}</b>PDVs asignados</div><div class=\"metric\"><b>${escape(summary.unassignedPdvs)}</b>PDVs sin asignar</div><div class=\"metric\"><b>${escape(summary.coveragePercent)}%</b>Cobertura</div><div class=\"metric\"><b>${escape(summary.totalDistanceKm)} km</b>Distancia</div><div class=\"metric\"><b>${escape(summary.totalWorkHours)} h</b>Trabajo</div><div class=\"metric\"><b>${fuel.configured ? `${escape(summary.fuelLiters)} L` : '\\u2014'}</b>Combustible</div><div class=\"metric\"><b>${fuel.configured ? `${escape(summary.fuelCurrency)} ${escape(summary.fuelCost)}` : '\\u2014'}</b>Costo combustible</div></div><h2>Rutas asignadas</h2><table><thead><tr><th>Usuario</th><th>Día</th><th>Orden</th><th>PDV</th><th>Origen</th><th>Distancia</th><th>Trabajo</th><th>Costo comb.</th></tr></thead><tbody>${routeRows}</tbody></table><h2>PDVs sin asignar</h2><table><thead><tr><th>PDV ID</th><th>PDV</th><th>Horas/semana</th><th>Motivo</th><th>Recomendación</th></tr></thead><tbody>${unassignedRows}</tbody></table><p class=\"footer\">Route Planner · GomezLee Marketing</p></body></html>`;\nreturn [{ json: { fileName: `rutas_${String(payload.project.name).replace(/[^a-z0-9]+/gi, '_').toLowerCase()}.pdf` }, binary: { data: { data: Buffer.from(html).toString('base64'), mimeType: 'text/html', fileName: 'index.html' } } }];"
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
-100,
|
|
||||||
260
|
|
||||||
],
|
|
||||||
"id": "9258c394-683f-4569-8ced-af2337ec8dba",
|
|
||||||
"name": "Build Report HTML",
|
|
||||||
"onError": "continueErrorOutput",
|
|
||||||
"notes": "Genera tablas deterministas; no usa IA para alterar cifras, rutas ni recomendaciones."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"method": "POST",
|
|
||||||
"url": "http://host.docker.internal:3000/forms/chromium/convert/html",
|
|
||||||
"authentication": "none",
|
|
||||||
"sendBody": true,
|
|
||||||
"contentType": "multipart-form-data",
|
|
||||||
"bodyParameters": {
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"parameterType": "formBinaryData",
|
|
||||||
"name": "files",
|
|
||||||
"inputDataFieldName": "data"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"response": {
|
|
||||||
"response": {
|
|
||||||
"responseFormat": "file",
|
|
||||||
"outputPropertyName": "data"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"timeout": 120000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.2,
|
|
||||||
"position": [
|
|
||||||
180,
|
|
||||||
260
|
|
||||||
],
|
|
||||||
"id": "73e2759c-d9f9-49dc-925f-3ad1ee7b3897",
|
|
||||||
"name": "Convert HTML to PDF",
|
|
||||||
"retryOnFail": true,
|
|
||||||
"maxTries": 3,
|
|
||||||
"waitBetweenTries": 3000,
|
|
||||||
"onError": "continueErrorOutput",
|
|
||||||
"notes": "Gotenberg debe estar disponible en host.docker.internal:3000 desde el contenedor n8n."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"respondWith": "binary",
|
|
||||||
"responseBinaryPropertyName": "data",
|
|
||||||
"options": {
|
|
||||||
"responseHeaders": {
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"name": "Content-Disposition",
|
|
||||||
"value": "attachment; filename=\"reporte_rutas.pdf\""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
|
||||||
"typeVersion": 1.5,
|
|
||||||
"position": [
|
|
||||||
460,
|
|
||||||
200
|
|
||||||
],
|
|
||||||
"id": "93928c3e-1e9c-4f77-a644-c156fae907a5",
|
|
||||||
"name": "Respond PDF"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"respondWith": "json",
|
|
||||||
"responseBody": "={{ { error: 'validation_error', message: $json.error?.message || $json.message || 'El reporte no contiene datos válidos.' } }}",
|
|
||||||
"options": {
|
|
||||||
"responseCode": 400
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
|
||||||
"typeVersion": 1.5,
|
|
||||||
"position": [
|
|
||||||
-100,
|
|
||||||
460
|
|
||||||
],
|
|
||||||
"id": "c8b69f14-45d0-4f55-9f7c-2e3fb3425f14",
|
|
||||||
"name": "Respond Validation Error"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"respondWith": "json",
|
|
||||||
"responseBody": "={{ { error: 'pdf_generation_error', message: 'No se pudo generar el PDF de rutas.', detail: $json.error?.message || $json.message || 'Gotenberg no está disponible.' } }}",
|
|
||||||
"options": {
|
|
||||||
"responseCode": 502
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
|
||||||
"typeVersion": 1.5,
|
|
||||||
"position": [
|
|
||||||
460,
|
|
||||||
430
|
|
||||||
],
|
|
||||||
"id": "c8d6e363-80b6-46ca-a8c6-32ce393207cc",
|
|
||||||
"name": "Respond PDF Error"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"connections": {
|
|
||||||
"Webhook: Generate PDF": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Validate Report Payload",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Validate Report Payload": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Build Report HTML",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Respond Validation Error",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Build Report HTML": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Convert HTML to PDF",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Respond Validation Error",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Convert HTML to PDF": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Respond PDF",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Respond PDF Error",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"active": false,
|
|
||||||
"settings": {
|
|
||||||
"executionOrder": "v1"
|
|
||||||
},
|
|
||||||
"meta": {
|
|
||||||
"templateCredsSetupCompleted": true
|
|
||||||
},
|
|
||||||
"tags": []
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,15 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
ors:
|
ors:
|
||||||
image: openrouteservice/openrouteservice:latest
|
image: openrouteservice/openrouteservice:latest
|
||||||
container_name: ors-republica-dominicana
|
container_name: ors-route-planner
|
||||||
ports:
|
ports:
|
||||||
- "18080:8082"
|
- "18080:8082"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -10,7 +10,5 @@ services:
|
|||||||
- ./ors-docker/elevation_cache:/home/ors/elevation_cache
|
- ./ors-docker/elevation_cache:/home/ors/elevation_cache
|
||||||
- ./ors-docker/config/ors-config.yml:/home/ors/config/ors-config.yml
|
- ./ors-docker/config/ors-config.yml:/home/ors/config/ors-config.yml
|
||||||
environment:
|
environment:
|
||||||
- XMS=1g
|
- 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
|
||||||
- XMX=4g
|
|
||||||
- ADDITIONAL_JAVA_OPTS=-XX:MaxGCPauseMillis=200 -XX:InitiatingHeapOccupancyPercent=80
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
services:
|
|
||||||
gotenberg:
|
|
||||||
image: gotenberg/gotenberg:8
|
|
||||||
container_name: route-planner-pdf
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
restart: unless-stopped
|
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"production": {
|
"production": {
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{ "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" },
|
{ "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" },
|
||||||
{ "type": "anyComponentStyle", "maximumWarning": "8kB", "maximumError": "15kB" }
|
{ "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "10kB" }
|
||||||
],
|
],
|
||||||
"outputHashing": "all",
|
"outputHashing": "all",
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
|
|||||||
Generated
-102
@@ -15,7 +15,6 @@
|
|||||||
"@angular/platform-browser": "^21.1.0",
|
"@angular/platform-browser": "^21.1.0",
|
||||||
"@angular/router": "^21.1.0",
|
"@angular/router": "^21.1.0",
|
||||||
"@primeng/themes": "^21.0.0",
|
"@primeng/themes": "^21.0.0",
|
||||||
"@supabase/supabase-js": "^2.115.0",
|
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.heat": "^0.2.0",
|
"leaflet.heat": "^0.2.0",
|
||||||
@@ -4038,98 +4037,6 @@
|
|||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/auth-js": {
|
|
||||||
"version": "2.115.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.115.0.tgz",
|
|
||||||
"integrity": "sha512-YNQlQWm1H0gsXHSY8Jd/xepBhjO0Zhwx04iW17A83/joQ5kFiUin6iPj9s9kZZvupnwLjXVP/diTFiLz2jUbwQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@supabase/functions-js": {
|
|
||||||
"version": "2.115.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.115.0.tgz",
|
|
||||||
"integrity": "sha512-p97V6/YFcdp+zblFDVJaE8f9rGKTNz0PRzyJ2d1w/EYIU5lwidKuc3l/wM+u27ACmAC62albvoGiUGzLPSg7Aw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@supabase/phoenix": {
|
|
||||||
"version": "0.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
|
|
||||||
"integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@supabase/postgrest-js": {
|
|
||||||
"version": "2.115.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.115.0.tgz",
|
|
||||||
"integrity": "sha512-DdERcurLh5t84pgSywDg2LjLR5le7XAzl52/iQhC7FdboWDqGfDPMfaWJV3MHvhyxSlSNzbpSQG+RiQOBSn/4w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@supabase/realtime-js": {
|
|
||||||
"version": "2.115.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.115.0.tgz",
|
|
||||||
"integrity": "sha512-5HyBkvlA/IUV2v8jX3uLTdy15jmTPRVXwXKoxvH2SKw5jZMNURxCxt+VpCEukscXQlY7pUpH8Cy4NH6io4iaRw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@supabase/phoenix": "0.4.5",
|
|
||||||
"tslib": "2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@supabase/storage-js": {
|
|
||||||
"version": "2.115.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.115.0.tgz",
|
|
||||||
"integrity": "sha512-dLyIxzbO+MCcKHhcce8rVUCQX1iyqXqQ8ytgkOVYJ7D+Zp0qKylPtQH3hamgxrGSxtDjaw47Urpzw2iK9PsKdA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"iceberg-js": "^0.8.1",
|
|
||||||
"tslib": "2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@supabase/supabase-js": {
|
|
||||||
"version": "2.115.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.115.0.tgz",
|
|
||||||
"integrity": "sha512-PYJSxtCo37R7tTZW6pAqsxUeSx/dlhA7zn8RzKEUSCqyTxCUhG+iHrDTb04UyVBYbttaUbhwkNTvb8h5HW+uZA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@supabase/auth-js": "2.115.0",
|
|
||||||
"@supabase/functions-js": "2.115.0",
|
|
||||||
"@supabase/postgrest-js": "2.115.0",
|
|
||||||
"@supabase/realtime-js": "2.115.0",
|
|
||||||
"@supabase/storage-js": "2.115.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@opentelemetry/api": ">=1.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@opentelemetry/api": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tufjs/canonical-json": {
|
"node_modules/@tufjs/canonical-json": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
|
||||||
@@ -5981,15 +5888,6 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iceberg-js": {
|
|
||||||
"version": "0.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
|
||||||
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.3",
|
"version": "0.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
"@angular/platform-browser": "^21.1.0",
|
"@angular/platform-browser": "^21.1.0",
|
||||||
"@angular/router": "^21.1.0",
|
"@angular/router": "^21.1.0",
|
||||||
"@primeng/themes": "^21.0.0",
|
"@primeng/themes": "^21.0.0",
|
||||||
"@supabase/supabase-js": "^2.115.0",
|
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.heat": "^0.2.0",
|
"leaflet.heat": "^0.2.0",
|
||||||
|
|||||||
@@ -3,23 +3,12 @@ import { LoginComponent } from './public/login/login.component';
|
|||||||
import { HomeComponent } from './private/home/home.component';
|
import { HomeComponent } from './private/home/home.component';
|
||||||
import { AssignedRouteDetailComponent } from './private/home/assigned-route-detail/assigned-route-detail.component';
|
import { AssignedRouteDetailComponent } from './private/home/assigned-route-detail/assigned-route-detail.component';
|
||||||
import { RouteOverviewMapComponent } from './private/home/route-overview-map/route-overview-map.component';
|
import { RouteOverviewMapComponent } from './private/home/route-overview-map/route-overview-map.component';
|
||||||
import { authGuard } from './core/services/auth.guard';
|
|
||||||
import { EmployeesComponent } from './private/employees/employees.component';
|
|
||||||
import { ProjectListComponent } from './private/projects/project-list.component';
|
|
||||||
import { NewProjectComponent } from './private/projects/new-project.component';
|
|
||||||
import { PdvsComponent } from './private/pdvs/pdvs.component';
|
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', redirectTo: 'login', pathMatch: 'full' },
|
{ path: '', redirectTo: 'login', pathMatch: 'full' },
|
||||||
{ path: 'login', component: LoginComponent },
|
{ path: 'login', component: LoginComponent },
|
||||||
{ path: 'home', component: ProjectListComponent, pathMatch: 'full', canActivate: [authGuard] },
|
{ path: 'home', component: HomeComponent, pathMatch: 'full' },
|
||||||
{ path: 'proyectos/nuevo', component: NewProjectComponent, canActivate: [authGuard] },
|
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent },
|
||||||
{ path: 'proyectos/:id', component: HomeComponent, pathMatch: 'full', canActivate: [authGuard] },
|
{ path: 'home/mapa', component: RouteOverviewMapComponent },
|
||||||
{ path: 'proyectos/:id/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] },
|
|
||||||
{ path: 'proyectos/:id/ruta', component: AssignedRouteDetailComponent, canActivate: [authGuard] },
|
|
||||||
{ path: 'proyectos/:id/empleados', component: EmployeesComponent, canActivate: [authGuard] },
|
|
||||||
{ path: 'proyectos/:id/pdvs', component: PdvsComponent, canActivate: [authGuard] },
|
|
||||||
{ path: 'home/assigned-route', redirectTo: 'home', pathMatch: 'full' },
|
|
||||||
{ path: 'home/mapa', redirectTo: 'home', pathMatch: 'full' },
|
|
||||||
{ path: '**', redirectTo: 'login' }
|
{ path: '**', redirectTo: 'login' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
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'>;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
export interface RuteroProyecto {
|
|
||||||
id: string;
|
|
||||||
nombre: string;
|
|
||||||
descripcion: string | null;
|
|
||||||
pais: string | null;
|
|
||||||
rendimiento_km_l: number | null;
|
|
||||||
precio_por_litro: number | null;
|
|
||||||
propietario_id: string;
|
|
||||||
estado: 'optimizado' | 'archivado' | null;
|
|
||||||
version: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuteroProyectoPdv {
|
|
||||||
id: string;
|
|
||||||
proyecto_id: string;
|
|
||||||
pdv_id: string;
|
|
||||||
pdv_data: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
@@ -27,26 +27,6 @@ export interface RouteAssignedJson {
|
|||||||
usuario_id: string;
|
usuario_id: string;
|
||||||
pdv_id: number;
|
pdv_id: number;
|
||||||
visit_order?: number;
|
visit_order?: number;
|
||||||
prioridad?: number;
|
|
||||||
segmentacion?: string;
|
|
||||||
geojson?: unknown;
|
|
||||||
horas_por_dia_usuario?: number;
|
|
||||||
horas_descanso_por_dia?: number;
|
|
||||||
jornada_codigo?: string;
|
|
||||||
cobertura?: string;
|
|
||||||
pais?: string;
|
|
||||||
ciudad?: string;
|
|
||||||
estado?: string;
|
|
||||||
provincia?: string;
|
|
||||||
region?: string;
|
|
||||||
zona?: string;
|
|
||||||
metro?: string;
|
|
||||||
pais_usuario?: string;
|
|
||||||
ciudad_usuario?: string;
|
|
||||||
estado_usuario?: string;
|
|
||||||
provincia_usuario?: string;
|
|
||||||
region_usuario?: string;
|
|
||||||
zona_usuario?: string;
|
|
||||||
}
|
}
|
||||||
export interface RouteNoAssignedResponse {
|
export interface RouteNoAssignedResponse {
|
||||||
pdv_id: number;
|
pdv_id: number;
|
||||||
@@ -55,12 +35,4 @@ export interface RouteAssignedJson {
|
|||||||
latitud: number,
|
latitud: number,
|
||||||
longitud: number,
|
longitud: number,
|
||||||
motivo: string;
|
motivo: string;
|
||||||
recomendacion?: string;
|
|
||||||
pais?: string;
|
|
||||||
ciudad?: string;
|
|
||||||
estado?: string;
|
|
||||||
provincia?: string;
|
|
||||||
region?: string;
|
|
||||||
zona?: string;
|
|
||||||
metro?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export interface UserProfile {
|
|
||||||
nombre: string;
|
|
||||||
pais: string;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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 = 'rp_no_assigned_routes';
|
||||||
const STORAGE_NO_ASSIGNED_ORIGINAL = 'rp_no_assigned_routes_original';
|
const STORAGE_NO_ASSIGNED_ORIGINAL = 'rp_no_assigned_routes_original';
|
||||||
const STORAGE_SELECTED = 'rp_selected_route';
|
const STORAGE_SELECTED = 'rp_selected_route';
|
||||||
const DAY_ORDER = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes', 'Sábado', 'Sabado'];
|
const DAY_ORDER = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes'];
|
||||||
export interface UnassignedAssignment {
|
export interface UnassignedAssignment {
|
||||||
userId: string;
|
userId: string;
|
||||||
day: string;
|
day: string;
|
||||||
@@ -21,18 +21,17 @@ export interface UnassignedAssignment {
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class RouteState {
|
export class RouteState {
|
||||||
private projectId: string | null = null;
|
|
||||||
|
|
||||||
private loading = new BehaviorSubject<boolean>(false);
|
private loading = new BehaviorSubject<boolean>(false);
|
||||||
loading$ = this.loading.asObservable();
|
loading$ = this.loading.asObservable();
|
||||||
|
|
||||||
private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>([]);
|
private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>(this.loadAssigned());
|
||||||
assignedRoutes$ = this.assignedRoutes.asObservable();
|
assignedRoutes$ = this.assignedRoutes.asObservable();
|
||||||
|
|
||||||
private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>([]);
|
private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>(this.loadNoAssigned());
|
||||||
noAssignedRoutes$ = this.noAssignedRoutes.asObservable();
|
noAssignedRoutes$ = this.noAssignedRoutes.asObservable();
|
||||||
|
|
||||||
private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(null);
|
private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(this.loadSelected());
|
||||||
selectedRoute$ = this.selectedRoute.asObservable();
|
selectedRoute$ = this.selectedRoute.asObservable();
|
||||||
|
|
||||||
resetLoading() {
|
resetLoading() {
|
||||||
@@ -43,15 +42,6 @@ export class RouteState {
|
|||||||
this.loading.next(value);
|
this.loading.next(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
useProject(projectId: string): void {
|
|
||||||
if (!projectId || this.projectId === projectId) return;
|
|
||||||
this.projectId = projectId;
|
|
||||||
this.assignedRoutes.next(this.loadAssigned());
|
|
||||||
this.noAssignedRoutes.next(this.loadNoAssigned());
|
|
||||||
this.selectedRoute.next(this.loadSelected());
|
|
||||||
this.loading.next(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
setAssignedRoutes(data: RouteAssignedResponse[]) {
|
setAssignedRoutes(data: RouteAssignedResponse[]) {
|
||||||
this.assignedRoutes.next(data);
|
this.assignedRoutes.next(data);
|
||||||
this.persist(STORAGE_ASSIGNED, data);
|
this.persist(STORAGE_ASSIGNED, data);
|
||||||
@@ -75,12 +65,6 @@ export class RouteState {
|
|||||||
nombre_usuario: profile.json.nombre_usuario,
|
nombre_usuario: profile.json.nombre_usuario,
|
||||||
latitud_usuario: profile.json.latitud_usuario,
|
latitud_usuario: profile.json.latitud_usuario,
|
||||||
longitud_usuario: profile.json.longitud_usuario,
|
longitud_usuario: profile.json.longitud_usuario,
|
||||||
pais_usuario: profile.json.pais_usuario,
|
|
||||||
ciudad_usuario: profile.json.ciudad_usuario,
|
|
||||||
estado_usuario: profile.json.estado_usuario,
|
|
||||||
provincia_usuario: profile.json.provincia_usuario,
|
|
||||||
region_usuario: profile.json.region_usuario,
|
|
||||||
zona_usuario: profile.json.zona_usuario,
|
|
||||||
hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario,
|
hora_laboral_semanal_usuario: profile.json.hora_laboral_semanal_usuario,
|
||||||
dia: day,
|
dia: day,
|
||||||
distancia: metrics?.distance ?? item.json.distancia,
|
distancia: metrics?.distance ?? item.json.distancia,
|
||||||
@@ -114,20 +98,7 @@ export class RouteState {
|
|||||||
nombre_usuario: profile.json.nombre_usuario,
|
nombre_usuario: profile.json.nombre_usuario,
|
||||||
nombre_pdv: route.pdv_nombre,
|
nombre_pdv: route.pdv_nombre,
|
||||||
usuario_id: profile.json.usuario_id,
|
usuario_id: profile.json.usuario_id,
|
||||||
pdv_id: route.pdv_id,
|
pdv_id: route.pdv_id
|
||||||
pais: route.pais,
|
|
||||||
ciudad: route.ciudad,
|
|
||||||
estado: route.estado,
|
|
||||||
provincia: route.provincia,
|
|
||||||
region: route.region,
|
|
||||||
zona: route.zona,
|
|
||||||
metro: route.metro,
|
|
||||||
pais_usuario: profile.json.pais_usuario,
|
|
||||||
ciudad_usuario: profile.json.ciudad_usuario,
|
|
||||||
estado_usuario: profile.json.estado_usuario,
|
|
||||||
provincia_usuario: profile.json.provincia_usuario,
|
|
||||||
region_usuario: profile.json.region_usuario,
|
|
||||||
zona_usuario: profile.json.zona_usuario
|
|
||||||
} } as RouteAssignedResponse;
|
} } as RouteAssignedResponse;
|
||||||
});
|
});
|
||||||
if (newRoutes.some(item => !item)) return;
|
if (newRoutes.some(item => !item)) return;
|
||||||
@@ -201,17 +172,9 @@ export class RouteState {
|
|||||||
const profiles = Array.from(new Map(current.map(route => [String(route.json.usuario_id), route])).values());
|
const profiles = Array.from(new Map(current.map(route => [String(route.json.usuario_id), route])).values());
|
||||||
if (profiles.length < 2) return;
|
if (profiles.length < 2) return;
|
||||||
|
|
||||||
const segmentWeight: Record<string, number> = { AA: 100, A: 80, B: 60, C: 40, D: 20 };
|
|
||||||
const priorityOf = (route: (typeof current)[number]): number => {
|
|
||||||
const value = Number(route.json.prioridad);
|
|
||||||
return Number.isFinite(value) ? value : 5;
|
|
||||||
};
|
|
||||||
const segmentOf = (route: (typeof current)[number]): number =>
|
|
||||||
segmentWeight[String(route.json.segmentacion ?? 'D').toUpperCase()] ?? 20;
|
|
||||||
const load = new Map<string, number>(profiles.map(profile => [String(profile.json.usuario_id), 0]));
|
const load = new Map<string, number>(profiles.map(profile => [String(profile.json.usuario_id), 0]));
|
||||||
const optimized = [...current]
|
const optimized = [...current]
|
||||||
.sort((a, b) => priorityOf(a) - priorityOf(b) || segmentOf(b) - segmentOf(a) ||
|
.sort((a, b) => Number(b.json.hora_minima_semanal_pdv) - Number(a.json.hora_minima_semanal_pdv))
|
||||||
Number(b.json.hora_minima_semanal_pdv) - Number(a.json.hora_minima_semanal_pdv))
|
|
||||||
.map(route => {
|
.map(route => {
|
||||||
const routeHours = Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0);
|
const routeHours = Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0);
|
||||||
const user = profiles
|
const user = profiles
|
||||||
@@ -232,12 +195,6 @@ export class RouteState {
|
|||||||
nombre_usuario: user.json.nombre_usuario,
|
nombre_usuario: user.json.nombre_usuario,
|
||||||
latitud_usuario: user.json.latitud_usuario,
|
latitud_usuario: user.json.latitud_usuario,
|
||||||
longitud_usuario: user.json.longitud_usuario,
|
longitud_usuario: user.json.longitud_usuario,
|
||||||
pais_usuario: user.json.pais_usuario,
|
|
||||||
ciudad_usuario: user.json.ciudad_usuario,
|
|
||||||
estado_usuario: user.json.estado_usuario,
|
|
||||||
provincia_usuario: user.json.provincia_usuario,
|
|
||||||
region_usuario: user.json.region_usuario,
|
|
||||||
zona_usuario: user.json.zona_usuario,
|
|
||||||
hora_laboral_semanal_usuario: user.json.hora_laboral_semanal_usuario
|
hora_laboral_semanal_usuario: user.json.hora_laboral_semanal_usuario
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -264,14 +221,13 @@ export class RouteState {
|
|||||||
return this.noAssignedRoutes.getValue();
|
return this.noAssignedRoutes.getValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
findRoute(pdvId: string | number, usuarioId?: string, day?: string): RouteAssignedResponse | null {
|
findRoute(pdvId: string | number, usuarioId?: string): RouteAssignedResponse | null {
|
||||||
const pid = String(pdvId);
|
const pid = String(pdvId);
|
||||||
const routes = this.getAssignedSnapshot();
|
const routes = this.getAssignedSnapshot();
|
||||||
const found = routes.find(r => {
|
const found = routes.find(r => {
|
||||||
const jp = r.json;
|
const jp = r.json;
|
||||||
if (String(jp.pdv_id) !== pid) return false;
|
if (String(jp.pdv_id) !== pid) return false;
|
||||||
if (usuarioId && String(jp.usuario_id) !== String(usuarioId)) return false;
|
if (usuarioId && String(jp.usuario_id) !== String(usuarioId)) return false;
|
||||||
if (day && jp.dia !== day) return false;
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return found ?? null;
|
return found ?? null;
|
||||||
@@ -301,18 +257,17 @@ export class RouteState {
|
|||||||
this.noAssignedRoutes.next([]);
|
this.noAssignedRoutes.next([]);
|
||||||
this.selectedRoute.next(null);
|
this.selectedRoute.next(null);
|
||||||
try {
|
try {
|
||||||
sessionStorage.removeItem(this.storageKey(STORAGE_ASSIGNED));
|
sessionStorage.removeItem(STORAGE_ASSIGNED);
|
||||||
sessionStorage.removeItem(this.storageKey(STORAGE_ASSIGNED_ORIGINAL));
|
sessionStorage.removeItem(STORAGE_ASSIGNED_ORIGINAL);
|
||||||
sessionStorage.removeItem(this.storageKey(STORAGE_NO_ASSIGNED));
|
sessionStorage.removeItem(STORAGE_NO_ASSIGNED);
|
||||||
sessionStorage.removeItem(this.storageKey(STORAGE_NO_ASSIGNED_ORIGINAL));
|
sessionStorage.removeItem(STORAGE_NO_ASSIGNED_ORIGINAL);
|
||||||
sessionStorage.removeItem(this.storageKey(STORAGE_SELECTED));
|
sessionStorage.removeItem(STORAGE_SELECTED);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private persist(key: string, data: unknown) {
|
private persist(key: string, data: unknown) {
|
||||||
if (!this.projectId) return;
|
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(this.storageKey(key), JSON.stringify(data));
|
sessionStorage.setItem(key, JSON.stringify(data));
|
||||||
} catch { /* ignore storage errors */ }
|
} catch { /* ignore storage errors */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +283,7 @@ export class RouteState {
|
|||||||
|
|
||||||
private loadAssigned(): RouteAssignedResponse[] {
|
private loadAssigned(): RouteAssignedResponse[] {
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(this.storageKey(STORAGE_ASSIGNED));
|
const raw = sessionStorage.getItem(STORAGE_ASSIGNED);
|
||||||
if (raw) return JSON.parse(raw) as RouteAssignedResponse[];
|
if (raw) return JSON.parse(raw) as RouteAssignedResponse[];
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return [];
|
return [];
|
||||||
@@ -336,7 +291,7 @@ export class RouteState {
|
|||||||
|
|
||||||
private loadOriginalAssigned(): RouteAssignedResponse[] {
|
private loadOriginalAssigned(): RouteAssignedResponse[] {
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(this.storageKey(STORAGE_ASSIGNED_ORIGINAL));
|
const raw = sessionStorage.getItem(STORAGE_ASSIGNED_ORIGINAL);
|
||||||
if (raw) return JSON.parse(raw) as RouteAssignedResponse[];
|
if (raw) return JSON.parse(raw) as RouteAssignedResponse[];
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return this.loadAssigned();
|
return this.loadAssigned();
|
||||||
@@ -344,7 +299,7 @@ export class RouteState {
|
|||||||
|
|
||||||
private loadNoAssigned(): RouteNoAssignedResponse[] {
|
private loadNoAssigned(): RouteNoAssignedResponse[] {
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(this.storageKey(STORAGE_NO_ASSIGNED));
|
const raw = sessionStorage.getItem(STORAGE_NO_ASSIGNED);
|
||||||
if (raw) return JSON.parse(raw) as RouteNoAssignedResponse[];
|
if (raw) return JSON.parse(raw) as RouteNoAssignedResponse[];
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return [];
|
return [];
|
||||||
@@ -352,13 +307,9 @@ export class RouteState {
|
|||||||
|
|
||||||
private loadSelected(): RouteAssignedResponse | null {
|
private loadSelected(): RouteAssignedResponse | null {
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(this.storageKey(STORAGE_SELECTED));
|
const raw = sessionStorage.getItem(STORAGE_SELECTED);
|
||||||
if (raw) return JSON.parse(raw) as RouteAssignedResponse;
|
if (raw) return JSON.parse(raw) as RouteAssignedResponse;
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private storageKey(key: string): string {
|
|
||||||
return this.projectId ? `${key}:${this.projectId}` : key;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
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');
|
|
||||||
};
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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,6 +16,8 @@ import {
|
|||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
const ENDPOINT = `${environment.orsBaseUrl}/directions/driving-car`;
|
||||||
|
const ENDPOINTJSON = `${environment.orsBaseUrl}/directions/driving-car/geojson`;
|
||||||
const MAX_CONCURRENT_REQUESTS = 2;
|
const MAX_CONCURRENT_REQUESTS = 2;
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -24,7 +26,6 @@ const MAX_CONCURRENT_REQUESTS = 2;
|
|||||||
export class OrsService {
|
export class OrsService {
|
||||||
|
|
||||||
private geoCache = new Map<string, unknown>();
|
private geoCache = new Map<string, unknown>();
|
||||||
private failedCache = new Map<string, string>();
|
|
||||||
|
|
||||||
constructor(private http: HttpClient) { }
|
constructor(private http: HttpClient) { }
|
||||||
|
|
||||||
@@ -38,15 +39,10 @@ export class OrsService {
|
|||||||
isJson = false
|
isJson = false
|
||||||
) {
|
) {
|
||||||
const body = {
|
const body = {
|
||||||
coordinates: [origin, destination],
|
coordinates: [origin, destination]
|
||||||
radiuses: [1000, 1000]
|
|
||||||
};
|
};
|
||||||
const baseUrl = this.isMexico(origin) || this.isMexico(destination)
|
|
||||||
? environment.orsMexicoBaseUrl
|
|
||||||
: environment.orsBaseUrl;
|
|
||||||
const endpoint = `${baseUrl}/directions/driving-car${isJson ? '/geojson' : ''}`;
|
|
||||||
|
|
||||||
return this.http.post<unknown>(endpoint, body).pipe(
|
return this.http.post<unknown>(isJson ? ENDPOINTJSON : ENDPOINT, body).pipe(
|
||||||
timeout(8000),
|
timeout(8000),
|
||||||
catchError(error => {
|
catchError(error => {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -54,19 +50,6 @@ export class OrsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isMexico(coordinates: number[]): boolean {
|
|
||||||
const [longitude, latitude] = coordinates;
|
|
||||||
return latitude >= 14 && latitude <= 33 && longitude >= -119 && longitude <= -86;
|
|
||||||
}
|
|
||||||
|
|
||||||
private orsErrorMessage(error: unknown): string {
|
|
||||||
const err = error as { message?: string; error?: { error?: { message?: string; code?: number }; message?: string } };
|
|
||||||
const serverMessage = err?.error?.error?.message ?? err?.error?.message;
|
|
||||||
if (serverMessage) return String(serverMessage);
|
|
||||||
if (err?.message) return String(err.message);
|
|
||||||
return 'Error ORS';
|
|
||||||
}
|
|
||||||
|
|
||||||
async processPairs(
|
async processPairs(
|
||||||
pares: {
|
pares: {
|
||||||
pdv_id: number;
|
pdv_id: number;
|
||||||
@@ -109,17 +92,6 @@ export class OrsService {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (isJson && this.failedCache.has(key)) {
|
|
||||||
const errOut = { pdv_id: pair.pdv_id, error: this.failedCache.get(key) ?? 'Sin conexión vial (ORS).' };
|
|
||||||
return of(errOut).pipe(
|
|
||||||
map(data => {
|
|
||||||
completed++;
|
|
||||||
progressSubject.next({ completed, total: pares.length, item: data as unknown });
|
|
||||||
if (completed === pares.length) progressSubject.complete();
|
|
||||||
return data as unknown;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.fetchRoute(pair.origin, pair.destination, isJson).pipe(
|
return this.fetchRoute(pair.origin, pair.destination, isJson).pipe(
|
||||||
delay(300),
|
delay(300),
|
||||||
map(data => {
|
map(data => {
|
||||||
@@ -145,11 +117,9 @@ export class OrsService {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
catchError(error => {
|
catchError(error => {
|
||||||
const message = this.orsErrorMessage(error);
|
|
||||||
if (isJson) this.failedCache.set(key, message);
|
|
||||||
const errOut = {
|
const errOut = {
|
||||||
pdv_id: pair.pdv_id,
|
pdv_id: pair.pdv_id,
|
||||||
error: message
|
error: (error as Error).message || 'Error ORS'
|
||||||
};
|
};
|
||||||
completed++;
|
completed++;
|
||||||
progressSubject.next({ completed, total: pares.length, item: errOut });
|
progressSubject.next({ completed, total: pares.length, item: errOut });
|
||||||
@@ -174,6 +144,5 @@ export class OrsService {
|
|||||||
|
|
||||||
clearCache() {
|
clearCache() {
|
||||||
this.geoCache.clear();
|
this.geoCache.clear();
|
||||||
this.failedCache.clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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,27 +2,23 @@ import { Injectable } from '@angular/core';
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PlanningInputService {
|
export class PlanningInputService {
|
||||||
private projectId: string | null = null;
|
|
||||||
private pdvFile: File | null = null;
|
private pdvFile: File | null = null;
|
||||||
private usuariosFile: File | null = null;
|
private usuariosFile: File | null = null;
|
||||||
|
|
||||||
setFiles(projectId: string, pdvFile: File, usuariosFile: File): void {
|
setFiles(pdvFile: File, usuariosFile: File): void {
|
||||||
this.projectId = projectId;
|
|
||||||
this.pdvFile = pdvFile;
|
this.pdvFile = pdvFile;
|
||||||
this.usuariosFile = usuariosFile;
|
this.usuariosFile = usuariosFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFormData(projectId: string): FormData | null {
|
getFormData(): FormData | null {
|
||||||
if (this.projectId !== projectId || !this.pdvFile || !this.usuariosFile) return null;
|
if (!this.pdvFile || !this.usuariosFile) return null;
|
||||||
const data = new FormData();
|
const data = new FormData();
|
||||||
data.append('pdv_file', this.pdvFile, this.pdvFile.name);
|
data.append('pdv_file', this.pdvFile, this.pdvFile.name);
|
||||||
data.append('usuarios_file', this.usuariosFile, this.usuariosFile.name);
|
data.append('usuarios_file', this.usuariosFile, this.usuariosFile.name);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(projectId?: string): void {
|
clear(): void {
|
||||||
if (projectId && this.projectId !== projectId) return;
|
|
||||||
this.projectId = null;
|
|
||||||
this.pdvFile = null;
|
this.pdvFile = null;
|
||||||
this.usuariosFile = null;
|
this.usuariosFile = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
import * as XLSX from 'xlsx';
|
|
||||||
import { RuteroProyecto, RuteroProyectoPdv } from '../models/project.model';
|
|
||||||
import { supabaseClient } from './supabase.client';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class ProjectService {
|
|
||||||
async list(): Promise<RuteroProyecto[]> {
|
|
||||||
const { data, error } = await supabaseClient.from('rutero_proyectos').select('*').order('updated_at', { ascending: false });
|
|
||||||
if (error) throw error;
|
|
||||||
return (data ?? []) as RuteroProyecto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(id: string): Promise<RuteroProyecto> {
|
|
||||||
const { data, error } = await supabaseClient.from('rutero_proyectos').select('*').eq('id', id).single();
|
|
||||||
if (error) throw error;
|
|
||||||
return data as RuteroProyecto;
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, changes: Partial<Pick<RuteroProyecto, 'nombre' | 'descripcion' | 'rendimiento_km_l' | 'precio_por_litro'>>): Promise<void> {
|
|
||||||
const { error } = await supabaseClient.from('rutero_proyectos').update(changes).eq('id', id);
|
|
||||||
if (error) throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
|
||||||
const dependentTables = ['rutero_proyecto_rutas', 'rutero_proyecto_empleados', 'rutero_proyecto_pdvs'];
|
|
||||||
for (const table of dependentTables) {
|
|
||||||
const { error } = await supabaseClient.from(table).delete().eq('proyecto_id', id);
|
|
||||||
if (error) throw error;
|
|
||||||
}
|
|
||||||
const { error } = await supabaseClient.from('rutero_proyectos').delete().eq('id', id);
|
|
||||||
if (error) throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(nombre: string, descripcion: string, pais: string): Promise<RuteroProyecto> {
|
|
||||||
const { data: sessionData, error: sessionError } = await supabaseClient.auth.getSession();
|
|
||||||
if (sessionError) throw sessionError;
|
|
||||||
const ownerId = sessionData.session?.user.id;
|
|
||||||
if (!ownerId) throw new Error('No hay una sesión activa.');
|
|
||||||
const projectId = crypto.randomUUID();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const project: RuteroProyecto = {
|
|
||||||
id: projectId,
|
|
||||||
nombre: nombre.trim(),
|
|
||||||
descripcion: descripcion.trim() || null,
|
|
||||||
pais: pais.trim(),
|
|
||||||
rendimiento_km_l: null,
|
|
||||||
precio_por_litro: null,
|
|
||||||
propietario_id: ownerId,
|
|
||||||
estado: null,
|
|
||||||
version: 1,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now
|
|
||||||
};
|
|
||||||
const { error } = await supabaseClient
|
|
||||||
.from('rutero_proyectos')
|
|
||||||
.insert({ id: projectId, nombre: project.nombre, descripcion: project.descripcion, pais: project.pais, propietario_id: ownerId, estado: null });
|
|
||||||
if (error) throw error;
|
|
||||||
return project;
|
|
||||||
}
|
|
||||||
|
|
||||||
async pdvs(id: string): Promise<RuteroProyectoPdv[]> {
|
|
||||||
const { data, error } = await supabaseClient.from('rutero_proyecto_pdvs').select('*').eq('proyecto_id', id).order('created_at');
|
|
||||||
if (error) throw error;
|
|
||||||
return (data ?? []) as RuteroProyectoPdv[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async upsertPdvs(projectId: string, rows: Record<string, unknown>[]): Promise<void> {
|
|
||||||
const pdvs = rows.map((pdv, index) => ({
|
|
||||||
proyecto_id: projectId,
|
|
||||||
pdv_id: String(pdv['ID'] ?? pdv['id'] ?? pdv['Id'] ?? index + 1),
|
|
||||||
pdv_data: pdv
|
|
||||||
}));
|
|
||||||
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').upsert(pdvs, { onConflict: 'proyecto_id,pdv_id' });
|
|
||||||
if (error) throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
async addPdv(projectId: string, pdv: Record<string, unknown>): Promise<void> {
|
|
||||||
const pdvId = String(pdv['ID'] ?? '').trim();
|
|
||||||
if (!pdvId) throw new Error('El ID del PDV es obligatorio.');
|
|
||||||
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').insert({ proyecto_id: projectId, pdv_id: pdvId, pdv_data: pdv });
|
|
||||||
if (error) throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updatePdv(id: string, pdv: Record<string, unknown>): Promise<void> {
|
|
||||||
const pdvId = String(pdv['ID'] ?? '').trim();
|
|
||||||
if (!pdvId) throw new Error('El ID del PDV es obligatorio.');
|
|
||||||
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').update({ pdv_id: pdvId, pdv_data: pdv }).eq('id', id);
|
|
||||||
if (error) throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
async removePdv(id: string): Promise<void> {
|
|
||||||
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').delete().eq('id', id);
|
|
||||||
if (error) throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
async projectInput(id: string): Promise<{ file: File; country: string; rows: Record<string, unknown>[] }> {
|
|
||||||
const [project, pdvs] = await Promise.all([this.get(id), this.pdvs(id)]);
|
|
||||||
const rows = pdvs.map(pdv => pdv.pdv_data);
|
|
||||||
const workbook = XLSX.utils.book_new();
|
|
||||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), 'PDVs');
|
|
||||||
const data = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
|
|
||||||
const country = rows.map(row => Object.entries(row).find(([key]) => {
|
|
||||||
const normalized = key.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
||||||
return normalized === 'pais' || normalized === 'country';
|
|
||||||
})?.[1]).find(value => String(value ?? '').trim());
|
|
||||||
return {
|
|
||||||
file: new File([data], 'pdvs_proyecto.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
|
||||||
country: String(country ?? project.pais ?? 'Republica Dominicana').trim(),
|
|
||||||
rows
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async pdvFile(id: string): Promise<File> {
|
|
||||||
return (await this.projectInput(id)).file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,7 @@ export class RutasService {
|
|||||||
timeout(environment.requestTimeoutMs),
|
timeout(environment.requestTimeoutMs),
|
||||||
catchError((err: HttpErrorResponse | Error) => {
|
catchError((err: HttpErrorResponse | Error) => {
|
||||||
if (err instanceof Error && err.name === 'TimeoutError') {
|
if (err instanceof Error && err.name === 'TimeoutError') {
|
||||||
return throwError(() => Object.assign(new Error(`Timeout al calcular rutas (${environment.requestTimeoutMs / 1000}s). Intenta con menos filas o reintenta.`), { name: 'TimeoutError' }));
|
return throwError(() => Object.assign(new Error('Timeout al calcular rutas (30s). Intenta con menos filas o reintenta.'), { name: 'TimeoutError' }));
|
||||||
}
|
}
|
||||||
return throwError(() => err);
|
return throwError(() => err);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
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 }
|
|
||||||
});
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
.employees-topbar { display: flex; align-items: center; gap: 12px; padding: 9px 24px; border-bottom: 3px solid var(--glm-verde); background: #fff; color: var(--glm-azul); font-weight: 700; }
|
|
||||||
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
|
|
||||||
.employees-topbar img { height: 38px; width: auto; }
|
|
||||||
.topbar-actions { display: flex; gap: 8px; margin-left: auto; }
|
|
||||||
.topbar-actions button, .table-header button, .form-actions button, .row-actions button { border: 1px solid var(--glm-gris-medio); border-radius: 4px; background: #fff; color: var(--glm-azul); cursor: pointer; font: inherit; font-size: .78rem; font-weight: 600; padding: 7px 10px; }
|
|
||||||
.topbar-actions button:hover, .table-header button:hover, .form-actions button:hover, .row-actions button:hover { background: var(--glm-verde-bg); border-color: var(--glm-verde); }
|
|
||||||
.employees-page { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
|
||||||
.employees-page header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; border-bottom: 3px solid var(--glm-verde); padding-bottom: 12px; }
|
|
||||||
.page-back-button { display:inline-flex; align-items:center; gap:6px; margin-bottom:18px; padding:8px 11px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:700 .82rem Arial,sans-serif; }.page-back-button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }
|
|
||||||
.employees-page h1, .employees-page h2 { margin: 0; color: var(--glm-azul); }
|
|
||||||
.employees-page h1 { font-size: 1.45rem; }.employees-page h2 { font-size: 1rem; }
|
|
||||||
.employees-page header p, .weekly-capacity { color: var(--glm-acero); }
|
|
||||||
.import-button { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 1px solid var(--glm-verde); border-radius: 4px; background: var(--glm-verde-bg); color: var(--glm-azul); cursor: pointer; font-size: .82rem; font-weight: 700; }.import-button input { display: none; }.import-message { color: var(--glm-verde); font-weight: 600; }
|
|
||||||
.employee-form-card, .employee-table-card { margin-top: 16px; padding: 18px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: #fff; }
|
|
||||||
.employee-form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 16px; }
|
|
||||||
.employee-form-grid label { display: flex; flex-direction: column; gap: 4px; color: var(--glm-azul); font-size: .75rem; font-weight: 700; text-transform: uppercase; }
|
|
||||||
.employee-form-grid input, .employee-form-grid select { padding: 8px; border: 1px solid #9aaab3; border-radius: 4px; background: #fff; color: #2f3f48; font: inherit; font-weight: 500; text-transform: none; }
|
|
||||||
.employee-form-grid input::placeholder { color: #71808a; opacity: 1; }
|
|
||||||
.employee-form-grid input:focus, .employee-form-grid select:focus { outline: 2px solid rgba(108, 194, 74, .35); border-color: var(--glm-verde); background: #fff; color: #1f2d34; }
|
|
||||||
.form-actions { display: flex; gap: 8px; }.form-actions button:first-child { background: var(--glm-azul); color: #fff; }.form-error { color: #c9252d; font-size: .82rem; }.table-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }.table-wrap { overflow-x: auto; margin-top: 12px; }table { width: 100%; min-width: 920px; border-collapse: collapse; font-size: .82rem; }th { padding: 9px; background: var(--glm-azul); color: #fff; text-align: left; }td { padding: 9px; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); }.status { color: var(--glm-verde); font-weight: 700; }.status.inactive { color: #c9252d; }.row-actions { display: flex; gap: 5px; }.row-actions .delete { color: #c9252d; }.loading { color: var(--glm-acero); }.rest-days-button { max-width: 155px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.rest-days-picker { display: grid; gap: 4px; margin-top: 6px; padding: 6px; border: 1px solid var(--glm-gris-medio); background: #fff; }.rest-days-picker label { font-size: .72rem; white-space: nowrap; }
|
|
||||||
.editing-row { background: var(--glm-verde-bg); }.editing-row select, .inline-number { width: 100%; min-width: 92px; box-sizing: border-box; padding: 7px; border: 1px solid var(--glm-verde); border-radius: 4px; background: #fff; color: var(--glm-gris-oscuro); font-size: .85rem; font-weight: 600; }.inline-number { max-width: 100px; }.inline-picker { min-width: 130px; margin: 0; }.editing-row .row-actions { flex-wrap: wrap; }
|
|
||||||
.table-header > div { display:flex; align-items:center; gap:8px; }.selected-count { color:var(--glm-azul); font-size:.78rem; font-weight:700; }.select-column { width:38px; }.bulk-actions { position:relative; z-index:30; display:flex; flex-wrap:wrap; align-items:end; gap:10px; margin:12px 0; padding:12px; border:1px solid var(--glm-verde-claro); background:var(--glm-verde-bg); }.bulk-actions > label,.bulk-actions fieldset { display:grid; gap:4px; margin:0; color:var(--glm-azul); font-size:.72rem; font-weight:700; text-transform:uppercase; }.bulk-actions input,.bulk-actions select { min-height:32px; padding:5px 7px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-gris-oscuro); font-size:.85rem; font-weight:600; text-transform:none; }.bulk-actions select { min-width:200px; }.bulk-actions select option,.employee-form-grid select option,.editing-row select option { color:var(--glm-gris-oscuro); background:#fff; }.bulk-actions fieldset { grid-template-columns:repeat(2,max-content); gap:5px 8px; padding:6px 8px; text-transform:none; }.bulk-actions legend { padding:0 3px; text-transform:uppercase; }.bulk-actions fieldset label { font-size:.72rem; font-weight:400; }.bulk-actions fieldset input { min-height:0; padding:0; }.bulk-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:7px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.bulk-actions button:hover:not(:disabled) { background:#fff; border-color:var(--glm-verde); }.bulk-actions button.delete { color:#c9252d; }
|
|
||||||
@media (max-width: 760px) { .employees-page { padding: 16px; }.employee-form-grid { grid-template-columns: 1fr; }.employees-topbar { padding: 9px 16px; }.topbar-actions button { font-size: 0; }.topbar-actions button i { font-size: .9rem; } }
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-26
@@ -6,7 +6,7 @@
|
|||||||
.detail-sidebar {
|
.detail-sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 340px;
|
width: 340px;
|
||||||
z-index: 900;
|
z-index: 900;
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--glm-surface);
|
background: var(--glm-surface);
|
||||||
border-right: 3px solid var(--glm-verde);
|
border-left: 3px solid var(--glm-verde);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-brand {
|
.sidebar-brand {
|
||||||
@@ -61,24 +61,6 @@
|
|||||||
color: var(--glm-gris-oscuro);
|
color: var(--glm-gris-oscuro);
|
||||||
}
|
}
|
||||||
|
|
||||||
.maps-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid var(--glm-verde);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--glm-verde-bg);
|
|
||||||
color: var(--glm-azul);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.maps-btn:hover { background: var(--glm-verde); color: #fff; }
|
|
||||||
|
|
||||||
.detail-title {
|
.detail-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -224,12 +206,6 @@
|
|||||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
|
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.42));
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-pin:hover {
|
|
||||||
transform: scale(1.12);
|
|
||||||
transform-origin: center bottom;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.location-pin::before {
|
.location-pin::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
+6
-2
@@ -18,6 +18,7 @@
|
|||||||
<span class="badge">{{ routeData.json.dia }}</span>
|
<span class="badge">{{ routeData.json.dia }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="info-card">
|
<section class="info-card">
|
||||||
<h3>Información de la ruta</h3>
|
<h3>Información de la ruta</h3>
|
||||||
<dl class="info-list">
|
<dl class="info-list">
|
||||||
@@ -33,7 +34,10 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="info-card">
|
<section class="info-card">
|
||||||
<h3>PDV destino</h3>
|
<h3>Coordenadas</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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+10
-61
@@ -5,11 +5,8 @@ import { Subscription } from 'rxjs';
|
|||||||
import * as L from 'leaflet';
|
import * as L from 'leaflet';
|
||||||
import { RouteState } from '../../../core/route-state/route';
|
import { RouteState } from '../../../core/route-state/route';
|
||||||
import { OrsService } from '../../../core/services/ors.service';
|
import { OrsService } from '../../../core/services/ors.service';
|
||||||
import { ProjectService } from '../../../core/services/project.service';
|
|
||||||
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
||||||
import { centerForCountry } from '../../../shared/utils/country-center.utils';
|
|
||||||
import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils';
|
import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||||
import { colorForUsuario } from '../../../shared/utils/palette.utils';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -34,20 +31,11 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
visitOrder = 0;
|
visitOrder = 0;
|
||||||
totalStops = 0;
|
totalStops = 0;
|
||||||
|
|
||||||
get googleMapsUrl(): string | null {
|
|
||||||
if (!this.routeData) return null;
|
|
||||||
const route = this.routeData.json;
|
|
||||||
const origin = `${route.latitud_usuario},${route.longitud_usuario}`;
|
|
||||||
const destination = `${route.latitud_pdv},${route.longitud_pdv}`;
|
|
||||||
return `https://www.google.com/maps/dir/?api=1&origin=${encodeURIComponent(origin)}&destination=${encodeURIComponent(destination)}&travelmode=driving`;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
private routeState: RouteState,
|
private routeState: RouteState,
|
||||||
private orsService: OrsService,
|
private orsService: OrsService,
|
||||||
private projects: ProjectService,
|
|
||||||
private cdr: ChangeDetectorRef
|
private cdr: ChangeDetectorRef
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -64,9 +52,8 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
const qp = this.activatedRoute.snapshot.queryParams;
|
const qp = this.activatedRoute.snapshot.queryParams;
|
||||||
const pdvId = qp['pdv_id'];
|
const pdvId = qp['pdv_id'];
|
||||||
const usuarioId = qp['usuario_id'];
|
const usuarioId = qp['usuario_id'];
|
||||||
const day = qp['dia'];
|
|
||||||
if (pdvId) {
|
if (pdvId) {
|
||||||
const found = this.routeState.findRoute(pdvId, usuarioId, day);
|
const found = this.routeState.findRoute(pdvId, usuarioId);
|
||||||
if (found) {
|
if (found) {
|
||||||
this.routeData = found;
|
this.routeData = found;
|
||||||
if (this.map) this.loadAndShowRoute(found);
|
if (this.map) this.loadAndShowRoute(found);
|
||||||
@@ -91,7 +78,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
// Re-attempt resolution after map init (queryParams case)
|
// Re-attempt resolution after map init (queryParams case)
|
||||||
const qp = this.activatedRoute.snapshot.queryParams;
|
const qp = this.activatedRoute.snapshot.queryParams;
|
||||||
if (qp['pdv_id']) {
|
if (qp['pdv_id']) {
|
||||||
const found = this.routeState.findRoute(qp['pdv_id'], qp['usuario_id'], qp['dia']);
|
const found = this.routeState.findRoute(qp['pdv_id'], qp['usuario_id']);
|
||||||
if (found) {
|
if (found) {
|
||||||
this.routeData = found;
|
this.routeData = found;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
@@ -112,35 +99,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
}
|
}
|
||||||
|
|
||||||
private initMap() {
|
private initMap() {
|
||||||
const territoryBounds = this.routeData ? L.latLngBounds([
|
this.map = L.map('map').setView([18.4861, -69.9312], 13);
|
||||||
[Number(this.routeData.json.latitud_usuario), Number(this.routeData.json.longitud_usuario)],
|
|
||||||
[Number(this.routeData.json.latitud_pdv), Number(this.routeData.json.longitud_pdv)]
|
|
||||||
]) : null;
|
|
||||||
this.map = L.map('map', { maxBoundsViscosity: 0.85 });
|
|
||||||
if (territoryBounds?.isValid()) {
|
|
||||||
this.fitRouteBounds(territoryBounds);
|
|
||||||
} else {
|
|
||||||
const projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? '';
|
|
||||||
void this.projects.get(projectId).then(project => {
|
|
||||||
const center = centerForCountry(project?.pais ?? '');
|
|
||||||
this.map.setView([center.lat, center.lng], 10);
|
|
||||||
}).catch(() => {
|
|
||||||
this.map.setView([19.4326, -99.1332], 5);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const pdvPane = this.map.createPane('pdvPane');
|
const pdvPane = this.map.createPane('pdvPane');
|
||||||
pdvPane.style.zIndex = '650';
|
pdvPane.style.zIndex = '650';
|
||||||
|
|
||||||
this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '© OpenStreetMap contributors',
|
attribution: '© OpenStreetMap contributors'
|
||||||
keepBuffer: 1,
|
|
||||||
noWrap: true,
|
|
||||||
updateWhenIdle: true
|
|
||||||
}).addTo(this.map);
|
}).addTo(this.map);
|
||||||
setTimeout(() => {
|
|
||||||
this.map.invalidateSize();
|
|
||||||
if (territoryBounds?.isValid()) this.fitRouteBounds(territoryBounds);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadAndShowRoute(route: RouteAssignedResponse) {
|
private async loadAndShowRoute(route: RouteAssignedResponse) {
|
||||||
@@ -162,13 +127,6 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
|
|
||||||
this.centerMapOnPoints(origin, destination);
|
this.centerMapOnPoints(origin, destination);
|
||||||
|
|
||||||
if (json.geojson) {
|
|
||||||
this.showRouteOnMap(json.geojson, origin, destination, json, this.segmentOriginName);
|
|
||||||
this.isLoading = false;
|
|
||||||
this.cdr.detectChanges();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await this.orsService.processPairs(
|
const results = await this.orsService.processPairs(
|
||||||
[{ pdv_id: json.pdv_id, origin, destination }],
|
[{ pdv_id: json.pdv_id, origin, destination }],
|
||||||
@@ -197,15 +155,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
L.latLng(origin[1], origin[0]),
|
L.latLng(origin[1], origin[0]),
|
||||||
L.latLng(destination[1], destination[0])
|
L.latLng(destination[1], destination[0])
|
||||||
]);
|
]);
|
||||||
this.fitRouteBounds(bounds);
|
this.map.fitBounds(bounds.pad(0.3));
|
||||||
}
|
|
||||||
|
|
||||||
private fitRouteBounds(bounds: L.LatLngBounds): void {
|
|
||||||
this.map.fitBounds(bounds.pad(0.3), {
|
|
||||||
paddingTopLeft: L.point(380, 30),
|
|
||||||
paddingBottomRight: L.point(30, 30),
|
|
||||||
maxZoom: 15
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractCoords(geojson: unknown): L.LatLngExpression[] {
|
private extractCoords(geojson: unknown): L.LatLngExpression[] {
|
||||||
@@ -298,12 +248,11 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
duration: string,
|
duration: string,
|
||||||
originName: string
|
originName: string
|
||||||
) {
|
) {
|
||||||
const startColor = colorForUsuario(json.usuario_id).color;
|
|
||||||
const startIcon = L.divIcon({
|
const startIcon = L.divIcon({
|
||||||
className: 'custom-marker marker-start',
|
className: 'custom-marker marker-start',
|
||||||
html: `<div class="marker-inner" style="background:${startColor};border:3px solid #fff;">A</div>`,
|
html: '<div class="marker-inner">A</div>',
|
||||||
iconSize: [34, 34],
|
iconSize: [30, 30],
|
||||||
iconAnchor: [17, 17],
|
iconAnchor: [15, 15],
|
||||||
popupAnchor: [0, -18]
|
popupAnchor: [0, -18]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -321,7 +270,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
.bindPopup(`<strong>Origen del tramo:</strong> ${originName}`),
|
.bindPopup(`<strong>Origen del tramo:</strong> ${originName}`),
|
||||||
L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
L.marker([destination[1], destination[0]], { icon: endIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
||||||
.addTo(this.map)
|
.addTo(this.map)
|
||||||
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Usuario:</strong> ${json.nombre_usuario}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
|
.bindPopup(`<strong>Destino:</strong> ${json.nombre_pdv}<br><strong>Distancia:</strong> ${distance} km<br><strong>Duración:</strong> ${duration} min`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +292,6 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
}
|
}
|
||||||
|
|
||||||
goBack() {
|
goBack() {
|
||||||
this.router.navigate(['/proyectos', this.activatedRoute.snapshot.paramMap.get('id')]);
|
this.router.navigate(['/home']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
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,125 +31,18 @@
|
|||||||
color: var(--glm-verde);
|
color: var(--glm-verde);
|
||||||
}
|
}
|
||||||
|
|
||||||
.projects-link { margin-left: auto; color: var(--glm-azul); font-size: .82rem; text-decoration: none; }
|
|
||||||
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
|
|
||||||
.projects-link button,.home-employees-link,.edit-project,.project-edit-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:inherit; font-weight:700; padding:8px 11px; text-decoration:none; }.home-employees-link { display:inline-block; margin-bottom:12px; }.project-heading { width:100%; }.project-title-line { display:flex; align-items:center; gap:14px; }.project-title-line h1 { margin:0; }.back-projects { color:var(--glm-azul); font-size:.85rem; font-weight:700; text-decoration:none; white-space:nowrap; }.edit-project,.project-edit-actions { margin-left:auto; }.project-title-line input,.project-heading textarea { border:1px solid #9aaab3; border-radius:4px; background:#fff; color:#2f3f48; font:inherit; padding:8px; }.project-heading textarea { display:block; width:100%; margin-top:10px; resize:vertical; }.project-edit-actions { display:flex; gap:6px; }.project-edit-actions button:first-child { background:var(--glm-azul); color:#fff; }
|
|
||||||
|
|
||||||
.home-container {
|
.home-container {
|
||||||
max-width: 1600px;
|
max-width: 1000px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px 24px 32px;
|
padding: 24px;
|
||||||
}
|
|
||||||
|
|
||||||
.planning-toolbar-card {
|
|
||||||
padding: 16px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.planning-toolbar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-summary {
|
|
||||||
margin: 0;
|
|
||||||
flex: 1 1 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.planning-toolbar-card .upload-error {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fuel-config { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
|
|
||||||
.fuel-config label { display: grid; gap: 5px; color: var(--glm-azul); font-size: .75rem; font-weight: 700; text-transform: uppercase; }
|
|
||||||
.fuel-config input { border: 1px solid #9aaab3; border-radius: 4px; background: #fff; color: #2f3f48; font: inherit; padding: 8px; width: 180px; }
|
|
||||||
.fuel-summary { margin: 4px 0 0; color: var(--glm-acero); font-size: .82rem; }
|
|
||||||
|
|
||||||
.dashboard-map-card {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-map-header {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 4px 16px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-map-header .section-title {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-map-hint {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--glm-acero);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-map-panel {
|
|
||||||
position: relative;
|
|
||||||
min-height: 480px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-map-panel app-route-overview-map {
|
|
||||||
display: block;
|
|
||||||
position: relative;
|
|
||||||
height: 620px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
.home-container {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
.dashboard-map-panel app-route-overview-map {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-header {
|
.home-header {
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
border-bottom: 3px solid var(--glm-verde);
|
border-bottom: 3px solid var(--glm-verde);
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-link { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 1px solid var(--glm-verde); border-radius: 4px; background: var(--glm-verde-bg); color: var(--glm-azul); font-size: .82rem; font-weight: 700; text-decoration: none; white-space: nowrap; }
|
|
||||||
.employees-link:hover { background: var(--glm-verde); color: #fff; }
|
|
||||||
.map-overview-link { margin: -8px 0 16px; }
|
|
||||||
.project-back-link { display:inline-flex; align-items:center; gap:6px; margin-bottom:18px; }
|
|
||||||
.project-management-links { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:18px; }
|
|
||||||
.project-management-links .home-employees-link { margin-bottom:0; }
|
|
||||||
.dashboard-section-toggle { display:flex; width:100%; align-items:center; justify-content:space-between; gap:12px; padding:0 0 12px; border:0; background:transparent; color:var(--glm-azul); cursor:pointer; font:inherit; font-weight:700; text-align:left; text-transform:uppercase; }
|
|
||||||
.dashboard-section-toggle span { display:inline-flex; align-items:center; gap:7px; }
|
|
||||||
.planning-filters-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:10px; align-items:end; min-width:0; }
|
|
||||||
.planning-filters-grid label { display:grid; min-width:0; gap:5px; color:var(--glm-azul); font-size:.72rem; font-weight:700; text-transform:uppercase; }
|
|
||||||
.planning-filters-grid select { display:block; width:100%; min-width:0; box-sizing:border-box; padding:8px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-gris-oscuro); }
|
|
||||||
.planning-filters-grid .btn-secondary { grid-column:1; width:100%; margin-top:5px; }
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.planning-filters-grid { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
|
||||||
.planning-filters-grid .btn-secondary { grid-column:auto; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.planning-filters-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-header h1 {
|
.home-header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--glm-azul);
|
color: var(--glm-azul);
|
||||||
@@ -181,7 +74,100 @@
|
|||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employees-summary { display: flex; align-items: center; gap: 7px; margin: -2px 0 16px; color: var(--glm-acero); font-size: .88rem; }.employees-summary i { color: var(--glm-verde); }.employees-summary.empty, .employees-summary.empty i { color: #c9252d; }
|
/* Upload card */
|
||||||
|
.upload-card {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.upload-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 2px dashed var(--glm-gris-medio);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--glm-verde-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label:hover {
|
||||||
|
border-color: var(--glm-verde);
|
||||||
|
background: #e5f5dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label:has(.file-input:focus-visible) {
|
||||||
|
border-color: var(--glm-verde);
|
||||||
|
box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label i {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--glm-verde);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label span:first-of-type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--glm-azul);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--glm-acero);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
position: absolute;
|
||||||
|
width: 0.1px;
|
||||||
|
height: 0.1px;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--glm-verde-bg);
|
||||||
|
border: 1px solid var(--glm-verde-claro);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--glm-gris-oscuro);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name i {
|
||||||
|
color: var(--glm-verde);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--glm-gris-medio);
|
||||||
|
}
|
||||||
|
|
||||||
.upload-error {
|
.upload-error {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -247,6 +233,89 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-label.has-error {
|
||||||
|
border-color: #F5A6A0;
|
||||||
|
background: var(--glm-rojo-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label.has-success {
|
||||||
|
border-color: var(--glm-verde);
|
||||||
|
background: var(--glm-verde-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--glm-gris-medio);
|
||||||
|
background: var(--glm-surface);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-success {
|
||||||
|
border-color: var(--glm-verde-claro);
|
||||||
|
background: var(--glm-verde-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-success .preview-header {
|
||||||
|
color: var(--glm-verde);
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-error {
|
||||||
|
border-color: #F5A6A0;
|
||||||
|
background: var(--glm-rojo-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-error .preview-header {
|
||||||
|
color: #D32F2F;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stats {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--glm-acero);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-errors {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 0 0 0 18px;
|
||||||
|
color: var(--glm-gris-oscuro);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-errors li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-value {
|
||||||
|
color: var(--glm-acero);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-errors {
|
||||||
|
color: var(--glm-acero);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-loading {
|
||||||
|
color: var(--glm-acero);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-loading-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--glm-acero);
|
||||||
|
}
|
||||||
|
|
||||||
.params-card {
|
.params-card {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
|||||||
@@ -1,30 +1,122 @@
|
|||||||
<div class="glm-topbar">
|
<div class="glm-topbar">
|
||||||
<img src="GLM_completo.png" alt="GomezLee Marketing" class="glm-topbar-logo" />
|
<img src="GLM_completo.png" alt="GomezLee Marketing" class="glm-topbar-logo" />
|
||||||
<span class="glm-topbar-sep">|</span>
|
<span class="glm-topbar-sep">|</span>
|
||||||
<span class="glm-topbar-context">Route Planner</span><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>
|
<span class="glm-topbar-context">Route Planner</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="home-container">
|
<div class="home-container">
|
||||||
<a class="back-projects project-back-link" routerLink="/home"><i class="pi pi-arrow-left"></i> Mis proyectos</a>
|
|
||||||
<div class="project-management-links"><a class="employees-link home-employees-link" [routerLink]="['/proyectos', projectId, 'empleados']"><i class="pi pi-users"></i> Gestionar empleados</a><a class="employees-link home-employees-link" [routerLink]="['/proyectos', projectId, 'pdvs']"><i class="pi pi-map-marker"></i> Gestionar PDVs</a></div>
|
|
||||||
<header class="home-header">
|
<header class="home-header">
|
||||||
<div class="project-heading"><div class="project-title-line"><h1 *ngIf="!editingProject">{{ projectName || 'Proyecto' }}</h1><input *ngIf="editingProject" [(ngModel)]="projectName" maxlength="120"><button *ngIf="!editingProject" type="button" class="edit-project" (click)="startProjectEdit()"><i class="pi pi-pencil"></i> Editar proyecto</button><div *ngIf="editingProject" class="project-edit-actions"><button type="button" (click)="saveProject()" [disabled]="projectSaving">Guardar</button><button type="button" (click)="cancelProjectEdit()" [disabled]="projectSaving">Cancelar</button></div></div><textarea *ngIf="editingProject" [(ngModel)]="projectDescription" maxlength="500" placeholder="Descripción del proyecto"></textarea><div class="fuel-config" *ngIf="editingProject"><label>Rendimiento (km/L)<input type="number" min="0.1" step="0.1" [(ngModel)]="fuelRendimiento" placeholder="Ej. 12"></label><label>Precio por litro<input type="number" min="0" step="0.01" [(ngModel)]="fuelPrecio" placeholder="Ej. 24"></label></div><p *ngIf="!editingProject">Gestión de rutas asignadas y puntos de venta pendientes</p><p class="fuel-summary" *ngIf="!editingProject && (fuelRendimiento || fuelPrecio)">Combustible: {{ fuelRendimiento ?? '—' }} km/L · {{ fuelPrecio ?? '—' }}/L</p></div>
|
<h1>Route Planner</h1>
|
||||||
|
<p>Gestión de rutas asignadas y puntos de venta pendientes</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card planning-toolbar-card">
|
<section class="card upload-card">
|
||||||
<div class="planning-toolbar">
|
<h2 class="section-title">Subir archivos para optimización</h2>
|
||||||
<p class="employees-summary toolbar-summary" [class.empty]="!employeesLoading && !activeEmployees"><i class="pi pi-users"></i> <span *ngIf="employeesLoading">Cargando empleados activos...</span><span *ngIf="!employeesLoading">{{ activeEmployees }} empleados activos de este proyecto se usarán para planificar.</span></p>
|
|
||||||
<div class="actions-row toolbar-actions">
|
<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>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pdvFile"
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
(change)="onPdvFileChange($event)"
|
||||||
|
class="file-input"
|
||||||
|
[class.has-file]="pdvFile"
|
||||||
|
/>
|
||||||
|
<div class="file-name" *ngIf="pdvFile">
|
||||||
|
<i class="pi" [class.pi-check-circle]="pdvPreview?.valid" [class.pi-exclamation-circle]="pdvPreview && !pdvPreview.valid" [class.pi-spin]="pdvPreviewLoading"></i>
|
||||||
|
{{ pdvFile.name }}
|
||||||
|
<span *ngIf="pdvPreviewLoading" class="preview-loading">Validando...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-card" *ngIf="pdvPreviewLoading">
|
||||||
|
<div class="preview-loading-row"><span class="spinner-inline"></span> Analizando archivo PDV...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-card preview-success" *ngIf="pdvPreview?.valid">
|
||||||
|
<div class="preview-header"><i class="pi pi-check-circle"></i> PDV válido — {{ pdvPreview!.totalRows }} filas</div>
|
||||||
|
<div class="preview-stats">{{ pdvPreview!.validRows }} filas válidas • Columnas: {{ pdvPreview!.headers.join(', ') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-card preview-error" *ngIf="pdvPreview && !pdvPreview.valid">
|
||||||
|
<div class="preview-header"><i class="pi pi-exclamation-triangle"></i> Errores en PDV ({{ pdvPreview.errors.length }})</div>
|
||||||
|
<div class="preview-stats" *ngIf="pdvPreview.missingHeaders.length">Faltan: {{ pdvPreview.missingHeaders.join(', ') }}</div>
|
||||||
|
<div class="preview-stats">{{ pdvPreview.totalRows }} filas leídas • {{ pdvPreview.validRows }} válidas</div>
|
||||||
|
<ul class="preview-errors">
|
||||||
|
<li *ngFor="let e of pdvPreview.errors.slice(0, 8)">
|
||||||
|
<strong>Fila {{ e.row }} — {{ e.field }}:</strong> {{ e.message }}
|
||||||
|
<span *ngIf="e.value !== undefined && e.value !== null" class="error-value">({{ e.value }})</span>
|
||||||
|
</li>
|
||||||
|
<li *ngIf="pdvPreview.errors.length > 8" class="more-errors">+ {{ pdvPreview.errors.length - 8 }} errores más...</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-input-wrapper">
|
||||||
|
<label class="file-label" for="usuariosFile" [class.has-error]="usuariosPreview && !usuariosPreview.valid" [class.has-success]="usuariosPreview?.valid">
|
||||||
|
<i class="pi pi-file-excel"></i>
|
||||||
|
<span>Archivo Usuarios (Excel)</span>
|
||||||
|
<span class="file-hint">.xlsx / .xls • máx 10MB • máx 50 filas</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="usuariosFile"
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
(change)="onUsuariosFileChange($event)"
|
||||||
|
class="file-input"
|
||||||
|
[class.has-file]="usuariosFile"
|
||||||
|
/>
|
||||||
|
<div class="file-name" *ngIf="usuariosFile">
|
||||||
|
<i class="pi" [class.pi-check-circle]="usuariosPreview?.valid" [class.pi-exclamation-circle]="usuariosPreview && !usuariosPreview.valid"></i>
|
||||||
|
{{ usuariosFile.name }}
|
||||||
|
<span *ngIf="usuariosPreviewLoading" class="preview-loading">Validando...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-card" *ngIf="usuariosPreviewLoading">
|
||||||
|
<div class="preview-loading-row"><span class="spinner-inline"></span> Analizando archivo Usuarios...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-card preview-success" *ngIf="usuariosPreview?.valid">
|
||||||
|
<div class="preview-header"><i class="pi pi-check-circle"></i> Usuarios válido — {{ usuariosPreview!.totalRows }} filas</div>
|
||||||
|
<div class="preview-stats">{{ usuariosPreview!.validRows }} filas válidas • Columnas: {{ usuariosPreview!.headers.join(', ') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-card preview-error" *ngIf="usuariosPreview && !usuariosPreview.valid">
|
||||||
|
<div class="preview-header"><i class="pi pi-exclamation-triangle"></i> Errores en Usuarios ({{ usuariosPreview.errors.length }})</div>
|
||||||
|
<div class="preview-stats" *ngIf="usuariosPreview.missingHeaders.length">Faltan: {{ usuariosPreview.missingHeaders.join(', ') }}</div>
|
||||||
|
<div class="preview-stats">{{ usuariosPreview.totalRows }} filas leídas • {{ usuariosPreview.validRows }} válidas</div>
|
||||||
|
<ul class="preview-errors">
|
||||||
|
<li *ngFor="let e of usuariosPreview.errors.slice(0, 8)">
|
||||||
|
<strong>Fila {{ e.row }} — {{ e.field }}:</strong> {{ e.message }}
|
||||||
|
<span *ngIf="e.value !== undefined && e.value !== null" class="error-value">({{ e.value }})</span>
|
||||||
|
</li>
|
||||||
|
<li *ngIf="usuariosPreview.errors.length > 8" class="more-errors">+ {{ usuariosPreview.errors.length - 8 }} errores más...</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-actions">
|
||||||
|
<div class="upload-error" *ngIf="uploadError">
|
||||||
|
<i class="pi pi-exclamation-triangle"></i>
|
||||||
|
{{ uploadError }}
|
||||||
|
</div>
|
||||||
|
<div class="actions-row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
(click)="cargarRutas()"
|
(click)="cargarRutas()"
|
||||||
[disabled]="submitDisabled"
|
[disabled]="!canSubmit"
|
||||||
title="Simular rutas"
|
[title]="submitDisabledReason || ''"
|
||||||
>
|
>
|
||||||
<span *ngIf="isLoading" class="spinner-inline"></span>
|
<span *ngIf="isLoading" class="spinner-inline"></span>
|
||||||
<i *ngIf="!isLoading" class="pi pi-upload"></i>
|
<i *ngIf="!isLoading" class="pi pi-upload"></i>
|
||||||
{{ isLoading ? 'Simulando...' : 'Simular rutas' }}
|
{{ isLoading ? 'Procesando...' : 'Cargar rutas' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -38,40 +130,25 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
(click)="clearFiles()"
|
(click)="clearFiles()"
|
||||||
[disabled]="!pdvFile"
|
[disabled]="!pdvFile && !usuariosFile"
|
||||||
>
|
>
|
||||||
<i class="pi pi-times"></i> Limpiar
|
<i class="pi pi-times"></i> Limpiar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<small class="hint-text" *ngIf="submitDisabledReason && !isLoading">{{ submitDisabledReason }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-error" *ngIf="uploadError">
|
|
||||||
<i class="pi pi-exclamation-triangle"></i>
|
|
||||||
{{ uploadError }}
|
|
||||||
</div>
|
|
||||||
<small class="hint-text" *ngIf="submitHint && !isLoading">{{ submitHint }}</small>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card planning-filters-card">
|
|
||||||
<button type="button" class="dashboard-section-toggle" (click)="toggleFilters()"><span><i class="pi pi-filter"></i> Filtros del dashboard</span><i class="pi" [class.pi-chevron-down]="filtersExpanded" [class.pi-chevron-right]="!filtersExpanded"></i></button>
|
|
||||||
<div class="planning-filters-grid" *ngIf="filtersExpanded"><label>Cobertura<select [(ngModel)]="planningFilters.cobertura" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('cobertura')" [value]="value">{{ value }}</option></select></label><label>Zona<select [(ngModel)]="planningFilters.zona" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('zona')" [value]="value">{{ value }}</option></select></label><label>Región<select [(ngModel)]="planningFilters.region" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('region')" [value]="value">{{ value }}</option></select></label><label>Estado<select [(ngModel)]="planningFilters.estado" (ngModelChange)="applyPlanningFilters()"><option value="">Todos</option><option *ngFor="let value of filterOptions('estado')" [value]="value">{{ value }}</option></select></label><label>Ciudad<select [(ngModel)]="planningFilters.ciudad" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('ciudad')" [value]="value">{{ value }}</option></select></label><button type="button" class="btn-secondary" (click)="clearPlanningFilters()"><i class="pi pi-filter-slash"></i> Limpiar</button></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card kpis-card">
|
<section class="card kpis-card">
|
||||||
<app-planning-kpis></app-planning-kpis>
|
<app-planning-kpis></app-planning-kpis>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card dashboard-map-card">
|
<section class="card">
|
||||||
<div class="dashboard-map-header">
|
<app-assigned-route></app-assigned-route>
|
||||||
<h2 class="section-title"><i class="pi pi-map"></i> Mapa general y detalle de ruta</h2>
|
|
||||||
<p class="dashboard-map-hint">Selecciona una ruta en el mapa o en el balance para ver su detalle sin cambiar de pantalla.</p>
|
|
||||||
</div>
|
|
||||||
<div class="dashboard-map-panel">
|
|
||||||
<app-route-overview-map [embedded]="true" [country]="projectCountry"></app-route-overview-map>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<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>
|
<h2 class="section-title">PDVs sin ruta asignada</h2>
|
||||||
<app-no-assigned-route *ngIf="unassignedExpanded"></app-no-assigned-route>
|
<app-no-assigned-route></app-no-assigned-route>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, ChangeDetectorRef, OnInit } from '@angular/core';
|
import { Component, ChangeDetectorRef } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { AssignedRouteComponent } from './assigned-route/assigned-route.component';
|
||||||
import { NoAssignedRouteComponent } from './no-assigned-route/no-assigned-route.component';
|
import { NoAssignedRouteComponent } from './no-assigned-route/no-assigned-route.component';
|
||||||
import { RutasService } from '../../core/services/router.service';
|
import { RutasService } from '../../core/services/router.service';
|
||||||
import { RouteState } from '../../core/route-state/route';
|
import { RouteState } from '../../core/route-state/route';
|
||||||
@@ -8,177 +9,146 @@ import { ApiResponse } from '../../core/models/route.model';
|
|||||||
import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service';
|
import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component';
|
import { PlanningKpisComponent } from './planning-kpis/planning-kpis.component';
|
||||||
import { RouteOverviewMapComponent } from './route-overview-map/route-overview-map.component';
|
|
||||||
import { PlanningInputService } from '../../core/services/planning-input.service';
|
import { PlanningInputService } from '../../core/services/planning-input.service';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
|
||||||
import { EmployeeService } from '../../core/services/employee.service';
|
|
||||||
import { ProjectService } from '../../core/services/project.service';
|
|
||||||
import { AuthService } from '../../core/services/auth.service';
|
|
||||||
import { UserProfileService } from '../../core/services/user-profile.service';
|
|
||||||
import { PlanningFilterKey, PlanningFilters, PlanningFilterService } from '../../core/services/planning-filter.service';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
imports: [CommonModule, FormsModule, RouterLink, NoAssignedRouteComponent, PlanningKpisComponent, RouteOverviewMapComponent],
|
imports: [CommonModule, FormsModule, AssignedRouteComponent, NoAssignedRouteComponent, PlanningKpisComponent],
|
||||||
templateUrl: './home.component.html',
|
templateUrl: './home.component.html',
|
||||||
styleUrls: ['./home.component.css']
|
styleUrls: ['./home.component.css']
|
||||||
})
|
})
|
||||||
export class HomeComponent implements OnInit {
|
export class HomeComponent {
|
||||||
pdvFile: File | null = null;
|
pdvFile: File | null = null;
|
||||||
|
usuariosFile: File | null = null;
|
||||||
pdvPreview: PreviewResult | null = null;
|
pdvPreview: PreviewResult | null = null;
|
||||||
|
usuariosPreview: PreviewResult | null = null;
|
||||||
pdvPreviewLoading = false;
|
pdvPreviewLoading = false;
|
||||||
activeEmployees = 0;
|
usuariosPreviewLoading = false;
|
||||||
employeesLoading = true;
|
|
||||||
uploadError: string | null = null;
|
uploadError: string | null = null;
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
projectName = '';
|
|
||||||
projectId = '';
|
|
||||||
editingProject = false;
|
|
||||||
projectDescription = '';
|
|
||||||
projectCountry = '';
|
|
||||||
projectSaving = false;
|
|
||||||
fuelRendimiento: number | null = null;
|
|
||||||
fuelPrecio: number | null = null;
|
|
||||||
filtersExpanded = true;
|
|
||||||
unassignedExpanded = true;
|
|
||||||
planningFilters: PlanningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
|
|
||||||
submitDisabled = true;
|
|
||||||
submitHint: string | null = 'Sube el archivo PDV';
|
|
||||||
private calcSub: Subscription | null = null;
|
private calcSub: Subscription | null = null;
|
||||||
|
|
||||||
private safeDetectChanges(): void {
|
|
||||||
setTimeout(() => {
|
|
||||||
try { this.cdr.detectChanges(); } catch { /* ignore destroyed view */ }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateSubmitState(): void {
|
|
||||||
this.submitDisabled = !this.canSubmit;
|
|
||||||
this.submitHint = this.computeSubmitHint();
|
|
||||||
}
|
|
||||||
|
|
||||||
private computeSubmitHint(): string | null {
|
|
||||||
if (!this.pdvFile) return 'Sube el archivo PDV';
|
|
||||||
if (this.employeesLoading) return 'Cargando empleados...';
|
|
||||||
if (!this.activeEmployees) return 'No hay empleados activos. Regístralos en Gestionar empleados.';
|
|
||||||
if (this.pdvPreviewLoading) return 'Validando archivo PDV...';
|
|
||||||
if (this.pdvPreview && !this.pdvPreview.valid) return 'Corrige los errores del archivo PDV';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private rutasService: RutasService,
|
private rutasService: RutasService,
|
||||||
private routeState: RouteState,
|
private routeState: RouteState,
|
||||||
private previewService: ExcelPreviewService,
|
private previewService: ExcelPreviewService,
|
||||||
private planningInput: PlanningInputService,
|
private planningInput: PlanningInputService,
|
||||||
private employeesService: EmployeeService,
|
|
||||||
private projectsService: ProjectService,
|
|
||||||
private activatedRoute: ActivatedRoute,
|
|
||||||
private auth: AuthService,
|
|
||||||
private router: Router,
|
|
||||||
public profile: UserProfileService,
|
|
||||||
public planningFilter: PlanningFilterService,
|
|
||||||
private cdr: ChangeDetectorRef
|
private cdr: ChangeDetectorRef
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
async onPdvFileChange(event: Event) {
|
||||||
void this.initialize();
|
const input = event.target as HTMLInputElement;
|
||||||
}
|
if (input.files && input.files.length > 0) {
|
||||||
|
const file = input.files[0];
|
||||||
private async initialize(): Promise<void> {
|
const basicError = this.validateFileBasic(file, 'PDV');
|
||||||
await this.loadProject();
|
if (basicError) {
|
||||||
await this.loadEmployees();
|
this.pdvFile = null;
|
||||||
}
|
this.pdvPreview = null;
|
||||||
|
this.uploadError = basicError;
|
||||||
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.fuelRendimiento = project.rendimiento_km_l ?? null;
|
|
||||||
this.fuelPrecio = project.precio_por_litro ?? null;
|
|
||||||
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.updateSubmitState(); this.safeDetectChanges();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startProjectEdit(): void { this.editingProject = true; }
|
|
||||||
|
|
||||||
cancelProjectEdit(): void { this.editingProject = false; void this.loadProject(); }
|
|
||||||
|
|
||||||
async saveProject(): Promise<void> {
|
|
||||||
if (!this.projectId || !this.projectName.trim()) return;
|
|
||||||
if (this.fuelRendimiento != null && !(this.fuelRendimiento > 0)) {
|
|
||||||
this.uploadError = 'El rendimiento debe ser mayor a 0 km/L.';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.fuelPrecio != null && !(this.fuelPrecio >= 0)) {
|
this.pdvFile = file;
|
||||||
this.uploadError = 'El precio por litro no puede ser negativo.';
|
if (this.usuariosFile) this.planningInput.setFiles(file, this.usuariosFile);
|
||||||
|
this.uploadError = null;
|
||||||
|
this.pdvPreviewLoading = true;
|
||||||
|
this.pdvPreview = null;
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
try {
|
||||||
|
this.pdvPreview = await this.previewService.previewFile(file, 'PDV');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Error al leer el archivo PDV';
|
||||||
|
this.pdvPreview = {
|
||||||
|
fileName: file.name,
|
||||||
|
type: 'PDV',
|
||||||
|
valid: false,
|
||||||
|
totalRows: 0,
|
||||||
|
validRows: 0,
|
||||||
|
headers: [],
|
||||||
|
normalizedHeaders: [],
|
||||||
|
missingHeaders: [],
|
||||||
|
errors: [{ row: 1, field: 'file', message: msg }],
|
||||||
|
duplicateIds: [],
|
||||||
|
sampleValidRows: [],
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.pdvPreviewLoading = false;
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onUsuariosFileChange(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
const file = input.files[0];
|
||||||
|
const basicError = this.validateFileBasic(file, 'Usuarios');
|
||||||
|
if (basicError) {
|
||||||
|
this.usuariosFile = null;
|
||||||
|
this.usuariosPreview = null;
|
||||||
|
this.uploadError = basicError;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.projectSaving = true;
|
this.usuariosFile = file;
|
||||||
|
if (this.pdvFile) this.planningInput.setFiles(this.pdvFile, file);
|
||||||
|
this.uploadError = null;
|
||||||
|
this.usuariosPreviewLoading = true;
|
||||||
|
this.usuariosPreview = null;
|
||||||
|
this.cdr.detectChanges();
|
||||||
try {
|
try {
|
||||||
await this.projectsService.update(this.projectId, {
|
this.usuariosPreview = await this.previewService.previewFile(file, 'Usuarios');
|
||||||
nombre: this.projectName.trim(),
|
} catch (e: unknown) {
|
||||||
descripcion: this.projectDescription.trim() || null,
|
const msg = e instanceof Error ? e.message : 'Error al leer el archivo Usuarios';
|
||||||
rendimiento_km_l: this.fuelRendimiento,
|
this.usuariosPreview = {
|
||||||
precio_por_litro: this.fuelPrecio
|
fileName: file.name,
|
||||||
});
|
type: 'Usuarios',
|
||||||
this.editingProject = false;
|
valid: false,
|
||||||
} catch (error: any) { this.uploadError = error?.message ?? 'No se pudo actualizar el proyecto.'; }
|
totalRows: 0,
|
||||||
finally { this.projectSaving = false; this.cdr.detectChanges(); }
|
validRows: 0,
|
||||||
}
|
headers: [],
|
||||||
|
normalizedHeaders: [],
|
||||||
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
|
missingHeaders: [],
|
||||||
|
errors: [{ row: 1, field: 'file', message: msg }],
|
||||||
filterOptions(key: PlanningFilterKey): string[] { return this.planningFilter.options(key); }
|
duplicateIds: [],
|
||||||
applyPlanningFilters(): void { this.planningFilter.set(this.planningFilters); }
|
sampleValidRows: [],
|
||||||
clearPlanningFilters(): void { this.planningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' }; this.applyPlanningFilters(); }
|
};
|
||||||
toggleFilters(): void { this.filtersExpanded = !this.filtersExpanded; }
|
|
||||||
toggleUnassigned(): void { this.unassignedExpanded = !this.unassignedExpanded; }
|
|
||||||
|
|
||||||
private async loadEmployees(): Promise<void> {
|
|
||||||
this.employeesLoading = true;
|
|
||||||
try {
|
|
||||||
this.activeEmployees = (await this.employeesService.active(this.projectId, this.projectCountry)).length;
|
|
||||||
} catch {
|
|
||||||
this.activeEmployees = 0;
|
|
||||||
} finally {
|
} finally {
|
||||||
this.employeesLoading = false;
|
this.usuariosPreviewLoading = false;
|
||||||
this.updateSubmitState(); this.safeDetectChanges();
|
this.cdr.detectChanges();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateFileBasic(file: File, type: string): string | null {
|
||||||
|
const validExtensions = ['.xlsx', '.xls'];
|
||||||
|
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
|
||||||
|
if (!validExtensions.includes(ext)) {
|
||||||
|
return `El archivo ${type} debe ser .xlsx o .xls`;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
return `El archivo ${type} supera 10MB`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
get canSubmit(): boolean {
|
get canSubmit(): boolean {
|
||||||
if (!this.pdvFile || !this.activeEmployees) return false;
|
if (!this.pdvFile || !this.usuariosFile) return false;
|
||||||
if (this.pdvPreviewLoading || this.employeesLoading) return false;
|
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return false;
|
||||||
if (this.isLoading) return false;
|
if (this.isLoading) return false;
|
||||||
if (this.uploadError) return false;
|
if (this.uploadError) return false;
|
||||||
// Require valid previews before enabling submit
|
// Require valid previews before enabling submit
|
||||||
if (this.pdvPreview && !this.pdvPreview.valid) return false;
|
if (this.pdvPreview && !this.pdvPreview.valid) return false;
|
||||||
if (!this.pdvPreview) return false;
|
if (this.usuariosPreview && !this.usuariosPreview.valid) return false;
|
||||||
|
// Previews must exist (file was parsed)
|
||||||
|
if (!this.pdvPreview || !this.usuariosPreview) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
get submitDisabledReason(): string | null {
|
get submitDisabledReason(): string | null {
|
||||||
if (!this.pdvFile) return 'Sube el archivo PDV';
|
if (!this.pdvFile || !this.usuariosFile) return 'Sube ambos archivos';
|
||||||
if (this.employeesLoading) return 'Cargando empleados...';
|
if (this.pdvPreviewLoading || this.usuariosPreviewLoading) return 'Validando archivos...';
|
||||||
if (!this.activeEmployees) return 'No hay empleados activos. Regístralos en Gestionar empleados.';
|
|
||||||
if (this.pdvPreviewLoading) return 'Validando archivo PDV...';
|
|
||||||
if (this.pdvPreview && !this.pdvPreview.valid) return 'Corrige los errores del archivo PDV';
|
if (this.pdvPreview && !this.pdvPreview.valid) return 'Corrige los errores del archivo PDV';
|
||||||
|
if (this.usuariosPreview && !this.usuariosPreview.valid) return 'Corrige los errores del archivo Usuarios';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,24 +165,14 @@ export class HomeComponent implements OnInit {
|
|||||||
this.uploadError = 'No se pudo preparar el archivo PDV para el optimizador.';
|
this.uploadError = 'No se pudo preparar el archivo PDV para el optimizador.';
|
||||||
this.routeState.setLoading(false);
|
this.routeState.setLoading(false);
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.updateSubmitState(); this.safeDetectChanges();
|
this.cdr.detectChanges();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let employeesFile: File;
|
this.planningInput.setFiles(pdvForOptimizer, this.usuariosFile!);
|
||||||
try {
|
|
||||||
employeesFile = await this.employeesService.activeEmployeesFile(this.projectId, this.projectCountry);
|
|
||||||
} catch {
|
|
||||||
this.uploadError = 'No se pudieron obtener los empleados activos.';
|
|
||||||
this.routeState.setLoading(false);
|
|
||||||
this.isLoading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.planningInput.setFiles(this.projectId, pdvForOptimizer, employeesFile);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name);
|
formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name);
|
||||||
formData.append('usuarios_file', employeesFile, employeesFile.name);
|
formData.append('usuarios_file', this.usuariosFile!, this.usuariosFile!.name);
|
||||||
formData.append('pais', this.projectCountry);
|
|
||||||
|
|
||||||
this.calcSub?.unsubscribe();
|
this.calcSub?.unsubscribe();
|
||||||
this.calcSub = this.rutasService.calcularRutas(formData).subscribe({
|
this.calcSub = this.rutasService.calcularRutas(formData).subscribe({
|
||||||
@@ -223,30 +183,12 @@ export class HomeComponent implements OnInit {
|
|||||||
this.uploadError = String((anyRes as { error: unknown }).error);
|
this.uploadError = String((anyRes as { error: unknown }).error);
|
||||||
this.routeState.setLoading(false);
|
this.routeState.setLoading(false);
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.updateSubmitState(); this.safeDetectChanges();
|
this.cdr.detectChanges();
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = Array.isArray(res)
|
|
||||||
? res
|
|
||||||
: res && typeof res === 'object'
|
|
||||||
? [res]
|
|
||||||
: null;
|
|
||||||
if (!payload) {
|
|
||||||
this.uploadError = 'El optimizador devolvió una respuesta vacía. Verifica que el workflow de n8n esté publicado.';
|
|
||||||
this.routeState.setLoading(false);
|
|
||||||
this.isLoading = false;
|
|
||||||
this.updateSubmitState(); this.safeDetectChanges();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const assigned = payload.find(r => r && typeof r === 'object' && 'assigned' in r) as { assigned: unknown } | undefined;
|
|
||||||
const noAssigned = payload.find(r => r && typeof r === 'object' && 'noAssigned' in r) as { noAssigned: unknown } | undefined;
|
|
||||||
if (!assigned && !noAssigned) {
|
|
||||||
this.uploadError = 'El optimizador devolvió un formato de respuesta no válido. Verifica el workflow de n8n.';
|
|
||||||
this.routeState.setLoading(false);
|
|
||||||
this.isLoading = false;
|
|
||||||
this.updateSubmitState(); this.safeDetectChanges();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const arr = res as unknown as unknown[];
|
||||||
|
const assigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'assigned' in r) as unknown as { assigned: unknown } | undefined;
|
||||||
|
const noAssigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'noAssigned' in r) as unknown as { noAssigned: unknown } | undefined;
|
||||||
if (assigned) {
|
if (assigned) {
|
||||||
this.routeState.setAssignedRoutes(assigned.assigned as never);
|
this.routeState.setAssignedRoutes(assigned.assigned as never);
|
||||||
}
|
}
|
||||||
@@ -255,13 +197,13 @@ export class HomeComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
this.routeState.setLoading(false);
|
this.routeState.setLoading(false);
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.updateSubmitState(); this.safeDetectChanges();
|
this.cdr.detectChanges();
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.uploadError = this.mapBackendError(err);
|
this.uploadError = this.mapBackendError(err);
|
||||||
this.routeState.setLoading(false);
|
this.routeState.setLoading(false);
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.updateSubmitState(); this.safeDetectChanges();
|
this.cdr.detectChanges();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -276,7 +218,7 @@ export class HomeComponent implements OnInit {
|
|||||||
|
|
||||||
private mapBackendError(err: unknown): string {
|
private mapBackendError(err: unknown): string {
|
||||||
const anyErr = err as { error?: { error?: string; message?: string }; message?: string; status?: number; name?: string };
|
const anyErr = err as { error?: { error?: string; message?: string }; message?: string; status?: number; name?: string };
|
||||||
if (anyErr?.name === 'TimeoutError') return 'El cálculo tardó demasiado (timeout 180s). Intenta de nuevo con menos filas.';
|
if (anyErr?.name === 'TimeoutError') return 'El cálculo tardó demasiado (timeout 30s). Intenta de nuevo con menos filas.';
|
||||||
if (anyErr?.error?.error) return anyErr.error.error;
|
if (anyErr?.error?.error) return anyErr.error.error;
|
||||||
if (anyErr?.error?.message) return anyErr.error.message;
|
if (anyErr?.error?.message) return anyErr.error.message;
|
||||||
if (anyErr?.message) return anyErr.message;
|
if (anyErr?.message) return anyErr.message;
|
||||||
@@ -285,15 +227,20 @@ export class HomeComponent implements OnInit {
|
|||||||
|
|
||||||
clearFiles() {
|
clearFiles() {
|
||||||
this.calcSub?.unsubscribe();
|
this.calcSub?.unsubscribe();
|
||||||
this.planningInput.clear(this.projectId);
|
this.planningInput.clear();
|
||||||
this.routeState.clearAll();
|
this.routeState.clearAll();
|
||||||
this.pdvFile = null;
|
this.pdvFile = null;
|
||||||
|
this.usuariosFile = null;
|
||||||
this.pdvPreview = null;
|
this.pdvPreview = null;
|
||||||
|
this.usuariosPreview = null;
|
||||||
this.pdvPreviewLoading = false;
|
this.pdvPreviewLoading = false;
|
||||||
|
this.usuariosPreviewLoading = false;
|
||||||
this.uploadError = null;
|
this.uploadError = null;
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.routeState.setLoading(false);
|
this.routeState.setLoading(false);
|
||||||
this.updateSubmitState();
|
const pdvInput = document.getElementById('pdvFile') as HTMLInputElement;
|
||||||
this.safeDetectChanges();
|
const usuariosInput = document.getElementById('usuariosFile') as HTMLInputElement;
|
||||||
|
if (pdvInput) pdvInput.value = '';
|
||||||
|
if (usuariosInput) usuariosInput.value = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -20,7 +20,6 @@
|
|||||||
<td class="text-left">{{ row.latitud }}</td>
|
<td class="text-left">{{ row.latitud }}</td>
|
||||||
<td class="text-left">{{ row.longitud }}</td>
|
<td class="text-left">{{ row.longitud }}</td>
|
||||||
<td class="text-left">{{ row.motivo }}</td>
|
<td class="text-left">{{ row.motivo }}</td>
|
||||||
<td class="text-left">{{ row.recomendacion }}</td>
|
|
||||||
<td class="assignment-actions">
|
<td class="assignment-actions">
|
||||||
<select [(ngModel)]="assignmentUser[row.pdv_id]" class="inline-select">
|
<select [(ngModel)]="assignmentUser[row.pdv_id]" class="inline-select">
|
||||||
<option value="">Usuario</option>
|
<option value="">Usuario</option>
|
||||||
@@ -38,7 +37,7 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template pTemplate="emptymessage">
|
<ng-template pTemplate="emptymessage">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="text-center">
|
<td colspan="7" class="text-center">
|
||||||
No hay registros asignados
|
No hay registros asignados
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
+12
-32
@@ -6,8 +6,6 @@ import { RouteNoAssignedResponse } from '../../../core/models/route.model';
|
|||||||
import { RouteState, UnassignedAssignment } from '../../../core/route-state/route';
|
import { RouteState, UnassignedAssignment } from '../../../core/route-state/route';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { OrsService } from '../../../core/services/ors.service';
|
import { OrsService } from '../../../core/services/ors.service';
|
||||||
import { PlanningFilterService } from '../../../core/services/planning-filter.service';
|
|
||||||
import { recommendationForUnassigned } from '../../../shared/utils/unassigned-recommendation.utils';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -19,7 +17,6 @@ import { recommendationForUnassigned } from '../../../shared/utils/unassigned-re
|
|||||||
export class NoAssignedRouteComponent implements OnDestroy {
|
export class NoAssignedRouteComponent implements OnDestroy {
|
||||||
|
|
||||||
noAssignedRoutes: RouteNoAssignedResponse[] = [];
|
noAssignedRoutes: RouteNoAssignedResponse[] = [];
|
||||||
private allNoAssignedRoutes: RouteNoAssignedResponse[] = [];
|
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
cols: { field: string; header: string }[] = [];
|
cols: { field: string; header: string }[] = [];
|
||||||
private subs: Subscription[] = [];
|
private subs: Subscription[] = [];
|
||||||
@@ -27,59 +24,43 @@ export class NoAssignedRouteComponent implements OnDestroy {
|
|||||||
assignmentDays: Record<string, string[]> = {};
|
assignmentDays: Record<string, string[]> = {};
|
||||||
assigningId: number | null = null;
|
assigningId: number | null = null;
|
||||||
actionError: string | null = null;
|
actionError: string | null = null;
|
||||||
userOptions: { id: string; name: string }[] = [];
|
|
||||||
dayOptions: string[] = [];
|
|
||||||
|
|
||||||
private safeDetectChanges(): void {
|
|
||||||
setTimeout(() => {
|
|
||||||
try { this.cdr.detectChanges(); } catch { /* ignore destroyed view */ }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private refreshOptions(): void {
|
|
||||||
this.userOptions = this.routesState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre }));
|
|
||||||
this.dayOptions = this.routesState.getDiasUnicos();
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private routesState: RouteState,
|
private routesState: RouteState,
|
||||||
private cdr: ChangeDetectorRef,
|
private cdr: ChangeDetectorRef,
|
||||||
private orsService: OrsService,
|
private orsService: OrsService
|
||||||
private filters: PlanningFilterService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.refreshOptions();
|
|
||||||
this.subs.push(
|
this.subs.push(
|
||||||
this.routesState.loading$.subscribe(value => {
|
this.routesState.loading$.subscribe(value => {
|
||||||
this.isLoading = value;
|
this.isLoading = value;
|
||||||
this.safeDetectChanges();
|
this.cdr.detectChanges();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
this.subs.push(
|
this.subs.push(
|
||||||
this.routesState.noAssignedRoutes$.subscribe(data => {
|
this.routesState.noAssignedRoutes$.subscribe(data => {
|
||||||
this.allNoAssignedRoutes = data.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
|
this.noAssignedRoutes = [...data];
|
||||||
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
|
this.cdr.detectChanges();
|
||||||
this.refreshOptions();
|
|
||||||
this.safeDetectChanges();
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
this.subs.push(this.filters.filters$.subscribe(() => {
|
|
||||||
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
|
|
||||||
this.safeDetectChanges();
|
|
||||||
}));
|
|
||||||
this.cols = [
|
this.cols = [
|
||||||
{ field: 'pdv_id', header: 'PDV ID' },
|
{ field: 'pdv_id', header: 'PDV ID' },
|
||||||
{ field: 'pdv_nombre', header: 'Nombre' },
|
{ field: 'pdv_nombre', header: 'Nombre' },
|
||||||
{ field: 'minimo_horas_semana', header: 'Min. Horas/Sem' },
|
{ field: 'minimo_horas_semana', header: 'Min. Horas/Sem' },
|
||||||
{ field: 'latitud', header: 'Latitud' },
|
{ field: 'latitud', header: 'Latitud' },
|
||||||
{ field: 'longitud', header: 'Longitud' },
|
{ field: 'longitud', header: 'Longitud' },
|
||||||
{ field: 'motivo', header: 'Motivo' },
|
{ field: 'motivo', header: 'Motivo' }
|
||||||
{ field: 'recomendacion', header: 'Recomendación' }
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get userOptions(): { id: string; name: string }[] {
|
||||||
|
return this.routesState.getUsuariosUnicos().map(user => ({ id: user.id, name: user.nombre }));
|
||||||
|
}
|
||||||
|
|
||||||
|
get dayOptions(): string[] {
|
||||||
|
return this.routesState.getDiasUnicos();
|
||||||
|
}
|
||||||
|
|
||||||
toggleDay(pdvId: number, day: string, checked: boolean): void {
|
toggleDay(pdvId: number, day: string, checked: boolean): void {
|
||||||
const key = String(pdvId);
|
const key = String(pdvId);
|
||||||
@@ -122,8 +103,7 @@ export class NoAssignedRouteComponent implements OnDestroy {
|
|||||||
this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`;
|
this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`;
|
||||||
} finally {
|
} finally {
|
||||||
this.assigningId = null;
|
this.assigningId = null;
|
||||||
this.refreshOptions();
|
this.cdr.detectChanges();
|
||||||
this.safeDetectChanges();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,6 @@
|
|||||||
border-top: 3px solid var(--glm-azul);
|
border-top: 3px solid var(--glm-azul);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-toggle,.subsection-toggle { display:flex; width:100%; align-items:center; justify-content:space-between; gap:12px; border:0; background:transparent; color:var(--glm-azul); cursor:pointer; font:inherit; font-weight:700; text-align:left; text-transform:uppercase; }
|
|
||||||
.section-toggle { padding:10px 0 14px; font-size:1rem; }
|
|
||||||
.subsection-toggle { padding:0 0 8px; font-size:.82rem; }
|
|
||||||
.section-toggle span,.subsection-toggle span { display:inline-flex; align-items:center; gap:7px; }
|
|
||||||
.table-error { margin:8px 0; color:#c9252d; font-size:.82rem; font-weight:700; }
|
|
||||||
|
|
||||||
.kpis-heading {
|
.kpis-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -88,7 +82,6 @@
|
|||||||
.kpi-warning { border-left-color: var(--glm-naranja); }
|
.kpi-warning { border-left-color: var(--glm-naranja); }
|
||||||
.kpi-blue { border-left-color: var(--glm-acero); }
|
.kpi-blue { border-left-color: var(--glm-acero); }
|
||||||
.kpi-orange { border-left-color: var(--glm-naranja); }
|
.kpi-orange { border-left-color: var(--glm-naranja); }
|
||||||
.kpi-fuel { border-left-color: var(--glm-verde); }
|
|
||||||
|
|
||||||
.kpi-icon {
|
.kpi-icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -137,6 +130,10 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.operational-grid {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.user-load-card {
|
.user-load-card {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
@@ -181,14 +178,6 @@
|
|||||||
.user-load-table tbody tr:nth-child(even) {
|
.user-load-table tbody tr:nth-child(even) {
|
||||||
background: var(--glm-gris-claro);
|
background: var(--glm-gris-claro);
|
||||||
}
|
}
|
||||||
.load-row { cursor:pointer; }
|
|
||||||
.load-row:hover { background:var(--glm-verde-bg)!important; }
|
|
||||||
.visit-detail-row>td { padding:10px 14px; background:#f8fafb; }
|
|
||||||
.visits-table { width:100%; border-collapse:collapse; font-size:.76rem; }
|
|
||||||
.visits-table th { background:#5b7080; }
|
|
||||||
.visits-table select { max-width:150px; padding:5px; border:1px solid var(--glm-gris-medio); border-radius:4px; }
|
|
||||||
.icon-button { margin:0 2px; padding:5px 7px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; }
|
|
||||||
.user-load-pagination { display:flex; align-items:center; justify-content:flex-end; gap:10px; margin-top:10px; color:var(--glm-acero); font-size:.78rem; }.user-load-pagination button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:6px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.user-load-pagination button:hover:not(:disabled) { background:var(--glm-verde-bg); border-color:var(--glm-verde); }.user-load-pagination button:disabled { cursor:not-allowed; opacity:.55; }
|
|
||||||
|
|
||||||
.utilization-cell {
|
.utilization-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,281 +1,99 @@
|
|||||||
<div class="kpis-panel">
|
<div class="kpis-panel">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="section-toggle"
|
|
||||||
(click)="toggleMetrics()"
|
|
||||||
[attr.aria-expanded]="metricsExpanded"
|
|
||||||
>
|
|
||||||
<span><i class="pi pi-chart-bar"></i> Indicadores de planificación</span
|
|
||||||
><i
|
|
||||||
class="pi"
|
|
||||||
[class.pi-chevron-down]="metricsExpanded"
|
|
||||||
[class.pi-chevron-right]="!metricsExpanded"
|
|
||||||
></i>
|
|
||||||
</button>
|
|
||||||
<div *ngIf="metricsExpanded">
|
|
||||||
<div class="kpis-heading">
|
<div class="kpis-heading">
|
||||||
<p class="kpis-subtitle">Resumen de la selección actual</p>
|
<div>
|
||||||
|
<h2 class="section-title">Indicadores de planificación</h2>
|
||||||
|
<p class="kpis-subtitle">Resumen total de la planificación actual</p>
|
||||||
|
</div>
|
||||||
<div class="kpis-actions">
|
<div class="kpis-actions">
|
||||||
<span class="kpis-status" *ngIf="hasData"
|
<span class="kpis-status" *ngIf="hasData"><i class="pi pi-check-circle"></i> Datos disponibles</span>
|
||||||
><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>
|
||||||
><button
|
|
||||||
type="button"
|
|
||||||
class="export-button"
|
|
||||||
*ngIf="hasData"
|
|
||||||
(click)="downloadRoutes()"
|
|
||||||
[disabled]="exportLoading"
|
|
||||||
>
|
|
||||||
<i class="pi" [class.pi-spin]="exportLoading" [class.pi-file-excel]="!exportLoading" [class.pi-spinner]="exportLoading"></i> {{ exportLoading ? "Generando Excel..." : "Descargar rutas" }}</button
|
|
||||||
><button
|
|
||||||
type="button"
|
|
||||||
class="export-button"
|
|
||||||
*ngIf="hasData"
|
|
||||||
(click)="reoptimize()"
|
|
||||||
[disabled]="!canReoptimize"
|
|
||||||
>
|
|
||||||
<i class="pi pi-sync"></i>
|
|
||||||
{{ reoptLoading ? "Reoptimizando..." : "Reoptimizar" }}</button
|
|
||||||
><button
|
|
||||||
type="button"
|
|
||||||
class="export-button"
|
|
||||||
*ngIf="hasLocalChanges"
|
|
||||||
(click)="restoreOriginal()"
|
|
||||||
>
|
|
||||||
<i class="pi pi-undo"></i> Deshacer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-error" *ngIf="reoptError || editError || exportError">
|
|
||||||
{{ reoptError || editError || exportError }}
|
|
||||||
</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>
|
|
||||||
<article class="kpi-card kpi-fuel">
|
|
||||||
<div class="kpi-icon"><i class="pi pi-money-bill"></i></div>
|
|
||||||
<div>
|
|
||||||
<span class="kpi-label">Combustible</span
|
|
||||||
><strong *ngIf="fuelConfigured"
|
|
||||||
>{{ summary.fuelLiters | number: "1.0-1" }} <em>L</em></strong
|
|
||||||
><strong *ngIf="!fuelConfigured">—</strong
|
|
||||||
><small *ngIf="fuelConfigured">{{ fuelCurrency }} {{ summary.fuelCost | number: "1.2-2" }}</small
|
|
||||||
><small *ngIf="!fuelConfigured">Sin configurar en el proyecto</small>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="user-load-card" *ngIf="userLoads.length">
|
||||||
<button type="button" class="subsection-toggle" (click)="toggleBalance()">
|
<h3><i class="pi pi-users"></i> Balance de carga por usuario</h3>
|
||||||
<span><i class="pi pi-users"></i> Balance de carga por usuario</span
|
<div class="user-load-table-wrap">
|
||||||
><i
|
|
||||||
class="pi"
|
|
||||||
[class.pi-chevron-down]="balanceExpanded"
|
|
||||||
[class.pi-chevron-right]="!balanceExpanded"
|
|
||||||
></i>
|
|
||||||
</button>
|
|
||||||
<div class="user-load-table-wrap" *ngIf="balanceExpanded">
|
|
||||||
<table class="user-load-table">
|
<table class="user-load-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr><th>Usuario</th><th>Rutas</th><th>Distancia</th><th>Trabajo</th><th>Utilización</th></tr>
|
||||||
<th></th>
|
|
||||||
<th>Usuario</th>
|
|
||||||
<th>PDVs</th>
|
|
||||||
<th>Visitas</th>
|
|
||||||
<th>Distancia</th>
|
|
||||||
<th>Trabajo</th>
|
|
||||||
<th>Costo comb.</th>
|
|
||||||
<th>Carga</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<ng-container *ngFor="let load of paginatedUserLoads"
|
<tr *ngFor="let load of userLoads" [class.load-over-capacity]="load.status === 'over-capacity'">
|
||||||
><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.name }}</td>
|
||||||
<td>{{ load.uniquePdvs }}</td>
|
<td>{{ load.routes }}</td>
|
||||||
<td>{{ load.visits }}</td>
|
<td>{{ load.distance | number:'1.0-1' }} km</td>
|
||||||
<td>{{ load.distance | number: "1.0-1" }} km</td>
|
<td>{{ load.workHours | number:'1.0-1' }} h</td>
|
||||||
<td>{{ load.workHours | number: "1.0-1" }} h</td>
|
|
||||||
<td *ngIf="fuelConfigured">{{ fuelCurrency }} {{ load.fuelCost | number: "1.2-2" }}</td>
|
|
||||||
<td *ngIf="!fuelConfigured">—</td>
|
|
||||||
<td>
|
<td>
|
||||||
<div
|
<div class="utilization-cell" [ngClass]="utilizationClass(load)">
|
||||||
class="utilization-cell"
|
<span>{{ load.utilization | number:'1.0-1' }}%</span>
|
||||||
[ngClass]="utilizationClass(load)"
|
<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>
|
||||||
<span>{{ load.utilization | number: "1.0-1" }}%</span
|
|
||||||
><span class="utilization-bar" [title]="load.alertMessage"
|
|
||||||
><span [style.width.%]="barWidth(load.utilization)"></span
|
|
||||||
></span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="icon-button"
|
|
||||||
(click)="$event.stopPropagation(); showUserMap(load.id)"
|
|
||||||
title="Ver usuario en mapa"
|
|
||||||
>
|
|
||||||
<i class="pi pi-map"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="visit-detail-row" *ngIf="isExpanded(load.id)">
|
|
||||||
<td colspan="9">
|
|
||||||
<table class="visits-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>PDV</th>
|
|
||||||
<th>Día</th>
|
|
||||||
<th>Orden</th>
|
|
||||||
<th>Trabajo</th>
|
|
||||||
<th>Distancia</th>
|
|
||||||
<th>Duración</th>
|
|
||||||
<th>Acciones</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let route of load.routes">
|
|
||||||
<td>{{ route.json.nombre_pdv }}</td>
|
|
||||||
<td>
|
|
||||||
<select
|
|
||||||
*ngIf="editingRoute === route; else dayText"
|
|
||||||
[(ngModel)]="editDay"
|
|
||||||
>
|
|
||||||
<option *ngFor="let day of dayOptions" [value]="day">
|
|
||||||
{{ day }}
|
|
||||||
</option></select
|
|
||||||
><ng-template #dayText>{{
|
|
||||||
route.json.dia
|
|
||||||
}}</ng-template>
|
|
||||||
</td>
|
|
||||||
<td>{{ route.json.visit_order || "-" }}</td>
|
|
||||||
<td>{{ route.json.horas_trabajo }} h</td>
|
|
||||||
<td>{{ route.json.distancia | number: "1.0-2" }} km</td>
|
|
||||||
<td>
|
|
||||||
{{
|
|
||||||
route.json.horas_desplazamiento * 60 | number: "1.0-0"
|
|
||||||
}}
|
|
||||||
min
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<ng-container
|
|
||||||
*ngIf="editingRoute === route; else normalActions"
|
|
||||||
><select [(ngModel)]="editUserId">
|
|
||||||
<option
|
|
||||||
*ngFor="let user of userOptions"
|
|
||||||
[value]="user.id"
|
|
||||||
>
|
|
||||||
{{ user.name }}
|
|
||||||
</option></select
|
|
||||||
><button class="icon-button" (click)="saveEdit()">
|
|
||||||
<i class="pi pi-check"></i></button
|
|
||||||
><button class="icon-button" (click)="cancelEdit()">
|
|
||||||
<i class="pi pi-times"></i></button></ng-container
|
|
||||||
><ng-template #normalActions
|
|
||||||
><button
|
|
||||||
class="icon-button"
|
|
||||||
(click)="startEdit(route)"
|
|
||||||
>
|
|
||||||
<i class="pi pi-pencil"></i></button
|
|
||||||
><button
|
|
||||||
class="icon-button"
|
|
||||||
(click)="showRoute(route)"
|
|
||||||
>
|
|
||||||
<i class="pi pi-map-marker"></i></button
|
|
||||||
></ng-template>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</td></tr
|
|
||||||
></ng-container>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="user-load-pagination" *ngIf="userLoadPages > 1">
|
|
||||||
<button type="button" (click)="previousUserLoadPage()" [disabled]="userLoadPage === 1">Anterior</button>
|
|
||||||
<span>Página {{ userLoadPage }} de {{ userLoadPages }}</span>
|
|
||||||
<button type="button" (click)="nextUserLoadPage()" [disabled]="userLoadPage === userLoadPages">Siguiente</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="reasons-card" *ngIf="reasons.length">
|
<div class="reasons-card" *ngIf="reasons.length">
|
||||||
<button type="button" class="subsection-toggle" (click)="toggleReasons()">
|
<h3><i class="pi pi-list"></i> Motivos de PDVs sin asignar</h3>
|
||||||
<span><i class="pi pi-list"></i> Motivos de PDVs sin asignar</span
|
<div class="reason-list">
|
||||||
><i
|
|
||||||
class="pi"
|
|
||||||
[class.pi-chevron-down]="reasonsExpanded"
|
|
||||||
[class.pi-chevron-right]="!reasonsExpanded"
|
|
||||||
></i>
|
|
||||||
</button>
|
|
||||||
<div class="reason-list" *ngIf="reasonsExpanded">
|
|
||||||
<div class="reason-row" *ngFor="let item of reasons">
|
<div class="reason-row" *ngFor="let item of reasons">
|
||||||
<span>{{ item.reason }}</span
|
<span>{{ item.reason }}</span>
|
||||||
><strong>{{ item.count }}</strong>
|
<strong>{{ item.count }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kpis-empty" *ngIf="!hasData">
|
<div class="kpis-empty" *ngIf="!hasData">
|
||||||
<i class="pi pi-chart-bar"></i
|
<i class="pi pi-chart-bar"></i>
|
||||||
><span>Carga una planificación para visualizar sus indicadores.</span>
|
<span>Carga una planificación para visualizar sus indicadores.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,193 +1,198 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { RouteState } from '../../../core/route-state/route';
|
import { RouteState } from '../../../core/route-state/route';
|
||||||
import { PlanningExportService } from '../../../shared/services/planning-export.service';
|
import { PlanningExportService } from '../../../shared/services/planning-export.service';
|
||||||
import { summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
|
import { summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||||
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
||||||
import { PlanningFilterService } from '../../../core/services/planning-filter.service';
|
|
||||||
import { RutasService } from '../../../core/services/router.service';
|
|
||||||
import { PlanningInputService } from '../../../core/services/planning-input.service';
|
|
||||||
import { ProjectService } from '../../../core/services/project.service';
|
|
||||||
import { currencyForCountry, fuelCost, isFuelConfigured, type FuelConfig } from '../../../shared/utils/fuel-estimation.utils';
|
|
||||||
|
|
||||||
interface KpiSummary { totalPdvs: number; assignedPdvs: number; unassignedPdvs: number; coverage: number; users: number; days: number; totalDistance: number; averageDistance: number; totalWorkHours: number; fuelLiters: number; fuelCost: number; }
|
interface KpiSummary {
|
||||||
interface ReasonSummary { reason: string; count: number; }
|
totalPdvs: number;
|
||||||
interface UserLoad {
|
assignedPdvs: number;
|
||||||
id: string; name: string; uniquePdvs: number; visits: number; routes: RouteAssignedResponse[]; distance: number; workHours: number; fuelLiters: number; fuelCost: number;
|
unassignedPdvs: number;
|
||||||
capacityHours: number; utilization: number; status: 'ok' | 'warning' | 'over-capacity'; alertMessage: string;
|
coverage: number;
|
||||||
|
users: number;
|
||||||
|
days: number;
|
||||||
|
totalDistance: number;
|
||||||
|
totalTravelHours: number;
|
||||||
|
averageDistance: number;
|
||||||
|
averageTravelHours: number;
|
||||||
|
totalWorkHours: number;
|
||||||
|
averageRoutesPerUser: number;
|
||||||
|
averageUtilization: number;
|
||||||
|
assignmentBalance: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({ standalone: true, selector: 'app-planning-kpis', imports: [CommonModule, FormsModule], templateUrl: './planning-kpis.component.html', styleUrl: './planning-kpis.component.css' })
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
selector: 'app-planning-kpis',
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './planning-kpis.component.html',
|
||||||
|
styleUrl: './planning-kpis.component.css'
|
||||||
|
})
|
||||||
export class PlanningKpisComponent implements OnInit, OnDestroy {
|
export class PlanningKpisComponent implements OnInit, OnDestroy {
|
||||||
summary: KpiSummary = this.emptySummary();
|
summary: KpiSummary = this.emptySummary();
|
||||||
reasons: ReasonSummary[] = [];
|
reasons: ReasonSummary[] = [];
|
||||||
userLoads: UserLoad[] = [];
|
userLoads: UserLoad[] = [];
|
||||||
hasData = false;
|
hasData = false;
|
||||||
metricsExpanded = true;
|
|
||||||
balanceExpanded = true;
|
|
||||||
reasonsExpanded = false;
|
|
||||||
expandedUsers = new Set<string>();
|
|
||||||
editingRoute: RouteAssignedResponse | null = null;
|
|
||||||
editUserId = '';
|
|
||||||
editDay = '';
|
|
||||||
editError = '';
|
|
||||||
reoptLoading = false;
|
|
||||||
reoptError = '';
|
|
||||||
hasLocalChanges = false;
|
|
||||||
exportLoading = false;
|
|
||||||
exportError = '';
|
|
||||||
readonly userLoadPageSize = 14;
|
|
||||||
userLoadPage = 1;
|
|
||||||
fuelConfig: FuelConfig = { rendimientoKmL: null, precioPorLitro: null };
|
|
||||||
fuelCurrency = '$';
|
|
||||||
fuelConfigured = false;
|
|
||||||
private readonly subs: Subscription[] = [];
|
private readonly subs: Subscription[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private routeState: RouteState,
|
private routeState: RouteState,
|
||||||
private filters: PlanningFilterService,
|
private exportService: PlanningExportService
|
||||||
private excelExport: PlanningExportService,
|
|
||||||
private rutasService: RutasService,
|
|
||||||
private planningInput: PlanningInputService,
|
|
||||||
private projects: ProjectService,
|
|
||||||
private router: Router,
|
|
||||||
private activatedRoute: ActivatedRoute,
|
|
||||||
private cdr: ChangeDetectorRef
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
void this.loadFuelConfig();
|
|
||||||
this.subs.push(
|
this.subs.push(
|
||||||
this.routeState.assignedRoutes$.subscribe(() => this.refresh()),
|
this.routeState.assignedRoutes$.subscribe(() => this.refresh()),
|
||||||
this.routeState.noAssignedRoutes$.subscribe(() => this.refresh()),
|
this.routeState.noAssignedRoutes$.subscribe(() => this.refresh())
|
||||||
this.filters.filters$.subscribe(() => this.refresh())
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadFuelConfig(): Promise<void> {
|
ngOnDestroy(): void {
|
||||||
try {
|
this.subs.forEach(subscription => subscription.unsubscribe());
|
||||||
const project = await this.projects.get(this.projectId);
|
|
||||||
this.fuelConfig = { rendimientoKmL: project.rendimiento_km_l ?? null, precioPorLitro: project.precio_por_litro ?? null };
|
|
||||||
this.fuelCurrency = currencyForCountry(project.pais);
|
|
||||||
} catch {
|
|
||||||
this.fuelConfig = { rendimientoKmL: null, precioPorLitro: null };
|
|
||||||
this.fuelCurrency = '$';
|
|
||||||
} finally {
|
|
||||||
this.fuelConfigured = isFuelConfigured(this.fuelConfig);
|
|
||||||
this.refresh();
|
|
||||||
this.cdr.detectChanges();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
ngOnDestroy(): void { this.subs.forEach(subscription => subscription.unsubscribe()); }
|
|
||||||
|
|
||||||
barWidth(value: number): number { return Math.min(Math.max(value, 0), 100); }
|
barWidth(value: number): number {
|
||||||
utilizationClass(load: UserLoad): string { return load.status; }
|
return Math.min(Math.max(value, 0), 100);
|
||||||
async downloadRoutes(): Promise<void> {
|
|
||||||
if (this.exportLoading || !this.hasData) return;
|
|
||||||
this.exportLoading = true;
|
|
||||||
this.exportError = '';
|
|
||||||
try {
|
|
||||||
await this.excelExport.export(this.projectId);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.exportError = error?.message ?? 'No se pudo generar el Excel de rutas.';
|
|
||||||
} finally {
|
|
||||||
this.exportLoading = false;
|
|
||||||
this.cdr.detectChanges();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
toggleUser(id: string): void { this.expandedUsers.has(id) ? this.expandedUsers.delete(id) : this.expandedUsers.add(id); }
|
|
||||||
isExpanded(id: string): boolean { return this.expandedUsers.has(id); }
|
|
||||||
toggleMetrics(): void { this.metricsExpanded = !this.metricsExpanded; }
|
|
||||||
toggleBalance(): void { this.balanceExpanded = !this.balanceExpanded; }
|
|
||||||
toggleReasons(): void { this.reasonsExpanded = !this.reasonsExpanded; }
|
|
||||||
previousUserLoadPage(): void { this.userLoadPage = Math.max(1, this.userLoadPage - 1); }
|
|
||||||
nextUserLoadPage(): void { this.userLoadPage = Math.min(this.userLoadPages, this.userLoadPage + 1); }
|
|
||||||
|
|
||||||
get userOptions(): { id: string; name: string }[] { return this.userLoads.map(load => ({ id: load.id, name: load.name })); }
|
utilizationClass(load: UserLoad): string {
|
||||||
get userLoadPages(): number { return Math.max(1, Math.ceil(this.userLoads.length / this.userLoadPageSize)); }
|
return load.status;
|
||||||
get paginatedUserLoads(): UserLoad[] {
|
|
||||||
const start = (this.userLoadPage - 1) * this.userLoadPageSize;
|
|
||||||
return this.userLoads.slice(start, start + this.userLoadPageSize);
|
|
||||||
}
|
}
|
||||||
get dayOptions(): string[] { return this.routeState.getDiasUnicos(); }
|
|
||||||
get canReoptimize(): boolean { return this.hasData && !!this.planningInput.getFormData(this.projectId) && !this.reoptLoading; }
|
|
||||||
|
|
||||||
showRoute(route: RouteAssignedResponse): void {
|
exportPlanning(): void {
|
||||||
this.routeState.sendRoute(route);
|
this.exportService.export();
|
||||||
void this.router.navigate(['/proyectos', this.projectId, 'ruta'], { queryParams: { pdv_id: route.json.pdv_id, usuario_id: route.json.usuario_id, dia: route.json.dia } });
|
|
||||||
}
|
}
|
||||||
showUserMap(userId: string): void { void this.router.navigate(['/proyectos', this.projectId, 'mapa'], { queryParams: { usuario_id: userId, dia: 'Todos' } }); }
|
|
||||||
startEdit(route: RouteAssignedResponse): void { this.editingRoute = route; this.editUserId = String(route.json.usuario_id); this.editDay = route.json.dia; this.editError = ''; }
|
|
||||||
cancelEdit(): void { this.editingRoute = null; this.editError = ''; }
|
|
||||||
saveEdit(): void {
|
|
||||||
if (!this.editingRoute || !this.editUserId || !this.editDay) return;
|
|
||||||
const capacity = this.routeState.canAssignToDay(this.editUserId, this.editDay, Number(this.editingRoute.json.horas_trabajo || this.editingRoute.json.hora_minima_semanal_pdv || 0), this.editingRoute);
|
|
||||||
if (!capacity.allowed) { this.editError = `La asignación excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`; return; }
|
|
||||||
this.routeState.updateAssignedRoute(this.editingRoute, this.editUserId, this.editDay);
|
|
||||||
this.hasLocalChanges = true; this.editingRoute = null;
|
|
||||||
}
|
|
||||||
reoptimize(): void {
|
|
||||||
const data = this.planningInput.getFormData(this.projectId);
|
|
||||||
if (!data || !this.canReoptimize) return;
|
|
||||||
this.reoptLoading = true; this.reoptError = ''; this.routeState.setLoading(true);
|
|
||||||
this.rutasService.reoptimizarRutas(data).subscribe({
|
|
||||||
next: response => {
|
|
||||||
const items = response as unknown as unknown[];
|
|
||||||
const assigned = items.find(item => item && typeof item === 'object' && 'assigned' in item) as { assigned?: unknown } | undefined;
|
|
||||||
const noAssigned = items.find(item => item && typeof item === 'object' && 'noAssigned' in item) as { noAssigned?: unknown } | undefined;
|
|
||||||
if (assigned?.assigned) this.routeState.setAssignedRoutes(assigned.assigned as never);
|
|
||||||
if (noAssigned?.noAssigned) this.routeState.setNoAssignedRoutes(noAssigned.noAssigned as never);
|
|
||||||
this.hasLocalChanges = false; this.reoptLoading = false; this.routeState.setLoading(false); this.cdr.detectChanges();
|
|
||||||
},
|
|
||||||
error: error => { this.reoptError = error?.error?.error || error?.message || 'No se pudo reoptimizar'; this.reoptLoading = false; this.routeState.setLoading(false); this.cdr.detectChanges(); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
restoreOriginal(): void { this.routeState.restoreOriginalAssignedRoutes(); this.hasLocalChanges = false; this.editingRoute = null; }
|
|
||||||
|
|
||||||
private get projectId(): string { return this.activatedRoute.snapshot.paramMap.get('id') ?? ''; }
|
|
||||||
private refresh(): void {
|
private refresh(): void {
|
||||||
const assigned = this.filters.filterAssigned(this.routeState.getAssignedSnapshot());
|
const assigned = this.routeState.getAssignedSnapshot();
|
||||||
const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot());
|
const unassigned = this.routeState.getNoAssignedSnapshot();
|
||||||
const assignedPdvIds = new Set(assigned.map(route => String(route.json.pdv_id)));
|
const assignedPdvIds = new Set(assigned.map(route => String(route.json.pdv_id)));
|
||||||
const unassignedPdvIds = new Set(unassigned.map(route => String(route.pdv_id)).filter(id => !assignedPdvIds.has(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 totalPdvs = new Set([...assignedPdvIds, ...unassignedPdvIds]).size;
|
const totalPdvs = new Set([...assignedPdvIds, ...unassignedPdvIds]).size;
|
||||||
const dailyRoutes = summarizeDailyRoutes(assigned);
|
const dailyRoutes = summarizeDailyRoutes(assigned);
|
||||||
const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0);
|
const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0);
|
||||||
|
const totalTravelHours = dailyRoutes.reduce((sum, day) => sum + day.travelHours, 0);
|
||||||
const totalWorkHours = dailyRoutes.reduce((sum, day) => sum + day.workHours, 0);
|
const totalWorkHours = dailyRoutes.reduce((sum, day) => sum + day.workHours, 0);
|
||||||
|
const users = new Set(assigned.map(route => String(route.json.usuario_id))).size;
|
||||||
|
const days = new Set(assigned.map(route => route.json.dia)).size;
|
||||||
const loads = new Map<string, UserLoad>();
|
const loads = new Map<string, UserLoad>();
|
||||||
|
|
||||||
for (const route of assigned) {
|
for (const route of assigned) {
|
||||||
const id = String(route.json.usuario_id);
|
const key = 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, fuelLiters: 0, fuelCost: 0, capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario), utilization: 0, status: 'ok', alertMessage: '' };
|
const current = loads.get(key) ?? {
|
||||||
load.routes.push(route); load.visits++;
|
id: key,
|
||||||
loads.set(id, load);
|
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);
|
||||||
}
|
}
|
||||||
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));
|
this.userLoads = Array.from(loads.values()).map(load => {
|
||||||
load.uniquePdvs = new Set(load.routes.map(route => String(route.json.pdv_id))).size;
|
|
||||||
const userDays = dailyRoutes.filter(day => day.userId === load.id);
|
const userDays = dailyRoutes.filter(day => day.userId === load.id);
|
||||||
load.distance = userDays.reduce((sum, day) => sum + day.distanceKm, 0);
|
load.distance = userDays.reduce((sum, day) => sum + day.distanceKm, 0);
|
||||||
load.workHours = userDays.reduce((sum, day) => sum + day.workHours, 0);
|
load.workHours = userDays.reduce((sum, day) => sum + day.workHours, 0);
|
||||||
const fuel = fuelCost(load.distance, this.fuelConfig);
|
load.utilization = load.capacityHours ? (load.workHours / load.capacityHours) * 100 : 0;
|
||||||
load.fuelLiters = fuel.liters;
|
|
||||||
load.fuelCost = fuel.cost;
|
|
||||||
load.utilization = load.capacityHours ? load.workHours / load.capacityHours * 100 : 0;
|
|
||||||
load.status = load.utilization > 100 ? 'over-capacity' : load.utilization > 90 ? 'warning' : 'ok';
|
load.status = load.utilization > 100 ? 'over-capacity' : load.utilization > 90 ? 'warning' : 'ok';
|
||||||
load.alertMessage = `${load.name}: ${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h.`;
|
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).`;
|
||||||
return load;
|
return load;
|
||||||
}).sort((a, b) => b.visits - a.visits || b.workHours - a.workHours);
|
}).sort((a, b) => b.routes - a.routes || b.workHours - a.workHours);
|
||||||
this.userLoadPage = Math.min(this.userLoadPage, this.userLoadPages);
|
const routeCounts = this.userLoads.map(load => load.routes);
|
||||||
const fuelTotal = fuelCost(totalDistance, this.fuelConfig);
|
const assignmentBalance = routeCounts.length ? Math.max(...routeCounts) - Math.min(...routeCounts) : 0;
|
||||||
this.fuelConfigured = isFuelConfigured(this.fuelConfig);
|
const averageUtilization = this.userLoads.length
|
||||||
this.summary = { totalPdvs, assignedPdvs: assignedPdvIds.size, unassignedPdvs: unassignedPdvIds.size, coverage: totalPdvs ? assignedPdvIds.size / totalPdvs * 100 : 0, users: loads.size, days: new Set(assigned.map(route => route.json.dia)).size, totalDistance, averageDistance: assignedPdvIds.size ? totalDistance / assignedPdvIds.size : 0, totalWorkHours, fuelLiters: fuelTotal.liters, fuelCost: fuelTotal.cost };
|
? 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
|
||||||
|
};
|
||||||
|
|
||||||
const reasonByPdv = new Map<string, string>();
|
const reasonByPdv = new Map<string, string>();
|
||||||
for (const route of unassigned) if (!assignedPdvIds.has(String(route.pdv_id)) && !reasonByPdv.has(String(route.pdv_id))) reasonByPdv.set(String(route.pdv_id), route.motivo?.trim() || 'Sin motivo especificado');
|
for (const route of unassigned) {
|
||||||
const counts = new Map<string, number>(); for (const reason of reasonByPdv.values()) counts.set(reason, (counts.get(reason) ?? 0) + 1);
|
const pdvId = String(route.pdv_id);
|
||||||
this.reasons = [...counts].map(([reason, count]) => ({ reason, count })).sort((a, b) => b.count - a.count);
|
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);
|
||||||
this.hasData = totalPdvs > 0;
|
this.hasData = totalPdvs > 0;
|
||||||
}
|
}
|
||||||
private numberValue(value: number | string | null | undefined): number { const result = Number(value); return Number.isFinite(result) ? result : 0; }
|
|
||||||
private emptySummary(): KpiSummary { return { totalPdvs: 0, assignedPdvs: 0, unassignedPdvs: 0, coverage: 0, users: 0, days: 0, totalDistance: 0, averageDistance: 0, totalWorkHours: 0, fuelLiters: 0, fuelCost: 0 }; }
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-202
@@ -77,9 +77,6 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.territory-summary { margin: 4px 0 0; color: var(--glm-acero); font-size: .76rem; line-height: 1.35; }
|
|
||||||
.territory-summary i { color: var(--glm-verde); margin-right: 3px; }
|
|
||||||
|
|
||||||
.filters-card {
|
.filters-card {
|
||||||
background: var(--glm-verde-bg);
|
background: var(--glm-verde-bg);
|
||||||
border: 1px solid var(--glm-verde-claro);
|
border: 1px solid var(--glm-verde-claro);
|
||||||
@@ -115,41 +112,6 @@
|
|||||||
box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15);
|
box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.segmented-control {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
|
||||||
gap: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--glm-gris-medio);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segmented-control button {
|
|
||||||
padding: 8px 4px;
|
|
||||||
border: 0;
|
|
||||||
border-right: 1px solid var(--glm-gris-medio);
|
|
||||||
background: #fff;
|
|
||||||
color: var(--glm-acero);
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segmented-control button:last-child {
|
|
||||||
border-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segmented-control button:hover {
|
|
||||||
background: var(--glm-verde-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.segmented-control button.active {
|
|
||||||
background: var(--glm-verde);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-stats {
|
.filter-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -164,8 +126,6 @@
|
|||||||
color: var(--glm-acero);
|
color: var(--glm-acero);
|
||||||
}
|
}
|
||||||
|
|
||||||
.heatmap-help { display: inline-grid; width: 16px; height: 16px; margin-left: auto; place-items: center; border: 1px solid var(--glm-acero); border-radius: 50%; color: var(--glm-acero); cursor: help; font-size: 0.68rem; font-weight: 700; }
|
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 24px 12px;
|
padding: 24px 12px;
|
||||||
@@ -191,45 +151,20 @@
|
|||||||
color: var(--glm-gris-oscuro);
|
color: var(--glm-gris-oscuro);
|
||||||
}
|
}
|
||||||
|
|
||||||
.unassigned-bottom-drawer {
|
.unassigned-map-card {
|
||||||
position: fixed;
|
padding: 12px;
|
||||||
left: 398px;
|
background: #fff8ed;
|
||||||
right: 18px;
|
|
||||||
bottom: 18px;
|
|
||||||
z-index: 950;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid #f2bf86;
|
border: 1px solid #f2bf86;
|
||||||
border-left: 4px solid var(--glm-naranja);
|
border-left: 3px solid var(--glm-naranja);
|
||||||
border-radius: 10px 10px 4px 4px;
|
border-radius: 4px;
|
||||||
background: #fff8ed;
|
|
||||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.22);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.unassigned-drawer-toggle {
|
.unassigned-map-card .info-card-header { margin-bottom: 8px; }
|
||||||
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-drawer-toggle span { display: inline-flex; align-items: center; gap: 8px; }
|
.unassigned-map-list {
|
||||||
.unassigned-drawer-toggle .pi-exclamation-triangle { color: var(--glm-naranja); }
|
|
||||||
|
|
||||||
.unassigned-drawer-content {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
gap: 5px;
|
||||||
gap: 8px;
|
max-height: 180px;
|
||||||
max-height: 240px;
|
|
||||||
padding: 0 12px 12px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,21 +219,86 @@
|
|||||||
.pending-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
|
.pending-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
|
||||||
.pending-actions .btn-primary, .pending-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
|
.pending-actions .btn-primary, .pending-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
|
||||||
|
|
||||||
.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; }
|
.routes-list h3 {
|
||||||
.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; }
|
margin: 0 0 8px;
|
||||||
.employee-load-row:hover, .employee-load-row.selected { border-color: var(--glm-verde); background: var(--glm-verde-bg); }
|
font-size: 0.85rem;
|
||||||
.employee-load-row.over-capacity { border-color: #c9252d; background: var(--glm-rojo-ok); }
|
color: var(--glm-azul);
|
||||||
.employee-load-name { overflow: hidden; color: var(--glm-azul); font-size: 0.78rem; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
font-weight: 700;
|
||||||
.employee-load-values { color: var(--glm-acero); font-size: 0.7rem; white-space: nowrap; }
|
text-transform: uppercase;
|
||||||
.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; }
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
.routes-list ul {
|
||||||
#map-overview { left: 0; }
|
list-style: none;
|
||||||
.overview-sidebar { width: 340px; max-width: calc(100vw - 24px); }
|
margin: 0;
|
||||||
.unassigned-bottom-drawer { right: 12px; bottom: 12px; left: 12px; }
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card {
|
.info-card {
|
||||||
@@ -309,12 +309,6 @@
|
|||||||
border-top: 3px solid var(--glm-azul);
|
border-top: 3px solid var(--glm-azul);
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-location-button {
|
|
||||||
margin-top: 0;
|
|
||||||
padding: 6px 9px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card-header {
|
.info-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -345,13 +339,6 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.visit-group-title {
|
|
||||||
margin: 8px 0 4px;
|
|
||||||
color: var(--glm-azul);
|
|
||||||
font-size: 0.76rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reassign-form {
|
.reassign-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -366,22 +353,15 @@
|
|||||||
.reassign-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
|
.reassign-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
|
||||||
.reassign-actions .btn-primary, .reassign-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
|
.reassign-actions .btn-primary, .reassign-actions .btn-secondary { padding: 7px 10px; font-size: 0.78rem; }
|
||||||
.reassign-hint { grid-column: 1 / -1; margin: 0; color: var(--glm-acero); font-size: 0.74rem; }
|
.reassign-hint { grid-column: 1 / -1; margin: 0; color: var(--glm-acero); font-size: 0.74rem; }
|
||||||
.reassign-form .reassign-actions, .reassign-form .employee-load-list, .reassign-form .error-card { grid-column: 1 / -1; }
|
.reassign-form .reassign-actions, .reassign-form .error-card { grid-column: 1 / -1; }
|
||||||
|
|
||||||
.daily-routes-card { padding: 12px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: var(--glm-surface); }
|
.daily-routes-card { padding: 12px; border: 1px solid var(--glm-gris-medio); border-top: 3px solid var(--glm-azul); border-radius: 4px; background: var(--glm-surface); }
|
||||||
.daily-routes-card h3 { margin: 0 0 8px; color: var(--glm-azul); font-size: 0.82rem; text-transform: uppercase; }
|
.daily-routes-card h3 { margin: 0 0 8px; color: var(--glm-azul); font-size: 0.82rem; text-transform: uppercase; }
|
||||||
.daily-route-group { border-bottom: 1px solid var(--glm-gris-claro); }
|
|
||||||
.daily-route-group:last-child { border-bottom: 0; }
|
|
||||||
.daily-route-toggle { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 0; border: 0; background: transparent; color: var(--glm-gris-oscuro); cursor: pointer; font: inherit; text-align: left; }
|
|
||||||
.daily-route-toggle:hover { background: var(--glm-gris-claro); }
|
|
||||||
.daily-route-toggle-title { color: var(--glm-azul); font-weight: 700; }
|
|
||||||
.daily-route-toggle > span:last-child { color: var(--glm-acero); font-size: 0.78rem; }
|
|
||||||
.daily-route-group-content { padding-left: 14px; }
|
|
||||||
.daily-route { display: grid; gap: 3px; padding: 8px 0; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); font-size: 0.78rem; }
|
.daily-route { display: grid; gap: 3px; padding: 8px 0; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); font-size: 0.78rem; }
|
||||||
.daily-route:last-child { border-bottom: 0; }
|
.daily-route:last-child { border-bottom: 0; }
|
||||||
.daily-route span, .daily-route small { color: var(--glm-acero); }
|
.daily-route span, .daily-route small { color: var(--glm-acero); }
|
||||||
.daily-route.over-capacity small { color: #c9252d; font-weight: 700; }
|
.daily-route.over-capacity small { color: #c9252d; font-weight: 700; }
|
||||||
.btn-icon:focus-visible, button:focus-visible, select:focus-visible { outline: 3px solid rgba(108, 194, 74, 0.45); outline-offset: 2px; }
|
.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 {
|
.btn-icon {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -406,7 +386,6 @@
|
|||||||
.info-list div {
|
.info-list div {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
border-bottom: 1px solid var(--glm-gris-claro);
|
border-bottom: 1px solid var(--glm-gris-claro);
|
||||||
@@ -495,44 +474,38 @@
|
|||||||
color: var(--glm-gris-oscuro);
|
color: var(--glm-gris-oscuro);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-marker {
|
.heat-opts {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding-left: 22px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--glm-acero);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-glm {
|
||||||
background: none !important;
|
background: none !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-pin-wrapper {
|
.cluster-inner {
|
||||||
width: 42px;
|
width: 40px;
|
||||||
height: 50px;
|
height: 40px;
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
padding-top: 3px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: pointer;
|
background: var(--glm-verde);
|
||||||
transition: transform 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
|
border: 3px solid #fff;
|
||||||
}
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
.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;
|
color: #fff;
|
||||||
font: 700 12px Arial, sans-serif;
|
font-weight: 700;
|
||||||
text-align: center;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker {
|
||||||
|
background: none !important;
|
||||||
|
border: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.marker-inner {
|
.marker-inner {
|
||||||
@@ -548,62 +521,3 @@
|
|||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
:host.embedded {
|
|
||||||
height: 620px;
|
|
||||||
min-height: 480px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--glm-gris-medio);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host.embedded #map-overview {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 340px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host.embedded .overview-sidebar {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 340px;
|
|
||||||
z-index: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host.embedded .unassigned-bottom-drawer {
|
|
||||||
position: absolute;
|
|
||||||
left: 352px;
|
|
||||||
right: 12px;
|
|
||||||
bottom: 12px;
|
|
||||||
z-index: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
:host.embedded {
|
|
||||||
height: auto;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
:host.embedded #map-overview {
|
|
||||||
position: relative;
|
|
||||||
left: 0;
|
|
||||||
height: 420px;
|
|
||||||
}
|
|
||||||
:host.embedded .overview-sidebar {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
max-height: none;
|
|
||||||
}
|
|
||||||
:host.embedded .unassigned-bottom-drawer {
|
|
||||||
position: relative;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
margin: 0 12px 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+42
-81
@@ -6,29 +6,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overview-sidebar">
|
<div class="overview-sidebar">
|
||||||
<div class="sidebar-brand" *ngIf="!embedded">
|
<div class="sidebar-brand">
|
||||||
<img src="GLM_completo.png" alt="GomezLee Marketing" class="sidebar-logo" />
|
<img src="GLM_completo.png" alt="GomezLee Marketing" class="sidebar-logo" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<button type="button" class="back-btn" *ngIf="!embedded" (click)="goBack()" aria-label="Volver a la planificación">
|
<button type="button" class="back-btn" (click)="goBack()" aria-label="Volver a la planificación">
|
||||||
<i class="pi pi-arrow-left"></i> Volver
|
<i class="pi pi-arrow-left"></i> Volver
|
||||||
</button>
|
</button>
|
||||||
<h2 class="sidebar-title">Mapa de rutas</h2>
|
<h2 class="sidebar-title">Mapa de rutas</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="territory-summary" *ngIf="activeTerritories.length"><i class="pi pi-map-marker"></i> {{ activeTerritories.join(', ') }}</p>
|
|
||||||
|
|
||||||
<div class="filters-card" *ngIf="usuarios.length">
|
<div class="filters-card" *ngIf="usuarios.length">
|
||||||
<label class="filter-label">Ciudad</label>
|
|
||||||
<select class="filter-select" [(ngModel)]="selectedMetro" (ngModelChange)="onMetroChange()">
|
|
||||||
<option value="Todos">Todas las ciudades</option>
|
|
||||||
<option *ngFor="let metro of metros" [value]="metro">{{ metro }}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label class="filter-label">Usuario</label>
|
<label class="filter-label">Usuario</label>
|
||||||
<select class="filter-select" [(ngModel)]="selectedUsuarioId" (ngModelChange)="onUsuarioChange()">
|
<select class="filter-select" [(ngModel)]="selectedUsuarioId" (ngModelChange)="onUsuarioChange()">
|
||||||
<option value="Todos">Todos los usuarios</option>
|
<option value="Todos">Todos los usuarios</option>
|
||||||
<option *ngFor="let u of metroUsuarios" [value]="u.id">{{ u.nombre }} ({{ u.id }})</option>
|
<option *ngFor="let u of usuarios" [value]="u.id">{{ u.nombre }} ({{ u.id }})</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label class="filter-label">Día</label>
|
<label class="filter-label">Día</label>
|
||||||
@@ -37,15 +30,8 @@
|
|||||||
<option *ngFor="let d of dias" [value]="d">{{ d }}</option>
|
<option *ngFor="let d of dias" [value]="d">{{ d }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label class="filter-label">Asignación</label>
|
|
||||||
<div class="segmented-control" role="group" aria-label="Filtrar PDVs por asignación">
|
|
||||||
<button type="button" [class.active]="selectedAsignacion === 'Todos'" (click)="selectedAsignacion = 'Todos'; onAsignacionChange()">Todos</button>
|
|
||||||
<button type="button" [class.active]="selectedAsignacion === 'Asignados'" (click)="selectedAsignacion = 'Asignados'; onAsignacionChange()">Asignados</button>
|
|
||||||
<button type="button" [class.active]="selectedAsignacion === 'NoAsignados'" (click)="selectedAsignacion = 'NoAsignados'; onAsignacionChange()">Sin asignar</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-stats" *ngIf="filteredRoutes.length">
|
<div class="filter-stats" *ngIf="filteredRoutes.length">
|
||||||
<span>{{ uniqueFilteredRoutes.length }} PDVs</span>
|
<span>{{ filteredRoutes.length }} PDVs</span>
|
||||||
<span>{{ totalDistancia }} km</span>
|
<span>{{ totalDistancia }} km</span>
|
||||||
<span>{{ totalDuracionMin }} min</span>
|
<span>{{ totalDuracionMin }} min</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,27 +40,22 @@
|
|||||||
<label class="toggle">
|
<label class="toggle">
|
||||||
<input type="checkbox" [checked]="showHeat" (change)="toggleHeat()" />
|
<input type="checkbox" [checked]="showHeat" (change)="toggleHeat()" />
|
||||||
<i class="pi pi-fire"></i> Heatmap (carga)
|
<i class="pi pi-fire"></i> Heatmap (carga)
|
||||||
<span class="heatmap-help" title="El mapa muestra con mayor intensidad las zonas donde se concentra más carga de trabajo en horas.">?</span>
|
|
||||||
</label>
|
</label>
|
||||||
|
<div class="heat-opts" *ngIf="showHeat">
|
||||||
|
<label><input type="radio" name="hw" value="uniform" [checked]="heatWeightMode==='uniform'" (change)="setHeatWeight('uniform')" /> Peso 1 (densidad)</label>
|
||||||
|
<label><input type="radio" name="hw" value="horas_trabajo" [checked]="heatWeightMode==='horas_trabajo'" (change)="setHeatWeight('horas_trabajo')" /> Por carga (horas)</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-empty" *ngIf="!filteredRoutes.length && !error">
|
<div class="filter-empty" *ngIf="!filteredRoutes.length && !error">
|
||||||
Sin rutas para este filtro.
|
Sin rutas para este filtro.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-empty" *ngIf="overviewMode && filteredRoutes.length">
|
|
||||||
Vista general ({{ filteredRoutes.length }} PDVs). Filtra por cobertura, zona, usuario o día para ver el detalle vial completo.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-empty" *ngIf="orsFailCount">
|
|
||||||
{{ orsFailCount }} tramo(s) sin conexión vial (línea recta). Toca la línea para ver el motivo.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="empty-state" *ngIf="!usuarios.length">
|
<div class="empty-state" *ngIf="!usuarios.length">
|
||||||
<i class="pi pi-inbox"></i>
|
<i class="pi pi-inbox"></i>
|
||||||
<p>No hay rutas cargadas.</p>
|
<p>No hay rutas cargadas.</p>
|
||||||
<button type="button" class="btn-secondary" *ngIf="!embedded" (click)="goBack()">Ir a Home</button>
|
<button type="button" class="btn-secondary" (click)="goBack()">Ir a Home</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="error-card" *ngIf="error && usuarios.length">
|
<div class="error-card" *ngIf="error && usuarios.length">
|
||||||
@@ -82,7 +63,19 @@
|
|||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="info-card unassigned-detail-card" *ngIf="selectedUnassigned && showUnassigned">
|
<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">
|
<div class="info-card-header">
|
||||||
<h3>Gestionar PDV sin ruta</h3>
|
<h3>Gestionar PDV sin ruta</h3>
|
||||||
<button type="button" class="btn-icon" (click)="selectedUnassigned = null"><i class="pi pi-times"></i></button>
|
<button type="button" class="btn-icon" (click)="selectedUnassigned = null"><i class="pi pi-times"></i></button>
|
||||||
@@ -108,13 +101,6 @@
|
|||||||
<label>Días<div class="day-checkboxes"><label *ngFor="let day of unassignedDayOptions"><input type="checkbox" [checked]="(assignmentDays[selectedUnassigned.pdv_id] || []).includes(day)" (change)="toggleUnassignedDay(selectedUnassigned.pdv_id, day, $any($event.target).checked)">{{ day.substring(0, 3) }}</label></div></label>
|
<label>Días<div class="day-checkboxes"><label *ngFor="let day of unassignedDayOptions"><input type="checkbox" [checked]="(assignmentDays[selectedUnassigned.pdv_id] || []).includes(day)" (change)="toggleUnassignedDay(selectedUnassigned.pdv_id, day, $any($event.target).checked)">{{ day.substring(0, 3) }}</label></div></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="pending-actions"><button type="button" class="btn-secondary" (click)="startUnassignedEdit(selectedUnassigned)"><i class="pi pi-pencil"></i> Editar</button><button type="button" class="btn-primary" (click)="assignUnassigned(selectedUnassigned)" [disabled]="assigningUnassignedId === selectedUnassigned.pdv_id"><span *ngIf="assigningUnassignedId === selectedUnassigned.pdv_id" class="spinner-inline"></span><i *ngIf="assigningUnassignedId !== selectedUnassigned.pdv_id" class="pi pi-user-plus"></i> Reasignar</button></div>
|
<div class="pending-actions"><button type="button" class="btn-secondary" (click)="startUnassignedEdit(selectedUnassigned)"><i class="pi pi-pencil"></i> Editar</button><button type="button" class="btn-primary" (click)="assignUnassigned(selectedUnassigned)" [disabled]="assigningUnassignedId === selectedUnassigned.pdv_id"><span *ngIf="assigningUnassignedId === selectedUnassigned.pdv_id" class="spinner-inline"></span><i *ngIf="assigningUnassignedId !== selectedUnassigned.pdv_id" class="pi pi-user-plus"></i> Reasignar</button></div>
|
||||||
<div class="employee-load-list" aria-label="Carga de empleados">
|
|
||||||
<button type="button" class="employee-load-row" *ngFor="let employee of unassignedEmployeeLoads" [class.selected]="assignmentUser[selectedUnassigned.pdv_id] === employee.id" [class.over-capacity]="employeeLoadStatus(employee) === 'over-capacity'" (click)="selectUnassignedEmployee(employee.id)">
|
|
||||||
<span class="employee-load-name">{{ employee.name }}</span>
|
|
||||||
<span class="employee-load-values">{{ employee.workHours | number:'1.0-1' }} / {{ employee.capacityHours | number:'1.0-1' }} h · {{ employee.utilization | number:'1.0-1' }}%</span>
|
|
||||||
<span class="employee-load-bar" [ngClass]="employeeLoadStatus(employee)"><span [style.width.%]="employeeLoadWidth(employee)"></span></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<div class="error-card" *ngIf="unassignedActionError">{{ unassignedActionError }}</div>
|
<div class="error-card" *ngIf="unassignedActionError">{{ unassignedActionError }}</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -143,23 +129,11 @@
|
|||||||
<div><dt>Día</dt><dd>{{ selectedRouteDetail.json.dia }}</dd></div>
|
<div><dt>Día</dt><dd>{{ selectedRouteDetail.json.dia }}</dd></div>
|
||||||
<div><dt>Orden de visita</dt><dd>{{ selectedVisitOrder }}</dd></div>
|
<div><dt>Orden de visita</dt><dd>{{ selectedVisitOrder }}</dd></div>
|
||||||
<div><dt>Origen del tramo</dt><dd>{{ selectedSegmentOrigin }}</dd></div>
|
<div><dt>Origen del tramo</dt><dd>{{ selectedSegmentOrigin }}</dd></div>
|
||||||
<div><dt>Destino</dt><dd><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>Destino</dt><dd>{{ selectedRouteDetail.json.latitud_pdv }}, {{ selectedRouteDetail.json.longitud_pdv }}</dd></div>
|
||||||
<div><dt>Horas trabajo</dt><dd>{{ selectedRouteDetail.json.horas_trabajo }}</dd></div>
|
<div><dt>Horas trabajo</dt><dd>{{ selectedRouteDetail.json.horas_trabajo }}</dd></div>
|
||||||
<div><dt>Distancia tramo</dt><dd>{{ selectedSegmentDistance | number:'1.2-2' }} km</dd></div>
|
<div><dt>Distancia tramo</dt><dd>{{ selectedSegmentDistance | number:'1.2-2' }} km</dd></div>
|
||||||
<div><dt>Duración tramo</dt><dd>{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min</dd></div>
|
<div><dt>Duración tramo</dt><dd>{{ selectedSegmentDurationMinutes | number:'1.0-0' }} min</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="detail-section" *ngIf="otherPdvVisitGroups.length">
|
|
||||||
<h4><i class="pi pi-calendar"></i> Otras visitas de este PDV</h4>
|
|
||||||
<div *ngFor="let group of otherPdvVisitGroups">
|
|
||||||
<p class="visit-group-title">Usuario: {{ group.usuario }}</p>
|
|
||||||
<div class="employee-load-list" [attr.aria-label]="'Visitas de ' + group.usuario">
|
|
||||||
<button type="button" class="employee-load-row" *ngFor="let visit of group.visits" (click)="selectRouteFromList(visit.route)">
|
|
||||||
<span class="employee-load-name">{{ visit.dia }} (parada {{ visit.orden }})</span>
|
|
||||||
<span class="employee-load-values">{{ visit.km | number:'1.2-2' }} km · {{ visit.min | number:'1.0-0' }} min</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="reassign-actions" *ngIf="!showReassignForm">
|
<div class="reassign-actions" *ngIf="!showReassignForm">
|
||||||
<button type="button" class="btn-secondary" (click)="moveSelectedVisit(-1)" [disabled]="!canMoveSelectedVisitUp" aria-label="Mover visita anterior"><i class="pi pi-arrow-up"></i> Subir</button>
|
<button type="button" class="btn-secondary" (click)="moveSelectedVisit(-1)" [disabled]="!canMoveSelectedVisitUp" aria-label="Mover visita anterior"><i class="pi pi-arrow-up"></i> Subir</button>
|
||||||
<button type="button" class="btn-secondary" (click)="moveSelectedVisit(1)" [disabled]="!canMoveSelectedVisitDown" aria-label="Mover visita posterior"><i class="pi pi-arrow-down"></i> Bajar</button>
|
<button type="button" class="btn-secondary" (click)="moveSelectedVisit(1)" [disabled]="!canMoveSelectedVisitDown" aria-label="Mover visita posterior"><i class="pi pi-arrow-down"></i> Bajar</button>
|
||||||
@@ -171,13 +145,6 @@
|
|||||||
<label>Día<select [(ngModel)]="reassignDay"><option *ngFor="let day of unassignedDayOptions" [value]="day">{{ day }}</option></select></label>
|
<label>Día<select [(ngModel)]="reassignDay"><option *ngFor="let day of unassignedDayOptions" [value]="day">{{ day }}</option></select></label>
|
||||||
<p class="reassign-hint">Se recalcularán la distancia y duración con ruta vial ORS.</p>
|
<p class="reassign-hint">Se recalcularán la distancia y duración con ruta vial ORS.</p>
|
||||||
<div class="reassign-actions"><button type="button" class="btn-primary" (click)="reassignRoute()" [disabled]="reassigningRoute"><span *ngIf="reassigningRoute" class="spinner-inline"></span><i *ngIf="!reassigningRoute" class="pi pi-check"></i> {{ reassigningRoute ? 'Calculando...' : 'Confirmar' }}</button><button type="button" class="btn-secondary" (click)="cancelReassignRoute()" [disabled]="reassigningRoute">Cancelar</button></div>
|
<div class="reassign-actions"><button type="button" class="btn-primary" (click)="reassignRoute()" [disabled]="reassigningRoute"><span *ngIf="reassigningRoute" class="spinner-inline"></span><i *ngIf="!reassigningRoute" class="pi pi-check"></i> {{ reassigningRoute ? 'Calculando...' : 'Confirmar' }}</button><button type="button" class="btn-secondary" (click)="cancelReassignRoute()" [disabled]="reassigningRoute">Cancelar</button></div>
|
||||||
<div class="employee-load-list" aria-label="Carga de empleados">
|
|
||||||
<button type="button" class="employee-load-row" *ngFor="let employee of reassignmentEmployeeLoads" [class.selected]="reassignUserId === employee.id" [class.over-capacity]="employeeLoadStatus(employee) === 'over-capacity'" (click)="selectReassignmentEmployee(employee.id)">
|
|
||||||
<span class="employee-load-name">{{ employee.name }}</span>
|
|
||||||
<span class="employee-load-values">{{ employee.workHours | number:'1.0-1' }} / {{ employee.capacityHours | number:'1.0-1' }} h · {{ employee.utilization | number:'1.0-1' }}%</span>
|
|
||||||
<span class="employee-load-bar" [ngClass]="employeeLoadStatus(employee)"><span [style.width.%]="employeeLoadWidth(employee)"></span></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="error-card" *ngIf="reassignError">{{ reassignError }}</div>
|
<div class="error-card" *ngIf="reassignError">{{ reassignError }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,32 +152,26 @@
|
|||||||
|
|
||||||
<section class="daily-routes-card" *ngIf="dailySummaries.length">
|
<section class="daily-routes-card" *ngIf="dailySummaries.length">
|
||||||
<h3>Rutas diarias</h3>
|
<h3>Rutas diarias</h3>
|
||||||
<article class="daily-route-group" *ngFor="let group of dailyGroups">
|
<article class="daily-route" *ngFor="let day of dailySummaries" [class.over-capacity]="day.exceedsCapacity">
|
||||||
<button type="button" class="daily-route-toggle" (click)="toggleDailyRouteDay(group.day)" [attr.aria-expanded]="isDailyRouteDayExpanded(group.day)">
|
<strong>{{ day.userName }} · {{ day.day }}</strong>
|
||||||
<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>{{ day.routes.length }} paradas · {{ day.distanceKm | number:'1.1-1' }} km · {{ (day.travelHours * 60) | number:'1.0-0' }} min</span>
|
||||||
<span>{{ group.totalStops }} paradas · {{ group.totalDistance | number:'1.1-1' }} km</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>
|
||||||
</button>
|
|
||||||
<div class="daily-route-group-content" *ngIf="isDailyRouteDayExpanded(group.day)">
|
|
||||||
<article class="daily-route" *ngFor="let route of group.routes" [class.over-capacity]="route.exceedsCapacity">
|
|
||||||
<strong>{{ route.userName }}</strong>
|
|
||||||
<span>{{ route.routes.length }} paradas · {{ route.distanceKm | number:'1.1-1' }} km</span>
|
|
||||||
<small [attr.aria-label]="route.exceedsCapacity ? 'Capacidad excedida' : 'Dentro de capacidad'">{{ route.totalHours | number:'1.1-1' }} / {{ route.capacityHours | number:'1.1-1' }} h {{ route.exceedsCapacity ? '· Excede capacidad' : '· Capacidad disponible' }}</small>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
<div class="routes-list" *ngIf="filteredRoutes.length">
|
||||||
|
<h3>{{ selectedUsuarioId === 'Todos' ? 'PDVs de todos los usuarios' : 'PDVs del usuario' }} ({{ filteredRoutes.length }})</h3>
|
||||||
<section class="unassigned-bottom-drawer" *ngIf="showUnassigned && selectedUsuarioId === 'Todos' && selectedDia === 'Todos' && unassignedRoutes.length" [class.open]="isUnassignedDrawerOpen">
|
<ul>
|
||||||
<button type="button" class="unassigned-drawer-toggle" (click)="toggleUnassignedDrawer()" [attr.aria-expanded]="isUnassignedDrawerOpen">
|
<li *ngFor="let r of filteredRoutes; let i = index" tabindex="0" role="button"
|
||||||
<span><i class="pi pi-exclamation-triangle"></i> PDVs sin ruta ({{ unassignedRoutes.length }})</span>
|
[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"
|
||||||
<i class="pi" [class.pi-chevron-up]="!isUnassignedDrawerOpen" [class.pi-chevron-down]="isUnassignedDrawerOpen"></i>
|
(click)="selectRouteFromList(r)" (keydown.enter)="selectRouteFromList(r)" (keydown.space)="selectRouteFromList(r); $event.preventDefault()">
|
||||||
</button>
|
<span class="idx">{{ routeVisitOrder(r) }}</span>
|
||||||
<div class="unassigned-drawer-content" *ngIf="isUnassignedDrawerOpen">
|
<div class="li-main">
|
||||||
<button type="button" class="unassigned-map-item" *ngFor="let pending of unassignedRoutes" [class.selected-pending]="selectedUnassigned?.pdv_id === pending.pdv_id" (click)="selectUnassigned(pending)">
|
<strong>{{ r.json.nombre_pdv }}</strong>
|
||||||
<span class="pending-icon">!</span>
|
<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>
|
||||||
<span class="pending-text"><strong>{{ pending.pdv_nombre }}</strong><small>{{ pending.motivo }}</small></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<i class="pi pi-chevron-right"></i>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
+89
-626
File diff suppressed because it is too large
Load Diff
@@ -1,31 +0,0 @@
|
|||||||
.pdvs-topbar{display:flex;align-items:center;gap:10px;padding:9px 24px;border-bottom:3px solid var(--glm-verde);background:#fff;color:var(--glm-azul);font-weight:700}.pdvs-topbar img{height:38px}.pdvs-topbar .user-profile{display:flex;align-items:center;gap:8px;margin-left:auto}.pdvs-topbar .user-profile img,.avatar-fallback{width:30px;height:30px;border-radius:50%}.avatar-fallback{display:grid;place-items:center;background:var(--glm-verde);color:#fff}.pdvs-topbar button{padding:8px 10px;border:1px solid var(--glm-gris-medio);border-radius:4px;background:#fff;color:var(--glm-azul);font-weight:700;cursor:pointer}.pdvs-page{max-width:1200px;margin:auto;padding:26px 24px}.pdvs-page>header{display:flex;justify-content:space-between;gap:20px;border-bottom:3px solid var(--glm-verde);padding-bottom:14px}.pdvs-page h1,.pdvs-page h2{margin:0;color:var(--glm-azul)}.pdvs-page header p{color:var(--glm-acero)}.import-button,.primary{padding:9px 12px;border-radius:4px;background:var(--glm-azul);color:#fff;font-weight:700;cursor:pointer}.import-button input{display:none}.pdv-form,.pdv-table{margin-top:18px;padding:18px;border:1px solid var(--glm-gris-medio);border-top:3px solid var(--glm-azul);border-radius:4px;background:#fff}.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-top:14px}.form-grid label{display:grid;gap:5px;color:var(--glm-azul);font-size:.75rem;font-weight:700;text-transform:uppercase}.form-grid input,.form-grid select{padding:9px;border:1px solid #9aaab3;border-radius:4px;background:#fff;color:#2f3f48}.form-actions{display:flex;gap:8px;margin-top:14px}.form-actions button{padding:9px 12px;border:1px solid var(--glm-gris-medio);border-radius:4px;cursor:pointer}.table-wrap{overflow:auto;margin-top:12px}table{width:100%;border-collapse:collapse;font-size:.82rem}th{padding:9px;background:var(--glm-azul);color:#fff;text-align:left}td{padding:9px;border-bottom:1px solid var(--glm-gris-claro)}td button{margin-right:5px;padding:6px;border:1px solid var(--glm-gris-medio);border-radius:4px;background:#fff;cursor:pointer}.danger{color:#c9252d}.error{color:#c9252d}.success{color:var(--glm-verde);font-weight:700}@media(max-width:650px){.pdvs-topbar{padding:8px 12px;flex-wrap:wrap}.pdvs-page{padding:18px 12px}.pdvs-page>header{flex-direction:column}}
|
|
||||||
.import-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; width: fit-content; min-width: 0; white-space: nowrap; text-decoration: none; }
|
|
||||||
.pdvs-page > header .import-button { align-self: flex-start; height: auto; }
|
|
||||||
.pdv-table td:last-child { white-space: nowrap; }
|
|
||||||
.pdv-table td:last-child button { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; margin: 0 6px 0 0; padding: 0; font-size: 1rem; line-height: 1; }
|
|
||||||
.pdv-table td:last-child button.edit-button { width: auto; min-width: 86px; padding: 0 10px; gap: 6px; }
|
|
||||||
.pdv-table td:last-child button.edit-button,
|
|
||||||
.pdv-table td.actions-cell button.edit-button { display: inline-flex !important; align-items: center !important; justify-content: center !important; width: auto !important; min-width: 86px !important; height: 38px !important; padding: 0 12px !important; gap: 6px !important; border: 1px solid var(--glm-gris-medio) !important; border-radius: 4px !important; background: #fff !important; color: var(--glm-azul) !important; font: 700 .78rem/1 Arial, sans-serif !important; visibility: visible !important; opacity: 1 !important; }
|
|
||||||
.pdv-table td:last-child button.edit-button span,
|
|
||||||
.pdv-table td.actions-cell button.edit-button span { display: inline !important; color: var(--glm-azul) !important; font-size: .78rem !important; font-weight: 700 !important; visibility: visible !important; }
|
|
||||||
.pdv-table td.actions-cell { white-space: nowrap; min-width: 170px; }
|
|
||||||
.pdv-table td:last-child button:last-child { margin-right: 0; }
|
|
||||||
.pdv-table td:last-child button.danger { color: #c9252d; }
|
|
||||||
.pdv-table .row-actions { display: flex; gap: 5px; min-width: 250px; white-space: nowrap; }
|
|
||||||
.pdv-table .row-actions button { width: auto !important; min-width: 0 !important; height: auto !important; margin: 0 !important; padding: 7px 10px !important; border: 1px solid var(--glm-gris-medio) !important; border-radius: 4px !important; background: #fff !important; color: var(--glm-azul) !important; font: 600 .78rem/1.2 Arial, sans-serif !important; }
|
|
||||||
.pdv-table .row-actions button:hover { background: var(--glm-verde-bg) !important; border-color: var(--glm-verde) !important; }
|
|
||||||
.pdv-table .row-actions button.delete { color: #c9252d !important; }
|
|
||||||
.pdv-table .table-header { display:flex; justify-content:space-between; align-items:center; gap:12px; }.pdv-table .table-header > div { display:flex; align-items:center; gap:8px; }.pdv-table .selected-count { color:var(--glm-azul); font-size:.78rem; font-weight:700; }.pdv-table .table-header button,.pdv-table .bulk-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:7px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.pdv-table .table-header button:hover,.pdv-table .bulk-actions button:hover:not(:disabled) { background:var(--glm-verde-bg); border-color:var(--glm-verde); }.pdv-table .bulk-actions { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0; padding:12px; border:1px solid var(--glm-verde-claro); background:var(--glm-verde-bg); }.pdv-table .bulk-actions button.delete { color:#c9252d; }.pdv-table .select-column { width:38px; }
|
|
||||||
.page-back-button { display:inline-flex; align-items:center; gap:6px; margin-bottom:18px; padding:8px 11px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:700 .82rem Arial,sans-serif; }.page-back-button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.pdvs-page { padding: 18px 14px; }
|
|
||||||
.pdvs-page > header { align-items: flex-start; flex-direction: column; }
|
|
||||||
.import-button { align-self: flex-start; }
|
|
||||||
}
|
|
||||||
.success { color: var(--glm-verde); font-weight: 700; }
|
|
||||||
.error { color: #c9252d; font-weight: 700; }
|
|
||||||
.import-errors { margin: 12px 0 0; padding: 12px 14px; border: 1px solid #F5A6A0; border-left: 3px solid #c9252d; border-radius: 4px; background: #fff5f4; color: var(--glm-gris-oscuro); font-size: .85rem; }
|
|
||||||
.import-errors p { margin: 0 0 8px; font-weight: 700; }
|
|
||||||
.import-errors ul { margin: 0; padding-left: 18px; }
|
|
||||||
.import-errors li { margin-bottom: 4px; }
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<div class="pdvs-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
|
|
||||||
<main class="pdvs-page"><button type="button" class="page-back-button" (click)="goBack()"><i class="pi pi-arrow-left"></i> Volver</button><header><div><h1>Gestionar PDVs</h1><p>{{ pdvs.length }} puntos de venta registrados en este proyecto</p></div><label class="import-button"><i class="pi pi-file-import"></i> {{ importing ? 'Importando...' : 'Importar Excel' }}<input type="file" accept=".xlsx,.xls" [disabled]="importing" (change)="importFile($event)"></label></header>
|
|
||||||
<p class="success" *ngIf="message">{{ message }}</p><p class="error" *ngIf="error">{{ error }}</p><div class="import-errors" *ngIf="importErrors.length"><p *ngIf="importMissingHeaders.length">Faltan: {{ importMissingHeaders.join(', ') }}</p><ul><li *ngFor="let e of importErrors.slice(0, 8)"><strong>Fila {{ e.row }} — {{ e.field }}:</strong> {{ e.message }}<span *ngIf="e.value !== undefined && e.value !== null"> ({{ e.value }})</span></li><li *ngIf="importErrors.length > 8">+ {{ importErrors.length - 8 }} errores más...</li></ul></div>
|
|
||||||
<section class="pdv-form"><h2>{{ editing ? 'Editar PDV' : 'Nuevo PDV' }}</h2><div class="form-grid"><label>ID<input [(ngModel)]="form.id"></label><label>Nombre<input [(ngModel)]="form.nombre"></label><label>Segmentación<select [(ngModel)]="form.segmentacion"><option *ngFor="let value of ['AA','A','B','C','D']" [value]="value">{{ value }}</option></select></label><label>Latitud<input type="number" step="any" [(ngModel)]="form.latitud"></label><label>Longitud<input type="number" step="any" [(ngModel)]="form.longitud"></label><label>Visitas semanales<input type="number" min="1" max="7" [(ngModel)]="form.visitas"></label><label>Duración (horas)<input type="number" min="0.1" step="0.1" [(ngModel)]="form.duracion"></label><label>Prioridad<input type="number" min="1" max="5" [(ngModel)]="form.prioridad"></label><label>Cobertura<input [(ngModel)]="form.cobertura"></label><label>Zona<input [(ngModel)]="form.zona"></label><label>Región<input [(ngModel)]="form.region"></label><label>Estado<input [(ngModel)]="form.estado"></label><label>Ciudad<input [(ngModel)]="form.ciudad"></label><label>Estado<select [(ngModel)]="form.activo"><option [ngValue]="true">Activo</option><option [ngValue]="false">Inactivo</option></select></label></div><div class="form-actions"><button type="button" class="primary" (click)="save()" [disabled]="saving">{{ saving ? 'Guardando...' : (editing ? 'Guardar cambios' : 'Crear PDV') }}</button><button type="button" *ngIf="editing" (click)="cancelEdit()">Cancelar</button></div></section>
|
|
||||||
<section class="pdv-table"><div class="table-header"><h2>PDVs del proyecto</h2><div><span class="selected-count" *ngIf="selectedIds.size">{{ selectedIds.size }} seleccionados</span><button type="button" (click)="toggleAll(!allSelected)" [disabled]="loading">{{ allSelected ? 'Deseleccionar todos' : 'Seleccionar todos' }}</button></div></div><p *ngIf="loading">Cargando...</p><div class="bulk-actions" *ngIf="selectedIds.size"><button type="button" (click)="setSelectedActive(true)" [disabled]="bulkSaving">Activar</button><button type="button" (click)="setSelectedActive(false)" [disabled]="bulkSaving">Desactivar</button><button type="button" class="delete" (click)="removeSelected()" [disabled]="bulkSaving">Eliminar seleccionados</button></div><div class="table-wrap" *ngIf="!loading"><table><thead><tr><th class="select-column"><input type="checkbox" [checked]="allSelected" (change)="toggleAll($any($event.target).checked)" aria-label="Seleccionar todos los PDVs"></th><th>ID</th><th>Nombre</th><th>Segmentación</th><th>Ciudad</th><th>Estado</th><th>Zona</th><th>Acciones</th></tr></thead><tbody><tr *ngFor="let pdv of pdvs"><td><input type="checkbox" [checked]="selectedIds.has(pdv.id)" (change)="toggleSelection(pdv.id, $any($event.target).checked)" [attr.aria-label]="'Seleccionar ' + pdv.pdv_id"></td><td>{{ pdv.pdv_id }}</td><td>{{ value(pdv, 'nombre_del_pdv') }}</td><td>{{ value(pdv, 'segmentacion') }}</td><td>{{ value(pdv, 'ciudad') }}</td><td>{{ value(pdv, 'estado') }}</td><td>{{ value(pdv, 'zona') }}</td><td class="row-actions"><button type="button" (click)="edit(pdv)">Editar</button><button type="button" (click)="toggleActive(pdv)">{{ isActive(pdv) ? 'Desactivar' : 'Activar' }}</button><button type="button" class="delete" (click)="remove(pdv)">Eliminar</button></td></tr><tr *ngIf="!pdvs.length"><td colspan="8">No hay PDVs registrados.</td></tr></tbody></table></div></section></main>
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
|
||||||
import * as XLSX from 'xlsx';
|
|
||||||
import { ProjectService } from '../../core/services/project.service';
|
|
||||||
import { RuteroProyectoPdv } from '../../core/models/project.model';
|
|
||||||
import { ExcelPreviewService, FieldError } from '../../shared/services/excel-preview.service';
|
|
||||||
import { normalizeRow } from '../../shared/utils/header-normalization.utils';
|
|
||||||
import { AuthService } from '../../core/services/auth.service';
|
|
||||||
import { UserProfileService } from '../../core/services/user-profile.service';
|
|
||||||
|
|
||||||
interface PdvForm {
|
|
||||||
id: string;
|
|
||||||
nombre: string;
|
|
||||||
segmentacion: string;
|
|
||||||
latitud: number;
|
|
||||||
longitud: number;
|
|
||||||
visitas: number;
|
|
||||||
duracion: number;
|
|
||||||
prioridad: number;
|
|
||||||
activo: boolean;
|
|
||||||
cobertura: string;
|
|
||||||
zona: string;
|
|
||||||
region: string;
|
|
||||||
estado: string;
|
|
||||||
ciudad: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
standalone: true,
|
|
||||||
selector: 'app-pdvs',
|
|
||||||
imports: [CommonModule, FormsModule],
|
|
||||||
templateUrl: './pdvs.component.html',
|
|
||||||
styleUrl: './pdvs.component.css'
|
|
||||||
})
|
|
||||||
export class PdvsComponent implements OnInit {
|
|
||||||
projectId = '';
|
|
||||||
pdvs: RuteroProyectoPdv[] = [];
|
|
||||||
form = this.emptyForm();
|
|
||||||
editing: RuteroProyectoPdv | null = null;
|
|
||||||
loading = true;
|
|
||||||
saving = false;
|
|
||||||
importing = false;
|
|
||||||
error = '';
|
|
||||||
message = '';
|
|
||||||
importErrors: FieldError[] = [];
|
|
||||||
importMissingHeaders: string[] = [];
|
|
||||||
projectCountry = 'Republica Dominicana';
|
|
||||||
selectedIds = new Set<string>();
|
|
||||||
bulkSaving = false;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private projects: ProjectService,
|
|
||||||
private preview: ExcelPreviewService,
|
|
||||||
private auth: AuthService,
|
|
||||||
private route: ActivatedRoute,
|
|
||||||
private router: Router,
|
|
||||||
public profile: UserProfileService,
|
|
||||||
private cdr: ChangeDetectorRef
|
|
||||||
) {}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.projectId = this.route.snapshot.paramMap.get('id') ?? '';
|
|
||||||
if (!this.projectId) { void this.router.navigate(['/home']); return; }
|
|
||||||
void this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
async load(): Promise<void> {
|
|
||||||
this.loading = true;
|
|
||||||
this.error = '';
|
|
||||||
try {
|
|
||||||
this.pdvs = await this.projects.pdvs(this.projectId);
|
|
||||||
this.selectedIds.clear();
|
|
||||||
const country = this.pdvs.map(pdv => normalizeRow(pdv.pdv_data)['pais']).find(Boolean);
|
|
||||||
if (country) this.projectCountry = String(country);
|
|
||||||
}
|
|
||||||
catch (error: any) { this.error = error?.message ?? 'No se pudieron cargar los PDVs.'; }
|
|
||||||
finally { this.loading = false; this.cdr.detectChanges(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
edit(pdv: RuteroProyectoPdv): void {
|
|
||||||
const row = normalizeRow(pdv.pdv_data);
|
|
||||||
this.editing = pdv;
|
|
||||||
this.form = {
|
|
||||||
id: String(row['id'] ?? pdv.pdv_id), nombre: String(row['nombre_del_pdv'] ?? ''), segmentacion: String(row['segmentacion'] ?? 'D'),
|
|
||||||
latitud: Number(row['latitud'] ?? 0), longitud: Number(row['longitud'] ?? 0), visitas: Number(row['visitas_semanales'] ?? 1),
|
|
||||||
duracion: Number(row['duracion_visitahoras'] ?? row['duracion_visita_horas'] ?? 1), prioridad: Number(row['prioridad'] ?? 5),
|
|
||||||
activo: !['no', 'n', '0', 'false'].includes(String(row['activo'] ?? 'si').toLowerCase()), cobertura: String(row['cobertura'] ?? ''),
|
|
||||||
zona: String(row['zona'] ?? ''), region: String(row['region'] ?? ''), estado: String(row['estado'] ?? ''), ciudad: String(row['ciudad'] ?? '')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelEdit(): void { this.editing = null; this.form = this.emptyForm(); this.error = ''; }
|
|
||||||
|
|
||||||
async save(): Promise<void> {
|
|
||||||
if (!this.form.id.trim() || !this.form.nombre.trim()) { this.error = 'ID y nombre son obligatorios.'; return; }
|
|
||||||
if (!Number.isFinite(this.form.latitud) || !Number.isFinite(this.form.longitud)) { this.error = 'Coordenadas inválidas.'; return; }
|
|
||||||
this.saving = true; this.error = ''; this.message = '';
|
|
||||||
const row: Record<string, unknown> = {
|
|
||||||
...(this.editing?.pdv_data ?? {}), ID: this.form.id.trim(), 'Nombre del PDV': this.form.nombre.trim(), Segmentación: this.form.segmentacion,
|
|
||||||
Latitud: this.form.latitud, Longitud: this.form.longitud, 'Visitas semanales': this.form.visitas, 'Duración visita(horas)': this.form.duracion,
|
|
||||||
Prioridad: this.form.prioridad, Activo: this.form.activo ? 'Sí' : 'No', Cobertura: this.form.cobertura, Zona: this.form.zona,
|
|
||||||
Región: this.form.region, Estado: this.form.estado, Ciudad: this.form.ciudad, País: normalizeRow(this.editing?.pdv_data ?? {})['pais'] ?? this.projectCountry
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
if (this.editing) await this.projects.updatePdv(this.editing.id, row);
|
|
||||||
else await this.projects.addPdv(this.projectId, row);
|
|
||||||
this.message = this.editing ? 'PDV actualizado.' : 'PDV creado.';
|
|
||||||
this.cancelEdit();
|
|
||||||
await this.load();
|
|
||||||
} catch (error: any) { this.error = error?.message ?? 'No se pudo guardar el PDV.'; }
|
|
||||||
finally { this.saving = false; this.cdr.detectChanges(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async importFile(event: Event): Promise<void> {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const file = input.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
this.importing = true; this.error = ''; this.message = ''; this.importErrors = []; this.importMissingHeaders = [];
|
|
||||||
try {
|
|
||||||
const result = await this.preview.previewFile(file, 'PDV');
|
|
||||||
if (!result.valid) {
|
|
||||||
this.importErrors = result.errors;
|
|
||||||
this.importMissingHeaders = result.missingHeaders;
|
|
||||||
throw new Error(result.errors[0]?.message ?? 'El archivo PDV contiene errores.');
|
|
||||||
}
|
|
||||||
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
|
|
||||||
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(workbook.Sheets[workbook.SheetNames[0]], { defval: null, raw: true });
|
|
||||||
await this.projects.upsertPdvs(this.projectId, rows);
|
|
||||||
this.message = `${rows.length} PDVs importados o actualizados.`;
|
|
||||||
await this.load();
|
|
||||||
} catch (error: any) { this.error = error?.message ?? 'No se pudo importar el archivo.'; }
|
|
||||||
finally { this.importing = false; input.value = ''; this.cdr.detectChanges(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(pdv: RuteroProyectoPdv): Promise<void> {
|
|
||||||
if (!confirm(`¿Eliminar el PDV ${pdv.pdv_id}?`)) return;
|
|
||||||
try { await this.projects.removePdv(pdv.id); await this.load(); }
|
|
||||||
catch (error: any) { this.error = error?.message ?? 'No se pudo eliminar el PDV.'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
isActive(pdv: RuteroProyectoPdv): boolean {
|
|
||||||
return !['no', 'n', '0', 'false'].includes(String(normalizeRow(pdv.pdv_data)['activo'] ?? 'si').toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleActive(pdv: RuteroProyectoPdv): Promise<void> {
|
|
||||||
const active = !this.isActive(pdv);
|
|
||||||
try {
|
|
||||||
await this.projects.updatePdv(pdv.id, { ...pdv.pdv_data, Activo: active ? 'Sí' : 'No' });
|
|
||||||
this.message = active ? 'PDV activado.' : 'PDV desactivado.';
|
|
||||||
await this.load();
|
|
||||||
} catch (error: any) {
|
|
||||||
this.error = error?.message ?? 'No se pudo actualizar el estado del PDV.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get allSelected(): boolean {
|
|
||||||
return this.pdvs.length > 0 && this.pdvs.every(pdv => this.selectedIds.has(pdv.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
get selectedPdvs(): RuteroProyectoPdv[] {
|
|
||||||
return this.pdvs.filter(pdv => this.selectedIds.has(pdv.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleSelection(id: string, checked: boolean): void {
|
|
||||||
checked ? this.selectedIds.add(id) : this.selectedIds.delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleAll(checked: boolean): void {
|
|
||||||
this.selectedIds = checked ? new Set(this.pdvs.map(pdv => pdv.id)) : new Set<string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
async setSelectedActive(active: boolean): Promise<void> {
|
|
||||||
const selected = this.selectedPdvs;
|
|
||||||
if (!selected.length) return;
|
|
||||||
this.bulkSaving = true;
|
|
||||||
this.error = '';
|
|
||||||
try {
|
|
||||||
await Promise.all(selected.map(pdv => this.projects.updatePdv(pdv.id, { ...pdv.pdv_data, Activo: active ? 'Sí' : 'No' })));
|
|
||||||
this.message = `${selected.length} PDVs ${active ? 'activados' : 'desactivados'}.`;
|
|
||||||
await this.load();
|
|
||||||
} catch (error: any) {
|
|
||||||
this.error = error?.message ?? 'No se pudo actualizar el estado de los PDVs.';
|
|
||||||
} finally {
|
|
||||||
this.bulkSaving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeSelected(): Promise<void> {
|
|
||||||
const selected = this.selectedPdvs;
|
|
||||||
if (!selected.length || !confirm(`¿Eliminar ${selected.length} PDVs seleccionados? Esta acción no se puede deshacer.`)) return;
|
|
||||||
this.bulkSaving = true;
|
|
||||||
this.error = '';
|
|
||||||
try {
|
|
||||||
await Promise.all(selected.map(pdv => this.projects.removePdv(pdv.id)));
|
|
||||||
this.message = `${selected.length} PDVs eliminados.`;
|
|
||||||
await this.load();
|
|
||||||
} catch (error: any) {
|
|
||||||
this.error = error?.message ?? 'No se pudieron eliminar los PDVs.';
|
|
||||||
} finally {
|
|
||||||
this.bulkSaving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
value(pdv: RuteroProyectoPdv, field: string): unknown { return normalizeRow(pdv.pdv_data)[field] ?? '-'; }
|
|
||||||
goBack(): void { void this.router.navigate(['/proyectos', this.projectId]); }
|
|
||||||
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
|
|
||||||
|
|
||||||
private emptyForm(): PdvForm {
|
|
||||||
return { id: '', nombre: '', segmentacion: 'D', latitud: 18.4861, longitud: -69.9312, visitas: 1, duracion: 1, prioridad: 5, activo: true, cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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']); }
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
.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,17 +87,13 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(108, 194, 74, 0.15);
|
box-shadow: 0 0 0 3px rgba(108, 194, 74, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.google-login {
|
button[type="submit"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding: 11px;
|
padding: 11px;
|
||||||
background: #fff;
|
background: var(--glm-azul);
|
||||||
color: var(--glm-gris-oscuro);
|
color: #fff;
|
||||||
border: 1px solid var(--glm-gris-medio);
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
@@ -106,25 +102,15 @@
|
|||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.google-login:hover:not(:disabled) {
|
button[type="submit"]:hover:not(:disabled) {
|
||||||
border-color: var(--glm-azul);
|
background: var(--glm-verde);
|
||||||
background: var(--glm-verde-bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.google-login:disabled {
|
button[type="submit"]:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.google-mark { color: #4285f4; font-size: 1.1rem; font-weight: 700; }
|
|
||||||
.domain-note { margin: 10px 0 0; color: var(--glm-acero); font-size: .78rem; text-align: center; }
|
|
||||||
|
|
||||||
.login-error {
|
|
||||||
margin: 12px 0 0;
|
|
||||||
color: #c9252d;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
|
|||||||
@@ -11,16 +11,37 @@
|
|||||||
Planifica rutas inteligentes de forma eficiente
|
Planifica rutas inteligentes de forma eficiente
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="login-actions">
|
<form (ngSubmit)="login()" #form="ngForm">
|
||||||
<button type="button" class="google-login" (click)="loginWithGoogle()" [disabled]="loading">
|
|
||||||
<span class="google-mark">G</span>
|
<div class="input-group">
|
||||||
<span *ngIf="!loading">Continuar con Google</span>
|
<label>Email</label>
|
||||||
<span *ngIf="loading">{{ loadingText }}</span>
|
<input
|
||||||
</button>
|
type="email"
|
||||||
<p class="domain-note">Solo cuentas @gomezleemarketing.com</p>
|
name="email"
|
||||||
<p class="login-error" *ngIf="error">{{ error }}</p>
|
[(ngModel)]="email"
|
||||||
|
required
|
||||||
|
placeholder="usuario@empresa.com"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label>Contraseña</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
[(ngModel)]="password"
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" [disabled]="loading || form.invalid">
|
||||||
|
<span *ngIf="!loading">Iniciar sesión</span>
|
||||||
|
<span *ngIf="loading">Verificando ruta...</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
© 2026 Route Planner System
|
© 2026 Route Planner System
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,57 +1,29 @@
|
|||||||
import { Component, ChangeDetectionStrategy, ChangeDetectorRef, OnInit } from '@angular/core';
|
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { AuthService } from '../../core/services/auth.service';
|
|
||||||
import { UserProfileService } from '../../core/services/user-profile.service';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
imports: [CommonModule],
|
imports: [CommonModule, FormsModule],
|
||||||
templateUrl: './login.component.html',
|
templateUrl: './login.component.html',
|
||||||
styleUrls: ['./login.component.css'],
|
styleUrls: ['./login.component.css'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class LoginComponent implements OnInit {
|
export class LoginComponent {
|
||||||
|
|
||||||
|
email = '';
|
||||||
|
password = '';
|
||||||
loading = false;
|
loading = false;
|
||||||
error = '';
|
|
||||||
loadingText = 'Redirigiendo a Google...';
|
|
||||||
|
|
||||||
constructor(private router: Router, private auth: AuthService, private profile: UserProfileService, private cdr: ChangeDetectorRef) {}
|
constructor(private router: Router) {}
|
||||||
|
login() {
|
||||||
async ngOnInit(): Promise<void> {
|
|
||||||
const session = await this.auth.getSession();
|
|
||||||
if (!session) return;
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.loadingText = 'Obteniendo datos del usuario...';
|
|
||||||
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.cdr.detectChanges();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loginWithGoogle() {
|
setTimeout(() => {
|
||||||
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.loading = false;
|
||||||
this.cdr.detectChanges();
|
this.router.navigate(['/home']);
|
||||||
}
|
}, 1500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,4 +36,4 @@ export const usuarioRowSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type UsuarioRow = z.infer<typeof usuarioRowSchema>;
|
export type UsuarioRow = z.infer<typeof usuarioRowSchema>;
|
||||||
export const USUARIO_MAX_ROWS = 300;
|
export const USUARIO_MAX_ROWS = 50;
|
||||||
|
|||||||
@@ -165,15 +165,7 @@ export class ExcelPreviewService {
|
|||||||
'Longitud': cleanNum(row['longitud']),
|
'Longitud': cleanNum(row['longitud']),
|
||||||
'Visitas semanales': Number.isFinite(visits) ? visits : 1,
|
'Visitas semanales': Number.isFinite(visits) ? visits : 1,
|
||||||
'Duración visita(horas)': Number.isFinite(duration) ? duration : weeklyHours,
|
'Duración visita(horas)': Number.isFinite(duration) ? duration : weeklyHours,
|
||||||
'Prioridad': cleanNum(row['prioridad']),
|
'Prioridad': cleanNum(row['prioridad'])
|
||||||
'Activo': row['activo'] ?? 'Sí',
|
|
||||||
'País': row['pais'] ?? row['country'] ?? '',
|
|
||||||
'Cobertura': row['cobertura'] ?? '',
|
|
||||||
'Zona': row['zona'] ?? '',
|
|
||||||
'Región': row['region'] ?? row['regional'] ?? '',
|
|
||||||
'Estado': row['estado'] ?? '',
|
|
||||||
'Ciudad': row['ciudad'] ?? '',
|
|
||||||
'Metro de prueba': row['metro_de_prueba'] ?? ''
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const output = XLSX.utils.book_new();
|
const output = XLSX.utils.book_new();
|
||||||
|
|||||||
@@ -3,63 +3,46 @@ import * as XLSX from 'xlsx';
|
|||||||
import { saveAs } from 'file-saver';
|
import { saveAs } from 'file-saver';
|
||||||
import { RouteState } from '../../core/route-state/route';
|
import { RouteState } from '../../core/route-state/route';
|
||||||
import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils';
|
import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils';
|
||||||
import { PlanningFilterService } from '../../core/services/planning-filter.service';
|
|
||||||
import { ProjectService } from '../../core/services/project.service';
|
|
||||||
import { currencyForCountry, fuelCost, isFuelConfigured } from '../utils/fuel-estimation.utils';
|
|
||||||
import { recommendationForUnassigned } from '../utils/unassigned-recommendation.utils';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PlanningExportService {
|
export class PlanningExportService {
|
||||||
constructor(private routeState: RouteState, private filters: PlanningFilterService, private projects: ProjectService) {}
|
constructor(private routeState: RouteState) {}
|
||||||
|
|
||||||
async export(projectId: string): Promise<void> {
|
export(): void {
|
||||||
const project = await this.projects.get(projectId);
|
const assigned = this.routeState.getAssignedSnapshot();
|
||||||
const fuelConfig = { rendimientoKmL: project.rendimiento_km_l ?? null, precioPorLitro: project.precio_por_litro ?? null };
|
const unassigned = this.routeState.getNoAssignedSnapshot();
|
||||||
const fuelConfigured = isFuelConfigured(fuelConfig);
|
const users = new Map<string, { nombre: string; rutas: number; distancia: number; trabajo: number; capacidad: number }>();
|
||||||
const fuelCurrency = currencyForCountry(project.pais);
|
|
||||||
const assigned = this.filters.filterAssigned(this.routeState.getAssignedSnapshot());
|
|
||||||
const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot())
|
|
||||||
.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
|
|
||||||
const users = new Map<string, { nombre: string; visitas: number; pdvs: Set<string>; distancia: number; trabajo: number; capacidad: number }>();
|
|
||||||
|
|
||||||
for (const route of assigned) {
|
for (const route of assigned) {
|
||||||
const key = String(route.json.usuario_id);
|
const key = String(route.json.usuario_id);
|
||||||
const current = users.get(key) ?? {
|
const current = users.get(key) ?? {
|
||||||
nombre: route.json.nombre_usuario,
|
nombre: route.json.nombre_usuario,
|
||||||
visitas: 0,
|
rutas: 0,
|
||||||
pdvs: new Set<string>(),
|
|
||||||
distancia: 0,
|
distancia: 0,
|
||||||
trabajo: 0,
|
trabajo: 0,
|
||||||
capacidad: Number(route.json.hora_laboral_semanal_usuario) || 0
|
capacidad: Number(route.json.hora_laboral_semanal_usuario) || 0
|
||||||
};
|
};
|
||||||
current.visitas++;
|
current.rutas++;
|
||||||
current.pdvs.add(String(route.json.pdv_id));
|
|
||||||
current.distancia += Number(route.json.distancia) || 0;
|
current.distancia += Number(route.json.distancia) || 0;
|
||||||
current.trabajo += Number.parseFloat(String(route.json.horas_trabajo ?? '')) || 0;
|
current.trabajo += Number.parseFloat(String(route.json.horas_trabajo ?? '')) || 0;
|
||||||
users.set(key, current);
|
users.set(key, current);
|
||||||
}
|
}
|
||||||
|
|
||||||
const assignedIds = new Set(assigned.map(route => String(route.json.pdv_id)));
|
const total = assigned.length + unassigned.length;
|
||||||
const pending = unassigned.filter(route => !assignedIds.has(String(route.pdv_id)));
|
|
||||||
const unassignedIds = new Set(pending.map(route => String(route.pdv_id)));
|
|
||||||
const total = assignedIds.size + unassignedIds.size;
|
|
||||||
const dailyRoutes = summarizeDailyRoutes(assigned);
|
const dailyRoutes = summarizeDailyRoutes(assigned);
|
||||||
const totalDistance = dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0);
|
const totalDistance = dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0);
|
||||||
const totalTravel = dailyRoutes.reduce((sum, route) => sum + route.travelHours, 0);
|
const totalTravel = dailyRoutes.reduce((sum, route) => sum + route.travelHours, 0);
|
||||||
const fuelTotal = fuelCost(totalDistance, fuelConfig);
|
|
||||||
const workbook = XLSX.utils.book_new();
|
const workbook = XLSX.utils.book_new();
|
||||||
|
|
||||||
const summary = [
|
const summary = [
|
||||||
{ Indicador: 'PDVs totales', Valor: total },
|
{ Indicador: 'PDVs totales', Valor: total },
|
||||||
{ Indicador: 'PDVs asignados', Valor: assignedIds.size },
|
{ Indicador: 'PDVs asignados', Valor: assigned.length },
|
||||||
{ Indicador: 'PDVs sin asignar', Valor: unassignedIds.size },
|
{ Indicador: 'PDVs sin asignar', Valor: unassigned.length },
|
||||||
{ Indicador: 'Cobertura (%)', Valor: total ? Number(((assignedIds.size / total) * 100).toFixed(1)) : 0 },
|
{ Indicador: 'Cobertura (%)', Valor: total ? Number(((assigned.length / total) * 100).toFixed(1)) : 0 },
|
||||||
{ Indicador: 'Usuarios activos', Valor: users.size },
|
{ Indicador: 'Usuarios activos', Valor: users.size },
|
||||||
{ Indicador: 'Días planificados', Valor: new Set(assigned.map(route => route.json.dia)).size },
|
{ Indicador: 'Días planificados', Valor: new Set(assigned.map(route => route.json.dia)).size },
|
||||||
{ Indicador: 'Distancia total (km)', Valor: Number(totalDistance.toFixed(2)) },
|
{ Indicador: 'Distancia total (km)', Valor: Number(totalDistance.toFixed(2)) },
|
||||||
{ Indicador: 'Desplazamiento total (h)', Valor: Number(totalTravel.toFixed(2)) },
|
{ Indicador: 'Desplazamiento total (h)', Valor: Number(totalTravel.toFixed(2)) }
|
||||||
{ Indicador: 'Combustible total (L)', Valor: fuelConfigured ? Number(fuelTotal.liters.toFixed(2)) : 'Sin configurar' },
|
|
||||||
{ Indicador: `Costo combustible total (${fuelCurrency})`, Valor: fuelConfigured ? Number(fuelTotal.cost.toFixed(2)) : 'Sin configurar' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const assignedRows = sequenceRoutes(assigned).map(segment => ({
|
const assignedRows = sequenceRoutes(assigned).map(segment => ({
|
||||||
@@ -73,30 +56,26 @@ export class PlanningExportService {
|
|||||||
'Horas trabajo': segment.route.json.horas_trabajo,
|
'Horas trabajo': segment.route.json.horas_trabajo,
|
||||||
'Horas disponibles': segment.route.json.hora_laboral_semanal_usuario,
|
'Horas disponibles': segment.route.json.hora_laboral_semanal_usuario,
|
||||||
'Orden visita': segment.order,
|
'Orden visita': segment.order,
|
||||||
'Origen del tramo': segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}`,
|
'Origen del tramo': segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}`
|
||||||
'Costo combustible': fuelConfigured ? Number(fuelCost(Number(segment.route.json.distancia) || 0, fuelConfig).cost.toFixed(2)) : 'Sin configurar'
|
|
||||||
}));
|
}));
|
||||||
const unassignedRows = pending.map(route => ({
|
const unassignedRows = unassigned.map(route => ({
|
||||||
'PDV ID': route.pdv_id,
|
'PDV ID': route.pdv_id,
|
||||||
PDV: route.pdv_nombre,
|
PDV: route.pdv_nombre,
|
||||||
'Mínimo horas/semana': route.minimo_horas_semana,
|
'Mínimo horas/semana': route.minimo_horas_semana,
|
||||||
Latitud: route.latitud,
|
Latitud: route.latitud,
|
||||||
Longitud: route.longitud,
|
Longitud: route.longitud,
|
||||||
Motivo: route.motivo,
|
Motivo: route.motivo
|
||||||
Recomendación: route.recomendacion ?? ''
|
|
||||||
}));
|
}));
|
||||||
const userRows = Array.from(users, ([id, user]) => {
|
const userRows = Array.from(users, ([id, user]) => {
|
||||||
const utilization = user.capacidad ? (user.trabajo / user.capacidad) * 100 : 0;
|
const utilization = user.capacidad ? (user.trabajo / user.capacidad) * 100 : 0;
|
||||||
return {
|
return {
|
||||||
'Usuario ID': id,
|
'Usuario ID': id,
|
||||||
Usuario: user.nombre,
|
Usuario: user.nombre,
|
||||||
PDVs: user.pdvs.size,
|
Rutas: user.rutas,
|
||||||
Visitas: user.visitas,
|
|
||||||
'Distancia (km)': Number(user.distancia.toFixed(2)),
|
'Distancia (km)': Number(user.distancia.toFixed(2)),
|
||||||
'Horas trabajo': Number(user.trabajo.toFixed(2)),
|
'Horas trabajo': Number(user.trabajo.toFixed(2)),
|
||||||
'Capacidad (h)': user.capacidad,
|
'Capacidad (h)': user.capacidad,
|
||||||
'Utilización (%)': Number(utilization.toFixed(1)),
|
'Utilización (%)': Number(utilization.toFixed(1)),
|
||||||
[`Costo combustible (${fuelCurrency})`]: fuelConfigured ? Number(fuelCost(user.distancia, fuelConfig).cost.toFixed(2)) : 'Sin configurar',
|
|
||||||
Estado: utilization > 100 ? 'Excede capacidad' : utilization > 90 ? 'Riesgo' : 'OK'
|
Estado: utilization > 100 ? 'Excede capacidad' : utilization > 90 ? 'Riesgo' : 'OK'
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
import { saveAs } from 'file-saver';
|
|
||||||
import { environment } from '../../../environments/environment';
|
|
||||||
import { RouteState } from '../../core/route-state/route';
|
|
||||||
import { ProjectService } from '../../core/services/project.service';
|
|
||||||
import { PlanningFilterService } from '../../core/services/planning-filter.service';
|
|
||||||
import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils';
|
|
||||||
import { recommendationForUnassigned } from '../utils/unassigned-recommendation.utils';
|
|
||||||
import { currencyForCountry, fuelCost, isFuelConfigured } from '../utils/fuel-estimation.utils';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class RoutePdfReportService {
|
|
||||||
constructor(
|
|
||||||
private http: HttpClient,
|
|
||||||
private routeState: RouteState,
|
|
||||||
private projects: ProjectService,
|
|
||||||
private filters: PlanningFilterService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async download(projectId: string): Promise<void> {
|
|
||||||
const [project, assigned, unassigned] = await Promise.all([
|
|
||||||
this.projects.get(projectId),
|
|
||||||
Promise.resolve(this.filters.filterAssigned(this.routeState.getAssignedSnapshot())),
|
|
||||||
Promise.resolve(this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot()))
|
|
||||||
]);
|
|
||||||
const assignedIds = new Set(assigned.map(route => String(route.json.pdv_id)));
|
|
||||||
const pending = unassigned
|
|
||||||
.filter(route => !assignedIds.has(String(route.pdv_id)))
|
|
||||||
.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
|
|
||||||
const dailyRoutes = summarizeDailyRoutes(assigned);
|
|
||||||
const totalPdvs = assignedIds.size + new Set(pending.map(route => String(route.pdv_id))).size;
|
|
||||||
const totalDistanceKm = dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0);
|
|
||||||
const fuelConfig = { rendimientoKmL: project.rendimiento_km_l ?? null, precioPorLitro: project.precio_por_litro ?? null };
|
|
||||||
const fuelConfigured = isFuelConfigured(fuelConfig);
|
|
||||||
const fuelTotal = fuelCost(totalDistanceKm, fuelConfig);
|
|
||||||
const fuelCurrency = currencyForCountry(project.pais);
|
|
||||||
const payload = {
|
|
||||||
project: { id: project.id, name: project.nombre, country: project.pais ?? '', description: project.descripcion ?? '' },
|
|
||||||
filters: this.filters.snapshot(),
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
fuel: {
|
|
||||||
configured: fuelConfigured,
|
|
||||||
rendimientoKmL: fuelConfig.rendimientoKmL,
|
|
||||||
precioPorLitro: fuelConfig.precioPorLitro,
|
|
||||||
currency: fuelCurrency
|
|
||||||
},
|
|
||||||
summary: {
|
|
||||||
totalPdvs,
|
|
||||||
assignedPdvs: assignedIds.size,
|
|
||||||
unassignedPdvs: pending.length,
|
|
||||||
coveragePercent: totalPdvs ? Number(((assignedIds.size / totalPdvs) * 100).toFixed(1)) : 0,
|
|
||||||
totalDistanceKm: Number(totalDistanceKm.toFixed(2)),
|
|
||||||
totalWorkHours: Number(dailyRoutes.reduce((sum, route) => sum + route.workHours, 0).toFixed(2)),
|
|
||||||
fuelLiters: Number(fuelTotal.liters.toFixed(2)),
|
|
||||||
fuelCost: Number(fuelTotal.cost.toFixed(2)),
|
|
||||||
fuelCurrency
|
|
||||||
},
|
|
||||||
assignedRoutes: sequenceRoutes(assigned).map(segment => ({
|
|
||||||
usuarioId: segment.route.json.usuario_id,
|
|
||||||
usuario: segment.route.json.nombre_usuario,
|
|
||||||
pdvId: segment.route.json.pdv_id,
|
|
||||||
pdv: segment.route.json.nombre_pdv,
|
|
||||||
dia: segment.route.json.dia,
|
|
||||||
orden: segment.order,
|
|
||||||
origen: segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}`,
|
|
||||||
distanciaKm: segment.route.json.distancia,
|
|
||||||
desplazamientoHoras: segment.route.json.horas_desplazamiento,
|
|
||||||
horasTrabajo: segment.route.json.horas_trabajo,
|
|
||||||
costoCombustible: fuelConfigured ? Number(fuelCost(Number(segment.route.json.distancia) || 0, fuelConfig).cost.toFixed(2)) : null,
|
|
||||||
monedaCombustible: fuelCurrency,
|
|
||||||
cobertura: segment.route.json.cobertura ?? '',
|
|
||||||
zona: segment.route.json.zona ?? '',
|
|
||||||
region: segment.route.json.region ?? '',
|
|
||||||
estado: segment.route.json.estado ?? '',
|
|
||||||
ciudad: segment.route.json.ciudad ?? ''
|
|
||||||
})),
|
|
||||||
unassignedPdvs: pending
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const pdf = await firstValueFrom(this.http.post(environment.n8nPdfWebhookUrl, payload, { responseType: 'blob' }));
|
|
||||||
const fileName = `rutas_${this.fileName(project.nombre)}.pdf`;
|
|
||||||
saveAs(pdf, fileName);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(await this.messageForError(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fileName(value: string): string {
|
|
||||||
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'proyecto';
|
|
||||||
}
|
|
||||||
|
|
||||||
private async messageForError(error: unknown): Promise<string> {
|
|
||||||
const response = error as HttpErrorResponse;
|
|
||||||
if (response.error instanceof Blob) {
|
|
||||||
try {
|
|
||||||
const body = JSON.parse(await response.error.text()) as { message?: string; error?: string };
|
|
||||||
return body.message ?? body.error ?? 'No se pudo generar el PDF.';
|
|
||||||
} catch { /* use fallback */ }
|
|
||||||
}
|
|
||||||
return response.error?.message ?? response.message ?? 'No se pudo generar el PDF.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
export interface CountryCenter {
|
|
||||||
lat: number;
|
|
||||||
lng: number;
|
|
||||||
zoom: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(value: string | null | undefined): string {
|
|
||||||
return (value ?? '')
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
|
||||||
.replace(/[^a-z0-9]/gi, '')
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function centerForCountry(country: string | null | undefined): CountryCenter {
|
|
||||||
const normalized = normalize(country);
|
|
||||||
if (!normalized) return { lat: 19.4326, lng: -99.1332, zoom: 5 };
|
|
||||||
if (['mexico', 'mx', 'cdmx', 'ciudaddemexico', 'mexicocity', 'estadosunidosmexicanos'].includes(normalized)) {
|
|
||||||
return { lat: 19.4326, lng: -99.1332, zoom: 10 };
|
|
||||||
}
|
|
||||||
if (['republicadominicana', 'dominicana', 'rd', 'do', 'santodomingo'].includes(normalized)) {
|
|
||||||
return { lat: 18.4861, lng: -69.9312, zoom: 8 };
|
|
||||||
}
|
|
||||||
return { lat: 19.4326, lng: -99.1332, zoom: 5 };
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
export interface FuelConfig {
|
|
||||||
rendimientoKmL: number | null;
|
|
||||||
precioPorLitro: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function currencyForCountry(country: string | null | undefined): string {
|
|
||||||
const normalized = (country ?? '')
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
|
||||||
.replace(/[^a-z0-9]/gi, '')
|
|
||||||
.toLowerCase();
|
|
||||||
if (!normalized) return '$';
|
|
||||||
if (['mexico', 'mx', 'cdmx', 'ciudaddemexico', 'mexicocity', 'estadosunidosmexicanos'].includes(normalized)) return 'MX$';
|
|
||||||
if (['republicadominicana', 'dominicana', 'rd', 'do', 'santodomingo'].includes(normalized)) return 'RD$';
|
|
||||||
return '$';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isFuelConfigured(config: FuelConfig): boolean {
|
|
||||||
return (
|
|
||||||
config.rendimientoKmL != null &&
|
|
||||||
config.precioPorLitro != null &&
|
|
||||||
Number.isFinite(config.rendimientoKmL) &&
|
|
||||||
Number.isFinite(config.precioPorLitro) &&
|
|
||||||
config.rendimientoKmL > 0 &&
|
|
||||||
config.precioPorLitro >= 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fuelLiters(distanceKm: number, rendimientoKmL: number): number {
|
|
||||||
if (!Number.isFinite(distanceKm) || distanceKm <= 0) return 0;
|
|
||||||
if (!Number.isFinite(rendimientoKmL) || rendimientoKmL <= 0) return 0;
|
|
||||||
return distanceKm / rendimientoKmL;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fuelCost(distanceKm: number, config: FuelConfig): { liters: number; cost: number } {
|
|
||||||
if (!isFuelConfigured(config)) return { liters: 0, cost: 0 };
|
|
||||||
const liters = fuelLiters(distanceKm, config.rendimientoKmL as number);
|
|
||||||
return { liters, cost: liters * (config.precioPorLitro as number) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatFuelCost(cost: number, currency: string): string {
|
|
||||||
return `${currency} ${cost.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
@@ -1,37 +1,22 @@
|
|||||||
const assignedColors = new Map<string, string>();
|
|
||||||
const usedColors = new Set<string>();
|
|
||||||
|
|
||||||
function hashId(value: string): number {
|
|
||||||
let hash = 2166136261;
|
|
||||||
for (let index = 0; index < value.length; index++) {
|
|
||||||
hash ^= value.charCodeAt(index);
|
|
||||||
hash = Math.imul(hash, 16777619);
|
|
||||||
}
|
|
||||||
return hash >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function routeColor(id: string): string {
|
|
||||||
const existing = assignedColors.get(id);
|
|
||||||
if (existing) return existing;
|
|
||||||
const hash = hashId(id);
|
|
||||||
let attempt = 0;
|
|
||||||
let color = '';
|
|
||||||
do {
|
|
||||||
// Restricted to dark navy/indigo/violet/plum. Avoids red, cyan, green, yellow and orange.
|
|
||||||
const hue = 225 + ((hash + attempt * 37) % 106);
|
|
||||||
const saturation = 58 + (((hash >>> 8) + attempt * 11) % 27);
|
|
||||||
const lightness = 27 + (((hash >>> 16) + attempt * 7) % 17);
|
|
||||||
color = `hsl(${hue} ${saturation}% ${lightness}%)`;
|
|
||||||
attempt++;
|
|
||||||
} while (usedColors.has(color));
|
|
||||||
assignedColors.set(id, color);
|
|
||||||
usedColors.add(color);
|
|
||||||
return color;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function colorForUsuario(usuarioId: string | number): { color: string; glow: string } {
|
export function colorForUsuario(usuarioId: string | number): { color: string; glow: string } {
|
||||||
const color = routeColor(String(usuarioId));
|
const str = String(usuarioId);
|
||||||
return { color, glow: color };
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markerStyleForUsuario(usuarioId: string | number): string {
|
export function markerStyleForUsuario(usuarioId: string | number): string {
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
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,11 +1,6 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino',
|
n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino',
|
||||||
n8nPdfWebhookUrl: 'http://localhost:5678/webhook/generar-rutas-pdf',
|
|
||||||
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
||||||
orsMexicoBaseUrl: 'http://localhost:18081/ors/v2',
|
requestTimeoutMs: 30000,
|
||||||
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,11 +1,6 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino',
|
n8nWebhookUrl: 'https://agenteit.digitalcompass.agency/webhook/calcular-camino',
|
||||||
n8nPdfWebhookUrl: 'http://localhost:5678/webhook/generar-rutas-pdf',
|
|
||||||
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
||||||
orsMexicoBaseUrl: 'http://localhost:18081/ors/v2',
|
requestTimeoutMs: 30000,
|
||||||
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,30 +40,6 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* leaflet.heat creates its canvas in the map overlay pane, outside Angular styles. */
|
|
||||||
.leaflet-heatmap-layer {
|
|
||||||
z-index: 700 !important;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Leaflet-generated marker elements do not receive Angular's scoped attributes. */
|
|
||||||
.custom-marker {
|
|
||||||
background: none !important;
|
|
||||||
border: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-marker .marker-inner {
|
|
||||||
display: flex;
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
|
||||||
font: 700 12px/1 Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--glm-verde: #6CC24A;
|
--glm-verde: #6CC24A;
|
||||||
--glm-verde-claro: #A4D65E;
|
--glm-verde-claro: #A4D65E;
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
alter table public.rutero_proyectos
|
|
||||||
add column if not exists rendimiento_km_l numeric null,
|
|
||||||
add column if not exists precio_por_litro numeric null;
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
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()
|
|
||||||
));
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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';
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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