From cb4324979b9381a9610b5b265a21c8ba6292ce81 Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Thu, 18 Jun 2026 17:30:22 +0000 Subject: [PATCH] feat(reportes): add functionality for downloading client and user reports (#12) - 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. Reviewed-on: https://git.aduanasoft.com/ADUANASOFT/PANEL_BASES_ANEXO24/pulls/12 Co-authored-by: AlexeerCT Co-committed-by: AlexeerCT --- 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' + } + }); +};