feat(reportes): add functionality for downloading client and user reports #12

Merged
acazares merged 1 commits from feature/reports into development 2026-06-18 17:30:23 +00:00
5 changed files with 379 additions and 0 deletions
Showing only changes of commit b9942fe2f3 - Show all commits

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