feat(reportes): add functionality for downloading client and user reports #12
@@ -190,6 +190,32 @@ export async function listPortalUsers(): Promise<any[]> {
|
|||||||
return r.rows;
|
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> {
|
export async function lookupNodeByNodoOrBdName(nodoName: string): Promise<any | null> {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
50
src/lib/server/report-excel.ts
Normal file
50
src/lib/server/report-excel.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 ──────────────────────────────────────────────────────────
|
// ── Descarga ──────────────────────────────────────────────────────────
|
||||||
async function descargarReporte() {
|
async function descargarReporte() {
|
||||||
msgError = '';
|
msgError = '';
|
||||||
@@ -370,5 +410,74 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
73
src/routes/reportes/clientes/+server.ts
Normal file
73
src/routes/reportes/clientes/+server.ts
Normal 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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
121
src/routes/reportes/usuarios/+server.ts
Normal file
121
src/routes/reportes/usuarios/+server.ts
Normal 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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user