feat: agregar generación de reportes PDF y recomendaciones para PDVs sin asignar #7

Merged
EidanThen merged 1 commits from feature/resumen-datos into dev 2026-09-28 19:17:19 +00:00
49 changed files with 1635 additions and 906 deletions
Showing only changes of commit b3d6653143 - Show all commits
+141
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[char]));\nconst filters = Object.entries(payload.filters ?? {}).filter(([, value]) => value).map(([key, value]) => `<span>${escape(key)}: ${escape(value)}</span>`).join('');\nconst routeRows = payload.assignedRoutes.map(route => `<tr><td>${escape(route.usuario)}</td><td>${escape(route.dia)}</td><td>${escape(route.orden)}</td><td>${escape(route.pdv)}</td><td>${escape(route.origen)}</td><td>${escape(route.distanciaKm)} km</td><td>${escape(route.horasTrabajo)} h</td></tr>`).join('') || '<tr><td colspan=\"7\">Sin rutas asignadas para los filtros seleccionados.</td></tr>';\nconst unassignedRows = payload.unassignedPdvs.map(route => `<tr><td>${escape(route.pdv_id)}</td><td>${escape(route.pdv_nombre)}</td><td>${escape(route.minimo_horas_semana)} h</td><td>${escape(route.motivo)}</td><td>${escape(route.recomendacion)}</td></tr>`).join('') || '<tr><td colspan=\"5\">No hay PDVs sin asignar para los filtros seleccionados.</td></tr>';\nconst summary = payload.summary ?? {};\nconst html = `<!doctype html><html><head><meta charset=\"utf-8\"><style>body{font-family:Arial,sans-serif;color:#243746;margin:28px;font-size:11px}h1{color:#4F758B;margin:0}h2{color:#4F758B;border-bottom:2px solid #6CC24A;padding-bottom:6px;margin-top:28px}p{line-height:1.45}.meta{color:#61798a}.filters span{display:inline-block;background:#eef6e8;border:1px solid #a4d65e;border-radius:3px;margin:3px 5px 3px 0;padding:4px 6px}.summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:15px 0}.metric{border:1px solid #d0d0d0;border-left:3px solid #6CC24A;padding:8px}.metric b{display:block;color:#4F758B;font-size:16px}table{width:100%;border-collapse:collapse;margin-top:10px}th{background:#4F758B;color:#fff;text-align:left;padding:7px}td{border-bottom:1px solid #e5e5e5;padding:7px;vertical-align:top}.footer{margin-top:24px;color:#6b8fa3;font-size:9px}</style></head><body><h1>Reporte de rutas</h1><p class=\"meta\"><b>${escape(payload.project.name)}</b><br>${escape(payload.project.country)}${payload.project.description ? `<br>${escape(payload.project.description)}` : ''}<br>Generado: ${escape(payload.generatedAt)}</p>${filters ? `<div class=\"filters\"><b>Filtros aplicados:</b><br>${filters}</div>` : '<p><b>Filtros aplicados:</b> Ninguno</p>'}<div class=\"summary\"><div class=\"metric\"><b>${escape(summary.totalPdvs)}</b>PDVs totales</div><div class=\"metric\"><b>${escape(summary.assignedPdvs)}</b>PDVs asignados</div><div class=\"metric\"><b>${escape(summary.unassignedPdvs)}</b>PDVs sin asignar</div><div class=\"metric\"><b>${escape(summary.coveragePercent)}%</b>Cobertura</div><div class=\"metric\"><b>${escape(summary.totalDistanceKm)} km</b>Distancia</div><div class=\"metric\"><b>${escape(summary.totalWorkHours)} h</b>Trabajo</div></div><h2>Rutas asignadas</h2><table><thead><tr><th>Usuario</th><th>Día</th><th>Orden</th><th>PDV</th><th>Origen</th><th>Distancia</th><th>Trabajo</th></tr></thead><tbody>${routeRows}</tbody></table><h2>PDVs sin asignar</h2><table><thead><tr><th>PDV ID</th><th>PDV</th><th>Horas/semana</th><th>Motivo</th><th>Recomendación</th></tr></thead><tbody>${unassignedRows}</tbody></table><p class=\"footer\">Route Planner · GomezLee Marketing</p></body></html>`;\nreturn [{ json: { fileName: `rutas_${String(payload.project.name).replace(/[^a-z0-9]+/gi, '_').toLowerCase()}.pdf` }, binary: { data: { data: Buffer.from(html).toString('base64'), mimeType: 'text/html', fileName: 'index.html' } } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [-100, 260],
"id": "9258c394-683f-4569-8ced-af2337ec8dba",
"name": "Build Report HTML",
"onError": "continueErrorOutput",
"notes": "Genera tablas deterministas; no usa IA para alterar cifras, rutas ni recomendaciones."
},
{
"parameters": {
"method": "POST",
"url": "http://host.docker.internal:3000/forms/chromium/convert/html",
"authentication": "none",
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"parameterType": "formBinaryData",
"name": "files",
"inputDataFieldName": "data"
}
]
},
"options": {
"response": {
"response": {
"responseFormat": "file",
"outputPropertyName": "data"
}
},
"timeout": 120000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [180, 260],
"id": "73e2759c-d9f9-49dc-925f-3ad1ee7b3897",
"name": "Convert HTML to PDF",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000,
"onError": "continueErrorOutput",
"notes": "Gotenberg debe estar disponible en host.docker.internal:3000 desde el contenedor n8n."
},
{
"parameters": {
"respondWith": "binary",
"responseBinaryPropertyName": "data",
"options": {
"responseHeaders": {
"entries": [
{
"name": "Content-Disposition",
"value": "attachment; filename=\"reporte_rutas.pdf\""
}
]
}
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [460, 200],
"id": "93928c3e-1e9c-4f77-a644-c156fae907a5",
"name": "Respond PDF"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { error: 'validation_error', message: $json.error?.message || $json.message || 'El reporte no contiene datos válidos.' } }}",
"options": {
"responseCode": 400
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [-100, 460],
"id": "c8b69f14-45d0-4f55-9f7c-2e3fb3425f14",
"name": "Respond Validation Error"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { error: 'pdf_generation_error', message: 'No se pudo generar el PDF de rutas.', detail: $json.error?.message || $json.message || 'Gotenberg no está disponible.' } }}",
"options": {
"responseCode": 502
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [460, 430],
"id": "c8d6e363-80b6-46ca-a8c6-32ce393207cc",
"name": "Respond PDF Error"
}
],
"connections": {
"Webhook: Generate PDF": { "main": [[{ "node": "Validate Report Payload", "type": "main", "index": 0 }]] },
"Validate Report Payload": { "main": [[{ "node": "Build Report HTML", "type": "main", "index": 0 }], [{ "node": "Respond Validation Error", "type": "main", "index": 0 }]] },
"Build Report HTML": { "main": [[{ "node": "Convert HTML to PDF", "type": "main", "index": 0 }], [{ "node": "Respond Validation Error", "type": "main", "index": 0 }]] },
"Convert HTML to PDF": { "main": [[{ "node": "Respond PDF", "type": "main", "index": 0 }], [{ "node": "Respond PDF Error", "type": "main", "index": 0 }]] }
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"meta": {
"templateCredsSetupCompleted": true
},
"tags": []
}
@@ -126,7 +126,7 @@
},
{
"parameters": {
"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,
+7
View File
@@ -0,0 +1,7 @@
services:
gotenberg:
image: gotenberg/gotenberg:8
container_name: route-planner-pdf
ports:
- "3000:3000"
restart: unless-stopped
+3 -1
View File
@@ -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' }
@@ -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<Employee, 'id'>;
export type EmployeeInput = Omit<Employee, 'id' | 'proyecto_id'>;
@@ -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;
@@ -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;
+30 -14
View File
@@ -21,17 +21,18 @@ export interface UnassignedAssignment {
@Injectable({ providedIn: 'root' })
export class RouteState {
private projectId: string | null = null;
private loading = new BehaviorSubject<boolean>(false);
loading$ = this.loading.asObservable();
private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>(this.loadAssigned());
private assignedRoutes = new BehaviorSubject<RouteAssignedResponse[]>([]);
assignedRoutes$ = this.assignedRoutes.asObservable();
private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>(this.loadNoAssigned());
private noAssignedRoutes = new BehaviorSubject<RouteNoAssignedResponse[]>([]);
noAssignedRoutes$ = this.noAssignedRoutes.asObservable();
private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(this.loadSelected());
private selectedRoute = new BehaviorSubject<RouteAssignedResponse | null>(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;
}
}
@@ -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<Employee[]> {
async list(projectId: string): Promise<Employee[]> {
const { data: sessionData, error: sessionError } = await this.client.auth.getSession();
if (sessionError) throw sessionError;
if (!sessionData.session) throw new Error('No hay una sesión Supabase activa. Cierra sesión e inicia sesión nuevamente.');
const { data, error } = await this.client.from(this.table).select('*').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<void> {
const { error } = await this.client.from(this.table).insert(employee);
async create(projectId: string, employee: EmployeeInput): Promise<void> {
const { error } = await this.client.from(this.table).insert({ ...employee, proyecto_id: projectId });
if (error) throw error;
}
async upsert(employees: EmployeeInput[]): Promise<void> {
const { error } = await this.client.from(this.table).upsert(employees, { onConflict: 'employee_code' });
async upsert(projectId: string, employees: EmployeeInput[]): Promise<void> {
const rows = employees.map(employee => ({ ...employee, proyecto_id: projectId }));
const { error } = await this.client.from(this.table).upsert(rows, { onConflict: 'proyecto_id,employee_code' });
if (error) throw error;
}
@@ -43,13 +44,12 @@ export class EmployeeService {
if (error) throw error;
}
async active(country = ''): Promise<Employee[]> {
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<Employee[]> {
return (await this.list(projectId)).filter(employee => employee.activo && (!country || this.sameCountry(employee.pais, country)));
}
async activeEmployeesFile(country = ''): Promise<File> {
const employees = await this.active(country);
async activeEmployeesFile(projectId: string, country?: string): Promise<File> {
const employees = await this.active(projectId, country);
const rows = employees.map(employee => ({
ID: employee.employee_code,
'Nombre del empleado': employee.nombre,
@@ -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');
}
}
@@ -0,0 +1,57 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { RouteAssignedResponse, RouteNoAssignedResponse } from '../models/route.model';
import { normalizeRow } from '../../shared/utils/header-normalization.utils';
export interface PlanningFilters {
cobertura: string;
zona: string;
region: string;
estado: string;
ciudad: string;
}
export type PlanningFilterKey = keyof PlanningFilters;
@Injectable({ providedIn: 'root' })
export class PlanningFilterService {
private readonly empty: PlanningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
private readonly filtersSubject = new BehaviorSubject<PlanningFilters>({ ...this.empty });
readonly filters$ = this.filtersSubject.asObservable();
private metadata = new Map<string, Record<string, unknown>>();
setPdvs(rows: Record<string, unknown>[]): void {
this.metadata = new Map(rows.map((row, index) => {
const normalized = normalizeRow(row);
return [String(normalized['id'] ?? index + 1), normalized];
}));
this.clear();
}
set(filters: PlanningFilters): void { this.filtersSubject.next({ ...filters }); }
clear(): void { this.filtersSubject.next({ ...this.empty }); }
snapshot(): PlanningFilters { return this.filtersSubject.value; }
options(key: PlanningFilterKey): string[] {
const values = [...this.metadata.values()].map(row => String(row[key] ?? '').trim()).filter(Boolean);
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
}
filterAssigned(routes: RouteAssignedResponse[]): RouteAssignedResponse[] {
return routes.filter(route => this.matches(String(route.json.pdv_id), route.json as unknown as Record<string, unknown>));
}
filterUnassigned(routes: RouteNoAssignedResponse[]): RouteNoAssignedResponse[] {
return routes.filter(route => this.matches(String(route.pdv_id), route as unknown as Record<string, unknown>));
}
private matches(id: string, fallback: Record<string, unknown>): boolean {
const metadata = this.metadata.get(id) ?? {};
const filters = this.filtersSubject.value;
return (Object.keys(filters) as PlanningFilterKey[]).every(key => {
const selected = filters[key];
if (!selected) return true;
return String(metadata[key] ?? fallback[key] ?? '').trim() === selected;
});
}
}
@@ -2,23 +2,27 @@ import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
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;
}
@@ -23,7 +23,7 @@ export class ProjectService {
}
async remove(id: string): Promise<void> {
const dependentTables = ['rutero_proyecto_rutas', 'rutero_proyecto_pdvs'];
const dependentTables = ['rutero_proyecto_rutas', 'rutero_proyecto_empleados', 'rutero_proyecto_pdvs'];
for (const table of dependentTables) {
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<string, unknown>[]): Promise<RuteroProyecto> {
async create(nombre: string, descripcion: string, pais: string): Promise<RuteroProyecto> {
const { data: sessionData, error: sessionError } = await supabaseClient.auth.getSession();
if (sessionError) throw sessionError;
const ownerId = sessionData.session?.user.id;
@@ -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<string, unknown>[]): Promise<void> {
const pdvs = rows.map((pdv, index) => ({
proyecto_id: projectId,
pdv_id: String(pdv['ID'] ?? pdv['id'] ?? pdv['Id'] ?? index + 1),
pdv_data: pdv
}));
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').upsert(pdvs, { onConflict: 'proyecto_id,pdv_id' });
if (error) throw error;
}
async addPdv(projectId: string, pdv: Record<string, unknown>): Promise<void> {
const pdvId = String(pdv['ID'] ?? '').trim();
if (!pdvId) throw new Error('El ID del PDV es obligatorio.');
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').insert({ proyecto_id: projectId, pdv_id: pdvId, pdv_data: pdv });
if (error) throw error;
}
async updatePdv(id: string, pdv: Record<string, unknown>): Promise<void> {
const pdvId = String(pdv['ID'] ?? '').trim();
if (!pdvId) throw new Error('El ID del PDV es obligatorio.');
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').update({ pdv_id: pdvId, pdv_data: pdv }).eq('id', id);
if (error) throw error;
}
async removePdv(id: string): Promise<void> {
const { error } = await supabaseClient.from('rutero_proyecto_pdvs').delete().eq('id', id);
if (error) throw error;
}
async projectInput(id: string): Promise<{ file: File; country: string; rows: Record<string, unknown>[] }> {
const [project, pdvs] = await Promise.all([this.get(id), this.pdvs(id)]);
const rows = pdvs.map(pdv => pdv.pdv_data);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), 'PDVs');
@@ -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
};
}
@@ -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; } }
@@ -1,50 +1,12 @@
<div class="employees-topbar">
<img src="GLM_completo.png" alt="GomezLee Marketing" />
<span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div>
<div class="topbar-actions"><button type="button" (click)="goBack()"><i class="pi pi-arrow-left"></i> Volver</button><button type="button" (click)="goHome()"><i class="pi pi-home"></i> Proyectos</button><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
<div class="topbar-actions"><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
</div>
<main class="employees-page">
<button type="button" class="page-back-button" (click)="goHome()"><i class="pi pi-arrow-left"></i> Volver</button>
<header><div><h1>Gestionar empleados</h1><p>{{ activeEmployees }} activos de {{ employees.length }} empleados registrados</p></div><label class="import-button"><i class="pi pi-file-import"></i> {{ importing ? 'Importando...' : 'Importar empleados' }}<input type="file" accept=".xlsx,.xls" (change)="importFile($event)" [disabled]="importing"></label></header>
<p class="import-message" *ngIf="importMessage">{{ importMessage }}</p>
<section class="employee-form-card">
<h2>Nuevo empleado</h2>
<form (ngSubmit)="save()" #employeeForm="ngForm">
<div class="employee-form-grid">
<label>ID<input name="employeeCode" [(ngModel)]="form.employee_code" required></label>
<label>Nombre<input name="nombre" [(ngModel)]="form.nombre" required></label>
<label>Usuario<input name="usuario" [(ngModel)]="form.usuario"></label>
<label>Email<input type="email" name="email" [(ngModel)]="form.email"></label>
<label>Perfil<input name="perfil" [(ngModel)]="form.perfil"></label>
<label>Regional<input name="regional" [(ngModel)]="form.regional"></label>
<label>País<input name="pais" [(ngModel)]="form.pais"></label>
<label>Estado<select name="activo" [(ngModel)]="form.activo"><option [ngValue]="true">Activo</option><option [ngValue]="false">Inactivo</option></select></label>
<label>Latitud<input type="number" name="latitud" [(ngModel)]="form.latitud" step="any" required></label>
<label>Longitud<input type="number" name="longitud" [(ngModel)]="form.longitud" step="any" required></label>
<label>Horas por día<input type="number" name="hoursDay" [(ngModel)]="form.horas_por_dia" min="0.5" max="24" step="0.5" required></label>
<label>Jornada de trabajo<select name="schedule" [(ngModel)]="form.jornada_codigo"><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></label>
<label>Horas de descanso/día<input type="number" name="dailyRest" [(ngModel)]="form.horas_descanso_por_dia" min="0" max="3" step="1" required></label>
<label>Visitas máximas/día<input type="number" name="visitsDay" [(ngModel)]="form.visitas_maximas_por_dia" min="1" required></label>
</div>
<p class="weekly-capacity">Capacidad semanal: {{ selectedScheduleHours }} h · Capacidad diaria efectiva: {{ form.horas_por_dia - form.horas_descanso_por_dia | number:'1.0-1' }} h</p>
<p class="form-error" *ngIf="error">{{ error }}</p>
<div class="form-actions"><button type="submit" [disabled]="saving || employeeForm.invalid"><i class="pi pi-save"></i> {{ saving ? 'Guardando...' : 'Agregar empleado' }}</button></div>
</form>
</section>
<section class="employee-table-card">
<div class="table-header"><h2>Empleados registrados</h2><button type="button" (click)="load()" [disabled]="loading"><i class="pi pi-refresh"></i> Actualizar</button></div>
<p class="form-error" *ngIf="error && !loading">{{ error }}</p>
<p class="loading" *ngIf="loading">Cargando empleados...</p>
<div class="table-wrap" *ngIf="!loading">
<table>
<thead><tr><th>ID</th><th>Nombre</th><th>Regional</th><th>Jornada</th><th>Descanso/día</th><th>Días de descanso</th><th>Visitas/día</th><th>Estado</th><th>Acciones</th></tr></thead>
<tbody>
<tr *ngFor="let employee of employees" [class.editing-row]="editingId === employee.id"><td>{{ employee.employee_code }}</td><td>{{ employee.nombre }}</td><td>{{ employee.regional || '-' }}</td><td><ng-container *ngIf="editingId === employee.id; else scheduleText"><select [(ngModel)]="employee.jornada_codigo" [name]="'schedule-' + employee.id" (ngModelChange)="onInlineScheduleChange(employee)"><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></ng-container><ng-template #scheduleText>{{ scheduleLabel(employee) }}</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else restText"><input class="inline-number" type="number" [(ngModel)]="employee.horas_descanso_por_dia" [name]="'rest-' + employee.id" min="0" max="3" step="1"></ng-container><ng-template #restText>{{ employee.horas_descanso_por_dia }} h</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else daysText"><div class="rest-days-picker inline-picker"><label *ngFor="let day of availableRestDays(employee)"><input type="checkbox" [checked]="employee.dias_descanso.includes(day)" (change)="toggleRestDay(employee, day, $any($event.target).checked)">{{ day }}</label></div></ng-container><ng-template #daysText>{{ employee.dias_descanso.length ? employee.dias_descanso.join(', ') : 'Ninguno' }}</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else visitsText"><input class="inline-number" type="number" [(ngModel)]="employee.visitas_maximas_por_dia" [name]="'visits-' + employee.id" min="1" step="1"></ng-container><ng-template #visitsText>{{ employee.visitas_maximas_por_dia }}</ng-template></td><td><span class="status" [class.inactive]="!employee.activo">{{ employee.activo ? 'Activo' : 'Inactivo' }}</span></td><td class="row-actions"><ng-container *ngIf="editingId === employee.id; else normalActions"><button type="button" (click)="saveInline(employee)" [disabled]="saving">Guardar</button><button type="button" (click)="cancelEdit()" [disabled]="saving">Cancelar</button></ng-container><ng-template #normalActions><button type="button" (click)="edit(employee)">Editar</button><button type="button" (click)="toggleActive(employee)">{{ employee.activo ? 'Desactivar' : 'Activar' }}</button><button type="button" class="delete" (click)="remove(employee)">Eliminar</button></ng-template></td></tr>
<tr *ngIf="!employees.length"><td colspan="9">No hay empleados registrados.</td></tr>
</tbody>
</table>
</div>
</section>
<section class="employee-form-card"><h2>Nuevo empleado</h2><form (ngSubmit)="save()" #employeeForm="ngForm"><div class="employee-form-grid"><label>ID<input name="employeeCode" [(ngModel)]="form.employee_code" required></label><label>Nombre<input name="nombre" [(ngModel)]="form.nombre" required></label><label>Usuario<input name="usuario" [(ngModel)]="form.usuario"></label><label>Email<input type="email" name="email" [(ngModel)]="form.email"></label><label>Perfil<input name="perfil" [(ngModel)]="form.perfil"></label><label>Regional<input name="regional" [(ngModel)]="form.regional"></label><label>País<input name="pais" [(ngModel)]="form.pais"></label><label>Estado<select name="activo" [(ngModel)]="form.activo"><option [ngValue]="true">Activo</option><option [ngValue]="false">Inactivo</option></select></label><label>Latitud<input type="number" name="latitud" [(ngModel)]="form.latitud" step="any" required></label><label>Longitud<input type="number" name="longitud" [(ngModel)]="form.longitud" step="any" required></label><label>Horas por día<input type="number" name="hoursDay" [(ngModel)]="form.horas_por_dia" min="0.5" max="24" step="0.5" required></label><label>Jornada de trabajo<select name="schedule" [(ngModel)]="form.jornada_codigo"><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></label><label>Horas de descanso/día<input type="number" name="dailyRest" [(ngModel)]="form.horas_descanso_por_dia" min="0" max="3" step="1" required></label><label>Visitas máximas/día<input type="number" name="visitsDay" [(ngModel)]="form.visitas_maximas_por_dia" min="1" required></label></div><p class="weekly-capacity">Capacidad semanal: {{ selectedScheduleHours }} h · Capacidad diaria efectiva: {{ form.horas_por_dia - form.horas_descanso_por_dia | number:'1.0-1' }} h</p><p class="form-error" *ngIf="error">{{ error }}</p><div class="form-actions"><button type="submit" [disabled]="saving || employeeForm.invalid"><i class="pi pi-save"></i> {{ saving ? 'Guardando...' : 'Agregar empleado' }}</button></div></form></section>
<section class="employee-table-card"><div class="table-header"><h2>Empleados registrados</h2><div><span class="selected-count" *ngIf="selectedIds.size">{{ selectedIds.size }} seleccionados</span><button type="button" (click)="toggleAll(!allSelected)" [disabled]="loading">{{ allSelected ? 'Deseleccionar todos' : 'Seleccionar todos' }}</button><button type="button" (click)="load()" [disabled]="loading"><i class="pi pi-refresh"></i> Actualizar</button></div></div><p class="form-error" *ngIf="error && !loading">{{ error }}</p><div class="bulk-actions" *ngIf="selectedIds.size"><label>Jornada<select [(ngModel)]="bulkSchedule"><option value="">Sin cambio</option><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></label><label>Descanso/día<input type="number" min="0" max="3" step="1" [(ngModel)]="bulkRestHours" placeholder="Sin cambio"></label><label>Visitas/día<input type="number" min="1" step="1" [(ngModel)]="bulkVisits" placeholder="Sin cambio"></label><fieldset><legend>Días de descanso</legend><label *ngFor="let day of ['Lunes','Martes','Miercoles','Jueves','Viernes','Sabado','Domingo']"><input type="checkbox" [checked]="bulkRestDays.has(day)" (change)="toggleBulkRestDay(day, $any($event.target).checked)">{{ day }}</label></fieldset><button type="button" (click)="applyBulkEmployeeChanges()" [disabled]="bulkSaving">Aplicar cambios</button><button type="button" (click)="setSelectedActive(true)" [disabled]="bulkSaving">Activar</button><button type="button" (click)="setSelectedActive(false)" [disabled]="bulkSaving">Desactivar</button><button type="button" class="delete" (click)="removeSelected()" [disabled]="bulkSaving">Eliminar seleccionados</button></div><p class="loading" *ngIf="loading">Cargando empleados...</p><div class="table-wrap" *ngIf="!loading"><table><thead><tr><th class="select-column"><input type="checkbox" [checked]="allSelected" (change)="toggleAll($any($event.target).checked)" aria-label="Seleccionar todos los empleados"></th><th>ID</th><th>Nombre</th><th>Regional</th><th>Jornada</th><th>Descanso/día</th><th>Días de descanso</th><th>Visitas/día</th><th>Estado</th><th>Acciones</th></tr></thead><tbody><tr *ngFor="let employee of employees" [class.editing-row]="editingId === employee.id"><td><input type="checkbox" [checked]="selectedIds.has(employee.id)" (change)="toggleSelection(employee.id, $any($event.target).checked)" [attr.aria-label]="'Seleccionar ' + employee.nombre"></td><td>{{ employee.employee_code }}</td><td>{{ employee.nombre }}</td><td>{{ employee.regional || '-' }}</td><td><ng-container *ngIf="editingId === employee.id; else scheduleText"><select [(ngModel)]="employee.jornada_codigo" [name]="'schedule-' + employee.id" (ngModelChange)="onInlineScheduleChange(employee)"><option *ngFor="let schedule of schedules" [value]="schedule.code">{{ schedule.label }}</option></select></ng-container><ng-template #scheduleText>{{ scheduleLabel(employee) }}</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else restText"><input class="inline-number" type="number" [(ngModel)]="employee.horas_descanso_por_dia" [name]="'rest-' + employee.id" min="0" max="3" step="1"></ng-container><ng-template #restText>{{ employee.horas_descanso_por_dia }} h</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else daysText"><div class="rest-days-picker inline-picker"><label *ngFor="let day of availableRestDays(employee)"><input type="checkbox" [checked]="employee.dias_descanso.includes(day)" (change)="toggleRestDay(employee, day, $any($event.target).checked)">{{ day }}</label></div></ng-container><ng-template #daysText>{{ employee.dias_descanso.length ? employee.dias_descanso.join(', ') : 'Ninguno' }}</ng-template></td><td><ng-container *ngIf="editingId === employee.id; else visitsText"><input class="inline-number" type="number" [(ngModel)]="employee.visitas_maximas_por_dia" [name]="'visits-' + employee.id" min="1" step="1"></ng-container><ng-template #visitsText>{{ employee.visitas_maximas_por_dia }}</ng-template></td><td><span class="status" [class.inactive]="!employee.activo">{{ employee.activo ? 'Activo' : 'Inactivo' }}</span></td><td class="row-actions"><ng-container *ngIf="editingId === employee.id; else normalActions"><button type="button" (click)="saveInline(employee)" [disabled]="saving">Guardar</button><button type="button" (click)="cancelEdit()">Cancelar</button></ng-container><ng-template #normalActions><button type="button" (click)="edit(employee)">Editar</button><button type="button" (click)="toggleActive(employee)">{{ employee.activo ? 'Desactivar' : 'Activar' }}</button><button type="button" class="delete" (click)="remove(employee)">Eliminar</button></ng-template></td></tr><tr *ngIf="!employees.length"><td colspan="10">No hay empleados registrados.</td></tr></tbody></table></div></section>
</main>
@@ -1,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<string>();
bulkSchedule = '';
bulkRestHours: number | null = null;
bulkVisits: number | null = null;
bulkRestDays = new Set<string>();
bulkSaving = false;
private destroyed = false;
private loadSequence = 0;
projectId = '';
constructor(
private employeesService: EmployeeService,
private auth: AuthService,
private router: Router,
private activatedRoute: ActivatedRoute,
private location: Location,
public profile: UserProfileService,
private cdr: ChangeDetectorRef
) {}
ngOnInit(): void {
this.projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? '';
if (!this.projectId) {
void this.router.navigate(['/home']);
return;
}
void this.load();
}
@@ -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<void> {
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<string>();
}
toggleBulkRestDay(day: string, checked: boolean): void {
checked ? this.bulkRestDays.add(day) : this.bulkRestDays.delete(day);
}
async applyBulkEmployeeChanges(): Promise<void> {
const selected = this.selectedEmployees;
if (!selected.length) return;
if (!this.bulkSchedule && this.bulkRestHours === null && this.bulkVisits === null && !this.bulkRestDays.size) {
this.error = 'Selecciona al menos un cambio masivo para aplicar.';
return;
}
if (this.bulkRestHours !== null && (!Number.isInteger(this.bulkRestHours) || this.bulkRestHours < 0 || this.bulkRestHours > 3)) {
this.error = 'Descanso/día debe ser un entero entre 0 y 3.';
return;
}
if (this.bulkVisits !== null && (!Number.isInteger(this.bulkVisits) || this.bulkVisits < 1)) {
this.error = 'Visitas/día debe ser un entero mayor que 0.';
return;
}
this.bulkSaving = true;
this.error = '';
try {
await Promise.all(selected.map(employee => {
const schedule = this.bulkSchedule || employee.jornada_codigo;
const restDays = this.bulkRestDays.size
? Array.from(this.bulkRestDays).filter(day => workSchedule(schedule).days.includes(day))
: (this.bulkSchedule ? employee.dias_descanso.filter(day => workSchedule(schedule).days.includes(day)) : employee.dias_descanso);
return this.employeesService.update(employee.id, {
...employee,
jornada_codigo: schedule,
dias_por_semana: workSchedule(schedule).days.length,
dias_descanso: restDays,
horas_descanso_por_dia: this.bulkRestHours ?? employee.horas_descanso_por_dia,
visitas_maximas_por_dia: this.bulkVisits ?? employee.visitas_maximas_por_dia
});
}));
this.importMessage = `${selected.length} empleados actualizados.`;
this.resetBulkFields();
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudieron aplicar los cambios masivos.';
} finally {
this.bulkSaving = false;
}
}
async setSelectedActive(active: boolean): Promise<void> {
const selected = this.selectedEmployees;
if (!selected.length) return;
this.bulkSaving = true;
try {
await Promise.all(selected.map(employee => this.employeesService.update(employee.id, { ...employee, activo: active })));
this.importMessage = `${selected.length} empleados ${active ? 'activados' : 'desactivados'}.`;
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudo actualizar el estado de los empleados.';
} finally {
this.bulkSaving = false;
}
}
async removeSelected(): Promise<void> {
const selected = this.selectedEmployees;
if (!selected.length || !window.confirm(`¿Eliminar ${selected.length} empleados seleccionados? Esta acción no se puede deshacer.`)) return;
this.bulkSaving = true;
try {
await Promise.all(selected.map(employee => this.employeesService.remove(employee.id)));
this.importMessage = `${selected.length} empleados eliminados.`;
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudieron eliminar los empleados.';
} finally {
this.bulkSaving = false;
}
}
scheduleLabel(employee: Employee): string {
return workSchedule(employee.jornada_codigo).label;
}
@@ -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<void> {
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: '',
@@ -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;
}
@@ -18,7 +18,6 @@
<span class="badge">{{ routeData.json.dia }}</span>
</div>
</div>
<section class="info-card">
<h3>Información de la ruta</h3>
<dl class="info-list">
@@ -34,10 +33,7 @@
</section>
<section class="info-card">
<h3>Coordenadas</h3>
<dl class="info-list">
<div><dt>Usuario (lat, lng)</dt><dd>{{ routeData.json.latitud_usuario }}, {{ routeData.json.longitud_usuario }}</dd></div>
<div><dt>PDV (lat, lng)</dt><dd>{{ routeData.json.latitud_pdv }}, {{ routeData.json.longitud_pdv }}</dd></div>
</dl>
<h3>PDV destino</h3>
<a *ngIf="googleMapsUrl" class="maps-btn" [href]="googleMapsUrl" target="_blank" rel="noopener noreferrer"><i class="pi pi-map-marker"></i> Ver en Google Maps</a>
</section>
</div>
@@ -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: '<div class="marker-inner">A</div>',
iconSize: [30, 30],
iconAnchor: [15, 15],
html: `<div class="marker-inner" style="background:${startColor};border:3px solid #fff;">A</div>`,
iconSize: [34, 34],
iconAnchor: [17, 17],
popupAnchor: [0, -18]
});
@@ -1,99 +0,0 @@
.assigned-route-container {
width: 100%;
}
.header-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.actions-right {
display: flex;
gap: 8px;
}
.btn-secondary {
background: var(--glm-surface);
color: var(--glm-azul);
border: 1px solid var(--glm-gris-medio);
border-radius: 4px;
padding: 8px 14px;
font-size: 13px;
font-family: Arial, sans-serif;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.btn-secondary:hover {
background: var(--glm-verde-bg);
border-color: var(--glm-verde);
}
.btn-icon {
background: transparent;
border: 1px solid var(--glm-gris-medio);
border-radius: 4px;
color: var(--glm-azul);
cursor: pointer;
font-size: 15px;
padding: 6px 8px;
transition: all 0.2s;
}
.btn-icon:hover {
background: var(--glm-verde);
border-color: var(--glm-verde);
color: #ffffff;
}
.table-caption {
display: flex;
justify-content: space-between;
align-items: center;
}
.table-caption .font-bold {
color: var(--glm-azul);
font-weight: 700;
}
.table-caption .count {
color: var(--glm-acero);
font-size: 13px;
}
.link-usuario {
color: var(--glm-azul);
font-weight: 600;
cursor: pointer;
text-decoration: underline;
text-decoration-color: transparent;
transition: text-decoration-color 0.15s;
}
.link-usuario:hover {
text-decoration-color: var(--glm-verde);
color: var(--glm-verde);
}
.inline-edit {
width: 100%;
min-width: 100px;
padding: 4px 6px;
border: 1px solid var(--glm-verde);
border-radius: 3px;
background: #fff;
color: var(--glm-gris-oscuro);
font: inherit;
}
.edit-actions {
white-space: nowrap;
}
@@ -1,124 +0,0 @@
<div class="assigned-route-container">
<div class="header-actions">
<input
pInputText
type="text"
placeholder="Buscar..."
(input)="dt.filterGlobal($any($event.target).value, 'contains')"
/>
<div class="actions-right">
<button type="button" class="btn-secondary" (click)="goToOverviewMap()" pTooltip="Ver mapa filtrado por usuario" tooltipPosition="top">
<i class="pi pi-map"></i> Ver mapa
</button>
<button type="button" class="btn-secondary" (click)="downloadCSV()">
<i class="pi pi-download"></i> Descargar CSV
</button>
<button type="button" class="btn-secondary" (click)="reoptimizeLocally()" [disabled]="!canReoptimize" title="Vuelve a ejecutar el optimizador con los archivos cargados">
<span *ngIf="reoptLoading" class="spinner-inline"></span>
<i *ngIf="!reoptLoading" class="pi pi-sync"></i> {{ reoptLoading ? 'Reoptimizando...' : 'Reoptimizar rutas' }}
</button>
<button type="button" class="btn-secondary" *ngIf="hasLocalChanges" (click)="restoreOriginal()">
<i class="pi pi-undo"></i> Deshacer cambios
</button>
<button
type="button"
class="btn-secondary"
(click)="toggleRutas()"
pTooltip="Mostrar u ocultar el detalle de rutas"
tooltipPosition="top"
>
<i [class]="rutasExpandido ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"></i>
{{ rutasExpandido ? 'Ocultar rutas' : 'Ver rutas' }}
</button>
</div>
</div>
<div class="table-error" *ngIf="reoptError"><i class="pi pi-exclamation-triangle"></i> {{ reoptError }}</div>
<div class="table-error" *ngIf="editError"><i class="pi pi-exclamation-triangle"></i> {{ editError }}</div>
<p-table
#dt
[value]="assignedRoutes"
[loading]="isLoading"
[paginator]="true"
[rows]="10"
size="small"
[columns]="cols"
[rowsPerPageOptions]="[10, 25, 50]"
styleClass="p-datatable-sm text-sm"
>
<ng-template pTemplate="caption">
<div class="table-caption">
<span class="font-bold">Rutas asignadas</span>
<span class="count">{{ assignedRoutes.length }} registros</span>
</div>
</ng-template>
<ng-template pTemplate="header">
<tr>
<th *ngFor="let col of cols" pSortableColumn="{{ col.field }}">
{{ col.header }}
<p-sortIcon [field]="col.field"></p-sortIcon>
<p-columnFilter
[field]="col.field"
display="menu"
matchMode="contains"
[showMatchModes]="false"
[showOperator]="false"
[showAddButton]="false"
></p-columnFilter>
</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-row>
<tr>
<td>
<ng-container *ngIf="editingRoute === row; else userValue">
<select class="inline-edit" [(ngModel)]="editUserId">
<option *ngFor="let user of userOptions" [value]="user.id">{{ user.name }}</option>
</select>
</ng-container>
<ng-template #userValue><a class="link-usuario" (click)="goToUserMap(row.json.usuario_id)" pTooltip="Ver todas las rutas de este usuario" tooltipPosition="top">{{ row.json.nombre_usuario }}</a></ng-template>
</td>
<td>{{ row.json.nombre_pdv }}</td>
<td>{{ row.json.hora_laboral_semanal_usuario }}</td>
<td>{{ row.json.hora_minima_semanal_pdv }}</td>
<td>{{ row.json.horas_trabajo }}</td>
<td>
<ng-container *ngIf="editingRoute === row; else dayValue">
<select class="inline-edit" [(ngModel)]="editDay">
<option *ngFor="let day of dayOptions" [value]="day">{{ day }}</option>
</select>
</ng-container>
<ng-template #dayValue>{{ row.json.dia }}</ng-template>
</td>
<td>{{ row.json.distancia }}</td>
<td>{{ (row.json.horas_desplazamiento * 60).toFixed(0) }} min</td>
<td class="text-center edit-actions">
<ng-container *ngIf="editingRoute === row; else editButton">
<button type="button" class="btn-icon" (click)="saveEdit()" title="Guardar"><i class="pi pi-check"></i></button>
<button type="button" class="btn-icon" (click)="cancelEdit()" title="Cancelar"><i class="pi pi-times"></i></button>
</ng-container>
<ng-template #editButton>
<button type="button" class="btn-icon" (click)="startEdit(row)" title="Editar asignación"><i class="pi pi-pencil"></i></button>
</ng-template>
<button
type="button"
class="btn-icon"
(click)="onShowMap(row)"
pTooltip="Ver en mapa (1 ruta)"
tooltipPosition="top"
>
<i class="pi pi-map-marker"></i>
</button>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="9" class="text-center">No hay rutas asignadas</td>
</tr>
</ng-template>
</p-table>
</div>
@@ -1,240 +0,0 @@
import { Component, ChangeDetectorRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { RouteAssignedResponse } from '../../../core/models/route.model';
import { saveAs } from 'file-saver';
import { Table } from 'primeng/table';
import { TableModule } from 'primeng/table';
import { InputTextModule } from 'primeng/inputtext';
import { TooltipModule } from 'primeng/tooltip';
import { RouteState } from '../../../core/route-state/route';
import { RutasService } from '../../../core/services/router.service';
import { PlanningInputService } from '../../../core/services/planning-input.service';
@Component({
standalone: true,
selector: 'app-assigned-route',
imports: [
CommonModule,
FormsModule,
TableModule,
InputTextModule,
TooltipModule
],
templateUrl: './assigned-route.component.html',
styleUrl: './assigned-route.component.css'
})
export class AssignedRouteComponent {
@ViewChild('dt') table!: Table;
rutasExpandido = false;
tablaRutas = '';
assignedRoutes: RouteAssignedResponse[] = [];
isLoading = false;
cols: any[] = [];
editingRoute: RouteAssignedResponse | null = null;
editUserId = '';
editDay = '';
hasLocalChanges = false;
editError: string | null = null;
reoptLoading = false;
reoptError: string | null = null;
constructor(
private routesState: RouteState,
private cdr: ChangeDetectorRef,
private router: Router,
private activatedRoute: ActivatedRoute,
private rutasService: RutasService,
private planningInput: PlanningInputService
) { }
private subs: import('rxjs').Subscription[] = [];
ngOnInit() {
this.routesState.resetLoading();
this.subs.push(
this.routesState.loading$.subscribe(value => {
this.isLoading = value;
this.cdr.detectChanges();
})
);
this.subs.push(
this.routesState.assignedRoutes$.subscribe(data => {
this.assignedRoutes = [...data];
this.cdr.detectChanges();
})
);
this.cols = [
{ field: 'json.nombre_usuario', header: 'Usuario' },
{ field: 'json.nombre_pdv', header: 'PDV' },
{ field: 'json.hora_laboral_semanal_usuario', header: 'Sem. Usuario (hr)' },
{ field: 'json.hora_minima_semanal_pdv', header: 'Horas Min. PDV' },
{ field: 'json.horas_trabajo', header: 'Horas Trabajo' },
{ field: 'json.dia', header: 'Día' },
{ field: 'json.distancia', header: 'Distancia (km)' },
{ field: 'json.horas_desplazamiento', header: 'Duración (min)' },
{ field: '', header: '' }
];
}
onShowMap(row: RouteAssignedResponse) {
this.routesState.sendRoute(row);
this.router.navigate(['/proyectos', this.projectId, 'ruta'], {
queryParams: { pdv_id: row.json.pdv_id, usuario_id: row.json.usuario_id, dia: row.json.dia }
});
}
goToOverviewMap() {
// If table has filtered view, use first visible row's usuario as default; else first assigned
const target = (this.table?.filteredValue?.[0] as RouteAssignedResponse | undefined) ?? this.assignedRoutes[0];
if (target) {
this.router.navigate(['/proyectos', this.projectId, 'mapa'], {
queryParams: { usuario_id: target.json.usuario_id, dia: 'Todos' }
});
} else {
this.router.navigate(['/proyectos', this.projectId, 'mapa']);
}
}
goToUserMap(usuarioId: string) {
this.router.navigate(['/proyectos', this.projectId, 'mapa'], { queryParams: { usuario_id: usuarioId, dia: 'Todos' } });
}
private get projectId(): string { return this.activatedRoute.snapshot.paramMap.get('id') ?? ''; }
get userOptions(): { id: string; name: string }[] {
const users = new Map<string, string>();
for (const route of this.assignedRoutes) users.set(String(route.json.usuario_id), route.json.nombre_usuario);
return Array.from(users, ([id, name]) => ({ id, name }));
}
get dayOptions(): string[] {
return this.routesState.getDiasUnicos();
}
startEdit(route: RouteAssignedResponse): void {
this.editingRoute = route;
this.editUserId = String(route.json.usuario_id);
this.editDay = route.json.dia;
this.editError = null;
}
cancelEdit(): void {
this.editingRoute = null;
}
saveEdit(): void {
if (!this.editingRoute || !this.editUserId || !this.editDay) return;
const capacity = this.routesState.canAssignToDay(
this.editUserId,
this.editDay,
Number(this.editingRoute.json.horas_trabajo || this.editingRoute.json.hora_minima_semanal_pdv || 0),
this.editingRoute
);
if (!capacity.allowed) {
this.editError = `La asignación excede la capacidad diaria (${capacity.totalHours.toFixed(1)} h de ${capacity.capacityHours.toFixed(1)} h).`;
return;
}
this.routesState.updateAssignedRoute(this.editingRoute, this.editUserId, this.editDay);
this.hasLocalChanges = true;
this.editingRoute = null;
}
get canReoptimize(): boolean {
return this.assignedRoutes.length > 0 && !!this.planningInput.getFormData() && !this.reoptLoading;
}
reoptimizeLocally(): void {
const data = this.planningInput.getFormData();
if (!data || !this.assignedRoutes.length || this.reoptLoading) return;
this.reoptLoading = true;
this.reoptError = null;
this.routesState.setLoading(true);
this.rutasService.reoptimizarRutas(data).subscribe({
next: response => {
const items = response as unknown as unknown[];
const assigned = items.find(item => item && typeof item === 'object' && 'assigned' in item) as { assigned?: unknown } | undefined;
const noAssigned = items.find(item => item && typeof item === 'object' && 'noAssigned' in item) as { noAssigned?: unknown } | undefined;
if (assigned?.assigned) this.routesState.setAssignedRoutes(assigned.assigned as never);
if (noAssigned?.noAssigned) this.routesState.setNoAssignedRoutes(noAssigned.noAssigned as never);
this.hasLocalChanges = false;
this.reoptLoading = false;
this.routesState.setLoading(false);
this.cdr.detectChanges();
},
error: err => {
this.reoptError = err?.error?.error || err?.message || 'No se pudo reoptimizar la planificación';
this.reoptLoading = false;
this.routesState.setLoading(false);
this.cdr.detectChanges();
}
});
this.editingRoute = null;
}
restoreOriginal(): void {
this.routesState.restoreOriginalAssignedRoutes();
this.hasLocalChanges = false;
this.editingRoute = null;
}
toggleRutas() {
this.rutasExpandido = !this.rutasExpandido;
}
ngOnDestroy() {
this.subs.forEach(s => s.unsubscribe());
}
downloadCSV() {
const headers = [
'Usuario ID',
'Usuario',
'Long Usuario',
'Lat Usuario',
'PDV ID',
'PDV',
'Long PDV',
'Lat PDV',
'Horas Usuario',
'Horas PDV',
'Horas Trabajo',
'Día',
'Distancia',
'Duracion'
];
const data =
this.table.filteredValue && this.table.filteredValue.length
? this.table.filteredValue
: this.assignedRoutes;
const rows = data.map(r => [
r.json.usuario_id,
r.json.nombre_usuario,
r.json.longitud_usuario,
r.json.latitud_usuario,
r.json.pdv_id,
r.json.nombre_pdv,
r.json.longitud_pdv,
r.json.latitud_pdv,
r.json.hora_laboral_semanal_usuario,
r.json.hora_minima_semanal_pdv,
r.json.horas_trabajo,
r.json.dia,
r.json.distancia,
r.json.horas_desplazamiento
]);
const csvContent =
[headers, ...rows].map(r => r.join(',')).join('\n');
const bom = '\uFEFF';
const blob = new Blob([bom + csvContent], {
type: 'text/csv;charset=utf-8;'
});
saveAs(blob, 'rutas_planificadas.csv');
}
}
@@ -53,6 +53,25 @@
.employees-link { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 1px solid var(--glm-verde); border-radius: 4px; background: var(--glm-verde-bg); color: var(--glm-azul); font-size: .82rem; font-weight: 700; text-decoration: none; white-space: nowrap; }
.employees-link: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;
@@ -5,14 +5,16 @@
</div>
<div class="home-container">
<a class="employees-link home-employees-link" routerLink="/empleados"><i class="pi pi-users"></i> Gestionar empleados</a>
<a class="back-projects project-back-link" routerLink="/home"><i class="pi pi-arrow-left"></i> Mis proyectos</a>
<div class="project-management-links"><a class="employees-link home-employees-link" [routerLink]="['/proyectos', projectId, 'empleados']"><i class="pi pi-users"></i> Gestionar empleados</a><a class="employees-link home-employees-link" [routerLink]="['/proyectos', projectId, 'pdvs']"><i class="pi pi-map-marker"></i> Gestionar PDVs</a></div>
<header class="home-header">
<div class="project-heading"><div class="project-title-line"><a class="back-projects" routerLink="/home"><i class="pi pi-arrow-left"></i> Mis proyectos</a><h1 *ngIf="!editingProject">{{ projectName || 'Proyecto' }}</h1><input *ngIf="editingProject" [(ngModel)]="projectName" maxlength="120"><button *ngIf="!editingProject" type="button" class="edit-project" (click)="startProjectEdit()"><i class="pi pi-pencil"></i> Editar proyecto</button><div *ngIf="editingProject" class="project-edit-actions"><button type="button" (click)="saveProject()" [disabled]="projectSaving">Guardar</button><button type="button" (click)="cancelProjectEdit()" [disabled]="projectSaving">Cancelar</button></div></div><textarea *ngIf="editingProject" [(ngModel)]="projectDescription" maxlength="500" placeholder="Descripción del proyecto"></textarea><p *ngIf="!editingProject">Gestión de rutas asignadas y puntos de venta pendientes</p></div>
<div class="project-heading"><div class="project-title-line"><h1 *ngIf="!editingProject">{{ projectName || 'Proyecto' }}</h1><input *ngIf="editingProject" [(ngModel)]="projectName" maxlength="120"><button *ngIf="!editingProject" type="button" class="edit-project" (click)="startProjectEdit()"><i class="pi pi-pencil"></i> Editar proyecto</button><div *ngIf="editingProject" class="project-edit-actions"><button type="button" (click)="saveProject()" [disabled]="projectSaving">Guardar</button><button type="button" (click)="cancelProjectEdit()" [disabled]="projectSaving">Cancelar</button></div></div><textarea *ngIf="editingProject" [(ngModel)]="projectDescription" maxlength="500" placeholder="Descripción del proyecto"></textarea><p *ngIf="!editingProject">Gestión de rutas asignadas y puntos de venta pendientes</p></div>
</header>
<section class="card upload-card">
<h2 class="section-title">PDVs del proyecto</h2>
<p class="employees-summary" [class.empty]="!employeesLoading && !activeEmployees"><i class="pi pi-users"></i> <span *ngIf="employeesLoading">Cargando empleados activos...</span><span *ngIf="!employeesLoading">{{ activeEmployees }} empleados activos de {{ projectCountry }} se usarán para planificar.</span></p>
<button type="button" class="dashboard-section-toggle" (click)="toggleInputs()"><span><i class="pi pi-database"></i> Datos para planificación</span><i class="pi" [class.pi-chevron-down]="inputsExpanded" [class.pi-chevron-right]="!inputsExpanded"></i></button>
<div *ngIf="inputsExpanded">
<p class="employees-summary" [class.empty]="!employeesLoading && !activeEmployees"><i class="pi pi-users"></i> <span *ngIf="employeesLoading">Cargando empleados activos...</span><span *ngIf="!employeesLoading">{{ activeEmployees }} empleados activos de este proyecto se usarán para planificar.</span></p>
<div class="upload-grid">
<div class="file-input-wrapper">
@@ -96,6 +98,14 @@
</div>
<small class="hint-text" *ngIf="submitDisabledReason && !isLoading">{{ submitDisabledReason }}</small>
</div>
</div>
</section>
<div class="map-overview-link"><a class="employees-link" [routerLink]="['/proyectos', projectId, 'mapa']"><i class="pi pi-map"></i> Ver mapa general</a></div>
<section class="card planning-filters-card">
<button type="button" class="dashboard-section-toggle" (click)="toggleFilters()"><span><i class="pi pi-filter"></i> Filtros del dashboard</span><i class="pi" [class.pi-chevron-down]="filtersExpanded" [class.pi-chevron-right]="!filtersExpanded"></i></button>
<div class="planning-filters-grid" *ngIf="filtersExpanded"><label>Cobertura<select [(ngModel)]="planningFilters.cobertura" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('cobertura')" [value]="value">{{ value }}</option></select></label><label>Zona<select [(ngModel)]="planningFilters.zona" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('zona')" [value]="value">{{ value }}</option></select></label><label>Región<select [(ngModel)]="planningFilters.region" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('region')" [value]="value">{{ value }}</option></select></label><label>Estado<select [(ngModel)]="planningFilters.estado" (ngModelChange)="applyPlanningFilters()"><option value="">Todos</option><option *ngFor="let value of filterOptions('estado')" [value]="value">{{ value }}</option></select></label><label>Ciudad<select [(ngModel)]="planningFilters.ciudad" (ngModelChange)="applyPlanningFilters()"><option value="">Todas</option><option *ngFor="let value of filterOptions('ciudad')" [value]="value">{{ value }}</option></select></label><button type="button" class="btn-secondary" (click)="clearPlanningFilters()"><i class="pi pi-filter-slash"></i> Limpiar</button></div>
</section>
<section class="card kpis-card">
@@ -103,11 +113,7 @@
</section>
<section class="card">
<app-assigned-route></app-assigned-route>
</section>
<section class="card">
<h2 class="section-title">PDVs sin ruta asignada</h2>
<app-no-assigned-route></app-no-assigned-route>
<button type="button" class="dashboard-section-toggle" (click)="toggleUnassigned()"><span><i class="pi pi-exclamation-triangle"></i> PDVs sin ruta asignada</span><i class="pi" [class.pi-chevron-down]="unassignedExpanded" [class.pi-chevron-right]="!unassignedExpanded"></i></button>
<app-no-assigned-route *ngIf="unassignedExpanded"></app-no-assigned-route>
</section>
</div>
@@ -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<void> {
this.projectId = this.activatedRoute.snapshot.paramMap.get('id') ?? '';
if (!this.projectId) return;
this.routeState.useProject(this.projectId);
this.planningInput.clear();
this.planningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
this.applyPlanningFilters();
try {
const [project, input] = await Promise.all([this.projectsService.get(this.projectId), this.projectsService.projectInput(this.projectId)]);
this.projectName = project.nombre;
this.projectDescription = project.descripcion ?? '';
this.projectCountry = input.country;
this.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<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
filterOptions(key: PlanningFilterKey): string[] { return this.planningFilter.options(key); }
applyPlanningFilters(): void { this.planningFilter.set(this.planningFilters); }
clearPlanningFilters(): void { this.planningFilters = { cobertura: '', zona: '', region: '', estado: '', ciudad: '' }; this.applyPlanningFilters(); }
toggleInputs(): void { this.inputsExpanded = !this.inputsExpanded; }
toggleFilters(): void { this.filtersExpanded = !this.filtersExpanded; }
toggleUnassigned(): void { this.unassignedExpanded = !this.unassignedExpanded; }
private async loadEmployees(): Promise<void> {
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;
@@ -20,6 +20,7 @@
<td class="text-left">{{ row.latitud }}</td>
<td class="text-left">{{ row.longitud }}</td>
<td class="text-left">{{ row.motivo }}</td>
<td class="text-left">{{ row.recomendacion }}</td>
<td class="assignment-actions">
<select [(ngModel)]="assignmentUser[row.pdv_id]" class="inline-select">
<option value="">Usuario</option>
@@ -37,7 +38,7 @@
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="7" class="text-center">
<td colspan="8" class="text-center">
No hay registros asignados
</td>
</tr>
@@ -6,6 +6,8 @@ import { RouteNoAssignedResponse } from '../../../core/models/route.model';
import { RouteState, UnassignedAssignment } from '../../../core/route-state/route';
import { Subscription } from 'rxjs';
import { OrsService } from '../../../core/services/ors.service';
import { PlanningFilterService } from '../../../core/services/planning-filter.service';
import { recommendationForUnassigned } from '../../../shared/utils/unassigned-recommendation.utils';
@Component({
standalone: true,
@@ -17,6 +19,7 @@ import { OrsService } from '../../../core/services/ors.service';
export class NoAssignedRouteComponent implements OnDestroy {
noAssignedRoutes: RouteNoAssignedResponse[] = [];
private allNoAssignedRoutes: RouteNoAssignedResponse[] = [];
isLoading = false;
cols: { field: string; header: string }[] = [];
private subs: Subscription[] = [];
@@ -28,7 +31,8 @@ export class NoAssignedRouteComponent implements OnDestroy {
constructor(
private routesState: RouteState,
private cdr: ChangeDetectorRef,
private orsService: OrsService
private orsService: OrsService,
private filters: PlanningFilterService
) {}
ngOnInit() {
@@ -40,17 +44,23 @@ export class NoAssignedRouteComponent implements OnDestroy {
);
this.subs.push(
this.routesState.noAssignedRoutes$.subscribe(data => {
this.noAssignedRoutes = [...data];
this.allNoAssignedRoutes = data.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
this.cdr.detectChanges();
})
);
this.subs.push(this.filters.filters$.subscribe(() => {
this.noAssignedRoutes = this.filters.filterUnassigned(this.allNoAssignedRoutes);
this.cdr.detectChanges();
}));
this.cols = [
{ field: 'pdv_id', header: 'PDV ID' },
{ field: 'pdv_nombre', header: 'Nombre' },
{ field: 'minimo_horas_semana', header: 'Min. Horas/Sem' },
{ field: 'latitud', header: 'Latitud' },
{ field: 'longitud', header: 'Longitud' },
{ field: 'motivo', header: 'Motivo' }
{ field: 'motivo', header: 'Motivo' },
{ field: 'recomendacion', header: 'Recomendación' }
];
}
@@ -2,6 +2,12 @@
border-top: 3px solid var(--glm-azul);
}
.section-toggle,.subsection-toggle { display:flex; width:100%; align-items:center; justify-content:space-between; gap:12px; border:0; background:transparent; color:var(--glm-azul); cursor:pointer; font:inherit; font-weight:700; text-align:left; text-transform:uppercase; }
.section-toggle { padding:10px 0 14px; font-size:1rem; }
.subsection-toggle { padding:0 0 8px; font-size:.82rem; }
.section-toggle span,.subsection-toggle span { display:inline-flex; align-items:center; gap:7px; }
.table-error { margin:8px 0; color:#c9252d; font-size:.82rem; font-weight:700; }
.kpis-heading {
display: flex;
align-items: flex-start;
@@ -174,6 +180,14 @@
.user-load-table tbody tr:nth-child(even) {
background: var(--glm-gris-claro);
}
.load-row { cursor:pointer; }
.load-row:hover { background:var(--glm-verde-bg)!important; }
.visit-detail-row>td { padding:10px 14px; background:#f8fafb; }
.visits-table { width:100%; border-collapse:collapse; font-size:.76rem; }
.visits-table th { background:#5b7080; }
.visits-table select { max-width:150px; padding:5px; border:1px solid var(--glm-gris-medio); border-radius:4px; }
.icon-button { margin:0 2px; padding:5px 7px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; }
.user-load-pagination { display:flex; align-items:center; justify-content:flex-end; gap:10px; margin-top:10px; color:var(--glm-acero); font-size:.78rem; }.user-load-pagination button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:6px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.user-load-pagination button:hover:not(:disabled) { background:var(--glm-verde-bg); border-color:var(--glm-verde); }.user-load-pagination button:disabled { cursor:not-allowed; opacity:.55; }
.utilization-cell {
display: flex;
@@ -1,82 +1,267 @@
<div class="kpis-panel">
<button
type="button"
class="section-toggle"
(click)="toggleMetrics()"
[attr.aria-expanded]="metricsExpanded"
>
<span><i class="pi pi-chart-bar"></i> Indicadores de planificación</span
><i
class="pi"
[class.pi-chevron-down]="metricsExpanded"
[class.pi-chevron-right]="!metricsExpanded"
></i>
</button>
<div *ngIf="metricsExpanded">
<div class="kpis-heading">
<div>
<h2 class="section-title">Indicadores de planificación</h2>
<p class="kpis-subtitle">Resumen total de la planificación actual</p>
</div>
<p class="kpis-subtitle">Resumen de la selección actual</p>
<div class="kpis-actions">
<span class="kpis-status" *ngIf="hasData"><i class="pi pi-check-circle"></i> Datos disponibles</span>
<button type="button" class="export-button" *ngIf="hasData" (click)="exportPlanning()"><i class="pi pi-download"></i> Descargar XLSX</button>
<span class="kpis-status" *ngIf="hasData"
><i class="pi pi-check-circle"></i> Datos disponibles</span
><button
type="button"
class="export-button"
*ngIf="hasData"
(click)="downloadRoutes()"
[disabled]="pdfLoading"
>
<i class="pi" [class.pi-spin]="pdfLoading" [class.pi-file-pdf]="!pdfLoading" [class.pi-spinner]="pdfLoading"></i> {{ pdfLoading ? "Generando PDF..." : "Descargar rutas" }}</button
><button
type="button"
class="export-button"
*ngIf="hasData"
(click)="reoptimize()"
[disabled]="!canReoptimize"
>
<i class="pi pi-sync"></i>
{{ reoptLoading ? "Reoptimizando..." : "Reoptimizar" }}</button
><button
type="button"
class="export-button"
*ngIf="hasLocalChanges"
(click)="restoreOriginal()"
>
<i class="pi pi-undo"></i> Deshacer
</button>
</div>
</div>
<div class="table-error" *ngIf="reoptError || editError || pdfError">
{{ reoptError || editError || pdfError }}
</div>
<div class="kpi-grid">
<article class="kpi-card kpi-primary">
<div class="kpi-icon"><i class="pi pi-map-marker"></i></div>
<div><span class="kpi-label">PDVs totales</span><strong>{{ summary.totalPdvs }}</strong><small>en la planificación</small></div>
<div>
<span class="kpi-label">PDVs totales</span
><strong>{{ summary.totalPdvs }}</strong>
</div>
</article>
<article class="kpi-card kpi-success">
<div class="kpi-icon"><i class="pi pi-check"></i></div>
<div><span class="kpi-label">PDVs asignados</span><strong>{{ summary.assignedPdvs }}</strong><small>{{ summary.coverage | number:'1.1-1' }}% de cobertura</small></div>
<div>
<span class="kpi-label">PDVs asignados</span
><strong>{{ summary.assignedPdvs }}</strong
><small>{{ summary.coverage | number: "1.1-1" }}% cobertura</small>
</div>
</article>
<article class="kpi-card kpi-warning">
<div class="kpi-icon"><i class="pi pi-exclamation-triangle"></i></div>
<div><span class="kpi-label">Sin asignar</span><strong>{{ summary.unassignedPdvs }}</strong><small>requieren revisión</small></div>
<div>
<span class="kpi-label">Sin asignar</span
><strong>{{ summary.unassignedPdvs }}</strong>
</div>
</article>
<article class="kpi-card kpi-blue">
<div class="kpi-icon"><i class="pi pi-users"></i></div>
<div><span class="kpi-label">Usuarios activos</span><strong>{{ summary.users }}</strong><small>{{ summary.days }} días planificados</small></div>
<div>
<span class="kpi-label">Usuarios</span
><strong>{{ summary.users }}</strong
><small>{{ summary.days }} días</small>
</div>
</article>
<article class="kpi-card kpi-orange">
<div class="kpi-icon"><i class="pi pi-compass"></i></div>
<div><span class="kpi-label">Distancia total</span><strong>{{ summary.totalDistance | number:'1.0-1' }} <em>km</em></strong><small>{{ summary.averageDistance | number:'1.0-1' }} km promedio por ruta</small></div>
<div>
<span class="kpi-label">Distancia</span
><strong
>{{ summary.totalDistance | number: "1.0-1" }} <em>km</em></strong
>
</div>
</article>
<ng-container *ngIf="hasData">
<article class="kpi-card kpi-success">
<div class="kpi-icon"><i class="pi pi-briefcase"></i></div>
<div><span class="kpi-label">Carga de trabajo</span><strong>{{ summary.totalWorkHours | number:'1.0-1' }} <em>h</em></strong><small>horas de trabajo planificadas</small></div>
<div>
<span class="kpi-label">Trabajo</span
><strong
>{{ summary.totalWorkHours | number: "1.0-1" }} <em>h</em></strong
>
</div>
</article>
</ng-container>
</div>
</div>
<div class="user-load-card" *ngIf="userLoads.length">
<h3><i class="pi pi-users"></i> Balance de carga por usuario</h3>
<div class="user-load-table-wrap">
<button type="button" class="subsection-toggle" (click)="toggleBalance()">
<span><i class="pi pi-users"></i> Balance de carga por usuario</span
><i
class="pi"
[class.pi-chevron-down]="balanceExpanded"
[class.pi-chevron-right]="!balanceExpanded"
></i>
</button>
<div class="user-load-table-wrap" *ngIf="balanceExpanded">
<table class="user-load-table">
<thead>
<tr><th>Usuario</th><th>Rutas</th><th>Distancia</th><th>Trabajo</th><th>Utilización</th></tr>
<tr>
<th></th>
<th>Usuario</th>
<th>PDVs</th>
<th>Visitas</th>
<th>Distancia</th>
<th>Trabajo</th>
<th>Carga</th>
<th></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let load of userLoads" [class.load-over-capacity]="load.status === 'over-capacity'">
<td>{{ load.name }}</td>
<td>{{ load.routes }}</td>
<td>{{ load.distance | number:'1.0-1' }} km</td>
<td>{{ load.workHours | number:'1.0-1' }} h</td>
<ng-container *ngFor="let load of paginatedUserLoads"
><tr
class="load-row"
[class.load-over-capacity]="load.status === 'over-capacity'"
(click)="toggleUser(load.id)"
>
<td>
<div class="utilization-cell" [ngClass]="utilizationClass(load)">
<span>{{ load.utilization | number:'1.0-1' }}%</span>
<span class="capacity-warning" *ngIf="load.status === 'over-capacity'" [title]="load.alertMessage" aria-label="Alerta de capacidad">⚠️</span>
<span class="utilization-bar" [title]="load.alertMessage"><span [style.width.%]="barWidth(load.utilization)"></span></span>
<i
class="pi"
[class.pi-chevron-down]="isExpanded(load.id)"
[class.pi-chevron-right]="!isExpanded(load.id)"
></i>
</td>
<td>{{ load.name }}</td>
<td>{{ load.uniquePdvs }}</td>
<td>{{ load.visits }}</td>
<td>{{ load.distance | number: "1.0-1" }} km</td>
<td>{{ load.workHours | number: "1.0-1" }} h</td>
<td>
<div
class="utilization-cell"
[ngClass]="utilizationClass(load)"
>
<span>{{ load.utilization | number: "1.0-1" }}%</span
><span class="utilization-bar" [title]="load.alertMessage"
><span [style.width.%]="barWidth(load.utilization)"></span
></span>
</div>
</td>
<td>
<button
type="button"
class="icon-button"
(click)="$event.stopPropagation(); showUserMap(load.id)"
title="Ver usuario en mapa"
>
<i class="pi pi-map"></i>
</button>
</td>
</tr>
<tr class="visit-detail-row" *ngIf="isExpanded(load.id)">
<td colspan="8">
<table class="visits-table">
<thead>
<tr>
<th>PDV</th>
<th>Día</th>
<th>Orden</th>
<th>Trabajo</th>
<th>Distancia</th>
<th>Duración</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let route of load.routes">
<td>{{ route.json.nombre_pdv }}</td>
<td>
<select
*ngIf="editingRoute === route; else dayText"
[(ngModel)]="editDay"
>
<option *ngFor="let day of dayOptions" [value]="day">
{{ day }}
</option></select
><ng-template #dayText>{{
route.json.dia
}}</ng-template>
</td>
<td>{{ route.json.visit_order || "-" }}</td>
<td>{{ route.json.horas_trabajo }} h</td>
<td>{{ route.json.distancia | number: "1.0-2" }} km</td>
<td>
{{
route.json.horas_desplazamiento * 60 | number: "1.0-0"
}}
min
</td>
<td>
<ng-container
*ngIf="editingRoute === route; else normalActions"
><select [(ngModel)]="editUserId">
<option
*ngFor="let user of userOptions"
[value]="user.id"
>
{{ user.name }}
</option></select
><button class="icon-button" (click)="saveEdit()">
<i class="pi pi-check"></i></button
><button class="icon-button" (click)="cancelEdit()">
<i class="pi pi-times"></i></button></ng-container
><ng-template #normalActions
><button
class="icon-button"
(click)="startEdit(route)"
>
<i class="pi pi-pencil"></i></button
><button
class="icon-button"
(click)="showRoute(route)"
>
<i class="pi pi-map-marker"></i></button
></ng-template>
</td>
</tr>
</tbody>
</table>
</td></tr
></ng-container>
</tbody>
</table>
</div>
<div class="user-load-pagination" *ngIf="userLoadPages > 1">
<button type="button" (click)="previousUserLoadPage()" [disabled]="userLoadPage === 1">Anterior</button>
<span>Página {{ userLoadPage }} de {{ userLoadPages }}</span>
<button type="button" (click)="nextUserLoadPage()" [disabled]="userLoadPage === userLoadPages">Siguiente</button>
</div>
</div>
<div class="reasons-card" *ngIf="reasons.length">
<h3><i class="pi pi-list"></i> Motivos de PDVs sin asignar</h3>
<div class="reason-list">
<button type="button" class="subsection-toggle" (click)="toggleReasons()">
<span><i class="pi pi-list"></i> Motivos de PDVs sin asignar</span
><i
class="pi"
[class.pi-chevron-down]="reasonsExpanded"
[class.pi-chevron-right]="!reasonsExpanded"
></i>
</button>
<div class="reason-list" *ngIf="reasonsExpanded">
<div class="reason-row" *ngFor="let item of reasons">
<span>{{ item.reason }}</span>
<strong>{{ item.count }}</strong>
<span>{{ item.reason }}</span
><strong>{{ item.count }}</strong>
</div>
</div>
</div>
<div class="kpis-empty" *ngIf="!hasData">
<i class="pi pi-chart-bar"></i>
<span>Carga una planificación para visualizar sus indicadores.</span>
<i class="pi pi-chart-bar"></i
><span>Carga una planificación para visualizar sus indicadores.</span>
</div>
</div>
@@ -1,176 +1,166 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { 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<string>();
editingRoute: RouteAssignedResponse | null = null;
editUserId = '';
editDay = '';
editError = '';
reoptLoading = false;
reoptError = '';
hasLocalChanges = false;
pdfLoading = false;
pdfError = '';
readonly userLoadPageSize = 14;
userLoadPage = 1;
private readonly subs: Subscription[] = [];
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<void> {
if (this.pdfLoading || !this.hasData) return;
this.pdfLoading = true;
this.pdfError = '';
try {
await this.pdfReport.download(this.projectId);
} catch (error: any) {
this.pdfError = error?.message ?? 'No se pudo generar el PDF de rutas.';
} finally {
this.pdfLoading = false;
this.cdr.detectChanges();
}
barWidth(value: number): number {
return Math.min(Math.max(value, 0), 100);
}
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); }
utilizationClass(load: UserLoad): string {
return load.status;
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; }
exportPlanning(): void {
this.exportService.export();
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 } });
}
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<string, UserLoad>();
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<string, string>();
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<string, number>();
for (const reason of reasonByPdv.values()) {
reasonCounts.set(reason, (reasonCounts.get(reason) ?? 0) + 1);
}
this.reasons = Array.from(reasonCounts.entries())
.map(([reason, count]) => ({ reason, count }))
.sort((a, b) => b.count - a.count);
for (const route of unassigned) if (!assignedPdvIds.has(String(route.pdv_id)) && !reasonByPdv.has(String(route.pdv_id))) reasonByPdv.set(String(route.pdv_id), route.motivo?.trim() || 'Sin motivo especificado');
const counts = new Map<string, number>(); for (const reason of reasonByPdv.values()) counts.set(reason, (counts.get(reason) ?? 0) + 1);
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 }; }
}
@@ -19,10 +19,15 @@
<p class="territory-summary" *ngIf="activeTerritories.length"><i class="pi pi-map-marker"></i> {{ activeTerritories.join(', ') }}</p>
<div class="filters-card" *ngIf="usuarios.length">
<label class="filter-label">Metro de prueba</label>
<select class="filter-select" [(ngModel)]="selectedMetro" (ngModelChange)="onMetroChange()">
<option *ngFor="let metro of metros" [value]="metro">{{ metro }}</option>
</select>
<label class="filter-label">Usuario</label>
<select class="filter-select" [(ngModel)]="selectedUsuarioId" (ngModelChange)="onUsuarioChange()">
<option value="Todos">Todos los usuarios</option>
<option *ngFor="let u of usuarios" [value]="u.id">{{ u.nombre }} ({{ u.id }})</option>
<option *ngFor="let u of metroUsuarios" [value]="u.id">{{ u.nombre }} ({{ u.id }})</option>
</select>
<label class="filter-label">Día</label>
@@ -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<string, string>();
for (const route of this.allRoutes) {
if (String(route.json.metro ?? '').trim() === this.selectedMetro) {
users.set(String(route.json.usuario_id), route.json.nombre_usuario);
}
}
return Array.from(users, ([id, nombre]) => ({ id, nombre }));
}
get activeTerritories(): string[] {
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<string, string | undefined>): void {
const userId = queryParams['usuario_id'];
const day = queryParams['dia'];
this.selectedUsuarioId = userId && this.usuarios.some(user => String(user.id) === String(userId)) ? String(userId) : 'Todos';
this.selectedDia = day && day !== 'Todos' && this.dias.includes(day) ? day : 'Todos';
const requestedMetro = String(queryParams['metro'] ?? '').trim();
const userMetro = this.selectedUsuarioId === 'Todos' ? '' : String(
this.allRoutes.find(route => String(route.json.usuario_id) === this.selectedUsuarioId)?.json.metro ?? ''
).trim();
const defaultMetro = this.metros.find(metro => ['cdmx', 'ciudaddemexico', 'mexicocity'].includes(this.normalizeMetro(metro))) ?? this.metros[0] ?? '';
this.selectedMetro = this.metros.includes(requestedMetro)
? requestedMetro
: this.metros.includes(userMetro)
? userMetro
: this.metros.includes(this.selectedMetro)
? this.selectedMetro
: defaultMetro;
}
private normalizeMetro(value: string): string {
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]/gi, '').toLowerCase();
}
private initMap() {
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: '<div class="location-pin unassigned-pin"><span>!</span></div>',
@@ -492,17 +546,18 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
}
}
private async hydrateWithOrs() {
private async hydrateWithOrs(version: number) {
if (!this.filteredRoutes.length) return;
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,10 +644,12 @@ export class RouteOverviewMapComponent implements OnInit, OnDestroy, AfterViewIn
} catch {
// Ignore abort / network errors, fallback lines remain
} finally {
if (version === this.renderVersion) {
this.isLoading = false;
this.cdr.detectChanges();
}
}
}
private extractCoords(geojson: unknown): L.LatLngExpression[] {
const g = geojson as { type: string; features?: { geometry: { coordinates: number[][] } }[]; coordinates?: number[][] };
@@ -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();
}
@@ -0,0 +1,25 @@
.pdvs-topbar{display:flex;align-items:center;gap:10px;padding:9px 24px;border-bottom:3px solid var(--glm-verde);background:#fff;color:var(--glm-azul);font-weight:700}.pdvs-topbar img{height:38px}.pdvs-topbar .user-profile{display:flex;align-items:center;gap:8px;margin-left:auto}.pdvs-topbar .user-profile img,.avatar-fallback{width:30px;height:30px;border-radius:50%}.avatar-fallback{display:grid;place-items:center;background:var(--glm-verde);color:#fff}.pdvs-topbar button{padding:8px 10px;border:1px solid var(--glm-gris-medio);border-radius:4px;background:#fff;color:var(--glm-azul);font-weight:700;cursor:pointer}.pdvs-page{max-width:1200px;margin:auto;padding:26px 24px}.pdvs-page>header{display:flex;justify-content:space-between;gap:20px;border-bottom:3px solid var(--glm-verde);padding-bottom:14px}.pdvs-page h1,.pdvs-page h2{margin:0;color:var(--glm-azul)}.pdvs-page header p{color:var(--glm-acero)}.import-button,.primary{padding:9px 12px;border-radius:4px;background:var(--glm-azul);color:#fff;font-weight:700;cursor:pointer}.import-button input{display:none}.pdv-form,.pdv-table{margin-top:18px;padding:18px;border:1px solid var(--glm-gris-medio);border-top:3px solid var(--glm-azul);border-radius:4px;background:#fff}.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-top:14px}.form-grid label{display:grid;gap:5px;color:var(--glm-azul);font-size:.75rem;font-weight:700;text-transform:uppercase}.form-grid input,.form-grid select{padding:9px;border:1px solid #9aaab3;border-radius:4px;background:#fff;color:#2f3f48}.form-actions{display:flex;gap:8px;margin-top:14px}.form-actions button{padding:9px 12px;border:1px solid var(--glm-gris-medio);border-radius:4px;cursor:pointer}.table-wrap{overflow:auto;margin-top:12px}table{width:100%;border-collapse:collapse;font-size:.82rem}th{padding:9px;background:var(--glm-azul);color:#fff;text-align:left}td{padding:9px;border-bottom:1px solid var(--glm-gris-claro)}td button{margin-right:5px;padding:6px;border:1px solid var(--glm-gris-medio);border-radius:4px;background:#fff;cursor:pointer}.danger{color:#c9252d}.error{color:#c9252d}.success{color:var(--glm-verde);font-weight:700}@media(max-width:650px){.pdvs-topbar{padding:8px 12px;flex-wrap:wrap}.pdvs-page{padding:18px 12px}.pdvs-page>header{flex-direction:column}}
.import-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; width: fit-content; min-width: 0; white-space: nowrap; text-decoration: none; }
.pdvs-page > header .import-button { align-self: flex-start; height: auto; }
.pdv-table td:last-child { white-space: nowrap; }
.pdv-table td:last-child button { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; margin: 0 6px 0 0; padding: 0; font-size: 1rem; line-height: 1; }
.pdv-table td:last-child button.edit-button { width: auto; min-width: 86px; padding: 0 10px; gap: 6px; }
.pdv-table td:last-child button.edit-button,
.pdv-table td.actions-cell button.edit-button { display: inline-flex !important; align-items: center !important; justify-content: center !important; width: auto !important; min-width: 86px !important; height: 38px !important; padding: 0 12px !important; gap: 6px !important; border: 1px solid var(--glm-gris-medio) !important; border-radius: 4px !important; background: #fff !important; color: var(--glm-azul) !important; font: 700 .78rem/1 Arial, sans-serif !important; visibility: visible !important; opacity: 1 !important; }
.pdv-table td:last-child button.edit-button span,
.pdv-table td.actions-cell button.edit-button span { display: inline !important; color: var(--glm-azul) !important; font-size: .78rem !important; font-weight: 700 !important; visibility: visible !important; }
.pdv-table td.actions-cell { white-space: nowrap; min-width: 170px; }
.pdv-table td:last-child button:last-child { margin-right: 0; }
.pdv-table td:last-child button.danger { color: #c9252d; }
.pdv-table .row-actions { display: flex; gap: 5px; min-width: 250px; white-space: nowrap; }
.pdv-table .row-actions button { width: auto !important; min-width: 0 !important; height: auto !important; margin: 0 !important; padding: 7px 10px !important; border: 1px solid var(--glm-gris-medio) !important; border-radius: 4px !important; background: #fff !important; color: var(--glm-azul) !important; font: 600 .78rem/1.2 Arial, sans-serif !important; }
.pdv-table .row-actions button:hover { background: var(--glm-verde-bg) !important; border-color: var(--glm-verde) !important; }
.pdv-table .row-actions button.delete { color: #c9252d !important; }
.pdv-table .table-header { display:flex; justify-content:space-between; align-items:center; gap:12px; }.pdv-table .table-header > div { display:flex; align-items:center; gap:8px; }.pdv-table .selected-count { color:var(--glm-azul); font-size:.78rem; font-weight:700; }.pdv-table .table-header button,.pdv-table .bulk-actions button { border:1px solid var(--glm-gris-medio); border-radius:4px; padding:7px 10px; background:#fff; color:var(--glm-azul); cursor:pointer; font:600 .78rem Arial,sans-serif; }.pdv-table .table-header button:hover,.pdv-table .bulk-actions button:hover:not(:disabled) { background:var(--glm-verde-bg); border-color:var(--glm-verde); }.pdv-table .bulk-actions { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0; padding:12px; border:1px solid var(--glm-verde-claro); background:var(--glm-verde-bg); }.pdv-table .bulk-actions button.delete { color:#c9252d; }.pdv-table .select-column { width:38px; }
.page-back-button { display:inline-flex; align-items:center; gap:6px; margin-bottom:18px; padding:8px 11px; border:1px solid var(--glm-gris-medio); border-radius:4px; background:#fff; color:var(--glm-azul); cursor:pointer; font:700 .82rem Arial,sans-serif; }.page-back-button:hover { background:var(--glm-verde-bg); border-color:var(--glm-verde); }
@media (max-width: 640px) {
.pdvs-page { padding: 18px 14px; }
.pdvs-page > header { align-items: flex-start; flex-direction: column; }
.import-button { align-self: flex-start; }
}
@@ -0,0 +1,5 @@
<div class="pdvs-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div>
<main class="pdvs-page"><button type="button" class="page-back-button" (click)="goBack()"><i class="pi pi-arrow-left"></i> Volver</button><header><div><h1>Gestionar PDVs</h1><p>{{ pdvs.length }} puntos de venta registrados en este proyecto</p></div><label class="import-button"><i class="pi pi-file-import"></i> {{ importing ? 'Importando...' : 'Importar Excel' }}<input type="file" accept=".xlsx,.xls" [disabled]="importing" (change)="importFile($event)"></label></header>
<p class="success" *ngIf="message">{{ message }}</p><p class="error" *ngIf="error">{{ error }}</p>
<section class="pdv-form"><h2>{{ editing ? 'Editar PDV' : 'Nuevo PDV' }}</h2><div class="form-grid"><label>ID<input [(ngModel)]="form.id"></label><label>Nombre<input [(ngModel)]="form.nombre"></label><label>Segmentación<select [(ngModel)]="form.segmentacion"><option *ngFor="let value of ['AA','A','B','C','D']" [value]="value">{{ value }}</option></select></label><label>Latitud<input type="number" step="any" [(ngModel)]="form.latitud"></label><label>Longitud<input type="number" step="any" [(ngModel)]="form.longitud"></label><label>Visitas semanales<input type="number" min="1" max="7" [(ngModel)]="form.visitas"></label><label>Duración (horas)<input type="number" min="0.1" step="0.1" [(ngModel)]="form.duracion"></label><label>Prioridad<input type="number" min="1" max="5" [(ngModel)]="form.prioridad"></label><label>Cobertura<input [(ngModel)]="form.cobertura"></label><label>Zona<input [(ngModel)]="form.zona"></label><label>Región<input [(ngModel)]="form.region"></label><label>Estado<input [(ngModel)]="form.estado"></label><label>Ciudad<input [(ngModel)]="form.ciudad"></label><label>Estado<select [(ngModel)]="form.activo"><option [ngValue]="true">Activo</option><option [ngValue]="false">Inactivo</option></select></label></div><div class="form-actions"><button type="button" class="primary" (click)="save()" [disabled]="saving">{{ saving ? 'Guardando...' : (editing ? 'Guardar cambios' : 'Crear PDV') }}</button><button type="button" *ngIf="editing" (click)="cancelEdit()">Cancelar</button></div></section>
<section class="pdv-table"><div class="table-header"><h2>PDVs del proyecto</h2><div><span class="selected-count" *ngIf="selectedIds.size">{{ selectedIds.size }} seleccionados</span><button type="button" (click)="toggleAll(!allSelected)" [disabled]="loading">{{ allSelected ? 'Deseleccionar todos' : 'Seleccionar todos' }}</button></div></div><p *ngIf="loading">Cargando...</p><div class="bulk-actions" *ngIf="selectedIds.size"><button type="button" (click)="setSelectedActive(true)" [disabled]="bulkSaving">Activar</button><button type="button" (click)="setSelectedActive(false)" [disabled]="bulkSaving">Desactivar</button><button type="button" class="delete" (click)="removeSelected()" [disabled]="bulkSaving">Eliminar seleccionados</button></div><div class="table-wrap" *ngIf="!loading"><table><thead><tr><th class="select-column"><input type="checkbox" [checked]="allSelected" (change)="toggleAll($any($event.target).checked)" aria-label="Seleccionar todos los PDVs"></th><th>ID</th><th>Nombre</th><th>Segmentación</th><th>Ciudad</th><th>Estado</th><th>Zona</th><th>Acciones</th></tr></thead><tbody><tr *ngFor="let pdv of pdvs"><td><input type="checkbox" [checked]="selectedIds.has(pdv.id)" (change)="toggleSelection(pdv.id, $any($event.target).checked)" [attr.aria-label]="'Seleccionar ' + pdv.pdv_id"></td><td>{{ pdv.pdv_id }}</td><td>{{ value(pdv, 'nombre_del_pdv') }}</td><td>{{ value(pdv, 'segmentacion') }}</td><td>{{ value(pdv, 'ciudad') }}</td><td>{{ value(pdv, 'estado') }}</td><td>{{ value(pdv, 'zona') }}</td><td class="row-actions"><button type="button" (click)="edit(pdv)">Editar</button><button type="button" (click)="toggleActive(pdv)">{{ isActive(pdv) ? 'Desactivar' : 'Activar' }}</button><button type="button" class="delete" (click)="remove(pdv)">Eliminar</button></td></tr><tr *ngIf="!pdvs.length"><td colspan="8">No hay PDVs registrados.</td></tr></tbody></table></div></section></main>
@@ -0,0 +1,207 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import * as XLSX from 'xlsx';
import { ProjectService } from '../../core/services/project.service';
import { RuteroProyectoPdv } from '../../core/models/project.model';
import { ExcelPreviewService } from '../../shared/services/excel-preview.service';
import { normalizeRow } from '../../shared/utils/header-normalization.utils';
import { AuthService } from '../../core/services/auth.service';
import { UserProfileService } from '../../core/services/user-profile.service';
interface PdvForm {
id: string;
nombre: string;
segmentacion: string;
latitud: number;
longitud: number;
visitas: number;
duracion: number;
prioridad: number;
activo: boolean;
cobertura: string;
zona: string;
region: string;
estado: string;
ciudad: string;
}
@Component({
standalone: true,
selector: 'app-pdvs',
imports: [CommonModule, FormsModule],
templateUrl: './pdvs.component.html',
styleUrl: './pdvs.component.css'
})
export class PdvsComponent implements OnInit {
projectId = '';
pdvs: RuteroProyectoPdv[] = [];
form = this.emptyForm();
editing: RuteroProyectoPdv | null = null;
loading = true;
saving = false;
importing = false;
error = '';
message = '';
projectCountry = 'Republica Dominicana';
selectedIds = new Set<string>();
bulkSaving = false;
constructor(
private projects: ProjectService,
private preview: ExcelPreviewService,
private auth: AuthService,
private route: ActivatedRoute,
private router: Router,
public profile: UserProfileService,
private cdr: ChangeDetectorRef
) {}
ngOnInit(): void {
this.projectId = this.route.snapshot.paramMap.get('id') ?? '';
if (!this.projectId) { void this.router.navigate(['/home']); return; }
void this.load();
}
async load(): Promise<void> {
this.loading = true;
this.error = '';
try {
this.pdvs = await this.projects.pdvs(this.projectId);
this.selectedIds.clear();
const country = this.pdvs.map(pdv => normalizeRow(pdv.pdv_data)['pais']).find(Boolean);
if (country) this.projectCountry = String(country);
}
catch (error: any) { this.error = error?.message ?? 'No se pudieron cargar los PDVs.'; }
finally { this.loading = false; this.cdr.detectChanges(); }
}
edit(pdv: RuteroProyectoPdv): void {
const row = normalizeRow(pdv.pdv_data);
this.editing = pdv;
this.form = {
id: String(row['id'] ?? pdv.pdv_id), nombre: String(row['nombre_del_pdv'] ?? ''), segmentacion: String(row['segmentacion'] ?? 'D'),
latitud: Number(row['latitud'] ?? 0), longitud: Number(row['longitud'] ?? 0), visitas: Number(row['visitas_semanales'] ?? 1),
duracion: Number(row['duracion_visitahoras'] ?? row['duracion_visita_horas'] ?? 1), prioridad: Number(row['prioridad'] ?? 5),
activo: !['no', 'n', '0', 'false'].includes(String(row['activo'] ?? 'si').toLowerCase()), cobertura: String(row['cobertura'] ?? ''),
zona: String(row['zona'] ?? ''), region: String(row['region'] ?? ''), estado: String(row['estado'] ?? ''), ciudad: String(row['ciudad'] ?? '')
};
}
cancelEdit(): void { this.editing = null; this.form = this.emptyForm(); this.error = ''; }
async save(): Promise<void> {
if (!this.form.id.trim() || !this.form.nombre.trim()) { this.error = 'ID y nombre son obligatorios.'; return; }
if (!Number.isFinite(this.form.latitud) || !Number.isFinite(this.form.longitud)) { this.error = 'Coordenadas inválidas.'; return; }
this.saving = true; this.error = ''; this.message = '';
const row: Record<string, unknown> = {
...(this.editing?.pdv_data ?? {}), ID: this.form.id.trim(), 'Nombre del PDV': this.form.nombre.trim(), Segmentación: this.form.segmentacion,
Latitud: this.form.latitud, Longitud: this.form.longitud, 'Visitas semanales': this.form.visitas, 'Duración visita(horas)': this.form.duracion,
Prioridad: this.form.prioridad, Activo: this.form.activo ? 'Sí' : 'No', Cobertura: this.form.cobertura, Zona: this.form.zona,
Región: this.form.region, Estado: this.form.estado, Ciudad: this.form.ciudad, País: normalizeRow(this.editing?.pdv_data ?? {})['pais'] ?? this.projectCountry
};
try {
if (this.editing) await this.projects.updatePdv(this.editing.id, row);
else await this.projects.addPdv(this.projectId, row);
this.message = this.editing ? 'PDV actualizado.' : 'PDV creado.';
this.cancelEdit();
await this.load();
} catch (error: any) { this.error = error?.message ?? 'No se pudo guardar el PDV.'; }
finally { this.saving = false; this.cdr.detectChanges(); }
}
async importFile(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
this.importing = true; this.error = ''; this.message = '';
try {
const result = await this.preview.previewFile(file, 'PDV');
if (!result.valid) throw new Error(result.errors[0]?.message ?? 'El archivo PDV contiene errores.');
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(workbook.Sheets[workbook.SheetNames[0]], { defval: null, raw: true });
await this.projects.upsertPdvs(this.projectId, rows);
this.message = `${rows.length} PDVs importados o actualizados.`;
await this.load();
} catch (error: any) { this.error = error?.message ?? 'No se pudo importar el archivo.'; }
finally { this.importing = false; input.value = ''; this.cdr.detectChanges(); }
}
async remove(pdv: RuteroProyectoPdv): Promise<void> {
if (!confirm(`¿Eliminar el PDV ${pdv.pdv_id}?`)) return;
try { await this.projects.removePdv(pdv.id); await this.load(); }
catch (error: any) { this.error = error?.message ?? 'No se pudo eliminar el PDV.'; }
}
isActive(pdv: RuteroProyectoPdv): boolean {
return !['no', 'n', '0', 'false'].includes(String(normalizeRow(pdv.pdv_data)['activo'] ?? 'si').toLowerCase());
}
async toggleActive(pdv: RuteroProyectoPdv): Promise<void> {
const active = !this.isActive(pdv);
try {
await this.projects.updatePdv(pdv.id, { ...pdv.pdv_data, Activo: active ? 'Sí' : 'No' });
this.message = active ? 'PDV activado.' : 'PDV desactivado.';
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudo actualizar el estado del PDV.';
}
}
get allSelected(): boolean {
return this.pdvs.length > 0 && this.pdvs.every(pdv => this.selectedIds.has(pdv.id));
}
get selectedPdvs(): RuteroProyectoPdv[] {
return this.pdvs.filter(pdv => this.selectedIds.has(pdv.id));
}
toggleSelection(id: string, checked: boolean): void {
checked ? this.selectedIds.add(id) : this.selectedIds.delete(id);
}
toggleAll(checked: boolean): void {
this.selectedIds = checked ? new Set(this.pdvs.map(pdv => pdv.id)) : new Set<string>();
}
async setSelectedActive(active: boolean): Promise<void> {
const selected = this.selectedPdvs;
if (!selected.length) return;
this.bulkSaving = true;
this.error = '';
try {
await Promise.all(selected.map(pdv => this.projects.updatePdv(pdv.id, { ...pdv.pdv_data, Activo: active ? 'Sí' : 'No' })));
this.message = `${selected.length} PDVs ${active ? 'activados' : 'desactivados'}.`;
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudo actualizar el estado de los PDVs.';
} finally {
this.bulkSaving = false;
}
}
async removeSelected(): Promise<void> {
const selected = this.selectedPdvs;
if (!selected.length || !confirm(`¿Eliminar ${selected.length} PDVs seleccionados? Esta acción no se puede deshacer.`)) return;
this.bulkSaving = true;
this.error = '';
try {
await Promise.all(selected.map(pdv => this.projects.removePdv(pdv.id)));
this.message = `${selected.length} PDVs eliminados.`;
await this.load();
} catch (error: any) {
this.error = error?.message ?? 'No se pudieron eliminar los PDVs.';
} finally {
this.bulkSaving = false;
}
}
value(pdv: RuteroProyectoPdv, field: string): unknown { return normalizeRow(pdv.pdv_data)[field] ?? '-'; }
goBack(): void { void this.router.navigate(['/proyectos', this.projectId]); }
async signOut(): Promise<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
private emptyForm(): PdvForm {
return { id: '', nombre: '', segmentacion: 'D', latitud: 18.4861, longitud: -69.9312, visitas: 1, duracion: 1, prioridad: 5, activo: true, cobertura: '', zona: '', region: '', estado: '', ciudad: '' };
}
}
@@ -1 +1 @@
<div class="projects-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="topbar-actions"><a routerLink="/home">Mis proyectos</a><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div></div><main class="projects-page narrow"><header><div><h1>Crear proyecto</h1><p>Define el proyecto e importa los PDVs que se planificarán.</p></div></header><section class="project-form"><label>Nombre del proyecto<input [(ngModel)]="name" placeholder="Ej. Ruta Santo Domingo Norte" maxlength="120"></label><label>País<select [(ngModel)]="country"><option value="Republica Dominicana">República Dominicana</option><option value="MEXICO">México</option></select></label><label class="file-label">Archivo PDVs (Excel)<input type="file" accept=".xlsx,.xls" (change)="fileChanged($event)"><span>.xlsx / .xls, máximo 10 MB. El país también se detecta desde el archivo.</span></label><p *ngIf="file">{{ file.name }}</p><p *ngIf="loading">Validando archivo...</p><p class="valid" *ngIf="preview?.valid">{{ preview!.validRows }} PDVs válidos para crear el proyecto.</p><p class="error" *ngIf="preview && !preview.valid">El archivo tiene errores. Corrígelos antes de crear el proyecto.</p><p class="error" *ngIf="error">{{ error }}</p><button class="primary" type="button" [disabled]="!canCreate" (click)="create()">{{ saving ? 'Creando proyecto...' : 'Crear proyecto' }}</button></section></main>
<div class="projects-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="topbar-actions"><a routerLink="/home">Mis proyectos</a><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div></div><main class="projects-page narrow"><header><div><h1>Crear proyecto</h1><p>Define el proyecto. Podrás agregar sus PDVs después desde su gestor.</p></div></header><section class="project-form"><label>Nombre del proyecto<input [(ngModel)]="name" placeholder="Ej. Ruta Santo Domingo Norte" maxlength="120"></label><label>Descripción<textarea [(ngModel)]="description" placeholder="Describe el objetivo o alcance del proyecto" maxlength="500"></textarea></label><label>País<select [(ngModel)]="country"><option value="Republica Dominicana">República Dominicana</option><option value="MEXICO">México</option></select></label><p class="error" *ngIf="error">{{ error }}</p><button class="primary" type="button" [disabled]="!canCreate" (click)="create()">{{ saving ? 'Creando proyecto...' : 'Crear proyecto' }}</button></section></main>
@@ -2,49 +2,25 @@ import { ChangeDetectorRef, Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { 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<void> { await this.auth.signOut(); await this.router.navigate(['/login']); }
async fileChanged(event: Event): Promise<void> {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
this.file = file; this.preview = null; this.error = ''; this.loading = true;
try {
this.preview = await this.previewService.previewFile(file, 'PDV');
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(workbook.Sheets[workbook.SheetNames[0]], { defval: null, raw: true });
const value = rows.map(row => Object.entries(row).find(([key]) => key.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase() === 'pais')?.[1]).find(Boolean);
const normalized = String(value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase();
if (normalized.includes('MEXICO')) this.country = 'MEXICO';
else if (normalized.includes('DOMINICANA')) this.country = 'Republica Dominicana';
}
catch (error: any) { this.error = error?.message ?? 'No se pudo leer el archivo.'; }
finally { this.loading = false; this.cdr.detectChanges(); }
}
get canCreate(): boolean { return !!this.name.trim() && !!this.country && !!this.file && !!this.preview?.valid && !this.saving; }
get canCreate(): boolean { return !!this.name.trim() && !!this.country && !this.saving; }
async create(): Promise<void> {
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<Record<string, unknown>>(sheet, { defval: null, raw: true });
const projectRows = rows.map(row => ({ ...row, 'País': this.country }));
const project = await this.projects.create(this.name, projectRows);
const project = await this.projects.create(this.name, this.description, this.country);
await this.router.navigate(['/proyectos', project.id]);
} catch (error: any) { this.error = error?.message ?? 'No se pudo crear el proyecto.'; }
finally { this.saving = false; this.cdr.detectChanges(); }
@@ -1,2 +1,3 @@
<div class="projects-topbar"><img src="GLM_completo.png" alt="GomezLee Marketing"><span>Route Planner</span><div class="user-profile" *ngIf="profile.current"><img *ngIf="profile.avatarUrl" [src]="profile.avatarUrl" alt=""><span *ngIf="!profile.avatarUrl" class="avatar-fallback">{{ profile.current.nombre.charAt(0) }}</span><span>{{ profile.current.nombre }}</span></div><div class="topbar-actions"><button type="button" (click)="signOut()"><i class="pi pi-sign-out"></i> Salir</button></div></div>
<main class="projects-page"><a class="manage-employees" routerLink="/empleados"><i class="pi pi-users"></i> Gestionar empleados</a><header><div><h1>Mis proyectos</h1><p>Organiza y conserva tus planificaciones de rutas.</p></div><a class="primary" routerLink="/proyectos/nuevo"><i class="pi pi-plus"></i> Crear proyecto</a></header><p class="error" *ngIf="error">{{ error }}</p><p *ngIf="loading">Cargando proyectos...</p><section class="project-grid" *ngIf="!loading"><article class="project-card" *ngFor="let project of projects"><span class="state">{{ project.estado }}</span><h2>{{ project.nombre }}</h2><p>{{ project.descripcion || 'Sin descripción' }}</p><small>Actualizado: {{ project.updated_at | date:'medium' }}</small><div class="project-card-actions"><button type="button" (click)="open(project)" [disabled]="deletingId === project.id">Abrir proyecto</button><button type="button" class="danger" (click)="remove(project)" [disabled]="deletingId === project.id"><i class="pi pi-trash"></i> {{ deletingId === project.id ? 'Eliminando...' : 'Eliminar' }}</button></div></article><section class="empty" *ngIf="!projects.length"><i class="pi pi-folder-open"></i><h2>Aún no tienes proyectos</h2><p>Crea uno e importa su archivo de PDVs para comenzar.</p><a class="primary" routerLink="/proyectos/nuevo">Crear proyecto</a></section></section></main>
<main class="projects-page"><header><div><h1>Mis proyectos</h1><p>Organiza y conserva tus planificaciones de rutas.</p></div><a class="primary" routerLink="/proyectos/nuevo"><i class="pi pi-plus"></i> Crear proyecto</a></header><p class="error" *ngIf="error">{{ error }}</p><p *ngIf="loading">Cargando proyectos...</p><section class="project-grid" *ngIf="!loading"><article class="project-card" *ngFor="let project of projects"><h2>{{ project.nombre }}</h2><p>{{ project.descripcion || 'Sin descripción' }}</p><small>Actualizado: {{ project.updated_at | date:'medium' }}</small><div class="project-card-actions"><button type="button" (click)="open(project)" [disabled]="deletingId === project.id">Abrir proyecto</button><button type="button" class="danger" (click)="requestRemove(project)" [disabled]="deletingId === project.id"><i class="pi pi-trash"></i> Eliminar</button></div></article><section class="empty" *ngIf="!projects.length"><i class="pi pi-folder-open"></i><h2>Aún no tienes proyectos</h2><p>Crea uno y agrega sus PDVs cuando estés listo para planificar.</p><a class="primary" routerLink="/proyectos/nuevo">Crear proyecto</a></section></section></main>
<div class="delete-dialog-backdrop" *ngIf="projectToDelete" (click)="closeDeleteDialog()"><section class="delete-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-project-title" (click)="$event.stopPropagation()"><i class="pi pi-exclamation-triangle"></i><h2 id="delete-project-title">Eliminar proyecto</h2><p>¿Eliminar permanentemente <strong>{{ projectToDelete.nombre }}</strong>?</p><p class="delete-dialog-warning">También se eliminarán sus PDVs, empleados y rutas. Esta acción no se puede deshacer.</p><div><button type="button" (click)="closeDeleteDialog()" [disabled]="!!deletingId">Cancelar</button><button type="button" class="danger" (click)="remove()" [disabled]="!!deletingId">{{ deletingId ? 'Eliminando...' : 'Eliminar proyecto' }}</button></div></section></div>
@@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, HostListener, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { 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<void> {
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<void> {
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();
}
}
@@ -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; }
@@ -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();
@@ -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<string, { nombre: string; rutas: number; distancia: number; trabajo: number; capacidad: number }>();
const assigned = this.filters.filterAssigned(this.routeState.getAssignedSnapshot());
const unassigned = this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot());
const users = new Map<string, { nombre: string; visitas: number; pdvs: Set<string>; 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<string>(),
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,
@@ -0,0 +1,87 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { saveAs } from 'file-saver';
import { environment } from '../../../environments/environment';
import { RouteState } from '../../core/route-state/route';
import { ProjectService } from '../../core/services/project.service';
import { PlanningFilterService } from '../../core/services/planning-filter.service';
import { sequenceRoutes, summarizeDailyRoutes } from '../utils/route-sequencing.utils';
import { recommendationForUnassigned } from '../utils/unassigned-recommendation.utils';
@Injectable({ providedIn: 'root' })
export class RoutePdfReportService {
constructor(
private http: HttpClient,
private routeState: RouteState,
private projects: ProjectService,
private filters: PlanningFilterService
) {}
async download(projectId: string): Promise<void> {
const [project, assigned, unassigned] = await Promise.all([
this.projects.get(projectId),
Promise.resolve(this.filters.filterAssigned(this.routeState.getAssignedSnapshot())),
Promise.resolve(this.filters.filterUnassigned(this.routeState.getNoAssignedSnapshot()))
]);
const assignedIds = new Set(assigned.map(route => String(route.json.pdv_id)));
const pending = unassigned
.filter(route => !assignedIds.has(String(route.pdv_id)))
.map(route => ({ ...route, recomendacion: route.recomendacion ?? recommendationForUnassigned(route) }));
const dailyRoutes = summarizeDailyRoutes(assigned);
const totalPdvs = assignedIds.size + new Set(pending.map(route => String(route.pdv_id))).size;
const payload = {
project: { id: project.id, name: project.nombre, country: project.pais ?? '', description: project.descripcion ?? '' },
filters: this.filters.snapshot(),
generatedAt: new Date().toISOString(),
summary: {
totalPdvs,
assignedPdvs: assignedIds.size,
unassignedPdvs: pending.length,
coveragePercent: totalPdvs ? Number(((assignedIds.size / totalPdvs) * 100).toFixed(1)) : 0,
totalDistanceKm: Number(dailyRoutes.reduce((sum, route) => sum + route.distanceKm, 0).toFixed(2)),
totalWorkHours: Number(dailyRoutes.reduce((sum, route) => sum + route.workHours, 0).toFixed(2))
},
assignedRoutes: sequenceRoutes(assigned).map(segment => ({
usuarioId: segment.route.json.usuario_id,
usuario: segment.route.json.nombre_usuario,
pdvId: segment.route.json.pdv_id,
pdv: segment.route.json.nombre_pdv,
dia: segment.route.json.dia,
orden: segment.order,
origen: segment.previousPdvName ?? `Casa de ${segment.route.json.nombre_usuario}`,
distanciaKm: segment.route.json.distancia,
desplazamientoHoras: segment.route.json.horas_desplazamiento,
horasTrabajo: segment.route.json.horas_trabajo,
cobertura: segment.route.json.cobertura ?? '',
zona: segment.route.json.zona ?? '',
region: segment.route.json.region ?? '',
estado: segment.route.json.estado ?? '',
ciudad: segment.route.json.ciudad ?? ''
})),
unassignedPdvs: pending
};
try {
const pdf = await firstValueFrom(this.http.post(environment.n8nPdfWebhookUrl, payload, { responseType: 'blob' }));
const fileName = `rutas_${this.fileName(project.nombre)}.pdf`;
saveAs(pdf, fileName);
} catch (error) {
throw new Error(await this.messageForError(error));
}
}
private fileName(value: string): string {
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'proyecto';
}
private async messageForError(error: unknown): Promise<string> {
const response = error as HttpErrorResponse;
if (response.error instanceof Blob) {
try {
const body = JSON.parse(await response.error.text()) as { message?: string; error?: string };
return body.message ?? body.error ?? 'No se pudo generar el PDF.';
} catch { /* use fallback */ }
}
return response.error?.message ?? response.message ?? 'No se pudo generar el PDF.';
}
}
@@ -1,22 +1,37 @@
const assignedColors = new Map<string, string>();
const usedColors = new Set<string>();
function hashId(value: string): number {
let hash = 2166136261;
for (let index = 0; index < value.length; index++) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function routeColor(id: string): string {
const existing = assignedColors.get(id);
if (existing) return existing;
const hash = hashId(id);
let attempt = 0;
let color = '';
do {
// Restricted to dark navy/indigo/violet/plum. Avoids red, cyan, green, yellow and orange.
const hue = 225 + ((hash + attempt * 37) % 106);
const saturation = 58 + (((hash >>> 8) + attempt * 11) % 27);
const lightness = 27 + (((hash >>> 16) + attempt * 7) % 17);
color = `hsl(${hue} ${saturation}% ${lightness}%)`;
attempt++;
} while (usedColors.has(color));
assignedColors.set(id, color);
usedColors.add(color);
return color;
}
export function colorForUsuario(usuarioId: string | number): { color: string; glow: string } {
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 {
@@ -0,0 +1,16 @@
import { RouteNoAssignedResponse } from '../../core/models/route.model';
export function recommendationForUnassigned(route: RouteNoAssignedResponse): string {
const reason = String(route.motivo ?? '').toLowerCase();
if (/coordenad|latitud|longitud/.test(reason)) return 'Corregir las coordenadas del PDV antes de volver a planificar.';
if (/capacidad|visitas maxima|visitas máximas|jornada/.test(reason)) {
return 'Redistribuir PDVs, ampliar capacidad o incorporar personal adicional.';
}
if (/distancia|desplazamiento|recorrido|lejan/.test(reason)) {
return 'Asignar personal más cercano o revisar la cobertura y zona del PDV.';
}
if (/empleado|usuario|disponib|compatible|sin asign/.test(reason)) {
return 'Reubicar o contratar personal disponible para esta zona.';
}
return 'Revisar datos del PDV, disponibilidad de personal y reglas de planificación.';
}
@@ -1,6 +1,7 @@
export const environment = {
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,
@@ -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,
+18
View File
@@ -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;
+102
View File
@@ -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()
));
+24
View File
@@ -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';