diff --git a/n8n-workflow/GenerarReporteRutasPDF.json b/n8n-workflow/GenerarReporteRutasPDF.json new file mode 100644 index 0000000..55ebdfa --- /dev/null +++ b/n8n-workflow/GenerarReporteRutasPDF.json @@ -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]) => `${escape(key)}: ${escape(value)}`).join('');\nconst routeRows = payload.assignedRoutes.map(route => `${escape(route.usuario)}${escape(route.dia)}${escape(route.orden)}${escape(route.pdv)}${escape(route.origen)}${escape(route.distanciaKm)} km${escape(route.horasTrabajo)} h`).join('') || 'Sin rutas asignadas para los filtros seleccionados.';\nconst unassignedRows = payload.unassignedPdvs.map(route => `${escape(route.pdv_id)}${escape(route.pdv_nombre)}${escape(route.minimo_horas_semana)} h${escape(route.motivo)}${escape(route.recomendacion)}`).join('') || 'No hay PDVs sin asignar para los filtros seleccionados.';\nconst summary = payload.summary ?? {};\nconst html = `

Reporte de rutas

${escape(payload.project.name)}
${escape(payload.project.country)}${payload.project.description ? `
${escape(payload.project.description)}` : ''}
Generado: ${escape(payload.generatedAt)}

${filters ? `
Filtros aplicados:
${filters}
` : '

Filtros aplicados: Ninguno

'}
${escape(summary.totalPdvs)}PDVs totales
${escape(summary.assignedPdvs)}PDVs asignados
${escape(summary.unassignedPdvs)}PDVs sin asignar
${escape(summary.coveragePercent)}%Cobertura
${escape(summary.totalDistanceKm)} kmDistancia
${escape(summary.totalWorkHours)} hTrabajo

Rutas asignadas

${routeRows}
UsuarioDíaOrdenPDVOrigenDistanciaTrabajo

PDVs sin asignar

${unassignedRows}
PDV IDPDVHoras/semanaMotivoRecomendación

Route Planner · GomezLee Marketing

`;\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": [] +} diff --git a/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json index f7cf25b..ab44024 100644 --- a/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json +++ b/n8n-workflow/OptimizadorRutasPDV TEST - Fixed v2.json @@ -126,7 +126,7 @@ }, { "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", "typeVersion": 2, @@ -151,8 +151,10 @@ { "parameters": { "respondWith": "json", - "responseBody": "={{ JSON.stringify({ error: $json[0].message || $json[1].message || 'Error de validación' }) }}", - "options": {} + "responseBody": "={{ { error: $json.error?.message || $json.error || $json.message || 'Error de validación' } }}", + "options": { + "responseCode": 400 + } }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.5, diff --git a/pdf-app/docker-compose.yml b/pdf-app/docker-compose.yml new file mode 100644 index 0000000..8bc11a7 --- /dev/null +++ b/pdf-app/docker-compose.yml @@ -0,0 +1,7 @@ +services: + gotenberg: + image: gotenberg/gotenberg:8 + container_name: route-planner-pdf + ports: + - "3000:3000" + restart: unless-stopped diff --git a/route-planner/src/app/app.routes.ts b/route-planner/src/app/app.routes.ts index 59395ce..b13cf52 100644 --- a/route-planner/src/app/app.routes.ts +++ b/route-planner/src/app/app.routes.ts @@ -7,6 +7,7 @@ import { authGuard } from './core/services/auth.guard'; import { EmployeesComponent } from './private/employees/employees.component'; import { ProjectListComponent } from './private/projects/project-list.component'; import { NewProjectComponent } from './private/projects/new-project.component'; +import { PdvsComponent } from './private/pdvs/pdvs.component'; export const routes: Routes = [ { path: '', redirectTo: 'login', pathMatch: 'full' }, @@ -16,7 +17,8 @@ export const routes: Routes = [ { path: 'proyectos/:id', component: HomeComponent, pathMatch: 'full', canActivate: [authGuard] }, { path: 'proyectos/:id/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] }, { path: 'proyectos/:id/ruta', component: AssignedRouteDetailComponent, canActivate: [authGuard] }, - { path: '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/mapa', component: RouteOverviewMapComponent, canActivate: [authGuard] }, { path: '**', redirectTo: 'login' } diff --git a/route-planner/src/app/core/models/employee.model.ts b/route-planner/src/app/core/models/employee.model.ts index f7d2800..a9e4fa3 100644 --- a/route-planner/src/app/core/models/employee.model.ts +++ b/route-planner/src/app/core/models/employee.model.ts @@ -1,5 +1,6 @@ export interface Employee { id: string; + proyecto_id: string; employee_code: string; nombre: string; usuario: string | null; @@ -18,4 +19,4 @@ export interface Employee { activo: boolean; } -export type EmployeeInput = Omit; +export type EmployeeInput = Omit; diff --git a/route-planner/src/app/core/models/project.model.ts b/route-planner/src/app/core/models/project.model.ts index f4d8612..95e79ce 100644 --- a/route-planner/src/app/core/models/project.model.ts +++ b/route-planner/src/app/core/models/project.model.ts @@ -2,8 +2,9 @@ export interface RuteroProyecto { id: string; nombre: string; descripcion: string | null; + pais: string | null; propietario_id: string; - estado: 'borrador' | 'optimizado' | 'archivado'; + estado: 'optimizado' | 'archivado' | null; version: number; created_at: string; updated_at: string; diff --git a/route-planner/src/app/core/models/route.model.ts b/route-planner/src/app/core/models/route.model.ts index 4956a6d..03cff79 100644 --- a/route-planner/src/app/core/models/route.model.ts +++ b/route-planner/src/app/core/models/route.model.ts @@ -31,6 +31,7 @@ export interface RouteAssignedJson { horas_por_dia_usuario?: number; horas_descanso_por_dia?: number; jornada_codigo?: string; + cobertura?: string; pais?: string; ciudad?: string; estado?: string; @@ -52,6 +53,7 @@ export interface RouteAssignedJson { latitud: number, longitud: number, motivo: string; + recomendacion?: string; pais?: string; ciudad?: string; estado?: string; diff --git a/route-planner/src/app/core/route-state/route.ts b/route-planner/src/app/core/route-state/route.ts index 8209fa8..0f95cf2 100644 --- a/route-planner/src/app/core/route-state/route.ts +++ b/route-planner/src/app/core/route-state/route.ts @@ -21,17 +21,18 @@ export interface UnassignedAssignment { @Injectable({ providedIn: 'root' }) export class RouteState { + private projectId: string | null = null; private loading = new BehaviorSubject(false); loading$ = this.loading.asObservable(); - private assignedRoutes = new BehaviorSubject(this.loadAssigned()); + private assignedRoutes = new BehaviorSubject([]); assignedRoutes$ = this.assignedRoutes.asObservable(); - private noAssignedRoutes = new BehaviorSubject(this.loadNoAssigned()); + private noAssignedRoutes = new BehaviorSubject([]); noAssignedRoutes$ = this.noAssignedRoutes.asObservable(); - private selectedRoute = new BehaviorSubject(this.loadSelected()); + private selectedRoute = new BehaviorSubject(null); selectedRoute$ = this.selectedRoute.asObservable(); resetLoading() { @@ -42,6 +43,15 @@ export class RouteState { this.loading.next(value); } + useProject(projectId: string): void { + if (!projectId || this.projectId === projectId) return; + this.projectId = projectId; + this.assignedRoutes.next(this.loadAssigned()); + this.noAssignedRoutes.next(this.loadNoAssigned()); + this.selectedRoute.next(this.loadSelected()); + this.loading.next(false); + } + setAssignedRoutes(data: RouteAssignedResponse[]) { this.assignedRoutes.next(data); this.persist(STORAGE_ASSIGNED, data); @@ -246,13 +256,14 @@ export class RouteState { return this.noAssignedRoutes.getValue(); } - findRoute(pdvId: string | number, usuarioId?: string): RouteAssignedResponse | null { + findRoute(pdvId: string | number, usuarioId?: string, day?: string): RouteAssignedResponse | null { const pid = String(pdvId); const routes = this.getAssignedSnapshot(); const found = routes.find(r => { const jp = r.json; if (String(jp.pdv_id) !== pid) return false; if (usuarioId && String(jp.usuario_id) !== String(usuarioId)) return false; + if (day && jp.dia !== day) return false; return true; }); return found ?? null; @@ -282,17 +293,18 @@ export class RouteState { this.noAssignedRoutes.next([]); this.selectedRoute.next(null); try { - sessionStorage.removeItem(STORAGE_ASSIGNED); - sessionStorage.removeItem(STORAGE_ASSIGNED_ORIGINAL); - sessionStorage.removeItem(STORAGE_NO_ASSIGNED); - sessionStorage.removeItem(STORAGE_NO_ASSIGNED_ORIGINAL); - sessionStorage.removeItem(STORAGE_SELECTED); + sessionStorage.removeItem(this.storageKey(STORAGE_ASSIGNED)); + sessionStorage.removeItem(this.storageKey(STORAGE_ASSIGNED_ORIGINAL)); + sessionStorage.removeItem(this.storageKey(STORAGE_NO_ASSIGNED)); + sessionStorage.removeItem(this.storageKey(STORAGE_NO_ASSIGNED_ORIGINAL)); + sessionStorage.removeItem(this.storageKey(STORAGE_SELECTED)); } catch { /* ignore */ } } private persist(key: string, data: unknown) { + if (!this.projectId) return; try { - sessionStorage.setItem(key, JSON.stringify(data)); + sessionStorage.setItem(this.storageKey(key), JSON.stringify(data)); } catch { /* ignore storage errors */ } } @@ -308,7 +320,7 @@ export class RouteState { private loadAssigned(): RouteAssignedResponse[] { try { - const raw = sessionStorage.getItem(STORAGE_ASSIGNED); + const raw = sessionStorage.getItem(this.storageKey(STORAGE_ASSIGNED)); if (raw) return JSON.parse(raw) as RouteAssignedResponse[]; } catch { /* ignore */ } return []; @@ -316,7 +328,7 @@ export class RouteState { private loadOriginalAssigned(): RouteAssignedResponse[] { try { - const raw = sessionStorage.getItem(STORAGE_ASSIGNED_ORIGINAL); + const raw = sessionStorage.getItem(this.storageKey(STORAGE_ASSIGNED_ORIGINAL)); if (raw) return JSON.parse(raw) as RouteAssignedResponse[]; } catch { /* ignore */ } return this.loadAssigned(); @@ -324,7 +336,7 @@ export class RouteState { private loadNoAssigned(): RouteNoAssignedResponse[] { try { - const raw = sessionStorage.getItem(STORAGE_NO_ASSIGNED); + const raw = sessionStorage.getItem(this.storageKey(STORAGE_NO_ASSIGNED)); if (raw) return JSON.parse(raw) as RouteNoAssignedResponse[]; } catch { /* ignore */ } return []; @@ -332,9 +344,13 @@ export class RouteState { private loadSelected(): RouteAssignedResponse | null { try { - const raw = sessionStorage.getItem(STORAGE_SELECTED); + const raw = sessionStorage.getItem(this.storageKey(STORAGE_SELECTED)); if (raw) return JSON.parse(raw) as RouteAssignedResponse; } catch { /* ignore */ } return null; } + + private storageKey(key: string): string { + return this.projectId ? `${key}:${this.projectId}` : key; + } } diff --git a/route-planner/src/app/core/services/employee.service.ts b/route-planner/src/app/core/services/employee.service.ts index bdb6a2f..99106e8 100644 --- a/route-planner/src/app/core/services/employee.service.ts +++ b/route-planner/src/app/core/services/employee.service.ts @@ -7,13 +7,13 @@ import { supabaseClient } from './supabase.client'; @Injectable({ providedIn: 'root' }) export class EmployeeService { private readonly client = supabaseClient; - private readonly table = 'rutero_empleados'; + private readonly table = 'rutero_proyecto_empleados'; - async list(): Promise { + async list(projectId: string): Promise { const { data: sessionData, error: sessionError } = await this.client.auth.getSession(); if (sessionError) throw sessionError; if (!sessionData.session) throw new Error('No hay una sesión Supabase activa. Cierra sesión e inicia sesión nuevamente.'); - const { data, error } = await this.client.from(this.table).select('*').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}`); return (data ?? []).map(row => ({ ...row, @@ -23,13 +23,14 @@ export class EmployeeService { })) as Employee[]; } - async create(employee: EmployeeInput): Promise { - const { error } = await this.client.from(this.table).insert(employee); + async create(projectId: string, employee: EmployeeInput): Promise { + const { error } = await this.client.from(this.table).insert({ ...employee, proyecto_id: projectId }); if (error) throw error; } - async upsert(employees: EmployeeInput[]): Promise { - const { error } = await this.client.from(this.table).upsert(employees, { onConflict: 'employee_code' }); + async upsert(projectId: string, employees: EmployeeInput[]): Promise { + 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; } @@ -43,13 +44,12 @@ export class EmployeeService { if (error) throw error; } - async active(country = ''): Promise { - const expectedCountry = this.normalizeCountry(country); - return (await this.list()).filter(employee => employee.activo && (!expectedCountry || this.normalizeCountry(employee.pais ?? '') === expectedCountry)); + async active(projectId: string, country?: string): Promise { + return (await this.list(projectId)).filter(employee => employee.activo && (!country || this.sameCountry(employee.pais, country))); } - async activeEmployeesFile(country = ''): Promise { - const employees = await this.active(country); + async activeEmployeesFile(projectId: string, country?: string): Promise { + const employees = await this.active(projectId, country); const rows = employees.map(employee => ({ ID: employee.employee_code, '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' }); } - private normalizeCountry(country: string): string { - const normalized = country.normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().toUpperCase(); - if (normalized.includes('MEXICO')) return 'MEXICO'; - if (normalized.includes('DOMINICANA')) return 'REPUBLICA DOMINICANA'; - return normalized; + private sameCountry(employeeCountry: string | null, projectCountry: string): boolean { + const normalize = (value: string | null) => String(value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z]/gi, '').toLowerCase(); + const employee = normalize(employeeCountry); + const project = normalize(projectCountry); + if (employee === project) return true; + return (employee === 'mexico' && project === 'mexico') || (employee === 'republicadominicana' && project === 'republicadominicana'); } + } diff --git a/route-planner/src/app/core/services/planning-filter.service.ts b/route-planner/src/app/core/services/planning-filter.service.ts new file mode 100644 index 0000000..d460fef --- /dev/null +++ b/route-planner/src/app/core/services/planning-filter.service.ts @@ -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({ ...this.empty }); + readonly filters$ = this.filtersSubject.asObservable(); + private metadata = new Map>(); + + setPdvs(rows: Record[]): 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)); + } + + filterUnassigned(routes: RouteNoAssignedResponse[]): RouteNoAssignedResponse[] { + return routes.filter(route => this.matches(String(route.pdv_id), route as unknown as Record)); + } + + private matches(id: string, fallback: Record): 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; + }); + } +} diff --git a/route-planner/src/app/core/services/planning-input.service.ts b/route-planner/src/app/core/services/planning-input.service.ts index c686c76..b12afee 100644 --- a/route-planner/src/app/core/services/planning-input.service.ts +++ b/route-planner/src/app/core/services/planning-input.service.ts @@ -2,23 +2,27 @@ import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class PlanningInputService { + private projectId: string | null = null; private pdvFile: File | null = null; private usuariosFile: File | null = null; - setFiles(pdvFile: File, usuariosFile: File): void { + setFiles(projectId: string, pdvFile: File, usuariosFile: File): void { + this.projectId = projectId; this.pdvFile = pdvFile; this.usuariosFile = usuariosFile; } - getFormData(): FormData | null { - if (!this.pdvFile || !this.usuariosFile) return null; + getFormData(projectId: string): FormData | null { + if (this.projectId !== projectId || !this.pdvFile || !this.usuariosFile) return null; const data = new FormData(); data.append('pdv_file', this.pdvFile, this.pdvFile.name); data.append('usuarios_file', this.usuariosFile, this.usuariosFile.name); return data; } - clear(): void { + clear(projectId?: string): void { + if (projectId && this.projectId !== projectId) return; + this.projectId = null; this.pdvFile = null; this.usuariosFile = null; } diff --git a/route-planner/src/app/core/services/project.service.ts b/route-planner/src/app/core/services/project.service.ts index 5cd2fd3..05f36b1 100644 --- a/route-planner/src/app/core/services/project.service.ts +++ b/route-planner/src/app/core/services/project.service.ts @@ -23,7 +23,7 @@ export class ProjectService { } async remove(id: string): Promise { - const dependentTables = ['rutero_proyecto_rutas', 'rutero_proyecto_pdvs']; + const dependentTables = ['rutero_proyecto_rutas', 'rutero_proyecto_empleados', 'rutero_proyecto_pdvs']; for (const table of dependentTables) { const { error } = await supabaseClient.from(table).delete().eq('proyecto_id', id); if (error) throw error; @@ -32,7 +32,7 @@ export class ProjectService { if (error) throw error; } - async create(nombre: string, rows: Record[]): Promise { + async create(nombre: string, descripcion: string, pais: string): Promise { const { data: sessionData, error: sessionError } = await supabaseClient.auth.getSession(); if (sessionError) throw sessionError; const ownerId = sessionData.session?.user.id; @@ -42,27 +42,18 @@ export class ProjectService { const project: RuteroProyecto = { id: projectId, nombre: nombre.trim(), - descripcion: null, + descripcion: descripcion.trim() || null, + pais: pais.trim(), propietario_id: ownerId, - estado: 'borrador', + estado: null, version: 1, created_at: now, updated_at: now }; const { error } = await supabaseClient .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; - 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; } @@ -72,8 +63,37 @@ export class ProjectService { return (data ?? []) as RuteroProyectoPdv[]; } - async projectInput(id: string): Promise<{ file: File; country: string }> { - const pdvs = await this.pdvs(id); + async upsertPdvs(projectId: string, rows: Record[]): Promise { + 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): Promise { + 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): Promise { + 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 { + 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[] }> { + const [project, pdvs] = await Promise.all([this.get(id), this.pdvs(id)]); const rows = pdvs.map(pdv => pdv.pdv_data); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), 'PDVs'); @@ -84,7 +104,8 @@ export class ProjectService { })?.[1]).find(value => String(value ?? '').trim()); return { 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 }; } diff --git a/route-planner/src/app/private/employees/employees.component.css b/route-planner/src/app/private/employees/employees.component.css index f9193f6..1f26c83 100644 --- a/route-planner/src/app/private/employees/employees.component.css +++ b/route-planner/src/app/private/employees/employees.component.css @@ -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); } .employees-page { max-width: 1200px; margin: 0 auto; padding: 24px; } .employees-page header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; border-bottom: 3px solid var(--glm-verde); padding-bottom: 12px; } +.page-back-button { display:inline-flex; align-items:center; gap:6px; margin-bottom:18px; padding:8px 11px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:700 .82rem Arial,sans-serif; }.page-back-button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); } .employees-page h1, .employees-page h2 { margin: 0; color: var(--glm-azul); } .employees-page h1 { font-size: 1.45rem; }.employees-page h2 { font-size: 1rem; } .employees-page header p, .weekly-capacity { color: var(--glm-acero); } @@ -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; } .form-actions { display: flex; gap: 8px; }.form-actions button:first-child { background: var(--glm-azul); color: #fff; }.form-error { color: #c9252d; font-size: .82rem; }.table-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }.table-wrap { overflow-x: auto; margin-top: 12px; }table { width: 100%; min-width: 920px; border-collapse: collapse; font-size: .82rem; }th { padding: 9px; background: var(--glm-azul); color: #fff; text-align: left; }td { padding: 9px; border-bottom: 1px solid var(--glm-gris-claro); color: var(--glm-gris-oscuro); }.status { color: var(--glm-verde); font-weight: 700; }.status.inactive { color: #c9252d; }.row-actions { display: flex; gap: 5px; }.row-actions .delete { color: #c9252d; }.loading { color: var(--glm-acero); }.rest-days-button { max-width: 155px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.rest-days-picker { display: grid; gap: 4px; margin-top: 6px; padding: 6px; border: 1px solid var(--glm-gris-medio); background: #fff; }.rest-days-picker label { font-size: .72rem; white-space: nowrap; } .editing-row { background: var(--glm-verde-bg); }.editing-row select, .inline-number { width: 100%; min-width: 92px; box-sizing: border-box; padding: 7px; border: 1px solid var(--glm-verde); border-radius: 4px; font: inherit; }.inline-number { max-width: 100px; }.inline-picker { min-width: 130px; margin: 0; }.editing-row .row-actions { flex-wrap: wrap; } +.table-header > div { display:flex; align-items:center; gap:8px; }.selected-count { color:var(--glm-azul); font-size:.78rem; font-weight:700; }.select-column { width:38px; }.bulk-actions { display:flex; flex-wrap:wrap; align-items:end; gap:10px; margin:12px 0; padding:12px; border:1px solid var(--glm-verde-claro); background:var(--glm-verde-bg); }.bulk-actions > label,.bulk-actions fieldset { display:grid; gap:4px; margin:0; color:var(--glm-azul); font-size:.72rem; font-weight:700; text-transform:uppercase; }.bulk-actions input,.bulk-actions select { min-height:32px; padding:5px 7px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; font:inherit; }.bulk-actions fieldset { grid-template-columns:repeat(2,max-content); gap:5px 8px; padding:6px 8px; text-transform:none; }.bulk-actions legend { padding:0 3px; text-transform:uppercase; }.bulk-actions fieldset label { font-size:.72rem; font-weight:400; }.bulk-actions fieldset input { min-height:0; padding:0; }.bulk-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:7px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.bulk-actions button:hover:not(:disabled) { background:#fff; border-color:var(--glm-verde); }.bulk-actions button.delete { color:#c9252d; } @media (max-width: 760px) { .employees-page { padding: 16px; }.employee-form-grid { grid-template-columns: 1fr; }.employees-topbar { padding: 9px 16px; }.topbar-actions button { font-size: 0; }.topbar-actions button i { font-size: .9rem; } } diff --git a/route-planner/src/app/private/employees/employees.component.html b/route-planner/src/app/private/employees/employees.component.html index a953774..1c386c1 100644 --- a/route-planner/src/app/private/employees/employees.component.html +++ b/route-planner/src/app/private/employees/employees.component.html @@ -1,50 +1,12 @@
GomezLee Marketing Route Planner -
+
-
+

Gestionar empleados

{{ activeEmployees }} activos de {{ employees.length }} empleados registrados

{{ importMessage }}

- -
-

Nuevo empleado

-
-
- - - - - - - - - - - - - - -
-

Capacidad semanal: {{ selectedScheduleHours }} h · Capacidad diaria efectiva: {{ form.horas_por_dia - form.horas_descanso_por_dia | number:'1.0-1' }} h

-

{{ error }}

-
-
-
- -
-

Empleados registrados

-

{{ error }}

-

Cargando empleados...

-
- - - - - - -
IDNombreRegionalJornadaDescanso/díaDías de descansoVisitas/díaEstadoAcciones
{{ employee.employee_code }}{{ employee.nombre }}{{ employee.regional || '-' }}{{ scheduleLabel(employee) }}{{ employee.horas_descanso_por_dia }} h
{{ employee.dias_descanso.length ? employee.dias_descanso.join(', ') : 'Ninguno' }}
{{ employee.visitas_maximas_por_dia }}{{ employee.activo ? 'Activo' : 'Inactivo' }}
No hay empleados registrados.
-
-
+

Nuevo empleado

Capacidad semanal: {{ selectedScheduleHours }} h · Capacidad diaria efectiva: {{ form.horas_por_dia - form.horas_descanso_por_dia | number:'1.0-1' }} h

{{ error }}

+

Empleados registrados

{{ selectedIds.size }} seleccionados

{{ error }}

Días de descanso

Cargando empleados...

IDNombreRegionalJornadaDescanso/díaDías de descansoVisitas/díaEstadoAcciones
{{ employee.employee_code }}{{ employee.nombre }}{{ employee.regional || '-' }}{{ scheduleLabel(employee) }}{{ employee.horas_descanso_por_dia }} h
{{ employee.dias_descanso.length ? employee.dias_descanso.join(', ') : 'Ninguno' }}
{{ employee.visitas_maximas_por_dia }}{{ employee.activo ? 'Activo' : 'Inactivo' }}
No hay empleados registrados.
diff --git a/route-planner/src/app/private/employees/employees.component.ts b/route-planner/src/app/private/employees/employees.component.ts index 7fa8f1c..9ed9b0f 100644 --- a/route-planner/src/app/private/employees/employees.component.ts +++ b/route-planner/src/app/private/employees/employees.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { Location } from '@angular/common'; import { Employee, EmployeeInput } from '../../core/models/employee.model'; import { EmployeeService } from '../../core/services/employee.service'; @@ -31,19 +31,32 @@ export class EmployeesComponent implements OnDestroy, OnInit { importing = false; openRestDaysFor: string | null = null; readonly schedules = WORK_SCHEDULES; + selectedIds = new Set(); + bulkSchedule = ''; + bulkRestHours: number | null = null; + bulkVisits: number | null = null; + bulkRestDays = new Set(); + bulkSaving = false; private destroyed = false; private loadSequence = 0; + projectId = ''; constructor( private employeesService: EmployeeService, private auth: AuthService, private router: Router, + private activatedRoute: ActivatedRoute, private location: Location, public profile: UserProfileService, private cdr: ChangeDetectorRef ) {} ngOnInit(): void { + this.projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? ''; + if (!this.projectId) { + void this.router.navigate(['/home']); + return; + } void this.load(); } @@ -60,14 +73,23 @@ export class EmployeesComponent implements OnDestroy, OnInit { 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 { const sequence = ++this.loadSequence; this.loading = true; this.error = ''; try { - const employees = await this.employeesService.list(); + const employees = await this.employeesService.list(this.projectId); if (this.destroyed || sequence !== this.loadSequence) return; this.employees = employees; + this.selectedIds.clear(); this.cdr.detectChanges(); } catch (error: any) { if (!this.destroyed && sequence === this.loadSequence) { @@ -144,7 +166,7 @@ export class EmployeesComponent implements OnDestroy, OnInit { }; try { 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(); await this.load(); } 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(); + } + + toggleBulkRestDay(day: string, checked: boolean): void { + checked ? this.bulkRestDays.add(day) : this.bulkRestDays.delete(day); + } + + async applyBulkEmployeeChanges(): Promise { + 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 { + 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 { + const selected = this.selectedEmployees; + if (!selected.length || !window.confirm(`¿Eliminar ${selected.length} empleados seleccionados? Esta acción no se puede deshacer.`)) return; + this.bulkSaving = true; + try { + await Promise.all(selected.map(employee => this.employeesService.remove(employee.id))); + this.importMessage = `${selected.length} empleados eliminados.`; + await this.load(); + } catch (error: any) { + this.error = error?.message ?? 'No se pudieron eliminar los empleados.'; + } finally { + this.bulkSaving = false; + } + } + scheduleLabel(employee: Employee): string { return workSchedule(employee.jornada_codigo).label; } @@ -195,7 +301,7 @@ export class EmployeesComponent implements OnDestroy, OnInit { const invalid = employees.filter((employee): employee is null => employee === null).length; const valid = employees.filter((employee): employee is EmployeeInput => employee !== null); if (!valid.length) throw new Error('El archivo no contiene empleados válidos.'); - await this.employeesService.upsert(valid); + await this.employeesService.upsert(this.projectId, valid); this.importMessage = `${valid.length} empleados guardados${invalid ? `; ${invalid} filas omitidas por datos inválidos` : ''}.`; await this.load(); } catch (error: any) { @@ -226,18 +332,25 @@ export class EmployeesComponent implements OnDestroy, OnInit { } async goHome(): Promise { - await this.router.navigate(['/home']); + await this.router.navigate(['/proyectos', this.projectId]); } goBack(): void { 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 { return value?.trim() || null; } + private resetBulkFields(): void { + this.bulkSchedule = ''; + this.bulkRestHours = null; + this.bulkVisits = null; + this.bulkRestDays.clear(); + } + private emptyForm(): EmployeeForm { return { employee_code: '', nombre: '', usuario: '', email: '', perfil: 'Promotor', pais: 'Republica Dominicana', regional: '', diff --git a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.css b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.css index d836798..2f5274a 100644 --- a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.css +++ b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.css @@ -6,7 +6,7 @@ .detail-sidebar { position: fixed; top: 0; - right: 0; + left: 0; bottom: 0; width: 340px; z-index: 900; @@ -16,7 +16,7 @@ padding: 16px; overflow-y: auto; background: var(--glm-surface); - border-left: 3px solid var(--glm-verde); + border-right: 3px solid var(--glm-verde); } .sidebar-brand { @@ -61,6 +61,24 @@ color: var(--glm-gris-oscuro); } +.maps-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border: 1px solid var(--glm-verde); + border-radius: 4px; + background: var(--glm-verde-bg); + color: var(--glm-azul); + font-size: 0.78rem; + font-weight: 600; + text-decoration: none; + white-space: nowrap; + margin-top: 12px; +} + +.maps-btn:hover { background: var(--glm-verde); color: #fff; } + .detail-title { flex: 1; } diff --git a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.html b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.html index d76fbc1..1667280 100644 --- a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.html +++ b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.html @@ -9,17 +9,16 @@ - diff --git a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts index 9eecc70..53fb747 100644 --- a/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts +++ b/route-planner/src/app/private/home/assigned-route-detail/assigned-route-detail.component.ts @@ -7,6 +7,7 @@ import { RouteState } from '../../../core/route-state/route'; import { OrsService } from '../../../core/services/ors.service'; import { RouteAssignedResponse } from '../../../core/models/route.model'; import { sequenceRoutes } from '../../../shared/utils/route-sequencing.utils'; +import { colorForUsuario } from '../../../shared/utils/palette.utils'; @Component({ standalone: true, @@ -31,6 +32,14 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie visitOrder = 0; totalStops = 0; + get googleMapsUrl(): string | null { + if (!this.routeData) return null; + const route = this.routeData.json; + const origin = `${route.latitud_usuario},${route.longitud_usuario}`; + const destination = `${route.latitud_pdv},${route.longitud_pdv}`; + return `https://www.google.com/maps/dir/?api=1&origin=${encodeURIComponent(origin)}&destination=${encodeURIComponent(destination)}&travelmode=driving`; + } + constructor( private router: Router, private activatedRoute: ActivatedRoute, @@ -52,8 +61,9 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie const qp = this.activatedRoute.snapshot.queryParams; const pdvId = qp['pdv_id']; const usuarioId = qp['usuario_id']; + const day = qp['dia']; if (pdvId) { - const found = this.routeState.findRoute(pdvId, usuarioId); + const found = this.routeState.findRoute(pdvId, usuarioId, day); if (found) { this.routeData = found; if (this.map) this.loadAndShowRoute(found); @@ -78,7 +88,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie // Re-attempt resolution after map init (queryParams case) const qp = this.activatedRoute.snapshot.queryParams; if (qp['pdv_id']) { - const found = this.routeState.findRoute(qp['pdv_id'], qp['usuario_id']); + const found = this.routeState.findRoute(qp['pdv_id'], qp['usuario_id'], qp['dia']); if (found) { this.routeData = found; this.error = null; @@ -105,8 +115,7 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie ]) : null; this.map = L.map('map', { maxBoundsViscosity: 0.85 }); if (territoryBounds?.isValid()) { - this.map.fitBounds(territoryBounds.pad(0.3)); - this.map.setMaxBounds(territoryBounds.pad(0.6)); + this.fitRouteBounds(territoryBounds); } else { 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', { attribution: '© OpenStreetMap contributors', - bounds: territoryBounds?.isValid() ? territoryBounds.pad(0.6) : undefined, keepBuffer: 1, noWrap: true, updateWhenIdle: true }).addTo(this.map); + setTimeout(() => { + this.map.invalidateSize(); + if (territoryBounds?.isValid()) this.fitRouteBounds(territoryBounds); + }); } private async loadAndShowRoute(route: RouteAssignedResponse) { @@ -176,7 +188,15 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie L.latLng(origin[1], origin[0]), L.latLng(destination[1], destination[0]) ]); - this.map.fitBounds(bounds.pad(0.3)); + this.fitRouteBounds(bounds); + } + + private fitRouteBounds(bounds: L.LatLngBounds): void { + this.map.fitBounds(bounds.pad(0.3), { + paddingTopLeft: L.point(380, 30), + paddingBottomRight: L.point(30, 30), + maxZoom: 15 + }); } private extractCoords(geojson: unknown): L.LatLngExpression[] { @@ -269,11 +289,12 @@ export class AssignedRouteDetailComponent implements OnInit, OnDestroy, AfterVie duration: string, originName: string ) { + const startColor = colorForUsuario(json.usuario_id).color; const startIcon = L.divIcon({ className: 'custom-marker marker-start', - html: '
A
', - iconSize: [30, 30], - iconAnchor: [15, 15], + html: `
A
`, + iconSize: [34, 34], + iconAnchor: [17, 17], popupAnchor: [0, -18] }); diff --git a/route-planner/src/app/private/home/assigned-route/assigned-route.component.css b/route-planner/src/app/private/home/assigned-route/assigned-route.component.css deleted file mode 100644 index ebfd491..0000000 --- a/route-planner/src/app/private/home/assigned-route/assigned-route.component.css +++ /dev/null @@ -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; -} diff --git a/route-planner/src/app/private/home/assigned-route/assigned-route.component.html b/route-planner/src/app/private/home/assigned-route/assigned-route.component.html deleted file mode 100644 index 36bc86e..0000000 --- a/route-planner/src/app/private/home/assigned-route/assigned-route.component.html +++ /dev/null @@ -1,124 +0,0 @@ -
-
- -
- - - - - -
-
-
{{ reoptError }}
-
{{ editError }}
- - - -
- Rutas asignadas - {{ assignedRoutes.length }} registros -
-
- - - - - {{ col.header }} - - - - - - - - - - - - - {{ row.json.nombre_usuario }} - - {{ row.json.nombre_pdv }} - {{ row.json.hora_laboral_semanal_usuario }} - {{ row.json.hora_minima_semanal_pdv }} - {{ row.json.horas_trabajo }} - - - - - {{ row.json.dia }} - - {{ row.json.distancia }} - {{ (row.json.horas_desplazamiento * 60).toFixed(0) }} min - - - - - - - - - - - - - - - - No hay rutas asignadas - - -
-
diff --git a/route-planner/src/app/private/home/assigned-route/assigned-route.component.ts b/route-planner/src/app/private/home/assigned-route/assigned-route.component.ts deleted file mode 100644 index afdfd31..0000000 --- a/route-planner/src/app/private/home/assigned-route/assigned-route.component.ts +++ /dev/null @@ -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(); - 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'); - } - -} diff --git a/route-planner/src/app/private/home/home.component.css b/route-planner/src/app/private/home/home.component.css index d905a05..0a0e79c 100644 --- a/route-planner/src/app/private/home/home.component.css +++ b/route-planner/src/app/private/home/home.component.css @@ -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:hover { background: var(--glm-verde); color: #fff; } +.map-overview-link { margin: -8px 0 16px; } +.project-back-link { display:inline-flex; align-items:center; gap:6px; margin-bottom:18px; } +.project-management-links { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:18px; } +.project-management-links .home-employees-link { margin-bottom:0; } +.dashboard-section-toggle { display:flex; width:100%; align-items:center; justify-content:space-between; gap:12px; padding:0 0 12px; border:0; background:transparent; color:var(--glm-azul); cursor:pointer; font:inherit; font-weight:700; text-align:left; text-transform:uppercase; } +.dashboard-section-toggle span { display:inline-flex; align-items:center; gap:7px; } +.planning-filters-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:10px; align-items:end; min-width:0; } +.planning-filters-grid label { display:grid; min-width:0; gap:5px; color:var(--glm-azul); font-size:.72rem; font-weight:700; text-transform:uppercase; } +.planning-filters-grid select { display:block; width:100%; min-width:0; box-sizing:border-box; padding:8px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-gris-oscuro); } +.planning-filters-grid .btn-secondary { grid-column:1; width:100%; margin-top:5px; } + +@media (max-width: 900px) { + .planning-filters-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } + .planning-filters-grid .btn-secondary { grid-column:auto; } +} + +@media (max-width: 600px) { + .planning-filters-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } +} .home-header h1 { margin: 0; diff --git a/route-planner/src/app/private/home/home.component.html b/route-planner/src/app/private/home/home.component.html index 18277a5..3cc0606 100644 --- a/route-planner/src/app/private/home/home.component.html +++ b/route-planner/src/app/private/home/home.component.html @@ -5,14 +5,16 @@
- Gestionar empleados + Mis proyectos +
-
Mis proyectos

{{ projectName || 'Proyecto' }}

Gestión de rutas asignadas y puntos de venta pendientes

+

{{ projectName || 'Proyecto' }}

Gestión de rutas asignadas y puntos de venta pendientes

-

PDVs del proyecto

-

Cargando empleados activos...{{ activeEmployees }} empleados activos de {{ projectCountry }} se usarán para planificar.

+ +
+

Cargando empleados activos...{{ activeEmployees }} empleados activos de este proyecto se usarán para planificar.

@@ -96,6 +98,14 @@
{{ submitDisabledReason }}
+
+
+ + + +
+ +
@@ -103,11 +113,7 @@
- -
- -
-

PDVs sin ruta asignada

- + +
diff --git a/route-planner/src/app/private/home/home.component.ts b/route-planner/src/app/private/home/home.component.ts index 55f8f47..b5286a8 100644 --- a/route-planner/src/app/private/home/home.component.ts +++ b/route-planner/src/app/private/home/home.component.ts @@ -1,7 +1,6 @@ import { Component, ChangeDetectorRef, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { AssignedRouteComponent } from './assigned-route/assigned-route.component'; import { NoAssignedRouteComponent } from './no-assigned-route/no-assigned-route.component'; import { RutasService } from '../../core/services/router.service'; import { RouteState } from '../../core/route-state/route'; @@ -15,11 +14,12 @@ import { EmployeeService } from '../../core/services/employee.service'; import { ProjectService } from '../../core/services/project.service'; import { AuthService } from '../../core/services/auth.service'; import { UserProfileService } from '../../core/services/user-profile.service'; +import { PlanningFilterKey, PlanningFilters, PlanningFilterService } from '../../core/services/planning-filter.service'; @Component({ standalone: true, selector: 'app-home', - imports: [CommonModule, FormsModule, RouterLink, AssignedRouteComponent, NoAssignedRouteComponent, PlanningKpisComponent], + imports: [CommonModule, FormsModule, RouterLink, NoAssignedRouteComponent, PlanningKpisComponent], templateUrl: './home.component.html', styleUrls: ['./home.component.css'] }) @@ -40,6 +40,10 @@ export class HomeComponent implements OnInit { projectDescription = ''; projectCountry = ''; projectSaving = false; + inputsExpanded = true; + filtersExpanded = true; + unassignedExpanded = false; + planningFilters: PlanningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' }; private calcSub: Subscription | null = null; constructor( @@ -53,6 +57,7 @@ export class HomeComponent implements OnInit { private auth: AuthService, private router: Router, public profile: UserProfileService, + public planningFilter: PlanningFilterService, private cdr: ChangeDetectorRef ) {} @@ -68,11 +73,16 @@ export class HomeComponent implements OnInit { private async loadProject(): Promise { this.projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? ''; if (!this.projectId) return; + this.routeState.useProject(this.projectId); + this.planningInput.clear(); + this.planningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' }; + this.applyPlanningFilters(); try { const [project, input] = await Promise.all([this.projectsService.get(this.projectId), this.projectsService.projectInput(this.projectId)]); this.projectName = project.nombre; this.projectDescription = project.descripcion ?? ''; this.projectCountry = input.country; + this.planningFilter.setPdvs(input.rows); this.pdvFile = input.file; this.pdvPreview = await this.previewService.previewFile(input.file, 'PDV'); } catch (error: any) { @@ -98,10 +108,17 @@ export class HomeComponent implements OnInit { async signOut(): Promise { 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 { this.employeesLoading = true; try { - this.activeEmployees = (await this.employeesService.active(this.projectCountry)).length; + this.activeEmployees = (await this.employeesService.active(this.projectId, this.projectCountry)).length; } catch { this.activeEmployees = 0; } finally { @@ -122,7 +139,7 @@ export class HomeComponent implements OnInit { return; } this.pdvFile = file; - if (this.usuariosFile) this.planningInput.setFiles(file, this.usuariosFile); + if (this.usuariosFile) this.planningInput.setFiles(this.projectId, file, this.usuariosFile); this.uploadError = null; this.pdvPreviewLoading = true; this.pdvPreview = null; @@ -163,7 +180,7 @@ export class HomeComponent implements OnInit { return; } this.usuariosFile = file; - if (this.pdvFile) this.planningInput.setFiles(this.pdvFile, file); + if (this.pdvFile) this.planningInput.setFiles(this.projectId, this.pdvFile, file); this.uploadError = null; this.usuariosPreviewLoading = true; this.usuariosPreview = null; @@ -242,14 +259,14 @@ export class HomeComponent implements OnInit { } let employeesFile: File; try { - employeesFile = await this.employeesService.activeEmployeesFile(this.projectCountry); + employeesFile = await this.employeesService.activeEmployeesFile(this.projectId, this.projectCountry); } catch { this.uploadError = 'No se pudieron obtener los empleados activos.'; this.routeState.setLoading(false); this.isLoading = false; return; } - this.planningInput.setFiles(pdvForOptimizer, employeesFile); + this.planningInput.setFiles(this.projectId, pdvForOptimizer, employeesFile); const formData = new FormData(); formData.append('pdv_file', pdvForOptimizer, pdvForOptimizer.name); @@ -268,9 +285,27 @@ export class HomeComponent implements OnInit { this.cdr.detectChanges(); return; } - const arr = res as unknown as unknown[]; - const assigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'assigned' in r) as unknown as { assigned: unknown } | undefined; - const noAssigned = (arr as ApiResponse).find(r => r && typeof r === 'object' && 'noAssigned' in r) as unknown as { noAssigned: unknown } | undefined; + const payload = Array.isArray(res) + ? res + : res && typeof res === 'object' + ? [res] + : null; + if (!payload) { + this.uploadError = 'El optimizador devolvió una respuesta vacía. Verifica que el workflow de n8n esté publicado.'; + this.routeState.setLoading(false); + this.isLoading = false; + this.cdr.detectChanges(); + return; + } + const assigned = payload.find(r => r && typeof r === 'object' && 'assigned' in r) as { assigned: unknown } | undefined; + const noAssigned = payload.find(r => r && typeof r === 'object' && 'noAssigned' in r) as { noAssigned: unknown } | undefined; + if (!assigned && !noAssigned) { + this.uploadError = 'El optimizador devolvió un formato de respuesta no válido. Verifica el workflow de n8n.'; + this.routeState.setLoading(false); + this.isLoading = false; + this.cdr.detectChanges(); + return; + } if (assigned) { this.routeState.setAssignedRoutes(assigned.assigned as never); } @@ -309,7 +344,7 @@ export class HomeComponent implements OnInit { clearFiles() { this.calcSub?.unsubscribe(); - this.planningInput.clear(); + this.planningInput.clear(this.projectId); this.routeState.clearAll(); this.pdvFile = null; this.usuariosFile = null; diff --git a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.html b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.html index 4795e29..f6ca96a 100644 --- a/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.html +++ b/route-planner/src/app/private/home/no-assigned-route/no-assigned-route.component.html @@ -20,6 +20,7 @@ {{ row.latitud }} {{ row.longitud }} {{ row.motivo }} + {{ row.recomendacion }} + {{ + route.json.dia + }} + + {{ route.json.visit_order || "-" }} + {{ route.json.horas_trabajo }} h + {{ route.json.distancia | number: "1.0-2" }} km + + {{ + route.json.horas_desplazamiento * 60 | number: "1.0-0" + }} + min + + + + + + + + +
+ + Página {{ userLoadPage }} de {{ userLoadPages }} + +
-

Motivos de PDVs sin asignar

-
+ +
- {{ item.reason }} - {{ item.count }} + {{ item.reason }}{{ item.count }}
-
- - Carga una planificación para visualizar sus indicadores. + Carga una planificación para visualizar sus indicadores.
diff --git a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts index a977716..e7f3cd8 100644 --- a/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts +++ b/route-planner/src/app/private/home/planning-kpis/planning-kpis.component.ts @@ -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 { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; import { Subscription } from 'rxjs'; import { RouteState } from '../../../core/route-state/route'; -import { PlanningExportService } from '../../../shared/services/planning-export.service'; +import { RoutePdfReportService } from '../../../shared/services/route-pdf-report.service'; import { summarizeDailyRoutes } from '../../../shared/utils/route-sequencing.utils'; import { RouteAssignedResponse } from '../../../core/models/route.model'; +import { PlanningFilterService } from '../../../core/services/planning-filter.service'; +import { RutasService } from '../../../core/services/router.service'; +import { PlanningInputService } from '../../../core/services/planning-input.service'; -interface KpiSummary { - totalPdvs: number; - assignedPdvs: number; - unassignedPdvs: number; - coverage: number; - users: number; - days: number; - totalDistance: number; - averageDistance: number; - totalWorkHours: number; -} - -interface ReasonSummary { - reason: string; - count: number; -} - +interface KpiSummary { totalPdvs: number; assignedPdvs: number; unassignedPdvs: number; coverage: number; users: number; days: number; totalDistance: number; averageDistance: number; totalWorkHours: number; } +interface ReasonSummary { reason: string; count: number; } interface UserLoad { - id: string; - name: string; - routes: number; - distance: number; - workHours: number; - capacityHours: number; - utilization: number; - status: 'ok' | 'warning' | 'over-capacity'; - alertMessage: string; + id: string; name: string; uniquePdvs: number; visits: number; routes: RouteAssignedResponse[]; distance: number; workHours: number; + capacityHours: number; utilization: number; status: 'ok' | 'warning' | 'over-capacity'; alertMessage: string; } -@Component({ - standalone: true, - selector: 'app-planning-kpis', - imports: [CommonModule], - templateUrl: './planning-kpis.component.html', - styleUrl: './planning-kpis.component.css' -}) +@Component({ standalone: true, selector: 'app-planning-kpis', imports: [CommonModule, FormsModule], templateUrl: './planning-kpis.component.html', styleUrl: './planning-kpis.component.css' }) export class PlanningKpisComponent implements OnInit, OnDestroy { summary: KpiSummary = this.emptySummary(); reasons: ReasonSummary[] = []; userLoads: UserLoad[] = []; hasData = false; - + metricsExpanded = true; + balanceExpanded = true; + reasonsExpanded = false; + expandedUsers = new Set(); + editingRoute: RouteAssignedResponse | null = null; + editUserId = ''; + editDay = ''; + editError = ''; + reoptLoading = false; + reoptError = ''; + hasLocalChanges = false; + pdfLoading = false; + pdfError = ''; + readonly userLoadPageSize = 14; + userLoadPage = 1; private readonly subs: Subscription[] = []; constructor( private routeState: RouteState, - private exportService: PlanningExportService + private filters: PlanningFilterService, + private pdfReport: RoutePdfReportService, + private rutasService: RutasService, + private planningInput: PlanningInputService, + private router: Router, + private activatedRoute: ActivatedRoute, + private cdr: ChangeDetectorRef ) {} ngOnInit(): void { this.subs.push( this.routeState.assignedRoutes$.subscribe(() => this.refresh()), - this.routeState.noAssignedRoutes$.subscribe(() => this.refresh()) + this.routeState.noAssignedRoutes$.subscribe(() => this.refresh()), + this.filters.filters$.subscribe(() => this.refresh()) ); } + ngOnDestroy(): void { this.subs.forEach(subscription => subscription.unsubscribe()); } - ngOnDestroy(): void { - this.subs.forEach(subscription => subscription.unsubscribe()); + barWidth(value: number): number { return Math.min(Math.max(value, 0), 100); } + utilizationClass(load: UserLoad): string { return load.status; } + async downloadRoutes(): Promise { + if (this.pdfLoading || !this.hasData) return; + this.pdfLoading = true; + this.pdfError = ''; + try { + await this.pdfReport.download(this.projectId); + } catch (error: any) { + this.pdfError = error?.message ?? 'No se pudo generar el PDF de rutas.'; + } finally { + this.pdfLoading = false; + this.cdr.detectChanges(); + } } + toggleUser(id: string): void { this.expandedUsers.has(id) ? this.expandedUsers.delete(id) : this.expandedUsers.add(id); } + isExpanded(id: string): boolean { return this.expandedUsers.has(id); } + toggleMetrics(): void { this.metricsExpanded = !this.metricsExpanded; } + toggleBalance(): void { this.balanceExpanded = !this.balanceExpanded; } + toggleReasons(): void { this.reasonsExpanded = !this.reasonsExpanded; } + previousUserLoadPage(): void { this.userLoadPage = Math.max(1, this.userLoadPage - 1); } + nextUserLoadPage(): void { this.userLoadPage = Math.min(this.userLoadPages, this.userLoadPage + 1); } - barWidth(value: number): number { - return Math.min(Math.max(value, 0), 100); + get userOptions(): { id: string; name: string }[] { return this.userLoads.map(load => ({ id: load.id, name: load.name })); } + get userLoadPages(): number { return Math.max(1, Math.ceil(this.userLoads.length / this.userLoadPageSize)); } + get paginatedUserLoads(): UserLoad[] { + const start = (this.userLoadPage - 1) * this.userLoadPageSize; + return this.userLoads.slice(start, start + this.userLoadPageSize); } + get dayOptions(): string[] { return this.routeState.getDiasUnicos(); } + get canReoptimize(): boolean { return this.hasData && !!this.planningInput.getFormData(this.projectId) && !this.reoptLoading; } - utilizationClass(load: UserLoad): string { - return load.status; + showRoute(route: RouteAssignedResponse): void { + this.routeState.sendRoute(route); + void this.router.navigate(['/proyectos', this.projectId, 'ruta'], { queryParams: { pdv_id: route.json.pdv_id, usuario_id: route.json.usuario_id, dia: route.json.dia } }); } - - exportPlanning(): void { - this.exportService.export(); + showUserMap(userId: string): void { void this.router.navigate(['/proyectos', this.projectId, 'mapa'], { queryParams: { usuario_id: userId, dia: 'Todos' } }); } + startEdit(route: RouteAssignedResponse): void { this.editingRoute = route; this.editUserId = String(route.json.usuario_id); this.editDay = route.json.dia; this.editError = ''; } + cancelEdit(): void { this.editingRoute = null; this.editError = ''; } + saveEdit(): void { + if (!this.editingRoute || !this.editUserId || !this.editDay) return; + const capacity = this.routeState.canAssignToDay(this.editUserId, this.editDay, Number(this.editingRoute.json.horas_trabajo || this.editingRoute.json.hora_minima_semanal_pdv || 0), this.editingRoute); + if (!capacity.allowed) { this.editError = `La asignación excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`; return; } + this.routeState.updateAssignedRoute(this.editingRoute, this.editUserId, this.editDay); + this.hasLocalChanges = true; this.editingRoute = null; } + reoptimize(): void { + const data = this.planningInput.getFormData(this.projectId); + if (!data || !this.canReoptimize) return; + this.reoptLoading = true; this.reoptError = ''; this.routeState.setLoading(true); + this.rutasService.reoptimizarRutas(data).subscribe({ + next: response => { + const items = response as unknown as unknown[]; + const assigned = items.find(item => item && typeof item === 'object' && 'assigned' in item) as { assigned?: unknown } | undefined; + const noAssigned = items.find(item => item && typeof item === 'object' && 'noAssigned' in item) as { noAssigned?: unknown } | undefined; + if (assigned?.assigned) this.routeState.setAssignedRoutes(assigned.assigned as never); + if (noAssigned?.noAssigned) this.routeState.setNoAssignedRoutes(noAssigned.noAssigned as never); + this.hasLocalChanges = false; this.reoptLoading = false; this.routeState.setLoading(false); this.cdr.detectChanges(); + }, + error: error => { this.reoptError = error?.error?.error || error?.message || 'No se pudo reoptimizar'; this.reoptLoading = false; this.routeState.setLoading(false); this.cdr.detectChanges(); } + }); + } + restoreOriginal(): void { this.routeState.restoreOriginalAssignedRoutes(); this.hasLocalChanges = false; this.editingRoute = null; } + private get projectId(): string { return this.activatedRoute.snapshot.paramMap.get('id') ?? ''; } private refresh(): void { - const assigned = this.routeState.getAssignedSnapshot(); - const unassigned = this.routeState.getNoAssignedSnapshot(); + const assigned = this.filters.filterAssigned(this.routeState.getAssignedSnapshot()); + const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot()); const assignedPdvIds = new Set(assigned.map(route => String(route.json.pdv_id))); - // A PDV with at least one scheduled visit is assigned; repeated weekly visits and - // partial-assignment alerts must not inflate the planning coverage counters. - const unassignedPdvIds = new Set(unassigned - .map(route => String(route.pdv_id)) - .filter(pdvId => !assignedPdvIds.has(pdvId))); + const unassignedPdvIds = new Set(unassigned.map(route => String(route.pdv_id)).filter(id => !assignedPdvIds.has(id))); const totalPdvs = new Set([...assignedPdvIds, ...unassignedPdvIds]).size; const dailyRoutes = summarizeDailyRoutes(assigned); const totalDistance = dailyRoutes.reduce((sum, day) => sum + day.distanceKm, 0); const 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(); - for (const route of assigned) { - const key = String(route.json.usuario_id); - const current = loads.get(key) ?? { - id: key, - name: route.json.nombre_usuario, - routes: 0, - distance: 0, - workHours: 0, - capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario), - utilization: 0, - status: 'ok', - alertMessage: '' - }; - current.routes++; - // Sequential distance and work are aggregated per user below. - loads.set(key, current); + const id = String(route.json.usuario_id); + const load = loads.get(id) ?? { id, name: route.json.nombre_usuario, uniquePdvs: 0, visits: 0, routes: [], distance: 0, workHours: 0, capacityHours: this.numberValue(route.json.hora_laboral_semanal_usuario), utilization: 0, status: 'ok', alertMessage: '' }; + load.routes.push(route); load.visits++; + loads.set(id, load); } - - this.userLoads = Array.from(loads.values()).map(load => { + this.userLoads = [...loads.values()].map(load => { + load.routes.sort((a, b) => a.json.dia.localeCompare(b.json.dia) || Number(a.json.visit_order ?? 0) - Number(b.json.visit_order ?? 0)); + load.uniquePdvs = new Set(load.routes.map(route => String(route.json.pdv_id))).size; const userDays = dailyRoutes.filter(day => day.userId === load.id); load.distance = userDays.reduce((sum, day) => sum + day.distanceKm, 0); load.workHours = userDays.reduce((sum, day) => sum + day.workHours, 0); - load.utilization = load.capacityHours ? (load.workHours / load.capacityHours) * 100 : 0; + load.utilization = load.capacityHours ? load.workHours / load.capacityHours * 100 : 0; load.status = load.utilization > 100 ? 'over-capacity' : load.utilization > 90 ? 'warning' : 'ok'; - load.alertMessage = load.status === 'over-capacity' - ? `Alerta: ${load.name} supera el 100% de su capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h). Reasigna PDVs o reduce horas.` - : load.status === 'warning' - ? `${load.name} está sobre el 90% de su capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).` - : `${load.name} está dentro de capacidad por carga (${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h).`; + load.alertMessage = `${load.name}: ${load.workHours.toFixed(1)} h de ${load.capacityHours.toFixed(1)} h.`; return load; - }).sort((a, b) => b.routes - a.routes || b.workHours - a.workHours); - this.summary = { - totalPdvs, - assignedPdvs: assignedPdvIds.size, - unassignedPdvs: unassignedPdvIds.size, - coverage: totalPdvs ? (assignedPdvIds.size / totalPdvs) * 100 : 0, - users, - days, - totalDistance, - averageDistance: assigned.length ? totalDistance / assigned.length : 0, - totalWorkHours, - }; - + }).sort((a, b) => b.visits - a.visits || b.workHours - a.workHours); + this.userLoadPage = Math.min(this.userLoadPage, this.userLoadPages); + this.summary = { totalPdvs, assignedPdvs: assignedPdvIds.size, unassignedPdvs: unassignedPdvIds.size, coverage: totalPdvs ? assignedPdvIds.size / totalPdvs * 100 : 0, users: loads.size, days: new Set(assigned.map(route => route.json.dia)).size, totalDistance, averageDistance: assignedPdvIds.size ? totalDistance / assignedPdvIds.size : 0, totalWorkHours }; const reasonByPdv = new Map(); - for (const route of unassigned) { - const pdvId = String(route.pdv_id); - if (assignedPdvIds.has(pdvId) || reasonByPdv.has(pdvId)) continue; - const reason = route.motivo?.trim() || 'Sin motivo especificado'; - reasonByPdv.set(pdvId, reason); - } - const reasonCounts = new Map(); - for (const reason of reasonByPdv.values()) { - reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1); - } - this.reasons = Array.from(reasonCounts.entries()) - .map(([reason, count]) => ({ reason, count })) - .sort((a, b) => b.count - a.count); + for (const route of unassigned) if (!assignedPdvIds.has(String(route.pdv_id)) && !reasonByPdv.has(String(route.pdv_id))) reasonByPdv.set(String(route.pdv_id), route.motivo?.trim() || 'Sin motivo especificado'); + const counts = new Map(); for (const reason of reasonByPdv.values()) counts.set(reason, (counts.get(reason) ?? 0) + 1); + this.reasons = [...counts].map(([reason, count]) => ({ reason, count })).sort((a, b) => b.count - a.count); this.hasData = totalPdvs > 0; } - - private numberValue(value: number | string | null | undefined): number { - const result = Number(value); - return Number.isFinite(result) ? result : 0; - } - - private emptySummary(): KpiSummary { - return { - totalPdvs: 0, - assignedPdvs: 0, - unassignedPdvs: 0, - coverage: 0, - users: 0, - days: 0, - totalDistance: 0, - averageDistance: 0, - totalWorkHours: 0, - }; - } + private numberValue(value: number | string | null | undefined): number { const result = Number(value); return Number.isFinite(result) ? result : 0; } + private emptySummary(): KpiSummary { return { totalPdvs: 0, assignedPdvs: 0, unassignedPdvs: 0, coverage: 0, users: 0, days: 0, totalDistance: 0, averageDistance: 0, totalWorkHours: 0 }; } } diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html index 6ef4ff8..129d7d1 100644 --- a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.html @@ -19,10 +19,15 @@

{{ activeTerritories.join(', ') }}

+ + + diff --git a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts index e3e43cb..5023d7f 100644 --- a/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts +++ b/route-planner/src/app/private/home/route-overview-map/route-overview-map.component.ts @@ -67,6 +67,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn dias: string[] = []; selectedUsuarioId: string = 'Todos'; selectedDia: string = 'Todos'; + selectedMetro = ''; expandedDailyRouteDay: string | null = null; selectedRouteDetail: RouteAssignedResponse | null = null; @@ -95,13 +96,32 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn reassignUserId = ''; reassignDay = ''; reassignError: string | null = null; + private renderVersion = 0; + + get metros(): string[] { + const values = [ + ...this.allRoutes.map(route => route.json.metro), + ...this.unassignedRoutes.map(route => route.metro) + ].map(value => String(value ?? '').trim()).filter(Boolean); + return [...new Set(values)].sort((a, b) => a.localeCompare(b)); + } + + get filteredUnassignedRoutes(): RouteNoAssignedResponse[] { + return this.unassignedRoutes.filter(route => String(route.metro ?? '').trim() === this.selectedMetro); + } + + get metroUsuarios(): { id: string; nombre: string }[] { + const users = new Map(); + 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[] { - const values = [ - ...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)); + return this.selectedMetro ? [this.selectedMetro] : []; } // Summary stats @@ -194,17 +214,13 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn ) {} ngOnInit() { + this.routeState.useProject(this.activatedRoute.snapshot.paramMap.get('id') ?? ''); this.subs.push( this.routeState.assignedRoutes$.subscribe(routes => { this.allRoutes = [...routes]; this.usuarios = this.routeState.getUsuariosUnicos(); this.dias = this.routeState.getDiasUnicos(); - // Start each map load with the complete planning selected by default. - const qp = this.activatedRoute.snapshot.queryParams; - if (qp['pdv_id']) { - this.selectedUsuarioId = 'Todos'; - this.selectedDia = 'Todos'; - } + this.applyQueryFilters(this.activatedRoute.snapshot.queryParams); // If focus pdv provided, auto-select detail after map loads this.applyFilter(); this.cdr.detectChanges(); @@ -223,9 +239,16 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn // React to queryParams changes this.subs.push( this.activatedRoute.queryParams.subscribe(qp => { - // Focus a specific route if pdv_id passed + this.applyQueryFilters(qp); + this.applyFilter(); + if (this.map) this.renderFiltered(); + // Focus a specific route if pdv_id passed. if (qp['pdv_id'] && this.filteredRoutes.length) { - const found = this.filteredRoutes.find(r => String(r.json.pdv_id) === String(qp['pdv_id'])); + const found = this.filteredRoutes.find(r => + String(r.json.pdv_id) === String(qp['pdv_id']) && + (!qp['usuario_id'] || String(r.json.usuario_id) === String(qp['usuario_id'])) && + (!qp['dia'] || qp['dia'] === 'Todos' || r.json.dia === qp['dia']) + ); if (found) { this.selectedRouteDetail = found; this.highlightRoute(found); @@ -257,35 +280,42 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn onUsuarioChange() { this.updateQueryParams(); - this.applyFilter(); - this.renderFiltered(); } onDiaChange() { this.updateQueryParams(); - this.applyFilter(); - this.renderFiltered(); + } + + onMetroChange() { + this.expandedDailyRouteDay = null; + if (this.selectedUsuarioId !== 'Todos' && !this.metroUsuarios.some(user => user.id === this.selectedUsuarioId)) { + this.selectedUsuarioId = 'Todos'; + } + this.updateQueryParams(); } private updateQueryParams() { this.router.navigate([], { relativeTo: this.activatedRoute, - queryParams: { usuario_id: this.selectedUsuarioId, dia: this.selectedDia }, + queryParams: { metro: this.selectedMetro, usuario_id: this.selectedUsuarioId, dia: this.selectedDia, pdv_id: null }, queryParamsHandling: 'merge', replaceUrl: true }); } private applyFilter() { - let list = this.selectedUsuarioId === 'Todos' - ? [...this.allRoutes] - : this.allRoutes.filter(r => String(r.json.usuario_id) === String(this.selectedUsuarioId)); + let list = this.allRoutes.filter(route => String(route.json.metro ?? '').trim() === this.selectedMetro); + if (this.selectedUsuarioId !== 'Todos') { + list = list.filter(r => String(r.json.usuario_id) === String(this.selectedUsuarioId)); + } if (this.selectedDia !== 'Todos') { list = list.filter(r => r.json.dia === this.selectedDia); } + const pdvId = this.activatedRoute.snapshot.queryParams['pdv_id']; + if (pdvId) list = list.filter(route => String(route.json.pdv_id) === String(pdvId)); this.filteredRoutes = list; if (this.filteredRoutes.length === 0) { - this.error = this.allRoutes.length === 0 ? 'No hay rutas cargadas. Sube archivos en Home.' : 'Sin rutas para este usuario/día.'; + this.error = this.allRoutes.length === 0 ? 'No hay rutas cargadas. Sube archivos en Home.' : 'Sin rutas para este metro, usuario o día.'; } else { this.error = null; } @@ -296,6 +326,29 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn } } + private applyQueryFilters(queryParams: Record): 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() { const territoryBounds = this.projectBounds(); this.map = L.map('map-overview', { maxBoundsViscosity: 0.85 }); @@ -349,6 +402,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn private async renderFiltered() { if (!this.map) return; + const version = ++this.renderVersion; this.abort$.next(); this.clearMap(); this.isLoading = false; @@ -389,7 +443,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn sequencedRoutes.forEach((segment, idx) => { const route = segment.route; const j = route.json; - const color = colorForUsuario(j.usuario_id).color; + const color = colorForUsuario(`${j.usuario_id}|${j.dia}`).color; const origin = segment.origin; const dest = segment.destination; bounds.push([origin[1], origin[0]]); @@ -437,7 +491,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn }); 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)]); } } @@ -451,19 +505,19 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn } catch { /* ignore */ } // Progressive ORS hydration — always in background (heat only hides visually) - this.hydrateWithOrs(); + this.hydrateWithOrs(version); this.renderUnassignedMarkers(); this.cdr.detectChanges(); } private projectBounds(): L.LatLngBounds | null { const points: L.LatLngTuple[] = []; - for (const route of this.allRoutes) { + for (const route of this.filteredRoutes) { const data = route.json; if (this.validPoint(data.latitud_usuario, data.longitud_usuario)) points.push([Number(data.latitud_usuario), Number(data.longitud_usuario)]); if (this.validPoint(data.latitud_pdv, data.longitud_pdv)) points.push([Number(data.latitud_pdv), Number(data.longitud_pdv)]); } - for (const route of this.unassignedRoutes) { + for (const route of this.filteredUnassignedRoutes) { if (this.validPoint(route.latitud, route.longitud)) points.push([Number(route.latitud), Number(route.longitud)]); } return points.length ? L.latLngBounds(points) : null; @@ -477,7 +531,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn private renderUnassignedMarkers(): void { if (!this.map || this.selectedUsuarioId !== 'Todos' || this.selectedDia !== 'Todos') return; - for (const route of this.unassignedRoutes) { + for (const route of this.filteredUnassignedRoutes) { const icon = L.divIcon({ className: 'custom-marker unassigned-marker', html: '
!
', @@ -492,17 +546,18 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn } } - private async hydrateWithOrs() { + private async hydrateWithOrs(version: number) { if (!this.filteredRoutes.length) return; this.isLoading = true; this.orsTotal = this.filteredRoutes.length; this.orsProgress = 0; this.cdr.detectChanges(); - const embeddedResults = this.mapRoutes + const routesForRender = [...this.mapRoutes]; + const embeddedResults = routesForRender .filter(route => !!route.data.json.geojson) .map(route => ({ pdv_id: route.requestId, geojson: route.data.json.geojson })); - const pares = 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, origin: route.origin, destination: route.destination @@ -512,6 +567,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn const remoteResults = pares.length ? await this.orsService.processPairsWithProgress(pares, 2, true, this.abort$).promise : []; + if (version !== this.renderVersion) return; const results = [...embeddedResults, ...remoteResults]; if (!results.length) { this.isLoading = false; @@ -534,8 +590,9 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn } } const useMap = geoByRequest.size > 0; - for (let i = 0; i < this.mapRoutes.length; i++) { - const mr = this.mapRoutes[i]; + for (let i = 0; i < routesForRender.length; i++) { + if (version !== this.renderVersion) return; + const mr = routesForRender[i]; let geo: unknown = null; if (useMap) { geo = geoByRequest.get(mr.requestId); @@ -587,8 +644,10 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn } catch { // Ignore abort / network errors, fallback lines remain } finally { - this.isLoading = false; - this.cdr.detectChanges(); + if (version === this.renderVersion) { + this.isLoading = false; + this.cdr.detectChanges(); + } } } @@ -979,7 +1038,7 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn this.toggleLayerVisibility(); const needsHydrate = !this.mapRoutes.some(m => m.orsLine) || this.orsTotal === 0 || this.orsProgress < this.orsTotal; if (!this.showHeat && needsHydrate) { - this.hydrateWithOrs(); + this.hydrateWithOrs(this.renderVersion); } this.cdr.detectChanges(); } diff --git a/route-planner/src/app/private/pdvs/pdvs.component.css b/route-planner/src/app/private/pdvs/pdvs.component.css new file mode 100644 index 0000000..347ac05 --- /dev/null +++ b/route-planner/src/app/private/pdvs/pdvs.component.css @@ -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; } + } diff --git a/route-planner/src/app/private/pdvs/pdvs.component.html b/route-planner/src/app/private/pdvs/pdvs.component.html new file mode 100644 index 0000000..f919fe1 --- /dev/null +++ b/route-planner/src/app/private/pdvs/pdvs.component.html @@ -0,0 +1,5 @@ +
GomezLee MarketingRoute Planner
+

Gestionar PDVs

{{ pdvs.length }} puntos de venta registrados en este proyecto

+

{{ message }}

{{ error }}

+

{{ editing ? 'Editar PDV' : 'Nuevo PDV' }}

+

PDVs del proyecto

{{ selectedIds.size }} seleccionados

Cargando...

IDNombreSegmentaciónCiudadEstadoZonaAcciones
{{ pdv.pdv_id }}{{ value(pdv, 'nombre_del_pdv') }}{{ value(pdv, 'segmentacion') }}{{ value(pdv, 'ciudad') }}{{ value(pdv, 'estado') }}{{ value(pdv, 'zona') }}
No hay PDVs registrados.
diff --git a/route-planner/src/app/private/pdvs/pdvs.component.ts b/route-planner/src/app/private/pdvs/pdvs.component.ts new file mode 100644 index 0000000..306557b --- /dev/null +++ b/route-planner/src/app/private/pdvs/pdvs.component.ts @@ -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(); + 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 { + 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 { + 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 = { + ...(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 { + 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>(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 { + 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 { + 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(); + } + + async setSelectedActive(active: boolean): Promise { + 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 { + 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 { 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: '' }; + } +} diff --git a/route-planner/src/app/private/projects/new-project.component.html b/route-planner/src/app/private/projects/new-project.component.html index c40c068..accafde 100644 --- a/route-planner/src/app/private/projects/new-project.component.html +++ b/route-planner/src/app/private/projects/new-project.component.html @@ -1 +1 @@ -
GomezLee MarketingRoute Planner

Crear proyecto

Define el proyecto e importa los PDVs que se planificarán.

{{ file.name }}

Validando archivo...

{{ preview!.validRows }} PDVs válidos para crear el proyecto.

El archivo tiene errores. Corrígelos antes de crear el proyecto.

{{ error }}

+
GomezLee MarketingRoute Planner

Crear proyecto

Define el proyecto. Podrás agregar sus PDVs después desde su gestor.

{{ error }}

diff --git a/route-planner/src/app/private/projects/new-project.component.ts b/route-planner/src/app/private/projects/new-project.component.ts index 92e9b4e..e18eea2 100644 --- a/route-planner/src/app/private/projects/new-project.component.ts +++ b/route-planner/src/app/private/projects/new-project.component.ts @@ -2,49 +2,25 @@ import { ChangeDetectorRef, Component } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; -import * as XLSX from 'xlsx'; import { ProjectService } from '../../core/services/project.service'; import { AuthService } from '../../core/services/auth.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' }) export class NewProjectComponent { name = ''; + description = ''; country = 'Republica Dominicana'; - file: File | null = null; - preview: PreviewResult | null = null; - loading = false; saving = false; 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 { await this.auth.signOut(); await this.router.navigate(['/login']); } - async fileChanged(event: Event): Promise { - 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>(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; } + get canCreate(): boolean { return !!this.name.trim() && !!this.country && !this.saving; } async create(): Promise { - if (!this.canCreate || !this.file) return; + if (!this.canCreate) return; this.saving = true; this.error = ''; try { - const workbook = XLSX.read(await this.file.arrayBuffer(), { type: 'array' }); - const sheet = workbook.Sheets[workbook.SheetNames[0]]; - const rows = XLSX.utils.sheet_to_json>(sheet, { defval: null, raw: true }); - const projectRows = rows.map(row => ({ ...row, 'País': this.country })); - const project = await this.projects.create(this.name, projectRows); + const project = await this.projects.create(this.name, this.description, this.country); await this.router.navigate(['/proyectos', project.id]); } catch (error: any) { this.error = error?.message ?? 'No se pudo crear el proyecto.'; } finally { this.saving = false; this.cdr.detectChanges(); } diff --git a/route-planner/src/app/private/projects/project-list.component.html b/route-planner/src/app/private/projects/project-list.component.html index 211ea80..13e6ff2 100644 --- a/route-planner/src/app/private/projects/project-list.component.html +++ b/route-planner/src/app/private/projects/project-list.component.html @@ -1,2 +1,3 @@
GomezLee MarketingRoute Planner
-
Gestionar empleados

Mis proyectos

Organiza y conserva tus planificaciones de rutas.

Crear proyecto

{{ error }}

Cargando proyectos...

{{ project.estado }}

{{ project.nombre }}

{{ project.descripcion || 'Sin descripción' }}

Actualizado: {{ project.updated_at | date:'medium' }}

Aún no tienes proyectos

Crea uno e importa su archivo de PDVs para comenzar.

Crear proyecto
+

Mis proyectos

Organiza y conserva tus planificaciones de rutas.

Crear proyecto

{{ error }}

Cargando proyectos...

{{ project.nombre }}

{{ project.descripcion || 'Sin descripción' }}

Actualizado: {{ project.updated_at | date:'medium' }}

Aún no tienes proyectos

Crea uno y agrega sus PDVs cuando estés listo para planificar.

Crear proyecto
+
diff --git a/route-planner/src/app/private/projects/project-list.component.ts b/route-planner/src/app/private/projects/project-list.component.ts index 1db4007..97f5664 100644 --- a/route-planner/src/app/private/projects/project-list.component.ts +++ b/route-planner/src/app/private/projects/project-list.component.ts @@ -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 { Router, RouterLink } from '@angular/router'; import { ProjectService } from '../../core/services/project.service'; @@ -11,6 +11,7 @@ export class ProjectListComponent implements OnInit { projects: RuteroProyecto[] = []; loading = true; deletingId = ''; + projectToDelete: RuteroProyecto | null = null; error = ''; constructor(private projectsService: ProjectService, private auth: AuthService, public profile: UserProfileService, private router: Router, private cdr: ChangeDetectorRef) {} ngOnInit(): void { void this.load(); } @@ -21,8 +22,12 @@ export class ProjectListComponent implements OnInit { finally { this.loading = false; this.cdr.detectChanges(); } } open(project: RuteroProyecto): void { void this.router.navigate(['/proyectos', project.id]); } - async remove(project: RuteroProyecto): Promise { - if (!confirm(`Eliminar permanentemente el proyecto "${project.nombre}" y todos sus PDVs y rutas?`)) return; + requestRemove(project: RuteroProyecto): void { this.projectToDelete = project; } + closeDeleteDialog(): void { if (!this.deletingId) this.projectToDelete = null; } + @HostListener('document:keydown.escape') onEscape(): void { this.closeDeleteDialog(); } + async remove(): Promise { + const project = this.projectToDelete; + if (!project) return; this.deletingId = project.id; this.error = ''; try { @@ -32,6 +37,7 @@ export class ProjectListComponent implements OnInit { this.error = error?.message ?? 'No se pudo eliminar el proyecto.'; } finally { this.deletingId = ''; + this.projectToDelete = null; this.cdr.detectChanges(); } } diff --git a/route-planner/src/app/private/projects/projects.component.css b/route-planner/src/app/private/projects/projects.component.css index feb4c92..c68122b 100644 --- a/route-planner/src/app/private/projects/projects.component.css +++ b/route-planner/src/app/private/projects/projects.component.css @@ -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); } .user-profile { display:flex; align-items:center; gap:8px; margin-left:auto; color:var(--glm-azul); font-size:.84rem; font-weight:700; }.user-profile img,.avatar-fallback { width:30px; height:30px; border-radius:50%; }.user-profile img { object-fit:cover; }.avatar-fallback { display:inline-flex; align-items:center; justify-content:center; background:var(--glm-verde); color:#fff; } .project-card-actions { display:flex; gap:8px; margin-top:auto; }.project-card-actions button { margin-top:0; }.project-card-actions .danger { border-color:#c9252d; background:#fff; color:#c9252d; }.project-card-actions .danger:hover:not(:disabled) { background:#c9252d; color:#fff; }.project-card-actions button:disabled { cursor:not-allowed; opacity:.65; } + .project-form textarea { min-height:88px; padding:10px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-gris-oscuro); font:inherit; resize:vertical; } + .delete-dialog-backdrop { position:fixed; inset:0; z-index:2000; display:grid; place-items:center; padding:20px; background:rgba(24,36,44,.56); }.delete-dialog { width:min(100%,440px); padding:26px; border-top:3px solid #c9252d; border-radius:4px; background:#fff; box-shadow:0 18px 48px rgba(0,0,0,.28); }.delete-dialog>i { color:#c9252d; font-size:1.5rem; }.delete-dialog h2 { margin:10px 0 8px; color:var(--glm-azul); }.delete-dialog p { margin:8px 0; color:var(--glm-gris-oscuro); line-height:1.45; }.delete-dialog-warning { color:var(--glm-acero)!important; font-size:.9rem; }.delete-dialog>div { display:flex; justify-content:flex-end; gap:8px; margin-top:20px; }.delete-dialog button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:9px 12px; background:#fff; color:var(--glm-azul); cursor:pointer; font:700 .82rem Arial,sans-serif; }.delete-dialog button.danger { border-color:#c9252d; color:#c9252d; }.delete-dialog button.danger:hover:not(:disabled) { background:#c9252d; color:#fff; }.delete-dialog button:disabled { cursor:not-allowed; opacity:.6; } diff --git a/route-planner/src/app/shared/services/excel-preview.service.ts b/route-planner/src/app/shared/services/excel-preview.service.ts index 8ed292b..485348b 100644 --- a/route-planner/src/app/shared/services/excel-preview.service.ts +++ b/route-planner/src/app/shared/services/excel-preview.service.ts @@ -165,7 +165,15 @@ export class ExcelPreviewService { 'Longitud': cleanNum(row['longitud']), 'Visitas semanales': Number.isFinite(visits) ? visits : 1, 'Duración visita(horas)': Number.isFinite(duration) ? duration : weeklyHours, - 'Prioridad': cleanNum(row['prioridad']) + 'Prioridad': cleanNum(row['prioridad']), + 'Activo': row['activo'] ?? 'Sí', + 'País': row['pais'] ?? row['country'] ?? '', + 'Cobertura': row['cobertura'] ?? '', + 'Zona': row['zona'] ?? '', + 'Región': row['region'] ?? row['regional'] ?? '', + 'Estado': row['estado'] ?? '', + 'Ciudad': row['ciudad'] ?? '', + 'Metro de prueba': row['metro_de_prueba'] ?? '' }; }); const output = XLSX.utils.book_new(); diff --git a/route-planner/src/app/shared/services/planning-export.service.ts b/route-planner/src/app/shared/services/planning-export.service.ts index 758db56..bc3e79f 100644 --- a/route-planner/src/app/shared/services/planning-export.service.ts +++ b/route-planner/src/app/shared/services/planning-export.service.ts @@ -3,32 +3,37 @@ import * as XLSX from 'xlsx'; import { saveAs } from 'file-saver'; import { RouteState } from '../../core/route-state/route'; import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils'; +import { PlanningFilterService } from '../../core/services/planning-filter.service'; @Injectable({ providedIn: 'root' }) export class PlanningExportService { - constructor(private routeState: RouteState) {} + constructor(private routeState: RouteState, private filters: PlanningFilterService) {} export(): void { - const assigned = this.routeState.getAssignedSnapshot(); - const unassigned = this.routeState.getNoAssignedSnapshot(); - const users = new Map(); + const assigned = this.filters.filterAssigned(this.routeState.getAssignedSnapshot()); + const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot()); + const users = new Map; distancia: number; trabajo: number; capacidad: number }>(); for (const route of assigned) { const key = String(route.json.usuario_id); const current = users.get(key) ?? { nombre: route.json.nombre_usuario, - rutas: 0, + visitas: 0, + pdvs: new Set(), distancia: 0, trabajo: 0, capacidad: Number(route.json.hora_laboral_semanal_usuario) || 0 }; - current.rutas++; + current.visitas++; + current.pdvs.add(String(route.json.pdv_id)); current.distancia += Number(route.json.distancia) || 0; current.trabajo += Number.parseFloat(String(route.json.horas_trabajo ?? '')) || 0; users.set(key, current); } - const total = assigned.length + unassigned.length; + const assignedIds = new Set(assigned.map(route => String(route.json.pdv_id))); + const unassignedIds = new Set(unassigned.map(route => String(route.pdv_id)).filter(id => !assignedIds.has(id))); + const total = assignedIds.size + unassignedIds.size; const dailyRoutes = summarizeDailyRoutes(assigned); const totalDistance = dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0); const totalTravel = dailyRoutes.reduce((sum, route) => sum + route.travelHours, 0); @@ -36,9 +41,9 @@ export class PlanningExportService { const summary = [ { Indicador: 'PDVs totales', Valor: total }, - { Indicador: 'PDVs asignados', Valor: assigned.length }, - { Indicador: 'PDVs sin asignar', Valor: unassigned.length }, - { Indicador: 'Cobertura (%)', Valor: total ? Number(((assigned.length / total) * 100).toFixed(1)) : 0 }, + { Indicador: 'PDVs asignados', Valor: assignedIds.size }, + { Indicador: 'PDVs sin asignar', Valor: unassignedIds.size }, + { Indicador: 'Cobertura (%)', Valor: total ? Number(((assignedIds.size / total) * 100).toFixed(1)) : 0 }, { Indicador: 'Usuarios activos', Valor: users.size }, { Indicador: 'Días planificados', Valor: new Set(assigned.map(route => route.json.dia)).size }, { Indicador: 'Distancia total (km)', Valor: Number(totalDistance.toFixed(2)) }, @@ -71,7 +76,8 @@ export class PlanningExportService { return { 'Usuario ID': id, Usuario: user.nombre, - Rutas: user.rutas, + PDVs: user.pdvs.size, + Visitas: user.visitas, 'Distancia (km)': Number(user.distancia.toFixed(2)), 'Horas trabajo': Number(user.trabajo.toFixed(2)), 'Capacidad (h)': user.capacidad, diff --git a/route-planner/src/app/shared/services/route-pdf-report.service.ts b/route-planner/src/app/shared/services/route-pdf-report.service.ts new file mode 100644 index 0000000..b7d6063 --- /dev/null +++ b/route-planner/src/app/shared/services/route-pdf-report.service.ts @@ -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 { + 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 { + 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.'; + } +} diff --git a/route-planner/src/app/shared/utils/palette.utils.ts b/route-planner/src/app/shared/utils/palette.utils.ts index fe27390..4538348 100644 --- a/route-planner/src/app/shared/utils/palette.utils.ts +++ b/route-planner/src/app/shared/utils/palette.utils.ts @@ -1,22 +1,37 @@ +const assignedColors = new Map(); +const usedColors = new Set(); + +function hashId(value: string): number { + let hash = 2166136261; + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function routeColor(id: string): string { + const existing = assignedColors.get(id); + if (existing) return existing; + const hash = hashId(id); + let attempt = 0; + let color = ''; + do { + // Restricted to dark navy/indigo/violet/plum. Avoids red, cyan, green, yellow and orange. + const hue = 225 + ((hash + attempt * 37) % 106); + const saturation = 58 + (((hash >>> 8) + attempt * 11) % 27); + const lightness = 27 + (((hash >>> 16) + attempt * 7) % 17); + color = `hsl(${hue} ${saturation}% ${lightness}%)`; + attempt++; + } while (usedColors.has(color)); + assignedColors.set(id, color); + usedColors.add(color); + return color; +} + export function colorForUsuario(usuarioId: string | number): { color: string; glow: string } { - const str = String(usuarioId); - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0; - } - hash = Math.abs(hash); - // Use a mix of GLM palette + generated HSL to avoid collisions but keep brand feel - const glmPalette = ['#6CC24A', '#4F758B', '#FF6A13', '#C4D600', '#5B7F95', '#A4D65E', '#6B8FA3']; - const idx = hash % glmPalette.length; - // If palette length covers typical usuarios (50 max) collisions low; fallback to HSL for overflow - if (hash % 2 === 0) { - return { color: glmPalette[idx], glow: glmPalette[idx] }; - } - const h = hash % 360; - const s = 65 + (hash % 20); - const l = 48 + (hash % 10); - const hsl = `hsl(${h} ${s}% ${l}%)`; - return { color: hsl, glow: hsl }; + const color = routeColor(String(usuarioId)); + return { color, glow: color }; } export function markerStyleForUsuario(usuarioId: string | number): string { diff --git a/route-planner/src/app/shared/utils/unassigned-recommendation.utils.ts b/route-planner/src/app/shared/utils/unassigned-recommendation.utils.ts new file mode 100644 index 0000000..a329da1 --- /dev/null +++ b/route-planner/src/app/shared/utils/unassigned-recommendation.utils.ts @@ -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.'; +} diff --git a/route-planner/src/environments/environment.prod.ts b/route-planner/src/environments/environment.prod.ts index b44fdef..463b3b1 100644 --- a/route-planner/src/environments/environment.prod.ts +++ b/route-planner/src/environments/environment.prod.ts @@ -1,6 +1,7 @@ export const environment = { production: true, n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino', + n8nPdfWebhookUrl: 'http://localhost:5678/webhook/generar-rutas-pdf', orsBaseUrl: 'http://localhost:18080/ors/v2', orsMexicoBaseUrl: 'http://localhost:18081/ors/v2', requestTimeoutMs: 180000, diff --git a/route-planner/src/environments/environment.ts b/route-planner/src/environments/environment.ts index c2840c1..fb6d14e 100644 --- a/route-planner/src/environments/environment.ts +++ b/route-planner/src/environments/environment.ts @@ -1,6 +1,7 @@ export const environment = { production: false, n8nWebhookUrl: 'http://localhost:5678/webhook/calcular-camino', + n8nPdfWebhookUrl: 'http://localhost:5678/webhook/generar-rutas-pdf', orsBaseUrl: 'http://localhost:18080/ors/v2', orsMexicoBaseUrl: 'http://localhost:18081/ors/v2', requestTimeoutMs: 180000, diff --git a/route-planner/src/styles.css b/route-planner/src/styles.css index 89ad4b2..485b949 100644 --- a/route-planner/src/styles.css +++ b/route-planner/src/styles.css @@ -46,6 +46,24 @@ pointer-events: none; } +/* Leaflet-generated marker elements do not receive Angular's scoped attributes. */ +.custom-marker { + background: none !important; + border: none !important; +} + +.custom-marker .marker-inner { + display: flex; + width: 34px; + height: 34px; + align-items: center; + justify-content: center; + border-radius: 50%; + color: #fff; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4); + font: 700 12px/1 Arial, sans-serif; +} + :root { --glm-verde: #6CC24A; --glm-verde-claro: #A4D65E; diff --git a/supabase/proyectos_empleados_pdvs.sql b/supabase/proyectos_empleados_pdvs.sql new file mode 100644 index 0000000..7a753bd --- /dev/null +++ b/supabase/proyectos_empleados_pdvs.sql @@ -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() +)); diff --git a/supabase/proyectos_pais_sin_estado.sql b/supabase/proyectos_pais_sin_estado.sql new file mode 100644 index 0000000..2e1e1f0 --- /dev/null +++ b/supabase/proyectos_pais_sin_estado.sql @@ -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';