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.
This commit is contained in:
2026-06-18 12:29:44 -05:00
parent b8e47f7654
commit b9942fe2f3
5 changed files with 379 additions and 0 deletions

View File

@@ -190,6 +190,32 @@ export async function listPortalUsers(): Promise<any[]> {
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<any[]> {
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<any | null> {
const sql = `
SELECT

View File

@@ -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<Usuario | null> {
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;
}

View File

@@ -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 @@
</div>
</div>
<!-- ── Reportes administrativos (solo admin) ───────────────────────── -->
{#if data.user?.es_admin}
<div class="mt-6 grid gap-4 md:grid-cols-2">
<!-- Reporte de clientes -->
<div class="rounded-xl border border-slate-200 bg-white shadow-sm">
<div class="flex items-center gap-3 border-b border-slate-100 px-6 py-4">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-sky-600 text-white shadow-sm">
<span class="material-icons-outlined text-base">groups</span>
</div>
<div>
<p class="font-semibold text-slate-800">Clientes (Administrativos)</p>
<p class="text-xs text-slate-500">Catálogo completo de clientes con nodo, RFC, correo y estatus</p>
</div>
</div>
<div class="px-6 py-5">
<button
onclick={descargarClientes}
disabled={loadingClientes}
class="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm
hover:bg-sky-700 active:bg-sky-800 disabled:cursor-not-allowed disabled:opacity-60 transition-colors"
>
{#if loadingClientes}
<span class="material-icons-outlined animate-spin text-base">sync</span>
Generando…
{:else}
<span class="material-icons-outlined text-base">download</span>
Descargar Excel
{/if}
</button>
</div>
</div>
<!-- Reporte de nodos + usuarios (asesores) -->
<div class="rounded-xl border border-slate-200 bg-white shadow-sm">
<div class="flex items-center gap-3 border-b border-slate-100 px-6 py-4">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-600 text-white shadow-sm">
<span class="material-icons-outlined text-base">badge</span>
</div>
<div>
<p class="font-semibold text-slate-800">Nodos y Usuarios (Asesores)</p>
<p class="text-xs text-slate-500">Usuarios cliente y autoridad por nodo</p>
</div>
</div>
<div class="px-6 py-5 space-y-3">
<div class="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
<span class="material-icons-outlined text-sm mt-0.5">lock</span>
<span>No incluye contraseñas: se almacenan con hash bcrypt (irreversible) y son confidenciales.</span>
</div>
<button
onclick={descargarUsuarios}
disabled={loadingUsuarios}
class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm
hover:bg-indigo-700 active:bg-indigo-800 disabled:cursor-not-allowed disabled:opacity-60 transition-colors"
>
{#if loadingUsuarios}
<span class="material-icons-outlined animate-spin text-base">sync</span>
Generando…
{:else}
<span class="material-icons-outlined text-base">download</span>
Descargar Excel
{/if}
</button>
</div>
</div>
</div>
{/if}
</main>
</div>

View File

@@ -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'
}
});
};

View File

@@ -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<string, { cliente: number; autoridad: number }>();
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'
}
});
};