From b9942fe2f3e73e063d51b0eae71546e18cd04fb4 Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Thu, 18 Jun 2026 12:29:44 -0500 Subject: [PATCH] feat(reportes): add functionality for downloading client and user reports - Introduced new functions to download Excel reports for clients and users. - Added UI elements for administrative users to trigger report downloads. - Implemented error handling for report generation failures. - Enhanced the existing portal user listing with node data for better reporting. This update improves the reporting capabilities within the application, allowing for easier access to client and user data. --- src/lib/server/controldesk-pg.ts | 26 +++++ src/lib/server/report-excel.ts | 50 ++++++++++ src/routes/reportes/+page.svelte | 109 +++++++++++++++++++++ src/routes/reportes/clientes/+server.ts | 73 ++++++++++++++ src/routes/reportes/usuarios/+server.ts | 121 ++++++++++++++++++++++++ 5 files changed, 379 insertions(+) create mode 100644 src/lib/server/report-excel.ts create mode 100644 src/routes/reportes/clientes/+server.ts create mode 100644 src/routes/reportes/usuarios/+server.ts diff --git a/src/lib/server/controldesk-pg.ts b/src/lib/server/controldesk-pg.ts index eb7ae07..59a657b 100644 --- a/src/lib/server/controldesk-pg.ts +++ b/src/lib/server/controldesk-pg.ts @@ -190,6 +190,32 @@ export async function listPortalUsers(): Promise { return r.rows; } +/** + * Usuarios del portal (cliente/autoridad) con los datos de su nodo, para el + * reporte de asesores. NO incluye contraseñas: `portal_users.password_hash` + * es un hash bcrypt irreversible y nunca se expone. + */ +export async function listPortalUsersWithNode(): Promise { + const sql = ` + SELECT + pu.id AS "ID", + pu.is_authority_client AS "ClienteAutoridad", + pu.full_name AS "Nombre", + pu.username AS "Usuario", + pu.bd_shelter AS "BD_Shelter", + dn.node_subnode_key AS "NodoSubNodo", + dn.legal_name AS "Cliente", + dn.rfc AS "RFC", + dn.database_name AS "BDName", + dn.is_active AS "NodoActivo" + FROM ${qUsers()} pu + LEFT JOIN ${qNodes()} dn ON pu.database_node_id = dn.id + ORDER BY dn.node_subnode_key, pu.is_authority_client, pu.username + `; + const r = await pgPool.query(sql); + return r.rows; +} + export async function lookupNodeByNodoOrBdName(nodoName: string): Promise { const sql = ` SELECT diff --git a/src/lib/server/report-excel.ts b/src/lib/server/report-excel.ts new file mode 100644 index 0000000..996db31 --- /dev/null +++ b/src/lib/server/report-excel.ts @@ -0,0 +1,50 @@ +/** + * Utilidades compartidas para los reportes en Excel del panel: + * - Guard de sesión admin (los reportes administrativos son confidenciales). + * - Estilos de encabezado y filas (cebra) reutilizados por todas las hojas. + */ +import type { Cookies } from '@sveltejs/kit'; +import type ExcelJS from 'exceljs'; +import { verifyToken } from './auth'; +import { getUserById } from './users'; +import type { Usuario } from './auth'; + +/** + * Valida la cookie de sesión y exige rol admin. Devuelve el usuario o `null` + * (sin lanzar) para que el endpoint responda 401/403 con JSON, no un redirect. + */ +export async function getAdminFromCookies(cookies: Cookies): Promise { + const token = cookies.get('session_token'); + if (!token) return null; + const session = verifyToken(token); + if (!session) return null; + const user = await getUserById(session.userId); + if (!user || !user.activo || !user.es_admin) return null; + return user; +} + +/** Aplica el estilo de encabezado oscuro a la primera fila de una hoja. */ +export function styleHeader(row: ExcelJS.Row): void { + row.eachCell((cell) => { + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E293B' } }; + cell.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 11 }; + cell.alignment = { vertical: 'middle', horizontal: 'center' }; + cell.border = { bottom: { style: 'thin', color: { argb: 'FF94A3B8' } } }; + }); + row.height = 20; +} + +/** Aplica el cebrado (filas alternas) y borde inferior tenue a una fila de datos. */ +export function styleDataRow(row: ExcelJS.Row, index: number): void { + const fill: ExcelJS.FillPattern = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: index % 2 === 0 ? 'FFF8FAFC' : 'FFFFFFFF' } + }; + row.eachCell((cell) => { + cell.fill = fill; + cell.alignment = { vertical: 'middle' }; + cell.border = { bottom: { style: 'hair', color: { argb: 'FFE2E8F0' } } }; + }); + row.height = 16; +} diff --git a/src/routes/reportes/+page.svelte b/src/routes/reportes/+page.svelte index 0141096..315ed77 100644 --- a/src/routes/reportes/+page.svelte +++ b/src/routes/reportes/+page.svelte @@ -80,6 +80,46 @@ } } + // ── Reportes administrativos (solo admin) ───────────────────────────── + let loadingClientes = $state(false); + let loadingUsuarios = $state(false); + + /** Descarga genérica de un endpoint de reporte Excel. */ + async function descargarArchivo(endpoint: string, nombreArchivo: string) { + msgError = ''; + try { + const res = await fetch(endpoint); + if (!res.ok) { + const body = await res.json().catch(() => ({ error: `Error ${res.status}` })); + msgError = body.error ?? `Error ${res.status}`; + return; + } + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = nombreArchivo; + a.click(); + URL.revokeObjectURL(blobUrl); + } catch (e: any) { + msgError = e.message ?? 'Error al generar el reporte'; + } + } + + async function descargarClientes() { + loadingClientes = true; + const fecha = new Date().toISOString().slice(0, 10); + await descargarArchivo('/reportes/clientes', `reporte_clientes_${fecha}.xlsx`); + loadingClientes = false; + } + + async function descargarUsuarios() { + loadingUsuarios = true; + const fecha = new Date().toISOString().slice(0, 10); + await descargarArchivo('/reportes/usuarios', `reporte_nodos_usuarios_${fecha}.xlsx`); + loadingUsuarios = false; + } + // ── Descarga ────────────────────────────────────────────────────────── async function descargarReporte() { msgError = ''; @@ -370,5 +410,74 @@ + + {#if data.user?.es_admin} +
+ + +
+
+
+ groups +
+
+

Clientes (Administrativos)

+

Catálogo completo de clientes con nodo, RFC, correo y estatus

+
+
+
+ +
+
+ + +
+
+
+ badge +
+
+

Nodos y Usuarios (Asesores)

+

Usuarios cliente y autoridad por nodo

+
+
+
+
+ lock + No incluye contraseñas: se almacenan con hash bcrypt (irreversible) y son confidenciales. +
+ +
+
+ +
+ {/if} + diff --git a/src/routes/reportes/clientes/+server.ts b/src/routes/reportes/clientes/+server.ts new file mode 100644 index 0000000..2442d07 --- /dev/null +++ b/src/routes/reportes/clientes/+server.ts @@ -0,0 +1,73 @@ +/** + * Reporte administrativo: catálogo completo de clientes (a24c.database_nodes). + * Solo admin. Sin datos sensibles (no incluye credenciales). + */ +import { listClientsCatalog } from '$lib/server/controldesk-pg'; +import { getAdminFromCookies, styleHeader, styleDataRow } from '$lib/server/report-excel'; +import type { RequestHandler } from './$types'; +import ExcelJS from 'exceljs'; + +export const GET: RequestHandler = async ({ cookies }) => { + const admin = await getAdminFromCookies(cookies); + if (!admin) { + return new Response(JSON.stringify({ error: 'No autorizado: requiere sesión de administrador.' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + let clientes: any[]; + try { + clientes = await listClientsCatalog(); + } catch (e: any) { + return new Response(JSON.stringify({ error: `Error obteniendo catálogo de clientes: ${e.message}` }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Orden estable por nodo para lectura administrativa. + clientes.sort((a, b) => + String(a.NodoSubNodo ?? '').localeCompare(String(b.NodoSubNodo ?? '')) + ); + + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'TransmitirAS - Panel CPANEL'; + workbook.created = new Date(); + workbook.modified = new Date(); + + const ws = workbook.addWorksheet('Clientes', { views: [{ state: 'frozen', ySplit: 1 }] }); + ws.columns = [ + { header: 'Nodo/SubNodo', key: 'NodoSubNodo', width: 22 }, + { header: 'Cliente', key: 'Nombre', width: 36 }, + { header: 'Correo de Notificación', key: 'CorreoNotificacion', width: 34 }, + { header: 'Base de Datos', key: 'BDName', width: 24 }, + { header: 'Estatus', key: 'Estatus', width: 12 } + ]; + styleHeader(ws.getRow(1)); + + clientes.forEach((c, i) => { + const row = ws.addRow({ + NodoSubNodo: c.NodoSubNodo ?? '', + Nombre: c.Nombre ?? '', + CorreoNotificacion: c.CorreoNotificacion ?? '', + BDName: c.BDName ?? '', + // is_active puede venir como 1/0 o booleano según el origen. + Estatus: Number(c.Activo) === 1 || c.Activo === true ? 'Activo' : 'Inactivo' + }); + styleDataRow(row, i); + }); + + ws.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } }; + + const buffer = await workbook.xlsx.writeBuffer(); + const fechaHoy = new Date().toISOString().slice(0, 10); + + return new Response(buffer as unknown as BodyInit, { + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="reporte_clientes_${fechaHoy}.xlsx"`, + 'Cache-Control': 'no-store' + } + }); +}; diff --git a/src/routes/reportes/usuarios/+server.ts b/src/routes/reportes/usuarios/+server.ts new file mode 100644 index 0000000..34d491e --- /dev/null +++ b/src/routes/reportes/usuarios/+server.ts @@ -0,0 +1,121 @@ +/** + * Reporte para asesores: nodos con sus usuarios de portal (cliente y autoridad). + * Solo admin. NO incluye contraseñas: portal_users.password_hash es bcrypt + * (irreversible) y las credenciales de cliente son confidenciales. + * + * Dos hojas: + * - "Usuarios": una fila por usuario (cliente/autoridad) con su nodo. + * - "Nodos": catálogo de nodos con el conteo de usuarios cliente/autoridad. + */ +import { listPortalUsersWithNode, listClientsCatalog } from '$lib/server/controldesk-pg'; +import { getAdminFromCookies, styleHeader, styleDataRow } from '$lib/server/report-excel'; +import type { RequestHandler } from './$types'; +import ExcelJS from 'exceljs'; + +// is_authority_client == 1 → Autoridad; cualquier otro valor → Cliente (paridad con la UI). +function tipoUsuario(clienteAutoridad: unknown): 'Autoridad' | 'Cliente' { + return Number(clienteAutoridad) === 1 ? 'Autoridad' : 'Cliente'; +} + +export const GET: RequestHandler = async ({ cookies }) => { + const admin = await getAdminFromCookies(cookies); + if (!admin) { + return new Response(JSON.stringify({ error: 'No autorizado: requiere sesión de administrador.' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + let usuarios: any[]; + let nodos: any[]; + try { + [usuarios, nodos] = await Promise.all([listPortalUsersWithNode(), listClientsCatalog()]); + } catch (e: any) { + return new Response(JSON.stringify({ error: `Error obteniendo usuarios/nodos: ${e.message}` }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'TransmitirAS - Panel CPANEL'; + workbook.created = new Date(); + workbook.modified = new Date(); + + // ── Hoja: Usuarios (cliente/autoridad) ───────────────────────────────── + const wsUsuarios = workbook.addWorksheet('Usuarios', { views: [{ state: 'frozen', ySplit: 1 }] }); + wsUsuarios.columns = [ + { header: 'Nodo/SubNodo', key: 'NodoSubNodo', width: 22 }, + { header: 'Cliente', key: 'Cliente', width: 32 }, + { header: 'Tipo', key: 'Tipo', width: 12 }, + { header: 'Usuario', key: 'Usuario', width: 24 }, + { header: 'Nombre', key: 'Nombre', width: 30 }, + { header: 'BD Shelter', key: 'BD_Shelter', width: 20 }, + { header: 'Base de Datos', key: 'BDName', width: 22 } + ]; + styleHeader(wsUsuarios.getRow(1)); + + usuarios.forEach((u, i) => { + const row = wsUsuarios.addRow({ + NodoSubNodo: u.NodoSubNodo ?? '—', + Cliente: u.Cliente ?? '', + Tipo: tipoUsuario(u.ClienteAutoridad), + Usuario: u.Usuario ?? '', + Nombre: u.Nombre ?? '', + BD_Shelter: u.BD_Shelter ?? '', + BDName: u.BDName ?? '' + }); + styleDataRow(row, i); + }); + wsUsuarios.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 7 } }; + + // ── Hoja: Nodos (con conteo de usuarios por tipo) ─────────────────────── + const conteo = new Map(); + for (const u of usuarios) { + // Agrupar por nodo usando la clave de nodo (los usuarios sin nodo quedan fuera del conteo por nodo). + const key = String(u.NodoSubNodo ?? ''); + if (!key) continue; + const acc = conteo.get(key) ?? { cliente: 0, autoridad: 0 }; + if (tipoUsuario(u.ClienteAutoridad) === 'Autoridad') acc.autoridad += 1; + else acc.cliente += 1; + conteo.set(key, acc); + } + + nodos.sort((a, b) => String(a.NodoSubNodo ?? '').localeCompare(String(b.NodoSubNodo ?? ''))); + + const wsNodos = workbook.addWorksheet('Nodos', { views: [{ state: 'frozen', ySplit: 1 }] }); + wsNodos.columns = [ + { header: 'Nodo/SubNodo', key: 'NodoSubNodo', width: 22 }, + { header: 'Cliente', key: 'Nombre', width: 36 }, + { header: 'Base de Datos', key: 'BDName', width: 24 }, + { header: 'Estatus', key: 'Estatus', width: 12 }, + { header: 'Usuarios Cliente', key: 'UsuariosCliente', width: 16 }, + { header: 'Usuarios Autoridad', key: 'UsuariosAutoridad', width: 18 } + ]; + styleHeader(wsNodos.getRow(1)); + + nodos.forEach((n, i) => { + const c = conteo.get(String(n.NodoSubNodo ?? '')) ?? { cliente: 0, autoridad: 0 }; + const row = wsNodos.addRow({ + NodoSubNodo: n.NodoSubNodo ?? '', + Nombre: n.Nombre ?? '', + BDName: n.BDName ?? '', + Estatus: Number(n.Activo) === 1 || n.Activo === true ? 'Activo' : 'Inactivo', + UsuariosCliente: c.cliente, + UsuariosAutoridad: c.autoridad + }); + styleDataRow(row, i); + }); + wsNodos.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 6 } }; + + const buffer = await workbook.xlsx.writeBuffer(); + const fechaHoy = new Date().toISOString().slice(0, 10); + + return new Response(buffer as unknown as BodyInit, { + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="reporte_nodos_usuarios_${fechaHoy}.xlsx"`, + 'Cache-Control': 'no-store' + } + }); +}; -- 2.49.1