feat: unir dashboard y mapa general en una misma pantalla

This commit is contained in:
2026-10-05 14:13:01 -04:00
parent afbcc792ae
commit 69f26cb124
35 changed files with 1059 additions and 535 deletions
Binary file not shown.
+94 -12
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[char]));\nconst filters = Object.entries(payload.filters ?? {}).filter(([, value]) => value).map(([key, value]) => `<span>${escape(key)}: ${escape(value)}</span>`).join('');\nconst routeRows = payload.assignedRoutes.map(route => `<tr><td>${escape(route.usuario)}</td><td>${escape(route.dia)}</td><td>${escape(route.orden)}</td><td>${escape(route.pdv)}</td><td>${escape(route.origen)}</td><td>${escape(route.distanciaKm)} km</td><td>${escape(route.horasTrabajo)} h</td></tr>`).join('') || '<tr><td colspan=\"7\">Sin rutas asignadas para los filtros seleccionados.</td></tr>';\nconst unassignedRows = payload.unassignedPdvs.map(route => `<tr><td>${escape(route.pdv_id)}</td><td>${escape(route.pdv_nombre)}</td><td>${escape(route.minimo_horas_semana)} h</td><td>${escape(route.motivo)}</td><td>${escape(route.recomendacion)}</td></tr>`).join('') || '<tr><td colspan=\"5\">No hay PDVs sin asignar para los filtros seleccionados.</td></tr>';\nconst summary = payload.summary ?? {};\nconst html = `<!doctype html><html><head><meta charset=\"utf-8\"><style>body{font-family:Arial,sans-serif;color:#243746;margin:28px;font-size:11px}h1{color:#4F758B;margin:0}h2{color:#4F758B;border-bottom:2px solid #6CC24A;padding-bottom:6px;margin-top:28px}p{line-height:1.45}.meta{color:#61798a}.filters span{display:inline-block;background:#eef6e8;border:1px solid #a4d65e;border-radius:3px;margin:3px 5px 3px 0;padding:4px 6px}.summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:15px 0}.metric{border:1px solid #d0d0d0;border-left:3px solid #6CC24A;padding:8px}.metric b{display:block;color:#4F758B;font-size:16px}table{width:100%;border-collapse:collapse;margin-top:10px}th{background:#4F758B;color:#fff;text-align:left;padding:7px}td{border-bottom:1px solid #e5e5e5;padding:7px;vertical-align:top}.footer{margin-top:24px;color:#6b8fa3;font-size:9px}</style></head><body><h1>Reporte de rutas</h1><p class=\"meta\"><b>${escape(payload.project.name)}</b><br>${escape(payload.project.country)}${payload.project.description ? `<br>${escape(payload.project.description)}` : ''}<br>Generado: ${escape(payload.generatedAt)}</p>${filters ? `<div class=\"filters\"><b>Filtros aplicados:</b><br>${filters}</div>` : '<p><b>Filtros aplicados:</b> Ninguno</p>'}<div class=\"summary\"><div class=\"metric\"><b>${escape(summary.totalPdvs)}</b>PDVs totales</div><div class=\"metric\"><b>${escape(summary.assignedPdvs)}</b>PDVs asignados</div><div class=\"metric\"><b>${escape(summary.unassignedPdvs)}</b>PDVs sin asignar</div><div class=\"metric\"><b>${escape(summary.coveragePercent)}%</b>Cobertura</div><div class=\"metric\"><b>${escape(summary.totalDistanceKm)} km</b>Distancia</div><div class=\"metric\"><b>${escape(summary.totalWorkHours)} h</b>Trabajo</div></div><h2>Rutas asignadas</h2><table><thead><tr><th>Usuario</th><th>Día</th><th>Orden</th><th>PDV</th><th>Origen</th><th>Distancia</th><th>Trabajo</th></tr></thead><tbody>${routeRows}</tbody></table><h2>PDVs sin asignar</h2><table><thead><tr><th>PDV ID</th><th>PDV</th><th>Horas/semana</th><th>Motivo</th><th>Recomendación</th></tr></thead><tbody>${unassignedRows}</tbody></table><p class=\"footer\">Route Planner · GomezLee Marketing</p></body></html>`;\nreturn [{ json: { fileName: `rutas_${String(payload.project.name).replace(/[^a-z0-9]+/gi, '_').toLowerCase()}.pdf` }, binary: { data: { data: Buffer.from(html).toString('base64'), mimeType: 'text/html', fileName: 'index.html' } } }];" "jsCode": "const payload = $json.payload;\nconst escape = value => String(value ?? '').replace(/[&<>\"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[char]));\nconst filters = Object.entries(payload.filters ?? {}).filter(([, value]) => value).map(([key, value]) => `<span>${escape(key)}: ${escape(value)}</span>`).join('');\nconst routeRows = payload.assignedRoutes.map(route => `<tr><td>${escape(route.usuario)}</td><td>${escape(route.dia)}</td><td>${escape(route.orden)}</td><td>${escape(route.pdv)}</td><td>${escape(route.origen)}</td><td>${escape(route.distanciaKm)} km</td><td>${escape(route.horasTrabajo)} h</td><td>${route.costoCombustible != null ? `${escape(route.monedaCombustible)} ${escape(route.costoCombustible)}` : '\\u2014'}</td></tr>`).join('') || '<tr><td colspan=\"8\">Sin rutas asignadas para los filtros seleccionados.</td></tr>';\nconst unassignedRows = payload.unassignedPdvs.map(route => `<tr><td>${escape(route.pdv_id)}</td><td>${escape(route.pdv_nombre)}</td><td>${escape(route.minimo_horas_semana)} h</td><td>${escape(route.motivo)}</td><td>${escape(route.recomendacion)}</td></tr>`).join('') || '<tr><td colspan=\"5\">No hay PDVs sin asignar para los filtros seleccionados.</td></tr>';\nconst summary = payload.summary ?? {};const fuel = payload.fuel ?? {};\nconst html = `<!doctype html><html><head><meta charset=\"utf-8\"><style>body{font-family:Arial,sans-serif;color:#243746;margin:28px;font-size:11px}h1{color:#4F758B;margin:0}h2{color:#4F758B;border-bottom:2px solid #6CC24A;padding-bottom:6px;margin-top:28px}p{line-height:1.45}.meta{color:#61798a}.filters span{display:inline-block;background:#eef6e8;border:1px solid #a4d65e;border-radius:3px;margin:3px 5px 3px 0;padding:4px 6px}.summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:15px 0}.metric{border:1px solid #d0d0d0;border-left:3px solid #6CC24A;padding:8px}.metric b{display:block;color:#4F758B;font-size:16px}table{width:100%;border-collapse:collapse;margin-top:10px}th{background:#4F758B;color:#fff;text-align:left;padding:7px}td{border-bottom:1px solid #e5e5e5;padding:7px;vertical-align:top}.footer{margin-top:24px;color:#6b8fa3;font-size:9px}</style></head><body><h1>Reporte de rutas</h1><p class=\"meta\"><b>${escape(payload.project.name)}</b><br>${escape(payload.project.country)}${payload.project.description ? `<br>${escape(payload.project.description)}` : ''}<br>Generado: ${escape(payload.generatedAt)}</p>${filters ? `<div class=\"filters\"><b>Filtros aplicados:</b><br>${filters}</div>` : '<p><b>Filtros aplicados:</b> Ninguno</p>'}<div class=\"summary\"><div class=\"metric\"><b>${escape(summary.totalPdvs)}</b>PDVs totales</div><div class=\"metric\"><b>${escape(summary.assignedPdvs)}</b>PDVs asignados</div><div class=\"metric\"><b>${escape(summary.unassignedPdvs)}</b>PDVs sin asignar</div><div class=\"metric\"><b>${escape(summary.coveragePercent)}%</b>Cobertura</div><div class=\"metric\"><b>${escape(summary.totalDistanceKm)} km</b>Distancia</div><div class=\"metric\"><b>${escape(summary.totalWorkHours)} h</b>Trabajo</div><div class=\"metric\"><b>${fuel.configured ? `${escape(summary.fuelLiters)} L` : '\\u2014'}</b>Combustible</div><div class=\"metric\"><b>${fuel.configured ? `${escape(summary.fuelCurrency)} ${escape(summary.fuelCost)}` : '\\u2014'}</b>Costo combustible</div></div><h2>Rutas asignadas</h2><table><thead><tr><th>Usuario</th><th>Día</th><th>Orden</th><th>PDV</th><th>Origen</th><th>Distancia</th><th>Trabajo</th><th>Costo comb.</th></tr></thead><tbody>${routeRows}</tbody></table><h2>PDVs sin asignar</h2><table><thead><tr><th>PDV ID</th><th>PDV</th><th>Horas/semana</th><th>Motivo</th><th>Recomendación</th></tr></thead><tbody>${unassignedRows}</tbody></table><p class=\"footer\">Route Planner · GomezLee Marketing</p></body></html>`;\nreturn [{ json: { fileName: `rutas_${String(payload.project.name).replace(/[^a-z0-9]+/gi, '_').toLowerCase()}.pdf` }, binary: { data: { data: Buffer.from(html).toString('base64'), mimeType: 'text/html', fileName: 'index.html' } } }];"
}, },
"type": "n8n-nodes-base.code", "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": {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -30,7 +30,7 @@
"production": { "production": {
"budgets": [ "budgets": [
{ "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" }, { "type": "initial", "maximumWarning": "2.1MB", "maximumError": "3MB" },
{ "type": "anyComponentStyle", "maximumWarning": "6kB", "maximumError": "12kB" } { "type": "anyComponentStyle", "maximumWarning": "8kB", "maximumError": "15kB" }
], ],
"outputHashing": "all", "outputHashing": "all",
"fileReplacements": [ "fileReplacements": [
+2 -2
View File
@@ -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; } }
@@ -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 = '';
} }
} }
@@ -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 }; }
} }
@@ -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;
}
}
@@ -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>
@@ -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,20 +669,60 @@ 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) => {
try {
const route = segment.route; const route = segment.route;
const j = route.json; const j = route.json;
if (!this.validPoint(segment.origin[1], segment.origin[0]) || !this.validPoint(j.latitud_pdv, j.longitud_pdv)) {
return;
}
const color = colorForUsuario(`${j.usuario_id}|${j.dia}`).color; const color = colorForUsuario(`${j.usuario_id}|${j.dia}`).color;
const origin = segment.origin; const origin = segment.origin;
const dest = segment.destination; const dest = segment.destination;
bounds.push([origin[1], origin[0]]); bounds.push([origin[1], origin[0]]);
bounds.push([Number(j.latitud_pdv), Number(j.longitud_pdv)]); 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 }
@@ -465,7 +738,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
}); });
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]);
@@ -488,9 +761,10 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
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,19 +781,23 @@ 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[] = [];
if (this.showAssigned) {
for (const route of this.filteredRoutes) { for (const route of this.filteredRoutes) {
const data = route.json; const data = route.json;
if (this.validPoint(data.latitud_usuario, data.longitud_usuario)) points.push([Number(data.latitud_usuario), Number(data.longitud_usuario)]); if (this.validPoint(data.latitud_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)]); if (this.validPoint(data.latitud_pdv, data.longitud_pdv)) points.push([Number(data.latitud_pdv), Number(data.longitud_pdv)]);
} }
}
if (this.showUnassigned) {
for (const route of this.filteredUnassignedRoutes) { for (const route of this.filteredUnassignedRoutes) {
if (this.validPoint(route.latitud, route.longitud)) points.push([Number(route.latitud), Number(route.longitud)]); 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();
} }
} }
} }
@@ -23,3 +23,9 @@
.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)}`;
}
+3
View File
@@ -0,0 +1,3 @@
alter table public.rutero_proyectos
add column if not exists rendimiento_km_l numeric null,
add column if not exists precio_por_litro numeric null;