Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69f26cb124 | |||
| afbcc792ae |
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.
@@ -10,7 +10,10 @@
|
|||||||
},
|
},
|
||||||
"type": "n8n-nodes-base.webhook",
|
"type": "n8n-nodes-base.webhook",
|
||||||
"typeVersion": 2.1,
|
"typeVersion": 2.1,
|
||||||
"position": [-600, 260],
|
"position": [
|
||||||
|
-600,
|
||||||
|
260
|
||||||
|
],
|
||||||
"id": "e91ef4ce-4c71-4a7b-91d7-1a4f1c2db015",
|
"id": "e91ef4ce-4c71-4a7b-91d7-1a4f1c2db015",
|
||||||
"name": "Webhook: Generate PDF"
|
"name": "Webhook: Generate PDF"
|
||||||
},
|
},
|
||||||
@@ -20,18 +23,24 @@
|
|||||||
},
|
},
|
||||||
"type": "n8n-nodes-base.code",
|
"type": "n8n-nodes-base.code",
|
||||||
"typeVersion": 2,
|
"typeVersion": 2,
|
||||||
"position": [-360, 260],
|
"position": [
|
||||||
|
-360,
|
||||||
|
260
|
||||||
|
],
|
||||||
"id": "abbd2d6e-5c04-4bbb-ae1b-e4f0d2e4574e",
|
"id": "abbd2d6e-5c04-4bbb-ae1b-e4f0d2e4574e",
|
||||||
"name": "Validate Report Payload",
|
"name": "Validate Report Payload",
|
||||||
"onError": "continueErrorOutput"
|
"onError": "continueErrorOutput"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"jsCode": "const payload = $json.payload;\nconst escape = value => String(value ?? '').replace(/[&<>\"']/g, char => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[char]));\nconst filters = Object.entries(payload.filters ?? {}).filter(([, value]) => value).map(([key, value]) => `<span>${escape(key)}: ${escape(value)}</span>`).join('');\nconst routeRows = payload.assignedRoutes.map(route => `<tr><td>${escape(route.usuario)}</td><td>${escape(route.dia)}</td><td>${escape(route.orden)}</td><td>${escape(route.pdv)}</td><td>${escape(route.origen)}</td><td>${escape(route.distanciaKm)} km</td><td>${escape(route.horasTrabajo)} h</td></tr>`).join('') || '<tr><td colspan=\"7\">Sin rutas asignadas para los filtros seleccionados.</td></tr>';\nconst unassignedRows = payload.unassignedPdvs.map(route => `<tr><td>${escape(route.pdv_id)}</td><td>${escape(route.pdv_nombre)}</td><td>${escape(route.minimo_horas_semana)} h</td><td>${escape(route.motivo)}</td><td>${escape(route.recomendacion)}</td></tr>`).join('') || '<tr><td colspan=\"5\">No hay PDVs sin asignar para los filtros seleccionados.</td></tr>';\nconst summary = payload.summary ?? {};\nconst html = `<!doctype html><html><head><meta charset=\"utf-8\"><style>body{font-family:Arial,sans-serif;color:#243746;margin:28px;font-size:11px}h1{color:#4F758B;margin:0}h2{color:#4F758B;border-bottom:2px solid #6CC24A;padding-bottom:6px;margin-top:28px}p{line-height:1.45}.meta{color:#61798a}.filters span{display:inline-block;background:#eef6e8;border:1px solid #a4d65e;border-radius:3px;margin:3px 5px 3px 0;padding:4px 6px}.summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:15px 0}.metric{border:1px solid #d0d0d0;border-left:3px solid #6CC24A;padding:8px}.metric b{display:block;color:#4F758B;font-size:16px}table{width:100%;border-collapse:collapse;margin-top:10px}th{background:#4F758B;color:#fff;text-align:left;padding:7px}td{border-bottom:1px solid #e5e5e5;padding:7px;vertical-align:top}.footer{margin-top:24px;color:#6b8fa3;font-size:9px}</style></head><body><h1>Reporte de rutas</h1><p class=\"meta\"><b>${escape(payload.project.name)}</b><br>${escape(payload.project.country)}${payload.project.description ? `<br>${escape(payload.project.description)}` : ''}<br>Generado: ${escape(payload.generatedAt)}</p>${filters ? `<div class=\"filters\"><b>Filtros aplicados:</b><br>${filters}</div>` : '<p><b>Filtros aplicados:</b> Ninguno</p>'}<div class=\"summary\"><div class=\"metric\"><b>${escape(summary.totalPdvs)}</b>PDVs totales</div><div class=\"metric\"><b>${escape(summary.assignedPdvs)}</b>PDVs asignados</div><div class=\"metric\"><b>${escape(summary.unassignedPdvs)}</b>PDVs sin asignar</div><div class=\"metric\"><b>${escape(summary.coveragePercent)}%</b>Cobertura</div><div class=\"metric\"><b>${escape(summary.totalDistanceKm)} km</b>Distancia</div><div class=\"metric\"><b>${escape(summary.totalWorkHours)} h</b>Trabajo</div></div><h2>Rutas asignadas</h2><table><thead><tr><th>Usuario</th><th>Día</th><th>Orden</th><th>PDV</th><th>Origen</th><th>Distancia</th><th>Trabajo</th></tr></thead><tbody>${routeRows}</tbody></table><h2>PDVs sin asignar</h2><table><thead><tr><th>PDV ID</th><th>PDV</th><th>Horas/semana</th><th>Motivo</th><th>Recomendación</th></tr></thead><tbody>${unassignedRows}</tbody></table><p class=\"footer\">Route Planner · GomezLee Marketing</p></body></html>`;\nreturn [{ json: { fileName: `rutas_${String(payload.project.name).replace(/[^a-z0-9]+/gi, '_').toLowerCase()}.pdf` }, binary: { data: { data: Buffer.from(html).toString('base64'), mimeType: 'text/html', fileName: 'index.html' } } }];"
|
"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",
|
"type": "n8n-nodes-base.code",
|
||||||
"typeVersion": 2,
|
"typeVersion": 2,
|
||||||
"position": [-100, 260],
|
"position": [
|
||||||
|
-100,
|
||||||
|
260
|
||||||
|
],
|
||||||
"id": "9258c394-683f-4569-8ced-af2337ec8dba",
|
"id": "9258c394-683f-4569-8ced-af2337ec8dba",
|
||||||
"name": "Build Report HTML",
|
"name": "Build Report HTML",
|
||||||
"onError": "continueErrorOutput",
|
"onError": "continueErrorOutput",
|
||||||
@@ -65,7 +74,10 @@
|
|||||||
},
|
},
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
"typeVersion": 4.2,
|
"typeVersion": 4.2,
|
||||||
"position": [180, 260],
|
"position": [
|
||||||
|
180,
|
||||||
|
260
|
||||||
|
],
|
||||||
"id": "73e2759c-d9f9-49dc-925f-3ad1ee7b3897",
|
"id": "73e2759c-d9f9-49dc-925f-3ad1ee7b3897",
|
||||||
"name": "Convert HTML to PDF",
|
"name": "Convert HTML to PDF",
|
||||||
"retryOnFail": true,
|
"retryOnFail": true,
|
||||||
@@ -91,7 +103,10 @@
|
|||||||
},
|
},
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
"typeVersion": 1.5,
|
"typeVersion": 1.5,
|
||||||
"position": [460, 200],
|
"position": [
|
||||||
|
460,
|
||||||
|
200
|
||||||
|
],
|
||||||
"id": "93928c3e-1e9c-4f77-a644-c156fae907a5",
|
"id": "93928c3e-1e9c-4f77-a644-c156fae907a5",
|
||||||
"name": "Respond PDF"
|
"name": "Respond PDF"
|
||||||
},
|
},
|
||||||
@@ -105,7 +120,10 @@
|
|||||||
},
|
},
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
"typeVersion": 1.5,
|
"typeVersion": 1.5,
|
||||||
"position": [-100, 460],
|
"position": [
|
||||||
|
-100,
|
||||||
|
460
|
||||||
|
],
|
||||||
"id": "c8b69f14-45d0-4f55-9f7c-2e3fb3425f14",
|
"id": "c8b69f14-45d0-4f55-9f7c-2e3fb3425f14",
|
||||||
"name": "Respond Validation Error"
|
"name": "Respond Validation Error"
|
||||||
},
|
},
|
||||||
@@ -119,16 +137,80 @@
|
|||||||
},
|
},
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
"typeVersion": 1.5,
|
"typeVersion": 1.5,
|
||||||
"position": [460, 430],
|
"position": [
|
||||||
|
460,
|
||||||
|
430
|
||||||
|
],
|
||||||
"id": "c8d6e363-80b6-46ca-a8c6-32ce393207cc",
|
"id": "c8d6e363-80b6-46ca-a8c6-32ce393207cc",
|
||||||
"name": "Respond PDF Error"
|
"name": "Respond PDF Error"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"connections": {
|
"connections": {
|
||||||
"Webhook: Generate PDF": { "main": [[{ "node": "Validate Report Payload", "type": "main", "index": 0 }]] },
|
"Webhook: Generate PDF": {
|
||||||
"Validate Report Payload": { "main": [[{ "node": "Build Report HTML", "type": "main", "index": 0 }], [{ "node": "Respond Validation Error", "type": "main", "index": 0 }]] },
|
"main": [
|
||||||
"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 }]] }
|
{
|
||||||
|
"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,
|
"active": false,
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -138,4 +220,4 @@
|
|||||||
"templateCredsSetupCompleted": true
|
"templateCredsSetupCompleted": true
|
||||||
},
|
},
|
||||||
"tags": []
|
"tags": []
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -30,7 +30,7 @@
|
|||||||
"production": {
|
"production": {
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{ "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" },
|
{ "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" },
|
||||||
{ "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "12kB" }
|
{ "type": "anyComponentStyle", "maximumWarning": "8kB", "maximumError": "15kB" }
|
||||||
],
|
],
|
||||||
"outputHashing": "all",
|
"outputHashing": "all",
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const routes: Routes = [
|
|||||||
{ path: 'proyectos/:id/ruta', component: AssignedRouteDetailComponent, canActivate: [authGuard] },
|
{ path: 'proyectos/:id/ruta', component: AssignedRouteDetailComponent, canActivate: [authGuard] },
|
||||||
{ path: 'proyectos/:id/empleados', component: EmployeesComponent, canActivate: [authGuard] },
|
{ path: 'proyectos/:id/empleados', component: EmployeesComponent, canActivate: [authGuard] },
|
||||||
{ path: 'proyectos/:id/pdvs', component: PdvsComponent, canActivate: [authGuard] },
|
{ path: 'proyectos/:id/pdvs', component: PdvsComponent, canActivate: [authGuard] },
|
||||||
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent, canActivate: [authGuard] },
|
{ path: 'home/assigned-route', redirectTo: 'home', pathMatch: 'full' },
|
||||||
{ path: 'home/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] },
|
{ path: 'home/mapa', redirectTo: 'home', pathMatch: 'full' },
|
||||||
{ path: '**', redirectTo: 'login' }
|
{ path: '**', redirectTo: 'login' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export interface RuteroProyecto {
|
|||||||
nombre: string;
|
nombre: string;
|
||||||
descripcion: string | null;
|
descripcion: string | null;
|
||||||
pais: string | null;
|
pais: string | null;
|
||||||
|
rendimiento_km_l: number | null;
|
||||||
|
precio_por_litro: number | null;
|
||||||
propietario_id: string;
|
propietario_id: string;
|
||||||
estado: 'optimizado' | 'archivado' | null;
|
estado: 'optimizado' | 'archivado' | null;
|
||||||
version: number;
|
version: number;
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ 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;
|
geojson?: unknown;
|
||||||
horas_por_dia_usuario?: number;
|
horas_por_dia_usuario?: number;
|
||||||
horas_descanso_por_dia?: number;
|
horas_descanso_por_dia?: number;
|
||||||
|
|||||||
@@ -201,9 +201,17 @@ export class RouteState {
|
|||||||
const profiles = Array.from(new Map(current.map(route => [String(route.json.usuario_id), route])).values());
|
const profiles = Array.from(new Map(current.map(route => [String(route.json.usuario_id), route])).values());
|
||||||
if (profiles.length < 2) return;
|
if (profiles.length < 2) return;
|
||||||
|
|
||||||
|
const segmentWeight: Record<string, number> = { AA: 100, A: 80, B: 60, C: 40, D: 20 };
|
||||||
|
const priorityOf = (route: (typeof current)[number]): number => {
|
||||||
|
const value = Number(route.json.prioridad);
|
||||||
|
return Number.isFinite(value) ? value : 5;
|
||||||
|
};
|
||||||
|
const segmentOf = (route: (typeof current)[number]): number =>
|
||||||
|
segmentWeight[String(route.json.segmentacion ?? 'D').toUpperCase()] ?? 20;
|
||||||
const load = new Map<string, number>(profiles.map(profile => [String(profile.json.usuario_id), 0]));
|
const load = new Map<string, number>(profiles.map(profile => [String(profile.json.usuario_id), 0]));
|
||||||
const optimized = [...current]
|
const optimized = [...current]
|
||||||
.sort((a, b) => Number(b.json.hora_minima_semanal_pdv) - Number(a.json.hora_minima_semanal_pdv))
|
.sort((a, b) => priorityOf(a) - priorityOf(b) || segmentOf(b) - segmentOf(a) ||
|
||||||
|
Number(b.json.hora_minima_semanal_pdv) - Number(a.json.hora_minima_semanal_pdv))
|
||||||
.map(route => {
|
.map(route => {
|
||||||
const routeHours = Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0);
|
const routeHours = Number(route.json.horas_trabajo || route.json.hora_minima_semanal_pdv || 0);
|
||||||
const user = profiles
|
const user = profiles
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const MAX_CONCURRENT_REQUESTS = 2;
|
|||||||
export class OrsService {
|
export class OrsService {
|
||||||
|
|
||||||
private geoCache = new Map<string, unknown>();
|
private geoCache = new Map<string, unknown>();
|
||||||
|
private failedCache = new Map<string, string>();
|
||||||
|
|
||||||
constructor(private http: HttpClient) { }
|
constructor(private http: HttpClient) { }
|
||||||
|
|
||||||
@@ -37,7 +38,8 @@ 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)
|
const baseUrl = this.isMexico(origin) || this.isMexico(destination)
|
||||||
? environment.orsMexicoBaseUrl
|
? environment.orsMexicoBaseUrl
|
||||||
@@ -57,6 +59,14 @@ export class OrsService {
|
|||||||
return latitude >= 14 && latitude <= 33 && longitude >= -119 && longitude <= -86;
|
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;
|
||||||
@@ -99,6 +109,17 @@ export class OrsService {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isJson && this.failedCache.has(key)) {
|
||||||
|
const errOut = { pdv_id: pair.pdv_id, error: this.failedCache.get(key) ?? 'Sin conexión vial (ORS).' };
|
||||||
|
return of(errOut).pipe(
|
||||||
|
map(data => {
|
||||||
|
completed++;
|
||||||
|
progressSubject.next({ completed, total: pares.length, item: data as unknown });
|
||||||
|
if (completed === pares.length) progressSubject.complete();
|
||||||
|
return data as unknown;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.fetchRoute(pair.origin, pair.destination, isJson).pipe(
|
return this.fetchRoute(pair.origin, pair.destination, isJson).pipe(
|
||||||
delay(300),
|
delay(300),
|
||||||
map(data => {
|
map(data => {
|
||||||
@@ -124,9 +145,11 @@ export class OrsService {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
catchError(error => {
|
catchError(error => {
|
||||||
|
const message = this.orsErrorMessage(error);
|
||||||
|
if (isJson) this.failedCache.set(key, message);
|
||||||
const errOut = {
|
const errOut = {
|
||||||
pdv_id: pair.pdv_id,
|
pdv_id: pair.pdv_id,
|
||||||
error: (error as Error).message || 'Error ORS'
|
error: message
|
||||||
};
|
};
|
||||||
completed++;
|
completed++;
|
||||||
progressSubject.next({ completed, total: pares.length, item: errOut });
|
progressSubject.next({ completed, total: pares.length, item: errOut });
|
||||||
@@ -151,5 +174,6 @@ export class OrsService {
|
|||||||
|
|
||||||
clearCache() {
|
clearCache() {
|
||||||
this.geoCache.clear();
|
this.geoCache.clear();
|
||||||
|
this.failedCache.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export class ProjectService {
|
|||||||
return data as RuteroProyecto;
|
return data as RuteroProyecto;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, changes: Partial<Pick<RuteroProyecto, 'nombre' | 'descripcion'>>): Promise<void> {
|
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);
|
const { error } = await supabaseClient.from('rutero_proyectos').update(changes).eq('id', id);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,8 @@ export class ProjectService {
|
|||||||
nombre: nombre.trim(),
|
nombre: nombre.trim(),
|
||||||
descripcion: descripcion.trim() || null,
|
descripcion: descripcion.trim() || null,
|
||||||
pais: pais.trim(),
|
pais: pais.trim(),
|
||||||
|
rendimiento_km_l: null,
|
||||||
|
precio_por_litro: null,
|
||||||
propietario_id: ownerId,
|
propietario_id: ownerId,
|
||||||
estado: null,
|
estado: null,
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|||||||
@@ -18,6 +18,6 @@
|
|||||||
.employee-form-grid input::placeholder { color: #71808a; opacity: 1; }
|
.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; }
|
.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; }
|
.form-actions { display: flex; gap: 8px; }.form-actions button:first-child { background: var(--glm-azul); color: #fff; }.form-error { color: #c9252d; font-size: .82rem; }.table-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }.table-wrap { overflow-x: auto; margin-top: 12px; }table { width: 100%; min-width: 920px; border-collapse: collapse; font-size: .82rem; }th { padding: 9px; background: var(--glm-azul); color: #fff; text-align: left; }td { padding: 9px; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); }.status { color: var(--glm-verde); font-weight: 700; }.status.inactive { color: #c9252d; }.row-actions { display: flex; gap: 5px; }.row-actions .delete { color: #c9252d; }.loading { color: var(--glm-acero); }.rest-days-button { max-width: 155px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.rest-days-picker { display: grid; gap: 4px; margin-top: 6px; padding: 6px; border: 1px solid var(--glm-gris-medio); background: #fff; }.rest-days-picker label { font-size: .72rem; white-space: nowrap; }
|
||||||
.editing-row { background: var(--glm-verde-bg); }.editing-row select, .inline-number { width: 100%; min-width: 92px; box-sizing: border-box; padding: 7px; border: 1px solid var(--glm-verde); border-radius: 4px; font: inherit; }.inline-number { max-width: 100px; }.inline-picker { min-width: 130px; margin: 0; }.editing-row .row-actions { flex-wrap: wrap; }
|
.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 { display:flex; flex-wrap:wrap; align-items:end; gap:10px; margin:12px 0; padding:12px; border:1px solid var(--glm-verde-claro); background:var(--glm-verde-bg); }.bulk-actions > label,.bulk-actions fieldset { display:grid; gap:4px; margin:0; color:var(--glm-azul); font-size:.72rem; font-weight:700; text-transform:uppercase; }.bulk-actions input,.bulk-actions select { min-height:32px; padding:5px 7px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; font:inherit; }.bulk-actions fieldset { grid-template-columns:repeat(2,max-content); gap:5px 8px; padding:6px 8px; text-transform:none; }.bulk-actions legend { padding:0 3px; text-transform:uppercase; }.bulk-actions fieldset label { font-size:.72rem; font-weight:400; }.bulk-actions fieldset input { min-height:0; padding:0; }.bulk-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:7px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.bulk-actions button:hover:not(:disabled) { background:#fff; border-color:var(--glm-verde); }.bulk-actions button.delete { color:#c9252d; }
|
.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; } }
|
@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; } }
|
||||||
|
|||||||
+10
-1
@@ -5,7 +5,9 @@ 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';
|
import { colorForUsuario } from '../../../shared/utils/palette.utils';
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
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
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -117,7 +120,13 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
if (territoryBounds?.isValid()) {
|
if (territoryBounds?.isValid()) {
|
||||||
this.fitRouteBounds(territoryBounds);
|
this.fitRouteBounds(territoryBounds);
|
||||||
} else {
|
} else {
|
||||||
this.map.setView([18.4861, -69.9312], 13);
|
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';
|
||||||
|
|||||||
@@ -36,9 +36,86 @@
|
|||||||
.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; }
|
.projects-link button,.home-employees-link,.edit-project,.project-edit-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:inherit; font-weight:700; padding:8px 11px; text-decoration:none; }.home-employees-link { display:inline-block; margin-bottom:12px; }.project-heading { width:100%; }.project-title-line { display:flex; align-items:center; gap:14px; }.project-title-line h1 { margin:0; }.back-projects { color:var(--glm-azul); font-size:.85rem; font-weight:700; text-decoration:none; white-space:nowrap; }.edit-project,.project-edit-actions { margin-left:auto; }.project-title-line input,.project-heading textarea { border:1px solid #9aaab3; border-radius:4px; background:#fff; color:#2f3f48; font:inherit; padding:8px; }.project-heading textarea { display:block; width:100%; margin-top:10px; resize:vertical; }.project-edit-actions { display:flex; gap:6px; }.project-edit-actions button:first-child { background:var(--glm-azul); color:#fff; }
|
||||||
|
|
||||||
.home-container {
|
.home-container {
|
||||||
max-width: 1000px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 24px;
|
padding: 20px 24px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-toolbar-card {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-summary {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1 1 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-toolbar-card .upload-error {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuel-config { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
|
||||||
|
.fuel-config label { display: grid; gap: 5px; color: var(--glm-azul); font-size: .75rem; font-weight: 700; text-transform: uppercase; }
|
||||||
|
.fuel-config input { border: 1px solid #9aaab3; border-radius: 4px; background: #fff; color: #2f3f48; font: inherit; padding: 8px; width: 180px; }
|
||||||
|
.fuel-summary { margin: 4px 0 0; color: var(--glm-acero); font-size: .82rem; }
|
||||||
|
|
||||||
|
.dashboard-map-card {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-map-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-map-header .section-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-map-hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--glm-acero);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-map-panel {
|
||||||
|
position: relative;
|
||||||
|
min-height: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-map-panel app-route-overview-map {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
height: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.home-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.dashboard-map-panel app-route-overview-map {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-header {
|
.home-header {
|
||||||
@@ -104,103 +181,8 @@
|
|||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Upload card */
|
|
||||||
.upload-card {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.employees-summary { display: flex; align-items: center; gap: 7px; margin: -2px 0 16px; color: var(--glm-acero); font-size: .88rem; }.employees-summary i { color: var(--glm-verde); }.employees-summary.empty, .employees-summary.empty i { color: #c9252d; }
|
.employees-summary { display: flex; align-items: center; gap: 7px; margin: -2px 0 16px; color: var(--glm-acero); font-size: .88rem; }.employees-summary i { color: var(--glm-verde); }.employees-summary.empty, .employees-summary.empty i { color: #c9252d; }
|
||||||
|
|
||||||
.upload-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
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;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -265,89 +247,6 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-label.has-error {
|
|
||||||
border-color: #F5A6A0;
|
|
||||||
background: var(--glm-rojo-ok);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-label.has-success {
|
|
||||||
border-color: var(--glm-verde);
|
|
||||||
background: var(--glm-verde-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-card {
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid var(--glm-gris-medio);
|
|
||||||
background: var(--glm-surface);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-success {
|
|
||||||
border-color: var(--glm-verde-claro);
|
|
||||||
background: var(--glm-verde-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-success .preview-header {
|
|
||||||
color: var(--glm-verde);
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-error {
|
|
||||||
border-color: #F5A6A0;
|
|
||||||
background: var(--glm-rojo-ok);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-error .preview-header {
|
|
||||||
color: #D32F2F;
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-stats {
|
|
||||||
margin-top: 6px;
|
|
||||||
color: var(--glm-acero);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-errors {
|
|
||||||
margin: 8px 0 0;
|
|
||||||
padding: 0 0 0 18px;
|
|
||||||
color: var(--glm-gris-oscuro);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-errors li {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-value {
|
|
||||||
color: var(--glm-acero);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.more-errors {
|
|
||||||
color: var(--glm-acero);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-loading {
|
|
||||||
color: var(--glm-acero);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-loading-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: var(--glm-acero);
|
|
||||||
}
|
|
||||||
|
|
||||||
.params-card {
|
.params-card {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
|||||||
@@ -8,76 +8,23 @@
|
|||||||
<a class="back-projects project-back-link" routerLink="/home"><i class="pi pi-arrow-left"></i> Mis proyectos</a>
|
<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>
|
<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><p *ngIf="!editingProject">Gestión de rutas asignadas y puntos de venta pendientes</p></div>
|
<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>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card upload-card">
|
<section class="card planning-toolbar-card">
|
||||||
<button type="button" class="dashboard-section-toggle" (click)="toggleInputs()"><span><i class="pi pi-database"></i> Datos para planificación</span><i class="pi" [class.pi-chevron-down]="inputsExpanded" [class.pi-chevron-right]="!inputsExpanded"></i></button>
|
<div class="planning-toolbar">
|
||||||
<div *ngIf="inputsExpanded">
|
<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>
|
||||||
<p class="employees-summary" [class.empty]="!employeesLoading && !activeEmployees"><i class="pi pi-users"></i> <span *ngIf="employeesLoading">Cargando empleados activos...</span><span *ngIf="!employeesLoading">{{ activeEmployees }} empleados activos de este proyecto se usarán para planificar.</span></p>
|
<div class="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 guardado</span>
|
|
||||||
<span class="file-hint">Los PDVs pertenecen a este proyecto</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="pdvFile"
|
|
||||||
type="file"
|
|
||||||
accept=".xlsx,.xls"
|
|
||||||
disabled
|
|
||||||
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>
|
|
||||||
|
|
||||||
<div class="upload-actions">
|
|
||||||
<div class="upload-error" *ngIf="uploadError">
|
|
||||||
<i class="pi pi-exclamation-triangle"></i>
|
|
||||||
{{ uploadError }}
|
|
||||||
</div>
|
|
||||||
<div class="actions-row">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
(click)="cargarRutas()"
|
(click)="cargarRutas()"
|
||||||
[disabled]="!canSubmit"
|
[disabled]="submitDisabled"
|
||||||
[title]="submitDisabledReason || ''"
|
title="Simular rutas"
|
||||||
>
|
>
|
||||||
<span *ngIf="isLoading" class="spinner-inline"></span>
|
<span *ngIf="isLoading" class="spinner-inline"></span>
|
||||||
<i *ngIf="!isLoading" class="pi pi-upload"></i>
|
<i *ngIf="!isLoading" class="pi pi-upload"></i>
|
||||||
{{ isLoading ? 'Procesando...' : 'Cargar rutas' }}
|
{{ isLoading ? 'Simulando...' : 'Simular rutas' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -96,13 +43,14 @@
|
|||||||
<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>
|
</div>
|
||||||
|
<small class="hint-text" *ngIf="submitHint && !isLoading">{{ submitHint }}</small>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="map-overview-link"><a class="employees-link" [routerLink]="['/proyectos', projectId, 'mapa']"><i class="pi pi-map"></i> Ver mapa general</a></div>
|
|
||||||
|
|
||||||
<section class="card planning-filters-card">
|
<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>
|
<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>
|
<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>
|
||||||
@@ -112,6 +60,16 @@
|
|||||||
<app-planning-kpis></app-planning-kpis>
|
<app-planning-kpis></app-planning-kpis>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card dashboard-map-card">
|
||||||
|
<div class="dashboard-map-header">
|
||||||
|
<h2 class="section-title"><i class="pi pi-map"></i> Mapa general y detalle de ruta</h2>
|
||||||
|
<p class="dashboard-map-hint">Selecciona una ruta en el mapa o en el balance para ver su detalle sin cambiar de pantalla.</p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-map-panel">
|
||||||
|
<app-route-overview-map [embedded]="true" [country]="projectCountry"></app-route-overview-map>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section 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>
|
<button type="button" class="dashboard-section-toggle" (click)="toggleUnassigned()"><span><i class="pi pi-exclamation-triangle"></i> PDVs sin ruta asignada</span><i class="pi" [class.pi-chevron-down]="unassignedExpanded" [class.pi-chevron-right]="!unassignedExpanded"></i></button>
|
||||||
<app-no-assigned-route *ngIf="unassignedExpanded"></app-no-assigned-route>
|
<app-no-assigned-route *ngIf="unassignedExpanded"></app-no-assigned-route>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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 { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { EmployeeService } from '../../core/services/employee.service';
|
import { EmployeeService } from '../../core/services/employee.service';
|
||||||
@@ -19,17 +20,14 @@ import { PlanningFilterKey, PlanningFilters, PlanningFilterService } from '../..
|
|||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
imports: [CommonModule, FormsModule, RouterLink, NoAssignedRouteComponent, PlanningKpisComponent],
|
imports: [CommonModule, FormsModule, RouterLink, NoAssignedRouteComponent, PlanningKpisComponent, RouteOverviewMapComponent],
|
||||||
templateUrl: './home.component.html',
|
templateUrl: './home.component.html',
|
||||||
styleUrls: ['./home.component.css']
|
styleUrls: ['./home.component.css']
|
||||||
})
|
})
|
||||||
export class HomeComponent implements OnInit {
|
export class HomeComponent implements OnInit {
|
||||||
pdvFile: File | null = null;
|
pdvFile: File | null = null;
|
||||||
usuariosFile: File | null = null;
|
|
||||||
pdvPreview: PreviewResult | null = null;
|
pdvPreview: PreviewResult | null = null;
|
||||||
usuariosPreview: PreviewResult | null = null;
|
|
||||||
pdvPreviewLoading = false;
|
pdvPreviewLoading = false;
|
||||||
usuariosPreviewLoading = false;
|
|
||||||
activeEmployees = 0;
|
activeEmployees = 0;
|
||||||
employeesLoading = true;
|
employeesLoading = true;
|
||||||
uploadError: string | null = null;
|
uploadError: string | null = null;
|
||||||
@@ -40,12 +38,35 @@ export class HomeComponent implements OnInit {
|
|||||||
projectDescription = '';
|
projectDescription = '';
|
||||||
projectCountry = '';
|
projectCountry = '';
|
||||||
projectSaving = false;
|
projectSaving = false;
|
||||||
inputsExpanded = true;
|
fuelRendimiento: number | null = null;
|
||||||
|
fuelPrecio: number | null = null;
|
||||||
filtersExpanded = true;
|
filtersExpanded = true;
|
||||||
unassignedExpanded = false;
|
unassignedExpanded = true;
|
||||||
planningFilters: PlanningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
|
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,
|
||||||
@@ -82,13 +103,15 @@ export class HomeComponent implements OnInit {
|
|||||||
this.projectName = project.nombre;
|
this.projectName = project.nombre;
|
||||||
this.projectDescription = project.descripcion ?? '';
|
this.projectDescription = project.descripcion ?? '';
|
||||||
this.projectCountry = input.country;
|
this.projectCountry = input.country;
|
||||||
|
this.fuelRendimiento = project.rendimiento_km_l ?? null;
|
||||||
|
this.fuelPrecio = project.precio_por_litro ?? null;
|
||||||
this.planningFilter.setPdvs(input.rows);
|
this.planningFilter.setPdvs(input.rows);
|
||||||
this.pdvFile = input.file;
|
this.pdvFile = input.file;
|
||||||
this.pdvPreview = await this.previewService.previewFile(input.file, 'PDV');
|
this.pdvPreview = await this.previewService.previewFile(input.file, 'PDV');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.uploadError = error?.message ?? 'No se pudo cargar el proyecto.';
|
this.uploadError = error?.message ?? 'No se pudo cargar el proyecto.';
|
||||||
} finally {
|
} finally {
|
||||||
this.cdr.detectChanges();
|
this.updateSubmitState(); this.safeDetectChanges();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,9 +121,22 @@ export class HomeComponent implements OnInit {
|
|||||||
|
|
||||||
async saveProject(): Promise<void> {
|
async saveProject(): Promise<void> {
|
||||||
if (!this.projectId || !this.projectName.trim()) return;
|
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;
|
||||||
|
}
|
||||||
|
if (this.fuelPrecio != null && !(this.fuelPrecio >= 0)) {
|
||||||
|
this.uploadError = 'El precio por litro no puede ser negativo.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.projectSaving = true;
|
this.projectSaving = true;
|
||||||
try {
|
try {
|
||||||
await this.projectsService.update(this.projectId, { nombre: this.projectName.trim(), descripcion: this.projectDescription.trim() || null });
|
await this.projectsService.update(this.projectId, {
|
||||||
|
nombre: this.projectName.trim(),
|
||||||
|
descripcion: this.projectDescription.trim() || null,
|
||||||
|
rendimiento_km_l: this.fuelRendimiento,
|
||||||
|
precio_por_litro: this.fuelPrecio
|
||||||
|
});
|
||||||
this.editingProject = false;
|
this.editingProject = false;
|
||||||
} catch (error: any) { this.uploadError = error?.message ?? 'No se pudo actualizar el proyecto.'; }
|
} catch (error: any) { this.uploadError = error?.message ?? 'No se pudo actualizar el proyecto.'; }
|
||||||
finally { this.projectSaving = false; this.cdr.detectChanges(); }
|
finally { this.projectSaving = false; this.cdr.detectChanges(); }
|
||||||
@@ -111,7 +147,6 @@ export class HomeComponent implements OnInit {
|
|||||||
filterOptions(key: PlanningFilterKey): string[] { return this.planningFilter.options(key); }
|
filterOptions(key: PlanningFilterKey): string[] { return this.planningFilter.options(key); }
|
||||||
applyPlanningFilters(): void { this.planningFilter.set(this.planningFilters); }
|
applyPlanningFilters(): void { this.planningFilter.set(this.planningFilters); }
|
||||||
clearPlanningFilters(): void { this.planningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' }; this.applyPlanningFilters(); }
|
clearPlanningFilters(): void { this.planningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' }; this.applyPlanningFilters(); }
|
||||||
toggleInputs(): void { this.inputsExpanded = !this.inputsExpanded; }
|
|
||||||
toggleFilters(): void { this.filtersExpanded = !this.filtersExpanded; }
|
toggleFilters(): void { this.filtersExpanded = !this.filtersExpanded; }
|
||||||
toggleUnassigned(): void { this.unassignedExpanded = !this.unassignedExpanded; }
|
toggleUnassigned(): void { this.unassignedExpanded = !this.unassignedExpanded; }
|
||||||
|
|
||||||
@@ -123,104 +158,10 @@ export class HomeComponent implements OnInit {
|
|||||||
this.activeEmployees = 0;
|
this.activeEmployees = 0;
|
||||||
} finally {
|
} finally {
|
||||||
this.employeesLoading = false;
|
this.employeesLoading = false;
|
||||||
this.cdr.detectChanges();
|
this.updateSubmitState(); this.safeDetectChanges();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onPdvFileChange(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, 'PDV');
|
|
||||||
if (basicError) {
|
|
||||||
this.pdvFile = null;
|
|
||||||
this.pdvPreview = null;
|
|
||||||
this.uploadError = basicError;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.pdvFile = file;
|
|
||||||
if (this.usuariosFile) this.planningInput.setFiles(this.projectId, 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;
|
|
||||||
}
|
|
||||||
this.usuariosFile = file;
|
|
||||||
if (this.pdvFile) this.planningInput.setFiles(this.projectId, this.pdvFile, file);
|
|
||||||
this.uploadError = null;
|
|
||||||
this.usuariosPreviewLoading = true;
|
|
||||||
this.usuariosPreview = null;
|
|
||||||
this.cdr.detectChanges();
|
|
||||||
try {
|
|
||||||
this.usuariosPreview = await this.previewService.previewFile(file, 'Usuarios');
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg = e instanceof Error ? e.message : 'Error al leer el archivo Usuarios';
|
|
||||||
this.usuariosPreview = {
|
|
||||||
fileName: file.name,
|
|
||||||
type: 'Usuarios',
|
|
||||||
valid: false,
|
|
||||||
totalRows: 0,
|
|
||||||
validRows: 0,
|
|
||||||
headers: [],
|
|
||||||
normalizedHeaders: [],
|
|
||||||
missingHeaders: [],
|
|
||||||
errors: [{ row: 1, field: 'file', message: msg }],
|
|
||||||
duplicateIds: [],
|
|
||||||
sampleValidRows: [],
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
this.usuariosPreviewLoading = false;
|
|
||||||
this.cdr.detectChanges();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateFileBasic(file: File, type: string): string | null {
|
|
||||||
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.activeEmployees) return false;
|
||||||
if (this.pdvPreviewLoading || this.employeesLoading) return false;
|
if (this.pdvPreviewLoading || this.employeesLoading) return false;
|
||||||
@@ -254,7 +195,7 @@ 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.cdr.detectChanges();
|
this.updateSubmitState(); this.safeDetectChanges();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let employeesFile: File;
|
let employeesFile: File;
|
||||||
@@ -282,7 +223,7 @@ 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.cdr.detectChanges();
|
this.updateSubmitState(); this.safeDetectChanges();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = Array.isArray(res)
|
const payload = Array.isArray(res)
|
||||||
@@ -294,7 +235,7 @@ export class HomeComponent implements OnInit {
|
|||||||
this.uploadError = 'El optimizador devolvió una respuesta vacía. Verifica que el workflow de n8n esté publicado.';
|
this.uploadError = 'El optimizador devolvió una respuesta vacía. Verifica que el workflow de n8n esté publicado.';
|
||||||
this.routeState.setLoading(false);
|
this.routeState.setLoading(false);
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.cdr.detectChanges();
|
this.updateSubmitState(); this.safeDetectChanges();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const assigned = payload.find(r => r && typeof r === 'object' && 'assigned' in r) as { assigned: unknown } | undefined;
|
const assigned = payload.find(r => r && typeof r === 'object' && 'assigned' in r) as { assigned: unknown } | undefined;
|
||||||
@@ -303,7 +244,7 @@ export class HomeComponent implements OnInit {
|
|||||||
this.uploadError = 'El optimizador devolvió un formato de respuesta no válido. Verifica el workflow de n8n.';
|
this.uploadError = 'El optimizador devolvió un formato de respuesta no válido. Verifica el workflow de n8n.';
|
||||||
this.routeState.setLoading(false);
|
this.routeState.setLoading(false);
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.cdr.detectChanges();
|
this.updateSubmitState(); this.safeDetectChanges();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (assigned) {
|
if (assigned) {
|
||||||
@@ -314,13 +255,13 @@ export class HomeComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
this.routeState.setLoading(false);
|
this.routeState.setLoading(false);
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.cdr.detectChanges();
|
this.updateSubmitState(); this.safeDetectChanges();
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.uploadError = this.mapBackendError(err);
|
this.uploadError = this.mapBackendError(err);
|
||||||
this.routeState.setLoading(false);
|
this.routeState.setLoading(false);
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.cdr.detectChanges();
|
this.updateSubmitState(); this.safeDetectChanges();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -347,17 +288,12 @@ export class HomeComponent implements OnInit {
|
|||||||
this.planningInput.clear(this.projectId);
|
this.planningInput.clear(this.projectId);
|
||||||
this.routeState.clearAll();
|
this.routeState.clearAll();
|
||||||
this.pdvFile = null;
|
this.pdvFile = null;
|
||||||
this.usuariosFile = null;
|
|
||||||
this.pdvPreview = null;
|
this.pdvPreview = null;
|
||||||
this.usuariosPreview = null;
|
|
||||||
this.pdvPreviewLoading = false;
|
this.pdvPreviewLoading = false;
|
||||||
this.usuariosPreviewLoading = false;
|
|
||||||
this.uploadError = null;
|
this.uploadError = null;
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.routeState.setLoading(false);
|
this.routeState.setLoading(false);
|
||||||
const pdvInput = document.getElementById('pdvFile') as HTMLInputElement;
|
this.updateSubmitState();
|
||||||
const usuariosInput = document.getElementById('usuariosFile') as HTMLInputElement;
|
this.safeDetectChanges();
|
||||||
if (pdvInput) pdvInput.value = '';
|
|
||||||
if (usuariosInput) usuariosInput.value = '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-10
@@ -27,6 +27,19 @@ 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,
|
||||||
@@ -36,22 +49,24 @@ export class NoAssignedRouteComponent implements OnDestroy {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
this.refreshOptions();
|
||||||
this.subs.push(
|
this.subs.push(
|
||||||
this.routesState.loading$.subscribe(value => {
|
this.routesState.loading$.subscribe(value => {
|
||||||
this.isLoading = value;
|
this.isLoading = value;
|
||||||
this.cdr.detectChanges();
|
this.safeDetectChanges();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
this.subs.push(
|
this.subs.push(
|
||||||
this.routesState.noAssignedRoutes$.subscribe(data => {
|
this.routesState.noAssignedRoutes$.subscribe(data => {
|
||||||
this.allNoAssignedRoutes = data.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
|
this.allNoAssignedRoutes = data.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
|
||||||
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
|
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
|
||||||
this.cdr.detectChanges();
|
this.refreshOptions();
|
||||||
|
this.safeDetectChanges();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
this.subs.push(this.filters.filters$.subscribe(() => {
|
this.subs.push(this.filters.filters$.subscribe(() => {
|
||||||
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
|
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
|
||||||
this.cdr.detectChanges();
|
this.safeDetectChanges();
|
||||||
}));
|
}));
|
||||||
this.cols = [
|
this.cols = [
|
||||||
{ field: 'pdv_id', header: 'PDV ID' },
|
{ field: 'pdv_id', header: 'PDV ID' },
|
||||||
@@ -64,13 +79,7 @@ export class NoAssignedRouteComponent implements OnDestroy {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -113,7 +122,8 @@ export class NoAssignedRouteComponent implements OnDestroy {
|
|||||||
this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`;
|
this.actionError = `No se pudo calcular la ruta vial para ${route.pdv_nombre}.`;
|
||||||
} finally {
|
} finally {
|
||||||
this.assigningId = null;
|
this.assigningId = null;
|
||||||
this.cdr.detectChanges();
|
this.refreshOptions();
|
||||||
|
this.safeDetectChanges();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
.kpi-warning { border-left-color: var(--glm-naranja); }
|
.kpi-warning { border-left-color: var(--glm-naranja); }
|
||||||
.kpi-blue { border-left-color: var(--glm-acero); }
|
.kpi-blue { border-left-color: var(--glm-acero); }
|
||||||
.kpi-orange { border-left-color: var(--glm-naranja); }
|
.kpi-orange { border-left-color: var(--glm-naranja); }
|
||||||
|
.kpi-fuel { border-left-color: var(--glm-verde); }
|
||||||
|
|
||||||
.kpi-icon {
|
.kpi-icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -23,9 +23,9 @@
|
|||||||
class="export-button"
|
class="export-button"
|
||||||
*ngIf="hasData"
|
*ngIf="hasData"
|
||||||
(click)="downloadRoutes()"
|
(click)="downloadRoutes()"
|
||||||
[disabled]="pdfLoading"
|
[disabled]="exportLoading"
|
||||||
>
|
>
|
||||||
<i class="pi" [class.pi-spin]="pdfLoading" [class.pi-file-pdf]="!pdfLoading" [class.pi-spinner]="pdfLoading"></i> {{ pdfLoading ? "Generando PDF..." : "Descargar rutas" }}</button
|
<i class="pi" [class.pi-spin]="exportLoading" [class.pi-file-excel]="!exportLoading" [class.pi-spinner]="exportLoading"></i> {{ exportLoading ? "Generando Excel..." : "Descargar rutas" }}</button
|
||||||
><button
|
><button
|
||||||
type="button"
|
type="button"
|
||||||
class="export-button"
|
class="export-button"
|
||||||
@@ -45,8 +45,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-error" *ngIf="reoptError || editError || pdfError">
|
<div class="table-error" *ngIf="reoptError || editError || exportError">
|
||||||
{{ reoptError || editError || pdfError }}
|
{{ reoptError || editError || exportError }}
|
||||||
</div>
|
</div>
|
||||||
<div class="kpi-grid">
|
<div class="kpi-grid">
|
||||||
<article class="kpi-card kpi-primary">
|
<article class="kpi-card kpi-primary">
|
||||||
@@ -97,6 +97,17 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</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>
|
||||||
|
|
||||||
@@ -119,6 +130,7 @@
|
|||||||
<th>Visitas</th>
|
<th>Visitas</th>
|
||||||
<th>Distancia</th>
|
<th>Distancia</th>
|
||||||
<th>Trabajo</th>
|
<th>Trabajo</th>
|
||||||
|
<th>Costo comb.</th>
|
||||||
<th>Carga</th>
|
<th>Carga</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -142,6 +154,8 @@
|
|||||||
<td>{{ load.visits }}</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"
|
class="utilization-cell"
|
||||||
@@ -165,7 +179,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="visit-detail-row" *ngIf="isExpanded(load.id)">
|
<tr class="visit-detail-row" *ngIf="isExpanded(load.id)">
|
||||||
<td colspan="8">
|
<td colspan="9">
|
||||||
<table class="visits-table">
|
<table class="visits-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -4,17 +4,19 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
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 { RoutePdfReportService } from '../../../shared/services/route-pdf-report.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 { PlanningFilterService } from '../../../core/services/planning-filter.service';
|
||||||
import { RutasService } from '../../../core/services/router.service';
|
import { RutasService } from '../../../core/services/router.service';
|
||||||
import { PlanningInputService } from '../../../core/services/planning-input.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; }
|
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 ReasonSummary { reason: string; count: number; }
|
interface ReasonSummary { reason: string; count: number; }
|
||||||
interface UserLoad {
|
interface UserLoad {
|
||||||
id: string; name: string; uniquePdvs: number; visits: number; routes: RouteAssignedResponse[]; distance: number; workHours: number;
|
id: string; name: string; uniquePdvs: number; visits: number; routes: RouteAssignedResponse[]; distance: number; workHours: number; fuelLiters: number; fuelCost: number;
|
||||||
capacityHours: number; utilization: number; status: 'ok' | 'warning' | 'over-capacity'; alertMessage: string;
|
capacityHours: number; utilization: number; status: 'ok' | 'warning' | 'over-capacity'; alertMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,44 +37,64 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
|||||||
reoptLoading = false;
|
reoptLoading = false;
|
||||||
reoptError = '';
|
reoptError = '';
|
||||||
hasLocalChanges = false;
|
hasLocalChanges = false;
|
||||||
pdfLoading = false;
|
exportLoading = false;
|
||||||
pdfError = '';
|
exportError = '';
|
||||||
readonly userLoadPageSize = 14;
|
readonly userLoadPageSize = 14;
|
||||||
userLoadPage = 1;
|
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 filters: PlanningFilterService,
|
||||||
private pdfReport: RoutePdfReportService,
|
private excelExport: PlanningExportService,
|
||||||
private rutasService: RutasService,
|
private rutasService: RutasService,
|
||||||
private planningInput: PlanningInputService,
|
private planningInput: PlanningInputService,
|
||||||
|
private projects: ProjectService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
private cdr: ChangeDetectorRef
|
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())
|
this.filters.filters$.subscribe(() => this.refresh())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadFuelConfig(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const project = await this.projects.get(this.projectId);
|
||||||
|
this.fuelConfig = { rendimientoKmL: project.rendimiento_km_l ?? null, precioPorLitro: project.precio_por_litro ?? null };
|
||||||
|
this.fuelCurrency = currencyForCountry(project.pais);
|
||||||
|
} catch {
|
||||||
|
this.fuelConfig = { rendimientoKmL: null, precioPorLitro: null };
|
||||||
|
this.fuelCurrency = '$';
|
||||||
|
} finally {
|
||||||
|
this.fuelConfigured = isFuelConfigured(this.fuelConfig);
|
||||||
|
this.refresh();
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
ngOnDestroy(): void { this.subs.forEach(subscription => subscription.unsubscribe()); }
|
ngOnDestroy(): void { this.subs.forEach(subscription => subscription.unsubscribe()); }
|
||||||
|
|
||||||
barWidth(value: number): number { return Math.min(Math.max(value, 0), 100); }
|
barWidth(value: number): number { return Math.min(Math.max(value, 0), 100); }
|
||||||
utilizationClass(load: UserLoad): string { return load.status; }
|
utilizationClass(load: UserLoad): string { return load.status; }
|
||||||
async downloadRoutes(): Promise<void> {
|
async downloadRoutes(): Promise<void> {
|
||||||
if (this.pdfLoading || !this.hasData) return;
|
if (this.exportLoading || !this.hasData) return;
|
||||||
this.pdfLoading = true;
|
this.exportLoading = true;
|
||||||
this.pdfError = '';
|
this.exportError = '';
|
||||||
try {
|
try {
|
||||||
await this.pdfReport.download(this.projectId);
|
await this.excelExport.export(this.projectId);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.pdfError = error?.message ?? 'No se pudo generar el PDF de rutas.';
|
this.exportError = error?.message ?? 'No se pudo generar el Excel de rutas.';
|
||||||
} finally {
|
} finally {
|
||||||
this.pdfLoading = false;
|
this.exportLoading = false;
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +160,7 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
|||||||
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 id = String(route.json.usuario_id);
|
||||||
const load = loads.get(id) ?? { id, name: route.json.nombre_usuario, uniquePdvs: 0, visits: 0, routes: [], distance: 0, workHours: 0, capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario), utilization: 0, status: 'ok', alertMessage: '' };
|
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: '' };
|
||||||
load.routes.push(route); load.visits++;
|
load.routes.push(route); load.visits++;
|
||||||
loads.set(id, load);
|
loads.set(id, load);
|
||||||
}
|
}
|
||||||
@@ -148,13 +170,18 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
|||||||
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.fuelLiters = fuel.liters;
|
||||||
|
load.fuelCost = fuel.cost;
|
||||||
load.utilization = load.capacityHours ? load.workHours / load.capacityHours * 100 : 0;
|
load.utilization = load.capacityHours ? load.workHours / load.capacityHours * 100 : 0;
|
||||||
load.status = load.utilization > 100 ? 'over-capacity' : load.utilization > 90 ? 'warning' : 'ok';
|
load.status = load.utilization > 100 ? 'over-capacity' : load.utilization > 90 ? 'warning' : 'ok';
|
||||||
load.alertMessage = `${load.name}: ${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h.`;
|
load.alertMessage = `${load.name}: ${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h.`;
|
||||||
return load;
|
return load;
|
||||||
}).sort((a, b) => b.visits - a.visits || b.workHours - a.workHours);
|
}).sort((a, b) => b.visits - a.visits || b.workHours - a.workHours);
|
||||||
this.userLoadPage = Math.min(this.userLoadPage, this.userLoadPages);
|
this.userLoadPage = Math.min(this.userLoadPage, this.userLoadPages);
|
||||||
this.summary = { totalPdvs, assignedPdvs: assignedPdvIds.size, unassignedPdvs: unassignedPdvIds.size, coverage: totalPdvs ? assignedPdvIds.size / totalPdvs * 100 : 0, users: loads.size, days: new Set(assigned.map(route => route.json.dia)).size, totalDistance, averageDistance: assignedPdvIds.size ? totalDistance / assignedPdvIds.size : 0, totalWorkHours };
|
const fuelTotal = fuelCost(totalDistance, this.fuelConfig);
|
||||||
|
this.fuelConfigured = isFuelConfigured(this.fuelConfig);
|
||||||
|
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 };
|
||||||
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) if (!assignedPdvIds.has(String(route.pdv_id)) && !reasonByPdv.has(String(route.pdv_id))) reasonByPdv.set(String(route.pdv_id), route.motivo?.trim() || 'Sin motivo especificado');
|
||||||
const counts = new Map<string, number>(); for (const reason of reasonByPdv.values()) counts.set(reason, (counts.get(reason) ?? 0) + 1);
|
const counts = new Map<string, number>(); for (const reason of reasonByPdv.values()) counts.set(reason, (counts.get(reason) ?? 0) + 1);
|
||||||
@@ -162,5 +189,5 @@ export class PlanningKpisComponent implements OnInit, OnDestroy {
|
|||||||
this.hasData = totalPdvs > 0;
|
this.hasData = totalPdvs > 0;
|
||||||
}
|
}
|
||||||
private numberValue(value: number | string | null | undefined): number { const result = Number(value); return Number.isFinite(result) ? result : 0; }
|
private numberValue(value: number | string | null | undefined): number { 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 }; }
|
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 }; }
|
||||||
}
|
}
|
||||||
|
|||||||
+101
@@ -115,6 +115,41 @@
|
|||||||
box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15);
|
box-shadow: 0 0 0 2px rgba(108, 194, 74, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.segmented-control {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--glm-gris-medio);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control button {
|
||||||
|
padding: 8px 4px;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid var(--glm-gris-medio);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--glm-acero);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control button:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control button:hover {
|
||||||
|
background: var(--glm-verde-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control button.active {
|
||||||
|
background: var(--glm-verde);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-stats {
|
.filter-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -310,6 +345,13 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.visit-group-title {
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
color: var(--glm-azul);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.reassign-form {
|
.reassign-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -506,3 +548,62 @@
|
|||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:host.embedded {
|
||||||
|
height: 620px;
|
||||||
|
min-height: 480px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--glm-gris-medio);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host.embedded #map-overview {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 340px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host.embedded .overview-sidebar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 340px;
|
||||||
|
z-index: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host.embedded .unassigned-bottom-drawer {
|
||||||
|
position: absolute;
|
||||||
|
left: 352px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
:host.embedded {
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
:host.embedded #map-overview {
|
||||||
|
position: relative;
|
||||||
|
left: 0;
|
||||||
|
height: 420px;
|
||||||
|
}
|
||||||
|
:host.embedded .overview-sidebar {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
:host.embedded .unassigned-bottom-drawer {
|
||||||
|
position: relative;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: 0 12px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+34
-6
@@ -6,12 +6,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overview-sidebar">
|
<div class="overview-sidebar">
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand" *ngIf="!embedded">
|
||||||
<img src="GLM_completo.png" alt="GomezLee Marketing" class="sidebar-logo" />
|
<img src="GLM_completo.png" alt="GomezLee Marketing" class="sidebar-logo" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<button type="button" class="back-btn" (click)="goBack()" aria-label="Volver a la planificación">
|
<button type="button" class="back-btn" *ngIf="!embedded" (click)="goBack()" aria-label="Volver a la planificación">
|
||||||
<i class="pi pi-arrow-left"></i> Volver
|
<i class="pi pi-arrow-left"></i> Volver
|
||||||
</button>
|
</button>
|
||||||
<h2 class="sidebar-title">Mapa de rutas</h2>
|
<h2 class="sidebar-title">Mapa de rutas</h2>
|
||||||
@@ -19,8 +19,9 @@
|
|||||||
<p class="territory-summary" *ngIf="activeTerritories.length"><i class="pi pi-map-marker"></i> {{ activeTerritories.join(', ') }}</p>
|
<p class="territory-summary" *ngIf="activeTerritories.length"><i class="pi pi-map-marker"></i> {{ activeTerritories.join(', ') }}</p>
|
||||||
|
|
||||||
<div class="filters-card" *ngIf="usuarios.length">
|
<div class="filters-card" *ngIf="usuarios.length">
|
||||||
<label class="filter-label">Metro de prueba</label>
|
<label class="filter-label">Ciudad</label>
|
||||||
<select class="filter-select" [(ngModel)]="selectedMetro" (ngModelChange)="onMetroChange()">
|
<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>
|
<option *ngFor="let metro of metros" [value]="metro">{{ metro }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -36,6 +37,13 @@
|
|||||||
<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>{{ uniqueFilteredRoutes.length }} PDVs</span>
|
||||||
<span>{{ totalDistancia }} km</span>
|
<span>{{ totalDistancia }} km</span>
|
||||||
@@ -53,12 +61,20 @@
|
|||||||
<div class="filter-empty" *ngIf="!filteredRoutes.length && !error">
|
<div class="filter-empty" *ngIf="!filteredRoutes.length && !error">
|
||||||
Sin rutas para este filtro.
|
Sin rutas para este filtro.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-empty" *ngIf="overviewMode && filteredRoutes.length">
|
||||||
|
Vista general ({{ filteredRoutes.length }} PDVs). Filtra por cobertura, zona, usuario o día para ver el detalle vial completo.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-empty" *ngIf="orsFailCount">
|
||||||
|
{{ orsFailCount }} tramo(s) sin conexión vial (línea recta). Toca la línea para ver el motivo.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="empty-state" *ngIf="!usuarios.length">
|
<div class="empty-state" *ngIf="!usuarios.length">
|
||||||
<i class="pi pi-inbox"></i>
|
<i class="pi pi-inbox"></i>
|
||||||
<p>No hay rutas cargadas.</p>
|
<p>No hay rutas cargadas.</p>
|
||||||
<button type="button" class="btn-secondary" (click)="goBack()">Ir a Home</button>
|
<button type="button" class="btn-secondary" *ngIf="!embedded" (click)="goBack()">Ir a Home</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="error-card" *ngIf="error && usuarios.length">
|
<div class="error-card" *ngIf="error && usuarios.length">
|
||||||
@@ -66,7 +82,7 @@
|
|||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="info-card unassigned-detail-card" *ngIf="selectedUnassigned">
|
<section class="info-card unassigned-detail-card" *ngIf="selectedUnassigned && showUnassigned">
|
||||||
<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>
|
||||||
@@ -132,6 +148,18 @@
|
|||||||
<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>
|
||||||
@@ -174,7 +202,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="unassigned-bottom-drawer" *ngIf="selectedUsuarioId === 'Todos' && selectedDia === 'Todos' && unassignedRoutes.length" [class.open]="isUnassignedDrawerOpen">
|
<section class="unassigned-bottom-drawer" *ngIf="showUnassigned && selectedUsuarioId === 'Todos' && selectedDia === 'Todos' && unassignedRoutes.length" [class.open]="isUnassignedDrawerOpen">
|
||||||
<button type="button" class="unassigned-drawer-toggle" (click)="toggleUnassignedDrawer()" [attr.aria-expanded]="isUnassignedDrawerOpen">
|
<button type="button" class="unassigned-drawer-toggle" (click)="toggleUnassignedDrawer()" [attr.aria-expanded]="isUnassignedDrawerOpen">
|
||||||
<span><i class="pi pi-exclamation-triangle"></i> PDVs sin ruta ({{ unassignedRoutes.length }})</span>
|
<span><i class="pi pi-exclamation-triangle"></i> PDVs sin ruta ({{ unassignedRoutes.length }})</span>
|
||||||
<i class="pi" [class.pi-chevron-up]="!isUnassignedDrawerOpen" [class.pi-chevron-down]="isUnassignedDrawerOpen"></i>
|
<i class="pi" [class.pi-chevron-up]="!isUnassignedDrawerOpen" [class.pi-chevron-down]="isUnassignedDrawerOpen"></i>
|
||||||
|
|||||||
+400
-94
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core';
|
import { Component, OnInit, OnDestroy, AfterViewInit, ChangeDetectorRef, HostBinding, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router, ActivatedRoute } from '@angular/router';
|
import { Router, ActivatedRoute } from '@angular/router';
|
||||||
@@ -7,7 +7,9 @@ import * as L from 'leaflet';
|
|||||||
import 'leaflet.heat';
|
import 'leaflet.heat';
|
||||||
import { RouteState, UnassignedAssignment } from '../../../core/route-state/route';
|
import { RouteState, UnassignedAssignment } from '../../../core/route-state/route';
|
||||||
import { OrsService } from '../../../core/services/ors.service';
|
import { OrsService } from '../../../core/services/ors.service';
|
||||||
|
import { PlanningFilterService } from '../../../core/services/planning-filter.service';
|
||||||
import { RouteAssignedResponse, RouteNoAssignedResponse } from '../../../core/models/route.model';
|
import { RouteAssignedResponse, RouteNoAssignedResponse } from '../../../core/models/route.model';
|
||||||
|
import { centerForCountry } from '../../../shared/utils/country-center.utils';
|
||||||
import { colorForUsuario } from '../../../shared/utils/palette.utils';
|
import { colorForUsuario } from '../../../shared/utils/palette.utils';
|
||||||
import { DailyRouteSummary, haversineKm, sequenceRoutes, summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
|
import { DailyRouteSummary, haversineKm, sequenceRoutes, summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ interface MapRoute {
|
|||||||
requestId: number;
|
requestId: number;
|
||||||
segmentDistanceKm?: number;
|
segmentDistanceKm?: number;
|
||||||
segmentDurationHours?: number;
|
segmentDurationHours?: number;
|
||||||
|
orsError?: string;
|
||||||
fallbackLine?: L.Polyline;
|
fallbackLine?: L.Polyline;
|
||||||
orsLine?: L.Polyline;
|
orsLine?: L.Polyline;
|
||||||
orsGlow?: L.Polyline;
|
orsGlow?: L.Polyline;
|
||||||
@@ -44,6 +47,16 @@ interface MapRoute {
|
|||||||
startMarker?: L.Marker;
|
startMarker?: L.Marker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PdvVisit {
|
||||||
|
route: RouteAssignedResponse;
|
||||||
|
usuario: string;
|
||||||
|
dia: string;
|
||||||
|
orden: number;
|
||||||
|
totalStops: number;
|
||||||
|
km: number;
|
||||||
|
min: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
selector: 'app-route-overview-map',
|
selector: 'app-route-overview-map',
|
||||||
@@ -51,7 +64,10 @@ interface MapRoute {
|
|||||||
templateUrl: './route-overview-map.component.html',
|
templateUrl: './route-overview-map.component.html',
|
||||||
styleUrls: ['./route-overview-map.component.css']
|
styleUrls: ['./route-overview-map.component.css']
|
||||||
})
|
})
|
||||||
export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewInit {
|
export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewInit, OnChanges {
|
||||||
|
@Input() embedded = false;
|
||||||
|
@Input() country = '';
|
||||||
|
@HostBinding('class.embedded') get isEmbedded(): boolean { return this.embedded; }
|
||||||
private map?: L.Map;
|
private map?: L.Map;
|
||||||
private abort$ = new Subject<void>();
|
private abort$ = new Subject<void>();
|
||||||
private subs: Subscription[] = [];
|
private subs: Subscription[] = [];
|
||||||
@@ -67,7 +83,8 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
dias: string[] = [];
|
dias: string[] = [];
|
||||||
selectedUsuarioId: string = 'Todos';
|
selectedUsuarioId: string = 'Todos';
|
||||||
selectedDia: string = 'Todos';
|
selectedDia: string = 'Todos';
|
||||||
selectedMetro = '';
|
selectedMetro = 'Todos';
|
||||||
|
selectedAsignacion: 'Todos' | 'Asignados' | 'NoAsignados' = 'Todos';
|
||||||
expandedDailyRouteDay: string | null = null;
|
expandedDailyRouteDay: string | null = null;
|
||||||
selectedRouteDetail: RouteAssignedResponse | null = null;
|
selectedRouteDetail: RouteAssignedResponse | null = null;
|
||||||
|
|
||||||
@@ -96,8 +113,17 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
reassignUserId = '';
|
reassignUserId = '';
|
||||||
reassignDay = '';
|
reassignDay = '';
|
||||||
reassignError: string | null = null;
|
reassignError: string | null = null;
|
||||||
|
overviewMode = true;
|
||||||
|
orsFailCount = 0;
|
||||||
|
private pdvVisits = new Map<string, PdvVisit[]>();
|
||||||
private renderVersion = 0;
|
private renderVersion = 0;
|
||||||
|
|
||||||
|
private safeDetectChanges(): void {
|
||||||
|
setTimeout(() => {
|
||||||
|
try { this.cdr.detectChanges(); } catch { /* ignore destroyed view */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
get metros(): string[] {
|
get metros(): string[] {
|
||||||
const values = [
|
const values = [
|
||||||
...this.allRoutes.map(route => route.json.metro),
|
...this.allRoutes.map(route => route.json.metro),
|
||||||
@@ -107,13 +133,14 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
get filteredUnassignedRoutes(): RouteNoAssignedResponse[] {
|
get filteredUnassignedRoutes(): RouteNoAssignedResponse[] {
|
||||||
|
if (!this.selectedMetro || this.selectedMetro === 'Todos') return [...this.unassignedRoutes];
|
||||||
return this.unassignedRoutes.filter(route => String(route.metro ?? '').trim() === this.selectedMetro);
|
return this.unassignedRoutes.filter(route => String(route.metro ?? '').trim() === this.selectedMetro);
|
||||||
}
|
}
|
||||||
|
|
||||||
get metroUsuarios(): { id: string; nombre: string }[] {
|
get metroUsuarios(): { id: string; nombre: string }[] {
|
||||||
const users = new Map<string, string>();
|
const users = new Map<string, string>();
|
||||||
for (const route of this.allRoutes) {
|
for (const route of this.allRoutes) {
|
||||||
if (String(route.json.metro ?? '').trim() === this.selectedMetro) {
|
if (!this.selectedMetro || this.selectedMetro === 'Todos' || String(route.json.metro ?? '').trim() === this.selectedMetro) {
|
||||||
users.set(String(route.json.usuario_id), route.json.nombre_usuario);
|
users.set(String(route.json.usuario_id), route.json.nombre_usuario);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +148,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
get activeTerritories(): string[] {
|
get activeTerritories(): string[] {
|
||||||
return this.selectedMetro ? [this.selectedMetro] : [];
|
return this.selectedMetro && this.selectedMetro !== 'Todos' ? [this.selectedMetro] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary stats
|
// Summary stats
|
||||||
@@ -208,11 +235,121 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
constructor(
|
constructor(
|
||||||
private routeState: RouteState,
|
private routeState: RouteState,
|
||||||
private orsService: OrsService,
|
private orsService: OrsService,
|
||||||
|
private planningFilters: PlanningFilterService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
private cdr: ChangeDetectorRef
|
private cdr: ChangeDetectorRef
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
get hasActivePlanningFilters(): boolean {
|
||||||
|
const filters = this.planningFilters.snapshot();
|
||||||
|
return Boolean(filters.cobertura || filters.zona || filters.region || filters.estado || filters.ciudad);
|
||||||
|
}
|
||||||
|
|
||||||
|
get showAssigned(): boolean {
|
||||||
|
return this.selectedAsignacion !== 'NoAsignados';
|
||||||
|
}
|
||||||
|
|
||||||
|
get showUnassigned(): boolean {
|
||||||
|
return this.selectedAsignacion !== 'Asignados';
|
||||||
|
}
|
||||||
|
|
||||||
|
isOverviewMode(): boolean {
|
||||||
|
return this.computeOverviewMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeOverviewMode(): boolean {
|
||||||
|
const metroTodos = !this.selectedMetro || this.selectedMetro === 'Todos';
|
||||||
|
if (!metroTodos) return false;
|
||||||
|
return !this.hasActivePlanningFilters && this.selectedUsuarioId === 'Todos' && this.selectedDia === 'Todos';
|
||||||
|
}
|
||||||
|
|
||||||
|
private centerByCountry(): void {
|
||||||
|
if (!this.map) return;
|
||||||
|
const center = centerForCountry(this.country);
|
||||||
|
this.map.setView([center.lat, center.lng], center.zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rebuildPdvVisitIndex(segments: { route: RouteAssignedResponse; order: number; totalStops: number }[]): void {
|
||||||
|
this.pdvVisits.clear();
|
||||||
|
for (const segment of segments) {
|
||||||
|
const j = segment.route.json;
|
||||||
|
const key = String(j.pdv_id);
|
||||||
|
const list = this.pdvVisits.get(key) ?? [];
|
||||||
|
list.push({
|
||||||
|
route: segment.route,
|
||||||
|
usuario: j.nombre_usuario,
|
||||||
|
dia: j.dia,
|
||||||
|
orden: segment.order,
|
||||||
|
totalStops: segment.totalStops,
|
||||||
|
km: Number(j.distancia) || 0,
|
||||||
|
min: (Number(j.horas_desplazamiento) || 0) * 60
|
||||||
|
});
|
||||||
|
this.pdvVisits.set(key, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private weeklyVisitsHtml(pdvId: string | number, current?: RouteAssignedResponse): string {
|
||||||
|
const visits = this.pdvVisits.get(String(pdvId)) ?? [];
|
||||||
|
if (visits.length < 2) return '';
|
||||||
|
const dayOrder = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes', 'Sábado', 'Sabado', 'Domingo'];
|
||||||
|
const dayIdx = (day: string): number => {
|
||||||
|
const index = dayOrder.indexOf(day);
|
||||||
|
return index < 0 ? dayOrder.length : index;
|
||||||
|
};
|
||||||
|
const byUser = new Map<string, PdvVisit[]>();
|
||||||
|
for (const v of visits) {
|
||||||
|
const list = byUser.get(v.usuario) ?? [];
|
||||||
|
list.push(v);
|
||||||
|
byUser.set(v.usuario, list);
|
||||||
|
}
|
||||||
|
for (const list of byUser.values()) {
|
||||||
|
list.sort((a, b) => dayIdx(a.dia) - dayIdx(b.dia) || a.orden - b.orden);
|
||||||
|
}
|
||||||
|
const currentUser = current?.json.nombre_usuario;
|
||||||
|
const users = [...byUser.keys()].sort((a, b) =>
|
||||||
|
(a === currentUser ? -1 : b === currentUser ? 1 : a.localeCompare(b)));
|
||||||
|
const groups = users.map(u => {
|
||||||
|
const lines = (byUser.get(u) ?? []).map(v => {
|
||||||
|
const isCurrent = !!current && this.sameRoute(v.route, current);
|
||||||
|
const label = `${v.dia} (parada ${v.orden}) · ${v.km.toFixed(2)} km · ${v.min.toFixed(0)} min`;
|
||||||
|
return isCurrent ? `<div><b>${label}</b></div>` : `<div>${label}</div>`;
|
||||||
|
}).join('');
|
||||||
|
return `<div><b>Usuario: ${u}</b></div>${lines}`;
|
||||||
|
}).join('');
|
||||||
|
return `<br><strong>Visitas de este PDV esta semana:</strong>${groups}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pdvPopupHtml(name: string, headerDetail: string, pdvId: string | number, current?: RouteAssignedResponse): string {
|
||||||
|
const list = this.weeklyVisitsHtml(pdvId, current);
|
||||||
|
if (!list) return `<strong>${name}</strong><br>${headerDetail}`;
|
||||||
|
return `<strong>${name}</strong>${list}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get otherPdvVisitGroups(): { usuario: string; visits: PdvVisit[] }[] {
|
||||||
|
if (!this.selectedRouteDetail) return [];
|
||||||
|
const dayOrder = ['Lunes', 'Martes', 'Miércoles', 'Miercoles', 'Jueves', 'Viernes', 'Sábado', 'Sabado', 'Domingo'];
|
||||||
|
const dayIdx = (day: string): number => {
|
||||||
|
const index = dayOrder.indexOf(day);
|
||||||
|
return index < 0 ? dayOrder.length : index;
|
||||||
|
};
|
||||||
|
const byUser = new Map<string, PdvVisit[]>();
|
||||||
|
for (const v of this.pdvVisits.get(String(this.selectedRouteDetail.json.pdv_id)) ?? []) {
|
||||||
|
if (this.sameRoute(v.route, this.selectedRouteDetail)) continue;
|
||||||
|
const list = byUser.get(v.usuario) ?? [];
|
||||||
|
list.push(v);
|
||||||
|
byUser.set(v.usuario, list);
|
||||||
|
}
|
||||||
|
if (!byUser.size) return [];
|
||||||
|
const currentUser = this.selectedRouteDetail.json.nombre_usuario;
|
||||||
|
for (const list of byUser.values()) {
|
||||||
|
list.sort((a, b) => dayIdx(a.dia) - dayIdx(b.dia) || a.orden - b.orden);
|
||||||
|
}
|
||||||
|
return [...byUser.keys()]
|
||||||
|
.sort((a, b) => (a === currentUser ? -1 : b === currentUser ? 1 : a.localeCompare(b)))
|
||||||
|
.map(usuario => ({ usuario, visits: byUser.get(usuario) ?? [] }));
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.routeState.useProject(this.activatedRoute.snapshot.paramMap.get('id') ?? '');
|
this.routeState.useProject(this.activatedRoute.snapshot.paramMap.get('id') ?? '');
|
||||||
this.subs.push(
|
this.subs.push(
|
||||||
@@ -223,16 +360,25 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
this.applyQueryFilters(this.activatedRoute.snapshot.queryParams);
|
this.applyQueryFilters(this.activatedRoute.snapshot.queryParams);
|
||||||
// If focus pdv provided, auto-select detail after map loads
|
// If focus pdv provided, auto-select detail after map loads
|
||||||
this.applyFilter();
|
this.applyFilter();
|
||||||
this.cdr.detectChanges();
|
|
||||||
if (this.map) this.renderFiltered();
|
if (this.map) this.renderFiltered();
|
||||||
|
else this.safeDetectChanges();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this.subs.push(
|
this.subs.push(
|
||||||
this.routeState.noAssignedRoutes$.subscribe(routes => {
|
this.routeState.noAssignedRoutes$.subscribe(routes => {
|
||||||
this.unassignedRoutes = [...routes];
|
this.unassignedRoutes = [...routes];
|
||||||
|
this.applyFilter();
|
||||||
if (this.map) this.renderFiltered();
|
if (this.map) this.renderFiltered();
|
||||||
this.cdr.detectChanges();
|
else this.safeDetectChanges();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.subs.push(
|
||||||
|
this.planningFilters.filters$.subscribe(() => {
|
||||||
|
this.applyFilter();
|
||||||
|
if (this.map) this.renderFiltered();
|
||||||
|
else this.safeDetectChanges();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -258,6 +404,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes['country'] && this.map && !this.filteredRoutes.length && !this.projectBounds()) {
|
||||||
|
this.centerByCountry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
this.initMap();
|
this.initMap();
|
||||||
this.applyFilter();
|
this.applyFilter();
|
||||||
@@ -279,10 +431,16 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
onUsuarioChange() {
|
onUsuarioChange() {
|
||||||
|
this.applyFilter();
|
||||||
|
if (this.map) this.renderFiltered();
|
||||||
|
else this.safeDetectChanges();
|
||||||
this.updateQueryParams();
|
this.updateQueryParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
onDiaChange() {
|
onDiaChange() {
|
||||||
|
this.applyFilter();
|
||||||
|
if (this.map) this.renderFiltered();
|
||||||
|
else this.safeDetectChanges();
|
||||||
this.updateQueryParams();
|
this.updateQueryParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,20 +449,35 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
if (this.selectedUsuarioId !== 'Todos' && !this.metroUsuarios.some(user => user.id === this.selectedUsuarioId)) {
|
if (this.selectedUsuarioId !== 'Todos' && !this.metroUsuarios.some(user => user.id === this.selectedUsuarioId)) {
|
||||||
this.selectedUsuarioId = 'Todos';
|
this.selectedUsuarioId = 'Todos';
|
||||||
}
|
}
|
||||||
|
this.selectedRouteDetail = null;
|
||||||
|
this.selectedMapRoute = null;
|
||||||
|
this.applyFilter();
|
||||||
|
if (this.map) this.renderFiltered();
|
||||||
|
else this.safeDetectChanges();
|
||||||
|
this.updateQueryParams();
|
||||||
|
}
|
||||||
|
|
||||||
|
onAsignacionChange() {
|
||||||
|
this.applyFilter();
|
||||||
|
if (this.map) this.renderFiltered();
|
||||||
|
else this.safeDetectChanges();
|
||||||
this.updateQueryParams();
|
this.updateQueryParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateQueryParams() {
|
private updateQueryParams() {
|
||||||
this.router.navigate([], {
|
this.router.navigate([], {
|
||||||
relativeTo: this.activatedRoute,
|
relativeTo: this.activatedRoute,
|
||||||
queryParams: { metro: this.selectedMetro, usuario_id: this.selectedUsuarioId, dia: this.selectedDia, pdv_id: null },
|
queryParams: { metro: this.selectedMetro, usuario_id: this.selectedUsuarioId, dia: this.selectedDia, asignacion: this.selectedAsignacion, pdv_id: null },
|
||||||
queryParamsHandling: 'merge',
|
queryParamsHandling: 'merge',
|
||||||
replaceUrl: true
|
replaceUrl: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyFilter() {
|
private applyFilter() {
|
||||||
let list = this.allRoutes.filter(route => String(route.json.metro ?? '').trim() === this.selectedMetro);
|
let list = this.planningFilters.filterAssigned(this.allRoutes);
|
||||||
|
if (this.selectedMetro && this.selectedMetro !== 'Todos') {
|
||||||
|
list = list.filter(route => String(route.json.metro ?? '').trim() === this.selectedMetro);
|
||||||
|
}
|
||||||
if (this.selectedUsuarioId !== 'Todos') {
|
if (this.selectedUsuarioId !== 'Todos') {
|
||||||
list = list.filter(r => String(r.json.usuario_id) === String(this.selectedUsuarioId));
|
list = list.filter(r => String(r.json.usuario_id) === String(this.selectedUsuarioId));
|
||||||
}
|
}
|
||||||
@@ -314,11 +487,26 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
const pdvId = this.activatedRoute.snapshot.queryParams['pdv_id'];
|
const pdvId = this.activatedRoute.snapshot.queryParams['pdv_id'];
|
||||||
if (pdvId) list = list.filter(route => String(route.json.pdv_id) === String(pdvId));
|
if (pdvId) list = list.filter(route => String(route.json.pdv_id) === String(pdvId));
|
||||||
this.filteredRoutes = list;
|
this.filteredRoutes = list;
|
||||||
|
this.unassignedRoutes = this.planningFilters.filterUnassigned(
|
||||||
|
this.routeState.getNoAssignedSnapshot().length ? this.routeState.getNoAssignedSnapshot() : this.unassignedRoutes
|
||||||
|
);
|
||||||
if (this.filteredRoutes.length === 0) {
|
if (this.filteredRoutes.length === 0) {
|
||||||
this.error = this.allRoutes.length === 0 ? 'No hay rutas cargadas. Sube archivos en Home.' : 'Sin rutas para este metro, usuario o día.';
|
this.error = this.allRoutes.length === 0 ? 'No hay rutas cargadas. Sube archivos en Home.' : 'Sin rutas para este metro, usuario o día.';
|
||||||
} else {
|
} else {
|
||||||
this.error = null;
|
this.error = null;
|
||||||
}
|
}
|
||||||
|
this.overviewMode = this.computeOverviewMode();
|
||||||
|
// Clear opposite selection when filtering by assignment state
|
||||||
|
if (!this.showAssigned) {
|
||||||
|
this.selectedRouteDetail = null;
|
||||||
|
this.selectedMapRoute = null;
|
||||||
|
}
|
||||||
|
if (!this.showUnassigned) {
|
||||||
|
this.selectedUnassigned = null;
|
||||||
|
this.editingUnassigned = null;
|
||||||
|
this.unassignedActionError = null;
|
||||||
|
this.isUnassignedDrawerOpen = false;
|
||||||
|
}
|
||||||
// Clear selected detail if not in filtered
|
// Clear selected detail if not in filtered
|
||||||
if (this.selectedRouteDetail && !this.filteredRoutes.some(route => this.sameRoute(route, this.selectedRouteDetail!))) {
|
if (this.selectedRouteDetail && !this.filteredRoutes.some(route => this.sameRoute(route, this.selectedRouteDetail!))) {
|
||||||
this.selectedRouteDetail = null;
|
this.selectedRouteDetail = null;
|
||||||
@@ -331,18 +519,27 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
const day = queryParams['dia'];
|
const day = queryParams['dia'];
|
||||||
this.selectedUsuarioId = userId && this.usuarios.some(user => String(user.id) === String(userId)) ? String(userId) : 'Todos';
|
this.selectedUsuarioId = userId && this.usuarios.some(user => String(user.id) === String(userId)) ? String(userId) : 'Todos';
|
||||||
this.selectedDia = day && day !== 'Todos' && this.dias.includes(day) ? day : 'Todos';
|
this.selectedDia = day && day !== 'Todos' && this.dias.includes(day) ? day : 'Todos';
|
||||||
|
const requestedAsignacion = String(queryParams['asignacion'] ?? 'Todos').trim();
|
||||||
|
this.selectedAsignacion = requestedAsignacion === 'Asignados' || requestedAsignacion === 'NoAsignados'
|
||||||
|
? requestedAsignacion
|
||||||
|
: 'Todos';
|
||||||
const requestedMetro = String(queryParams['metro'] ?? '').trim();
|
const requestedMetro = String(queryParams['metro'] ?? '').trim();
|
||||||
|
if (!requestedMetro || requestedMetro === 'Todos') {
|
||||||
|
this.selectedMetro = 'Todos';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const userMetro = this.selectedUsuarioId === 'Todos' ? '' : String(
|
const userMetro = this.selectedUsuarioId === 'Todos' ? '' : String(
|
||||||
this.allRoutes.find(route => String(route.json.usuario_id) === this.selectedUsuarioId)?.json.metro ?? ''
|
this.allRoutes.find(route => String(route.json.usuario_id) === this.selectedUsuarioId)?.json.metro ?? ''
|
||||||
).trim();
|
).trim();
|
||||||
const defaultMetro = this.metros.find(metro => ['cdmx', 'ciudaddemexico', 'mexicocity'].includes(this.normalizeMetro(metro))) ?? this.metros[0] ?? '';
|
if (this.metros.includes(requestedMetro)) {
|
||||||
this.selectedMetro = this.metros.includes(requestedMetro)
|
this.selectedMetro = requestedMetro;
|
||||||
? requestedMetro
|
} else if (this.metros.includes(userMetro)) {
|
||||||
: this.metros.includes(userMetro)
|
this.selectedMetro = userMetro;
|
||||||
? userMetro
|
} else if (this.selectedMetro !== 'Todos' && this.metros.includes(this.selectedMetro)) {
|
||||||
: this.metros.includes(this.selectedMetro)
|
return;
|
||||||
? this.selectedMetro
|
} else {
|
||||||
: defaultMetro;
|
this.selectedMetro = 'Todos';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeMetro(value: string): string {
|
private normalizeMetro(value: string): string {
|
||||||
@@ -351,12 +548,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
|
|
||||||
private initMap() {
|
private initMap() {
|
||||||
const territoryBounds = this.projectBounds();
|
const territoryBounds = this.projectBounds();
|
||||||
this.map = L.map('map-overview', { maxBoundsViscosity: 0.85 });
|
this.map = L.map('map-overview', { maxBoundsViscosity: 0.85, worldCopyJump: true });
|
||||||
if (territoryBounds) {
|
if (territoryBounds) {
|
||||||
this.map.fitBounds(territoryBounds.pad(0.15));
|
this.map.fitBounds(territoryBounds.pad(0.15));
|
||||||
this.map.setMaxBounds(territoryBounds.pad(0.35));
|
|
||||||
} else {
|
} else {
|
||||||
this.map.setView([18.4861, -69.9312], 10);
|
const center = centerForCountry(this.country);
|
||||||
|
this.map.setView([center.lat, center.lng], center.zoom);
|
||||||
}
|
}
|
||||||
const pdvPane = this.map.createPane('pdvPane');
|
const pdvPane = this.map.createPane('pdvPane');
|
||||||
const heatPane = this.map.createPane('heatPane');
|
const heatPane = this.map.createPane('heatPane');
|
||||||
@@ -366,13 +563,19 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
if (popupPane) popupPane.style.zIndex = '1000';
|
if (popupPane) popupPane.style.zIndex = '1000';
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '© OpenStreetMap contributors',
|
attribution: '© OpenStreetMap contributors',
|
||||||
bounds: territoryBounds?.pad(0.35),
|
keepBuffer: 4,
|
||||||
keepBuffer: 1,
|
updateWhenIdle: false
|
||||||
noWrap: true,
|
|
||||||
updateWhenIdle: true
|
|
||||||
}).addTo(this.map);
|
}).addTo(this.map);
|
||||||
// Fix tiles after sidebar overlay
|
// Fix tiles after sidebar overlay / embedded layout settles
|
||||||
setTimeout(() => this.map?.invalidateSize(), 200);
|
setTimeout(() => this.map?.invalidateSize(), 200);
|
||||||
|
setTimeout(() => this.map?.invalidateSize(), 600);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.map?.invalidateSize();
|
||||||
|
if (!this.filteredRoutes.length) {
|
||||||
|
const bounds = this.projectBounds();
|
||||||
|
if (bounds) this.map?.fitBounds(bounds.pad(0.15));
|
||||||
|
}
|
||||||
|
}, 1200);
|
||||||
// Resize handler
|
// Resize handler
|
||||||
const onResize = () => this.map?.invalidateSize();
|
const onResize = () => this.map?.invalidateSize();
|
||||||
window.addEventListener('resize', onResize);
|
window.addEventListener('resize', onResize);
|
||||||
@@ -408,11 +611,40 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.orsProgress = 0;
|
this.orsProgress = 0;
|
||||||
this.orsTotal = 0;
|
this.orsTotal = 0;
|
||||||
|
this.orsFailCount = 0;
|
||||||
if (!this.filteredRoutes.length) {
|
if (!this.filteredRoutes.length) {
|
||||||
this.renderUnassignedMarkers();
|
this.renderUnassignedMarkers();
|
||||||
const territoryBounds = this.projectBounds();
|
const territoryBounds = this.projectBounds();
|
||||||
if (territoryBounds) this.map.fitBounds(territoryBounds.pad(0.15));
|
if (territoryBounds) {
|
||||||
this.cdr.detectChanges();
|
this.map.fitBounds(territoryBounds.pad(0.15));
|
||||||
|
} else {
|
||||||
|
this.centerByCountry();
|
||||||
|
}
|
||||||
|
this.safeDetectChanges();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.overviewMode = this.computeOverviewMode();
|
||||||
|
const overviewOnly = this.overviewMode;
|
||||||
|
const showAssigned = this.showAssigned;
|
||||||
|
const showUnassigned = this.showUnassigned;
|
||||||
|
this.rebuildPdvVisitIndex(sequenceRoutes(this.filteredRoutes));
|
||||||
|
|
||||||
|
// Solo no asignados: sin capas asignadas, solo marcadores pendientes.
|
||||||
|
if (!showAssigned) {
|
||||||
|
const onlyBounds: L.LatLngExpression[] = [];
|
||||||
|
for (const route of this.filteredUnassignedRoutes) {
|
||||||
|
if (this.validPoint(route.latitud, route.longitud)) onlyBounds.push([Number(route.latitud), Number(route.longitud)]);
|
||||||
|
}
|
||||||
|
this.updateHeatLayer();
|
||||||
|
this.toggleLayerVisibility();
|
||||||
|
try {
|
||||||
|
if (onlyBounds.length) this.map!.fitBounds(L.latLngBounds(onlyBounds as L.LatLngTuple[]).pad(0.15));
|
||||||
|
else this.centerByCountry();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
this.error = this.filteredUnassignedRoutes.length ? null : 'Sin PDVs sin ruta para este filtro.';
|
||||||
|
this.renderUnassignedMarkers();
|
||||||
|
this.safeDetectChanges();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +661,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
iconAnchor: [17, 17],
|
iconAnchor: [17, 17],
|
||||||
popupAnchor: [0, -18]
|
popupAnchor: [0, -18]
|
||||||
});
|
});
|
||||||
|
if (!this.validPoint(start.latitud_usuario, start.longitud_usuario)) return;
|
||||||
this.startMarkers.push(
|
this.startMarkers.push(
|
||||||
L.marker([Number(start.latitud_usuario), Number(start.longitud_usuario)], { icon: startIcon, zIndexOffset: 100 })
|
L.marker([Number(start.latitud_usuario), Number(start.longitud_usuario)], { icon: startIcon, zIndexOffset: 100 })
|
||||||
.addTo(this.map!)
|
.addTo(this.map!)
|
||||||
@@ -436,61 +669,102 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build one daily chain per user: home -> nearest PDV -> next nearest PDV.
|
// Overview mode (Todos sin filtros): solo inicios + heat + puntos livianos, sin líneas viales ni ORS.
|
||||||
const bounds: L.LatLngExpression[] = [];
|
const bounds: L.LatLngExpression[] = [];
|
||||||
|
if (overviewOnly) {
|
||||||
|
for (const route of this.filteredRoutes) {
|
||||||
|
const j = route.json;
|
||||||
|
if (this.validPoint(j.latitud_usuario, j.longitud_usuario)) bounds.push([Number(j.latitud_usuario), Number(j.longitud_usuario)]);
|
||||||
|
if (this.validPoint(j.latitud_pdv, j.longitud_pdv)) {
|
||||||
|
bounds.push([Number(j.latitud_pdv), Number(j.longitud_pdv)]);
|
||||||
|
const dot = L.circleMarker([Number(j.latitud_pdv), Number(j.longitud_pdv)], {
|
||||||
|
radius: 5,
|
||||||
|
color: '#4F758B',
|
||||||
|
weight: 1,
|
||||||
|
fillOpacity: 0.7
|
||||||
|
}).addTo(this.map!);
|
||||||
|
dot.bindPopup(this.pdvPopupHtml(j.nombre_pdv, `<strong>Usuario:</strong> ${j.nombre_usuario} • ${j.dia}`, String(j.pdv_id), route));
|
||||||
|
this.heatPointsCache.push([Number(j.latitud_pdv), Number(j.longitud_pdv), this.computeWeight(j)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showUnassigned && this.selectedUsuarioId === 'Todos' && this.selectedDia === 'Todos') {
|
||||||
|
for (const route of this.filteredUnassignedRoutes) {
|
||||||
|
if (this.validPoint(route.latitud, route.longitud)) bounds.push([Number(route.latitud), Number(route.longitud)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.updateHeatLayer();
|
||||||
|
this.toggleLayerVisibility();
|
||||||
|
try {
|
||||||
|
if (bounds.length) this.map!.fitBounds(L.latLngBounds(bounds as L.LatLngTuple[]).pad(0.15));
|
||||||
|
else this.centerByCountry();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
this.error = null;
|
||||||
|
this.renderUnassignedMarkers();
|
||||||
|
this.safeDetectChanges();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build one daily chain per user: home -> nearest PDV -> next nearest PDV.
|
||||||
|
// (El índice semanal ya se construyó arriba con el mismo orden.)
|
||||||
const sequencedRoutes = sequenceRoutes(this.filteredRoutes);
|
const sequencedRoutes = sequenceRoutes(this.filteredRoutes);
|
||||||
this.filteredRoutes = sequencedRoutes.map(segment => segment.route);
|
this.filteredRoutes = sequencedRoutes.map(segment => segment.route);
|
||||||
|
this.rebuildPdvVisitIndex(sequencedRoutes);
|
||||||
sequencedRoutes.forEach((segment, idx) => {
|
sequencedRoutes.forEach((segment, idx) => {
|
||||||
const route = segment.route;
|
try {
|
||||||
const j = route.json;
|
const route = segment.route;
|
||||||
const color = colorForUsuario(`${j.usuario_id}|${j.dia}`).color;
|
const j = route.json;
|
||||||
const origin = segment.origin;
|
if (!this.validPoint(segment.origin[1], segment.origin[0]) || !this.validPoint(j.latitud_pdv, j.longitud_pdv)) {
|
||||||
const dest = segment.destination;
|
return;
|
||||||
bounds.push([origin[1], origin[0]]);
|
}
|
||||||
bounds.push([Number(j.latitud_pdv), Number(j.longitud_pdv)]);
|
const color = colorForUsuario(`${j.usuario_id}|${j.dia}`).color;
|
||||||
|
const origin = segment.origin;
|
||||||
|
const dest = segment.destination;
|
||||||
|
bounds.push([origin[1], origin[0]]);
|
||||||
|
bounds.push([Number(j.latitud_pdv), Number(j.longitud_pdv)]);
|
||||||
|
|
||||||
// Fallback dashed line (visible immediately)
|
// Fallback dashed line (visible inmediatamente; si ORS falla queda como recta explicada)
|
||||||
const fallbackLine = L.polyline(
|
const fallbackLine = L.polyline(
|
||||||
[[origin[1], origin[0]], [dest[1], dest[0]]],
|
[[origin[1], origin[0]], [dest[1], dest[0]]],
|
||||||
{ color, weight: 4, dashArray: '10, 10', opacity: 0.6 }
|
{ color, weight: 4, dashArray: '10, 10', opacity: 0.6 }
|
||||||
).addTo(this.map!);
|
).addTo(this.map!);
|
||||||
|
|
||||||
// Marker for PDV
|
// Marker for PDV
|
||||||
const pdvIcon = L.divIcon({
|
const pdvIcon = L.divIcon({
|
||||||
className: 'custom-marker marker-end',
|
className: 'custom-marker marker-end',
|
||||||
html: `<div class="location-pin-wrapper"><div class="location-pin"><span>${segment.order}</span></div></div>`,
|
html: `<div class="location-pin-wrapper"><div class="location-pin"><span>${segment.order}</span></div></div>`,
|
||||||
iconSize: [42, 50],
|
iconSize: [42, 50],
|
||||||
iconAnchor: [21, 46],
|
iconAnchor: [21, 46],
|
||||||
popupAnchor: [0, -18]
|
popupAnchor: [0, -18]
|
||||||
});
|
});
|
||||||
const marker = L.marker([Number(j.latitud_pdv), Number(j.longitud_pdv)], { icon: pdvIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
const marker = L.marker([Number(j.latitud_pdv), Number(j.longitud_pdv)], { icon: pdvIcon, pane: 'pdvPane', zIndexOffset: 1000 })
|
||||||
.addTo(this.map!)
|
.addTo(this.map!)
|
||||||
.bindPopup(`<strong>${j.nombre_pdv}</strong><br><strong>Usuario:</strong> ${j.nombre_usuario}<br>Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`);
|
.bindPopup(this.pdvPopupHtml(j.nombre_pdv, `<strong>Usuario:</strong> ${j.nombre_usuario}<br>Parada ${segment.order} de ${segment.totalStops} • ${j.dia}`, String(j.pdv_id), route));
|
||||||
// Heat point
|
// Heat point
|
||||||
const w = this.computeWeight(j);
|
const w = this.computeWeight(j);
|
||||||
this.heatPointsCache.push([Number(j.latitud_pdv), Number(j.longitud_pdv), w]);
|
this.heatPointsCache.push([Number(j.latitud_pdv), Number(j.longitud_pdv), w]);
|
||||||
|
|
||||||
const mr: MapRoute = {
|
const mr: MapRoute = {
|
||||||
data: route,
|
data: route,
|
||||||
color,
|
color,
|
||||||
origin,
|
origin,
|
||||||
destination: dest,
|
destination: dest,
|
||||||
visitOrder: segment.order,
|
visitOrder: segment.order,
|
||||||
totalStops: segment.totalStops,
|
totalStops: segment.totalStops,
|
||||||
previousPdvName: segment.previousPdvName,
|
previousPdvName: segment.previousPdvName,
|
||||||
requestId: idx + 1,
|
requestId: idx + 1,
|
||||||
fallbackLine,
|
fallbackLine,
|
||||||
marker
|
marker
|
||||||
};
|
};
|
||||||
this.mapRoutes.push(mr);
|
this.mapRoutes.push(mr);
|
||||||
|
|
||||||
// Click handlers
|
// Click handlers
|
||||||
const onClick = () => this.selectRoute(mr);
|
const onClick = () => this.selectRoute(mr);
|
||||||
fallbackLine.on('click', onClick);
|
fallbackLine.on('click', onClick);
|
||||||
marker.on('click', onClick);
|
marker.on('click', onClick);
|
||||||
|
} catch { /* un punto malo no debe tumbar el resto del mapa */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.selectedUsuarioId === 'Todos' && this.selectedDia === 'Todos') {
|
if (showUnassigned && this.selectedUsuarioId === 'Todos' && this.selectedDia === 'Todos') {
|
||||||
for (const route of this.filteredUnassignedRoutes) {
|
for (const route of this.filteredUnassignedRoutes) {
|
||||||
if (this.validPoint(route.latitud, route.longitud)) bounds.push([Number(route.latitud), Number(route.longitud)]);
|
if (this.validPoint(route.latitud, route.longitud)) bounds.push([Number(route.latitud), Number(route.longitud)]);
|
||||||
}
|
}
|
||||||
@@ -507,18 +781,22 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
// Progressive ORS hydration — always in background (heat only hides visually)
|
// Progressive ORS hydration — always in background (heat only hides visually)
|
||||||
this.hydrateWithOrs(version);
|
this.hydrateWithOrs(version);
|
||||||
this.renderUnassignedMarkers();
|
this.renderUnassignedMarkers();
|
||||||
this.cdr.detectChanges();
|
this.safeDetectChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
private projectBounds(): L.LatLngBounds | null {
|
private projectBounds(): L.LatLngBounds | null {
|
||||||
const points: L.LatLngTuple[] = [];
|
const points: L.LatLngTuple[] = [];
|
||||||
for (const route of this.filteredRoutes) {
|
if (this.showAssigned) {
|
||||||
const data = route.json;
|
for (const route of this.filteredRoutes) {
|
||||||
if (this.validPoint(data.latitud_usuario, data.longitud_usuario)) points.push([Number(data.latitud_usuario), Number(data.longitud_usuario)]);
|
const data = route.json;
|
||||||
if (this.validPoint(data.latitud_pdv, data.longitud_pdv)) points.push([Number(data.latitud_pdv), Number(data.longitud_pdv)]);
|
if (this.validPoint(data.latitud_usuario, data.longitud_usuario)) points.push([Number(data.latitud_usuario), Number(data.longitud_usuario)]);
|
||||||
|
if (this.validPoint(data.latitud_pdv, data.longitud_pdv)) points.push([Number(data.latitud_pdv), Number(data.longitud_pdv)]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const route of this.filteredUnassignedRoutes) {
|
if (this.showUnassigned) {
|
||||||
if (this.validPoint(route.latitud, route.longitud)) points.push([Number(route.latitud), Number(route.longitud)]);
|
for (const route of this.filteredUnassignedRoutes) {
|
||||||
|
if (this.validPoint(route.latitud, route.longitud)) points.push([Number(route.latitud), Number(route.longitud)]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return points.length ? L.latLngBounds(points) : null;
|
return points.length ? L.latLngBounds(points) : null;
|
||||||
}
|
}
|
||||||
@@ -530,7 +808,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderUnassignedMarkers(): void {
|
private renderUnassignedMarkers(): void {
|
||||||
if (!this.map || this.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return;
|
if (!this.map || !this.showUnassigned || this.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return;
|
||||||
for (const route of this.filteredUnassignedRoutes) {
|
for (const route of this.filteredUnassignedRoutes) {
|
||||||
const icon = L.divIcon({
|
const icon = L.divIcon({
|
||||||
className: 'custom-marker unassigned-marker',
|
className: 'custom-marker unassigned-marker',
|
||||||
@@ -551,13 +829,28 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.orsTotal = this.filteredRoutes.length;
|
this.orsTotal = this.filteredRoutes.length;
|
||||||
this.orsProgress = 0;
|
this.orsProgress = 0;
|
||||||
this.cdr.detectChanges();
|
this.safeDetectChanges();
|
||||||
|
|
||||||
const routesForRender = [...this.mapRoutes];
|
const routesForRender = [...this.mapRoutes];
|
||||||
|
// Guard: micro-tramos (<100 m, mismo punto o vecinos) no necesitan ORS.
|
||||||
|
const microIds = new Set<number>();
|
||||||
|
for (const mr of routesForRender) {
|
||||||
|
try {
|
||||||
|
if (haversineKm(mr.origin, mr.destination) < 0.1) {
|
||||||
|
microIds.add(mr.requestId);
|
||||||
|
if (mr.fallbackLine) {
|
||||||
|
this.map?.removeLayer(mr.fallbackLine);
|
||||||
|
mr.fallbackLine = undefined;
|
||||||
|
}
|
||||||
|
mr.segmentDistanceKm = 0;
|
||||||
|
mr.segmentDurationHours = 0;
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
const embeddedResults = routesForRender
|
const embeddedResults = routesForRender
|
||||||
.filter(route => !!route.data.json.geojson)
|
.filter(route => !microIds.has(route.requestId) && !!route.data.json.geojson)
|
||||||
.map(route => ({ pdv_id: route.requestId, geojson: route.data.json.geojson }));
|
.map(route => ({ pdv_id: route.requestId, geojson: route.data.json.geojson }));
|
||||||
const pares = routesForRender.filter(route => !route.data.json.geojson).map(route => ({
|
const pares = routesForRender.filter(route => !microIds.has(route.requestId) && !route.data.json.geojson).map(route => ({
|
||||||
pdv_id: route.requestId,
|
pdv_id: route.requestId,
|
||||||
origin: route.origin,
|
origin: route.origin,
|
||||||
destination: route.destination
|
destination: route.destination
|
||||||
@@ -571,16 +864,17 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
const results = [...embeddedResults, ...remoteResults];
|
const results = [...embeddedResults, ...remoteResults];
|
||||||
if (!results.length) {
|
if (!results.length) {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.cdr.detectChanges();
|
this.safeDetectChanges();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const geoByRequest = new Map<number, unknown>();
|
const geoByRequest = new Map<number, unknown>();
|
||||||
|
const errorByRequest = new Map<number, string>();
|
||||||
for (const item of results) {
|
for (const item of results) {
|
||||||
const g = item as { pdv_id?: number; geojson?: unknown; error?: string };
|
const g = item as { pdv_id?: number; geojson?: unknown; error?: string };
|
||||||
if (g && !g.error && g.geojson) {
|
if (g && !g.error && g.geojson) {
|
||||||
geoByRequest.set(g.pdv_id!, g.geojson);
|
geoByRequest.set(g.pdv_id!, g.geojson);
|
||||||
} else if (g && g.error) {
|
} else if (g && g.error) {
|
||||||
// error entry, skip but count progress
|
if (g.pdv_id != null) errorByRequest.set(g.pdv_id, String(g.error));
|
||||||
} else {
|
} else {
|
||||||
// fallback: if item is raw geojson without wrapper (old cache), treat as geojson
|
// fallback: if item is raw geojson without wrapper (old cache), treat as geojson
|
||||||
const maybeGeo = item as unknown;
|
const maybeGeo = item as unknown;
|
||||||
@@ -590,30 +884,42 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const useMap = geoByRequest.size > 0;
|
const useMap = geoByRequest.size > 0;
|
||||||
|
let failCount = 0;
|
||||||
|
const markUnroutable = (mr: MapRoute, reason: string): void => {
|
||||||
|
mr.orsError = reason;
|
||||||
|
failCount++;
|
||||||
|
this.orsProgress = mr.requestId;
|
||||||
|
try {
|
||||||
|
mr.fallbackLine?.bindPopup(
|
||||||
|
`<strong>Sin conexión vial</strong><br>${mr.data.json.nombre_pdv} • ${mr.data.json.nombre_usuario} • ${mr.data.json.dia}<br><small>${reason}</small>`
|
||||||
|
);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
for (let i = 0; i < routesForRender.length; i++) {
|
for (let i = 0; i < routesForRender.length; i++) {
|
||||||
if (version !== this.renderVersion) return;
|
if (version !== this.renderVersion) return;
|
||||||
const mr = routesForRender[i];
|
const mr = routesForRender[i];
|
||||||
|
if (microIds.has(mr.requestId)) {
|
||||||
|
this.orsProgress = mr.requestId;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let geo: unknown = null;
|
let geo: unknown = null;
|
||||||
if (useMap) {
|
if (useMap) {
|
||||||
geo = geoByRequest.get(mr.requestId);
|
geo = geoByRequest.get(mr.requestId);
|
||||||
} else {
|
} else {
|
||||||
const res = results[i] as { error?: string; geojson?: unknown; type?: string; features?: unknown[] };
|
const res = results[i] as { error?: string; geojson?: unknown; type?: string; features?: unknown[] };
|
||||||
if ((res as any)?.error) {
|
if ((res as any)?.error) {
|
||||||
this.orsProgress = i + 1;
|
markUnroutable(mr, String((res as any).error));
|
||||||
this.cdr.detectChanges();
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
geo = (res as any)?.geojson ?? res;
|
geo = (res as any)?.geojson ?? res;
|
||||||
}
|
}
|
||||||
if (!geo) {
|
if (!geo) {
|
||||||
this.orsProgress = i + 1;
|
markUnroutable(mr, errorByRequest.get(mr.requestId) ?? 'ORS no devolvió geometría para este tramo.');
|
||||||
this.cdr.detectChanges();
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const coords = this.extractCoords(geo);
|
const coords = this.extractCoords(geo);
|
||||||
if (!coords.length) {
|
if (!coords.length) {
|
||||||
this.orsProgress = i + 1;
|
markUnroutable(mr, errorByRequest.get(mr.requestId) ?? 'ORS no devolvió geometría para este tramo.');
|
||||||
this.cdr.detectChanges();
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (mr.fallbackLine) {
|
if (mr.fallbackLine) {
|
||||||
@@ -628,7 +934,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
mr.segmentDistanceKm = summary.distance / 1000;
|
mr.segmentDistanceKm = summary.distance / 1000;
|
||||||
mr.segmentDurationHours = summary.duration / 3600;
|
mr.segmentDurationHours = summary.duration / 3600;
|
||||||
mr.marker?.setPopupContent(
|
mr.marker?.setPopupContent(
|
||||||
`<strong>${mr.data.json.nombre_pdv}</strong><br><strong>Usuario:</strong> ${mr.data.json.nombre_usuario}<br>Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}<br>${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min`
|
this.pdvPopupHtml(mr.data.json.nombre_pdv, `<strong>Usuario:</strong> ${mr.data.json.nombre_usuario}<br>Parada ${mr.visitOrder} de ${mr.totalStops} • ${mr.data.json.dia}<br>${mr.segmentDistanceKm.toFixed(2)} km • ${(mr.segmentDurationHours * 60).toFixed(0)} min`, String(mr.data.json.pdv_id), mr.data)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
line.on('click', () => this.selectRoute(mr));
|
line.on('click', () => this.selectRoute(mr));
|
||||||
@@ -638,15 +944,15 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
this.fitSelectedRoute(mr);
|
this.fitSelectedRoute(mr);
|
||||||
}
|
}
|
||||||
this.orsProgress = i + 1;
|
this.orsProgress = i + 1;
|
||||||
this.cdr.detectChanges();
|
|
||||||
}
|
}
|
||||||
|
this.orsFailCount = failCount;
|
||||||
this.toggleLayerVisibility();
|
this.toggleLayerVisibility();
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore abort / network errors, fallback lines remain
|
// Ignore abort / network errors, fallback lines remain
|
||||||
} finally {
|
} finally {
|
||||||
if (version === this.renderVersion) {
|
if (version === this.renderVersion) {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.cdr.detectChanges();
|
this.safeDetectChanges();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,14 @@
|
|||||||
.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; }
|
.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); }
|
.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) {
|
@media (max-width: 640px) {
|
||||||
.pdvs-page { padding: 18px 14px; }
|
.pdvs-page { padding: 18px 14px; }
|
||||||
.pdvs-page > header { align-items: flex-start; flex-direction: column; }
|
.pdvs-page > header { align-items: flex-start; flex-direction: column; }
|
||||||
.import-button { align-self: flex-start; }
|
.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 +1,5 @@
|
|||||||
<div class="pdvs-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
|
<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>
|
<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>
|
<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-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>
|
<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>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { ProjectService } from '../../core/services/project.service';
|
import { ProjectService } from '../../core/services/project.service';
|
||||||
import { RuteroProyectoPdv } from '../../core/models/project.model';
|
import { RuteroProyectoPdv } from '../../core/models/project.model';
|
||||||
import { ExcelPreviewService } from '../../shared/services/excel-preview.service';
|
import { ExcelPreviewService, FieldError } from '../../shared/services/excel-preview.service';
|
||||||
import { normalizeRow } from '../../shared/utils/header-normalization.utils';
|
import { normalizeRow } from '../../shared/utils/header-normalization.utils';
|
||||||
import { AuthService } from '../../core/services/auth.service';
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
import { UserProfileService } from '../../core/services/user-profile.service';
|
import { UserProfileService } from '../../core/services/user-profile.service';
|
||||||
@@ -44,6 +44,8 @@ export class PdvsComponent implements OnInit {
|
|||||||
importing = false;
|
importing = false;
|
||||||
error = '';
|
error = '';
|
||||||
message = '';
|
message = '';
|
||||||
|
importErrors: FieldError[] = [];
|
||||||
|
importMissingHeaders: string[] = [];
|
||||||
projectCountry = 'Republica Dominicana';
|
projectCountry = 'Republica Dominicana';
|
||||||
selectedIds = new Set<string>();
|
selectedIds = new Set<string>();
|
||||||
bulkSaving = false;
|
bulkSaving = false;
|
||||||
@@ -115,10 +117,14 @@ export class PdvsComponent implements OnInit {
|
|||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
this.importing = true; this.error = ''; this.message = '';
|
this.importing = true; this.error = ''; this.message = ''; this.importErrors = []; this.importMissingHeaders = [];
|
||||||
try {
|
try {
|
||||||
const result = await this.preview.previewFile(file, 'PDV');
|
const result = await this.preview.previewFile(file, 'PDV');
|
||||||
if (!result.valid) throw new Error(result.errors[0]?.message ?? 'El archivo PDV contiene errores.');
|
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 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 });
|
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);
|
await this.projects.upsertPdvs(this.projectId, rows);
|
||||||
|
|||||||
@@ -4,14 +4,22 @@ 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 { 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) {}
|
constructor(private routeState: RouteState, private filters: PlanningFilterService, private projects: ProjectService) {}
|
||||||
|
|
||||||
export(): void {
|
async export(projectId: string): Promise<void> {
|
||||||
|
const project = await this.projects.get(projectId);
|
||||||
|
const fuelConfig = { rendimientoKmL: project.rendimiento_km_l ?? null, precioPorLitro: project.precio_por_litro ?? null };
|
||||||
|
const fuelConfigured = isFuelConfigured(fuelConfig);
|
||||||
|
const fuelCurrency = currencyForCountry(project.pais);
|
||||||
const assigned = this.filters.filterAssigned(this.routeState.getAssignedSnapshot());
|
const assigned = this.filters.filterAssigned(this.routeState.getAssignedSnapshot());
|
||||||
const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot());
|
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 }>();
|
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) {
|
||||||
@@ -32,11 +40,13 @@ export class PlanningExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const assignedIds = new Set(assigned.map(route => String(route.json.pdv_id)));
|
const assignedIds = new Set(assigned.map(route => String(route.json.pdv_id)));
|
||||||
const unassignedIds = new Set(unassigned.map(route => String(route.pdv_id)).filter(id => !assignedIds.has(id)));
|
const 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 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 = [
|
||||||
@@ -47,7 +57,9 @@ export class PlanningExportService {
|
|||||||
{ 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 => ({
|
||||||
@@ -61,15 +73,17 @@ export class PlanningExportService {
|
|||||||
'Horas trabajo': segment.route.json.horas_trabajo,
|
'Horas trabajo': segment.route.json.horas_trabajo,
|
||||||
'Horas disponibles': segment.route.json.hora_laboral_semanal_usuario,
|
'Horas disponibles': segment.route.json.hora_laboral_semanal_usuario,
|
||||||
'Orden visita': segment.order,
|
'Orden visita': segment.order,
|
||||||
'Origen del tramo': segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}`
|
'Origen del tramo': segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}`,
|
||||||
|
'Costo combustible': fuelConfigured ? Number(fuelCost(Number(segment.route.json.distancia) || 0, fuelConfig).cost.toFixed(2)) : 'Sin configurar'
|
||||||
}));
|
}));
|
||||||
const unassignedRows = unassigned.map(route => ({
|
const unassignedRows = pending.map(route => ({
|
||||||
'PDV ID': route.pdv_id,
|
'PDV ID': route.pdv_id,
|
||||||
PDV: route.pdv_nombre,
|
PDV: route.pdv_nombre,
|
||||||
'Mínimo horas/semana': route.minimo_horas_semana,
|
'Mínimo horas/semana': route.minimo_horas_semana,
|
||||||
Latitud: route.latitud,
|
Latitud: route.latitud,
|
||||||
Longitud: route.longitud,
|
Longitud: route.longitud,
|
||||||
Motivo: route.motivo
|
Motivo: route.motivo,
|
||||||
|
Recomendación: route.recomendacion ?? ''
|
||||||
}));
|
}));
|
||||||
const userRows = Array.from(users, ([id, user]) => {
|
const userRows = Array.from(users, ([id, user]) => {
|
||||||
const utilization = user.capacidad ? (user.trabajo / user.capacidad) * 100 : 0;
|
const utilization = user.capacidad ? (user.trabajo / user.capacidad) * 100 : 0;
|
||||||
@@ -82,6 +96,7 @@ export class PlanningExportService {
|
|||||||
'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'
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ProjectService } from '../../core/services/project.service';
|
|||||||
import { PlanningFilterService } from '../../core/services/planning-filter.service';
|
import { PlanningFilterService } from '../../core/services/planning-filter.service';
|
||||||
import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils';
|
import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils';
|
||||||
import { recommendationForUnassigned } from '../utils/unassigned-recommendation.utils';
|
import { recommendationForUnassigned } from '../utils/unassigned-recommendation.utils';
|
||||||
|
import { currencyForCountry, fuelCost, isFuelConfigured } from '../utils/fuel-estimation.utils';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class RoutePdfReportService {
|
export class RoutePdfReportService {
|
||||||
@@ -30,17 +31,31 @@ export class RoutePdfReportService {
|
|||||||
.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
|
.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
|
||||||
const dailyRoutes = summarizeDailyRoutes(assigned);
|
const dailyRoutes = summarizeDailyRoutes(assigned);
|
||||||
const totalPdvs = assignedIds.size + new Set(pending.map(route => String(route.pdv_id))).size;
|
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 = {
|
const payload = {
|
||||||
project: { id: project.id, name: project.nombre, country: project.pais ?? '', description: project.descripcion ?? '' },
|
project: { id: project.id, name: project.nombre, country: project.pais ?? '', description: project.descripcion ?? '' },
|
||||||
filters: this.filters.snapshot(),
|
filters: this.filters.snapshot(),
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
|
fuel: {
|
||||||
|
configured: fuelConfigured,
|
||||||
|
rendimientoKmL: fuelConfig.rendimientoKmL,
|
||||||
|
precioPorLitro: fuelConfig.precioPorLitro,
|
||||||
|
currency: fuelCurrency
|
||||||
|
},
|
||||||
summary: {
|
summary: {
|
||||||
totalPdvs,
|
totalPdvs,
|
||||||
assignedPdvs: assignedIds.size,
|
assignedPdvs: assignedIds.size,
|
||||||
unassignedPdvs: pending.length,
|
unassignedPdvs: pending.length,
|
||||||
coveragePercent: totalPdvs ? Number(((assignedIds.size / totalPdvs) * 100).toFixed(1)) : 0,
|
coveragePercent: totalPdvs ? Number(((assignedIds.size / totalPdvs) * 100).toFixed(1)) : 0,
|
||||||
totalDistanceKm: Number(dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0).toFixed(2)),
|
totalDistanceKm: Number(totalDistanceKm.toFixed(2)),
|
||||||
totalWorkHours: Number(dailyRoutes.reduce((sum, route) => sum + route.workHours, 0).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 => ({
|
assignedRoutes: sequenceRoutes(assigned).map(segment => ({
|
||||||
usuarioId: segment.route.json.usuario_id,
|
usuarioId: segment.route.json.usuario_id,
|
||||||
@@ -53,6 +68,8 @@ export class RoutePdfReportService {
|
|||||||
distanciaKm: segment.route.json.distancia,
|
distanciaKm: segment.route.json.distancia,
|
||||||
desplazamientoHoras: segment.route.json.horas_desplazamiento,
|
desplazamientoHoras: segment.route.json.horas_desplazamiento,
|
||||||
horasTrabajo: segment.route.json.horas_trabajo,
|
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 ?? '',
|
cobertura: segment.route.json.cobertura ?? '',
|
||||||
zona: segment.route.json.zona ?? '',
|
zona: segment.route.json.zona ?? '',
|
||||||
region: segment.route.json.region ?? '',
|
region: segment.route.json.region ?? '',
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export interface CountryCenter {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
zoom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(value: string | null | undefined): string {
|
||||||
|
return (value ?? '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]/gi, '')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function centerForCountry(country: string | null | undefined): CountryCenter {
|
||||||
|
const normalized = normalize(country);
|
||||||
|
if (!normalized) return { lat: 19.4326, lng: -99.1332, zoom: 5 };
|
||||||
|
if (['mexico', 'mx', 'cdmx', 'ciudaddemexico', 'mexicocity', 'estadosunidosmexicanos'].includes(normalized)) {
|
||||||
|
return { lat: 19.4326, lng: -99.1332, zoom: 10 };
|
||||||
|
}
|
||||||
|
if (['republicadominicana', 'dominicana', 'rd', 'do', 'santodomingo'].includes(normalized)) {
|
||||||
|
return { lat: 18.4861, lng: -69.9312, zoom: 8 };
|
||||||
|
}
|
||||||
|
return { lat: 19.4326, lng: -99.1332, zoom: 5 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export interface FuelConfig {
|
||||||
|
rendimientoKmL: number | null;
|
||||||
|
precioPorLitro: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currencyForCountry(country: string | null | undefined): string {
|
||||||
|
const normalized = (country ?? '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]/gi, '')
|
||||||
|
.toLowerCase();
|
||||||
|
if (!normalized) return '$';
|
||||||
|
if (['mexico', 'mx', 'cdmx', 'ciudaddemexico', 'mexicocity', 'estadosunidosmexicanos'].includes(normalized)) return 'MX$';
|
||||||
|
if (['republicadominicana', 'dominicana', 'rd', 'do', 'santodomingo'].includes(normalized)) return 'RD$';
|
||||||
|
return '$';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFuelConfigured(config: FuelConfig): boolean {
|
||||||
|
return (
|
||||||
|
config.rendimientoKmL != null &&
|
||||||
|
config.precioPorLitro != null &&
|
||||||
|
Number.isFinite(config.rendimientoKmL) &&
|
||||||
|
Number.isFinite(config.precioPorLitro) &&
|
||||||
|
config.rendimientoKmL > 0 &&
|
||||||
|
config.precioPorLitro >= 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fuelLiters(distanceKm: number, rendimientoKmL: number): number {
|
||||||
|
if (!Number.isFinite(distanceKm) || distanceKm <= 0) return 0;
|
||||||
|
if (!Number.isFinite(rendimientoKmL) || rendimientoKmL <= 0) return 0;
|
||||||
|
return distanceKm / rendimientoKmL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fuelCost(distanceKm: number, config: FuelConfig): { liters: number; cost: number } {
|
||||||
|
if (!isFuelConfigured(config)) return { liters: 0, cost: 0 };
|
||||||
|
const liters = fuelLiters(distanceKm, config.rendimientoKmL as number);
|
||||||
|
return { liters, cost: liters * (config.precioPorLitro as number) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFuelCost(cost: number, currency: string): string {
|
||||||
|
return `${currency} ${cost.toFixed(2)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
alter table public.rutero_proyectos
|
||||||
|
add column if not exists rendimiento_km_l numeric null,
|
||||||
|
add column if not exists precio_por_litro numeric null;
|
||||||
Reference in New Issue
Block a user