Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3d6653143 | |||
| 23e01f398b |
@@ -0,0 +1,141 @@
|
|||||||
|
{
|
||||||
|
"name": "Generar reporte de rutas PDF",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "generar-rutas-pdf",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2.1,
|
||||||
|
"position": [-600, 260],
|
||||||
|
"id": "e91ef4ce-4c71-4a7b-91d7-1a4f1c2db015",
|
||||||
|
"name": "Webhook: Generate PDF"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const payload = $json.body && typeof $json.body === 'object' ? $json.body : $json;\nconst errors = [];\nif (!payload?.project?.id || !payload?.project?.name) errors.push('Se requiere la información del proyecto.');\nif (!Array.isArray(payload?.assignedRoutes)) errors.push('assignedRoutes debe ser una lista.');\nif (!Array.isArray(payload?.unassignedPdvs)) errors.push('unassignedPdvs debe ser una lista.');\nif ((payload?.assignedRoutes?.length ?? 0) + (payload?.unassignedPdvs?.length ?? 0) > 5000) errors.push('El reporte excede el máximo de 5,000 registros.');\nif (errors.length) throw new Error(errors.join(' '));\nreturn [{ json: { payload } }];"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [-360, 260],
|
||||||
|
"id": "abbd2d6e-5c04-4bbb-ae1b-e4f0d2e4574e",
|
||||||
|
"name": "Validate Report Payload",
|
||||||
|
"onError": "continueErrorOutput"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const payload = $json.payload;\nconst escape = value => String(value ?? '').replace(/[&<>\"']/g, char => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[char]));\nconst filters = Object.entries(payload.filters ?? {}).filter(([, value]) => value).map(([key, value]) => `<span>${escape(key)}: ${escape(value)}</span>`).join('');\nconst routeRows = payload.assignedRoutes.map(route => `<tr><td>${escape(route.usuario)}</td><td>${escape(route.dia)}</td><td>${escape(route.orden)}</td><td>${escape(route.pdv)}</td><td>${escape(route.origen)}</td><td>${escape(route.distanciaKm)} km</td><td>${escape(route.horasTrabajo)} h</td></tr>`).join('') || '<tr><td colspan=\"7\">Sin rutas asignadas para los filtros seleccionados.</td></tr>';\nconst unassignedRows = payload.unassignedPdvs.map(route => `<tr><td>${escape(route.pdv_id)}</td><td>${escape(route.pdv_nombre)}</td><td>${escape(route.minimo_horas_semana)} h</td><td>${escape(route.motivo)}</td><td>${escape(route.recomendacion)}</td></tr>`).join('') || '<tr><td colspan=\"5\">No hay PDVs sin asignar para los filtros seleccionados.</td></tr>';\nconst summary = payload.summary ?? {};\nconst html = `<!doctype html><html><head><meta charset=\"utf-8\"><style>body{font-family:Arial,sans-serif;color:#243746;margin:28px;font-size:11px}h1{color:#4F758B;margin:0}h2{color:#4F758B;border-bottom:2px solid #6CC24A;padding-bottom:6px;margin-top:28px}p{line-height:1.45}.meta{color:#61798a}.filters span{display:inline-block;background:#eef6e8;border:1px solid #a4d65e;border-radius:3px;margin:3px 5px 3px 0;padding:4px 6px}.summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:15px 0}.metric{border:1px solid #d0d0d0;border-left:3px solid #6CC24A;padding:8px}.metric b{display:block;color:#4F758B;font-size:16px}table{width:100%;border-collapse:collapse;margin-top:10px}th{background:#4F758B;color:#fff;text-align:left;padding:7px}td{border-bottom:1px solid #e5e5e5;padding:7px;vertical-align:top}.footer{margin-top:24px;color:#6b8fa3;font-size:9px}</style></head><body><h1>Reporte de rutas</h1><p class=\"meta\"><b>${escape(payload.project.name)}</b><br>${escape(payload.project.country)}${payload.project.description ? `<br>${escape(payload.project.description)}` : ''}<br>Generado: ${escape(payload.generatedAt)}</p>${filters ? `<div class=\"filters\"><b>Filtros aplicados:</b><br>${filters}</div>` : '<p><b>Filtros aplicados:</b> Ninguno</p>'}<div class=\"summary\"><div class=\"metric\"><b>${escape(summary.totalPdvs)}</b>PDVs totales</div><div class=\"metric\"><b>${escape(summary.assignedPdvs)}</b>PDVs asignados</div><div class=\"metric\"><b>${escape(summary.unassignedPdvs)}</b>PDVs sin asignar</div><div class=\"metric\"><b>${escape(summary.coveragePercent)}%</b>Cobertura</div><div class=\"metric\"><b>${escape(summary.totalDistanceKm)} km</b>Distancia</div><div class=\"metric\"><b>${escape(summary.totalWorkHours)} h</b>Trabajo</div></div><h2>Rutas asignadas</h2><table><thead><tr><th>Usuario</th><th>Día</th><th>Orden</th><th>PDV</th><th>Origen</th><th>Distancia</th><th>Trabajo</th></tr></thead><tbody>${routeRows}</tbody></table><h2>PDVs sin asignar</h2><table><thead><tr><th>PDV ID</th><th>PDV</th><th>Horas/semana</th><th>Motivo</th><th>Recomendación</th></tr></thead><tbody>${unassignedRows}</tbody></table><p class=\"footer\">Route Planner · GomezLee Marketing</p></body></html>`;\nreturn [{ json: { fileName: `rutas_${String(payload.project.name).replace(/[^a-z0-9]+/gi, '_').toLowerCase()}.pdf` }, binary: { data: { data: Buffer.from(html).toString('base64'), mimeType: 'text/html', fileName: 'index.html' } } }];"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [-100, 260],
|
||||||
|
"id": "9258c394-683f-4569-8ced-af2337ec8dba",
|
||||||
|
"name": "Build Report HTML",
|
||||||
|
"onError": "continueErrorOutput",
|
||||||
|
"notes": "Genera tablas deterministas; no usa IA para alterar cifras, rutas ni recomendaciones."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://host.docker.internal:3000/forms/chromium/convert/html",
|
||||||
|
"authentication": "none",
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterType": "formBinaryData",
|
||||||
|
"name": "files",
|
||||||
|
"inputDataFieldName": "data"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"responseFormat": "file",
|
||||||
|
"outputPropertyName": "data"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeout": 120000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [180, 260],
|
||||||
|
"id": "73e2759c-d9f9-49dc-925f-3ad1ee7b3897",
|
||||||
|
"name": "Convert HTML to PDF",
|
||||||
|
"retryOnFail": true,
|
||||||
|
"maxTries": 3,
|
||||||
|
"waitBetweenTries": 3000,
|
||||||
|
"onError": "continueErrorOutput",
|
||||||
|
"notes": "Gotenberg debe estar disponible en host.docker.internal:3000 desde el contenedor n8n."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "binary",
|
||||||
|
"responseBinaryPropertyName": "data",
|
||||||
|
"options": {
|
||||||
|
"responseHeaders": {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "Content-Disposition",
|
||||||
|
"value": "attachment; filename=\"reporte_rutas.pdf\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.5,
|
||||||
|
"position": [460, 200],
|
||||||
|
"id": "93928c3e-1e9c-4f77-a644-c156fae907a5",
|
||||||
|
"name": "Respond PDF"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ { error: 'validation_error', message: $json.error?.message || $json.message || 'El reporte no contiene datos válidos.' } }}",
|
||||||
|
"options": {
|
||||||
|
"responseCode": 400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.5,
|
||||||
|
"position": [-100, 460],
|
||||||
|
"id": "c8b69f14-45d0-4f55-9f7c-2e3fb3425f14",
|
||||||
|
"name": "Respond Validation Error"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ { error: 'pdf_generation_error', message: 'No se pudo generar el PDF de rutas.', detail: $json.error?.message || $json.message || 'Gotenberg no está disponible.' } }}",
|
||||||
|
"options": {
|
||||||
|
"responseCode": 502
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.5,
|
||||||
|
"position": [460, 430],
|
||||||
|
"id": "c8d6e363-80b6-46ca-a8c6-32ce393207cc",
|
||||||
|
"name": "Respond PDF Error"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Webhook: Generate PDF": { "main": [[{ "node": "Validate Report Payload", "type": "main", "index": 0 }]] },
|
||||||
|
"Validate Report Payload": { "main": [[{ "node": "Build Report HTML", "type": "main", "index": 0 }], [{ "node": "Respond Validation Error", "type": "main", "index": 0 }]] },
|
||||||
|
"Build Report HTML": { "main": [[{ "node": "Convert HTML to PDF", "type": "main", "index": 0 }], [{ "node": "Respond Validation Error", "type": "main", "index": 0 }]] },
|
||||||
|
"Convert HTML to PDF": { "main": [[{ "node": "Respond PDF", "type": "main", "index": 0 }], [{ "node": "Respond PDF Error", "type": "main", "index": 0 }]] }
|
||||||
|
},
|
||||||
|
"active": false,
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": true
|
||||||
|
},
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"jsCode": "const allRows = $input.all().map(item => item.json);\nif (!allRows.length) {\n return [{ json: { success: false, data: [], message: 'Usuarios: Archivo vacío' } }];\n}\nconst keys = new Set(allRows.flatMap(r => Object.keys(r)));\nconst required = ['ID','Nombre del empleado','Latitud','Longitud','Horas por día usuario','Horas por semana usuario'];\nconst missing = required.filter(c => !keys.has(c));\nlet message = '';\nlet success = true;\nif (missing.length) {\n success = false;\n message = `Usuarios: Columnas faltantes: ${missing.join(', ')}`;\n}\nif (allRows.length > 300) {\n success = false;\n message = 'Usuarios: Máximo 300 filas permitidas';\n}\nreturn [{ json: { success, data: $input.all(), message } }];"
|
"jsCode": "const allRows = $input.all().map(item => item.json);\nif (!allRows.length) {\n return [{ json: { success: false, data: [], message: 'Usuarios: Archivo vacío' } }];\n}\nconst keys = new Set(allRows.flatMap(r => Object.keys(r)));\nconst required = ['ID','Nombre del empleado','Latitud','Longitud','Horas por día usuario','Horas por semana usuario'];\nconst missing = required.filter(c => !keys.has(c));\nlet message = '';\nlet success = true;\nif (missing.length) {\n success = false;\n message = `Usuarios: Columnas faltantes: ${missing.join(', ')}`;\n}\nif (allRows.length > 500) {\n success = false;\n message = 'Usuarios: Máximo 500 filas permitidas';\n}\nreturn [{ json: { success, data: $input.all(), message } }];"
|
||||||
},
|
},
|
||||||
"type": "n8n-nodes-base.code",
|
"type": "n8n-nodes-base.code",
|
||||||
"typeVersion": 2,
|
"typeVersion": 2,
|
||||||
@@ -151,8 +151,10 @@
|
|||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"respondWith": "json",
|
"respondWith": "json",
|
||||||
"responseBody": "={{ JSON.stringify({ error: $json[0].message || $json[1].message || 'Error de validación' }) }}",
|
"responseBody": "={{ { error: $json.error?.message || $json.error || $json.message || 'Error de validación' } }}",
|
||||||
"options": {}
|
"options": {
|
||||||
|
"responseCode": 400
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"type": "n8n-nodes-base.respondToWebhook",
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
"typeVersion": 1.5,
|
"typeVersion": 1.5,
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
gotenberg:
|
||||||
|
image: gotenberg/gotenberg:8
|
||||||
|
container_name: route-planner-pdf
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
restart: unless-stopped
|
||||||
@@ -7,6 +7,7 @@ import { authGuard } from './core/services/auth.guard';
|
|||||||
import { EmployeesComponent } from './private/employees/employees.component';
|
import { EmployeesComponent } from './private/employees/employees.component';
|
||||||
import { ProjectListComponent } from './private/projects/project-list.component';
|
import { ProjectListComponent } from './private/projects/project-list.component';
|
||||||
import { NewProjectComponent } from './private/projects/new-project.component';
|
import { NewProjectComponent } from './private/projects/new-project.component';
|
||||||
|
import { PdvsComponent } from './private/pdvs/pdvs.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', redirectTo: 'login', pathMatch: 'full' },
|
{ path: '', redirectTo: 'login', pathMatch: 'full' },
|
||||||
@@ -16,7 +17,8 @@ export const routes: Routes = [
|
|||||||
{ path: 'proyectos/:id', component: HomeComponent, pathMatch: 'full', canActivate: [authGuard] },
|
{ path: 'proyectos/:id', component: HomeComponent, pathMatch: 'full', canActivate: [authGuard] },
|
||||||
{ path: 'proyectos/:id/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] },
|
{ path: 'proyectos/:id/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] },
|
||||||
{ path: 'proyectos/:id/ruta', component: AssignedRouteDetailComponent, canActivate: [authGuard] },
|
{ path: 'proyectos/:id/ruta', component: AssignedRouteDetailComponent, canActivate: [authGuard] },
|
||||||
{ path: 'empleados', component: EmployeesComponent, canActivate: [authGuard] },
|
{ path: 'proyectos/:id/empleados', component: EmployeesComponent, canActivate: [authGuard] },
|
||||||
|
{ path: 'proyectos/:id/pdvs', component: PdvsComponent, canActivate: [authGuard] },
|
||||||
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent, canActivate: [authGuard] },
|
{ path: 'home/assigned-route', component: AssignedRouteDetailComponent, canActivate: [authGuard] },
|
||||||
{ path: 'home/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] },
|
{ path: 'home/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] },
|
||||||
{ path: '**', redirectTo: 'login' }
|
{ path: '**', redirectTo: 'login' }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export interface Employee {
|
export interface Employee {
|
||||||
id: string;
|
id: string;
|
||||||
|
proyecto_id: string;
|
||||||
employee_code: string;
|
employee_code: string;
|
||||||
nombre: string;
|
nombre: string;
|
||||||
usuario: string | null;
|
usuario: string | null;
|
||||||
@@ -18,4 +19,4 @@ export interface Employee {
|
|||||||
activo: boolean;
|
activo: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EmployeeInput = Omit<Employee, 'id'>;
|
export type EmployeeInput = Omit<Employee, 'id' | 'proyecto_id'>;
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ export interface RuteroProyecto {
|
|||||||
id: string;
|
id: string;
|
||||||
nombre: string;
|
nombre: string;
|
||||||
descripcion: string | null;
|
descripcion: string | null;
|
||||||
|
pais: string | null;
|
||||||
propietario_id: string;
|
propietario_id: string;
|
||||||
estado: 'borrador' | 'optimizado' | 'archivado';
|
estado: 'optimizado' | 'archivado' | null;
|
||||||
version: number;
|
version: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface RouteAssignedJson {
|
|||||||
horas_por_dia_usuario?: number;
|
horas_por_dia_usuario?: number;
|
||||||
horas_descanso_por_dia?: number;
|
horas_descanso_por_dia?: number;
|
||||||
jornada_codigo?: string;
|
jornada_codigo?: string;
|
||||||
|
cobertura?: string;
|
||||||
pais?: string;
|
pais?: string;
|
||||||
ciudad?: string;
|
ciudad?: string;
|
||||||
estado?: string;
|
estado?: string;
|
||||||
@@ -52,6 +53,7 @@ export interface RouteAssignedJson {
|
|||||||
latitud: number,
|
latitud: number,
|
||||||
longitud: number,
|
longitud: number,
|
||||||
motivo: string;
|
motivo: string;
|
||||||
|
recomendacion?: string;
|
||||||
pais?: string;
|
pais?: string;
|
||||||
ciudad?: string;
|
ciudad?: string;
|
||||||
estado?: string;
|
estado?: string;
|
||||||
|
|||||||
@@ -21,17 +21,18 @@ export interface UnassignedAssignment {
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class RouteState {
|
export class RouteState {
|
||||||
|
private projectId: string | null = null;
|
||||||
|
|
||||||
private loading = new BehaviorSubject<boolean>(false);
|
private loading = new BehaviorSubject<boolean>(false);
|
||||||
loading$ = this.loading.asObservable();
|
loading$ = this.loading.asObservable();
|
||||||
|
|
||||||
private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>(this.loadAssigned());
|
private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>([]);
|
||||||
assignedRoutes$ = this.assignedRoutes.asObservable();
|
assignedRoutes$ = this.assignedRoutes.asObservable();
|
||||||
|
|
||||||
private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>(this.loadNoAssigned());
|
private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>([]);
|
||||||
noAssignedRoutes$ = this.noAssignedRoutes.asObservable();
|
noAssignedRoutes$ = this.noAssignedRoutes.asObservable();
|
||||||
|
|
||||||
private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(this.loadSelected());
|
private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(null);
|
||||||
selectedRoute$ = this.selectedRoute.asObservable();
|
selectedRoute$ = this.selectedRoute.asObservable();
|
||||||
|
|
||||||
resetLoading() {
|
resetLoading() {
|
||||||
@@ -42,6 +43,15 @@ export class RouteState {
|
|||||||
this.loading.next(value);
|
this.loading.next(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useProject(projectId: string): void {
|
||||||
|
if (!projectId || this.projectId === projectId) return;
|
||||||
|
this.projectId = projectId;
|
||||||
|
this.assignedRoutes.next(this.loadAssigned());
|
||||||
|
this.noAssignedRoutes.next(this.loadNoAssigned());
|
||||||
|
this.selectedRoute.next(this.loadSelected());
|
||||||
|
this.loading.next(false);
|
||||||
|
}
|
||||||
|
|
||||||
setAssignedRoutes(data: RouteAssignedResponse[]) {
|
setAssignedRoutes(data: RouteAssignedResponse[]) {
|
||||||
this.assignedRoutes.next(data);
|
this.assignedRoutes.next(data);
|
||||||
this.persist(STORAGE_ASSIGNED, data);
|
this.persist(STORAGE_ASSIGNED, data);
|
||||||
@@ -246,13 +256,14 @@ export class RouteState {
|
|||||||
return this.noAssignedRoutes.getValue();
|
return this.noAssignedRoutes.getValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
findRoute(pdvId: string | number, usuarioId?: string): RouteAssignedResponse | null {
|
findRoute(pdvId: string | number, usuarioId?: string, day?: string): RouteAssignedResponse | null {
|
||||||
const pid = String(pdvId);
|
const pid = String(pdvId);
|
||||||
const routes = this.getAssignedSnapshot();
|
const routes = this.getAssignedSnapshot();
|
||||||
const found = routes.find(r => {
|
const found = routes.find(r => {
|
||||||
const jp = r.json;
|
const jp = r.json;
|
||||||
if (String(jp.pdv_id) !== pid) return false;
|
if (String(jp.pdv_id) !== pid) return false;
|
||||||
if (usuarioId && String(jp.usuario_id) !== String(usuarioId)) return false;
|
if (usuarioId && String(jp.usuario_id) !== String(usuarioId)) return false;
|
||||||
|
if (day && jp.dia !== day) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return found ?? null;
|
return found ?? null;
|
||||||
@@ -282,17 +293,18 @@ export class RouteState {
|
|||||||
this.noAssignedRoutes.next([]);
|
this.noAssignedRoutes.next([]);
|
||||||
this.selectedRoute.next(null);
|
this.selectedRoute.next(null);
|
||||||
try {
|
try {
|
||||||
sessionStorage.removeItem(STORAGE_ASSIGNED);
|
sessionStorage.removeItem(this.storageKey(STORAGE_ASSIGNED));
|
||||||
sessionStorage.removeItem(STORAGE_ASSIGNED_ORIGINAL);
|
sessionStorage.removeItem(this.storageKey(STORAGE_ASSIGNED_ORIGINAL));
|
||||||
sessionStorage.removeItem(STORAGE_NO_ASSIGNED);
|
sessionStorage.removeItem(this.storageKey(STORAGE_NO_ASSIGNED));
|
||||||
sessionStorage.removeItem(STORAGE_NO_ASSIGNED_ORIGINAL);
|
sessionStorage.removeItem(this.storageKey(STORAGE_NO_ASSIGNED_ORIGINAL));
|
||||||
sessionStorage.removeItem(STORAGE_SELECTED);
|
sessionStorage.removeItem(this.storageKey(STORAGE_SELECTED));
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private persist(key: string, data: unknown) {
|
private persist(key: string, data: unknown) {
|
||||||
|
if (!this.projectId) return;
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(key, JSON.stringify(data));
|
sessionStorage.setItem(this.storageKey(key), JSON.stringify(data));
|
||||||
} catch { /* ignore storage errors */ }
|
} catch { /* ignore storage errors */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +320,7 @@ export class RouteState {
|
|||||||
|
|
||||||
private loadAssigned(): RouteAssignedResponse[] {
|
private loadAssigned(): RouteAssignedResponse[] {
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(STORAGE_ASSIGNED);
|
const raw = sessionStorage.getItem(this.storageKey(STORAGE_ASSIGNED));
|
||||||
if (raw) return JSON.parse(raw) as RouteAssignedResponse[];
|
if (raw) return JSON.parse(raw) as RouteAssignedResponse[];
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return [];
|
return [];
|
||||||
@@ -316,7 +328,7 @@ export class RouteState {
|
|||||||
|
|
||||||
private loadOriginalAssigned(): RouteAssignedResponse[] {
|
private loadOriginalAssigned(): RouteAssignedResponse[] {
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(STORAGE_ASSIGNED_ORIGINAL);
|
const raw = sessionStorage.getItem(this.storageKey(STORAGE_ASSIGNED_ORIGINAL));
|
||||||
if (raw) return JSON.parse(raw) as RouteAssignedResponse[];
|
if (raw) return JSON.parse(raw) as RouteAssignedResponse[];
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return this.loadAssigned();
|
return this.loadAssigned();
|
||||||
@@ -324,7 +336,7 @@ export class RouteState {
|
|||||||
|
|
||||||
private loadNoAssigned(): RouteNoAssignedResponse[] {
|
private loadNoAssigned(): RouteNoAssignedResponse[] {
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(STORAGE_NO_ASSIGNED);
|
const raw = sessionStorage.getItem(this.storageKey(STORAGE_NO_ASSIGNED));
|
||||||
if (raw) return JSON.parse(raw) as RouteNoAssignedResponse[];
|
if (raw) return JSON.parse(raw) as RouteNoAssignedResponse[];
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return [];
|
return [];
|
||||||
@@ -332,9 +344,13 @@ export class RouteState {
|
|||||||
|
|
||||||
private loadSelected(): RouteAssignedResponse | null {
|
private loadSelected(): RouteAssignedResponse | null {
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(STORAGE_SELECTED);
|
const raw = sessionStorage.getItem(this.storageKey(STORAGE_SELECTED));
|
||||||
if (raw) return JSON.parse(raw) as RouteAssignedResponse;
|
if (raw) return JSON.parse(raw) as RouteAssignedResponse;
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private storageKey(key: string): string {
|
||||||
|
return this.projectId ? `${key}:${this.projectId}` : key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import { supabaseClient } from './supabase.client';
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class EmployeeService {
|
export class EmployeeService {
|
||||||
private readonly client = supabaseClient;
|
private readonly client = supabaseClient;
|
||||||
private readonly table = 'rutero_empleados';
|
private readonly table = 'rutero_proyecto_empleados';
|
||||||
|
|
||||||
async list(): Promise<Employee[]> {
|
async list(projectId: string): Promise<Employee[]> {
|
||||||
const { data: sessionData, error: sessionError } = await this.client.auth.getSession();
|
const { data: sessionData, error: sessionError } = await this.client.auth.getSession();
|
||||||
if (sessionError) throw sessionError;
|
if (sessionError) throw sessionError;
|
||||||
if (!sessionData.session) throw new Error('No hay una sesión Supabase activa. Cierra sesión e inicia sesión nuevamente.');
|
if (!sessionData.session) throw new Error('No hay una sesión Supabase activa. Cierra sesión e inicia sesión nuevamente.');
|
||||||
const { data, error } = await this.client.from(this.table).select('*').order('nombre');
|
const { data, error } = await this.client.from(this.table).select('*').eq('proyecto_id', projectId).order('nombre');
|
||||||
if (error) throw new Error(`Supabase ${error.code ?? 'error'}: ${error.message}`);
|
if (error) throw new Error(`Supabase ${error.code ?? 'error'}: ${error.message}`);
|
||||||
return (data ?? []).map(row => ({
|
return (data ?? []).map(row => ({
|
||||||
...row,
|
...row,
|
||||||
@@ -23,13 +23,14 @@ export class EmployeeService {
|
|||||||
})) as Employee[];
|
})) as Employee[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(employee: EmployeeInput): Promise<void> {
|
async create(projectId: string, employee: EmployeeInput): Promise<void> {
|
||||||
const { error } = await this.client.from(this.table).insert(employee);
|
const { error } = await this.client.from(this.table).insert({ ...employee, proyecto_id: projectId });
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsert(employees: EmployeeInput[]): Promise<void> {
|
async upsert(projectId: string, employees: EmployeeInput[]): Promise<void> {
|
||||||
const { error } = await this.client.from(this.table).upsert(employees, { onConflict: 'employee_code' });
|
const rows = employees.map(employee => ({ ...employee, proyecto_id: projectId }));
|
||||||
|
const { error } = await this.client.from(this.table).upsert(rows, { onConflict: 'proyecto_id,employee_code' });
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,13 +44,12 @@ export class EmployeeService {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
async active(country = ''): Promise<Employee[]> {
|
async active(projectId: string, country?: string): Promise<Employee[]> {
|
||||||
const expectedCountry = this.normalizeCountry(country);
|
return (await this.list(projectId)).filter(employee => employee.activo && (!country || this.sameCountry(employee.pais, country)));
|
||||||
return (await this.list()).filter(employee => employee.activo && (!expectedCountry || this.normalizeCountry(employee.pais ?? '') === expectedCountry));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async activeEmployeesFile(country = ''): Promise<File> {
|
async activeEmployeesFile(projectId: string, country?: string): Promise<File> {
|
||||||
const employees = await this.active(country);
|
const employees = await this.active(projectId, country);
|
||||||
const rows = employees.map(employee => ({
|
const rows = employees.map(employee => ({
|
||||||
ID: employee.employee_code,
|
ID: employee.employee_code,
|
||||||
'Nombre del empleado': employee.nombre,
|
'Nombre del empleado': employee.nombre,
|
||||||
@@ -71,10 +71,12 @@ export class EmployeeService {
|
|||||||
return new File([data], 'empleados_activos.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
return new File([data], 'empleados_activos.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeCountry(country: string): string {
|
private sameCountry(employeeCountry: string | null, projectCountry: string): boolean {
|
||||||
const normalized = country.normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().toUpperCase();
|
const normalize = (value: string | null) => String(value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z]/gi, '').toLowerCase();
|
||||||
if (normalized.includes('MEXICO')) return 'MEXICO';
|
const employee = normalize(employeeCountry);
|
||||||
if (normalized.includes('DOMINICANA')) return 'REPUBLICA DOMINICANA';
|
const project = normalize(projectCountry);
|
||||||
return normalized;
|
if (employee === project) return true;
|
||||||
|
return (employee === 'mexico' && project === 'mexico') || (employee === 'republicadominicana' && project === 'republicadominicana');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
import { RouteAssignedResponse, RouteNoAssignedResponse } from '../models/route.model';
|
||||||
|
import { normalizeRow } from '../../shared/utils/header-normalization.utils';
|
||||||
|
|
||||||
|
export interface PlanningFilters {
|
||||||
|
cobertura: string;
|
||||||
|
zona: string;
|
||||||
|
region: string;
|
||||||
|
estado: string;
|
||||||
|
ciudad: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlanningFilterKey = keyof PlanningFilters;
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class PlanningFilterService {
|
||||||
|
private readonly empty: PlanningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
|
||||||
|
private readonly filtersSubject = new BehaviorSubject<PlanningFilters>({ ...this.empty });
|
||||||
|
readonly filters$ = this.filtersSubject.asObservable();
|
||||||
|
private metadata = new Map<string, Record<string, unknown>>();
|
||||||
|
|
||||||
|
setPdvs(rows: Record<string, unknown>[]): void {
|
||||||
|
this.metadata = new Map(rows.map((row, index) => {
|
||||||
|
const normalized = normalizeRow(row);
|
||||||
|
return [String(normalized['id'] ?? index + 1), normalized];
|
||||||
|
}));
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
set(filters: PlanningFilters): void { this.filtersSubject.next({ ...filters }); }
|
||||||
|
clear(): void { this.filtersSubject.next({ ...this.empty }); }
|
||||||
|
snapshot(): PlanningFilters { return this.filtersSubject.value; }
|
||||||
|
|
||||||
|
options(key: PlanningFilterKey): string[] {
|
||||||
|
const values = [...this.metadata.values()].map(row => String(row[key] ?? '').trim()).filter(Boolean);
|
||||||
|
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
filterAssigned(routes: RouteAssignedResponse[]): RouteAssignedResponse[] {
|
||||||
|
return routes.filter(route => this.matches(String(route.json.pdv_id), route.json as unknown as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
filterUnassigned(routes: RouteNoAssignedResponse[]): RouteNoAssignedResponse[] {
|
||||||
|
return routes.filter(route => this.matches(String(route.pdv_id), route as unknown as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
private matches(id: string, fallback: Record<string, unknown>): boolean {
|
||||||
|
const metadata = this.metadata.get(id) ?? {};
|
||||||
|
const filters = this.filtersSubject.value;
|
||||||
|
return (Object.keys(filters) as PlanningFilterKey[]).every(key => {
|
||||||
|
const selected = filters[key];
|
||||||
|
if (!selected) return true;
|
||||||
|
return String(metadata[key] ?? fallback[key] ?? '').trim() === selected;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,23 +2,27 @@ import { Injectable } from '@angular/core';
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PlanningInputService {
|
export class PlanningInputService {
|
||||||
|
private projectId: string | null = null;
|
||||||
private pdvFile: File | null = null;
|
private pdvFile: File | null = null;
|
||||||
private usuariosFile: File | null = null;
|
private usuariosFile: File | null = null;
|
||||||
|
|
||||||
setFiles(pdvFile: File, usuariosFile: File): void {
|
setFiles(projectId: string, pdvFile: File, usuariosFile: File): void {
|
||||||
|
this.projectId = projectId;
|
||||||
this.pdvFile = pdvFile;
|
this.pdvFile = pdvFile;
|
||||||
this.usuariosFile = usuariosFile;
|
this.usuariosFile = usuariosFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFormData(): FormData | null {
|
getFormData(projectId: string): FormData | null {
|
||||||
if (!this.pdvFile || !this.usuariosFile) return null;
|
if (this.projectId !== projectId || !this.pdvFile || !this.usuariosFile) return null;
|
||||||
const data = new FormData();
|
const data = new FormData();
|
||||||
data.append('pdv_file', this.pdvFile, this.pdvFile.name);
|
data.append('pdv_file', this.pdvFile, this.pdvFile.name);
|
||||||
data.append('usuarios_file', this.usuariosFile, this.usuariosFile.name);
|
data.append('usuarios_file', this.usuariosFile, this.usuariosFile.name);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(projectId?: string): void {
|
||||||
|
if (projectId && this.projectId !== projectId) return;
|
||||||
|
this.projectId = null;
|
||||||
this.pdvFile = null;
|
this.pdvFile = null;
|
||||||
this.usuariosFile = null;
|
this.usuariosFile = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class ProjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
const dependentTables = ['rutero_proyecto_rutas', 'rutero_proyecto_pdvs'];
|
const dependentTables = ['rutero_proyecto_rutas', 'rutero_proyecto_empleados', 'rutero_proyecto_pdvs'];
|
||||||
for (const table of dependentTables) {
|
for (const table of dependentTables) {
|
||||||
const { error } = await supabaseClient.from(table).delete().eq('proyecto_id', id);
|
const { error } = await supabaseClient.from(table).delete().eq('proyecto_id', id);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -32,7 +32,7 @@ export class ProjectService {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(nombre: string, rows: Record<string, unknown>[]): Promise<RuteroProyecto> {
|
async create(nombre: string, descripcion: string, pais: string): Promise<RuteroProyecto> {
|
||||||
const { data: sessionData, error: sessionError } = await supabaseClient.auth.getSession();
|
const { data: sessionData, error: sessionError } = await supabaseClient.auth.getSession();
|
||||||
if (sessionError) throw sessionError;
|
if (sessionError) throw sessionError;
|
||||||
const ownerId = sessionData.session?.user.id;
|
const ownerId = sessionData.session?.user.id;
|
||||||
@@ -42,27 +42,18 @@ export class ProjectService {
|
|||||||
const project: RuteroProyecto = {
|
const project: RuteroProyecto = {
|
||||||
id: projectId,
|
id: projectId,
|
||||||
nombre: nombre.trim(),
|
nombre: nombre.trim(),
|
||||||
descripcion: null,
|
descripcion: descripcion.trim() || null,
|
||||||
|
pais: pais.trim(),
|
||||||
propietario_id: ownerId,
|
propietario_id: ownerId,
|
||||||
estado: 'borrador',
|
estado: null,
|
||||||
version: 1,
|
version: 1,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now
|
updated_at: now
|
||||||
};
|
};
|
||||||
const { error } = await supabaseClient
|
const { error } = await supabaseClient
|
||||||
.from('rutero_proyectos')
|
.from('rutero_proyectos')
|
||||||
.insert({ id: projectId, nombre: project.nombre, propietario_id: ownerId });
|
.insert({ id: projectId, nombre: project.nombre, descripcion: project.descripcion, pais: project.pais, propietario_id: ownerId, estado: null });
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
const pdvs = rows.map((pdv, index) => ({
|
|
||||||
proyecto_id: projectId,
|
|
||||||
pdv_id: String(pdv['ID'] ?? pdv['id'] ?? pdv['Id'] ?? index + 1),
|
|
||||||
pdv_data: pdv
|
|
||||||
}));
|
|
||||||
const { error: pdvError } = await supabaseClient.from('rutero_proyecto_pdvs').insert(pdvs);
|
|
||||||
if (pdvError) {
|
|
||||||
await supabaseClient.from('rutero_proyectos').delete().eq('id', projectId);
|
|
||||||
throw pdvError;
|
|
||||||
}
|
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,8 +63,37 @@ export class ProjectService {
|
|||||||
return (data ?? []) as RuteroProyectoPdv[];
|
return (data ?? []) as RuteroProyectoPdv[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async projectInput(id: string): Promise<{ file: File; country: string }> {
|
async upsertPdvs(projectId: string, rows: Record<string, unknown>[]): Promise<void> {
|
||||||
const pdvs = await this.pdvs(id);
|
const pdvs = rows.map((pdv, index) => ({
|
||||||
|
proyecto_id: projectId,
|
||||||
|
pdv_id: String(pdv['ID'] ?? pdv['id'] ?? pdv['Id'] ?? index + 1),
|
||||||
|
pdv_data: pdv
|
||||||
|
}));
|
||||||
|
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').upsert(pdvs, { onConflict: 'proyecto_id,pdv_id' });
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPdv(projectId: string, pdv: Record<string, unknown>): Promise<void> {
|
||||||
|
const pdvId = String(pdv['ID'] ?? '').trim();
|
||||||
|
if (!pdvId) throw new Error('El ID del PDV es obligatorio.');
|
||||||
|
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').insert({ proyecto_id: projectId, pdv_id: pdvId, pdv_data: pdv });
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePdv(id: string, pdv: Record<string, unknown>): Promise<void> {
|
||||||
|
const pdvId = String(pdv['ID'] ?? '').trim();
|
||||||
|
if (!pdvId) throw new Error('El ID del PDV es obligatorio.');
|
||||||
|
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').update({ pdv_id: pdvId, pdv_data: pdv }).eq('id', id);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePdv(id: string): Promise<void> {
|
||||||
|
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').delete().eq('id', id);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async projectInput(id: string): Promise<{ file: File; country: string; rows: Record<string, unknown>[] }> {
|
||||||
|
const [project, pdvs] = await Promise.all([this.get(id), this.pdvs(id)]);
|
||||||
const rows = pdvs.map(pdv => pdv.pdv_data);
|
const rows = pdvs.map(pdv => pdv.pdv_data);
|
||||||
const workbook = XLSX.utils.book_new();
|
const workbook = XLSX.utils.book_new();
|
||||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), 'PDVs');
|
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), 'PDVs');
|
||||||
@@ -84,7 +104,8 @@ export class ProjectService {
|
|||||||
})?.[1]).find(value => String(value ?? '').trim());
|
})?.[1]).find(value => String(value ?? '').trim());
|
||||||
return {
|
return {
|
||||||
file: new File([data], 'pdvs_proyecto.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
file: new File([data], 'pdvs_proyecto.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
||||||
country: String(country ?? 'Republica Dominicana').trim()
|
country: String(country ?? project.pais ?? 'Republica Dominicana').trim(),
|
||||||
|
rows
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
.topbar-actions button:hover, .table-header button:hover, .form-actions button:hover, .row-actions button:hover { background: var(--glm-verde-bg); border-color: var(--glm-verde); }
|
.topbar-actions button:hover, .table-header button:hover, .form-actions button:hover, .row-actions button:hover { background: var(--glm-verde-bg); border-color: var(--glm-verde); }
|
||||||
.employees-page { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
.employees-page { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||||
.employees-page header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; border-bottom: 3px solid var(--glm-verde); padding-bottom: 12px; }
|
.employees-page header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; border-bottom: 3px solid var(--glm-verde); padding-bottom: 12px; }
|
||||||
|
.page-back-button { display:inline-flex; align-items:center; gap:6px; margin-bottom:18px; padding:8px 11px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:700 .82rem Arial,sans-serif; }.page-back-button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }
|
||||||
.employees-page h1, .employees-page h2 { margin: 0; color: var(--glm-azul); }
|
.employees-page h1, .employees-page h2 { margin: 0; color: var(--glm-azul); }
|
||||||
.employees-page h1 { font-size: 1.45rem; }.employees-page h2 { font-size: 1rem; }
|
.employees-page h1 { font-size: 1.45rem; }.employees-page h2 { font-size: 1rem; }
|
||||||
.employees-page header p, .weekly-capacity { color: var(--glm-acero); }
|
.employees-page header p, .weekly-capacity { color: var(--glm-acero); }
|
||||||
@@ -18,4 +19,5 @@
|
|||||||
.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; font: inherit; }.inline-number { max-width: 100px; }.inline-picker { min-width: 130px; margin: 0; }.editing-row .row-actions { flex-wrap: wrap; }
|
||||||
|
.table-header > div { display:flex; align-items:center; gap:8px; }.selected-count { color:var(--glm-azul); font-size:.78rem; font-weight:700; }.select-column { width:38px; }.bulk-actions { display:flex; flex-wrap:wrap; align-items:end; gap:10px; margin:12px 0; padding:12px; border:1px solid var(--glm-verde-claro); background:var(--glm-verde-bg); }.bulk-actions > label,.bulk-actions fieldset { display:grid; gap:4px; margin:0; color:var(--glm-azul); font-size:.72rem; font-weight:700; text-transform:uppercase; }.bulk-actions input,.bulk-actions select { min-height:32px; padding:5px 7px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; font:inherit; }.bulk-actions fieldset { grid-template-columns:repeat(2,max-content); gap:5px 8px; padding:6px 8px; text-transform:none; }.bulk-actions legend { padding:0 3px; text-transform:uppercase; }.bulk-actions fieldset label { font-size:.72rem; font-weight:400; }.bulk-actions fieldset input { min-height:0; padding:0; }.bulk-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:7px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.bulk-actions button:hover:not(:disabled) { background:#fff; border-color:var(--glm-verde); }.bulk-actions button.delete { color:#c9252d; }
|
||||||
@media (max-width: 760px) { .employees-page { padding: 16px; }.employee-form-grid { grid-template-columns: 1fr; }.employees-topbar { padding: 9px 16px; }.topbar-actions button { font-size: 0; }.topbar-actions button i { font-size: .9rem; } }
|
@media (max-width: 760px) { .employees-page { padding: 16px; }.employee-form-grid { grid-template-columns: 1fr; }.employees-topbar { padding: 9px 16px; }.topbar-actions button { font-size: 0; }.topbar-actions button i { font-size: .9rem; } }
|
||||||
|
|||||||
@@ -1,50 +1,12 @@
|
|||||||
<div class="employees-topbar">
|
<div class="employees-topbar">
|
||||||
<img src="GLM_completo.png" alt="GomezLee Marketing" />
|
<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>
|
<span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div>
|
||||||
<div class="topbar-actions"><button type="button" (click)="goBack()"><i class="pi pi-arrow-left"></i> Volver</button><button type="button" (click)="goHome()"><i class="pi pi-home"></i> Proyectos</button><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
|
<div class="topbar-actions"><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="employees-page">
|
<main class="employees-page">
|
||||||
|
<button type="button" class="page-back-button" (click)="goHome()"><i class="pi pi-arrow-left"></i> Volver</button>
|
||||||
<header><div><h1>Gestionar empleados</h1><p>{{ activeEmployees }} activos de {{ employees.length }} empleados registrados</p></div><label class="import-button"><i class="pi pi-file-import"></i> {{ importing ? 'Importando...' : 'Importar empleados' }}<input type="file" accept=".xlsx,.xls" (change)="importFile($event)" [disabled]="importing"></label></header>
|
<header><div><h1>Gestionar empleados</h1><p>{{ activeEmployees }} activos de {{ employees.length }} empleados registrados</p></div><label class="import-button"><i class="pi pi-file-import"></i> {{ importing ? 'Importando...' : 'Importar empleados' }}<input type="file" accept=".xlsx,.xls" (change)="importFile($event)" [disabled]="importing"></label></header>
|
||||||
<p class="import-message" *ngIf="importMessage">{{ importMessage }}</p>
|
<p class="import-message" *ngIf="importMessage">{{ importMessage }}</p>
|
||||||
|
<section class="employee-form-card"><h2>Nuevo empleado</h2><form (ngSubmit)="save()" #employeeForm="ngForm"><div class="employee-form-grid"><label>ID<input name="employeeCode" [(ngModel)]="form.employee_code" required></label><label>Nombre<input name="nombre" [(ngModel)]="form.nombre" required></label><label>Usuario<input name="usuario" [(ngModel)]="form.usuario"></label><label>Email<input type="email" name="email" [(ngModel)]="form.email"></label><label>Perfil<input name="perfil" [(ngModel)]="form.perfil"></label><label>Regional<input name="regional" [(ngModel)]="form.regional"></label><label>País<input name="pais" [(ngModel)]="form.pais"></label><label>Estado<select name="activo" [(ngModel)]="form.activo"><option [ngValue]="true">Activo</option><option [ngValue]="false">Inactivo</option></select></label><label>Latitud<input type="number" name="latitud" [(ngModel)]="form.latitud" step="any" required></label><label>Longitud<input type="number" name="longitud" [(ngModel)]="form.longitud" step="any" required></label><label>Horas por día<input type="number" name="hoursDay" [(ngModel)]="form.horas_por_dia" min="0.5" max="24" step="0.5" required></label><label>Jornada de trabajo<select name="schedule" [(ngModel)]="form.jornada_codigo"><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></label><label>Horas de descanso/día<input type="number" name="dailyRest" [(ngModel)]="form.horas_descanso_por_dia" min="0" max="3" step="1" required></label><label>Visitas máximas/día<input type="number" name="visitsDay" [(ngModel)]="form.visitas_maximas_por_dia" min="1" required></label></div><p class="weekly-capacity">Capacidad semanal: {{ selectedScheduleHours }} h · Capacidad diaria efectiva: {{ form.horas_por_dia - form.horas_descanso_por_dia | number:'1.0-1' }} h</p><p class="form-error" *ngIf="error">{{ error }}</p><div class="form-actions"><button type="submit" [disabled]="saving || employeeForm.invalid"><i class="pi pi-save"></i> {{ saving ? 'Guardando...' : 'Agregar empleado' }}</button></div></form></section>
|
||||||
<section class="employee-form-card">
|
<section class="employee-table-card"><div class="table-header"><h2>Empleados registrados</h2><div><span class="selected-count" *ngIf="selectedIds.size">{{ selectedIds.size }} seleccionados</span><button type="button" (click)="toggleAll(!allSelected)" [disabled]="loading">{{ allSelected ? 'Deseleccionar todos' : 'Seleccionar todos' }}</button><button type="button" (click)="load()" [disabled]="loading"><i class="pi pi-refresh"></i> Actualizar</button></div></div><p class="form-error" *ngIf="error && !loading">{{ error }}</p><div class="bulk-actions" *ngIf="selectedIds.size"><label>Jornada<select [(ngModel)]="bulkSchedule"><option value="">Sin cambio</option><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></label><label>Descanso/día<input type="number" min="0" max="3" step="1" [(ngModel)]="bulkRestHours" placeholder="Sin cambio"></label><label>Visitas/día<input type="number" min="1" step="1" [(ngModel)]="bulkVisits" placeholder="Sin cambio"></label><fieldset><legend>Días de descanso</legend><label *ngFor="let day of ['Lunes','Martes','Miercoles','Jueves','Viernes','Sabado','Domingo']"><input type="checkbox" [checked]="bulkRestDays.has(day)" (change)="toggleBulkRestDay(day, $any($event.target).checked)">{{ day }}</label></fieldset><button type="button" (click)="applyBulkEmployeeChanges()" [disabled]="bulkSaving">Aplicar cambios</button><button type="button" (click)="setSelectedActive(true)" [disabled]="bulkSaving">Activar</button><button type="button" (click)="setSelectedActive(false)" [disabled]="bulkSaving">Desactivar</button><button type="button" class="delete" (click)="removeSelected()" [disabled]="bulkSaving">Eliminar seleccionados</button></div><p class="loading" *ngIf="loading">Cargando empleados...</p><div class="table-wrap" *ngIf="!loading"><table><thead><tr><th class="select-column"><input type="checkbox" [checked]="allSelected" (change)="toggleAll($any($event.target).checked)" aria-label="Seleccionar todos los empleados"></th><th>ID</th><th>Nombre</th><th>Regional</th><th>Jornada</th><th>Descanso/día</th><th>Días de descanso</th><th>Visitas/día</th><th>Estado</th><th>Acciones</th></tr></thead><tbody><tr *ngFor="let employee of employees" [class.editing-row]="editingId === employee.id"><td><input type="checkbox" [checked]="selectedIds.has(employee.id)" (change)="toggleSelection(employee.id, $any($event.target).checked)" [attr.aria-label]="'Seleccionar ' + employee.nombre"></td><td>{{ employee.employee_code }}</td><td>{{ employee.nombre }}</td><td>{{ employee.regional || '-' }}</td><td><ng-container *ngIf="editingId === employee.id; else scheduleText"><select [(ngModel)]="employee.jornada_codigo" [name]="'schedule-' + employee.id" (ngModelChange)="onInlineScheduleChange(employee)"><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></ng-container><ng-template #scheduleText>{{ scheduleLabel(employee) }}</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else restText"><input class="inline-number" type="number" [(ngModel)]="employee.horas_descanso_por_dia" [name]="'rest-' + employee.id" min="0" max="3" step="1"></ng-container><ng-template #restText>{{ employee.horas_descanso_por_dia }} h</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else daysText"><div class="rest-days-picker inline-picker"><label *ngFor="let day of availableRestDays(employee)"><input type="checkbox" [checked]="employee.dias_descanso.includes(day)" (change)="toggleRestDay(employee, day, $any($event.target).checked)">{{ day }}</label></div></ng-container><ng-template #daysText>{{ employee.dias_descanso.length ? employee.dias_descanso.join(', ') : 'Ninguno' }}</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else visitsText"><input class="inline-number" type="number" [(ngModel)]="employee.visitas_maximas_por_dia" [name]="'visits-' + employee.id" min="1" step="1"></ng-container><ng-template #visitsText>{{ employee.visitas_maximas_por_dia }}</ng-template></td><td><span class="status" [class.inactive]="!employee.activo">{{ employee.activo ? 'Activo' : 'Inactivo' }}</span></td><td class="row-actions"><ng-container *ngIf="editingId === employee.id; else normalActions"><button type="button" (click)="saveInline(employee)" [disabled]="saving">Guardar</button><button type="button" (click)="cancelEdit()">Cancelar</button></ng-container><ng-template #normalActions><button type="button" (click)="edit(employee)">Editar</button><button type="button" (click)="toggleActive(employee)">{{ employee.activo ? 'Desactivar' : 'Activar' }}</button><button type="button" class="delete" (click)="remove(employee)">Eliminar</button></ng-template></td></tr><tr *ngIf="!employees.length"><td colspan="10">No hay empleados registrados.</td></tr></tbody></table></div></section>
|
||||||
<h2>Nuevo empleado</h2>
|
|
||||||
<form (ngSubmit)="save()" #employeeForm="ngForm">
|
|
||||||
<div class="employee-form-grid">
|
|
||||||
<label>ID<input name="employeeCode" [(ngModel)]="form.employee_code" required></label>
|
|
||||||
<label>Nombre<input name="nombre" [(ngModel)]="form.nombre" required></label>
|
|
||||||
<label>Usuario<input name="usuario" [(ngModel)]="form.usuario"></label>
|
|
||||||
<label>Email<input type="email" name="email" [(ngModel)]="form.email"></label>
|
|
||||||
<label>Perfil<input name="perfil" [(ngModel)]="form.perfil"></label>
|
|
||||||
<label>Regional<input name="regional" [(ngModel)]="form.regional"></label>
|
|
||||||
<label>País<input name="pais" [(ngModel)]="form.pais"></label>
|
|
||||||
<label>Estado<select name="activo" [(ngModel)]="form.activo"><option [ngValue]="true">Activo</option><option [ngValue]="false">Inactivo</option></select></label>
|
|
||||||
<label>Latitud<input type="number" name="latitud" [(ngModel)]="form.latitud" step="any" required></label>
|
|
||||||
<label>Longitud<input type="number" name="longitud" [(ngModel)]="form.longitud" step="any" required></label>
|
|
||||||
<label>Horas por día<input type="number" name="hoursDay" [(ngModel)]="form.horas_por_dia" min="0.5" max="24" step="0.5" required></label>
|
|
||||||
<label>Jornada de trabajo<select name="schedule" [(ngModel)]="form.jornada_codigo"><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></label>
|
|
||||||
<label>Horas de descanso/día<input type="number" name="dailyRest" [(ngModel)]="form.horas_descanso_por_dia" min="0" max="3" step="1" required></label>
|
|
||||||
<label>Visitas máximas/día<input type="number" name="visitsDay" [(ngModel)]="form.visitas_maximas_por_dia" min="1" required></label>
|
|
||||||
</div>
|
|
||||||
<p class="weekly-capacity">Capacidad semanal: {{ selectedScheduleHours }} h · Capacidad diaria efectiva: {{ form.horas_por_dia - form.horas_descanso_por_dia | number:'1.0-1' }} h</p>
|
|
||||||
<p class="form-error" *ngIf="error">{{ error }}</p>
|
|
||||||
<div class="form-actions"><button type="submit" [disabled]="saving || employeeForm.invalid"><i class="pi pi-save"></i> {{ saving ? 'Guardando...' : 'Agregar empleado' }}</button></div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="employee-table-card">
|
|
||||||
<div class="table-header"><h2>Empleados registrados</h2><button type="button" (click)="load()" [disabled]="loading"><i class="pi pi-refresh"></i> Actualizar</button></div>
|
|
||||||
<p class="form-error" *ngIf="error && !loading">{{ error }}</p>
|
|
||||||
<p class="loading" *ngIf="loading">Cargando empleados...</p>
|
|
||||||
<div class="table-wrap" *ngIf="!loading">
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>ID</th><th>Nombre</th><th>Regional</th><th>Jornada</th><th>Descanso/día</th><th>Días de descanso</th><th>Visitas/día</th><th>Estado</th><th>Acciones</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let employee of employees" [class.editing-row]="editingId === employee.id"><td>{{ employee.employee_code }}</td><td>{{ employee.nombre }}</td><td>{{ employee.regional || '-' }}</td><td><ng-container *ngIf="editingId === employee.id; else scheduleText"><select [(ngModel)]="employee.jornada_codigo" [name]="'schedule-' + employee.id" (ngModelChange)="onInlineScheduleChange(employee)"><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></ng-container><ng-template #scheduleText>{{ scheduleLabel(employee) }}</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else restText"><input class="inline-number" type="number" [(ngModel)]="employee.horas_descanso_por_dia" [name]="'rest-' + employee.id" min="0" max="3" step="1"></ng-container><ng-template #restText>{{ employee.horas_descanso_por_dia }} h</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else daysText"><div class="rest-days-picker inline-picker"><label *ngFor="let day of availableRestDays(employee)"><input type="checkbox" [checked]="employee.dias_descanso.includes(day)" (change)="toggleRestDay(employee, day, $any($event.target).checked)">{{ day }}</label></div></ng-container><ng-template #daysText>{{ employee.dias_descanso.length ? employee.dias_descanso.join(', ') : 'Ninguno' }}</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else visitsText"><input class="inline-number" type="number" [(ngModel)]="employee.visitas_maximas_por_dia" [name]="'visits-' + employee.id" min="1" step="1"></ng-container><ng-template #visitsText>{{ employee.visitas_maximas_por_dia }}</ng-template></td><td><span class="status" [class.inactive]="!employee.activo">{{ employee.activo ? 'Activo' : 'Inactivo' }}</span></td><td class="row-actions"><ng-container *ngIf="editingId === employee.id; else normalActions"><button type="button" (click)="saveInline(employee)" [disabled]="saving">Guardar</button><button type="button" (click)="cancelEdit()" [disabled]="saving">Cancelar</button></ng-container><ng-template #normalActions><button type="button" (click)="edit(employee)">Editar</button><button type="button" (click)="toggleActive(employee)">{{ employee.activo ? 'Desactivar' : 'Activar' }}</button><button type="button" class="delete" (click)="remove(employee)">Eliminar</button></ng-template></td></tr>
|
|
||||||
<tr *ngIf="!employees.length"><td colspan="9">No hay empleados registrados.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { Location } from '@angular/common';
|
import { Location } from '@angular/common';
|
||||||
import { Employee, EmployeeInput } from '../../core/models/employee.model';
|
import { Employee, EmployeeInput } from '../../core/models/employee.model';
|
||||||
import { EmployeeService } from '../../core/services/employee.service';
|
import { EmployeeService } from '../../core/services/employee.service';
|
||||||
@@ -31,19 +31,32 @@ export class EmployeesComponent implements OnDestroy, OnInit {
|
|||||||
importing = false;
|
importing = false;
|
||||||
openRestDaysFor: string | null = null;
|
openRestDaysFor: string | null = null;
|
||||||
readonly schedules = WORK_SCHEDULES;
|
readonly schedules = WORK_SCHEDULES;
|
||||||
|
selectedIds = new Set<string>();
|
||||||
|
bulkSchedule = '';
|
||||||
|
bulkRestHours: number | null = null;
|
||||||
|
bulkVisits: number | null = null;
|
||||||
|
bulkRestDays = new Set<string>();
|
||||||
|
bulkSaving = false;
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
private loadSequence = 0;
|
private loadSequence = 0;
|
||||||
|
projectId = '';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private employeesService: EmployeeService,
|
private employeesService: EmployeeService,
|
||||||
private auth: AuthService,
|
private auth: AuthService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
|
private activatedRoute: ActivatedRoute,
|
||||||
private location: Location,
|
private location: Location,
|
||||||
public profile: UserProfileService,
|
public profile: UserProfileService,
|
||||||
private cdr: ChangeDetectorRef
|
private cdr: ChangeDetectorRef
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? '';
|
||||||
|
if (!this.projectId) {
|
||||||
|
void this.router.navigate(['/home']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void this.load();
|
void this.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,14 +73,23 @@ export class EmployeesComponent implements OnDestroy, OnInit {
|
|||||||
return workSchedule(this.form.jornada_codigo).weeklyHours;
|
return workSchedule(this.form.jornada_codigo).weeklyHours;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get allSelected(): boolean {
|
||||||
|
return this.employees.length > 0 && this.employees.every(employee => this.selectedIds.has(employee.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedEmployees(): Employee[] {
|
||||||
|
return this.employees.filter(employee => this.selectedIds.has(employee.id));
|
||||||
|
}
|
||||||
|
|
||||||
async load(): Promise<void> {
|
async load(): Promise<void> {
|
||||||
const sequence = ++this.loadSequence;
|
const sequence = ++this.loadSequence;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
try {
|
try {
|
||||||
const employees = await this.employeesService.list();
|
const employees = await this.employeesService.list(this.projectId);
|
||||||
if (this.destroyed || sequence !== this.loadSequence) return;
|
if (this.destroyed || sequence !== this.loadSequence) return;
|
||||||
this.employees = employees;
|
this.employees = employees;
|
||||||
|
this.selectedIds.clear();
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (!this.destroyed && sequence === this.loadSequence) {
|
if (!this.destroyed && sequence === this.loadSequence) {
|
||||||
@@ -144,7 +166,7 @@ export class EmployeesComponent implements OnDestroy, OnInit {
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
if (this.editingId) await this.employeesService.update(this.editingId, employee);
|
if (this.editingId) await this.employeesService.update(this.editingId, employee);
|
||||||
else await this.employeesService.create(employee);
|
else await this.employeesService.create(this.projectId, employee);
|
||||||
this.cancelEdit();
|
this.cancelEdit();
|
||||||
await this.load();
|
await this.load();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -163,6 +185,90 @@ export class EmployeesComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleSelection(id: string, checked: boolean): void {
|
||||||
|
checked ? this.selectedIds.add(id) : this.selectedIds.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAll(checked: boolean): void {
|
||||||
|
this.selectedIds = checked ? new Set(this.employees.map(employee => employee.id)) : new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleBulkRestDay(day: string, checked: boolean): void {
|
||||||
|
checked ? this.bulkRestDays.add(day) : this.bulkRestDays.delete(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyBulkEmployeeChanges(): Promise<void> {
|
||||||
|
const selected = this.selectedEmployees;
|
||||||
|
if (!selected.length) return;
|
||||||
|
if (!this.bulkSchedule && this.bulkRestHours === null && this.bulkVisits === null && !this.bulkRestDays.size) {
|
||||||
|
this.error = 'Selecciona al menos un cambio masivo para aplicar.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.bulkRestHours !== null && (!Number.isInteger(this.bulkRestHours) || this.bulkRestHours < 0 || this.bulkRestHours > 3)) {
|
||||||
|
this.error = 'Descanso/día debe ser un entero entre 0 y 3.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.bulkVisits !== null && (!Number.isInteger(this.bulkVisits) || this.bulkVisits < 1)) {
|
||||||
|
this.error = 'Visitas/día debe ser un entero mayor que 0.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.bulkSaving = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
await Promise.all(selected.map(employee => {
|
||||||
|
const schedule = this.bulkSchedule || employee.jornada_codigo;
|
||||||
|
const restDays = this.bulkRestDays.size
|
||||||
|
? Array.from(this.bulkRestDays).filter(day => workSchedule(schedule).days.includes(day))
|
||||||
|
: (this.bulkSchedule ? employee.dias_descanso.filter(day => workSchedule(schedule).days.includes(day)) : employee.dias_descanso);
|
||||||
|
return this.employeesService.update(employee.id, {
|
||||||
|
...employee,
|
||||||
|
jornada_codigo: schedule,
|
||||||
|
dias_por_semana: workSchedule(schedule).days.length,
|
||||||
|
dias_descanso: restDays,
|
||||||
|
horas_descanso_por_dia: this.bulkRestHours ?? employee.horas_descanso_por_dia,
|
||||||
|
visitas_maximas_por_dia: this.bulkVisits ?? employee.visitas_maximas_por_dia
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
this.importMessage = `${selected.length} empleados actualizados.`;
|
||||||
|
this.resetBulkFields();
|
||||||
|
await this.load();
|
||||||
|
} catch (error: any) {
|
||||||
|
this.error = error?.message ?? 'No se pudieron aplicar los cambios masivos.';
|
||||||
|
} finally {
|
||||||
|
this.bulkSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSelectedActive(active: boolean): Promise<void> {
|
||||||
|
const selected = this.selectedEmployees;
|
||||||
|
if (!selected.length) return;
|
||||||
|
this.bulkSaving = true;
|
||||||
|
try {
|
||||||
|
await Promise.all(selected.map(employee => this.employeesService.update(employee.id, { ...employee, activo: active })));
|
||||||
|
this.importMessage = `${selected.length} empleados ${active ? 'activados' : 'desactivados'}.`;
|
||||||
|
await this.load();
|
||||||
|
} catch (error: any) {
|
||||||
|
this.error = error?.message ?? 'No se pudo actualizar el estado de los empleados.';
|
||||||
|
} finally {
|
||||||
|
this.bulkSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSelected(): Promise<void> {
|
||||||
|
const selected = this.selectedEmployees;
|
||||||
|
if (!selected.length || !window.confirm(`¿Eliminar ${selected.length} empleados seleccionados? Esta acción no se puede deshacer.`)) return;
|
||||||
|
this.bulkSaving = true;
|
||||||
|
try {
|
||||||
|
await Promise.all(selected.map(employee => this.employeesService.remove(employee.id)));
|
||||||
|
this.importMessage = `${selected.length} empleados eliminados.`;
|
||||||
|
await this.load();
|
||||||
|
} catch (error: any) {
|
||||||
|
this.error = error?.message ?? 'No se pudieron eliminar los empleados.';
|
||||||
|
} finally {
|
||||||
|
this.bulkSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
scheduleLabel(employee: Employee): string {
|
scheduleLabel(employee: Employee): string {
|
||||||
return workSchedule(employee.jornada_codigo).label;
|
return workSchedule(employee.jornada_codigo).label;
|
||||||
}
|
}
|
||||||
@@ -195,7 +301,7 @@ export class EmployeesComponent implements OnDestroy, OnInit {
|
|||||||
const invalid = employees.filter((employee): employee is null => employee === null).length;
|
const invalid = employees.filter((employee): employee is null => employee === null).length;
|
||||||
const valid = employees.filter((employee): employee is EmployeeInput => employee !== null);
|
const valid = employees.filter((employee): employee is EmployeeInput => employee !== null);
|
||||||
if (!valid.length) throw new Error('El archivo no contiene empleados válidos.');
|
if (!valid.length) throw new Error('El archivo no contiene empleados válidos.');
|
||||||
await this.employeesService.upsert(valid);
|
await this.employeesService.upsert(this.projectId, valid);
|
||||||
this.importMessage = `${valid.length} empleados guardados${invalid ? `; ${invalid} filas omitidas por datos inválidos` : ''}.`;
|
this.importMessage = `${valid.length} empleados guardados${invalid ? `; ${invalid} filas omitidas por datos inválidos` : ''}.`;
|
||||||
await this.load();
|
await this.load();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -226,18 +332,25 @@ export class EmployeesComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async goHome(): Promise<void> {
|
async goHome(): Promise<void> {
|
||||||
await this.router.navigate(['/home']);
|
await this.router.navigate(['/proyectos', this.projectId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
goBack(): void {
|
goBack(): void {
|
||||||
if (history.length > 1) this.location.back();
|
if (history.length > 1) this.location.back();
|
||||||
else void this.router.navigate(['/home']);
|
else void this.router.navigate(['/proyectos', this.projectId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private nullable(value: string | null): string | null {
|
private nullable(value: string | null): string | null {
|
||||||
return value?.trim() || null;
|
return value?.trim() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resetBulkFields(): void {
|
||||||
|
this.bulkSchedule = '';
|
||||||
|
this.bulkRestHours = null;
|
||||||
|
this.bulkVisits = null;
|
||||||
|
this.bulkRestDays.clear();
|
||||||
|
}
|
||||||
|
|
||||||
private emptyForm(): EmployeeForm {
|
private emptyForm(): EmployeeForm {
|
||||||
return {
|
return {
|
||||||
employee_code: '', nombre: '', usuario: '', email: '', perfil: 'Promotor', pais: 'Republica Dominicana', regional: '',
|
employee_code: '', nombre: '', usuario: '', email: '', perfil: 'Promotor', pais: 'Republica Dominicana', regional: '',
|
||||||
|
|||||||
+20
-2
@@ -6,7 +6,7 @@
|
|||||||
.detail-sidebar {
|
.detail-sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 340px;
|
width: 340px;
|
||||||
z-index: 900;
|
z-index: 900;
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--glm-surface);
|
background: var(--glm-surface);
|
||||||
border-left: 3px solid var(--glm-verde);
|
border-right: 3px solid var(--glm-verde);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-brand {
|
.sidebar-brand {
|
||||||
@@ -61,6 +61,24 @@
|
|||||||
color: var(--glm-gris-oscuro);
|
color: var(--glm-gris-oscuro);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.maps-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--glm-verde);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--glm-verde-bg);
|
||||||
|
color: var(--glm-azul);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maps-btn:hover { background: var(--glm-verde); color: #fff; }
|
||||||
|
|
||||||
.detail-title {
|
.detail-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-15
@@ -9,17 +9,16 @@
|
|||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
<img src="GLM_completo.png" alt="GomezLee Marketing" class="sidebar-logo" />
|
<img src="GLM_completo.png" alt="GomezLee Marketing" class="sidebar-logo" />
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<button type="button" class="back-btn" (click)="goBack()">
|
<button type="button" class="back-btn" (click)="goBack()">
|
||||||
<i class="pi pi-arrow-left"></i> Volver
|
<i class="pi pi-arrow-left"></i> Volver
|
||||||
</button>
|
</button>
|
||||||
<div class="detail-title">
|
<div class="detail-title">
|
||||||
<h2>{{ routeData.json.nombre_usuario }} → {{ routeData.json.nombre_pdv }}</h2>
|
<h2>{{ routeData.json.nombre_usuario }} → {{ routeData.json.nombre_pdv }}</h2>
|
||||||
<span class="badge">{{ routeData.json.dia }}</span>
|
<span class="badge">{{ routeData.json.dia }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<section class="info-card">
|
||||||
<section class="info-card">
|
|
||||||
<h3>Información de la ruta</h3>
|
<h3>Información de la ruta</h3>
|
||||||
<dl class="info-list">
|
<dl class="info-list">
|
||||||
<div><dt>Usuario</dt><dd>{{ routeData.json.nombre_usuario }}</dd></div>
|
<div><dt>Usuario</dt><dd>{{ routeData.json.nombre_usuario }}</dd></div>
|
||||||
@@ -33,11 +32,8 @@
|
|||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="info-card">
|
<section class="info-card">
|
||||||
<h3>Coordenadas</h3>
|
<h3>PDV destino</h3>
|
||||||
<dl class="info-list">
|
<a *ngIf="googleMapsUrl" class="maps-btn" [href]="googleMapsUrl" target="_blank" rel="noopener noreferrer"><i class="pi pi-map-marker"></i> Ver en Google Maps</a>
|
||||||
<div><dt>Usuario (lat, lng)</dt><dd>{{ routeData.json.latitud_usuario }}, {{ routeData.json.longitud_usuario }}</dd></div>
|
</section>
|
||||||
<div><dt>PDV (lat, lng)</dt><dd>{{ routeData.json.latitud_pdv }}, {{ routeData.json.longitud_pdv }}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+30
-9
@@ -7,6 +7,7 @@ import { RouteState } from '../../../core/route-state/route';
|
|||||||
import { OrsService } from '../../../core/services/ors.service';
|
import { OrsService } from '../../../core/services/ors.service';
|
||||||
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
||||||
import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils';
|
import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||||
|
import { colorForUsuario } from '../../../shared/utils/palette.utils';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -31,6 +32,14 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
visitOrder = 0;
|
visitOrder = 0;
|
||||||
totalStops = 0;
|
totalStops = 0;
|
||||||
|
|
||||||
|
get googleMapsUrl(): string | null {
|
||||||
|
if (!this.routeData) return null;
|
||||||
|
const route = this.routeData.json;
|
||||||
|
const origin = `${route.latitud_usuario},${route.longitud_usuario}`;
|
||||||
|
const destination = `${route.latitud_pdv},${route.longitud_pdv}`;
|
||||||
|
return `https://www.google.com/maps/dir/?api=1&origin=${encodeURIComponent(origin)}&destination=${encodeURIComponent(destination)}&travelmode=driving`;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
@@ -52,8 +61,9 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
const qp = this.activatedRoute.snapshot.queryParams;
|
const qp = this.activatedRoute.snapshot.queryParams;
|
||||||
const pdvId = qp['pdv_id'];
|
const pdvId = qp['pdv_id'];
|
||||||
const usuarioId = qp['usuario_id'];
|
const usuarioId = qp['usuario_id'];
|
||||||
|
const day = qp['dia'];
|
||||||
if (pdvId) {
|
if (pdvId) {
|
||||||
const found = this.routeState.findRoute(pdvId, usuarioId);
|
const found = this.routeState.findRoute(pdvId, usuarioId, day);
|
||||||
if (found) {
|
if (found) {
|
||||||
this.routeData = found;
|
this.routeData = found;
|
||||||
if (this.map) this.loadAndShowRoute(found);
|
if (this.map) this.loadAndShowRoute(found);
|
||||||
@@ -78,7 +88,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
// Re-attempt resolution after map init (queryParams case)
|
// Re-attempt resolution after map init (queryParams case)
|
||||||
const qp = this.activatedRoute.snapshot.queryParams;
|
const qp = this.activatedRoute.snapshot.queryParams;
|
||||||
if (qp['pdv_id']) {
|
if (qp['pdv_id']) {
|
||||||
const found = this.routeState.findRoute(qp['pdv_id'], qp['usuario_id']);
|
const found = this.routeState.findRoute(qp['pdv_id'], qp['usuario_id'], qp['dia']);
|
||||||
if (found) {
|
if (found) {
|
||||||
this.routeData = found;
|
this.routeData = found;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
@@ -105,8 +115,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
]) : null;
|
]) : null;
|
||||||
this.map = L.map('map', { maxBoundsViscosity: 0.85 });
|
this.map = L.map('map', { maxBoundsViscosity: 0.85 });
|
||||||
if (territoryBounds?.isValid()) {
|
if (territoryBounds?.isValid()) {
|
||||||
this.map.fitBounds(territoryBounds.pad(0.3));
|
this.fitRouteBounds(territoryBounds);
|
||||||
this.map.setMaxBounds(territoryBounds.pad(0.6));
|
|
||||||
} else {
|
} else {
|
||||||
this.map.setView([18.4861, -69.9312], 13);
|
this.map.setView([18.4861, -69.9312], 13);
|
||||||
}
|
}
|
||||||
@@ -115,11 +124,14 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
|
|
||||||
this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
this.tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '© OpenStreetMap contributors',
|
attribution: '© OpenStreetMap contributors',
|
||||||
bounds: territoryBounds?.isValid() ? territoryBounds.pad(0.6) : undefined,
|
|
||||||
keepBuffer: 1,
|
keepBuffer: 1,
|
||||||
noWrap: true,
|
noWrap: true,
|
||||||
updateWhenIdle: true
|
updateWhenIdle: true
|
||||||
}).addTo(this.map);
|
}).addTo(this.map);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.map.invalidateSize();
|
||||||
|
if (territoryBounds?.isValid()) this.fitRouteBounds(territoryBounds);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadAndShowRoute(route: RouteAssignedResponse) {
|
private async loadAndShowRoute(route: RouteAssignedResponse) {
|
||||||
@@ -176,7 +188,15 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
L.latLng(origin[1], origin[0]),
|
L.latLng(origin[1], origin[0]),
|
||||||
L.latLng(destination[1], destination[0])
|
L.latLng(destination[1], destination[0])
|
||||||
]);
|
]);
|
||||||
this.map.fitBounds(bounds.pad(0.3));
|
this.fitRouteBounds(bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fitRouteBounds(bounds: L.LatLngBounds): void {
|
||||||
|
this.map.fitBounds(bounds.pad(0.3), {
|
||||||
|
paddingTopLeft: L.point(380, 30),
|
||||||
|
paddingBottomRight: L.point(30, 30),
|
||||||
|
maxZoom: 15
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractCoords(geojson: unknown): L.LatLngExpression[] {
|
private extractCoords(geojson: unknown): L.LatLngExpression[] {
|
||||||
@@ -269,11 +289,12 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie
|
|||||||
duration: string,
|
duration: string,
|
||||||
originName: string
|
originName: string
|
||||||
) {
|
) {
|
||||||
|
const startColor = colorForUsuario(json.usuario_id).color;
|
||||||
const startIcon = L.divIcon({
|
const startIcon = L.divIcon({
|
||||||
className: 'custom-marker marker-start',
|
className: 'custom-marker marker-start',
|
||||||
html: '<div class="marker-inner">A</div>',
|
html: `<div class="marker-inner" style="background:${startColor};border:3px solid #fff;">A</div>`,
|
||||||
iconSize: [30, 30],
|
iconSize: [34, 34],
|
||||||
iconAnchor: [15, 15],
|
iconAnchor: [17, 17],
|
||||||
popupAnchor: [0, -18]
|
popupAnchor: [0, -18]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
.assigned-route-container {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-right {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--glm-surface);
|
|
||||||
color: var(--glm-azul);
|
|
||||||
border: 1px solid var(--glm-gris-medio);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--glm-verde-bg);
|
|
||||||
border-color: var(--glm-verde);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--glm-gris-medio);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--glm-azul);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 15px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon:hover {
|
|
||||||
background: var(--glm-verde);
|
|
||||||
border-color: var(--glm-verde);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-caption {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-caption .font-bold {
|
|
||||||
color: var(--glm-azul);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-caption .count {
|
|
||||||
color: var(--glm-acero);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-usuario {
|
|
||||||
color: var(--glm-azul);
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: underline;
|
|
||||||
text-decoration-color: transparent;
|
|
||||||
transition: text-decoration-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-usuario:hover {
|
|
||||||
text-decoration-color: var(--glm-verde);
|
|
||||||
color: var(--glm-verde);
|
|
||||||
}
|
|
||||||
.inline-edit {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 100px;
|
|
||||||
padding: 4px 6px;
|
|
||||||
border: 1px solid var(--glm-verde);
|
|
||||||
border-radius: 3px;
|
|
||||||
background: #fff;
|
|
||||||
color: var(--glm-gris-oscuro);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-actions {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
<div class="assigned-route-container">
|
|
||||||
<div class="header-actions">
|
|
||||||
<input
|
|
||||||
pInputText
|
|
||||||
type="text"
|
|
||||||
placeholder="Buscar..."
|
|
||||||
(input)="dt.filterGlobal($any($event.target).value, 'contains')"
|
|
||||||
/>
|
|
||||||
<div class="actions-right">
|
|
||||||
<button type="button" class="btn-secondary" (click)="goToOverviewMap()" pTooltip="Ver mapa filtrado por usuario" tooltipPosition="top">
|
|
||||||
<i class="pi pi-map"></i> Ver mapa
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-secondary" (click)="downloadCSV()">
|
|
||||||
<i class="pi pi-download"></i> Descargar CSV
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-secondary" (click)="reoptimizeLocally()" [disabled]="!canReoptimize" title="Vuelve a ejecutar el optimizador con los archivos cargados">
|
|
||||||
<span *ngIf="reoptLoading" class="spinner-inline"></span>
|
|
||||||
<i *ngIf="!reoptLoading" class="pi pi-sync"></i> {{ reoptLoading ? 'Reoptimizando...' : 'Reoptimizar rutas' }}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-secondary" *ngIf="hasLocalChanges" (click)="restoreOriginal()">
|
|
||||||
<i class="pi pi-undo"></i> Deshacer cambios
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-secondary"
|
|
||||||
(click)="toggleRutas()"
|
|
||||||
pTooltip="Mostrar u ocultar el detalle de rutas"
|
|
||||||
tooltipPosition="top"
|
|
||||||
>
|
|
||||||
<i [class]="rutasExpandido ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"></i>
|
|
||||||
{{ rutasExpandido ? 'Ocultar rutas' : 'Ver rutas' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-error" *ngIf="reoptError"><i class="pi pi-exclamation-triangle"></i> {{ reoptError }}</div>
|
|
||||||
<div class="table-error" *ngIf="editError"><i class="pi pi-exclamation-triangle"></i> {{ editError }}</div>
|
|
||||||
|
|
||||||
<p-table
|
|
||||||
#dt
|
|
||||||
[value]="assignedRoutes"
|
|
||||||
[loading]="isLoading"
|
|
||||||
[paginator]="true"
|
|
||||||
[rows]="10"
|
|
||||||
size="small"
|
|
||||||
[columns]="cols"
|
|
||||||
[rowsPerPageOptions]="[10, 25, 50]"
|
|
||||||
styleClass="p-datatable-sm text-sm"
|
|
||||||
>
|
|
||||||
<ng-template pTemplate="caption">
|
|
||||||
<div class="table-caption">
|
|
||||||
<span class="font-bold">Rutas asignadas</span>
|
|
||||||
<span class="count">{{ assignedRoutes.length }} registros</span>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
<ng-template pTemplate="header">
|
|
||||||
<tr>
|
|
||||||
<th *ngFor="let col of cols" pSortableColumn="{{ col.field }}">
|
|
||||||
{{ col.header }}
|
|
||||||
<p-sortIcon [field]="col.field"></p-sortIcon>
|
|
||||||
<p-columnFilter
|
|
||||||
[field]="col.field"
|
|
||||||
display="menu"
|
|
||||||
matchMode="contains"
|
|
||||||
[showMatchModes]="false"
|
|
||||||
[showOperator]="false"
|
|
||||||
[showAddButton]="false"
|
|
||||||
></p-columnFilter>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
<ng-template pTemplate="body" let-row>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<ng-container *ngIf="editingRoute === row; else userValue">
|
|
||||||
<select class="inline-edit" [(ngModel)]="editUserId">
|
|
||||||
<option *ngFor="let user of userOptions" [value]="user.id">{{ user.name }}</option>
|
|
||||||
</select>
|
|
||||||
</ng-container>
|
|
||||||
<ng-template #userValue><a class="link-usuario" (click)="goToUserMap(row.json.usuario_id)" pTooltip="Ver todas las rutas de este usuario" tooltipPosition="top">{{ row.json.nombre_usuario }}</a></ng-template>
|
|
||||||
</td>
|
|
||||||
<td>{{ row.json.nombre_pdv }}</td>
|
|
||||||
<td>{{ row.json.hora_laboral_semanal_usuario }}</td>
|
|
||||||
<td>{{ row.json.hora_minima_semanal_pdv }}</td>
|
|
||||||
<td>{{ row.json.horas_trabajo }}</td>
|
|
||||||
<td>
|
|
||||||
<ng-container *ngIf="editingRoute === row; else dayValue">
|
|
||||||
<select class="inline-edit" [(ngModel)]="editDay">
|
|
||||||
<option *ngFor="let day of dayOptions" [value]="day">{{ day }}</option>
|
|
||||||
</select>
|
|
||||||
</ng-container>
|
|
||||||
<ng-template #dayValue>{{ row.json.dia }}</ng-template>
|
|
||||||
</td>
|
|
||||||
<td>{{ row.json.distancia }}</td>
|
|
||||||
<td>{{ (row.json.horas_desplazamiento * 60).toFixed(0) }} min</td>
|
|
||||||
<td class="text-center edit-actions">
|
|
||||||
<ng-container *ngIf="editingRoute === row; else editButton">
|
|
||||||
<button type="button" class="btn-icon" (click)="saveEdit()" title="Guardar"><i class="pi pi-check"></i></button>
|
|
||||||
<button type="button" class="btn-icon" (click)="cancelEdit()" title="Cancelar"><i class="pi pi-times"></i></button>
|
|
||||||
</ng-container>
|
|
||||||
<ng-template #editButton>
|
|
||||||
<button type="button" class="btn-icon" (click)="startEdit(row)" title="Editar asignación"><i class="pi pi-pencil"></i></button>
|
|
||||||
</ng-template>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-icon"
|
|
||||||
(click)="onShowMap(row)"
|
|
||||||
pTooltip="Ver en mapa (1 ruta)"
|
|
||||||
tooltipPosition="top"
|
|
||||||
>
|
|
||||||
<i class="pi pi-map-marker"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</ng-template>
|
|
||||||
|
|
||||||
<ng-template pTemplate="emptymessage">
|
|
||||||
<tr>
|
|
||||||
<td colspan="9" class="text-center">No hay rutas asignadas</td>
|
|
||||||
</tr>
|
|
||||||
</ng-template>
|
|
||||||
</p-table>
|
|
||||||
</div>
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
import { Component, ChangeDetectorRef, ViewChild } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
|
||||||
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
|
||||||
import { saveAs } from 'file-saver';
|
|
||||||
import { Table } from 'primeng/table';
|
|
||||||
import { TableModule } from 'primeng/table';
|
|
||||||
import { InputTextModule } from 'primeng/inputtext';
|
|
||||||
import { TooltipModule } from 'primeng/tooltip';
|
|
||||||
import { RouteState } from '../../../core/route-state/route';
|
|
||||||
import { RutasService } from '../../../core/services/router.service';
|
|
||||||
import { PlanningInputService } from '../../../core/services/planning-input.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
standalone: true,
|
|
||||||
selector: 'app-assigned-route',
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
TableModule,
|
|
||||||
InputTextModule,
|
|
||||||
TooltipModule
|
|
||||||
],
|
|
||||||
templateUrl: './assigned-route.component.html',
|
|
||||||
styleUrl: './assigned-route.component.css'
|
|
||||||
})
|
|
||||||
export class AssignedRouteComponent {
|
|
||||||
@ViewChild('dt') table!: Table;
|
|
||||||
rutasExpandido = false;
|
|
||||||
tablaRutas = '';
|
|
||||||
assignedRoutes: RouteAssignedResponse[] = [];
|
|
||||||
isLoading = false;
|
|
||||||
cols: any[] = [];
|
|
||||||
editingRoute: RouteAssignedResponse | null = null;
|
|
||||||
editUserId = '';
|
|
||||||
editDay = '';
|
|
||||||
hasLocalChanges = false;
|
|
||||||
editError: string | null = null;
|
|
||||||
reoptLoading = false;
|
|
||||||
reoptError: string | null = null;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private routesState: RouteState,
|
|
||||||
private cdr: ChangeDetectorRef,
|
|
||||||
private router: Router,
|
|
||||||
private activatedRoute: ActivatedRoute,
|
|
||||||
private rutasService: RutasService,
|
|
||||||
private planningInput: PlanningInputService
|
|
||||||
) { }
|
|
||||||
|
|
||||||
private subs: import('rxjs').Subscription[] = [];
|
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
this.routesState.resetLoading();
|
|
||||||
this.subs.push(
|
|
||||||
this.routesState.loading$.subscribe(value => {
|
|
||||||
this.isLoading = value;
|
|
||||||
this.cdr.detectChanges();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
this.subs.push(
|
|
||||||
this.routesState.assignedRoutes$.subscribe(data => {
|
|
||||||
this.assignedRoutes = [...data];
|
|
||||||
this.cdr.detectChanges();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
this.cols = [
|
|
||||||
{ field: 'json.nombre_usuario', header: 'Usuario' },
|
|
||||||
{ field: 'json.nombre_pdv', header: 'PDV' },
|
|
||||||
{ field: 'json.hora_laboral_semanal_usuario', header: 'Sem. Usuario (hr)' },
|
|
||||||
{ field: 'json.hora_minima_semanal_pdv', header: 'Horas Min. PDV' },
|
|
||||||
{ field: 'json.horas_trabajo', header: 'Horas Trabajo' },
|
|
||||||
{ field: 'json.dia', header: 'Día' },
|
|
||||||
{ field: 'json.distancia', header: 'Distancia (km)' },
|
|
||||||
{ field: 'json.horas_desplazamiento', header: 'Duración (min)' },
|
|
||||||
{ field: '', header: '' }
|
|
||||||
];
|
|
||||||
|
|
||||||
}
|
|
||||||
onShowMap(row: RouteAssignedResponse) {
|
|
||||||
this.routesState.sendRoute(row);
|
|
||||||
this.router.navigate(['/proyectos', this.projectId, 'ruta'], {
|
|
||||||
queryParams: { pdv_id: row.json.pdv_id, usuario_id: row.json.usuario_id, dia: row.json.dia }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
goToOverviewMap() {
|
|
||||||
// If table has filtered view, use first visible row's usuario as default; else first assigned
|
|
||||||
const target = (this.table?.filteredValue?.[0] as RouteAssignedResponse | undefined) ?? this.assignedRoutes[0];
|
|
||||||
if (target) {
|
|
||||||
this.router.navigate(['/proyectos', this.projectId, 'mapa'], {
|
|
||||||
queryParams: { usuario_id: target.json.usuario_id, dia: 'Todos' }
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.router.navigate(['/proyectos', this.projectId, 'mapa']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
goToUserMap(usuarioId: string) {
|
|
||||||
this.router.navigate(['/proyectos', this.projectId, 'mapa'], { queryParams: { usuario_id: usuarioId, dia: 'Todos' } });
|
|
||||||
}
|
|
||||||
|
|
||||||
private get projectId(): string { return this.activatedRoute.snapshot.paramMap.get('id') ?? ''; }
|
|
||||||
|
|
||||||
get userOptions(): { id: string; name: string }[] {
|
|
||||||
const users = new Map<string, string>();
|
|
||||||
for (const route of this.assignedRoutes) users.set(String(route.json.usuario_id), route.json.nombre_usuario);
|
|
||||||
return Array.from(users, ([id, name]) => ({ id, name }));
|
|
||||||
}
|
|
||||||
|
|
||||||
get dayOptions(): string[] {
|
|
||||||
return this.routesState.getDiasUnicos();
|
|
||||||
}
|
|
||||||
|
|
||||||
startEdit(route: RouteAssignedResponse): void {
|
|
||||||
this.editingRoute = route;
|
|
||||||
this.editUserId = String(route.json.usuario_id);
|
|
||||||
this.editDay = route.json.dia;
|
|
||||||
this.editError = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelEdit(): void {
|
|
||||||
this.editingRoute = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveEdit(): void {
|
|
||||||
if (!this.editingRoute || !this.editUserId || !this.editDay) return;
|
|
||||||
const capacity = this.routesState.canAssignToDay(
|
|
||||||
this.editUserId,
|
|
||||||
this.editDay,
|
|
||||||
Number(this.editingRoute.json.horas_trabajo || this.editingRoute.json.hora_minima_semanal_pdv || 0),
|
|
||||||
this.editingRoute
|
|
||||||
);
|
|
||||||
if (!capacity.allowed) {
|
|
||||||
this.editError = `La asignación excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.routesState.updateAssignedRoute(this.editingRoute, this.editUserId, this.editDay);
|
|
||||||
this.hasLocalChanges = true;
|
|
||||||
this.editingRoute = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
get canReoptimize(): boolean {
|
|
||||||
return this.assignedRoutes.length > 0 && !!this.planningInput.getFormData() && !this.reoptLoading;
|
|
||||||
}
|
|
||||||
|
|
||||||
reoptimizeLocally(): void {
|
|
||||||
const data = this.planningInput.getFormData();
|
|
||||||
if (!data || !this.assignedRoutes.length || this.reoptLoading) return;
|
|
||||||
this.reoptLoading = true;
|
|
||||||
this.reoptError = null;
|
|
||||||
this.routesState.setLoading(true);
|
|
||||||
this.rutasService.reoptimizarRutas(data).subscribe({
|
|
||||||
next: response => {
|
|
||||||
const items = response as unknown as unknown[];
|
|
||||||
const assigned = items.find(item => item && typeof item === 'object' && 'assigned' in item) as { assigned?: unknown } | undefined;
|
|
||||||
const noAssigned = items.find(item => item && typeof item === 'object' && 'noAssigned' in item) as { noAssigned?: unknown } | undefined;
|
|
||||||
if (assigned?.assigned) this.routesState.setAssignedRoutes(assigned.assigned as never);
|
|
||||||
if (noAssigned?.noAssigned) this.routesState.setNoAssignedRoutes(noAssigned.noAssigned as never);
|
|
||||||
this.hasLocalChanges = false;
|
|
||||||
this.reoptLoading = false;
|
|
||||||
this.routesState.setLoading(false);
|
|
||||||
this.cdr.detectChanges();
|
|
||||||
},
|
|
||||||
error: err => {
|
|
||||||
this.reoptError = err?.error?.error || err?.message || 'No se pudo reoptimizar la planificación';
|
|
||||||
this.reoptLoading = false;
|
|
||||||
this.routesState.setLoading(false);
|
|
||||||
this.cdr.detectChanges();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.editingRoute = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreOriginal(): void {
|
|
||||||
this.routesState.restoreOriginalAssignedRoutes();
|
|
||||||
this.hasLocalChanges = false;
|
|
||||||
this.editingRoute = null;
|
|
||||||
}
|
|
||||||
toggleRutas() {
|
|
||||||
this.rutasExpandido = !this.rutasExpandido;
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy() {
|
|
||||||
this.subs.forEach(s => s.unsubscribe());
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadCSV() {
|
|
||||||
const headers = [
|
|
||||||
'Usuario ID',
|
|
||||||
'Usuario',
|
|
||||||
'Long Usuario',
|
|
||||||
'Lat Usuario',
|
|
||||||
'PDV ID',
|
|
||||||
'PDV',
|
|
||||||
'Long PDV',
|
|
||||||
'Lat PDV',
|
|
||||||
'Horas Usuario',
|
|
||||||
'Horas PDV',
|
|
||||||
'Horas Trabajo',
|
|
||||||
'Día',
|
|
||||||
'Distancia',
|
|
||||||
'Duracion'
|
|
||||||
];
|
|
||||||
|
|
||||||
const data =
|
|
||||||
this.table.filteredValue && this.table.filteredValue.length
|
|
||||||
? this.table.filteredValue
|
|
||||||
: this.assignedRoutes;
|
|
||||||
|
|
||||||
const rows = data.map(r => [
|
|
||||||
r.json.usuario_id,
|
|
||||||
r.json.nombre_usuario,
|
|
||||||
r.json.longitud_usuario,
|
|
||||||
r.json.latitud_usuario,
|
|
||||||
r.json.pdv_id,
|
|
||||||
r.json.nombre_pdv,
|
|
||||||
r.json.longitud_pdv,
|
|
||||||
r.json.latitud_pdv,
|
|
||||||
r.json.hora_laboral_semanal_usuario,
|
|
||||||
r.json.hora_minima_semanal_pdv,
|
|
||||||
r.json.horas_trabajo,
|
|
||||||
r.json.dia,
|
|
||||||
r.json.distancia,
|
|
||||||
r.json.horas_desplazamiento
|
|
||||||
]);
|
|
||||||
|
|
||||||
const csvContent =
|
|
||||||
[headers, ...rows].map(r => r.join(',')).join('\n');
|
|
||||||
|
|
||||||
const bom = '\uFEFF';
|
|
||||||
const blob = new Blob([bom + csvContent], {
|
|
||||||
type: 'text/csv;charset=utf-8;'
|
|
||||||
});
|
|
||||||
|
|
||||||
saveAs(blob, 'rutas_planificadas.csv');
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -53,6 +53,25 @@
|
|||||||
|
|
||||||
.employees-link { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 1px solid var(--glm-verde); border-radius: 4px; background: var(--glm-verde-bg); color: var(--glm-azul); font-size: .82rem; font-weight: 700; text-decoration: none; white-space: nowrap; }
|
.employees-link { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 1px solid var(--glm-verde); border-radius: 4px; background: var(--glm-verde-bg); color: var(--glm-azul); font-size: .82rem; font-weight: 700; text-decoration: none; white-space: nowrap; }
|
||||||
.employees-link:hover { background: var(--glm-verde); color: #fff; }
|
.employees-link:hover { background: var(--glm-verde); color: #fff; }
|
||||||
|
.map-overview-link { margin: -8px 0 16px; }
|
||||||
|
.project-back-link { display:inline-flex; align-items:center; gap:6px; margin-bottom:18px; }
|
||||||
|
.project-management-links { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:18px; }
|
||||||
|
.project-management-links .home-employees-link { margin-bottom:0; }
|
||||||
|
.dashboard-section-toggle { display:flex; width:100%; align-items:center; justify-content:space-between; gap:12px; padding:0 0 12px; border:0; background:transparent; color:var(--glm-azul); cursor:pointer; font:inherit; font-weight:700; text-align:left; text-transform:uppercase; }
|
||||||
|
.dashboard-section-toggle span { display:inline-flex; align-items:center; gap:7px; }
|
||||||
|
.planning-filters-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:10px; align-items:end; min-width:0; }
|
||||||
|
.planning-filters-grid label { display:grid; min-width:0; gap:5px; color:var(--glm-azul); font-size:.72rem; font-weight:700; text-transform:uppercase; }
|
||||||
|
.planning-filters-grid select { display:block; width:100%; min-width:0; box-sizing:border-box; padding:8px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-gris-oscuro); }
|
||||||
|
.planning-filters-grid .btn-secondary { grid-column:1; width:100%; margin-top:5px; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.planning-filters-grid { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||||
|
.planning-filters-grid .btn-secondary { grid-column:auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.planning-filters-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
.home-header h1 {
|
.home-header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -5,14 +5,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="home-container">
|
<div class="home-container">
|
||||||
<a class="employees-link home-employees-link" routerLink="/empleados"><i class="pi pi-users"></i> Gestionar empleados</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>
|
||||||
<header class="home-header">
|
<header class="home-header">
|
||||||
<div class="project-heading"><div class="project-title-line"><a class="back-projects" routerLink="/home"><i class="pi pi-arrow-left"></i> Mis proyectos</a><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><p *ngIf="!editingProject">Gestión de rutas asignadas y puntos de venta pendientes</p></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card upload-card">
|
<section class="card upload-card">
|
||||||
<h2 class="section-title">PDVs del proyecto</h2>
|
<button type="button" class="dashboard-section-toggle" (click)="toggleInputs()"><span><i class="pi pi-database"></i> Datos para planificación</span><i class="pi" [class.pi-chevron-down]="inputsExpanded" [class.pi-chevron-right]="!inputsExpanded"></i></button>
|
||||||
<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 {{ projectCountry }} se usarán para planificar.</span></p>
|
<div *ngIf="inputsExpanded">
|
||||||
|
<p class="employees-summary" [class.empty]="!employeesLoading && !activeEmployees"><i class="pi pi-users"></i> <span *ngIf="employeesLoading">Cargando empleados activos...</span><span *ngIf="!employeesLoading">{{ activeEmployees }} empleados activos de este proyecto se usarán para planificar.</span></p>
|
||||||
|
|
||||||
<div class="upload-grid">
|
<div class="upload-grid">
|
||||||
<div class="file-input-wrapper">
|
<div class="file-input-wrapper">
|
||||||
@@ -96,6 +98,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<small class="hint-text" *ngIf="submitDisabledReason && !isLoading">{{ submitDisabledReason }}</small>
|
<small class="hint-text" *ngIf="submitDisabledReason && !isLoading">{{ submitDisabledReason }}</small>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="map-overview-link"><a class="employees-link" [routerLink]="['/proyectos', projectId, 'mapa']"><i class="pi pi-map"></i> Ver mapa general</a></div>
|
||||||
|
|
||||||
|
<section class="card planning-filters-card">
|
||||||
|
<button type="button" class="dashboard-section-toggle" (click)="toggleFilters()"><span><i class="pi pi-filter"></i> Filtros del dashboard</span><i class="pi" [class.pi-chevron-down]="filtersExpanded" [class.pi-chevron-right]="!filtersExpanded"></i></button>
|
||||||
|
<div class="planning-filters-grid" *ngIf="filtersExpanded"><label>Cobertura<select [(ngModel)]="planningFilters.cobertura" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('cobertura')" [value]="value">{{ value }}</option></select></label><label>Zona<select [(ngModel)]="planningFilters.zona" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('zona')" [value]="value">{{ value }}</option></select></label><label>Región<select [(ngModel)]="planningFilters.region" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('region')" [value]="value">{{ value }}</option></select></label><label>Estado<select [(ngModel)]="planningFilters.estado" (ngModelChange)="applyPlanningFilters()"><option value="">Todos</option><option *ngFor="let value of filterOptions('estado')" [value]="value">{{ value }}</option></select></label><label>Ciudad<select [(ngModel)]="planningFilters.ciudad" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('ciudad')" [value]="value">{{ value }}</option></select></label><button type="button" class="btn-secondary" (click)="clearPlanningFilters()"><i class="pi pi-filter-slash"></i> Limpiar</button></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card kpis-card">
|
<section class="card kpis-card">
|
||||||
@@ -103,11 +113,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<app-assigned-route></app-assigned-route>
|
<button type="button" class="dashboard-section-toggle" (click)="toggleUnassigned()"><span><i class="pi pi-exclamation-triangle"></i> PDVs sin ruta asignada</span><i class="pi" [class.pi-chevron-down]="unassignedExpanded" [class.pi-chevron-right]="!unassignedExpanded"></i></button>
|
||||||
</section>
|
<app-no-assigned-route *ngIf="unassignedExpanded"></app-no-assigned-route>
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2 class="section-title">PDVs sin ruta asignada</h2>
|
|
||||||
<app-no-assigned-route></app-no-assigned-route>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Component, ChangeDetectorRef, OnInit } from '@angular/core';
|
import { Component, ChangeDetectorRef, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { AssignedRouteComponent } from './assigned-route/assigned-route.component';
|
|
||||||
import { NoAssignedRouteComponent } from './no-assigned-route/no-assigned-route.component';
|
import { NoAssignedRouteComponent } from './no-assigned-route/no-assigned-route.component';
|
||||||
import { RutasService } from '../../core/services/router.service';
|
import { RutasService } from '../../core/services/router.service';
|
||||||
import { RouteState } from '../../core/route-state/route';
|
import { RouteState } from '../../core/route-state/route';
|
||||||
@@ -15,11 +14,12 @@ import { EmployeeService } from '../../core/services/employee.service';
|
|||||||
import { ProjectService } from '../../core/services/project.service';
|
import { ProjectService } from '../../core/services/project.service';
|
||||||
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';
|
||||||
|
import { PlanningFilterKey, PlanningFilters, PlanningFilterService } from '../../core/services/planning-filter.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
imports: [CommonModule, FormsModule, RouterLink, AssignedRouteComponent, NoAssignedRouteComponent, PlanningKpisComponent],
|
imports: [CommonModule, FormsModule, RouterLink, NoAssignedRouteComponent, PlanningKpisComponent],
|
||||||
templateUrl: './home.component.html',
|
templateUrl: './home.component.html',
|
||||||
styleUrls: ['./home.component.css']
|
styleUrls: ['./home.component.css']
|
||||||
})
|
})
|
||||||
@@ -40,6 +40,10 @@ export class HomeComponent implements OnInit {
|
|||||||
projectDescription = '';
|
projectDescription = '';
|
||||||
projectCountry = '';
|
projectCountry = '';
|
||||||
projectSaving = false;
|
projectSaving = false;
|
||||||
|
inputsExpanded = true;
|
||||||
|
filtersExpanded = true;
|
||||||
|
unassignedExpanded = false;
|
||||||
|
planningFilters: PlanningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
|
||||||
private calcSub: Subscription | null = null;
|
private calcSub: Subscription | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -53,6 +57,7 @@ export class HomeComponent implements OnInit {
|
|||||||
private auth: AuthService,
|
private auth: AuthService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
public profile: UserProfileService,
|
public profile: UserProfileService,
|
||||||
|
public planningFilter: PlanningFilterService,
|
||||||
private cdr: ChangeDetectorRef
|
private cdr: ChangeDetectorRef
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -68,11 +73,16 @@ export class HomeComponent implements OnInit {
|
|||||||
private async loadProject(): Promise<void> {
|
private async loadProject(): Promise<void> {
|
||||||
this.projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? '';
|
this.projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? '';
|
||||||
if (!this.projectId) return;
|
if (!this.projectId) return;
|
||||||
|
this.routeState.useProject(this.projectId);
|
||||||
|
this.planningInput.clear();
|
||||||
|
this.planningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
|
||||||
|
this.applyPlanningFilters();
|
||||||
try {
|
try {
|
||||||
const [project, input] = await Promise.all([this.projectsService.get(this.projectId), this.projectsService.projectInput(this.projectId)]);
|
const [project, input] = await Promise.all([this.projectsService.get(this.projectId), this.projectsService.projectInput(this.projectId)]);
|
||||||
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.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) {
|
||||||
@@ -98,10 +108,17 @@ export class HomeComponent implements OnInit {
|
|||||||
|
|
||||||
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
|
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
|
||||||
|
|
||||||
|
filterOptions(key: PlanningFilterKey): string[] { return this.planningFilter.options(key); }
|
||||||
|
applyPlanningFilters(): void { this.planningFilter.set(this.planningFilters); }
|
||||||
|
clearPlanningFilters(): void { this.planningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' }; this.applyPlanningFilters(); }
|
||||||
|
toggleInputs(): void { this.inputsExpanded = !this.inputsExpanded; }
|
||||||
|
toggleFilters(): void { this.filtersExpanded = !this.filtersExpanded; }
|
||||||
|
toggleUnassigned(): void { this.unassignedExpanded = !this.unassignedExpanded; }
|
||||||
|
|
||||||
private async loadEmployees(): Promise<void> {
|
private async loadEmployees(): Promise<void> {
|
||||||
this.employeesLoading = true;
|
this.employeesLoading = true;
|
||||||
try {
|
try {
|
||||||
this.activeEmployees = (await this.employeesService.active(this.projectCountry)).length;
|
this.activeEmployees = (await this.employeesService.active(this.projectId, this.projectCountry)).length;
|
||||||
} catch {
|
} catch {
|
||||||
this.activeEmployees = 0;
|
this.activeEmployees = 0;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -122,7 +139,7 @@ export class HomeComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.pdvFile = file;
|
this.pdvFile = file;
|
||||||
if (this.usuariosFile) this.planningInput.setFiles(file, this.usuariosFile);
|
if (this.usuariosFile) this.planningInput.setFiles(this.projectId, file, this.usuariosFile);
|
||||||
this.uploadError = null;
|
this.uploadError = null;
|
||||||
this.pdvPreviewLoading = true;
|
this.pdvPreviewLoading = true;
|
||||||
this.pdvPreview = null;
|
this.pdvPreview = null;
|
||||||
@@ -163,7 +180,7 @@ export class HomeComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.usuariosFile = file;
|
this.usuariosFile = file;
|
||||||
if (this.pdvFile) this.planningInput.setFiles(this.pdvFile, file);
|
if (this.pdvFile) this.planningInput.setFiles(this.projectId, this.pdvFile, file);
|
||||||
this.uploadError = null;
|
this.uploadError = null;
|
||||||
this.usuariosPreviewLoading = true;
|
this.usuariosPreviewLoading = true;
|
||||||
this.usuariosPreview = null;
|
this.usuariosPreview = null;
|
||||||
@@ -242,14 +259,14 @@ export class HomeComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
let employeesFile: File;
|
let employeesFile: File;
|
||||||
try {
|
try {
|
||||||
employeesFile = await this.employeesService.activeEmployeesFile(this.projectCountry);
|
employeesFile = await this.employeesService.activeEmployeesFile(this.projectId, this.projectCountry);
|
||||||
} catch {
|
} catch {
|
||||||
this.uploadError = 'No se pudieron obtener los empleados activos.';
|
this.uploadError = 'No se pudieron obtener los empleados activos.';
|
||||||
this.routeState.setLoading(false);
|
this.routeState.setLoading(false);
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.planningInput.setFiles(pdvForOptimizer, employeesFile);
|
this.planningInput.setFiles(this.projectId, pdvForOptimizer, employeesFile);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name);
|
formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name);
|
||||||
@@ -268,9 +285,27 @@ export class HomeComponent implements OnInit {
|
|||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const arr = res as unknown as unknown[];
|
const payload = Array.isArray(res)
|
||||||
const assigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'assigned' in r) as unknown as { assigned: unknown } | undefined;
|
? res
|
||||||
const noAssigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'noAssigned' in r) as unknown as { noAssigned: unknown } | undefined;
|
: res && typeof res === 'object'
|
||||||
|
? [res]
|
||||||
|
: null;
|
||||||
|
if (!payload) {
|
||||||
|
this.uploadError = 'El optimizador devolvió una respuesta vacía. Verifica que el workflow de n8n esté publicado.';
|
||||||
|
this.routeState.setLoading(false);
|
||||||
|
this.isLoading = false;
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const assigned = payload.find(r => r && typeof r === 'object' && 'assigned' in r) as { assigned: unknown } | undefined;
|
||||||
|
const noAssigned = payload.find(r => r && typeof r === 'object' && 'noAssigned' in r) as { noAssigned: unknown } | undefined;
|
||||||
|
if (!assigned && !noAssigned) {
|
||||||
|
this.uploadError = 'El optimizador devolvió un formato de respuesta no válido. Verifica el workflow de n8n.';
|
||||||
|
this.routeState.setLoading(false);
|
||||||
|
this.isLoading = false;
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (assigned) {
|
if (assigned) {
|
||||||
this.routeState.setAssignedRoutes(assigned.assigned as never);
|
this.routeState.setAssignedRoutes(assigned.assigned as never);
|
||||||
}
|
}
|
||||||
@@ -309,7 +344,7 @@ export class HomeComponent implements OnInit {
|
|||||||
|
|
||||||
clearFiles() {
|
clearFiles() {
|
||||||
this.calcSub?.unsubscribe();
|
this.calcSub?.unsubscribe();
|
||||||
this.planningInput.clear();
|
this.planningInput.clear(this.projectId);
|
||||||
this.routeState.clearAll();
|
this.routeState.clearAll();
|
||||||
this.pdvFile = null;
|
this.pdvFile = null;
|
||||||
this.usuariosFile = null;
|
this.usuariosFile = null;
|
||||||
|
|||||||
+2
-1
@@ -20,6 +20,7 @@
|
|||||||
<td class="text-left">{{ row.latitud }}</td>
|
<td class="text-left">{{ row.latitud }}</td>
|
||||||
<td class="text-left">{{ row.longitud }}</td>
|
<td class="text-left">{{ row.longitud }}</td>
|
||||||
<td class="text-left">{{ row.motivo }}</td>
|
<td class="text-left">{{ row.motivo }}</td>
|
||||||
|
<td class="text-left">{{ row.recomendacion }}</td>
|
||||||
<td class="assignment-actions">
|
<td class="assignment-actions">
|
||||||
<select [(ngModel)]="assignmentUser[row.pdv_id]" class="inline-select">
|
<select [(ngModel)]="assignmentUser[row.pdv_id]" class="inline-select">
|
||||||
<option value="">Usuario</option>
|
<option value="">Usuario</option>
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template pTemplate="emptymessage">
|
<ng-template pTemplate="emptymessage">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="text-center">
|
<td colspan="8" class="text-center">
|
||||||
No hay registros asignados
|
No hay registros asignados
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
+13
-3
@@ -6,6 +6,8 @@ import { RouteNoAssignedResponse } from '../../../core/models/route.model';
|
|||||||
import { RouteState, UnassignedAssignment } from '../../../core/route-state/route';
|
import { RouteState, UnassignedAssignment } from '../../../core/route-state/route';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { OrsService } from '../../../core/services/ors.service';
|
import { OrsService } from '../../../core/services/ors.service';
|
||||||
|
import { PlanningFilterService } from '../../../core/services/planning-filter.service';
|
||||||
|
import { recommendationForUnassigned } from '../../../shared/utils/unassigned-recommendation.utils';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -17,6 +19,7 @@ import { OrsService } from '../../../core/services/ors.service';
|
|||||||
export class NoAssignedRouteComponent implements OnDestroy {
|
export class NoAssignedRouteComponent implements OnDestroy {
|
||||||
|
|
||||||
noAssignedRoutes: RouteNoAssignedResponse[] = [];
|
noAssignedRoutes: RouteNoAssignedResponse[] = [];
|
||||||
|
private allNoAssignedRoutes: RouteNoAssignedResponse[] = [];
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
cols: { field: string; header: string }[] = [];
|
cols: { field: string; header: string }[] = [];
|
||||||
private subs: Subscription[] = [];
|
private subs: Subscription[] = [];
|
||||||
@@ -28,7 +31,8 @@ export class NoAssignedRouteComponent implements OnDestroy {
|
|||||||
constructor(
|
constructor(
|
||||||
private routesState: RouteState,
|
private routesState: RouteState,
|
||||||
private cdr: ChangeDetectorRef,
|
private cdr: ChangeDetectorRef,
|
||||||
private orsService: OrsService
|
private orsService: OrsService,
|
||||||
|
private filters: PlanningFilterService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@@ -40,17 +44,23 @@ export class NoAssignedRouteComponent implements OnDestroy {
|
|||||||
);
|
);
|
||||||
this.subs.push(
|
this.subs.push(
|
||||||
this.routesState.noAssignedRoutes$.subscribe(data => {
|
this.routesState.noAssignedRoutes$.subscribe(data => {
|
||||||
this.noAssignedRoutes = [...data];
|
this.allNoAssignedRoutes = data.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
|
||||||
|
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
this.subs.push(this.filters.filters$.subscribe(() => {
|
||||||
|
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}));
|
||||||
this.cols = [
|
this.cols = [
|
||||||
{ field: 'pdv_id', header: 'PDV ID' },
|
{ field: 'pdv_id', header: 'PDV ID' },
|
||||||
{ field: 'pdv_nombre', header: 'Nombre' },
|
{ field: 'pdv_nombre', header: 'Nombre' },
|
||||||
{ field: 'minimo_horas_semana', header: 'Min. Horas/Sem' },
|
{ field: 'minimo_horas_semana', header: 'Min. Horas/Sem' },
|
||||||
{ field: 'latitud', header: 'Latitud' },
|
{ field: 'latitud', header: 'Latitud' },
|
||||||
{ field: 'longitud', header: 'Longitud' },
|
{ field: 'longitud', header: 'Longitud' },
|
||||||
{ field: 'motivo', header: 'Motivo' }
|
{ field: 'motivo', header: 'Motivo' },
|
||||||
|
{ field: 'recomendacion', header: 'Recomendación' }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
border-top: 3px solid var(--glm-azul);
|
border-top: 3px solid var(--glm-azul);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-toggle,.subsection-toggle { display:flex; width:100%; align-items:center; justify-content:space-between; gap:12px; border:0; background:transparent; color:var(--glm-azul); cursor:pointer; font:inherit; font-weight:700; text-align:left; text-transform:uppercase; }
|
||||||
|
.section-toggle { padding:10px 0 14px; font-size:1rem; }
|
||||||
|
.subsection-toggle { padding:0 0 8px; font-size:.82rem; }
|
||||||
|
.section-toggle span,.subsection-toggle span { display:inline-flex; align-items:center; gap:7px; }
|
||||||
|
.table-error { margin:8px 0; color:#c9252d; font-size:.82rem; font-weight:700; }
|
||||||
|
|
||||||
.kpis-heading {
|
.kpis-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -174,6 +180,14 @@
|
|||||||
.user-load-table tbody tr:nth-child(even) {
|
.user-load-table tbody tr:nth-child(even) {
|
||||||
background: var(--glm-gris-claro);
|
background: var(--glm-gris-claro);
|
||||||
}
|
}
|
||||||
|
.load-row { cursor:pointer; }
|
||||||
|
.load-row:hover { background:var(--glm-verde-bg)!important; }
|
||||||
|
.visit-detail-row>td { padding:10px 14px; background:#f8fafb; }
|
||||||
|
.visits-table { width:100%; border-collapse:collapse; font-size:.76rem; }
|
||||||
|
.visits-table th { background:#5b7080; }
|
||||||
|
.visits-table select { max-width:150px; padding:5px; border:1px solid var(--glm-gris-medio); border-radius:4px; }
|
||||||
|
.icon-button { margin:0 2px; padding:5px 7px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; }
|
||||||
|
.user-load-pagination { display:flex; align-items:center; justify-content:flex-end; gap:10px; margin-top:10px; color:var(--glm-acero); font-size:.78rem; }.user-load-pagination button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:6px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.user-load-pagination button:hover:not(:disabled) { background:var(--glm-verde-bg); border-color:var(--glm-verde); }.user-load-pagination button:disabled { cursor:not-allowed; opacity:.55; }
|
||||||
|
|
||||||
.utilization-cell {
|
.utilization-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,82 +1,267 @@
|
|||||||
<div class="kpis-panel">
|
<div class="kpis-panel">
|
||||||
<div class="kpis-heading">
|
<button
|
||||||
<div>
|
type="button"
|
||||||
<h2 class="section-title">Indicadores de planificación</h2>
|
class="section-toggle"
|
||||||
<p class="kpis-subtitle">Resumen total de la planificación actual</p>
|
(click)="toggleMetrics()"
|
||||||
|
[attr.aria-expanded]="metricsExpanded"
|
||||||
|
>
|
||||||
|
<span><i class="pi pi-chart-bar"></i> Indicadores de planificación</span
|
||||||
|
><i
|
||||||
|
class="pi"
|
||||||
|
[class.pi-chevron-down]="metricsExpanded"
|
||||||
|
[class.pi-chevron-right]="!metricsExpanded"
|
||||||
|
></i>
|
||||||
|
</button>
|
||||||
|
<div *ngIf="metricsExpanded">
|
||||||
|
<div class="kpis-heading">
|
||||||
|
<p class="kpis-subtitle">Resumen de la selección actual</p>
|
||||||
|
<div class="kpis-actions">
|
||||||
|
<span class="kpis-status" *ngIf="hasData"
|
||||||
|
><i class="pi pi-check-circle"></i> Datos disponibles</span
|
||||||
|
><button
|
||||||
|
type="button"
|
||||||
|
class="export-button"
|
||||||
|
*ngIf="hasData"
|
||||||
|
(click)="downloadRoutes()"
|
||||||
|
[disabled]="pdfLoading"
|
||||||
|
>
|
||||||
|
<i class="pi" [class.pi-spin]="pdfLoading" [class.pi-file-pdf]="!pdfLoading" [class.pi-spinner]="pdfLoading"></i> {{ pdfLoading ? "Generando PDF..." : "Descargar rutas" }}</button
|
||||||
|
><button
|
||||||
|
type="button"
|
||||||
|
class="export-button"
|
||||||
|
*ngIf="hasData"
|
||||||
|
(click)="reoptimize()"
|
||||||
|
[disabled]="!canReoptimize"
|
||||||
|
>
|
||||||
|
<i class="pi pi-sync"></i>
|
||||||
|
{{ reoptLoading ? "Reoptimizando..." : "Reoptimizar" }}</button
|
||||||
|
><button
|
||||||
|
type="button"
|
||||||
|
class="export-button"
|
||||||
|
*ngIf="hasLocalChanges"
|
||||||
|
(click)="restoreOriginal()"
|
||||||
|
>
|
||||||
|
<i class="pi pi-undo"></i> Deshacer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="kpis-actions">
|
<div class="table-error" *ngIf="reoptError || editError || pdfError">
|
||||||
<span class="kpis-status" *ngIf="hasData"><i class="pi pi-check-circle"></i> Datos disponibles</span>
|
{{ reoptError || editError || pdfError }}
|
||||||
<button type="button" class="export-button" *ngIf="hasData" (click)="exportPlanning()"><i class="pi pi-download"></i> Descargar XLSX</button>
|
</div>
|
||||||
|
<div class="kpi-grid">
|
||||||
|
<article class="kpi-card kpi-primary">
|
||||||
|
<div class="kpi-icon"><i class="pi pi-map-marker"></i></div>
|
||||||
|
<div>
|
||||||
|
<span class="kpi-label">PDVs totales</span
|
||||||
|
><strong>{{ summary.totalPdvs }}</strong>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="kpi-card kpi-success">
|
||||||
|
<div class="kpi-icon"><i class="pi pi-check"></i></div>
|
||||||
|
<div>
|
||||||
|
<span class="kpi-label">PDVs asignados</span
|
||||||
|
><strong>{{ summary.assignedPdvs }}</strong
|
||||||
|
><small>{{ summary.coverage | number: "1.1-1" }}% cobertura</small>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="kpi-card kpi-warning">
|
||||||
|
<div class="kpi-icon"><i class="pi pi-exclamation-triangle"></i></div>
|
||||||
|
<div>
|
||||||
|
<span class="kpi-label">Sin asignar</span
|
||||||
|
><strong>{{ summary.unassignedPdvs }}</strong>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="kpi-card kpi-blue">
|
||||||
|
<div class="kpi-icon"><i class="pi pi-users"></i></div>
|
||||||
|
<div>
|
||||||
|
<span class="kpi-label">Usuarios</span
|
||||||
|
><strong>{{ summary.users }}</strong
|
||||||
|
><small>{{ summary.days }} días</small>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="kpi-card kpi-orange">
|
||||||
|
<div class="kpi-icon"><i class="pi pi-compass"></i></div>
|
||||||
|
<div>
|
||||||
|
<span class="kpi-label">Distancia</span
|
||||||
|
><strong
|
||||||
|
>{{ summary.totalDistance | number: "1.0-1" }} <em>km</em></strong
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="kpi-card kpi-success">
|
||||||
|
<div class="kpi-icon"><i class="pi pi-briefcase"></i></div>
|
||||||
|
<div>
|
||||||
|
<span class="kpi-label">Trabajo</span
|
||||||
|
><strong
|
||||||
|
>{{ summary.totalWorkHours | number: "1.0-1" }} <em>h</em></strong
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="kpi-grid">
|
|
||||||
<article class="kpi-card kpi-primary">
|
|
||||||
<div class="kpi-icon"><i class="pi pi-map-marker"></i></div>
|
|
||||||
<div><span class="kpi-label">PDVs totales</span><strong>{{ summary.totalPdvs }}</strong><small>en la planificación</small></div>
|
|
||||||
</article>
|
|
||||||
<article class="kpi-card kpi-success">
|
|
||||||
<div class="kpi-icon"><i class="pi pi-check"></i></div>
|
|
||||||
<div><span class="kpi-label">PDVs asignados</span><strong>{{ summary.assignedPdvs }}</strong><small>{{ summary.coverage | number:'1.1-1' }}% de cobertura</small></div>
|
|
||||||
</article>
|
|
||||||
<article class="kpi-card kpi-warning">
|
|
||||||
<div class="kpi-icon"><i class="pi pi-exclamation-triangle"></i></div>
|
|
||||||
<div><span class="kpi-label">Sin asignar</span><strong>{{ summary.unassignedPdvs }}</strong><small>requieren revisión</small></div>
|
|
||||||
</article>
|
|
||||||
<article class="kpi-card kpi-blue">
|
|
||||||
<div class="kpi-icon"><i class="pi pi-users"></i></div>
|
|
||||||
<div><span class="kpi-label">Usuarios activos</span><strong>{{ summary.users }}</strong><small>{{ summary.days }} días planificados</small></div>
|
|
||||||
</article>
|
|
||||||
<article class="kpi-card kpi-orange">
|
|
||||||
<div class="kpi-icon"><i class="pi pi-compass"></i></div>
|
|
||||||
<div><span class="kpi-label">Distancia total</span><strong>{{ summary.totalDistance | number:'1.0-1' }} <em>km</em></strong><small>{{ summary.averageDistance | number:'1.0-1' }} km promedio por ruta</small></div>
|
|
||||||
</article>
|
|
||||||
<ng-container *ngIf="hasData">
|
|
||||||
<article class="kpi-card kpi-success">
|
|
||||||
<div class="kpi-icon"><i class="pi pi-briefcase"></i></div>
|
|
||||||
<div><span class="kpi-label">Carga de trabajo</span><strong>{{ summary.totalWorkHours | number:'1.0-1' }} <em>h</em></strong><small>horas de trabajo planificadas</small></div>
|
|
||||||
</article>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="user-load-card" *ngIf="userLoads.length">
|
<div class="user-load-card" *ngIf="userLoads.length">
|
||||||
<h3><i class="pi pi-users"></i> Balance de carga por usuario</h3>
|
<button type="button" class="subsection-toggle" (click)="toggleBalance()">
|
||||||
<div class="user-load-table-wrap">
|
<span><i class="pi pi-users"></i> Balance de carga por usuario</span
|
||||||
|
><i
|
||||||
|
class="pi"
|
||||||
|
[class.pi-chevron-down]="balanceExpanded"
|
||||||
|
[class.pi-chevron-right]="!balanceExpanded"
|
||||||
|
></i>
|
||||||
|
</button>
|
||||||
|
<div class="user-load-table-wrap" *ngIf="balanceExpanded">
|
||||||
<table class="user-load-table">
|
<table class="user-load-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Usuario</th><th>Rutas</th><th>Distancia</th><th>Trabajo</th><th>Utilización</th></tr>
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Usuario</th>
|
||||||
|
<th>PDVs</th>
|
||||||
|
<th>Visitas</th>
|
||||||
|
<th>Distancia</th>
|
||||||
|
<th>Trabajo</th>
|
||||||
|
<th>Carga</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let load of userLoads" [class.load-over-capacity]="load.status === 'over-capacity'">
|
<ng-container *ngFor="let load of paginatedUserLoads"
|
||||||
<td>{{ load.name }}</td>
|
><tr
|
||||||
<td>{{ load.routes }}</td>
|
class="load-row"
|
||||||
<td>{{ load.distance | number:'1.0-1' }} km</td>
|
[class.load-over-capacity]="load.status === 'over-capacity'"
|
||||||
<td>{{ load.workHours | number:'1.0-1' }} h</td>
|
(click)="toggleUser(load.id)"
|
||||||
<td>
|
>
|
||||||
<div class="utilization-cell" [ngClass]="utilizationClass(load)">
|
<td>
|
||||||
<span>{{ load.utilization | number:'1.0-1' }}%</span>
|
<i
|
||||||
<span class="capacity-warning" *ngIf="load.status === 'over-capacity'" [title]="load.alertMessage" aria-label="Alerta de capacidad">⚠️</span>
|
class="pi"
|
||||||
<span class="utilization-bar" [title]="load.alertMessage"><span [style.width.%]="barWidth(load.utilization)"></span></span>
|
[class.pi-chevron-down]="isExpanded(load.id)"
|
||||||
</div>
|
[class.pi-chevron-right]="!isExpanded(load.id)"
|
||||||
</td>
|
></i>
|
||||||
</tr>
|
</td>
|
||||||
|
<td>{{ load.name }}</td>
|
||||||
|
<td>{{ load.uniquePdvs }}</td>
|
||||||
|
<td>{{ load.visits }}</td>
|
||||||
|
<td>{{ load.distance | number: "1.0-1" }} km</td>
|
||||||
|
<td>{{ load.workHours | number: "1.0-1" }} h</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
class="utilization-cell"
|
||||||
|
[ngClass]="utilizationClass(load)"
|
||||||
|
>
|
||||||
|
<span>{{ load.utilization | number: "1.0-1" }}%</span
|
||||||
|
><span class="utilization-bar" [title]="load.alertMessage"
|
||||||
|
><span [style.width.%]="barWidth(load.utilization)"></span
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-button"
|
||||||
|
(click)="$event.stopPropagation(); showUserMap(load.id)"
|
||||||
|
title="Ver usuario en mapa"
|
||||||
|
>
|
||||||
|
<i class="pi pi-map"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="visit-detail-row" *ngIf="isExpanded(load.id)">
|
||||||
|
<td colspan="8">
|
||||||
|
<table class="visits-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>PDV</th>
|
||||||
|
<th>Día</th>
|
||||||
|
<th>Orden</th>
|
||||||
|
<th>Trabajo</th>
|
||||||
|
<th>Distancia</th>
|
||||||
|
<th>Duración</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let route of load.routes">
|
||||||
|
<td>{{ route.json.nombre_pdv }}</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
*ngIf="editingRoute === route; else dayText"
|
||||||
|
[(ngModel)]="editDay"
|
||||||
|
>
|
||||||
|
<option *ngFor="let day of dayOptions" [value]="day">
|
||||||
|
{{ day }}
|
||||||
|
</option></select
|
||||||
|
><ng-template #dayText>{{
|
||||||
|
route.json.dia
|
||||||
|
}}</ng-template>
|
||||||
|
</td>
|
||||||
|
<td>{{ route.json.visit_order || "-" }}</td>
|
||||||
|
<td>{{ route.json.horas_trabajo }} h</td>
|
||||||
|
<td>{{ route.json.distancia | number: "1.0-2" }} km</td>
|
||||||
|
<td>
|
||||||
|
{{
|
||||||
|
route.json.horas_desplazamiento * 60 | number: "1.0-0"
|
||||||
|
}}
|
||||||
|
min
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ng-container
|
||||||
|
*ngIf="editingRoute === route; else normalActions"
|
||||||
|
><select [(ngModel)]="editUserId">
|
||||||
|
<option
|
||||||
|
*ngFor="let user of userOptions"
|
||||||
|
[value]="user.id"
|
||||||
|
>
|
||||||
|
{{ user.name }}
|
||||||
|
</option></select
|
||||||
|
><button class="icon-button" (click)="saveEdit()">
|
||||||
|
<i class="pi pi-check"></i></button
|
||||||
|
><button class="icon-button" (click)="cancelEdit()">
|
||||||
|
<i class="pi pi-times"></i></button></ng-container
|
||||||
|
><ng-template #normalActions
|
||||||
|
><button
|
||||||
|
class="icon-button"
|
||||||
|
(click)="startEdit(route)"
|
||||||
|
>
|
||||||
|
<i class="pi pi-pencil"></i></button
|
||||||
|
><button
|
||||||
|
class="icon-button"
|
||||||
|
(click)="showRoute(route)"
|
||||||
|
>
|
||||||
|
<i class="pi pi-map-marker"></i></button
|
||||||
|
></ng-template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td></tr
|
||||||
|
></ng-container>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="user-load-pagination" *ngIf="userLoadPages > 1">
|
||||||
|
<button type="button" (click)="previousUserLoadPage()" [disabled]="userLoadPage === 1">Anterior</button>
|
||||||
|
<span>Página {{ userLoadPage }} de {{ userLoadPages }}</span>
|
||||||
|
<button type="button" (click)="nextUserLoadPage()" [disabled]="userLoadPage === userLoadPages">Siguiente</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="reasons-card" *ngIf="reasons.length">
|
<div class="reasons-card" *ngIf="reasons.length">
|
||||||
<h3><i class="pi pi-list"></i> Motivos de PDVs sin asignar</h3>
|
<button type="button" class="subsection-toggle" (click)="toggleReasons()">
|
||||||
<div class="reason-list">
|
<span><i class="pi pi-list"></i> Motivos de PDVs sin asignar</span
|
||||||
|
><i
|
||||||
|
class="pi"
|
||||||
|
[class.pi-chevron-down]="reasonsExpanded"
|
||||||
|
[class.pi-chevron-right]="!reasonsExpanded"
|
||||||
|
></i>
|
||||||
|
</button>
|
||||||
|
<div class="reason-list" *ngIf="reasonsExpanded">
|
||||||
<div class="reason-row" *ngFor="let item of reasons">
|
<div class="reason-row" *ngFor="let item of reasons">
|
||||||
<span>{{ item.reason }}</span>
|
<span>{{ item.reason }}</span
|
||||||
<strong>{{ item.count }}</strong>
|
><strong>{{ item.count }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kpis-empty" *ngIf="!hasData">
|
<div class="kpis-empty" *ngIf="!hasData">
|
||||||
<i class="pi pi-chart-bar"></i>
|
<i class="pi pi-chart-bar"></i
|
||||||
<span>Carga una planificación para visualizar sus indicadores.</span>
|
><span>Carga una planificación para visualizar sus indicadores.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,176 +1,166 @@
|
|||||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { RouteState } from '../../../core/route-state/route';
|
import { RouteState } from '../../../core/route-state/route';
|
||||||
import { PlanningExportService } from '../../../shared/services/planning-export.service';
|
import { RoutePdfReportService } from '../../../shared/services/route-pdf-report.service';
|
||||||
import { summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
|
import { summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils';
|
||||||
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
import { RouteAssignedResponse } from '../../../core/models/route.model';
|
||||||
|
import { PlanningFilterService } from '../../../core/services/planning-filter.service';
|
||||||
|
import { RutasService } from '../../../core/services/router.service';
|
||||||
|
import { PlanningInputService } from '../../../core/services/planning-input.service';
|
||||||
|
|
||||||
interface KpiSummary {
|
interface KpiSummary { totalPdvs: number; assignedPdvs: number; unassignedPdvs: number; coverage: number; users: number; days: number; totalDistance: number; averageDistance: number; totalWorkHours: number; }
|
||||||
totalPdvs: number;
|
interface ReasonSummary { reason: string; count: number; }
|
||||||
assignedPdvs: number;
|
|
||||||
unassignedPdvs: number;
|
|
||||||
coverage: number;
|
|
||||||
users: number;
|
|
||||||
days: number;
|
|
||||||
totalDistance: number;
|
|
||||||
averageDistance: number;
|
|
||||||
totalWorkHours: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReasonSummary {
|
|
||||||
reason: string;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserLoad {
|
interface UserLoad {
|
||||||
id: string;
|
id: string; name: string; uniquePdvs: number; visits: number; routes: RouteAssignedResponse[]; distance: number; workHours: number;
|
||||||
name: string;
|
capacityHours: number; utilization: number; status: 'ok' | 'warning' | 'over-capacity'; alertMessage: string;
|
||||||
routes: number;
|
|
||||||
distance: number;
|
|
||||||
workHours: number;
|
|
||||||
capacityHours: number;
|
|
||||||
utilization: number;
|
|
||||||
status: 'ok' | 'warning' | 'over-capacity';
|
|
||||||
alertMessage: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({ standalone: true, selector: 'app-planning-kpis', imports: [CommonModule, FormsModule], templateUrl: './planning-kpis.component.html', styleUrl: './planning-kpis.component.css' })
|
||||||
standalone: true,
|
|
||||||
selector: 'app-planning-kpis',
|
|
||||||
imports: [CommonModule],
|
|
||||||
templateUrl: './planning-kpis.component.html',
|
|
||||||
styleUrl: './planning-kpis.component.css'
|
|
||||||
})
|
|
||||||
export class PlanningKpisComponent implements OnInit, OnDestroy {
|
export class PlanningKpisComponent implements OnInit, OnDestroy {
|
||||||
summary: KpiSummary = this.emptySummary();
|
summary: KpiSummary = this.emptySummary();
|
||||||
reasons: ReasonSummary[] = [];
|
reasons: ReasonSummary[] = [];
|
||||||
userLoads: UserLoad[] = [];
|
userLoads: UserLoad[] = [];
|
||||||
hasData = false;
|
hasData = false;
|
||||||
|
metricsExpanded = true;
|
||||||
|
balanceExpanded = true;
|
||||||
|
reasonsExpanded = false;
|
||||||
|
expandedUsers = new Set<string>();
|
||||||
|
editingRoute: RouteAssignedResponse | null = null;
|
||||||
|
editUserId = '';
|
||||||
|
editDay = '';
|
||||||
|
editError = '';
|
||||||
|
reoptLoading = false;
|
||||||
|
reoptError = '';
|
||||||
|
hasLocalChanges = false;
|
||||||
|
pdfLoading = false;
|
||||||
|
pdfError = '';
|
||||||
|
readonly userLoadPageSize = 14;
|
||||||
|
userLoadPage = 1;
|
||||||
private readonly subs: Subscription[] = [];
|
private readonly subs: Subscription[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private routeState: RouteState,
|
private routeState: RouteState,
|
||||||
private exportService: PlanningExportService
|
private filters: PlanningFilterService,
|
||||||
|
private pdfReport: RoutePdfReportService,
|
||||||
|
private rutasService: RutasService,
|
||||||
|
private planningInput: PlanningInputService,
|
||||||
|
private router: Router,
|
||||||
|
private activatedRoute: ActivatedRoute,
|
||||||
|
private cdr: ChangeDetectorRef
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.subs.push(
|
this.subs.push(
|
||||||
this.routeState.assignedRoutes$.subscribe(() => this.refresh()),
|
this.routeState.assignedRoutes$.subscribe(() => this.refresh()),
|
||||||
this.routeState.noAssignedRoutes$.subscribe(() => this.refresh())
|
this.routeState.noAssignedRoutes$.subscribe(() => this.refresh()),
|
||||||
|
this.filters.filters$.subscribe(() => this.refresh())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
ngOnDestroy(): void { this.subs.forEach(subscription => subscription.unsubscribe()); }
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
barWidth(value: number): number { return Math.min(Math.max(value, 0), 100); }
|
||||||
this.subs.forEach(subscription => subscription.unsubscribe());
|
utilizationClass(load: UserLoad): string { return load.status; }
|
||||||
|
async downloadRoutes(): Promise<void> {
|
||||||
|
if (this.pdfLoading || !this.hasData) return;
|
||||||
|
this.pdfLoading = true;
|
||||||
|
this.pdfError = '';
|
||||||
|
try {
|
||||||
|
await this.pdfReport.download(this.projectId);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.pdfError = error?.message ?? 'No se pudo generar el PDF de rutas.';
|
||||||
|
} finally {
|
||||||
|
this.pdfLoading = false;
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
toggleUser(id: string): void { this.expandedUsers.has(id) ? this.expandedUsers.delete(id) : this.expandedUsers.add(id); }
|
||||||
|
isExpanded(id: string): boolean { return this.expandedUsers.has(id); }
|
||||||
|
toggleMetrics(): void { this.metricsExpanded = !this.metricsExpanded; }
|
||||||
|
toggleBalance(): void { this.balanceExpanded = !this.balanceExpanded; }
|
||||||
|
toggleReasons(): void { this.reasonsExpanded = !this.reasonsExpanded; }
|
||||||
|
previousUserLoadPage(): void { this.userLoadPage = Math.max(1, this.userLoadPage - 1); }
|
||||||
|
nextUserLoadPage(): void { this.userLoadPage = Math.min(this.userLoadPages, this.userLoadPage + 1); }
|
||||||
|
|
||||||
barWidth(value: number): number {
|
get userOptions(): { id: string; name: string }[] { return this.userLoads.map(load => ({ id: load.id, name: load.name })); }
|
||||||
return Math.min(Math.max(value, 0), 100);
|
get userLoadPages(): number { return Math.max(1, Math.ceil(this.userLoads.length / this.userLoadPageSize)); }
|
||||||
|
get paginatedUserLoads(): UserLoad[] {
|
||||||
|
const start = (this.userLoadPage - 1) * this.userLoadPageSize;
|
||||||
|
return this.userLoads.slice(start, start + this.userLoadPageSize);
|
||||||
}
|
}
|
||||||
|
get dayOptions(): string[] { return this.routeState.getDiasUnicos(); }
|
||||||
|
get canReoptimize(): boolean { return this.hasData && !!this.planningInput.getFormData(this.projectId) && !this.reoptLoading; }
|
||||||
|
|
||||||
utilizationClass(load: UserLoad): string {
|
showRoute(route: RouteAssignedResponse): void {
|
||||||
return load.status;
|
this.routeState.sendRoute(route);
|
||||||
|
void this.router.navigate(['/proyectos', this.projectId, 'ruta'], { queryParams: { pdv_id: route.json.pdv_id, usuario_id: route.json.usuario_id, dia: route.json.dia } });
|
||||||
}
|
}
|
||||||
|
showUserMap(userId: string): void { void this.router.navigate(['/proyectos', this.projectId, 'mapa'], { queryParams: { usuario_id: userId, dia: 'Todos' } }); }
|
||||||
exportPlanning(): void {
|
startEdit(route: RouteAssignedResponse): void { this.editingRoute = route; this.editUserId = String(route.json.usuario_id); this.editDay = route.json.dia; this.editError = ''; }
|
||||||
this.exportService.export();
|
cancelEdit(): void { this.editingRoute = null; this.editError = ''; }
|
||||||
|
saveEdit(): void {
|
||||||
|
if (!this.editingRoute || !this.editUserId || !this.editDay) return;
|
||||||
|
const capacity = this.routeState.canAssignToDay(this.editUserId, this.editDay, Number(this.editingRoute.json.horas_trabajo || this.editingRoute.json.hora_minima_semanal_pdv || 0), this.editingRoute);
|
||||||
|
if (!capacity.allowed) { this.editError = `La asignación excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`; return; }
|
||||||
|
this.routeState.updateAssignedRoute(this.editingRoute, this.editUserId, this.editDay);
|
||||||
|
this.hasLocalChanges = true; this.editingRoute = null;
|
||||||
}
|
}
|
||||||
|
reoptimize(): void {
|
||||||
|
const data = this.planningInput.getFormData(this.projectId);
|
||||||
|
if (!data || !this.canReoptimize) return;
|
||||||
|
this.reoptLoading = true; this.reoptError = ''; this.routeState.setLoading(true);
|
||||||
|
this.rutasService.reoptimizarRutas(data).subscribe({
|
||||||
|
next: response => {
|
||||||
|
const items = response as unknown as unknown[];
|
||||||
|
const assigned = items.find(item => item && typeof item === 'object' && 'assigned' in item) as { assigned?: unknown } | undefined;
|
||||||
|
const noAssigned = items.find(item => item && typeof item === 'object' && 'noAssigned' in item) as { noAssigned?: unknown } | undefined;
|
||||||
|
if (assigned?.assigned) this.routeState.setAssignedRoutes(assigned.assigned as never);
|
||||||
|
if (noAssigned?.noAssigned) this.routeState.setNoAssignedRoutes(noAssigned.noAssigned as never);
|
||||||
|
this.hasLocalChanges = false; this.reoptLoading = false; this.routeState.setLoading(false); this.cdr.detectChanges();
|
||||||
|
},
|
||||||
|
error: error => { this.reoptError = error?.error?.error || error?.message || 'No se pudo reoptimizar'; this.reoptLoading = false; this.routeState.setLoading(false); this.cdr.detectChanges(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
restoreOriginal(): void { this.routeState.restoreOriginalAssignedRoutes(); this.hasLocalChanges = false; this.editingRoute = null; }
|
||||||
|
|
||||||
|
private get projectId(): string { return this.activatedRoute.snapshot.paramMap.get('id') ?? ''; }
|
||||||
private refresh(): void {
|
private refresh(): void {
|
||||||
const assigned = this.routeState.getAssignedSnapshot();
|
const assigned = this.filters.filterAssigned(this.routeState.getAssignedSnapshot());
|
||||||
const unassigned = this.routeState.getNoAssignedSnapshot();
|
const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot());
|
||||||
const assignedPdvIds = new Set(assigned.map(route => String(route.json.pdv_id)));
|
const assignedPdvIds = new Set(assigned.map(route => String(route.json.pdv_id)));
|
||||||
// A PDV with at least one scheduled visit is assigned; repeated weekly visits and
|
const unassignedPdvIds = new Set(unassigned.map(route => String(route.pdv_id)).filter(id => !assignedPdvIds.has(id)));
|
||||||
// partial-assignment alerts must not inflate the planning coverage counters.
|
|
||||||
const unassignedPdvIds = new Set(unassigned
|
|
||||||
.map(route => String(route.pdv_id))
|
|
||||||
.filter(pdvId => !assignedPdvIds.has(pdvId)));
|
|
||||||
const totalPdvs = new Set([...assignedPdvIds, ...unassignedPdvIds]).size;
|
const totalPdvs = new Set([...assignedPdvIds, ...unassignedPdvIds]).size;
|
||||||
const dailyRoutes = summarizeDailyRoutes(assigned);
|
const dailyRoutes = summarizeDailyRoutes(assigned);
|
||||||
const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0);
|
const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0);
|
||||||
const totalWorkHours = dailyRoutes.reduce((sum, day) => sum + day.workHours, 0);
|
const totalWorkHours = dailyRoutes.reduce((sum, day) => sum + day.workHours, 0);
|
||||||
const users = new Set(assigned.map(route => String(route.json.usuario_id))).size;
|
|
||||||
const days = new Set(assigned.map(route => route.json.dia)).size;
|
|
||||||
const loads = new Map<string, UserLoad>();
|
const loads = new Map<string, UserLoad>();
|
||||||
|
|
||||||
for (const route of assigned) {
|
for (const route of assigned) {
|
||||||
const key = String(route.json.usuario_id);
|
const id = String(route.json.usuario_id);
|
||||||
const current = loads.get(key) ?? {
|
const load = loads.get(id) ?? { id, name: route.json.nombre_usuario, uniquePdvs: 0, visits: 0, routes: [], distance: 0, workHours: 0, capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario), utilization: 0, status: 'ok', alertMessage: '' };
|
||||||
id: key,
|
load.routes.push(route); load.visits++;
|
||||||
name: route.json.nombre_usuario,
|
loads.set(id, load);
|
||||||
routes: 0,
|
|
||||||
distance: 0,
|
|
||||||
workHours: 0,
|
|
||||||
capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario),
|
|
||||||
utilization: 0,
|
|
||||||
status: 'ok',
|
|
||||||
alertMessage: ''
|
|
||||||
};
|
|
||||||
current.routes++;
|
|
||||||
// Sequential distance and work are aggregated per user below.
|
|
||||||
loads.set(key, current);
|
|
||||||
}
|
}
|
||||||
|
this.userLoads = [...loads.values()].map(load => {
|
||||||
this.userLoads = Array.from(loads.values()).map(load => {
|
load.routes.sort((a, b) => a.json.dia.localeCompare(b.json.dia) || Number(a.json.visit_order ?? 0) - Number(b.json.visit_order ?? 0));
|
||||||
|
load.uniquePdvs = new Set(load.routes.map(route => String(route.json.pdv_id))).size;
|
||||||
const userDays = dailyRoutes.filter(day => day.userId === load.id);
|
const userDays = dailyRoutes.filter(day => day.userId === load.id);
|
||||||
load.distance = userDays.reduce((sum, day) => sum + day.distanceKm, 0);
|
load.distance = userDays.reduce((sum, day) => sum + day.distanceKm, 0);
|
||||||
load.workHours = userDays.reduce((sum, day) => sum + day.workHours, 0);
|
load.workHours = userDays.reduce((sum, day) => sum + day.workHours, 0);
|
||||||
load.utilization = load.capacityHours ? (load.workHours / load.capacityHours) * 100 : 0;
|
load.utilization = load.capacityHours ? load.workHours / load.capacityHours * 100 : 0;
|
||||||
load.status = load.utilization > 100 ? 'over-capacity' : load.utilization > 90 ? 'warning' : 'ok';
|
load.status = load.utilization > 100 ? 'over-capacity' : load.utilization > 90 ? 'warning' : 'ok';
|
||||||
load.alertMessage = load.status === 'over-capacity'
|
load.alertMessage = `${load.name}: ${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h.`;
|
||||||
? `Alerta: ${load.name} supera el 100% de su capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h). Reasigna PDVs o reduce horas.`
|
|
||||||
: load.status === 'warning'
|
|
||||||
? `${load.name} está sobre el 90% de su capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).`
|
|
||||||
: `${load.name} está dentro de capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).`;
|
|
||||||
return load;
|
return load;
|
||||||
}).sort((a, b) => b.routes - a.routes || b.workHours - a.workHours);
|
}).sort((a, b) => b.visits - a.visits || b.workHours - a.workHours);
|
||||||
this.summary = {
|
this.userLoadPage = Math.min(this.userLoadPage, this.userLoadPages);
|
||||||
totalPdvs,
|
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 };
|
||||||
assignedPdvs: assignedPdvIds.size,
|
|
||||||
unassignedPdvs: unassignedPdvIds.size,
|
|
||||||
coverage: totalPdvs ? (assignedPdvIds.size / totalPdvs) * 100 : 0,
|
|
||||||
users,
|
|
||||||
days,
|
|
||||||
totalDistance,
|
|
||||||
averageDistance: assigned.length ? totalDistance / assigned.length : 0,
|
|
||||||
totalWorkHours,
|
|
||||||
};
|
|
||||||
|
|
||||||
const reasonByPdv = new Map<string, string>();
|
const reasonByPdv = new Map<string, string>();
|
||||||
for (const route of unassigned) {
|
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 pdvId = String(route.pdv_id);
|
const counts = new Map<string, number>(); for (const reason of reasonByPdv.values()) counts.set(reason, (counts.get(reason) ?? 0) + 1);
|
||||||
if (assignedPdvIds.has(pdvId) || reasonByPdv.has(pdvId)) continue;
|
this.reasons = [...counts].map(([reason, count]) => ({ reason, count })).sort((a, b) => b.count - a.count);
|
||||||
const reason = route.motivo?.trim() || 'Sin motivo especificado';
|
|
||||||
reasonByPdv.set(pdvId, reason);
|
|
||||||
}
|
|
||||||
const reasonCounts = new Map<string, number>();
|
|
||||||
for (const reason of reasonByPdv.values()) {
|
|
||||||
reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
this.reasons = Array.from(reasonCounts.entries())
|
|
||||||
.map(([reason, count]) => ({ reason, count }))
|
|
||||||
.sort((a, b) => b.count - a.count);
|
|
||||||
this.hasData = totalPdvs > 0;
|
this.hasData = totalPdvs > 0;
|
||||||
}
|
}
|
||||||
|
private numberValue(value: number | string | null | undefined): number { const result = Number(value); return Number.isFinite(result) ? result : 0; }
|
||||||
private numberValue(value: number | string | null | undefined): number {
|
private emptySummary(): KpiSummary { return { totalPdvs: 0, assignedPdvs: 0, unassignedPdvs: 0, coverage: 0, users: 0, days: 0, totalDistance: 0, averageDistance: 0, totalWorkHours: 0 }; }
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -19,10 +19,15 @@
|
|||||||
<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>
|
||||||
|
<select class="filter-select" [(ngModel)]="selectedMetro" (ngModelChange)="onMetroChange()">
|
||||||
|
<option *ngFor="let metro of metros" [value]="metro">{{ metro }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<label class="filter-label">Usuario</label>
|
<label class="filter-label">Usuario</label>
|
||||||
<select class="filter-select" [(ngModel)]="selectedUsuarioId" (ngModelChange)="onUsuarioChange()">
|
<select class="filter-select" [(ngModel)]="selectedUsuarioId" (ngModelChange)="onUsuarioChange()">
|
||||||
<option value="Todos">Todos los usuarios</option>
|
<option value="Todos">Todos los usuarios</option>
|
||||||
<option *ngFor="let u of usuarios" [value]="u.id">{{ u.nombre }} ({{ u.id }})</option>
|
<option *ngFor="let u of metroUsuarios" [value]="u.id">{{ u.nombre }} ({{ u.id }})</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label class="filter-label">Día</label>
|
<label class="filter-label">Día</label>
|
||||||
|
|||||||
+95
-36
@@ -67,6 +67,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
dias: string[] = [];
|
dias: string[] = [];
|
||||||
selectedUsuarioId: string = 'Todos';
|
selectedUsuarioId: string = 'Todos';
|
||||||
selectedDia: string = 'Todos';
|
selectedDia: string = 'Todos';
|
||||||
|
selectedMetro = '';
|
||||||
expandedDailyRouteDay: string | null = null;
|
expandedDailyRouteDay: string | null = null;
|
||||||
selectedRouteDetail: RouteAssignedResponse | null = null;
|
selectedRouteDetail: RouteAssignedResponse | null = null;
|
||||||
|
|
||||||
@@ -95,13 +96,32 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
reassignUserId = '';
|
reassignUserId = '';
|
||||||
reassignDay = '';
|
reassignDay = '';
|
||||||
reassignError: string | null = null;
|
reassignError: string | null = null;
|
||||||
|
private renderVersion = 0;
|
||||||
|
|
||||||
|
get metros(): string[] {
|
||||||
|
const values = [
|
||||||
|
...this.allRoutes.map(route => route.json.metro),
|
||||||
|
...this.unassignedRoutes.map(route => route.metro)
|
||||||
|
].map(value => String(value ?? '').trim()).filter(Boolean);
|
||||||
|
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
get filteredUnassignedRoutes(): RouteNoAssignedResponse[] {
|
||||||
|
return this.unassignedRoutes.filter(route => String(route.metro ?? '').trim() === this.selectedMetro);
|
||||||
|
}
|
||||||
|
|
||||||
|
get metroUsuarios(): { id: string; nombre: string }[] {
|
||||||
|
const users = new Map<string, string>();
|
||||||
|
for (const route of this.allRoutes) {
|
||||||
|
if (String(route.json.metro ?? '').trim() === this.selectedMetro) {
|
||||||
|
users.set(String(route.json.usuario_id), route.json.nombre_usuario);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(users, ([id, nombre]) => ({ id, nombre }));
|
||||||
|
}
|
||||||
|
|
||||||
get activeTerritories(): string[] {
|
get activeTerritories(): string[] {
|
||||||
const values = [
|
return this.selectedMetro ? [this.selectedMetro] : [];
|
||||||
...this.allRoutes.map(route => route.json.metro || route.json.ciudad || route.json.estado || route.json.provincia || route.json.region || route.json.zona),
|
|
||||||
...this.unassignedRoutes.map(route => route.metro || route.ciudad || route.estado || route.provincia || route.region || route.zona)
|
|
||||||
].filter((value): value is string => !!value?.trim());
|
|
||||||
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary stats
|
// Summary stats
|
||||||
@@ -194,17 +214,13 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
this.routeState.useProject(this.activatedRoute.snapshot.paramMap.get('id') ?? '');
|
||||||
this.subs.push(
|
this.subs.push(
|
||||||
this.routeState.assignedRoutes$.subscribe(routes => {
|
this.routeState.assignedRoutes$.subscribe(routes => {
|
||||||
this.allRoutes = [...routes];
|
this.allRoutes = [...routes];
|
||||||
this.usuarios = this.routeState.getUsuariosUnicos();
|
this.usuarios = this.routeState.getUsuariosUnicos();
|
||||||
this.dias = this.routeState.getDiasUnicos();
|
this.dias = this.routeState.getDiasUnicos();
|
||||||
// Start each map load with the complete planning selected by default.
|
this.applyQueryFilters(this.activatedRoute.snapshot.queryParams);
|
||||||
const qp = this.activatedRoute.snapshot.queryParams;
|
|
||||||
if (qp['pdv_id']) {
|
|
||||||
this.selectedUsuarioId = 'Todos';
|
|
||||||
this.selectedDia = 'Todos';
|
|
||||||
}
|
|
||||||
// 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();
|
this.cdr.detectChanges();
|
||||||
@@ -223,9 +239,16 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
// React to queryParams changes
|
// React to queryParams changes
|
||||||
this.subs.push(
|
this.subs.push(
|
||||||
this.activatedRoute.queryParams.subscribe(qp => {
|
this.activatedRoute.queryParams.subscribe(qp => {
|
||||||
// Focus a specific route if pdv_id passed
|
this.applyQueryFilters(qp);
|
||||||
|
this.applyFilter();
|
||||||
|
if (this.map) this.renderFiltered();
|
||||||
|
// Focus a specific route if pdv_id passed.
|
||||||
if (qp['pdv_id'] && this.filteredRoutes.length) {
|
if (qp['pdv_id'] && this.filteredRoutes.length) {
|
||||||
const found = this.filteredRoutes.find(r => String(r.json.pdv_id) === String(qp['pdv_id']));
|
const found = this.filteredRoutes.find(r =>
|
||||||
|
String(r.json.pdv_id) === String(qp['pdv_id']) &&
|
||||||
|
(!qp['usuario_id'] || String(r.json.usuario_id) === String(qp['usuario_id'])) &&
|
||||||
|
(!qp['dia'] || qp['dia'] === 'Todos' || r.json.dia === qp['dia'])
|
||||||
|
);
|
||||||
if (found) {
|
if (found) {
|
||||||
this.selectedRouteDetail = found;
|
this.selectedRouteDetail = found;
|
||||||
this.highlightRoute(found);
|
this.highlightRoute(found);
|
||||||
@@ -257,35 +280,42 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
|
|
||||||
onUsuarioChange() {
|
onUsuarioChange() {
|
||||||
this.updateQueryParams();
|
this.updateQueryParams();
|
||||||
this.applyFilter();
|
|
||||||
this.renderFiltered();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onDiaChange() {
|
onDiaChange() {
|
||||||
this.updateQueryParams();
|
this.updateQueryParams();
|
||||||
this.applyFilter();
|
}
|
||||||
this.renderFiltered();
|
|
||||||
|
onMetroChange() {
|
||||||
|
this.expandedDailyRouteDay = null;
|
||||||
|
if (this.selectedUsuarioId !== 'Todos' && !this.metroUsuarios.some(user => user.id === this.selectedUsuarioId)) {
|
||||||
|
this.selectedUsuarioId = 'Todos';
|
||||||
|
}
|
||||||
|
this.updateQueryParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateQueryParams() {
|
private updateQueryParams() {
|
||||||
this.router.navigate([], {
|
this.router.navigate([], {
|
||||||
relativeTo: this.activatedRoute,
|
relativeTo: this.activatedRoute,
|
||||||
queryParams: { usuario_id: this.selectedUsuarioId, dia: this.selectedDia },
|
queryParams: { metro: this.selectedMetro, usuario_id: this.selectedUsuarioId, dia: this.selectedDia, pdv_id: null },
|
||||||
queryParamsHandling: 'merge',
|
queryParamsHandling: 'merge',
|
||||||
replaceUrl: true
|
replaceUrl: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyFilter() {
|
private applyFilter() {
|
||||||
let list = this.selectedUsuarioId === 'Todos'
|
let list = this.allRoutes.filter(route => String(route.json.metro ?? '').trim() === this.selectedMetro);
|
||||||
? [...this.allRoutes]
|
if (this.selectedUsuarioId !== 'Todos') {
|
||||||
: this.allRoutes.filter(r => String(r.json.usuario_id) === String(this.selectedUsuarioId));
|
list = list.filter(r => String(r.json.usuario_id) === String(this.selectedUsuarioId));
|
||||||
|
}
|
||||||
if (this.selectedDia !== 'Todos') {
|
if (this.selectedDia !== 'Todos') {
|
||||||
list = list.filter(r => r.json.dia === this.selectedDia);
|
list = list.filter(r => r.json.dia === this.selectedDia);
|
||||||
}
|
}
|
||||||
|
const pdvId = this.activatedRoute.snapshot.queryParams['pdv_id'];
|
||||||
|
if (pdvId) list = list.filter(route => String(route.json.pdv_id) === String(pdvId));
|
||||||
this.filteredRoutes = list;
|
this.filteredRoutes = list;
|
||||||
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 usuario/día.';
|
this.error = this.allRoutes.length === 0 ? 'No hay rutas cargadas. Sube archivos en Home.' : 'Sin rutas para este metro, usuario o día.';
|
||||||
} else {
|
} else {
|
||||||
this.error = null;
|
this.error = null;
|
||||||
}
|
}
|
||||||
@@ -296,6 +326,29 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyQueryFilters(queryParams: Record<string, string | undefined>): void {
|
||||||
|
const userId = queryParams['usuario_id'];
|
||||||
|
const day = queryParams['dia'];
|
||||||
|
this.selectedUsuarioId = userId && this.usuarios.some(user => String(user.id) === String(userId)) ? String(userId) : 'Todos';
|
||||||
|
this.selectedDia = day && day !== 'Todos' && this.dias.includes(day) ? day : 'Todos';
|
||||||
|
const requestedMetro = String(queryParams['metro'] ?? '').trim();
|
||||||
|
const userMetro = this.selectedUsuarioId === 'Todos' ? '' : String(
|
||||||
|
this.allRoutes.find(route => String(route.json.usuario_id) === this.selectedUsuarioId)?.json.metro ?? ''
|
||||||
|
).trim();
|
||||||
|
const defaultMetro = this.metros.find(metro => ['cdmx', 'ciudaddemexico', 'mexicocity'].includes(this.normalizeMetro(metro))) ?? this.metros[0] ?? '';
|
||||||
|
this.selectedMetro = this.metros.includes(requestedMetro)
|
||||||
|
? requestedMetro
|
||||||
|
: this.metros.includes(userMetro)
|
||||||
|
? userMetro
|
||||||
|
: this.metros.includes(this.selectedMetro)
|
||||||
|
? this.selectedMetro
|
||||||
|
: defaultMetro;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMetro(value: string): string {
|
||||||
|
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
private initMap() {
|
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 });
|
||||||
@@ -349,6 +402,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
|
|
||||||
private async renderFiltered() {
|
private async renderFiltered() {
|
||||||
if (!this.map) return;
|
if (!this.map) return;
|
||||||
|
const version = ++this.renderVersion;
|
||||||
this.abort$.next();
|
this.abort$.next();
|
||||||
this.clearMap();
|
this.clearMap();
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
@@ -389,7 +443,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
sequencedRoutes.forEach((segment, idx) => {
|
sequencedRoutes.forEach((segment, idx) => {
|
||||||
const route = segment.route;
|
const route = segment.route;
|
||||||
const j = route.json;
|
const j = route.json;
|
||||||
const color = colorForUsuario(j.usuario_id).color;
|
const color = colorForUsuario(`${j.usuario_id}|${j.dia}`).color;
|
||||||
const origin = segment.origin;
|
const origin = segment.origin;
|
||||||
const dest = segment.destination;
|
const dest = segment.destination;
|
||||||
bounds.push([origin[1], origin[0]]);
|
bounds.push([origin[1], origin[0]]);
|
||||||
@@ -437,7 +491,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (this.selectedUsuarioId === 'Todos' && this.selectedDia === 'Todos') {
|
if (this.selectedUsuarioId === 'Todos' && this.selectedDia === 'Todos') {
|
||||||
for (const route of this.unassignedRoutes) {
|
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)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -451,19 +505,19 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
// Progressive ORS hydration — always in background (heat only hides visually)
|
// Progressive ORS hydration — always in background (heat only hides visually)
|
||||||
this.hydrateWithOrs();
|
this.hydrateWithOrs(version);
|
||||||
this.renderUnassignedMarkers();
|
this.renderUnassignedMarkers();
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
private projectBounds(): L.LatLngBounds | null {
|
private projectBounds(): L.LatLngBounds | null {
|
||||||
const points: L.LatLngTuple[] = [];
|
const points: L.LatLngTuple[] = [];
|
||||||
for (const route of this.allRoutes) {
|
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)]);
|
||||||
}
|
}
|
||||||
for (const route of this.unassignedRoutes) {
|
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;
|
||||||
@@ -477,7 +531,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.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return;
|
||||||
for (const route of this.unassignedRoutes) {
|
for (const route of this.filteredUnassignedRoutes) {
|
||||||
const icon = L.divIcon({
|
const icon = L.divIcon({
|
||||||
className: 'custom-marker unassigned-marker',
|
className: 'custom-marker unassigned-marker',
|
||||||
html: '<div class="location-pin unassigned-pin"><span>!</span></div>',
|
html: '<div class="location-pin unassigned-pin"><span>!</span></div>',
|
||||||
@@ -492,17 +546,18 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async hydrateWithOrs() {
|
private async hydrateWithOrs(version: number) {
|
||||||
if (!this.filteredRoutes.length) return;
|
if (!this.filteredRoutes.length) return;
|
||||||
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.cdr.detectChanges();
|
||||||
|
|
||||||
const embeddedResults = this.mapRoutes
|
const routesForRender = [...this.mapRoutes];
|
||||||
|
const embeddedResults = routesForRender
|
||||||
.filter(route => !!route.data.json.geojson)
|
.filter(route => !!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 = this.mapRoutes.filter(route => !route.data.json.geojson).map(route => ({
|
const pares = routesForRender.filter(route => !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
|
||||||
@@ -512,6 +567,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
const remoteResults = pares.length
|
const remoteResults = pares.length
|
||||||
? await this.orsService.processPairsWithProgress(pares, 2, true, this.abort$).promise
|
? await this.orsService.processPairsWithProgress(pares, 2, true, this.abort$).promise
|
||||||
: [];
|
: [];
|
||||||
|
if (version !== this.renderVersion) return;
|
||||||
const results = [...embeddedResults, ...remoteResults];
|
const results = [...embeddedResults, ...remoteResults];
|
||||||
if (!results.length) {
|
if (!results.length) {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
@@ -534,8 +590,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const useMap = geoByRequest.size > 0;
|
const useMap = geoByRequest.size > 0;
|
||||||
for (let i = 0; i < this.mapRoutes.length; i++) {
|
for (let i = 0; i < routesForRender.length; i++) {
|
||||||
const mr = this.mapRoutes[i];
|
if (version !== this.renderVersion) return;
|
||||||
|
const mr = routesForRender[i];
|
||||||
let geo: unknown = null;
|
let geo: unknown = null;
|
||||||
if (useMap) {
|
if (useMap) {
|
||||||
geo = geoByRequest.get(mr.requestId);
|
geo = geoByRequest.get(mr.requestId);
|
||||||
@@ -587,8 +644,10 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
} catch {
|
} catch {
|
||||||
// Ignore abort / network errors, fallback lines remain
|
// Ignore abort / network errors, fallback lines remain
|
||||||
} finally {
|
} finally {
|
||||||
this.isLoading = false;
|
if (version === this.renderVersion) {
|
||||||
this.cdr.detectChanges();
|
this.isLoading = false;
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,7 +1038,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
|
|||||||
this.toggleLayerVisibility();
|
this.toggleLayerVisibility();
|
||||||
const needsHydrate = !this.mapRoutes.some(m => m.orsLine) || this.orsTotal === 0 || this.orsProgress < this.orsTotal;
|
const needsHydrate = !this.mapRoutes.some(m => m.orsLine) || this.orsTotal === 0 || this.orsProgress < this.orsTotal;
|
||||||
if (!this.showHeat && needsHydrate) {
|
if (!this.showHeat && needsHydrate) {
|
||||||
this.hydrateWithOrs();
|
this.hydrateWithOrs(this.renderVersion);
|
||||||
}
|
}
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
.pdvs-topbar{display:flex;align-items:center;gap:10px;padding:9px 24px;border-bottom:3px solid var(--glm-verde);background:#fff;color:var(--glm-azul);font-weight:700}.pdvs-topbar img{height:38px}.pdvs-topbar .user-profile{display:flex;align-items:center;gap:8px;margin-left:auto}.pdvs-topbar .user-profile img,.avatar-fallback{width:30px;height:30px;border-radius:50%}.avatar-fallback{display:grid;place-items:center;background:var(--glm-verde);color:#fff}.pdvs-topbar button{padding:8px 10px;border:1px solid var(--glm-gris-medio);border-radius:4px;background:#fff;color:var(--glm-azul);font-weight:700;cursor:pointer}.pdvs-page{max-width:1200px;margin:auto;padding:26px 24px}.pdvs-page>header{display:flex;justify-content:space-between;gap:20px;border-bottom:3px solid var(--glm-verde);padding-bottom:14px}.pdvs-page h1,.pdvs-page h2{margin:0;color:var(--glm-azul)}.pdvs-page header p{color:var(--glm-acero)}.import-button,.primary{padding:9px 12px;border-radius:4px;background:var(--glm-azul);color:#fff;font-weight:700;cursor:pointer}.import-button input{display:none}.pdv-form,.pdv-table{margin-top:18px;padding:18px;border:1px solid var(--glm-gris-medio);border-top:3px solid var(--glm-azul);border-radius:4px;background:#fff}.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-top:14px}.form-grid label{display:grid;gap:5px;color:var(--glm-azul);font-size:.75rem;font-weight:700;text-transform:uppercase}.form-grid input,.form-grid select{padding:9px;border:1px solid #9aaab3;border-radius:4px;background:#fff;color:#2f3f48}.form-actions{display:flex;gap:8px;margin-top:14px}.form-actions button{padding:9px 12px;border:1px solid var(--glm-gris-medio);border-radius:4px;cursor:pointer}.table-wrap{overflow:auto;margin-top:12px}table{width:100%;border-collapse:collapse;font-size:.82rem}th{padding:9px;background:var(--glm-azul);color:#fff;text-align:left}td{padding:9px;border-bottom:1px solid var(--glm-gris-claro)}td button{margin-right:5px;padding:6px;border:1px solid var(--glm-gris-medio);border-radius:4px;background:#fff;cursor:pointer}.danger{color:#c9252d}.error{color:#c9252d}.success{color:var(--glm-verde);font-weight:700}@media(max-width:650px){.pdvs-topbar{padding:8px 12px;flex-wrap:wrap}.pdvs-page{padding:18px 12px}.pdvs-page>header{flex-direction:column}}
|
||||||
|
.import-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; width: fit-content; min-width: 0; white-space: nowrap; text-decoration: none; }
|
||||||
|
.pdvs-page > header .import-button { align-self: flex-start; height: auto; }
|
||||||
|
.pdv-table td:last-child { white-space: nowrap; }
|
||||||
|
.pdv-table td:last-child button { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; margin: 0 6px 0 0; padding: 0; font-size: 1rem; line-height: 1; }
|
||||||
|
.pdv-table td:last-child button.edit-button { width: auto; min-width: 86px; padding: 0 10px; gap: 6px; }
|
||||||
|
.pdv-table td:last-child button.edit-button,
|
||||||
|
.pdv-table td.actions-cell button.edit-button { display: inline-flex !important; align-items: center !important; justify-content: center !important; width: auto !important; min-width: 86px !important; height: 38px !important; padding: 0 12px !important; gap: 6px !important; border: 1px solid var(--glm-gris-medio) !important; border-radius: 4px !important; background: #fff !important; color: var(--glm-azul) !important; font: 700 .78rem/1 Arial, sans-serif !important; visibility: visible !important; opacity: 1 !important; }
|
||||||
|
.pdv-table td:last-child button.edit-button span,
|
||||||
|
.pdv-table td.actions-cell button.edit-button span { display: inline !important; color: var(--glm-azul) !important; font-size: .78rem !important; font-weight: 700 !important; visibility: visible !important; }
|
||||||
|
.pdv-table td.actions-cell { white-space: nowrap; min-width: 170px; }
|
||||||
|
.pdv-table td:last-child button:last-child { margin-right: 0; }
|
||||||
|
.pdv-table td:last-child button.danger { color: #c9252d; }
|
||||||
|
.pdv-table .row-actions { display: flex; gap: 5px; min-width: 250px; white-space: nowrap; }
|
||||||
|
.pdv-table .row-actions button { width: auto !important; min-width: 0 !important; height: auto !important; margin: 0 !important; padding: 7px 10px !important; border: 1px solid var(--glm-gris-medio) !important; border-radius: 4px !important; background: #fff !important; color: var(--glm-azul) !important; font: 600 .78rem/1.2 Arial, sans-serif !important; }
|
||||||
|
.pdv-table .row-actions button:hover { background: var(--glm-verde-bg) !important; border-color: var(--glm-verde) !important; }
|
||||||
|
.pdv-table .row-actions button.delete { color: #c9252d !important; }
|
||||||
|
.pdv-table .table-header { display:flex; justify-content:space-between; align-items:center; gap:12px; }.pdv-table .table-header > div { display:flex; align-items:center; gap:8px; }.pdv-table .selected-count { color:var(--glm-azul); font-size:.78rem; font-weight:700; }.pdv-table .table-header button,.pdv-table .bulk-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:7px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.pdv-table .table-header button:hover,.pdv-table .bulk-actions button:hover:not(:disabled) { background:var(--glm-verde-bg); border-color:var(--glm-verde); }.pdv-table .bulk-actions { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0; padding:12px; border:1px solid var(--glm-verde-claro); background:var(--glm-verde-bg); }.pdv-table .bulk-actions button.delete { color:#c9252d; }.pdv-table .select-column { width:38px; }
|
||||||
|
.page-back-button { display:inline-flex; align-items:center; gap:6px; margin-bottom:18px; padding:8px 11px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:700 .82rem Arial,sans-serif; }.page-back-button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pdvs-page { padding: 18px 14px; }
|
||||||
|
.pdvs-page > header { align-items: flex-start; flex-direction: column; }
|
||||||
|
.import-button { align-self: flex-start; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<div class="pdvs-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
|
||||||
|
<main class="pdvs-page"><button type="button" class="page-back-button" (click)="goBack()"><i class="pi pi-arrow-left"></i> Volver</button><header><div><h1>Gestionar PDVs</h1><p>{{ pdvs.length }} puntos de venta registrados en este proyecto</p></div><label class="import-button"><i class="pi pi-file-import"></i> {{ importing ? 'Importando...' : 'Importar Excel' }}<input type="file" accept=".xlsx,.xls" [disabled]="importing" (change)="importFile($event)"></label></header>
|
||||||
|
<p class="success" *ngIf="message">{{ message }}</p><p class="error" *ngIf="error">{{ error }}</p>
|
||||||
|
<section class="pdv-form"><h2>{{ editing ? 'Editar PDV' : 'Nuevo PDV' }}</h2><div class="form-grid"><label>ID<input [(ngModel)]="form.id"></label><label>Nombre<input [(ngModel)]="form.nombre"></label><label>Segmentación<select [(ngModel)]="form.segmentacion"><option *ngFor="let value of ['AA','A','B','C','D']" [value]="value">{{ value }}</option></select></label><label>Latitud<input type="number" step="any" [(ngModel)]="form.latitud"></label><label>Longitud<input type="number" step="any" [(ngModel)]="form.longitud"></label><label>Visitas semanales<input type="number" min="1" max="7" [(ngModel)]="form.visitas"></label><label>Duración (horas)<input type="number" min="0.1" step="0.1" [(ngModel)]="form.duracion"></label><label>Prioridad<input type="number" min="1" max="5" [(ngModel)]="form.prioridad"></label><label>Cobertura<input [(ngModel)]="form.cobertura"></label><label>Zona<input [(ngModel)]="form.zona"></label><label>Región<input [(ngModel)]="form.region"></label><label>Estado<input [(ngModel)]="form.estado"></label><label>Ciudad<input [(ngModel)]="form.ciudad"></label><label>Estado<select [(ngModel)]="form.activo"><option [ngValue]="true">Activo</option><option [ngValue]="false">Inactivo</option></select></label></div><div class="form-actions"><button type="button" class="primary" (click)="save()" [disabled]="saving">{{ saving ? 'Guardando...' : (editing ? 'Guardar cambios' : 'Crear PDV') }}</button><button type="button" *ngIf="editing" (click)="cancelEdit()">Cancelar</button></div></section>
|
||||||
|
<section class="pdv-table"><div class="table-header"><h2>PDVs del proyecto</h2><div><span class="selected-count" *ngIf="selectedIds.size">{{ selectedIds.size }} seleccionados</span><button type="button" (click)="toggleAll(!allSelected)" [disabled]="loading">{{ allSelected ? 'Deseleccionar todos' : 'Seleccionar todos' }}</button></div></div><p *ngIf="loading">Cargando...</p><div class="bulk-actions" *ngIf="selectedIds.size"><button type="button" (click)="setSelectedActive(true)" [disabled]="bulkSaving">Activar</button><button type="button" (click)="setSelectedActive(false)" [disabled]="bulkSaving">Desactivar</button><button type="button" class="delete" (click)="removeSelected()" [disabled]="bulkSaving">Eliminar seleccionados</button></div><div class="table-wrap" *ngIf="!loading"><table><thead><tr><th class="select-column"><input type="checkbox" [checked]="allSelected" (change)="toggleAll($any($event.target).checked)" aria-label="Seleccionar todos los PDVs"></th><th>ID</th><th>Nombre</th><th>Segmentación</th><th>Ciudad</th><th>Estado</th><th>Zona</th><th>Acciones</th></tr></thead><tbody><tr *ngFor="let pdv of pdvs"><td><input type="checkbox" [checked]="selectedIds.has(pdv.id)" (change)="toggleSelection(pdv.id, $any($event.target).checked)" [attr.aria-label]="'Seleccionar ' + pdv.pdv_id"></td><td>{{ pdv.pdv_id }}</td><td>{{ value(pdv, 'nombre_del_pdv') }}</td><td>{{ value(pdv, 'segmentacion') }}</td><td>{{ value(pdv, 'ciudad') }}</td><td>{{ value(pdv, 'estado') }}</td><td>{{ value(pdv, 'zona') }}</td><td class="row-actions"><button type="button" (click)="edit(pdv)">Editar</button><button type="button" (click)="toggleActive(pdv)">{{ isActive(pdv) ? 'Desactivar' : 'Activar' }}</button><button type="button" class="delete" (click)="remove(pdv)">Eliminar</button></td></tr><tr *ngIf="!pdvs.length"><td colspan="8">No hay PDVs registrados.</td></tr></tbody></table></div></section></main>
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { ProjectService } from '../../core/services/project.service';
|
||||||
|
import { RuteroProyectoPdv } from '../../core/models/project.model';
|
||||||
|
import { ExcelPreviewService } from '../../shared/services/excel-preview.service';
|
||||||
|
import { normalizeRow } from '../../shared/utils/header-normalization.utils';
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
import { UserProfileService } from '../../core/services/user-profile.service';
|
||||||
|
|
||||||
|
interface PdvForm {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
segmentacion: string;
|
||||||
|
latitud: number;
|
||||||
|
longitud: number;
|
||||||
|
visitas: number;
|
||||||
|
duracion: number;
|
||||||
|
prioridad: number;
|
||||||
|
activo: boolean;
|
||||||
|
cobertura: string;
|
||||||
|
zona: string;
|
||||||
|
region: string;
|
||||||
|
estado: string;
|
||||||
|
ciudad: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
selector: 'app-pdvs',
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './pdvs.component.html',
|
||||||
|
styleUrl: './pdvs.component.css'
|
||||||
|
})
|
||||||
|
export class PdvsComponent implements OnInit {
|
||||||
|
projectId = '';
|
||||||
|
pdvs: RuteroProyectoPdv[] = [];
|
||||||
|
form = this.emptyForm();
|
||||||
|
editing: RuteroProyectoPdv | null = null;
|
||||||
|
loading = true;
|
||||||
|
saving = false;
|
||||||
|
importing = false;
|
||||||
|
error = '';
|
||||||
|
message = '';
|
||||||
|
projectCountry = 'Republica Dominicana';
|
||||||
|
selectedIds = new Set<string>();
|
||||||
|
bulkSaving = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private projects: ProjectService,
|
||||||
|
private preview: ExcelPreviewService,
|
||||||
|
private auth: AuthService,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
public profile: UserProfileService,
|
||||||
|
private cdr: ChangeDetectorRef
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.projectId = this.route.snapshot.paramMap.get('id') ?? '';
|
||||||
|
if (!this.projectId) { void this.router.navigate(['/home']); return; }
|
||||||
|
void this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
this.pdvs = await this.projects.pdvs(this.projectId);
|
||||||
|
this.selectedIds.clear();
|
||||||
|
const country = this.pdvs.map(pdv => normalizeRow(pdv.pdv_data)['pais']).find(Boolean);
|
||||||
|
if (country) this.projectCountry = String(country);
|
||||||
|
}
|
||||||
|
catch (error: any) { this.error = error?.message ?? 'No se pudieron cargar los PDVs.'; }
|
||||||
|
finally { this.loading = false; this.cdr.detectChanges(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
edit(pdv: RuteroProyectoPdv): void {
|
||||||
|
const row = normalizeRow(pdv.pdv_data);
|
||||||
|
this.editing = pdv;
|
||||||
|
this.form = {
|
||||||
|
id: String(row['id'] ?? pdv.pdv_id), nombre: String(row['nombre_del_pdv'] ?? ''), segmentacion: String(row['segmentacion'] ?? 'D'),
|
||||||
|
latitud: Number(row['latitud'] ?? 0), longitud: Number(row['longitud'] ?? 0), visitas: Number(row['visitas_semanales'] ?? 1),
|
||||||
|
duracion: Number(row['duracion_visitahoras'] ?? row['duracion_visita_horas'] ?? 1), prioridad: Number(row['prioridad'] ?? 5),
|
||||||
|
activo: !['no', 'n', '0', 'false'].includes(String(row['activo'] ?? 'si').toLowerCase()), cobertura: String(row['cobertura'] ?? ''),
|
||||||
|
zona: String(row['zona'] ?? ''), region: String(row['region'] ?? ''), estado: String(row['estado'] ?? ''), ciudad: String(row['ciudad'] ?? '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelEdit(): void { this.editing = null; this.form = this.emptyForm(); this.error = ''; }
|
||||||
|
|
||||||
|
async save(): Promise<void> {
|
||||||
|
if (!this.form.id.trim() || !this.form.nombre.trim()) { this.error = 'ID y nombre son obligatorios.'; return; }
|
||||||
|
if (!Number.isFinite(this.form.latitud) || !Number.isFinite(this.form.longitud)) { this.error = 'Coordenadas inválidas.'; return; }
|
||||||
|
this.saving = true; this.error = ''; this.message = '';
|
||||||
|
const row: Record<string, unknown> = {
|
||||||
|
...(this.editing?.pdv_data ?? {}), ID: this.form.id.trim(), 'Nombre del PDV': this.form.nombre.trim(), Segmentación: this.form.segmentacion,
|
||||||
|
Latitud: this.form.latitud, Longitud: this.form.longitud, 'Visitas semanales': this.form.visitas, 'Duración visita(horas)': this.form.duracion,
|
||||||
|
Prioridad: this.form.prioridad, Activo: this.form.activo ? 'Sí' : 'No', Cobertura: this.form.cobertura, Zona: this.form.zona,
|
||||||
|
Región: this.form.region, Estado: this.form.estado, Ciudad: this.form.ciudad, País: normalizeRow(this.editing?.pdv_data ?? {})['pais'] ?? this.projectCountry
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (this.editing) await this.projects.updatePdv(this.editing.id, row);
|
||||||
|
else await this.projects.addPdv(this.projectId, row);
|
||||||
|
this.message = this.editing ? 'PDV actualizado.' : 'PDV creado.';
|
||||||
|
this.cancelEdit();
|
||||||
|
await this.load();
|
||||||
|
} catch (error: any) { this.error = error?.message ?? 'No se pudo guardar el PDV.'; }
|
||||||
|
finally { this.saving = false; this.cdr.detectChanges(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async importFile(event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
this.importing = true; this.error = ''; this.message = '';
|
||||||
|
try {
|
||||||
|
const result = await this.preview.previewFile(file, 'PDV');
|
||||||
|
if (!result.valid) throw new Error(result.errors[0]?.message ?? 'El archivo PDV contiene errores.');
|
||||||
|
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
|
||||||
|
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(workbook.Sheets[workbook.SheetNames[0]], { defval: null, raw: true });
|
||||||
|
await this.projects.upsertPdvs(this.projectId, rows);
|
||||||
|
this.message = `${rows.length} PDVs importados o actualizados.`;
|
||||||
|
await this.load();
|
||||||
|
} catch (error: any) { this.error = error?.message ?? 'No se pudo importar el archivo.'; }
|
||||||
|
finally { this.importing = false; input.value = ''; this.cdr.detectChanges(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(pdv: RuteroProyectoPdv): Promise<void> {
|
||||||
|
if (!confirm(`¿Eliminar el PDV ${pdv.pdv_id}?`)) return;
|
||||||
|
try { await this.projects.removePdv(pdv.id); await this.load(); }
|
||||||
|
catch (error: any) { this.error = error?.message ?? 'No se pudo eliminar el PDV.'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive(pdv: RuteroProyectoPdv): boolean {
|
||||||
|
return !['no', 'n', '0', 'false'].includes(String(normalizeRow(pdv.pdv_data)['activo'] ?? 'si').toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleActive(pdv: RuteroProyectoPdv): Promise<void> {
|
||||||
|
const active = !this.isActive(pdv);
|
||||||
|
try {
|
||||||
|
await this.projects.updatePdv(pdv.id, { ...pdv.pdv_data, Activo: active ? 'Sí' : 'No' });
|
||||||
|
this.message = active ? 'PDV activado.' : 'PDV desactivado.';
|
||||||
|
await this.load();
|
||||||
|
} catch (error: any) {
|
||||||
|
this.error = error?.message ?? 'No se pudo actualizar el estado del PDV.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get allSelected(): boolean {
|
||||||
|
return this.pdvs.length > 0 && this.pdvs.every(pdv => this.selectedIds.has(pdv.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedPdvs(): RuteroProyectoPdv[] {
|
||||||
|
return this.pdvs.filter(pdv => this.selectedIds.has(pdv.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelection(id: string, checked: boolean): void {
|
||||||
|
checked ? this.selectedIds.add(id) : this.selectedIds.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAll(checked: boolean): void {
|
||||||
|
this.selectedIds = checked ? new Set(this.pdvs.map(pdv => pdv.id)) : new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSelectedActive(active: boolean): Promise<void> {
|
||||||
|
const selected = this.selectedPdvs;
|
||||||
|
if (!selected.length) return;
|
||||||
|
this.bulkSaving = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
await Promise.all(selected.map(pdv => this.projects.updatePdv(pdv.id, { ...pdv.pdv_data, Activo: active ? 'Sí' : 'No' })));
|
||||||
|
this.message = `${selected.length} PDVs ${active ? 'activados' : 'desactivados'}.`;
|
||||||
|
await this.load();
|
||||||
|
} catch (error: any) {
|
||||||
|
this.error = error?.message ?? 'No se pudo actualizar el estado de los PDVs.';
|
||||||
|
} finally {
|
||||||
|
this.bulkSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSelected(): Promise<void> {
|
||||||
|
const selected = this.selectedPdvs;
|
||||||
|
if (!selected.length || !confirm(`¿Eliminar ${selected.length} PDVs seleccionados? Esta acción no se puede deshacer.`)) return;
|
||||||
|
this.bulkSaving = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
await Promise.all(selected.map(pdv => this.projects.removePdv(pdv.id)));
|
||||||
|
this.message = `${selected.length} PDVs eliminados.`;
|
||||||
|
await this.load();
|
||||||
|
} catch (error: any) {
|
||||||
|
this.error = error?.message ?? 'No se pudieron eliminar los PDVs.';
|
||||||
|
} finally {
|
||||||
|
this.bulkSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
value(pdv: RuteroProyectoPdv, field: string): unknown { return normalizeRow(pdv.pdv_data)[field] ?? '-'; }
|
||||||
|
goBack(): void { void this.router.navigate(['/proyectos', this.projectId]); }
|
||||||
|
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
|
||||||
|
|
||||||
|
private emptyForm(): PdvForm {
|
||||||
|
return { id: '', nombre: '', segmentacion: 'D', latitud: 18.4861, longitud: -69.9312, visitas: 1, duracion: 1, prioridad: 5, activo: true, cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
<div class="projects-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="topbar-actions"><a routerLink="/home">Mis proyectos</a><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div></div><main class="projects-page narrow"><header><div><h1>Crear proyecto</h1><p>Define el proyecto e importa los PDVs que se planificarán.</p></div></header><section class="project-form"><label>Nombre del proyecto<input [(ngModel)]="name" placeholder="Ej. Ruta Santo Domingo Norte" maxlength="120"></label><label>País<select [(ngModel)]="country"><option value="Republica Dominicana">República Dominicana</option><option value="MEXICO">México</option></select></label><label class="file-label">Archivo PDVs (Excel)<input type="file" accept=".xlsx,.xls" (change)="fileChanged($event)"><span>.xlsx / .xls, máximo 10 MB. El país también se detecta desde el archivo.</span></label><p *ngIf="file">{{ file.name }}</p><p *ngIf="loading">Validando archivo...</p><p class="valid" *ngIf="preview?.valid">{{ preview!.validRows }} PDVs válidos para crear el proyecto.</p><p class="error" *ngIf="preview && !preview.valid">El archivo tiene errores. Corrígelos antes de crear el proyecto.</p><p class="error" *ngIf="error">{{ error }}</p><button class="primary" type="button" [disabled]="!canCreate" (click)="create()">{{ saving ? 'Creando proyecto...' : 'Crear proyecto' }}</button></section></main>
|
<div class="projects-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="topbar-actions"><a routerLink="/home">Mis proyectos</a><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div></div><main class="projects-page narrow"><header><div><h1>Crear proyecto</h1><p>Define el proyecto. Podrás agregar sus PDVs después desde su gestor.</p></div></header><section class="project-form"><label>Nombre del proyecto<input [(ngModel)]="name" placeholder="Ej. Ruta Santo Domingo Norte" maxlength="120"></label><label>Descripción<textarea [(ngModel)]="description" placeholder="Describe el objetivo o alcance del proyecto" maxlength="500"></textarea></label><label>País<select [(ngModel)]="country"><option value="Republica Dominicana">República Dominicana</option><option value="MEXICO">México</option></select></label><p class="error" *ngIf="error">{{ error }}</p><button class="primary" type="button" [disabled]="!canCreate" (click)="create()">{{ saving ? 'Creando proyecto...' : 'Crear proyecto' }}</button></section></main>
|
||||||
|
|||||||
@@ -2,49 +2,25 @@ import { ChangeDetectorRef, Component } 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, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import * as XLSX from 'xlsx';
|
|
||||||
import { ProjectService } from '../../core/services/project.service';
|
import { ProjectService } from '../../core/services/project.service';
|
||||||
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';
|
||||||
import { ExcelPreviewService, PreviewResult } from '../../shared/services/excel-preview.service';
|
|
||||||
|
|
||||||
@Component({ standalone: true, selector: 'app-new-project', imports: [CommonModule, FormsModule, RouterLink], templateUrl: './new-project.component.html', styleUrl: './projects.component.css' })
|
@Component({ standalone: true, selector: 'app-new-project', imports: [CommonModule, FormsModule, RouterLink], templateUrl: './new-project.component.html', styleUrl: './projects.component.css' })
|
||||||
export class NewProjectComponent {
|
export class NewProjectComponent {
|
||||||
name = '';
|
name = '';
|
||||||
|
description = '';
|
||||||
country = 'Republica Dominicana';
|
country = 'Republica Dominicana';
|
||||||
file: File | null = null;
|
|
||||||
preview: PreviewResult | null = null;
|
|
||||||
loading = false;
|
|
||||||
saving = false;
|
saving = false;
|
||||||
error = '';
|
error = '';
|
||||||
constructor(private projects: ProjectService, private auth: AuthService, public profile: UserProfileService, private previewService: ExcelPreviewService, private router: Router, private cdr: ChangeDetectorRef) {}
|
constructor(private projects: ProjectService, private auth: AuthService, public profile: UserProfileService, private router: Router, private cdr: ChangeDetectorRef) {}
|
||||||
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
|
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
|
||||||
async fileChanged(event: Event): Promise<void> {
|
get canCreate(): boolean { return !!this.name.trim() && !!this.country && !this.saving; }
|
||||||
const file = (event.target as HTMLInputElement).files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
this.file = file; this.preview = null; this.error = ''; this.loading = true;
|
|
||||||
try {
|
|
||||||
this.preview = await this.previewService.previewFile(file, 'PDV');
|
|
||||||
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 value = rows.map(row => Object.entries(row).find(([key]) => key.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase() === 'pais')?.[1]).find(Boolean);
|
|
||||||
const normalized = String(value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase();
|
|
||||||
if (normalized.includes('MEXICO')) this.country = 'MEXICO';
|
|
||||||
else if (normalized.includes('DOMINICANA')) this.country = 'Republica Dominicana';
|
|
||||||
}
|
|
||||||
catch (error: any) { this.error = error?.message ?? 'No se pudo leer el archivo.'; }
|
|
||||||
finally { this.loading = false; this.cdr.detectChanges(); }
|
|
||||||
}
|
|
||||||
get canCreate(): boolean { return !!this.name.trim() && !!this.country && !!this.file && !!this.preview?.valid && !this.saving; }
|
|
||||||
async create(): Promise<void> {
|
async create(): Promise<void> {
|
||||||
if (!this.canCreate || !this.file) return;
|
if (!this.canCreate) return;
|
||||||
this.saving = true; this.error = '';
|
this.saving = true; this.error = '';
|
||||||
try {
|
try {
|
||||||
const workbook = XLSX.read(await this.file.arrayBuffer(), { type: 'array' });
|
const project = await this.projects.create(this.name, this.description, this.country);
|
||||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
|
||||||
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: null, raw: true });
|
|
||||||
const projectRows = rows.map(row => ({ ...row, 'País': this.country }));
|
|
||||||
const project = await this.projects.create(this.name, projectRows);
|
|
||||||
await this.router.navigate(['/proyectos', project.id]);
|
await this.router.navigate(['/proyectos', project.id]);
|
||||||
} catch (error: any) { this.error = error?.message ?? 'No se pudo crear el proyecto.'; }
|
} catch (error: any) { this.error = error?.message ?? 'No se pudo crear el proyecto.'; }
|
||||||
finally { this.saving = false; this.cdr.detectChanges(); }
|
finally { this.saving = false; this.cdr.detectChanges(); }
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
<div class="projects-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="topbar-actions"><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div></div>
|
<div class="projects-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="topbar-actions"><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div></div>
|
||||||
<main class="projects-page"><a class="manage-employees" routerLink="/empleados"><i class="pi pi-users"></i> Gestionar empleados</a><header><div><h1>Mis proyectos</h1><p>Organiza y conserva tus planificaciones de rutas.</p></div><a class="primary" routerLink="/proyectos/nuevo"><i class="pi pi-plus"></i> Crear proyecto</a></header><p class="error" *ngIf="error">{{ error }}</p><p *ngIf="loading">Cargando proyectos...</p><section class="project-grid" *ngIf="!loading"><article class="project-card" *ngFor="let project of projects"><span class="state">{{ project.estado }}</span><h2>{{ project.nombre }}</h2><p>{{ project.descripcion || 'Sin descripción' }}</p><small>Actualizado: {{ project.updated_at | date:'medium' }}</small><div class="project-card-actions"><button type="button" (click)="open(project)" [disabled]="deletingId === project.id">Abrir proyecto</button><button type="button" class="danger" (click)="remove(project)" [disabled]="deletingId === project.id"><i class="pi pi-trash"></i> {{ deletingId === project.id ? 'Eliminando...' : 'Eliminar' }}</button></div></article><section class="empty" *ngIf="!projects.length"><i class="pi pi-folder-open"></i><h2>Aún no tienes proyectos</h2><p>Crea uno e importa su archivo de PDVs para comenzar.</p><a class="primary" routerLink="/proyectos/nuevo">Crear proyecto</a></section></section></main>
|
<main class="projects-page"><header><div><h1>Mis proyectos</h1><p>Organiza y conserva tus planificaciones de rutas.</p></div><a class="primary" routerLink="/proyectos/nuevo"><i class="pi pi-plus"></i> Crear proyecto</a></header><p class="error" *ngIf="error">{{ error }}</p><p *ngIf="loading">Cargando proyectos...</p><section class="project-grid" *ngIf="!loading"><article class="project-card" *ngFor="let project of projects"><h2>{{ project.nombre }}</h2><p>{{ project.descripcion || 'Sin descripción' }}</p><small>Actualizado: {{ project.updated_at | date:'medium' }}</small><div class="project-card-actions"><button type="button" (click)="open(project)" [disabled]="deletingId === project.id">Abrir proyecto</button><button type="button" class="danger" (click)="requestRemove(project)" [disabled]="deletingId === project.id"><i class="pi pi-trash"></i> Eliminar</button></div></article><section class="empty" *ngIf="!projects.length"><i class="pi pi-folder-open"></i><h2>Aún no tienes proyectos</h2><p>Crea uno y agrega sus PDVs cuando estés listo para planificar.</p><a class="primary" routerLink="/proyectos/nuevo">Crear proyecto</a></section></section></main>
|
||||||
|
<div class="delete-dialog-backdrop" *ngIf="projectToDelete" (click)="closeDeleteDialog()"><section class="delete-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-project-title" (click)="$event.stopPropagation()"><i class="pi pi-exclamation-triangle"></i><h2 id="delete-project-title">Eliminar proyecto</h2><p>¿Eliminar permanentemente <strong>{{ projectToDelete.nombre }}</strong>?</p><p class="delete-dialog-warning">También se eliminarán sus PDVs, empleados y rutas. Esta acción no se puede deshacer.</p><div><button type="button" (click)="closeDeleteDialog()" [disabled]="!!deletingId">Cancelar</button><button type="button" class="danger" (click)="remove()" [disabled]="!!deletingId">{{ deletingId ? 'Eliminando...' : 'Eliminar proyecto' }}</button></div></section></div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, HostListener, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { ProjectService } from '../../core/services/project.service';
|
import { ProjectService } from '../../core/services/project.service';
|
||||||
@@ -11,6 +11,7 @@ export class ProjectListComponent implements OnInit {
|
|||||||
projects: RuteroProyecto[] = [];
|
projects: RuteroProyecto[] = [];
|
||||||
loading = true;
|
loading = true;
|
||||||
deletingId = '';
|
deletingId = '';
|
||||||
|
projectToDelete: RuteroProyecto | null = null;
|
||||||
error = '';
|
error = '';
|
||||||
constructor(private projectsService: ProjectService, private auth: AuthService, public profile: UserProfileService, private router: Router, private cdr: ChangeDetectorRef) {}
|
constructor(private projectsService: ProjectService, private auth: AuthService, public profile: UserProfileService, private router: Router, private cdr: ChangeDetectorRef) {}
|
||||||
ngOnInit(): void { void this.load(); }
|
ngOnInit(): void { void this.load(); }
|
||||||
@@ -21,8 +22,12 @@ export class ProjectListComponent implements OnInit {
|
|||||||
finally { this.loading = false; this.cdr.detectChanges(); }
|
finally { this.loading = false; this.cdr.detectChanges(); }
|
||||||
}
|
}
|
||||||
open(project: RuteroProyecto): void { void this.router.navigate(['/proyectos', project.id]); }
|
open(project: RuteroProyecto): void { void this.router.navigate(['/proyectos', project.id]); }
|
||||||
async remove(project: RuteroProyecto): Promise<void> {
|
requestRemove(project: RuteroProyecto): void { this.projectToDelete = project; }
|
||||||
if (!confirm(`Eliminar permanentemente el proyecto "${project.nombre}" y todos sus PDVs y rutas?`)) return;
|
closeDeleteDialog(): void { if (!this.deletingId) this.projectToDelete = null; }
|
||||||
|
@HostListener('document:keydown.escape') onEscape(): void { this.closeDeleteDialog(); }
|
||||||
|
async remove(): Promise<void> {
|
||||||
|
const project = this.projectToDelete;
|
||||||
|
if (!project) return;
|
||||||
this.deletingId = project.id;
|
this.deletingId = project.id;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
try {
|
try {
|
||||||
@@ -32,6 +37,7 @@ export class ProjectListComponent implements OnInit {
|
|||||||
this.error = error?.message ?? 'No se pudo eliminar el proyecto.';
|
this.error = error?.message ?? 'No se pudo eliminar el proyecto.';
|
||||||
} finally {
|
} finally {
|
||||||
this.deletingId = '';
|
this.deletingId = '';
|
||||||
|
this.projectToDelete = null;
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,3 +3,5 @@
|
|||||||
.topbar-actions { display:flex; align-items:center; gap:8px; margin-left:auto; }.topbar-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:inherit; font-size:.82rem; font-weight:700; padding:8px 12px; }.topbar-actions button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }.manage-employees { display:inline-flex; align-items:center; gap:6px; margin-bottom:14px; border:1px solid var(--glm-gris-medio); border-radius:4px; padding:8px 12px; color:var(--glm-azul); font-weight:700; text-decoration:none; background:#fff; }.manage-employees:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }
|
.topbar-actions { display:flex; align-items:center; gap:8px; margin-left:auto; }.topbar-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:inherit; font-size:.82rem; font-weight:700; padding:8px 12px; }.topbar-actions button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }.manage-employees { display:inline-flex; align-items:center; gap:6px; margin-bottom:14px; border:1px solid var(--glm-gris-medio); border-radius:4px; padding:8px 12px; color:var(--glm-azul); font-weight:700; text-decoration:none; background:#fff; }.manage-employees:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }
|
||||||
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
|
.user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; }
|
||||||
.project-card-actions { display:flex; gap:8px; margin-top:auto; }.project-card-actions button { margin-top:0; }.project-card-actions .danger { border-color:#c9252d; background:#fff; color:#c9252d; }.project-card-actions .danger:hover:not(:disabled) { background:#c9252d; color:#fff; }.project-card-actions button:disabled { cursor:not-allowed; opacity:.65; }
|
.project-card-actions { display:flex; gap:8px; margin-top:auto; }.project-card-actions button { margin-top:0; }.project-card-actions .danger { border-color:#c9252d; background:#fff; color:#c9252d; }.project-card-actions .danger:hover:not(:disabled) { background:#c9252d; color:#fff; }.project-card-actions button:disabled { cursor:not-allowed; opacity:.65; }
|
||||||
|
.project-form textarea { min-height:88px; padding:10px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-gris-oscuro); font:inherit; resize:vertical; }
|
||||||
|
.delete-dialog-backdrop { position:fixed; inset:0; z-index:2000; display:grid; place-items:center; padding:20px; background:rgba(24,36,44,.56); }.delete-dialog { width:min(100%,440px); padding:26px; border-top:3px solid #c9252d; border-radius:4px; background:#fff; box-shadow:0 18px 48px rgba(0,0,0,.28); }.delete-dialog>i { color:#c9252d; font-size:1.5rem; }.delete-dialog h2 { margin:10px 0 8px; color:var(--glm-azul); }.delete-dialog p { margin:8px 0; color:var(--glm-gris-oscuro); line-height:1.45; }.delete-dialog-warning { color:var(--glm-acero)!important; font-size:.9rem; }.delete-dialog>div { display:flex; justify-content:flex-end; gap:8px; margin-top:20px; }.delete-dialog button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:9px 12px; background:#fff; color:var(--glm-azul); cursor:pointer; font:700 .82rem Arial,sans-serif; }.delete-dialog button.danger { border-color:#c9252d; color:#c9252d; }.delete-dialog button.danger:hover:not(:disabled) { background:#c9252d; color:#fff; }.delete-dialog button:disabled { cursor:not-allowed; opacity:.6; }
|
||||||
|
|||||||
@@ -165,7 +165,15 @@ export class ExcelPreviewService {
|
|||||||
'Longitud': cleanNum(row['longitud']),
|
'Longitud': cleanNum(row['longitud']),
|
||||||
'Visitas semanales': Number.isFinite(visits) ? visits : 1,
|
'Visitas semanales': Number.isFinite(visits) ? visits : 1,
|
||||||
'Duración visita(horas)': Number.isFinite(duration) ? duration : weeklyHours,
|
'Duración visita(horas)': Number.isFinite(duration) ? duration : weeklyHours,
|
||||||
'Prioridad': cleanNum(row['prioridad'])
|
'Prioridad': cleanNum(row['prioridad']),
|
||||||
|
'Activo': row['activo'] ?? 'Sí',
|
||||||
|
'País': row['pais'] ?? row['country'] ?? '',
|
||||||
|
'Cobertura': row['cobertura'] ?? '',
|
||||||
|
'Zona': row['zona'] ?? '',
|
||||||
|
'Región': row['region'] ?? row['regional'] ?? '',
|
||||||
|
'Estado': row['estado'] ?? '',
|
||||||
|
'Ciudad': row['ciudad'] ?? '',
|
||||||
|
'Metro de prueba': row['metro_de_prueba'] ?? ''
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const output = XLSX.utils.book_new();
|
const output = XLSX.utils.book_new();
|
||||||
|
|||||||
@@ -3,32 +3,37 @@ import * as XLSX from 'xlsx';
|
|||||||
import { saveAs } from 'file-saver';
|
import { saveAs } from 'file-saver';
|
||||||
import { RouteState } from '../../core/route-state/route';
|
import { RouteState } from '../../core/route-state/route';
|
||||||
import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils';
|
import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils';
|
||||||
|
import { PlanningFilterService } from '../../core/services/planning-filter.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PlanningExportService {
|
export class PlanningExportService {
|
||||||
constructor(private routeState: RouteState) {}
|
constructor(private routeState: RouteState, private filters: PlanningFilterService) {}
|
||||||
|
|
||||||
export(): void {
|
export(): void {
|
||||||
const assigned = this.routeState.getAssignedSnapshot();
|
const assigned = this.filters.filterAssigned(this.routeState.getAssignedSnapshot());
|
||||||
const unassigned = this.routeState.getNoAssignedSnapshot();
|
const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot());
|
||||||
const users = new Map<string, { nombre: string; rutas: number; 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) {
|
||||||
const key = String(route.json.usuario_id);
|
const key = String(route.json.usuario_id);
|
||||||
const current = users.get(key) ?? {
|
const current = users.get(key) ?? {
|
||||||
nombre: route.json.nombre_usuario,
|
nombre: route.json.nombre_usuario,
|
||||||
rutas: 0,
|
visitas: 0,
|
||||||
|
pdvs: new Set<string>(),
|
||||||
distancia: 0,
|
distancia: 0,
|
||||||
trabajo: 0,
|
trabajo: 0,
|
||||||
capacidad: Number(route.json.hora_laboral_semanal_usuario) || 0
|
capacidad: Number(route.json.hora_laboral_semanal_usuario) || 0
|
||||||
};
|
};
|
||||||
current.rutas++;
|
current.visitas++;
|
||||||
|
current.pdvs.add(String(route.json.pdv_id));
|
||||||
current.distancia += Number(route.json.distancia) || 0;
|
current.distancia += Number(route.json.distancia) || 0;
|
||||||
current.trabajo += Number.parseFloat(String(route.json.horas_trabajo ?? '')) || 0;
|
current.trabajo += Number.parseFloat(String(route.json.horas_trabajo ?? '')) || 0;
|
||||||
users.set(key, current);
|
users.set(key, current);
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = assigned.length + unassigned.length;
|
const assignedIds = new Set(assigned.map(route => String(route.json.pdv_id)));
|
||||||
|
const unassignedIds = new Set(unassigned.map(route => String(route.pdv_id)).filter(id => !assignedIds.has(id)));
|
||||||
|
const total = assignedIds.size + unassignedIds.size;
|
||||||
const dailyRoutes = summarizeDailyRoutes(assigned);
|
const 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);
|
||||||
@@ -36,9 +41,9 @@ export class PlanningExportService {
|
|||||||
|
|
||||||
const summary = [
|
const summary = [
|
||||||
{ Indicador: 'PDVs totales', Valor: total },
|
{ Indicador: 'PDVs totales', Valor: total },
|
||||||
{ Indicador: 'PDVs asignados', Valor: assigned.length },
|
{ Indicador: 'PDVs asignados', Valor: assignedIds.size },
|
||||||
{ Indicador: 'PDVs sin asignar', Valor: unassigned.length },
|
{ Indicador: 'PDVs sin asignar', Valor: unassignedIds.size },
|
||||||
{ Indicador: 'Cobertura (%)', Valor: total ? Number(((assigned.length / total) * 100).toFixed(1)) : 0 },
|
{ Indicador: 'Cobertura (%)', Valor: total ? Number(((assignedIds.size / total) * 100).toFixed(1)) : 0 },
|
||||||
{ Indicador: 'Usuarios activos', Valor: users.size },
|
{ Indicador: 'Usuarios activos', Valor: users.size },
|
||||||
{ Indicador: 'Días planificados', Valor: new Set(assigned.map(route => route.json.dia)).size },
|
{ Indicador: 'Días planificados', Valor: new Set(assigned.map(route => route.json.dia)).size },
|
||||||
{ Indicador: 'Distancia total (km)', Valor: Number(totalDistance.toFixed(2)) },
|
{ Indicador: 'Distancia total (km)', Valor: Number(totalDistance.toFixed(2)) },
|
||||||
@@ -71,7 +76,8 @@ export class PlanningExportService {
|
|||||||
return {
|
return {
|
||||||
'Usuario ID': id,
|
'Usuario ID': id,
|
||||||
Usuario: user.nombre,
|
Usuario: user.nombre,
|
||||||
Rutas: user.rutas,
|
PDVs: user.pdvs.size,
|
||||||
|
Visitas: user.visitas,
|
||||||
'Distancia (km)': Number(user.distancia.toFixed(2)),
|
'Distancia (km)': Number(user.distancia.toFixed(2)),
|
||||||
'Horas trabajo': Number(user.trabajo.toFixed(2)),
|
'Horas trabajo': Number(user.trabajo.toFixed(2)),
|
||||||
'Capacidad (h)': user.capacidad,
|
'Capacidad (h)': user.capacidad,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { RouteState } from '../../core/route-state/route';
|
||||||
|
import { ProjectService } from '../../core/services/project.service';
|
||||||
|
import { PlanningFilterService } from '../../core/services/planning-filter.service';
|
||||||
|
import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils';
|
||||||
|
import { recommendationForUnassigned } from '../utils/unassigned-recommendation.utils';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class RoutePdfReportService {
|
||||||
|
constructor(
|
||||||
|
private http: HttpClient,
|
||||||
|
private routeState: RouteState,
|
||||||
|
private projects: ProjectService,
|
||||||
|
private filters: PlanningFilterService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async download(projectId: string): Promise<void> {
|
||||||
|
const [project, assigned, unassigned] = await Promise.all([
|
||||||
|
this.projects.get(projectId),
|
||||||
|
Promise.resolve(this.filters.filterAssigned(this.routeState.getAssignedSnapshot())),
|
||||||
|
Promise.resolve(this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot()))
|
||||||
|
]);
|
||||||
|
const assignedIds = new Set(assigned.map(route => String(route.json.pdv_id)));
|
||||||
|
const pending = unassigned
|
||||||
|
.filter(route => !assignedIds.has(String(route.pdv_id)))
|
||||||
|
.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
|
||||||
|
const dailyRoutes = summarizeDailyRoutes(assigned);
|
||||||
|
const totalPdvs = assignedIds.size + new Set(pending.map(route => String(route.pdv_id))).size;
|
||||||
|
const payload = {
|
||||||
|
project: { id: project.id, name: project.nombre, country: project.pais ?? '', description: project.descripcion ?? '' },
|
||||||
|
filters: this.filters.snapshot(),
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
summary: {
|
||||||
|
totalPdvs,
|
||||||
|
assignedPdvs: assignedIds.size,
|
||||||
|
unassignedPdvs: pending.length,
|
||||||
|
coveragePercent: totalPdvs ? Number(((assignedIds.size / totalPdvs) * 100).toFixed(1)) : 0,
|
||||||
|
totalDistanceKm: Number(dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0).toFixed(2)),
|
||||||
|
totalWorkHours: Number(dailyRoutes.reduce((sum, route) => sum + route.workHours, 0).toFixed(2))
|
||||||
|
},
|
||||||
|
assignedRoutes: sequenceRoutes(assigned).map(segment => ({
|
||||||
|
usuarioId: segment.route.json.usuario_id,
|
||||||
|
usuario: segment.route.json.nombre_usuario,
|
||||||
|
pdvId: segment.route.json.pdv_id,
|
||||||
|
pdv: segment.route.json.nombre_pdv,
|
||||||
|
dia: segment.route.json.dia,
|
||||||
|
orden: segment.order,
|
||||||
|
origen: segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}`,
|
||||||
|
distanciaKm: segment.route.json.distancia,
|
||||||
|
desplazamientoHoras: segment.route.json.horas_desplazamiento,
|
||||||
|
horasTrabajo: segment.route.json.horas_trabajo,
|
||||||
|
cobertura: segment.route.json.cobertura ?? '',
|
||||||
|
zona: segment.route.json.zona ?? '',
|
||||||
|
region: segment.route.json.region ?? '',
|
||||||
|
estado: segment.route.json.estado ?? '',
|
||||||
|
ciudad: segment.route.json.ciudad ?? ''
|
||||||
|
})),
|
||||||
|
unassignedPdvs: pending
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const pdf = await firstValueFrom(this.http.post(environment.n8nPdfWebhookUrl, payload, { responseType: 'blob' }));
|
||||||
|
const fileName = `rutas_${this.fileName(project.nombre)}.pdf`;
|
||||||
|
saveAs(pdf, fileName);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(await this.messageForError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fileName(value: string): string {
|
||||||
|
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'proyecto';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async messageForError(error: unknown): Promise<string> {
|
||||||
|
const response = error as HttpErrorResponse;
|
||||||
|
if (response.error instanceof Blob) {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(await response.error.text()) as { message?: string; error?: string };
|
||||||
|
return body.message ?? body.error ?? 'No se pudo generar el PDF.';
|
||||||
|
} catch { /* use fallback */ }
|
||||||
|
}
|
||||||
|
return response.error?.message ?? response.message ?? 'No se pudo generar el PDF.';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,37 @@
|
|||||||
|
const assignedColors = new Map<string, string>();
|
||||||
|
const usedColors = new Set<string>();
|
||||||
|
|
||||||
|
function hashId(value: string): number {
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let index = 0; index < value.length; index++) {
|
||||||
|
hash ^= value.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeColor(id: string): string {
|
||||||
|
const existing = assignedColors.get(id);
|
||||||
|
if (existing) return existing;
|
||||||
|
const hash = hashId(id);
|
||||||
|
let attempt = 0;
|
||||||
|
let color = '';
|
||||||
|
do {
|
||||||
|
// Restricted to dark navy/indigo/violet/plum. Avoids red, cyan, green, yellow and orange.
|
||||||
|
const hue = 225 + ((hash + attempt * 37) % 106);
|
||||||
|
const saturation = 58 + (((hash >>> 8) + attempt * 11) % 27);
|
||||||
|
const lightness = 27 + (((hash >>> 16) + attempt * 7) % 17);
|
||||||
|
color = `hsl(${hue} ${saturation}% ${lightness}%)`;
|
||||||
|
attempt++;
|
||||||
|
} while (usedColors.has(color));
|
||||||
|
assignedColors.set(id, color);
|
||||||
|
usedColors.add(color);
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
export function colorForUsuario(usuarioId: string | number): { color: string; glow: string } {
|
export function colorForUsuario(usuarioId: string | number): { color: string; glow: string } {
|
||||||
const str = String(usuarioId);
|
const color = routeColor(String(usuarioId));
|
||||||
let hash = 0;
|
return { color, glow: color };
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
|
||||||
}
|
|
||||||
hash = Math.abs(hash);
|
|
||||||
// Use a mix of GLM palette + generated HSL to avoid collisions but keep brand feel
|
|
||||||
const glmPalette = ['#6CC24A', '#4F758B', '#FF6A13', '#C4D600', '#5B7F95', '#A4D65E', '#6B8FA3'];
|
|
||||||
const idx = hash % glmPalette.length;
|
|
||||||
// If palette length covers typical usuarios (50 max) collisions low; fallback to HSL for overflow
|
|
||||||
if (hash % 2 === 0) {
|
|
||||||
return { color: glmPalette[idx], glow: glmPalette[idx] };
|
|
||||||
}
|
|
||||||
const h = hash % 360;
|
|
||||||
const s = 65 + (hash % 20);
|
|
||||||
const l = 48 + (hash % 10);
|
|
||||||
const hsl = `hsl(${h} ${s}% ${l}%)`;
|
|
||||||
return { color: hsl, glow: hsl };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markerStyleForUsuario(usuarioId: string | number): string {
|
export function markerStyleForUsuario(usuarioId: string | number): string {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { RouteNoAssignedResponse } from '../../core/models/route.model';
|
||||||
|
|
||||||
|
export function recommendationForUnassigned(route: RouteNoAssignedResponse): string {
|
||||||
|
const reason = String(route.motivo ?? '').toLowerCase();
|
||||||
|
if (/coordenad|latitud|longitud/.test(reason)) return 'Corregir las coordenadas del PDV antes de volver a planificar.';
|
||||||
|
if (/capacidad|visitas maxima|visitas máximas|jornada/.test(reason)) {
|
||||||
|
return 'Redistribuir PDVs, ampliar capacidad o incorporar personal adicional.';
|
||||||
|
}
|
||||||
|
if (/distancia|desplazamiento|recorrido|lejan/.test(reason)) {
|
||||||
|
return 'Asignar personal más cercano o revisar la cobertura y zona del PDV.';
|
||||||
|
}
|
||||||
|
if (/empleado|usuario|disponib|compatible|sin asign/.test(reason)) {
|
||||||
|
return 'Reubicar o contratar personal disponible para esta zona.';
|
||||||
|
}
|
||||||
|
return 'Revisar datos del PDV, disponibilidad de personal y reglas de planificación.';
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino',
|
n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino',
|
||||||
|
n8nPdfWebhookUrl: 'http://localhost:5678/webhook/generar-rutas-pdf',
|
||||||
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
||||||
orsMexicoBaseUrl: 'http://localhost:18081/ors/v2',
|
orsMexicoBaseUrl: 'http://localhost:18081/ors/v2',
|
||||||
requestTimeoutMs: 180000,
|
requestTimeoutMs: 180000,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino',
|
n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino',
|
||||||
|
n8nPdfWebhookUrl: 'http://localhost:5678/webhook/generar-rutas-pdf',
|
||||||
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
orsBaseUrl: 'http://localhost:18080/ors/v2',
|
||||||
orsMexicoBaseUrl: 'http://localhost:18081/ors/v2',
|
orsMexicoBaseUrl: 'http://localhost:18081/ors/v2',
|
||||||
requestTimeoutMs: 180000,
|
requestTimeoutMs: 180000,
|
||||||
|
|||||||
@@ -46,6 +46,24 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Leaflet-generated marker elements do not receive Angular's scoped attributes. */
|
||||||
|
.custom-marker {
|
||||||
|
background: none !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-marker .marker-inner {
|
||||||
|
display: flex;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
||||||
|
font: 700 12px/1 Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--glm-verde: #6CC24A;
|
--glm-verde: #6CC24A;
|
||||||
--glm-verde-claro: #A4D65E;
|
--glm-verde-claro: #A4D65E;
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
create table if not exists public.rutero_proyecto_empleados (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
proyecto_id uuid not null references public.rutero_proyectos(id) on delete cascade,
|
||||||
|
employee_code text not null,
|
||||||
|
nombre text not null,
|
||||||
|
usuario text,
|
||||||
|
email text,
|
||||||
|
perfil text,
|
||||||
|
pais text,
|
||||||
|
regional text,
|
||||||
|
latitud double precision not null,
|
||||||
|
longitud double precision not null,
|
||||||
|
horas_por_dia numeric(5,2) not null default 8,
|
||||||
|
dias_por_semana integer not null default 5,
|
||||||
|
jornada_codigo text not null default 'LUN_VIE_40',
|
||||||
|
horas_descanso_por_dia numeric(4,2) not null default 1,
|
||||||
|
dias_descanso jsonb not null default '[]'::jsonb,
|
||||||
|
visitas_maximas_por_dia integer not null default 3,
|
||||||
|
activo boolean not null default true,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique (proyecto_id, employee_code),
|
||||||
|
constraint rutero_proyecto_empleados_jornada_valida check (jornada_codigo in ('LUN_DOM_44', 'LUN_SAB_40', 'LUN_SAB_44', 'LUN_VIE_40')),
|
||||||
|
constraint rutero_proyecto_empleados_descanso_valido check (horas_descanso_por_dia >= 0 and horas_descanso_por_dia <= 3),
|
||||||
|
constraint rutero_proyecto_empleados_visitas_validas check (visitas_maximas_por_dia > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists rutero_proyecto_empleados_proyecto_idx
|
||||||
|
on public.rutero_proyecto_empleados (proyecto_id);
|
||||||
|
|
||||||
|
-- Preserve current behavior once: copy the existing global catalog into every
|
||||||
|
-- existing project. From this point onward each copy is edited independently.
|
||||||
|
insert into public.rutero_proyecto_empleados (
|
||||||
|
proyecto_id, employee_code, nombre, usuario, email, perfil, pais, regional,
|
||||||
|
latitud, longitud, horas_por_dia, dias_por_semana, jornada_codigo,
|
||||||
|
horas_descanso_por_dia, dias_descanso, visitas_maximas_por_dia, activo
|
||||||
|
)
|
||||||
|
select proyecto.id, empleado.employee_code, empleado.nombre, empleado.usuario,
|
||||||
|
empleado.email, empleado.perfil, empleado.pais, empleado.regional,
|
||||||
|
empleado.latitud, empleado.longitud, empleado.horas_por_dia,
|
||||||
|
empleado.dias_por_semana, empleado.jornada_codigo,
|
||||||
|
empleado.horas_descanso_por_dia, empleado.dias_descanso,
|
||||||
|
empleado.visitas_maximas_por_dia, empleado.activo
|
||||||
|
from public.rutero_proyectos proyecto
|
||||||
|
cross join public.rutero_empleados empleado
|
||||||
|
on conflict (proyecto_id, employee_code) do nothing;
|
||||||
|
|
||||||
|
alter table public.rutero_proyecto_empleados enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists "Consultar empleados de proyectos propios" on public.rutero_proyecto_empleados;
|
||||||
|
drop policy if exists "Crear empleados en proyectos propios" on public.rutero_proyecto_empleados;
|
||||||
|
drop policy if exists "Editar empleados de proyectos propios" on public.rutero_proyecto_empleados;
|
||||||
|
drop policy if exists "Eliminar empleados de proyectos propios" on public.rutero_proyecto_empleados;
|
||||||
|
|
||||||
|
create policy "Consultar empleados de proyectos propios"
|
||||||
|
on public.rutero_proyecto_empleados for select to authenticated
|
||||||
|
using (exists (
|
||||||
|
select 1 from public.rutero_proyectos proyecto
|
||||||
|
where proyecto.id = proyecto_id and proyecto.propietario_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
create policy "Crear empleados en proyectos propios"
|
||||||
|
on public.rutero_proyecto_empleados for insert to authenticated
|
||||||
|
with check (exists (
|
||||||
|
select 1 from public.rutero_proyectos proyecto
|
||||||
|
where proyecto.id = proyecto_id and proyecto.propietario_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
create policy "Editar empleados de proyectos propios"
|
||||||
|
on public.rutero_proyecto_empleados for update to authenticated
|
||||||
|
using (exists (
|
||||||
|
select 1 from public.rutero_proyectos proyecto
|
||||||
|
where proyecto.id = proyecto_id and proyecto.propietario_id = auth.uid()
|
||||||
|
))
|
||||||
|
with check (exists (
|
||||||
|
select 1 from public.rutero_proyectos proyecto
|
||||||
|
where proyecto.id = proyecto_id and proyecto.propietario_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
create policy "Eliminar empleados de proyectos propios"
|
||||||
|
on public.rutero_proyecto_empleados for delete to authenticated
|
||||||
|
using (exists (
|
||||||
|
select 1 from public.rutero_proyectos proyecto
|
||||||
|
where proyecto.id = proyecto_id and proyecto.propietario_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
create unique index if not exists rutero_proyecto_pdvs_proyecto_pdv_idx
|
||||||
|
on public.rutero_proyecto_pdvs (proyecto_id, pdv_id);
|
||||||
|
|
||||||
|
alter table public.rutero_proyecto_pdvs enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists "Gestionar PDVs de proyectos propios" on public.rutero_proyecto_pdvs;
|
||||||
|
create policy "Gestionar PDVs de proyectos propios"
|
||||||
|
on public.rutero_proyecto_pdvs for all to authenticated
|
||||||
|
using (exists (
|
||||||
|
select 1 from public.rutero_proyectos proyecto
|
||||||
|
where proyecto.id = proyecto_id and proyecto.propietario_id = auth.uid()
|
||||||
|
))
|
||||||
|
with check (exists (
|
||||||
|
select 1 from public.rutero_proyectos proyecto
|
||||||
|
where proyecto.id = proyecto_id and proyecto.propietario_id = auth.uid()
|
||||||
|
));
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
alter table public.rutero_proyectos
|
||||||
|
add column if not exists pais text;
|
||||||
|
|
||||||
|
-- Preserve the country of projects created before pais was stored explicitly.
|
||||||
|
update public.rutero_proyectos proyecto
|
||||||
|
set pais = source.pais
|
||||||
|
from (
|
||||||
|
select distinct on (pdv.proyecto_id)
|
||||||
|
pdv.proyecto_id,
|
||||||
|
nullif(trim(pdv.pdv_data ->> 'País'), '') as pais
|
||||||
|
from public.rutero_proyecto_pdvs pdv
|
||||||
|
where nullif(trim(pdv.pdv_data ->> 'País'), '') is not null
|
||||||
|
order by pdv.proyecto_id, pdv.created_at
|
||||||
|
) source
|
||||||
|
where proyecto.id = source.proyecto_id
|
||||||
|
and proyecto.pais is null;
|
||||||
|
|
||||||
|
alter table public.rutero_proyectos
|
||||||
|
alter column estado drop not null,
|
||||||
|
alter column estado drop default;
|
||||||
|
|
||||||
|
update public.rutero_proyectos
|
||||||
|
set estado = null
|
||||||
|
where estado = 'borrador';
|
||||||
Reference in New Issue
Block a user